From 7525bf5102867340624290b45a3071dcbb22f178 Mon Sep 17 00:00:00 2001 From: Janusz Kaczmarek Date: Tue, 2 Jul 2024 10:59:38 +0000 Subject: [PATCH] Bug 31109: Prevent overwriting bibliographic records in case of simultaneous modification Koha, till now, lacks the controll of concurrent modification of data. Perhaps the most urgent case is parallel modifications of a bibliograhic record by two independent agents. The proposed procedure, collision detection when saving a record, comes from Nick, who is also the author of the original version of the patch. This idea is simpler to implement than introducing a record lock. The idea of using a checksum to confirm that a record has not changed during editing comes from Jonathan. The other elements, including the implementation of redirecting to the merge page after a failed attempt to modify a record, come from Janusz. The checksum is passed to C4::Biblio::ModBiblio as an optional original_digest parameter, which in the future should perhaps be mandatory for all record modifications, including authority records and other Koha objects. The patch prevents also a possible collision between edit and merge. Test plan: ========== 1. In two independent browsers, A and B, open in parallel for edit the same bibliographic record. Make different changes in each browser. Save first A, then B. Reload the record in each browser. You should see, in both browsers, the version of the record modified in B. Note that you have lost the changes made in browser A without any warning. This is a serious problem. 2. Apply the patch; restart_all. 3. Repeat p. 1. While trying to save the record in browser B, you will be redirect to a merge page, with a warning about a conflict in modification, and with a possibility to merge your modification to the current version of the record, and, possibly, amend the record again in the regular editor. N.B., this should work both with the basic and with the advanced editor. Sponsored-by: Ignatianum University in Cracow Signed-off-by: Roman Dolny --- C4/Biblio.pm | 81 ++++++++++++------- cataloguing/addbiblio.pl | 48 +++++++++-- cataloguing/merge.pl | 50 +++++++++++- .../prog/en/includes/cateditor-ui.inc | 28 +++++++ .../prog/en/includes/merge-record.inc | 6 +- .../prog/en/modules/cataloguing/addbiblio.tt | 1 + .../prog/en/modules/cataloguing/merge.tt | 13 +++ svc/bib | 43 +++++++--- svc/mid_air_collision | 68 ++++++++++++++++ 9 files changed, 288 insertions(+), 50 deletions(-) create mode 100755 svc/mid_air_collision diff --git a/C4/Biblio.pm b/C4/Biblio.pm index 4b32ad43d5..a9ccb5efa0 100644 --- a/C4/Biblio.pm +++ b/C4/Biblio.pm @@ -116,6 +116,7 @@ use Koha::SearchEngine::Indexer; use Koha::SimpleMARC; use Koha::Libraries; use Koha::Util::MARC; +use Koha::Util::Misc; =head1 NAME @@ -377,9 +378,13 @@ Used when the indexing schedulling will be handled by the caller Set as the record source when saving the record. +=item C + +Digest of the original version of the modified record. + =back -Returns 1 on success 0 on failure +Returns 1 on success, -1 in case of modification collision, 0 on other failure. =cut @@ -401,20 +406,6 @@ sub ModBiblio { return 0; } - if ( C4::Context->preference("CataloguingLog") ) { - my $biblio = Koha::Biblios->find($biblionumber); - my $record; - my $decoding_error = ""; - eval { $record = $biblio->metadata->record }; - if ($@) { - my $exception = $@; - $exception->rethrow unless ( $exception->isa('Koha::Exceptions::Metadata::Invalid') ); - $decoding_error = "There was an error with this bibliographic record: " . $exception; - $record = $biblio->metadata->record_strip_nonxml; - } - logaction( "CATALOGUING", "MODIFY", $biblionumber, "biblio $decoding_error BEFORE=>" . $record->as_formatted ); - } - if ( !$options->{disable_autolink} && C4::Context->preference('AutoLinkBiblios') ) { BiblioAutoLink( $record, $frameworkcode ); } @@ -465,27 +456,57 @@ sub ModBiblio { # update MARC subfield that stores biblioitems.cn_sort _koha_marc_update_biblioitem_cn_sort( $record, $oldbiblio, $frameworkcode ); - # update the MARC record (that now contains biblio and items) with the new record data - ModBiblioMarc( $record, $biblionumber, $mod_biblio_marc_options ); + my $schema = Koha::Database->schema; + my $result = 0; + $schema->txn_do( + sub { - # modify the other koha tables - _koha_modify_biblio( $dbh, $oldbiblio, $frameworkcode ); - _koha_modify_biblioitem_nonmarc( $dbh, $oldbiblio ); + my $biblio = Koha::Biblios->find($biblionumber); + my $orig_rec; + my $decoding_error = ""; + eval { $orig_rec = $biblio->metadata->record }; + if ($@) { + my $exception = $@; + $exception->rethrow unless ( $exception->isa('Koha::Exceptions::Metadata::Invalid') ); + $decoding_error = "There was an error with this bibliographic record: " . $exception; + $orig_rec = $biblio->metadata->record_strip_nonxml; + } + if ( !$options->{original_digest} || $options->{original_digest} eq Koha::Util::Misc::digest($orig_rec) ) { - _after_biblio_action_hooks({ action => 'modify', biblio_id => $biblionumber }); + if ( C4::Context->preference("CataloguingLog") ) { + logaction( + "CATALOGUING", "MODIFY", $biblionumber, + "biblio $decoding_error BEFORE=>" . $orig_rec->as_formatted + ); + } - # update OAI-PMH sets - if(C4::Context->preference("OAI-PMH:AutoUpdateSets")) { - C4::OAI::Sets::UpdateOAISetsBiblio($biblionumber, $record); - } + # update the MARC record (that now contains biblio and items) with the new record data + ModBiblioMarc( $record, $biblionumber, $mod_biblio_marc_options ); - Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue( - { - biblio_ids => [ $biblionumber ] + # modify the other koha tables + _koha_modify_biblio( $dbh, $oldbiblio, $frameworkcode ); + _koha_modify_biblioitem_nonmarc( $dbh, $oldbiblio ); + + _after_biblio_action_hooks( { action => 'modify', biblio_id => $biblionumber } ); + + # update OAI-PMH sets + if ( C4::Context->preference("OAI-PMH:AutoUpdateSets") ) { + C4::OAI::Sets::UpdateOAISetsBiblio( $biblionumber, $record ); + } + + Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue( { biblio_ids => [$biblionumber] } ) + unless $options->{skip_holds_queue} + or !C4::Context->preference('RealTimeHoldsQueue'); + + $result = 1; + + } else { + $result = -1; + } } - ) unless $options->{skip_holds_queue} or !C4::Context->preference('RealTimeHoldsQueue'); + ); - return 1; + return $result; } =head2 _strip_item_fields diff --git a/cataloguing/addbiblio.pl b/cataloguing/addbiblio.pl index 99c20580b5..5036f4fb0c 100755 --- a/cataloguing/addbiblio.pl +++ b/cataloguing/addbiblio.pl @@ -57,6 +57,7 @@ use Koha::Libraries; use Koha::BiblioFrameworks; use Koha::Patrons; use Koha::UI::Form::Builder::Biblio; +use Koha::Util::Misc; use MARC::File::USMARC; use MARC::File::XML; @@ -603,6 +604,7 @@ my ( $biblioitemnumber ); +my $digest; if ( $biblio && !$breedingid ) { eval { $record = $biblio->metadata->record }; if ($@) { @@ -611,6 +613,7 @@ if ( $biblio && !$breedingid ) { $record = $biblio->metadata->record_strip_nonxml; $template->param( INVALID_METADATA => $exception ); } + $digest = Koha::Util::Misc::digest($record) if $record; } if ($breedingid) { ( $record, $encoding ) = MARCfindbreeding( $breedingid ) ; @@ -701,11 +704,12 @@ if ( $op eq "cud-addbiblio" ) { ( $duplicatebiblionumber, $duplicatetitle ) = FindDuplicate($record); } my $confirm_not_duplicate = $input->param('confirm_not_duplicate'); + my $digest_orig = $input->param('digest'); # it is not a duplicate (determined either by Koha itself or by user checking it's not a duplicate) if ( !$duplicatebiblionumber or $confirm_not_duplicate ) { my $oldbibitemnum; if ( $is_a_modif ) { - ModBiblio( + my $mod_biblio_result = ModBiblio( $record, $biblionumber, $frameworkcode, @@ -714,9 +718,40 @@ if ( $op eq "cud-addbiblio" ) { source => $z3950 ? 'z3950' : 'intranet', categorycode => $logged_in_patron->categorycode, userid => $logged_in_patron->userid, - } + }, + original_digest => $digest_orig, } ); + + # in case of unsuccessful record modification let's redirect to merge + if ( $mod_biblio_result == -1 ) { + ( $template, undef, undef ) = get_template_and_user( + { + template_name => "cataloguing/merge.tt", + query => $input, + type => "intranet", + flagsrequired => { editcatalogue => 'edit_catalogue' }, + } + ); + + my ( $records, $framework_database ) = Koha::Util::Misc::prepare_mid_air_collision_merge({ biblionumber => $biblionumber, modified_record => $record }); + + my ($biblionumbertag) = GetMarcFromKohaField('biblio.biblionumber'); + + # Parameters + $template->param( + ref_biblionumber => $biblionumber, + records => $records, + ref_record => $records->[0], + framework => $framework_database, + biblionumbertag => $biblionumbertag, + mid_air_collision => 1, + digest => Koha::Util::Misc::digest( $records->[0]->{record} ), + ); + + output_html_with_http_headers $input, $cookie, $template->output; + exit; + } } else { ( $biblionumber, $oldbibitemnum ) = AddBiblio( $record, $frameworkcode ); @@ -845,13 +880,14 @@ elsif ( $op eq "cud-delete" ) { build_tabs( $template, $record, $dbh, $encoding,$input ); $template->param( biblionumber => $biblionumber, - biblionumbertagfield => $biblionumbertagfield, - biblionumbertagsubfield => $biblionumbertagsubfield, + biblionumbertagfield => $biblionumbertagfield, + biblionumbertagsubfield => $biblionumbertagsubfield, biblioitemnumtagfield => $biblioitemnumtagfield, biblioitemnumtagsubfield => $biblioitemnumtagsubfield, biblioitemnumber => $biblioitemnumber, - hostbiblionumber => $hostbiblionumber, - hostitemnumber => $hostitemnumber + hostbiblionumber => $hostbiblionumber, + hostitemnumber => $hostitemnumber, + digest => $digest, ); } diff --git a/cataloguing/merge.pl b/cataloguing/merge.pl index 9fe7bc4f8b..cae6d91e21 100755 --- a/cataloguing/merge.pl +++ b/cataloguing/merge.pl @@ -32,14 +32,16 @@ use C4::Biblio qw( ModBiblio TransformHtmlToMarc ); -use C4::Serials qw( CountSubscriptionFromBiblionumber ); -use C4::Reserves qw( MergeHolds ); +use C4::Serials qw( CountSubscriptionFromBiblionumber ); +use C4::Reserves qw( MergeHolds ); use C4::Acquisition qw( ModOrder GetOrdersByBiblionumber ); +use C4::Search qw( enabled_staff_search_views ); use Koha::BiblioFrameworks; use Koha::Biblios; use Koha::Items; use Koha::MetadataRecord; +use Koha::Util::Misc; my $input = CGI->new; my ( $template, $loggedinuser, $cookie ) = get_template_and_user( @@ -88,8 +90,49 @@ if ($op eq 'cud-merge') { #Take new framework code my $frameworkcode = $input->param('frameworkcode'); + my $digest = $input->param('digest'); + # Modifying the reference record - ModBiblio($record, $ref_biblionumber, $frameworkcode); + my $mod_biblio_result = ModBiblio( $record, $ref_biblionumber, $frameworkcode, { original_digest => $digest } ); + + # in case of unsuccessful record modification let's redirect to merge again + if ( $mod_biblio_result == -1 ) { + my ( $records, $framework_database ) = Koha::Util::Misc::prepare_mid_air_collision_merge( + { biblionumber => $ref_biblionumber, modified_record => $record } ); + + my ($biblionumbertag) = GetMarcFromKohaField('biblio.biblionumber'); + + # Parameters + $template->param( + ref_biblionumber => $ref_biblionumber, + records => $records, + ref_record => $records->[0], + framework => $framework_database, + biblionumbertag => $biblionumbertag, + mid_air_collision => 1, + digest => Koha::Util::Misc::digest( $records->[0]->{record} ), + ); + + output_html_with_http_headers $input, $cookie, + $template->output; + exit; + } + + # This is a merge after a mid-air collision, not a real merge + if ( $biblionumbers[0] == -1 ) { + my $defaultview = C4::Context->preference('IntranetBiblioDefaultView'); + my $views = {C4::Search::enabled_staff_search_views}; + if ( $defaultview eq 'isbd' && $views->{can_view_ISBD} ) { + print $input->redirect("/cgi-bin/koha/catalogue/ISBDdetail.pl?biblionumber=$ref_biblionumber"); + } elsif ( $defaultview eq 'marc' && $views->{can_view_MARC} ) { + print $input->redirect("/cgi-bin/koha/catalogue/MARCdetail.pl?biblionumber=$ref_biblionumber"); + } elsif ( $defaultview eq 'labeled_marc' && $views->{can_view_labeledMARC} ) { + print $input->redirect("/cgi-bin/koha/catalogue/labeledMARCdetail.pl?biblionumber=$ref_biblionumber"); + } else { + print $input->redirect("/cgi-bin/koha/catalogue/detail.pl?biblionumber=$ref_biblionumber"); + } + exit; + } my $report_header = {}; foreach my $biblionumber ($ref_biblionumber, @biblionumbers) { @@ -174,6 +217,7 @@ if ($op eq 'cud-merge') { if ($ref_biblionumber and $ref_biblionumber == $biblionumber) { $record->{reference} = 1; $template->param(ref_record => $record); + $template->param(digest => Koha::Util::Misc::digest($marcrecord)); unshift @records, $record; } else { push @records, $record; diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc index c767a21212..bf39aec3fc 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc @@ -393,6 +393,9 @@ require( [ 'koha-backend', 'search', 'macros', 'marc-editor', 'marc-record', 'pr return; } + if ( state.digest ) { + record.addField( new MARC.Field( 'DGS', ' ', ' ', [ [ 'a', state.digest ] ] ) ); + } backends[ parts[0] ].save( parts[1], record, function(data) { state.saving = false; @@ -400,6 +403,10 @@ require( [ 'koha-backend', 'search', 'macros', 'marc-editor', 'marc-record', 'pr var record = new MARC.Record(); record.loadMARCXML(data.newRecord); record.frameworkcode = data.newRecord.frameworkcode; + if ( record.hasField("DGS") ) { + state.digest = record.field("DGS").subfield("a"); + record.removeField("DGS"); + } editor.displayRecord( record ); } @@ -423,6 +430,10 @@ require( [ 'koha-backend', 'search', 'macros', 'marc-editor', 'marc-record', 'pr if ( !record.error ) { var remove_control_num = [% IF Koha.Preference('autoControlNumber') == 'OFF' %] false [% ELSE %] true [% END %]; if( remove_control_num ){ record.removeField("001"); } + if ( record.hasField("DGS") ) { + state.digest = record.field("DGS").subfield("a"); + record.removeField("DGS"); + } editor.displayRecord( record ); editor.focus(); } @@ -1108,6 +1119,23 @@ require( [ 'koha-backend', 'search', 'macros', 'marc-editor', 'marc-record', 'pr humanMsg.displayAlert( _("Incorrect syntax, cannot save"), { className: 'humanError' } ); } else if ( result.error == 'invalid' ) { humanMsg.displayAlert( _("Record structure invalid, cannot save"), { className: 'humanError' } ); + } else if ( result.error == 'mid-air-collision' ) { + humanMsg.displayAlert( _("Record edited elsewhere, cannot save. You can merge your modifications with the current database version of the record"), { className: 'humanError' } ); + const xhr = new XMLHttpRequest; + xhr.onreadystatechange=function(){ + if (xhr.readyState === 4 && xhr.status === 200) { + const merge_window = window.open(); + merge_window.document.write(xhr.responseText); + merge_window.document.close(); + } + } + const merge_url = "/cgi-bin/koha/svc/mid_air_collision/"+result.newId[1]; + xhr.open("POST", merge_url, true); + const csrf_token = $('meta[name="csrf-token"]').attr("content"); + xhr.setRequestHeader("Content-Type", "text/xml"); + xhr.setRequestHeader("CSRF-TOKEN", csrf_token); + const xml_editor_record = `\n${result.newRecord.outerHTML}`; + xhr.send(xml_editor_record); } else if ( result.error ) { humanMsg.displayAlert( _("Something went wrong, cannot save"), { className: 'humanError' } ); } else if ( !result.error ) { diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/merge-record.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/merge-record.inc index d9a9db593d..a26228e4fe 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/merge-record.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/merge-record.inc @@ -49,7 +49,11 @@ [% WRAPPER tabs_nav %] [% FOREACH record IN sourcerecords %] [% WRAPPER tab_item tabname= "tabrecord${record.recordid}" %] - [% record.recordid | html %] + [% IF record.recordid == -1 %] + your version + [% ELSE %] + [% record.recordid | html %] + [% END %] [% IF record.reference %](ref)[% END %] ([% record.frameworktext | html %]) [% END %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt index 85b41b2daf..1a57575beb 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt @@ -920,6 +920,7 @@ $(document).ready(function(){ + [% END %]
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/merge.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/merge.tt index 6688d4e828..ec7cf00eac 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/merge.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/merge.tt @@ -40,6 +40,12 @@ div#result { margin-top: 1em; } [% INCLUDE 'messages.inc' %]

Merging records

+ [% IF mid_air_collision %] +
+

Record edited elsewhere

+ You can try to merge your version with the current database version of the record or cancel and abandon changes. +
+ [% END %] [% IF ( result ) %] [% IF ( errors.size ) %] [% FOREACH error IN errors %] @@ -179,6 +185,7 @@ div#result { margin-top: 1em; }
+ [% FOREACH record IN records %] [% END %] @@ -187,9 +194,15 @@ div#result { margin-top: 1em; }
+ [% IF ! mid_air_collision %] (Example: "001,245ab,600") + [% ELSE %] +
+ Cancel +
+ [% END %]
[% END %] diff --git a/svc/bib b/svc/bib index da96e01f9a..395f079d7d 100755 --- a/svc/bib +++ b/svc/bib @@ -27,6 +27,7 @@ use C4::Biblio qw( GetFrameworkCode ); use C4::Items; use XML::Simple; use Koha::Biblios; +use Koha::Util::Misc; my $query = CGI->new; binmode STDOUT, ':encoding(UTF-8)'; @@ -79,6 +80,8 @@ sub fetch_bib { $invalid_metadata = 1; } print $query->header( -type => 'text/xml', -charset => 'utf-8', -invalid_metadata => $invalid_metadata ); + my $digest = Koha::Util::Misc::digest($record); + $record->append_fields( MARC::Field->new( 'DGS', ' ', ' ', a => $digest ) ); print $record->as_xml_record(); } else { print $query->header( -type => 'text/xml', -status => '404 Not Found' ); @@ -124,16 +127,36 @@ sub update_bib { } } - C4::Biblio::ModBiblio( $record, $biblionumber, $frameworkcode ); - my $biblio = Koha::Biblios->find( $biblionumber ); - my $new_record = $biblio->metadata->record({ embed_items => scalar $query->url_param('items') }); - - $result->{'status'} = "ok"; - $result->{'biblionumber'} = $biblionumber; - my $xml = $new_record->as_xml_record(); - $xml =~ s/<\?xml.*?\?>//i; - $result->{'marcxml'} = $xml; - $do_not_escape = 1; + my $mod_biblio_options; + if ( $record->subfield( 'DGS', 'a' ) ) { + $mod_biblio_options->{original_digest} = $record->subfield( 'DGS', 'a' ); + $record->delete_fields( $record->field('DGS') ); + } + my $res = C4::Biblio::ModBiblio( $record, $biblionumber, $frameworkcode, $mod_biblio_options ); + if ( $res == 1 ) { + my $biblio = Koha::Biblios->find($biblionumber); + my $new_record = $biblio->metadata->record( { embed_items => scalar $query->url_param('items') } ); + + $result->{'status'} = "ok"; + $result->{'biblionumber'} = $biblionumber; + my $digest = Koha::Util::Misc::digest($new_record); + $new_record->append_fields( MARC::Field->new( 'DGS', ' ', ' ', a => $digest ) ); + my $xml = $new_record->as_xml_record(); + $xml =~ s/<\?xml.*?\?>//i; + $result->{'marcxml'} = $xml; + $do_not_escape = 1; + } elsif ( $res == -1 ) { + $result->{'status'} = "failed"; + $result->{'error'} = "mid-air-collision"; + $result->{'biblionumber'} = $biblionumber; + my $xml = $record->as_xml_record(); + $xml =~ s/<\?xml.*?\?>//i; + $result->{'marcxml'} = $xml; + $do_not_escape = 1; + } else { + $result->{'status'} = "failed"; + $result->{'error'} = "unexpected modification status"; + } } print XMLout($result, NoAttr => 1, RootName => 'response', XMLDecl => 1, NoEscape => $do_not_escape); diff --git a/svc/mid_air_collision b/svc/mid_air_collision new file mode 100755 index 0000000000..76f1e8a14a --- /dev/null +++ b/svc/mid_air_collision @@ -0,0 +1,68 @@ +#!/usr/bin/perl +use Modern::Perl; +use CGI qw ( -utf8 ); +use C4::Auth qw( get_template_and_user check_api_auth ); +use C4::Context; +use C4::Output qw( output_html_with_http_headers ); +use C4::Biblio qw( GetFrameworkCode GetMarcStructure GetMarcFromKohaField ); + +use Koha::Biblios; +use Koha::MetadataRecord; +use Koha::Util::Misc; + +my $query = CGI->new; +binmode STDOUT, ':encoding(UTF-8)'; + +my ( $status, $cookie, $sessionID ) = check_api_auth( $query, { editcatalogue => 'edit_catalogue' } ); +unless ( $status eq "ok" ) { + print $query->header( -type => 'text/xml', -status => '403 Forbidden' ); + print XMLout( { auth_status => $status }, NoAttr => 1, RootName => 'response', XMLDecl => 1 ); + exit 0; +} + +# do initial validation +my $path_info = $query->path_info(); + +my $biblionumber = undef; +if ( $path_info =~ m!^/(\d+)$! ) { + $biblionumber = $1; +} else { + print $query->header( -type => 'text/xml', -status => '400 Bad Request' ); +} + +my $frameworkcode = $query->url_param('frameworkcode') // GetFrameworkCode($biblionumber); +my $inxml = $query->param('POSTDATA'); +my $modified_record = eval { + MARC::Record::new_from_xml( + $inxml, "UTF-8", + C4::Context->preference('marcflavour') + ); +}; + +my ( $template, undef, undef ) = get_template_and_user( + { + template_name => "cataloguing/merge.tt", + query => $query, + type => "intranet", + flagsrequired => { editcatalogue => 'edit_catalogue' }, + } +); + +my ( $records, $framework_database ) = + Koha::Util::Misc::prepare_mid_air_collision_merge( + { biblionumber => $biblionumber, modified_record => $modified_record } ); + +my ($biblionumbertag) = GetMarcFromKohaField('biblio.biblionumber'); + +# Parameters +$template->param( + ref_biblionumber => $biblionumber, + records => $records, + ref_record => $records->[0], + framework => $framework_database, + biblionumbertag => $biblionumbertag, + mid_air_collision => 1, + digest => Koha::Util::Misc::digest( $records->[0]->{record} ), +); + +output_html_with_http_headers $query, $cookie, $template->output; -- 2.39.2