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