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