]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/OpenILS/Utils/MFHD/Caption.pm
Refactor to pass around array refs instead of long lists. Generalize on_or_before()
[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             # $dt1 occurs AFTER $dt2
288             return 1;
289         } elsif ($dt1->[$i] < $dt2->[$i]) {
290             # $dt1 occurs BEFORE $dt2
291             return 0;
292         }
293         # both are still equal, keep going
294     }
295
296     # We fell out of the loop with them being equal, so it's 'on'
297     return 1;
298 }
299
300 sub calendar_increment {
301     my $self = shift;
302     my $cur = shift;
303     my $new = shift;
304     my $cal_change = $self->calendar_change;
305     my $month;
306     my $day;
307     my $cur_before;
308     my $new_on_or_after;
309
310     # A calendar change is defined, need to check if it applies
311     if ((scalar(@{$new}) == 2 && $new->[1] > 20) || (scalar(@{$new}) == 1)) {
312         carp "Can't calculate date change for ", $self->as_string;
313         return;
314     }
315
316     foreach my $change (@{$cal_change}) {
317         my $incr;
318
319         if (length($change) == 2) {
320             $month = $change;
321         } elsif (length($change) == 4) {
322             ($month, $day) = unpack("a2a2", $change);
323         }
324
325         printf("# calendar_increment('%s', '%s'): change on '%s/%s'\n",
326                join('/', @{$cur}), join('/', @{$new}),
327                $month, defined($day) ? $day : 'UNDEF');
328
329         if ($cur->[0] == $new->[0]) {
330             # Same year, so a 'simple' month/day comparison will be fine
331             $incr = (!on_or_after([$cur->[1], $cur->[2]], [$month, $day])
332                      && on_or_after([$new->[1], $new->[2]], [$month, $day]));
333         } else {
334             # @cur is in the year before @new. There are
335             # two possible cases for the calendar change date that
336             # indicate that it's time to change the volume:
337             # (1) the change date is AFTER @cur in the year, or
338             # (2) the change date is BEFORE @new in the year.
339             # 
340             #  -------|------|------X------|------|
341             #       @cur    (1)   Jan 1   (2)   @new
342
343             $incr = (on_or_after([$new->[1], $new->[2]], [$month, $day])
344                      || !on_or_after([$cur->[1], $cur->[2]], [$month, $day]));
345         }
346         return $incr if $incr;
347     }
348
349     return 0;
350 }
351
352 sub next_date {
353     my $self = shift;
354     my $next = shift;
355     my $carry = shift;
356     my @keys = @_;
357     my @cur;
358     my @new;
359     my @newend; # only used for combined issues
360     my $incr;
361
362     my $reg = $self->{_mfhdc_REGULARITY};
363     my $pattern = $self->{_mfhdc_PATTERN};
364     my $freq = $pattern->{w};
365
366     foreach my $i (0..$#keys) {
367         $cur[$i] = $next->{$keys[$i]} if exists $next->{$keys[$i]};
368     }
369
370     # If the current issue has a combined date (eg, May/June)
371     # get rid of the first date and base the calculation
372     # on the final date in the combined issue.
373     $cur[-1] =~ s|^[^/]+/||;
374
375     if (defined $pattern->{y}->{p}) {
376         # There is a $y publication pattern defined in the record:
377         # use it to calculate the next issue date.
378
379         # XXX TODO: need to handle combined issues.
380         foreach my $pubpat (@{$pattern->{y}->{p}}) {
381             my $chroncode = substr($pubpat, 0, 1);
382             my $genfunc = MFHD::Date::generator($chroncode);
383             my @pats = split(/,/, substr($pubpat, 1));
384
385             if (!defined $genfunc) {
386                 carp "Unrecognized chroncode '$chroncode'";
387                 return undef;
388             }
389
390             foreach my $pat (@pats) {
391                 printf("# next_date: generating with pattern '%s'\n", $pat);
392                 my @candidate = $genfunc->($pat, @cur);
393
394                 while ($self->is_omitted(@candidate)) {
395 #                   printf("# pubpat omitting date '%s'\n",
396 #                          join('/', @candidate));
397                     @candidate = $genfunc->($pat, @candidate);
398                 }
399
400                 printf("# testing new candidate '%s' against '%s'\n",
401                        join('/', @candidate), join('/', @new));
402                 if (!defined($new[0])
403                     || !on_or_after(\@candidate, \@new)) {
404                     # first time through the loop
405                     # or @candidate is before @new => @candidate is the next
406                     # issue.
407                     @new = @candidate;
408                     printf("# selecting candidate date '%s'\n", join('/', @new));
409                 }
410             }
411         }
412
413         # Now check for combined issues, like "May/June"
414         foreach my $combpat (@{$pattern->{y}->{c}}) {
415             my $chroncode = substr($combpat, 0, 1);
416             my $genfunc = MFHD::Date::generator($chroncode);
417             my @pats = split(/,/, substr($combpat, 1));
418
419             foreach my $combined (@pats) {
420                 my ($start, $end) = split('/', $combined, 2);
421                 my @candidate = $genfunc->($start, @cur);
422
423                 # We don't need to check for omitted issues because
424                 # combined issues are always published. OR ARE THEY????
425                 if (!defined($new[0])
426                     || !on_or_after(\@candidate, \@new)) {
427                     # Haven't found a next issue at all yet, or
428                     # this one is before the best guess so far
429                     @new = @candidate;
430                     @newend = $genfunc->($end, @cur);
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     } else {
444         # There is no $y publication pattern defined, so use
445         # the $w frequency to figure out the next date
446
447         if (!defined($freq)) {
448             carp "Undefined frequency in next_date!";
449         } elsif (!MFHD::Date::can_increment($freq)) {
450             carp "Don't know how to deal with frequency '$freq'!";
451         } else {
452             #
453             # One of the standard defined issue frequencies
454             #
455             @new = MFHD::Date::incr_date($freq, @cur);
456
457             while ($self->is_omitted(@new)) {
458                 @new = MFHD::Date::incr_date($freq, @new);
459             }
460
461             if ($self->is_combined(@new)) {
462                 my @second_date = MFHD::Date::incr_date($freq, @new);
463
464                 # I am cheating: This code assumes that only the smallest
465                 # time increment is combined. So, no "Apr 15/May 1" allowed.
466                 $new[-1] = $new[-1] . '/' . $second_date[-1];
467             }
468         }
469     }
470
471     for my $i (0..$#new) {
472         $next->{$keys[$i]} = $new[$i];
473     }
474     # Figure out if we need to adust volume number
475     # right now just use the $carry that was passed in.
476     # in long run, need to base this on ($carry or date_change)
477     if ($carry) {
478         # if $carry is set, the date doesn't matter: we're not
479         # going to increment the v. number twice at year-change.
480         $next->{a} += $carry;
481     } elsif (defined $pattern->{x}) {
482         $next->{a} += $self->calendar_increment(\@cur, \@new);
483     }
484 }
485
486 sub next_alt_enum {
487     my $self = shift;
488     my $next = shift;
489
490     # First handle any "alternative enumeration", since they're
491     # a lot simpler, and don't depend on the the calendar
492     foreach my $key ('h', 'g') {
493         next if !exists $next->{$key};
494         if (!$self->capstr($key)) {
495             warn "Holding data exists for $key, but no caption specified";
496             $next->{$key} += 1;
497             last;
498         }
499
500         my $cap = $self->capfield($key);
501         if ($cap->{RESTART} && $cap->{COUNT}
502             && ($next->{$key} == $cap->{COUNT})) {
503             $next->{$key} = 1;
504         } else {
505             $next->{$key} += 1;
506             last;
507         }
508     }
509 }
510
511 sub next_enum {
512     my $self = shift;
513     my $next = shift;
514     my $carry;
515
516     # $carry keeps track of whether we need to carry into the next
517     # higher level of enumeration. It's not actually necessary except
518     # for when the loop ends: if we need to carry from $b into $a
519     # then $carry will be set when the loop ends.
520     #
521     # We need to keep track of this because there are two different
522     # reasons why we might increment the highest level of enumeration ($a)
523     # 1) we hit the correct number of items in $b (ie, 5th iss of quarterly)
524     # 2) it's the right time of the year.
525     #
526     $carry = 0;
527     foreach my $key (reverse('b'..'f')) {
528         next if !exists $next->{$key};
529
530         if (!$self->capstr($key)) {
531             # Just assume that it increments continuously and give up
532             warn "Holding data exists for $key, but no caption specified";
533             $next->{$key} += 1;
534             $carry = 0;
535             last;
536         }
537
538         # If the current issue has a combined issue number (eg, 2/3)
539         # get rid of the first issue number and base the calculation
540         # on the final issue number in the combined issue.
541         if ($next->{$key} =~ m|/|) {
542             $next->{$key} =~ s|^[^/]+/||;
543         }
544
545         my $cap = $self->capfield($key);
546         if ($cap->{RESTART} && $cap->{COUNT}
547             && ($next->{$key} eq $cap->{COUNT})) {
548             $next->{$key} = 1;
549             $carry = 1;
550         } else {
551             # If I don't need to "carry" beyond here, then I just increment
552             # this level of the enumeration and stop looping, since the
553             # "next" hash has been initialized with the current values
554
555             $next->{$key} += 1;
556             $carry = 0;
557         }
558
559         # You can't have a combined issue that spans two volumes: no.12/1
560         # is forbidden
561         if ($self->enum_is_combined($key, $next->{$key})) {
562             $next->{$key} .= '/' . ($next->{$key} + 1);
563         }
564
565         last if !$carry;
566     }
567
568     # The easy part is done. There are two things left to do:
569     # 1) Calculate the date of the next issue, if necessary
570     # 2) Increment the highest level of enumeration (either by date
571     #    or because $carry is set because of the above loop
572
573     if (!$self->subfield('i')) {
574         # The simple case: if there is no chronology specified
575         # then just check $carry and return
576         $next->{'a'} += $carry;
577     } else {
578         # Figure out date of next issue, then decide if we need
579         # to adjust top level enumeration based on that
580         $self->next_date($next, $carry, ('i'..'m'));
581     }
582 }
583
584 sub next {
585     my $self = shift;
586     my $holding = shift;
587     my $next = {};
588
589     # Initialize $next with current enumeration & chronology, then
590     # we can just operate on $next, based on the contents of the caption
591
592     if ($self->enumeration_is_chronology) {
593         foreach my $key ('a' .. 'h') {
594             $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}
595               if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
596         }
597         $self->next_date($next, 0, ('a' .. 'h'));
598
599         return $next;
600     }
601
602     foreach my $key ('a' .. 'h') {
603         $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}->{HOLDINGS}
604           if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
605     }
606
607     foreach my $key ('i'..'m') {
608         $next->{$key} = $holding->{_mfhdh_SUBFIELDS}->{$key}
609           if defined $holding->{_mfhdh_SUBFIELDS}->{$key};
610     }
611
612     if (exists $next->{'h'}) {
613         $self->next_alt_enum($next);
614     }
615
616     $self->next_enum($next);
617
618     return($next);
619 }
620
621 1;