#!/usr/bin/perl -w
=head1 NAME
Webnote - an online tool for taking notes
=head1 DESCRIPTION
This is based on great webnote from
L
=head1 METHODS
=cut
package Webnote;
use strict;
BEGIN {
use Exporter ();
use vars qw ($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS);
$VERSION = 0.1;
@ISA = qw (Exporter);
#Give a hoot don't pollute, do not export more than needed by default
@EXPORT = qw ();
@EXPORT_OK = qw ();
%EXPORT_TAGS = ();
}
use CGI qw(:standard);
use CGI::Carp qw(fatalsToBrowser);
use XML::Simple;
use DBI;
use POSIX qw(strftime);
use Date::Parse;
use URI::Escape;
use Encode qw/decode from_to encode/;
use XML::RSS;
use Data::Dumper;
my $debug = 1;
my $webnote1 = <%s
EOF2
sub debug {
return unless ($debug);
return unless (@_);
print STDERR '[' . localtime() . '] ' . Carp::shortmess(@_);
}
=head2 new
Create new Webnote object
my $wn = new Webnote(
dsn => 'dbi:Pg:dbname=webnote',
db_user => 'foobar',
db_passwd => 'secret',
db_codepage => 'ISO-8859-2',
adminEmail => 'dpavlin(at)rot13(dot)org',
debug => 1,
);
If C isn't specified, ISO-8859-1 will be used.
=cut
sub new {
my $class = shift;
my $self = {@_};
bless($self, $class);
die "need dsn" unless ($self->{'dsn'});
$self->{'dbh'} = DBI->connect($self->{'dsn'}, $self->{'db_user'}, $self->{'db_passwd'}) || die $DBI::errstr;
$self->{'cgi'} = new CGI || die "can't make CGI object";
$debug = $self->{'debug'};
# default database codepage
$self->{'db_codepage'} ||= 'ISO-8859-1';
$self ? return $self : return undef;
}
=head2 run
Main loop of Webnote.
$wn->run( default => sub {
my $self = shift;
# do something if no action is handled
# (by default, it's form to enter workspace name)
} );
This is main loop of Webnote back-end which will handle following methods
(invoked via URL):
=over 12
=item /save
Save workplace to database
=item /getdates
Get all modifications of workplace
=item /getrecent
Get last modification of workplace
=item /load
Load workplace
=item /rss
Generate RSS for all notes in workplace
=item /stats
Generate RSS for statistics of all workplaces
=back
If there isn't any method specified in URL, it will by default produce form
to enter workspace name. This can be overridden with C parametar
and coderef.
C coderef should emit it's own headers and return true.
=cut
sub run {
my $self = shift;
my $arg = {@_};
my $q = $self->{'cgi'} || die "no CGI?";
my $method = $q->path_info();
debug "method: $method ",$q->query_string()," [",($q->content_type() || 'unknown'),"]";
if ( ( $q->content_type() && $q->content_type() eq 'text/xml' ) || $method eq '/save') {
$self->save();
} elsif (my $name = $q->param('name')) {
if ($method eq '/getdates') {
my $offset = $q->param('offset') || 0;
$self->getdates($name, $offset, 100);
} elsif ($method eq '/getrecent') {
$self->getdates($name,0,1);
} else {
# handle /load too
$self->load($name);
}
} elsif ($method =~ m#^/rss/(.+)$#) {
$self->rss($1);
} elsif ($method eq '/stats') {
$self->stats();
} elsif ($method) {
print $q->header,
"unknown method: $method";
} elsif (ref $arg->{'default'}) {
no strict 'refs';
$arg->{'default'}->($self) || die "default method didn't return true";
} else {
print $q->header,
$q->start_html(
-title=>'webnote - with perl back-end',
-style=>{'src'=>'style.css'},
),
$q->start_form,
"load or create workspace ",$q->textfield('name'),
$q->submit(-name=>'Go'),
$q->end_form;
}
}
=head2 save
Save note to database. This action is called when there is C
content from POST request.
$wn->save() || die "can't save";
=cut
sub save {
my $self = shift;
my $q = $self->{'cgi'} || return;
my $xml_post;
if ($q->content_type() && $q->content_type() eq 'text/xml') {
# this is ugly cludge!
$xml_post = $q->{'POSTDATA'}->[0];
} elsif ($q->path_info() eq '/save') {
my $post = $q->query_string() || return;
# fix microsoft smart quotes
$post =~ s/%E2%80%9[CD]/"/gi;
$post =~ s/%E2%80%9[89]/'/gi;
$xml_post = uri_unescape( $post );
} else {
return;
}
return unless ($xml_post);
$xml_post =~ s/%u201[cd]/"/gi;
$xml_post =~ s/%u201[89]/'/gi;
$xml_post =~ s/^POSTDATA=//;
# $xml_post = decode('UTF-8',$xml_post);
# $xml_post = '' . $xml_post;
debug "xml_post = $xml_post\n";
# open(my $t, '>', '/tmp/post.xml');
# if ($t) {
# print $t $xml_post;
# close($t);
# }
my $xml = XMLin($xml_post, ForceArray => [ 'note' ]);
if (! $xml) {
warn "can't prase $xml_post\n";
print $q->header(-type=>'text/xml'), qq{\n\n\n\n};
return undef;
};
$xml->{'time'} ||= $self->_fmt_utime(time());
debug "note: ",Dumper($xml);
if ($self->store_workspace($xml)) {
# return ok to JavaScript
print $q->header(-type=>'text/xml'), qq{\n\n\n\n};
} else {
print $q->header(-type=>'text/xml'), qq{\n\n\n\n};
return undef;
}
return 1;
}
=head2 load
This function loads workspace.
my $wn->load('Workspace name');
=cut
sub load {
my $self = shift;
my $name = shift || return;
my $q = $self->{'cgi'} || die "need cgi";
print $q->header(
-charset => 'UTF-8',
);
my $ws = $self->load_workspace($name, $q->param('time'));
my $base_href = $q->url();
my $rel = $q->url(-relative=>1);
$base_href =~ s/$rel$//g;
$ws->{'time'} ||= $self->_fmt_utime(time());
$ws->{'nextnotenum'} ||= 1;
printf($webnote1,
$base_href,
($debug || 0),
$self->js_escape($name),
$ws->{'time'},
$ws->{'nextnotenum'}
);
print " baseURI = '", $q->url(), "';\n";
print " adminEmail = '", $self->{'adminEmail'}, "';\n";
print " numDates = 10;\n";
if ($ws) {
debug "loaded workspace: $name\n";
debug Dumper($ws);
foreach my $n (@{$ws->{'note'}}) {
my $c = $n->{'content'};
$c =~ s/[\n\r]+//g;
printf qq# workspace.createNote( { 'id':'%s', 'xPos':'%s', 'yPos':'%s', 'height':'%s', 'width':'%s', 'bgcolor':'%s', 'zIndex':'%s', 'text':unescape('%s') }, true );\n#,
$n->{'noteid'},
$n->{'xposition'},
$n->{'yposition'},
$n->{'height'},
$n->{'width'},
$n->{'bgcolor'},
$n->{'zindex'},
$self->js_escape($c);
}
if (my $newnote = $q->param('nn')) {
debug "newnote: $newnote";
my $via = $q->param('via');
$newnote .= qq{ via:$via} if ($via);
$newnote = $self->js_escape($newnote);
print qq# workspace.createNote( { 'text':unescape('$newnote') } );\n#;
}
} else {
debug "new workspace: $name\n";
}
my $rss_url = $q->url().'/rss/'.uri_escape($name);
printf $webnote2,$rss_url,$name,$rss_url;
}
=head2 workspace_info
Get workspace by name (and time)
my $ws_info = $wn->workspace_info('Workspace name', 1111679331);
If no time is specified, it will return latest version of workspace.
=cut
sub workspace_info {
my $self = shift;
my $name = shift || return;
my $time = shift;
my $sql = qq{
select
wsid, wsname, nextNoteNum, time
from wn_workspaces
where wsname = ?
};
my @arg = ( $name );
if ($time) {
$sql .= qq{ and time = ? };
push @arg, $time;
} else {
$sql .= qq{ order by time desc limit 1 };
}
my $sth = $self->{'dbh'}->prepare($sql) || die $self->{'dbh'}->errstr();
$sth->execute(@arg) || die $sth->errstr();
my $ws_info = $sth->fetchrow_hashref();
debug "workspace_info($name,$time): ",Dumper($ws_info);
return $ws_info;
}
=head2 store_workspace
Save workspace data to database
$wn->store_workspace($workspace_hash);
C<$workspace_hash> has following structure:
'name' => 'foo',
'nextNoteNum' => '153',
'time' => 1111674868,
'note' => [
{
'noteid' => 'note146',
'content' => 'foobar%0A',
'bgcolor' => '#8facff'
'xposition' => '384',
'yposition' => '411',
'width' => '221',
'height' => '153',
'zindex' => '1',
},
# ...
]
=cut
sub store_workspace {
my $self = shift;
my $ws = shift || return;
return unless ($ws->{'name'} && $ws->{'time'});
$self->{'dbh'}->begin_work;
my $sth = $self->{'dbh'}->prepare(qq{
insert into wn_workspaces (wsname, nextNoteNum, time)
values (?, ?, ?)
}) || die $self->{'dbh'}->errstr();
$sth->execute(
map { $ws->{$_} } qw(name nextNoteNum time)
) || die $sth->errstr();
my $ws_info = $self->workspace_info($ws->{'name'}, $ws->{'time'}) || die "can't find newly inserted row!";
my $wsid = $ws_info->{'wsid'} || die "can't find wsid in workspace_info";
my @note_data = qw(noteid content bgcolor xposition yposition height width zindex wsid);
$sth = $self->{'dbh'}->prepare(
'insert into wn_notes (' .
join(",", @note_data) .
') values (?,?,?,?,?,?,?,?,?)') || die $self->{'dbh'}->errstr();
debug "using db_codepage: ".$self->{'db_codepage'};
my $ok = 1;
foreach my $n (@{$ws->{'note'}}) {
$n->{'wsid'} ||= $wsid;
my $c = $self->js_unescape($n->{'content'}) || '';
# encode content into database codepage
$c = encode($self->{db_codepage}, $c);
debug "store_workplace(".$ws->{'name'}.") - $wsid ".$n->{'noteid'}." ==> $c";
# save empty notes also
$n->{content} = $c;
unless ($sth->execute( map { $n->{$_} } @note_data ) ) {
$ok = 0;
$self->{'dbh'}->rollback;
last;
}
}
$self->{'dbh'}->commit if ($ok);
return $ok;
}
=head2 load_workspace
Load workspace from database.
$workspace_hash = $wn->load_workspace('Workspace name', 1111679331);
=cut
sub load_workspace {
my $self = shift;
my ($name, $time) = @_;
$time ||= ''; # fix warning
my $ws = $self->workspace_info($name, $time) || return;
my $sth = $self->{'dbh'}->prepare(qq{
select noteid,content,bgcolor,xposition,yposition,height,width,zindex from wn_notes where wsid = ? order by noteid desc
}) || die $self->{'dbh'}->errstr();
$sth->execute($ws->{'wsid'}) || die $sth->errstr();
while (my $n = $sth->fetchrow_hashref()) {
# decode content back from database encoding to UTF-8
from_to($n->{content}, $self->{'db_codepage'}, 'UTF-8');
push @{$ws->{'note'}}, $n;
}
debug "load_workspace($name,$time)",Dumper($ws);
return $ws;
}
=head2 getdates
Method to return formatted data for JavaScript call C and
C.
print $wn->getdates('Workspace name', $offset, $limit);
=cut
sub getdates {
my $self = shift;
my ($name,$offset,$limit) = @_;
die "no name?" unless ($name);
$offset ||= 0;
$limit ||= 100;
my $sth = $self->{'dbh'}->prepare(qq{
select time
from wn_workspaces
where wsname=?
order by time desc
limit $limit offset $offset
}) || die $self->{'dbh'}->errstr();
my @times;
$sth->execute($name) || die $sth->errstr();
while (my ($t) = $sth->fetchrow_array()) {
push @times, $t;
}
my $ret = join("|",@times);
# default to unix time 1, for last update of new notes (never)
$ret ||= $self->_fmt_utime(1);
debug "getdates($name, $offset, $limit) = $ret";
print "Content-type: text/plain\r\n\r\n$ret";
}
=head2 rss
Create RSS 2.0 feed for notes
print $wn->rss("Workspace name");
=cut
sub rss {
my $self = shift;
my $name = shift || return;
$name = uri_unescape($name);
my $ws = $self->load_workspace($name);
my $q = $self->{'cgi'} || die "no cgi?";
my $url = $q->url()."?name=".uri_escape($name);
my $rss = new XML::RSS(
version => '2.0',
encoding => 'UTF-8',
);
$rss->channel(
title => $name,
link => $url,
description => "Webnote notes called $name",
);
foreach my $n (@{$ws->{'note'}}) {
my $desc = '
' .
$n->{'content'} . '
';
my $title = $1 if ($n->{content} =~ m/^\s*([^\n\r]+)/s);
$rss->add_item(
title => $title || $n->{noteid},
permaLink => $url . '#' . $n->{'noteid'},
description => $desc,
);
}
print "Content-type: text/xml\n\n" . $rss->as_string;
}
=head2 stats
Return RSS 1.0 with some statistic about Webnote workspaces
print $wn->stats;
=cut
sub stats {
my $self = shift;
my $q = $self->{'cgi'} || die "no cgi?";
my $url = $q->url();
my $rss = new XML::RSS(
version => '1.0',
encoding => 'UTF-8',
);
$rss->channel(
title => "Webnote statistics",
link => $url,
description => "Webnote statistics",
);
my $sth = $self->{'dbh'}->prepare(qq{
select
wsname,
count(distinct(wn_notes.time)) as nr_saves,
max(wn_workspaces.time) as last_saved
from wn_workspaces, wn_notes
where wn_workspaces.wsid = wn_notes.wsid
group by wsname
order by last_saved desc
limit 10
}) || die $self->{'dbh'}->errstr();
$sth->execute() || die $sth->errstr();
while (my $row = $sth->fetchrow_hashref()) {
my $dc_date = $self->_fmt_dc_date( $row->{'last_saved'} || time() );
$rss->add_item(
title => $row->{'wsname'},
link => $url . "?name=".uri_escape($row->{'wsname'}),
description => "Saved ". $row->{'nr_saves'} . " times",
dc => {
creator => 'Webnote back-end',
date => $dc_date,
},
);
}
print "Content-type: text/xml\n\n" . $rss->as_string;
}
#
#
=head1 INTERNAL METHODS
You shouldn't need to call this directly.
=head2 _fmt_utime
Return time in ISO format
=cut
sub _fmt_utime {
my $self = shift;
my $t = shift || return;
return strftime('%Y-%m-%d %H:%M:%S', localtime($t));
}
=head2 _fmt_dc_date
Return time in ISO format for dc:date
=cut
sub _fmt_dc_date {
my $self = shift;
my $t = shift || time();
$t = str2time($t) unless ($t =~ m#^\d+$#);
return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime($t));
}
=head2 js_unsescape
Convert string that JavaScript function C encoded.
print $wn->js_unescape('odr%u017Ean');
It will return string in UTF-8 encoding.
=cut
sub js_unescape {
my $self = shift;
my $m = shift || return;
sub _js_uxxxx {
my ($lo,$hi) = @_;
my $c = chr(hex($lo)). chr(hex($hi));
from_to($c, 'UTF-16BE', 'UTF-8');
return $c;
}
if ($m =~ s/%u([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})/_js_uxxxx($1,$2)/eg) {
$m = decode('UTF-8', $m);
}
$m =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/eg;
debug "js_unescape: $m";
return $m;
}
=head2 js_escape
Convert string into for JavaScript funcition C.
print $wn->js_escape("some text");
Input text is assumed to be in UTF-8 encoding.
=cut
sub js_escape {
my $self = shift;
my $t = shift || return;
# $t = decode('UTF-8', $t);
# $t = uri_escape($t, '\x00-\x1f\x7f-\xff');
$t =~ s#%0a#\\n#gis;
$t =~ s/'/\\'/gs;
$t =~ s/(\s)/'%'.unpack('H*',$1)/eg;
return $t;
}
1;