]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/OpenILS/Utils/MFHD/Caption.pm
Move 'next' calcuations into MFHD::Caption, since they depend primarily on the captio...
[Evergreen.git] / Open-ILS / src / perlmods / OpenILS / Utils / MFHD / Caption.pm
1 package MFHD::Caption;
2 use strict;
3 use integer;
4 use Carp;
5
6 use Data::Dumper;
7
8 use DateTime;
9
10 use base 'MARC::Field';
11
12 sub new
13 {
14     my $proto = shift;
15     my $class = ref($proto) || $proto;
16     my $self = shift;
17     my $last_enum = undef;
18
19     $self->{_mfhdc_ENUMS} = {};
20     $self->{_mfhdc_CHRONS} = {};
21     $self->{_mfhdc_PATTERN} = {};
22     $self->{_mfhdc_COPY} = undef;
23     $self->{_mfhdc_UNIT} = undef;
24     $self->{_mfhdc_COMPRESSIBLE} = 1;   # until proven otherwise
25
26     foreach my $subfield ($self->subfields) {
27         my ($key, $val) = @$subfield;
28         if ($key eq '8') {
29             $self->{LINK} = $val;
30         } elsif ($key =~ /[a-h]/) {
31             # Enumeration Captions
32             $self->{_mfhdc_ENUMS}->{$key} = {CAPTION => $val,
33                                              COUNT => undef,
34                                              RESTART => undef};
35             if ($key =~ /[ag]/) {
36                 $last_enum = undef;
37             } else {
38                 $last_enum = $key;
39             }
40         } elsif ($key =~ /[i-m]/) {
41             # Chronology captions
42             $self->{_mfhdc_CHRONS}->{$key} = $val;
43         } elsif ($key eq 'u') {
44             # Bib units per next higher enumeration level
45             carp('$u specified for top-level enumeration')
46               unless defined($last_enum);
47             $self->{_mfhdc_ENUMS}->{$last_enum}->{COUNT} = $val;
48         } elsif ($key eq 'v') {
49             carp '$v specified for top-level enumeration'
50               unless defined($last_enum);
51             $self->{_mfhdc_ENUMS}->{$last_enum}->{RESTART} = ($val eq 'r');
52         } elsif ($key =~ /[npwz]/) {
53             # Publication Pattern info ('o' == type of unit, 'q'..'t' undefined)
54             $self->{_mfhdc_PATTERN}->{$key} = $val;
55         } elsif ($key =~ /x/) {
56             # Calendar change can have multiple comma-separated values
57             $self->{_mfhdc_PATTERN}->{x} = [split /,/, $val];
58         } elsif ($key eq 'y') {
59             $self->{_mfhdc_PATTERN}->{y} = []
60               unless exists $self->{_mfhdc_PATTERN}->{y};
61             push @{$self->{_mfhdc_PATTERN}->{y}}, $val;
62         } elsif ($key eq 'o') {
63             # Type of unit
64             $self->{_mfhdc_UNIT} = $val;
65         } elsif ($key eq 't') {
66             $self->{_mfhdc_COPY} = $val;
67         } else {
68             carp "Unknown caption subfield '$key'";
69         }
70     }
71
72     # subsequent levels of enumeration (primary and alternate)
73     # If an enumeration level doesn't document the number
74     # of "issues" per "volume", or whether numbering of issues
75     # restarts, then we can't compress.
76     foreach my $key ('b', 'c', 'd', 'e', 'f', 'h') {
77         if (exists $self->{_mfhdc_ENUMS}->{$key}) {
78             my $pattern = $self->{_mfhdc_ENUMS}->{$key};
79             if (!$pattern->{RESTART} || !$pattern->{COUNT}
80                 || ($pattern->{COUNT} eq 'var')
81                 || ($pattern->{COUNT} eq 'und')) {
82                 $self->{_mfhdc_COMPRESSIBLE} = 0;
83                 last;
84             }
85         }
86     }
87
88     # If there's a $x subfield and a $j, then it's compressible
89     if (exists $self->{_mfhdc_PATTERN}->{x} && exists $self->{_mfhdc_CHRONS}->{'j'}) {
90         $self->{_mfhdc_COMPRESSIBLE} = 1;
91     }
92
93     bless ($self, $class);
94
95     if (exists $self->{_mfhdc_PATTERN}->{y}) {
96         $self->decode_pattern;
97     }
98
99     return $self;
100 }
101
102 sub decode_pattern {
103     my $self = shift;
104     my $pattern = $self->{_mfhdc_PATTERN}->{y};
105
106     # XXX WRITE ME (?)
107 }
108
109 sub compressible {
110     my $self = shift;
111
112     return $self->{_mfhdc_COMPRESSIBLE};
113 }
114
115 sub chrons {
116     my $self = shift;
117     my $key = shift;
118
119     if (exists $self->{_mfhdc_CHRONS}->{$key}) {
120         return $self->{_mfhdc_CHRONS}->{$key};
121     } else {
122         return undef;
123     }
124 }
125
126 sub capfield {
127     my $self = shift;
128     my $key = shift;
129
130     if (exists $self->{_mfhdc_ENUMS}->{$key}) {
131         return $self->{_mfhdc_ENUMS}->{$key};
132     } elsif (exists $self->{_mfhdc_CHRONS}->{$key}) {
133         return $self->{_mfhdc_CHRONS}->{$key};
134     } else {
135         return undef;
136     }
137 }
138
139 sub capstr {
140     my $self = shift;
141     my $key = shift;
142     my $val = $self->capfield($key);
143
144     if (ref $val) {
145         return $val->{CAPTION};
146     } else {
147         return $val;
148     }
149 }
150
151 sub calendar_change {
152     my $self = shift;
153
154     return $self->{_mfhdc_PATTERN}->{x};
155 }
156
157 # If items are identified by chronology only, with no separate
158 # enumeration (eg, a newspaper issue), then the chronology is
159 # recorded in the enumeration subfields $a - $f.  We can tell
160 # that this is the case if there are $a - $f subfields and no
161 # chronology subfields ($i-$k), and none of the $a-$f subfields
162 # have associated $u or $v subfields, but there's a $w and no $x
163
164 sub enumeration_is_chronology {
165     my $self = shift;
166
167     # There is always a '$a' subfield in well-formed fields.
168     return 0 if exists $self->{_mfhdc_CHRONS}->{i}
169       || exists $self->{_mfhdc_PATTERN}->{x};
170
171     foreach my $key ('a' .. 'f') {
172         my $enum;
173
174         last if !exists $self->{_mfhdc_ENUMS}->{$key};
175
176         $enum = $self->{_mfhdc_ENUMS}->{$key};
177         return 0 if defined $enum->{COUNT} || defined $enum->{RESTART};
178     }
179
180     return (exists $self->{_mfhdc_PATTERN}->{w});
181 }
182
183 my %daynames = (
184                 'mo' => 1,
185                 'tu' => 2,
186                 'we' => 3,
187                 'th' => 4,
188                 'fr' => 5,
189                 'sa' => 6,
190                 'su' => 7,
191                );
192
193 my $daypat = '(mo|tu|we|th|fr|sa|su)';
194 my $weekpat = '(99|98|97|00|01|02|03|04|05)';
195 my $weeknopat;
196 my $monthpat = '(01|02|03|04|05|06|07|08|09|10|11|12)';
197 my $seasonpat = '(21|22|23|24)';
198
199 # Initialize $weeknopat to be '(01|02|03|...|51|52|53)'
200 $weeknopat = '(';
201 foreach my $weekno (1..52) {
202     $weeknopat .= sprintf('%02d|', $weekno);
203 }
204 $weeknopat .= '53)';
205
206 sub match_day {
207     my $pat = shift;
208     my @date = @_;
209     # Translate daynames into day of week for DateTime
210     # also used to check if dayname is valid.
211
212     if (exists $daynames{$pat}) {
213         # dd
214         # figure out day of week for date and compare
215         my $dt = DateTime->new(year  => $date[0],
216                                month => $date[1],
217                                day   => $date[2]);
218         return ($dt->day_of_week == $daynames{$pat});
219     } elsif (length($pat) == 2) {
220         # MM
221         return $pat == $date[3];
222     } elsif (length($pat) == 4) {
223         # MMDD
224         my ($mon, $day);
225         $mon = substr($pat, 0, 2);
226         $day = substr($pat, 2, 2);
227
228         return (($mon == $date[1]) && ($day == $date[2]));
229     } else {
230         carp "Invalid day pattern '$pat'";
231         return 0;
232     }
233 }
234
235 # Calcuate date of "n"th last "dayname" of month: second last Tuesday
236 sub last_week_of_month {
237     my $dt = shift;
238     my $week = shift;
239     my $day = shift;
240     my $end_dt = DateTime->last_day_of_month(year  => $dt->year,
241                                              month => $dt->month);
242
243     $day = $daynames{$day};
244     while ($end_dt->day_of_week != $day) {
245         $end_dt->subtract(days => 1);
246     }
247
248     # 99: last week of month, 98: second last, etc.
249     for (my $i = 99 - $week; $i > 0; $i--) {
250         $end_dt->subtract(weeks => 1);
251     }
252
253     return $end_dt;
254 }
255
256 sub check_date {
257     my $dt = shift;
258     my $month = shift;
259     my $weekno = shift;
260     my $day = shift;
261
262     if (!defined $day) {
263         # MMWW
264         return (($dt->month == $month)
265                 && (($dt->week_of_month == $weekno)
266                     || ($dt->week_of_month == last_day_of_month($dt, $weekno, 'th')->week_of_month)));
267     }
268
269     # simple cases first
270     if ($daynames{$day} != $dt->day_of_week) {
271         # if it's the wrong day of the week, rest doesn't matter
272         return 0;
273     }
274
275     if (!defined $month) {
276         # WWdd
277         return (($dt->weekday_of_month == $weekno)
278                 || ($dt->weekday_of_month == last_day_of_month($dt, $weekno, $day)->weekday_of_month));
279     }
280
281     # MMWWdd
282     if ($month != $dt->month) {
283         # If it's the wrong month, then we're done
284         return 0;
285     }
286
287     # It's the right day of the week
288     # It's the right month
289
290     if ($weekno == $dt->weekday_of_month) {
291         # If this matches, then we're counting from the beginning
292         # of the month and it matches and we're done.
293         return 1;
294     }
295
296     # only case left is that the week number is counting from
297     # the end of the month: eg, second last wednesday
298     return (last_week_of_month($weekno, $day)->weekday_of_month == $dt->weekday_of_month);
299 }
300
301 sub match_week {
302     my $pat = shift;
303     my @date = @_;
304     my $dt = DateTime->new(year  => $date[0],
305                            month => $date[1],
306                            day   => $date[2]);
307
308     if ($pat =~ m/^$weekpat$daypat$/) {
309         # WWdd: 03we = Third Wednesday
310         return check_date($dt, undef, $1, $2);
311     } elsif ($pat =~ m/^$monthpat$weekpat$daypat$/) {
312         # MMWWdd: 0599tu Last Tuesday in May XXX WRITE ME
313         return check_date($dt, $1, $2, $3);
314     } elsif ($pat =~ m/^$monthpat$weekpat$/) {
315         # MMWW: 1204: Fourth week in December XXX WRITE ME
316         return check_date($dt, $1, $2, undef);
317     } else {
318         carp "invalid week pattern '$pat'";
319         return 0;
320     }
321 }
322
323 sub match_month {
324     my $pat = shift;
325     my @date = @_;
326
327     return ($pat eq $date[1]);
328 }
329
330 sub match_season {
331     my $pat = shift;
332     my @date = @_;
333
334     return ($pat eq $date[1]);
335 }
336
337 sub match_year {
338     my $pat = shift;
339     my @date = @_;
340
341     # XXX WRITE ME
342     return 0;
343 }
344
345 sub match_issue {
346     my $pat = shift;
347     my @date = @_;
348
349     # We handle enumeration patterns separately. This just
350     # ensures that when we're processing chronological patterns
351     # we don't match an enumeration pattern.
352     return 0;
353 }
354
355 my %dispatch = (
356                 'd' => \&match_day,
357                 'e' => \&match_issue, # not really a "chron" code
358                 'w' => \&match_week,
359                 'm' => \&match_month,
360                 's' => \&match_season,
361                 'y' => \&match_year,
362 );
363
364 sub regularity_match {
365     my $self = shift;
366     my $pubcode = shift;
367     my @date = @_;
368
369     # we can't match something that doesn't exist.
370     return 0 if !exists $self->{_mfhdc_PATTERN}->{y};
371
372     foreach my $regularity (@{$self->{_mfhdc_PATTERN}->{y}}) {
373         next unless $regularity =~ m/^$pubcode/;
374
375         my $chroncode= substr($regularity, 1, 1);
376         my @pats = split(/,/, substr($regularity, 2));
377
378         if (!exists $dispatch{$chroncode}) {
379             carp "Unrecognized chroncode '$chroncode'";
380             return 0;
381         }
382
383         # XXX WRITE ME
384         foreach my $pat (@pats) {
385             $pat =~ s|/.+||;    # If it's a combined date, match the start
386             if ($dispatch{$chroncode}->($pat, @date)) {
387                 return 1;
388             }
389         }
390     }
391
392     return 0;
393 }
394
395 sub is_omitted {
396     my $self = shift;
397     my @date = @_;
398
399     return $self->regularity_match('o', @date);
400 }
401
402 sub is_published {
403     my $self = shift;
404     my @date = @_;
405
406     return $self->regularity_match('p', @date);
407 }
408
409 sub is_combined {
410     my $self = shift;
411     my @date = @_;
412
413     return $self->regularity_match('c', @date);
414 }
415
416 sub enum_is_combined {
417     my $self = shift;
418     my $subfield = shift;
419     my $iss = shift;
420     my $level = ord($subfield) - ord('a') + 1;
421
422     return 0 if !exists $self->{_mfhdc_PATTERN}->{y};
423
424     foreach my $regularity (@{$self->{_mfhdc_PATTERN}->{y}}) {
425         next unless $regularity =~ m/^ce$level/o;
426
427         my @pats = split(/,/, substr($regularity, 3));
428
429         foreach my $pat (@pats) {
430             $pat =~ s|/.+||;    # if it's a combined issue, match the start
431             return 1 if ($iss eq $pat);
432         }
433     }
434
435     return 0;
436 }
437
438
439 my %increments = (
440                   a => {years => 1}, # annual
441                   b => {months => 2}, # bimonthly
442                   c => {days => 3}, # semiweekly
443                   d => {days => 1}, # daily
444                   e => {weeks => 2}, # biweekly
445                   f => {months => 6}, # semiannual
446                   g => {years => 2},  # biennial
447                   h => {years => 3},  # triennial
448                   i => {days => 2}, # three times / week
449                   j => {days => 10}, # three times /month
450                   # k => continuous
451                   m => {months => 1}, # monthly
452                   q => {months => 3}, # quarterly
453                   s => {days => 15},  # semimonthly
454                   t => {months => 4}, # three times / year
455                   w => {weeks => 1},  # weekly
456                   # x => completely irregular
457 );
458
459 sub incr_date {
460     my $incr = shift;
461     my @new = @_;
462
463     if (scalar(@new) == 1) {
464         # only a year is specified. Next date is easy
465         $new[0] += $incr->{years} || 1;
466     } elsif (scalar(@new) == 2) {
467         # Year and month or season
468         if ($new[1] > 20) {
469             # season
470             $new[1] += ($incr->{months}/3) || 1;
471             if ($new[1] > 24) {
472                 # carry
473                 $new[0] += 1;
474                 $new[1] -= 4;   # 25 - 4 == 21 == Spring after Winter
475             }
476         } else {
477             # month
478             $new[1] += $incr->{months} || 1;
479             if ($new[1] > 12) {
480                 # carry
481                 $new[0] += 1;
482                 $new[1] -= 12;
483             }
484             $new[1] = '0' . $new[1] if ($new[1] < 10);
485         }
486     } elsif (scalar(@new) == 3) {
487         # Year, Month, Day: now it gets complicated.
488
489         if ($new[2] =~ /^[0-9]+$/) {
490             # A single number for the day of month, relatively simple
491             my $dt = DateTime->new(year => $new[0],
492                                    month=> $new[1],
493                                    day  => $new[2]);
494             $dt->add(%{$incr});
495             $new[0] = $dt->year;
496             $new[1] = $dt->month;
497             $new[2] = $dt->day;
498         }
499         $new[1] = '0' . $new[1] if ($new[1] < 10);
500         $new[2] = '0' . $new[2] if ($new[2] < 10);
501     } else {
502         warn("Don't know how to cope with @new");
503     }
504
505     return @new;
506 }
507
508 # Test to see if $m1/$d1 is on or after $m2/$d2
509 # if $d2 is undefined, test is based on just months
510 sub on_or_after {
511     my ($m1, $d1, $m2, $d2) = @_;
512
513     return (($m1 > $m2)
514             || ($m1 == $m2 && ((!defined $d2) || ($d1 >= $d2))));
515 }
516
517 sub calendar_increment {
518     my $self = shift;
519     my $cur = shift;
520     my @new = @_;
521     my $cal_change = $self->calendar_change;
522     my $month;
523     my $day;
524     my $cur_before;
525     my $new_on_or_after;
526
527     # A calendar change is defined, need to check if it applies
528     if ((scalar(@new) == 2 && $new[1] > 20) || (scalar(@new) == 1)) {
529         carp "Can't calculate date change for ", $self->as_string;
530         return;
531     }
532
533     foreach my $change (@{$cal_change}) {
534         my $incr;
535
536         if (length($change) == 2) {
537             $month = $change;
538         } elsif (length($change) == 4) {
539             ($month, $day) = unpack("a2a2", $change);
540         }
541
542         if ($cur->[0] == $new[0]) {
543             # Same year, so a 'simple' month/day comparison will be fine
544             $incr = (!on_or_after($cur->[1], $cur->[2], $month, $day)
545                      && on_or_after($new[1], $new[2], $month, $day));
546         } else {
547             # @cur is in the year before @new. There are
548             # two possible cases for the calendar change date that
549             # indicate that it's time to change the volume:
550             # (1) the change date is AFTER @cur in the year, or
551             # (2) the change date is BEFORE @new in the year.
552             # 
553             #  -------|------|------X------|------|
554             #       @cur    (1)   Jan 1   (2)   @new
555
556             $incr = (on_or_after($new[1], $new[2], $month, $day)
557                      || !on_or_after($cur->[1], $cur->[2], $month, $day));
558         }
559         return $incr if $incr;
560     }
561 }
562
563 sub next_date {
564     my $self = shift;
565     my $next = shift;
566     my $carry = shift;
567     my @keys = @_;
568     my @cur;
569     my @new;
570     my $incr;
571
572     my $reg = $self->{_mfhdc_REGULARITY};
573     my $pattern = $self->{_mfhdc_PATTERN};
574     my $freq = $pattern->{w};
575
576     foreach my $i (0..$#keys) {
577         $new[$i] = $cur[$i] = $next->{$keys[$i]} if exists $next->{$keys[$i]};
578     }
579
580     # If the current issue has a combined date (eg, May/June)
581     # get rid of the first date and base the calculation
582     # on the final date in the combined issue.
583     $new[-1] =~ s|^[^/]+/||;
584
585     # If $frequency is not one of the standard codes defined in %increments
586     # then there has to be a $yp publication regularity pattern that
587     # lists the dates of publication. Use that that list to find the next
588     # date following the current one.
589     # XXX: the code doesn't handle this case yet.
590     if (!defined($freq)) {
591         carp "Undefined frequency in next_date!";
592     } elsif (!exists $increments{$freq}) {
593         carp "Don't know how to deal with frequency '$freq'!";
594     } else {
595         #
596         # One of the standard defined issue frequencies
597         #
598         @new = incr_date($increments{$freq}, @new);
599
600         while ($self->is_omitted(@new)) {
601             @new = incr_date($increments{$freq}, @new);
602         }
603
604         if ($self->is_combined(@new)) {
605             my @second_date = incr_date($increments{$freq}, @new);
606
607             # I am cheating: This code assumes that only the smallest
608             # time increment is combined. So, no "Apr 15/May 1" allowed.
609             $new[-1] = $new[-1] . '/' . $second_date[-1];
610         }
611     }
612
613     for my $i (0..$#new) {
614         $next->{$keys[$i]} = $new[$i];
615     }
616
617     # Figure out if we need to adust volume number
618     # right now just use the $carry that was passed in.
619     # in long run, need to base this on ($carry or date_change)
620     if ($carry) {
621         # if $carry is set, the date doesn't matter: we're not
622         # going to increment the v. number twice at year-change.
623         $next->{a} += $carry;
624     } elsif (defined $self->{_mfhdc_PATTERN}->{x}) {
625         $next->{a} += $self->calendar_increment(\@cur, @new);
626     }
627 }
628
629 sub next_alt_enum {
630     my $self = shift;
631     my $next = shift;
632
633     # First handle any "alternative enumeration", since they're
634     # a lot simpler, and don't depend on the the calendar
635     foreach my $key ('h', 'g') {
636         next if !exists $next->{$key};
637         if (!$self->capstr($key)) {
638             warn "Holding data exists for $key, but no caption specified";
639             $next->{$key} += 1;
640             last;
641         }
642
643         my $cap = $self->capfield($key);
644         if ($cap->{RESTART} && $cap->{COUNT}
645             && ($next->{$key} == $cap->{COUNT})) {
646             $next->{$key} = 1;
647         } else {
648             $next->{$key} += 1;
649             last;
650         }
651     }
652 }
653
654 sub next_enum {
655     my $self = shift;
656     my $next = shift;
657     my $carry;
658
659     # $carry keeps track of whether we need to carry into the next
660     # higher level of enumeration. It's not actually necessary except
661     # for when the loop ends: if we need to carry from $b into $a
662     # then $carry will be set when the loop ends.
663     #
664     # We need to keep track of this because there are two different
665     # reasons why we might increment the highest level of enumeration ($a)
666     # 1) we hit the correct number of items in $b (ie, 5th iss of quarterly)
667     # 2) it's the right time of the year.
668     #
669     $carry = 0;
670     foreach my $key (reverse('b'..'f')) {
671         next if !exists $next->{$key};
672
673         if (!$self->capstr($key)) {
674             # Just assume that it increments continuously and give up
675             warn "Holding data exists for $key, but no caption specified";
676             $next->{$key} += 1;
677             $carry = 0;
678             last;
679         }
680
681         # If the current issue has a combined issue number (eg, 2/3)
682         # get rid of the first issue number and base the calculation
683         # on the final issue number in the combined issue.
684         if ($next->{$key} =~ m|/|) {
685             $next->{$key} =~ s|^[^/]+/||;
686         }
687
688         my $cap = $self->capfield($key);
689         if ($cap->{RESTART} && $cap->{COUNT}
690             && ($next->{$key} eq $cap->{COUNT})) {
691             $next->{$key} = 1;
692             $carry = 1;
693         } else {
694             # If I don't need to "carry" beyond here, then I just increment
695             # this level of the enumeration and stop looping, since the
696             # "next" hash has been initialized with the current values
697
698             $next->{$key} += 1;
699             $carry = 0;
700         }
701
702         # You can't have a combined issue that spans two volumes: no.12/1
703         # is forbidden
704         if ($self->enum_is_combined($key, $next->{$key})) {
705             $next->{$key} .= '/' . ($next->{$key} + 1);
706         }
707
708         last if !$carry;
709     }
710
711     # The easy part is done. There are two things left to do:
712     # 1) Calculate the date of the next issue, if necessary
713     # 2) Increment the highest level of enumeration (either by date
714     #    or because $carry is set because of the above loop
715
716     if (!$self->subfield('i')) {
717         # The simple case: if there is no chronology specified
718         # then just check $carry and return
719         $next->{'a'} += $carry;
720     } else {
721         # Figure out date of next issue, then decide if we need
722         # to adjust top level enumeration based on that
723         $self->next_date($next, $carry, ('i'..'m'));
724     }
725 }
726
727 1;