From 60d3a823675e4c8f377176eb0c4d93917fef43b5 Mon Sep 17 00:00:00 2001 From: Jesse Weaver Date: Wed, 11 Jun 2014 01:41:43 -0600 Subject: [PATCH] Bug 11559 followup - refactoring and cleanup Added license headers to all JS files, moved metasearcher code into a new service that largely lives in Koha::, and massively cleaned up the internals of the textual MARC machinery. Also, fixed some encoding and saving issues. --- C4/Breeding.pm | 104 +--- C4/Service.pm | 1 + Koha/MetaSearcher.pm | 351 +++++++++++ Koha/MetadataRecord.pm | 25 + cataloguing/editor.pl | 13 + .../lib/koha/cateditor/koha-backend.js | 21 +- .../intranet-tmpl/lib/koha/cateditor/macros.js | 220 +------ .../intranet-tmpl/lib/koha/cateditor/macros/its.js | 208 +++++++ .../lib/koha/cateditor/macros/rancor.js | 277 +++++++++ .../lib/koha/cateditor/marc-editor.js | 662 +++++++++++++++------ .../intranet-tmpl/lib/koha/cateditor/marc-mode.js | 53 +- .../lib/koha/cateditor/marc-record.js | 19 + .../lib/koha/cateditor/preferences.js | 20 + .../intranet-tmpl/lib/koha/cateditor/resources.js | 19 + .../intranet-tmpl/lib/koha/cateditor/search.js | 42 +- .../intranet-tmpl/lib/koha/cateditor/text-marc.js | 19 + .../intranet-tmpl/lib/koha/cateditor/widget.js | 22 + koha-tmpl/intranet-tmpl/prog/en/css/cateditor.css | 38 +- .../prog/en/includes/cateditor-ui.inc | 313 ++++++++-- .../prog/en/modules/cataloguing/editor.tt | 129 ++-- svc/bib | 2 +- svc/cataloguing/framework | 38 +- svc/cataloguing/metasearch | 76 +++ svc/z3950 | 102 ---- 24 files changed, 2071 insertions(+), 703 deletions(-) create mode 100644 Koha/MetaSearcher.pm create mode 100644 koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/its.js create mode 100644 koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/rancor.js create mode 100755 svc/cataloguing/metasearch delete mode 100755 svc/z3950 diff --git a/C4/Breeding.pm b/C4/Breeding.pm index d9c78be..db0e696 100644 --- a/C4/Breeding.pm +++ b/C4/Breeding.pm @@ -39,7 +39,7 @@ BEGIN { $VERSION = 3.07.00.049; require Exporter; @ISA = qw(Exporter); - @EXPORT = qw(&BreedingSearch &Z3950Search &Z3950SearchAuth &RunZ3950); + @EXPORT = qw(&BreedingSearch &Z3950Search &Z3950SearchAuth); } =head1 NAME @@ -327,10 +327,8 @@ sub _add_rowdata { my ($row, $record)=@_; my %fetch= ( title => 'biblio.title', - seriestitle => 'biblio.seriestitle', author => 'biblio.author', isbn =>'biblioitems.isbn', - issn =>'biblioitems.issn', lccn =>'biblioitems.lccn', #LC control number (not call number) edition =>'biblioitems.editionstatement', date => 'biblio.copyrightdate', #MARC21 @@ -714,106 +712,6 @@ sub Z3950SearchAuth { ); } -sub RunZ3950 { - my ( $server_ids, $query, $options ) = @_; - - $options = { - offset => 0, - fetch => 20, - on_error => sub {}, - on_hit => sub {}, - %{ $options || {} } - }; - - my $schema = Koha::Database->new->schema; - my $stats = { - num_fetched => { - map { $_ => 0 } @$server_ids - }, - num_hits => { - map { $_ => 0 } @$server_ids - }, - total_fetched => 0, - total_hits => 0, - }; - my $start = clock_gettime( CLOCK_MONOTONIC ); - my @servers; - - foreach my $server ( $schema->resultset('Z3950server')->search( { id => $server_ids } )->all ) { - my $zoptions = ZOOM::Options->new(); - $zoptions->option( 'async', 1 ); - $zoptions->option( 'elementSetName', 'F' ); - $zoptions->option( 'databaseName', $server->db ); - $zoptions->option( 'user', $server->userid ) if $server->userid; - $zoptions->option( 'password', $server->password ) if $server->password; - $zoptions->option( 'preferredRecordSyntax', $server->syntax ); - $zoptions->option( 'timeout', $server->timeout ) if $server->timeout; - - my $connection = ZOOM::Connection->create($zoptions); - $connection->connect( $server->host, $server->port ); - - push @servers, { - connection => $connection, - id => $server->id, - host => $server->host, - name => $server->name, - encoding => ( $server->encoding ? $server->encoding : "iso-5426" ), - results => $connection->search_pqf( $query ), # Starts the search - }; - } - - my $servers_left = scalar @servers; - my $total_raw_size = 0; - - while ( $servers_left ) { - my $i; - - # Read pending events from servers until one finishes - while ( ( $i = ZOOM::event( [ map { $_->{connection} } @servers ] ) ) != 0 ) { - last if $servers[ $i - 1 ]->{connection}->last_event() == ZOOM::Event::ZEND; - } - - $servers_left--; - my $server = $servers[ --$i ]; - my $exception = $server->{connection}->exception(); #ignores errmsg, addinfo, diagset - - if ($exception) { - $options->{on_error}->( $server, $exception ); - } else { - my $num_results = $stats->{num_hits}->{ $server->{id} } = $server->{results}->size; - my $num_fetched = $stats->{num_fetched}->{ $server->{id} } = ( $options->{offset} + $options->{fetch} ) < $num_results ? $options->{fetch} : $num_results; - - $stats->{total_hits} += $num_results; - $stats->{total_fetched} += $num_fetched; - - next if ( !$num_results ); - - my $hits = $server->{results}->records( $options->{offset}, $num_fetched, 1 ); - - if ( !@$hits ) { - $options->{on_error}->( $server, $server->{connection}->exception() ) if ( $server->{connection}->exception() ); - next; - } - - foreach my $j ( 0..$#$hits ) { - $total_raw_size += length $hits->[$j]->raw(); - my ($marcrecord) = MarcToUTF8Record( $hits->[$j]->raw(), C4::Context->preference('marcflavour'), $server->{encoding} ); #ignores charset return values - my $metadata = {}; - _add_rowdata( $metadata, $marcrecord ); - $options->{on_hit}->( $server, { - index => $options->{offset} + $j, - record => $marcrecord, - metadata => $metadata, - } ); - } - } - } - - $stats->{time} = clock_gettime( CLOCK_MONOTONIC ) - $start; - - return $stats; -} - 1; __END__ diff --git a/C4/Service.pm b/C4/Service.pm index d2e1643..da1d1ee 100644 --- a/C4/Service.pm +++ b/C4/Service.pm @@ -57,6 +57,7 @@ our ( $query, $cookie ); sub _output { my ( $response, $status ) = @_; + binmode STDOUT, ':encoding(UTF-8)'; if ( $query->param( 'callback' ) ) { output_with_http_headers $query, $cookie, $query->param( 'callback' ) . '(' . $response->output . ');', 'js'; diff --git a/Koha/MetaSearcher.pm b/Koha/MetaSearcher.pm new file mode 100644 index 0000000..7c4c87a --- /dev/null +++ b/Koha/MetaSearcher.pm @@ -0,0 +1,351 @@ +package Koha::MetaSearcher; + +# Copyright 2014 ByWater Solutions +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use base 'Class::Accessor'; + +use C4::Charset qw( MarcToUTF8Record ); +use C4::Search qw(); # Purely for new_record_from_zebra +use DBIx::Class::ResultClass::HashRefInflator; +use IO::Select; +use Koha::Cache; +use Koha::Database; +use Koha::MetadataRecord; +use MARC::File::XML; +use Storable qw( store_fd fd_retrieve ); +use Time::HiRes qw( clock_gettime CLOCK_MONOTONIC ); +use UUID; +use ZOOM; + +use sort 'stable'; + +__PACKAGE__->mk_accessors( qw( fetch offset on_error resultset ) ); + +sub new { + my ( $class, $options ) = @_; + + my ( $uuid, $uuidstring ); + UUID::generate($uuid); + UUID::unparse( $uuid, $uuidstring ); + + return bless { + offset => 0, + fetch => 100, + on_error => sub {}, + results => [], + resultset => $uuidstring, + %{ $options || {} } + }, $class; +} + +sub handle_hit { + my ( $self, $index, $server, $marcrecord ) = @_; + + my $record = Koha::MetadataRecord->new( { schema => 'marc', record => $marcrecord } ); + + my %fetch = ( + title => 'biblio.title', + seriestitle => 'biblio.seriestitle', + author => 'biblio.author', + isbn =>'biblioitems.isbn', + issn =>'biblioitems.issn', + lccn =>'biblioitems.lccn', #LC control number (not call number) + edition =>'biblioitems.editionstatement', + date => 'biblio.copyrightdate', #MARC21 + date2 => 'biblioitems.publicationyear', #UNIMARC + ); + + my $metadata = {}; + while ( my ( $key, $kohafield ) = each %fetch ) { + $metadata->{$key} = $record->getKohaField($kohafield); + } + $metadata->{date} //= $metadata->{date2}; + + push @{ $self->{results} }, { + server => $server, + index => $index, + record => $marcrecord, + metadata => $metadata, + }; +} + +sub search { + my ( $self, $server_ids, $query ) = @_; + + my $resultset_expiry = 300; + + my $cache; + eval { $cache = Koha::Cache->new(); }; + my $schema = Koha::Database->new->schema; + my $stats = { + num_fetched => { + map { $_ => 0 } @$server_ids + }, + num_hits => { + map { $_ => 0 } @$server_ids + }, + total_fetched => 0, + total_hits => 0, + }; + my $start = clock_gettime( CLOCK_MONOTONIC ); + my $select = IO::Select->new; + my @worker_fhs; + + my @cached_sets; + my @servers; + + foreach my $server_id ( @$server_ids ) { + if ( $server_id =~ /^\d+$/ ) { + # Z39.50 server + my $server = $schema->resultset('Z3950server')->find( + { id => $server_id }, + { result_class => 'DBIx::Class::ResultClass::HashRefInflator' }, + ); + $server->{type} = 'z3950'; + + push @servers, $server; + } elsif ( $server_id =~ /(\w+)(?::(\w+))?/ ) { + # Special server + push @servers, { + type => $1, + extra => $2, + id => $server_id, + host => $server_id, + name => $server_id, + }; + } + } + + # HashRefInflator is used so that the information will survive into the fork + foreach my $server ( @servers ) { + if ( $cache ) { + my $set = $cache->get_from_cache( 'z3950-resultset-' . $self->resultset . '-' . $server->{id} ); + if ( ref($set) eq 'HASH' ) { + $set->{server} = $server; + push @cached_sets, $set; + next; + } + } + + $select->add( $self->_start_worker( $server, $query ) ); + } + + # Handle these while the servers are searching + foreach my $set ( @cached_sets ) { + $self->_handle_hits( $stats, $set ); + } + + while ( $select->count ) { + foreach my $readfh ( $select->can_read() ) { + my $result = fd_retrieve( $readfh ); + + $select->remove( $readfh ); + close $readfh; + wait; + + next if ( ref $result ne 'HASH' ); + + if ( $result->{error} ) { + $self->{on_error}->( $result->{server}, $result->{error} ); + next; + } + + $self->_handle_hits( $stats, $result ); + + if ( $cache ) { + $cache->set_in_cache( 'z3950-resultset-' . $self->resultset . '-' . $result->{server}->{id}, { + hits => $result->{hits}, + num_fetched => $result->{num_fetched}, + num_hits => $result->{num_hits}, + }, $resultset_expiry ); + } + } + } + + $stats->{time} = clock_gettime( CLOCK_MONOTONIC ) - $start; + + return $stats; +} + +sub _start_worker { + my ( $self, $server, $query ) = @_; + pipe my $readfh, my $writefh; + + # Accessing the cache or Koha database after the fork is risky, so get any resources we need + # here. + my $pid; + my $marcflavour = C4::Context->preference('marcflavour'); + + if ( ( $pid = fork ) ) { + # Parent process + close $writefh; + + return $readfh; + } elsif ( !defined $pid ) { + # Error + + $self->{on_error}->( $server, 'Failed to fork' ); + return; + } + + close $readfh; + my $connection; + my ( $num_hits, $num_fetched, $hits, $results ); + + eval { + if ( $server->{type} eq 'z3950' ) { + my $zoptions = ZOOM::Options->new(); + $zoptions->option( 'elementSetName', 'F' ); + $zoptions->option( 'databaseName', $server->{db} ); + $zoptions->option( 'user', $server->{userid} ) if $server->{userid}; + $zoptions->option( 'password', $server->{password} ) if $server->{password}; + $zoptions->option( 'preferredRecordSyntax', $server->{syntax} ); + $zoptions->option( 'timeout', $server->{timeout} ) if $server->{timeout}; + + $connection = ZOOM::Connection->create($zoptions); + + $connection->connect( $server->{host}, $server->{port} ); + $results = $connection->search_pqf( $query ); # Starts the search + } elsif ( $server->{type} eq 'koha' ) { + $connection = C4::Context->Zconn( $server->{extra} ); + $results = $connection->search_pqf( $query ); # Starts the search + } elsif ( $server->{type} eq 'batch' ) { + $server->{encoding} = 'utf-8'; + } + }; + if ($@) { + store_fd { + error => $connection ? $connection->exception() : $@, + server => $server, + }, $writefh; + exit; + } + + if ( $server->{type} eq 'batch' ) { + # TODO: actually handle PQF + $query =~ s/@\w+ (?:\d+=\d+ )?//g; + $query =~ s/"//g; + + my $schema = Koha::Database->new->schema; + $schema->storage->debug(1); + my $match_condition = [ map +{ -like => '%' . $_ . '%' }, split( /\s+/, $query ) ]; + $hits = [ $schema->resultset('ImportRecord')->search( + { + import_batch_id => $server->{extra}, + -or => [ + { 'import_biblios.title' => $match_condition }, + { 'import_biblios.author' => $match_condition }, + { 'import_biblios.isbn' => $match_condition }, + { 'import_biblios.issn' => $match_condition }, + ], + }, + { + join => [ qw( import_biblios ) ], + rows => $self->{fetch}, + } + )->get_column( 'marc' )->all ]; + + $num_hits = $num_fetched = scalar @$hits; + } else { + $num_hits = $results->size; + $num_fetched = ( $self->{offset} + $self->{fetch} ) < $num_hits ? $self->{fetch} : $num_hits; + + $hits = [ map { $_->raw() } @{ $results->records( $self->{offset}, $num_fetched, 1 ) } ]; + } + + if ( !@$hits && $connection && $connection->exception() ) { + store_fd { + error => $connection->exception(), + server => $server, + }, $writefh; + exit; + } + + if ( $server->{type} eq 'koha' ) { + $hits = [ map { C4::Search::new_record_from_zebra( $server->{extra}, $_ ) } @$hits ]; + } else { + $hits = [ map { $self->_import_record( $_, $marcflavour, $server->{encoding} ? $server->{encoding} : "iso-5426" ) } @$hits ]; + } + + store_fd { + hits => $hits, + num_fetched => $num_fetched, + num_hits => $num_hits, + server => $server, + }, $writefh; + + exit; +} + +sub _import_record { + my ( $self, $raw, $marcflavour, $encoding ) = @_; + + my ( $marcrecord ) = MarcToUTF8Record( $raw, $marcflavour, $encoding ); #ignores charset return values + + return $marcrecord; +} + +sub _handle_hits { + my ( $self, $stats, $set ) = @_; + + my $server = $set->{server}; + + my $num_hits = $stats->{num_hits}->{ $server->{id} } = $set->{num_hits}; + my $num_fetched = $stats->{num_fetched}->{ $server->{id} } = $set->{num_fetched}; + + $stats->{total_hits} += $num_hits; + $stats->{total_fetched} += $num_fetched; + + foreach my $j ( 0..$#{ $set->{hits} } ) { + $self->handle_hit( $self->{offset} + $j, $server, $set->{hits}->[$j] ); + } +} + +sub sort { + my ( $self, $key, $direction ) = @_; + + my $empty_flip = -1; # Determines the flip of ordering for records with empty sort keys. + + foreach my $hit ( @{ $self->{results} } ) { + ( $hit->{sort_key} = $hit->{metadata}->{$key} || '' ) =~ s/\W//g; + } + + $self->{results} = [ sort { + # Sort empty records at the end + return -$empty_flip unless $a->{sort_key}; + return $empty_flip unless $b->{sort_key}; + + $direction * ( $a->{sort_key} cmp $b->{sort_key} ); + } @{ $self->{results} } ]; +} + +sub results { + my ( $self, $offset, $length ) = @_; + + my @subset; + + foreach my $i ( $offset..( $offset + $length - 1 ) ) { + push @subset, $self->{results}->[$i] if $self->{results}->[$i]; + } + + return @subset; +} + +1; diff --git a/Koha/MetadataRecord.pm b/Koha/MetadataRecord.pm index 865dc0d..7b802e6 100644 --- a/Koha/MetadataRecord.pm +++ b/Koha/MetadataRecord.pm @@ -56,4 +56,29 @@ sub createMergeHash { } } +sub getKohaField { + my ($self, $kohafield) = @_; + + if ($self->schema =~ m/marc/) { + my $relations = C4::Context->marcfromkohafield->{''}; + my $tagfield = $relations->{$kohafield}; + + return '' if ref($tagfield) ne 'ARRAY'; + + my ($tag, $subfield) = @$tagfield; + my @kohafield; + foreach my $field ( $self->record->field($tag) ) { + if ( $field->tag() < 10 ) { + push @kohafield, $field->data(); + } else { + foreach my $contents ( $field->subfield($subfield) ) { + push @kohafield, $contents; + } + } + } + + return join ' | ', @kohafield; + } +} + 1; diff --git a/cataloguing/editor.pl b/cataloguing/editor.pl index 85a1191..4f4f380 100755 --- a/cataloguing/editor.pl +++ b/cataloguing/editor.pl @@ -27,6 +27,8 @@ use C4::Auth; use C4::Biblio; use C4::Context; use C4::Output; +use DBIx::Class::ResultClass::HashRefInflator; +use Koha::Database; my $input = CGI->new; @@ -40,6 +42,17 @@ my ( $template, $loggedinuser, $cookie ) = get_template_and_user( } ); +my $schema = Koha::Database->new->schema; + +# Available import batches +$template->{VARS}->{editable_batches} = [ $schema->resultset('ImportBatch')->search( + { + batch_type => [ 'batch', 'webservice' ], + import_status => 'staged', + }, + { result_class => 'DBIx::Class::ResultClass::HashRefInflator' }, +) ]; + # Needed information for cataloging plugins $template->{VARS}->{DefaultLanguageField008} = pack( 'A3', C4::Context->preference('DefaultLanguageField008') || 'eng' ); diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/koha-backend.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/koha-backend.js index c70c3bf..7827f60 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/koha-backend.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/koha-backend.js @@ -1,3 +1,22 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + define( [ '/cgi-bin/koha/svc/cataloguing/framework?frameworkcode=&callback=define', 'marc-record' ], function( defaultFramework, MARC ) { var _authorised_values = defaultFramework.authorised_values; var _frameworks = {}; @@ -61,7 +80,7 @@ define( [ '/cgi-bin/koha/svc/cataloguing/framework?frameworkcode=&callback=defin record.loadMARCXML(data); callback(record); } ).fail( function( data ) { - callback( { data: { error: data } } ); + callback( { error: data } ); } ); }, diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros.js index 8aaa2f3..4d747f9 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros.js @@ -1,194 +1,36 @@ -define( [ 'widget' ], function( Widget ) { - function _setIndicators( editor, ind1, ind2 ) { - var info = editor.getLineInfo( editor.cm.getCursor() ); - if (!info || !info.subfields) return false; - - var cur = editor.cm.getCursor(); - - var indicators = [ ind1 || info.contents.substring(4, 5) || '_', ind2 || info.contents.substring(6, 7) || '_' ]; - - editor.cm.replaceRange( - info.tagNumber + ' ' + indicators.join(' ') + ' ' + info.contents.substring(8), - { line: cur.line, ch: 0 }, - { line: cur.line }, - 'marcAware' - ); - - return true; - } - - var _commandGenerators = [ - [ /^copy field data$/i, function() { - return function( editor, state ) { - var info = editor.getLineInfo( editor.cm.getCursor() ); - if (!info) return false; - - if (info.subfields) { - state.clipboard = info.contents.substring(4); - } else { - state.clipboard = info.contents.substring(8); - } - }; - } ], - [ /^copy subfield data$/i, function() { - return function( editor, state ) { - var info = editor.getLineInfo( editor.cm.getCursor() ); - if (!info) return false; - - var cur = editor.cm.getCursor(); - - if (info.subfields) { - for (var i = 0; i < info.subfields.length; i++) { - var end = i == info.subfields.length - 1 ? info.contents.length : info.subfields[i+1].ch; - if (cur.ch > end) continue; - - state.clipboard = info.contents.substring(info.subfields[i].ch + 3, end); - return; - } - } - - return false; - } - } ], - [ /^delete field$/i, function() { - return function( editor, state ) { - var cur = editor.cm.getCursor(); - - editor.cm.replaceRange( "", { line: cur.line, ch: 0 }, { line: cur.line + 1, ch: 0 }, 'marcAware' ); - } - } ], - [ /^goto field end$/i, function() { - return function( editor, state ) { - editor.cm.setCursor( { line: editor.cm.lastLine() } ); - } - } ], - [ /^goto field (\w{3})$/i, function(field) { - var matcher = new RegExp('^' + field + ' '); - return function( editor, state ) { - for ( var line = 0, contents; (contents = editor.cm.getLine(line)); line++ ) { - if ( matcher.exec( contents ) ) { - editor.cm.setCursor( { line: line, ch: 0 } ); - return; - } - } - - return false; - } - } ], - [ /^goto subfield end$/i, function() { - return function( editor, state ) { - var cur = editor.cm.getCursor(); - - editor.cm.setCursor( { line: cur.line } ); - } - } ], - [ /^goto subfield (\w)$/i, function( code ) { - return function( editor, state ) { - var info = editor.getLineInfo( editor.cm.getCursor() ); - if (!info || !info.subfields) return false; - - var cur = editor.cm.getCursor(); - - for (var i = 0; i < info.subfields.length; i++) { - if ( info.subfields[i].code != code ) continue; - - var end = i == info.subfields.length - 1 ? info.contents.length : info.subfields[i+1].ch; - editor.cm.setCursor( { line: cur.line, ch: end } ); - return; - } - - return false; - } - } ], - [ /^insert (new )?field (\w{3}) data=(.*)/i, function(undef, field, data) { - var new_contents = field + ( field < '100' ? ' ' : ' _ _ ' ) + data.replace(/\\([0-9a-z])/g, '$$$1 '); - return function( editor, state ) { - var line, contents; - - for ( line = 0; (contents = editor.cm.getLine(line)); line++ ) { - if ( contents && contents[0] > field[0] ) break; - } - - if ( line > editor.cm.lastLine() ) { - new_contents = '\n' + new_contents; - } else { - new_contents = new_contents + '\n'; - } - - editor.cm.replaceRange( new_contents, { line: line, ch: 0 }, null, 'marcAware' ); - editor.cm.setCursor( { line: line, ch: 0 } ); - } - } ], - [ /^insert (new )?subfield (\w) data=(.*)/i, function(undef, subfield, data) { - return function( editor, state ) { - editor.cm.replaceRange( '$' + subfield + ' ' + data, { line: editor.cm.getCursor().line }, null, 'marcAware' ); - } - } ], - [ /^paste$/i, function() { - return function( editor, state ) { - var cur = editor.cm.getCursor(); - - editor.cm.replaceRange( state.clipboard, cur, null, 'marcAware' ); - } - } ], - [ /^set indicator([12])=([ _0-9])$/i, function( ind, value ) { - return function( editor, state ) { - return ind == '1' ? _setIndicators( editor, ind1, null ) : _setIndicators( editor, null, ind2 ); - } - } ], - [ /^set indicators=([ _0-9])([ _0-9])$/i, function( ind1, ind2 ) { - return function( editor, state ) { - return _setIndicators( editor, ind1, ind2 ); - } - } ], - ]; - +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + +define( [ 'macros/its', 'macros/rancor' ], function( ITSMacro, RancorMacro ) { var Macros = { - Compile: function( macro ) { - var result = { commands: [], errors: [] }; - - $.each( macro.split(/\r\n|\n/), function( line, contents ) { - var command; - - if ( contents.match(/^\s*$/) ) return; - - $.each( _commandGenerators, function( undef, gen ) { - var match; - - if ( !( match = gen[0].exec( contents ) ) ) return; - - command = gen[1].apply(null, match.slice(1)); - return false; - } ); - - if ( !command ) { - result.errors.push( { line: line, error: 'unrecognized' } ); - } - - result.commands.push( { func: command, orig: contents, line: line } ); - } ); - - return result; + formats: { + its: { + description: 'TLC® ITS', + Run: ITSMacro.Run, + }, + rancor: { + description: 'Rancor', + Run: RancorMacro.Run, + }, }, - Run: function( editor, macro ) { - var compiled = Macros.Compile(macro); - if ( compiled.errors.length ) return { errors: compiled.errors }; - var state = { - clipboard: '', - }; - - var result = { errors: [] }; - - editor.cm.operation( function() { - $.each( compiled.commands, function( undef, command ) { - if ( command.func( editor, state ) === false ) { - result.errors.push( { line: command.line, error: 'failed' } ); - return false; - } - } ); - } ); - - return result; + Run: function( editor, format, macro ) { + return Macros.formats[format].Run( editor, macro ); }, }; diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/its.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/its.js new file mode 100644 index 0000000..07bfbeb --- /dev/null +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/its.js @@ -0,0 +1,208 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + +define( function() { + var NAV_FAILED = new Object(); + var NAV_SUCCEEDED = new Object(); + + var _commandGenerators = [ + [ /^copy field data$/i, function() { + return function( editor, state ) { + if ( state.field == null ) return; + + return state.field.getText(); + }; + } ], + [ /^copy subfield data$/i, function() { + return function( editor, state ) { + if ( state.field == null ) return; + + var cur = editor.getCursor(); + var subfields = state.field.getSubfields(); + + for (var i = 0; i < subfields.length; i++) { + if ( cur.ch > subfields[i].end ) continue; + + state.clipboard = subfields[i].text; + return; + } + + return false; + } + } ], + [ /^del(ete)? field$/i, function() { + return function( editor, state ) { + if ( state.field == null ) return; + + state.field.delete(); + return NAV_FAILED; + } + } ], + [ /^goto field end$/i, function() { + return function( editor, state ) { + if ( state.field == null ) return NAV_FAILED; + var cur = editor.cm.getCursor(); + + editor.cm.setCursor( { line: cur.line } ); + return NAV_SUCCEEDED; + } + } ], + [ /^goto field (\w{3})$/i, function(tag) { + return function( editor, state ) { + var field = editor.getFirstField(tag); + if ( field == null ) return NAV_FAILED; + + field.focus(); + return NAV_SUCCEEDED; + } + } ], + [ /^goto subfield end$/i, function() { + return function( editor, state ) { + if ( state.field == null ) return NAV_FAILED; + + var cur = editor.getCursor(); + var subfields = state.field.getSubfields(); + + for (var i = 0; i < subfields.length; i++) { + if ( cur.ch > subfields[i].end ) continue; + + subfield.focusEnd(); + return NAV_SUCCEEDED; + } + + return NAV_FAILED; + } + } ], + [ /^goto subfield (\w)$/i, function( code ) { + return function( editor, state ) { + if ( state.field == null ) return NAV_FAILED; + + var subfield = state.field.getFirstSubfield( code ); + if ( subfield == null ) return NAV_FAILED; + + subfield.focus(); + return NAV_SUCCEEDED; + } + } ], + [ /^insert (new )?field (\w{3}) data=(.*)/i, function(undef, tag, text) { + text = text.replace(/\\([0-9a-z])/g, '$$$1 '); + return function( editor, state ) { + editor.createFieldGrouped(tag).setText(text).focus(); + return NAV_SUCCEEDED; + } + } ], + [ /^insert (new )?subfield (\w) data=(.*)/i, function(undef, code, text) { + return function( editor, state ) { + if ( state.field == null ) return; + + state.field.appendSubfield(code).setText(text); + } + } ], + [ /^paste$/i, function() { + return function( editor, state ) { + var cur = editor.cm.getCursor(); + + editor.cm.replaceRange( state.clipboard, cur, null, 'marcAware' ); + } + } ], + [ /^set indicator([12])=([ _0-9])$/i, function( ind, value ) { + return function( editor, state ) { + if ( state.field == null ) return; + if ( state.field.isControlField ) return false; + + if ( ind == '1' ) { + state.field.setIndicator1(value); + return true; + } else if ( ind == '2' ) { + state.field.setIndicator2(value); + return true; + } else { + return false; + } + } + } ], + [ /^set indicators=([ _0-9])([ _0-9])?$/i, function( ind1, ind2 ) { + return function( editor, state ) { + if ( state.field == null ) return; + if ( state.field.isControlField ) return false; + + state.field.setIndicator1(ind1); + state.field.setIndicator2(ind2 || '_'); + } + } ], + ]; + + var ITSMacro = { + Compile: function( macro ) { + var result = { commands: [], errors: [] }; + + $.each( macro.split(/\r\n|\n/), function( line, contents ) { + var command; + + if ( contents.match(/^\s*$/) ) return; + + $.each( _commandGenerators, function( undef, gen ) { + var match; + + if ( !( match = gen[0].exec( contents ) ) ) return; + + command = gen[1].apply(null, match.slice(1)); + return false; + } ); + + if ( !command ) { + result.errors.push( { line: line, error: 'unrecognized' } ); + } + + result.commands.push( { func: command, orig: contents, line: line } ); + } ); + + return result; + }, + Run: function( editor, macro ) { + var compiled = ITSMacro.Compile(macro); + if ( compiled.errors.length ) return { errors: compiled.errors }; + var state = { + clipboard: '', + field: null, + }; + + var run_result = { errors: [] }; + + editor.cm.operation( function() { + $.each( compiled.commands, function( undef, command ) { + var result = command.func( editor, state ); + + if ( result == NAV_FAILED ) { + state.field = null; + } else if ( result == NAV_SUCCEEDED ) { + state.field = editor.getCurrentField(); + } else if ( result === false ) { + run_result.errors.push( { line: command.line, error: 'failed' } ); + return false; + } + } ); + } ); + + return run_result; + }, + }; + + return ITSMacro; +} ); diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/rancor.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/rancor.js new file mode 100644 index 0000000..e9b484e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/rancor.js @@ -0,0 +1,277 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + +define( [ 'marc-editor' ], function( MARCEditor ) { + // These are the generators for targets that appear on the left-hand side of an assignment. + var _lhsGenerators = [ + // Field; will replace the entire contents of the tag except for indicators. + // Examples: + // * 245 - will return the first 245 tag it finds, or create a new one + // * new 245 - will always create a new 245 + // * new 245 grouped - will always create a new 245, and insert it at the end of the 2xx + // block + [ /^(new )?(\w{3})( (grouped))?$/, function( forceCreate, tag, position, positionGrouped ) { + if ( !forceCreate && positionGrouped ) return null; + + // The extra argument allows the delete command to prevent this from needlessly creating + // a tag that it is about to delete. + return function( editor, state, extra ) { + extra = extra || {}; + + if ( !forceCreate ) { + var result = editor.getFirstField(tag); + + if ( result != null || extra.dontCreate ) return result; + } + + if ( positionGrouped ) { + return editor.createFieldGrouped(tag); + } else { + return editor.createFieldOrdered(tag); + } + } + } ], + + // This regex is a little complicated, but allows for the following possibilities: + // * 245a - Finds the first 245 field, then tries to find an a subfield within it. If none + // exists, it is created. Will still fail if there is no 245 field. + // * new 245a - always creates a new a subfield. + // * new 245a at end - does the same as the above. + // * $a or new $a - does the same as the above, but for the last-used tag. + // * new 245a after b - creates a new subfield, placing it after the first subfield $b. + [ /^(new )?(\w{3}|\$)(\w)( (at end)| after (\w))?$/, function( forceCreate, tag, code, position, positionAtEnd, positionAfterSubfield ) { + if ( tag != '$' && tag < '010' ) return null; + if ( !forceCreate && position ) return null; + + return function( editor, state, extra ) { + extra = extra || {}; + + var field; + + if ( tag == '$' ) { + field = state.field; + } else { + field = editor.getFirstField(tag); + } + if ( field == null || field.isControlField ) return null; + + if ( !forceCreate ) { + var subfield = field.getFirstSubfield(code) + + if ( subfield != null || extra.dontCreate ) return subfield; + } + + if ( !position || position == ' at end' ) { + return field.appendSubfield(code); + } else if ( positionAfterSubfield ) { + var afterSubfield = field.getFirstSubfield(positionAfterSubfield); + + if ( afterSubfield == null ) return null; + + return field.insertSubfield( code, afterSubfield.index + 1 ); + } + } + } ], + + // Can set indicatators either for a particular field or the last-used tag. + [ /^((\w{3}) )?indicators$/, function( undef, tag ) { + if ( tag && tag < '010' ) return null; + + return function( editor, state ) { + var field; + + if ( tag == null ) { + field = state.field; + } else { + field = editor.getFirstField(tag); + } + if ( field == null || field.isControlField ) return null; + + return { + field: field, + setText: function( text ) { + field.setIndicator1( text.substr( 0, 1 ) ); + field.setIndicator2( text.substr( 1, 1 ) ); + } + }; + } + } ], + ]; + + // These patterns, on the other hand, appear inside interpolations on the right hand side. + var _rhsGenerators = [ + [ /^(\w{3})$/, function( tag ) { + return function( editor, state, extra ) { + return editor.getFirstField(tag); + } + } ], + [ /^(\w{3})(\w)$/, function( tag, code ) { + if ( tag < '010' ) return null; + + return function( editor, state, extra ) { + extra = extra || {}; + + var field = editor.getFirstField(tag); + if ( field == null ) return null; + + return field.getFirstSubfield(code); + } + } ], + ]; + + var _commandGenerators = [ + [ /^delete (.+)$/, function( target ) { + var target_closure = _generate( _lhsGenerators, target ); + if ( !target_closure ) return null; + + return function( editor, state ) { + var target = target_closure( editor, state, { dontCreate: true } ); + if ( target == null ) return; + if ( !target.delete ) return false; + + state.field = null; // As other fields may have been invalidated + target.delete(); + } + } ], + [ /^([^=]+)=([^=]*)$/, function( lhs_desc, rhs_desc ) { + var lhs_closure = _generate( _lhsGenerators, lhs_desc ); + if ( !lhs_closure ) return null; + + var rhs_closure = _generateInterpolation( _rhsGenerators, rhs_desc ); + if ( !rhs_closure ) return null; + + return function( editor, state ) { + var lhs = lhs_closure( editor, state ); + if ( lhs == null ) return; + + state.field = lhs.field || lhs; + + try { + return lhs.setText( rhs_closure( editor, state ) ); + } catch (e) { + if ( e instanceof MARCEditor.FieldError ) { + return false; + } else { + throw e; + } + } + }; + } ], + ]; + + function _generate( set, contents ) { + var closure; + + if ( contents.match(/^\s*$/) ) return; + + $.each( set, function( undef, gen ) { + var match; + + if ( !( match = gen[0].exec( contents ) ) ) return; + + closure = gen[1].apply(null, match.slice(1)); + return false; + } ); + + return closure; + } + + function _generateInterpolation( set, contents ) { + // While this regex will not match at all for an empty string, that just leaves an empty + // parts array which yields an empty string (which is what we want.) + var matcher = /\{([^}]+)\}|([^{]+)/g; + var match; + + var parts = []; + + while ( ( match = matcher.exec(contents) ) ) { + var closure; + if ( match[1] ) { + // Found an interpolation + var rhs_closure = _generate( set, match[1] ); + if ( rhs_closure == null ) return null; + + closure = ( function(rhs_closure) { return function( editor, state ) { + var rhs = rhs_closure( editor, state ); + + return rhs ? rhs.getText() : ''; + } } )( rhs_closure ); + } else { + // Plain text (needs artificial closure to keep match) + closure = ( function(text) { return function() { return text }; } )( match[2] ); + } + + parts.push( closure ); + } + + return function( editor, state ) { + var result = ''; + $.each( parts, function( i, part ) { + result += part( editor, state ); + } ); + + return result; + }; + } + + var RancorMacro = { + Compile: function( macro ) { + var result = { commands: [], errors: [] }; + + $.each( macro.split(/\r\n|\n/), function( line, contents ) { + contents = contents.replace( /#.*$/, '' ); + if ( contents.match(/^\s*$/) ) return; + + var command = _generate( _commandGenerators, contents ); + + if ( !command ) { + result.errors.push( { line: line, error: 'unrecognized' } ); + } + + result.commands.push( { func: command, orig: contents, line: line } ); + } ); + + return result; + }, + Run: function( editor, macro ) { + var compiled = RancorMacro.Compile(macro); + if ( compiled.errors.length ) return { errors: compiled.errors }; + var state = { + field: null, + }; + + var run_result = { errors: [] }; + + editor.cm.operation( function() { + $.each( compiled.commands, function( undef, command ) { + var result = command.func( editor, state ); + + if ( result === false ) { + run_result.errors.push( { line: command.line, error: 'failed' } ); + return false; + } + } ); + } ); + + return run_result; + }, + }; + + return RancorMacro; +} ); diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-editor.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-editor.js index 3c6992b..025d10d 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-editor.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-editor.js @@ -1,38 +1,43 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], function( MARC, KohaBackend, Preferences, TextMARC, Widget ) { var NOTIFY_TIMEOUT = 250; function editorCursorActivity( cm ) { var editor = cm.marceditor; - if ( editor.textMode ) return; - - $('#status-tag-info').empty(); - $('#status-subfield-info').empty(); - - var info = editor.getLineInfo( cm.getCursor() ); - - if ( !info ) return; // No tag at all on this line - - var taginfo = KohaBackend.GetTagInfo( '', info.tagNumber ); - $('#status-tag-info').html( '' + info.tagNumber + ': ' ); - - if ( taginfo ) { - $('#status-tag-info').append( taginfo.lib ); + var field = editor.getCurrentField(); + if ( !field ) return; - if ( !info.currentSubfield ) return; // No current subfield - - var subfieldinfo = taginfo.subfields[info.currentSubfield]; - $('#status-subfield-info').html( '$' + info.currentSubfield + ': ' ); - - if ( subfieldinfo ) { - $('#status-subfield-info').append( subfieldinfo.lib ); - } else { - $('#status-subfield-info').append( '' + _("Unknown subfield") + '' ); - } + // Set overwrite mode for tag numbers/indicators and contents of fixed fields + if ( field.isControlField || cm.getCursor().ch < 8 ) { + cm.toggleOverwrite(true); } else { - $('#status-tag-info').append( '' + _("Unknown tag") + '' ); + cm.toggleOverwrite(false); } + + editor.onCursorActivity(); } + // This function exists to prevent inserting or partially deleting text that belongs to a + // widget. The 'marcAware' change source exists for other parts of the editor code to bypass + // this check. function editorBeforeChange( cm, change ) { var editor = cm.marceditor; if ( editor.textMode || change.origin == 'marcAware' ) return; @@ -62,15 +67,15 @@ define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], } var startLine, endLine; - if ( change.text.length == 2 && from.line == to.line && from.ch == to.ch) { + if ( change.text.length == 2 && from.line == to.line && from.ch == to.ch ) { if ( from.ch == 0 ) { startLine = endLine = from.line; } else if ( from.ch == cm.getLine(from.line).length ){ startLine = endLine = from.line + 1; } } else { - startLine = (from.ch == cm.getLine(from.line).length && from.line < to.line) ? Math.min(cm.lastLine(), from.line + 1) : from.line; - endLine = ((to.ch == 0 && from.line < to.line) ? Math.max(to.line - 1, 0) : to.line) + change.text.length - 1; + startLine = ( from.ch == cm.getLine( from.line ).length && from.line < to.line ) ? Math.min( cm.lastLine(), from.line + 1 ) : from.line; + endLine = ( ( to.ch == 0 && from.line < to.line ) ? Math.max( to.line - 1, 0 ) : to.line ) + change.text.length - 1; } for ( var line = startLine; line <= endLine; line++ ) { @@ -87,25 +92,49 @@ define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], } // Editor helper functions - function activateTabPosition( cm, cur, idx ) { - cm.setCursor( cur ); - Widget.ActivateAt( cm, cur, idx ); + function activateTabPosition( cm, pos, idx ) { + // Allow tabbing to as-yet-nonexistent positions + var lenDiff = pos.ch - cm.getLine( pos.line ).length; + if ( lenDiff > 0 ) { + var extra = ''; + while ( lenDiff-- > 0 ) extra += ' '; + if ( pos.prefill ) extra += pos.prefill; + cm.replaceRange( extra, { line: pos.line } ); + } + + cm.setCursor( pos ); + Widget.ActivateAt( cm, pos, idx ); } function getTabPositions( editor, cur ) { - var info = editor.getLineInfo( cur || editor.cm.getCursor() ); + cur = cur || editor.cm.getCursor(); + var field = editor.getFieldAt( cur.line ); - if ( info ) { - if ( info.subfields ) { - var positions = [ 0, 4, 6 ]; + if ( field ) { + if ( field.isControlField ) { + var positions = [ { ch: 0 }, { ch: 4 } ]; - $.each( info.subfields, function( undef, subfield ) { - positions.push( subfield.ch + 3 ); + $.each( positions, function( undef, pos ) { + pos.line = cur.line; } ); return positions; } else { - return [ 0, 4 ]; + var positions = [ { ch: 0 }, { ch: 4, prefill: '_' }, { ch: 6, prefill: '_' } ]; + + $.each( positions, function( undef, pos ) { + pos.line = cur.line; + } ); + $.each( field.getSubfields(), function( undef, subfield ) { + positions.push( { line: cur.line, ch: subfield.contentsStart } ); + } ); + + // Allow to tab to start of empty field + if ( field.getSubfields().length == 0 ) { + positions.push( { line: cur.line, ch: 8 } ); + } + + return positions; } } else { return []; @@ -119,34 +148,21 @@ define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], cm.setCursor( { line: cursor.line + 1, ch: 0 } ); }, - 'Shift-Enter': function( cm ) { - var cursor = cm.getCursor(); - cm.replaceRange( '\n', { line: cursor.line, ch: 0 }, null, 'marcAware' ); - cm.setCursor( { line: cursor.line, ch: 0 } ); - }, - 'Ctrl-X': function( cm ) { - // Delete subfield (or cut) + // Delete line (or cut) if ( cm.somethingSelected() ) return true; - var cur = cm.getCursor(); - var info = cm.marceditor.getLineInfo( cur ); - if ( !info || !info.subfields ) return true; - - for (var i = 0; i < info.subfields.length; i++) { - var end = i == info.subfields.length - 1 ? info.contents.length : info.subfields[i+1].ch; - if (cur.ch > end) continue; - - cm.replaceRange( "", { line: cur.line, ch: info.subfields[i].ch }, { line: cur.line, ch: end }, 'marcAware' ); - return; - } + var field = cm.marceditor.getCurrentField(); + if ( field ) field.delete(); }, 'Shift-Ctrl-X': function( cm ) { - // Delete line - var cur = cm.getCursor(); + // Delete subfield + var field = cm.marceditor.getCurrentField(); + if ( !field ) return; - cm.replaceRange( "", { line: cur.line, ch: 0 }, { line: cur.line + 1, ch: 0 }, 'marcAware' ); + var subfield = field.getSubfieldAt( cm.getCursor().ch ); + if ( subfield ) subfield.delete(); }, Tab: function( cm ) { @@ -155,8 +171,8 @@ define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], var cur = cm.getCursor(); for ( var i = 0; i < positions.length; i++ ) { - if ( positions[i] > cur.ch ) { - activateTabPosition( cm, { line: cur.line, ch: positions[i] } ); + if ( positions[i].ch > cur.ch ) { + activateTabPosition( cm, positions[i] ); return false; } } @@ -170,27 +186,267 @@ define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], var cur = cm.getCursor(); for ( var i = positions.length - 1; i >= 0; i-- ) { - if ( positions[i] < cur.ch ) { - activateTabPosition( cm, { line: cur.line, ch: positions[i] } ); + if ( positions[i].ch < cur.ch ) { + activateTabPosition( cm, positions[i] ); return false; } } if ( cur.line == 0 ) return; - var prevPositions = getTabPositions( cm, { line: cur.line - 1, ch: cm.getLine( cur.line - 1 ).length } ); + var prevPositions = getTabPositions( cm.marceditor, { line: cur.line - 1, ch: cm.getLine( cur.line - 1 ).length } ); if ( prevPositions.length ) { - activateTabPosition( cm, { line: cur.line - 1, ch: prevPositions[ prevPositions.length - 1 ] }, -1 ); + activateTabPosition( cm, prevPositions[ prevPositions.length - 1 ], -1 ); } else { cm.setCursor( { line: cur.line - 1, ch: 0 } ); } }, + + 'Ctrl-D': function( cm ) { + // Insert subfield delimiter + // This will be extended later to allow either a configurable subfield delimiter or just + // make it be the double cross. + var cur = cm.getCursor(); + + cm.replaceRange( "$", cur, null ); + }, + }; + + // The objects below are part of a field/subfield manipulation API, accessed through the base + // editor object. + // + // Each one is tied to a particular line; this means that using a field or subfield object after + // any other changes to the record will cause entertaining explosions. The objects are meant to + // be temporary, and should only be reused with great care. The macro code does this only + // because it is careful to dispose of the object after any other updates. + // + // Note, however, tha you can continue to use a field object after changing subfields. It's just + // the subfield objects that become invalid. + + // This is an exception raised by the EditorSubfield and EditorField when an invalid change is + // attempted. + function FieldError(line, message) { + this.line = line; + this.message = message; + }; + + FieldError.prototype.toString = function() { + return 'FieldError(' + this.line + ', "' + this.message + '")'; }; - function MARCEditor( position ) { + // This is the temporary object for a particular subfield in a field. Any change to any other + // subfields will invalidate this subfield object. + function EditorSubfield( field, index, start, end ) { + this.field = field; + this.index = index; + this.start = start; + this.end = end; + + if ( this.field.isControlField ) { + this.contentsStart = start; + this.code = '@'; + } else { + this.contentsStart = start + 3; + this.code = this.field.contents.substr( this.start + 1, 1 ); + } + + this.cm = field.cm; + + var marks = this.cm.findMarksAt( { line: field.line, ch: this.contentsStart } ); + if ( marks[0] && marks[0].widget ) { + this.widget = marks[0].widget; + + this.text = this.widget.text; + this.setText = this.widget.setText; + this.getFixed = this.widget.getFixed; + this.setFixed = this.widget.setFixed; + } else { + this.widget = null; + this.text = this.field.contents.substr( this.contentsStart, end - this.contentsStart ); + } + }; + + $.extend( EditorSubfield.prototype, { + _invalid: function() { + return this.field._subfieldsInvalid(); + }, + + focus: function() { + this.cm.setCursor( { line: this.field.line, ch: this.contentsStart } ); + }, + focusEnd: function() { + this.cm.setCursor( { line: this.field.line, ch: this.end } ); + }, + getText: function() { + return this.text; + }, + setText: function( text ) { + if ( !this._invalid() ) throw new FieldError( this.field.line, 'subfield invalid' ); + this.cm.replaceRange( text, { line: this.field.line, ch: this.contentsStart }, { line: this.field.line, ch: this.end }, 'marcAware' ); + this.field._invalidateSubfields(); + }, + } ); + + function EditorField( editor, line ) { + this.editor = editor; + this.line = line; + + this.cm = editor.cm; + + this._updateInfo(); + this.tag = this.contents.substr( 0, 3 ); + this.isControlField = ( this.tag < '010' ); + + if ( this.isControlField ) { + this._ind1 = this.contents.substr( 4, 1 ); + this._ind2 = this.contents.substr( 6, 1 ); + } else { + this._ind1 = null; + this._ind2 = null; + } + + this.subfields = null; + } + + $.extend( EditorField.prototype, { + _subfieldsInvalid: function() { + return !this.subfields; + }, + _invalidateSubfields: function() { + this._subfields = null; + }, + + _updateInfo: function() { + this.info = this.editor.getLineInfo( { line: this.line, ch: 0 } ); + if ( this.info == null ) throw new FieldError( 'Invalid field' ); + this.contents = this.info.contents; + }, + _scanSubfields: function() { + this._updateInfo(); + + if ( this.isControlField ) { + this._subfields = [ new EditorSubfield( this, 0, 4, this.contents.length ) ]; + } else { + var field = this; + var subfields = this.info.subfields; + this._subfields = []; + + for (var i = 0; i < this.info.subfields.length; i++) { + var end = i == subfields.length - 1 ? this.contents.length : subfields[i+1].ch; + + this._subfields.push( new EditorSubfield( this, i, subfields[i].ch, end ) ); + } + } + }, + + delete: function() { + this.cm.replaceRange( "", { line: this.line, ch: 0 }, { line: this.line + 1, ch: 0 }, 'marcAware' ); + }, + focus: function() { + this.cm.setCursor( { line: this.line, ch: 0 } ); + + return this; + }, + + getText: function() { + var result = ''; + + $.each( this.getSubfields(), function() { + if ( this.code != '@' ) result += '$' + this.code + ' '; + + result += this.getText(); + } ); + + return result; + }, + setText: function( text ) { + var indicator_match = /^([_ 0-9])([_ 0-9])\$/.exec( text ); + if ( indicator_match ) { + text = text.substr(2); + this.setIndicator1( indicator_match[1] ); + this.setIndicator2( indicator_match[2] ); + } + + this.cm.replaceRange( text, { line: this.line, ch: this.isControlField ? 4 : 8 }, { line: this.line }, 'marcAware' ); + this._invalidateSubfields(); + + return this; + }, + + getIndicator1: function() { + return this._ind1; + }, + getIndicator2: function() { + return this._ind2; + }, + setIndicator1: function(val) { + if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field'); + + this._ind1 = ( !val || val == ' ' ) ? '_' : val; + this.cm.replaceRange( this._ind1, { line: this.line, ch: 4 }, { line: this.line, ch: 5 }, 'marcAware' ); + + return this; + }, + setIndicator2: function(val) { + if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field'); + + this._ind2 = ( !val || val == ' ' ) ? '_' : val; + this.cm.replaceRange( this._ind2, { line: this.line, ch: 6 }, { line: this.line, ch: 7 }, 'marcAware' ); + + return this; + }, + + appendSubfield: function( code ) { + if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field'); + + this._invalidateSubfields(); + this.cm.replaceRange( '$' + code + ' ', { line: this.line }, null, 'marcAware' ); + var subfields = this.getSubfields(); + + return subfields[ subfields.length - 1 ]; + }, + insertSubfield: function( code, position ) { + if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field'); + + position = position || 0; + + var subfields = this.getSubfields(); + this._invalidateSubfields(); + this.cm.replaceRange( '$' + code + ' ', { line: this.line, ch: subfields[position] ? subfields[position].start : null }, null, 'marcAware' ); + subfields = this.getSubfields(); + + return subfields[ position ]; + }, + getSubfields: function( code ) { + if ( !this._subfields ) this._scanSubfields(); + if ( code == null ) return this._subfields; + + var result = []; + + $.each( this._subfields, function() { + if ( code == null || this.code == code ) result.push(this); + } ); + + return result; + }, + getFirstSubfield: function( code ) { + var result = this.getSubfields( code ); + + return ( result && result.length ) ? result[0] : null; + }, + getSubfieldAt: function( ch ) { + var subfields = this.getSubfields(); + + for (var i = 0; i < subfields.length; i++) { + if ( subfields[i].start < ch && subfields[i].end >= ch ) return subfields[i]; + } + }, + } ); + + function MARCEditor( options ) { this.cm = CodeMirror( - position, + options.position, { extraKeys: _editorKeys, gutters: [ @@ -210,149 +466,221 @@ define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], this.cm.on( 'change', editorChange ); this.cm.on( 'cursorActivity', editorCursorActivity ); + this.onCursorActivity = options.onCursorActivity; + this.subscribers = []; this.subscribe( function( marceditor ) { Widget.Notify( marceditor ); } ); } - MARCEditor.prototype.setUseWidgets = function( val ) { - if ( val ) { - for ( var line = 0; line <= this.cm.lastLine(); line++ ) { - Widget.UpdateLine( this, line ); + MARCEditor.FieldError = FieldError; + + $.extend( MARCEditor.prototype, { + setUseWidgets: function( val ) { + if ( val ) { + for ( var line = 0; line <= this.cm.lastLine(); line++ ) { + Widget.UpdateLine( this, line ); + } + } else { + $.each( this.cm.getAllMarks(), function( undef, mark ) { + if ( mark.widget ) mark.widget.clearToText(); + } ); } - } else { + }, + + focus: function() { + this.cm.focus(); + }, + + getCursor: function() { + return this.cm.getCursor(); + }, + + refresh: function() { + this.cm.refresh(); + }, + + displayRecord: function( record ) { + this.cm.setValue( TextMARC.RecordToText(record) ); + }, + + getRecord: function() { + this.textMode = true; + $.each( this.cm.getAllMarks(), function( undef, mark ) { if ( mark.widget ) mark.widget.clearToText(); } ); - } - }; + var record = TextMARC.TextToRecord( this.cm.getValue() ); + for ( var line = 0; line <= this.cm.lastLine(); line++ ) { + if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( this, line ); + } - MARCEditor.prototype.focus = function() { - this.cm.focus(); - }; + this.textMode = false; - MARCEditor.prototype.refresh = function() { - this.cm.refresh(); - }; + return record; + }, - MARCEditor.prototype.displayRecord = function( record ) { - this.cm.setValue( TextMARC.RecordToText(record) ); - }; + getLineInfo: function( pos ) { + var contents = this.cm.getLine( pos.line ); + if ( contents == null ) return {}; - MARCEditor.prototype.getRecord = function() { - this.textMode = true; + var tagNumber = contents.match( /^([A-Za-z0-9]{3})/ ); - $.each( this.cm.getAllMarks(), function( undef, mark ) { - if ( mark.widget ) mark.widget.clearToText(); - } ); - var record = TextMARC.TextToRecord( this.cm.getValue() ); - for ( var line = 0; line <= this.cm.lastLine(); line++ ) { - if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( this, line ); - } + if ( !tagNumber ) return null; // No tag at all on this line + tagNumber = tagNumber[1]; - this.textMode = false; + if ( tagNumber < '010' ) return { tagNumber: tagNumber, contents: contents }; // No current subfield - return record; - }; + var matcher = /[$|ǂ‡]([a-z0-9%]) /g; + var match; - MARCEditor.prototype.getLineInfo = function( pos ) { - var contents = this.cm.getLine( pos.line ); - if ( contents == null ) return {}; + var subfields = []; + var currentSubfield; - var tagNumber = contents.match( /^([A-Za-z0-9]{3}) / ); + while ( ( match = matcher.exec(contents) ) ) { + subfields.push( { code: match[1], ch: match.index } ); + if ( match.index < pos.ch ) currentSubfield = match[1]; + } - if ( !tagNumber ) return null; // No tag at all on this line - tagNumber = tagNumber[1]; + return { tagNumber: tagNumber, subfields: subfields, currentSubfield: currentSubfield, contents: contents }; + }, - if ( tagNumber < '010' ) return { tagNumber: tagNumber, contents: contents }; // No current subfield + addError: function( line, error ) { + var found = false; + var options = {}; - var matcher = /[$|ǂ‡]([a-z0-9%]) /g; - var match; + if ( line == null ) { + line = 0; + options.above = true; + } - var subfields = []; - var currentSubfield; + $.each( this.cm.getLineHandle(line).widgets || [], function( undef, widget ) { + if ( !widget.isErrorMarker ) return; - while ( ( match = matcher.exec(contents) ) ) { - subfields.push( { code: match[1], ch: match.index } ); - if ( match.index < pos.ch ) currentSubfield = match[1]; - } + found = true; - return { tagNumber: tagNumber, subfields: subfields, currentSubfield: currentSubfield, contents: contents }; - }; - - MARCEditor.prototype.addError = function( line, error ) { - var found = false; - var options = {}; + $( widget.node ).append( '; ' + error ); + widget.changed(); - if ( line == null ) { - line = 0; - options.above = true; - } + return false; + } ); - $.each( this.cm.getLineHandle(line).widgets || [], function( undef, widget ) { - if ( !widget.isErrorMarker ) return; + if ( found ) return; - found = true; + var node = $( '
' + error + '
' )[0]; + var widget = this.cm.addLineWidget( line, node, options ); - $( widget.node ).append( '; ' + error ); - widget.changed(); + widget.node = node; + widget.isErrorMarker = true; + }, - return false; - } ); + removeErrors: function() { + for ( var line = 0; line < this.cm.lineCount(); line++ ) { + $.each( this.cm.getLineHandle( line ).widgets || [], function( undef, lineWidget ) { + if ( lineWidget.isErrorMarker ) lineWidget.clear(); + } ); + } + }, - if ( found ) return; + startNotify: function() { + if ( this.notifyTimeout ) clearTimeout( this.notifyTimeout ); + this.notifyTimeout = setTimeout( $.proxy( function() { + this.notifyAll(); - var node = $( '
' + error + '
' )[0]; - var widget = this.cm.addLineWidget( line, node, options ); + this.notifyTimeout = null; + }, this ), NOTIFY_TIMEOUT ); + }, - widget.node = node; - widget.isErrorMarker = true; - }; + notifyAll: function() { + $.each( this.subscribers, $.proxy( function( undef, subscriber ) { + subscriber(this); + }, this ) ); + }, - MARCEditor.prototype.removeErrors = function() { - for ( var line = 0; line < this.cm.lineCount(); line++ ) { - $.each( this.cm.getLineHandle( line ).widgets || [], function( undef, lineWidget ) { - if ( lineWidget.isErrorMarker ) lineWidget.clear(); - } ); - } - }; + subscribe: function( subscriber ) { + this.subscribers.push( subscriber ); + }, - MARCEditor.prototype.getFixedField = function(field) { - field += ' '; - for ( var line = 0; line < this.cm.lineCount(); line++ ) { - var contents = this.cm.getLine(line); - if ( contents.substr( 0, 4 ) != field ) continue; + createField: function( tag, line ) { + var contents = tag + ( tag < '010' ? ' ' : ' _ _ ' ); - var marks = this.cm.findMarksAt( { line: line, ch: 4 } ); - if ( marks[0] && marks[0].widget ) { - return marks[0].widget.text; + if ( line > this.cm.lastLine() ) { + contents = '\n' + contents; } else { - return contents.substr(4); + contents = contents + '\n'; } - } - return null; - }; + this.cm.replaceRange( contents, { line: line, ch: 0 }, null, 'marcAware' ); - MARCEditor.prototype.startNotify = function() { - if ( this.notifyTimeout ) clearTimeout( this.notifyTimeout ); - this.notifyTimeout = setTimeout( $.proxy( function() { - this.notifyAll(); + return new EditorField( this, line ); + }, - this.notifyTimeout = null; - }, this ), NOTIFY_TIMEOUT ); - }; + createFieldOrdered: function( tag ) { + var line, contents; - MARCEditor.prototype.notifyAll = function() { - $.each( this.subscribers, $.proxy( function( undef, subscriber ) { - subscriber(this); - }, this ) ); - }; + for ( line = 0; (contents = this.cm.getLine(line)); line++ ) { + if ( contents && contents.substr(0, 3) > tag ) break; + } - MARCEditor.prototype.subscribe = function( subscriber ) { - this.subscribers.push( subscriber ); - }; + return this.createField( tag, line ); + }, + + createFieldGrouped: function( tag ) { + // Control fields should be inserted in actual order, whereas other fields should be + // inserted grouped + if ( tag < '010' ) return this.createFieldOrdered( tag ); + + var line, contents; + + for ( line = 0; (contents = this.cm.getLine(line)); line++ ) { + if ( contents && contents[0] > tag[0] ) break; + } + + return this.createField( tag, line ); + }, + + getFieldAt: function( line ) { + try { + return new EditorField( this, line ); + } catch (e) { + return null; + } + }, + + getCurrentField: function() { + return this.getFieldAt( this.cm.getCursor().line ); + }, + + getFields: function( tag ) { + var result = []; + + if ( tag != null ) tag += ' '; + + for ( var line = 0; line < this.cm.lineCount(); line++ ) { + if ( tag && this.cm.getLine(line).substr( 0, 4 ) != tag ) continue; + + // If this throws a FieldError, pretend it doesn't exist + try { + result.push( new EditorField( this, line ) ); + } catch (e) { + if ( !( e instanceof FieldError ) ) throw e; + } + } + + return result; + }, + + getFirstField: function( tag ) { + var result = this.getFields( tag ); + + return ( result && result.length ) ? result[0] : null; + }, + + getAllFields: function( tag ) { + return this.getFields( null ); + }, + } ); return MARCEditor; } ); diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-mode.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-mode.js index bfa0174..f9bf862 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-mode.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-mode.js @@ -1,6 +1,20 @@ /** - * Textual MARC mode for CodeMirror. - * Copyright (c) 2013 ByWater + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . */ // Expected format: 245 _ 1 $a Pizza |c 34ars @@ -34,6 +48,7 @@ CodeMirror.defineMode( 'marc', function( config, modeConfig ) { }, token: function( stream, state ) { var match; + // First, try to match some kind of valid tag if ( stream.sol() ) { this.startState( state ); if ( match = stream.match( /[0-9A-Za-z]+/ ) ) { @@ -50,7 +65,7 @@ CodeMirror.defineMode( 'marc', function( config, modeConfig ) { state.tagNumber = match; if ( state.tagNumber < '010' ) { - // Control field + // Control field, no subfields or indicators state.subAllowed = false; } @@ -66,26 +81,29 @@ CodeMirror.defineMode( 'marc', function( config, modeConfig ) { } } + // Don't need to do anything if ( stream.eol() ) { return; } + // Check for the correct space after the tag number for a control field if ( !state.subAllowed && stream.pos == 3 ) { if ( stream.next() == ' ' ) { - return 'reqspace'; + return 'required-space'; } else { stream.skipToEnd(); return 'error'; } } + // For a normal field, check for correct indicators and spacing if ( stream.pos < 8 && state.subAllowed ) { switch ( stream.pos ) { case 3: case 5: case 7: if ( stream.next() == ' ' ) { - return 'reqspace'; + return 'required-space'; } else { stream.skipToEnd(); return 'error'; @@ -101,8 +119,21 @@ CodeMirror.defineMode( 'marc', function( config, modeConfig ) { } } + // Otherwise, we're after the start of the line. if ( state.subAllowed ) { - if ( stream.pos != 8 && stream.match( /[^$|ǂ‡]+/ ) ) return; + // If we don't have to match a subfield, try to consume text. + if ( stream.pos != 8 ) { + // Try to match space at the end of the line, then everything but spaces, and as + // a final fallback, only spaces. + // + // This is required to keep the contents matching from stepping on the end-space + // matching. + if ( stream.match( /[ \t]+$/ ) ) { + return 'end-space'; + } else if ( stream.match( /[^ \t$|ǂ‡]+/ ) || stream.match( /[ \t]+/ ) ) { + return; + } + } if ( stream.eat( /[$|ǂ‡]/ ) ) { var subfieldCode; @@ -122,7 +153,15 @@ CodeMirror.defineMode( 'marc', function( config, modeConfig ) { return 'error'; } } else { - stream.skipToEnd(); + // Match space at end of line + if ( stream.match( /[ \t]+$/ ) ) { + return 'end-space'; + } else { + stream.match( /[ \t]+/ ); + } + + stream.match( /[^ \t]+/ ); + return; } } }; diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-record.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-record.js index 95adf85..f745533 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-record.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-record.js @@ -1,4 +1,23 @@ /** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + +/** * Adapted and cleaned up from biblios.net, which is purportedly under the GPL. * Source: http://git.librarypolice.com/?p=biblios.git;a=blob_plain;f=plugins/marc21editor/marcrecord.js;hb=master * diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/preferences.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/preferences.js index 6e324e6..5cf81da 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/preferences.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/preferences.js @@ -1,3 +1,22 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + define( function() { var Preferences = { Load: function( borrowernumber ) { @@ -14,6 +33,7 @@ define( function() { font: 'monospace', fontSize: '1em', macros: {}, + selected_search_targets: {}, }, saved_prefs ); }, diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/resources.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/resources.js index 24806ce..5be97d6 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/resources.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/resources.js @@ -1,3 +1,22 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + define( [ 'module' ], function( module ) { var _allResources = []; diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/search.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/search.js index e2e248c..341da39 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/search.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/search.js @@ -1,3 +1,22 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + define( [ 'marc-record' ], function( MARC ) { var _options; var _records = {}; @@ -39,6 +58,11 @@ define( [ 'marc-record' ], function( MARC ) { return q; }, Run: function( servers, q, options ) { + options = $.extend( { + offset: 0, + page_size: 20, + }, _options, options ); + Search.includedServers = []; _records = {}; _last = { @@ -47,25 +71,24 @@ define( [ 'marc-record' ], function( MARC ) { options: options, }; - options = $.extend( { - offset: 0, - page_size: 20, - }, _options, options ); - $.each( servers, function ( id, info ) { if ( info.checked ) Search.includedServers.push( id ); } ); $.get( - '/cgi-bin/koha/svc/z3950', + '/cgi-bin/koha/svc/cataloguing/metasearch', { q: q, servers: Search.includedServers.join( ',' ), offset: options.offset, - page_size: options.page_size + page_size: options.page_size, + sort_direction: options.sort_direction, + sort_key: options.sort_key, + resultset: options.resultset, } ) .done( function( data ) { + _last.options.resultset = data.resultset; $.each( data.hits, function( undef, hit ) { var record = new MARC.Record(); record.loadMARCXML( hit.record ); @@ -80,9 +103,10 @@ define( [ 'marc-record' ], function( MARC ) { return true; }, - Fetch: function( offset ) { + Fetch: function( options ) { if ( !_last ) return; - Search.Run( _last.servers, _last.q, $.extend( {}, _last.options, { offset: offset } ) ); + $.extend( _last.options, options ); + Search.Run( _last.servers, _last.q, _last.options ); } }; diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/text-marc.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/text-marc.js index f522f2c..ba471e6 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/text-marc.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/text-marc.js @@ -1,3 +1,22 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + define( [ 'marc-record' ], function( MARC ) { // Convert any characters for display function _sanitize( text ) { diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/widget.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/widget.js index 5e7bb48..188a027 100644 --- a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/widget.js +++ b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/widget.js @@ -1,3 +1,22 @@ +/** + * Copyright 2015 ByWater Solutions + * + * This file is part of Koha. + * + * Koha is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * Koha is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Koha; if not, see . + */ + define( [ 'resources' ], function( Resources ) { var _widgets = {}; @@ -233,6 +252,9 @@ define( [ 'resources' ], function( Resources ) { var next = ( i < info.subfields.length - 1 ) ? info.subfields[i + 1].ch : end; subfields.push( { code: info.subfields[i].code, from: info.subfields[i].ch + 3, to: next } ); } + // If not a fixed field, and we didn't find any subfields, we need to throw in the + // '@' subfield so we can properly remove it + if ( subfields.length == 0 ) subfields.push( { code: '@', from: 4, to: end } ); } $.each( subfields, function ( undef, subfield ) { diff --git a/koha-tmpl/intranet-tmpl/prog/en/css/cateditor.css b/koha-tmpl/intranet-tmpl/prog/en/css/cateditor.css index ad9f9cb..04258a5 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/css/cateditor.css +++ b/koha-tmpl/intranet-tmpl/prog/en/css/cateditor.css @@ -90,6 +90,10 @@ body { font-weight: bold; } +.cm-end-space { + background-color: #DDDDBB; +} + #editor .modified-line-gutter { width: 10px; } @@ -202,7 +206,7 @@ body { } .modal-body { - height: 400px; + max-height: none; padding: 0; } @@ -237,7 +241,7 @@ body { #quicksearch input, #quicksearch a { font-size: 1.2em; padding: 3px 0; - width: 96%; // I have no idea why this is necessary + width: 96%; /* I have no idea why this is necessary */ } #show-advanced-search { @@ -246,12 +250,15 @@ body { } #advanced-search-fields { + -moz-column-width: 26em; + -webkit-column-width: 26em; + column-width: 26em; margin: 0; padding: 0; } #advanced-search-fields li { - display: inline-block; + display: block; list-style-type: none; } @@ -280,6 +287,14 @@ body { /*> Search results */ +#search-serversinfo li { + list-style-type: none; +} + +#search-serversinfo .search-toggle-server { + margin-right: 5px; +} + #searchresults table { width: 100%; } @@ -400,3 +415,20 @@ body { #macro-list .active .macro-info { display: block; } + +.btn-toolbar label, .btn-toolbar select { + font-size: 13px; + vertical-align: middle; +} + +.btn-toolbar label { + margin-left: 1em; +} + +.btn-toolbar select { + padding: 2px; +} + +#macro-editor .CodeMirror { + height: 100%; +} 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 673aa2f..fcc8136 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc @@ -10,6 +10,7 @@ require.config( { themelang: '[% themelang %]', }, }, + waitSeconds: 30, } ); @@ -22,19 +23,32 @@ require.config( { [% IF ( bidi ) %] @@ -29,6 +30,7 @@
+
@@ -190,10 +201,9 @@
- -
Loading...
+
Loading...
@@ -209,8 +219,9 @@
    - + +
    diff --git a/svc/bib b/svc/bib index 40c0792..a878e30 100755 --- a/svc/bib +++ b/svc/bib @@ -86,7 +86,7 @@ sub update_bib { my $result = {}; my $inxml = $query->param('POSTDATA'); - print $query->header(-type => 'text/xml'); + print $query->header(-type => 'text/xml', -charset => 'utf-8'); my $record = eval {MARC::Record::new_from_xml( $inxml, "utf8", C4::Context->preference('marcflavour'))}; my $do_not_escape = 0; diff --git a/svc/cataloguing/framework b/svc/cataloguing/framework index 39b1719..49d341d 100755 --- a/svc/cataloguing/framework +++ b/svc/cataloguing/framework @@ -8,6 +8,7 @@ use C4::ClassSource; use C4::Context; use C4::Biblio; use C4::Service; +use Koha::Database; my ( $query, $response ) = C4::Service->init( editcatalogue => 'edit_catalogue' ); @@ -25,7 +26,7 @@ foreach my $tag ( sort keys %$tagslib ) { push @tags, [ $tag, $taginfo ]; } -my $dbh = C4::Context->dbh; +my $schema = Koha::Database->new->schema; my $authorised_values = {}; $authorised_values->{branches} = []; @@ -38,9 +39,11 @@ foreach my $thisbranch ( sort keys %$branches ) { push @{ $authorised_values->{branches} }, { value => $thisbranch, lib => $branches->{$thisbranch}->{'branchname'} }; } -$authorised_values->{itemtypes} = $dbh->selectall_arrayref( q{ - SELECT itemtype AS value, description AS lib FROM itemtypes ORDER BY description -}, { Slice => {} } ); +$authorised_values->{itemtypes} = [ $schema->resultset( "Itemtype" )->search( undef, { + columns => [ { value => 'itemtype' }, { lib => "description" } ], + order_by => "description", + result_class => 'DBIx::Class::ResultClass::HashRefInflator' +} ) ]; my $class_sources = GetClassSources(); @@ -53,19 +56,20 @@ foreach my $class_source (sort keys %$class_sources) { } my $branch_limit = C4::Context->userenv ? C4::Context->userenv->{"branch"} : ""; -my $auth_query = "SELECT category, authorised_value, lib - FROM authorised_values"; -$auth_query .= qq{ LEFT JOIN authorised_values_branches ON ( id = av_id )} if $branch_limit; -$auth_query .= " AND ( branchcode = ? OR branchcode IS NULL )" if $branch_limit; -$auth_query .= " GROUP BY lib ORDER BY lib, lib_opac"; -my $authorised_values_sth = $dbh->prepare( $auth_query ); -$authorised_values_sth->execute( - $branch_limit ? $branch_limit : (), -); - -while ( my ( $category, $value, $lib ) = $authorised_values_sth->fetchrow_array ) { - $authorised_values->{$category} ||= []; - push @{ $authorised_values->{$category} }, { value => $lib, lib => $lib }; +my $results; +if( $branch_limit ) { + $results = $schema->resultset( "AuthorisedValue" )->search( + { "authorised_values_branches.branchcode" => { "=", [ $branch_limit, undef ] } }, + { join => "authorised_values_branches", order_by => "lib" } ); +} else { + $results = $schema->resultset( "AuthorisedValue" )->search( + undef, + { order_by => "lib" } ); +} + +foreach my $result ( $results->all ) { + $authorised_values->{$result->category} ||= []; + push @{ $authorised_values->{$result->category} }, { value => $result->authorised_value, lib => $result->lib }; } $response->param( framework => \@tags, authorised_values => $authorised_values ); diff --git a/svc/cataloguing/metasearch b/svc/cataloguing/metasearch new file mode 100755 index 0000000..110732f --- /dev/null +++ b/svc/cataloguing/metasearch @@ -0,0 +1,76 @@ +#!/usr/bin/perl +# +# Copyright 2014 ByWater Solutions +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use C4::Service; +use Encode qw( encode_utf8 ); +use Koha::MetaSearcher; + +my ( $query, $response ) = C4::Service->init( catalogue => 1 ); + +my ( $query_string, $servers ) = C4::Service->require_params( 'q', 'servers' ); + +my $server_errors = {}; + +my $sort_key = $query->param( 'sort_key' ) || 'title'; +my $sort_direction = $query->param( 'sort_direction' ) || 'asc'; +my $offset = $query->param( 'offset' ) || 0; +my $page_size = $query->param( 'page_size' ) || 20; +my $fetched = $query->param( 'fetched' ) || 100; + +my $searcher = Koha::MetaSearcher->new( { + fetched => $fetched, + on_error => sub { + my ( $server, $exception ) = @_; + + $server_errors->{ $server->{id} } = $exception->message; + }, +} ); + +$searcher->resultset( $query->param('resultset') ) if ( $query->param('resultset') ); + +my @server_ids = split( /,/, $servers ); +my $stats = $searcher->search( \@server_ids, $query_string ); + +$searcher->sort( $sort_key, $sort_direction eq 'desc' ? -1 : 1 ); + +my @hits; + +foreach my $hit ( $searcher->results( $offset, $page_size ) ) { + push @hits, { + server => $hit->{server}->{id}, + index => $hit->{index}, + record => $hit->{record}->as_xml_record(), + metadata => $hit->{metadata} + }; +} + +$response->param( + resultset => $searcher->resultset, + sort_key => $sort_key, + sort_direction => $sort_direction, + offset => $offset, + page_size => $page_size, + errors => $server_errors, + hits => \@hits, + %$stats +); + +C4::Service->return_success( $response ); diff --git a/svc/z3950 b/svc/z3950 deleted file mode 100755 index e0c07e3..0000000 --- a/svc/z3950 +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/perl -# -# Copyright 2014 ByWater Solutions - -# -# This file is part of Koha. -# -# Koha is free software; you can redistribute it and/or modify it under the -# terms of the GNU General Public License as published by the Free Software -# Foundation; either version 3 of the License, or (at your option) any later -# version. -# -# Koha is distributed in the hope that it will be useful, but WITHOUT ANY -# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -# A PARTICULAR PURPOSE. See the GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with Koha; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -use Modern::Perl; - -use C4::Breeding qw( RunZ3950 ); -use C4::Service; -use Encode qw( encode_utf8 ); - -use sort 'stable'; - -my ( $query, $response ) = C4::Service->init( catalogue => 1 ); - -my ( $query_string, $servers ) = C4::Service->require_params( 'q', 'servers' ); - -my $server_hits = {}; -my $server_errors = {}; - -my $offset = $query->param( 'offset' ) || 0; -my $page_size = $query->param( 'page_size' ) || 20; -my $fetched = $query->param( 'fetched' ) || 100; -my $empty_flip = -1; # Determines the flip of ordering for records with empty sort keys. - -my @server_ids = split( /,/, $servers ); - -my $stats = RunZ3950( \@server_ids, $query_string, { - on_error => sub { - my ( $server, $exception ) = @_; - - $server_errors->{ $server->{id} } = $exception->message; - }, - on_hit => sub { - my ( $server, $hit ) = @_; - - push @{ $server_hits->{ $server->{id} } }, { server => $server->{id}, - index => $hit->{index}, - record => encode_utf8( $hit->{record}->as_xml_record() ), - metadata => $hit->{metadata} - }; - }, - - offset => 0, - fetch => $fetched, -} ); - -my @hits; - -foreach my $id ( @server_ids ) { - warn scalar @{ $server_hits->{$id} ||= [] } . ' hits from ' . $id; -} - -# Interleave hits; should be replaced by actual relevance ranking at some point -foreach my $i ( 0..$fetched ) { - foreach my $id ( @server_ids ) { - my $hit = shift @{ $server_hits->{ $id } }; - next unless ( $hit ); - - ( $hit->{sort_key} = $hit->{metadata}->{title} || '' ) =~ s/\W//g; - push @hits, $hit; - } -} - -@hits = sort { - # Sort empty records at the end - return -$empty_flip unless $a->{sort_key}; - return $empty_flip unless $b->{sort_key}; - - $a->{sort_key} cmp $b->{sort_key}; -} @hits; - -my @hits_subset; - -foreach my $i ( $offset..( $offset + $page_size - 1 ) ) { - push @hits_subset, $hits[$i] if $hits[$i]; -} - -$response->param( - offset => $offset, - page_size => $page_size, - errors => $server_errors, - hits => \@hits_subset, - %$stats -); - -C4::Service->return_success( $response ); -- 2.1.0