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