source: trunk/lib/OpenGuides.pm @ 539

Last change on this file since 539 was 539, checked in by kake, 17 years ago

Bump version number.

  • Property svn:eol-style set to native
  • Property svn:keywords set to Author Date Id Revision
File size: 25.6 KB
Line 
1package OpenGuides;
2use strict;
3
4use Carp "croak";
5use CGI;
6use CGI::Wiki::Plugin::Diff;
7use CGI::Wiki::Plugin::Locator::UK;
8use OpenGuides::CGI;
9use OpenGuides::Template;
10use OpenGuides::Utils;
11use Time::Piece;
12use URI::Escape;
13
14use vars qw( $VERSION );
15
16$VERSION = '0.43';
17
18=head1 NAME
19
20OpenGuides - A complete web application for managing a collaboratively-written guide to a city or town.
21
22=head1 DESCRIPTION
23
24The OpenGuides software provides the framework for a collaboratively-written
25city guide.  It is similar to a wiki but provides somewhat more structured
26data storage allowing you to annotate wiki pages with information such as
27category, location, and much more.  It provides searching facilities
28including "find me everything within a certain distance of this place".
29Every page includes a link to a machine-readable (RDF) version of the page.
30
31=head1 METHODS
32
33=over
34
35=item B<new>
36
37  my $guide = OpenGuides->new( config => $config );
38
39=cut
40
41sub new {
42    my ($class, %args) = @_;
43    my $self = {};
44    bless $self, $class;
45    my $wiki = OpenGuides::Utils->make_wiki_object( config => $args{config} );
46    $self->{wiki} = $wiki;
47    $self->{config} = $args{config};
48    my $locator = CGI::Wiki::Plugin::Locator::UK->new;
49    $wiki->register_plugin( plugin => $locator );
50    $self->{locator} = $locator;
51    my $differ = CGI::Wiki::Plugin::Diff->new;
52    $wiki->register_plugin( plugin => $differ );
53    $self->{differ} = $differ;
54    return $self;
55}
56
57=item B<wiki>
58
59An accessor, returns the underlying L<CGI::Wiki> object.
60
61=cut
62
63sub wiki {
64    my $self = shift;
65    return $self->{wiki};
66}
67
68=item B<config>
69
70An accessor, returns the underlying L<Config::Tiny> object.
71
72=cut
73
74sub config {
75    my $self = shift;
76    return $self->{config};
77}
78
79=item B<locator>
80
81An accessor, returns the underlying L<CGI::Wiki::Plugin::Locator::UK> object.
82
83=cut
84
85sub locator {
86    my $self = shift;
87    return $self->{locator};
88}
89
90=item B<differ>
91
92An accessor, returns the underlying L<CGI::Wiki::Plugin::Diff> object.
93
94=cut
95
96sub differ {
97    my $self = shift;
98    return $self->{differ};
99}
100
101=item B<display_node>
102
103  # Print node to STDOUT.
104  $guide->display_node(
105                        id      => "Calthorpe Arms",
106                        version => 2,
107                      );
108
109  # Or return output as a string (useful for writing tests).
110  $guide->display_node(
111                        id            => "Calthorpe Arms",
112                        return_output => 1,
113                      );
114
115  # Or return the hash of variables that will be passed to the template
116  # (not including those set additionally by OpenGuides::Template).
117  $guide->display_node(
118                        id             => "Calthorpe Arms",
119                        return_tt_vars => 1,
120                      );
121
122If C<version> is omitted then the latest version will be displayed.
123
124=cut
125
126sub display_node {
127    my ($self, %args) = @_;
128    my $return_output = $args{return_output} || 0;
129    my $version = $args{version};
130    my $id = $args{id} || $self->config->{_}->{home_name};
131    my $wiki = $self->wiki;
132    my $config = $self->config;
133
134    my %tt_vars;
135
136    if ( $id =~ /^(Category|Locale) (.*)$/ ) {
137        my $type = $1;
138        $tt_vars{is_indexable_node} = 1;
139        $tt_vars{index_type} = lc($type);
140        $tt_vars{index_value} = $2;
141        $tt_vars{"rss_".lc($type)."_url"} =
142                           $config->{_}{script_name} . "?action=rss;"
143                           . lc($type) . "=" . lc(CGI->escape($2));
144    }
145
146    my %current_data = $wiki->retrieve_node( $id );
147    my $current_version = $current_data{version};
148    undef $version if ($version && $version == $current_version);
149    my %criteria = ( name => $id );
150    $criteria{version} = $version if $version;#retrieve_node default is current
151
152    my %node_data = $wiki->retrieve_node( %criteria );
153    my $raw = $node_data{content};
154    if ( $raw =~ /^#REDIRECT\s+(.+?)\s*$/ ) {
155        my $redirect = $1;
156        # Strip off enclosing [[ ]] in case this is an extended link.
157        $redirect =~ s/^\[\[//;
158        $redirect =~ s/\]\]\s*$//;
159        # See if this is a valid node, if not then just show the page as-is.
160        if ( $wiki->node_exists($redirect) ) {
161            my $output = $self->redirect_to_node($redirect);
162            return $output if $return_output;
163            print $output;
164            exit 0;
165        }
166    }
167    my $content    = $wiki->format($raw);
168    my $modified   = $node_data{last_modified};
169    my %metadata   = %{$node_data{metadata}};
170
171    my %metadata_vars = OpenGuides::Template->extract_metadata_vars(
172                            wiki     => $wiki,
173                            config   => $config,
174                            metadata => $node_data{metadata} );
175
176    %tt_vars = (
177                 %tt_vars,
178                 %metadata_vars,
179                 content       => $content,
180                 last_modified => $modified,
181                 version       => $node_data{version},
182                 node          => $id,
183                 language      => $config->{_}->{default_language},
184               );
185
186
187    # We've undef'ed $version above if this is the current version.
188    $tt_vars{current} = 1 unless $version;
189
190    if ($id eq "RecentChanges") {
191        my $minor_edits = $self->get_cookie( "show_minor_edits_in_rc" );
192        my %recent_changes;
193        my $q = CGI->new;
194        my $since = $q->param("since");
195        if ( $since ) {
196            $tt_vars{since} = $since;
197            my $t = localtime($since); # overloaded by Time::Piece
198            $tt_vars{since_string} = $t->strftime;
199            my %criteria = ( since => $since );
200            $criteria{metadata_was} = { edit_type => "Normal edit" }
201              unless $minor_edits;
202            my @rc = $self->{wiki}->list_recent_changes( %criteria );
203
204            @rc = map {
205                {
206                  name        => CGI->escapeHTML($_->{name}),
207                  last_modified => CGI->escapeHTML($_->{last_modified}),
208                  version     => CGI->escapeHTML($_->{version}),
209                  comment     => CGI->escapeHTML($_->{metadata}{comment}[0]),
210                  username    => CGI->escapeHTML($_->{metadata}{username}[0]),
211                  host        => CGI->escapeHTML($_->{metadata}{host}[0]),
212                  username_param => CGI->escape($_->{metadata}{username}[0]),
213                  edit_type   => CGI->escapeHTML($_->{metadata}{edit_type}[0]),
214                  url         => "$config->{_}->{script_name}?"
215          . CGI->escape($wiki->formatter->node_name_to_node_param($_->{name})),
216                }
217                       } @rc;
218            if ( scalar @rc ) {
219                $recent_changes{since} = \@rc;
220            }
221        } else {
222            for my $days ( [0, 1], [1, 7], [7, 14], [14, 30] ) {
223                my %criteria = ( between_days => $days );
224                $criteria{metadata_was} = { edit_type => "Normal edit" }
225                  unless $minor_edits;
226                my @rc = $self->{wiki}->list_recent_changes( %criteria );
227
228                @rc = map {
229                {
230                  name        => CGI->escapeHTML($_->{name}),
231                  last_modified => CGI->escapeHTML($_->{last_modified}),
232                  version     => CGI->escapeHTML($_->{version}),
233                  comment     => CGI->escapeHTML($_->{metadata}{comment}[0]),
234                  username    => CGI->escapeHTML($_->{metadata}{username}[0]),
235                  host        => CGI->escapeHTML($_->{metadata}{host}[0]),
236                  username_param => CGI->escape($_->{metadata}{username}[0]),
237                  edit_type   => CGI->escapeHTML($_->{metadata}{edit_type}[0]),
238                  url         => "$config->{_}->{script_name}?"
239          . CGI->escape($wiki->formatter->node_name_to_node_param($_->{name})),
240                }
241                           } @rc;
242                if ( scalar @rc ) {
243                    $recent_changes{$days->[1]} = \@rc;
244                }
245            }
246        }
247        $tt_vars{recent_changes} = \%recent_changes;
248        my %processing_args = (
249                                id            => $id,
250                                template      => "recent_changes.tt",
251                                tt_vars       => \%tt_vars,
252                               );
253        if ( !$since && $self->get_cookie("track_recent_changes_views") ) {
254            my $cookie =
255               OpenGuides::CGI->make_recent_changes_cookie(config => $config );
256            $processing_args{cookies} = $cookie;
257            $tt_vars{last_viewed} = OpenGuides::CGI->get_last_recent_changes_visit_from_cookie( config => $config );
258        }
259        return %tt_vars if $args{return_tt_vars};
260        my $output = $self->process_template( %processing_args );
261        return $output if $return_output;
262        print $output;
263    } elsif ( $id eq $self->config->{_}->{home_name} ) {
264        my @recent = $wiki->list_recent_changes(
265            last_n_changes => 10,
266            metadata_was   => { edit_type => "Normal edit" },
267        );
268        @recent = map { {name          => CGI->escapeHTML($_->{name}),
269                         last_modified => CGI->escapeHTML($_->{last_modified}),
270                         comment       => CGI->escapeHTML($_->{metadata}{comment}[0]),
271                         username      => CGI->escapeHTML($_->{metadata}{username}[0]),
272                         url           => "$config->{_}->{script_name}?"
273          . CGI->escape($wiki->formatter->node_name_to_node_param($_->{name})) }
274                       } @recent;
275        $tt_vars{recent_changes} = \@recent;
276        return %tt_vars if $args{return_tt_vars};
277        my $output = $self->process_template(
278                                              id            => $id,
279                                              template      => "home_node.tt",
280                                              tt_vars       => \%tt_vars,
281                                            );
282        return $output if $return_output;
283        print $output;
284    } else {
285        return %tt_vars if $args{return_tt_vars};
286        my $output = $self->process_template(
287                                              id            => $id,
288                                              template      => "node.tt",
289                                              tt_vars       => \%tt_vars,
290                                            );
291        return $output if $return_output;
292        print $output;
293    }
294}
295
296=item B<display_diffs>
297
298  $guide->display_diffs(
299                         id            => "Home Page",
300                         version       => 6,
301                         other_version => 5,
302                       );
303
304  # Or return output as a string (useful for writing tests).
305  my $output = $guide->display_diffs(
306                                      id            => "Home Page",
307                                      version       => 6,
308                                      other_version => 5,
309                                      return_output => 1,
310                                    );
311
312  # Or return the hash of variables that will be passed to the template
313  # (not including those set additionally by OpenGuides::Template).
314  my %vars = $guide->display_diffs(
315                                    id             => "Home Page",
316                                    version        => 6,
317                                    other_version  => 5,
318                                    return_tt_vars => 1,
319                                  );
320
321=cut
322
323sub display_diffs {
324    my ($self, %args) = @_;
325    my %diff_vars = $self->differ->differences(
326                                        node          => $args{id},
327                                        left_version  => $args{version},
328                                        right_version => $args{other_version},
329                                              );
330    $diff_vars{not_deletable} = 1;
331    $diff_vars{not_editable} = 1;
332    $diff_vars{deter_robots} = 1;
333    return %diff_vars if $args{return_tt_vars};
334    my $output = $self->process_template(
335                                          id       => $args{id},
336                                          template => "differences.tt",
337                                          tt_vars  => \%diff_vars
338                                        );
339    return $output if $args{return_output};
340    print $output;
341}
342
343=item B<find_within_distance>
344
345  $guide->find_within_distance(
346                                id => $node,
347                                metres => $q->param("distance_in_metres")
348                              );
349
350=cut
351
352sub find_within_distance {
353    my ($self, %args) = @_;
354    my $node = $args{id};
355    my $metres = $args{metres};
356    my %data = $self->wiki->retrieve_node( $node );
357    my $lat = $data{metadata}{latitude}[0];
358    my $long = $data{metadata}{longitude}[0];
359    my $script_url = $self->config->{_}{script_url};
360    print CGI->redirect( $script_url . "supersearch.cgi?lat=$lat;long=$long;distance_in_metres=$metres" );
361}
362
363=item B<show_index>
364
365  $guide->show_index(
366                      type   => "category",
367                      value  => "pubs",
368                    );
369
370  # RDF version.
371  $guide->show_index(
372                      type   => "locale",
373                      value  => "Holborn",
374                      format => "rdf",
375                    );
376
377  # Or return output as a string (useful for writing tests).
378  $guide->show_index(
379                      type          => "category",
380                      value         => "pubs",
381                      return_output => 1,
382                    );
383
384=cut
385
386sub show_index {
387    my ($self, %args) = @_;
388    my $wiki = $self->wiki;
389    my $formatter = $wiki->formatter;
390    my %tt_vars;
391    my @selnodes;
392
393    if ( $args{type} and $args{value} ) {
394        if ( $args{type} eq "fuzzy_title_match" ) {
395            my %finds = $wiki->fuzzy_title_match( $args{value} );
396            @selnodes = sort { $finds{$a} <=> $finds{$b} } keys %finds;
397            $tt_vars{criterion} = {
398                type  => $args{type},  # for RDF version
399                value => $args{value}, # for RDF version
400                name  => CGI->escapeHTML("Fuzzy Title Match on '$args{value}'")
401            };
402        } else {
403            @selnodes = $wiki->list_nodes_by_metadata(
404                metadata_type  => $args{type},
405                metadata_value => $args{value},
406                ignore_case    => 1,
407            );
408            my $name = ucfirst($args{type}) . " $args{value}" ;
409            my $url = $self->config->{_}->{script_name}
410                      . "?"
411                      . ucfirst( $args{type} )
412                      . "_"
413                      . uri_escape(
414                              $formatter->node_name_to_node_param($args{value})
415                                  );
416            $tt_vars{criterion} = {
417                type  => $args{type},
418                value => $args{value}, # for RDF version
419                name  => CGI->escapeHTML( $name ),
420                url   => $url,
421            };
422        }
423    } else {
424        @selnodes = $wiki->list_all_nodes();
425    }
426
427    my @nodes = map { { name      => $_,
428                        node_data => { $wiki->retrieve_node( name => $_ ) },
429                        param     => $formatter->node_name_to_node_param($_) }
430                    } sort @selnodes;
431
432    $tt_vars{nodes} = \@nodes;
433
434    my ($template, %conf);
435
436    if ( $args{format} and $args{format} eq "rdf" ) {
437        $template = "rdf_index.tt";
438        $conf{content_type} = "text/plain";
439    } else {
440        $template = "site_index.tt";
441    }
442
443    %conf = (
444              %conf,
445              node        => "$args{type} index", # KLUDGE
446              template    => $template,
447              tt_vars     => \%tt_vars,
448    );
449
450    my $output = $self->process_template( %conf );
451    return $output if $args{return_output};
452    print $output;
453}
454
455=item B<list_all_versions>
456
457  $guide->list_all_versions ( id => "Home Page" );
458
459  # Or return output as a string (useful for writing tests).
460  $guide->list_all_versions (
461                              id            => "Home Page",
462                              return_output => 1,
463                            );
464
465  # Or return the hash of variables that will be passed to the template
466  # (not including those set additionally by OpenGuides::Template).
467  $guide->list_all_versions (
468                              id             => "Home Page",
469                              return_tt_vars => 1,
470                            );
471
472=cut
473
474sub list_all_versions {
475    my ($self, %args) = @_;
476    my $return_output = $args{return_output} || 0;
477    my $node = $args{id};
478    my %curr_data = $self->wiki->retrieve_node($node);
479    my $curr_version = $curr_data{version};
480    croak "This is the first version" unless $curr_version > 1;
481    my @history;
482    for my $version ( 1 .. $curr_version ) {
483        my %node_data = $self->wiki->retrieve_node( name    => $node,
484                                                    version => $version );
485        # $node_data{version} will be zero if this version was deleted.
486        push @history, {
487            version  => CGI->escapeHTML( $version ),
488            modified => CGI->escapeHTML( $node_data{last_modified} ),
489            username => CGI->escapeHTML( $node_data{metadata}{username}[0] ),
490            comment  => CGI->escapeHTML( $node_data{metadata}{comment}[0] ),
491                       } if $node_data{version};
492    }
493    @history = reverse @history;
494    my %tt_vars = ( node          => $node,
495                    version       => $curr_version,
496                    not_deletable => 1,
497                    not_editable  => 1,
498                    deter_robots  => 1,
499                    history       => \@history );
500    return %tt_vars if $args{return_tt_vars};
501    my $output = $self->process_template(
502                                          id       => $node,
503                                          template => "node_history.tt",
504                                          tt_vars  => \%tt_vars,
505                                        );
506    return $output if $return_output;
507    print $output;
508}
509
510=item B<commit_node>
511
512  $guide->commit_node(
513                       id      => $node,
514                       cgi_obj => $q,
515                     );
516
517As with other methods, parameters C<return_tt_vars> and
518C<return_output> can be used to return these things instead of
519printing the output to STDOUT.
520
521=cut
522
523sub commit_node {
524    my ($self, %args) = @_;
525    my $node = $args{id};
526    my $q = $args{cgi_obj};
527    my $wiki = $self->wiki;
528    my $config = $self->config;
529
530    my $content  = $q->param("content");
531    $content =~ s/\r\n/\n/gs;
532    my $checksum = $q->param("checksum");
533
534    my %metadata = OpenGuides::Template->extract_metadata_vars(
535        wiki    => $wiki,
536        config  => $config,
537        cgi_obj => $q
538    );
539
540    $metadata{opening_hours_text} = $q->param("hours_text") || "";
541
542    # Check to make sure all the indexable nodes are created
543    foreach my $type (qw(Category Locale)) {
544        my $lctype = lc($type);
545        foreach my $index (@{$metadata{$lctype}}) {
546            $index =~ s/(.*)/\u$1/;
547            my $node = $type . " " . $index;
548            # Uppercase the node name before checking for existence
549            $node =~ s/ (\S+)/ \u$1/g;
550            unless ( $wiki->node_exists($node) ) {
551                my $category = $type eq "Category" ? "Category" : "Locales";
552                $wiki->write_node( $node,
553                                   "\@INDEX_LINK [[$node]]",
554                                   undef,
555                                   { username => "Auto Create",
556                                     comment  => "Auto created $lctype stub page",
557                                     category => $category
558                                   }
559                );
560            }
561        }
562    }
563       
564    foreach my $var ( qw( username comment edit_type ) ) {
565        $metadata{$var} = $q->param($var) || "";
566    }
567    $metadata{host} = $ENV{REMOTE_ADDR};
568
569    # CGI::Wiki::Plugin::RSS::ModWiki wants "major_change" to be set.
570    $metadata{major_change} = ( $metadata{edit_type} eq "Normal edit" )
571                            ? 1
572                            : 0;
573
574    my $written = $wiki->write_node($node, $content, $checksum, \%metadata );
575
576    if ($written) {
577        print $self->redirect_to_node($node);
578    } else {
579        my %node_data = $wiki->retrieve_node($node);
580        my %tt_vars = ( checksum       => $node_data{checksum},
581                        new_content    => $content,
582                        stored_content => $node_data{content} );
583        foreach my $mdvar ( keys %metadata ) {
584            if ($mdvar eq "locales") {
585                $tt_vars{"stored_$mdvar"} = $node_data{metadata}{locale};
586                $tt_vars{"new_$mdvar"}    = $metadata{locale};
587            } elsif ($mdvar eq "categories") {
588                $tt_vars{"stored_$mdvar"} = $node_data{metadata}{category};
589                $tt_vars{"new_$mdvar"}    = $metadata{category};
590            } elsif ($mdvar eq "username" or $mdvar eq "comment"
591                      or $mdvar eq "edit_type" ) {
592                $tt_vars{$mdvar} = $metadata{$mdvar};
593            } else {
594                $tt_vars{"stored_$mdvar"} = $node_data{metadata}{$mdvar}[0];
595                $tt_vars{"new_$mdvar"}    = $metadata{$mdvar};
596            }
597        }
598        return %tt_vars if $args{return_tt_vars};
599        my $output = $self->process_template(
600                                              id       => $node,
601                                              template => "edit_conflict.tt",
602                                              tt_vars  => \%tt_vars,
603                                            );
604        return $output if $args{return_output};
605        print $output;
606    }
607}
608
609
610=item B<delete_node>
611
612  $guide->delete_node(
613                       id       => "FAQ",
614                       version  => 15,
615                       password => "beer",
616                     );
617
618C<version> is optional - if it isn't supplied then all versions of the
619node will be deleted; in other words the node will be entirely
620removed.
621
622If C<password> is not supplied then a form for entering the password
623will be displayed.
624
625=cut
626
627sub delete_node {
628    my ($self, %args) = @_;
629    my $node = $args{id} or croak "No node ID supplied for deletion";
630
631    my %tt_vars = (
632                    not_editable  => 1,
633                    not_deletable => 1,
634                    deter_robots  => 1,
635                  );
636    $tt_vars{delete_version} = $args{version} || "";
637
638    my $password = $args{password};
639
640    if ($password) {
641        if ($password ne $self->config->{_}->{admin_pass}) {
642            print $self->process_template(
643                                     id       => $node,
644                                     template => "delete_password_wrong.tt",
645                                     tt_vars  => \%tt_vars,
646                                   );
647        } else {
648            $self->wiki->delete_node(
649                                      name    => $node,
650                                      version => $args{version},
651                                    );
652            # Check whether any versions of this node remain.
653            my %check = $self->wiki->retrieve_node( name => $node );
654            $tt_vars{other_versions_remain} = 1 if $check{version};
655            print $self->process_template(
656                                     id       => $node,
657                                     template => "delete_done.tt",
658                                     tt_vars  => \%tt_vars,
659                                   );
660        }
661    } else {
662        print $self->process_template(
663                                 id       => $node,
664                                 template => "delete_confirm.tt",
665                                 tt_vars  => \%tt_vars,
666                               );
667    }
668}
669
670sub process_template {
671    my ($self, %args) = @_;
672    my %output_conf = ( wiki     => $self->wiki,
673                        config   => $self->config,
674                        node     => $args{id},
675                        template => $args{template},
676                        vars     => $args{tt_vars},
677                        cookies  => $args{cookies},
678    );
679    if ( $args{content_type} ) {
680        $output_conf{content_type} = "";
681        my $output = "Content-Type: $args{content_type}\n\n"
682                     . OpenGuides::Template->output( %output_conf );
683    } else {
684        return OpenGuides::Template->output( %output_conf );
685    }
686}
687
688sub redirect_to_node {
689    my ($self, $node) = @_;
690    my $script_url = $self->config->{_}->{script_url};
691    my $script_name = $self->config->{_}->{script_name};
692    my $formatter = $self->wiki->formatter;
693    my $param = $formatter->node_name_to_node_param( $node );
694    return CGI->redirect( "$script_url$script_name?$param" );
695}
696
697sub get_cookie {
698    my $self = shift;
699    my $config = $self->config;
700    my $pref_name = shift or return "";
701    my %cookie_data = OpenGuides::CGI->get_prefs_from_cookie(config=>$config);
702    return $cookie_data{$pref_name};
703}
704
705
706=back
707
708=head1 BUGS AND CAVEATS
709
710At the moment, the location data uses a United-Kingdom-specific module,
711so the location features might not work so well outside the UK.
712
713=head1 SEE ALSO
714
715=over 4
716
717=item * L<http://london.openguides.org/|The Open Guide to London>, the first and biggest OpenGuides site.
718
719=item * L<http://openguides.org/|The OpenGuides website>, with a list of all live OpenGuides installs.
720
721=item * L<CGI::Wiki>, the Wiki toolkit which does the heavy lifting for OpenGuides
722
723=back
724
725=head1 FEEDBACK
726
727If you have a question, a bug report, or a patch, or you're interested
728in joining the development team, please contact openguides-dev@openguides.org
729(moderated mailing list, will reach all current developers but you'll have
730to wait for your post to be approved) or kake@earth.li (a real person who
731may take a little while to reply to your mail if she's busy).
732
733=head1 AUTHOR
734
735The OpenGuides Project (openguides-dev@openguides.org)
736
737=head1 COPYRIGHT
738
739     Copyright (C) 2003-4 The OpenGuides Project.  All Rights Reserved.
740
741The OpenGuides distribution is free software; you can redistribute it
742and/or modify it under the same terms as Perl itself.
743
744=head1 CREDITS
745
746Programming by Dominic Hargreaves, Earle Martin, Kake Pugh, and Ivor
747Williams.  Testing and bug reporting by Billy Abbott, Jody Belka,
748Kerry Bosworth, Simon Cozens, Cal Henderson, Steve Jolly, and Bob
749Walker (among others).  Much of the Module::Build stuff copied from
750the Siesta project L<http://siesta.unixbeard.net/>
751
752=cut
753
7541;
Note: See TracBrowser for help on using the repository browser.