#! /usr/bin/perl -w
#
# Generate an HTML weekly schedule based on an RDF calendar document
#
# Author:
#
#	Hugo Haas <mailto:hugo@w3.org> <http://www.w3.org/People/Hugo/>
#
# More information:
#
#	http://dev.w3.org/cvsweb/2003/weekly-view/
#
# This program is licensed under the W3C(r) License:
#
#	http://www.w3.org/Consortium/Legal/copyright-software
#
# TODO:
# - use an RDF parser instead of an XML one (RDF::Core)
# - check that RDF used is OK
# - improve performance
# - support for timezones
# - real support for recurrence
# - different types of output
# - add support for overlapping events

use strict;
use XML::GDOME;
use Date::Manip;
use Getopt::Long;
use POSIX qw(floor ceil);

my ($version) = q$Revision: 1.6 $ =~ /(\d+[\d\.]*\.\d+)/;

# Capture command-line
my $cl = $0 . ' ' . join(' ', @ARGV);

# Options
my $debug = 0;
my $quiet = 0;
# Range
# Days
my $dbeg = 'Monday';
my $dend = 'Sunday';
# Hours
my $hbeg = '8am';
my $hend = '8pm';
# Interval (in minutes)
my $tint = 30;
# Width of the time column
my $hwidth = 5;
# Show only worked hours?
my $onlyworking = 0;
# Encoding
my $encoding = 'iso-8859-1';
# Output table document only?
my $tableOnly = 0;
# Output file
my $outfile;
# Timezone offset
my $offset;

sub showUsage() {
    print STDERR "Usage: $0 <URL>\n\n";
    print STDERR "  -f <day>	First day to show (default: $dbeg)\n";
    print STDERR "  -l <day>	Last day to show (default: $dend)\n";
    print STDERR "  -b <time>	First time to show (default: $hbeg)\n";
    print STDERR "  -e <time>	Last time to show (default: $hend)\n";
    print STDERR "  -i #		Step, in minutes (default: $tint)\n";
    print STDERR "  -z #		Timezone offset for display, in hours\n";
    print STDERR "  -w #		Width of the time column, in percent (default: $hwidth)\n";
    print STDERR "  -o		Show only worked hours\n";
    print STDERR "  -s <URL>   	Add URL as a stylesheet in the output\n";
    print STDERR "  -c <encoding>	Specify output encoding (default: $encoding)\n";
    print STDERR "  -n <file>	Specify output filename (default: STDOUT)\n";
    print STDERR "  -t		Only output the table element, not the whole document\n";
    print STDERR "\n  -h		This help message\n";
    print STDERR "  -v		Show version\n";
    print STDERR "  -d		Debug mode\n";
    print STDERR "  -q		Quiet mode\n";
    print STDERR "\nSee http://dev.w3.org/cvsweb/2003/weekly-view/ for more information.\n"
}

# Parse command line
my %opts;
my @stylesheets = ();
# Add -o
my $help = 0;
my $showver = 0;
GetOptions('help|?' => \$help,
	   'd' => \$debug,
	   'v' => \$showver,
	   'q' => \$quiet,
	   'f=s' => \$dbeg,
	   'l=s' => \$dend,
	   'b=s' => \$hbeg,
	   'e=s' => \$hend,
	   'i=i' => \$tint,
	   'w=i' => \$hwidth,
	   'c=s' => \$encoding,
	   'n=s' => \$outfile,
	   'z=f' => \$offset,
	   's=s' => \@stylesheets,
	   'o' => \$onlyworking,
	   't' => \$tableOnly
	   );
if ($help) {
    &showUsage();
    exit(0);
}
if ($showver) {
    print STDERR "weekly.pl $version\n";
    print STDERR "Hugo Haas";
    exit(0);
}

if ($#ARGV < 0) {
    &showUsage();
    exit(1);
}

my $file = $ARGV[0];

my %eventList;

# Day numbers to name and back (isn't there a function for that?)
my %days = ( 'SU' => 7, 'MO' => 1, 'TU' => 2, 'WE' => 3,
	     'TH' => 4, 'FR' => 5, 'SA' => 6 );
my %namedays = ( 7 => 'Sunday' , 1 => 'Monday', 2 => 'Tuesday',
		 3 => 'Wednesday', 4 => 'Thursday', 5 => 'Friday',
		 6 => 'Saturday');
# Out of office entry in the view
my %outofoffice = ( name => 'Off', class => 'off' );

# Namespaces
my $icalNS = 'http://www.w3.org/2002/12/cal/ical#';
my $xhtmlNS = 'http://www.w3.org/1999/xhtml';
my $wschedNS = 'http://www.w3.org/2003/07/wsched#';
my $xlinkNS = 'http://www.w3.org/1999/xlink';

# Parse document
my $doc = XML::GDOME->createDocFromURI($file);

my $events = $doc->getElementsByTagNameNS($icalNS, 'Vevent');

my $ecount = 0;
# Find events
for (my $i = 0; $i < $events->getLength; $i++) {
    my $e = $events->item($i);
    my %ev;
    # Get start and end dates
    $ev{start} = &getDateTime($e, 'dtstart');
    next unless defined($ev{start});
    $ev{end} = &getDateTime($e, 'dtend');
    next unless defined($ev{end});
    $ev{duration} = Delta_Format(DateCalc($ev{start}, $ev{end}),0, '%hd') * 60;
    # Get class, if any
    my $attr;
    $attr = $e->getAttributeNodeNS($xhtmlNS, 'class');
    if (defined($attr)) {
	$ev{class} = $attr->getValue;
    }
    $attr = $e->getAttributeNodeNS($xlinkNS, 'href');
    if (defined($attr)) {
	$ev{href} = $attr->getValue;
    }
    # If it a working hours defining entry?
    $attr = $e->getAttributeNodeNS($wschedNS, 'type');
    if (defined($attr) && $attr->getValue eq 'working') {
	$ev{working} = 1;
    }
    # Description
    my $nl;
    $nl = $e->getElementsByTagNameNS($icalNS, 'summary');
    if (defined($nl) and defined($nl->item(0))) {
	my $sum = $nl->item(0);
	$ev{name} = $sum->getFirstChild->getNodeValue;
    } else {
	$ev{name} = "Event $i";
    }
    $ev{description} = &getFirstTextValue($e, $icalNS, 'description');
    # Get recurrence
    my $f = &getFirstTextValue($e, $icalNS, 'freq');
    if (! defined($f) || $f ne 'WEEKLY') {
	&debug("Sorry, only WEEKLY is supported for the weekly view");
	# Add support for daily and others...
    }
    my $b = &getFirstTextValue($e, $icalNS, 'byday');
    next unless defined($b);
    # Description of recurrence
    foreach my $day (split(',', $b)) {
	# Hmmm, this is useless in the end
	my $recu = '0:0:1*' . $days{$day} . ':' . UnixDate($ev{start}, "%T");
	&debug("Recurrence on $day -> $recu");
	$ev{recu} = $recu;
	# Time to store
	$eventList{$ecount}->{name} = $ev{name};
	$eventList{$ecount}->{class} = $ev{class};
	$eventList{$ecount}->{start} = $ev{start};
	$eventList{$ecount}->{recu} = $ev{recu};
	$eventList{$ecount}->{end} = $ev{end};
	$eventList{$ecount}->{duration} = $ev{duration};
	$eventList{$ecount}->{working} = $ev{working} if $ev{working};
	$eventList{$ecount}->{href} = $ev{href} if $ev{href};
	$eventList{$ecount}->{description} = $ev{description} if $ev{description};
	$ecount++;
    }
}

# Figure out what events to show
my %calendar;
my $dayCount;
my $dint = Delta_Format(DateCalc($dbeg, $dend), 0, '%dv') + 1;
my $hint = Delta_Format(DateCalc($hbeg, $hend), 0, '%hd');
my $titer = ceil($hint * 60 / $tint);
my $hintm = ceil($hint * 60);
&debug("Recursivity counters: dint: $dint; hint: $hint; titer = $titer");
my $totaliter = $dint * $titer;
my $globcount = 1;

my $d = ParseDate("$dbeg $hbeg");
for ($dayCount = 1; $dayCount <= $dint; $dayCount++) {
    my $lim;
    my $hlim = DateCalc($d, "+ $hintm minutes");
    &debug("==> Scanning $d - $hlim range:");
    my $begofday = ParseDate(UnixDate($d, "%Y%m%d"));
    my $h = $d;
    for(my $i = $titer; $i; $h = $lim) {
	&counter($globcount++, $totaliter);
	$i--;
	$lim = DateCalc($h, "+ $tint minutes");
	my $hvalue = UnixDate($h, "%T");
	&debug("====> Slot: $h $lim");
	if ($onlyworking) {
	    my $amiworking = 0;
	    foreach my $e (keys(%eventList)) {
		next unless $eventList{$e}->{working};
		my @rec = ParseRecur($eventList{$e}->{recu},
				     $eventList{$e}->{start},
				     $begofday, $lim);
		next unless ($#rec > -1);
		if (Date_Cmp(DateCalc($rec[0],
				      "+ $eventList{$e}->{duration} minutes"),
			     $h) <= 0 ) {
		    next;
		}
		$amiworking = 1;
		last;
	    }
	    unless ($amiworking) {
		&debug('Out of working hours.');
		$calendar{$dayCount}{$hvalue}{event} = \%outofoffice;
		$calendar{$dayCount}{$hvalue}{fill} = 0;
		next;
	    }
	}
	foreach my $e (keys(%eventList)) {
	    next if $eventList{$e}->{working};
	    my @rec = ParseRecur($eventList{$e}->{recu},
				 $eventList{$e}->{start},
				 $h, $lim);
	    next unless ($#rec > -1);
	    &debug($eventList{$e}->{name}." starting");
	    # Figure out the number of slots filled
	    my $intervals = floor($eventList{$e}->{duration} / $tint);
	    my $add = $intervals * $tint; 
	    if (Date_Cmp(DateCalc($h, "+ $add minutes"),
			 DateCalc($rec[0],
				  "+ $eventList{$e}->{duration} minutes"))
		< 0 ) {
		$intervals++;
	    }
	    &debug("$intervals slots filled");
	    # Store information
	    $calendar{$dayCount}{$hvalue}{event} = $eventList{$e};
	    $calendar{$dayCount}{$hvalue}{fill} = $intervals - 1;
	    $calendar{$dayCount}{$hvalue}{time} =
		&cfTime($eventList{$e}->{start})
		. '-'
		. &cfTime($eventList{$e}->{end});
	}
    }
    # Next iteration
    $d = DateCalc($d, '+ 1 day');
}

my $xhtmlOutput = '<?xml version="1.0" encoding="iso-8859-1"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
  <title>Weekly view</title>
  <style type="text/css">';
$xhtmlOutput .= 'th.hwidth { width: ' . $hwidth . '% }';
$xhtmlOutput .= 'th.dwidth { width: ' . floor((100 - $hwidth) / $dint) . '% }';
$xhtmlOutput .=  "</style>\n";
foreach my $ss (@stylesheets) {
    $xhtmlOutput .=  "<link rel='stylesheet' type='text/css' href='$ss' />\n";
}
$cl =~ s/\'/&quot;/g;
$xhtmlOutput .= "<meta name='Generator' content='weekly.pl version $version' />\n";
$xhtmlOutput .= "<meta name='Generator' content='http://dev.w3.org/cvsweb/~checkout~/2003/weekly-view/' />\n";
$xhtmlOutput .= "<meta name='Generator' content='$cl' />\n";
$xhtmlOutput .=  '</head>
<body>
<table class="schedule">
  <tbody>
    <tr>
      <th class="hwidth"></th>';

for ($dayCount = 1; $dayCount <= $dint; $dayCount++) {
   $xhtmlOutput .= '<th class="dwidth">'. $namedays{$dayCount} . '</th>';
}
   $xhtmlOutput .= '    </tr>
  </tbody>
</table>
</body>
</html>
';

my $output = XML::GDOME->createDocFromString($xhtmlOutput);
my $tbody = $output->getElementsByTagName('tbody')->item(0);

$offset = floor($offset * 60) if $offset;
my %rowspanning;
my $h = ParseDate("$dbeg $hbeg");
for (my $i = $titer;
     $i;
     $h = DateCalc($h, "+ $tint minutes")) {
    $i--;
    my $tr = $output->createElement('tr');
    my $time = UnixDate($h, "%H:%M");
    my $child;
    $child = $output->createElement('th');
    $child->appendText($time);
    if ($offset) {
	$child->appendText(' ');
	my $offh = DateCalc($h, "$offset minutes");
	$time = UnixDate($offh, "%H:%M");
	&addSpanned($output, $child, 'wsoff', $time);
    }
    $tr->appendChild($child);
    for ($dayCount = 1; $dayCount <= $dint; $dayCount++) {
	if (defined($rowspanning{$dayCount})) {
	    # Rowspanning: skip
	    if ($rowspanning{$dayCount} > 0) {
		$rowspanning{$dayCount}--;
		next;
		# @@@ If we have overlapping events, it will break here
	    }
	}
	if (defined($calendar{$dayCount})) {
	    my $hu = UnixDate($h, "%T");
	    if (defined($calendar{$dayCount}{$hu})) {
		my $e = $calendar{$dayCount}{$hu};
		# Rowspanning?
		$child = $output->createElement('td');
		if ($e->{fill} > 0) {
		    $child->setAttribute('rowspan', $e->{fill} + 1);
		    $rowspanning{$dayCount} = $e->{fill};
		}
		# Add class
		if ($e->{event}->{class}) {
		    $child->setAttribute('class', $e->{event}->{class});
		}
		&addSpanned($output, $child, 'wstime', $e->{time});
		$child->appendText(' ');
		my $descElem;
		if ($e->{event}->{href}) {
		    $descElem = $output->createElement('a');
		    $descElem->setAttribute('href', $e->{event}->{href});
		    $descElem->appendText($e->{event}->{name});
		} else {
		    $descElem = $output->createTextNode($e->{event}->{name});
		}
		$child->appendChild($descElem);
		$child->appendText(' ');
		&addSpanned($output, $child, 'wscomm',
			    $e->{event}->{description});
		$tr->appendChild($child);
		next;
	    }
	}
	# We should not arrive here unless there is really nothing happening
	$child = $output->createElement('td');
	&addSpanned($output, $child, 'wstime', $time);
	$tr->appendChild($child);
    }
    $tbody->appendChild($tr);
}

print STDERR "\n" unless ($quiet || $debug);

my $outstring;
if ($tableOnly) {
    # How does one specify the encoding?
    my $outelem;
    $outelem = $output->createComment("\nGenerated with:\n$cl\n");
    $outstring = $outelem->toString() . "\n";
    $outelem = $output->getElementsByTagName('table')->item(0);
    $outstring .= $outelem->toString() . "\n";
} else {
    $outstring = $output->toStringEnc($encoding);
}

my $outh = \*STDOUT;

if ($outfile) {
    open(OUT, "> $outfile") || die "Can't open $outfile: $!";
    $outh = \*OUT;
}

print $outh $outstring;

exit(0);

###################################################################

sub addSpanned() {
    my ($d, $c, $class, $t) = @_;
    return unless defined($t);
    my $e = $d->createElement('span');
    $e->setAttribute('class', $class);
    $e->appendText($t);
    $c->appendChild($e);
}

sub getFirstTextValue() {
    my ($node, $ns, $tag) = @_;
    my $nl = $node->getElementsByTagNameNS($ns, $tag);
    return undef unless defined($nl)
	&& defined($nl->item(0))
	&& defined($nl->item(0)->getFirstChild);
    return $nl->item(0)->getFirstChild->getNodeValue;
}

sub getDateTime() {
    my ($node, $tag) = @_;
    my $nl = $node->getElementsByTagNameNS($icalNS, $tag);
    return undef unless defined($nl);
    my $n = $nl->item(0);
    my $dateTime = $n->getElementsByTagNameNS($icalNS, 'dateTime');
    return undef unless defined($dateTime);
    my $date =  $dateTime->item(0)->getFirstChild->getNodeValue;
    &debug("Found $tag: $date");
    return ParseDate($date);
}

# Compress time displayed: 07:00 -> 7
sub cfTime() {
    my ($h) = @_;
    my $ts = UnixDate($h, "%k:%M");
    if ($ts =~ /(\d+):00$/) {
	return $1;
    } else {
	return $ts;
    }
}

# Debugging code
sub debug() {
    return unless ($debug);
    print STDERR $_[0] . "\n";
}

sub counter() {
    return if ($debug || $quiet);
    my ($a, $b) = @_;
    if ($a == 1) {
	print STDERR "Processing: 000 %";
    }
    printf STDERR "\b\b\b\b\b";    
    my $percent = floor(100 * $a / $b);
    printf STDERR "%3d %%", $percent;
}
