#!/usr/bin/perl -w
=head1 NAME
Webnote - an online tool for taking notes
=head1 DESCRIPTION
This is based on great webnote from
L<http://www.aypwip.org/webnote/>
=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 = <<EOF1;
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<base href="%s">
<script type="text/javascript" src="objects.js"></script>
<script type="text/javascript" src="webnote.js"></script>
<script type="text/javascript">
<!--
function loadinit()
{
debugOn = %d;
workspace.setName(unescape('%s'));
workspace.loadedTime = '%s';
init();
workspace.nextNoteNum = %d;
EOF1
my $webnote2 = <<EOF2;
workspace.changed = false;
}
// -->
</script>
<link rel="stylesheet" href="style.css" type="text/css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="%s" />
<title>%s</title>
</head>
<body onload='loadinit();' style='background-color: #f0f0f0;'>
<div id='content'>
<div id='toolbar'>
<div class='controls'>
<img src='images/new.gif' class='controls' onclick="workspace.createNote()" title="new note" alt='new note' />
<img src='images/save.gif' class='controlsDisabled' onclick="workspace.save()" title="save workspace" id="saveImg" alt='save workspace' />
<img src='images/reload.gif' class='controls' onclick="workspace.loadlist()" title="load previous version" alt='load previous version' />
<img src='images/undo.gif' class='controlsDisabled' onclick="workspace.history.undo()" title="nothing to undo" id="undoImg" alt='undo icon' />
<img src='images/redo.gif' class='controlsDisabled' onclick="workspace.history.redo()" title="nothing to redo" id="redoImg" alt='redo icon' />
</div>
<div id='filters'>
<input class='filters' style='width: 135px; padding: 1px 2px 1px 2px;' id='textfilter' title='enter a regular expression to filter by' onchange='workspace.filter(this.value)' onkeydown='if(13==event.keyCode){workspace.filter(this.value);}; event.cancelBubble=true;'/>
<!-- this button is strictly for looks -->
<input class='filters' style='width: 50px;' type='button' value='filter' />
</div>
<div id='mini' title='you have no notes'>
</div>
<div id='links'>
<a href="%s" title='rss feed of these notes' ><img style='margin: 6px 2px;border:0;width:19px;height:9px;' src='images/minixml.gif' alt='xml' /></a>
</div>
<div id='wsname'>
</div>
</div>
<div id='db'></div>
<div style='display: none;'><img src='images/close.gif' alt='red X' /></div>
</div>
</body>
</html>
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<db_codepage> 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<default> parametar
and coderef.
C<default> 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<text/xml>
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 version="1.0" encoding="UTF-8"?>' . $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<return>\n<status value="error">\n</return>\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<return>\n<status value="ok" update="$xml->{'time'}"/>\n</return>\n};
} else {
print $q->header(-type=>'text/xml'), qq{\n<return>\n<status value="error">\n</return>\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{ <br/><span class="via">via:<a href="$via">$via</a></span>} 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</getdates> and
C</getrecent>.
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 = '<div class="webnote" style="background: ' .
$n->{'bgcolor'} . ';">' .
$n->{'content'} . '</div>';
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<escape> 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<unescape>.
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;