]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/OpenILS/Utils/MFHD/Caption.pm
Merge processing for $yp and $yc into single loop, since combined
[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             if (!defined $genfunc) {
388                 carp "Unrecognized chroncode '$chroncode'";
389                 return undef;
390             }
391
392             foreach my $pat (@pats) {
393                 my $combined = $pat =~ m|/|;
394                 my ($start, $end);
395                 my @candidate;
396
397 #               printf("# next_date: generating with pattern '%s'\n", $pat);
398
399                 if ($combined) {
400                     ($start, $end) = split('/', $pat, 2);
401                 } else {
402                     ($start, $end) = (undef, undef);
403                 }
404
405                 @candidate = $genfunc->($start || $pat, @cur);
406
407                 while ($self->is_omitted(@candidate)) {
408 #                   printf("# pubpat omitting date '%s'\n",
409 #                          join('/', @candidate));
410                     @candidate = $genfunc->($start || $pat, @candidate);
411                 }
412
413 #               printf("# testing new candidate '%s' against '%s'\n",
414 #                      join('/', @candidate), join('/', @new));
415
416                 if (!defined($new[0])
417                     || !on_or_after(\@candidate, \@new)) {
418                     # first time through the loop
419                     # or @candidate is before @new =>
420                     # @candidate is the next issue.
421                     @new = @candidate;
422                     if (defined $end) {
423                         @newend = $genfunc->($end, @cur);
424                     } else {
425                         $newend[0] = undef;
426                     }
427
428 #                   printf("# selecting candidate date '%s'\n", join('/', @new));
429                 }
430             }
431         }
432
433         if (defined($newend[0])) {
434             # The best match was a combined issue
435             foreach my $i (0..$#new) {
436                 # don't combine identical fields
437                 next if $new[$i] eq $newend[$i];
438                 $new[$i] .= '/' . $newend[$i];
439             }
440         }
441     } else {
442         # There is no $y publication pattern defined, so use
443         # the $w frequency to figure out the next date
444
445         if (!defined($freq)) {
446             carp "Undefined frequency in next_date!";
447         } elsif (!MFHD::Date::can_increment($freq)) {
448             carp "Don't know how to deal with frequency '$freq'!";
449         } else {
450             #
451             # One of the standard defined issue frequencies
452             #
453             @new = MFHD::Date::incr_date($freq, @cur);
454
455             while ($self->is_omitted(@new)) {
456                 @new = MFHD::Date::incr_date($freq, @new);
457             }
458
459             if ($self->is_combined(@new)) {
460                 my @second_date = MFHD::Date::incr_date($freq, @new);
461
462                 # I am cheating: This code assumes that only the smallest
463                 # time increment is combined. So, no "Apr 15/May 1" allowed.
464                 $new[-1] = $new[-1] . '/' . $second_date[-1];
465             }
466         }
467     }
468
469     for my $i (0..$#new) {
470         $next->{$keys[$i]} = $new[$i];
471     }
472     # Figure out if we need to adust volume number
473     # right now just use the $carry that was passed in.
474     # in long run, need to base this on ($carry or date_change)
475     if ($carry) {
476         # if $carry is set, the date doesn't matter: we're not
477         # going to increment the v. number twice at year-change.
478         $next->{a} += $carry;
479     } elsif (defined $pattern->{x}) {
480         $next->{a} += $self->calendar_increment(\@cur, \@new);
481     }
482 }
483
484 sub next_alt_enum {
485     my $self = shift;
486     my $next = shift;
487
488     # First handle any "alternative enumeration", since they're
489     # a lot simpler, and don't depend on the the calendar
490     foreach my $key ('h', 'g') {
491         next if !exists $next->{$key};
492         if (!$self->capstr($key)) {
493             warn "Holding data exists for $key, but no caption specified";
494             $next->{$key} += 1;
495             last;
496         }
497
498         my $cap = $self->capfield($key);
499         if ($cap->{RESTART} && $cap->{COUNT}
500             && ($next->{$key} == $cap->{COUNT})) {
501             $next->{$key} = 1;
502         } else {
503             $next->{$key} += 1;
504             last;
505         }
506     }
507 }
508
509 sub next_enum {
510     my $self = shift;
511     my $next = shift;
512     my $carry;
513
514     # $carry keeps track of whether we need to carry into the next
515     # higher level of enumeration. It's not actually necessary except
516     # for when the loop ends: if we need to carry from $b into $a
517     # then $carry will be set when the loop ends.
518     #
519     # We need to keep track of this because there are two different
520     # reasons why we might increment the highest level of enumeration ($a)
521     # 1) we hit the correct number of items in $b (ie, 5th iss of quarterly)
522     # 2) it's the right time of the year.
523     #
524     $carry = 0;
525     foreach my $key (reverse('b'..'f')) {
526         next if !exists $next->{$key};
527
528         if (!$self->capstr($key)) {
529             # Just assume that it increments continuously and give up
530             warn "Holding data exists for $key, but no caption specified";
531             $next->{$key} += 1;
532             $carry = 0;
533             last;
534         }
535
536         # If the current issue has a combined issue number (eg, 2/3)
537         # get rid of the first issue number and base the calculation
538         # on the final issue number in the combined issue.
539         if ($next->{$key} =~ m|/|) {
540             $next->{$key} =~ s|^[^/]+/||;
541         }
542
543         my $cap = $self->capfield($key);
544         if ($cap->{RESTART} && $cap->{COUNT}
545             && ($next->{$key} eq $cap->{COUNT})) {
546             $next->{$key} = 1;
547             $carry = 1;
548         } else {
549             # If I don't need to "carry" beyond here, then I just increment
550             # this level of the enumeration and stop looping, since the
551             # "next" hash has been initialized with the current values
552
553             $next->{$key} += 1;
554             $carry = 0;
555         }
556
557         # You can't have a combined issue that spans two volumes: no.12/1
558         # is forbidden
559         if ($self->enum_is_combined($key, $next->{$key})) {
560             $next->{$key} .= '/' . ($next->{$key} + 1);
561         }
562
563         last if !$carry;
564     }
565
566     # The easy part is done. There are two things left to do:
567     # 1) Calculate the date of the next issue, if necessary
568     # 2) Increment the highest level of enumeration (either by date
569     #    or because $carry is set because of the above loop
570
571     if (!$self->subfield('i')) {
572         # The simple case: if there is no chronology specified
573         # then just check $carry and return
574         $next->{'a'} += $carry;
575     } else {
576         # Figure out date of next issue, then decide if we need
577         # to adjust top level enumeration based on that
578         $self->next_date($next, $carry, ('i'..'m'));
579     }
580 }
581
582 sub next {
583     my $self = shift;
584     my $holding = shift;
585     my $next = {};
586
587     # Initialize $next with current enumeration & chronology, then
588     # we can just operate on $next, based on the contents of the caption
589
590     if ($self->enumeration_is_chronology) {
591         foreach my $key ('a' .. 'h') {
592             $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}
593               if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
594         }
595         $self->next_date($next, 0, ('a' .. 'h'));
596
597         return $next;
598     }
599
600     foreach my $key ('a' .. 'h') {
601         $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}->{HOLDINGS}
602           if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
603     }
604
605     foreach my $key ('i'..'m') {
606         $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}
607           if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
608     }
609
610     if (exists $next->{'h'}) {
611         $self->next_alt_enum($next);
612     }
613
614     $self->next_enum($next);
615
616     return($next);
617 }
618
619 1;