1 # MFHD::Holding provides some additional holdings logic to a MARC::Field
2 # object. In its current state it is primarily read-only, as direct changes
3 # to the underlying MARC::Field are not reflected in the MFHD logic layer, and
4 # only the 'increment', 'notes', and 'seqno' methods do updates to the
15 use base 'MARC::Field';
19 my $class = ref($proto) || $proto;
23 my $last_enum = undef;
25 $self->{_mfhdh_SEQNO} = $seqno;
26 $self->{_mfhdh_CAPTION} = $caption;
27 $self->{_mfhdh_DESCR} = {};
28 $self->{_mfhdh_COPY} = undef;
29 $self->{_mfhdh_BREAK} = undef;
30 $self->{_mfhdh_NOTES} = {};
31 $self->{_mfhdh_NOTES}{public} = [];
32 $self->{_mfhdh_NOTES}{private} = [];
33 $self->{_mfhdh_COPYRIGHT} = [];
34 $self->{_mfhdh_COMPRESSED} = ($self->indicator(2) eq '0' || $self->indicator(2) eq '2') ? 1 : 0;
35 # TODO: full support for second indicators 2, 3, and 4
36 $self->{_mfhdh_OPEN_ENDED} = 0;
38 my $first_enum_or_chron_code = '';
39 foreach my $subfield ($self->subfields) {
40 my ($key, $val) = @$subfield;
42 if ($key =~ /[a-m]/) {
43 $first_enum_or_chron_code = $key unless $first_enum_or_chron_code;
44 if (exists($self->{_mfhdh_FIELDS}->{$key})) {
45 carp("Duplicate, non-repeatable subfield '$key' found, ignoring");
47 } elsif (!$caption->capfield($key)) {
48 carp("Subfield '$key' has no corresponding caption, ignoring");
51 if ($self->{_mfhdh_COMPRESSED}) {
52 $self->{_mfhdh_FIELDS}->{$key}{HOLDINGS} = [split(/\-/, $val, -1)];
53 if (!defined($self->{_mfhdh_FIELDS}->{$key}{HOLDINGS}[1])) {
54 $self->{_mfhdh_FIELDS}->{$key}{HOLDINGS}[1] = $self->{_mfhdh_FIELDS}->{$key}{HOLDINGS}[0];
57 $self->{_mfhdh_FIELDS}->{$key}{HOLDINGS} = [$val];
59 if ($key =~ /[a-h]/) {
60 # Enumeration specific details of holdings
61 $self->{_mfhdh_FIELDS}->{$key}{UNIT} = undef;
64 } elsif ($key eq 'o') {
65 warn '$o specified prior to first enumeration'
66 unless defined($last_enum);
67 $self->{_mfhdh_FIELDS}->{$last_enum}->{UNIT} = $val;
69 } elsif ($key =~ /[npq]/) {
70 $self->{_mfhdh_DESCR}->{$key} = $val;
71 } elsif ($key eq 's') {
72 push @{$self->{_mfhdh_COPYRIGHT}}, $val;
73 } elsif ($key eq 't') {
74 $self->{_mfhdh_COPY} = $val;
75 } elsif ($key eq 'w') {
76 carp "Unrecognized break indicator '$val'"
77 unless $val =~ /^[gn]$/;
78 $self->{_mfhdh_BREAK} = $val;
79 } elsif ($key eq 'x') {
80 push @{$self->{_mfhdh_NOTES}{private}}, $val;
81 } elsif ($key eq 'z') {
82 push @{$self->{_mfhdh_NOTES}{public}}, $val;
86 if ( $self->{_mfhdh_COMPRESSED}
87 && $self->{_mfhdh_FIELDS}{$first_enum_or_chron_code}{HOLDINGS}[1] eq '') {
88 $self->{_mfhdh_OPEN_ENDED} = 1;
95 # accessor to the object's field hash
97 # We are avoiding calling these elements 'subfields' because they are more
98 # than simply the MARC subfields, although in the current implementation they
99 # are indexed on the subfield key
101 # TODO: this accessor should probably be replaced with methods which hide the
102 # underlying structure of {_mfhdh_FIELDS} (see field_values for a start)
107 return $self->{_mfhdh_FIELDS};
111 # Given a field key, returns an array ref of one (for single statements)
112 # or two (for compressed statements) values
114 # TODO: add setter functionality to replace direct {HOLDINGS} access in other
115 # methods. It also makes sense to override some of the MARC::Field setter
116 # methods (such as update()) to accomplish this level of encapsulation.
119 my ($self, $key) = @_;
121 if (exists $self->fields->{$key}) {
122 my @values = @{$self->fields->{$key}{HOLDINGS}};
124 } elsif ($self->caption->capfield($key)) {
125 carp("No values found for existing caption subfield '$key', returning '*' (unknown value indicator)");
126 if ($self->is_compressed) {
140 $self->{_mfhdh_SEQNO} = $_[0];
141 $self->update(8 => $self->caption->link_id . '.' . $_[0]);
144 return $self->{_mfhdh_SEQNO};
148 # Optionally accepts a true/false value to set the 'compressed' attribute
149 # Returns 'compressed' attribute
153 my $is_compressed = shift;
155 if (defined($is_compressed)) {
156 if ($is_compressed) {
157 $self->{_mfhdh_COMPRESSED} = 1;
158 $self->update(ind2 => '0');
160 $self->{_mfhdh_COMPRESSED} = 0;
161 $self->update(ind2 => '1');
165 return $self->{_mfhdh_COMPRESSED};
171 return $self->{_mfhdh_OPEN_ENDED};
179 $self->{_mfhdh_CAPTION} = $caption;
182 return $self->{_mfhdh_CAPTION};
186 # notes: If called with no arguments, returns the public notes array ref.
187 # If called with a single argument, it returns either 'public' or
188 # 'private' notes based on the passed string.
190 # If called with more than one argument, it sets the proper note field, with
191 # type being the first argument and the note value(s) as the remaining
194 # It is also optional to pass in an array ref of note values as the third
195 # argument rather than a list.
204 } elsif ($type ne 'public' && $type ne 'private') {
205 carp("Notes being applied without specifying type");
206 unshift(@notes, $type);
210 if (ref($notes[0])) {
211 $self->{_mfhdh_NOTES}{$type} = $notes[0];
212 $self->_replace_note_subfields($type, @{$notes[0]});
215 $self->{_mfhdh_NOTES}{$type} = \@notes;
217 $self->{_mfhdh_NOTES}{$type} = [];
219 $self->_replace_note_subfields($type, @notes);
222 return $self->{_mfhdh_NOTES}{$type};
226 # utility function for 'notes' method
228 sub _replace_note_subfields {
232 my %note_subfield_ids = ('public' => 'z', 'private' => 'x');
234 $self->delete_subfield(code => $note_subfield_ids{$type});
236 foreach my $note (@notes) {
237 $self->add_subfields($note_subfield_ids{$type} => $note);
242 # return a simple subfields list (for easier revivification from database)
248 foreach my $subfield ($self->subfields) {
249 push(@subfields, $subfield->[0], $subfield->[1]);
253 my %__mfhd_month_labels = (
254 '01' => ['Jan.', 'January'],
255 '02' => ['Feb.', 'February'],
256 '03' => ['Mar.', 'March'],
257 '04' => ['Apr.', 'April'],
258 '05' => ['May ', 'May'],
259 '06' => ['Jun.', 'June'],
260 '07' => ['Jul.', 'July'],
261 '08' => ['Aug.', 'August'],
262 '09' => ['Sep.', 'September'],
263 '10' => ['Oct.', 'October'],
264 '11' => ['Nov.', 'November'],
265 '12' => ['Dec.', 'December'],
272 sub _get_mfhd_month_label {
273 my ($month, $long) = @_;
276 my $o = $__mfhd_month_labels{$month};
277 return (ref $o) ? $o->[$long] : $o;
280 # Called by method 'format_chron'
282 sub format_single_chron {
284 my $holdings = shift;
286 my $skip_sep = shift;
292 return if !defined $self->caption->capstr($key);
294 $capstr = $self->caption->capstr($key);
295 if (substr($capstr, 0, 1) eq '(') {
296 # a caption enclosed in parentheses is not displayed
300 # If this is the second level of chronology, then it's
301 # likely to be a month or season, so we should use the
302 # string name rather than the number given.
303 if ($key eq 'b' or $key eq 'j') {
304 # account for possible combined issue chronology
305 my @chron_parts = split('/', $holdings->{$key});
306 for (my $i = 0; $i < @chron_parts; $i++) {
307 my $month_label = _get_mfhd_month_label($chron_parts[$i], $long);
308 $chron_parts[$i] = $month_label if defined $month_label;
310 $chron = join('/', @chron_parts);
312 $chron = $holdings->{$key};
315 $skip_sep ||= ($key eq 'a' || $key eq 'i');
317 return ($skip_sep ? '' : $sep) . $capstr . $chron;
321 # Called by method 'format_part' for formatting the chronology portion of
322 # the holding statement
326 my $holdings = shift;
330 foreach my $key (@keys) {
331 my $skip_sep = ($str =~ /[. ]$/);
332 my $new_part = $self->format_single_chron($holdings, $key, $skip_sep);
333 last unless defined $new_part;
341 # Called by method 'format_part' for each enum subfield
343 sub format_single_enum {
345 my $holding_values = shift;
347 my $skip_sep = shift;
352 return if !defined $self->caption->capstr($key);
354 $capstr = $self->caption->capstr($key);
355 if (substr($capstr, 0, 1) eq '(') {
356 # a caption enclosed in parentheses is not displayed
358 } elsif ($skip_sep) {
359 # We'll let a $skip_sep parameter of true mean what it means down by
360 # the return statement AND to pad the caption itself here.
365 $skip_sep ||= ($key eq 'a');
366 return ($skip_sep ? '' : $sep) . $capstr . $holding_values->{$key};
370 # Called by method 'format' for each member of a possibly compressed holding
374 my $holding_values = shift;
375 my $caption = $self->caption;
378 if ($caption->type_of_unit) {
379 $str = $caption->type_of_unit . ' ';
382 if ($caption->enumeration_is_chronology) {
383 # if issues are identified by chronology only, then the
384 # chronology data is stored in the enumeration subfields,
385 # so format those fields as if they were chronological.
386 $str = $self->format_chron($holding_values, 'a'..'f');
388 # OK, there is enumeration data and maybe chronology
389 # data as well, format both parts appropriately
392 foreach my $key ('a'..'f') {
393 my $new_part = $self->format_single_enum($holding_values, $key);
394 last unless defined $new_part;
399 if (defined $caption->capstr('i')) {
401 $str .= $self->format_chron($holding_values, 'i'..'l');
405 if ($caption->capstr('g')) {
406 # There's at least one level of alternative enumeration
408 foreach my $key ('g', 'h') {
410 ($key eq 'g' ? '' : ':')
411 . $caption->capstr($key)
412 . $holding_values->{$key};
415 # This assumes that alternative chronology is only ever
416 # provided if there is an alternative enumeration.
417 if ($caption->capstr('m')) {
418 # Alternative Chronology
420 $str .= $caption->capstr('m') . $holding_values->{'m'};
426 # Breaks in the sequence
427 # XXX: this is non-standard and also not the right place for this, since gaps
428 # only make sense in the context of multiple holding segments, not a single
430 # if (defined($self->{_mfhdh_BREAK})) {
431 # if ($self->{_mfhdh_BREAK} eq 'n') {
432 # $str .= ' non-gap break';
433 # } elsif ($self->{_mfhdh_BREAK} eq 'g') {
436 # warn "unrecognized break indicator '$self->{_mfhdh_BREAK}'";
444 # Create and return a string which conforms to display standard Z39.71
448 my $subfields = $self->fields;
453 foreach my $key (keys %$subfields) {
454 ($holding_start{$key}, $holding_end{$key}) =
455 @{$self->field_values($key)};
458 if ($self->is_compressed) {
459 # deal with open-ended statements
461 if ($self->is_open_ended) {
464 $formatted_end = $self->format_part(\%holding_end);
467 $self->format_part(\%holding_start) . ' - ' . $formatted_end;
469 $formatted = $self->format_part(\%holding_start);
473 if (@{$self->notes}) {
474 $formatted .= ' -- ' . join(', ', @{$self->notes});
480 # next: Given a holding statement, return a hash containing the
481 # enumeration values for the next issues, whether we hold it or not
482 # Just pass through to Caption::next
486 my $caption = $self->caption;
488 return $caption->next($self);
492 # matches($pat): check to see if $self matches the enumeration hashref passed
493 # in as $pat, as returned by the 'next' method. e.g.:
494 # $holding2->matches($holding1->next) # true if $holding2 directly follows
497 # Always returns false if $self is compressed
503 return 0 if $self->is_compressed;
505 foreach my $key ('a'..'f') {
506 # If a subfield exists in $self but not in $pat, or vice versa
507 # or if the field has different values, then fail
509 defined($self->field_values($key)) != exists($pat->{$key})
510 || (exists $pat->{$key}
511 && ($self->field_values($key)->[0] ne $pat->{$key}))
520 # Check that all the fields in a holdings statement are
521 # included in the corresponding caption.
526 foreach my $key (keys %{$self->fields}) {
527 if (!$self->caption || !$self->caption->capfield($key)) {
535 # Replace a single holding with it's next prediction
541 if ($self->is_open_ended) {
542 carp "Holding is open-ended, cannot increment";
544 } elsif ($self->is_compressed) {
545 carp "Incrementing a compressed holding is deprecated, use extend instead";
546 return $self->extend;
549 my $next = $self->next();
551 foreach my $key (keys %{$next}) {
552 $self->fields->{$key}{HOLDINGS}[0] = $next->{$key};
555 $self->seqno($self->seqno + 1);
556 $self->update(%{$next}); # update underlying subfields
561 # Extends a holding (compressing if needed) to include the next
562 # prediction and returns itself
567 if ($self->is_open_ended) {
568 carp "Holding is open-ended, cannot extend";
572 my $next = $self->next();
574 if (!$self->is_compressed) {
575 $self->is_compressed(1); # add compressed state
578 foreach my $key (keys %{$next}) {
579 my @values = @{$self->field_values($key)};
580 $values[1] = $next->{$key};
581 $self->fields->{$key}{HOLDINGS} = \@values;
582 $next->{$key} = join('-', @values);
585 $self->update(%{$next}); # update underlying subfields
590 # Turns a compressed holding into the singular form of the first member
593 sub compressed_to_first {
596 if (!$self->is_compressed) {
597 carp "Holding not compressed, cannot convert to first member";
602 foreach my $key (keys %{$self->fields}) {
603 my @values = @{$self->field_values($key)};
604 $self->fields->{$key}{HOLDINGS} = [$values[0]];
605 $changes{$key} = $values[0];
608 $self->update(%changes); # update underlying subfields
609 $self->is_compressed(0); # remove compressed state
615 # Turns a compressed holding into the singular form of the last member
618 sub compressed_to_last {
621 if (!$self->is_compressed) {
622 carp "Holding not compressed, cannot convert to last member";
624 } elsif ($self->is_open_ended) {
625 carp "Holding is open-ended, cannot convert to last member";
630 foreach my $key (keys %{$self->fields}) {
631 my @values = @{$self->field_values($key)};
632 $self->fields->{$key}{HOLDINGS} = [$values[1]];
633 $changes{$key} = $values[1];
636 $self->update(%changes); # update underlying subfields
637 $self->is_compressed(0); # remove compressed state
643 # Creates or replaces an end of a compressed holding
645 # If $end_holding does not share caption data with $self, results
646 # will be unpredicable
650 my $end_holding = shift;
653 if ($end_holding and !$end_holding->is_open_ended) {
654 if ($end_holding->is_compressed) {
655 $end_holding = $end_holding->clone->compressed_to_last;
657 foreach my $key (keys %{$self->fields}) {
658 my @values = @{$self->field_values($key)};
659 my @end_values = @{$end_holding->field_values($key)};
660 $values[1] = $end_values[0];
661 $self->fields->{$key}{HOLDINGS} = \@values;
662 $changes{$key} = join('-', @values);
664 } elsif (!$self->is_open_ended) { # make open-ended if no $end_holding (or $end_holding was open ended)
665 foreach my $key (keys %{$self->fields}) {
666 my @values = @{$self->field_values($key)};
667 $self->fields->{$key}{HOLDINGS} = [$values[0]];
668 $changes{$key} = $values[0] . '-';
670 $self->{_mfhdh_OPEN_ENDED} = 1; #TODO: setter for this value
673 $self->update(%changes); # update underlying subfields
675 if (!$self->is_compressed) {
676 $self->is_compressed(1); # add compressed state
683 # Basic, working, unoptimized clone operation
688 my $clone_field = $self->SUPER::clone();
689 return new MFHD::Holding($self->seqno, $clone_field, $self->caption);
693 # Turn a chronology instance into date(s) in YYYY-MM-DD format
695 # In list context it returns a list of start and (possibly undefined)
698 # In scalar context, it returns a YYYY-MM-DD date string of either the
699 # single date or the (possibly undefined) end date of a compressed holding
703 my $caption = $self->caption;
706 if ($caption->enumeration_is_chronology) {
712 # @chron_start and @chron_end will hold the (year, month, day) values
713 # represented by the start and optional end of the chronology instance.
714 # Default to January 1 with a year of 0 as initial values.
715 my @chron_start = (0, 1, 1);
716 my @chron_end = (0, 1, 1);
717 my @chrons = (\@chron_start, \@chron_end);
718 foreach my $key (@keys) {
719 my $capstr = $caption->capstr($key);
720 last if !defined($capstr);
721 if ($capstr =~ /year/) {
722 ($chron_start[0], $chron_end[0]) = @{$self->field_values($key)};
723 } elsif ($capstr =~ /month/) {
724 ($chron_start[1], $chron_end[1]) = @{$self->field_values($key)};
725 } elsif ($capstr =~ /day/) {
726 ($chron_start[2], $chron_end[2]) = @{$self->field_values($key)};
727 } elsif ($capstr =~ /season/) {
728 # chrons defined as season-only will use the astronomical season
729 # dates as a basic estimate.
730 my @seasons = @{$self->field_values($key)};
731 for (my $i = 0; $i < @seasons; $i++) {
732 $seasons[$i] = &_uncombine($seasons[$i], 0);
733 if ($seasons[$i] == 21) {
734 $chrons[$i]->[1] = 3;
735 $chrons[$i]->[2] = 20;
736 } elsif ($seasons[$i] == 22) {
737 $chrons[$i]->[1] = 6;
738 $chrons[$i]->[2] = 21;
739 } elsif ($seasons[$i] == 23) {
740 $chrons[$i]->[1] = 9;
741 $chrons[$i]->[2] = 22;
742 } elsif ($seasons[$i] == 24) {
743 # "winter" can come at the beginning or end of a year,
744 if ($self->caption->winter_starts_year()) {
745 $chrons[$i]->[1] = 1;
746 $chrons[$i]->[2] = 1;
747 } else { # default to astronomical
748 $chrons[$i]->[1] = 12;
749 $chrons[$i]->[2] = 21;
756 # if we have an an annual, set the month to ypm## if available
757 if (exists($self->caption->{_mfhdc_PATTERN}->{y}->{p}) and $self->caption->{_mfhdc_PATTERN}->{w} eq 'a') {
758 my $reg = $self->caption->{_mfhdc_PATTERN}->{y}->{p}->[0];
759 if ($reg =~ /^m(\d+)/) {
760 $chrons[0]->[1] = $1;
761 $chrons[1]->[1] = $1;
766 foreach my $chron (@chrons) {
768 if ($chron->[0] != 0) {
770 &_uncombine($chron->[0], 0) . '-'
771 . sprintf('%02d', $chron->[1]) . '-'
772 . sprintf('%02d', $chron->[2]);
779 } elsif ($self->is_compressed) {
787 # utility function for uncombining instance parts
790 my ($combo, $pos) = @_;
793 carp("Function '_uncombine' is not an instance method");
797 my @parts = split('/', $combo);
802 # Overload string comparison operators
804 # We are not overloading '<=>' because '==' is used liberally in MARC::Record
805 # to compare field identity (i.e. is this the same exact Field object?), not value
807 # Other string operators are auto-generated from 'cmp'
809 # Please note that this comparison is based on what the holding represents,
810 # not whether it is strictly identical (e.g. the seqno and link may vary)
812 # XXX: sorting using this operator is currently not deterministic for
813 # nonsensical holdings (e.g. V.10-V.5), and may require further consideration
814 use overload ('cmp' => \&_compare,
817 my ($holding_1, $holding_2, $swap) = @_;
819 # TODO: this needs some more consideration
820 # fall back to 'built-in' comparison
821 if (!UNIVERSAL::isa($holding_2, ref $holding_1)) {
822 if (defined $holding_2) {
823 carp("Use of non-holding in holding comparison operation") if $holding_2 ne '~~~';
825 return ( "$holding_2" cmp "$holding_1" );
827 return ( "$holding_1" cmp "$holding_2" );
830 carp("Use of undefined value in holding comparison operation");
831 return 1; # similar to built-in, something is "greater than" nothing
835 # special cases for compressed holdings
836 my ($holding_1_first, $holding_1_last, $holding_2_first, $holding_2_last, $found_compressed);
837 # 0 for no compressed, 1 for first compressed, 2 for second compressed, 3 for both compressed
838 $found_compressed = 0;
839 if ($holding_1->is_compressed) {
840 if (!$holding_1->is_open_ended) {
841 $holding_1_last = $holding_1->clone->compressed_to_last;
843 $holding_1_last = '~~~'; # take advantage of string sort fallback
845 $found_compressed += 1;
847 $holding_1_first = $holding_1;
848 $holding_1_last = $holding_1;
850 if ($holding_2->is_compressed) {
851 $holding_2_first = $holding_2->clone->compressed_to_first;
852 $found_compressed += 2;
854 $holding_2_first = $holding_2;
855 $holding_2_last = $holding_2;
858 if ($found_compressed) {
859 my $cmp = ($holding_1_last cmp $holding_2_first); # 1 ends before 2 starts
861 return -1; # 1 is fully lt
862 } elsif ($cmp == 0) {
863 carp("Overlapping holdings in comparison, lt and gt based on start value only");
865 } else { # check the opposite, 2 ends before 1 starts
866 # clone is expensive, wait until we need it (here)
867 if (!defined($holding_2_last)) {
868 if (!$holding_2->is_open_ended) {
869 $holding_2_last = $holding_2->clone->compressed_to_last;
871 $holding_2_last = '~~~'; # take advantage of string sort fallback
874 if (!defined($holding_1_first)) {
875 $holding_1_first = $holding_1->clone->compressed_to_first;
877 $cmp = ($holding_2_last cmp $holding_1_first);
879 return 1; # 1 is fully gt
880 } elsif ($cmp == 0) {
881 carp("Overlapping holdings in comparison, lt and gt based on start value only");
884 $cmp = ($holding_1_first cmp $holding_2_first);
885 if ($cmp) { # they are not equal
886 carp("Overlapping holdings in comparison, lt and gt based on start value only");
888 } elsif ($found_compressed == 1) {
889 carp("Compressed holding found with start equal to non-compressed holding");
890 return 1; # compressed (first holding) is 'greater than' non-compressed
891 } elsif ($found_compressed == 2) {
892 carp("Compressed holding found with start equal to non-compressed holding");
893 return -1; # compressed (second holding) is 'greater than' non-compressed
894 } else { # both holdings compressed, check for full equality
895 $cmp = ($holding_1_last cmp $holding_2_last);
896 if ($cmp) { # they are not equal
897 carp("Compressed holdings in comparison have equal starts, lt and gt based on end value only");
900 return 0; # both are compressed, both ends are equal
907 # start doing the actual comparison
909 foreach my $key ('a'..'f', 'i'..'m') {
910 if (defined($holding_1->field_values($key))) {
911 if (!defined($holding_2->field_values($key))) {
912 return 1; # more details equals 'greater' (?)
914 my $holding_1_value = $holding_1->field_values($key)->[0];
915 my $holding_1_unsure = ($holding_1_value =~ s/\[|\]//g);
916 my $holding_2_value = $holding_2->field_values($key)->[0];
917 my $holding_2_unsure = ($holding_2_value =~ s/\[|\]//g);
918 $result = $holding_1_value <=> $holding_2_value;
919 if (!$result) { # they are 'equal' but we will sort 'maybe' values before 'sure' values (TODO: rethink this is it complicates some algorithms)
920 $result = $holding_2_unsure <=> $holding_1_unsure;
923 } elsif (defined($holding_2->field_values($key))) {
924 return -1; # more details equals 'greater' (?)
927 return $result if $result;
930 # got through, return 0 for equal