Don't delete old users
[scpubgit/stemmaweb.git] / lib / Text / Tradition / Directory.pm
CommitLineData
83ed6665 1package Text::Tradition::Directory;
2
3use strict;
4use warnings;
5use Moose;
6use DBI;
7use Encode qw/ encode decode_utf8 /;
8use KiokuDB::GC::Naive;
9use KiokuDB::TypeMap;
10use KiokuDB::TypeMap::Entry::Naive;
11use Safe::Isa;
12use Text::Tradition::Error;
13
14## users
15use KiokuX::User::Util qw(crypt_password);
16use Text::Tradition::Store;
17use Text::Tradition::User;
18use Text::Tradition::TypeMap::Entry;
19
20extends 'KiokuX::Model';
21
22use vars qw/ $VERSION /;
23$VERSION = "1.2";
24
25=head1 NAME
26
27Text::Tradition::Directory - a KiokuDB interface for storing and retrieving
28traditions and their owners
29
30=head1 SYNOPSIS
31
32 use Text::Tradition::Directory;
33 my $d = Text::Tradition::Directory->new(
34 'dsn' => 'dbi:SQLite:mytraditions.db',
35 'extra_args' => { 'create' => 1 },
36 );
37
38 my $tradition = Text::Tradition->new( @args );
39 my $stemma = $tradition->add_stemma( dotfile => $dotfile ); # if Analysis module installed
40 $d->save_tradition( $tradition );
41
42 foreach my $id ( $d->traditions ) {
43 print $d->tradition( $id )->name;
44 }
45
46 ## Users:
47 my $userstore = Text::Tradition::UserStore->new(dsn => 'dbi:SQLite:foo.db');
48 my $newuser = $userstore->add_user({ username => 'fred',
49 password => 'somepassword' });
50
51 my $fetchuser = $userstore->find_user({ username => 'fred' });
52 if($fetchuser->check_password('somepassword')) {
53 ## login user or .. whatever
54 }
55
56 my $user = $userstore->deactivate_user({ username => 'fred' });
57 if(!$user->active) {
58 ## shouldnt be able to login etc
59 }
60
61=head1 DESCRIPTION
62
63Text::Tradition::Directory is an interface for storing and retrieving text
64traditions and all their data, including an associated stemma hypothesis
65and a user who has ownership rights to the tradition data. It is an
66instantiation of a KiokuDB::Model, storing traditions and associated
67stemmas by UUID.
68
69The Text::Tradition::Directory package also includes the
70L<Text::Tradition::User> class for user objects, and the
71L<Text::Tradition::Ownership> role which extends the Text::Tradition class
72to handle user ownership.
73
74=head1 ATTRIBUTES
75
76=head2 MIN_PASS_LEN
77
78Constant for the minimum password length when validating passwords,
79defaults to "8".
80
81=cut
82
83has MIN_PASS_LEN => ( is => 'ro', isa => 'Num', default => sub { 8 } );
84
85=head1 METHODS
86
87=head2 new
88
89Returns a Directory object.
90
91=head2 traditionlist
92
93Returns a hashref mapping of ID => name for all traditions in the directory.
94
95=head2 tradition( $id )
96
97Returns the Text::Tradition object of the given ID.
98
99=head2 save( $tradition )
100
101Writes the given tradition to the database, returning its ID.
102
103=head2 delete( $tradition )
104
105Deletes the given tradition object from the database.
106WARNING!! Garbage collection does not yet work. Use this sparingly.
107
108=begin testing
109
110use TryCatch;
111use File::Temp;
112use Safe::Isa;
113use Text::Tradition;
114use_ok 'Text::Tradition::Directory';
115
116my $fh = File::Temp->new();
117my $file = $fh->filename;
118$fh->close;
119my $dsn = "dbi:SQLite:dbname=$file";
120my $uuid;
121my $user = 'user@example.org';
122my $t = Text::Tradition->new(
123 'name' => 'inline',
124 'input' => 'Tabular',
125 'file' => 't/data/simple.txt',
126 );
127my $stemma_enabled = $t->can( 'add_stemma' );
128
129{
130 my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
131 'extra_args' => { 'create' => 1 } );
132 ok( $d->$_isa('Text::Tradition::Directory'), "Got directory object" );
133
134 my $scope = $d->new_scope;
135 $uuid = $d->save( $t );
136 ok( $uuid, "Saved test tradition" );
137
138 # Add a test user
139 my $user = $d->add_user({ username => $user, password => 'UserPass' });
140 $user->add_tradition( $t );
141 $d->store( $user );
142 is( $t->user, $user, "Assigned tradition to test user" );
143
144 SKIP: {
145 skip "Analysis package not installed", 5 unless $stemma_enabled;
146 my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
147 ok( $d->save( $t ), "Updated tradition with stemma" );
148 is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
149 is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
150 try {
151 $d->save( $s );
152 } catch( Text::Tradition::Error $e ) {
153 is( $e->ident, 'database error', "Got exception trying to save stemma directly" );
154 like( $e->message, qr/Cannot directly save non-Tradition object/,
155 "Exception has correct message" );
156 }
157 }
158}
159my $nt = Text::Tradition->new(
160 'name' => 'CX',
161 'input' => 'CollateX',
162 'file' => 't/data/Collatex-16.xml',
163 );
164ok( $nt->$_isa('Text::Tradition'), "Made new tradition" );
165
166{
167 my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
168 my $scope = $f->new_scope;
169 is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
170 my $nuuid = $f->save( $nt );
171 ok( $nuuid, "Stored second tradition" );
172 my @tlist = $f->traditionlist;
173 is( scalar @tlist, 2, "Directory index has both traditions" );
174 my $tf = $f->tradition( $uuid );
175 my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
176 is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
177 is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
178 my $sid;
179 SKIP: {
180 skip "Analysis package not installed", 4 unless $stemma_enabled;
181 $sid = $f->object_to_id( $tf->stemma(0) );
182 try {
183 $f->tradition( $sid );
184 } catch( Text::Tradition::Error $e ) {
185 is( $e->ident, 'database error', "Got exception trying to fetch stemma directly" );
186 like( $e->message, qr/not a Text::Tradition/, "Exception has correct message" );
187 }
188 if( $ENV{TEST_DELETION} ) {
189 try {
190 $f->delete( $sid );
191 } catch( Text::Tradition::Error $e ) {
192 is( $e->ident, 'database error', "Got exception trying to delete stemma directly" );
193 like( $e->message, qr/Cannot directly delete non-Tradition object/,
194 "Exception has correct message" );
195 }
196 }
197 }
198
199 SKIP: {
200 skip "Set TEST_DELETION in env to test DB deletion functionality", 3
201 unless $ENV{TEST_DELETION};
202 $f->delete( $uuid );
203 ok( !$f->exists( $uuid ), "Object is deleted from DB" );
204 ok( !$f->exists( $sid ), "Object stemma also deleted from DB" ) if $stemma_enabled;
205 is( scalar $f->traditionlist, 1, "Object is deleted from index" );
206 }
207}
208
209{
210 my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
211 my $scope = $g->new_scope;
212 SKIP: {
213 skip "Set TEST_DELETION in env to test DB deletion functionality", 1
214 unless $ENV{TEST_DELETION};
215 is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
216 }
217 my $ntobj = $g->tradition( 'CX' );
218 my @w1 = sort { $a->sigil cmp $b->sigil } $ntobj->witnesses;
219 my @w2 = sort{ $a->sigil cmp $b->sigil } $nt->witnesses;
220 is_deeply( \@w1, \@w2, "Looked up remaining tradition by name" );
221}
222
223=end testing
224
225=cut
226use Text::Tradition::TypeMap::Entry;
227
228has +typemap => (
229 is => 'rw',
230 isa => 'KiokuDB::TypeMap',
231 default => sub {
232 KiokuDB::TypeMap->new(
233 isa_entries => {
234 # now that we fall back to YAML deflation, all attributes of
235 # Text::Tradition will be serialized to YAML as individual objects
236 # Except if we declare a specific entry type here
237 "Text::Tradition" =>
238 KiokuDB::TypeMap::Entry::MOP->new(),
239 # We need users to be naive entries so that they hold
240 # references to the original tradition objects, not clones
241 "Text::Tradition::User" =>
242 KiokuDB::TypeMap::Entry::MOP->new(),
243 "Text::Tradition::Collation" =>
244 KiokuDB::TypeMap::Entry::MOP->new(),
245 "Text::Tradition::Witness" =>
246 KiokuDB::TypeMap::Entry::MOP->new(),
247 "Graph" => Text::Tradition::TypeMap::Entry->new(),
248 "Set::Scalar" => Text::Tradition::TypeMap::Entry->new(),
249 }
250 );
251 },
252);
253
254has '_mysql_utf8_hack' => (
255 is => 'ro',
256 isa => 'Bool',
257 default => undef,
258);
259
260# Push some columns into the extra_args
261around BUILDARGS => sub {
262 my $orig = shift;
263 my $class = shift;
264 my $args;
265 if( @_ == 1 ) {
266 $args = $_[0];
267 } else {
268 $args = { @_ };
269 }
270 my @column_args;
271 if( $args->{'dsn'} =~ /^dbi:(\w+):/ ) { # We're using Backend::DBI
272 my $dbtype = $1;
273 @column_args = ( 'columns',
274 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 },
275 'public' => { 'data_type' => 'bool', 'is_nullable' => 1 } ] );
276 if( $dbtype eq 'mysql' &&
277 exists $args->{extra_args}->{dbi_attrs} &&
278 $args->{extra_args}->{dbi_attrs}->{mysql_enable_utf8} ) {
279 # There is a bad interaction with MySQL in utf-8 mode.
280 # Work around it here.
281 # TODO fix the underlying storage problem
282 $args->{extra_args}->{dbi_attrs}->{mysql_enable_utf8} = undef;
283 $args->{_mysql_utf8_hack} = 1;
284 }
285 }
286 my $ea = $args->{'extra_args'};
287 if( ref( $ea ) eq 'ARRAY' ) {
288 push( @$ea, @column_args );
289 } elsif( ref( $ea ) eq 'HASH' ) {
290 $ea = { %$ea, @column_args };
291 } else {
292 $ea = { @column_args };
293 }
294 $args->{'extra_args'} = $ea;
295
296 return $class->$orig( $args );
297};
298
299override _build_directory => sub {
300 my($self) = @_;
301 Text::Tradition::Store->connect(@{ $self->_connect_args },
302 resolver_constructor => sub {
303 my($class) = @_;
304 $class->new({ typemap => $self->directory->merged_typemap,
305 fallback_entry => Text::Tradition::TypeMap::Entry->new() });
306 });
307};
308
309## These checks don't cover store($id, $obj)
310# before [ qw/ store update insert delete / ] => sub {
311before [ qw/ delete / ] => sub {
312 my $self = shift;
313 my @nontrad;
314 foreach my $obj ( @_ ) {
315 if( ref( $obj ) && !$obj->$_isa( 'Text::Tradition' )
316 && !$obj->$_isa('Text::Tradition::User') ) {
317 # Is it an id => Tradition hash?
318 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
319 my( $k ) = keys %$obj;
320 next if $obj->{$k}->$_isa('Text::Tradition');
321 }
322 push( @nontrad, $obj );
323 }
324 }
325 if( @nontrad ) {
326 throw( "Cannot directly save non-Tradition object of type "
327 . ref( $nontrad[0] ) );
328 }
329};
330
331# TODO Garbage collection doesn't work. Suck it up and live with the
332# inflated DB.
333after delete => sub {
334 my $self = shift;
335 my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
336 $self->directory->backend->delete( $gc->garbage->members );
337};
338
339sub save {
340 my $self = shift;
341 return $self->store( @_ );
342}
343
344sub tradition {
345 my( $self, $id ) = @_;
346 my $obj = $self->lookup( $id );
347 unless( $obj ) {
348 # Try looking up by name.
349 foreach my $item ( $self->traditionlist ) {
350 if( $item->{'name'} eq $id ) {
351 $obj = $self->lookup( $item->{'id'} );
352 last;
353 }
354 }
355 }
356 if( $obj && !$obj->$_isa('Text::Tradition') ) {
357 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
358 }
359 return $obj;
360}
361
362sub traditionlist {
363 my $self = shift;
364 my ($user) = @_;
365
366 return $self->user_traditionlist($user) if($user);
367 return $self->_get_object_idlist( 'Text::Tradition' );
368}
369
370sub _get_object_idlist {
371 my( $self, $objclass ) = @_;
372 my @tlist;
373 # If we are using DBI, we can do it the easy way; if not, the hard way.
374 # Easy way still involves making a separate DBI connection. Ew.
375 if( $self->dsn =~ /^dbi:(\w+):/ ) {
376 my $dbtype = $1;
377 my @connection = @{$self->directory->backend->connect_info};
378 # Get rid of KiokuDB-specific arg
379 pop @connection if scalar @connection > 4;
380 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
381 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
382 my $dbh = DBI->connect( @connection );
383 my $q = $dbh->prepare( 'SELECT id, name, public from entries WHERE class = "'
384 . $objclass . '"' );
385 $q->execute();
386 while( my @row = $q->fetchrow_array ) {
387 # Horrible horrible hack. Re-convert the name to UTF-8.
388 if( $self->_mysql_utf8_hack ) {
389 # Convert the chars into a raw bytestring.
390 my $octets = encode( 'ISO-8859-1', $row[1] );
391 $row[1] = decode_utf8( $octets );
392 }
393 push( @tlist, { 'id' => $row[0], 'name' => $row[1], 'public' => $row[2] } );
394 }
395 } else {
396 $self->scan( sub { my $o = shift;
397 push( @tlist, { 'id' => $self->object_to_id( $o ),
398 'name' => $o->name,
399 'public' => $o->public } )
400 if( ref $o eq $objclass ) } );
401 }
402 return @tlist;
403}
404
405sub throw {
406 Text::Tradition::Error->throw(
407 'ident' => 'database error',
408 'message' => $_[0],
409 );
410}
411
412
413# has 'directory' => (
414# is => 'rw',
415# isa => 'KiokuX::Model',
416# handles => []
417# );
418
419## TODO: Some of these methods should probably optionally take $user objects
420## instead of hashrefs.
421
422## It also occurs to me that all these methods don't need to be named
423## XX_user, but leaving that way for now incase we merge this code
424## into ::Directory for one-store.
425
426=head1 USER DIRECTORY METHODS
427
428=head2 add_user( $userinfo )
429
430Takes a hashref of C<username>, C<password>.
431
432Create a new user object, store in the KiokuDB backend, and return it.
433
434=cut
435
436sub add_user {
437 my ($self, $userinfo) = @_;
438
439 my $username = $userinfo->{username};
440 my $password = $userinfo->{password};
441 my $role = $userinfo->{role} || 'user';
442
40d56e64 443 if ($userinfo->{sub}) {
444 $username = $userinfo->{sub};
445 }
446
83ed6665 447 throw( "No username given" ) unless $username;
448 throw( "Invalid password - must be at least " . $self->MIN_PASS_LEN
449 . " characters long" )
26c6f68f 450 unless ( $self->validate_password($password) || $username =~ /^https?:/ || exists ($userinfo->{openid_id}) || exists ($userinfo->{sub}));
83ed6665 451
452 my $user = Text::Tradition::User->new(
453 id => $username,
454 password => ($password ? crypt_password($password) : ''),
455 email => ($userinfo->{email} ? $userinfo->{email} : $username),
456 role => $role,
457 );
458
459 $self->store($user->kiokudb_object_id, $user);
460
461 return $user;
462}
463
464=head2 create_user( $userinfo )
465
466Takes a hashref that can either be suitable for add_user (see above) or be
467a hash of OpenID user information from Credential::OpenID.
468
469=cut
470
471sub create_user {
472 my ($self, $userinfo) = @_;
473
474 ## No username means probably an OpenID based user
475 if(!exists $userinfo->{username}) {
476 _extract_openid_data($userinfo);
477 }
478
479 return $self->add_user($userinfo);
480}
481
482## Not quite sure where this method should be.. Auth /
483## Credential::OpenID just pass us back the chunk of extension data
484sub _extract_openid_data {
485 my ($userinfo) = @_;
486
487 ## Spec says SHOULD use url as identifier
488 $userinfo->{username} = $userinfo->{url};
489
490 ## Use email addy as display if available
491 if(exists $userinfo->{extensions} &&
492 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
493 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
494 ## Somewhat ugly attribute extension reponse, contains
495 ## google-email string which we can use as the id
496
497 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
498 }
499
500 return;
501}
502
503=head2 find_user( $userinfo )
504
505Takes a hashref of C<username> or C<email>, and possibly openIDish results from
506L<Net::OpenID::Consumer>.
507
508Fetches the user object for the given username and returns it.
509
510=cut
511
512sub find_user {
513 my ($self, $userinfo) = @_;
514
515 ## A URL field means probably an OpenID based user
516 if( exists $userinfo->{url} ) {
517 _extract_openid_data($userinfo);
518 }
519
520 if (exists $userinfo->{sub} && exists $userinfo->{openid_id}) {
521 return $self->_find_gplus($userinfo);
522 }
523
524 my $user;
525 if( exists $userinfo->{username} ) {
526 my $username = $userinfo->{username};
527 ## No logins if user is deactivated (use lookup to fetch to re-activate)
528 $user = $self->lookup(Text::Tradition::User->id_for_user($username));
529 ## If there is an inactive user, skip it
530 return if( $user && !$user->active );
531 } elsif( exists $userinfo->{email} ) {
532 ## Scan the users looking for a matching email
533 my @matches;
534 $self->scan( sub { push( @matches, @_ )
535 if $_[0]->isa('Text::Tradition::User')
536 && $_[0]->email eq $userinfo->{email} } );
537 $user = shift @matches;
538 }
539# print STDERR "Found user, $username, email is :", $user->email, ":\n";
540 return $user;
541}
542
543sub _find_gplus {
544 my ($self, $userinfo) = @_;
545
546 my $sub = $userinfo->{sub};
547 my $openid = $userinfo->{openid_id};
1c65af41 548 my $email = $userinfo->{email};
83ed6665 549
550 # Do we have a user with the google id already?
551
552 my $user = $self->find_user({
553 username => $sub
554 });
555
556 if ($user) {
557 return $user;
558 }
559
560 # Do we have a user with the openid?
561
562 $user = $self->find_user({
563 url => $openid
564 });
565
566 if (!$user) {
40d56e64 567 return undef;
83ed6665 568 }
569
570 my $new_user = $self->add_user({
1c65af41 571 username => $sub,
572 password => $user->password,
573 role => $user->role,
574 active => $user->active,
575 sub => $sub,
26c6f68f 576 openid_id => $openid,
1c65af41 577 email => $email,
83ed6665 578 });
579
580 foreach my $t (@{ $user->traditions }) {
581 $new_user->add_tradition($t);
582 }
583
feeb6c31 584 # $self->delete_user({ username => $user->id });
83ed6665 585 return $new_user;
586}
587
588=head2 modify_user( $userinfo )
589
590Takes a hashref of C<username> and C<password> (same as add_user).
591
592Retrieves the user, and updates it with the new information. Username
593changing is not currently supported.
594
595Returns the updated user object, or undef if not found.
596
597=cut
598
599sub modify_user {
600 my ($self, $userinfo) = @_;
601 my $username = $userinfo->{username};
602 my $password = $userinfo->{password};
603 my $role = $userinfo->{role};
604
605 throw( "Missing username" ) unless $username;
606
607 my $user = $self->find_user({ username => $username });
608 throw( "Could not find user $username" ) unless $user;
609
610 if($password) {
611 throw( "Bad password" ) unless $self->validate_password($password);
612 $user->password(crypt_password($password));
613 }
614 if($role) {
615 $user->role($role);
616 }
617
618 $self->update($user);
619
620 return $user;
621}
622
623=head2 deactivate_user( $userinfo )
624
625Takes a hashref of C<username>.
626
627Sets the users C<active> flag to false (0), and sets all traditions
628assigned to them to non-public, updates the storage and returns the
629deactivated user.
630
631Returns undef if user not found.
632
633=cut
634
635sub deactivate_user {
636 my ($self, $userinfo) = @_;
637 my $username = $userinfo->{username};
638
639 throw( "Need to specify a username for deactivation" ) unless $username;
640
641 my $user = $self->find_user({ username => $username });
642 throw( "User $username not found" ) unless $user;
643
644 $user->active(0);
645 foreach my $tradition (@{ $user->traditions }) {
646 ## Not implemented yet
647 # $tradition->public(0);
648 }
649
650 ## Should we be using Text::Tradition::Directory also?
651 $self->update(@{ $user->traditions });
652
653 $self->update($user);
654
655 return $user;
656}
657
658=head2 reactivate_user( $userinfo )
659
660Takes a hashref of C<username>.
661
662Returns the user object if already activated. Activates (sets the
663active flag to true (1)), updates the storage and returns the user.
664
665Returns undef if the user is not found.
666
667=cut
668
669sub reactivate_user {
670 my ($self, $userinfo) = @_;
671 my $username = $userinfo->{username};
672
673 throw( "Need to specify a username for reactivation" ) unless $username;
674
675 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
676 throw( "User $username not found" ) unless $user;
677
678 return $user if $user->active;
679
680 $user->active(1);
681 $self->update($user);
682
683 return $user;
684}
685
686=head2 delete_user( $userinfo )
687
688CAUTION: Deletes actual data!
689
690Takes a hashref of C<username>.
691
692Returns undef if the user doesn't exist.
693
694Removes the user from the store and returns 1.
695
696=cut
697
698sub delete_user {
699 my ($self, $userinfo) = @_;
700 my $username = $userinfo->{username};
701
702 throw( "Need to specify a username for deletion" ) unless $username;
703
704 my $user = $self->find_user({ username => $username });
705 throw( "User $username not found" ) unless $user;
706
707 ## Should we be using Text::Tradition::Directory for this bit?
708 $self->delete( @{ $user->traditions });
709
710 ## Poof, gone.
711 $self->delete($user);
712
713 return 1;
714}
715
716=head2 validate_password( $password )
717
718Takes a password string. Returns true if it is longer than
719L</MIN_PASS_LEN>, false otherwise.
720
721Used internally by L</add_user>.
722
723=cut
724
725sub validate_password {
726 my ($self, $password) = @_;
727
728 return if !$password;
729 return if length($password) < $self->MIN_PASS_LEN;
730
731 return 1;
732}
733
734=head2 user_traditionlist( $user )
735
736Returns a tradition list (see specification above) but containing only
737those traditions visible to the specified user. If $user is the string
738'public', returns only publicly-viewable traditions.
739
740=cut
741
742sub user_traditionlist {
743 my ($self, $user) = @_;
744
745 my @tlist;
746 if(ref $user && $user->is_admin) {
747 ## Admin sees all
748 return $self->traditionlist();
749 } elsif(ref $user) {
750 ## We have a user object already, so just fetch its traditions and use tose
751 foreach my $t (@{ $user->traditions }) {
752 push( @tlist, { 'id' => $self->object_to_id( $t ),
753 'name' => $t->name } );
754 }
755 return @tlist;
756 } elsif($user ne 'public') {
757 die "Passed neither a user object nor 'public' to user_traditionlist";
758 }
759
760 ## Search for all traditions which allow public viewing
761 my @list = grep { $_->{public} } $self->traditionlist();
762 return @list;
763}
764
7651;
766
767=head1 LICENSE
768
769This package is free software and is provided "as is" without express
770or implied warranty. You can redistribute it and/or modify it under
771the same terms as Perl itself.
772
773=head1 AUTHORS
774
775Tara L Andrews E<lt>aurum@cpan.orgE<gt> (initial release)
776
777Shadowcat Systems L<http://www.scsys.co.uk/> (user functionality; making it all work)
778