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