File Coverage

blib/lib/Oxford/Calendar.pm
Criterion Covered Total %
statement 226 251 90.0
branch 72 100 72.0
condition 18 23 78.2
subroutine 32 32 100.0
pod 6 7 85.7
total 354 413 85.7


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