Merge branch 'master' of git.evergreen-ils.org:Evergreen into social
authorMike Rylander <mrylander@gmail.com>
Mon, 16 May 2011 20:35:54 +0000 (16:35 -0400)
committerMike Rylander <mrylander@gmail.com>
Mon, 16 May 2011 20:35:54 +0000 (16:35 -0400)
Open-ILS/examples/apache/eg_vhost.conf
Open-ILS/examples/apache/startup.pl
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/Makefile.am
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/biblio.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/social.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/Social.pm [new file with mode: 0644]
Open-ILS/src/sql/Pg/060.schema.social.sql [new file with mode: 0644]
Open-ILS/src/sql/Pg/sql_file_manifest
Open-ILS/src/templates/social/user/about.tt2 [new file with mode: 0644]

index 7fea97c..1f9437a 100644 (file)
@@ -263,6 +263,17 @@ RewriteRule . - [E=locale:en-US]
 </Location>
 
 # ----------------------------------------------------------------------------------
+# Social intelligence interface
+# ----------------------------------------------------------------------------------
+<Location /opac/social/>
+    SetHandler perl-script
+    PerlHandler OpenILS::WWW::Social
+    Options +ExecCGI
+    PerlSendHeader On
+    allow from all
+</Location>
+
+# ----------------------------------------------------------------------------------
 # Supercat feeds
 # ----------------------------------------------------------------------------------
 <Location /opac/extras/oisbn>
index f828447..03e1806 100755 (executable)
@@ -9,6 +9,7 @@ use OpenILS::WWW::TemplateBatchBibUpdate qw( /openils/conf/opensrf_core.xml );
 use OpenILS::WWW::EGWeb ('/openils/conf/oils_web.xml');
 use OpenILS::WWW::PasswordReset ('/openils/conf/opensrf_core.xml');
 use OpenILS::WWW::IDL2js ('/openils/conf/opensrf_core.xml');
+use OpenILS::WWW::Social ('/openils/conf/opensrf_core.xml');
 
 # - Uncoment the following 2 lines to make use of the IP redirection code
 # - The IP file should to contain a map with the following format:
index 724e8f5..23b131e 100644 (file)
@@ -4512,6 +4512,79 @@ SELECT  usr,
                        <link field="record" reltype="has_a" key="id" map="" class="bre"/>
                </links>
        </class>
+       <class id="socrates" controller="open-ils.cstore" oils_obj:fieldmapper="social::user_rating" oils_persist:tablename="social.user_rating" reporter:label="Bib Rating">
+               <fields oils_persist:primary="id" oils_persist:sequence="social.user_rating_id_seq">
+                       <field name="create_date" reporter:datatype="timestamp"/>
+                       <field name="creator" reporter:datatype="link"/>
+                       <field name="edit_date" reporter:datatype="timestamp"/>
+                       <field name="id" reporter:datatype="id" />
+                       <field name="record" reporter:datatype="link"/>
+                       <field name="value"  reporter:datatype="text"/>
+               </fields>
+               <links>
+                       <link field="creator" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="record" reltype="has_a" key="id" map="" class="bre"/>
+               </links>
+       </class>
+       <class id="socr" controller="open-ils.cstore" oils_obj:fieldmapper="social::user_review" oils_persist:tablename="social.user_review" reporter:label="Bib Review">
+               <fields oils_persist:primary="id" oils_persist:sequence="social.user_review_id_seq">
+                       <field name="create_date" reporter:datatype="timestamp"/>
+                       <field name="creator" reporter:datatype="link"/>
+                       <field name="edit_date" reporter:datatype="timestamp"/>
+                       <field name="editor" reporter:datatype="link"/>
+                       <field name="approver" reporter:datatype="link"/>
+                       <field name="approved" reporter:datatype="bool"/>
+                       <field name="id" reporter:datatype="id" />
+                       <field name="record" reporter:datatype="link"/>
+                       <field name="value"  reporter:datatype="text"/>
+               </fields>
+               <links>
+                       <link field="creator" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="editor" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="approver" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="record" reltype="has_a" key="id" map="" class="bre"/>
+               </links>
+       </class>
+       <class id="soct" controller="open-ils.cstore" oils_obj:fieldmapper="social::tag" oils_persist:tablename="social.tag" reporter:label="Bib Tag">
+               <fields oils_persist:primary="id" oils_persist:sequence="social.tag_id_seq">
+                       <field name="edit_date" reporter:datatype="timestamp"/>
+                       <field name="editor" reporter:datatype="link"/>
+                       <field name="approver" reporter:datatype="link"/>
+                       <field name="approved" reporter:datatype="bool"/>
+                       <field name="id" reporter:datatype="id" />
+                       <field name="value"  reporter:datatype="text"/>
+               </fields>
+               <links>
+                       <link field="editor" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="approver" reltype="has_a" key="id" map="" class="au"/>
+               </links>
+       </class>
+       <class id="socbtm" controller="open-ils.cstore" oils_obj:fieldmapper="social::biblio_tag_map" oils_persist:tablename="social.biblio_tag_map" reporter:label="Bib Tag Map">
+               <fields oils_persist:primary="id" oils_persist:sequence="biblio.tag_id_seq">
+                       <field name="create_date" reporter:datatype="timestamp"/>
+                       <field name="creator" reporter:datatype="link"/>
+                       <field name="id" reporter:datatype="id" />
+                       <field name="record" reporter:datatype="link"/>
+                       <field name="tag"  reporter:datatype="link"/>
+               </fields>
+               <links>
+                       <link field="creator" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="record" reltype="has_a" key="id" map="" class="bre"/>
+                       <link field="tag" reltype="has_a" key="id" map="" class="soct"/>
+               </links>
+       </class>
+       <class id="socas" controller="open-ils.cstore" oils_obj:fieldmapper="social::activity_stream" reporter:label="Social Activity Stream" oils_persist:readonly="true">
+               <fields>
+                       <field reporter:label="Actor" name="actor" reporter:datatype="id"/>
+                       <field reporter:label="Object" name="object" reporter:datatype="int"/>
+                       <field reporter:label="Target" name="target" reporter:datatype="int"/>
+                       <field reporter:label="Activity Time" name="stamped" reporter:datatype="timestamp"/>
+                       <field reporter:label="Activity" name="activity" reporter:datatype="text"/>
+               </fields>
+               <links>
+                       <link field="actor" reltype="has_a" key="id" map="" class="au"/>
+               </links>
+       </class>
        <class id="mucs" controller="open-ils.cstore" oils_obj:fieldmapper="money::user_circulation_summary" oils_persist:tablename="money.usr_circulation_summary" reporter:label="User Circulation Summary">
                <fields oils_persist:primary="usr" oils_persist:sequence="">
                        <field name="balance_owed" reporter:datatype="money" />
index 7d52ad5..2cce0ba 100644 (file)
@@ -170,6 +170,7 @@ ilscore-install:
        $(MKDIR_P) $(TEMPLATEDIR)
        cp -r @srcdir@/templates/marc $(TEMPLATEDIR)
        cp -r @srcdir@/templates/password-reset $(TEMPLATEDIR)
+       cp -r @srcdir@/templates/social $(TEMPLATEDIR)
        @echo "Installing string templates to $(TEMPLATEDIR)"
        $(MKDIR_P) $(TEMPLATEDIR)
        $(MKDIR_P) $(datadir)/overdue/
index 7780cbc..f9cbd22 100644 (file)
@@ -20,7 +20,6 @@ use base qw/biblio/;
 biblio::record_note->table( 'biblio_record_note' );
 biblio::record_note->columns( Essential => qw/id record value creator
                                        editor create_date edit_date pub/ );
-#-------------------------------------------------------------------------------
 
 #-------------------------------------------------------------------------------
 package biblio::peer_type;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/social.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Storage/CDBI/social.pm
new file mode 100644 (file)
index 0000000..7a105f4
--- /dev/null
@@ -0,0 +1,45 @@
+package OpenILS::Application::Storage::CDBI::social;
+our $VERSION = 1;
+
+#-------------------------------------------------------------------------------
+package social;
+use base qw/OpenILS::Application::Storage::CDBI/;
+#-------------------------------------------------------------------------------
+package social::user_rating;
+use base qw/social/;
+
+social::user_rating->table('social_user_rating');
+social::user_rating->columns (Essential => qw/id record value creator
+   create_date edit_date/);
+
+#-------------------------------------------------------------------------------
+package social::user_review;
+use base qw/social/;
+
+social::user_review->table('social_user_review');
+social::user_review->columns (Essential => qw/id record value creator
+   approver approved create_date edit_date/);
+
+#-------------------------------------------------------------------------------
+package social::tag;
+use base qw/social/;
+
+social::tag->table('social_tag');
+social::tag->columns (Essential => qw/id value approver approved editor
+    edit_date/);
+
+#-------------------------------------------------------------------------------
+package social::biblio_tag_map;
+use base qw/social/;
+
+social::biblio_tag_map->table('social_user_tag');
+social::biblio_tag_map->columns (Essential => qw/id record tag creator create_date /);
+
+#-------------------------------------------------------------------------------
+package social::activity_stream;
+use base qw/social/;
+
+social::biblio_tag_map->table('social_activity_stream');
+social::biblio_tag_map->columns (Essential => qw/actor object target stamped activity/);
+
+1;
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/Social.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/Social.pm
new file mode 100644 (file)
index 0000000..6e8f621
--- /dev/null
@@ -0,0 +1,793 @@
+package OpenILS::WWW::Social;
+
+# Copyright (C) 2010 Laurentian University
+# Dan Scott <dscott@laurentian.ca>
+# 
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+# 
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+# 
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+use strict; use warnings;
+
+use Apache2::Log;
+use Apache2::Const -compile => qw(OK REDIRECT DECLINED NOT_FOUND :log);
+use APR::Const    -compile => qw(:error SUCCESS);
+use Apache2::RequestRec ();
+use Apache2::RequestIO ();
+use Apache2::RequestUtil;
+use CGI;
+use Template;
+
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils qw/:datetime/;
+use OpenSRF::Utils::Cache;
+use OpenSRF::System;
+use OpenSRF::AppSession;
+
+use OpenILS::Utils::Fieldmapper;
+use OpenSRF::Utils::Logger qw/$logger/;
+use OpenILS::Application::AppUtils;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::ModsParser;
+use XML::LibXML;
+use DateTime;
+
+my $log = 'OpenSRF::Utils::Logger';
+my $U = 'OpenILS::Application::AppUtils';
+
+my ($bootstrap, $editor, $actor, $templates, $LocalTZ);
+my $i18n = {};
+my $init_done = 0; # has child_init been called?
+
+# helper functions inserted into the TT environment
+my $_TT_helpers = {
+
+    # turns a date into something TT can understand
+    format_date => sub {
+        my $date = shift;
+        $date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($date));
+        return sprintf(
+            "%0.2d:%0.2d:%0.2d %0.2d-%0.2d-%0.4d",
+            $date->hour,
+            $date->minute,
+            $date->second,
+            $date->day,
+            $date->month,
+            $date->year
+        );
+    },
+
+    # escapes a string for inclusion in an XML document.  escapes &, <, and > characters
+    escape_xml => sub {
+        my $str = shift;
+        $str =~ s/&/&amp;/sog;
+        $str =~ s/</&lt;/sog;
+        $str =~ s/>/&gt;/sog;
+        return $str;
+    },
+
+};
+
+sub import {
+    my $self = shift;
+    $bootstrap = shift;
+}
+
+sub child_init {
+    OpenSRF::System->bootstrap_client( config_file => $bootstrap );
+    
+    my $conf = OpenSRF::Utils::SettingsClient->new();
+    my $idl = $conf->config_value("IDL");
+    Fieldmapper->import(IDL => $idl);
+    $templates = $conf->config_value("dirs", "templates");
+    OpenILS::Utils::CStoreEditor::init();
+    $editor = new_editor();
+    load_i18n();
+
+   # Getting the timezone is slow; do it once
+    $LocalTZ = DateTime::TimeZone->new( name => 'local' );
+    $init_done = 1;
+}
+
+sub handler {
+    my $apache = shift;
+
+    child_init() unless $init_done;
+
+    return Apache2::Const::DECLINED if (-e $apache->filename);
+
+    my $ctx = {};
+
+    $ctx->{'helpers'} = $_TT_helpers;
+
+    $ctx->{'uri'} = $apache->uri;
+
+    # Get our locale from the URL
+    (my $locale = $apache->path_info) =~ s{^.*?/([a-z]{2}-[A-Z]{2})/.*?$}{$1};
+    if (!$locale) {
+        $locale = 'en-US';
+    }
+
+    # If locale exists, use it; otherwise fall back to en-US
+    if (exists $i18n->{$locale}) {
+        $ctx->{'i18n'} = $i18n->{$locale};
+    } else {
+        $ctx->{'i18n'} = $i18n->{'en-US'};
+    }
+
+    my $tt = Template->new({
+        INCLUDE_PATH => $templates
+    }) || die "$Template::ERROR\n";
+
+    # So what object are we dealing with here?
+    if ($apache->uri =~ m{/social(/[a-z]{2}-[A-Z]{2})?/user/}) {
+        if ($apache->uri =~ m{.atom$}) {
+            return user_activity_stream($apache);
+        } else {
+            return display_user($apache, $tt, $ctx);
+        }
+    } elsif ($apache->uri =~ m{/social(/[a-z]{2}-[A-Z]{2})?/thing/}) {
+        # A "thing" is represented by a biblio.record_entry
+        # It is not necessarily a book, thus "thing"
+        if ($apache->uri =~ m{.atom$}) {
+            return thing_activity_stream($apache);
+        } else {
+            return display_thing($apache, $tt, $ctx);
+        }
+    }
+}
+
+# XXX Need to build an HTML-friendly template for displaying a thing
+# and all of the social activity around it
+sub display_thing {
+    return;
+}
+
+# The user URIs identify the user by ID because usernames and email can change
+# Grab the user object from the database while we're at it
+sub _get_base_user {
+    my $apache = shift;
+
+    (my $uid = $apache->uri) =~ s{.*/social(/[a-z]{2}-[A-Z]{2})?/user/(\d+).*?$}{$2};
+
+    my $user = $editor->retrieve_actor_user($uid);
+
+    return $user;
+}
+
+# Things are identified strictly by their record ID
+sub thing_activity_stream {
+    my ($apache) = @_;
+
+    (my $thing = $apache->uri) =~ s{.*/social(/[a-z]{2}-[A-Z]{2})?/thing/(\d+).*?$}{$2};
+
+    if (!$thing) {
+        return Apache2::Const::OK;
+    }
+
+    $apache->content_type('application/atom+xml');
+
+    my $stream = create_thing_feed($apache, $thing);
+
+    print $stream->toString(1);
+    
+    return Apache2::Const::OK;
+}
+
+# Generate the Activity Stream for a given user
+sub user_activity_stream {
+    my ($apache) = @_;
+
+    my $user = _get_base_user($apache);
+    if (!$user) {
+        return Apache2::Const::OK;
+    }
+
+    $apache->content_type('application/atom+xml');
+
+    my $stream = create_user_feed($apache, $user);
+    print $stream->toString(1);
+    
+    return Apache2::Const::OK;
+}
+
+# Generate the Activity Stream for a given thing
+sub create_thing_feed {
+    my ($apache, $thing) = @_;
+
+    my ($doc, $feed) = base_feed($apache);
+
+    my $title = $doc->createElementNS(undef, "title");
+    $title->appendTextNode('Activity stream for the object "' . get_bib_and_mods($thing)->title . '"');
+    $feed->addChild($title);
+
+    create_thing_entries($apache, $doc, $thing);
+
+    return $doc;
+}
+
+# All Activity Stream feeds have a common base
+# XXX We're assuming HTTP, not HTTPS here...
+sub base_feed {
+    my ($apache) = @_;
+    
+    my $now = DateTime->now(time_zone => $LocalTZ);
+
+    my $host = $apache->hostname;
+    my $uri = $apache->uri;
+    my $html_uri = $uri;
+    $html_uri =~ s/.atom$//;
+
+    my $doc = XML::LibXML->createDocument();
+    my $feed = $doc->createElementNS("http://www.w3.org/2005/Atom", "feed");
+    $doc->setDocumentElement($feed);
+
+    $feed->setNamespace("http://www.w3.org/2005/Atom");
+    $feed->setNamespace("http://activitystrea.ms/spec/1.0/", "activity", 0);
+    $feed->setNamespace("http://portablecontacts.net/spec/1.0", "poco", 0);
+
+    my $feed_id = $doc->createElementNS(undef, "id");
+    $feed_id->appendTextNode(generate_tag($host, $uri, $now));
+    $feed->addChild($feed_id);
+
+    my $updated = $doc->createElementNS(undef, "updated");
+    $updated->appendTextNode($now->strftime('%FT%TZ'));
+    $feed->addChild($updated);
+
+    # Link to our HTML representation
+    my $l_html = $doc->createElementNS(undef, "link");
+    $l_html->setAttributeNS(undef, "rel", "alternate");
+    $l_html->setAttributeNS(undef, "type", "text/html");
+    $l_html->setAttributeNS(undef, "href", "http://$host$html_uri");
+    $feed->addChild($l_html);
+
+    # Link to our self
+    my $l_self = $doc->createElementNS(undef, "link");
+    $l_self->setAttributeNS(undef, "rel", "self");
+    $l_self->setAttributeNS(undef, "type", "application/atom+xml");
+    $l_self->setAttributeNS(undef, "href", "http://$host$uri");
+    $feed->addChild($l_self);
+
+    return ($doc, $feed);
+}
+
+# Standardized form of representing the user's full name
+sub generate_user_name {
+    my ($user) = @_;
+
+    my $display_name = $user->first_given_name . ' ';
+    $display_name .= ($user->second_given_name || '') . ' ';
+    $display_name .= $user->family_name || '';
+    $display_name =~ s/ +/ /g;
+
+    return $display_name;
+}
+
+sub create_user_feed {
+    my ($apache, $user) = @_;
+
+    my ($doc, $feed) = base_feed($apache);
+    my $display_name = generate_user_name($user);
+
+    my $title = $doc->createElementNS(undef, "title");
+    $title->appendTextNode("Personal activity stream for " . ($display_name || ("person " . $user->id)));
+    $feed->addChild($title);
+
+    add_author($doc, $user, $feed);
+
+    create_user_entries($apache, $doc, $user, $display_name || $user->usrname);
+
+    return $doc;
+}
+
+sub add_author {
+    my ($doc, $user, $anchor) = @_;
+
+    my $display_name = generate_user_name($user);
+
+    # Introduce the actor (atom:author)
+    my $author = $doc->createElementNS(undef, "author");
+
+    # atom:name link, required in atom:author elements
+    my $aname = $doc->createElementNS(undef, "name");
+    my $aname_done = 0;
+
+    # Get the best name we can; flesh out Portable Contacts while we're at it
+    if ($display_name) {
+        my $dname = $doc->createElementNS(undef, "poco:displayName");
+        $dname->appendTextNode($display_name);
+        $anchor->addChild($dname);
+
+        $aname->appendTextNode($display_name);
+        $author->addChild($aname);
+        $aname_done = 1;
+    }
+
+    my $uname = $doc->createElementNS(undef, "poco:preferredUsername");
+    if ($user->usrname) {
+        $uname->appendTextNode($user->usrname);
+        if (!$aname_done) {
+            $aname->appendTextNode($user->usrname);
+            $author->addChild($aname);
+            $aname_done = 1;
+        }
+    } else {
+        # This is a poor excuse for an author name; oh well
+        $uname->appendTextNode($user->id);
+        if (!$aname_done) {
+            $aname->appendTextNode($user->id);
+        }
+        $author->addChild($aname);
+    }
+    $anchor->addChild($author);
+
+    if ($user->email) {
+        my $email = $doc->createElementNS(undef, "email");
+        $email->appendTextNode($user->email);
+        $author->addChild($email);
+    }
+    $anchor->addChild($uname);
+}
+
+sub create_user_entries {
+    my ($apache, $doc, $user, $user_name) = @_;
+
+    my $activities = $editor->search_social_activity_stream(
+        { actor => $user->id },
+        { order_by => { socas => "stamped DESC" }, limit => 10 }
+    );
+
+    foreach my $activity (@$activities) {
+        generate_activity_entries($activity, $apache, $doc, $user, $user_name);
+    }
+}
+
+sub create_thing_entries {
+    my ($apache, $doc, $thing) = @_;
+
+    my $activities = $editor->search_social_activity_stream(
+        { object => $thing },
+        { order_by => { socas => "stamped DESC" }, limit => 10 }
+    );
+
+    foreach my $activity (@$activities) {
+        my $user = $editor->retrieve_actor_user($activity->actor);
+        my $display_name = generate_user_name($user);
+        generate_activity_entries($activity, $apache, $doc, $user, $display_name || $user->usrname);
+    }
+}
+
+
+sub generate_activity_entries {
+    my ($activity, $apache, $doc, $user, $user_name) = @_;
+
+    my $entry = $doc->createElementNS(undef, "entry");
+
+    if ($activity->activity eq "add_bookbag") {
+        _add_bookbag($activity, $user_name, $apache, $doc, $entry);
+    } elsif ($activity->activity eq "add_bookbag_item") {
+        _add_bookbag_item($activity, $user_name, $apache, $doc, $entry);
+    } elsif ($activity->activity eq "add_review") {
+        _add_review($activity, $user_name, $apache, $doc, $entry);
+    } elsif ($activity->activity eq "circ") {
+        _circ($activity, $user_name, $apache, $doc, $entry);
+    }
+
+    # Required by Atom Activity spec
+    my $published = $doc->createElementNS(undef, "published");
+    my $date = DateTime::Format::ISO8601->new->parse_datetime(cleanse_ISO8601($activity->stamped));
+    $published->appendTextNode($date->strftime('%FT%TZ'));
+    $entry->addChild($published);
+
+    # Required by Atom spec
+    my $updated = $doc->createElementNS(undef, "updated");
+    $updated->appendTextNode($date->strftime('%FT%TZ'));
+    $entry->addChild($updated);
+
+    add_author($doc, $user, $entry);
+
+    $doc->documentElement()->addChild($entry);
+}
+
+sub _add_review {
+    my ($activity, $user_name, $apache, $doc, $entry) = @_;
+    # verb = post (http://activitystrea.ms/schema/1.0/post)
+    # object = list (http://activitystrea.ms/schema/1.0/review)
+
+    my $review = $editor->retrieve_social_user_review($activity->object);
+    my $mods = get_bib_and_mods($activity->target);
+
+    my $title = $doc->createElementNS(undef, "title");
+    $title->appendTextNode("$user_name reviewed " . $mods->title);
+    $entry->addChild($title);
+
+    my $summary = $doc->createElementNS(undef, "summary");
+    $summary->appendTextNode("$user_name reviewed " . $mods->title);
+    $entry->addChild($summary);
+
+    my $content = $doc->createElementNS(undef, "content");
+    $content->setAttributeNS(undef, "type", "text/html");
+    $content->appendTextNode($review->value);
+    $entry->addChild($content);
+
+    my $id = $doc->createElementNS(undef, "id");
+    $id->appendTextNode(generate_tag($apache->hostname, '/opac/social/create/review/' . $activity->object));
+    $entry->addChild($id);
+
+    my $verb = $doc->createElementNS(undef, "activity:verb");
+    $verb->appendTextNode("http://activitystrea.ms/schema/1.0/post");
+    $entry->addChild($verb);
+
+    my $object = $doc->createElementNS(undef, "activity:object");
+    my $object_type = $doc->createElementNS(undef, "activity:object-type");
+    $object_type->appendTextNode("http://activitystrea.ms/schema/1.0/review");
+    $object->addChild($object_type);
+
+    my $object_id =  $doc->createElementNS(undef, "id");
+    $object_id->appendTextNode(generate_tag($apache->hostname, '/opac/social/review/' . $activity->object));
+    $object->addChild($object_id);
+
+    my $object_content =  $doc->createElementNS(undef, "content");
+    $object_content->setAttributeNS(undef, "type", "text/html");
+    $object_content->appendTextNode($review->value);
+    $object->addChild($object_content);
+
+    my $object_link =  $doc->createElementNS(undef, "link");
+    $object_link->setAttributeNS(undef, "rel", "alternate");
+    $object_link->setAttributeNS(undef, "type", "text/html");
+    $object_link->setAttributeNS(undef, "href", 'http://' . $apache->hostname . '/opac/social/review/' . $activity->object);
+    $object->addChild($object_link);
+
+    # Must be empty if the review does not have a user-generated title
+    my $object_title = $doc->createElementNS(undef, "title");
+    $object->addChild($object_title);
+    $entry->addChild($object);
+
+    my $target = $doc->createElementNS(undef, "activity:target");
+    my $t_object_type = $doc->createElementNS(undef, "activity:object-type");
+    $t_object_type->appendTextNode("http://activitystrea.ms/schema/1.0/list");
+    $target->addChild($t_object_type);
+
+    my $t_object_id =  $doc->createElementNS(undef, "id");
+    $t_object_id->appendTextNode(generate_tag($apache->hostname, '/opac/social/thing/' . $activity->target));
+    $target->addChild($t_object_id);
+
+    my $t_object_title = $doc->createElementNS(undef, "title");
+    $t_object_title->appendTextNode($mods->title);
+    $target->addChild($t_object_title);
+
+    $entry->addChild($target);
+
+}
+
+
+sub _circ {
+    my ($activity, $user_name, $apache, $doc, $entry) = @_;
+    # verb = post (http://activitystrea.ms/schema/1.0/post)
+    # object = list (http://activitystrea.ms/schema/1.0/list)
+
+    my $circ = $editor->retrieve_action_circulation($activity->object);
+    my $mods = get_bib_and_mods($activity->target);
+
+    my $title = $doc->createElementNS(undef, "title");
+    $title->appendTextNode("$user_name returned " . $mods->title);
+    $entry->addChild($title);
+
+    my $summary = $doc->createElementNS(undef, "summary");
+    $summary->appendTextNode("$user_name returned " . $mods->title);
+    $entry->addChild($summary);
+
+    my $content = $doc->createElementNS(undef, "content");
+    $content->appendTextNode("$user_name returned " . $mods->title);
+    $entry->addChild($content);
+
+    my $id = $doc->createElementNS(undef, "id");
+    $id->appendTextNode(generate_tag($apache->hostname, '/opac/social/create/circ/' . $activity->object));
+    $entry->addChild($id);
+
+    my $verb = $doc->createElementNS(undef, "activity:verb");
+    $verb->appendTextNode("http://activitystrea.ms/schema/1.0/post");
+    $entry->addChild($verb);
+
+    my $object = $doc->createElementNS(undef, "activity:object");
+    my $object_type = $doc->createElementNS(undef, "activity:object-type");
+    $object_type->appendTextNode("http://activitystrea.ms/schema/1.0/list");
+    $object->addChild($object_type);
+
+    my $object_id =  $doc->createElementNS(undef, "id");
+    $object_id->appendTextNode(generate_tag($apache->hostname, '/opac/social/circ/' . $activity->object));
+    $object->addChild($object_id);
+
+    my $object_title = $doc->createElementNS(undef, "title");
+    $object_title->appendTextNode("Circulation");
+    $object->addChild($object_title);
+    $entry->addChild($object);
+}
+
+
+sub _add_bookbag {
+    my ($activity, $user_name, $apache, $doc, $entry) = @_;
+    # verb = post (http://activitystrea.ms/schema/1.0/post)
+    # object = list (http://activitystrea.ms/schema/1.0/list)
+
+    my $bb = $editor->retrieve_container_biblio_record_entry_bucket($activity->object);
+
+    my $title = $doc->createElementNS(undef, "title");
+    $title->appendTextNode("$user_name created the bookbag " . $bb->name);
+    $entry->addChild($title);
+
+    my $summary = $doc->createElementNS(undef, "summary");
+    $summary->appendTextNode("$user_name created the bookbag " . $bb->name);
+    $entry->addChild($summary);
+
+    my $content = $doc->createElementNS(undef, "content");
+    $content->appendTextNode("$user_name created the bookbag " . $bb->name);
+    $entry->addChild($content);
+
+    my $id = $doc->createElementNS(undef, "id");
+    $id->appendTextNode(generate_tag($apache->hostname, '/opac/social/create/bookbag/' . $activity->object));
+    $entry->addChild($id);
+
+    my $verb = $doc->createElementNS(undef, "activity:verb");
+    $verb->appendTextNode("http://activitystrea.ms/schema/1.0/post");
+    $entry->addChild($verb);
+
+    my $object = $doc->createElementNS(undef, "activity:object");
+    my $object_type = $doc->createElementNS(undef, "activity:object-type");
+    $object_type->appendTextNode("http://activitystrea.ms/schema/1.0/list");
+    $object->addChild($object_type);
+
+    my $object_id =  $doc->createElementNS(undef, "id");
+    $object_id->appendTextNode(generate_tag($apache->hostname, '/opac/social/bookbag/' . $activity->object));
+    $object->addChild($object_id);
+
+    my $object_title = $doc->createElementNS(undef, "title");
+    $object_title->appendTextNode($bb->name);
+    $object->addChild($object_title);
+    $entry->addChild($object);
+}
+
+sub _add_bookbag_item {
+    my ($activity, $user_name, $apache, $doc, $entry) = @_;
+    # verb = save (http://activitystrea.ms/schema/1.0/save)
+    # object = article (ugh) (http://activitystrea.ms/schema/1.0/article)
+    # target = list (http://activitystrea.ms/schema/1.0/list) 
+
+    my $bb = $editor->retrieve_container_biblio_record_entry_bucket($activity->target);
+    my $mods = get_bib_and_mods($activity->object);
+
+    my $id = $doc->createElementNS(undef, "id");
+    $id->appendTextNode(generate_tag($apache->hostname, '/opac/social/save/bookbag_item/' . $activity->target . '/' . $activity->object));
+    $entry->addChild($id);
+
+    my $title = $doc->createElementNS(undef, "title");
+    $title->appendTextNode("$user_name added " . $mods->title . " to bookbag " . $bb->name);
+    $entry->addChild($title);
+
+    my $summary = $doc->createElementNS(undef, "summary");
+    $summary->appendTextNode("$user_name added " . $mods->title . " to bookbag " . $bb->name);
+    $entry->addChild($summary);
+
+    my $content = $doc->createElementNS(undef, "content");
+    $content->appendTextNode("$user_name added " . $mods->title . " to bookbag " . $bb->name);
+    $entry->addChild($content);
+
+    my $verb = $doc->createElementNS(undef, "activity:verb");
+    $verb->appendTextNode("http://activitystrea.ms/schema/1.0/save");
+    $entry->addChild($verb);
+
+    my $object = $doc->createElementNS(undef, "activity:object");
+    my $object_type = $doc->createElementNS(undef, "activity:object-type");
+    $object_type->appendTextNode("http://activitystrea.ms/schema/1.0/article");
+    $object->addChild($object_type);
+
+    my $object_id =  $doc->createElementNS(undef, "id");
+    $object_id->appendTextNode(generate_tag($apache->hostname, '/opac/social/bookbag/' . $activity->object));
+    $object->addChild($object_id);
+
+    my $object_title = $doc->createElementNS(undef, "title");
+    $object_title->appendTextNode($mods->title);
+    $object->addChild($object_title);
+
+    $entry->addChild($object);
+
+    my $target = $doc->createElementNS(undef, "activity:target");
+    my $t_object_type = $doc->createElementNS(undef, "activity:object-type");
+    $t_object_type->appendTextNode("http://activitystrea.ms/schema/1.0/list");
+    $target->addChild($t_object_type);
+
+    my $t_object_id =  $doc->createElementNS(undef, "id");
+    $t_object_id->appendTextNode(generate_tag($apache->hostname, '/opac/social/thing/' . $activity->target));
+    $target->addChild($t_object_id);
+
+    my $t_object_title = $doc->createElementNS(undef, "title");
+    $t_object_title->appendTextNode($bb->name);
+    $target->addChild($t_object_title);
+
+    $entry->addChild($target);
+}
+
+
+# Generate a unique tag for this feed or feed entry
+sub generate_tag {
+    my ($hostname, $uri, $now) = @_;
+
+    if (!$now) {
+        $now = DateTime->now(time_zone => $LocalTZ);
+    }
+
+    $hostname =~ s{^.*?([^\.]+\.[^\.]{2,4})/?$}{$1};
+    $uri =~ s{#}{/}g;
+    my $date = $now->strftime('%F');
+    return "tag:$hostname,$date:$uri";
+}
+
+=pod
+
+Need to build a $ctx structure for the user info like so:
+    $ctx->{'user'}->{'avatar'}
+    $ctx->{'user'}->{'email'}
+    $ctx->{'user'}->{'name'}
+    $ctx->{'user'}->{'usrname'}
+    $ctx->{'home_library'}->{'name'}
+    $ctx->{'circ_history'}->[ {'isbn', 'record_id', 'title', 'author', 'xact_finish' }, ]
+=cut
+sub display_user {
+    my ($apache, $tt, $ctx) = @_;
+
+    my $user = _get_base_user($apache);
+    if (!$user) {
+        $tt->process('social/user/about.tt2', $ctx) || die $tt->error();
+        return Apache2::Const::OK;
+    }
+
+    # Check user settings here to prevent disclosing information they
+    # do not want to disclose
+
+    $ctx->{'user'}{'usrname'} = $user->usrname;
+    $ctx->{'user'}{'email'} = $user->email || '';
+    $ctx->{'user'}{'name'} = generate_user_name($user);
+
+    my $home_ou = $editor->retrieve_actor_org_unit($user->home_ou);
+    $ctx->{'home_library'}{'name'} = $home_ou->name;
+
+    # Now get circs - expose most recent 10 circs by default?
+    my $circs = $editor->search_action_circulation([
+        { usr => $user->id, xact_finish => { '!=' => undef } },
+        { order_by => { circ => 'xact_start DESC' }, limit => 10 }
+    ]);
+
+    my @circ_history;
+    foreach my $circ (@$circs) {
+        my $title = $U->simple_scalar_request(
+            "open-ils.storage", 
+            "open-ils.storage.fleshed.biblio.record_entry.retrieve_by_copy",
+            $circ->target_copy
+        );
+
+               next unless $title;
+
+        my $mods = get_mods($title);
+
+        push @circ_history, {
+            record_id => $title->id,
+            isbn => $mods->isbn,
+            title => $mods->title,
+            author => $mods->author,
+            xact_finish => $circ->xact_finish
+        };
+    }
+
+    if (@circ_history) {
+        $ctx->{'circ_history'} = \@circ_history;
+    }
+
+    # Now expose public bookbags; set a reasonable limit
+    my @bookbags;
+    my $bbs = $editor->search_container_biblio_record_entry_bucket([
+        { owner => $user->id, pub => 't', btype => 'bookbag' },
+        { order_by => { cbreb => 'name ASC' }, limit => 100 }
+    ]);
+
+    foreach my $bb (@$bbs) {
+        my $bbitems = $editor->search_container_biblio_record_entry_bucket_item([
+            { bucket => $bb->id }
+        ]);
+
+        push @bookbags, { id => $bb->id, name => $bb->name, count => (scalar @$bbitems) };
+
+    };
+
+    if (@bookbags) {
+        $ctx->{'bookbags'} = \@bookbags;
+    }
+#        my $bbitems = $editor->search_container_biblio_record_entry_bucket_item([
+#            { bucket => $bb->id },
+#            { order_by => { cbrebi => 'id ASC' }, limit => 100 }
+#        ]);
+#
+#        foreach my $bbitem (@$bbitems) {
+#            my $title = $editor->retrieve_biblio_record_entry($bbitem->target_biblio_record_entry);
+#            get_mini_records($title, $bookbags);
+#        }
+
+    # Now expose published reviews; set a reasonable limit
+    my @reviews;
+    my $revs = $editor->search_social_user_review([
+        { creator => $user->id, approved => 't' },
+        { order_by => { socr => 'create_date DESC' }, limit => 100 }
+    ]);
+
+    foreach my $review (@$revs) {
+        my $mods = get_bib_and_mods($review->record);
+
+        push @reviews, {
+            record_id => $review->record,
+            isbn => $mods->isbn,
+            title => $mods->title,
+            author => $mods->author,
+            review => $review->value
+        };
+    };
+
+    if (@reviews) {
+        $ctx->{'reviews'} = \@reviews;
+    }
+
+    $apache->content_type('text/html');
+
+    $tt->process('social/user/about.tt2', $ctx) || die $tt->error();
+    return Apache2::Const::OK;
+}
+
+sub get_bib_and_mods {
+    my ($bib_id) = @_;
+    
+    my $bre = $editor->retrieve_biblio_record_entry($bib_id);
+
+    my $mods = get_mods($bre);
+}
+
+sub get_mods {
+    my ($bre) = @_;
+
+    my $u = OpenILS::Utils::ModsParser->new();
+    $u->start_mods_batch($bre->marc());
+    my $mods = $u->finish_mods_batch();
+    $mods->doc_id($bre->id) if $mods;
+    my $isbn = $mods->isbn || '';
+    $isbn =~ s/-//g;
+    $isbn =~ s/(\d+).*?$/$1/g;
+    $mods->isbn($isbn);
+
+    return $mods;
+}
+
+# Load our localized strings - lame, need to convert to Locale::Maketext
+sub load_i18n {
+    foreach my $string_bundle (glob("$templates/password-reset/strings.*")) {
+        open(I18NFH, '<', $string_bundle);
+        (my $locale = $string_bundle) =~ s/^.*\.([a-z]{2}-[A-Z]{2})$/$1/;
+        $logger->debug("Loaded locale [$locale] from file: [$string_bundle]");
+        while(<I18NFH>) {
+            my ($string_id, $string) = ($_ =~ m/^(.+?)=(.*?)$/);
+            $i18n->{$locale}{$string_id} = $string;
+        }
+        close(I18NFH);
+    }
+}
+
+1;
+
+# vim: et:ts=4:sw=4
diff --git a/Open-ILS/src/sql/Pg/060.schema.social.sql b/Open-ILS/src/sql/Pg/060.schema.social.sql
new file mode 100644 (file)
index 0000000..7e8a29a
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2010  Laurentian University
+ * Dan Scott <dscott@laurentian.ca> 
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ */
+
+DROP SCHEMA IF EXISTS social CASCADE;
+
+BEGIN;
+
+CREATE SCHEMA social;
+
+CREATE TABLE social.user_rating (
+    id          BIGSERIAL   PRIMARY KEY,
+    record      BIGINT      NOT NULL REFERENCES biblio.record_entry (id),
+    value       INT         NOT NULL,
+    creator     INT         NOT NULL REFERENCES actor.usr (id),
+    create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+    edit_date   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+);
+CREATE INDEX social_user_rating_record_idx ON social.user_rating( record );
+CREATE INDEX social_user_rating_creator_idx ON social.user_rating( creator );
+
+CREATE TABLE social.user_review (
+    id          BIGSERIAL   PRIMARY KEY,
+    record      BIGINT      NOT NULL REFERENCES biblio.record_entry (id),
+    value       TEXT        NOT NULL,
+    creator     INT         NOT NULL REFERENCES actor.usr (id),
+    editor      INT         REFERENCES actor.usr (id),
+    approver    INT         REFERENCES actor.usr (id),
+    approved    BOOL        NOT NULL DEFAULT FALSE,
+    create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+    edit_date   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+);
+CREATE INDEX social_user_review_approved_idx ON social.user_review( approved );
+CREATE INDEX social_user_review_record_idx ON social.user_review( record );
+CREATE INDEX social_user_review_creator_idx ON social.user_review( creator );
+
+CREATE TABLE social.tag (
+    id          BIGSERIAL   PRIMARY KEY,
+    value       TEXT        NOT NULL,
+    approver    INT         REFERENCES actor.usr (id),
+    approved    BOOL        NOT NULL DEFAULT FALSE,
+    editor      INT         REFERENCES actor.usr (id),
+    edit_date   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+);
+CREATE INDEX social_tag_approved_idx ON social.tag( approved );
+
+CREATE TABLE social.biblio_tag_map (
+    id          BIGSERIAL   PRIMARY KEY,
+    record      BIGINT      NOT NULL REFERENCES biblio.record_entry (id),
+    tag         BIGINT      NOT NULL REFERENCES social.tag (id),
+    creator     INT         NOT NULL REFERENCES actor.usr (id),
+    create_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+);
+CREATE INDEX social_biblio_tag_map_tag_record_idx ON social.biblio_tag_map( record );
+CREATE INDEX social_biblio_tag_map_tag_creator_idx ON social.biblio_tag_map( creator );
+
+CREATE OR REPLACE VIEW social.activity_stream AS
+SELECT actor, object, target, stamped, activity FROM (
+  (
+    SELECT acirc.usr AS "actor", acirc.id AS "object", acn.record AS "target", acirc.xact_finish AS "stamped", 'circ' AS "activity"
+      FROM action.circulation acirc
+        INNER JOIN asset.copy ac ON ac.id = acirc.target_copy
+        INNER JOIN asset.call_number acn ON acn.id = ac.call_number
+      WHERE xact_finish IS NOT NULL
+  )
+  UNION
+  (
+    SELECT creator AS "actor", id AS "object", record AS "target", create_date AS "stamped", 'add_review' AS activity
+      FROM social.user_review
+      WHERE approved IS NOT NULL
+  )
+  UNION
+  (
+    SELECT creator AS "actor", id AS "object", record AS "target", edit_date AS "stamped", 'add_rating' AS activity
+      FROM social.user_rating
+  )
+  UNION
+  (
+      SELECT owner AS "actor", id AS "object", NULL AS "target", create_time, 'add_bookbag' AS "activity"
+        FROM container.biblio_record_entry_bucket
+        WHERE pub IS TRUE
+  )
+  UNION
+  (
+      SELECT cbreb.owner AS "actor", cbrebi.target_biblio_record_entry AS "object", cbrebi.bucket AS "target", cbrebi.create_time, 'add_bookbag_item' AS "activity"
+        FROM container.biblio_record_entry_bucket_item cbrebi
+          INNER JOIN container.biblio_record_entry_bucket cbreb ON cbrebi.bucket = cbreb.id
+        WHERE cbreb.pub IS TRUE
+  )
+) AS activities
+ORDER BY 4 DESC;
+
+COMMIT;
index 70d7dcd..8b0d4f4 100644 (file)
@@ -23,6 +23,7 @@ FTS_CONFIG_FILE
 020.schema.functions.sql
 030.schema.metabib.sql
 040.schema.asset.sql
+060.schema.social.sql
 070.schema.container.sql
 080.schema.money.sql
 090.schema.action.sql
diff --git a/Open-ILS/src/templates/social/user/about.tt2 b/Open-ILS/src/templates/social/user/about.tt2
new file mode 100644 (file)
index 0000000..cf07727
--- /dev/null
@@ -0,0 +1,103 @@
+[%- USE date -%]
+<html>
+<head>
+  <style type='text/css'>
+    body, * { font-family: Verdana, Arial, sans-serif; }
+    td.category { font-weight: bold; }
+    td.author, td.title { text-align: center;}
+    table.circ, table.circ th, table.circ td, table.review, table.review th, table.review td {
+        border: solid thin black;
+        border-collapse: collapse;
+        margin: 0em;
+        padding-right: 1em;
+        padding-left: 1em;
+    }
+    img { border-style: none; }
+  </style>
+[% UNLESS user.usrname %]
+  <title>User not found or not public</title>
+</head>
+<body>
+  <h1>User not found or not public</h1>
+</body>
+</html>
+[% ELSE %]
+  <title>About [% helpers.escape_xml(user.usrname) %]</title>
+</head>
+<body>
+  <h1>About [% helpers.escape_xml(user.usrname) %]</h1>
+  <h2>Personal</h2>
+  <table><tbody>
+  [% IF user.avatar %]
+    <img src="[% user.avatar %]" class="avatar" />
+  [% END %]
+  [% IF user.name %]
+  <tr id='user.name'><td class='category'>Name</td><td class='data'>[% helpers.escape_xml(user.name) %]</td></tr>
+  [% END %]
+  [% IF user.email %]
+  <tr id='user.email'><td class='category'>Email</td><td class='data'>[% helpers.escape_xml(user.email) %]</td></tr>
+  [% END %]
+  [% IF home_library %]
+  <tr id='home_ou'><td class='category'>Home library</td><td class='data'>[% helpers.escape_xml(home_library.name) %]</td></tr>
+  [% END %]
+  </tbody></table>
+  <h2>Circulation history</h2>
+  [% IF circ_history %]
+    <table class="circ">
+      <thead>
+        <tr><th>Cover</th><th>Title</th><th>Author</th><th>Date returned</th></tr>
+      </thead>
+      <tbody>
+      [% FOREACH circ = circ_history %]
+        <tr>
+          <td><img src="/opac/extras/ac/jacket/small/[% circ.isbn %]" /></td>
+          <td class="title"><a href="../thing/[% circ.record_id %]">[% helpers.escape_xml(circ.title) %]</a></td>
+          <td class="author">[% helpers.escape_xml(circ.author) %]</td>
+          <td class="checkin">[% date.format(helpers.format_date(circ.xact_finish), '%Y-%m-%d') %]</td></tr>
+      [% END %]
+      </tbody>
+    </table>
+  [% ELSE %]
+    <div>No circulation history available for this user</div>
+  [% END %]
+  <h2>Lists</h2>
+  [% IF bookbags %]
+    <ul>
+      [% FOREACH bb = bookbags %]
+        <li><a href="/opac/extras/feed/bookbag/html-full/[% bb.id %]">[% helpers.escape_xml(bb.name) %]</a>
+            <a href="/opac/extras/feed/bookbag/rss2-full/[% bb.id %]"><img src="/opac/images/small-rss.png" /></a>
+            ([% bb.count %] items)
+        </li>
+      [% END %]
+    </ul>
+  [% ELSE %]
+    <div>This user has shared no lists</div>
+  [% END %]
+  <h2>Reviews written by this user</h2>
+  [% IF reviews %]
+    <table class="review">
+      <thead>
+        <tr><th>Cover</th><th>Title</th><th>Author</th><th>Review</th></tr>
+      </thead>
+      <tbody>
+      [% FOREACH review = reviews %]
+        <tr>
+          <td><img src="/opac/extras/ac/jacket/small/[% review.isbn %]" /></td>
+          <td class="title"><a href="../thing/[% helpers.escape_xml(review.record_id) %]">[% review.title %]</a></td>
+          <td class="author">[% helpers.escape_xml(review.author) %]</td>
+          <td class="checkin">[% helpers.escape_xml(review.review) %]</td></tr>
+      [% END %]
+      </tbody>
+    </table>
+  [% ELSE %]
+    <div>This user has not written any approved reviews</div>
+  [% END %]
+</body>
+</html>
+[% END %]
+<!--
+  * Show links to other social networks (via rel="me" as well as publicly)
+  * Show ratings
+  * Show tags
+  * Link to activity stream
+-->