Index: trunk/BookmarkController.pm
===================================================================
--- trunk/BookmarkController.pm	(revision 59)
+++ trunk/BookmarkController.pm	(revision 59)
@@ -0,0 +1,390 @@
+package BookmarkController;
+use Moose;
+
+use Encode;
+use HTTP::Date qw{time2isoz time2iso time2str str2time};
+use JSON;
+use Bookmarks;
+use URI;
+use Template;
+
+has bookmarks => (
+    is => 'rw',
+    handles => [qw{get_bookmark}],
+);
+has base_uri => (
+    is => 'ro',
+    builder => '_build_base_uri',
+    lazy => 1,
+);
+has request => (
+    is => 'ro',
+);
+
+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',
+            {
+                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);
+
+    my $feed_uri = URI->new_abs('feed', $self->base_uri);
+    $feed_uri->query_form(tag => \@tags);
+    $feed->id($feed_uri->canonical);
+
+    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]];
+}
+
+sub view {
+    my ($self, $id) = @_;
+
+    my $format = $self->request->param('format') || 'html';
+
+    my $bookmark = $self->get_bookmark({ id => $id });
+    if ($bookmark) {
+        # 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});
+            if ($bookmark->mtime <= $cache_time) {
+                return [304, [], []];
+            }
+        }
+        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
+            $bookmark->{exists} = 1;
+            $bookmark->{created} = "Created " . localtime($bookmark->ctime);
+            $bookmark->{created} .= '; Updated ' . localtime($bookmark->mtime) unless $bookmark->ctime == $bookmark->mtime;
+            my $template = Template->new;
+            $template->process(
+                'bookmark.tt',
+                $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->bookmarks->get_bookmark({ id => $id });
+    if ($bookmark) {
+        # respond with just the requested field as plain text
+        my $value = eval { $bookmark->$field };
+        if ($@) {
+            if ($@ =~ /Can't locate object method/) {
+                return [404, ['Content-Type' => 'text/plain; charset=UTF-8'], [qq{"$field" is not a valid bookmark data field}]];
+            } else {
+                die $@;
+            }
+        }
+        return [200, ['Content-Type' => 'text/plain; charset=UTF-8'], [ref $value eq 'ARRAY' ? join(' ', @{ $value }) : $value]];
+    } else {
+        return [404, ['Content-Type' => 'text/plain; charset=UTF-8'], ["Boomark $id not found"]];
+    }
+}
+
+sub create {
+    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 edit {
+    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/app.psgi
===================================================================
--- trunk/app.psgi	(revision 58)
+++ trunk/app.psgi	(revision 59)
@@ -4,15 +4,111 @@
 use YAML;
 use Plack::Builder;
+use Plack::Request;
+use Router::Resource;
 
-use BookmarkApp::Dispatch;
+use BookmarkController;
+use Bookmarks;
+
+#TODO: allow a different config file on the command line, or set options from the command line
 
 -e 'conf.yml' or die "Missing required conf.yml config file\n";
 
 my $config = YAML::LoadFile('conf.yml');
-my $app = BookmarkApp::Dispatch->as_psgi(
-    args_to_new => {
-        PARAMS => { dbname => $config->{dbname} },
-    },
-);
+
+sub get_controller {
+    my $req = shift;
+
+    my $controller = BookmarkController->new({
+        request => $req,
+    });
+    my $bookmarks = Bookmarks->new({
+        dbname   => $config->{dbname},
+        base_uri => $controller->base_uri,
+    });
+    $controller->bookmarks($bookmarks);
+    return $controller;
+}
+
+my $router = router {
+    resource '/' => sub {
+        GET {
+            my ($env) = @_;
+            my $req = Plack::Request->new($env);
+            my $controller = get_controller($req);
+
+            # check for a uri param, and if there is one present,
+            # see if a bookmark for that URI already exists; if so
+            # redirect to that bookmark, and if not, show the form
+            # to create a new bookmark
+            if (defined $req->param('uri')) {
+                return $controller->find_or_new;
+            }
+
+            # otherwise return a list
+            return $controller->list;
+        };
+        POST {
+            my ($env) = @_;
+            my $req = Plack::Request->new($env);
+            my $controller = get_controller($req);
+
+            # create the bookmark and redirect to the new bookmark's edit form
+            return $controller->create;
+        };
+    };
+
+    resource '/list' => sub {
+        GET {
+            my ($env) = @_;
+            my $req = Plack::Request->new($env);
+            my $controller = get_controller($req);
+
+            return $controller->list;
+        };
+    };
+
+    resource '/feed' => sub {
+        GET {
+            my ($env) = @_;
+            my $req = Plack::Request->new($env);
+            my $controller = get_controller($req);
+
+            return $controller->feed;
+        };
+    };
+
+    resource '/{id}' => sub {
+        GET {
+            my ($env, $params) = @_;
+            my $req = Plack::Request->new($env);
+            my $controller = get_controller($req);
+
+            return $controller->view($params->{id});
+        };
+        POST {
+            my ($env, $params) = @_;
+            my $req = Plack::Request->new($env);
+            my $controller = get_controller($req);
+
+            return $controller->edit($params->{id});
+
+            return [200, ['Content-Type' => 'text/plain'], ['update ', $params->{id}]];
+        };
+    };
+
+    resource '/{id}/{field}' => sub {
+        GET {
+            my ($env, $params) = @_;
+            my $req = Plack::Request->new($env);
+            my $controller = get_controller($req);
+
+            return $controller->view_field($params->{id}, $params->{field});
+        };
+    };
+};
+
+#builder {
+#    sub { $router->dispatch(shift); };
+#};
 
 builder {
@@ -24,4 +120,4 @@
         authenticator   => sub { $config->{digest_password} }
     );
-    $app;
+    sub { $router->dispatch(shift); };
 };
Index: trunk/start
===================================================================
--- trunk/start	(revision 58)
+++ trunk/start	(revision 59)
@@ -1,3 +1,3 @@
 #!/bin/sh
 
-starman --listen :5000 --daemonize --pid pid --error-log errors
+starman --listen :5000 --daemonize --pid pid --error-log errors --access-log access
