1b92c098b051388255302c8baa72f1aa1a0fc235
[catagits/HTML-Zoom.git] / lib / HTML / Zoom / FilterBuilder.pm
1 package HTML::Zoom::FilterBuilder;
2
3 use strictures 1;
4 use base qw(HTML::Zoom::SubObject);
5 use HTML::Zoom::CodeStream;
6
7 sub _stream_from_code {
8   shift->_zconfig->stream_utils->stream_from_code(@_)
9 }
10
11 sub _stream_from_array {
12   shift->_zconfig->stream_utils->stream_from_array(@_)
13 }
14
15 sub _stream_from_proto {
16   shift->_zconfig->stream_utils->stream_from_proto(@_)
17 }
18
19 sub _stream_concat {
20   shift->_zconfig->stream_utils->stream_concat(@_)
21 }
22
23 sub _flatten_stream_of_streams {
24   shift->_zconfig->stream_utils->flatten_stream_of_streams(@_)
25 }
26
27 sub set_attr { shift->set_attribute(@_); }
28
29 sub set_attribute {
30   my $self = shift;
31   my ($name, $value) = $self->_parse_attribute_args(@_);
32   sub {
33     my $a = (my $evt = $_[0])->{attrs};
34     my $e = exists $a->{$name};
35     +{ %$evt, raw => undef, raw_attrs => undef,
36        attrs => { %$a, $name => $value },
37       ($e # add to name list if not present
38         ? ()
39         : (attr_names => [ @{$evt->{attr_names}}, $name ]))
40      }
41    };
42 }
43
44 sub _parse_attribute_args {
45   my $self = shift;
46   # allow ->add_to_attribute(name => 'value')
47   #    or ->add_to_attribute({ name => 'name', value => 'value' })
48
49   die "Long form arg (name => 'class', value => 'x') is no longer supported"
50     if(@_ == 1 && $_[0]->{'name'} && $_[0]->{'value'});
51   my ($name, $value) = @_ > 1 ? @_ : @{$_[0]}{qw(name value)};
52   return ($name, $self->_zconfig->parser->html_escape($value));
53 }
54
55 sub add_attribute {
56     die "renamed to add_to_attribute. killing this entirely for 1.0";
57 }
58
59 sub add_class { shift->add_to_attribute('class',@_) }
60
61 sub remove_class { shift->remove_from_attribute('class',@_) }
62
63 sub set_class { shift->set_attribute('class',@_) }
64
65 sub set_id { shift->set_attribute('id',@_) }
66
67 sub add_to_attribute {
68   my $self = shift;
69   my ($name, $value) = $self->_parse_attribute_args(@_);
70   sub {
71     my $a = (my $evt = $_[0])->{attrs};
72     my $e = exists $a->{$name};
73     +{ %$evt, raw => undef, raw_attrs => undef,
74        attrs => {
75          %$a,
76          $name => join(' ', ($e ? $a->{$name} : ()), $value)
77       },
78       ($e # add to name list if not present
79         ? ()
80         : (attr_names => [ @{$evt->{attr_names}}, $name ]))
81     }
82   };
83 }
84
85 sub remove_from_attribute {
86   my $self = shift;
87   my $attr = $self->_parse_attribute_args(@_);
88   sub {
89     my $a = (my $evt = $_[0])->{attrs};
90     +{ %$evt, raw => undef, raw_attrs => undef,
91        attrs => {
92          %$a,
93          #TODO needs to support multiple removes
94          map { my $tar = $_; $_ => join ' ', 
95           map {$attr->{$tar} ne $_} split ' ', $a->{$_} }
96             grep {exists $a->{$_}} keys %$attr
97       },
98     }
99   };
100 }
101
102 sub remove_attribute {
103   my ($self, $args) = @_;
104   my $name = (ref($args) eq 'HASH') ? $args->{name} : $args;
105   sub {
106     my $a = (my $evt = $_[0])->{attrs};
107     return $evt unless exists $a->{$name};
108     $a = { %$a }; delete $a->{$name};
109     +{ %$evt, raw => undef, raw_attrs => undef,
110        attrs => $a,
111        attr_names => [ grep $_ ne $name, @{$evt->{attr_names}} ]
112     }
113   };
114 }
115
116 sub transform_attribute {
117   my $self = shift;
118   my ( $name, $code ) = @_ > 1 ? @_ : @{$_[0]}{qw(name code)};
119
120   sub {
121     my $evt = $_[0];
122     my %a = %{ $evt->{attrs} };
123     my @names = @{ $evt->{attr_names} };
124
125     my $existed_before = exists $a{$name};
126     my $v = $code->( $a{$name} );
127     my $deleted =   $existed_before && ! defined $v;
128     my $added   = ! $existed_before &&   defined $v;
129     if( $added ) {
130         push @names, $name;
131         $a{$name} = $v;
132     }
133     elsif( $deleted ) {
134         delete $a{$name};
135         @names = grep $_ ne $name, @names;
136     } else {
137         $a{$name} = $v;
138     }
139     +{ %$evt, raw => undef, raw_attrs => undef,
140        attrs => \%a,
141       ( $deleted || $added
142         ? (attr_names => \@names )
143         : () )
144      }
145    };
146 }
147
148 sub collect {
149   my ($self, $options) = @_;
150   my ($into, $passthrough, $content, $filter, $flush_before) =
151     @{$options}{qw(into passthrough content filter flush_before)};
152   sub {
153     my ($evt, $stream) = @_;
154     # We wipe the contents of @$into here so that other actions depending
155     # on this (such as a repeater) can be invoked multiple times easily.
156     # I -suspect- it's better for that state reset to be managed here; if it
157     # ever becomes painful the decision should be revisited
158     if ($into) {
159       @$into = $content ? () : ($evt);
160     }
161     if ($evt->{is_in_place_close}) {
162       return $evt if $passthrough || $content;
163       return;
164     }
165     my $name = $evt->{name};
166     my $depth = 1;
167     my $_next = $content ? 'peek' : 'next';
168     if ($filter) {
169       if ($content) {
170         $stream = do { local $_ = $stream; $filter->($stream) };
171       } else {
172         $stream = do {
173           local $_ = $self->_stream_concat(
174                        $self->_stream_from_array($evt),
175                        $stream,
176                      );
177           $filter->($_);
178         };
179         $evt = $stream->next;
180       }
181     }
182     my $collector = $self->_stream_from_code(sub {
183       return unless $stream;
184       while (my ($evt) = $stream->$_next) {
185         $depth++ if ($evt->{type} eq 'OPEN');
186         $depth-- if ($evt->{type} eq 'CLOSE');
187         unless ($depth) {
188           undef $stream;
189           return if $content;
190           push(@$into, $evt) if $into;
191           return $evt if $passthrough;
192           return;
193         }
194         push(@$into, $evt) if $into;
195         $stream->next if $content;
196         return $evt if $passthrough;
197       }
198       die "Never saw closing </${name}> before end of source";
199     });
200     if ($flush_before) {
201       if ($passthrough||$content) {
202         $evt = { %$evt, flush => 1 };
203       } else {
204         $evt = { type => 'EMPTY', flush => 1 };
205       }
206     }
207     return ($passthrough||$content||$flush_before)
208              ? [ $evt, $collector ]
209              : $collector;
210   };
211 }
212
213 sub collect_content {
214   my ($self, $options) = @_;
215   $self->collect({ %{$options||{}}, content => 1 })
216 }
217
218 sub add_before {
219   my ($self, $events) = @_;
220   my $coll_proto = $self->collect({ passthrough => 1 });
221   sub {
222     my $emit = $self->_stream_from_proto($events);
223     my $coll = &$coll_proto;
224     if($coll) {
225       if(ref $coll eq 'ARRAY') {
226         my $firstbit = $self->_stream_from_proto([$coll->[0]]);
227         return $self->_stream_concat($emit, $firstbit, $coll->[1]);
228       } elsif(ref $coll eq 'HASH') {
229         return [$emit, $coll];
230       } else {
231         return $self->_stream_concat($emit, $coll);
232       }
233     } else { return $emit }
234   }
235 }
236
237 sub add_after {
238   my ($self, $events) = @_;
239   my $coll_proto = $self->collect({ passthrough => 1 });
240   sub {
241     my ($evt) = @_;
242     my $emit = $self->_stream_from_proto($events);
243     my $coll = &$coll_proto;
244     return ref($coll) eq 'HASH' # single event, no collect
245       ? [ $coll, $emit ]
246       : [ $coll->[0], $self->_stream_concat($coll->[1], $emit) ];
247   };
248 }
249
250 sub prepend_content {
251   my ($self, $events) = @_;
252   my $coll_proto = $self->collect({ passthrough => 1, content => 1 });
253   sub {
254     my ($evt) = @_;
255     my $emit = $self->_stream_from_proto($events);
256     if ($evt->{is_in_place_close}) {
257       $evt = { %$evt }; delete @{$evt}{qw(raw is_in_place_close)};
258       return [ $evt, $self->_stream_from_array(
259         $emit->next, { type => 'CLOSE', name => $evt->{name} }
260       ) ];
261     }
262     my $coll = &$coll_proto;
263     return [ $coll->[0], $self->_stream_concat($emit, $coll->[1]) ];
264   };
265 }
266
267 sub append_content {
268   my ($self, $events) = @_;
269   my $coll_proto = $self->collect({ passthrough => 1, content => 1 });
270   sub {
271     my ($evt) = @_;
272     my $emit = $self->_stream_from_proto($events);
273     if ($evt->{is_in_place_close}) {
274       $evt = { %$evt }; delete @{$evt}{qw(raw is_in_place_close)};
275       return [ $evt, $self->_stream_from_array(
276         $emit->next, { type => 'CLOSE', name => $evt->{name} }
277       ) ];
278     }
279     my $coll = &$coll_proto;
280     return [ $coll->[0], $self->_stream_concat($coll->[1], $emit) ];
281   };
282 }
283
284 sub replace {
285   my ($self, $replace_with, $options) = @_;
286   my $coll_proto = $self->collect($options);
287   sub {
288     my ($evt, $stream) = @_;
289     my $emit = $self->_stream_from_proto($replace_with);
290     my $coll = &$coll_proto;
291     # if we're replacing the contents of an in place close
292     # then we need to handle that here
293     if ($options->{content}
294         && ref($coll) eq 'HASH'
295         && $coll->{is_in_place_close}
296       ) {
297       my $close = $stream->next;
298       # shallow copy and nuke in place and raw (to force smart print)
299       $_ = { %$_ }, delete @{$_}{qw(is_in_place_close raw)} for ($coll, $close);
300       $emit = $self->_stream_concat(
301                 $emit,
302                 $self->_stream_from_array($close),
303               );
304     }
305     # For a straightforward replace operation we can, in fact, do the emit
306     # -before- the collect, and my first cut did so. However in order to
307     # use the captured content in generating the new content, we need
308     # the collect stage to happen first - and it seems highly unlikely
309     # that in normal operation the collect phase will take long enough
310     # for the difference to be noticeable
311     return
312       ($coll
313         ? (ref $coll eq 'ARRAY' # [ event, stream ]
314             ? [ $coll->[0], $self->_stream_concat($coll->[1], $emit) ]
315             : (ref $coll eq 'HASH' # event or stream?
316                  ? [ $coll, $emit ]
317                  : $self->_stream_concat($coll, $emit))
318           )
319         : $emit
320       );
321   };
322 }
323
324 sub replace_content {
325   my ($self, $replace_with, $options) = @_;
326   $self->replace($replace_with, { %{$options||{}}, content => 1 })
327 }
328
329 sub repeat {
330   my ($self, $repeat_for, $options) = @_;
331   $options->{into} = \my @into;
332   my @between;
333   my $repeat_between = delete $options->{repeat_between};
334   if ($repeat_between) {
335     $options->{filter} = sub {
336       $_->select($repeat_between)->collect({ into => \@between })
337     }
338   }
339   my $repeater = sub {
340     my $s = $self->_stream_from_proto($repeat_for);
341     # We have to test $repeat_between not @between here because
342     # at the point we're constructing our return stream @between
343     # hasn't been populated yet - but we can test @between in the
344     # map routine because it has been by then and that saves us doing
345     # the extra stream construction if we don't need it.
346     $self->_flatten_stream_of_streams(do {
347       if ($repeat_between) {
348         $s->map(sub {
349               local $_ = $self->_stream_from_array(@into);
350               (@between && $s->peek)
351                 ? $self->_stream_concat(
352                     $_[0]->($_), $self->_stream_from_array(@between)
353                   )
354                 : $_[0]->($_)
355             })
356       } else {
357         $s->map(sub {
358               local $_ = $self->_stream_from_array(@into);
359               $_[0]->($_)
360           })
361       }
362     })
363   };
364   $self->replace($repeater, $options);
365 }
366
367 sub repeat_content {
368   my ($self, $repeat_for, $options) = @_;
369   $self->repeat($repeat_for, { %{$options||{}}, content => 1 })
370 }
371
372 1;
373
374 =head1 NAME
375
376 HTML::Zoom::FilterBuilder - Add Filters to a Stream
377
378 =head1 SYNOPSIS
379
380 Create an L<HTML::Zoom> instance:
381
382   use HTML::Zoom;
383   my $root = HTML::Zoom
384       ->from_html(<<MAIN);
385   <html>
386     <head>
387       <title>Default Title</title>
388     </head>
389     <body bad_attr='junk'>
390       Default Content
391     </body>
392   </html>
393   MAIN
394
395 Create a new attribute on the  C<body> tag:
396
397   $root = $root
398     ->select('body')
399     ->set_attribute(class=>'main');
400
401 Add a extra value to an existing attribute:
402
403   $root = $root
404     ->select('body')
405     ->add_to_attribute(class=>'one-column');
406
407 Set the content of the C<title> tag:
408
409   $root = $root
410     ->select('title')
411     ->replace_content('Hello World');
412
413 Set content from another L<HTML::Zoom> instance:
414
415   my $body = HTML::Zoom
416       ->from_html(<<BODY);
417   <div id="stuff">
418       <p>Well Now</p>
419       <p id="p2">Is the Time</p>
420   </div>
421   BODY
422
423   $root = $root
424     ->select('body')
425     ->replace_content($body);
426
427 Set an attribute on multiple matches:
428
429   $root = $root
430     ->select('p')
431     ->set_attribute(class=>'para');
432
433 Remove an attribute:
434
435   $root = $root
436     ->select('body')
437     ->remove_attribute('bad_attr');
438
439 will produce:
440
441 =begin testinfo
442
443   my $output = $root->to_html;
444   my $expect = <<HTML;
445
446 =end testinfo
447
448   <html>
449     <head>
450       <title>Hello World</title>
451     </head>
452     <body class="main one-column"><div id="stuff">
453       <p class="para">Well Now</p>
454       <p id="p2" class="para">Is the Time</p>
455   </div>
456   </body>
457   </html>
458
459 =begin testinfo
460
461   HTML
462   is($output, $expect, 'Synopsis code works ok');
463
464 =end testinfo
465
466 =head1 DESCRIPTION
467
468 Given a L<HTML::Zoom> stream, provide methods to apply filters which
469 alter the content of that stream.
470
471 =head1 METHODS
472
473 This class defines the following public API
474
475 =head2 set_attribute
476
477 Sets an attribute of a given name to a given value for all matching selections.
478
479     $html_zoom
480       ->select('p')
481       ->set_attribute(class=>'paragraph')
482       ->select('div')
483       ->set_attribute({class=>'paragraph', name=>'divider'});
484
485 Overrides existing values, if such exist.  When multiple L</set_attribute>
486 calls are made against the same or overlapping selection sets, the final
487 call wins.
488
489 =head2 add_to_attribute
490
491 Adds a value to an existing attribute, or creates one if the attribute does not
492 yet exist.  You may call this method with either an Array or HashRef of Args.
493
494     $html_zoom
495       ->select('p')
496       ->set_attribute({class => 'paragraph', name => 'test'})
497       ->then
498       ->add_to_attribute(class=>'divider');
499
500 Attributes with more than one value will have a dividing space.
501
502 =head2 remove_attribute
503
504 Removes an attribute and all its values.
505
506     $html_zoom
507       ->select('p')
508       ->set_attribute(class=>'paragraph')
509       ->then
510       ->remove_attribute('class');
511
512 =head2 remove_from_attribute
513
514 Removes a value from existing attribute
515
516     $html_zoom
517       ->select('p')
518       ->set_attribute(class=>'paragraph lead')
519       ->then
520       ->remove_from_attribute('class' => 'lead');
521
522 Removes attributes from the original stream or events already added.
523
524 =head2 add_class
525
526 Add to a class attribute
527
528 =head2 remove_class
529
530 Remove from a class attribute
531
532 =head2 transform_attribute
533
534 Transforms (or creates or deletes) an attribute by running the passed
535 coderef on it.  If the coderef returns nothing, the attribute is
536 removed.
537
538     $html_zoom
539       ->select('a')
540       ->transform_attribute( href => sub {
541             ( my $a = shift ) =~ s/localhost/example.com/;
542             return $a;
543           },
544         );
545
546 =head2 collect
547
548 Collects and extracts results of L<HTML::Zoom/select>.  It takes the following
549 optional common options as hash reference.
550
551 =over
552
553 =item into [ARRAY REFERENCE]
554
555 Where to save collected events (selected elements).
556
557     $z1->select('#main-content')
558        ->collect({ into => \@body })
559        ->run;
560     $z2->select('#main-content')
561        ->replace(\@body)
562        ->memoize;
563
564 =item filter [CODE]
565
566 Run filter on collected elements (locally setting $_ to stream, and passing
567 stream as an argument to given code reference).  Filtered stream would be
568 returned.
569
570     $z->select('.outer')
571       ->collect({
572         filter => sub { $_->select('.inner')->replace_content('bar!') },
573         passthrough => 1,
574       })
575
576 It can be used to further filter selection.  For example
577
578     $z->select('tr')
579       ->collect({
580         filter => sub { $_->select('td') },
581         passthrough => 1,
582       })
583
584 is equivalent to (not implemented yet) descendant selector combination, i.e.
585
586     $z->select('tr td')
587
588 =item passthrough [BOOLEAN]
589
590 Extract copy of elements; the stream is unchanged (it does not remove collected
591 elements).  For example without 'passthrough'
592
593     HTML::Zoom->from_html('<foo><bar /></foo>')
594       ->select('foo')
595       ->collect({ content => 1 })
596       ->to_html
597
598 returns '<foo></foo>', while with C<passthrough> option
599
600     HTML::Zoom->from_html('<foo><bar /></foo>')
601       ->select('foo')
602       ->collect({ content => 1, passthough => 1 })
603       ->to_html
604
605 returns '<foo><bar /></foo>'.
606
607 =item content [BOOLEAN]
608
609 Collect content of the element, and not the element itself.
610
611 For example
612
613     HTML::Zoom->from_html('<h1>Title</h1><p>foo</p>')
614       ->select('h1')
615       ->collect
616       ->to_html
617
618 would return '<p>foo</p>', while
619
620     HTML::Zoom->from_html('<h1>Title</h1><p>foo</p>')
621       ->select('h1')
622       ->collect({ content => 1 })
623       ->to_html
624
625 would return '<h1></h1><p>foo</p>'.
626
627 See also L</collect_content>.
628
629 =item flush_before [BOOLEAN]
630
631 Generate C<flush> event before collecting, to ensure that the HTML generated up
632 to selected element being collected is flushed throught to the browser.  Usually
633 used in L</repeat> or L</repeat_content>.
634
635 =back
636
637 =head2 collect_content
638
639 Collects contents of L<HTML::Zoom/select> result.
640
641     HTML::Zoom->from_file($foo)
642               ->select('#main-content')
643               ->collect_content({ into => \@foo_body })
644               ->run;
645     $z->select('#foo')
646       ->replace_content(\@foo_body)
647       ->memoize;
648
649 Equivalent to running L</collect> with C<content> option set.
650
651 =head2 add_before
652
653 Given a L<HTML::Zoom/select> result, add given content (which might be string,
654 array or another L<HTML::Zoom> object) before it.
655
656     $html_zoom
657         ->select('input[name="foo"]')
658         ->add_before(\ '<span class="warning">required field</span>');
659
660 =head2 add_after
661
662 Like L</add_before>, only after L<HTML::Zoom/select> result.
663
664     $html_zoom
665         ->select('p')
666         ->add_after("\n\n");
667
668 You can add zoom events directly
669
670     $html_zoom
671         ->select('p')
672         ->add_after([ { type => 'TEXT', raw => 'O HAI' } ]);
673
674 =head2 prepend_content
675
676 Similar to add_before, but adds the content to the match.
677
678   HTML::Zoom
679     ->from_html(q[<p>World</p>])
680     ->select('p')
681     ->prepend_content("Hello ")
682     ->to_html
683     
684   ## <p>Hello World</p>
685   
686 Acceptable values are strings, scalar refs and L<HTML::Zoom> objects
687
688 =head2 append_content
689
690 Similar to add_after, but adds the content to the match.
691
692   HTML::Zoom
693     ->from_html(q[<p>Hello </p>])
694     ->select('p')
695     ->prepend_content("World")
696     ->to_html
697     
698   ## <p>Hello World</p>
699
700 Acceptable values are strings, scalar refs and L<HTML::Zoom> objects
701
702 =head2 replace
703
704 Given a L<HTML::Zoom/select> result, replace it with a string, array or another
705 L<HTML::Zoom> object.  It takes the same optional common options as L</collect>
706 (via hash reference).
707
708 =head2 replace_content
709
710 Given a L<HTML::Zoom/select> result, replace the content with a string, array
711 or another L<HTML::Zoom> object.
712
713     $html_zoom
714       ->select('title, #greeting')
715       ->replace_content('Hello world!');
716
717 =head2 repeat
718
719 For a given selection, repeat over transformations, typically for the purposes
720 of populating lists.  Takes either an array of anonymous subroutines or a zoom-
721 able object consisting of transformation.
722
723 Example of array reference style (when it doesn't matter that all iterations are
724 pre-generated)
725
726     $zoom->select('table')->repeat([
727       map {
728         my $elem = $_;
729         sub {
730           $_->select('td')->replace_content($e);
731         }
732       } @list
733     ]);
734     
735 Subroutines would be run with $_ localized to result of L<HTML::Zoom/select> (of
736 collected elements), and with said result passed as parameter to subroutine.
737
738 You might want to use CodeStream when you don't have all elements upfront
739
740     $zoom->select('.contents')->repeat(sub {
741       HTML::Zoom::CodeStream->new({
742         code => sub {
743           while (my $line = $fh->getline) {
744             return sub {
745               $_->select('.lno')->replace_content($fh->input_line_number)
746                 ->select('.line')->replace_content($line)
747             }
748           }
749           return
750         },
751       })
752     });
753
754 In addition to common options as in L</collect>, it also supports:
755
756 =over
757
758 =item repeat_between [SELECTOR]
759
760 Selects object to be repeated between items.  In the case of array this object
761 is put between elements, in case of iterator it is put between results of
762 subsequent iterations, in the case of streamable it is put between events
763 (->to_stream->next).
764
765 See documentation for L</repeat_content>
766
767 =back
768
769 =head2 repeat_content
770
771 Given a L<HTML::Zoom/select> result, run provided iterator passing content of
772 this result to this iterator.  Accepts the same options as L</repeat>.
773
774 Equivalent to using C<contents> option with L</repeat>.
775
776     $html_zoom
777        ->select('#list')
778        ->repeat_content(
779           [
780              sub {
781                 $_->select('.name')->replace_content('Matt')
782                   ->select('.age')->replace_content('26')
783              },
784              sub {
785                 $_->select('.name')->replace_content('Mark')
786                   ->select('.age')->replace_content('0x29')
787              },
788              sub {
789                 $_->select('.name')->replace_content('Epitaph')
790                   ->select('.age')->replace_content('<redacted>')
791              },
792           ],
793           { repeat_between => '.between' }
794        );
795
796
797 =head1 ALSO SEE
798
799 L<HTML::Zoom>
800
801 =head1 AUTHORS
802
803 See L<HTML::Zoom> for authors.
804
805 =head1 LICENSE
806
807 See L<HTML::Zoom> for the license.
808
809 =cut
810