=head1 NAME
coursedb.pm - an object-oriented PAI for reading and processing
lecture course information from a coursedb.txt file
=cut
use lib '/anfs/www/tools/share/ucampas/perl-PlexTree';
use PlexTree;
use bytes;
use strict;
package Misc;
# Miscellaneous auxiliary routines, e.g. for writing HTML and CSV files
sub utf8_to_sgml {
use Encode;
return unless defined wantarray;
my @s = @_;
for my $s (@s) {
$s=~s/&/&/g;
$s=~s/</g;
$s=~s/>/>/g;
$s=~s/([\x00-\x08\x0b\x0c\x0e-\x1f\x7f])/sprintf("%d;", ord($1))/ge;
no bytes;
my $u;
$s=~s/([\xc0-\xdf][\x80-\xbf]|[\xe0-\xef][\x80-\xbf]{2}|[\xf0-\xf7][\x80-\xbf]{3})/$u=$1,Encode::_utf8_on($u),sprintf("%d;", ord($u))/ge;
}
return wantarray ? @s : $s[0];
}
sub utf8_to_sgmlatt {
use Encode;
return unless defined wantarray;
my @s = @_;
for my $s (@s) {
$s=~s/&/&/g;
$s=~s/</g;
$s=~s/>/>/g;
$s=~s/\'/'/g;
$s=~s/\"/"/g;
$s=~s/([\x00-\x08\x0b\x0c\x0e-\x1f\x7f])/sprintf("%d;", ord($1))/ge;
no bytes;
my $u;
$s=~s/([\xc0-\xdf][\x80-\xbf]|[\xe0-\xef][\x80-\xbf]{2}|[\xf0-\xf7][\x80-\xbf]{3})/$u=$1,Encode::_utf8_on($u),sprintf("%d;", ord($u))/ge;
}
return wantarray ? @s : $s[0];
}
# Convert an array into a comma-separated value line
# according to http://tools.ietf.org/html/rfc4180
sub csv_line {
return join(',', map {
if (/[\r\n\",]/) {
# quote
my $s = $_;
s/\"/\"\"/g;
'"'.$s.'"';
} else {
# quoting not necessary
$_;
}
} @_) . "\r\n";
}
# Output an RFC2822 header field, folding the unstructured body
# to not exceed the recommended maximum line length of 78 characters.
# $name name of the header field (wihout trailing colon)
# $sep set to ',' to output a comma separated list, otherwise ''.
# @body sequence of body elements that will be output as foldable white space
# http://www.ietf.org/rfc/rfc2822.txt (section 2.2)
sub mail_header {
my ($name, $sep, @body) = @_;
local $_;
$name =~ /^[!-9;-~]+$/
or die("Invalid character in mail header-field name '$name'\n");
my @h;
my $col;
push @h, $name, ':';
$col += length($name) + 1;
while ($_ = shift @body) {
/[\n\r]/ and
die("Invalid CR or LF in mail header-field body element '$_'\n");
$_ .= $sep if @body;
if ($col + 1 + length($_) > 78) {
push @h, "\n";
$col = 0;
}
push @h, ' ', $_;
$col += 1 + length($_);
}
push @h, "\n";
return join('', @h);
}
package CourseDB;
# Load PlexTree datastructure into memory and map its main nodes onto
# suitable subclasses such that we can add domain-specific methods
# here without having to copy the whole tree into new objects.
use PlexTree;
use PlexTree::Text;
our @ISA = ("PlexTreeMem");
sub load {
my ($fndb) = @_;
my $db = bless PlexTree::Text::load($fndb) => 'CourseDB';
$db->update_indices;
my @errors = $db->verify;
die(@errors) if @errors;
return $db;
}
# initialize access indices
sub update_indices {
my ($db) = @_;
my $groups = $db->cd('groups');
my $classes = $db->cd('classes');
if (defined $groups) {
warn("Both groups and classes are defined, ignoring classes!\n")
if defined $classes;
$db->setaux('class', {
map { $_->str => (bless $_ => 'Class') }
grep { $_->listlen == 0 }
$groups->lfind(undef, 'all')
});
$db->setaux('classes', [
map { bless $_ => 'Class' }
grep { $_->listlen == 0 }
$groups->lfind(undef, 'all')
]);
} elsif (defined $classes) {
$db->setaux('class', {
map { $_->str => (bless $classes->cd($_) => 'Class') }
grep { $_->tag == PlexTree::TEXT }
$classes->keys
});
$db->setaux('classes', [
map { bless $_->cd($_) => 'Class' }
grep { $_->tag == PlexTree::TEXT }
$classes->keys
]);
}
my $courses = $db->cd('courses');
if (defined $courses) {
$db->setaux('course', {
map { $_->str => (bless $courses->cd($_) => 'Course') }
grep { $_->tag == PlexTree::TEXT }
$courses->keys
});
}
my $lecturers = $db->cd('lecturers');
if (defined $lecturers) {
$db->setaux('lecturer', {
map { $_->str => (bless $lecturers->cd($_) => 'Lecturer') }
grep { $_->tag == PlexTree::TEXT }
$lecturers->keys
});
}
foreach my $course ($db->courses) {
next unless $course->takes_place;
# index what each lecturer teaches
foreach my $lecturer ($course->proprietors, $course->contributors) {
$lecturer->addkey('teaches')->addkey(text($course->code));
}
}
}
sub verify {
my ($db) = @_;
my @errors;
my $myear=$db->get('myear');
unless ($myear > 1950 && $myear < 2020) {
push @errors, "Attribute mtern must specify the 4-digit calendar year of the Michaelmas term,\n" .
"e.g. mterm='2007'\n";
}
foreach my $class ($db->classes) {
push @errors, $class->verify;
}
foreach my $course ($db->courses) {
push @errors, $course->verify;
}
foreach my $lecturer ($db->lecturers) {
push @errors, $lecturer->verify;
}
return @errors;
}
sub class {
my ($db, $code) = @_;
return $db->aux('class')->{$code};
}
sub course {
my ($db, $code) = @_;
return $db->aux('course')->{$code};
}
sub lecturer {
my ($db, $code) = @_;
return $db->aux('lecturer')->{$code};
}
sub classes {
my ($db) = @_;
return @{$db->aux('classes')};
}
sub courses {
my ($db) = @_;
my @l = values %{$db->aux('course')};
use locale;
return sort { $a->title cmp $b->title } @l;
}
sub lecturers {
my ($db) = @_;
my @l = values %{$db->aux('lecturer')};
use locale;
return sort { $a->surname cmp $b->surname } @l;
}
# notations for the academic year, e.g. 1112 for 2011/2012
sub year{
my ($db, $notation, $offset) = @_;
my $myear=$db->get('myear');
$myear += $offset if defined $offset;
if ($notation eq '22') {
return sprintf("%02d%02d", $myear % 100, ($myear+1) % 100);
} elsif ($notation eq '4-2') {
return sprintf("%4d–%02d", $myear, ($myear+1) % 100);
} else {
die("year(): unknown notation '$notation'\n");
}
}
# Domain-specific subclasses of PlexTreeMem for courses, lecturers and classes
package Course;
use PlexTree;
our @ISA = ("PlexTreeMem");
# override cl() to switch back to PlexTreeMem class
sub cl { my ($self, @pos) = @_; (bless [@$self] => $ISA[0])->cd(@pos); }
# override cd() to switch back to PlexTreeMem class and to implement a
# sabbatical-cover attribute, which can contain overriding attributes
sub cd {
my ($self, @keys) = @_;
my $baseself = bless [@$self] => $ISA[0];
my $try = $baseself->cd(text 'sabbatical-cover', @keys);
return $try if defined $try;
$baseself->cd(@keys);
}
# Check for a parameters first in the course node, then in the node
# associated with the first class that attends this course, and
# finally at the top-level, returning a node cursor to the first value
# found.
sub paramc {
my ($self, $pname) = @_;
my $v;
my $n;
$v = $self->cd(text $pname);
return $v if defined $v;
my @classes = $self->classes;
return $classes[0]->paramc($pname) if @classes;
return;
}
# Like paramc, but return a string instead of a node cursor
sub param {
my ($self, $pname) = @_;
my $v = $self->paramc($pname);
return undef unless defined $v;
return $v->str_text;
}
sub title($) {
my ($self) = @_;
return $self->str_text;
}
sub code($) {
my ($self) = @_;
return $self->key->str_text;
}
sub needs_directory {
my ($self) = @_;
return 0 unless $self->takes_place;
return 0 if $self->get('url');
return 0 if $self->param('directory') eq '0';
return 1;
}
sub url($) {
my ($self, $pathprefix) = @_;
return 0 unless $self->takes_place;
my $url = $self->get('url');
return $url if $url;
return $pathprefix . $self->code . '/'
unless $self->param('directory') eq '0';
return;
}
sub html {
my ($self, $pathprefix) = @_;
my $html = Misc::utf8_to_sgml($self->title);
my $url = $self->url($pathprefix);
$html = '' . $html . ''
if $url;
return $html;
}
sub verify {
my ($self) = @_;
my $l = $self->cd('lecturer');
return () unless defined $l;
my $db = $self->top;
my @l;
push @l, $l->str if $l->str;
push @l, map { $_->str_text } $l->list;
@l = grep { !defined $db->lecturer($_) } @l;
return ( "Course '" . $self->code . "': unknown lecturers " .
join(', ', map { "'$_'" } @l) . ".\n" )
if @l;
return ();
}
sub proprietors {
my ($self) = @_;
my $l = $self->cd('lecturer');
return () unless defined $l;
my $db = $self->top;
if ($l->str) {
# we have one course proprietor, and optionally several contributors
return ($db->lecturer($l->str));
} else {
# all contributors are proprietors
return map { $db->lecturer($_->str) } $l->list;
}
}
sub contributors {
my ($self) = @_;
my $l = $self->cd('lecturer');
return () unless defined $l;
my $db = $self->top;
if ($l->str) {
# we have one course proprietor, and optionally several contributors
return map { $db->lecturer($_->str) } $l->list;
} else {
# all contributors are proprietors
return ();
}
}
sub oldnames($) {
my ($self) = @_;
my $oldnames = $self->cd('oldnames');
return map {$_->str_text} $oldnames->list if defined $oldnames;
return ();
}
sub takes_place {
my ($self) = @_;
# empty terms attribute means that the course will not take
# place this year, e.g. due to sabbatical leave
return defined $self->get('term');
}
sub terms {
my ($self) = @_;
return grep { defined $_ } ($self->get('term'),
$self->cd('term')->getl(0));
}
sub in_term {
my ($self, $term) = @_;
my $t = $self->cd('term');
return unless defined $t;
return $term eq $t->str_text;
}
sub continues_in_term {
my ($self, $term) = @_;
my $t = $self->cd('term');
return unless defined $t;
return $term eq $t->getl(0);
}
sub classes {
my ($self) = @_;
my $c = $self->cd('classes');
return () unless defined $c;
my $db = $self->top;
return map { $db->class($_->str) || die("Unknown class " .$_->str)} $c->keys;
}
sub group {
my ($self) = @_;
my @classes = $self->classes;
return $classes[0]->group if @classes;
return;
}
my $pastexamsdir = '/anfs/www/html/teaching/exams/pastpapers/';
my $pastexamsurl = 'http://www.cl.cam.ac.uk/teaching/exams/pastpapers/';
# return (title => url, title => url, ...) list of pages with related past exam questions)
sub examlinks {
my ($self) = @_;
my @l;
for my $coursename ($self->title, $self->oldnames) {
my $fn = $coursename;
$fn =~ s/\s//g;
$fn =~ s/ //g; # also eliminate NBSP
$fn = "t-$fn.html";
if (-r "$pastexamsdir$fn") {
push @l, $coursename => "$pastexamsurl$fn";
}
}
return @l;
}
package Lecturer;
use PlexTree;
our @ISA = ("PlexTreeMem");
# override cd() and cl() to switch back to PlexTreeMem class
sub cd { my ($self, @keys) = @_; (bless [@$self] => 'PlexTreeMem')->cd(@keys); }
sub cl { my ($self, @pos) = @_; (bless [@$self] => 'PlexTreeMem')->cd(@pos); }
sub code {
return $_[0]->key->str;
}
sub fullname {
return $_[0]->str;
}
sub title {
my $name = $_[0]->fullname;
$name =~ /^(Prof|Dr|Mr|Ms|Mrs|Miss) /;
return $1;
}
sub firstnames {
my $name = $_[0]->fullname;
$name =~ /^(?:(?:Prof|Dr|Mr|Ms|Mrs|Miss) )?(.+) \S+$/;
return $1;
}
sub surname {
my $name = $_[0]->fullname;
$name =~ /(\S+)$/;
return $1;
}
sub url {
my ($self) = @_;
my $url = $self->get('url');
return $url if defined $url;
my $l = $self->code;
if (-r "/home/$l/public_html/index.html" ||
-r "/home/$l/public_html/.htaccess") {
$url = "http://www.cl.cam.ac.uk/~$l/";
$self->setatt('url' => $url);
}
return $url;
}
sub html {
my ($self) = @_;
my $html = Misc::utf8_to_sgml($self->fullname) || '[name unknown]';
my $url = $self->url;
if (defined $url) {
$html = '' . $html . ''
}
return $html;
}
sub teaches {
my ($self) = @_;
my $t = $self->cd('teaches');
return unless defined $t;
my $db = $self->top;
return map { $db->course($_->str) } $t->keys
}
sub verify {
my @errors;
return @errors;
}
package Class;
use PlexTree;
our @ISA = ("PlexTreeMem");
# override cd() and cl() to switch back to PlexTreeMem class
sub cd { my ($self, @keys) = @_; (bless [@$self] => 'PlexTreeMem')->cd(@keys); }
sub cl { my ($self, @pos) = @_; (bless [@$self] => 'PlexTreeMem')->cd(@pos); }
# Check for a parameters first in the class node, then in the year node,
# then in the groups node, and finally in the top node,
# returning a node cursor to the first value found.
sub paramc {
my ($self, $pname) = @_;
my $v;
my $n;
$pname = text($pname); # convert attribute to text compound
while (defined $self) {
$v = $self->cd($pname);
return $v if defined $v;
$self = $self->parent;
}
return;
}
# Like paramc, but return a string instead of a node cursor
sub param {
my ($self, $pname) = @_;
my $v = $self->paramc($pname);
return undef unless defined $v;
return $v->str_text;
}
sub code {
my ($self) = @_;
if (defined $self->pos) {
return $self->str;
} else {
return $self->key->str;
}
}
sub title {
my ($self) = @_;
if (defined $self->pos) {
return $self->get('title');
} else {
return $self->str;
}
}
sub html {
my ($self, $pathprefix) = @_;
my $html = Misc::utf8_to_sgml($self->title);
$html = '' . $html . ''
if $self->param('index') || 1;
return $html;
}
sub group {
my ($self) = @_;
if (defined $self->pos && $self->depth == 3) {
return $self->parent;
} else {
die("unexpected location: ", join('/', map {$_->str} $self->path));
}
return;
}
sub verify {
my @errors;
return @errors;
}
1;