6 use DateTime::Format::Strptime;
9 # for inherited methods to work properly, we need to force a
10 # MARC::Record version greater than 2.0.0
11 use MARC::Record "2.0.1";
12 use base 'MARC::Record';
14 use OpenILS::Utils::MFHD::Caption;
15 use OpenILS::Utils::MFHD::Holding;
19 my $class = ref($proto) || $proto;
22 $self->{_strp_date} = new DateTime::Format::Strptime(pattern => '%F');
24 $self->{_mfhd_CAPTIONS} = {};
25 $self->{_mfhd_COMPRESSIBLE} = (substr($self->leader, 17, 1) =~ /[45]/);
27 foreach my $field ('853', '854', '855') {
29 foreach my $caption ($self->field($field)) {
32 $cap_id = $caption->subfield('8') || '0';
34 if (exists $captions->{$cap_id}) {
35 carp "Multiple MFHD captions with label '$cap_id'";
38 $captions->{$cap_id} = new MFHD::Caption($caption);
39 if ($self->{_mfhd_COMPRESSIBLE}) {
40 $self->{_mfhd_COMPRESSIBLE} &&=
41 $captions->{$cap_id}->compressible;
44 $self->{_mfhd_CAPTIONS}->{$field} = $captions;
47 foreach my $field ('863', '864', '865') {
51 ($cap_field = $field) =~ s/6/5/;
53 foreach my $hfield ($self->field($field)) {
54 my ($linkage, $link_id, $seqno);
57 $linkage = $hfield->subfield('8');
58 ($link_id, $seqno) = split(/\./, $linkage);
60 if (!exists $holdings->{$link_id}) {
61 $holdings->{$link_id} = {};
64 new MFHD::Holding($seqno, $hfield,
65 $self->{_mfhd_CAPTIONS}->{$cap_field}->{$link_id});
66 $holdings->{$link_id}->{$seqno} = $holding;
68 if ($self->{_mfhd_COMPRESSIBLE}) {
69 $self->{_mfhd_COMPRESSIBLE} &&= $holding->validate;
72 $self->{_mfhd_HOLDINGS}->{$field} = $holdings;
82 return $self->{_mfhd_COMPRESSIBLE};
85 sub caption_link_ids {
89 return sort keys %{$self->{_mfhd_CAPTIONS}->{$field}};
92 # optional argument to get back a 'hashref' or an 'array' (default)
96 my $return_type = shift;
98 # TODO: add support for caption types as argument? (base, index, supplement)
99 my @sorted_ids = $self->caption_link_ids($tag);
101 if (defined($return_type) and $return_type eq 'hashref') {
103 foreach my $link_id (@sorted_ids) {
104 $captions{$link_id} = $self->{_mfhd_CAPTIONS}{$tag}{$link_id};
109 foreach my $link_id (@sorted_ids) {
110 push(@captions, $self->{_mfhd_CAPTIONS}{$tag}{$link_id});
119 my $field_count = $self->SUPER::append_fields(@_);
121 foreach my $field (@_) {
122 $self->_avoid_link_collision($field);
123 my $field_type = ref $field;
124 if ($field_type eq 'MFHD::Holding') {
125 $self->{_mfhd_HOLDINGS}{$field->tag}{$field->caption->link_id}{$field->seqno} = $field;
126 } elsif ($field_type eq 'MFHD::Caption') {
127 $self->{_mfhd_CAPTIONS}{$field->tag}{$field->link_id} = $field;
140 my $field_count = $self->SUPER::delete_field($field);
142 my $field_type = ref($field);
143 if ($field_type eq 'MFHD::Holding') {
144 delete($self->{_mfhd_HOLDINGS}{$field->tag}{$field->caption->link_id}{$field->seqno});
145 } elsif ($field_type eq 'MFHD::Caption') {
146 delete($self->{_mfhd_CAPTIONS}{$field->tag}{$field->link_id});
154 sub insert_fields_before {
158 my $field_count = $self->SUPER::insert_fields_before($before, @_);
160 foreach my $field (@_) {
161 $self->_avoid_link_collision($field);
162 my $field_type = ref $field;
163 if ($field_type eq 'MFHD::Holding') {
164 $self->{_mfhd_HOLDINGS}{$field->tag}{$field->caption->link_id}{$field->seqno} = $field;
165 } elsif ($field_type eq 'MFHD::Caption') {
166 $self->{_mfhd_CAPTIONS}{$field->tag}{$field->link_id} = $field;
175 sub insert_fields_after {
179 my $field_count = $self->SUPER::insert_fields_after($after, @_);
181 foreach my $field (@_) {
182 $self->_avoid_link_collision($field);
183 my $field_type = ref $field;
184 if ($field_type eq 'MFHD::Holding') {
185 $self->{_mfhd_HOLDINGS}{$field->tag}{$field->caption->link_id}{$field->seqno} = $field;
186 } elsif ($field_type eq 'MFHD::Caption') {
187 $self->{_mfhd_CAPTIONS}{$field->tag}{$field->link_id} = $field;
196 sub _avoid_link_collision {
200 my $fieldref = ref($field);
201 if ($fieldref eq 'MFHD::Holding') {
202 my $seqno = $field->seqno;
203 my $changed_seqno = 0;
204 if (exists($self->{_mfhd_HOLDINGS}{$field->tag}{$field->caption->link_id}{$seqno})) {
208 } while (exists($self->{_mfhd_HOLDINGS}{$field->tag}{$field->caption->link_id}{$seqno}));
210 $field->seqno($seqno) if $changed_seqno;
211 } elsif ($fieldref eq 'MFHD::Caption') {
212 my $link_id = $field->link_id;
213 my $changed_link_id = 0;
214 if (exists($self->{_mfhd_CAPTIONS}{$field->tag}{$link_id})) {
216 $changed_link_id = 1;
219 } while (exists($self->{_mfhd_CAPTIONS}{$field->tag}{$link_id}));
221 $field->link_id($link_id) if $changed_link_id;
225 sub active_captions {
229 # TODO: add support for caption types as argument? (basic, index, supplement)
233 @captions = $self->captions($tag);
235 # TODO: for now, we will assume the last 85X field is active
236 # and the rest are historical. The standard is hazy about
237 # how multiple active patterns of the same 85X type should be
238 # handled. We will, however, return as an array for future
240 push(@active_captions, $captions[-1]);
242 return @active_captions;
251 sort { $a->seqno <=> $b->seqno }
252 values %{$self->{_mfhd_HOLDINGS}->{$field}->{$capid}};
255 sub holdings_by_caption {
259 my $htag = $caption->tag;
260 my $link_id = $caption->link_id;
262 return $self->holdings($htag, $link_id);
269 return $self->{_strp_date}->parse_datetime($holding->chron_to_date);
273 # generate_predictions()
274 # Accepts a hash ref of options initially defined as:
275 # base_holding : reference to the holding field to predict from
276 # include_base_issuance : whether to "predict" the startting holding, so as to generate a label for it
277 # num_to_predict : the number of issues you wish to predict
279 # end_holding : holding field ref, keep predicting until you meet or exceed it
281 # end_date : keep predicting until you exceed this
283 # The basic method is to first convert to a single holding if compressed, then
284 # increment the holding and save the resulting values to @predictions.
286 # returns @predictions, an array of holding field refs (including end_holding
287 # if applicable but NOT base_holding)
289 sub generate_predictions {
290 my ($self, $options) = @_;
292 my $base_holding = $options->{base_holding};
293 my $num_to_predict = $options->{num_to_predict};
294 my $end_holding = $options->{end_holding};
295 my $end_date = $options->{end_date};
296 my $max_to_predict = $options->{max_to_predict} || 10000; # fail-safe
297 my $include_base_issuance = $options->{include_base_issuance};
299 if (!defined($base_holding)) {
300 carp("Base holding not defined in generate_predictions, returning empty set");
303 if ($base_holding->is_compressed) {
304 carp("Ambiguous compressed base holding in generate_predictions, returning empty set");
307 my $curr_holding = $base_holding->clone; # prevent side-effects
310 push(@predictions, $curr_holding->clone) if ($include_base_issuance);
312 if ($num_to_predict) {
313 for (my $i = 0; $i < $num_to_predict; $i++) {
314 push(@predictions, $curr_holding->increment->clone);
316 } elsif (defined($end_holding)) {
317 $end_holding = $end_holding->clone; # prevent side-effects
318 my $next_holding = $curr_holding->increment->clone;
319 my $num_predicted = 0;
320 while ($next_holding le $end_holding) {
321 push(@predictions, $next_holding);
323 if ($num_predicted >= $max_to_predict) {
324 carp("Maximum prediction count exceeded");
327 $next_holding = $curr_holding->increment->clone;
329 } elsif (defined($end_date)) {
330 my $next_holding = $curr_holding->increment->clone;
331 my $num_predicted = 0;
332 while ($self->_holding_date($next_holding) <= $end_date) {
333 push(@predictions, $next_holding);
335 if ($num_predicted >= $max_to_predict) {
336 carp("Maximum prediction count exceeded");
339 $next_holding = $curr_holding->increment->clone;
347 # create an array of compressed holdings from all holdings for a given caption,
348 # compressing as needed
350 # Optionally you can skip sorting, but the resulting compression will be compromised
351 # if the current holdings are out of order
353 # TODO: gap marking, gap preservation
355 # TODO: some of this could be moved to the Caption object to allow for
356 # decompression in the absense of an overarching MFHD object
358 sub get_compressed_holdings {
362 my $skip_sort = $opts->{'skip_sort'};
364 # basic check for necessary pattern information
365 if (!scalar keys %{$caption->pattern}) {
366 carp "Cannot compress without pattern data, returning original holdings";
367 return $self->holdings_by_caption($caption);
370 # make sure none are compressed (except for open-ended)
373 @decomp_holdings = $self->get_decompressed_holdings($caption, {'skip_sort' => 1, 'passthru_open_ended' => 1});
375 # sort for best algorithm
376 @decomp_holdings = $self->get_decompressed_holdings($caption, {'dedupe' => 1, 'passthru_open_ended' => 1});
379 return () if !@decomp_holdings;
381 # if first holding is open-ended, it 'includes' all the rest, so return
382 if ($decomp_holdings[0]->is_open_ended) {
383 return ($decomp_holdings[0]);
386 my $runner = $decomp_holdings[0]->clone->increment;
387 my $curr_holding = shift(@decomp_holdings);
388 $curr_holding = $curr_holding->clone;
390 $curr_holding->seqno($seqno);
392 foreach my $holding (@decomp_holdings) {
393 if ($runner eq $holding) {
394 $curr_holding->extend;
396 } elsif ($holding->is_open_ended) { # special case, as it will always be the last
397 if ($runner ge $holding->clone->compressed_to_first) {
398 $curr_holding->compressed_end();
400 push(@comp_holdings, $curr_holding);
401 $curr_holding = $holding->clone;
403 $curr_holding->seqno($seqno);
406 } elsif ($runner gt $holding) { # should not happen unless holding is not in series
407 carp("Found unexpected holding, skipping");
409 push(@comp_holdings, $curr_holding);
412 (my $runner_dump = $runner->as_formatted) =~ s/\n\s+/*/gm; # logging
414 while ($runner le $holding) {
415 # Infinite loops used to happen here. As written today,
416 # ->increment() cannot be guaranteed to eventually falsify
417 # the condition ($runner le $holding) in certain cases.
421 if (++$loop_count >= 10000) {
422 (my $holding_dump = $holding->as_formatted) =~ s/\n\s+/*/gm;
424 croak "\$runner<$runner_dump> didn't catch up with " .
425 "\$holding<$holding_dump> after 10000 increments";
428 $curr_holding = $holding->clone;
430 $curr_holding->seqno($seqno);
433 push(@comp_holdings, $curr_holding);
435 return @comp_holdings;
439 # create an array of single holdings from all holdings for a given caption,
440 # decompressing as needed
442 # optional arguments:
443 # skip_sort: do not sort the returned holdings
444 # dedupe: remove any duplicate holdings from the set
445 # passthru_open_ended: open-ended compressed holdings cannot be logically
446 # decompressed (they are infinite); if set to true these holdings are passed
447 # thru rather than skipped
448 # TODO: some of this could be moved to the Caption (and/or Holding) object to
449 # allow for decompression in the absense of an overarching MFHD object
451 sub get_decompressed_holdings {
455 my $skip_sort = $opts->{'skip_sort'};
456 my $dedupe = $opts->{'dedupe'};
457 my $passthru_open_ended = $opts->{'passthru_open_ended'};
459 if ($dedupe and $skip_sort) {
460 carp("Attempted deduplication without sorting, failure likely");
463 my @holdings = $self->holdings_by_caption($caption);
465 return () if !@holdings;
469 foreach my $holding (@holdings) {
470 if (!$holding->is_compressed) {
471 push(@decomp_holdings, $holding->clone);
472 } elsif ($holding->is_open_ended) {
473 if ($passthru_open_ended) {
474 push(@decomp_holdings, $holding->clone);
476 carp("Open-ended holdings cannot be decompressed, skipping");
479 my $base_holding = $holding->clone->compressed_to_first;
480 my @new_holdings = $self->generate_predictions(
481 {'base_holding' => $base_holding,
482 'end_holding' => $holding->clone->compressed_to_last});
483 push(@decomp_holdings, $base_holding, @new_holdings);
487 unless ($skip_sort) {
488 my @temp_holdings = sort {$a cmp $b} @decomp_holdings;
489 @decomp_holdings = @temp_holdings;
492 my @return_holdings = (shift(@decomp_holdings));
493 $return_holdings[0]->seqno(1);
495 foreach my $holding (@decomp_holdings) { # renumber sequence
496 if ($holding eq $return_holdings[-1] and $dedupe) {
497 carp("Found duplicate holding in decompression set, discarding");
500 $holding->seqno($seqno);
502 push(@return_holdings, $holding);
505 return @return_holdings;
509 # create an array of compressed holdings from all holdings for a given caption,
510 # combining as needed
512 # NOTE: this sub is similar to, but much less aggressive/strict than
513 # get_compressed_holdings(). Ultimately, get_compressed_holdings() might be
514 # deprecated in favor of this
516 # TODO: gap marking, gap preservation
518 # TODO: some of this could be moved to the Caption (and/or Holding) object to
519 # allow for combining in the absense of an overarching MFHD object
521 sub get_combined_holdings {
525 my @holdings = $self->holdings_by_caption($caption);
526 return () if !@holdings;
528 # basic check for necessary pattern information
529 if (!scalar keys %{$caption->pattern}) {
530 carp "Cannot combine without pattern data, returning original holdings";
534 my @sorted_holdings = sort {$a cmp $b} @holdings;
536 my @combined_holdings = (shift(@sorted_holdings));
538 $combined_holdings[0]->seqno($seqno);
539 foreach my $holding (@sorted_holdings) {
540 # short-circuit: if we hit an open-ended holding,
541 # it 'includes' all the rest, so just exit the loop
542 if ($combined_holdings[-1]->is_open_ended) {
544 } elsif ($holding eq $combined_holdings[-1]) {
548 # at this point, we know that $holding is gt $combined_holdings[-1]
549 # we just need to figure out if they overlap or not
551 # first, get the end (or only) holding of [-1]
552 my $last_holding_end = $combined_holdings[-1]->is_compressed ?
553 $combined_holdings[-1]->clone->compressed_to_last
554 : $combined_holdings[-1]->clone;
556 # next, get the end (or only) holding of the current
557 # holding being considered
559 if ($holding->is_compressed) {
560 $holding_end = $holding->is_open_ended ?
562 : $holding->clone->compressed_to_last;
564 $holding_end = $holding;
567 # next, make sure $holding isn't fully contained
569 if ($holding_end and $holding_end le $last_holding_end) {
573 # now, get the beginning (or only) holding of $holding
574 my $holding_start = $holding->is_compressed ?
575 $holding->clone->compressed_to_first
578 # see if they overlap
579 if ($last_holding_end->increment ge $holding_start) {
580 # they overlap, combine them
581 $combined_holdings[-1]->compressed_end($holding_end);
583 # no overlap, start a new group
584 $holding->seqno(++$seqno);
585 push(@combined_holdings, $holding);
590 return @combined_holdings;
594 ## close any open-ended holdings which are followed by another holding by
597 ## This needs more thought about concerning usability (e.g. should it be a
598 ## mutator?), commenting out for now
599 #sub _get_truncated_holdings {
601 # my $caption = shift;
603 # my @holdings = $self->holdings_by_caption($caption);
605 # return () if !@holdings;
607 # @holdings = sort {$a cmp $b} @holdings;
609 # my $current_open_holding;
610 # my @truncated_holdings;
611 # foreach my $holding (@holdings) {
612 # if ($current_open_holding) {
613 # if ($holding->is_open_ended) {
614 # next; # consecutive open holdings are meaningless, as they are contained by the previous
615 # } elsif ($holding->is_compressed) {
616 # $current_open_holding->compressed_end($holding->compressed_to_last);
618 # $current_open_holding->compressed_end($holding);
620 # push(@truncated_holdings, $current_open_holding);
621 # $current_open_holding = undef;
622 # } elsif ($holding->is_open_ended) {
623 # $current_open_holding = $holding;
625 # push(@truncated_holdings, $holding);
629 # # catch possible open holding at end
630 # push(@truncated_holdings, $current_open_holding) if $current_open_holding;
633 # foreach my $holding (@truncated_holdings) { # renumber sequence
634 # $holding->seqno($seqno);
638 # return @truncated_holdings;
642 # format_holdings(): Generate textual display of all holdings in record
643 # for given type of caption (853--855) taking into account all the
644 # captions, holdings statements, and textual
647 # returns string formatted holdings as one very long line.
648 # Caller must provide any label (such as "library has:" and insert
649 # line breaks as appropriate.
651 # Translate caption field labels to the corresponding textual holdings
652 # statement labels. That is, convert 853 "Basic bib unit" caption to
653 # 866 "basic bib unit" text holdings label.
661 sub format_holdings {
667 my $holdings_stmt = '';
670 # convert caption field id to holdings field id
671 ($holdings_field = $field) =~ s/5/6/;
673 # Textual holdings statements complicate the basic algorithm for
674 # formatting the holdings: If there's a textual holdings statement
675 # with the subfield "$80", then that overrides ALL the MFHD holdings
676 # information and is all that is displayed. Otherwise, the textual
677 # holdings statements will either replace some of the MFHD holdings
678 # information, or supplement it, depending on the value of the
679 # $8 linkage subfield.
681 if (defined $self->field($cap_to_txt{$field})) {
682 @txt_holdings = $self->field($cap_to_txt{$field});
684 foreach my $txt (@txt_holdings) {
686 # if there's a $80 subfield, then we're done, it's
687 # all the formatted holdings
688 if ($txt->subfield('8') eq '0') {
689 # textual holdings statement that completely
690 # replaces MFHD holdings in 853/863, etc.
691 $holdings_stmt = $txt->subfield('a');
693 if (defined $txt->subfield('z')) {
694 $holdings_stmt .= ' -- ' . $txt->subfield('z');
697 printf("# format_holdings() returning %s txt holdings\n",
698 $cap_to_txt{$field});
699 return $holdings_stmt;
702 # If there are non-$80 subfields in the textual holdings
703 # then we need to keep track of the subfields, so we can
704 # intersperse the textual holdings in with the the calculated
705 # holdings from the 853/863 fields.
706 foreach my $linkid ($txt->subfield('8')) {
707 $txt_link_ids{$linkid} = $txt;
712 # Now loop through all the captions, finding the corresponding
713 # holdings statements (either MFHD or textual), and build up the
714 # complete formatted holdings statement. The textual holdings statements
715 # have either the same link id field as a caption, which means that
716 # the text holdings win, or they have ids that are interfiled with
717 # the captions, which mean they go into the middle.
719 my @ids = sort($self->caption_link_ids($field), keys %txt_link_ids);
720 foreach my $cap_id (@ids) {
721 my $last_txt = undef;
723 if (exists $txt_link_ids{$cap_id}) {
724 # there's a textual holding statement with this caption ID,
725 # so just use that. This covers both the "replaces" and
726 # the "supplements" holdings information options.
728 # a single textual holdings statement can replace multiple
729 # captions. If the _last_ caption we saw had a textual
730 # holdings statement, and this caption has the same one, then
731 # we don't add the holdings again.
732 if (!defined $last_txt || ($last_txt != $txt_link_ids{$cap_id})) {
733 my $txt = $txt_link_ids{$cap_id};
734 $holdings_stmt .= ',' if $holdings_stmt;
735 $holdings_stmt .= $txt->subfield('a');
736 if (defined $txt->subfield('z')) {
737 $holdings_stmt .= ' -- ' . $txt->subfield('z');
745 # We found a caption that doesn't have a corresponding textual
746 # holdings statement, so reset $last_txt to undef.
749 my @holdings = $self->holdings($holdings_field, $cap_id);
751 next unless scalar @holdings;
753 # XXX Need to format compressed holdings. see code in test.pl
754 # for example. Try to do it without indexing?
755 $holdings_stmt .= ',' if $holdings_stmt;
757 if ($self->compressible) {
758 $start = $l = shift @holdings;
759 $holdings_stmt .= $l->format;
761 while (my $h = shift @holdings) {
762 if (!$h->matches($l->next)) {
763 # this item is not part of the current run,
764 # close out the run and record this item
766 $holdings_stmt .= '-' . $l->format;
769 $holdings_stmt .= ',' . $h->format;
771 } elsif (!scalar(@holdings) || defined($h->subfield('z'))) {
772 # This is the end of the holdings for this caption
773 # or this item has a public note that we want
775 $holdings_stmt .= '-' . $h->format;
778 if (defined $h->subfield('z')) {
779 $holdings_stmt .= ' -- ' . $h->subfield('z');
785 $holdings_stmt .= ',' if $holdings_stmt;
786 $holdings_stmt .= (shift @holdings)->format;
787 foreach my $h (@holdings) {
788 $holdings_stmt .= ',' . $h->format;
789 if (defined $h->subfield('z')) {
790 $holdings_stmt .= ' -- ' . $h->subfield('z');
796 return $holdings_stmt;