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
Line 
1package BookmarkApp;
2use strict;
3use warnings;
4use base qw{CGI::Application};
5
6use CGI::Application::Plugin::TT;
7
8use Encode;
9use HTTP::Date qw{time2isoz time2iso time2str str2time};
10use JSON;
11use Bookmarks;
12use URI;
13
14sub setup {
15    my $self = shift;
16    $self->mode_param(path_info => 1);
17    $self->run_modes([qw{
18        list
19        feed
20        view
21        view_field
22        create
23        edit
24    }]);
25
26    my $url = $self->query->url;
27    $url .= '/' unless $url =~ m{/$};
28    my $base_uri = URI->new($url);
29
30    my $bookmarks = Bookmarks->new({
31        dbname   => $self->param('dbname'),
32        base_uri => $base_uri,
33    });
34
35    $self->param(
36        base_uri  => $base_uri,
37        bookmarks => $bookmarks,
38    );
39}
40
41sub _bookmarks { $_[0]->param('bookmarks') }
42
43sub _get_list_links {
44    my $self = shift;
45    my ($self_type, $query) = @_;
46    my @links = (
47        {
48            text => 'JSON',
49            type => 'application/json',
50            query => {
51                %$query,
52                format => 'json',
53            },
54        },
55        {
56            text => 'XBEL',
57            type => 'application/xml',
58            query => {
59                %$query,
60                format => 'xbel',
61            },
62        },
63        {
64            text => 'Atom',
65            type => 'application/atom+xml',
66            path => 'feed',
67            query => {
68                %$query,
69            },
70        },
71        {
72            text => 'CSV',
73            type => 'text/csv',
74            query => {
75                %$query,
76                format => 'csv',
77            },
78        },
79        {
80            text => 'URI List',
81            type => 'text/uri-list',
82            query => {
83                %$query,
84                format => 'text',
85            },
86        },
87        {
88            text => 'HTML',
89            type => 'text/html',
90            query => {
91                %$query,
92            },
93        },
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
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'))) {
112        my $bookmark = $self->_bookmarks->get_bookmark({ uri => $uri });
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            );
129            return $self->tt_process(
130                'bookmark.tt',
131                $bookmark,
132            );
133        }
134    }
135
136    # list all the bookmarks
137    my $mtime = $self->_bookmarks->get_last_modified_time;
138
139    my $format = $q->param('format') || 'html';
140
141    my @tags = grep { $_ ne '' } $q->param('tag');
142    my $query = $q->param('q');
143    my $limit = $q->param('limit');
144    my $offset = $q->param('offset');
145    my @resources = $self->_bookmarks->get_bookmarks({
146        query  => $query,
147        tag    => \@tags,
148        limit  => $limit,
149        offset => $offset,
150    });
151    my @all_tags = $self->_bookmarks->get_tags({ selected => $tags[0] });
152    my @cotags = $self->_bookmarks->get_cotags({
153        query  => $query,
154        tag    => \@tags,
155    });
156   
157    my $title = 'Bookmarks' . (@tags ? " tagged as " . join(' & ', @tags) : '') . ($query ? " matching '$query'" : '');
158
159    if ($format eq 'json') {
160        $self->header_props(
161            -type    => 'application/json',
162            -charset => 'UTF-8',
163        );
164        return decode_utf8(
165            JSON->new->utf8->convert_blessed->encode({
166                bookmarks => \@resources,
167            })
168        );
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({
176            title => $title,
177        });
178
179        for my $bookmark (@resources) {
180            my $cdatetime = time2isoz $bookmark->ctime;
181            my $mdatetime = time2isoz $bookmark->mtime;
182            # make the timestamps W3C-correct
183            s/ /T/ foreach ($cdatetime, $mdatetime);
184
185            $xbel->add_bookmark({
186                href     => $bookmark->uri,
187                title    => $bookmark->title,
188                desc     => 'Tags: ' . join(', ', @{ $bookmark->tags }),
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;
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;
213    } elsif ($format eq 'csv') {
214        require Text::CSV::Encoded;
215        my $csv = Text::CSV::Encoded->new({ encoding => 'utf8' });
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;
239    } else {
240        $self->header_props(
241            -type    => 'text/html',
242            -charset => 'UTF-8',
243            -Last_Modified => time2str($mtime),
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
250        return $self->tt_process(
251            'list.tt',
252            {
253                base_url     => $base_url,
254                title        => $title,
255                query        => $query,
256                selected_tag => $tags[0],
257                search_tags  => \@tags,
258                links        => [ $self->_get_list_links('text/html', { q => $query, tag => \@tags }) ],
259                all_tags     => \@all_tags,
260                cotags       => \@cotags,
261                resources    => \@resources,
262            },
263        );
264    }
265}
266
267sub feed {
268    my $self = shift;
269    my $q = $self->query;
270
271    my $query = $q->param('q');
272    my @tags = grep { $_ ne '' } $q->param('tag');
273    my $title = 'Bookmarks' . (@tags ? " tagged as " . join(' & ', @tags) : '');
274
275    require XML::Atom;
276    $XML::Atom::DefaultVersion = "1.0";
277
278    require XML::Atom::Feed;
279    require XML::Atom::Entry;
280    require XML::Atom::Link;
281    require XML::Atom::Category;
282
283    my $feed = XML::Atom::Feed->new;
284    $feed->title($title);
285
286    my $feed_uri = URI->new_abs('feed', $self->param('base_uri'));
287    $feed_uri->query_form(tag => \@tags);
288    $feed->id($feed_uri->canonical);
289
290    for my $link ($self->_get_list_links('application/atom+xml', { q => $query, tag => \@tags })) {
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    }
297
298    # construct a feed from the most recent 12 bookmarks
299    for my $bookmark ($self->_bookmarks->get_bookmarks({ query => $query, tag => \@tags, limit => 12 })) {
300        my $entry = XML::Atom::Entry->new;
301        $entry->id($bookmark->bookmark_uri->canonical);
302        $entry->title($bookmark->title);
303       
304        my $link = XML::Atom::Link->new;
305        $link->href($bookmark->uri);
306        $entry->add_link($link);
307       
308        $entry->summary('Tags: ' . join(', ', @{ $bookmark->tags }));
309
310        my $cdatetime = time2isoz $bookmark->ctime;
311        my $mdatetime = time2isoz $bookmark->mtime;
312        # make the timestamp W3C-correct
313        s/ /T/ foreach ($cdatetime, $mdatetime);
314        $entry->published($cdatetime);
315        $entry->updated($mdatetime);
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
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
333sub view {
334    my $self = shift;
335    my $id = $self->param('id');
336    my $format = $self->query->param('format') || 'html';
337
338    my $bookmark = $self->_bookmarks->get_bookmark({ id => $id });
339    if ($bookmark) {
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       
352        if ($format eq 'json') {
353            $self->header_props(
354                -type    => 'application/json',
355                -charset => 'UTF-8',
356            );
357            return decode_utf8(JSON->new->utf8->convert_blessed->encode($bookmark));
358        } else {
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',
366                -Last_Modified => time2str($bookmark->mtime),
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
388    my $bookmark = $self->_bookmarks->get_bookmark({ id => $id });
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/) {
394                $self->header_props(
395                    -type    => 'text/plain',
396                    -charset => 'UTF-8',
397                    -status  => 404,
398                );
399                return qq{"$field" is not a valid bookmark data field};
400            } else {
401                die $@;
402            }
403        }
404        $self->header_props(
405            -type    => 'text/plain',
406            -charset => 'UTF-8',
407        );
408        return ref $value eq 'ARRAY' ? join(' ', @{ $value }) : $value;
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
419sub create {
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');
425    my $bookmark = $self->_bookmarks->add({
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(
442        -uri => $bookmark->bookmark_uri->canonical,
443        -status => 303,
444    );
445}
446
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'));
457        $bookmark->tags([ split ' ', $q->param('tags') || '' ]);
458
459        # write to the database
460        $self->_bookmarks->update($bookmark);
461
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
4781;
Note: See TracBrowser for help on using the repository browser.