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