Baseline - Launch of backpan.org
wreis [Mon, 11 Oct 2010 20:13:14 +0000 (17:13 -0300)]
27 files changed:
Changes [new file with mode: 0644]
MANIFEST [new file with mode: 0644]
Makefile.PL [new file with mode: 0644]
README [new file with mode: 0644]
lib/BackPAN/Index.pm [new file with mode: 0644]
lib/BackPAN/Index/Dist.pm [new file with mode: 0644]
lib/BackPAN/Index/File.pm [new file with mode: 0644]
lib/BackPAN/Index/Release.pm [new file with mode: 0644]
lib/BackPAN/Index/Role/AsHash.pm [new file with mode: 0644]
lib/BackPAN/Index/Schema.pm [new file with mode: 0644]
lib/BackPAN/Web.pm [new file with mode: 0644]
lib/Parse/BACKPAN/Packages.pm [new file with mode: 0644]
log/log.conf [new file with mode: 0644]
root/html/about.html [new file with mode: 0644]
root/html/dist.html [new file with mode: 0644]
root/html/error_404.html [new file with mode: 0644]
root/html/error_500.html [new file with mode: 0644]
root/html/index.html [new file with mode: 0644]
root/html/layout.html [new file with mode: 0644]
root/html/listing.html [new file with mode: 0644]
root/static/arrow-down.gif [new file with mode: 0644]
root/static/arrow-up.gif [new file with mode: 0644]
root/static/main.css [new file with mode: 0644]
root/static/searchbtn.png [new file with mode: 0644]
script/update_index.pl [new file with mode: 0644]
t/00-load.t [new file with mode: 0644]
t/01-leaktrace.t [new file with mode: 0644]

diff --git a/Changes b/Changes
new file mode 100644 (file)
index 0000000..126540c
--- /dev/null
+++ b/Changes
@@ -0,0 +1,4 @@
+Revision history for BackPAN
+
+0.01
+        First version, released on an unsuspecting world.
diff --git a/MANIFEST b/MANIFEST
new file mode 100644 (file)
index 0000000..2026837
--- /dev/null
+++ b/MANIFEST
@@ -0,0 +1,27 @@
+Changes
+inc/Module/AutoInstall.pm
+inc/Module/Install.pm
+inc/Module/Install/AutoInstall.pm
+inc/Module/Install/Base.pm
+inc/Module/Install/Can.pm
+inc/Module/Install/Fetch.pm
+inc/Module/Install/Include.pm
+inc/Module/Install/Makefile.pm
+inc/Module/Install/Metadata.pm
+inc/Module/Install/Win32.pm
+inc/Module/Install/WriteAll.pm
+lib/BackPAN/Web.pm
+Makefile.PL
+MANIFEST
+META.yml
+README
+root/about.html
+root/listing.html
+root/static/main.css
+script/backpan.psgi
+script/update_index.pl
+t/00-load.t
+t/boilerplate.t
+t/manifest.t
+t/pod-coverage.t
+t/pod.t
diff --git a/Makefile.PL b/Makefile.PL
new file mode 100644 (file)
index 0000000..b4fc923
--- /dev/null
@@ -0,0 +1,35 @@
+use inc::Module::Install;
+
+name     'BackPAN';
+all_from 'lib/BackPAN/Web.pm';
+author   q{Wallace Reis <wreis@cpan.org>};
+license  'perl';
+
+test_requires 'Test::More';
+
+requires 'parent';
+requires 'CLASS' => '1.00';
+requires 'DBD::SQLite' => '1.25';
+requires 'App::Cache' => '0.37';
+requires 'Class::Accessor::Fast';
+requires 'CPAN::DistnameInfo' => '0.09';
+requires 'Archive::Extract';
+requires 'autodie';
+requires 'Path::Class' => '0.17';
+requires 'LWP::Simple';
+requires 'DBIx::Class::Schema::Loader' => '0.05003';
+requires 'DBIx::Class' => '0.08109';
+requires 'Plack' => '0.9910';
+requires 'Plack::Middleware::Debug';
+requires 'Web::Simple';
+requires 'HTML::Zoom';
+requires 'Template::Tiny';
+requires 'Data::Page::FlickrLike';
+requires 'DateTime';
+requires 'Log::Log4perl';
+requires 'Log::Dispatch';
+requires 'Mail::Send';
+
+auto_install;
+
+WriteAll;
diff --git a/README b/README
new file mode 100644 (file)
index 0000000..9e67397
--- /dev/null
+++ b/README
@@ -0,0 +1,20 @@
+BackPAN
+
+INSTALLATION
+
+To install this module, run the following commands:
+
+       perl Makefile.PL
+       make
+       make test
+       make install
+
+LICENSE AND COPYRIGHT
+
+Copyright (C) 2010 Wallace Reis
+
+This program is free software; you can redistribute it and/or modify it
+under the terms of either: the GNU General Public License as published
+by the Free Software Foundation; or the Artistic License.
+
+See http://dev.perl.org/licenses/ for more information.
diff --git a/lib/BackPAN/Index.pm b/lib/BackPAN/Index.pm
new file mode 100644 (file)
index 0000000..faf439a
--- /dev/null
@@ -0,0 +1,673 @@
+package BackPAN::Index;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.39';
+
+use autodie;
+use App::Cache 0.37;
+use CPAN::DistnameInfo 0.09;
+use LWP::Simple qw(getstore head is_success);
+use Archive::Extract;
+use Path::Class ();
+use File::stat;
+use BackPAN::Index::Schema;
+
+use parent qw( Class::Accessor::Fast );
+
+__PACKAGE__->mk_accessors(qw(
+    update
+    cache_ttl
+    debug
+    releases_only_from_authors
+    cache_dir
+    backpan_index_url
+
+    backpan_index schema cache 
+));
+
+my %Defaults = (
+    backpan_index_url           => "http://www.astray.com/tmp/backpan.txt.gz",
+    releases_only_from_authors  => 1,
+    debug                       => 0,
+    cache_ttl                   => 60 * 60,
+);
+
+
+sub new {
+    my $class   = shift;
+    my $options = shift;
+
+    $options ||= {};
+
+    # Apply defaults
+    %$options = ( %Defaults, %$options );
+
+    my $self  = $class->SUPER::new($options);
+
+    my %cache_opts;
+    $cache_opts{ttl}       = $self->cache_ttl;
+    $cache_opts{directory} = $self->cache_dir if $self->cache_dir;
+    $cache_opts{enabled}   = !$self->update;
+
+    my $cache = App::Cache->new( \%cache_opts );
+    $self->cache($cache);
+
+    $self->_update_database();
+
+    return $self;
+}
+
+sub _dbh {
+    my $self = shift;
+    return $self->schema->storage->dbh;
+}
+
+sub _log {
+    my $self = shift;
+    return unless $self->debug;
+    print STDERR @_, "\n";
+}
+
+sub _update_database {
+    my $self = shift;
+
+    my $cache = $self->cache;
+    my $db_file = Path::Class::file($cache->directory, "backpan.sqlite");
+
+    my $should_update_db;
+    if( ! -e $db_file ) {
+        $should_update_db = 1;
+    }
+    elsif( defined $self->update ) {
+        $should_update_db = $self->update;
+    }
+    else {
+        # Check the database file before we connect to it.  Connecting will create
+        # the file.
+        # XXX Should probably just put a timestamp in the DB
+        my $db_mtime = $db_file->stat->mtime;
+        my $db_age = time - $db_mtime;
+        $should_update_db = ($db_age > $cache->ttl);
+
+        # No matter what, update the DB if we got a new index file.
+        my $archive_mtime = -e $self->_backpan_index_archive ? $self->_backpan_index_archive->stat->mtime : 0;
+        $should_update_db = 1 if $db_mtime < $archive_mtime;
+    }
+
+    unlink $db_file if -e $db_file and $should_update_db;
+
+    $self->schema( BackPAN::Index::Schema->connect("dbi:SQLite:dbname=$db_file") );
+    return unless $should_update_db;
+
+    # Delay loading it into memory until we need it
+    $self->_log("Fetching BackPAN index...");
+    $self->_get_backpan_index;
+    $self->_log("Done.");
+
+    $self->_setup_database;
+
+    $should_update_db = 1 if $self->_database_is_empty;
+
+    return unless $should_update_db;
+
+    my $dbh = $self->_dbh;
+
+    $self->_log("Populating database...");
+    $dbh->begin_work;
+
+    # Get it out of the hot loop.
+    my $only_authors = $self->releases_only_from_authors;
+
+    my $insert_file_sth = $dbh->prepare(q[
+        INSERT INTO files
+               (path, date, size)
+        VALUES (?,      ?,    ?   )
+    ]);
+
+    my $insert_release_sth = $dbh->prepare(q[
+        INSERT INTO releases
+               (path, dist, version, date, size, maturity, cpanid, distvname)
+        VALUES (?,    ?,    ?,       ?,    ?,    ?,        ?,      ?        )
+    ]);
+
+    my $insert_dist_sth = $dbh->prepare(q[
+        INSERT INTO dists
+               (name, num_releases,
+                first_release,  first_date,  first_author,
+                latest_release, latest_date, latest_author)
+        VALUES (?,    ?,
+                ?,              ?,           ?,
+                ?,              ?,           ?)
+    ]);
+
+    my %dists;
+    my %files;
+    open my $fh, $self->_backpan_index_file;
+    while( my $line = <$fh> ) {
+        chomp $line;
+        my ( $path, $date, $size, @junk ) = split ' ', $line;
+
+        if( $files{$path}++ ) {
+            $self->_log("Duplicate file $path in index, ignoring");
+            next;
+        }
+
+        if( !defined $path or !defined $date or !defined $size or @junk ) {
+            $self->_log("Bad data read at line $.: $line");
+            next;
+        }
+
+        next unless $size;
+        next if $only_authors and $path !~ m{^authors/};
+
+        $insert_file_sth->execute($path, $date, $size);
+
+        next if $path =~ /\.(readme|meta)$/;
+
+        my $i = CPAN::DistnameInfo->new( $path );
+
+        my $dist = $i->dist;
+        next unless $i->dist;
+
+        $insert_release_sth->execute(
+            $path,
+            $dist,
+            $i->version || '',
+            $date,
+            $size,
+            $i->maturity,
+            $i->cpanid,
+            $i->distvname,
+        );
+
+
+        # Update aggregate data about dists
+        my $distdata = ($dists{$dist} ||= { name => $dist });
+
+        if( !defined $distdata->{first_release} ||
+            $date < $distdata->{first_date} )
+        {
+            $distdata->{first_release} = $path;
+            $distdata->{first_author}  = $i->cpanid;
+            $distdata->{first_date}    = $date;
+        }
+
+        if( !defined $distdata->{latest_release} ||
+            $date > $distdata->{latest_date} )
+        {
+            $distdata->{latest_release} = $path;
+            $distdata->{latest_author}  = $i->cpanid;
+            $distdata->{latest_date}    = $date;
+        }
+
+        $distdata->{num_releases}++;
+    }
+
+    for my $dist (values %dists) {
+        $insert_dist_sth->execute(
+            @{$dist}
+              {qw(name num_releases
+                  first_release  first_date  first_author
+                  latest_release latest_date latest_author
+              )}
+        );
+    }
+
+    # Add indexes after inserting so as not to slow down the inserts
+    $self->_add_indexes;
+
+    $dbh->commit;
+
+    $self->_log("Done.");
+
+    return;
+}
+
+
+sub _database_is_empty {
+    my $self = shift;
+
+    return 1 unless $self->files->count;
+    return 1 unless $self->releases->count;
+    return 0;
+}
+
+
+# This is denormalized for performance, its read-only anyway
+sub _setup_database {
+    my $self = shift;
+
+    my %create_for = (
+        files           => <<'SQL',
+CREATE TABLE IF NOT EXISTS files (
+    path        TEXT            NOT NULL PRIMARY KEY,
+    date        INTEGER         NOT NULL,
+    size        INTEGER         NOT NULL CHECK ( size >= 0 )
+)
+SQL
+        releases        => <<'SQL',
+CREATE TABLE IF NOT EXISTS releases (
+    path        TEXT            NOT NULL PRIMARY KEY REFERENCES files,
+    dist        TEXT            NOT NULL REFERENCES dists,
+    date        INTEGER         NOT NULL,
+    size        TEXT            NOT NULL,
+    version     TEXT            NOT NULL,
+    maturity    TEXT            NOT NULL,
+    distvname   TEXT            NOT NULL,
+    cpanid      TEXT            NOT NULL
+)
+SQL
+
+        dists           => <<'SQL',
+CREATE TABLE IF NOT EXISTS dists (
+    name                TEXT            NOT NULL PRIMARY KEY,
+    first_release       TEXT            NOT NULL REFERENCES releases,
+    latest_release      TEXT            NOT NULL REFERENCES releases,
+    first_date          INTEGER         NOT NULL,
+    latest_date         INTEGER         NOT NULL,
+    first_author        TEXT            NOT NULL,
+    latest_author       TEXT            NOT NULL,
+    num_releases        INTEGER         NOT NULL
+)
+SQL
+);
+
+    my $dbh = $self->_dbh;
+    for my $sql (values %create_for) {
+        $dbh->do($sql);
+    }
+
+    $self->schema->rescan;
+
+    return;
+}
+
+
+sub _add_indexes {
+    my $self = shift;
+
+    my @indexes = (
+        # Speed up dists_by several orders of magnitude
+        "CREATE INDEX IF NOT EXISTS dists_by ON releases (cpanid, dist)",
+
+        # Speed up files_by a lot
+        "CREATE INDEX IF NOT EXISTS files_by ON releases (cpanid, path)",
+
+        # Let us order releases by date quickly
+        "CREATE INDEX IF NOT EXISTS releases_by_date ON releases (date, dist)",
+    );
+    my $dbh = $self->_dbh;
+    for my $sql (@indexes) {
+        $dbh->do($sql);
+    }
+}
+
+
+sub _get_backpan_index {
+    my $self = shift;
+    
+    my $url = $self->backpan_index_url;
+
+    return if !$self->_backpan_index_has_changed;
+
+    my $status = getstore($url, $self->_backpan_index_archive.'');
+    die "Error fetching $url: $status" unless is_success($status);
+
+    # Faster
+    local $Archive::Extract::PREFER_BIN = 1;
+
+    # Archive::Extract is vulnerable to the ORS.
+    local $\;
+
+    my $ae = Archive::Extract->new( archive => $self->_backpan_index_archive );
+    $ae->extract( to => $self->_backpan_index_file )
+      or die "Problem extracting @{[ $self->_backpan_index_archive ]}: @{[ $ae->error ]}";
+
+    # If the backpan index age is older than the TTL this prevents us
+    # from immediately looking again.
+    # XXX Should probably use a "last checked" semaphore file
+    $self->_backpan_index_file->touch;
+
+    return;
+}
+
+
+sub _backpan_index_archive {
+    my $self = shift;
+
+    my $file = URI->new($self->backpan_index_url)->path;
+    $file = Path::Class::file($file)->basename;
+    return Path::Class::file($file)->absolute($self->cache->directory);
+}
+
+
+sub _backpan_index_file {
+    my $self = shift;
+
+    my $file = $self->_backpan_index_archive;
+    $file =~ s{\.[^.]+$}{};
+
+    return Path::Class::file($file);
+}
+
+
+sub _backpan_index_has_changed {
+    my $self = shift;
+
+    my $file = $self->_backpan_index_file;
+    return 1 unless -e $file;
+
+    my $local_mod_time = stat($file)->mtime;
+    my $local_age = time - $local_mod_time;
+    return 0 unless $local_age > $self->cache->ttl;
+
+    # We looked, don't have to look again until the ttl is up.
+    $self->_backpan_index_file->touch;
+
+    my(undef, undef, $remote_mod_time) = head($self->backpan_index_url);
+    return $remote_mod_time > $local_mod_time;
+}
+
+
+sub files {
+    my $self = shift;
+    return $self->schema->resultset('File');
+}
+
+
+sub dist {
+    my($self, $dist) = @_;
+
+    return $self->dists->single({ name => $dist });
+}
+
+
+sub releases {
+    my($self, $dist) = @_;
+
+    return $self->schema->resultset("Release") unless defined $dist;
+    return $self->schema->resultset("Release")->search({ dist => $dist });
+}
+
+
+sub release {
+    my($self, $dist, $version) = @_;
+
+    return $self->releases($dist)->single({ version => $version });
+}
+
+
+sub dists {
+    my $self = shift;
+
+    return $self->schema->resultset("Dist");
+}
+
+
+=head1 NAME
+
+BackPAN::Index - An interface to the BackPAN index
+
+=head1 SYNOPSIS
+
+    use BackPAN::Index;
+    my $backpan = BackPAN::Index->new;
+
+    # These are all DBIx::Class::ResultSet's
+    my $files    = $backpan->files;
+    my $dists    = $backpan->dists;
+    my $releases = $backpan->releases("Acme-Pony");
+
+    # Use DBIx::Class::ResultSet methods on them
+    my $release = $releases->single({ version => '1.23' });
+
+    my $dist = $backpan->dist("Test-Simple");
+    my $releases = $dist->releases;
+
+=head1 DESCRIPTION
+
+This downloads, caches and parses the BackPAN index into a local
+database for efficient querying.
+
+Its a pretty thin wrapper around DBIx::Class returning
+L<DBIx::Class::ResultSet> objects which makes it efficient and
+flexible.
+
+The Comprehensive Perl Archive Network (CPAN) is a very useful
+collection of Perl code. However, in order to keep CPAN relatively
+small, authors of modules can delete older versions of modules to only
+let CPAN have the latest version of a module. BackPAN is where these
+deleted modules are backed up. It's more like a full CPAN mirror, only
+without the deletions. This module provides an index of BackPAN and
+some handy methods.
+
+=head1 METHODS
+
+=head2 new
+
+    my $backpan = BackPAN::Index->new(\%options);
+
+Create a new object representing the BackPAN index.
+
+It will, if necessary, download the BackPAN index and compile it into
+a database for efficient storage.  Initial creation is slow, but it
+will be cached.
+
+new() takes some options
+
+=head3 update
+
+Because it is rather large, BackPAN::Index caches a copy of the
+BackPAN index and builds a local database to speed access.  This flag
+controls if the local index is updated.
+
+If true, forces an update of the BACKPAN index.
+
+If false, the index will never be updated even if the cache is
+expired.  It will always create a new index if one does not exist.
+
+By default the index is cached and checked for updates according to
+C<<$backpan->cache_ttl>>.
+
+=head3 cache_ttl
+
+How many seconds before checking for an updated index.
+
+Defaults to an hour.
+
+=head3 debug
+
+If true, debug messages will be printed.
+
+Defaults to false.
+
+=head3 releases_only_from_authors
+
+If true, only files in the C<authors> directory will be considered as
+releases.  If false any file in the index may be considered for a
+release.
+
+Defaults to true.
+
+=head3 cache_dir
+
+Location of the cache directory.
+
+Defaults to whatever L<App::Cache> does.
+
+=head3 backpan_index_url
+
+URL to the BackPAN index.
+
+Defaults to a sensible location.
+
+
+=head2 files
+
+    my $files = $backpan->files;
+
+Returns a ResultSet representing all the files on BackPAN.
+
+=head2 files_by
+
+    my $files = $backpan->files_by($cpanid);
+    my @files = $backpan->files_by($cpanid);
+
+Returns all the files by a given $cpanid.
+
+Returns either a list of BackPAN::Index::Files or a ResultSet.
+
+=cut
+
+sub files_by {
+    my $self = shift;
+    my $cpanid = shift;
+
+    return $self->files->search({ "releases.cpanid" => $cpanid }, { join => "releases" });
+}
+
+=head2 dists
+
+    my $dists = $backpan->dists;
+
+Returns a ResultSet representing all the distributions on BackPAN.
+
+=head2 dist
+
+    my $dists = $backpan->dist($dist_name);
+
+Returns a single BackPAN::Index::Dist object for $dist_name.
+
+=head2 dists_by
+
+    my $dists = $backpan->dists_by($cpanid);
+    my @dists = $backpan->dists_by($cpanid);
+
+Returns the dists which contain at least one release by the given
+$cpanid.
+
+Returns either a ResultSet or a list of the Dists.
+
+=cut
+
+sub dists_by {
+    my $self = shift;
+    my $cpanid = shift;
+
+    return $self->dists->search({ "releases.cpanid" => $cpanid }, { join => "releases", distinct => 1 });
+}
+
+
+=head2 dists_changed_since
+
+    my $dists = $backpan->dists_changed_since($time);
+
+Returns a ResultSet of distributions which have had releases at or after after $time.
+
+=cut
+
+sub dists_changed_since {
+    my $self = shift;
+    my $time = shift;
+
+    return $self->dists->search( latest_date => \">= $time" );
+}
+
+=head2 releases
+
+    my $all_releases  = $backpan->releases();
+    my $dist_releases = $backpan->releases($dist_name);
+
+Returns a ResultSet representing all the releases on BackPAN.  If a
+$dist_name is given it returns the releases of just one distribution.
+
+=head2 release
+
+    my $release = $backpan->release($dist_name, $version);
+
+Returns a single BackPAN::Index::Release object for the given
+$dist_name and $version.
+
+=head2 releases_by
+
+    my $releases = $backpan->releases_by($cpanid);
+    my @releases = $backpan->releases_by($cpanid);
+
+Returns all the releases of a single author.
+
+Returns either a list of Releases or a ResultSet representing those releases.
+
+=cut
+
+sub releases_by {
+    my $self   = shift;
+    my $cpanid = shift;
+
+    return $self->releases->search({ cpanid => $cpanid });
+}
+
+
+=head2 releases_since
+
+    my $releases = $backpan->releases_since($time);
+
+Returns a ResultSet of releases which were released at or after $time.
+
+=cut
+
+sub releases_since {
+    my $self = shift;
+    my $time = shift;
+
+    return $self->releases->search( date => \">= $time" );
+}
+
+
+=head1 EXAMPLES
+
+The real power of BackPAN::Index comes from L<DBIx::Class::ResultSet>.
+Its very flexible and very powerful but not always obvious how to get
+it to do things.  Here's some examples.
+
+    # How many files are on BackPAN?
+    my $count = $backpan->files->count;
+
+    # How big is BackPAN?
+    my $size = $backpan->files->get_column("size")->sum;
+
+    # What are the names of all the distributions?
+    my @names = $backpan->dists->get_column("name")->all;
+
+    # What path contains this release?
+    my $path = $backpan->release("Acme-Pony", 1.01)->path;
+
+    # Get all the releases of Moose ordered by version
+    my @releases = $backpan->dist("Moose")->releases
+                                          ->search(undef, { order_by => "version" });
+
+=head1 AUTHOR
+
+Michael G Schwern <schwern@pobox.com>
+
+=head1 COPYRIGHT
+
+Copyright 2009, Michael G Schwern
+
+=head1 LICENSE
+
+This module is free software; you can redistribute it or modify it under
+the same terms as Perl itself.
+
+=head1 SEE ALSO
+
+L<DBIx::Class::ResultSet>, L<BackPAN::Index::File>,
+L<BackPAN::Index::Release>, L<BackPAN::Index::Dist>
+
+Repository:  L<http://github.com/acme/parse-backpan-packages>
+Bugs:        L<http://rt.cpan.org/Public/Dist/Display.html?Name=Parse-BACKPAN-Packages>
+
+=cut
+
+1;
diff --git a/lib/BackPAN/Index/Dist.pm b/lib/BackPAN/Index/Dist.pm
new file mode 100644 (file)
index 0000000..4dcc706
--- /dev/null
@@ -0,0 +1,105 @@
+package BackPAN::Index::Dist;
+
+use strict;
+use warnings;
+
+use CLASS;
+use BackPAN::Index::Role::AsHash;
+
+use overload
+  q[""]         => sub { $_[0]->name },
+  fallback      => 1;
+
+sub data_methods {
+    return qw(
+        name num_releases
+        first_release  first_date  first_author
+        latest_release latest_date latest_author
+    );
+}
+
+sub authors {
+    my $self = shift;
+
+    return $self->releases->search(undef, { distinct => 1 })->get_column("cpanid")->all;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+BackPAN::Index::Dist - Representing a distribution on BackPAN
+
+=head1 SYNOPSIS
+
+Use through BackPAN::Index.
+
+=head1 DESCRIPTION
+
+An object representing a distribution on BackPAN.  A distribution is
+made up of releases.
+
+=head2 releases
+
+    my $releases = $dist->releases;
+
+A ResultSet of this distribution's releases.
+
+=head2 name
+
+    my $dist_name = $dist->name;
+
+Name of the distribution.
+
+=head2 authors
+
+    my @authors = $dist->authors;
+
+Return the CPANIDs which made releases of this $dist, in no particular order.
+
+=head2 num_releases
+
+    my $num_releases = $dist->num_releases;
+
+Returns the number of releases this distribution has.
+
+=head2 first_release
+
+=head2 latest_release
+
+    my $release = $dist->first_release;
+
+Returns the first or latest release of this distribution as a BackPAN::Index::Release.
+
+=head2 first_date
+
+=head2 latest_date
+
+    my $release = $dist->first_date;
+
+Returns the date of the first or latest release of this distribution.
+
+=head2 first_author
+
+=head2 latest_author
+
+    my $cpanid = $dist->first_author;
+
+Returns the CPANID of the author of the first or latest release.
+
+=head2 as_hash
+
+    my $data = $dist->as_hash;
+
+Returns a hash ref containing the data inside C<$dist>.
+
+
+=head1 SEE ALSO
+
+L<BackPAN::Index>
+
+=cut
+
+1;
diff --git a/lib/BackPAN/Index/File.pm b/lib/BackPAN/Index/File.pm
new file mode 100644 (file)
index 0000000..669c5bb
--- /dev/null
@@ -0,0 +1,127 @@
+package BackPAN::Index::File;
+
+use strict;
+use warnings;
+
+use File::Basename qw(basename);
+
+use overload
+  q[""]         => sub { $_[0]->path },
+  fallback      => 1;
+
+use BackPAN::Index::Role::AsHash;
+
+sub data_methods {
+    return qw(path date size);
+}
+
+sub url {
+    my $self = shift;
+    return "http://backpan.cpan.org/" . $self->path;
+}
+
+sub filename {
+    my $self = shift;
+    return basename $self->path;
+}
+
+# Backwards compatibility with PBP
+sub prefix {
+    my $self = shift;
+    return $self->path;
+}
+
+sub release {
+    my $self = shift;
+
+    my $schema = $self->result_source->schema;
+    my($release) = $schema->resultset("Release")
+                          ->search({ file => $self->path }, { rows => 1 })
+                          ->first;
+
+    return $release;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+BackPAN::Index::File - Represent a file on BackPAN
+
+=head1 SYNOPSIS
+
+  my $b = BackPAN::Index->new();
+  my $file = $b->file("authors/id/L/LB/LBROCARD/Acme-Colour-0.16.tar.gz");
+  print "  Date: " . $file->date . "\n";
+  print "  Path: " . $file->path . "\n";
+  print "  Size: " . $file->size . "\n";
+  print "   URL: " . $file->url . "\n";
+
+=head1 DESCRIPTION
+
+BackPAN::Index::File objects represent files on BackPAN.  It may
+represent a release, a readme or meta file or just some random stuff
+on BackPAN.
+
+=head1 METHODS
+
+=head2 date
+
+    my $date = $file->date;
+
+Returns the upload date of the file, in UNIX epoch seconds.
+
+=head2 path
+
+    my $path = $file->path;
+
+Returns the full path to the file on CPAN.
+
+=head2 size
+
+    my $size = $file->size;
+
+Returns the size of the file in bytes.
+
+=head2 url
+
+    my $url = $file->url;
+
+Returns a URL to the file on a BackPAN mirror.
+
+=head2 filename
+
+    my $filename = $file->filename;
+
+Returns the filename part of the path.
+
+=head2 release
+
+    my $release = $file->release;
+
+Returns the release associated with this file, if any, as a
+L<BackPAN::Index::Release> instance.
+
+=head2 as_hash
+
+    my $data = $file->as_hash;
+
+Returns a hash ref containing the data inside C<$file>.
+
+
+=head1 AUTHOR
+
+Leon Brocard <acme@astray.com>
+
+=head1 COPYRIGHT
+
+Copyright (C) 2005-2009, Leon Brocard
+
+This module is free software; you can redistribute it or modify it under
+the same terms as Perl itself.
+
+=head1 SEE ALSO
+
+L<BackPAN::Index>, L<BackPAN::Index::Release>
diff --git a/lib/BackPAN/Index/Release.pm b/lib/BackPAN/Index/Release.pm
new file mode 100644 (file)
index 0000000..f2fe234
--- /dev/null
@@ -0,0 +1,131 @@
+package BackPAN::Index::Release;
+
+use strict;
+use warnings;
+
+use overload
+  q[""]         => sub { $_[0]->distvname },
+  fallback      => 1;
+
+use BackPAN::Index::Role::AsHash;
+
+sub data_methods {
+    return qw(dist version cpanid date path maturity);
+}
+
+sub filename {
+    my $self = shift;
+    return $self->path->filename;
+}
+
+# Compatibility with PBP
+sub prefix {
+    my $self = shift;
+    return $self->path;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+BackPAN::Index::Release - A single release of a distribution
+
+=head1 SYNOPSIS
+
+  my $b = BackPAN::Index->new();
+
+  # Find version 1.2.3 of Acme-Colour
+  my $release = $b->release("Acme-Colour", '1.2.3');
+
+  print "   CPANID: " . $release->cpanid . "\n";
+  print "     Date: " . $release->date . "\n";
+  print "     Dist: " . $release->dist . "\n";
+  print "Distvname: " . $release->distvname . "\n";
+  print " Filename: " . $release->filename . "\n";
+  print " Maturity: " . $release->maturity . "\n";
+  print "     Path: " . $release->path . "\n";
+  print "  Version: " . $release->version . "\n";
+
+=head1 DESCRIPTION
+
+BackPAN::Index::Release objects represent releases,
+individual tarballs/zip files, of a distribution on BackPAN.
+
+For example, Acme-Pony-1.2.3.tar.gz is a release of the Acme-Pony
+distribution.
+
+=head1 METHODS
+
+=head2 cpanid
+
+    my $cpanid = $release->cpanid;
+
+Returns the PAUSE ID of the author of the release.
+
+=head2 date
+
+    my $date = $release->date;
+
+Returns the date of the release, in UNIX epoch seconds.
+
+=head2 dist
+
+    my $dist_name = $release->dist;
+
+Returns the name of the distribution this release belongs to.
+
+=head2 distvname
+
+    my $distvname = $release->distvname;
+
+Returns the name of the distribution, hyphen, and version.
+
+=head2 filename
+
+    my $filename = $release->filename;
+
+Returns the filename of the release, just the file part.
+
+=head2 maturity
+
+    my $maturity = $release->maturity;
+
+Returns the maturity of the release.
+
+=head2 path
+
+    my $path = $release->path;
+
+Returns the full path on CPAN to the release.  This is a
+L<BackPAN::File> object.
+
+=head2 version
+
+    my $version = $release->version;
+
+Returns the version of the release:
+
+=head2 as_hash
+
+    my $data = $release->as_hash;
+
+Returns a hash ref containing the data inside C<$release>.
+
+
+=head1 AUTHOR
+
+Leon Brocard <acme@astray.com> and Michael G Schwern <schwern@pobox.com>
+
+=head1 COPYRIGHT
+
+Copyright (C) 2005-2009, Leon Brocard
+
+This module is free software; you can redistribute it or modify it under
+the same terms as Perl itself.
+
+=head1 SEE ALSO
+
+L<BackPAN::Index>, L<BackPAN::Index::Dist>, L<BackPAN::Index::File>
+
diff --git a/lib/BackPAN/Index/Role/AsHash.pm b/lib/BackPAN/Index/Role/AsHash.pm
new file mode 100644 (file)
index 0000000..a007216
--- /dev/null
@@ -0,0 +1,60 @@
+package BackPAN::Index::Role::AsHash;
+
+use strict;
+use warnings;
+
+use base 'Exporter';
+our @EXPORT   = qw(as_hash);
+our @REQUIRED = qw(data_methods);
+
+use CLASS;
+
+sub as_hash {
+    my $self = shift;
+
+    my %data;
+    for my $method ($self->data_methods) {
+        $data{$method} = $self->$method;
+    }
+
+    return \%data;
+}
+
+
+=head1 NAME
+
+BackPAN::Index::Role::AsHash - Role to dump object data as a hash
+
+=head1 SYNOPSIS
+
+    use BackPAN::Index::Role::AsHash;
+
+    sub data_methods { return qw(list of data methods) }
+
+=head1 DESCRIPTION
+
+A role to implement C<<as_hash>> in result objects.
+
+=head2 Requires
+
+The receiving class must implement...
+
+=head3 data_methods
+
+    my @methods = $self->data_methods;
+
+Returns a list of methods which get data about the object.
+
+=head2 Implements
+
+=head3 as_hash
+
+    my $hash = $self->as_hash;
+
+Produces a hash reference representing the object's data based on
+C<<$self->data_methods>>.  Each key is a method name, the value is
+C<<$self->$method>>.
+
+=cut
+
+1;
diff --git a/lib/BackPAN/Index/Schema.pm b/lib/BackPAN/Index/Schema.pm
new file mode 100644 (file)
index 0000000..3987af6
--- /dev/null
@@ -0,0 +1,38 @@
+package BackPAN::Index::Schema;
+
+use strict;
+use warnings;
+
+use parent qw(DBIx::Class::Schema::Loader);
+
+use CLASS;
+
+CLASS->loader_options(
+    moniker_map => sub {
+        my $table = shift;
+        my $class = ucfirst $table;
+        $class =~ s/s$//;
+
+        return $class;
+    },
+    result_namespace => '+BackPAN::Index',
+    use_namespaces => 1,
+    naming         => 'current',
+);
+
+
+=head1 NAME
+
+BackPAN::Index::Schema - DBIx::Class schema class
+
+=head1 SYNOPSIS
+
+No user servicable parts inside
+
+=head1 DESCRIPTION
+
+No user servicable parts inside
+
+=cut
+
+1;
diff --git a/lib/BackPAN/Web.pm b/lib/BackPAN/Web.pm
new file mode 100644 (file)
index 0000000..c1062a0
--- /dev/null
@@ -0,0 +1,492 @@
+package BackPAN::Web;
+
+use Web::Simple __PACKAGE__;
+use Plack::Request;
+use Plack::Builder;
+use Plack::Util;
+use HTML::Zoom;
+use HTML::Zoom::FilterBuilder::Template;
+use BackPAN::Index;
+use Data::Page;
+use Data::Page::FlickrLike;
+use File::stat;
+use DateTime;
+use Log::Log4perl 'get_logger';
+
+our $VERSION = '0.12';
+
+default_config(
+    template_dir => 'root/html',
+    backpan_url => 'http://backpan.perl.org/',
+    cpan_search_url => 'http://search.cpan.org/',
+);
+
+sub _build_request_obj_from {
+    my ( $self, $env ) = @_;
+    return $self->request(Plack::Request->new($env));
+}
+
+sub request {
+    my $self = shift;
+    $self->{'request'} = shift if @_;
+    return $self->{'request'};
+}
+
+sub req { return shift->request(@_) }
+
+sub log {
+    my ( $self, $level, $msg ) = @_;
+    chomp $msg;
+    $self->request->{'env'}->{'psgix.logger'}->({
+        level => $level,
+        message => $msg,
+    });
+}
+
+sub backpan_index {
+    return $_[0]->{'backpan_index'} ||= BackPAN::Index->new({
+        update => 0,
+        debug => 0,
+        cache_dir => 'index',
+        releases_only_from_authors => 1,
+    });
+}
+
+sub slurp {
+    my ( $self, $filename ) = @_;
+    return do { local (@ARGV, $/) = $filename; <> };
+}
+
+sub template_filename_for {
+    my ( $self, $name ) = @_;
+    return $self->config->{'template_dir'} . "/${name}.html";
+}
+
+sub layout_zoom {
+    my $self = shift;
+    return $self->{'template_zoom_for_template'}{'layout'} ||= HTML::Zoom->from_file(
+        $self->template_filename_for('layout')
+    );
+}
+
+sub template_zoom_for {
+    my ( $self, $template_name ) = @_;
+    $self->{'template_zoom_for_template'}{$template_name} ||= do {
+        my @body;
+        HTML::Zoom->from_file(
+            $self->template_filename_for($template_name)
+        )->select('#content')->collect_content({ into => \@body })->run;
+        $self->layout_zoom
+        ->select('#content')->replace_content(\@body)
+        ->memoize;
+    };
+}
+
+sub error_404 {
+    my $self = shift;
+    return $self->slurp( $self->template_filename_for('error_404') );
+}
+
+sub html_response {
+    my ( $self, $args ) = @_;
+    my ( $header, $body ) = @$args{qw/header body/};
+    return [ 200, [
+        $header ? ( %$header ) : (),
+        'Content-type' => 'text/html',
+    ], ref $body ? $body->to_fh : [ $body ] ];
+}
+
+sub add_listing {
+    my ( $self, $resultset, $row_data_cb ) = @_;
+    my $req_base = $self->req->base;
+    my $i = 1;
+    return sub {
+        $_->select('.main-list')
+        ->repeat_content([
+            map { my $row = $_;
+                my ( $name, $label, $href ) = $self->$row_data_cb($row);
+                sub {
+                    my $zoom = $_;
+                    $zoom = $zoom->select('li')->add_to_attribute(class => 'even')
+                        if $i++ % 2 == 0;
+                    if ( $href =~ m/http/i ) {
+                        $zoom = $zoom->select('a')->add_to_attribute(href => $href)
+                            ->then
+                            ->add_to_attribute(target => '_blank')
+                            ->then;
+                    }
+                    else {
+                        $zoom = $zoom->select('a')->add_to_attribute(
+                            href => $req_base . "${href}/${name}/"
+                        )->then;
+                    }
+                    $zoom->replace_content($label);
+                }
+            } ( ref $resultset eq 'ARRAY' ? @$resultset : $resultset->all )
+        ]);
+    };
+}
+
+sub add_paging_ordering {
+    my ( $self, $pager, $ordering_options ) = @_;
+    return sub {
+        $_->apply($self->add_paging($pager))
+        ->apply($self->add_ordering($ordering_options));
+    };
+}
+
+sub add_paging {
+    my ( $self, $pager ) = @_;
+    my ( $curr_page, $curr_page_size )
+        = ( $pager->current_page, $pager->entries_per_page );
+    my $paging_uri = $self->req->uri;
+    return sub {
+        $_->select('#pages')
+        ->repeat_content([
+            map { my $page_number = $_;
+                $page_number == 0 ?
+                    sub {
+                        $_->select('span')->replace_content('...');
+                    }
+                    : sub {
+                        $paging_uri->query_form({
+                            $paging_uri->query_form,
+                            page => $page_number,
+                            rows => $curr_page_size,
+                        });
+                        $_->select('a')->add_to_attribute(href => $paging_uri)
+                          ->then
+                          ->replace_content($page_number);
+                    }
+            } $pager->navigations
+        ])
+        ->select('.paging-desc')
+        ->replace_content(
+            join(q{ }, 'Page', $curr_page, 'of', $pager->last_page) . q{.}
+        )
+        ->select('.entries')
+        ->replace_content($pager->total_entries . ' entries.')
+        ->select('.page-size-options')
+        ->repeat_content([
+            map {
+                my $page_size = $_; sub {
+                    $paging_uri->query_form({
+                        $paging_uri->query_form,
+                        page => $curr_page,
+                        rows => $page_size,
+                    });
+                    $_->select('a')->add_to_attribute(href => $paging_uri)
+                      ->then
+                      ->replace_content($page_size);
+                }
+            } qw/10 20 30 50 100 200/
+        ]);
+    };
+}
+
+sub add_ordering {
+    my ( $self, $options ) = @_;
+    my $ordering_uri = $self->req->uri;
+    return sub {
+        $_->select('.ordering-options')
+        ->repeat_content([
+            map { my $order_by = $_; sub {
+                    $ordering_uri->query_form({
+                        $ordering_uri->query_form,
+                        order_by => $order_by,
+                    });
+                    my $order_by_label = join(q{ }, map ucfirst, split(/\_/, $order_by));
+                    $_->select('a')->add_to_attribute(href => $ordering_uri)
+                      ->then
+                      ->replace_content($order_by_label);
+                }
+            } @$options
+        ]);
+    };
+}
+
+sub index_page_content {
+    my $self = shift;
+    return $self->template_zoom_for('index')
+        ->apply($self->add_listing(scalar $self->releases, sub {
+            return ((map { $_[1]->$_ } qw/dist distvname/), 'distribution');
+        }));
+}
+
+sub validate_paging_params {
+    my ( $self, $args ) = @_;
+    my ( $page, $rows ) = @$args{qw/page rows/};
+    $page = 1 unless $page && $page =~ /^\d+$/;
+    $rows = 25 unless $rows && $rows =~ /^\d+$/;
+    return ( $page, $rows );
+}
+
+sub releases {
+    my ( $self, $args ) = @_;
+    my ( $order_by, $page, $rows )
+        = ( $args->{'order_by'}, $self->validate_paging_params($args) );
+    return $self->backpan_index->releases->search({}, {
+        order_by => { -desc => 'date' },
+        page => $page,
+        rows => $rows,
+    });
+}
+
+sub releases_page_content {
+    my ( $self, $release_rs ) = @_;
+    return $self->template_zoom_for('listing')
+        ->apply($self->add_listing($release_rs, sub {
+            return ((map { $_[1]->$_ } qw/dist distvname/), 'distribution');
+        }))
+        ->apply($self->add_paging($release_rs->pager));
+}
+
+sub dists {
+    my ( $self, $args ) = @_;
+    my ( $order_by, $page, $rows )
+        = ( $args->{'order_by'}, $self->validate_paging_params($args) );
+    return $self->backpan_index->dists->search({}, {
+        order_by => 'name',
+        page => $page,
+        rows => $rows,
+    });
+}
+
+sub get_dist { return shift->backpan_index->dist(@_) }
+
+sub format_dist_name { return join(q{::}, split /-/, $_[1] ) }
+
+sub dists_page_content {
+    my ( $self, $dist_rs ) = @_;
+    return $self->template_zoom_for('listing')
+        ->apply($self->add_listing($dist_rs, sub {
+            my $dist_name = $_[1]->name;
+            return (
+                $dist_name, $self->format_dist_name($dist_name), 'distribution'
+            );
+        }))
+        ->apply($self->add_paging($dist_rs->pager));
+}
+
+sub dist_info_page_content {
+    my ( $self, $dist, $query_params ) = @_;
+    my ( $page, $rows ) = $self->validate_paging_params($query_params);
+    my $release_rs = $dist->releases->search({}, {
+        order_by => { -desc => 'date' },
+        page => $page,
+        rows => $rows,
+    });
+    my ( $f_release, $l_release )
+        = ( $dist->first_release, $dist->latest_release );
+    my @maints = $dist->authors;
+    my $config = $self->config;
+    my ( $backpan_url, $cpan_search_url )
+        = ( $config->{'backpan_url'}, $config->{'cpan_search_url'} );
+    return $self->template_zoom_for('dist')
+        ->select('#dist')->template_text_raw({
+            name => $self->format_dist_name($dist->name),
+            num_releases => $dist->num_releases,
+            f_release_label => $f_release->distvname,
+            l_release_label => $l_release->distvname,
+        })
+        ->select('.f_rel_link')->add_to_attribute(
+            href => $backpan_url . $f_release->path->path,
+        )
+        ->select('.l_rel_link')->add_to_attribute(
+            href => $backpan_url . $l_release->path->path,
+        )
+        ->select('.maintainer-list')->repeat_content([
+            map { my $cpanid = $_; sub {
+                    $_->select('a')->add_to_attribute(
+                        href => $cpan_search_url . lc "~${cpanid}"
+                    )->then
+                    ->add_to_attribute(target => '_blank')
+                    ->then
+                    ->replace_content($cpanid);
+                }
+            } @maints
+        ])
+        ->apply($self->add_listing($release_rs, sub {
+            my $release = $_;
+            return (
+                $release->distvname,
+                join(q{ | }, $release->distvname,
+                    DateTime->from_epoch({ epoch => $release->date })
+                        ->strftime('%b %d, %Y - %T')),
+                $backpan_url . $release->path->path,
+            );
+        }))
+        ->apply($self->add_paging($release_rs->pager));
+}
+
+sub authors {
+    my ( $self, $args ) = @_;
+    my ( $page, $rows ) = $self->validate_paging_params($args);
+    my @authors = $self->backpan_index->releases->search({}, {
+        group_by => 'cpanid',
+        order_by => 'cpanid',
+    })->get_column('cpanid')->all;
+    my $pager = Data::Page->new;
+    $pager->total_entries(scalar @authors);
+    $pager->entries_per_page($rows);
+    if ( $page > $pager->last_page ) {
+        return undef;
+    }
+    else {
+        $pager->current_page($page);
+        return {
+            list => [ splice @authors, ($page-1) * $rows, $rows ],
+            pager => $pager,
+        };
+    }
+}
+
+sub authors_page_content {
+    my ( $self, $authors ) = @_;
+    return $self->template_zoom_for('listing')
+        ->apply($self->add_listing($authors->{'list'}, sub {
+            my $cpanid = $_[1];
+            return (
+                $cpanid, $cpanid,
+                $self->config->{'cpan_search_url'} . lc "~${cpanid}"
+            );
+        }))
+        ->apply($self->add_paging($authors->{'pager'}));
+}
+
+sub search {
+    my ( $self, $q, $query_params ) = @_;
+    my $query = lc "%$q%";
+    return $self->dists($query_params)->search({
+        -or => [
+            { 'LOWER(me.name)' => { -like => $query } },
+            { 'LOWER(me.first_author)' => { -like => $query } },
+            { 'LOWER(me.latest_author)' => { -like => $query } },
+        ],
+    });
+}
+
+dispatch {
+    subdispatch sub () {
+        $self->_build_request_obj_from($_[+PSGI_ENV]);
+        my $body;
+        [
+            sub (/) {
+                $self->html_response({ body => $self->index_page_content });
+            },
+
+            sub ( /about|/about/ ) {
+                my $about_filename = $self->template_filename_for('about');
+                my $about_st = stat($about_filename)
+                    or $self->log(error_die => "No $about_filename: $!");
+                $self->html_response({
+                    header => {
+                        'Last-Modified' => $about_st->mtime,
+                    },
+                    body => $self->slurp($about_filename),
+                });
+            },
+
+            sub ( /releases|/releases/ + ?* ) {
+                my $release_rs = $self->releases($_[1]);
+                if ( $release_rs->count ) {
+                    $body = $self->releases_page_content($release_rs)
+                        ->select('#nav-releases')->add_to_attribute(class => 'active');
+                }
+                else {
+                    $body = $self->error_404;
+                }
+                $self->html_response({ body => $body });
+            },
+
+            sub ( /dists|/dists/ + ?* ) {
+                my $dist_rs = $self->dists($_[1]);
+                if ( $dist_rs->count ) {
+                    $body = $self->dists_page_content($dist_rs)
+                        ->select('#nav-dists')->add_to_attribute(class => 'active');
+                }
+                else {
+                    $body = $self->error_404;
+                }
+                $self->html_response({ body => $body });
+            },
+
+            sub ( /distribution/*|/distribution/*/ + ?* ) {
+                if ( my $dist = $self->get_dist($_[1]) ) {
+                    $body = $self->dist_info_page_content($dist, $_[2]);
+                }
+                else {
+                    $body = $self->error_404;
+                }
+                $self->html_response({ body => $body });
+            },
+
+            sub ( /authors|/authors/ + ?* ) {
+                if ( my $authors = $self->authors($_[1]) ) {
+                    $body = $self->authors_page_content($authors)
+                        ->select('#nav-authors')->add_to_attribute(class => 'active');
+                }
+                else {
+                    $body = $self->error_404;
+                }
+                $self->html_response({ body => $body });
+            },
+
+            sub ( /search|/search/ + ?q=&* ) {
+                my $dist_rs = $self->search(@_[1,2]);
+                if ( $dist_rs->count ) {
+                    $body = $self->dists_page_content($dist_rs);
+                }
+                else {
+                    $body = $self->error_404;
+                }
+                $self->html_response({ body => $body });
+            },
+        ],
+    },
+};
+
+sub as_psgi_app {
+    my $class = shift;
+    my $app = $class->SUPER::as_psgi_app;
+    return builder {
+        enable_if { $ENV{PLACK_ENV} ne 'deployment' }
+            sub {
+                my $mw_prefix = 'Plack::Middleware';
+                my $mw_class = Plack::Util::load_class('Static', $mw_prefix);
+                $app = $mw_class->wrap($app,
+                    path => qr{^/static},
+                    root => './root/',
+                );
+                $mw_class = Plack::Util::load_class('Debug', $mw_prefix);
+                $app = $mw_class->wrap($app,
+                    panels => [qw(DBITrace Memory Timer Environment Response)],
+                );
+                $app;
+            };
+        enable 'ContentLength';
+        enable 'ConditionalGET';
+        enable 'ErrorDocument',
+            500 => 'root/html/error_500.html',
+            404 => 'root/html/error_404.html';
+        enable 'HTTPExceptions';
+        enable 'Head';
+        enable 'AccessLog',
+            format => 'combined',
+            logger => sub { get_logger('accesslog')->info(@_) };
+        enable 'Log4perl', conf => 'log/log.conf';
+        $app;
+    };
+}
+
+=head1 AUTHOR
+
+Wallace Reis, C<< <wreis at cpan.org> >>
+
+=head1 LICENSE AND COPYRIGHT
+
+Copyright 2010 Wallace Reis.
+
+=cut
+
+__PACKAGE__->run_if_script;
diff --git a/lib/Parse/BACKPAN/Packages.pm b/lib/Parse/BACKPAN/Packages.pm
new file mode 100644 (file)
index 0000000..c76e106
--- /dev/null
@@ -0,0 +1,233 @@
+package Parse::BACKPAN::Packages;
+
+use strict;
+use warnings;
+
+our $VERSION = '0.39';
+
+use parent qw(Class::Accessor::Fast);
+
+use BackPAN::Index;
+
+__PACKAGE__->mk_accessors(qw(
+    _delegate
+));
+
+sub new {
+    my $class   = shift;
+    my $options = shift;
+
+    # Translate from PBP options to BackPAN::Index
+    $options->{update}                     = 1 if $options->{no_cache};
+    $options->{releases_only_from_authors} = $options->{only_authors};
+
+    my $backpan = BackPAN::Index->new($options);
+    return $class->SUPER::new({ _delegate => $backpan });
+}
+
+our $AUTOLOAD;
+sub AUTOLOAD {
+    my $self = shift;
+    my($method) = $AUTOLOAD =~ /:: ([^:]+) $/x;
+
+    # Skip things like DESTROY
+    return if uc $method eq $method;
+
+    $self->_delegate->$method(@_);
+}
+
+sub files {
+    my $self = shift;
+
+    my %files;
+    my $rs = $self->_delegate->files;
+    while( my $file = $rs->next ) {
+        $files{$file->path} = $file;
+    }
+    
+    return \%files;
+}
+
+sub file {
+    my ( $self, $path ) = @_;
+
+    return $self->_delegate->files->single({ path => $path });
+}
+
+sub releases {
+    my($self, $dist) = @_;
+
+    return $self->_delegate->releases($dist)->all;
+}
+
+
+sub distributions {
+    my $self = shift;
+
+    # For backwards compatibilty when releases() was distributions()
+    return $self->releases(shift) if @_;
+
+    return [$self->_delegate->distributions->get_column("name")->all];
+}
+
+sub distributions_by {
+    my ( $self, $author ) = @_;
+    return unless $author;
+
+    my $dists = $self->_dbh->selectcol_arrayref(q[
+             SELECT DISTINCT dist
+             FROM   releases
+             WHERE  cpanid = ?
+             ORDER BY dist
+        ],
+        undef,
+        $author
+    );
+
+    return @$dists;
+}
+
+sub authors {
+    my $self     = shift;
+
+    my $authors = $self->_dbh->selectcol_arrayref(q[
+        SELECT DISTINCT cpanid
+        FROM     releases
+        ORDER BY cpanid
+    ]);
+
+    return @$authors;
+}
+
+sub size {
+    my $self = shift;
+
+    my $size = $self->_dbh->selectcol_arrayref(q[
+        SELECT SUM(size) FROM files
+    ]);
+
+    return $size->[0];
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Parse::BACKPAN::Packages - Provide an index of BACKPAN
+
+=head1 SYNOPSIS
+
+  use Parse::BACKPAN::Packages;
+  my $p = Parse::BACKPAN::Packages->new();
+  print "BACKPAN is " . $p->size . " bytes\n";
+
+  my @filenames = keys %$p->files;
+
+  # see Parse::BACKPAN::Packages::File
+  my $file = $p->file("authors/id/L/LB/LBROCARD/Acme-Colour-0.16.tar.gz");
+  print "That's " . $file->size . " bytes\n";
+
+  # see Parse::BACKPAN::Packages::Release
+  my @acme_colours = $p->releases("Acme-Colour");
+  
+  my @authors = $p->authors;
+  my @acmes = $p->distributions_by('LBROCARD');
+
+=head1 DESCRIPTION
+
+Parse::BackPAN::Packages has been B<DEPRECATED>.  Please use the
+faster and more flexible L<BackPAN::Index>.
+
+The Comprehensive Perl Archive Network (CPAN) is a very useful
+collection of Perl code. However, in order to keep CPAN relatively
+small, authors of modules can delete older versions of modules to only
+let CPAN have the latest version of a module. BACKPAN is where these
+deleted modules are backed up. It's more like a full CPAN mirror, only
+without the deletions. This module provides an index of BACKPAN and
+some handy functions.
+
+The data is fetched from the net and cached for an hour.
+
+=head1 METHODS
+
+=head2 new
+
+The constructor downloads a ~1M index file from the web and parses it,
+so it might take a while to run:
+
+  my $p = Parse::BACKPAN::Packages->new();
+
+By default it caches the file locally for one hour. If you do not
+want this caching then you can pass in:
+
+  my $p = Parse::BACKPAN::Packages->new( { no_cache => 1 } );
+
+=head2 authors
+
+The authors method returns a list of all the authors. This is meant so
+that you can pass them into the distributions_by method:
+
+  my @authors = $p->authors;
+
+=head2 distributions
+
+  my $distributions = $p->distributions;
+
+The distributions method returns an array ref of the names of all the
+distributions in BackPAN.
+
+=head2 releases
+
+The releases method returns a list of objects representing all
+the different releases of a distribution:
+
+  # see Parse::BACKPAN::Packages::Release
+  my @acme_colours = $p->releases("Acme-Colour");
+
+=head2 distributions_by
+
+The distributions_by method returns a list of distribution names
+representing all the distributions that an author has uploaded:
+
+  my @acmes = $p->distributions_by('LBROCARD');
+
+=head2 file
+
+The file method finds metadata relating to a file:
+
+  # see Parse::BACKPAN::Packages::File
+  my $file = $p->file("authors/id/L/LB/LBROCARD/Acme-Colour-0.16.tar.gz");
+  print "That's " . $file->size . " bytes\n";
+
+=head2 files
+
+The files method returns a hash reference where the keys are the
+filenames of the files on CPAN and the values are
+Parse::BACKPAN::Packages::File objects:
+
+  my @filenames = keys %$p->files;
+
+=head2 size
+
+The size method returns the sum of all the file sizes in BACKPAN:
+
+  print "BACKPAN is " . $p->size . " bytes\n";
+
+=head1 AUTHOR
+
+Leon Brocard <acme@astray.com>
+
+=head1 COPYRIGHT
+
+Copyright (C) 2005-9, Leon Brocard
+
+=head1 LICENSE
+
+This module is free software; you can redistribute it or modify it under
+the same terms as Perl itself.
+
+=head1 SEE ALSO
+
+L<BackPAN::Index>, L<CPAN::DistInfoname>
diff --git a/log/log.conf b/log/log.conf
new file mode 100644 (file)
index 0000000..fa9f589
--- /dev/null
@@ -0,0 +1,23 @@
+log4perl.rootLogger = DEBUG, GlobalLog, GlobalMail
+
+log4perl.appender.GlobalLog = Log::Log4perl::Appender::File
+log4perl.appender.GlobalLog.filename = log/debug.log
+log4perl.appender.GlobalLog.mode = append
+log4perl.appender.GlobalLog.layout = Log::Log4perl::Layout::PatternLayout
+log4perl.appender.GlobalLog.layout.ConversionPattern=[%d] [%p] %m%n
+
+log4perl.appender.GlobalMail = Log::Dispatch::Email::MailSend
+log4perl.appender.GlobalMail.to = wallace@reis.org.br
+log4perl.appender.GlobalMail.from = logger@backpan.org
+log4perl.appender.GlobalMail.subject = BackPAN.org reported an error
+log4perl.appender.GlobalMail.min_level = warning
+log4perl.appender.GlobalMail.layout = Log::Log4perl::Layout::PatternLayout
+log4perl.appender.GlobalMail.layout.ConversionPattern=[%d] [%p] %m | From %l%n
+
+log4perl.logger.accesslog = INFO, AccessLog
+
+log4perl.appender.AccessLog = Log::Log4perl::Appender::File
+log4perl.appender.AccessLog.filename = log/access.log
+log4perl.appender.AccessLog.mode = append
+log4perl.appender.AccessLog.layout = Log::Log4perl::Layout::PatternLayout
+log4perl.appender.AccessLog.layout.ConversionPattern=%m%n
diff --git a/root/html/about.html b/root/html/about.html
new file mode 100644 (file)
index 0000000..27a9711
--- /dev/null
@@ -0,0 +1,51 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<html>
+       <head>
+               <title>BackPAN</title>
+               <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
+               <link rel="stylesheet" href="/static/main.css" type="text/css" />
+       </head>
+<body>
+<div class="container">
+       <div id="search">
+                <form action="/search" method="GET">
+                        <input type="text" id="q" name="q" />
+                       <input type="submit" id="searchbtn" value="Search" />
+                </form>
+        </div>
+       <div id="logo">
+               <a href="/"><h1>BackPAN.org</h1><a>
+       </div>
+       <div id="menu">
+               <div class="nav">
+                       <ul>
+                               <li><a id="nav-releases" href="/releases">Releases</a></li>
+                               <li><a id="nav-dists" href="/dists">Distributions</a></li>
+                               <li><a id="nav-authors" href="/authors">Authors</a></li>
+                               <li><a id="nav-about" class="active" href="/about">About</a></li>
+                       </ul>
+               </div>
+       </div>
+       <div id="header">
+               <p>A Complete History of CPAN.</p>
+       </div>
+       <div id="content">
+               <div id="plain-text">
+                       <p>The <a href="http://cpan.org" target="_blank">Comprehensive Perl Archive Network (CPAN)</a> is a very useful
+                       collection of Perl code. However, authors of modules can delete older versions of modules to only
+                       let CPAN have the latest version of a module and BackPAN is where these deleted modules are backed up.</p>
+                       <p>BackPAN.org is a project created by <a href="http://search.cpan.org/~wreis/" target="_blank">Wallace Reis</a>
+                       using <a href="http://search.cpan.org/perldoc?Web::Simple" target="_blank">Web::Simple</a> and
+                       <a href="http://search.cpan.org/perldoc?HTML::Zoom" target="_blank">HTML::Zoom</a>. Kindly hosted by
+                       <a href="http://shadowcat.co.uk" target="_blank">Shadowcat Systems</a>. Website design developed by
+                       <a href="http://paulovitor.com" target="_blank">Paulo Vitor</a>.</p>
+                       <p>Please send any corrections, suggestions or comments to wreis at cpan.org.</p>
+               </div>
+       </div>
+</div>
+<div id="footer">
+       <p>Created by <a href="http://search.cpan.org/~wreis/" target="_blank">Wallace Reis</a></p>
+       <p>Hosted by <a href="http://shadowcat.co.uk" target="_blank">Shadowcat Systems</a></p>
+</div>
+</body>
+</html>
diff --git a/root/html/dist.html b/root/html/dist.html
new file mode 100644 (file)
index 0000000..fbd2d03
--- /dev/null
@@ -0,0 +1,25 @@
+<div id="content">
+       <div id="dist">
+               <span>Name: [% name %]</span><br />
+               <span>Number of releases: [% num_releases %]</span><br />
+               <span>Maintainers:</span><br />
+               <ul class="maintainer-list">
+                       <li><a>Name</a></li>
+               </ul><br />
+               <span>First release: <a class="f_rel_link">[% f_release_label %]</a></span><br />
+               <span>Latest release: <a class="l_rel_link">[% l_release_label %]</a></span><br />
+       </div>
+       <div id="list">
+               <span>Releases:</span><br />
+               <ul class="main-list">
+                       <li><a>Name</a></li>
+               </ul>
+               <div id="paging">
+                       <div id="pages">
+                               <span><a>Page Number</a></span>
+                       </div>
+                       <span class="paging-desc" />
+                       <span class="entries" />
+               </div>
+       </div>
+</div>
diff --git a/root/html/error_404.html b/root/html/error_404.html
new file mode 100644 (file)
index 0000000..4dbbaa9
--- /dev/null
@@ -0,0 +1,45 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<html>
+        <head>
+                <title>BackPAN</title>
+                <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
+                <link rel="stylesheet" href="/static/main.css" type="text/css" />
+        </head>
+<body>
+<div class="container">
+        <div id="search">
+                <form action="/search" method="GET">
+                        <input type="text" id="q" name="q" />
+                        <input type="submit" id="searchbtn" value="Search" />
+                </form>
+        </div>
+        <div id="logo">
+                <a href="/"><h1>BackPAN.org</h1><a>
+        </div>
+        <div id="menu">
+                <div class="nav">
+                        <ul>
+                                <li><a id="nav-releases" href="/releases">Releases</a></li>
+                                <li><a id="nav-dists" href="/dists">Distributions</a></li>
+                                <li><a id="nav-authors" href="/authors">Authors</a></li>
+                                <li><a id="nav-about" href="/about">About</a></li>
+                        </ul>
+                </div>
+        </div>
+        <div id="header">
+                <p>A Complete History of CPAN.</p>
+        </div>
+       <div id="content">
+               <div id="plain-text">
+                       <p>Sorry, not found!</p>
+                       <p>Whatever it was you were looking for, it's not here.</p>
+                       <p>Please send any corrections, suggestions or comments to wreis at cpan.org.</p>
+               </div>
+       </div>
+</div>
+<div id="footer">
+        <p>Created by <a href="http://search.cpan.org/~wreis/" target="_blank">Wallace Reis</a></p>
+        <p>Hosted by <a href="http://shadowcat.co.uk" target="_blank">Shadowcat Systems</a></p>
+</div>
+</body>
+</html>
diff --git a/root/html/error_500.html b/root/html/error_500.html
new file mode 100644 (file)
index 0000000..a5ddf83
--- /dev/null
@@ -0,0 +1,41 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<html>
+       <head>
+               <title>BackPAN</title>
+               <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
+               <link rel="stylesheet" href="/static/main.css" type="text/css" />
+       </head>
+<body>
+<div class="container">
+       <div id="search">
+                <form action="/search" method="GET">
+                        <input type="text" id="q" name="q" />
+                       <input type="submit" id="searchbtn" value="Search" />
+                </form>
+        </div>
+       <div id="logo">
+               <a href="/"><h1>BackPAN.org</h1><a>
+       </div>
+       <div id="menu">
+               <div class="nav">
+                       <ul>
+                               <li><a id="nav-releases" href="/releases">Releases</a></li>
+                               <li><a id="nav-dists" href="/dists">Distributions</a></li>
+                               <li><a id="nav-authors" href="/authors">Authors</a></li>
+                               <li><a id="nav-about" href="/about">About</a></li>
+                       </ul>
+               </div>
+       </div>
+       <div id="header">
+               <p>A Complete History of CPAN.</p>
+       </div>
+       <div id="content">
+               <div id="plain-text"><p>Ooooops! Sorry, we couldn't process your request.</p></div>
+       </div>
+</div>
+<div id="footer">
+       <p>Created by <a href="http://search.cpan.org/~wreis/" target="_blank">Wallace Reis</a></p>
+       <p>Hosted by <a href="http://shadowcat.co.uk" target="_blank">Shadowcat Systems</a></p>
+</div>
+</body>
+</html>
diff --git a/root/html/index.html b/root/html/index.html
new file mode 100644 (file)
index 0000000..3872765
--- /dev/null
@@ -0,0 +1,8 @@
+<div id="content">
+       <span>New releases</p>
+       <div id="list">
+               <ul class="main-list">
+                       <li><a>Name</a></li>
+               </ul>
+       </div>
+</div>
diff --git a/root/html/layout.html b/root/html/layout.html
new file mode 100644 (file)
index 0000000..d375747
--- /dev/null
@@ -0,0 +1,40 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
+<html>
+       <head>
+               <title>BackPAN</title>
+               <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
+               <link rel="stylesheet" href="/static/main.css" type="text/css" />
+       </head>
+<body>
+<div class="container">
+       <div id="search">
+               <form action="/search" method="GET">
+                       <input type="text" id="q" name="q" />
+                       <input type="submit" id="searchbtn" value="Search" />
+               </form>
+       </div>
+       <div id="logo">
+               <a href="/"><h1>BackPAN.org</h1><a>
+       </div>
+       <div id="menu">
+               <div class="nav">
+                       <ul>
+                               <li><a id="nav-releases" href="/releases">Releases</a></li>
+                               <li><a id="nav-dists" href="/dists">Distributions</a></li>
+                               <li><a id="nav-authors" href="/authors">Authors</a></li>
+                               <li><a id="nav-about" href="/about">About</a></li>
+                       </ul>
+               </div>
+       </div>
+       <div id="header">
+               <p>A Complete History of CPAN.</p>
+       </div>
+       <div id="content">
+       </div>
+</div>
+<div id="footer">
+       <p>Created by <a href="http://search.cpan.org/~wreis/" target="_blank">Wallace Reis</a></p>
+       <p>Hosted by <a href="http://shadowcat.co.uk" target="_blank">Shadowcat Systems</a></p>
+</div>
+</body>
+</html>
diff --git a/root/html/listing.html b/root/html/listing.html
new file mode 100644 (file)
index 0000000..4f33502
--- /dev/null
@@ -0,0 +1,26 @@
+<div id="content">
+       <div id="page-size">
+               <span>Page Size:</span>
+               <div class="page-size-options">
+                       <span><a>Page Size</a></span>
+               </div>
+       </div>
+       <div id="ordering">
+               <span>Sort by:</span>
+               <div class="ordering-options">
+                       <span><a>Name</a></span>
+               </div>
+       </div>
+       <div id="list">
+               <ul class="main-list">
+                       <li><a>Name</a></li>
+               </ul>
+       </div>
+       <div id="paging">
+               <div id="pages">
+                       <span><a>Page Number</a></span>
+               </div>
+               <span class="paging-desc" />
+               <span class="entries" />
+       </div>
+</div>
diff --git a/root/static/arrow-down.gif b/root/static/arrow-down.gif
new file mode 100644 (file)
index 0000000..4d29cc7
Binary files /dev/null and b/root/static/arrow-down.gif differ
diff --git a/root/static/arrow-up.gif b/root/static/arrow-up.gif
new file mode 100644 (file)
index 0000000..5555b2d
Binary files /dev/null and b/root/static/arrow-up.gif differ
diff --git a/root/static/main.css b/root/static/main.css
new file mode 100644 (file)
index 0000000..a701748
--- /dev/null
@@ -0,0 +1,149 @@
+* {
+margin:0;
+padding:0;
+list-style: none;
+}
+
+body {
+text-align: center;
+font-family: Arial, sans-serif;
+color: #333;
+}
+
+a { color:#004065; }
+
+.container {
+margin: 20px auto 40px auto;
+text-align: left;
+width: 900px;
+}
+
+#ordering, #header, #paging, #list { float: left; }
+#menu, #page-size, #search { float: right; }
+
+#search input#q {
+float: right;
+border: 1px solid #004065;
+padding: 1px 25px 1px 5px;
+font-size: 14px;
+background: url(searchbtn.png) no-repeat right;
+}
+
+#header {
+font-size: 12px;
+color: #999;
+}
+
+.nav ul li {
+display: inline;
+}
+
+.nav ul li a {
+font-size: 14px;
+padding: 0 15px;
+text-decoration: none;
+color: #004065;
+height: 30px;
+line-height: 30px;
+display: block;
+float: left;
+}
+
+.nav ul li a:hover {
+background: #004065;
+color: #FFF;
+text-shadow: 1px 1px 1px #333;
+}
+
+#content, #list, #footer { width: 100%; overflow: auto; }
+
+#content {
+margin-top: 30px;
+border-top: 5px solid #004065;
+}
+
+#page-size, #ordering { width: 245px; }
+#page-size span, #ordering span {
+float: left; 
+font-size: 14px;
+padding-top: 6px;
+}
+
+.page-size-options span a, .ordering-options span a {
+text-decoration: none;
+margin-left: 2px;
+padding: 0 4px;
+margin-top: -6px;
+display: block;
+height: 28px;
+line-height: 28px;
+float: left;
+}
+.page-size-options span a:hover, .ordering-options span a:hover {
+background: url(arrow-down.gif) no-repeat center top;
+}
+
+a.active {
+background: url(arrow-up.gif) no-repeat center bottom;
+}
+
+.main-list {
+border-top: 1px solid #004065;
+border-bottom: 1px solid #004065;
+margin-top: 5px;
+}
+/* IExpression to the horror */
+.main-list li {
+display: table;
+width: 900px;
+}
+/* I Love IExplorer! */
+
+.main-list li a {
+display: block;
+font-size: 15px;
+color: #333;
+text-decoration: none;
+padding: 5px 30px;
+}
+.main-list li a:hover {
+color: #FFF;
+background: #004065;
+}
+.main-list li.even a { background: #eaeaea;  }
+.main-list li.even a:hover {
+color: #FFF;
+background: #004065;
+}
+
+#paging span {
+font-size: 12px;
+}
+#pages span a {
+margin-left: 2px;
+padding: 2px;
+color: #004065;
+font-size: 14px;
+text-decoration: none;
+}
+
+#plain-text {
+min-height: 518px;
+}
+#plain-text a { text-decoration: none }
+#plain-text a:hover { text-decoration: underline }
+#plain-text p {
+padding: 20px 20px 5px 20px;
+}
+
+#footer {
+min-height: 40px;
+background: #004065;
+color: #fff;
+}
+#footer a {
+text-decoration: none;
+text-shadow: 1px 1px 1px #333;
+color: #FFF;
+}
+#footer a:hover { text-decoration: underline }
diff --git a/root/static/searchbtn.png b/root/static/searchbtn.png
new file mode 100644 (file)
index 0000000..0fcf85c
Binary files /dev/null and b/root/static/searchbtn.png differ
diff --git a/script/update_index.pl b/script/update_index.pl
new file mode 100644 (file)
index 0000000..4acf8d5
--- /dev/null
@@ -0,0 +1,10 @@
+use strict;
+use warnings;
+
+use BackPAN::Index;
+BackPAN::Index->new({
+  update => 1,
+  debug => 1,
+  cache_dir => 'index',
+  releases_only_from_authors => 1,
+});
diff --git a/t/00-load.t b/t/00-load.t
new file mode 100644 (file)
index 0000000..86d1b3f
--- /dev/null
@@ -0,0 +1,8 @@
+use lib 'lib';
+use Test::More tests => 1;
+
+BEGIN {
+    use_ok( 'BackPAN::Web' ) || print "Bail out!";
+}
+
+diag( "Testing BackPAN::Web $BackPAN::Web::VERSION, Perl $], $^X" );
diff --git a/t/01-leaktrace.t b/t/01-leaktrace.t
new file mode 100644 (file)
index 0000000..c03656a
--- /dev/null
@@ -0,0 +1,57 @@
+use strict;
+use warnings;
+use lib 'lib';
+use BackPAN::Web;
+use Plack::Test;
+use HTTP::Request::Common;
+use Test::More;
+use Devel::Cycle;
+use Data::Dumper;
+use Devel::Events::Handler::ObjectTracker;
+use Devel::Events::Filter::RemoveFields;
+use Devel::Events::Generator::Objects;
+
+$ENV{PLACK_ENV} = 'test';
+
+my $tracker = Devel::Events::Handler::ObjectTracker->new();
+
+my $gen = Devel::Events::Generator::Objects->new(
+    handler => Devel::Events::Filter::RemoveFields->new(
+    fields => [qw/generator/],
+    handler => $tracker,
+),
+);
+
+
+my $app = BackPAN::Web->as_psgi_app;
+
+my $run = sub {
+    $gen->enable();
+    test_psgi app => $app,
+        client => sub {
+            my $cb = shift;
+            foreach my $path (qw{/ /releases /dists /authors /about}) {
+                for (1..1000) {
+                    my $res = $cb->(GET $path);
+                    ok($res->code == 200) or diag($res->code);
+                }
+            }
+        };
+    $gen->disable();
+};
+$run->();
+
+my @leaked_objects = keys %{ $tracker->live_objects };
+
+print "leaked ", scalar(@leaked_objects), " objects\n";
+
+foreach my $object ( @leaked_objects ) {
+    print "Leaked object: $object\n";
+
+    # the event that generated it
+    #print Dumper( $object, $tracker->live_objects->{$object} );
+
+    find_cycle( $object );
+}
+
+done_testing();