source: bookmarks/trunk/BookmarkApp.pm @ 57

Last change on this file since 57 was 57, checked in by peter, 11 years ago
  • include a Last-Modified header in the HTML list and individual bookmark view
  • added a get_last_modified_time() method to Bookmarks that return the time of the most recently modified bookmark
  • for the individual bookmark view, check the Is-Modified-Since header and return a 304 response if the bookmark is unchanged
File size: 13.6 KB
RevLine 
[5]1package BookmarkApp;
2use strict;
3use warnings;
4use base qw{CGI::Application};
5
[6]6use CGI::Application::Plugin::TT;
7
[5]8use Encode;
[57]9use HTTP::Date qw{time2isoz time2iso time2str str2time};
[5]10use JSON;
11use Bookmarks;
[7]12use URI;
13
[5]14sub setup {
15    my $self = shift;
16    $self->mode_param(path_info => 1);
17    $self->run_modes([qw{
18        list
[9]19        feed
[5]20        view
[30]21        view_field
[45]22        create
[5]23        edit
24    }]);
[41]25
[53]26    my $url = $self->query->url;
27    $url .= '/' unless $url =~ m{/$};
28    my $base_uri = URI->new($url);
[37]29
30    my $bookmarks = Bookmarks->new({
[41]31        dbname   => $self->param('dbname'),
[37]32        base_uri => $base_uri,
33    });
34
35    $self->param(
36        base_uri  => $base_uri,
37        bookmarks => $bookmarks,
38    );
[5]39}
40
[37]41sub _bookmarks { $_[0]->param('bookmarks') }
42
[38]43sub _get_list_links {
44    my $self = shift;
[52]45    my ($self_type, $query) = @_;
[38]46    my @links = (
47        {
48            text => 'JSON',
49            type => 'application/json',
50            query => {
[52]51                %$query,
[38]52                format => 'json',
53            },
54        },
55        {
56            text => 'XBEL',
57            type => 'application/xml',
58            query => {
[52]59                %$query,
[38]60                format => 'xbel',
61            },
62        },
63        {
64            text => 'Atom',
65            type => 'application/atom+xml',
66            path => 'feed',
67            query => {
[52]68                %$query,
[38]69            },
70        },
71        {
[49]72            text => 'CSV',
73            type => 'text/csv',
74            query => {
[52]75                %$query,
[49]76                format => 'csv',
77            },
78        },
79        {
[38]80            text => 'URI List',
81            type => 'text/uri-list',
82            query => {
[52]83                %$query,
[40]84                format => 'text',
[38]85            },
[49]86        },
[50]87        {
88            text => 'HTML',
89            type => 'text/html',
90            query => {
[52]91                %$query,
[50]92            },
93        },
[38]94    );
95
96    for my $link (@links) {
97        $link->{rel}  = $link->{type} eq $self_type ? 'self' : 'alternate';
98        $link->{href} = URI->new_abs($link->{path} || '', $self->param('base_uri'));
99        $link->{href}->query_form($link->{query});
100    }
101
102    return @links;
103}
104
[5]105sub list {
106    my $self = shift;
107    my $q = $self->query;
108
109    # check for a uri param, and if there is one present,
110    # see if a bookmark for that URI already exists
111    if (defined(my $uri = $q->param('uri'))) {
[37]112        my $bookmark = $self->_bookmarks->get_bookmark({ uri => $uri });
[5]113        if ($bookmark) {
114            # redirect to the view of the existing bookmark
115            $self->header_type('redirect');
116            $self->header_props(
117                -uri => $q->url . '/' . $bookmark->{id},
118            );
119            return;
120        } else {
121            # bookmark was not found; show the form to create a new bookmark
122            $bookmark->{uri} = $uri;
123            $bookmark->{title} = $q->param('title');
124            $self->header_props(
125                -type    => 'text/html',
126                -charset => 'UTF-8',
127                -status  => 404,
128            );
[6]129            return $self->tt_process(
[5]130                'bookmark.tt',
131                $bookmark,
132            );
133        }
134    }
135
136    # list all the bookmarks
[57]137    my $mtime = $self->_bookmarks->get_last_modified_time;
138
[5]139    my $format = $q->param('format') || 'html';
[35]140
141    my @tags = grep { $_ ne '' } $q->param('tag');
[52]142    my $query = $q->param('q');
[13]143    my $limit = $q->param('limit');
144    my $offset = $q->param('offset');
[37]145    my @resources = $self->_bookmarks->get_bookmarks({
[52]146        query  => $query,
[20]147        tag    => \@tags,
[13]148        limit  => $limit,
149        offset => $offset,
150    });
[37]151    my @all_tags = $self->_bookmarks->get_tags({ selected => $tags[0] });
[52]152    my @cotags = $self->_bookmarks->get_cotags({
153        query  => $query,
154        tag    => \@tags,
155    });
[31]156   
[52]157    my $title = 'Bookmarks' . (@tags ? " tagged as " . join(' & ', @tags) : '') . ($query ? " matching '$query'" : '');
[5]158
159    if ($format eq 'json') {
160        $self->header_props(
161            -type    => 'application/json',
162            -charset => 'UTF-8',
163        );
164        return decode_utf8(
[25]165            JSON->new->utf8->convert_blessed->encode({
[7]166                bookmarks => \@resources,
[5]167            })
168        );
[19]169    } elsif ($format eq 'xbel') {
170        require XML::XBEL;
171        #TODO: conditional support; if XML::XBEL is not present, return a 5xx response
172
173        my $xbel = XML::XBEL->new;
174
175        $xbel->new_document({
[31]176            title => $title,
[19]177        });
178
179        for my $bookmark (@resources) {
[36]180            my $cdatetime = time2isoz $bookmark->ctime;
181            my $mdatetime = time2isoz $bookmark->mtime;
[19]182            # make the timestamps W3C-correct
183            s/ /T/ foreach ($cdatetime, $mdatetime);
184
185            $xbel->add_bookmark({
[36]186                href     => $bookmark->uri,
187                title    => $bookmark->title,
188                desc     => 'Tags: ' . join(', ', @{ $bookmark->tags }),
[19]189                added    => $cdatetime,
190                #XXX: are we sure that modified is the mtime of the bookmark or the resource?
191                modified => $mdatetime,
192            });
193        }
194
195        $self->header_props(
196            -type    => 'application/xml',
197            -charset => 'UTF-8',
198        );
199
200        return $xbel->toString;
[36]201    } elsif ($format eq 'text') {
202        $self->header_props(
203            -type    => 'text/uri-list',
204            -charset => 'UTF-8',
205        );
206        return join '', 
207            map {
208                sprintf "# %s\n# Tags: %s\n%s\n",
209                $_->title,
210                join(', ', @{ $_->tags }), 
211                $_->uri
212            } @resources;
[49]213    } elsif ($format eq 'csv') {
[51]214        require Text::CSV::Encoded;
215        my $csv = Text::CSV::Encoded->new({ encoding => 'utf8' });
[49]216        my $text = qq{id,uri,title,tags,ctime,mtime\n};
217        for my $bookmark (@resources) {
218            my $success = $csv->combine(
219                $bookmark->id,
220                $bookmark->uri,
221                $bookmark->title,
222                join(' ', @{ $bookmark->tags }),
223                $bookmark->ctime,
224                $bookmark->mtime,
225            );
226            $text .= $csv->string . "\n" if $success;
227        }
228
229        # include the local timestamp in the attchment filename
230        my $dt = time2iso;
231        $dt =~ s/[^\d]//g;
232
233        $self->header_props(
234            -type       => 'text/csv',
235            -charset    => 'UTF-8',
236            -attachment => 'bookmarks-' . join('_', @tags) . "-$dt.csv",
237        );
238        return $text;
[5]239    } else {
240        $self->header_props(
241            -type    => 'text/html',
242            -charset => 'UTF-8',
[57]243            -Last_Modified => time2str($mtime),
[5]244        );
245
246        # set the base URL, adding a trailing slash if needed
247        my $base_url = $q->url;
248        $base_url .= '/' if $base_url =~ m{/bookmarks$};
249
[6]250        return $self->tt_process(
[5]251            'list.tt',
252            {
253                base_url     => $base_url,
[31]254                title        => $title,
[52]255                query        => $query,
[35]256                selected_tag => $tags[0],
[20]257                search_tags  => \@tags,
[52]258                links        => [ $self->_get_list_links('text/html', { q => $query, tag => \@tags }) ],
[20]259                all_tags     => \@all_tags,
[5]260                cotags       => \@cotags,
261                resources    => \@resources,
262            },
263        );
264    }
265}
266
[9]267sub feed {
268    my $self = shift;
269    my $q = $self->query;
270
[52]271    my $query = $q->param('q');
[35]272    my @tags = grep { $_ ne '' } $q->param('tag');
[31]273    my $title = 'Bookmarks' . (@tags ? " tagged as " . join(' & ', @tags) : '');
274
[33]275    require XML::Atom;
276    $XML::Atom::DefaultVersion = "1.0";
277
[9]278    require XML::Atom::Feed;
279    require XML::Atom::Entry;
280    require XML::Atom::Link;
[39]281    require XML::Atom::Category;
[9]282
283    my $feed = XML::Atom::Feed->new;
[31]284    $feed->title($title);
[32]285
[37]286    my $feed_uri = URI->new_abs('feed', $self->param('base_uri'));
[32]287    $feed_uri->query_form(tag => \@tags);
288    $feed->id($feed_uri->canonical);
289
[52]290    for my $link ($self->_get_list_links('application/atom+xml', { q => $query, tag => \@tags })) {
[38]291        my $atom_link = XML::Atom::Link->new;
292        $atom_link->type($link->{type});
293        $atom_link->rel($link->{rel});
294        $atom_link->href($link->{href}->canonical);
295        $feed->add_link($atom_link);
296    }
[32]297
[9]298    # construct a feed from the most recent 12 bookmarks
[52]299    for my $bookmark ($self->_bookmarks->get_bookmarks({ query => $query, tag => \@tags, limit => 12 })) {
[9]300        my $entry = XML::Atom::Entry->new;
[31]301        $entry->id($bookmark->bookmark_uri->canonical);
302        $entry->title($bookmark->title);
[39]303       
[9]304        my $link = XML::Atom::Link->new;
[31]305        $link->href($bookmark->uri);
[9]306        $entry->add_link($link);
[39]307       
[31]308        $entry->summary('Tags: ' . join(', ', @{ $bookmark->tags }));
[39]309
310        my $cdatetime = time2isoz $bookmark->ctime;
[33]311        my $mdatetime = time2isoz $bookmark->mtime;
312        # make the timestamp W3C-correct
[39]313        s/ /T/ foreach ($cdatetime, $mdatetime);
314        $entry->published($cdatetime);
[33]315        $entry->updated($mdatetime);
[39]316       
317        for my $tag (@{ $bookmark->tags }) {
318            my $category = XML::Atom::Category->new;
319            $category->term($tag);
320            $entry->add_category($category);
321        }
322
[9]323        $feed->add_entry($entry);
324    }
325
326    $self->header_props(
327        -type => 'application/atom+xml',
328        -charset => 'UTF-8',
329    );
330    return $feed->as_xml;
331}
332
[5]333sub view {
334    my $self = shift;
335    my $id = $self->param('id');
[7]336    my $format = $self->query->param('format') || 'html';
[5]337
[37]338    my $bookmark = $self->_bookmarks->get_bookmark({ id => $id });
[5]339    if ($bookmark) {
[57]340        # check If-Modified-Since header to return cache response
341        if ($self->query->env->{HTTP_IF_MODIFIED_SINCE}) {
342            my $cache_time = str2time($self->query->env->{HTTP_IF_MODIFIED_SINCE});
343            if ($bookmark->mtime <= $cache_time) {
344                $self->header_type('redirect');
345                $self->header_props(
346                    -status => 304,
347                );
348                return;
349            }
350        }
351       
[30]352        if ($format eq 'json') {
[5]353            $self->header_props(
[30]354                -type    => 'application/json',
[5]355                -charset => 'UTF-8',
356            );
[30]357            return decode_utf8(JSON->new->utf8->convert_blessed->encode($bookmark));
[5]358        } else {
[30]359            # display the bookmark form for this bookmark
360            $bookmark->{exists} = 1;
361            $bookmark->{created} = "Created " . localtime($bookmark->ctime);
362            $bookmark->{created} .= '; Updated ' . localtime($bookmark->mtime) unless $bookmark->ctime == $bookmark->mtime;
363            $self->header_props(
364                -type    => 'text/html',
365                -charset => 'UTF-8',
[57]366                -Last_Modified => time2str($bookmark->mtime),
[30]367            );
368            return $self->tt_process(
369                'bookmark.tt',
370                $bookmark,
371            );
372        }
373    } else {
374        $self->header_props(
375            -type    => 'text/html',
376            -charset => 'UTF-8',
377            -status  => 404,
378        );
379        return "Bookmark $id Not Found";
380    }
381}
382
383sub view_field {
384    my $self = shift;
385    my $id = $self->param('id');
386    my $field = $self->param('field');
387
[37]388    my $bookmark = $self->_bookmarks->get_bookmark({ id => $id });
[30]389    if ($bookmark) {
390        # respond with just the requested field as plain text
391        my $value = eval { $bookmark->$field };
392        if ($@) {
393            if ($@ =~ /Can't locate object method/) {
[7]394                $self->header_props(
[30]395                    -type    => 'text/plain',
[7]396                    -charset => 'UTF-8',
[30]397                    -status  => 404,
[7]398                );
[30]399                return qq{"$field" is not a valid bookmark data field};
[7]400            } else {
[30]401                die $@;
[7]402            }
[5]403        }
[30]404        $self->header_props(
405            -type    => 'text/plain',
406            -charset => 'UTF-8',
407        );
[48]408        return ref $value eq 'ARRAY' ? join(' ', @{ $value }) : $value;
[5]409    } else {
410        $self->header_props(
411            -type    => 'text/html',
412            -charset => 'UTF-8',
413            -status  => 404,
414        );
415        return "Bookmark $id Not Found";
416    }
417}
418
[45]419sub create {
[5]420    my $self = shift;
421    my $q = $self->query;
422    my $uri = $q->param('uri');
423    my $title = $q->param('title');
424    my @tags = split ' ', $q->param('tags');
[43]425    my $bookmark = $self->_bookmarks->add({
[5]426        uri   => $uri,
427        title => $title,
428        tags  => \@tags,
429    });
430
431=begin
432
433    my $location = URI->new($q->url);
434    $location->query_form(uri => $uri) if defined $q->url_param('uri');
435    $location->fragment('updated');
436
437=cut
438
439    # return to the form
440    $self->header_type('redirect');
441    $self->header_props(
[43]442        -uri => $bookmark->bookmark_uri->canonical,
[5]443        -status => 303,
444    );
445}
446
[45]447sub edit {
448    my $self = shift;
449    my $q = $self->query;
450    my $id = $self->param('id');
451
452    my $bookmark = $self->_bookmarks->get_bookmark({ id => $id });
453    if ($bookmark) {
454        # update the URI, title, and tags
455        $bookmark->uri($q->param('uri'));
456        $bookmark->title($q->param('title'));
[53]457        $bookmark->tags([ split ' ', $q->param('tags') || '' ]);
[45]458
[46]459        # write to the database
460        $self->_bookmarks->update($bookmark);
461
[45]462        # return to the form
463        $self->header_type('redirect');
464        $self->header_props(
465            -uri => $bookmark->bookmark_uri->canonical,
466            -status => 303,
467        );
468    } else {
469        $self->header_props(
470            -type    => 'text/html',
471            -charset => 'UTF-8',
472            -status  => 404,
473        );
474        return "Bookmark $id Not Found";
475    }
476}
477
[5]4781;
Note: See TracBrowser for help on using the repository browser.