package BookmarkController; use Moose; use Encode; use HTTP::Date qw{time2isoz time2iso time2str str2time}; use JSON; use Bookmarks; use URI; use Template; has dbname => ( is => 'ro', required => 1, ); has bookmarks => ( is => 'ro', handles => [qw{get_bookmark}], builder => '_build_bookmarks', lazy => 1, ); has base_uri => ( is => 'ro', builder => '_build_base_uri', lazy => 1, ); has request => ( is => 'ro', required => 1, ); sub _build_bookmarks { my $self = shift; return Bookmarks->new({ dbname => $self->dbname, base_uri => $self->base_uri, }); } sub _build_base_uri { my $self = shift; my $url = $self->request->base; $url .= '/' unless $url =~ m{/$}; return URI->new($url); } sub _get_list_links { my $self = shift; my ($self_type, $query) = @_; my @links = ( { text => 'JSON', type => 'application/json', query => { %$query, format => 'json', }, }, { text => 'XBEL', type => 'application/xml', query => { %$query, format => 'xbel', }, }, { text => 'Atom', type => 'application/atom+xml', path => 'feed', query => { %$query, }, }, { text => 'CSV', type => 'text/csv', query => { %$query, format => 'csv', }, }, { text => 'URI List', type => 'text/uri-list', query => { %$query, format => 'text', }, }, { text => 'HTML', type => 'text/html', query => { %$query, }, }, ); for my $link (@links) { $link->{rel} = $link->{type} eq $self_type ? 'self' : 'alternate'; $link->{href} = URI->new_abs($link->{path} || '', $self->base_uri); $link->{href}->query_form($link->{query}); } return @links; } sub find_or_new { my $self = shift; my $bookmark = $self->bookmarks->get_bookmark({ uri => $self->request->param('uri') }); if ($bookmark) { # redirect to the view of the existing bookmark return [301, [Location => $bookmark->bookmark_uri], []]; } else { # bookmark was not found; show the form to create a new bookmark my $template = Template->new; $template->process( 'bookmark.tt', { bookmark => { uri => $self->request->param('uri'), title => $self->request->param('title') || '', }, }, \my $output, ); return [404, ['Content-Type' => 'text/html; charset=UTF-8'], [$output]]; } } sub list { my $self = shift; # list all the bookmarks my $mtime = $self->bookmarks->get_last_modified_time; my $format = $self->request->param('format') || 'html'; my @tags = grep { $_ ne '' } $self->request->param('tag'); my $query = $self->request->param('q'); my $limit = $self->request->param('limit'); my $offset = $self->request->param('offset'); my @resources = $self->bookmarks->get_bookmarks({ query => $query, tag => \@tags, limit => $limit, offset => $offset, }); my @all_tags = $self->bookmarks->get_tags({ selected => $tags[0] }); my @cotags = $self->bookmarks->get_cotags({ query => $query, tag => \@tags, }); my $title = 'Bookmarks' . (@tags ? " tagged as " . join(' & ', @tags) : '') . ($query ? " matching '$query'" : ''); if ($format eq 'json') { my $json = decode_utf8( JSON->new->utf8->convert_blessed->encode({ bookmarks => \@resources, }) ); return [200, ['Content-Type' => 'application/json; charset=UTF-8'], [$json]]; } elsif ($format eq 'xbel') { require XML::XBEL; #TODO: conditional support; if XML::XBEL is not present, return a 5xx response my $xbel = XML::XBEL->new; $xbel->new_document({ title => $title, }); for my $bookmark (@resources) { my $cdatetime = time2isoz $bookmark->ctime; my $mdatetime = time2isoz $bookmark->mtime; # make the timestamps W3C-correct s/ /T/ foreach ($cdatetime, $mdatetime); $xbel->add_bookmark({ href => $bookmark->uri, title => $bookmark->title, desc => 'Tags: ' . join(', ', @{ $bookmark->tags }), added => $cdatetime, #XXX: are we sure that modified is the mtime of the bookmark or the resource? modified => $mdatetime, }); } return [200, ['Content-Type' => 'application/xml; charset=UTF-8'], [$xbel->toString]]; } elsif ($format eq 'text') { my $text = join '', map { sprintf "# %s\n# Tags: %s\n%s\n", $_->title, join(', ', @{ $_->tags }), $_->uri } @resources; return [200, ['Content-Type' => 'text/uri-list; charset=UTF-8'], [$text]]; } elsif ($format eq 'csv') { require Text::CSV::Encoded; my $csv = Text::CSV::Encoded->new({ encoding_out => 'utf8' }); my $text = qq{id,uri,title,tags,ctime,mtime\n}; for my $bookmark (@resources) { my $success = $csv->combine( $bookmark->id, $bookmark->uri, $bookmark->title, join(' ', @{ $bookmark->tags }), $bookmark->ctime, $bookmark->mtime, ); $text .= $csv->string . "\n" if $success; } # include the local timestamp in the attchment filename my $dt = time2iso; $dt =~ s/[^\d]//g; my $filename = sprintf( 'bookmarks-%s-%s.csv', join('_', @tags), $dt, ); return [200, ['Content-Type' => 'text/csv; charset=UTF-8', 'Content-Disposition' => sprintf('attachement; filename="%s"', $filename)], [$text]]; } else { my $template = Template->new; $template->process( 'list.tt', { base_url => $self->base_uri, title => $title, query => $query, selected_tag => $tags[0], search_tags => \@tags, links => [ $self->_get_list_links('text/html', { q => $query, tag => \@tags }) ], all_tags => \@all_tags, cotags => \@cotags, resources => \@resources, }, \my $output, ); return [200, ['Content-Type' => 'text/html; charset=UTF-8'], [$output]]; } } sub feed { my $self = shift; my $query = $self->request->param('q'); my @tags = grep { $_ ne '' } $self->request->param('tag'); my $title = 'Bookmarks' . (@tags ? " tagged as " . join(' & ', @tags) : ''); require XML::Atom; $XML::Atom::DefaultVersion = "1.0"; require XML::Atom::Feed; require XML::Atom::Entry; require XML::Atom::Link; require XML::Atom::Category; my $feed = XML::Atom::Feed->new; $feed->title($title); for my $link ($self->_get_list_links('application/atom+xml', { q => $query, tag => \@tags })) { my $atom_link = XML::Atom::Link->new; $atom_link->type($link->{type}); $atom_link->rel($link->{rel}); $atom_link->href($link->{href}->canonical); $feed->add_link($atom_link); } # construct a feed from the most recent 12 bookmarks for my $bookmark ($self->bookmarks->get_bookmarks({ query => $query, tag => \@tags, limit => 12 })) { my $entry = XML::Atom::Entry->new; $entry->id($bookmark->bookmark_uri->canonical); $entry->title($bookmark->title); my $link = XML::Atom::Link->new; $link->href($bookmark->uri); $entry->add_link($link); $entry->summary('Tags: ' . join(', ', @{ $bookmark->tags })); my $cdatetime = time2isoz $bookmark->ctime; my $mdatetime = time2isoz $bookmark->mtime; # make the timestamp W3C-correct s/ /T/ foreach ($cdatetime, $mdatetime); $entry->published($cdatetime); $entry->updated($mdatetime); for my $tag (@{ $bookmark->tags }) { my $category = XML::Atom::Category->new; $category->term($tag); $entry->add_category($category); } $feed->add_entry($entry); } return [200, ['Content-Type' => 'application/atom+xml; charset=UTF-8'], [$feed->as_xml]]; } # returns 1 if there is an If-Modified-Since header and it is newer than the given $mtime # returns 0 if there is an If-Modified-Since header but the $mtime is newer # returns undef if there is no If-Modified-Since header sub _request_is_newer_than { my $self = shift; my $mtime = shift; # check If-Modified-Since header to return cache response if ($self->request->env->{HTTP_IF_MODIFIED_SINCE}) { my $cache_time = str2time($self->request->env->{HTTP_IF_MODIFIED_SINCE}); return $mtime <= $cache_time ? 1 : 0; } else { return; } } sub view { my ($self, $id) = @_; my $format = $self->request->param('format') || 'html'; my $bookmark = $self->get_bookmark({ id => $id }); if ($bookmark) { return [304, [], []] if $self->_request_is_newer_than($bookmark->mtime); my $last_modified = time2str($bookmark->mtime); if ($format eq 'json') { my $json = decode_utf8(JSON->new->utf8->convert_blessed->encode($bookmark)); return [200, ['Content-Type' => 'application/json; charset=UTF-8', 'Last-Modified' => $last_modified], [$json]]; } else { # display the bookmark form for this bookmark my $template = Template->new; $template->process( 'bookmark.tt', { bookmark => $bookmark }, \my $output, ); return [200, ['Content-Type' => 'text/html; charset=UTF-8', 'Last-Modified' => $last_modified], [$output]]; } } else { return [404, ['Content-Type' => 'text/plain; charset=UTF-8'], ["Boomark $id not found"]]; } } sub view_field { my ($self, $id, $field) = @_; my $bookmark = $self->get_bookmark({ id => $id }); if ($bookmark) { return [304, [], []] if $self->_request_is_newer_than($bookmark->mtime); # check whether the requested field is part of the bookmark if (!$bookmark->meta->has_attribute($field)) { return [404, ['Content-Type' => 'text/plain; charset=UTF-8'], [qq{"$field" is not a valid bookmark data field}]]; } # respond with just the requested field as plain text my $value = $bookmark->$field; my $last_modified = time2str($bookmark->mtime); return [200, ['Content-Type' => 'text/plain; charset=UTF-8', 'Last-Modified' => $last_modified], [ref $value eq 'ARRAY' ? join(' ', @{ $value }) : $value]]; } else { return [404, ['Content-Type' => 'text/plain; charset=UTF-8'], ["Boomark $id not found"]]; } } sub create_and_redirect { my $self = shift; my $uri = $self->request->param('uri'); my $title = $self->request->param('title'); my @tags = split ' ', $self->request->param('tags'); my $bookmark = $self->bookmarks->add({ uri => $uri, title => $title, tags => \@tags, }); #TODO: not RESTful; the proper RESTful response would be a 201 return [303, ['Location' => $bookmark->bookmark_uri->canonical], []]; } sub update_and_redirect { my $self = shift; my $id = shift; my $bookmark = $self->bookmarks->get_bookmark({ id => $id }); if ($bookmark) { # update the URI, title, and tags $bookmark->uri($self->request->param('uri')); $bookmark->title($self->request->param('title')); $bookmark->tags([ split ' ', $self->request->param('tags') || '' ]); # write to the database $self->bookmarks->update($bookmark); #TODO: not RESTful; proper response would be a 200 return [303, ['Location' => $bookmark->bookmark_uri->canonical], []]; } else { return [404, ['Content-Type' => 'text/plain; charset=UTF-8'], ["Boomark $id not found"]]; } } 1;