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