]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/OpenILS/Utils/MFHD/Caption.pm
Initial support for displaying the type of unit (eg, 'playing
[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 OpenILS::Utils::MFHD::Date;
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             update_pattern($self, $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     my $pat = $self->{_mfhdc_PATTERN};
89
90     # Sanity check publication frequency vs publication pattern:
91     # if the frequency is a number, then the pattern better
92     # have that number of values associated with it.
93     if (exists($pat->{w}) && ($pat->{w} =~ /^\d+$/)
94         && ($pat->{w} != scalar(@{$pat->{y}->{p}}))) {
95         carp("Caption::new: publication frequency '$pat->{w}' != publication pattern @{$pat->{y}->{p}}");
96     }
97
98
99     # If there's a $x subfield and a $j, then it's compressible
100     if (exists $pat->{x} && exists $self->{_mfhdc_CHRONS}->{'j'}) {
101         $self->{_mfhdc_COMPRESSIBLE} = 1;
102     }
103
104     bless ($self, $class);
105
106     return $self;
107 }
108
109 sub update_pattern {
110     my $self = shift;
111     my $val = shift;
112     my $pathash = $self->{_mfhdc_PATTERN}->{y};
113     my ($pubcode, $pat) = unpack("a1a*", $val);
114
115     $pathash->{$pubcode} = [] unless exists $pathash->{$pubcode};
116     push @{$pathash->{$pubcode}}, $pat;
117 }
118
119 sub decode_pattern {
120     my $self = shift;
121     my $pattern = $self->{_mfhdc_PATTERN}->{y};
122
123     # XXX WRITE ME (?)
124 }
125
126 sub compressible {
127     my $self = shift;
128
129     return $self->{_mfhdc_COMPRESSIBLE};
130 }
131
132 sub chrons {
133     my $self = shift;
134     my $key = shift;
135
136     if (exists $self->{_mfhdc_CHRONS}->{$key}) {
137         return $self->{_mfhdc_CHRONS}->{$key};
138     } else {
139         return undef;
140     }
141 }
142
143 sub capfield {
144     my $self = shift;
145     my $key = shift;
146
147     if (exists $self->{_mfhdc_ENUMS}->{$key}) {
148         return $self->{_mfhdc_ENUMS}->{$key};
149     } elsif (exists $self->{_mfhdc_CHRONS}->{$key}) {
150         return $self->{_mfhdc_CHRONS}->{$key};
151     } else {
152         return undef;
153     }
154 }
155
156 sub capstr {
157     my $self = shift;
158     my $key = shift;
159     my $val = $self->capfield($key);
160
161     if (ref $val) {
162         return $val->{CAPTION};
163     } else {
164         return $val;
165     }
166 }
167
168 sub type_of_unit {
169     my $self = shift;
170
171     return $self->{_mfhdc_UNIT};
172 }
173
174 sub calendar_change {
175     my $self = shift;
176
177     return $self->{_mfhdc_PATTERN}->{x};
178 }
179
180 # If items are identified by chronology only, with no separate
181 # enumeration (eg, a newspaper issue), then the chronology is
182 # recorded in the enumeration subfields $a - $f.  We can tell
183 # that this is the case if there are $a - $f subfields and no
184 # chronology subfields ($i-$k), and none of the $a-$f subfields
185 # have associated $u or $v subfields, but there's a $w and no $x
186
187 sub enumeration_is_chronology {
188     my $self = shift;
189
190     # There is always a '$a' subfield in well-formed fields.
191     return 0 if exists $self->{_mfhdc_CHRONS}->{i}
192       || exists $self->{_mfhdc_PATTERN}->{x};
193
194     foreach my $key ('a' .. 'f') {
195         my $enum;
196
197         last if !exists $self->{_mfhdc_ENUMS}->{$key};
198
199         $enum = $self->{_mfhdc_ENUMS}->{$key};
200         return 0 if defined $enum->{COUNT} || defined $enum->{RESTART};
201     }
202
203     return (exists $self->{_mfhdc_PATTERN}->{w});
204 }
205
206 sub regularity_match {
207     my $self = shift;
208     my $pubcode = shift;
209     my @date = @_;
210
211     # we can't match something that doesn't exist.
212     return 0 if !exists $self->{_mfhdc_PATTERN}->{y}->{$pubcode};
213
214     foreach my $regularity (@{$self->{_mfhdc_PATTERN}->{y}->{$pubcode}}) {
215         my $chroncode= substr($regularity, 0, 1);
216         my $matchfunc = MFHD::Date::dispatch($chroncode);
217         my @pats = split(/,/, substr($regularity, 1));
218
219         if (!defined $matchfunc) {
220             carp "Unrecognized chroncode '$chroncode'";
221             return 0;
222         }
223
224         # XXX WRITE ME
225         foreach my $pat (@pats) {
226             $pat =~ s|/.+||;    # If it's a combined date, match the start
227             if ($matchfunc->($pat, @date)) {
228                 return 1;
229             }
230         }
231     }
232
233     return 0;
234 }
235
236 sub is_omitted {
237     my $self = shift;
238     my @date = @_;
239
240 #     printf("# is_omitted: testing date %s: %d\n", join('/', @date),
241 #          $self->regularity_match('o', @date));
242     return $self->regularity_match('o', @date);
243 }
244
245 sub is_published {
246     my $self = shift;
247     my @date = @_;
248
249     return $self->regularity_match('p', @date);
250 }
251
252 sub is_combined {
253     my $self = shift;
254     my @date = @_;
255
256     return $self->regularity_match('c', @date);
257 }
258
259 sub enum_is_combined {
260     my $self = shift;
261     my $subfield = shift;
262     my $iss = shift;
263     my $level = ord($subfield) - ord('a') + 1;
264
265     return 0 if !exists $self->{_mfhdc_PATTERN}->{y}->{c};
266
267     foreach my $regularity (@{$self->{_mfhdc_PATTERN}->{y}->{c}}) {
268         next unless $regularity =~ m/^e$level/o;
269
270         my @pats = split(/,/, substr($regularity, 2));
271
272         foreach my $pat (@pats) {
273             $pat =~ s|/.+||;    # if it's a combined issue, match the start
274             return 1 if ($iss eq $pat);
275         }
276     }
277
278     return 0;
279 }
280
281
282 # Test to see if $dt1 is on or after $dt2
283 # if length(@{$dt2} == 2, then just month/day are compared
284 # if length(@{$dt2} == 1, then just the months are compared
285 sub on_or_after {
286     my $dt1 = shift;
287     my $dt2 = shift;
288
289 #     printf("# on_or_after(%s, %s): ", join('/', @{$dt1}), join('/', @{$dt2}));
290
291     foreach my $i (0..(scalar(@{$dt2})-1)) {
292         if ($dt1->[$i] > $dt2->[$i]) {
293 #           printf("after - pass\n");
294             # $dt1 occurs AFTER $dt2
295             return 1;
296         } elsif ($dt1->[$i] < $dt2->[$i]) {
297 #           printf("before - fail\n");
298             # $dt1 occurs BEFORE $dt2
299             return 0;
300         }
301         # both are still equal, keep going
302     }
303
304     # We fell out of the loop with them being equal, so it's 'on'
305 #     printf("on - pass\n");
306     return 1;
307 }
308
309 sub calendar_increment {
310     my $self = shift;
311     my $cur = shift;
312     my $new = shift;
313     my $cal_change = $self->calendar_change;
314     my $month;
315     my $day;
316     my $cur_before;
317     my $new_on_or_after;
318
319     # A calendar change is defined, need to check if it applies
320     if (scalar(@{$new}) == 1) {
321         carp "Can't calculate date change for ", $self->as_string;
322         return 0;
323     }
324
325     foreach my $change (@{$cal_change}) {
326         my $incr;
327
328         if (length($change) == 2) {
329             $month = $change;
330         } elsif (length($change) == 4) {
331             ($month, $day) = unpack("a2a2", $change);
332         }
333
334 #       printf("# calendar_increment('%s', '%s'): change on '%s/%s'\n",
335 #              join('/', @{$cur}), join('/', @{$new}),
336 #              $month, defined($day) ? $day : 'UNDEF');
337
338         if ($cur->[0] == $new->[0]) {
339             # Same year, so a 'simple' month/day comparison will be fine
340             $incr = (!on_or_after([$cur->[1], $cur->[2]], [$month, $day])
341                      && on_or_after([$new->[1], $new->[2]], [$month, $day]));
342         } else {
343             # @cur is in the year before @new. There are
344             # two possible cases for the calendar change date that
345             # indicate that it's time to change the volume:
346             # (1) the change date is AFTER @cur in the year, or
347             # (2) the change date is BEFORE @new in the year.
348             # 
349             #  -------|------|------X------|------|
350             #       @cur    (1)   Jan 1   (2)   @new
351
352             $incr = (on_or_after([$new->[1], $new->[2]], [$month, $day])
353                      || !on_or_after([$cur->[1], $cur->[2]], [$month, $day]));
354         }
355         return $incr if $incr;
356     }
357
358     return 0;
359 }
360
361 sub next_date {
362     my $self = shift;
363     my $next = shift;
364     my $carry = shift;
365     my @keys = @_;
366     my @cur;
367     my @new;
368     my @newend; # only used for combined issues
369     my $incr;
370
371     my $reg = $self->{_mfhdc_REGULARITY};
372     my $pattern = $self->{_mfhdc_PATTERN};
373     my $freq = $pattern->{w};
374
375     foreach my $i (0..$#keys) {
376         $cur[$i] = $next->{$keys[$i]} if exists $next->{$keys[$i]};
377     }
378
379     # If the current issue has a combined date (eg, May/June)
380     # get rid of the first date and base the calculation
381     # on the final date in the combined issue.
382     $cur[-1] =~ s|^[^/]+/||;
383
384     if (defined $pattern->{y}->{p}) {
385         # There is a $y publication pattern defined in the record:
386         # use it to calculate the next issue date.
387
388         foreach my $pubpat (@{$pattern->{y}->{p}}, @{$pattern->{y}->{c}}) {
389             my $chroncode = substr($pubpat, 0, 1);
390             my $genfunc = MFHD::Date::generator($chroncode);
391             my @pats = split(/,/, substr($pubpat, 1));
392
393             next if $chroncode eq 'e';
394
395             if (!defined $genfunc) {
396                 carp "Unrecognized chroncode '$chroncode'";
397                 return undef;
398             }
399
400             foreach my $pat (@pats) {
401                 my $combined = $pat =~ m|/|;
402                 my ($start, $end);
403                 my @candidate;
404
405 #               printf("# next_date: generating with pattern '%s'\n", $pat);
406
407                 if ($combined) {
408                     ($start, $end) = split('/', $pat, 2);
409                 } else {
410                     ($start, $end) = (undef, undef);
411                 }
412
413                 @candidate = $genfunc->($start || $pat, @cur);
414
415                 while ($self->is_omitted(@candidate)) {
416 #                   printf("# pubpat omitting date '%s'\n",
417 #                          join('/', @candidate));
418                     @candidate = $genfunc->($start || $pat, @candidate);
419                 }
420
421 #               printf("# testing new candidate '%s' against '%s'\n",
422 #                      join('/', @candidate), join('/', @new));
423
424                 if (!defined($new[0])
425                     || !on_or_after(\@candidate, \@new)) {
426                     # first time through the loop
427                     # or @candidate is before @new =>
428                     # @candidate is the next issue.
429                     @new = @candidate;
430                     if (defined $end) {
431                         @newend = $genfunc->($end, @cur);
432                     } else {
433                         $newend[0] = undef;
434                     }
435
436 #                   printf("# selecting candidate date '%s'\n", join('/', @new));
437                 }
438             }
439         }
440
441         if (defined($newend[0])) {
442             # The best match was a combined issue
443             foreach my $i (0..$#new) {
444                 # don't combine identical fields
445                 next if $new[$i] eq $newend[$i];
446                 $new[$i] .= '/' . $newend[$i];
447             }
448         }
449     }
450
451     if (scalar @new == 0) {
452         # There was no suitable publication pattern defined,
453         # so use the $w frequency to figure out the next date
454         if (!defined($freq)) {
455             carp "Undefined frequency in next_date!";
456         } elsif (!MFHD::Date::can_increment($freq)) {
457             carp "Don't know how to deal with frequency '$freq'!";
458         } else {
459             #
460             # One of the standard defined issue frequencies
461             #
462             @new = MFHD::Date::incr_date($freq, @cur);
463
464             while ($self->is_omitted(@new)) {
465                 @new = MFHD::Date::incr_date($freq, @new);
466             }
467
468             if ($self->is_combined(@new)) {
469                 my @second_date = MFHD::Date::incr_date($freq, @new);
470
471                 # I am cheating: This code assumes that only the smallest
472                 # time increment is combined. So, no "Apr 15/May 1" allowed.
473                 $new[-1] = $new[-1] . '/' . $second_date[-1];
474             }
475         }
476     }
477
478     for my $i (0..$#new) {
479         $next->{$keys[$i]} = $new[$i];
480     }
481     # Figure out if we need to adust volume number
482     # right now just use the $carry that was passed in.
483     # in long run, need to base this on ($carry or date_change)
484     if ($carry) {
485         # if $carry is set, the date doesn't matter: we're not
486         # going to increment the v. number twice at year-change.
487         $next->{a} += $carry;
488     } elsif (defined $pattern->{x}) {
489         $next->{a} += $self->calendar_increment(\@cur, \@new);
490     }
491 }
492
493 sub next_alt_enum {
494     my $self = shift;
495     my $next = shift;
496
497     # First handle any "alternative enumeration", since they're
498     # a lot simpler, and don't depend on the the calendar
499     foreach my $key ('h', 'g') {
500         next if !exists $next->{$key};
501         if (!$self->capstr($key)) {
502             warn "Holding data exists for $key, but no caption specified";
503             $next->{$key} += 1;
504             last;
505         }
506
507         my $cap = $self->capfield($key);
508         if ($cap->{RESTART} && $cap->{COUNT}
509             && ($next->{$key} == $cap->{COUNT})) {
510             $next->{$key} = 1;
511         } else {
512             $next->{$key} += 1;
513             last;
514         }
515     }
516 }
517
518 # Check caption for $ype subfield, specifying that there's a
519 # particular publication pattern for the given level of enumeration
520 # returns the pattern string or undef
521 sub enum_pubpat {
522     my $self = shift;
523     my $level = shift;
524
525     return undef if !exists $self->{_mfhdc_PATTERN}->{y}->{p};
526
527     foreach my $reg (@{$self->{_mfhdc_PATTERN}->{y}->{p}}) {
528         if ($reg =~ m/^e$level/o) {
529             return substr($reg, 2);
530         }
531     }
532     return undef;
533 }
534
535 sub next_enum {
536     my $self = shift;
537     my $next = shift;
538     my $carry;
539
540     # $carry keeps track of whether we need to carry into the next
541     # higher level of enumeration. It's not actually necessary except
542     # for when the loop ends: if we need to carry from $b into $a
543     # then $carry will be set when the loop ends.
544     #
545     # We need to keep track of this because there are two different
546     # reasons why we might increment the highest level of enumeration ($a)
547     # 1) we hit the correct number of items in $b (ie, 5th iss of quarterly)
548     # 2) it's the right time of the year.
549     #
550
551     # If there's a subfield b, then we will go through the loop at
552     # least once. If there's no subfield b, then there's only a single
553     # level of enumeration, so we just add one to it and we're done.
554     if (exists $next->{b}) {
555         $carry = 0;
556     } else {
557         $carry = 1;
558     }
559     foreach my $key (reverse('b'..'f')) {
560         my $level;
561         my $pubpat;
562
563         next if !exists $next->{$key};
564
565         # If the current issue has a combined issue number (eg, 2/3)
566         # get rid of the first issue number and base the calculation
567         # on the final issue number in the combined issue.
568         if ($next->{$key} =~ m|/|) {
569             $next->{$key} =~ s|^[^/]+/||;
570         }
571
572         $level = ord($key) - ord('a') + 1; # enumeration level
573
574         $pubpat = $self->enum_pubpat($level);
575
576         if ($pubpat) {
577 #           printf("# next_enum: found pubpat '%s' for subfield '%s'\n",
578 #                  $pubpat, $key);
579             my @pats = split(/,/, $pubpat);
580
581             # If we fall out the bottom of the loop, then $carry
582             # will still be 1, and we will reset the current
583             # level to the first value in @pats and increment
584             # then next higher level.
585             $carry = 1;
586
587             foreach my $pat (@pats) {
588                 my $combined = $pat =~ m|/|;
589                 my $end;
590
591 #               printf("# next_enum: checking current '%s' against pat '%s'\n",
592 #                      $next->{$key}, $pat);
593
594                 if ($combined) {
595                     ($pat, $end) = split('/', $pat, 2);
596                 } else {
597                     $end = undef;
598                 }
599
600                 if ($pat > $next->{$key}) {
601                     $carry = 0;
602                     $next->{$key} = $pat;
603                     $next->{$key} .= '/' . $end if $end;
604 #                   printf("# next_enum: selecting new issue no. %s\n", $next->{$key});
605                     last; # We've found the correct next issue number
606                 }
607             }
608             if ($carry) {
609                 $next->{$key} = $pats[0];
610             } else {
611                 last; # exit the top level loop because we're done
612             }
613
614         } else {
615             # No enumeration publication pattern specified for this level,
616             # just keed adding one.
617
618             if (!$self->capstr($key)) {
619                 # Just assume that it increments continuously and give up
620                 warn "Holding data exists for $key, but no caption specified";
621                 $next->{$key} += 1;
622                 $carry = 0;
623                 last;
624             }
625
626 #           printf("# next_enum: no publication pattern, using frequency\n");
627
628             my $cap = $self->capfield($key);
629             if ($cap->{RESTART} && $cap->{COUNT}
630                 && ($next->{$key} eq $cap->{COUNT})) {
631                 $next->{$key} = 1;
632                 $carry = 1;
633             } else {
634                 # If I don't need to "carry" beyond here, then I just increment
635                 # this level of the enumeration and stop looping, since the
636                 # "next" hash has been initialized with the current values
637
638                 $next->{$key} += 1;
639                 $carry = 0;
640             }
641
642             # You can't have a combined issue that spans two volumes: no.12/1
643             # is forbidden
644             if ($self->enum_is_combined($key, $next->{$key})) {
645                 $next->{$key} .= '/' . ($next->{$key} + 1);
646             }
647
648             last if !$carry;
649         }
650     }
651
652     # The easy part is done. There are two things left to do:
653     # 1) Calculate the date of the next issue, if necessary
654     # 2) Increment the highest level of enumeration (either by date
655     #    or because $carry is set because of the above loop
656
657     if (!$self->subfield('i')) {
658         # The simple case: if there is no chronology specified
659         # then just check $carry and return
660         $next->{'a'} += $carry;
661     } else {
662         # Figure out date of next issue, then decide if we need
663         # to adjust top level enumeration based on that
664         $self->next_date($next, $carry, ('i'..'m'));
665     }
666 }
667
668 sub next {
669     my $self = shift;
670     my $holding = shift;
671     my $next = {};
672
673     # Initialize $next with current enumeration & chronology, then
674     # we can just operate on $next, based on the contents of the caption
675
676     if ($self->enumeration_is_chronology) {
677         foreach my $key ('a' .. 'h') {
678             $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}
679               if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
680         }
681         $self->next_date($next, 0, ('a' .. 'h'));
682
683         return $next;
684     }
685
686     foreach my $key ('a' .. 'h') {
687         $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}->{HOLDINGS}
688           if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
689     }
690
691     foreach my $key ('i'..'m') {
692         $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}
693           if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
694     }
695
696     if (exists $next->{'h'}) {
697         $self->next_alt_enum($next);
698     }
699
700     $self->next_enum($next);
701
702     return($next);
703 }
704
705 1;