aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--TODO10
-rwxr-xr-xgreypool257
-rwxr-xr-xropeylog468
3 files changed, 673 insertions, 62 deletions
diff --git a/TODO b/TODO
index 278b05b..4672246 100644
--- a/TODO
+++ b/TODO
@@ -1,9 +1,7 @@
TODO list for greypool
- handle the case where a project has more than one maintainer...
- or no maintainer. this does't happen with SlackBuilds.org.
-
-- handle ^C gracefully during wget downloads.
+ or no maintainer. this doesn't happen with SlackBuilds.org.
- improve the interactive config:
- cache the repo list?
@@ -20,15 +18,9 @@ TODO list for greypool
- HTML: allow setting font and font size in config file
-- allow user-specified stylesheet for HTML. I am not a web designer, nor
- do I want to be.
-
- handle (unlikely) possibility of commas in CSV fields. is there a
single universally-supported correct way to do this?
-- transforms in config file. regex to change my version number into
- what repology tracks.
-
Maybe:
- allow multiple repos in config file. check them all by default, add
diff --git a/greypool b/greypool
index 5929e13..183adf1 100755
--- a/greypool
+++ b/greypool
@@ -17,13 +17,26 @@ BEGIN {
eval { require JSON::PP; JSON::PP->import(); };
if($@) {
# should never happen on Slackware:
- die "$SELF: couldn't load either JSON::XS or JSON::PP\n";
+ warn "$SELF: couldn't load either JSON::XS or JSON::PP\n";
+ $modules_failed++;
}
}
+ eval { require Getopt::Long; Getopt::Long->import(); };
+ if($@) {
+ warn "$SELF: couldn't load Getopt::Long perl module\n";
+ $modules_failed++;
+ }
+ eval { require POSIX; POSIX->import('strftime') };
+ if($@) {
+ warn "$SELF: couldn't load POSIX perl module\n";
+ $modules_failed++;
+ }
+
+ if($modules_failed) {
+ die "$SELF: Failed to load required perl module(s)\n";
+ }
}
-use Getopt::Long;
-use POSIX 'strftime';
use feature 'unicode_strings';
$VERSION = "0.0.0";
@@ -68,7 +81,9 @@ sub read_file {
sub parse_opts {
Getopt::Long::Configure("bundling");
GetOptions(
- "verbose|v" => sub { $verbosity = 2; },
+ "all|a" => \$show_all,
+ "stylesheet|s" => \$css_file,
+ "verbose|v" => sub { $verbose = 2; },
"force|f" => \$force_download,
"html|h" => \$html,
"html-fragment|H" => \$html_frag,
@@ -78,13 +93,15 @@ sub parse_opts {
"doc|help" => sub { exec("perldoc $0"); },
"man" => sub { exec("pod2man --stderr -s1 -cgreypool -r$VERSION $0"); },
"color|colour|c" => \$color_output,
- "mono|no-color|no-colour|m" => sub { $color_output = 0; },
+ "mono|no-color|no-colour|m" => sub { $color_output = 0; $mono = 1; },
"config-dir|C=s" => \$privdir,
- "quiet|q" => sub { $verbosity = 0; },
+ "quiet|q" => sub { $verbose = 0; },
+ "no-pager|P" => \$no_pager,
) or fatal("error in command line options (try --help).");
$html = 1 if $html_frag;
$html = 0 if $csv_output;
+ $color_output = 1 if $html && !$mono;
fatal "can't use -f/--force and -n/--cached together"
if $force_download && $no_download;
@@ -92,7 +109,6 @@ sub parse_opts {
$email = shift @ARGV;
fatal "extra junk on the command line after '$email'" if @ARGV;
- $color_output = -t STDOUT unless defined $color_output;
$privdir = $ENV{HOME} . "/.greypool" unless defined $privdir;
$config_file = $privdir . "/config";
$css_file = $privdir . "/style.css";
@@ -300,7 +316,9 @@ sub parse_transform {
my $junk;
($pkg, $subst, $junk) = split /\s+/, $_[0];
- fatal "$config_file:$.: trailing junk on transform line" if $junk;
+ if(defined $junk || !defined $subst || !defined $pkg) {
+ fatal "$config_file:$.: transform requires exactly 2 arguments";
+ }
eval 'my $i = ""; $i =~ ' . $subst . ';';
if($@) {
@@ -318,11 +336,33 @@ sub parse_mask {
$masked{$pkg} = \@versions if @versions;
}
+sub parse_color {
+ for($_[0]) {
+ /^[y1t]/i && do { $config_color = 1; return; };
+ /^[n0f]/i && do { $config_color = 0; return; };
+ /^a/i && do { $config_color = undef; return; };
+ fatal "$config_file:$.: color must be 'yes', 'no', or 'auto'.";
+ }
+}
+
+sub parse_pager {
+ $pager = $_[0];
+}
+
+sub find_config_file {
+ my $file = shift;
+ if($file =~ m,^/,) {
+ return $file;
+ } else {
+ return "$privdir/$file";
+ }
+}
+
sub read_config_file {
- create_default_config() unless -e $config_file;
- info "reading config from $config_file";
+ my $file = shift;
+ info "reading config from $file";
- open my $f, "<$config_file" or fatal $!;
+ open my $f, "<$file" or fatal $!;
while(<$f>) {
chomp;
@@ -332,7 +372,7 @@ sub read_config_file {
s/\s+$//; # trim trailing whitespace
(my $key, $val) = split /\s+/, $_, 2;
- fatal "$config_file line $. malformed (no spaces)" unless defined $val;
+ fatal "$file line $. malformed (no spaces)" unless defined $val;
for(lc $key) {
my $ok;
@@ -342,11 +382,33 @@ sub read_config_file {
/ignore/ && do { $ok++; $ignore{$_}++ for split /\s+/, $val; };
/mask/ && do { $ok++; parse_mask($val); };
/transform/ && do { $ok++; parse_transform($val) };
+ /include/ && do {
+ read_config_file(find_config_file($val));
+ next;
+ };
+ /color/ && do { $ok++; parse_color($val); };
+ /pager/ && do { $ok++; parse_pager($val); };
fatal "$config_file line $. malformed (unknown key '$_')" unless $ok;
}
}
close $f;
+}
+sub print_config_summary {
+ warn <<EOF;
+config summary:
+cachedir: $cachedir
+ email: $email
+ repo: $repo
+EOF
+ my $i = join(" ", keys %ignore);
+ warn " ignored: $i \n" if $i;
+ for(keys %masked) {
+ warn " masked $_ versions: " . join(",", @{$masked{$_}}) . "\n";
+ }
+}
+
+sub apply_config {
fatal "no email given on the command line or in the config file"
unless defined $email;
@@ -355,19 +417,18 @@ sub read_config_file {
$cachedir = $privdir . "/cache/$email";
$cachestampfile = $cachedir . "/stamp";
- if($verbose > 1) {
- warn <<EOF;
-config summary:
-cachedir: $cachedir
- email: $email
- repo: $repo
-EOF
- my $i = join(" ", keys %ignore);
- warn " ignored: $i \n" if $i;
- for(keys %masked) {
- warn " masked $_ versions: " . join(",", @{$masked{$_}}) . "\n";
- }
+ if(defined($config_color)) {
+ $color_output = $config_color unless defined $color_output;
+ } else {
+ $color_output = -t STDOUT;
+ }
+
+ if(!$html && !defined($no_pager) && defined($pager) && -t STDOUT) {
+ open $outfh, "|$pager" or fatal "can't execute pager";
+ select $outfh;
}
+
+ print_config_summary() if $verbose > 1;
}
sub decode_file {
@@ -456,6 +517,7 @@ sub transformed_versions_eq {
}
sub ignored_or_masked {
+ return 0 if $show_all;
my $i = shift;
for(qw/name projname/) {
@@ -501,6 +563,10 @@ sub stamp_cache {
sub check_cache {
mkdir_p $cachedir;
return if cache_ok() || $no_download;
+ invalidate_cache();
+}
+
+sub invalidate_cache {
for(<$cachedir/*>) {
unlink $_;
}
@@ -542,8 +608,13 @@ sub download {
"'$url'";
debug $cmd;
+
+ $SIG{INT} = sub { invalidate_cache(); fatal "exiting due to ^C"; exit 1; };
my $status = system($cmd);
+ $SIG{INT} = 'DEFAULT';
+
if($status) {
+ invalidate_cache();
fatal "wget failed, exit status " . ($status >> 8);
}
sleep 1; # repology wants to limit requests to 1 per sec.
@@ -583,6 +654,7 @@ sub generate_report {
####### report printing stuff
sub create_default_css {
+ debug "$css_file doesn't exist, creating";
open my $f, ">$css_file" or fatal "$css_file: $!";
print $f <<EOF;
body { color: #ffffff; background-color: #000000; }
@@ -602,6 +674,7 @@ sub print_css {
if(! -e $css_file) {
create_default_css();
}
+ debug "reading stylesheet from $css_file";
print read_file($css_file);
}
@@ -613,13 +686,13 @@ sub print_report_header {
<html>
<head>
<meta name="Generator" content="$SELF/$VERSION">
-<style>
-EOF
- print_css();
- print <<EOF;
-</style>
-<title>
EOF
+ if($color_output) {
+ print "<style>\n";
+ print_css();
+ print "</style>\n";
+ }
+ print "<title>\n";
}
print "Repology status report for $email ($repo repository)\n\n";
print "</title>\n</head>\n<body>\n" if $html;
@@ -690,7 +763,7 @@ sub print_report_footer {
if($count && !$html) {
print $count . " package" . ($count == 1 ? "" : "s") . " out of date.\n";
}
- print "</body></html>" if $html && (!$html_frag);
+ print "</body></html>\n" if $html && (!$html_frag);
}
####### ANSI color stuff
@@ -714,7 +787,9 @@ sub colorize {
parse_opts();
mkdir_p($privdir);
create_wgetrc();
-read_config_file();
+create_default_config() unless -e $config_file;
+read_config_file($config_file);
+apply_config();
check_cache();
generate_report();
stamp_cache();
@@ -755,6 +830,11 @@ Bundling options is supported, e.g. B<-qf> is the same as B<-q -f>.
=over 4
+=item B<-a>, B<--all>
+
+Show all packages; ignore any I<ignore> or I<mask> directives in
+the config file.
+
=item B<-c>, B<--color>, B<--colour>
Colorize the output. For HTML output, this is already the default. For
@@ -763,8 +843,9 @@ tty device (e.g. not redirected to a file).
=item B<-m>, B<--mono> B<--no-color>, B<--no-colour>
-Do no colorize the output, even if stdout is a terminal. This also
-produces monochrome HTML pages, with the B<-h> / B<--html> option.
+Do not colorize the plain text output, even if stdout is a terminal.
+This also produces monochrome (unstyled) HTML pages, with the
+B<-h> / B<--html> option.
=item B<-C>, B<--config-dir> I<directory>
@@ -792,8 +873,8 @@ is not a web designer and doesn't want to be one.
=item B<-H>, B<--html-fragment>
-Like B<--html>, but I<just> the HTML table (no header, body tag, etc).
-Suitable for embedding in another HTML page.
+Like B<--html>, but I<just> the HTML table (no header, stylesheet,
+body tag, etc). Suitable for embedding in another HTML page.
=item B<-C>, B<--csv>
@@ -801,11 +882,24 @@ Comma-separated value output. The first line is a "header" with the
field names; the rest of the output is one line per package. This
would fail if someone put a comma in a version number... I<*shrug*>
+=item B<-s> I<css-file>, B<--stylesheet> I<css-file>
+
+Embed I<css-file> in the HTML report. Default is
+B<~/.greypool/style.css>. Has no effect without the B<--html>
+option. If the file doesn't exist, it will be created, with the
+default stylesheet. Note that there's no support for an external
+stylesheet (e.g. B<<link stylesheet="...">>).
+
=item B<-q>, B<--quiet>
Don't print any messages to standard error, except errors and
warning.
+=item B<-P>, B<--no-pager>
+
+Ignores B<pager> from the config file; output is sent directly to the
+terminal.
+
=item B<-v>, B<--verbose>
Print extra informational messages about what B<greypool> is doing,
@@ -818,7 +912,10 @@ Print B<greypool> version and exit.
=item B<--help>
-Display documentation using your pager.
+Display documentation using your pager: either B<PAGER> from the
+environment, or B<less> if it's not set. This I<doesn't> use the pager
+defined in your config file, since the config file doesn't get read
+when B<--help> is used.
=item B<--man>
@@ -840,10 +937,10 @@ with spaces.
=over 4
-=item B<repo>
+=item B<repo> I<repository>
-Limit the search to one specific repository. This key is required,
-and there's no command-line option to override it.
+The repository this config file is for. This key is required, and
+there's no command-line option to override it.
This key can only have one value, and can only occur once in the
config file.
@@ -852,7 +949,7 @@ If you maintain packages for more than one distribution, you'll
have to create separate config directories for each one, and call
B<greypool> with the B<--config-dir> or B<-C> option.
-=item B<email>
+=item B<email> I<email-address>
The default email address to use, if one isn't provided on the
command line. This key can only have one value, and can only
@@ -866,7 +963,7 @@ Example:
email urchlay@slackware.uk
-=item B<ignore>
+=item B<ignore> I<package ...>
This key can have multiple values, and can occur multiple times. Use
it to list packages whose status you don't want to check. Examples would
@@ -887,7 +984,7 @@ Note that the above could also be written as:
ignore evince gst-plugins-bad-nonfree
-=item B<mask>
+=item B<mask> I<package> I<version ...>
This key can occur multiple times, and must have at least two values.
Use it to pretend a particular version of a project doesn't exist.
@@ -901,7 +998,27 @@ Multiple versions can be listed on one mask line:
mask somepackage 1.2 1.3 1.4
-=item B<pkgurl>
+=item B<transform> I<package> I<substitution>
+
+Sometimes, repology's version number for a project doesn't quite
+match the version number you used for your package, even though
+they're the same version. Example: your "foo" version number is
+1.2.3+20260101, for a package that's built from the 1.2.3 source
+plus the latest patches from upstream git as of Jan 1, 2026.
+But repology lists it simply as 20260101. You can work around
+this, like so:
+
+ transform foo s/1\.2\.3\+//
+
+Note that the last argument is executable Perl code. If it's not valid
+Perl, B<greypool> will show an error message and fail to run.
+
+You're expected to use a simple regex substitution here. Yes, you
+could insert any code you wanted to, which can cause all kinds of
+problems. So don't do that unless you I<really> know what you're doing
+and have a I<very> good reason for it.
+
+=item B<pkgurl> I<URL>
Optional. When generating HTML output, if this is set, the distro
package names in the HTML will be clickable links that go to the
@@ -913,27 +1030,55 @@ Example:
pkgurl https://slackbuilds.org/repository/15.0/$NAME/
+=item B<include> I<file>
+
+Include a config file, which will be parsed as though it occurred
+inline in the current file. This can be useful for e.g. ignoring
+packages you've updated recently, but the updates haven't been picked
+up by reology yet. The filename can be a full path (used as-is), or
+else it will be treated as relative to the config directory.
+
+=item B<color> I<yes> | I<no> | I<auto>
+
+Whether to colorize output. Default is I<auto>, meaning yes if
+standard output is a terminal, otherwise no.
+
+This sets the default, but you can still use the B<-c> / B<--color>
+or B<-m> / B<--mono> options to override.
+
+=item B<pager> I<command>
+
+If this is set, and standard output is a terminal, and the B<-h> /
+B<--html> option was not used, output will be piped to I<command>.
+There is no default for this.
+
+If you want colorized output in the pager, try B<color yes> and
+B<pager less -MR>.
+
=back
=head1 NOTES
=head2 Required Perl Modules
+B<greypool> requires the B<POSIX>, B<Getopt::Long>, and either the
+B<JSON::PP> or B<JSON::XS> modules.
+
+B<JSON::XS> is highly recommended. It runs about 20 times as fast as
+B<JSON::PP>. However, either will work.
+
If you get this error message on startup:
- greypool: couldn't load either JSON::XS or JSON::PP
+ greypool: Failed to load required perl module(s)
-...then you should install your OS's package for the JSON::XS perl module.
-On Slackware, this is B<perl-JSON-XS> from SlackBuilds.org. For other
-distributions, repology.org will tell you:
+...you'll have to install the missing modules. Depending on your
+distribution, some or all of the modules may be bundled with the main
+perl package, or you may have to install them separately. B<POSIX>
+usually is bundled. For the others, see:
https://repology.org/project/perl%3Ajson-xs/versions
-You will also need the B<POSIX> perl module, which is usually bundled
-with the main perl package, and the B<Getopt::Long> module which
-is bundled on some distributions. See:
-
- https://repology.org/projects/?search=perl%3Agetopt-long
+ https://repology.org/project/perl%3Agetopt-long/versions
=head2 External Binaries
@@ -964,6 +1109,12 @@ create it, with default settings for a custom user agent and with gzip
compression enabled. Ideally, you shouldn't need to change anything
here, but it's available if needed.
+=item B<style.css>
+
+The default stylesheet to embed in the HTML report. Will be created
+the first time it's needed, if it doesn't exist. This can be changed
+with the B<-s> / B<--stylesheet> option.
+
=item B<cache/>
Contains one cache directory per email address that's been checked.
diff --git a/ropeylog b/ropeylog
new file mode 100755
index 0000000..66aae99
--- /dev/null
+++ b/ropeylog
@@ -0,0 +1,468 @@
+#!/usr/bin/perl -w
+
+# Defaults for the -g, -o, -v options:
+$git_root = $ENV{SBO_GITROOT} || "/slackbuilds/www/slackbuilds/15.0/";
+$outdir = "$ENV{HOME}/www";
+$verbose = 0;
+
+###############
+# This shouldn't need changing.
+$user_agent = 'Mozilla/5.0 (compatible; ropeylog-repology-bot/1.0; +https://github.com/SlackBuildsOrg/slackbuilds)';
+
+###############
+
+use JSON::XS; # from SBo perl-JSON-XS
+use POSIX qw/strftime getcwd/; # bundled with Slackware's perl
+use Getopt::Long; # ditto
+
+($SELF = $0) =~ s,.*/,,;
+
+@reports = (
+ [ "Maintainer", '', \&sort_abc ],
+ [ "Last Git (+)", '-bygit_up', \&sort_git_up ],
+ [ "Last Git (-)", '-bygit_down', \&sort_git_down ],
+ [ "Num Builds (+)", '-bynum_up', \&sort_num_up ],
+ [ "Num Builds (-)", '-bynum_down', \&sort_num_down ],
+);
+
+###############
+
+sub warning {
+ warn "$SELF: warning: " . join(" ", @_). "\n";
+}
+
+sub fatal {
+ die "$SELF: fatal: " . join(" ", @_). "\n";
+}
+
+sub info {
+ return unless $verbose;
+ warn "$SELF: " . join(" ", @_). "\n";
+}
+
+###############
+
+sub usage {
+ print <<EOF;
+$SELF - generate SBo Maintainer Status reports (HTML)
+
+Usage: $SELF [-d] [-g <git-dir>] [-o <output-dir>] [-s] [-v]
+
+Generates HTML maintainer status reports, suitable for use on the
+SlackBuilds.org site.
+
+The HTML pages are written to the [output-dir] directory. If any file
+already exists, it is backed up with a .bak extension.
+
+EOF
+
+ print scalar @reports . " reports are generated:\n\n";
+ printf "%20s | %-s\n", "Sorted by", "Filename";
+ printf "%20s + %-s\n", "-" x 20, "-" x 35;
+ for(@reports) {
+ printf "%20s | %-s\n", $_->[0], get_page_name($_->[1]);
+ }
+ print <<EOF;
+
+Options:
+
+ -d, --download
+ Download fresh package status data from repology.org.
+ Without this option, cached data from ~/.ropeylog/ is used.
+
+ -o <output-dir>, --output-dir=<output-dir>
+ Write HTML reports to this directory. The default is:
+ $outdir
+
+ -g <git-dir>
+ Read .info files and git logs from this tree. The default is:
+ $git_root
+
+ -s, --save-json
+ Save the JSON data downloaded from repology.org. Only useful
+ for debugging purposes.
+
+ -v, --verbose
+ Print verbose information on what ropeylog is doing, including
+ download progress from wget. Only useful for debugging.
+EOF
+
+}
+
+###############
+
+sub make_api_url {
+ my $last = shift;
+ my $url = "https://repology.org/api/v1/projects/";
+ $url .= "$last/" if defined $last;
+ $url .= "?status=outdated&inrepo=slackbuilds";
+}
+
+# note to self: wget needs to write to an actual file, not stdout,
+# because repology's json api doesn't support continuing, if the
+# transfer dies part of the way through. wgetting to stdout results
+# in incomplete files if that happens.
+sub wget {
+ my $url = shift;
+ my $file = shift;
+
+ my $q = $verbose ? "" : "-q";
+ my $cmd =
+ "wget $q --compression=gzip --user-agent '$user_agent' -O$file '$url'";
+
+ info "$cmd";
+
+ system($cmd);
+
+ undef local $/;
+ open my $f, "<$file" or fatal $!;
+ my $resp = <$f>;
+ close $f;
+ unlink $file unless $save_json;
+
+ info "sleeping 1s to comply with repology.org spec";
+ sleep 1;
+
+ return $resp;
+}
+
+sub download {
+ my $firstpkg = $_[0] || "";
+ my $count = $_[1];
+ my $lastpkg = "";
+
+ my $filename = sprintf("%s/%03d.json", $cache_dir, $count);
+ my $json = wget(make_api_url($_[0]), $filename);
+
+ eval { $j = decode_json($json); };
+ fatal "failed to decode JSON: $@\n" if $@;
+
+ for $proj (keys %{$j}) {
+ $lastpkg = $proj if $proj gt $lastpkg;
+ my $arr = $$j{$proj};
+ for my $hr (@{$arr}) {
+ next unless $hr->{repo} eq 'slackbuilds';
+ my $catbuild = $hr->{visiblename};
+ $outdated{$catbuild}++ if $hr->{status} eq 'outdated';
+ }
+ }
+
+ return (($lastpkg eq $firstpkg) ? undef : $lastpkg);
+}
+
+sub download_json_files {
+ info "downloading JSON results from repology.org";
+ my $lastpkg;
+ my $count = 1;
+
+ do {
+ $lastpkg = download($lastpkg, $count++);
+ } while defined $lastpkg;
+}
+
+###############
+
+sub get_cache_filename {
+ return "$cache_dir/outdated";
+}
+
+sub write_cache {
+ my $fname = get_cache_filename();
+ info "saving outdated build list to cache: $fname";
+ open my $f, ">$fname" or fatal "$fname: $!";
+ for my $pkg (sort { $a cmp $b } keys %outdated) {
+ print $f $pkg . "\n";
+ }
+ close $f;
+}
+
+sub read_cache {
+ my $fname = get_cache_filename();
+ info "loading outdated build list from cache: $fname";
+ open my $f, "<$fname" or fatal "$fname: $!\n*** You must run '$SELF -d' to initialize the cache.";
+ while(<$f>) {
+ chomp;
+ $outdated{$_}++;
+ }
+ close $f;
+}
+
+###############
+
+sub read_info_file {
+ my $file = shift;
+
+ my $maint;
+ my $email;
+
+ open my $f, "<$git_root/$file" or die $!;
+
+ while(<$f>) {
+ chomp;
+ if(/^MAINTAINER="(.*?)"/) {
+ $maint = $1;
+ } elsif(/^EMAIL="(.*?)"/) {
+ $email = $1;
+ }
+ }
+
+ close $f;
+
+ return ($maint, $email);
+}
+
+sub parse_info_files {
+ info "reading info files from $git_root";
+ my $files = `git $git_args ls-files '*/*/*.info'`;
+ for my $info (split /\n/, $files) {
+ $total_builds++;
+ my ($maintainer, $email) = read_info_file($info);
+ if($email eq 'NOEMAIL') {
+ $total_orphans++;
+ next;
+ }
+ my $catbuild = $info;
+ $catbuild =~ s,/[^/]*$,,;
+ $name2email{$maintainer} = $email;
+ $lastgitdate{$maintainer} = $lastgithash{$maintainer} = 0;
+ push @{$maintbuilds{$maintainer}}, $catbuild;
+ }
+ @maintainers = keys %name2email;
+ info "read $total_builds .info files, " . @maintainers . " maintainers"
+}
+
+sub parse_git_log {
+ info "reading & parsing git log";
+
+ open my $in, "git $git_args log " .
+ "--pretty=format:%h%x09%an%x09%ae%x09%as%x09%s|"
+ or die $!;
+
+ while(<$in>) {
+ chomp;
+ my ($hash, $name, $email, $date, $catbuild, undef) = split /\t/, $_, 6;
+ $catbuild =~ s/:.*$//;
+
+ if(!($lastgitdate{$name})) {
+ $lastgitdate{$name} = $date;
+ $lastgithash{$name} = $hash;
+ }
+ }
+ close $in;
+}
+
+###############
+
+sub sort_abc {
+ lc($a) cmp lc($b);
+}
+
+sub sort_git_up {
+ $lastgitdate{$a} cmp $lastgitdate{$b};
+}
+
+sub sort_git_down {
+ $lastgitdate{$b} cmp $lastgitdate{$a};
+}
+
+sub sort_num_up {
+ scalar(@{$maintbuilds{$a}}) <=> scalar(@{$maintbuilds{$b}});
+}
+
+sub sort_num_down {
+ scalar(@{$maintbuilds{$b}}) <=> scalar(@{$maintbuilds{$a}});
+}
+
+###############
+
+sub url_escape {
+ my $s = shift;
+ $s =~ s{[^-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz]}{sprintf("%%%x", ord($&))}ge;
+ return $s;
+}
+
+sub get_percent {
+ my $a = shift;
+ my $b = shift;
+
+ if(!$b) {
+ print "0";
+ return;
+ }
+
+ return sprintf("%d (%.1f%%)", $a, ($a / $b) * 100);
+}
+
+sub print_sort_links {
+ for my $r (@reports) {
+ my $page = get_page_name($r->[1]);
+ print <<EOF;
+Sort by <a href="$page">$r->[0]</a><br />
+EOF
+ }
+}
+
+sub print_report {
+ my $title = shift;
+ my $sortfunc = shift;
+
+ my $stamp = strftime("%Y-%m-%d %H:%M:%S", gmtime);
+ my $maintcount = @maintainers;
+ my $total_outdated = keys %outdated;
+ my $print_uptodate = get_percent($total_builds - $total_outdated, $total_builds);
+ my $print_outdated = get_percent($total_outdated, $total_builds);
+
+ $total_orphans ||= 0;
+ my $print_orphaned = get_percent($total_orphans, $total_builds);
+
+ print <<EOF;
+<!DOCTYPE html>
+<html>
+<head>
+<title>SBo maintainer status report</title>
+<meta name="Generator" content="ropeylog/0.0.0">
+<meta charset="utf-8">
+<style>
+ table {border:1px solid;border-collapse:collapse}
+ th.m, td.m {text-align:left;padding:0.5rem;border:1px solid}
+ th.l, td.l {text-align:center;padding:0.5rem;border:1px solid}
+ th.s, td.s {text-align:center;padding:0.5rem;border:1px solid}
+ th.n, td.n {text-align:right;padding:0.5rem;border:1px solid}
+ th.o, td.o {text-align:right;padding:0.5rem;border:1px solid}
+ a.m {font-size:small}
+</style>
+</head>
+<body>
+<h1>SBo maintainer status report</h1>
+<h2>Sorted by $title</h2>
+EOF
+
+ print_sort_links();
+
+print <<EOF;
+<p>Date: $stamp<br />
+SlackBuilds: $total_builds<br />
+Maintainers: $maintcount<br />
+Total up-to-date builds: $print_uptodate<br />
+Total outdated builds: $print_outdated<br />
+Orphaned builds: $print_orphaned<br />
+</p>
+<table>
+<thead>
+<tr><th class="m">Maintainer</th><th class="l">Last git</th><th class="s">SlackBuilds</th><th class="n">Up to date</th><th class="o">Out of date</th></tr>
+</thead>
+<tbody>
+EOF
+
+ for my $maintainer (sort $sortfunc @maintainers) {
+ my $count = 0;
+ my $outdated_count = 0;
+ for(@{$maintbuilds{$maintainer}}) {
+ $count++;
+ $outdated_count++ if $outdated{$_};
+ }
+
+ my $newest = get_percent($count - $outdated_count, $count);
+ my $outdated = get_percent($outdated_count, $count);
+ my $maint_email = $name2email{$maintainer};
+
+ my $repology_link = "";
+ my $count_link = $count;
+ if(defined $maint_email) {
+ my $escmail = url_escape($maint_email);
+ my $escmaint = url_escape($maintainer);
+ $count_link = "<a class=\"s\" target=\"_blank\" href=\"https://slackbuilds.org/advsearch.php?stype=maint&q=$escmaint\">$count</a>";
+ if($maint_email =~ /\@/) {
+ $repology_link =
+ "<a class=\"m\" href=\"mailto:$escmail\">✉</a>&nbsp;" .
+ "<a class=\"m\" target=\"_blank\" href=\"https://repology.org/projects/?maintainer=$escmail\">$maint_email</a>";
+ } else {
+ $repology_link = $maint_email;
+ }
+ }
+
+ my $git_hash = $lastgithash{$maintainer};
+ my $git_date = $lastgitdate{$maintainer};
+ my $git_link = "";
+ if(defined $git_hash && defined $git_date) {
+ $git_link = "<a class=\"c\" target=\"_blank\" href=\"https://git.slackbuilds.org/slackbuilds/commit/?id=$git_hash\">$git_date</a>";
+ }
+
+ print <<EOF;
+<tr>
+ <td class="m">$maintainer<br />$repology_link</td>
+ <td class="l">$git_link</td>
+ <td class="s">$count_link</td>
+ <td class="n">$newest</td>
+ <td class="o">$outdated</td>
+</tr>
+EOF
+ }
+
+ print <<EOF;
+</tbody>
+</table>
+</body>
+</html>
+EOF
+}
+
+sub get_page_name {
+ return "maintainer_status$_[0].html";
+}
+
+sub print_all_reports {
+ for my $rep (@reports) {
+ my ($title, $fname, $sortfunc) = @$rep;
+
+ $fname = "$outdir/" . get_page_name($fname);
+
+ open my $f, ">$fname.new" or die $!;
+ my $oldf = select $f;
+ print_report($title, $sortfunc);
+ select $oldf;
+ close $f;
+
+ rename $fname, $fname . ".bak";
+ rename $fname . ".new", $fname;
+ }
+}
+
+###############
+
+# main()
+Getopt::Long::Configure("bundling");
+GetOptions(
+ "download|d" => \$do_download,
+ "save-json|s" => \$save_json,
+ "git-dir|g" => \$git_root,
+ "output-dir|o" => \$outdir,
+ "help|h" => sub { usage(); exit 0; },
+ "verbose|v" => \$verbose,
+);
+
+$start_time = time;
+
+$cache_dir = "$ENV{HOME}/.$SELF";
+system("mkdir -p $cache_dir $outdir");
+fatal "can't create $cache_dir" unless -d $cache_dir;
+fatal "can't create $cache_dir" unless -d $outdir;
+info "\$cache_dir: $cache_dir";
+info "\$outdir: $outdir";
+
+$git_args = "--work-tree $git_root --git-dir $git_root/.git";
+info "\$git_args: $git_args";
+
+if($do_download) {
+ download_json_files();
+ write_cache();
+} else {
+ read_cache();
+}
+
+parse_info_files();
+parse_git_log();
+print_all_reports();
+
+info "elapsed time: " . (time - $start_time) . " sec";
+exit 0;