X-Git-Url: http://git.vanrenterghem.biz/git.ikiwiki.info.git/blobdiff_plain/fafb2edaa7aeb1293e716fa96f087cb713f4a70a..de468ba12dfc0891145b3f8b432eb97b70b60c0c:/IkiWiki/Plugin/aggregate.pm?ds=sidebyside diff --git a/IkiWiki/Plugin/aggregate.pm b/IkiWiki/Plugin/aggregate.pm index 71368e254..b5354a823 100644 --- a/IkiWiki/Plugin/aggregate.pm +++ b/IkiWiki/Plugin/aggregate.pm @@ -1,13 +1,13 @@ #!/usr/bin/perl -# Blog aggregation plugin. +# Feed aggregation plugin. package IkiWiki::Plugin::aggregate; use warnings; use strict; use IkiWiki 2.00; -use HTML::Entities; use HTML::Parser; use HTML::Tagset; +use HTML::Entities; use URI; use open qw{:utf8 :std}; @@ -21,6 +21,9 @@ sub import { #{{{ hook(type => "preprocess", id => "aggregate", call => \&preprocess); hook(type => "delete", id => "aggregate", call => \&delete); hook(type => "savestate", id => "aggregate", call => \&savestate); + if (exists $config{aggregate_webtrigger} && $config{aggregate_webtrigger}) { + hook(type => "cgi", id => "aggregate", call => \&cgi); + } } # }}} sub getopt () { #{{{ @@ -33,43 +36,92 @@ sub getopt () { #{{{ sub checkconfig () { #{{{ if ($config{aggregate} && ! ($config{post_commit} && IkiWiki::commit_hook_enabled())) { - if (! IkiWiki::lockwiki(0)) { - debug("wiki is locked by another process, not aggregating"); - exit 1; - } - - # Fork a child process to handle the aggregation. - # The parent process will then handle building the result. - # This avoids messy code to clear state accumulated while - # aggregating. - defined(my $pid = fork) or error("Can't fork: $!"); - if (! $pid) { - loadstate(); + launchaggregation(); + } +} #}}} + +sub cgi ($) { #{{{ + my $cgi=shift; + + if (defined $cgi->param('do') && + $cgi->param("do") eq "aggregate_webtrigger") { + $|=1; + print "Content-Type: text/plain\n\n"; + $config{cgi}=0; + $config{verbose}=1; + $config{syslog}=0; + print gettext("Aggregation triggered via web.")."\n\n"; + if (launchaggregation()) { + IkiWiki::lockwiki(); IkiWiki::loadindex(); - aggregate(); - expire(); - savestate(); - exit 0; + require IkiWiki::Render; + IkiWiki::refresh(); + IkiWiki::saveindex(); } - waitpid($pid,0); - if ($?) { - error "aggregation failed with code $?"; + else { + print gettext("Nothing to do right now, all feeds are up-to-date!")."\n"; } - - IkiWiki::unlockwiki(); + exit 0; + } +} #}}} + +sub launchaggregation () { #{{{ + # See if any feeds need aggregation. + loadstate(); + my @feeds=needsaggregate(); + return unless @feeds; + if (! lockaggregate()) { + debug("an aggregation process is already running"); + return; + } + # force a later rebuild of source pages + $IkiWiki::forcerebuild{$_->{sourcepage}}=1 + foreach @feeds; + + # Fork a child process to handle the aggregation. + # The parent process will then handle building the + # result. This avoids messy code to clear state + # accumulated while aggregating. + defined(my $pid = fork) or error("Can't fork: $!"); + if (! $pid) { + IkiWiki::loadindex(); + # Aggregation happens without the main wiki lock + # being held. This allows editing pages etc while + # aggregation is running. + aggregate(@feeds); + + IkiWiki::lockwiki; + # Merge changes, since aggregation state may have + # changed on disk while the aggregation was happening. + mergestate(); + expire(); + savestate(); + IkiWiki::unlockwiki; + exit 0; + } + waitpid($pid,0); + if ($?) { + error "aggregation failed with code $?"; } + + clearstate(); + unlockaggregate(); + + return 1; } #}}} sub needsbuild (@) { #{{{ my $needsbuild=shift; - loadstate(); # if not already loaded + loadstate(); foreach my $feed (values %feeds) { - if (grep { $_ eq $pagesources{$feed->{sourcepage}} } @$needsbuild) { - # Mark all feeds originating on this page as removable; - # preprocess will unmark those that still exist. - remove_feeds($feed->{sourcepage}); + if (exists $pagesources{$feed->{sourcepage}} && + grep { $_ eq $pagesources{$feed->{sourcepage}} } @$needsbuild) { + # Mark all feeds originating on this page as + # not yet seen; preprocess will unmark those that + # still exist. + markunseen($feed->{sourcepage}); } } } # }}} @@ -102,8 +154,14 @@ sub preprocess (@) { #{{{ $feed->{updateinterval}=defined $params{updateinterval} ? $params{updateinterval} * 60 : 15 * 60; $feed->{expireage}=defined $params{expireage} ? $params{expireage} : 0; $feed->{expirecount}=defined $params{expirecount} ? $params{expirecount} : 0; - delete $feed->{remove}; - delete $feed->{expired}; + if (exists $params{template}) { + $params{template}=~s/[^-_a-zA-Z0-9]+//g; + } + else { + $params{template} = "aggregatepost" + } + $feed->{template}=$params{template} . ".tmpl"; + delete $feed->{unseen}; $feed->{lastupdate}=0 unless defined $feed->{lastupdate}; $feed->{numposts}=0 unless defined $feed->{numposts}; $feed->{newposts}=0 unless defined $feed->{newposts}; @@ -133,11 +191,22 @@ sub delete (@) { #{{{ # Remove feed data for removed pages. foreach my $file (@files) { my $page=pagename($file); - remove_feeds($page); + markunseen($page); + } +} #}}} + +sub markunseen ($) { #{{{ + my $page=shift; + + foreach my $id (keys %feeds) { + if ($feeds{$id}->{sourcepage} eq $page) { + $feeds{$id}->{unseen}=1; + } } } #}}} my $state_loaded=0; + sub loadstate () { #{{{ return if $state_loaded; $state_loaded=1; @@ -176,32 +245,11 @@ sub loadstate () { #{{{ sub savestate () { #{{{ return unless $state_loaded; - eval q{use HTML::Entities}; - error($@) if $@; + garbage_collect(); my $newfile="$config{wikistatedir}/aggregate.new"; my $cleanup = sub { unlink($newfile) }; open (OUT, ">$newfile") || error("open $newfile: $!", $cleanup); foreach my $data (values %feeds, values %guids) { - if ($data->{remove}) { - if ($data->{name}) { - foreach my $guid (values %guids) { - if ($guid->{feed} eq $data->{name}) { - $guid->{remove}=1; - } - } - } - else { - unlink pagefile($data->{page}) - if exists $data->{page}; - } - next; - } - elsif ($data->{expired} && exists $data->{page}) { - unlink pagefile($data->{page}); - delete $data->{page}; - delete $data->{md5}; - } - my @line; foreach my $field (keys %$data) { if ($field eq "name" || $field eq "feed" || @@ -222,6 +270,69 @@ sub savestate () { #{{{ error("rename $newfile: $!", $cleanup); } #}}} +sub garbage_collect () { #{{{ + foreach my $name (keys %feeds) { + # remove any feeds that were not seen while building the pages + # that used to contain them + if ($feeds{$name}->{unseen}) { + delete $feeds{$name}; + } + } + + foreach my $guid (values %guids) { + # any guid whose feed is gone should be removed + if (! exists $feeds{$guid->{feed}}) { + unlink pagefile($guid->{page}) + if exists $guid->{page}; + delete $guids{$guid->{guid}}; + } + # handle expired guids + elsif ($guid->{expired} && exists $guid->{page}) { + unlink pagefile($guid->{page}); + delete $guid->{page}; + delete $guid->{md5}; + } + } +} #}}} + +sub mergestate () { #{{{ + # Load the current state in from disk, and merge into it + # values from the state in memory that might have changed + # during aggregation. + my %myfeeds=%feeds; + my %myguids=%guids; + clearstate(); + loadstate(); + + # All that can change in feed state during aggregation is a few + # fields. + foreach my $name (keys %myfeeds) { + if (exists $feeds{$name}) { + foreach my $field (qw{message lastupdate numposts + newposts error}) { + $feeds{$name}->{$field}=$myfeeds{$name}->{$field}; + } + } + } + + # New guids can be created during aggregation. + # It's also possible that guids were removed from the on-disk state + # while the aggregation was in process. That would only happen if + # their feed was also removed, so any removed guids added back here + # will be garbage collected later. + foreach my $guid (keys %myguids) { + if (! exists $guids{$guid}) { + $guids{$guid}=$myguids{$guid}; + } + } +} #}}} + +sub clearstate () { #{{{ + %feeds=(); + %guids=(); + $state_loaded=0; +} #}}} + sub expire () { #{{{ foreach my $feed (values %feeds) { next unless $feed->{expireage} || $feed->{expirecount}; @@ -253,23 +364,23 @@ sub expire () { #{{{ } } #}}} -sub aggregate () { #{{{ +sub needsaggregate () { #{{{ + return values %feeds if $config{rebuild}; + return grep { time - $_->{lastupdate} >= $_->{updateinterval} } values %feeds; +} #}}} + +sub aggregate (@) { #{{{ eval q{use XML::Feed}; error($@) if $@; eval q{use URI::Fetch}; error($@) if $@; - eval q{use HTML::Entities}; - error($@) if $@; - foreach my $feed (values %feeds) { - next unless $config{rebuild} || - time - $feed->{lastupdate} >= $feed->{updateinterval}; + foreach my $feed (@_) { $feed->{lastupdate}=time; $feed->{newposts}=0; $feed->{message}=sprintf(gettext("processed ok at %s"), displaytime($feed->{lastupdate})); $feed->{error}=0; - $IkiWiki::forcerebuild{$feed->{sourcepage}}=1; debug(sprintf(gettext("checking feed %s ..."), $feed->{name})); @@ -303,14 +414,14 @@ sub aggregate () { #{{{ # that contains invalid UTF-8 sequences. Convert # feed to ascii to try to work around. $feed->{message}.=" ".sprintf(gettext("(invalid UTF-8 stripped from feed)")); - $content=Encode::decode_utf8($content); + $content=Encode::decode_utf8($content, 0); $f=eval{XML::Feed->parse(\$content)}; } if ($@) { # Another possibility is badly escaped entities. $feed->{message}.=" ".sprintf(gettext("(feed entities escaped)")); $content=~s/\&(?!amp)(\w+);/&$1;/g; - $content=Encode::decode_utf8($content); + $content=Encode::decode_utf8($content, 0); $f=eval{XML::Feed->parse(\$content)}; } if ($@) { @@ -333,7 +444,7 @@ sub aggregate () { #{{{ title => defined $entry->title ? decode_entities($entry->title) : "untitled", link => $entry->link, content => defined $entry->content->body ? $entry->content->body : "", - guid => defined $entry->id ? $entry->id : time."_".$feed->name, + guid => defined $entry->id ? $entry->id : time."_".$feed->{name}, ctime => $entry->issued ? ($entry->issued->epoch || time) : time, ); } @@ -403,7 +514,7 @@ sub add_page (@) { #{{{ $guid->{md5}=$digest; # Create the page. - my $template=template("aggregatepost.tmpl", blind_cache => 1); + my $template=template($feed->{template}, blind_cache => 1); $template->param(title => $params{title}) if defined $params{title} && length($params{title}); $template->param(content => htmlescape(htmlabs($params{content}, $feed->{feedurl}))); @@ -477,18 +588,6 @@ sub htmlabs ($$) { #{{{ return $ret; } #}}} -sub remove_feeds () { #{{{ - my $page=shift; - - my %removed; - foreach my $id (keys %feeds) { - if ($feeds{$id}->{sourcepage} eq $page) { - $feeds{$id}->{remove}=1; - $removed{$id}=1; - } - } -} #}}} - sub pagefile ($) { #{{{ my $page=shift; @@ -499,4 +598,26 @@ sub htmlfn ($) { #{{{ return shift().".".$config{htmlext}; } #}}} +my $aggregatelock; + +sub lockaggregate () { #{{{ + # Take an exclusive lock to prevent multiple concurrent aggregators. + # Returns true if the lock was aquired. + if (! -d $config{wikistatedir}) { + mkdir($config{wikistatedir}); + } + open($aggregatelock, '>', "$config{wikistatedir}/aggregatelock") || + error ("cannot open to $config{wikistatedir}/aggregatelock: $!"); + if (! flock($aggregatelock, 2 | 4)) { # LOCK_EX | LOCK_NB + close($aggregatelock) || error("failed closing aggregatelock: $!"); + return 0; + } + return 1; +} #}}} + +sub unlockaggregate () { #{{{ + return close($aggregatelock) if $aggregatelock; + return; +} #}}} + 1