File Coverage

blib/lib/Oxford/Calendar.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             # Oxford University calendar conversion.
2             # Simon Cozens (c) 1999-2002
3             # Eugene van der Pijll (c) 2004
4             # Dominic Hargreaves / University of Oxford (c) 2007-2015
5             # Artistic License
6             package Oxford::Calendar;
7             $Oxford::Calendar::VERSION = "2.10";
8 2     2   36944 use strict;
  2         4  
  2         59  
9 2     2   1264 use Text::Abbrev;
  2         54  
  2         133  
10 2     2   1733 use Date::Calc qw(Add_Delta_Days Decode_Date_EU Delta_Days Mktime Easter_Sunday Date_to_Days Day_of_Week_to_Text Day_of_Week);
  0            
  0            
11             use YAML;
12             use Time::Seconds;
13             use Time::Piece;
14              
15             use constant CALENDAR => '/etc/oxford-calendar.yaml';
16             use constant SEVEN_WEEKS => 7 * ONE_WEEK;
17             use constant DEFAULT_MODE => 'nearest';
18             use constant TERMS => qw(Michaelmas Hilary Trinity);
19             use constant DAYS => qw(Sunday Monday Tuesday Wednesday Thursday Friday Saturday);
20              
21             # Constants defined by University regulations
22             use constant MICHAELMAS_START => (10, 1);
23             use constant MICHAELMAS_END => (12, 17);
24             use constant HILARY_START => (1, 7);
25             use constant HILARY_END_IF_EARLIER => (3, 25);
26             use constant TRINITY_START_IF_LATER => (4, 20);
27             use constant TRINITY_END => (7, 6);
28              
29             =head1 NAME
30              
31             Oxford::Calendar - University of Oxford calendar conversion routines
32              
33             =head1 SYNOPSIS
34              
35             use 5.10.0;
36             use Oxford::Calendar;
37             use Date::Calc;
38             say "Today is " . Oxford::Calendar::ToOx(reverse Date::Calc::Today);
39              
40             =head1 DESCRIPTION
41              
42             This module converts University of Oxford dates (Oxford academic dates)
43             to and from Real World dates, and provides information on Terms of the
44             University.
45              
46             The Terms of the University are defined by the
47             B, available online from
48              
49             L
50              
51             This document describes the start and end dates of Oxford Terms.
52              
53             In addition to this, the dates of Full Term, required to calculate the
54             week number of the term, are prescribed by Council, and published
55             periodically in the B.
56              
57             Full term comprises weeks 1-8 inclusive, but sometimes, dates outside of
58             full term are presented in the Oxford academic date format.
59             This module will optionally provide such dates.
60              
61             Data for these prescribed dates may be supplied in the file
62             F; if this file does not exist, built-in data
63             will be used. The built-in data is periodically updated from the
64             semi-authoritative source at
65              
66             L.
67              
68             or the authoritative source, the Gazette, available online from
69              
70             L.
71              
72             L
73             describes the academic year at Oxford.
74              
75             =head1 DATE FORMAT
76              
77             An Oxford academic date has the following format:
78              
79             =over
80              
81             , [st,nd,rd,th] week,
82              
83             =back
84              
85             where term name is one of
86              
87             =over
88              
89             =item *
90              
91             Michaelmas (autumn)
92              
93             =item *
94              
95             Hilary (spring)
96              
97             =item *
98              
99             Trinity (summer)
100              
101             =back
102              
103             Example:
104              
105             Friday, 8th Week, Michaelmas 2007
106              
107             =cut
108              
109             our %db;
110              
111             my $_initcal; # If this is true, we have our database of dates already.
112             my $_initrange;
113             my @_oxford_full_terms;
114              
115             sub _get_week_suffix {
116             my $week = shift;
117             die "_get_week_suffix: No week given" unless defined $week;
118             my $wsuffix = "th";
119             abs($week) == 1 && ( $wsuffix = "st" );
120             abs($week) == 2 && ( $wsuffix = "nd" );
121             abs($week) == 3 && ( $wsuffix = "rd" );
122            
123             return $wsuffix;
124             }
125              
126             sub _find_week {
127             my $tm = shift;
128             my $sweek = shift;
129             my $sweek_tm = shift;
130              
131             my $eow = $sweek_tm + ONE_WEEK;
132              
133             while ( $tm >= $eow ) {
134             $eow += ONE_WEEK;
135             $sweek++;
136             }
137             return $sweek;
138             }
139              
140             sub _init_db {
141             my $db;
142             if ( -r CALENDAR ) {
143             $db = YAML::LoadFile(CALENDAR);
144             }
145             else {
146             my $data = join '', ;
147             $db = YAML::Load($data);
148             }
149             %db = %{ $db->{Calendar} };
150             }
151              
152             sub _init_range {
153             foreach my $termspec ( keys %db ) {
154             next unless $db{$termspec};
155              
156             my $time = eval { Time::Piece->strptime($db{$termspec}->{start}, '%d/%m/%Y' ) }
157             or die
158             "Could not decode date ($db{$termspec}->{start}) for term $termspec: $@";
159              
160             push @_oxford_full_terms,
161             [$time, ($time + SEVEN_WEEKS), split(/ /, $termspec), $db{$termspec}->{provisional}];
162             }
163              
164             $_initrange++;
165             }
166              
167             sub _fmt_oxdate_as_string {
168             my ( $dow, $week, $term, $year ) = @_;
169             my $wsuffix = _get_week_suffix($week);
170             return "$dow, $week$wsuffix week, $term $year";
171             }
172              
173             sub _increment_term {
174             my ( $year, $term ) = @_;
175             if ( $term eq 'Michaelmas' ) {
176             return $year + 1, 'Hilary';
177             } elsif ( $term eq 'Hilary' ) {
178             return $year, 'Trinity'
179             } elsif ( $term eq 'Trinity' ) {
180             return $year, 'Michaelmas';
181             } else {
182             die "_increment_term: Unknown term $term";
183             }
184             }
185              
186             sub _sunday_of_first {
187             my ( $year, $term ) = @_;
188             Init() unless defined $_initcal;
189             my $date = $db{"$term $year"};
190             return undef unless $date;
191             return ( $date->{provisional}, Decode_Date_EU($date->{start}) );
192             }
193              
194             sub _to_ox_nearest {
195             my @date = @_;
196             my $confirmed = pop @date;
197             my $week;
198             my @term;
199             _init_range() unless defined $_initrange;
200             my $dow = Day_of_Week_to_Text( Day_of_Week( @date ) );
201             my $tm = Time::Piece->strptime(join('/', @date[0..2]), '%Y/%m/%d');
202             my @terms = sort { $a->[0] <=> $b->[0] } @_oxford_full_terms;
203             my ( $prevterm, $nextterm );
204             my $curterm = shift @terms;
205              
206             while ($curterm) {
207             if ( $tm < $curterm->[0] ) {
208             if ( $prevterm && $tm >= ($prevterm->[1] + ONE_WEEK) ) {
209             $nextterm = $curterm;
210             last;
211             } else {
212             die "Date out of range";
213             }
214             }
215             $prevterm = $curterm;
216             $curterm = shift @terms;
217             }
218             return undef unless $nextterm;
219              
220             # We are in the gap between terms .. which one is closest?
221             my $prevgap = $tm - ($prevterm->[1] + ONE_WEEK);
222             my $nextgap = $tm - $nextterm->[0];
223              
224             if ( abs($prevgap) < abs($nextgap) ) {
225             # if equal go for -th week
226             $week = _find_week( $tm, 8, $prevterm->[1] );
227             @term = @{$prevterm};
228             } else {
229             my $delta = $nextgap / (24 * 60 * 60);
230             $week = 1 + int( $delta / 7 );
231             $week -= 1 if $delta % 7;
232             @term = @{$nextterm};
233             }
234             return undef if $term[4] && $confirmed;
235             return ($dow, $week, $term[2], $term[3]) if ( wantarray );
236             return _fmt_oxdate_as_string( $dow, $week, $term[2], $term[3] );
237             }
238              
239              
240             sub Init {
241             _init_db;
242             Date::Calc::Language(Date::Calc::Decode_Language('English'));
243             $_initcal++;
244             }
245              
246             =head1 FUNCTIONS
247              
248             =over 3
249              
250             =item ToOx($day, $month, $year, [\%options])
251              
252             Given a day, month and year in standard human format (that is, month is
253             1-12, not 0-11, and year is four digits) will return a string of the
254             form
255              
256             Day, xth week, Term year
257              
258             or an array
259              
260             (Day, week of term, Term, year)
261            
262             depending on how it is called. The exact behaviour is modified by the 'mode'
263             option described below.
264              
265             If the requested date is not in full term or extended term (see below),
266             undef will be returned.
267              
268             If the requested date is not covered by the database, ToOx will die with
269             an "out of range" error message. Therefore it is recommended to eval ToOx
270             with appropriate error handling.
271              
272             %options can contain additional named parameter options:
273              
274             =over 5
275              
276             =item mode
277              
278             Several modes are available:
279              
280             =over 6
281              
282             =item full_term
283              
284             Term dates will only be returned if the date requested is part of a full
285             term (as defined by the web page above).
286              
287             =item ext_term
288              
289             Term dates will only be returned if the date requested is part of an extended
290             term, or statutory term.
291              
292             =item nearest
293              
294             Will return term dates based on the nearest term, even if the date requested
295             is not part of an extended term (i.e. will include fictional week numbers).
296              
297             This is currently the default behaviour, for backwards compatibility with
298             previous releases; this may be changed in future.
299              
300             =back
301              
302             =back
303              
304             =over 4
305              
306             =item confirmed
307              
308             If true, ignores dates marked as provisional in the database.
309              
310             =back
311              
312             =cut
313              
314             sub ToOx {
315             my (@dmy, $options);
316             ($dmy[0], $dmy[1], $dmy[2], $options) = @_;
317             my $mode = $options->{mode} || DEFAULT_MODE;
318             my ($week, @term);
319             my @date = reverse @dmy;
320             Init unless defined $_initcal;
321             my $dow = Day_of_Week_to_Text( Day_of_Week( @date ) );
322              
323             @term = ThisTerm( @date );
324             if ( $#term ) {
325             # We're in term
326             my @term_start = _sunday_of_first( @term );
327             my $provisional = shift @term_start;
328             die "Date out of range" unless ( $#term_start == 2 );
329             my $days_from_start = Delta_Days( @term_start, @date );
330             my $week_offset = $days_from_start < 0 ? 1 : 7;
331             my $week = int( ( $days_from_start + $week_offset ) / 7);
332             return undef if $options->{confirmed} && $provisional;
333             return undef if ( ( $week < 1 || $week > 8 ) && $mode eq 'full_term' );
334             return ( $dow, $week, $term[1], $term[0] ) if ( wantarray );
335             return _fmt_oxdate_as_string( $dow, $week, $term[1], $term[0] );
336             } else {
337             return undef if $mode eq 'full_term';
338             return undef if $mode eq 'ext_term';
339             return _to_ox_nearest( @date, $options->{confirmed} );
340             }
341             }
342              
343             =item ThisTerm($year, $month, $day)
344              
345             Given a year, month, term in standard human format (that is, month is
346             1-12, not 0-11, and year is four digits) will returns the current term
347             or undef if in vacation or unknown. The term is given as an array in the
348             form (year, term).
349              
350             =cut
351              
352             sub ThisTerm {
353             my ( $year, $month, $day ) = @_;
354             my $term_dates = StatutoryTermDates( $year );
355             foreach my $term ( keys %{$term_dates} ) {
356             my $start = Date_to_Days( @{$term_dates->{$term}->{start}} );
357             my $end = Date_to_Days( @{$term_dates->{$term}->{end}} );
358             my $date = Date_to_Days( $year, $month, $day );
359             if ( ( $date >= $start ) && ( $date <= $end )) {
360             return ( $year, $term );
361             }
362             }
363             return undef;
364             }
365              
366             =item NextTerm($year, $month, $day)
367              
368             Given a day, month and year in standard human format (that is, month is
369             1-12, not 0-11, and year is four digits) will return the next term (whether
370             or not the date given is in term time).
371             The term is given as an array in the form (year, term).
372              
373             =cut
374              
375             sub NextTerm {
376             my @date = @_;
377             my @next_term;
378             my @this_term = ThisTerm( @date );
379             if ( @this_term == 2 ) {
380             @next_term = _increment_term( @this_term );
381             } else {
382             my @test_date = @date;
383             while ( @next_term != 2 ) {
384             @test_date = Add_Delta_Days( @test_date, 1 );
385             @next_term = ThisTerm( @test_date );
386             }
387             }
388             return @next_term;
389             }
390              
391             =item StatutoryTermDates($year)
392              
393             Returns a hash reference keyed on terms for a given year, the value of
394             each being a hash reference containing start and end dates for that term.
395             The dates are stored as array references containing numeric
396             year, month, day values.
397              
398             Note: these are the statutory term dates, not full term dates.
399              
400             =cut
401              
402             sub StatutoryTermDates {
403             my $year = shift;
404             die "StatutoryTermDates: no year given" unless $year;
405            
406             # Calculate end of Hilary
407             my @palm_sunday =
408             Date::Calc::Add_Delta_Days( Date::Calc::Easter_Sunday( $year ), -7 );
409             my @saturday_before_palm_sunday =
410             Date::Calc::Add_Delta_Days( @palm_sunday, -6 );
411              
412             my $hilary_delta = Date::Calc::Delta_Days(
413             $year, HILARY_END_IF_EARLIER,
414             @saturday_before_palm_sunday
415             );
416              
417             my @hilary_end;
418             if ( $hilary_delta == 1 ) {
419             @hilary_end = ( $year, HILARY_END_IF_EARLIER );
420             } else {
421             @hilary_end = @saturday_before_palm_sunday;
422             }
423            
424             # Calculate start of Trinity
425             my @wednesday_after_easter_sunday =
426             Date::Calc::Add_Delta_Days( Date::Calc::Easter_Sunday( $year ), 3 );
427              
428             my $trinity_delta = Date::Calc::Delta_Days(
429             @wednesday_after_easter_sunday,
430             $year, TRINITY_START_IF_LATER
431             );
432              
433             my @trinity_start;
434             if ( $trinity_delta == 1 ) {
435             @trinity_start = ( $year, TRINITY_START_IF_LATER );
436             } else {
437             @trinity_start = @wednesday_after_easter_sunday;
438             }
439              
440             my $term_dates = {
441             Michaelmas => {
442             start => [$year, MICHAELMAS_START],
443             end => [$year, MICHAELMAS_END]
444             },
445             Hilary => {
446             start => [$year, HILARY_START],
447             end => [@hilary_end]
448             },
449             Trinity => {
450             start => [@trinity_start],
451             end => [$year, TRINITY_END]
452             }
453             };
454             return $term_dates;
455             }
456              
457             =item Parse($string)
458              
459             Takes a free-form description of an Oxford calendar date, and attempts
460             to divine the expected meaning. If the name of a term is not found, the
461             current term will be assumed. If the description is unparsable, undef
462             is returned. Otherwise, an array will be returned of the form
463             C<($year,$term,$week,$day)>.
464              
465             This function is experimental.
466              
467             =cut
468              
469             sub Parse {
470             my $string = shift;
471             my $term = "";
472             my ( $day, $week, $year );
473             $day = $week = $year = "";
474              
475             $string = lc($string);
476             $string =~ s/week//g;
477             $string =~ s/(\d+)(?:rd|st|nd|th)/$1/;
478             my %ab = Text::Abbrev::abbrev( DAYS, TERMS );
479             my $expand;
480             while ( $string =~ s/((?:\d|-)\d*)/ / ) {
481             if ( $1 > 50 ) { $year = $1; $year += 1900 if $year < 1900; }
482             else { $week = $1 }
483             }
484             foreach ( sort { length $b <=> length $a } keys %ab ) {
485             if ( $string =~ s/\b$_\w+//i ) {
486              
487             #pos($string)-=length($_);
488             #my $foo=lc($_); $string=~s/\G$foo[a-z]*/ /i;
489             $expand = $ab{$_};
490             $term = $expand if ( scalar( grep /$expand/, TERMS ) > 0 );
491             $day = $expand if ( scalar( grep /$expand/, DAYS ) > 0 );
492             }
493             }
494             unless ($day) {
495             %ab = Text::Abbrev::abbrev(DAYS);
496             foreach ( sort { length $b <=> length $a } keys %ab ) {
497             if ( $string =~ /$_/ig ) {
498             pos($string) -= length($_);
499             my $foo = lc($_);
500             $string =~ s/\G$foo[a-z]*/ /;
501             $day = $ab{$_};
502             }
503             }
504             }
505             unless ($term) {
506             %ab = Text::Abbrev::abbrev(TERMS);
507             foreach ( sort { length $b <=> length $a } keys %ab ) {
508             if ( $string =~ /$_/ig ) {
509             pos($string) -= length($_);
510             my $foo = lc($_);
511             $string =~ s/\G$foo[a-z]*/ /;
512             $term = $ab{$_};
513             }
514             }
515             }
516              
517             # Assume this term?
518             unless ($term) {
519             $term = ToOx( reverse Date::Calc::Today() );
520             return "Can't work out what term" unless $term =~ /week/;
521             $term =~ s/.*eek,\s+(\w+).*/$1/;
522             }
523             $year = ( Date::Calc::Today() )[0] unless $year;
524             return undef unless defined $week and defined $day;
525             return ( $year, $term, $week, $day );
526             }
527              
528             =item FromOx($year, $term, $week, $day)
529              
530             Converts an Oxford date into a Gregorian date, returning a string of the
531             form C
or undef.
532              
533             The arguments are of the same format as returned by ToOx in array context;
534             that is, a four-digit year, the name of the term, the week number, and
535             the name of the day of week (e.g. 'Sunday').
536              
537             If the requested date is not covered by the database, FromOx will die with
538             an "out of range" error message. Therefore it is recommended to eval ToOx
539             with appropriate error handling.
540              
541             =cut
542              
543             sub FromOx {
544             my %lu;
545             Init unless defined $_initcal;
546             my ( $year, $term, $week, $day );
547             ( $year, $term, $week, $day ) = @_;
548             $year =~ s/\s//g;
549             $term =~ s/\s//g;
550             die "No data for $term $year" unless exists $db{"$term $year"};
551             {
552             my $foo = 0;
553             %lu = ( map { $_, $foo++ } DAYS );
554             }
555             my $delta = 7 * ( $week - 1 ) + $lu{$day};
556             my @start = _sunday_of_first( $year, $term );
557             shift @start;
558             die "The internal database is bad for $term $year"
559             unless $start[0];
560             return join "/", reverse( Date::Calc::Add_Delta_Days( @start, $delta ) );
561             }
562              
563             1;
564              
565             =back
566              
567             =head1 BUGS
568              
569             Bugs may be browsed and submitted at
570              
571             L
572              
573             A copy of the maintainer's git repository may be found at
574              
575             L
576              
577             =head1 AUTHOR
578              
579             Simon Cozens is the original author of this module.
580              
581             Eugene van der Pijll, C took over maintenance from
582             Simon for a time.
583              
584             Dominic Hargreaves currently maintains this module for
585             IT Services, University of Oxford.
586              
587             =cut
588              
589             __DATA__