#!/usr/local/bin/perl

# site-dater.pl
#   A script that will generate a table of pages within a web directory
#     stamped and sorted by date
#
#   04/29/2002 Version 1.1.0
#   Use and distribute this script as per the Artistic License
#   Copyright (C) 2002 Igor S. Livshits <mailto:igorl@ayradyss.org>
#
#   Original idea from sitemap.sh by Adam Kopacz
#     <mailto:Adam.K@klografx.de> <http://www.klografx.de/>
#
#   Command line inputs [all optional]:
#     path to a directory [-d]; use pwd if omitted
#     follow links [-l]; skip symbolic links if omitted
#     alternate directives file name [-c]
#     relative dates [-r]; absolute dates if omitted
#     verbose and quiet flags [-q -v]
#     cgi/ssi mode [-cgi -ssi]
#
#   .site-dater file at the top of the hierarchy with additional directives:
#     file and URL paths; e.g., `/path/to/file' or `../dir'
#       OUTPUT= path to target file which will receive out output (path)
#       URLOFFSET= offset to generated URLs relative to the OUTPUT file (path)
#
#     whitespace delimited patterns; e.g., `.html$' or `footer header menu'
#       KEEP= files to keep
#       SKIP= files to skip
#
#     limitations; e.g., `5' or `23'
#       MAXENTRIES= maximum number of pages to list
#       MINAGE= minimum age (in days) at which MAXENTRIES applies
#       Note: Multiple entries with the same time stamp may exceed the
#             MAXENTRIES limit. This is a feature :)
#
#     table specification; e.g., `border="0" cellspacing="1"'
#       TABLE= all that goes into the <table> directive
#
#     font specifications; e.g., `size="-1" color="#dddddd"'
#       PAGEFONT= the title (first) column
#       DATEFONT= the date (second) column
#       PATHFONT= the path (third) column
#       TITLEFONT= the title row
#       HEADINGFONT= the column headings row
#       FOOTERFONT= the footer row
#       ONEDAY= for file dates younger than a day
#       THREEDAY= for file dates younger than 3 days
#       WEEK= for file dates younger than a week
#
#     cell background colors; e.g., `#83a3b8'
#       TITLEBG= for the title row
#       HEADINGBG= for the column headings row
#       FOOTERBG= for the footer row
#       TABLEBG= one or more colors for the table rows;
#                e.g., `#dddddd #eeeeee'
#                multiple colors will get rotated
#
#     titles, headers, and footers; e.g., `Random static text by #me'
#       "#me" gets expanded to reference this script
#       "#date" gets expanded to the current date and time string
#       TITLE= Table title (text string)
#       FOOTER= Table footer (text string)
#  
#   Expected outputs:
#     modified OUTPUT file or default site-dater.html


# Define some global constants
#
$directoryDelimiter= "/";	# a directory delimiter character
$headerDelimiter= "<!-- site-dater.pl header -->\n";
$footerDelimiter= "<!-- site-dater.pl footer -->\n";
$dateTag= "#date";		# substitute a real date for this tag
$meTag= "#me";			# substitute my reference for this tag
$me= "<a href="			# a reference to this script
  . "\"http://www.ayradyss.org/programs/"
  . "past.html#site-dater\">site-dater.pl</a>"; 

# Define libraries and modules
#
push (@INC, 'pwd');		# add current directory to the search list
use Getopt::Long;		# command line options processor
require "ctime.pl";		# for human readable date conversion


# Initialize
#
GetOptions("c|configuration=s" => \$configFile,
	   "cgi|ssi" => \$cgi,
	   "d|dir|directory=s" => \$directory,
	   "l|links|followlinks" => \$links,
	   "r|rel|relative" => \$relative,
	   "q|quiet" => \$quiet,
	   "v|verbose" => \$verbose
	   );
&Initialize();


# Scan the directory hierarchy
#
%list= ();			# clear the list of files before accumulation
&ScanDirectory($directory);
print "\n" if $verbose;
print("Finished scan.\n\n") unless $quiet;


# Build ordered HTML table of files
#
print "Generating HTML code...\n\n" unless $quiet;
if (-f $outputFile)
{				# output file already exists
  &ScanOutputFile();
}
else
{				# use defaults for header and footer
  &SetDefaultHeader();
  &SetDefaultFooter();
}
if ($cgi)
{				# dump our output directly for CGI or SSI mode 
  &GenerateOutput();
  print $output;
}
else
{				# create an HTML page 
  &GenerateOutputFile(); 
}
print "Done.\n" unless $quiet;


# Terminate gracefully
#
exit;


#
# Subroutines
#

# Initialize
#
# Learn setting and configure defaults
#
sub Initialize
{
  $configFile= ".site-dater" unless $configFile;
  $directory= `pwd` unless $directory;
  chop($directory) if $directory=~ /\n$/;
  $directory.= "$directoryDelimiter"
    unless $directory=~ /$directoryDelimiter$/;
  $configFile= $directory.$configFile # make absolute if relative
    unless $configFile=~ /^$directoryDelimiter/;
  $quiet= 1 if $cgi;		# cgi mode forces quiet
  $verbose= 0 if $cgi;		# cgi mode overrides verbose
  $quiet= 0 if $verbose;	# verbose overrides quiet
  
  $now= time;			# capture current time
  $date= &ctime($now);		# convert to human readable form
  chop($date);			# kill the trailing cr
  
  print("Starting scan of <$directory> on $date...\n\n")
    unless $quiet;
  
  if (-f $configFile)
  {				# found configuration directives
    open(CONFIG, $configFile)
      || die "Cannot read configuration from <$configFile>";
    while (<CONFIG>)
    {				# learn the directives
    Directives:
      {
	$outputFile= $2, last Directives 
	  if /^(OUTPUT=\s*)(.*)/;
	$urlOffset= $2, last Directives 
	  if /^(URLOFFSET=\s*)(.*)/;

	@keep= split(/[ \t\n]+/, $2), last Directives 
	  if /^(KEEP=\s*)(.*)/;
	@skip= split(/[ \t\n]+/, $2), last Directives
	  if /^(SKIP=\s*)(.*)/;

	$maxEntries= $2, last Directives 
	  if /^(MAXENTRIES=\s*)(.*)/;
	$minAge= $2, last Directives 
	  if /^(MINAGE=\s*)(.*)/;

	$table= $2, last Directives 
	  if /^(TABLE=\s*)(.*)/;

	$tablePageFont= $2, last Directives
	  if /^(PAGEFONT=\s*)(.*)/;
	$tableDateFont= $2, last Directives
	  if /^(DATEFONT=\s*)(.*)/;
	$tablePathFont= $2, last Directives
	  if /^(PATHFONT=\s*)(.*)/;
	$tableTitleFont= $2, last Directives
	  if /^(TITLEFONT=\s*)(.*)/;
	$tableHeadingFont= $2, last Directives
	  if /^(HEADINGFONT=\s*)(.*)/;
	$tableFooterFont= $2, last Directives
	  if /^(FOOTERFONT=\s*)(.*)/;
	$oneDayFont= $2, last Directives 
	  if /^(ONEDAY=\s*)(.*)/;
	$threeDayFont= $2, last Directives 
	  if /^(THREEDAY=\s*)(.*)/;
	$weekFont= $2, last Directives 
	  if /^(WEEK=\s*)(.*)/;

	$tableTitleBg= $2, last Directives
	  if /^(TITLEBG=\s*)(.*)/;
	$tableHeadingBg= $2, last Directives 
	  if /^(HEADINGBG=\s*)(.*)/;
	@tableBg= split(/[ \t\n]+/, $2), last Directives 
	  if /^(TABLEBG=\s*)(.*)/;
	$tableFooterBg= $2, last Directives 
	  if /^(FOOTERBG=\s*)(.*)/;

	$tableTitle= $2, last Directives
	  if /^(TITLE=\s*)(.*)/;
	$tableFooter= $2, last Directives 
	  if /^(FOOTER=\s*)(.*)/;
      }
    }
    close(CONFIG);
  }
  else
  {
    print "Did not find configuration directives; learning default settings.\n"
      if $verbose;
  }
  
  $outputFile= "site-dater.html" unless $outputFile;
  $outputFile= $directory.$outputFile # make absolute if relative
    unless $outputFile=~ /^$directoryDelimiter/;
  
  if ($verbose)
  {
    print "outputFile= $outputFile\n";
    print "Keep files matching: ", join(", ", @keep), "\n";
    print "Skip files matching: ", join(", ", @skip), "\n";
    if (defined($maxEntries))
    {
      if ($maxEntries == 1)
      { print "Only collecting one entry"; }
      else
      { print "Only collecting $maxEntries entries"; }
      if (defined($minAge))
      {
	if ($minAge == 1)
	{ print ", yet all entries younger than a day" }
	else
	{ print ", yet all entries younger than $minAge days" }
      }
      print ".\n";
    }
    else
    {
      print "Collecting all entries.\n";
    }
    print "\n";
  }
}


# ScanDirectory
#
# Scan a directory for entries and recurse into subdirectories
#
sub ScanDirectory
{
  my($directory)= shift;	# our target directory
  my(@subDirectories)= ();	# subdirectories withing current directory
  my(@entries)= ();		# valid entries within the current directory

  opendir(DIR, $directory) || warn "Cannot scan $directory.";
  @entries= readdir(DIR);	# list the directory contents
  closedir(DIR);
  
 CheckEntries:
  foreach $entry (@entries)
  {				# check each entry
    next if $entry eq '.';	# skip special entries
    next if $entry eq '..';

    if (@skip)
    {				# skip matched entries
      foreach $pattern (@skip)
      {
	if ($entry=~ /$pattern/)
	{
	  print "Skipping <$directory$entry>",
	  " due to matched pattern <$pattern>.\n" if $verbose;
	  next CheckEntries;
	}
      }
    }
      
    if ($links)
    {				# resolve symbolic links
      stat($directory.$entry);	# gather node information
    }
    else
    {				# do no resolve symbolic links
      lstat($directory.$entry);	# gather node information
      next if -l _;		# skip links
    }
    
    if (-d _)
    {				# found a subdirectory
      print "Descending into $directory$entry$directoryDelimiter...\n"
	if $verbose;
      &ScanDirectory($directory.$entry.$directoryDelimiter);
      print "Returned from $directory$entry$directoryDelimiter.\n"
	if $verbose;
      next;
    }

    if (-T _)
    {				# we should only care about text files
      if (@keep)
      {				# only keep matched entries
	foreach $pattern (@keep)
	{
	  if ($entry=~ /$pattern/)
	  {			# found a match
	    $key= -M _;		# key based on file modification date
	    print "Found <$directory$entry>\n" if $verbose;
	    push(@{$list{$key}}, $directory.$entry);
	  }
	}
      }
      else
      {				# match each unskipped
	$key= -M _;		# key based on file modification date
	print "Found <$directory.$entry>\n" if $verbose;
	push(@{$list{$key}}, $directory.$entry);
      }
    }
  }
}


# ScanOutputFile
#
# Scan exisiting output file for a header and a footer
#
sub ScanOutputFile
{
  open(TEMPLATE, $outputFile)
    || die "Cannot scan specified output file <$outputFile>";
    
  $header= "";			# clear header the text block
  while (<TEMPLATE>)
  {				# scan for header delimiters
    $header.= $_;		# accumulate header text
    last if (/$headerDelimiter/); # matched a header end delimiter
  }
  
  while (<TEMPLATE>)
  {				# scan for footer delimiters
    if (/$footerDelimiter/)
    {				# matched a footer start delimiter
      $footer= $footerDelimiter; # start the footer text block
      last;			# go on to footer text collection
    }
  }
  
  while (<TEMPLATE>)
  {				# the rest belongs to the footer
    $footer.= $_;		# accumulte footer text
  }
  
  close(TEMPLATE);

  &SetDefaultHeader		# confirm a proper header text block
    unless $header=~ /$headerDelimiter$/;
  &SetDefaultFooter		# confirm a proper footer text block
    unless $footer=~ /^$footerDelimiter/;
}


# SetDefaultHeader
#
# Return a default header block
#
sub SetDefaultHeader
{
  print "Using default header.\n" if $verbose;

  $header= "<html>\n"
    . "<head>\n"
      . "<title>site-dater.pl</title>\n"
	. "</head>\n"
	  . "\n"
	    . "<body>\n"
	      . $headerDelimiter;
}


# SetDefaultFooter
#
# Return a default footer block
#
sub SetDefaultFooter
{
  print "Using default footer.\n" if $verbose;

  $footer= $footerDelimiter
    . "</body>\n"
      . "</html>\n";
}


# GenerateOutputFile
#
# Generate an HTML page from the header, our file list, and the footer
#
sub GenerateOutputFile
{
  &GenerateOutput();		# create our table

  open(OUTPUT, ">$outputFile")	# generate a new web page
    || die "Cannot create an output file <$outputFile>";
  
  print OUTPUT 
    $header,			# default or preserved header
    $output,			# our generated table
    $footer;			# default or preserved footer

  close(OUTPUT);
}

# GenerateOutput
#
# Generate an HTML table of our processed entries
#
sub GenerateOutput
{
  my($row)= 1;			# table row counter
  
  # Start the table
  $table= " $table" if $table;
  $output= "<table".$table.">\n";

  # Table title
  if ($tableTitle)
  {				# construct the title row
    if ($tableTitleBg) { $bgColor= " bgcolor=\"$tableTitleBg\""; }
    else { $bgColor= ""; }
    if ($tableTitleFont)
    {				# specify a font for the title row
      $font= "<font $tableTitleFont>";
      $terminator= "</font>";
    }
    else { $font= $terminator= ""; }
    
    $output.= "<tr><td colSpan=\"3\"".$bgColor." align=\"center\">"
      .$font.$tableTitle.$terminator."</td></tr>\n";
  }

  # Column headings
  if ($tableHeadingBg) { $bgColor= " bgcolor=\"$tableHeadingBg\""; }
  else { $bgColor= ""; }
  if ($tableHeadingFont)
  {				# specify a font for the headings row
    $font= "<font $tableHeadingFont>";
    $terminator= "</font>";
  }
  else { $font= $terminator= ""; }

  $output.= "<tr><td align=\"left\"".$bgColor.">".$font
    ."&nbsp;&nbsp;Title".$terminator."</td>"
    ."<td align=\"center\"".$bgColor.">".$font
    ."Date Modified".$terminator."</td>"
    ."<td align=\"left\"".$bgColor.">".$font
    ."&nbsp;&nbsp;Path".$terminator."</td></tr>\n";

  # Table body
  for ($tableRowBg=0; $tableBg[$tableRowBg]; $tableRowBg++)
  {				# transform row background colors
    $tableBg[$tableRowBg]= " bgcolor=\"$tableBg[$tableRowBg]\"";
  }

  if ($tablePageFont)
  {				# specify a font for the page titles column
    $titlesFont= "<font $tablePageFont>";
    $titlesTerminator= "</font>";
  }
  else { $titlesFont= $titlesTerminator= ""; }
  if ($tableDateFont)
  {				# specify a font for the dates column
    $datesFont= "<font $tableDateFont>";
    $datesTerminator= "</font>";
  }
  else { $datesFont= $datesTerminator= ""; }
  if ($oneDayFont)
  {				# specify a font for dates within a day
    $oneDayFont= "<font $oneDayFont>";
    $oneDayTerminator= "</font>";
    $oneDay= " $oneDayFont"."Within a day</font> ";
  }
  else { $oneDayFont= $oneDayTerminator= ""; }
  if ($threeDayFont)
  {				# specify a font for dates within three days
    $threeDayFont= "<font $threeDayFont>";
    $threeDayTerminator= "</font>";
    $threeDay= " $threeDayFont"."Within three days</font> ";
  }
  else { $threeDayFont= $threeDayTerminator= ""; }
  if ($weekFont)
  {				# specify a font for dates within a week
    $weekFont= "<font $weekFont>";
    $weekTerminator= "</font>";
    $week= " $weekFont"."Within a week</font> ";
  }
  else { $weekFont= $weekTerminator= ""; }
  if ($tablePathFont)
  {				# specify a font for the paths column
    $pathsFont= "<font $tablePathFont>";
    $pathsTerminator= "</font>";
  }
  else { $pathsFont= $pathsTerminator= ""; }

  foreach $entry (sort numerically (keys %list))
  {
    if (defined($maxEntries) && $row > $maxEntries)
    {				# check if we collected too many entries
      if (!defined($minAge))
      {				# no minimum age limit
	print "Row #$row: reached entries limit [$maxEntries].\n" if $verbose;
	last;
      }
      elsif ($minAge < $entry)
      {				# too many entries and older than the age limit
	print "Row #$row: reached entries limit [$maxEntries]",
	" at entry aged $entry days.\n" if $verbose;
	last;
      }
    }
    
    print scalar(@{$list{$entry}}), " files with time stamp <$entry>;\n",
    "\t @{$list{$entry}}\n"
      if $verbose and @{$list{$entry}} > 1;
    foreach $candidate (@{$list{$entry}})
    {			        # iterate through entries with this stamp
      next			# skip the file we are generating
	if $candidate eq $outputFile;

      if ($candidate=~ /^$directory(.*)/)
      {				# make the path relative to the top
	$relativePath= $1;
      }
      else
      {				# should not happen
	$relativePath= "";	# but just in case
      }
      $title= &GetPageTitle($candidate);
      print "Row #$row: grabbed title \"$title\" from <$candidate>\n"
	if $verbose;
      $url= "<a href=\"$urlOffset" . &FixSpecials($relativePath) . "\">$title</a>";
      $modificationTime= &TimeStamp($entry);
    
      $days= int($entry);	# only need whole days below
      if ($days < 1)
      {
	$modificationTime=
	  $oneDayFont.$modificationTime.$oneDayTerminator; 
      }
      elsif ($days < 3) 
      { 
	$modificationTime=
	  $threeDayFont.$modificationTime.$threeDayTerminator;
      }
      elsif ($days < 7)
      {
	$modificationTime=
	  $weekFont.$modificationTime.$weekTerminator;
      }
      else 
      { 
	$modificationTime=
	  $datesFont.$modificationTime.$datesTerminator;
      }
      
      if (@tableBg)
      {				# alternate background colors
	$bgColor= $tableBg[$row % @tableBg];
      }
      else { $bgColor= ""; }	# none set

      $output.= "<tr><td".$bgColor.">"
	.$titlesFont.$url.$titlesTerminator."</td>"
	."<td align=\"right\"".$bgColor.">".$modificationTime."</td>"
	."<td".$bgColor.">".$pathsFont.$relativePath.$pathsTerminator
	."</td></tr>\n";	# a table row

      $row++;			# increment our row count
    }
  }

  # Table legend
  if ($tableHeadingBg) { $bgColor= " bgcolor=\"$tableHeadingBg\""; }
  else { $bgColor= ""; }
  if ($oneDay || $threeDay || $week)
  {				# generate a legend from the color codes
    if ($tableHeadingFont)
    {				# specify font for the legend row
      $font= "<font $tableHeadingFont>" ;
      $terminator= "</font>";
    }
    else { $font= $terminator= ""; }
    $legend= $font."Age color legend: ".$terminator;
    $legend.= " [$oneDay] " if $oneDay;
    $legend.= " [$threeDay] " if $threeDay;
    $legend.= " [$week] " if $week;

    $output.= "<tr><td colSpan=\"3\" align=\"center\"".$bgColor.">"
      .$legend."</td></tr>\n";
  }

  # Table footer
  if ($tableFooter)
  {				# construct the table footer
    $tableFooter=~ s/$dateTag/$date/; # date stamp
    $tableFooter=~ s/$meTag/$me/; # personal reference

    if ($tableFooterBg) { $bgColor= " bgcolor=\"$tableFooterBg\""; }
    else { $bgColor= ""; }
    if ($tableFooterFont)
    {				# specify a font for the footer row
      $font= "<font $tableFooterFont>";
      $terminator= "</font>";
    }
    else { $font= $terminator= ""; }

    $output.= "<tr><td colSpan=\"3\"".$bgColor." align=\"center\">"
      .$font.$tableFooter.$terminator."</td></tr>\n";
  }

  $output.= "</table>\n";	# end our table
}


# GetPageTitle
#
# Open a specified HTML file and grab its title
#
sub GetPageTitle
{
  $path= shift;			# path to our target

  open(INPUT, $path) || die "Cannot read <$path>";
  while (<INPUT>)
  {				# scan until we hit out keyword
    if (/<title>(.+)<\/title>/ 
	|| /<TITLE>(.+)<\/TITLE>/
	|| /<Title>(.+)<\/Title>/)
    {				# got a same line match
      close(INPUT);
      return $1;
    }
  }				# don't deal with multi-line titles yet
  
  close(INPUT);
  print "Failed to find a title within <$path>\n" if $verbose;
  return "[no title]";		# return a default -- did not find the title
}


# TimeStamp
#
# Return a verbose time stamp based on the days since now value
# Make the stamp relative to now if so set, absolute otherwise 
#
sub TimeStamp
{
  my($daysElapsed)= shift;	# days before now
  my($amPmLabel);		# am or pm time label
  my($weekDay);			# long weekday name
  my($absoluteDate);		# absolute modification date value

  unless ($relative)
  {				# create an absolute date stamp
    $absoluteDate= &ctime($now-$daysElapsed * 60 * 60 * 24);
    chop($absoluteDate);	# remove that pesky return
    return $absoluteDate;
  }

  my($eSecond, $eMinute, $eHour, $eMDay, $eMon, $eYear,
     $eWeekDay, $eYearDay, $eIsDST)=
    localtime($now-$daysElapsed * 60 * 60 * 24);
  my($nSecond, $nMinute, $nHour, $nMDay, $nMon, $nYear,
     $nWeekDay, $nYearDay, $nIsDST)=
    localtime($now);

  $daysElapsed= int($daysElapsed); # ignore day fractions
  if ($daysElapsed < 1)
  {				# within 24 hours
    $eMinute= "0" . $eMinute if $eMinute < 10;
    if ($eHour > 12)
    {				# convert to pm 12-hour time
      $amPmLabel= "pm";
      $eHour-= 12;
    }
    else
    {				# still am
      $amPmLabel= "am";
      $eHour= 12 if $eHour == 0; # midnight
    }
    if ($eMDay == $nMDay)
    {				# same day
      return "Today at $eHour:$eMinute" . $amPmLabel;
    }
    else
    {				# yesterday
      return "Yesterday at $eHour:$eMinute" . $amPmLabel;
    }
  }
  elsif ($daysElapsed < 2)
  {				# within 48 hours
    if ($eMDay == ($nMDay - 1) )
    {				# yesterday
      $eMinute= "0" . $eMinute if $eMinute < 10;
      if ($eHour > 12)
      {				# convert to pm 12-hour time
	$amPmLabel= "pm";
	$eHour-= 12;
      }
      else
      {				# still am
	$amPmLabel= "am";
	$eHour= 12 if $eHour == 0; # midnight
      }
      return "Yesterday at $eHour:$eMinute" . $amPmLabel;
    }
    else
    {				# day before yesterday
      $weekDay=			# assign a weekday name
	(Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
	  [$eWeekDay];
      return "last $weekDay";
    }
  }
  elsif ($daysElapsed < 7)
  {				# sometime within a week
    $weekDay=			# assign a weekday name
	(Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
	  [$eWeekDay];
    return "last $weekDay";
  }
  else
  {				# more than a week ago
    my($date)= ($eMon + 1) . "/$eMDay/" . (1900 + $eYear) . "  ";
    return $date;
  }
}


# FixSpecials
#
# Substitute special characters with their proper URL hexadecimal codes
# <http://www.blooberry.com/indexdot/html/topics/urlencoding.htm>
#
sub FixSpecials
{
  my($string)= shift;		# original string

  print "Original: $string\n" if $verbose;

  $string=~ s/%/%25/g;	# replace "percent" symbols
  $string=~ s/ /%20/g;	# replace spaces
  $string=~ s/\"/%22/g;	# replace quotation marks
  $string=~ s/\</%3C/g;	# replace "less than" symbols
  $string=~ s/\>/%3E/g;	# replace "more than" symbols
  $string=~ s/\#/%23/g;	# replace "pound" symbols
  $string=~ s/\{/%7B/g;	# replace left curly braces
  $string=~ s/\}/%7D/g;	# replace right curly braces
  $string=~ s/\|/%7C/g;	# replace vertical bars
  $string=~ s/\\/%5C/g;	# replace backslashes
  $string=~ s/\^/%5E/g;	# replace carets
  $string=~ s/\~/%7E/g;	# replace tildes
  $string=~ s/\[/%5B/g;	# replace left square braces
  $string=~ s/\]/%5D/g;	# replace right square braces
  $string=~ s/\`/%60/g;	# replace back ticks

  print "Filtered: $string\n" if $verbose;

  return $string;
}


# numerically
#
# Specifies a numerical rather than an alphabetical sort
#
sub numerically { $a <=> $b; }