| 1 | package Wiki::Toolkit::Feed::Atom; |
|---|
| 2 | |
|---|
| 3 | use strict; |
|---|
| 4 | |
|---|
| 5 | use vars qw( @ISA $VERSION ); |
|---|
| 6 | $VERSION = '0.02'; |
|---|
| 7 | |
|---|
| 8 | use POSIX 'strftime'; |
|---|
| 9 | use Time::Piece; |
|---|
| 10 | use URI::Escape; |
|---|
| 11 | use Carp qw( croak ); |
|---|
| 12 | |
|---|
| 13 | use Wiki::Toolkit::Feed::Listing; |
|---|
| 14 | @ISA = qw( Wiki::Toolkit::Feed::Listing ); |
|---|
| 15 | |
|---|
| 16 | =head1 NAME |
|---|
| 17 | |
|---|
| 18 | Wiki::Toolkit::Feed::Atom - A Wiki::Toolkit plugin to output RecentChanges Atom. |
|---|
| 19 | |
|---|
| 20 | =head1 DESCRIPTION |
|---|
| 21 | |
|---|
| 22 | This is an alternative access to the recent changes of a Wiki::Toolkit |
|---|
| 23 | wiki. It outputs the Atom Syndication Format as described at |
|---|
| 24 | L<http://www.atomenabled.org/developers/syndication/>. |
|---|
| 25 | |
|---|
| 26 | This module is a straight port of L<Wiki::Toolkit::Feed::RSS>. |
|---|
| 27 | |
|---|
| 28 | =head1 SYNOPSIS |
|---|
| 29 | |
|---|
| 30 | use Wiki::Toolkit; |
|---|
| 31 | use Wiki::Toolkit::Feed::Atom; |
|---|
| 32 | |
|---|
| 33 | my $wiki = Wiki::Toolkit->new( ... ); # See perldoc Wiki::Toolkit |
|---|
| 34 | |
|---|
| 35 | # Set up the RSS feeder with the mandatory arguments - see |
|---|
| 36 | # C<new()> below for more, optional, arguments. |
|---|
| 37 | my $atom = Wiki::Toolkit::Feed::Atom->new( |
|---|
| 38 | wiki => $wiki, |
|---|
| 39 | site_name => 'My Wiki', |
|---|
| 40 | site_url => 'http://example.com/', |
|---|
| 41 | make_node_url => sub |
|---|
| 42 | { |
|---|
| 43 | my ($node_name, $version) = @_; |
|---|
| 44 | return 'http://example.com/?id=' . uri_escape($node_name) . ';version=' . uri_escape($version); |
|---|
| 45 | }, |
|---|
| 46 | html_equiv_link => 'http://example.com/?RecentChanges', |
|---|
| 47 | atom_link => 'http://example.com/?action=rc;format=atom', |
|---|
| 48 | ); |
|---|
| 49 | |
|---|
| 50 | print "Content-type: application/atom+xml\n\n"; |
|---|
| 51 | print $atom->recent_changes; |
|---|
| 52 | |
|---|
| 53 | =head1 METHODS |
|---|
| 54 | |
|---|
| 55 | =head2 C<new()> |
|---|
| 56 | |
|---|
| 57 | my $atom = Wiki::Toolkit::Feed::Atom->new( |
|---|
| 58 | # Mandatory arguments: |
|---|
| 59 | wiki => $wiki, |
|---|
| 60 | site_name => 'My Wiki', |
|---|
| 61 | site_url => 'http://example.com/', |
|---|
| 62 | make_node_url => sub |
|---|
| 63 | { |
|---|
| 64 | my ($node_name, $version) = @_; |
|---|
| 65 | return 'http://example.com/?id=' . uri_escape($node_name) . ';version=' . uri_escape($version); |
|---|
| 66 | }, |
|---|
| 67 | html_equiv_link => 'http://example.com/?RecentChanges',, |
|---|
| 68 | atom_link => 'http://example.com/?action=rc;format=atom', |
|---|
| 69 | |
|---|
| 70 | # Optional arguments: |
|---|
| 71 | site_description => 'My wiki about my stuff', |
|---|
| 72 | software_name => $your_software_name, # e.g. "Wiki::Toolkit" |
|---|
| 73 | software_version => $your_software_version, # e.g. "0.73" |
|---|
| 74 | software_homepage => $your_software_homepage, # e.g. "http://search.cpan.org/dist/CGI-Wiki/" |
|---|
| 75 | encoding => 'UTF-8' |
|---|
| 76 | ); |
|---|
| 77 | |
|---|
| 78 | C<wiki> must be a L<Wiki::Toolkit> object. C<make_node_url>, if supplied, must |
|---|
| 79 | be a coderef. |
|---|
| 80 | |
|---|
| 81 | The mandatory arguments are: |
|---|
| 82 | |
|---|
| 83 | =over 4 |
|---|
| 84 | |
|---|
| 85 | =item * wiki |
|---|
| 86 | |
|---|
| 87 | =item * site_name |
|---|
| 88 | |
|---|
| 89 | =item * site_url |
|---|
| 90 | |
|---|
| 91 | =item * make_node_url |
|---|
| 92 | |
|---|
| 93 | =item * html_equiv_link or recent_changes_link |
|---|
| 94 | |
|---|
| 95 | =item * atom_link |
|---|
| 96 | |
|---|
| 97 | =back |
|---|
| 98 | |
|---|
| 99 | The three optional arguments |
|---|
| 100 | |
|---|
| 101 | =over 4 |
|---|
| 102 | |
|---|
| 103 | =item * software_name |
|---|
| 104 | |
|---|
| 105 | =item * software_version |
|---|
| 106 | |
|---|
| 107 | =item * software_homepage |
|---|
| 108 | |
|---|
| 109 | =back |
|---|
| 110 | |
|---|
| 111 | are used to generate the C<generator> part of the feed. |
|---|
| 112 | |
|---|
| 113 | The optional argument |
|---|
| 114 | |
|---|
| 115 | =over 4 |
|---|
| 116 | |
|---|
| 117 | =item * encoding |
|---|
| 118 | |
|---|
| 119 | =back |
|---|
| 120 | |
|---|
| 121 | will be used to specify the character encoding in the feed. If not set, |
|---|
| 122 | will default to the wiki store's encoding. |
|---|
| 123 | |
|---|
| 124 | =head2 C<recent_changes()> |
|---|
| 125 | |
|---|
| 126 | $wiki->write_node( |
|---|
| 127 | 'About This Wiki', |
|---|
| 128 | 'blah blah blah', |
|---|
| 129 | $checksum, |
|---|
| 130 | { |
|---|
| 131 | comment => 'Stub page, please update!', |
|---|
| 132 | username => 'Fred', |
|---|
| 133 | } |
|---|
| 134 | ); |
|---|
| 135 | |
|---|
| 136 | print "Content-type: application/atom+xml\n\n"; |
|---|
| 137 | print $atom->recent_changes; |
|---|
| 138 | |
|---|
| 139 | # Or get something other than the default of the latest 15 changes. |
|---|
| 140 | print $atom->recent_changes( items => 50 ); |
|---|
| 141 | print $atom->recent_changes( days => 7 ); |
|---|
| 142 | |
|---|
| 143 | # Or ignore minor edits. |
|---|
| 144 | print $atom->recent_changes( ignore_minor_edits => 1 ); |
|---|
| 145 | |
|---|
| 146 | # Personalise your feed further - consider only changes |
|---|
| 147 | # made by Fred to pages about bookshops. |
|---|
| 148 | print $atom->recent_changes( |
|---|
| 149 | filter_on_metadata => { |
|---|
| 150 | username => 'Fred', |
|---|
| 151 | category => 'Bookshops', |
|---|
| 152 | }, |
|---|
| 153 | ); |
|---|
| 154 | |
|---|
| 155 | If using C<filter_on_metadata>, note that only changes satisfying |
|---|
| 156 | I<all> criteria will be returned. |
|---|
| 157 | |
|---|
| 158 | B<Note:> Many of the fields emitted by the Atom generator are taken |
|---|
| 159 | from the node metadata. The form of this metadata is I<not> mandated |
|---|
| 160 | by L<Wiki::Toolkit>. Your wiki application should make sure to store some or |
|---|
| 161 | all of the following metadata when calling C<write_node>: |
|---|
| 162 | |
|---|
| 163 | =over 4 |
|---|
| 164 | |
|---|
| 165 | =item B<comment> - a brief comment summarising the edit that has just been made; will be used in the summary for this item. Defaults to the empty string. |
|---|
| 166 | |
|---|
| 167 | =item B<username> - an identifier for the person who made the edit; will be used as the Dublin Core contributor for this item, and also in the RDF description. Defaults to 'No description given for change'. |
|---|
| 168 | |
|---|
| 169 | =item B<host> - the hostname or IP address of the computer used to make the edit; if no username is supplied then this will be used as the author for this item. Defaults to 'Anonymous'. |
|---|
| 170 | |
|---|
| 171 | =back |
|---|
| 172 | |
|---|
| 173 | =cut |
|---|
| 174 | |
|---|
| 175 | sub new { |
|---|
| 176 | my $class = shift; |
|---|
| 177 | my $self = {}; |
|---|
| 178 | bless $self, $class; |
|---|
| 179 | |
|---|
| 180 | my %args = @_; |
|---|
| 181 | my $wiki = $args{wiki}; |
|---|
| 182 | |
|---|
| 183 | unless ($wiki && UNIVERSAL::isa($wiki, 'Wiki::Toolkit')) { |
|---|
| 184 | croak 'No Wiki::Toolkit object supplied'; |
|---|
| 185 | } |
|---|
| 186 | |
|---|
| 187 | $self->{wiki} = $wiki; |
|---|
| 188 | |
|---|
| 189 | # Mandatory arguments. |
|---|
| 190 | foreach my $arg (qw/site_name site_url make_node_url atom_link/) { |
|---|
| 191 | croak "No $arg supplied" unless $args{$arg}; |
|---|
| 192 | $self->{$arg} = $args{$arg}; |
|---|
| 193 | } |
|---|
| 194 | |
|---|
| 195 | # Must-supply-one-of arguments |
|---|
| 196 | my %mustoneof = ( 'html_equiv_link' => ['html_equiv_link','recent_changes_link'] ); |
|---|
| 197 | $self->handle_supply_one_of(\%mustoneof,\%args); |
|---|
| 198 | |
|---|
| 199 | # Optional arguments. |
|---|
| 200 | foreach my $arg (qw/site_description software_name software_version software_homepage encoding/) { |
|---|
| 201 | $self->{$arg} = $args{$arg} || ''; |
|---|
| 202 | } |
|---|
| 203 | |
|---|
| 204 | # Supply some defaults, if a blank string isn't what we want |
|---|
| 205 | unless($self->{encoding}) { |
|---|
| 206 | $self->{encoding} = $self->{wiki}->store->{_charset}; |
|---|
| 207 | } |
|---|
| 208 | |
|---|
| 209 | $self->{timestamp_fmt} = $Wiki::Toolkit::Store::Database::timestamp_fmt; |
|---|
| 210 | $self->{utc_offset} = strftime "%z", localtime; |
|---|
| 211 | $self->{utc_offset} =~ s/(..)(..)$/$1:$2/; |
|---|
| 212 | |
|---|
| 213 | # Escape any &'s in the urls |
|---|
| 214 | foreach my $key (qw(site_url atom_link)) { |
|---|
| 215 | my @ands = ($self->{$key} =~ /(\&.{1,6})/g); |
|---|
| 216 | foreach my $and (@ands) { |
|---|
| 217 | if($and ne "&") { |
|---|
| 218 | my $new_and = $and; |
|---|
| 219 | $new_and =~ s/\&/\&/; |
|---|
| 220 | $self->{$key} =~ s/$and/$new_and/; |
|---|
| 221 | } |
|---|
| 222 | } |
|---|
| 223 | } |
|---|
| 224 | |
|---|
| 225 | $self; |
|---|
| 226 | } |
|---|
| 227 | |
|---|
| 228 | # Internal method, to build all the stuff that will go at the start of a feed. |
|---|
| 229 | # Outputs the feed header, and initial feed info. |
|---|
| 230 | |
|---|
| 231 | sub build_feed_start { |
|---|
| 232 | my ($self,$atom_timestamp) = @_; |
|---|
| 233 | |
|---|
| 234 | my $generator = ''; |
|---|
| 235 | |
|---|
| 236 | if ($self->{software_name}) { |
|---|
| 237 | $generator = ' <generator'; |
|---|
| 238 | $generator .= ' uri="' . $self->{software_homepage} . '"' if $self->{software_homepage}; |
|---|
| 239 | $generator .= ' version=' . $self->{software_version} . '"' if $self->{software_version}; |
|---|
| 240 | $generator .= ">\n"; |
|---|
| 241 | $generator .= $self->{software_name} . "</generator>\n"; |
|---|
| 242 | } |
|---|
| 243 | |
|---|
| 244 | my $subtitle = $self->{site_description} |
|---|
| 245 | ? '<subtitle>' . $self->{site_description} . "</subtitle>\n" |
|---|
| 246 | : ''; |
|---|
| 247 | |
|---|
| 248 | $atom_timestamp ||= ''; |
|---|
| 249 | |
|---|
| 250 | my $atom = qq{<?xml version="1.0" encoding="} . $self->{encoding} . qq{"?> |
|---|
| 251 | |
|---|
| 252 | <feed |
|---|
| 253 | xmlns = "http://www.w3.org/2005/Atom" |
|---|
| 254 | xmlns:geo = "http://www.w3.org/2003/01/geo/wgs84_pos#" |
|---|
| 255 | xmlns:space = "http://frot.org/space/0.1/" |
|---|
| 256 | > |
|---|
| 257 | |
|---|
| 258 | <link href="} . $self->{site_url} . qq{" /> |
|---|
| 259 | <title>} . $self->{site_name} . qq{</title> |
|---|
| 260 | <link rel="self" href="} . $self->{atom_link} . qq{" /> |
|---|
| 261 | <updated>} . $atom_timestamp . qq{</updated> |
|---|
| 262 | <id>} . $self->{site_url} . qq{</id> |
|---|
| 263 | $subtitle}; |
|---|
| 264 | |
|---|
| 265 | return $atom; |
|---|
| 266 | } |
|---|
| 267 | |
|---|
| 268 | # Internal method, to build all the stuff that will go at the end of a feed. |
|---|
| 269 | |
|---|
| 270 | sub build_feed_end { |
|---|
| 271 | my ($self,$feed_timestamp) = @_; |
|---|
| 272 | |
|---|
| 273 | return "</feed>\n"; |
|---|
| 274 | } |
|---|
| 275 | |
|---|
| 276 | =head2 C<generate_node_list_feed> |
|---|
| 277 | |
|---|
| 278 | Generate and return an Atom feed for a list of nodes |
|---|
| 279 | |
|---|
| 280 | =cut |
|---|
| 281 | |
|---|
| 282 | sub generate_node_list_feed { |
|---|
| 283 | my ($self,$atom_timestamp,@nodes) = @_; |
|---|
| 284 | |
|---|
| 285 | my $atom = $self->build_feed_start($atom_timestamp); |
|---|
| 286 | |
|---|
| 287 | my (@urls, @items); |
|---|
| 288 | |
|---|
| 289 | foreach my $node (@nodes) { |
|---|
| 290 | my $node_name = $node->{name}; |
|---|
| 291 | |
|---|
| 292 | my $item_timestamp = $node->{last_modified}; |
|---|
| 293 | |
|---|
| 294 | # Make a Time::Piece object. |
|---|
| 295 | my $time = Time::Piece->strptime($item_timestamp, $self->{timestamp_fmt}); |
|---|
| 296 | |
|---|
| 297 | my $utc_offset = $self->{utc_offset}; |
|---|
| 298 | |
|---|
| 299 | $item_timestamp = $time->strftime( "%Y-%m-%dT%H:%M:%S$utc_offset" ); |
|---|
| 300 | |
|---|
| 301 | my $author = $node->{metadata}{username}[0] || $node->{metadata}{host}[0] || 'Anonymous'; |
|---|
| 302 | my $description = $node->{metadata}{comment}[0] || 'No description given for node'; |
|---|
| 303 | |
|---|
| 304 | $description .= " [$author]" if $author; |
|---|
| 305 | |
|---|
| 306 | my $version = $node->{version}; |
|---|
| 307 | my $status = (1 == $version) ? 'new' : 'updated'; |
|---|
| 308 | |
|---|
| 309 | my $major_change = $node->{metadata}{major_change}[0]; |
|---|
| 310 | $major_change = 1 unless defined $major_change; |
|---|
| 311 | my $importance = $major_change ? 'major' : 'minor'; |
|---|
| 312 | |
|---|
| 313 | my $url = $self->{make_node_url}->($node_name, $version); |
|---|
| 314 | |
|---|
| 315 | # make XML-clean |
|---|
| 316 | my $title = $node_name; |
|---|
| 317 | $title =~ s/&/&/g; |
|---|
| 318 | $title =~ s/</</g; |
|---|
| 319 | $title =~ s/>/>/g; |
|---|
| 320 | |
|---|
| 321 | # Pop the categories into atom:category elements (4.2.2) |
|---|
| 322 | # We can do this because the spec says: |
|---|
| 323 | # "This specification assigns no meaning to the content (if any) |
|---|
| 324 | # of this element." |
|---|
| 325 | # TODO: Decide if we should include the "all categories listing" url |
|---|
| 326 | # as the scheme (URI) attribute? |
|---|
| 327 | my $category_atom = ""; |
|---|
| 328 | if ($node->{metadata}->{category}) { |
|---|
| 329 | foreach my $cat (@{ $node->{metadata}->{category} }) { |
|---|
| 330 | $category_atom .= " <category term=\"$cat\" />\n"; |
|---|
| 331 | } |
|---|
| 332 | } |
|---|
| 333 | |
|---|
| 334 | # Include geospacial data, if we have it |
|---|
| 335 | my $geo_atom = $self->format_geo($node->{metadata}); |
|---|
| 336 | |
|---|
| 337 | # TODO: Find an Atom equivalent of ModWiki, so we can include more info |
|---|
| 338 | |
|---|
| 339 | |
|---|
| 340 | push @items, qq{ |
|---|
| 341 | <entry> |
|---|
| 342 | <title>$title</title> |
|---|
| 343 | <link href="$url" /> |
|---|
| 344 | <id>$url</id> |
|---|
| 345 | <summary>$description</summary> |
|---|
| 346 | <updated>$item_timestamp</updated> |
|---|
| 347 | <author><name>$author</name></author> |
|---|
| 348 | $category_atom |
|---|
| 349 | $geo_atom |
|---|
| 350 | </entry> |
|---|
| 351 | }; |
|---|
| 352 | |
|---|
| 353 | } |
|---|
| 354 | |
|---|
| 355 | $atom .= join('', @items) . "\n"; |
|---|
| 356 | $atom .= $self->build_feed_end($atom_timestamp); |
|---|
| 357 | |
|---|
| 358 | return $atom; |
|---|
| 359 | } |
|---|
| 360 | |
|---|
| 361 | =head2 C<generate_node_name_distance_feed> |
|---|
| 362 | |
|---|
| 363 | Generate a very cut down atom feed, based just on the nodes, their locations |
|---|
| 364 | (if given), and their distance from a reference location (if given). |
|---|
| 365 | |
|---|
| 366 | Typically used on search feeds. |
|---|
| 367 | |
|---|
| 368 | =cut |
|---|
| 369 | |
|---|
| 370 | sub generate_node_name_distance_feed { |
|---|
| 371 | my ($self,$atom_timestamp,@nodes) = @_; |
|---|
| 372 | |
|---|
| 373 | my $atom = $self->build_feed_start($atom_timestamp); |
|---|
| 374 | |
|---|
| 375 | my (@urls, @items); |
|---|
| 376 | |
|---|
| 377 | foreach my $node (@nodes) { |
|---|
| 378 | my $node_name = $node->{name}; |
|---|
| 379 | |
|---|
| 380 | my $url = $self->{make_node_url}->($node_name); |
|---|
| 381 | |
|---|
| 382 | # make XML-clean |
|---|
| 383 | my $title = $node_name; |
|---|
| 384 | $title =~ s/&/&/g; |
|---|
| 385 | $title =~ s/</</g; |
|---|
| 386 | $title =~ s/>/>/g; |
|---|
| 387 | |
|---|
| 388 | # What location stuff do we have? |
|---|
| 389 | my $geo_atom = $self->format_geo($node); |
|---|
| 390 | |
|---|
| 391 | push @items, qq{ |
|---|
| 392 | <entry> |
|---|
| 393 | <title>$title</title> |
|---|
| 394 | <link href="$url" /> |
|---|
| 395 | <id>$url</id> |
|---|
| 396 | $geo_atom |
|---|
| 397 | </entry> |
|---|
| 398 | }; |
|---|
| 399 | |
|---|
| 400 | } |
|---|
| 401 | |
|---|
| 402 | $atom .= join('', @items) . "\n"; |
|---|
| 403 | $atom .= $self->build_feed_end($atom_timestamp); |
|---|
| 404 | |
|---|
| 405 | return $atom; |
|---|
| 406 | } |
|---|
| 407 | |
|---|
| 408 | =head2 C<feed_timestamp()> |
|---|
| 409 | |
|---|
| 410 | print $atom->feed_timestamp(); |
|---|
| 411 | |
|---|
| 412 | Returns the timestamp of the feed in POSIX::strftime style ("Tue, 29 Feb 2000 |
|---|
| 413 | 12:34:56 GMT"), which is equivalent to the timestamp of the most recent item |
|---|
| 414 | in the feed. Takes the same arguments as recent_changes(). You will most likely |
|---|
| 415 | need this to print a Last-Modified HTTP header so user-agents can determine |
|---|
| 416 | whether they need to reload the feed or not. |
|---|
| 417 | |
|---|
| 418 | =cut |
|---|
| 419 | |
|---|
| 420 | sub feed_timestamp { |
|---|
| 421 | my ($self, $newest_node) = @_; |
|---|
| 422 | |
|---|
| 423 | my $time; |
|---|
| 424 | if ($newest_node->{last_modified}) { |
|---|
| 425 | $time = Time::Piece->strptime( $newest_node->{last_modified}, $self->{timestamp_fmt} ); |
|---|
| 426 | } else { |
|---|
| 427 | $time = localtime; |
|---|
| 428 | } |
|---|
| 429 | |
|---|
| 430 | my $utc_offset = $self->{utc_offset}; |
|---|
| 431 | |
|---|
| 432 | return $time->strftime( "%Y-%m-%dT%H:%M:%S$utc_offset" ); |
|---|
| 433 | } |
|---|
| 434 | |
|---|
| 435 | |
|---|
| 436 | =head2 C<parse_feed_timestamp> |
|---|
| 437 | |
|---|
| 438 | Take a feed_timestamp and return a Time::Piece object. |
|---|
| 439 | |
|---|
| 440 | =cut |
|---|
| 441 | |
|---|
| 442 | sub parse_feed_timestamp { |
|---|
| 443 | my ($self, $feed_timestamp) = @_; |
|---|
| 444 | |
|---|
| 445 | $feed_timestamp = substr($feed_timestamp, 0, -length( $self->{utc_offset})); |
|---|
| 446 | return Time::Piece->strptime( $feed_timestamp, '%Y-%m-%dT%H:%M:%S' ); |
|---|
| 447 | } |
|---|
| 448 | 1; |
|---|
| 449 | |
|---|
| 450 | __END__ |
|---|
| 451 | |
|---|
| 452 | |
|---|
| 453 | =head1 SEE ALSO |
|---|
| 454 | |
|---|
| 455 | =over 4 |
|---|
| 456 | |
|---|
| 457 | =item * L<Wiki::Toolkit> |
|---|
| 458 | |
|---|
| 459 | =item * L<http://www.atomenabled.org/developers/syndication/> |
|---|
| 460 | |
|---|
| 461 | =back |
|---|
| 462 | |
|---|
| 463 | =head1 MAINTAINER |
|---|
| 464 | |
|---|
| 465 | The Wiki::Toolkit team, http://www.wiki-toolkit.org/. |
|---|
| 466 | |
|---|
| 467 | =head1 COPYRIGHT AND LICENSE |
|---|
| 468 | |
|---|
| 469 | Copyright 2006-2009 Earle Martin and the Wiki::Toolkit team. |
|---|
| 470 | |
|---|
| 471 | This module is free software; you can redistribute it and/or modify it |
|---|
| 472 | under the same terms as Perl itself. |
|---|
| 473 | |
|---|
| 474 | =head1 THANKS |
|---|
| 475 | |
|---|
| 476 | Kake Pugh for originally writing Wiki::Toolkit::Feed::RSS and indeed |
|---|
| 477 | Wiki::Toolkit itself. |
|---|
| 478 | |
|---|
| 479 | =cut |
|---|