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