Index: unk/Bookmark.pm
===================================================================
--- /trunk/Bookmark.pm	(revision 69)
+++ 	(revision )
@@ -1,47 +1,0 @@
-package Bookmark;
-
-use Moose;
-use HTTP::Date qw{time2isoz};
-
-has id    => ( is => 'ro' );
-has uri   => ( is => 'rw' );
-has title => ( is => 'rw' );
-has ctime => ( is => 'ro' );
-has mtime => (
-    is => 'ro',
-    # mtime defaults to ctime
-    default => sub { $_[0]->ctime },
-    lazy => 1,
-);
-has tags  => ( is => 'rw' );
-has bookmark_uri => ( is => 'rw' );
-has exists => ( is => 'ro' );
-
-sub created { scalar localtime $_[0]->ctime }
-sub updated { scalar localtime $_[0]->mtime }
-sub created_iso { (my $iso = time2isoz($_[0]->ctime)) =~ s/\D//g; return $iso; }
-sub updated_iso { (my $iso = time2isoz($_[0]->ctime)) =~ s/\D//g; return $iso; }
-
-sub BUILD {
-    my $self = shift;
-    my $args = shift;
-    if ($args->{base_uri}) {
-        $self->bookmark_uri(URI->new_abs($self->id, $args->{base_uri}));
-    }
-}
-
-sub TO_JSON {
-    my $self = shift;
-    return {
-        id    => $self->id,
-        uri   => $self->uri,
-        title => $self->title,
-        ctime => $self->ctime,
-        mtime => $self->mtime,
-        tags  => $self->tags,
-        ($self->bookmark_uri ? (bookmark_uri => $self->bookmark_uri->canonical->as_string) : ()),
-    };
-}
-
-# module return
-1;
Index: unk/BookmarkController.pm
===================================================================
--- /trunk/BookmarkController.pm	(revision 69)
+++ 	(revision )
@@ -1,213 +1,0 @@
-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 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 $list = $self->bookmarks->get_bookmarks({
-        query  => $query,
-        tag    => \@tags,
-        limit  => $limit,
-        offset => $offset,
-    });
-
-    my $as_format = "as_$format";
-    if (!$list->meta->has_method($as_format)) {
-        return [406, ['Content-Type' => 'text/plain; charset=UTF-8'], [qq{"$format" is not a supported format}]];
-    }
-    return $list->$as_format;
-}
-
-sub feed {
-    my $self = shift;
-
-    my $query = $self->request->param('q');
-    my @tags = grep { $_ ne '' } $self->request->param('tag');
-
-    # construct a feed from the most recent 12 bookmarks
-    my $list = $self->bookmarks->get_bookmarks({ query => $query, tag => \@tags, limit => 12 });
-    return $list->as_atom;
-}
-
-# 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;
Index: unk/Bookmarks.pm
===================================================================
--- /trunk/Bookmarks.pm	(revision 69)
+++ 	(revision )
@@ -1,314 +1,0 @@
-package Bookmarks;
-
-use Moose;
-use SQL::Interp qw{:all};
-use URI;
-use Bookmark;
-use BookmarksList;
-
-has dbh      => ( is => 'rw' );
-has base_uri => ( is => 'ro', isa => 'URI' );
-
-has _sth_tags_from_uri => (
-    is       => 'ro',
-    init_arg => undef,
-    lazy     => 1,
-    default  => sub { $_[0]->dbh->prepare('select tag from tags where uri = ? order by tag'); },
-);
-
-sub BUILD {
-    my $self = shift;
-    my $args = shift;
-
-    if (!$self->dbh) {
-        if ($args->{dbname}) {
-            require DBI;
-            $self->dbh(DBI->connect("dbi:SQLite:dbname=$$args{dbname}", "", "", { RaiseError => 1, PrintError => 0 }));
-            # enable foreign key support (requires DBD::SQLite 1.26_05 or above (sqlite 3.6.19 or above))
-            $self->dbh->do('pragma foreign_keys = on;');
-        } else {
-            #TODO: figure out how to make croak play nice with Moose to get the user-visible caller line
-            die "No dbh or dbname specified in the constructor";
-        }
-    }
-}
-
-sub create_tables {
-    my $self = shift;
-    require File::Slurp;
-    my $table_definitions = File::Slurp::read_file('bookmarks.sql');
-    $self->dbh->{sqlite_allow_multiple_statements} = 1;
-    $self->dbh->do($table_definitions);
-    $self->dbh->{sqlite_allow_multiple_statements} = 0;
-    return $self;
-}
-
-sub get_bookmark {
-    my $self = shift;
-    my $params = shift;
-
-    # look for bookmark by id or uri
-    my $sth;
-    if ($params->{id}) {
-        $sth = $self->dbh->prepare('select id,resources.uri,title,ctime,mtime from bookmarks join resources on bookmarks.uri=resources.uri where id=?');
-        $sth->execute($params->{id});
-    } elsif ($params->{uri}) {
-        $sth = $self->dbh->prepare('select id,resources.uri,title,ctime,mtime from bookmarks join resources on bookmarks.uri=resources.uri where resources.uri=?');
-        $sth->execute($params->{uri});
-    } else {
-        die "Must specify either id or uri";
-    }
-    my $bookmark = $sth->fetchrow_hashref;
-    return unless $bookmark;
-
-    return Bookmark->new({
-        %$bookmark,
-        exists   => 1,
-        tags     => [ $self->get_tags({ uri => $bookmark->{uri} }) ],
-        base_uri => $self->base_uri,
-    });
-}
-
-sub get_bookmarks {
-    my $self = shift;
-    my $params = shift;
-    my $query = $params->{query};
-    my $tags = $params->{tag} || [];
-    my $limit = $params->{limit};
-    my $offset = $params->{offset};
-
-    # build the query
-    my @sql;
-
-    if (!ref $tags) {
-        $tags = [ $tags ];
-    }
-    if (@$tags) {
-        my $intersect = 0;
-        for my $tag (@{ $tags }) {
-            push @sql, 'intersect' if $intersect;
-            push @sql, 'select resources.*, bookmarks.* from resources join bookmarks on resources.uri = bookmarks.uri';
-            push @sql, 'join tags on resources.uri = tags.uri where tags.tag =', \$tag;
-            $intersect++;
-        }
-    } else {
-        push @sql, 'select * from resources join bookmarks on resources.uri = bookmarks.uri';
-    }
-    if ($query) {
-        push @sql, (@$tags ? 'and' : 'where'), 'title like', \"%$query%";
-    }
-    push @sql, 'order by ctime desc';
-    push @sql, ('limit', \$limit) if $limit;
-    # an offset is only allowed if we have a limit clause
-    push @sql, ('offset', \$offset) if $limit && $offset;
-
-    my ($sql, @bind) = sql_interp(@sql);
-    #die $sql;
-
-    my $sth_resource = $self->dbh->prepare($sql);
-    $sth_resource->execute(@bind);
-
-    my @resources;
-    while (my $resource = $sth_resource->fetchrow_hashref) {
-        push @resources, Bookmark->new({
-            %$resource,
-            tags     => [ $self->get_tags({ uri => $resource->{uri} }) ],
-            base_uri => $self->base_uri,
-        });
-    }
-    return BookmarksList->new({
-        bookmarks => $self,
-        tags      => $tags,
-        query     => $query,
-        limit     => $limit,
-        offset    => $offset,
-        results   => \@resources,
-    });
-}
-
-sub get_tags {
-    my $self = shift;
-    my $params = shift;
-    if (my $uri = $params->{uri}) {
-        # get the tags for a particular URI
-        $self->_sth_tags_from_uri->execute($uri);
-        return map { $$_[0] } @{ $self->_sth_tags_from_uri->fetchall_arrayref };
-    } else {
-        # return all tags
-        my $tag = $params->{selected};
-        my $sth_all_tags = $self->dbh->prepare('select tag, count(tag) as count, tag = ? as selected from tags group by tag order by tag');
-        $sth_all_tags->execute($tag);
-        my $all_tags = $sth_all_tags->fetchall_arrayref({});
-        return @{ $all_tags };
-    }
-}
-
-sub get_cotags {
-    my $self = shift;
-    my $params = shift;
-    my $query = $params->{query};
-    my $tags = $params->{tag} || [];
-    if (!ref $tags) {
-        $tags = [ $tags ];
-    }
-    my @sql;
-
-    push @sql, 'select tag, count(tag) as count from tags';
-    push @sql, 'join resources on tags.uri = resources.uri' if $query;
-
-    # build the where clause
-    if (@$tags) {
-        push @sql, 'where tags.uri in (';
-        my $intersect = 0;
-        for my $tag (@{ $tags }) {
-            push @sql, 'intersect' if $intersect;
-            push @sql, 'select uri from tags where tag = ', \$tag;
-            $intersect++;
-        }
-        push @sql, ') and tag not in ', $tags, '';
-    }
-    if ($query) {
-        push @sql, (@$tags ? 'and' : 'where'), 'title like', \"%$query%";
-    }
-
-    push @sql, 'group by tag order by tag';
-
-    my ($sql, @bind) = sql_interp(@sql);
-    my $sth = $self->dbh->prepare($sql);
-    $sth->execute(@bind);
-    return @{ $sth->fetchall_arrayref({}) };
-}
-
-sub get_last_modified_time {
-    my $self = shift;
-    my $sth = $self->dbh->prepare('select mtime from bookmarks order by mtime desc limit 1');
-    $sth->execute;
-    my ($mtime) = $sth->fetchrow_array;
-    return $mtime;
-}
-
-sub add {
-    my $self = shift;
-    my $bookmark = shift;
-
-    my $uri = $bookmark->{uri};
-    my $title = $bookmark->{title};
-    my $ctime = $bookmark->{ctime} || time;
-    my $mtime = $bookmark->{mtime} || $ctime;
-    my $id = $bookmark->{id};
-
-    # create an entry for the resource
-    my $sth_resource = $self->dbh->prepare('insert into resources (uri, title) values (?, ?)');
-    eval {
-        $sth_resource->execute($uri, $title);
-    };
-    if ($@) {
-        if ($@ =~ /column uri is not unique/) {
-            # this is not truly an error condition; the resource is already listed
-            # update the title instead
-            my $sth_update = $self->dbh->prepare('update resources set title = ? where uri = ?');
-            $sth_update->execute($title, $uri);
-        } else {
-            die $@;
-        }
-    }
-
-    # create the bookmark
-    my $bookmark_exists = 0;
-    my ($sql_bookmark, @bind_bookmark) = sql_interp(
-        'insert into bookmarks', { ($id ? (id => $id) : ()), uri => $uri, ctime => $ctime, mtime => $mtime }
-    );
-    my $sth_bookmark = $self->dbh->prepare($sql_bookmark);
-    eval {
-        $sth_bookmark->execute(@bind_bookmark);
-    };
-    if ($@) {
-        if ($@ =~ /column uri is not unique/) {
-            # this is not truly an error condition; the bookmark was already there
-            # set this flag so that later we can update the mtime if tags change
-            $bookmark_exists = 1;
-        } else {
-            die $@;
-        }
-    }
-
-    my $changed_tags = $self->_update_tags($uri, $bookmark->{tags});
-
-    if ($bookmark_exists && $changed_tags) {
-        # update the mtime if the bookmark already existed but the tags were changed
-        my $sth_update = $self->dbh->prepare('update bookmarks set mtime = ? where uri = ?');
-        $sth_update->execute($mtime, $uri);
-    }
-
-    # return the newly created or updated bookmark
-    return $self->get_bookmark({ uri => $uri });
-}
-
-sub update {
-    my $self = shift;
-    my $bookmark = shift;
-
-    my $mtime = time;
-
-    my $changed_uri = 0;
-    my $sth_current = $self->dbh->prepare('select uri from bookmarks where id = ?');
-    $sth_current->execute($bookmark->id);
-    my ($stored_uri) = $sth_current->fetchrow_array;
-
-    if ($stored_uri ne $bookmark->uri) {
-        # the URI has changed
-        my $sth_update_uri = $self->dbh->prepare('update resources set uri = ? where uri = ?');
-        $sth_update_uri->execute($bookmark->uri, $stored_uri);
-        $changed_uri++;
-    }
-
-    # update the title
-    # TODO: only do this if the title has changed
-    # TODO: should we update mtime if the title changes?
-    my $sth_update = $self->dbh->prepare('update resources set title = ? where uri = ?');
-    $sth_update->execute($bookmark->title, $bookmark->uri);
-
-    my $changed_tags = $self->_update_tags($bookmark->uri, $bookmark->tags);
-
-    if ($changed_uri or $changed_tags) {
-        # update the mtime if the bookmark already existed but the tags were changed
-        my $sth_update = $self->dbh->prepare('update bookmarks set mtime = ? where uri = ?');
-        $sth_update->execute($mtime, $bookmark->uri);
-    }
-
-    # return the bookmark
-    return $bookmark;
-}
-
-sub _update_tags {
-    my $self = shift;
-    my ($uri, $tags) = @_;
-
-    my $changed_tags = 0;
-    my %new_tags = map { $_ => 1 } @{ $tags };
-    my $sth_delete_tag = $self->dbh->prepare('delete from tags where uri = ? and tag = ?');
-    my $sth_insert_tag = $self->dbh->prepare('insert into tags (uri, tag) values (?, ?)');
-    my $sth_current_tags = $self->dbh->prepare('select tag from tags where uri = ?');
-    $sth_current_tags->execute($uri);
-    while (my ($tag) = $sth_current_tags->fetchrow_array) {
-        if (!$new_tags{$tag}) {
-            # if a current tag is not in the new tags, remove it from the database
-            $sth_delete_tag->execute($uri, $tag);
-            $changed_tags++;
-        } else {
-            # if a new tag is already in the database, remove it from the list of tags to add
-            delete $new_tags{$tag};
-        }
-    }
-    for my $tag (keys %new_tags) {
-        $sth_insert_tag->execute($uri, $tag);
-        $changed_tags++;
-    }
-
-    # how many tags have changed?
-    return $changed_tags;
-}
-
-
-# module returns true
-1;
Index: unk/BookmarksList.pm
===================================================================
--- /trunk/BookmarksList.pm	(revision 69)
+++ 	(revision )
@@ -1,259 +1,0 @@
-package BookmarksList;
-
-use Moose;
-
-use Encode;
-use HTTP::Date qw{time2iso time2isoz};
-
-has bookmarks => (is => 'ro');
-has query  => (is => 'ro');
-has tags   => (
-    is => 'ro',
-    default => sub { [] },
-);
-has limit  => (is => 'ro');
-has offset => (is => 'ro');
-has results => ( is => 'ro' );
-has title => (
-    is => 'ro',
-    builder => '_build_title',
-    lazy => 1,
-);
-
-sub _get_list_links {
-    my $self = shift;
-    my ($self_type, $query) = @_;
-
-    # remove extraneous blank ?q= parameters
-    delete $query->{q} if defined $query->{q} && $query->{q} eq '';
-    
-    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->bookmarks->base_uri);
-        $link->{href}->query_form($link->{query});
-    }
-
-    return @links;
-}
-
-sub _build_title {
-    my $self = shift;
-    return 'Bookmarks' . (@{ $self->tags } ? " tagged as " . join(' & ', @{ $self->tags }) : '') . ($self->query ? " matching '" . $self->query . "'" : '');
-}
-
-sub as_json {
-    my $self = shift;
-    require JSON;
-    my $json = decode_utf8(
-        JSON->new->utf8->convert_blessed->encode({
-            bookmarks => $self->results,
-        })
-    );
-    return [200, ['Content-Type' => 'application/json; charset=UTF-8'], [$json]];
-}
-
-sub as_xbel {
-    my $self = shift;
-    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 => $self->title,
-    });
-
-    for my $bookmark (@{ $self->results }) {
-        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]];
-}
-
-sub as_text {
-    my $self = shift;
-    my $text = join '', 
-        map {
-            sprintf "# %s\n# Tags: %s\n%s\n",
-            $_->title,
-            join(', ', @{ $_->tags }), 
-            $_->uri
-        } @{ $self->results };
-    return [200, ['Content-Type' => 'text/uri-list; charset=UTF-8'], [$text]];
-}
-
-sub as_csv {
-    my $self = shift;
-    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 (@{ $self->results }) {
-        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('_', @{ $self->tags }),
-        $dt,
-    );
-
-    return [200, ['Content-Type' => 'text/csv; charset=UTF-8', 'Content-Disposition' => sprintf('attachement; filename="%s"', $filename)], [$text]];
-}
-
-sub as_html {
-    my $self = shift;
-
-    require Template;
-    my $template = Template->new;
-
-    my @all_tags = $self->bookmarks->get_tags({ selected => @{ $self->tags }[0] });
-    my @cotags = $self->bookmarks->get_cotags({
-        query  => $self->query,
-        tag    => $self->tags,
-    });
-
-    $template->process(
-        'list.tt',
-        {
-            base_url     => $self->bookmarks->base_uri,
-            title        => $self->title,
-            query        => $self->query,
-            selected_tag => @{ $self->tags }[0],
-            search_tags  => $self->tags,
-            links        => [ $self->_get_list_links('text/html', { q => $self->query, tag => $self->tags }) ],
-            all_tags     => \@all_tags,
-            cotags       => \@cotags,
-            resources    => $self->results,
-        },
-        \my $output,
-    );
-    return [200, ['Content-Type' => 'text/html; charset=UTF-8'], [$output]];
-}
-
-sub as_atom {
-    my $self = shift;
-
-    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($self->title);
-
-    for my $link ($self->_get_list_links('application/atom+xml', { q => $self->query, tag => $self->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);
-    }
-
-    for my $bookmark (@{ $self->results }) {
-        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]];
-}
-
-1;
Index: /trunk/app.psgi
===================================================================
--- /trunk/app.psgi	(revision 69)
+++ /trunk/app.psgi	(revision 70)
@@ -7,5 +7,5 @@
 use Router::Resource;
 
-use BookmarkController;
+use Bookmarks::Controller;
 
 #TODO: allow individual options to be set via environment vars, too
@@ -19,5 +19,5 @@
     my $req = Plack::Request->new($env);
 
-    return BookmarkController->new({
+    return Bookmarks::Controller->new({
         request => $req,
         dbname  => $config->{dbname},
Index: /trunk/lib/Bookmark.pm
===================================================================
--- /trunk/lib/Bookmark.pm	(revision 70)
+++ /trunk/lib/Bookmark.pm	(revision 70)
@@ -0,0 +1,47 @@
+package Bookmark;
+
+use Moose;
+use HTTP::Date qw{time2isoz};
+
+has id    => ( is => 'ro' );
+has uri   => ( is => 'rw' );
+has title => ( is => 'rw' );
+has ctime => ( is => 'ro' );
+has mtime => (
+    is => 'ro',
+    # mtime defaults to ctime
+    default => sub { $_[0]->ctime },
+    lazy => 1,
+);
+has tags  => ( is => 'rw' );
+has bookmark_uri => ( is => 'rw' );
+has exists => ( is => 'ro' );
+
+sub created { scalar localtime $_[0]->ctime }
+sub updated { scalar localtime $_[0]->mtime }
+sub created_iso { (my $iso = time2isoz($_[0]->ctime)) =~ s/\D//g; return $iso; }
+sub updated_iso { (my $iso = time2isoz($_[0]->ctime)) =~ s/\D//g; return $iso; }
+
+sub BUILD {
+    my $self = shift;
+    my $args = shift;
+    if ($args->{base_uri}) {
+        $self->bookmark_uri(URI->new_abs($self->id, $args->{base_uri}));
+    }
+}
+
+sub TO_JSON {
+    my $self = shift;
+    return {
+        id    => $self->id,
+        uri   => $self->uri,
+        title => $self->title,
+        ctime => $self->ctime,
+        mtime => $self->mtime,
+        tags  => $self->tags,
+        ($self->bookmark_uri ? (bookmark_uri => $self->bookmark_uri->canonical->as_string) : ()),
+    };
+}
+
+# module return
+1;
Index: /trunk/lib/Bookmarks.pm
===================================================================
--- /trunk/lib/Bookmarks.pm	(revision 70)
+++ /trunk/lib/Bookmarks.pm	(revision 70)
@@ -0,0 +1,314 @@
+package Bookmarks;
+
+use Moose;
+use SQL::Interp qw{:all};
+use URI;
+use Bookmark;
+use Bookmarks::List;
+
+has dbh      => ( is => 'rw' );
+has base_uri => ( is => 'ro', isa => 'URI' );
+
+has _sth_tags_from_uri => (
+    is       => 'ro',
+    init_arg => undef,
+    lazy     => 1,
+    default  => sub { $_[0]->dbh->prepare('select tag from tags where uri = ? order by tag'); },
+);
+
+sub BUILD {
+    my $self = shift;
+    my $args = shift;
+
+    if (!$self->dbh) {
+        if ($args->{dbname}) {
+            require DBI;
+            $self->dbh(DBI->connect("dbi:SQLite:dbname=$$args{dbname}", "", "", { RaiseError => 1, PrintError => 0 }));
+            # enable foreign key support (requires DBD::SQLite 1.26_05 or above (sqlite 3.6.19 or above))
+            $self->dbh->do('pragma foreign_keys = on;');
+        } else {
+            #TODO: figure out how to make croak play nice with Moose to get the user-visible caller line
+            die "No dbh or dbname specified in the constructor";
+        }
+    }
+}
+
+sub create_tables {
+    my $self = shift;
+    require File::Slurp;
+    my $table_definitions = File::Slurp::read_file('bookmarks.sql');
+    $self->dbh->{sqlite_allow_multiple_statements} = 1;
+    $self->dbh->do($table_definitions);
+    $self->dbh->{sqlite_allow_multiple_statements} = 0;
+    return $self;
+}
+
+sub get_bookmark {
+    my $self = shift;
+    my $params = shift;
+
+    # look for bookmark by id or uri
+    my $sth;
+    if ($params->{id}) {
+        $sth = $self->dbh->prepare('select id,resources.uri,title,ctime,mtime from bookmarks join resources on bookmarks.uri=resources.uri where id=?');
+        $sth->execute($params->{id});
+    } elsif ($params->{uri}) {
+        $sth = $self->dbh->prepare('select id,resources.uri,title,ctime,mtime from bookmarks join resources on bookmarks.uri=resources.uri where resources.uri=?');
+        $sth->execute($params->{uri});
+    } else {
+        die "Must specify either id or uri";
+    }
+    my $bookmark = $sth->fetchrow_hashref;
+    return unless $bookmark;
+
+    return Bookmark->new({
+        %$bookmark,
+        exists   => 1,
+        tags     => [ $self->get_tags({ uri => $bookmark->{uri} }) ],
+        base_uri => $self->base_uri,
+    });
+}
+
+sub get_bookmarks {
+    my $self = shift;
+    my $params = shift;
+    my $query = $params->{query};
+    my $tags = $params->{tag} || [];
+    my $limit = $params->{limit};
+    my $offset = $params->{offset};
+
+    # build the query
+    my @sql;
+
+    if (!ref $tags) {
+        $tags = [ $tags ];
+    }
+    if (@$tags) {
+        my $intersect = 0;
+        for my $tag (@{ $tags }) {
+            push @sql, 'intersect' if $intersect;
+            push @sql, 'select resources.*, bookmarks.* from resources join bookmarks on resources.uri = bookmarks.uri';
+            push @sql, 'join tags on resources.uri = tags.uri where tags.tag =', \$tag;
+            $intersect++;
+        }
+    } else {
+        push @sql, 'select * from resources join bookmarks on resources.uri = bookmarks.uri';
+    }
+    if ($query) {
+        push @sql, (@$tags ? 'and' : 'where'), 'title like', \"%$query%";
+    }
+    push @sql, 'order by ctime desc';
+    push @sql, ('limit', \$limit) if $limit;
+    # an offset is only allowed if we have a limit clause
+    push @sql, ('offset', \$offset) if $limit && $offset;
+
+    my ($sql, @bind) = sql_interp(@sql);
+    #die $sql;
+
+    my $sth_resource = $self->dbh->prepare($sql);
+    $sth_resource->execute(@bind);
+
+    my @resources;
+    while (my $resource = $sth_resource->fetchrow_hashref) {
+        push @resources, Bookmark->new({
+            %$resource,
+            tags     => [ $self->get_tags({ uri => $resource->{uri} }) ],
+            base_uri => $self->base_uri,
+        });
+    }
+    return Bookmarks::List->new({
+        bookmarks => $self,
+        tags      => $tags,
+        query     => $query,
+        limit     => $limit,
+        offset    => $offset,
+        results   => \@resources,
+    });
+}
+
+sub get_tags {
+    my $self = shift;
+    my $params = shift;
+    if (my $uri = $params->{uri}) {
+        # get the tags for a particular URI
+        $self->_sth_tags_from_uri->execute($uri);
+        return map { $$_[0] } @{ $self->_sth_tags_from_uri->fetchall_arrayref };
+    } else {
+        # return all tags
+        my $tag = $params->{selected};
+        my $sth_all_tags = $self->dbh->prepare('select tag, count(tag) as count, tag = ? as selected from tags group by tag order by tag');
+        $sth_all_tags->execute($tag);
+        my $all_tags = $sth_all_tags->fetchall_arrayref({});
+        return @{ $all_tags };
+    }
+}
+
+sub get_cotags {
+    my $self = shift;
+    my $params = shift;
+    my $query = $params->{query};
+    my $tags = $params->{tag} || [];
+    if (!ref $tags) {
+        $tags = [ $tags ];
+    }
+    my @sql;
+
+    push @sql, 'select tag, count(tag) as count from tags';
+    push @sql, 'join resources on tags.uri = resources.uri' if $query;
+
+    # build the where clause
+    if (@$tags) {
+        push @sql, 'where tags.uri in (';
+        my $intersect = 0;
+        for my $tag (@{ $tags }) {
+            push @sql, 'intersect' if $intersect;
+            push @sql, 'select uri from tags where tag = ', \$tag;
+            $intersect++;
+        }
+        push @sql, ') and tag not in ', $tags, '';
+    }
+    if ($query) {
+        push @sql, (@$tags ? 'and' : 'where'), 'title like', \"%$query%";
+    }
+
+    push @sql, 'group by tag order by tag';
+
+    my ($sql, @bind) = sql_interp(@sql);
+    my $sth = $self->dbh->prepare($sql);
+    $sth->execute(@bind);
+    return @{ $sth->fetchall_arrayref({}) };
+}
+
+sub get_last_modified_time {
+    my $self = shift;
+    my $sth = $self->dbh->prepare('select mtime from bookmarks order by mtime desc limit 1');
+    $sth->execute;
+    my ($mtime) = $sth->fetchrow_array;
+    return $mtime;
+}
+
+sub add {
+    my $self = shift;
+    my $bookmark = shift;
+
+    my $uri = $bookmark->{uri};
+    my $title = $bookmark->{title};
+    my $ctime = $bookmark->{ctime} || time;
+    my $mtime = $bookmark->{mtime} || $ctime;
+    my $id = $bookmark->{id};
+
+    # create an entry for the resource
+    my $sth_resource = $self->dbh->prepare('insert into resources (uri, title) values (?, ?)');
+    eval {
+        $sth_resource->execute($uri, $title);
+    };
+    if ($@) {
+        if ($@ =~ /column uri is not unique/) {
+            # this is not truly an error condition; the resource is already listed
+            # update the title instead
+            my $sth_update = $self->dbh->prepare('update resources set title = ? where uri = ?');
+            $sth_update->execute($title, $uri);
+        } else {
+            die $@;
+        }
+    }
+
+    # create the bookmark
+    my $bookmark_exists = 0;
+    my ($sql_bookmark, @bind_bookmark) = sql_interp(
+        'insert into bookmarks', { ($id ? (id => $id) : ()), uri => $uri, ctime => $ctime, mtime => $mtime }
+    );
+    my $sth_bookmark = $self->dbh->prepare($sql_bookmark);
+    eval {
+        $sth_bookmark->execute(@bind_bookmark);
+    };
+    if ($@) {
+        if ($@ =~ /column uri is not unique/) {
+            # this is not truly an error condition; the bookmark was already there
+            # set this flag so that later we can update the mtime if tags change
+            $bookmark_exists = 1;
+        } else {
+            die $@;
+        }
+    }
+
+    my $changed_tags = $self->_update_tags($uri, $bookmark->{tags});
+
+    if ($bookmark_exists && $changed_tags) {
+        # update the mtime if the bookmark already existed but the tags were changed
+        my $sth_update = $self->dbh->prepare('update bookmarks set mtime = ? where uri = ?');
+        $sth_update->execute($mtime, $uri);
+    }
+
+    # return the newly created or updated bookmark
+    return $self->get_bookmark({ uri => $uri });
+}
+
+sub update {
+    my $self = shift;
+    my $bookmark = shift;
+
+    my $mtime = time;
+
+    my $changed_uri = 0;
+    my $sth_current = $self->dbh->prepare('select uri from bookmarks where id = ?');
+    $sth_current->execute($bookmark->id);
+    my ($stored_uri) = $sth_current->fetchrow_array;
+
+    if ($stored_uri ne $bookmark->uri) {
+        # the URI has changed
+        my $sth_update_uri = $self->dbh->prepare('update resources set uri = ? where uri = ?');
+        $sth_update_uri->execute($bookmark->uri, $stored_uri);
+        $changed_uri++;
+    }
+
+    # update the title
+    # TODO: only do this if the title has changed
+    # TODO: should we update mtime if the title changes?
+    my $sth_update = $self->dbh->prepare('update resources set title = ? where uri = ?');
+    $sth_update->execute($bookmark->title, $bookmark->uri);
+
+    my $changed_tags = $self->_update_tags($bookmark->uri, $bookmark->tags);
+
+    if ($changed_uri or $changed_tags) {
+        # update the mtime if the bookmark already existed but the tags were changed
+        my $sth_update = $self->dbh->prepare('update bookmarks set mtime = ? where uri = ?');
+        $sth_update->execute($mtime, $bookmark->uri);
+    }
+
+    # return the bookmark
+    return $bookmark;
+}
+
+sub _update_tags {
+    my $self = shift;
+    my ($uri, $tags) = @_;
+
+    my $changed_tags = 0;
+    my %new_tags = map { $_ => 1 } @{ $tags };
+    my $sth_delete_tag = $self->dbh->prepare('delete from tags where uri = ? and tag = ?');
+    my $sth_insert_tag = $self->dbh->prepare('insert into tags (uri, tag) values (?, ?)');
+    my $sth_current_tags = $self->dbh->prepare('select tag from tags where uri = ?');
+    $sth_current_tags->execute($uri);
+    while (my ($tag) = $sth_current_tags->fetchrow_array) {
+        if (!$new_tags{$tag}) {
+            # if a current tag is not in the new tags, remove it from the database
+            $sth_delete_tag->execute($uri, $tag);
+            $changed_tags++;
+        } else {
+            # if a new tag is already in the database, remove it from the list of tags to add
+            delete $new_tags{$tag};
+        }
+    }
+    for my $tag (keys %new_tags) {
+        $sth_insert_tag->execute($uri, $tag);
+        $changed_tags++;
+    }
+
+    # how many tags have changed?
+    return $changed_tags;
+}
+
+
+# module returns true
+1;
Index: /trunk/lib/Bookmarks/Controller.pm
===================================================================
--- /trunk/lib/Bookmarks/Controller.pm	(revision 70)
+++ /trunk/lib/Bookmarks/Controller.pm	(revision 70)
@@ -0,0 +1,214 @@
+package Bookmarks::Controller;
+
+use Moose;
+
+use Encode;
+use HTTP::Date qw{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 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 $list = $self->bookmarks->get_bookmarks({
+        query  => $query,
+        tag    => \@tags,
+        limit  => $limit,
+        offset => $offset,
+    });
+
+    my $as_format = "as_$format";
+    if (!$list->meta->has_method($as_format)) {
+        return [406, ['Content-Type' => 'text/plain; charset=UTF-8'], [qq{"$format" is not a supported format}]];
+    }
+    return $list->$as_format;
+}
+
+sub feed {
+    my $self = shift;
+
+    my $query = $self->request->param('q');
+    my @tags = grep { $_ ne '' } $self->request->param('tag');
+
+    # construct a feed from the most recent 12 bookmarks
+    my $list = $self->bookmarks->get_bookmarks({ query => $query, tag => \@tags, limit => 12 });
+    return $list->as_atom;
+}
+
+# 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;
Index: /trunk/lib/Bookmarks/List.pm
===================================================================
--- /trunk/lib/Bookmarks/List.pm	(revision 70)
+++ /trunk/lib/Bookmarks/List.pm	(revision 70)
@@ -0,0 +1,259 @@
+package Bookmarks::List;
+
+use Moose;
+
+use Encode;
+use HTTP::Date qw{time2iso time2isoz};
+
+has bookmarks => (is => 'ro');
+has query  => (is => 'ro');
+has tags   => (
+    is => 'ro',
+    default => sub { [] },
+);
+has limit  => (is => 'ro');
+has offset => (is => 'ro');
+has results => ( is => 'ro' );
+has title => (
+    is => 'ro',
+    builder => '_build_title',
+    lazy => 1,
+);
+
+sub _get_list_links {
+    my $self = shift;
+    my ($self_type, $query) = @_;
+
+    # remove extraneous blank ?q= parameters
+    delete $query->{q} if defined $query->{q} && $query->{q} eq '';
+    
+    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->bookmarks->base_uri);
+        $link->{href}->query_form($link->{query});
+    }
+
+    return @links;
+}
+
+sub _build_title {
+    my $self = shift;
+    return 'Bookmarks' . (@{ $self->tags } ? " tagged as " . join(' & ', @{ $self->tags }) : '') . ($self->query ? " matching '" . $self->query . "'" : '');
+}
+
+sub as_json {
+    my $self = shift;
+    require JSON;
+    my $json = decode_utf8(
+        JSON->new->utf8->convert_blessed->encode({
+            bookmarks => $self->results,
+        })
+    );
+    return [200, ['Content-Type' => 'application/json; charset=UTF-8'], [$json]];
+}
+
+sub as_xbel {
+    my $self = shift;
+    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 => $self->title,
+    });
+
+    for my $bookmark (@{ $self->results }) {
+        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]];
+}
+
+sub as_text {
+    my $self = shift;
+    my $text = join '', 
+        map {
+            sprintf "# %s\n# Tags: %s\n%s\n",
+            $_->title,
+            join(', ', @{ $_->tags }), 
+            $_->uri
+        } @{ $self->results };
+    return [200, ['Content-Type' => 'text/uri-list; charset=UTF-8'], [$text]];
+}
+
+sub as_csv {
+    my $self = shift;
+    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 (@{ $self->results }) {
+        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('_', @{ $self->tags }),
+        $dt,
+    );
+
+    return [200, ['Content-Type' => 'text/csv; charset=UTF-8', 'Content-Disposition' => sprintf('attachement; filename="%s"', $filename)], [$text]];
+}
+
+sub as_html {
+    my $self = shift;
+
+    require Template;
+    my $template = Template->new;
+
+    my @all_tags = $self->bookmarks->get_tags({ selected => @{ $self->tags }[0] });
+    my @cotags = $self->bookmarks->get_cotags({
+        query  => $self->query,
+        tag    => $self->tags,
+    });
+
+    $template->process(
+        'list.tt',
+        {
+            base_url     => $self->bookmarks->base_uri,
+            title        => $self->title,
+            query        => $self->query,
+            selected_tag => @{ $self->tags }[0],
+            search_tags  => $self->tags,
+            links        => [ $self->_get_list_links('text/html', { q => $self->query, tag => $self->tags }) ],
+            all_tags     => \@all_tags,
+            cotags       => \@cotags,
+            resources    => $self->results,
+        },
+        \my $output,
+    );
+    return [200, ['Content-Type' => 'text/html; charset=UTF-8'], [$output]];
+}
+
+sub as_atom {
+    my $self = shift;
+
+    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($self->title);
+
+    for my $link ($self->_get_list_links('application/atom+xml', { q => $self->query, tag => $self->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);
+    }
+
+    for my $bookmark (@{ $self->results }) {
+        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]];
+}
+
+1;
Index: /trunk/start
===================================================================
--- /trunk/start	(revision 69)
+++ /trunk/start	(revision 70)
@@ -1,4 +1,5 @@
 #!/bin/sh
 
+export PERL5LIB=./lib
 export CONFIG_FILE=${1:-"conf.yml"}
 
