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