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