Bug 19277: TT syntax - Display ordered data
[koha.git] / C4 / Creators / Lib.pm
1 package C4::Creators::Lib;
2
3 # Copyright 2009 Foundations Bible College.
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use strict;
21 use warnings;
22
23 use autouse 'Data::Dumper' => qw(Dumper);
24
25 use C4::Context;
26 use C4::Debug;
27
28 BEGIN {
29     use base qw(Exporter);
30     our @EXPORT = qw(get_all_templates
31                      get_all_layouts
32                      get_all_profiles
33                      get_all_image_names
34                      get_batch_summary
35                      get_label_summary
36                      get_card_summary
37                      get_barcode_types
38                      get_label_types
39                      get_font_types
40                      get_text_justification_types
41                      get_output_formats
42                      get_table_names
43                      get_unit_values
44                      html_table
45     );
46 }
47
48 =head1 NAME
49
50 C4::Creators::Lib
51
52 =cut
53
54 =head1 FUNCTIONS
55
56 =cut
57
58 #=head2 C4::Creators::Lib::_SELECT()
59 #
60 #    This function returns a recordset upon success and 1 upon failure. Errors are logged to the Apache log.
61 #
62 #    examples:
63 #
64 #        my $field_value = _SELECT(field_name, table_name, condition);
65 #
66 #=cut
67
68 sub _SELECT {
69     my @params = @_;
70     my $query = "SELECT $params[0] FROM $params[1]";
71     $params[2] ? $query .= " WHERE $params[2];" : $query .= ';';
72     my $sth = C4::Context->dbh->prepare($query);
73 #    $sth->{'TraceLevel'} = 3;
74     $sth->execute();
75     if ($sth->err) {
76         warn sprintf('Database returned the following error: %s', $sth->errstr);
77         return 1;
78     }
79     my $record_set = [];
80     while (my $row = $sth->fetchrow_hashref()) {
81         push(@$record_set, $row);
82     }
83     return $record_set;
84 }
85
86 my $barcode_types = [
87     {type => 'CODE39',          name => 'Code 39',              desc => 'Translates the characters 0-9, A-Z, \'-\', \'*\', \'+\', \'$\', \'%\', \'/\', \'.\' and \' \' to a barcode pattern.',                                  selected => 0},
88     {type => 'CODE39MOD',       name => 'Code 39 + Modulo43',   desc => 'Translates the characters 0-9, A-Z, \'-\', \'*\', \'+\', \'$\', \'%\', \'/\', \'.\' and \' \' to a barcode pattern. Encodes Mod 43 checksum.',         selected => 0},
89     {type => 'CODE39MOD10',     name => 'Code 39 + Modulo10',   desc => 'Translates the characters 0-9, A-Z, \'-\', \'*\', \'+\', \'$\', \'%\', \'/\', \'.\' and \' \' to a barcode pattern. Encodes Mod 10 checksum.',         selected => 0},
90     {type => 'COOP2OF5',        name => 'COOP2of5',             desc => 'Creates COOP2of5 barcodes from a string consisting of the numeric characters 0-9',                                                                     selected => 0},
91     {type => 'EAN13',           name => 'EAN13',                desc => 'Creates EAN13 barcodes from a string of 12 or 13 digits. The check number (the 13:th digit) is calculated if not supplied.',                           selected => 0},
92 #    {type => 'EAN8',            name => 'EAN8',                 desc => 'Translates a string of 7 or 8 digits to EAN8 barcodes. The check number (the 8:th digit) is calculated if not supplied.',                              selected => 0},
93 #    {type => 'IATA2of5',        name => 'IATA2of5',             desc => 'Creates IATA2of5 barcodes from a string consisting of the numeric characters 0-9',                                                                     selected => 0},
94     {type => 'INDUSTRIAL2OF5',  name => 'Industrial2of5',       desc => 'Creates Industrial2of5 barcodes from a string consisting of the numeric characters 0-9',                                                               selected => 0},
95 #    {type => 'ITF',             name => 'Interleaved2of5',      desc => 'Translates the characters 0-9 to a barcodes. These barcodes could also be called 'Interleaved2of5'.',                                                  selected => 0},
96 #    {type => 'MATRIX2OF5',      name => 'Matrix2of5',           desc => 'Creates Matrix2of5 barcodes from a string consisting of the numeric characters 0-9',                                                                   selected => 0},
97 #    {type => 'NW7',             name => 'NW7',                  desc => 'Creates a NW7 barcodes from a string consisting of the numeric characters 0-9',                                                                        selected => 0},
98 #    {type => 'UPCA',            name => 'UPCA',                 desc => 'Translates a string of 11 or 12 digits to UPCA barcodes. The check number (the 12:th digit) is calculated if not supplied.',                           selected => 0},
99 #    {type => 'UPCE',            name => 'UPCE',                 desc => 'Translates a string of 6, 7 or 8 digits to UPCE barcodes. If the string is 6 digits long, '0' is added first in the string. The check number (the 8:th digit) is calculated if not supplied.',                                 selected => 0},
100 ];
101
102 my $label_types = [
103     {type => 'BIB',     name => 'Biblio',               desc => 'Only the bibliographic data is printed.',                              selected => 0},
104     {type => 'BARBIB',  name => 'Barcode/Biblio',       desc => 'Barcode proceeds bibliographic data.',                                 selected => 0},
105     {type => 'BIBBAR',  name => 'Biblio/Barcode',       desc => 'Bibliographic data proceeds barcode.',                                 selected => 0},
106     {type => 'ALT',     name => 'Alternating',          desc => 'Barcode and bibliographic data are printed on alternating labels.',    selected => 0},
107     {type => 'BAR',     name => 'Barcode',              desc => 'Only the barcode is printed.',                                         selected => 0},
108 ];
109
110 my $font_types = [
111     {type => 'TR',      name => 'Times-Roman',                  selected => 0},
112     {type => 'TB',      name => 'Times-Bold',                   selected => 0},
113     {type => 'TI',      name => 'Times-Italic',                 selected => 0},
114     {type => 'TBI',     name => 'Times-Bold-Italic',            selected => 0},
115     {type => 'C',       name => 'Courier',                      selected => 0},
116     {type => 'CB',      name => 'Courier-Bold',                 selected => 0},
117     {type => 'CO',      name => 'Courier-Oblique',              selected => 0},
118     {type => 'CBO',     name => 'Courier-Bold-Oblique',         selected => 0},
119     {type => 'H',       name => 'Helvetica',                    selected => 0},
120     {type => 'HO',      name => 'Helvetica-Oblique',            selected => 0},
121     {type => 'HB',      name => 'Helvetica-Bold',               selected => 0},
122     {type => 'HBO',     name => 'Helvetica-Bold-Oblique',       selected => 0},
123 ];
124
125 my $text_justification_types = [
126     {type => 'L',       name => 'Left',                         selected => 0},
127     {type => 'C',       name => 'Center',                       selected => 0},
128     {type => 'R',       name => 'Right',                        selected => 0},
129 #    {type => 'F',       name => 'Full',                         selected => 0},
130 ];
131
132 my $unit_values = [
133     {type       => 'POINT',      desc    => 'PostScript Points',  value   => 1,                 selected => 0},
134     {type       => 'AGATE',      desc    => 'Adobe Agates',       value   => 5.1428571,         selected => 0},
135     {type       => 'INCH',       desc    => 'US Inches',          value   => 72,                selected => 0},
136     {type       => 'MM',         desc    => 'SI Millimeters',     value   => 2.83464567,        selected => 0},
137     {type       => 'CM',         desc    => 'SI Centimeters',     value   => 28.3464567,        selected => 0},
138 ];
139
140 my $output_formats = [
141     {type       => 'pdf',       desc    => 'PDF File'},
142     {type       => 'csv',       desc    => 'CSV File'},
143 ];
144
145 sub _build_query {
146     my ( $params, $table ) = @_;
147     my @fields = exists $params->{fields} ? @{ $params->{fields} } : ();
148     my $query = "SELECT " . ( @fields ? join(', ', @fields ) : '*' ) . " FROM $table";
149     my @where_args;
150     if ( exists $params->{filters} ) {
151         $query .= ' WHERE 1 ';
152         while ( my ( $field, $values ) = each %{ $params->{filters} } ) {
153             if ( ref( $values ) ) {
154                 $query .= " AND $field IN ( " . ( ('?,') x (@$values-1) ) . "? ) "; # a comma separates elements in a list...
155                 push @where_args, @$values;
156             } else {
157                 $query .= " AND $field = ? ";
158                 push @where_args, $values;
159             }
160         }
161     }
162     $query .= (exists $params->{orderby} ? " ORDER BY $params->{orderby} " : '');
163     return ( $query, @where_args );
164 }
165
166 =head2 C4::Creators::Lib::get_all_templates()
167
168   my $templates = get_all_templates();
169
170 This function returns a reference to a hash containing all templates upon success and 1 upon failure. Errors are logged to the Apache log.
171
172 =cut
173
174 sub get_all_templates {
175     my ( $params ) = @_;
176     my @templates = ();
177     my ( $query, @where_args ) = _build_query( $params, 'creator_templates' );
178     my $sth = C4::Context->dbh->prepare($query);
179     $sth->execute( @where_args );
180     if ($sth->err) {
181         warn sprintf('Database returned the following error: %s', $sth->errstr);
182         return -1;
183     }
184     ADD_TEMPLATES:
185     while (my $template = $sth->fetchrow_hashref) {
186         push(@templates, $template);
187     }
188     return \@templates;
189 }
190
191 =head2 C4::Creators::Lib::get_all_layouts()
192
193   my $layouts = get_all_layouts();
194
195 This function returns a reference to a hash containing all layouts upon success and 1 upon failure. Errors are logged to the Apache log.
196
197 =cut
198
199 sub get_all_layouts {
200     my ( $params ) = @_;
201     my @layouts = ();
202     my ( $query, @where_args ) = _build_query( $params, 'creator_layouts' );
203     my $sth = C4::Context->dbh->prepare($query);
204     $sth->execute( @where_args );
205     if ($sth->err) {
206         warn sprintf('Database returned the following error: %s', $sth->errstr);
207         return -1;
208     }
209     ADD_LAYOUTS:
210     while (my $layout = $sth->fetchrow_hashref) {
211         push(@layouts, $layout);
212     }
213     return \@layouts;
214 }
215
216 =head2 C4::Creators::Lib::get_all_profiles()
217
218   my $profiles = get_all_profiles();
219
220   my $profiles = get_all_profiles({ fields => [@fields], filters => { filters => [$value1, $value2] } });
221
222 This function returns an arrayref whose elements are hashes containing all profiles upon success and 1 upon failure. Errors are logged
223 to the Apache log. Two parameters are accepted. The first limits the field(s) returned. This parameter should be string of comma separted
224 fields. ie. "field_1, field_2, ...field_n" The second limits the records returned based on a string containing a valud SQL 'WHERE' filter.
225
226 NOTE: Do not pass in the keyword 'WHERE.'
227
228 =cut
229
230 sub get_all_profiles {
231     my ( $params ) = @_;
232     my @profiles = ();
233     my ( $query, @where_args ) = _build_query( $params, 'printers_profile' );
234     my $sth = C4::Context->dbh->prepare($query);
235     $sth->execute( @where_args );
236     if ($sth->err) {
237         warn sprintf('Database returned the following error: %s', $sth->errstr);
238         return -1;
239     }
240     ADD_PROFILES:
241     while (my $profile = $sth->fetchrow_hashref) {
242         push(@profiles, $profile);
243     }
244     return \@profiles;
245 }
246
247 =head2 C4::Creators::Lib::get_all_image_names()
248
249 =cut
250
251 sub get_all_image_names {
252     my $image_names = [];
253     my $query = "SELECT image_name FROM creator_images";
254     my $sth = C4::Context->dbh->prepare($query);
255 #    $sth->{'TraceLevel'} = 3 if $debug;
256     $sth->execute();
257     if ($sth->err) {
258         warn sprintf('Database returned the following error: %s', $sth->errstr);
259         return -1;
260     }
261     grep {push @$image_names, {type => $$_[0], name => $$_[0], selected => 0}} @{$sth->fetchall_arrayref([0])};
262     return $image_names;
263 }
264
265 =head2 C4::Creators::Lib::get_batch_summary()
266
267   my $batches = get_batch_summary();
268
269   my $batches = get_batch_summary(filter => filter_string);
270
271 This function returns an arrayref whose elements are hashes containing the batch_ids of current batches along with the item count
272 for each batch upon success and 1 upon failure. Item counts are stored under the key '_item_count' Errors are logged to the Apache log.
273 One parameter is accepted which limits the records returned based on a string containing a valud SQL 'WHERE' filter.
274
275 NOTE: Do not pass in the keyword 'WHERE.'
276
277 =cut
278
279 sub get_batch_summary {
280     my ( $params ) = @_;
281     my @batches = ();
282     $params->{fields} = ['batch_id', 'count(batch_id) as _item_count'];
283     my ( $query, @where_args ) = _build_query( $params, 'creator_batches' );
284     $query .= " GROUP BY batch_id";
285     my $sth = C4::Context->dbh->prepare($query);
286     $sth->execute( @where_args );
287     if ($sth->err) {
288         warn sprintf('Database returned the following error on attempted SELECT: %s', $sth->errstr);
289         return -1;
290     }
291     while (my $batch = $sth->fetchrow_hashref) {
292         push(@batches, $batch);
293     }
294     return \@batches;
295 }
296
297 =head2 C4::Creators::Lib::get_label_summary()
298
299   my $labels = get_label_summary();
300
301   my $labels = get_label_summary(items => @item_list);
302
303 This function returns an arrayref whose elements are hashes containing the label_ids of current labels along with the item count
304 for each label upon success and 1 upon failure. Item counts are stored under the key '_item_count' Errors are logged to the Apache log.
305 One parameter is accepted which limits the records returned based on a string containing a valud SQL 'WHERE' filter.
306
307 NOTE: Do not pass in the keyword 'WHERE.'
308
309 =cut
310
311 sub get_label_summary {
312     my %params = @_;
313     my $label_number = 0;
314     my @label_summaries = ();
315     my $query = "     SELECT b.title, b.author, bi.itemtype, i.barcode, i.itemcallnumber, i.biblionumber, i.itype
316                       FROM creator_batches AS c LEFT JOIN items AS i ON (c.item_number=i.itemnumber)
317                       LEFT JOIN biblioitems AS bi ON (i.biblioitemnumber=bi.biblioitemnumber)
318                       LEFT JOIN biblio AS b ON (bi.biblionumber=b.biblionumber)
319                       WHERE itemnumber=? AND batch_id=?;
320                   ";
321     my $sth = C4::Context->dbh->prepare($query);
322     foreach my $item (@{$params{'items'}}) {
323         $label_number++;
324         $sth->execute($item->{'item_number'}, $params{'batch_id'});
325         if ($sth->err) {
326             warn sprintf('Database returned the following error on attempted SELECT: %s', $sth->errstr);
327             return -1;
328         }
329         my $record = $sth->fetchrow_hashref;
330         my $label_summary;
331         $label_summary->{'_label_number'} = $label_number;
332         $record->{'author'} =~ s/[^\.|\w]$// if $record->{'author'};  # strip off ugly trailing chars... but not periods or word chars
333         $record->{'title'} =~ s/\W*$//;  # strip off ugly trailing chars
334         # FIXME contructing staff interface URLs should be done *much* higher up the stack - for the most part, C4 module code
335         # should not know that it's part of a web app
336         $record->{'title'} = '<a href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=' . $record->{'biblionumber'} . '"> ' . $record->{'title'} . '</a>';
337         $label_summary->{'_summary'} = $record->{'title'} . " | " . ($record->{'author'} ? $record->{'author'} : 'N/A');
338         $label_summary->{'_item_type'} = C4::Context->preference("item-level_itypes") ? $record->{'itype'} : $record->{'itemtype'};
339         $label_summary->{'_barcode'} = $record->{'barcode'};
340         $label_summary->{'_item_number'} = $item->{'item_number'};
341         $label_summary->{'_item_cn'} = $record->{'itemcallnumber'};
342         $label_summary->{'_label_id'} = $item->{'label_id'};
343         push (@label_summaries, $label_summary);
344     }
345     return \@label_summaries;
346 }
347
348 =head2 C4::Creators::Lib::get_card_summary()
349
350   my $cards = get_card_summary();
351
352   my $cards = get_card_summary(items => @item_list);
353
354 This function returns an arrayref whose elements are hashes containing the label_ids of current cards along with the item count
355 for each card upon success and 1 upon failure. Item counts are stored under the key '_item_count' Errors are logged to the Apache log.
356 One parameter is accepted which limits the records returned based on a string containing a valud SQL 'WHERE' filter.
357
358 NOTE: Do not pass in the keyword 'WHERE.'
359
360 =cut
361
362 sub get_card_summary {
363     my %params = @_;
364     my $card_number = 0;
365     my @card_summaries = ();
366     my $query = "SELECT CONCAT_WS(', ', surname, firstname) AS name, cardnumber FROM borrowers WHERE borrowernumber=?;";
367     my $sth = C4::Context->dbh->prepare($query);
368     foreach my $item (@{$params{'items'}}) {
369         $card_number++;
370         $sth->execute($item->{'borrower_number'});
371         if ($sth->err) {
372             warn sprintf('Database returned the following error on attempted SELECT: %s', $sth->errstr);
373             return -1;
374         }
375         my $record = $sth->fetchrow_hashref;
376         my $card_summary->{'_card_number'} = $card_number;
377         $card_summary->{'_summary'} = $record->{'name'};
378         $card_summary->{'borrowernumber'} = $item->{'borrower_number'};
379         $card_summary->{'_label_id'} = $item->{'label_id'};
380         push (@card_summaries, $card_summary);
381     }
382     return \@card_summaries;
383 }
384
385 =head2 C4::Creators::Lib::get_barcode_types()
386
387   my $barcode_types = get_barcode_types();
388
389 This function returns a reference to an array of hashes containing all barcode types along with their name and description.
390
391 =cut
392
393 sub get_barcode_types {
394     return $barcode_types;
395 }
396
397 =head2 C4::Creators::Lib::get_label_types()
398
399   my $label_types = get_label_types();
400
401 This function returns a reference to an array of hashes containing all label types along with their name and description.
402
403 =cut
404
405 sub get_label_types {
406     return $label_types;
407 }
408
409 =head2 C4::Creators::Lib::get_font_types()
410
411   my $font_types = get_font_types();
412
413 This function returns a reference to an array of hashes containing all font types along with their name and description.
414
415 =cut
416
417 sub get_font_types {
418     return $font_types;
419 }
420
421 =head2 C4::Creators::Lib::get_text_justification_types()
422
423   my $text_justification_types = get_text_justification_types();
424
425 This function returns a reference to an array of hashes containing all text justification types along with their name and description.
426
427 =cut
428
429 sub get_text_justification_types {
430     return $text_justification_types;
431 }
432
433 =head2 C4::Creators::Lib::get_unit_values()
434
435   my $unit_values = get_unit_values();
436
437 This function returns a reference to an array of  hashes containing all unit types along with their description and multiplier.
438 NOTE: All units are relative to a PostScript Point.
439 There are 72 PS points to the inch.
440
441 =cut
442
443 sub get_unit_values {
444     return $unit_values;
445 }
446
447 =head2 C4::Creators::Lib::get_output_formats()
448
449   my $label_output_formats = get_output_formats();
450
451 This function returns a reference to an array of hashes containing all label output formats along with their description.
452
453 =cut
454
455 sub get_output_formats {
456     return $output_formats;
457 }
458
459
460 =head2 C4::Creators::Lib::get_table_names($search_term)
461
462 Return an arrayref of an array containing the table names which contain the supplied search term.
463
464 =cut
465
466 sub get_table_names {
467     my $search_term = shift;
468     my $dbh = C4::Context->dbh();
469     my $table_names = [];
470     my $sth = $dbh->table_info(undef,undef,"%$search_term%");
471     while (my $info = $sth->fetchrow_hashref()){
472         push (@$table_names, $info->{'TABLE_NAME'});
473     }
474     return $table_names;
475 }
476
477 =head2 C4::Creators::Lib::html_table()
478
479 This function returns an arrayref of an array of hashes contianing the supplied data formatted suitably to
480 be passed off as a template parameter and used to build an html table.
481
482    my $table = html_table(header_fields, array_of_row_data);
483    $template->param(
484        table_loop => $table,
485    );
486
487     html example:
488
489         <table>
490             [% FOREACH table_loo IN table_loop %]
491                 [% IF ( table_loo.header_fields ) %]
492                     <tr>
493                         [% FOREACH header_field IN table_loo.header_fields %]
494                             <th>[% header_field.field_label %]</th>
495                         [% END %]
496                     </tr>
497                 [% ELSE %]
498                     <tr>
499                         [% FOREACH text_field IN table_loo.text_fields %]
500                             [% IF ( text_field.select_field ) %]
501                                 <td><input type="checkbox" name="action" value="[% text_field.field_value %]"></td>
502                             [% ELSE %]
503                                 <td>[% text_field.field_value %]</td>
504                             [% END %]
505                         [% END %]
506                     </tr>
507                 [% END %]
508             [% END %]
509         </table>
510
511 =cut
512
513 sub html_table {
514     my $headers = shift;
515     my $data = shift;
516     return undef if scalar(@$data) == 0;      # no need to generate a table if there is not data to display
517     my $table = [];
518     my $fields = [];
519     my @table_columns = ();
520     my ($row_index, $col_index) = (0,0);
521     my $cols = 0;       # number of columns to wrap on
522     my $field_count = 0;
523     my $select_value = undef;
524     my $link_field = undef;
525     POPULATE_HEADER:
526     foreach my $header (@$headers) {
527         my @key = keys %$header;
528         if ($key[0] eq 'select' ) {
529             push (@table_columns, $key[0]);
530             $$fields[$col_index] = {hidden => 0, select_field => 0, field_name => ($key[0]), field_label => $header->{$key[0]}{'label'}};
531             # do special formatting stuff....
532             $select_value = $header->{$key[0]}{'value'};
533         }
534         else {
535             # do special formatting stuff....
536             $link_field->{$key[0]} = ($header->{$key[0]}{'link_field'} == 1 ? 1 : 0);
537             push (@table_columns, $key[0]);
538             $$fields[$col_index] = {hidden => 0, select_field => 0, field_name => ($key[0]), field_label => $header->{$key[0]}{'label'}};
539         }
540         $field_count++;
541         $col_index++;
542     }
543     $$table[$row_index] = {header_fields => $fields};
544     $cols = $col_index;
545     $field_count *= scalar(@$data);     # total fields to be displayed in the table
546     $col_index = 0;
547     $row_index++;
548     $fields = [];
549     POPULATE_TABLE:
550     foreach my $db_row (@$data) {
551         POPULATE_ROW:
552         foreach my $table_column (@table_columns) {
553             if (grep {$table_column eq $_} keys %$db_row) {
554                 $$fields[$col_index] = {hidden => 0, link_field => $link_field->{$table_column}, select_field => 0, field_name => ($table_column . "_tbl"), field_value => $db_row->{$table_column}};
555                 $col_index++;
556                 next POPULATE_ROW;
557             }
558             elsif ($table_column =~ m/^_((.*)_(.*$))/) {   # this a special case
559                 my $table_name = get_table_names('creator_'.$2); #Bug 14143 fix to remove ambiguity with table 'club_template_enrollment_fields'
560                 my $record_set = _SELECT($1, @$table_name[0], $2 . "_id = " . $db_row->{$2 . "_id"});
561                 $$fields[$col_index] = {hidden => 0, link_field => $link_field->{$table_column}, select_field => 0, field_name => ($table_column . "_tbl"), field_value => $$record_set[0]{$1}};
562                 $col_index++;
563                 next POPULATE_ROW;
564             }
565             elsif ($table_column eq 'select' ) {
566                 $$fields[$col_index] = {hidden => 0, select_field => 1, field_name => 'select', field_value => $db_row->{$select_value}};
567             }
568         }
569         $$table[$row_index] = {text_fields => $fields};
570         $col_index = 0;
571         $row_index++;
572         $fields = [];
573     }
574     return $table;
575 }
576
577 1;
578 __END__
579
580 =head1 AUTHOR
581
582 Chris Nighswonger <cnighswonger AT foundations DOT edu>
583
584 =cut