#!/usr/bin/env perl

use warnings;

# https://repology.org/api/v1

# This stuff is so we can run (slowly) on a stock Slackware
# install. With JSON::PP, it takes 0.9 sec to parse one JSON
# file from repology that contains 200 of my builds... but I
# have over 1000 builds. Most of you, it's going to run
# faster than that with PP.
BEGIN {
	($SELF = $0) =~ s,.*/,,;
	eval { require JSON::XS; JSON::XS->import(); }; # can we go fast?
	if($@) {
		warn "$SELF: JSON::XS not available, falling back to JSON::PP (slow!)\n";
		eval { require JSON::PP; JSON::PP->import(); };
		if($@) {
			# should never happen on Slackware:
			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 feature 'unicode_strings';

$VERSION = "0.0.0";
$force_download = $html = 0;
$verbose = 1; # 0 = silent, 1 = info messages, 2 = debug
$stamp = strftime("%Y%m%d", localtime);

# all the parsed data gets shoved in here.
# keys are visiblename of each project.
# values are another hash, with keys projname (repology's name for
# the project), oldver (our outdated version), newver (repology's
# reported "newest" version(s)).
%report = ();

# TODO: make this configurable
$user_agent = 'Mozilla/5.0 (compatible; greypool-repology-bot/1.0; +https://github.com/SlackBuildsOrg/slackbuilds)';

sub warning {
	warn "$SELF: warning: " . join(" ", @_). "\n";
}

sub fatal {
	die "$SELF: fatal: " . join(" ", @_). "\n";
}

sub info {
	return unless $verbose;
	warn "$SELF: " . join(" ", @_). "\n";
}

sub debug {
	return unless $verbose > 1;
	warn "$SELF: " . join(" ", @_). "\n";
}

sub read_file {
	undef local $/;
	local @ARGV = shift;
	$_ = <>;
}

sub parse_opts {
	Getopt::Long::Configure("bundling");
	GetOptions(
		"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,
		"cached|n" => \$no_download,
		"csv|s" => \$csv_output,
		"version|V" => sub { print "$VERSION\n"; exit 0; },
		"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 = 1; },
		"config-dir|C=s" => \$privdir,
		"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;

	$email = shift @ARGV;
	fatal "extra junk on the command line after '$email'" if @ARGV;

	$privdir = $ENV{HOME} . "/.greypool" unless defined $privdir;
	$config_file = $privdir . "/config";
	$css_file = $privdir . "/style.css";
	$wgetrc = $privdir . "/wgetrc";
}

sub create_wgetrc {
	return if -e $wgetrc;
	info "$wgetrc doesn't exist, creating";
	open my $f, ">$wgetrc" || fatal "can't create $wgetrc: $!\n";
	print $f <<EOF;
# $wgetrc

# wget settings for greypool(1). See wget(1) for full details.

# It's entirely possible to break greypool's ability to download
# files, if you mangle this config file. If all else fails, you
# can delete this file and greypool will create a default one
# next time it runs.

# repology.org requires a user-agent that identifies the client.
# You probably shouldn't change this.
user-agent = $user_agent

# repology.org supports gzip compression, and JSON compresses well.
# Enable by default.
compression = gzip

EOF
	close $f;
}

# perl replacement for shell's "mkdir -p".
# no error checking at all, but that's fine, the caller will know it
# failed when it tries to *use* the directory that didn't get created.
sub mkdir_p {
	for(@_) {
		my $path;
		for my $d (split /\//) {
			$path .= "$d/";
			-d $path || mkdir $path;
		}
	}
}

# the API doesn't seem to have a way to just get a list of repos
# from repology, so this does traditional HTML scraping. Yuck.
sub get_repo_list {
	my $count = 0;
	print "Getting repo list\n";
	open my $f,
		"wget --config=$wgetrc -qO- " .
		'https://repology.org/repositories/statistics' .
		'|';
	fatal "failed to run wget: $!\n" unless $f;
	while(<$f>) {
		if(m,<a\s*href="/repository/([^"/]+)">([^<]+)</a>,) {
			$repo_list{$1} = $2;
			$count++;
		}
	}
	close $f;

	if($count) {
		print "Found $count repos at repology.org\n";
	} else {
		warning "couldn't get repo list from repology.org";
	}
}

sub show_repo_list {
	my @res;
	my $regex = shift || '.*';
	for(sort keys %repo_list) {
		if(/$regex/) {
			print "$_ ($repo_list{$_})\n";
			push @res, $_;
		}
	}
	return @res;
}

sub choose_repo {
	my $dflt = shift || "L";
	get_repo_list() unless %repo_list;

	while(1) {
		my $got = prompt("Repo or 'l <substring>' to list", $dflt);
		$got = lc $got;
		if($got =~ /^l(?:\s+(\S+))?/) {
			my $term = $1;
			@list = show_repo_list($term);
			if(!@list) {
				print "No repos matching '$term'\n";
			} elsif(@list == 1) {
				$dflt = $list[0];
			}
		} else {
			if($repo_list{$got}) {
				return $got;
			} else {
				print "No such repository '$got'\n";
			}
		}
	}
}

sub prompt {
	(my $p, $d) = @_;
	chomp $p;
	(local $|)++;
	print $p;
	print " [$d]" if $d;
	print "? ";
	my $resp = <>;
	chomp $resp;
	$resp = $d if $resp eq '' && defined $d;
	return $resp;
}

sub create_default_config {
	if(! (-t STDOUT && -t STDIN) ) {
		fatal "$config_file doesn't exist; run without redirection to create\n";
	}

	print "$config_file doesn't exist; creating new.\nPress ^C to abort.\n\n";

	my $ok;
	while(!$ok) {
		$email = prompt("Your email address", $email);
		redo if $email =~ /^\s*$/;
		#show_repo_list();
		#$repo = prompt("What repo is this config file for", $repo);
		$repo = choose_repo($repo);
		redo if $repo =~ /^\s*$/;
		my $yn = prompt(<<EOF, "Y");

Email address: $email
Repository:    $repo

Is this correct
EOF
		$ok = 1 if ($yn =~ /\s*y/i);
		print "\n";
	}

	$pfmt = 'pkgurl https://slackbuilds.org/repository/15.0/$NAME/';
	if($repo ne 'slackbuilds') {
		$pfmt = '# ' . $pfmt;
	}

	mkdir_p($privdir);

	open my $f, ">$config_file" or fatal "$config_file: $!";
	print $f <<EOF;
# Config file for greypool.

# See the greypool man page or --help output for more information.

# Comments and whitespace can be used freely here, including on
# the same line as a setting. Example:

#email me\@myhouse.com # my fake email address.

# Settings
##########

# Optional but recommended: Your email address (as seen by
# repology). This can be overridden on the command line, to check
# someone else's packages. If you don't set this here, you'll have to
# pass it as an argument to greypool every time you run it.
email $email

# Required: The repository you want to check (repology's name for it).
# Only one allowed, and no way to override on the command line. If you
# need to check multiple repos, use multiple config directories and
# the -C option.
repo $repo

# Optional: List any packages you don't want to check for updates here.
# They can be any mix of one per line, or multiple per line.
#ignore evince
#ignore yar calf-ladspa

# Optional: List any bad versions of packages you don't want to update
# to here. One line per package, multiple versions per line allowed.
#mask srecord 1.65.0

# Optional: Format string for package URLs. These are the "local" package
# names (what your repository calls them, not repology's name).
# The string \$NAME will be replaced by the package name (globally).
# If this is not set, the package names won't by clickable links.
# NOTE: For SlackBuilds.org, the URL *must* end in a trailing slash!
$pfmt
EOF

	close $f;
	print "Config file created. Re-run to check package status.\n";
	exit 0;
}

sub parse_transform {
	my $pkg;
	my $subst;
	my $junk;
	($pkg, $subst, $junk) = split /\s+/, $_[0];

	if(defined $junk || !defined $subst || !defined $pkg) {
		fatal "$config_file:$.: transform requires exactly 2 arguments";
	}

	eval 'my $i = ""; $i =~ ' . $subst . ';';
	if($@) {
		fatal "$config_file:$.: invalid transform: $@";
	}

	$transforms{$pkg} = $subst;
}

sub parse_mask {
	my $pkg;
	my @versions;
	($pkg, @versions) = split /\s+/, $_[0];
	fatal "$config_file:$.: no versions listed for 'mask'" unless @versions;
	$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 {
	my $file = shift;
	info "reading config from $file";

	open my $f, "<$file" or fatal $!;

	while(<$f>) {
		chomp;
		s/#.*//;         # remove comments
		next if /^\s*$/; # skip blanks
		s/^\s+//;        # trim leading whitespace
		s/\s+$//;        # trim trailing whitespace

		(my $key, $val) = split /\s+/, $_, 2;
		fatal "$file line $. malformed (no spaces)" unless defined $val;

		for(lc $key) {
			my $ok;
			/email/  && do { $ok++; $email = $val unless defined $email; };
			/repo/   && do { $ok++; $repo  = $val;                       };
			/pkgurl/ && do { $ok++; $pkgurl  = $val;                     };
			/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;

	fatal "no repo set in the config file" unless defined $repo;

	$cachedir = $privdir . "/cache/$email";
	$cachestampfile = $cachedir . "/stamp";

	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 {
	$_ = shift;

	debug("decoding JSON file $_");

	eval {
		$j = decode_json(read_file($_));
	};

	fatal "failed to decode JSON: $@" if $@;
}

sub get_cache_filename {
	return $cachedir . "/" . $_[0] . ".json";
}

sub parse_file {
	my $filenum = shift;
	my $file = get_cache_filename($filenum);
	decode_file($file);

	my $lastpkg = "";
	my $firstpkg;

	my $count = 0;
	for $proj (keys %{$j}) {
		$count++;
		$firstpkg = $proj unless defined $firstpkg;
		$lastpkg = $proj if $proj gt $lastpkg;
		my $arr = $$j{$proj};
		my $newest;
		for my $hr (@{$arr}) {
			my($status, $version) = @$hr{qw/status version/};

			# *anyone's* newest version.
			if($status eq 'newest') {
				$newest = $version;
				last;
			}

			# FIXME: what about the other statuses? repology has:
			# newest, devel, unique, outdated, legacy, rolling,
			# noscheme, incorrect, untrusted, ignored.
		}
		for my $hr (@{$arr}) {
			my($status, $r_repo, $name, $version, $maint) =
				@$hr{qw/status repo visiblename version maintainers/};

			if($r_repo eq $repo && $status eq 'outdated') {
				my $item = {
						name     => $name,
						projname => $proj,
						oldver   => $version,
						newver   => $newest};

				# we have to check the maintainer because e.g. 2 different
				# people maintain libxm++3 and libml++5, or qt4 and qt6.
				# or "foo" and "foo-legacy"
				# repology considers these the same project.
				if(@$maint[0] eq $email) {
					next if ignored_or_masked($item);
					next if transformed_versions_eq($item);
					$report{$name} = $item;
					last;
				}
			}
		}
	}

	debug "$count entries in file, last package $lastpkg\n";
	return $lastpkg eq $firstpkg ? undef : $lastpkg;
}

sub transformed_versions_eq {
	my $i = shift;

	my $t = $transforms{$i->{name}};
	$t = $transforms{$i->{projname}} unless $t;
	return unless $t;

	my $v = $i->{oldver};
	eval '$v =~ ' . $t . ';';
	return $i->{newver} eq $v;
}

sub ignored_or_masked {
	return 0 if $show_all;
	my $i = shift;

	for(qw/name projname/) {
		#warn "checking ignore: $i->{$_}";
		return 1 if $ignore{$i->{$_}};
	}

	my $maskvers = $masked{$i->{name}};
	$maskvers = $masked{$i->{projname}} unless $maskvers;
	return unless $maskvers;
	#warn "checking masked: $i->{name} $i->{newver}, " . join ",", @$maskvers;

	return grep { $i->{newver} eq $_ } @$maskvers;
}

# do our own URL escaping to avoid depending on an external module.
# based on: https://en.wikipedia.org/wiki/URL_escaping
# ...and also the perldoc for URI::Escape
# neither of these mentions the blindingly obvious fact that the %
# character itself has to be escaped.
# % ! # $ & ' ( ) * + , / : ; = ? @ [ ]
sub url_escape {
	my $s = shift;
	$s =~ s{[^-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz]}{sprintf("%%%x", ord($&))}ge;
	return $s;
}

sub cache_ok {
	return 1 if $no_download;
	return 0 unless -e $cachestampfile;
	my $oldstamp = read_file($cachestampfile);
	chomp($oldstamp);
	#warn "stamp $stamp, oldstamp $oldstamp";
	return $stamp eq $oldstamp;
}

sub stamp_cache {
	open my $f, ">$cachestampfile" or fatal $!;
	print $f $stamp;
	close $f;
}

sub check_cache {
	mkdir_p $cachedir;
	return if cache_ok() || $no_download;
	invalidate_cache();
}

sub invalidate_cache {
	for(<$cachedir/*>) {
		unlink $_;
	}
}

sub make_api_url {
	my $lastpkg = shift;
	my $url = "https://repology.org/api/v1/projects/";
	$url .= "$lastpkg/" if defined $lastpkg;
	$url .= "?maintainer=";
	$url .= url_escape($email);
	$url .= "&status=outdated&repo=";
	$url .= url_escape($repo);
}

sub download {
	my $url = make_api_url(shift);
	my $num = shift;

	my $filename = $cachedir . "/" . $num . ".json";
	if(!$force_download && (-e $filename)) {
		debug "$filename found in cache, not downloading";
		return;
	}
	info "downloading JSON file #$num";
	$download_count++;

	if($no_download && (! -e $filename)) {
		fatal "-n / --cached option given, but no cached data in $cachedir";
	}

	my $qopt = ($verbose > 1) ? "" : "-q";

	my $cmd =
		"wget " .
		"--config=$wgetrc " .
		"$qopt " .
		"-O $filename " .
		"'$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.
}

sub generate_report {
	my $filecount = 1;
	my $lastpkg = "NONE"; # don't use undef

	# we can't really download all the json files, then parse them
	# all after downloading: to know how to get the 2nd one, you have
   # to parse the first one to get the last (lexicallly greatest)
   # package name.
	while(defined $lastpkg) {
		download($lastpkg, $filecount);
		$lastpkg = parse_file($filecount++);
	}

	if(!$download_count) {
		info "using cached data";
	}

	print_report_header();
	my $count = keys %report;
	if(!$count) {
		print_empty_report();
	} else {
		print_table_start($count);
		for(sort keys %report) {
			print_report_line($_);
		}
		print_table_end();
	}
	print_report_footer($count);
}

####### 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; }
a         { text-decoration: none;}
a:link    { color: #ffffff; }
a:visited { color: #ffffff; }
a:hover   { color: #00ff00; }
.name     { color: #ffffff; }
.oldver   { color: #ff0000; }
.newver   { color: #00ff00; }
.caption  { color: #ffff00; }
EOF
	close $f;
}

sub print_css {
	if(! -e $css_file) {
		create_default_css();
	}
	debug "reading stylesheet from $css_file";
	print read_file($css_file);
}

sub print_report_header {
	return if $csv_output;
	return if $html_frag;
	if($html) {
		print <<EOF;
<html>
<head>
<meta name="Generator" content="$SELF/$VERSION">
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;
}

sub print_empty_report {
	return if $csv_output;
	print "All packages up to date.\n";
}

sub print_table_start {
	my $count = shift;
	if($html) {
		print "<span class=\"caption\">Repology status report for $email ($repo repository)<p />\n";
		print $count . " package" . ($count == 1 ? "" : "s") . " out of date.<p />" if $html;
		print "</span>\n";
		print "<table><tr><th class=\"caption\">Package</th><th class=\"caption\">Repology Link</th><th class=\"caption\">Version</th><th class=\"caption\">Newest Version</th></tr>\n";
	} elsif($csv_output) {
		print "Package,RepologyName,Version,Newest\n";
	} else {
		printf("%-30s", "Package");
		printf("%24s", "Version");
		printf("%24s", "Newest");
		print "\n" . ("-" x 79) . "\n";;
	}
}

sub print_report_line {
	my $name = shift;

	my $hr = $report{$name};
	my $projname = $hr->{projname};
	my $oldver = $hr->{oldver};
	my $newver = $hr->{newver} || "???";

	if($html) {
		my $nlink;
		if($pkgurl) {
			(my $target = $pkgurl) =~ s,\$NAME,$name,g;
			$nlink = "<a href=\"$target\">$name</a>";
		} else {
			$nlink = $name;
		}
		my $pnlink = "<a href=\"https://repology.org/project/$projname/versions\">$projname</a>";
		print "<tr><td class=\"name\">$nlink</td><td class=\"name\">$pnlink</td><td class=\"oldver\">$oldver</td><td class=\"newver\">$newver</td></tr>\n";
	} elsif($csv_output) {
		print "$name,$projname,$oldver,$newver\n";
	} else {
		my $w2 = 24;
		if(length($name) > 30) {
			$w2 = 24 - ((length($name)) - 30);
		}
		printf("%-30s", $name);
		red(sprintf("%${w2}s", $oldver));
		print " ";
		green(sprintf("%24s", $newver));
		print "\n";
	}
}

sub print_table_end {
	print "</table>\n" if $html;
}

sub print_report_footer {
	return if $csv_output;
	my $count = shift;
	if($count && !$html) {
		print $count . " package" . ($count == 1 ? "" : "s") . " out of date.\n";
	}
	print "</body></html>\n" if $html && (!$html_frag);
}

####### ANSI color stuff
sub red {
	colorize(1, @_);
}

sub green {
	colorize(2, @_);
}

sub colorize {
	my $color = shift;
	print "\x1b[01;3${color}m" if $color_output;
	print for @_;
	print "\x1b[0m" if $color_output;
}

### main()

parse_opts();
mkdir_p($privdir);
create_wgetrc();
create_default_config() unless -e $config_file;
read_config_file($config_file);
apply_config();
check_cache();
generate_report();
stamp_cache();

__END__

=pod

=head1 NAME

greypool - check for outdated packages using repology.org API

=head1 SYNOPSIS

B<greypool> [--help | --man | --version] | [-options] [I<email-address>]

=head1 DESCRIPTION

B<greypool> uses repology.org's JSON API to check for outdated
distribution packages by a particular maintainer.

To avoid excessive traffic to the repology.org server, the JSON files
are cached locally, and will only be re-downloaded after 24 hours.

Output is a report, either plain text (possibly with ANSI color) or
HTML. In either case, the report is printed to standard output.

The report is a table, where each row lists the package name, the
current version, and the newest version according to repology. If
the latter is B<???>, that means repology knows the package is outdated,
but no other distro has packaged the newest version. In the HTML
report, repology's name for the package is also shown, as a
clickable link that goes to the "versions" page for the package.

=head1 OPTIONS

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
plain text, the default is to colorize only if standard output is a
tty device (e.g. not redirected to a file).

=item B<-m>, B<--mono> B<--no-color>, B<--no-colour>

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>

Use a directory other than I<~/.greypool/> for the config file, logs,
and cache. This directory will be created (with a default all-comments
config file) if it doesn't already exist.

=item B<-f>, B<--force>

Re-download the JSON files, even if we have a cached copy less than
24 hours old. Use this option with caution: excessive requests to the
repology.org server may cause the server to block your IP address.

=item B<-n>, B<--cached>

Do not download JSON files from the repology API. Use cached data,
even if it's outdated. If there is no cached data, you'll get a fatal
error.

=item B<-h>, B<--html>

Generate report as an HTML page, rather than the default plain text.
The HTML output is kinda rough-looking, because the B<greypool> author
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, stylesheet,
body tag, etc). Suitable for embedding in another HTML page.

=item B<-C>, B<--csv>

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,
including the B<wget>(1) progress bar during downloads. Messages go
to standard error output.

=item B<-V>, B<--version>

Print B<greypool> version and exit.

=item B<--help>

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>

Format documentation as a man page (troff), print to standard output.

=back

=head1 CONFIG FILE

The config file is located at B<~/.greypool/config>. If it doesn't
exist, B<greypool> will create it, with commented-out examples.

The config syntax is simple. Comments and whitespace are allowed in
the usual way, and each non-comment line consists of a key, followed
by one or more values. If there are multiple values, separate them
with spaces.

=head2 Config File Keys

=over 4

=item B<repo> I<repository>

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.

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> 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
occur once in the config file.

If no email address is set in the config file, you must give an
address on the command line. If not, B<greypool> will exit with error
status.

Example:

    email urchlay@slackware.uk

=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
be projects that have upstream version updates available, but the
newer versions can't be built on the latest stable release of your OS
due to outdated libraries.

Examples:

    # evince can't be updated on Slackware 15.0
    ignore evince

    # gstreamer plugins must match the version of gstreamer
    # included in Slackware
    ignore gst-plugins-bad-nonfree

Note that the above could also be written as:

    ignore evince gst-plugins-bad-nonfree

=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.

Example:

    # version 1.65 of srecord doesn't ship any documentation at all
    mask srecord 1.65

Multiple versions can be listed on one mask line:

    mask somepackage 1.2 1.3 1.4

=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
distro's web page for that package... provided you set the value
correctly! The string B<$NAME> will be replaced with the package
name. Note that these are "your" names, not repology's.

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: Failed to load required perl module(s)

...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

    https://repology.org/project/perl%3Agetopt-long/versions

=head2 External Binaries

B<greypool> uses B<wget>(1) to talk to the repology.org API. This is a
deliberate design choice, to avoid the long chain of perl dependencies
required for libwww-perl.

=head1 FILES

=over 4

=item B<~/.greypool/>

The default config directory. Can be changed with the B<-C> /
B<--config-dir> option. The rest of the files listed here are relative
to this path.

=item B<config>

The config file, which at minimum sets the default email address and
repository to check. See the B<CONFIG FILE> section, above.

=item B<wgetrc>

When B<greypool> runs B<wget>, it uses this file instead of the
default B<~/.wgetrc>. If this file doesn't exist, B<greypool> will
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.

=item B<cache/>I<email-address>B</*.json>

The cached JSON files, downloaded from the repology.org API. These
are named B<1.json>, B<2.json>, etc.

=item B<cache/>I<email-address>B</stamp>

The date the B<*.json> files were downloaded. B<greypool> will only
use the cached files if the current date matches this, otherwise
they're considered expired and will be re-downloaded.

=back

=head1 AUTHOR

B. Watson <urchlay@slackware.uk>, aka Urchlay on Libera IRC.

=head1 LICENSE

B<greypool> is licensed under the WTFPL. See
http://www.wtfpl.net/txt/copying/ for details.

=head1 SEE ALSO

The repology.org API documentation: https://repology.org/api/v1

=cut
