source: trunk/lib/OpenGuides/Search.pm @ 818

Last change on this file since 818 was 818, checked in by nick, 15 years ago

Support (+test) searching as a feed

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
File size: 24.0 KB
Line 
1package OpenGuides::Search;
2use strict;
3our $VERSION = '0.10';
4
5use CGI qw( :standard );
6use Wiki::Toolkit::Plugin::Locator::Grid;
7use File::Spec::Functions qw(:ALL);
8use OpenGuides::Template;
9use OpenGuides::Utils;
10use Parse::RecDescent;
11
12=head1 NAME
13
14OpenGuides::Search - Search form generation and processing for OpenGuides.
15
16=head1 DESCRIPTION
17
18Does search stuff for OpenGuides.  Distributed and installed as part of
19the OpenGuides project, not intended for independent installation.
20This documentation is probably only useful to OpenGuides developers.
21
22=head1 SYNOPSIS
23
24  use CGI;
25  use OpenGuides::Config;
26  use OpenGuides::Search;
27
28  my $config = OpenGuides::Config->new( file => "wiki.conf" );
29  my $search = OpenGuides::Search->new( config => $config );
30  my %vars = CGI::Vars();
31  $search->run( vars => \%vars );
32
33=head1 METHODS
34
35=over 4
36
37=item B<new>
38
39  my $config = OpenGuides::Config->new( file => "wiki.conf" );
40  my $search = OpenGuides::Search->new( config => $config );
41
42=cut
43
44sub new {
45    my ($class, %args) = @_;
46    my $config = $args{config};
47    my $self   = { config => $config };
48    bless $self, $class;
49
50    my $wiki = OpenGuides::Utils->make_wiki_object( config => $config );
51
52    $self->{wiki}     = $wiki;
53    $self->{wikimain} = $config->script_url . $config->script_name;
54    $self->{css}      = $config->stylesheet_url;
55    $self->{head}     = $config->site_name . " Search";
56
57    my $geo_handler = $config->geo_handler;
58    my %locator_params;
59    if ( $geo_handler == 1 ) {
60        %locator_params = ( x => "os_x", y => "os_y" );
61    } elsif ( $geo_handler == 2 ) {
62        %locator_params = ( x => "osie_x", y => "osie_y" );
63    } elsif ( $geo_handler == 3 ) {
64        %locator_params = ( x => "easting", y => "northing" );
65    }
66
67    my $locator = Wiki::Toolkit::Plugin::Locator::Grid->new( %locator_params );
68    $wiki->register_plugin( plugin => $locator );
69    $self->{locator} = $locator;
70
71    return $self;
72}
73
74=item B<wiki>
75
76  my $wiki = $search->wiki;
77
78An accessor; returns the underlying L<Wiki::Toolkit> object.
79
80=cut
81
82sub wiki {
83    my $self = shift;
84    return $self->{wiki};
85}
86
87=item B<config>
88
89  my $config = $search->config;
90
91An accessor; returns the underlying L<OpenGuides::Config> object.
92
93=cut
94
95sub config {
96    my $self = shift;
97    return $self->{config};
98}
99
100=item B<run>
101
102  my %vars = CGI::Vars();
103  $search->run(
104                vars           => \%vars,
105                return_output  => 1,   # defaults to 0
106                return_tt_vars => 1,  # defaults to 0
107              );
108
109The C<return_output> parameter is optional.  If supplied and true, the
110stuff that would normally be printed to STDOUT will be returned as a
111string instead.
112
113The C<return_tt_vars> parameter is also optional.  If supplied and
114true, the template is not processed and the variables that would have
115been passed to it are returned as a hash.  This parameter takes
116precedence over C<return_output>.
117
118These two parameters exist to make testing easier; you probably don't
119want to use them in production.
120
121
122In case you're struggling to follow the code, it does the following:
1231) Processes the parameters, and bails out if it hit a problem with them
1242) If a search string was given, do a text search
1253) If distance search paramaters were given, do a distance search
1264) If no search has occured, print out the search form
1275) If an error occured, bail out
1286) If we got a single hit on a string search, redirect to it
1297) If no results were found, give an empty search results page
1308) Sort the results by either score or distance
1319) Decide which results to show, based on paging
13210) Display the appropriate page of the results
133
134=back
135
136=cut
137
138sub run {
139    my ($self, %args) = @_;
140    $self->{return_output}  = $args{return_output}  || 0;
141    $self->{return_tt_vars} = $args{return_tt_vars} || 0;
142
143    $self->process_params( $args{vars} );
144    if ( $self->{error} ) {
145        warn $self->{error};
146        my %tt_vars = ( error_message => $self->{error} );
147        $self->process_template( tt_vars => \%tt_vars );
148        return;
149    }
150
151    my %tt_vars = (
152                   format      => $args{'vars'}->{'format'},
153                   ss_version  => $VERSION,
154                   ss_info_url => 'http://openguides.org/page/search_help'
155                  );
156
157    my $doing_search;
158
159    # Run a text search if we have a search string.
160    if ( $self->{search_string} ) {
161        $doing_search = 1;
162        $tt_vars{search_terms} = $self->{search_string};
163        $self->run_text_search;
164    }
165
166    # Run a distance search if we have sufficient criteria.
167    if ( defined $self->{distance_in_metres}
168         && defined $self->{x} && defined $self->{y} ) {
169        $doing_search = 1;
170        # Make sure to pass the criteria to the template.
171        $tt_vars{dist} = $self->{distance_in_metres};
172        $tt_vars{latitude} = $self->{latitude};
173        $tt_vars{longitude} = $self->{longitude};
174        if ( $self->config->geo_handler eq 1 ) {
175            $tt_vars{coord_field_1_value} = $self->{os_x};
176            $tt_vars{coord_field_2_value} = $self->{os_y};
177        } elsif ( $self->config->geo_handler eq 2 ) {
178            $tt_vars{coord_field_1_value} = $self->{osie_x};
179            $tt_vars{coord_field_2_value} = $self->{osie_y};
180        } elsif ( $self->config->geo_handler eq 3 ) {
181            $tt_vars{coord_field_1_value} = $self->{latitude};
182            $tt_vars{coord_field_2_value} = $self->{longitude};
183        }
184        $self->run_distance_search;
185    }
186
187    # If we're not doing a search then just print the search form.
188    unless ( $doing_search ) {
189        return $self->process_template( tt_vars => \%tt_vars );
190    }
191
192    # At this point either $self->{error} or $self->{results} will be filled.
193    if ( $self->{error} ) {
194        $tt_vars{error_message} = $self->{error};
195        $self->process_template( tt_vars => \%tt_vars );
196        return;
197    }
198
199    # So now we know that we have been asked to perform a search, and we
200    # have performed it.
201    #
202    # $self->{results} will be a hash of refs to hashes like so:
203    #   'Node Name' => {
204    #                    name     => 'Node Name',
205    #                    distance => $distance_from_origin_if_any,
206    #                    score    => $relevance_to_search_string
207    #                  }
208
209    my %results_hash = %{ $self->{results} || [] };
210    my @results = values %results_hash;
211    my $numres = scalar @results;
212
213    # If we only have a single hit, and the title is a good enough match
214    # to the search string, redirect to that node.
215    # (Don't try a fuzzy search on a blank search string - Plucene chokes.)
216    if ( $self->{search_string} && $numres == 1 && !$self->{return_tt_vars}) {
217        my %fuzzies = $self->wiki->fuzzy_title_match($self->{search_string});
218        if ( scalar keys %fuzzies ) {
219            my $node = $results[0]{name};
220            my $formatter = $self->wiki->formatter;
221            my $node_param = CGI::escape(
222                            $formatter->node_name_to_node_param( $node )
223                                        );
224            my $output = CGI::redirect( $self->{wikimain} . "?$node_param" );
225            return $output if $self->{return_output};
226            print $output;
227            return;
228        }
229    }
230
231    # If we had no hits then go straight to the template.
232    if ( $numres == 0 ) {
233        %tt_vars = (
234                     %tt_vars,
235                     first_num => 0,
236                     results   => [],
237                   );
238        return $self->process_template( tt_vars => \%tt_vars );
239    }
240
241    # Otherwise, we browse through the results a page at a time.
242
243    # Figure out which results we're going to be showing on this
244    # page, and what the first one for the next page will be.
245    my $startpos = $args{vars}{next} || 0;
246    $tt_vars{first_num} = $numres ? $startpos + 1 : 0;
247    $tt_vars{last_num}  = $numres > $startpos + 20 ? $startpos + 20 : $numres;
248    $tt_vars{total_num} = $numres;
249    if ( $numres > $startpos + 20 ) {
250        $tt_vars{next_page_startpos} = $startpos + 20;
251    }
252
253    # Sort the results - by distance if we're searching on that
254    # or by score otherwise.
255    if ( $self->{distance_in_metres} ) {
256        @results = sort { $a->{distance} <=> $b->{distance} } @results;
257    } else {
258        @results = sort { $b->{score} <=> $a->{score} } @results;
259    }
260
261    # Now snip out just the ones for this page.  The -1 is because
262    # arrays index from 0 and people from 1.
263    my $from = $tt_vars{first_num} ? $tt_vars{first_num} - 1 : 0;
264    my $to   = $tt_vars{last_num} - 1; # kludge to empty arr for no results
265    @results = @results[ $from .. $to ];
266
267    # Add the URL to each result hit.
268    my $formatter = $self->wiki->formatter;
269    foreach my $i ( 0 .. $#results ) {
270        my $name = $results[$i]{name};
271
272        # Add the one-line summary of the node, if there is one.
273        my %node = $self->wiki->retrieve_node($name);
274        $results[$i]{summary} = $node{metadata}{summary}[0];
275
276        my $node_param = $formatter->node_name_to_node_param( $name );
277        $results[$i]{url} = $self->{wikimain} . "?$node_param";
278    }
279
280    # Finally pass the results to the template.
281    $tt_vars{results} = \@results;
282    $self->process_template( tt_vars => \%tt_vars );
283}
284
285sub run_text_search {
286    my $self = shift;
287    my $searchstr = $self->{search_string};
288    my $wiki = $self->wiki;
289
290    # Create parser to parse the search string.
291    my $parser = Parse::RecDescent->new( q{
292
293        search: list eostring {$return = $item[1]}
294
295        list: comby(s)
296            {$return = (@{$item[1]}>1) ? ['AND', @{$item[1]}] : $item[1][0]}
297
298        comby: <leftop: term ',' term>
299            {$return = (@{$item[1]}>1) ? ['OR', @{$item[1]}] : $item[1][0]}
300
301        term: '(' list ')' {$return = $item[2]}
302            |        '-' term {$return = ['NOT', @{$item[2]}]}
303            |        '"' word(s) '"' {$return = ['phrase', join " ", @{$item[2]}]}
304            |        word {$return = ['word', $item[1]]}
305            |        '[' word(s) ']' {$return = ['title', @{$item[2]}]}
306
307        word: /[\w'*%]+/ {$return = $item[1]}
308
309        eostring: /^\Z/
310
311    } );
312
313    unless ( $parser ) {
314        warn $@;
315        $self->{error} = "Can't create parse object - $@";
316        return $self;
317    }
318
319    # Run parser over search string.
320    my $tree = $parser->search( $searchstr );
321    unless ( $tree ) {
322        $self->{error} = "Syntax error in search: $searchstr";
323        return $self;
324    }
325
326    # Run the search over the generated search tree.
327    my %results = $self->_run_search_tree( tree => $tree );
328    $self->{results} = \%results;
329    return $self;
330}
331
332sub _run_search_tree {
333    my ($self, %args) = @_;
334    my $tree = $args{tree};
335    my @tree_arr = @$tree;
336    my $op = shift @tree_arr;
337    my $method = "_run_" . $op . "_search";
338    return $self->can($method) ? $self->$method(@tree_arr) : undef;
339}
340
341=head1 INPUT
342
343=over
344
345=item B<word>
346
347a single word will be matched as-is. For example, a search on
348
349  escalator
350
351will return all pages containing the word "escalator".
352
353=cut
354
355sub _run_word_search {
356    my ($self, $word) = @_;
357    # A word is just a small phrase.
358    return $self->_run_phrase_search( $word );
359}
360
361=item B<AND searches>
362
363A list of words with no punctuation will be ANDed, for example:
364
365  restaurant vegetarian
366
367will return all pages containing both the word "restaurant" and the word
368"vegetarian".
369
370=cut
371
372sub _run_AND_search {
373    my ($self, @subsearches) = @_;
374
375    # Do the first subsearch.
376    my %results = $self->_run_search_tree( tree => $subsearches[0] );
377
378    # Now do the rest one at a time and remove from the results anything
379    # that doesn't come up in each subsearch.  Results that survive will
380    # have a score that's the sum of their score in each subsearch.
381    foreach my $tree ( @subsearches[ 1 .. $#subsearches ] ) {
382        my %subres = $self->_run_search_tree( tree => $tree );
383        my @pages = keys %results;
384        foreach my $page ( @pages ) {
385          if ( exists $subres{$page} ) {
386                $results{$page}{score} += $subres{$page}{score};
387              } else {
388                delete $results{$page};
389            }
390        }
391      }
392
393    return %results;
394}
395
396=item B<OR searches>
397
398A list of words separated by commas (and optional spaces) will be ORed,
399for example:
400
401  restaurant, cafe
402
403will return all pages containing either the word "restaurant" or the
404word "cafe".
405
406=cut
407
408sub _run_OR_search {
409    my ($self, @subsearches) = @_;
410
411    # Do all the searches.  Results will have a score that's the sum
412    # of their score in each subsearch.
413    my %results;
414    foreach my $tree ( @subsearches ) {
415        my %subres = $self->_run_search_tree( tree => $tree );
416        foreach my $page ( keys %subres ) {
417          if ( $results{$page} ) {
418                $results{$page}{score} += $subres{$page}{score};
419              } else {
420                $results{$page} = $subres{$page};
421            }
422        }
423      }
424    return %results;
425}
426
427=item B<phrase searches>
428
429Enclose phrases in double quotes, for example:
430
431  "meat pie"
432
433will return all pages that contain the exact phrase "meat pie" - not pages
434that only contain, for example, "apple pie and meat sausage".
435
436=cut
437
438sub _run_phrase_search {
439    my ($self, $phrase) = @_;
440    my $wiki = $self->wiki;
441
442    # Search title and body.
443    my %contents_res = $wiki->search_nodes( $phrase );
444
445    # Rationalise the scores a little.  The scores returned by
446    # Wiki::Toolkit::Search::Plucene are simply a ranking.
447    my $num_results = scalar keys %contents_res;
448    foreach my $node ( keys %contents_res ) {
449        $contents_res{$node} = int( $contents_res{$node} / $num_results ) + 1;
450    }
451
452    # It'll be a real phrase (as opposed to a word) if it has a space in it.
453    # In this case, dump out the nodes that don't match the search exactly.
454    # I don't know why the phrase searching isn't working properly.  Fix later.
455    if ( $phrase =~ /\s/ ) {
456        my @tmp = keys %contents_res;
457        foreach my $node ( @tmp ) {
458            my $content = $wiki->retrieve_node( $node );
459            unless ( $content =~ /$phrase/i || $node =~ /$phrase/i ) {
460                delete $contents_res{$node};
461            }
462        }
463    }
464
465    my %results = map { $_ => { name => $_, score => $contents_res{$_} } }
466                      keys %contents_res;
467
468    # Bump up the score if the title matches.
469    foreach my $node ( keys %results ) {
470        $results{$node}{score} += 10 if $node =~ /$phrase/i;
471    }
472
473    # Search categories.
474    my @catmatches = $wiki->list_nodes_by_metadata(
475                                 metadata_type  => "category",
476                                 metadata_value => $phrase,
477                                 ignore_case    => 1,
478    );
479
480    foreach my $node ( @catmatches ) {
481        if ( $results{$node} ) {
482            $results{$node}{score} += 3;
483        } else {
484            $results{$node} = { name => $node, score => 3 };
485        }
486    }
487
488    # Search locales.
489    my @locmatches = $wiki->list_nodes_by_metadata(
490                                 metadata_type  => "locale",
491                                 metadata_value => $phrase,
492                                 ignore_case    => 1,
493    );
494
495    foreach my $node ( @locmatches ) {
496        if ( $results{$node} ) {
497            $results{$node}{score} += 3;
498        } else {
499            $results{$node} = { name => $node, score => 3 };
500        }
501    }
502
503    return %results;
504}
505
506=back
507
508=head1 SEARCHING BY DISTANCE
509
510To perform a distance search, you need to supply one of the following
511sets of criteria to specify the distance to search within, and the
512origin (centre) of the search:
513
514=over
515
516=item B<os_dist, os_x, and os_y>
517
518Only works if you chose to use British National Grid in wiki.conf
519
520=item B<osie_dist, osie_x, and osie_y>
521
522Only works if you chose to use Irish National Grid in wiki.conf
523
524=item B<latlong_dist, latitude, and longitude>
525
526Should always work, but has a habit of "finding" things a couple of
527metres away from themselves.
528
529=back
530
531You can perform both pure distance searches and distance searches in
532combination with text searches.
533
534=cut
535
536# Note this is called after any text search is run, and it is only called
537# if there are sufficient criteria to perform the search.
538sub run_distance_search {
539    my $self = shift;
540    my $x    = $self->{x};
541    my $y    = $self->{y};
542    my $dist = $self->{distance_in_metres};
543
544    my @close = $self->{locator}->find_within_distance(
545                                                        x      => $x,
546                                                        y      => $y,
547                                                        metres => $dist,
548                                                      );
549
550    if ( $self->{results} ) {
551        my %close_hash = map { $_ => 1 } @close;
552        my %results = %{ $self->{results} };
553        my @candidates = keys %results;
554        foreach my $node ( @candidates ) {
555            if ( exists $close_hash{$node} ) {
556                my $distance = $self->_get_distance(
557                                                     node => $node,
558                                                     x    => $x,
559                                                     y    => $y,
560                                                   );
561                $results{$node}{distance} = $distance;
562            } else {
563                delete $results{$node};
564            }
565        }
566        $self->{results} = \%results;
567    } else {
568        my %results;
569        foreach my $node ( @close ) {
570            my $distance = $self->_get_distance (
571                                                     node => $node,
572                                                     x    => $x,
573                                                     y    => $y,
574                                                   );
575            $results{$node} = {
576                                name     => $node,
577                                distance => $distance,
578                              };
579        }
580        $self->{results} = \%results;
581    }
582    return $self;
583}
584
585sub _get_distance {
586    my ($self, %args) = @_;
587    my ($node, $x, $y) = @args{ qw( node x y ) };
588    return $self->{locator}->distance(
589                                       from_x  => $x,
590                                       from_y  => $y,
591                                       to_node => $node,
592                                       unit    => "metres"
593                                     );
594}
595
596sub process_params {
597    my ($self, $vars_hashref) = @_;
598    my %vars = %{ $vars_hashref || {} };
599
600    # Make sure that we don't have any data left over from previous invocation.
601    # This is useful for testing purposes at the moment and will be essential
602    # for mod_perl implementations.
603    delete $self->{x};
604    delete $self->{y};
605    delete $self->{distance_in_metres};
606    delete $self->{search_string};
607
608    # Strip out any non-digits from distance and OS co-ords.
609    foreach my $param ( qw( os_x os_y osie_x osie_y
610                            osie_dist os_dist latlong_dist ) ) {
611        if ( defined $vars{$param} ) {
612            $vars{$param} =~ s/[^0-9]//g;
613            # 0 is an allowed value but the empty string isn't.
614            delete $vars{$param} if $vars{$param} eq "";
615        }
616    }
617
618    # Latitude and longitude are also allowed '-' and '.'
619    foreach my $param( qw( latitude longitude ) ) {
620        if ( defined $vars{$param} ) {
621            $vars{$param} =~ s/[^-\.0-9]//g;
622            # 0 is an allowed value but the empty string isn't.
623            delete $vars{$param} if $vars{$param} eq "";
624        }
625    }
626
627    # Set $self->{distance_in_metres}, $self->{x}, $self->{y},
628    # depending on whether we got
629    # OS co-ords or lat/long.  Only store parameters if they're complete,
630    # and supported by our method of distance calculation.
631    if ( defined $vars{os_x} && defined $vars{os_y} && defined $vars{os_dist}
632         && $self->config->geo_handler eq 1 ) {
633        $self->{x} = $vars{os_x};
634        $self->{y} = $vars{os_y};
635        $self->{distance_in_metres} = $vars{os_dist};
636    } elsif ( defined $vars{osie_x} && defined $vars{osie_y}
637         && defined $vars{osie_dist}
638         && $self->config->geo_handler eq 2 ) {
639        $self->{x} = $vars{osie_x};
640        $self->{y} = $vars{osie_y};
641        $self->{distance_in_metres} = $vars{osie_dist};
642    } elsif ( defined $vars{latitude} && defined $vars{longitude}
643              && defined $vars{latlong_dist} ) {
644        # All handlers can do lat/long, but they all do it differently.
645        if ( $self->config->geo_handler eq 1 ) {
646            require Geography::NationalGrid::GB;
647            my $point = Geography::NationalGrid::GB->new(
648                Latitude  => $vars{latitude},
649                Longitude => $vars{longitude},
650            );
651            $self->{x} = $point->easting;
652            $self->{y} = $point->northing;
653        } elsif ( $self->config->geo_handler eq 2 ) {
654            require Geography::NationalGrid::IE;
655            my $point = Geography::NationalGrid::IE->new(
656                Latitude  => $vars{latitude},
657                Longitude => $vars{longitude},
658            );
659            $self->{x} = $point->easting;
660            $self->{y} = $point->northing;
661        } elsif ( $self->config->geo_handler eq 3 ) {
662            require Geo::Coordinates::UTM;
663            my ($zone, $x, $y) = Geo::Coordinates::UTM::latlon_to_utm(
664                                                $self->config->ellipsoid,
665                                                $vars{latitude},
666                                                $vars{longitude},
667                                              );
668            $self->{x} = $x;
669            $self->{y} = $y;
670        }
671        $self->{distance_in_metres} = $vars{latlong_dist};
672    }
673
674    # Store os_x etc so we can pass them to template.
675    foreach my $param ( qw( os_x os_y osie_x osie_y latitude longitude ) ) {
676        $self->{$param} = $vars{$param};
677    }
678
679    # Strip leading and trailing whitespace from search text.
680    $vars{search} ||= ""; # avoid uninitialised value warning
681    $vars{search} =~ s/^\s*//;
682    $vars{search} =~ s/\s*$//;
683
684    # Check for only valid characters in tainted search param
685    # (quoted literals are OK, as they are escaped)
686    # This regex copied verbatim from Ivor's old supersearch.
687    if ( $vars{search}
688         && $vars{search} !~ /^("[^"]*"|[\w \-',()!*%\[\]])+$/i) {
689        $self->{error} = "Search expression $vars{search} contains invalid character(s)";
690        return $self;
691    }
692    $self->{search_string} = $vars{search};
693
694    return $self;
695}
696
697# thin wrapper around OpenGuides::Template, or OpenGuides::Feed
698sub process_template {
699    my ($self, %args) = @_;
700
701    my $tt_vars = $args{tt_vars} || {};
702    $tt_vars->{not_editable} = 1;
703    $tt_vars->{not_deletable} = 1;
704    return %$tt_vars if $self->{return_tt_vars};
705
706    # Do we want a feed, or TT html?
707    my $output;
708    if($tt_vars->{'format'}) {
709        my $format = $tt_vars->{'format'};
710        my @nodes = @{$tt_vars->{'results'}};
711
712        my $feed = OpenGuides::Feed->new(
713                                               wiki       => $self->wiki,
714                                               config     => $self->config,
715                                               og_version => $VERSION,
716                                        );
717        $output  = "Content-Type: ".$feed->default_content_type($format)."\n";
718        $output .= $feed->build_mini_feed_for_nodes($format,@nodes);
719    } else {
720        $output =  OpenGuides::Template->output(
721                                                wiki     => $self->wiki,
722                                                config   => $self->config,
723                                                template => "search.tt",
724                                                vars     => $tt_vars,
725                                              );
726    }
727
728    return $output if $self->{return_output};
729
730    print $output;
731    return 1;
732}
733
734=head1 OUTPUT
735
736Results will be put into some form of relevance ordering.  These are
737the rules we have tests for so far (and hence the only rules that can
738be relied on):
739
740=over
741
742=item *
743
744A match on page title will score higher than a match on page category
745or locale.
746
747=item *
748
749A match on page category or locale will score higher than a match on
750page content.
751
752=item *
753
754Two matches in the title beats one match in the title and one in the content.
755
756=back
757
758=cut
759
760=head1 AUTHOR
761
762The OpenGuides Project (openguides-dev@openguides.org)
763
764=head1 COPYRIGHT
765
766     Copyright (C) 2003-2004 The OpenGuides Project.  All Rights Reserved.
767
768The OpenGuides distribution is free software; you can redistribute it
769and/or modify it under the same terms as Perl itself.
770
771=head1 SEE ALSO
772
773L<OpenGuides>
774
775=cut
776
7771;
Note: See TracBrowser for help on using the repository browser.