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