Bug 11559: Rancor: advanced cataloging interface
authorJesse Weaver <pianohacker@gmail.com>
Mon, 15 Jun 2015 21:09:27 +0000 (17:09 -0400)
committerTomas Cohen Arazi <tomascohen@theke.io>
Tue, 27 Oct 2015 15:17:39 +0000 (12:17 -0300)
Full test plan is posted on bug. Test plan for system preference:

  1. Apply patch, clear cookies.
  2. Go to "Cataloging."
  3. Add new record, verify that basic editor is used.
  4. Navigate to existing record, click on "Edit record", verify that
     basic editor is used.
  5. Inside basic editor, verify that no button appears to switch to the
     advanced editor.
  6. Enable the "EnableAdvancedCatalogingEditor" syspref.
  7. Repeat above steps, should still go to basic editor, but button
     should appear to switch to the advanced editor; click it.
  8. Now, adding new records and editing existing records should go to
     the advanced editor.

Signed-off-by: Nick Clemens <nick@quecheelibrary.org>
Signed-off-by: Katrin Fischer <katrin.fischer.83@web.de>
Signed-off-by: Tomas Cohen Arazi <tomascohen@theke.io>
29 files changed:
Koha/MetaSearcher.pm [new file with mode: 0644]
Koha/MetadataRecord.pm
cataloguing/addbiblio.pl
cataloguing/editor.pl [new file with mode: 0755]
installer/data/mysql/atomicupdate/bug_11559-add_EnableAdvancedCatalogingEditor_syspref.sql [new file with mode: 0644]
installer/data/mysql/sysprefs.sql
koha-tmpl/intranet-tmpl/lib/koha/cateditor/koha-backend.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/its.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros/rancor.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-editor.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-mode.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/marc-record.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/preferences.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/resources.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/search.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/text-marc.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/lib/koha/cateditor/widget.js [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/css/cateditor.css [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/css/staff-global.css
koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-widgets-marc21.inc [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/includes/prefs-menu.inc
koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/labs.pref [new file with mode: 0644]
koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbiblio.tt
koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/addbooks.tt
koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/editor.tt [new file with mode: 0644]
svc/cataloguing/framework [new file with mode: 0755]
svc/cataloguing/metasearch [new file with mode: 0755]

diff --git a/Koha/MetaSearcher.pm b/Koha/MetaSearcher.pm
new file mode 100644 (file)
index 0000000..7c4c87a
--- /dev/null
@@ -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 <http://www.gnu.org/licenses>.
+
+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;
index 8b0d8a4..1577cd0 100644 (file)
@@ -117,4 +117,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;
index 890db0f..8cf3b6a 100755 (executable)
@@ -746,8 +746,14 @@ if ($frameworkcode eq 'FA'){
         'stickyduedate'      => $fa_stickyduedate,
         'duedatespec'        => $fa_duedatespec,
     );
+} elsif ( C4::Context->preference('EnableAdvancedCatalogingEditor') && $input->cookie( 'catalogue_editor_' . $loggedinuser ) eq 'advanced' && !$breedingid ) {
+    # Only use the advanced editor for non-fast-cataloging.
+    # breedingid is not handled because those would only come off a Z39.50
+    # search initiated by the basic editor.
+    print $input->redirect( '/cgi-bin/koha/cataloguing/editor.pl' . ( $biblionumber ? ( '#catalog/' . $biblionumber ) : '' ) );
 }
 
+
 # Getting the list of all frameworks
 # get framework list
 my $frameworks = getframeworks;
diff --git a/cataloguing/editor.pl b/cataloguing/editor.pl
new file mode 100755 (executable)
index 0000000..6d0fd0d
--- /dev/null
@@ -0,0 +1,67 @@
+#!/usr/bin/perl
+#
+# Copyright 2013 ByWater
+#
+# 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 2 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 '2009';
+
+use CGI;
+use MARC::Record;
+
+use C4::Auth;
+use C4::Biblio;
+use C4::Context;
+use C4::Output;
+use DBIx::Class::ResultClass::HashRefInflator;
+use Koha::Database;
+
+my $input = CGI->new;
+
+my ( $template, $loggedinuser, $cookie ) = get_template_and_user(
+    {
+        template_name   => 'cataloguing/editor.tt',
+        query           => $input,
+        type            => 'intranet',
+        authnotrequired => 0,
+        flagsrequired   => { editcatalogue => 'edit_catalogue' },
+    }
+);
+
+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' );
+
+# Z39.50 servers
+my $dbh = C4::Context->dbh;
+$template->{VARS}->{z3950_servers} = $dbh->selectall_arrayref( q{
+    SELECT * FROM z3950servers
+    WHERE recordtype != 'authority' AND servertype = 'zed'
+    ORDER BY servername
+}, { Slice => {} } );
+
+output_html_with_http_headers $input, $cookie, $template->output;
diff --git a/installer/data/mysql/atomicupdate/bug_11559-add_EnableAdvancedCatalogingEditor_syspref.sql b/installer/data/mysql/atomicupdate/bug_11559-add_EnableAdvancedCatalogingEditor_syspref.sql
new file mode 100644 (file)
index 0000000..c7f0fac
--- /dev/null
@@ -0,0 +1 @@
+INSERT IGNORE INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `type` ) VALUES ('EnableAdvancedCatalogingEditor','0','','Enable the Rancor advanced cataloging editor','YesNo');
index 578ab79..b7d3302 100644 (file)
@@ -123,6 +123,7 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `
 ('DumpTemplateVarsOpac',  '0', NULL ,  'If enabled, dump all Template Toolkit variable to a comment in the html source for the opac.',  'YesNo'),
 ('EasyAnalyticalRecords','0','','If on, display in the catalogue screens tools to easily setup analytical record relationships','YesNo'),
 ('emailLibrarianWhenHoldIsPlaced','0',NULL,'If ON, emails the librarian whenever a hold is placed','YesNo'),
+('EnableAdvancedCatalogingEditor','0','','Enable the Rancor advanced cataloging editor','YesNo'),
 ('EnableBorrowerFiles','0',NULL,'If enabled, allows librarians to upload and attach arbitrary files to a borrower record.','YesNo'),
 ('EnableOpacSearchHistory','1','YesNo','Enable or disable opac search history',''),
 ('EnableSearchHistory','0','','Enable or disable search history','YesNo'),
diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/koha-backend.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/koha-backend.js
new file mode 100644 (file)
index 0000000..7827f60
--- /dev/null
@@ -0,0 +1,220 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+define( [ '/cgi-bin/koha/svc/cataloguing/framework?frameworkcode=&callback=define', 'marc-record' ], function( defaultFramework, MARC ) {
+    var _authorised_values = defaultFramework.authorised_values;
+    var _frameworks = {};
+    var _framework_mappings = {};
+
+    function _fromXMLStruct( data ) {
+        result = {};
+
+        $(data).children().eq(0).children().each( function() {
+            var $contents = $(this).contents();
+            if ( $contents.length == 1 && $contents[0].nodeType == Node.TEXT_NODE ) {
+                result[ this.localName ] = $contents[0].data;
+            } else {
+                result[ this.localName ] = $contents.filter( function() { return this.nodeType != Node.TEXT_NODE || !this.data.match( /^\s+$/ ) } ).toArray();
+            }
+        } );
+
+        return result;
+    }
+
+    function _importFramework( frameworkcode, frameworkinfo ) {
+        _frameworks[frameworkcode] = frameworkinfo;
+        _framework_mappings[frameworkcode] = {};
+
+        $.each( frameworkinfo, function( i, tag ) {
+            var tagnum = tag[0], taginfo = tag[1];
+
+            var subfields = {};
+
+            $.each( taginfo.subfields, function( i, subfield ) {
+                subfields[ subfield[0] ] = subfield[1];
+            } );
+
+            _framework_mappings[frameworkcode][tagnum] = $.extend( {}, taginfo, { subfields: subfields } );
+        } );
+    }
+
+    _importFramework( '', defaultFramework.framework );
+
+    var KohaBackend = {
+        NOT_EMPTY: {}, // Sentinel value
+
+        GetAllTagsInfo: function( frameworkcode, tagnumber ) {
+            return _framework_mappings[frameworkcode];
+        },
+
+        GetAuthorisedValues: function( category ) {
+            return _authorised_values[category];
+        },
+
+        GetTagInfo: function( frameworkcode, tagnumber ) {
+            if ( !_framework_mappings[frameworkcode] ) return undefined;
+            return _framework_mappings[frameworkcode][tagnumber];
+        },
+
+        GetRecord: function( id, callback ) {
+            $.get(
+                '/cgi-bin/koha/svc/bib/' + id
+            ).done( function( data ) {
+                var record = new MARC.Record();
+                record.loadMARCXML(data);
+                callback(record);
+            } ).fail( function( data ) {
+                callback( { error: data } );
+            } );
+        },
+
+        CreateRecord: function( record, callback ) {
+            $.ajax( {
+                type: 'POST',
+                url: '/cgi-bin/koha/svc/new_bib',
+                data: record.toXML(),
+                contentType: 'text/xml'
+            } ).done( function( data ) {
+                callback( _fromXMLStruct( data ) );
+            } ).fail( function( data ) {
+                callback( { error: data } );
+            } );
+        },
+
+        SaveRecord: function( id, record, callback ) {
+            $.ajax( {
+                type: 'POST',
+                url: '/cgi-bin/koha/svc/bib/' + id,
+                data: record.toXML(),
+                contentType: 'text/xml'
+            } ).done( function( data ) {
+                callback( _fromXMLStruct( data ) );
+            } ).fail( function( data ) {
+                callback( { data: { error: data } } );
+            } );
+        },
+
+        GetTagsBy: function( frameworkcode, field, value ) {
+            var result = {};
+
+            $.each( _frameworks[frameworkcode], function( undef, tag ) {
+                var tagnum = tag[0], taginfo = tag[1];
+
+                if ( taginfo[field] == value ) result[tagnum] = true;
+            } );
+
+            return result;
+        },
+
+        GetSubfieldsBy: function( frameworkcode, field, value ) {
+            var result = {};
+
+            $.each( _frameworks[frameworkcode], function( undef, tag ) {
+                var tagnum = tag[0], taginfo = tag[1];
+
+                $.each( taginfo.subfields, function( undef, subfield ) {
+                    var subfieldcode = subfield[0], subfieldinfo = subfield[1];
+
+                    if ( subfieldinfo[field] == value ) {
+                        if ( !result[tagnum] ) result[tagnum] = {};
+
+                        result[tagnum][subfieldcode] = true;
+                    }
+                } );
+            } );
+
+            return result;
+        },
+
+        FillRecord: function( frameworkcode, record, allTags ) {
+            $.each( _frameworks[frameworkcode], function( undef, tag ) {
+                var tagnum = tag[0], taginfo = tag[1];
+
+                if ( taginfo.mandatory != "1" && !allTags ) return;
+
+                var fields = record.fields(tagnum);
+
+                if ( fields.length == 0 ) {
+                    var newField = new MARC.Field( tagnum, ' ', ' ', [] );
+                    fields.push( newField );
+                    record.addFieldGrouped( newField );
+
+                    if ( tagnum < '010' ) {
+                        newField.addSubfield( [ '@', '' ] );
+                        return;
+                    }
+                }
+
+                $.each( taginfo.subfields, function( undef, subfield ) {
+                    var subfieldcode = subfield[0], subfieldinfo = subfield[1];
+
+                    if ( subfieldinfo.mandatory != "1" && !allTags ) return;
+
+                    $.each( fields, function( undef, field ) {
+                        if ( !field.hasSubfield(subfieldcode) ) field.addSubfieldGrouped( [ subfieldcode, '' ] );
+                    } );
+                } );
+            } );
+        },
+
+        ValidateRecord: function( frameworkcode, record ) {
+            var errors = [];
+
+            var mandatoryTags = KohaBackend.GetTagsBy( '', 'mandatory', '1' );
+            var mandatorySubfields = KohaBackend.GetSubfieldsBy( '', 'mandatory', '1' );
+            var nonRepeatableTags = KohaBackend.GetTagsBy( '', 'repeatable', '0' );
+            var nonRepeatableSubfields = KohaBackend.GetSubfieldsBy( '', 'repeatable', '0' );
+
+            $.each( mandatoryTags, function( tag ) {
+                if ( !record.hasField( tag ) ) errors.push( { type: 'missingTag', tag: tag } );
+            } );
+
+            var seenTags = {};
+
+            $.each( record.fields(), function( undef, field ) {
+                if ( seenTags[ field.tagnumber() ] && nonRepeatableTags[ field.tagnumber() ] ) {
+                    errors.push( { type: 'unrepeatableTag', line: field.sourceLine, tag: field.tagnumber() } );
+                    return;
+                }
+
+                seenTags[ field.tagnumber() ] = true;
+
+                var seenSubfields = {};
+
+                $.each( field.subfields(), function( undef, subfield ) {
+                    if ( seenSubfields[ subfield[0] ] != null && nonRepeatableSubfields[ field.tagnumber() ][ subfield[0] ] ) {
+                        errors.push( { type: 'unrepeatableSubfield', subfield: subfield[0], line: field.sourceLine } );
+                    } else {
+                        seenSubfields[ subfield[0] ] = subfield[1];
+                    }
+                } );
+
+                $.each( mandatorySubfields[ field.tagnumber() ] || {}, function( subfield ) {
+                    if ( !seenSubfields[ subfield ] ) {
+                        errors.push( { type: 'missingSubfield', subfield: subfield[0], line: field.sourceLine } );
+                    }
+                } );
+            } );
+
+            return errors;
+        },
+    };
+
+    return KohaBackend;
+} );
diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/macros.js
new file mode 100644 (file)
index 0000000..4d747f9
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+define( [ 'macros/its', 'macros/rancor' ], function( ITSMacro, RancorMacro ) {
+    var Macros = {
+        formats: {
+            its: {
+                description: 'TLC® ITS',
+                Run: ITSMacro.Run,
+            },
+            rancor: {
+                description: 'Rancor',
+                Run: RancorMacro.Run,
+            },
+        },
+        Run: function( editor, format, macro ) {
+            return Macros.formats[format].Run( editor, macro );
+        },
+    };
+
+    return Macros;
+} );
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 (file)
index 0000000..07bfbeb
--- /dev/null
@@ -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 <http://www.gnu.org/licenses>.
+ */
+
+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 (file)
index 0000000..e9b484e
--- /dev/null
@@ -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 <http://www.gnu.org/licenses>.
+ */
+
+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
new file mode 100644 (file)
index 0000000..bc79eb6
--- /dev/null
@@ -0,0 +1,671 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+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;
+        var field = editor.getCurrentField();
+        if ( !field ) return;
+
+        // Set overwrite mode for tag numbers/indicators and contents of fixed fields
+        if ( field.isControlField || cm.getCursor().ch < 8 ) {
+            cm.toggleOverwrite(true);
+        } else {
+            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' || change.origin == 'widget.clearToText' ) return;
+
+        // FIXME: Should only cancel changes if this is a control field/subfield widget
+        if ( change.from.line !== change.to.line || Math.abs( change.from.ch - change.to.ch ) > 1 || change.text.length != 1 || change.text[0].length != 0 ) return; // Not single-char change
+
+        if ( change.from.ch == change.to.ch - 1 && cm.findMarksAt( { line: change.from.line, ch: change.from.ch + 1 } ).length ) {
+            change.cancel();
+        } else if ( change.from.ch == change.to.ch && cm.findMarksAt(change.from).length && !change.text[0].match(/^[$|ǂ‡]$/) ) {
+            change.cancel();
+        }
+    }
+
+    function editorChanges( cm, changes ) {
+        var editor = cm.marceditor;
+        if ( editor.textMode ) return;
+
+        for (var i = 0; i < changes.length; i++) {
+            var change = changes[i];
+
+            var origin = change.from.line;
+            var newTo = CodeMirror.changeEnd(change);
+
+            for (var delLine = origin; delLine <= change.to.line; delLine++) {
+                // Line deleted; currently nothing to do
+            }
+
+            for (var line = origin; line <= newTo.line; line++) {
+                if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( cm.marceditor, line );
+                if ( change.origin != 'setValue' && change.origin != 'marcWidgetPrefill' && change.origin != 'widget.clearToText' ) cm.addLineClass( line, 'wrapper', 'modified-line' );
+            }
+        }
+
+        Widget.ActivateAt( cm, cm.getCursor() );
+        cm.marceditor.startNotify();
+    }
+
+    // Editor helper functions
+    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 ) {
+        cur = cur || editor.cm.getCursor();
+        var field = editor.getFieldAt( cur.line );
+
+        if ( field ) {
+            if ( field.isControlField ) {
+                var positions = [ { ch: 0 }, { ch: 4 } ];
+
+                $.each( positions, function( undef, pos ) {
+                    pos.line = cur.line;
+                } );
+
+                return positions;
+            } else {
+                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 [];
+        }
+    }
+
+    var _editorKeys = {
+        Enter: function( cm ) {
+            var cursor = cm.getCursor();
+            cm.replaceRange( '\n', { line: cursor.line }, null, 'marcAware' );
+            cm.setCursor( { line: cursor.line + 1, ch: 0 } );
+        },
+
+        'Ctrl-X': function( cm ) {
+            // Delete line (or cut)
+            if ( cm.somethingSelected() ) return true;
+
+            var field = cm.marceditor.getCurrentField();
+            if ( field ) field.delete();
+        },
+
+        'Shift-Ctrl-X': function( cm ) {
+            // Delete subfield
+            var field = cm.marceditor.getCurrentField();
+            if ( !field ) return;
+
+            var subfield = field.getSubfieldAt( cm.getCursor().ch );
+            if ( subfield ) subfield.delete();
+        },
+
+        Tab: function( cm ) {
+            // Move through parts of tag/fixed fields
+            var positions = getTabPositions( cm.marceditor );
+            var cur = cm.getCursor();
+
+            for ( var i = 0; i < positions.length; i++ ) {
+                if ( positions[i].ch > cur.ch ) {
+                    activateTabPosition( cm, positions[i] );
+                    return false;
+                }
+            }
+
+            cm.setCursor( { line: cur.line + 1, ch: 0 } );
+        },
+
+        'Shift-Tab': function( cm ) {
+            // Move backwards through parts of tag/fixed fields
+            var positions = getTabPositions( cm.marceditor );
+            var cur = cm.getCursor();
+
+            for ( var i = positions.length - 1; i >= 0; i-- ) {
+                if ( positions[i].ch < cur.ch ) {
+                    activateTabPosition( cm, positions[i] );
+                    return false;
+                }
+            }
+
+            if ( cur.line == 0 ) return;
+
+            var prevPositions = getTabPositions( cm.marceditor, { line: cur.line - 1, ch: cm.getLine( cur.line - 1 ).length } );
+
+            if ( prevPositions.length ) {
+                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 + '")';
+    };
+
+    // 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(
+            options.position,
+            {
+                extraKeys: _editorKeys,
+                gutters: [
+                    'modified-line-gutter',
+                ],
+                lineWrapping: true,
+                mode: {
+                    name: 'marc',
+                    nonRepeatableTags: KohaBackend.GetTagsBy( '', 'repeatable', '0' ),
+                    nonRepeatableSubfields: KohaBackend.GetSubfieldsBy( '', 'repeatable', '0' )
+                }
+            }
+        );
+        this.cm.marceditor = this;
+
+        this.cm.on( 'beforeChange', editorBeforeChange );
+        this.cm.on( 'changes', editorChanges );
+        this.cm.on( 'cursorActivity', editorCursorActivity );
+
+        this.onCursorActivity = options.onCursorActivity;
+
+        this.subscribers = [];
+        this.subscribe( function( marceditor ) {
+            Widget.Notify( marceditor );
+        } );
+    }
+
+    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();
+                } );
+            }
+        },
+
+        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 );
+            }
+
+            this.textMode = false;
+
+            return record;
+        },
+
+        getLineInfo: function( pos ) {
+            var contents = this.cm.getLine( pos.line );
+            if ( contents == null ) return {};
+
+            var tagNumber = contents.match( /^([A-Za-z0-9]{3})/ );
+
+            if ( !tagNumber ) return null; // No tag at all on this line
+            tagNumber = tagNumber[1];
+
+            if ( tagNumber < '010' ) return { tagNumber: tagNumber, contents: contents }; // No current subfield
+
+            var matcher = /[$|ǂ‡]([a-z0-9%]) /g;
+            var match;
+
+            var subfields = [];
+            var currentSubfield;
+
+            while ( ( match = matcher.exec(contents) ) ) {
+                subfields.push( { code: match[1], ch: match.index } );
+                if ( match.index < pos.ch ) currentSubfield = match[1];
+            }
+
+            return { tagNumber: tagNumber, subfields: subfields, currentSubfield: currentSubfield, contents: contents };
+        },
+
+        addError: function( line, error ) {
+            var found = false;
+            var options = {};
+
+            if ( line == null ) {
+                line = 0;
+                options.above = true;
+            }
+
+            $.each( this.cm.getLineHandle(line).widgets || [], function( undef, widget ) {
+                if ( !widget.isErrorMarker ) return;
+
+                found = true;
+
+                $( widget.node ).append( '; ' + error );
+                widget.changed();
+
+                return false;
+            } );
+
+            if ( found ) return;
+
+            var node = $( '<div class="structure-error"><i class="icon-remove"></i> ' + error + '</div>' )[0];
+            var widget = this.cm.addLineWidget( line, node, options );
+
+            widget.node = node;
+            widget.isErrorMarker = true;
+        },
+
+        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();
+                } );
+            }
+        },
+
+        startNotify: function() {
+            if ( this.notifyTimeout ) clearTimeout( this.notifyTimeout );
+            this.notifyTimeout = setTimeout( $.proxy( function() {
+                this.notifyAll();
+
+                this.notifyTimeout = null;
+            }, this ), NOTIFY_TIMEOUT );
+        },
+
+        notifyAll: function() {
+            $.each( this.subscribers, $.proxy( function( undef, subscriber ) {
+                subscriber(this);
+            }, this ) );
+        },
+
+        subscribe: function( subscriber ) {
+            this.subscribers.push( subscriber );
+        },
+
+        createField: function( tag, line ) {
+            var contents = tag + ( tag < '010' ? ' ' : ' _ _ ' );
+
+            if ( line > this.cm.lastLine() ) {
+                contents = '\n' + contents;
+            } else {
+                contents = contents + '\n';
+            }
+
+            this.cm.replaceRange( contents, { line: line, ch: 0 }, null, 'marcAware' );
+
+            return new EditorField( this, line );
+        },
+
+        createFieldOrdered: function( tag ) {
+            var line, contents;
+
+            for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
+                if ( contents && contents.substr(0, 3) > tag ) break;
+            }
+
+            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
new file mode 100644 (file)
index 0000000..f9bf862
--- /dev/null
@@ -0,0 +1,168 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+// Expected format: 245 _ 1 $a Pizza |c 34ars
+
+CodeMirror.defineMode( 'marc', function( config, modeConfig ) {
+    modeConfig.nonRepeatableTags = modeConfig.nonRepeatableTags || {};
+    modeConfig.nonRepeatableSubfields = modeConfig.nonRepeatableSubfields || {};
+
+    return {
+        startState: function( prevState ) {
+            var state = prevState || {};
+
+            if ( !prevState ) {
+                state.seenTags = {};
+            }
+
+            state.indicatorNeeded = false;
+            state.subAllowed = true;
+            state.subfieldCode = undefined;
+            state.tagNumber = undefined;
+            state.seenSubfields = {};
+
+            return state;
+        },
+        copyState: function( prevState ) {
+            var result = $.extend( {}, prevState );
+            result.seenTags = $.extend( {}, prevState.seenTags );
+            result.seenSubfields = $.extend( {}, prevState.seenSubfields );
+
+            return result;
+        },
+        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]+/ ) ) {
+                    match = match[0];
+                    if ( match.length != 3 ) {
+                        if ( stream.eol() && match.length < 3 ) {
+                            // Don't show error for incomplete number
+                            return 'tagnumber';
+                        } else {
+                            stream.skipToEnd();
+                            return 'error';
+                        }
+                    }
+
+                    state.tagNumber = match;
+                    if ( state.tagNumber < '010' ) {
+                        // Control field, no subfields or indicators
+                        state.subAllowed = false;
+                    }
+
+                    if ( state.seenTags[state.tagNumber] && modeConfig.nonRepeatableTags[state.tagNumber] ) {
+                        return 'bad-tagnumber';
+                    } else {
+                        state.seenTags[state.tagNumber] = true;
+                        return 'tagnumber';
+                    }
+                } else {
+                    stream.skipToEnd();
+                    return 'error';
+                }
+            }
+
+            // 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 '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 'required-space';
+                        } else {
+                            stream.skipToEnd();
+                            return 'error';
+                        }
+                    case 4:
+                    case 6:
+                        if ( /[0-9A-Za-z_]/.test( stream.next() ) ) {
+                            return 'indicator';
+                        } else {
+                            stream.skipToEnd();
+                            return 'error';
+                        }
+                }
+            }
+
+            // Otherwise, we're after the start of the line.
+            if ( state.subAllowed ) {
+                // 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;
+                    if ( ( subfieldCode = stream.eat( /[a-z0-9%]/ ) ) && stream.eat( ' ' ) ) {
+                        state.subfieldCode = subfieldCode;
+                        if ( state.seenSubfields[state.subfieldCode] && ( modeConfig.nonRepeatableSubfields[state.tagNumber] || {} )[state.subfieldCode] ) {
+                            return 'bad-subfieldcode';
+                        } else {
+                            state.seenSubfields[state.subfieldCode] = true;
+                            return 'subfieldcode';
+                        }
+                    }
+                }
+
+                if ( stream.pos < 11 && ( !stream.eol() || stream.pos == 8 ) ) {
+                    stream.skipToEnd();
+                    return 'error';
+                }
+            } else {
+                // 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
new file mode 100644 (file)
index 0000000..f745533
--- /dev/null
@@ -0,0 +1,370 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+/**
+ * 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
+ *
+ * ISO2709 import/export is cribbed from marcjs, which is under the MIT license.
+ * Source: https://github.com/fredericd/marcjs/blob/master/lib/marcjs.js
+ */
+
+define( function() {
+    var MARC = {};
+
+    var _escape_map = {
+        "<": "&lt;",
+        "&": "&amp;",
+        "\"": "&quot;"
+    };
+
+    function _escape(str) {
+        return str.replace( /[<&"]/, function (c) { return _escape_map[c] } );
+    }
+
+    function _intpadded(i, digits) {
+        i = i + '';
+        while (i.length < digits) {
+            i = '0' + i;
+        }
+        return i;
+    }
+
+    MARC.Record = function (fieldlist) {
+        this._fieldlist = fieldlist || [];
+    }
+
+    $.extend( MARC.Record.prototype, {
+        leader: function(val) {
+            var field = this.field('000');
+
+            if (val) {
+                if (field) {
+                    field.subfield( '@', val );
+                } else {
+                    field = new MARC.Field( '000', '', '', [ [ '@', val ] ] );
+                    this.addFieldGrouped(field);
+                }
+            } else {
+                return ( field && field.subfield('@') ) || '     nam a22     7a 4500';
+            }
+        },
+
+        /**
+         * If a tagnumber is given, returns all fields with that tagnumber.
+         * Otherwise, returns all fields.
+         */
+        fields: function(fieldno) {
+            if (!fieldno) return this._fieldlist;
+
+            var results = [];
+            for(var i=0; i<this._fieldlist.length; i++){
+                if( this._fieldlist[i].tagnumber() == fieldno ) {
+                    results.push(this._fieldlist[i]);
+                }
+            }
+
+            return results;
+        },
+
+        /**
+         * Returns the first field with the given tagnumber, or false.
+         */
+        field: function(fieldno) {
+            for(var i=0; i<this._fieldlist.length; i++){
+                if( this._fieldlist[i].tagnumber() == fieldno ) {
+                    return this._fieldlist[i];
+                }
+            }
+            return false;
+        },
+
+        /**
+         * Adds the given MARC.Field to the record, at the end.
+         */
+        addField: function(field) {
+            this._fieldlist.push(field);
+            return true;
+        },
+
+        /**
+         * Adds the given MARC.Field to the record, at the end of the matching
+         * x00 group. If a record has a 100, 245 and 300 field, for instance, a
+         * 260 field would be added after the 245 field.
+         */
+        addFieldGrouped: function(field) {
+            for ( var i = this._fieldlist.length - 1; i >= 0; i-- ) {
+                if ( this._fieldlist[i].tagnumber()[0] <= field.tagnumber()[0] ) {
+                    this._fieldlist.splice(i+1, 0, field);
+                    return true;
+                }
+            }
+            this._fieldlist.push(field);
+            return true;
+        },
+
+        /**
+         * Removes the first field with the given tagnumber. Returns false if no
+         * such field was found.
+         */
+        removeField: function(fieldno) {
+            for(var i=0; i<this._fieldlist.length; i++){
+                if( this._fieldlist[i].tagnumber() == fieldno ) {
+                    this._fieldlist.splice(i, 1);
+                    return true;
+                }
+            }
+            return false;
+        },
+
+        /**
+         * Check to see if this record contains a field with the given
+         * tagnumber.
+         */
+        hasField: function(fieldno) {
+            for(var i=0; i<this._fieldlist.length; i++){
+                if( this._fieldlist[i].tagnumber() == fieldno ) {
+                    return true;
+                }
+            }
+            return false;
+        },
+
+        toXML: function() {
+            var xml = '<record xmlns="http://www.loc.gov/MARC21/slim">';
+            for(var i=0; i<this._fieldlist.length; i++){
+                xml += this._fieldlist[i].toXML();
+            }
+            xml += '</record>';
+            return xml;
+        },
+
+        /**
+         * Truncates this record, and loads in the data from the given MARCXML
+         * document.
+         */
+        loadMARCXML: function(xmldoc) {
+            var record = this;
+            record.xmlSource = xmldoc;
+            this._fieldlist.length = 0;
+            this.leader( $('leader', xmldoc).text() );
+            $('controlfield', xmldoc).each( function(i) {
+                val = $(this).text();
+                tagnum = $(this).attr('tag');
+                record._fieldlist.push( new MARC.Field(tagnum, '', '', [ [ '@', val ] ]) );
+            });
+            $('datafield', xmldoc).each(function(i) {
+                var value = $(this).text();
+                var tagnum = $(this).attr('tag');
+                var ind1 = $(this).attr('ind1') || ' ';
+                var ind2 = $(this).attr('ind2') || ' ';
+                var subfields = new Array();
+                $('subfield', this).each(function(j) {
+                    var sfval = $(this).text();
+                    var sfcode = $(this).attr('code');
+                    subfields.push( [ sfcode, sfval ] );
+                });
+                record._fieldlist.push( new MARC.Field(tagnum, ind1, ind2, subfields) );
+            });
+        },
+
+        toISO2709: function() {
+            var FT = '\x1e', RT = '\x1d', DE = '\x1f';
+            var directory = '',
+                from = 0,
+                chunks = ['', ''];
+
+            $.each( this._fieldlist, function( undef, element ) {
+                var chunk = '';
+                var tag = element.tagnumber();
+                if (tag == '000') {
+                    return;
+                } else if (tag < '010') {
+                    chunk = element.subfields()[0][1];
+                } else {
+                    chunk = element.indicators().join('');
+                    $.each( element.subfields(), function( undef, subfield ) {
+                        chunk += DE + subfield[0] + subfield[1];
+                    } );
+                }
+                chunk += FT;
+                chunks.push(chunk);
+                directory += _intpadded(tag,3) + _intpadded(chunk.length,4) + _intpadded(from,5);
+                from += chunk.length;
+            });
+
+            chunks.push(RT);
+            directory += FT;
+            var offset = 24 + 12 * (this._fieldlist.length - 1) + 1;
+            var length = offset + from + 1;
+            var leader = this.leader();
+            leader = _intpadded(length,5) + leader.substr(5,7) + _intpadded(offset,5) +
+                leader.substr(17);
+            chunks[0] = leader;
+            chunks[1] = directory;
+            return chunks.join('');
+        },
+
+        loadISO2709: function(data) {
+            this._fieldlist.length = 0;
+            this.leader(data.substr(0, 24));
+            var directory_len = parseInt(data.substring(12, 17), 0) - 25,
+                number_of_tag = directory_len / 12;
+            for (var i = 0; i < number_of_tag; i++) {
+                var off = 24 + i * 12,
+                    tag = data.substring(off, off+3),
+                    len = parseInt(data.substring(off+3, off+7), 0) - 1,
+                    pos = parseInt(data.substring(off+7, off+12), 0) + 25 + directory_len,
+                    value = data.substring(pos, pos+len);
+                if ( parseInt(tag) < 10 ) {
+                    this.addField( new MARC.Field( tag, '', '', [ [ '@', value ] ] ) );
+                } else {
+                    if ( value.indexOf('\x1F') ) { // There are some letters
+                        var ind1 = value.substr(0, 1), ind2 = value.substr(1, 1);
+                        var subfields = [];
+
+                        $.each( value.substr(3).split('\x1f'), function( undef, v ) {
+                            if (v.length < 2) return;
+                            subfields.push([v.substr(0, 1), v.substr(1)]);
+                        } );
+
+                        this.addField( new MARC.Field( tag, ind1, ind2, subfields ) );
+                    }
+                }
+            }
+        }
+    } );
+
+    MARC.Field = function(tagnumber, indicator1, indicator2, subfields) {
+        this._tagnumber = tagnumber;
+        this._indicators = [ indicator1, indicator2 ];
+        this._subfields = subfields;
+    };
+
+    $.extend( MARC.Field.prototype, {
+        tagnumber: function() {
+            return this._tagnumber;
+        },
+
+        isControlField: function() {
+            return this._tagnumber < '010';
+        },
+
+        indicator: function(num, val) {
+            if( val != null ) {
+                this._indicators[num] = val;
+            }
+            return this._indicators[num];
+        },
+
+        indicators: function() {
+            return this._indicators;
+        },
+
+        hasSubfield: function(code) {
+            for(var i = 0; i<this._subfields.length; i++) {
+                if( this._subfields[i][0] == code ) {
+                    return true;
+                }
+            }
+            return false;
+        },
+
+        removeSubfield: function(code) {
+            for(var i = 0; i<this._subfields.length; i++) {
+                if( this._subfields[i][0] == code ) {
+                    this._subfields.splice(i,1);
+                    return true;
+                }
+            }
+            return false;
+        },
+
+        subfields: function() {
+            return this._subfields;
+        },
+
+        addSubfield: function(sf) {
+            this._subfields.push(sf);
+            return true;
+        },
+
+        addSubfieldGrouped: function(sf) {
+            function _kind( sc ) {
+                if ( /[a-z]/.test( sc ) ) {
+                    return 0;
+                } else if ( /[0-9]/.test( sc ) ) {
+                    return 1;
+                } else {
+                    return 2;
+                }
+            }
+
+            for ( var i = this._subfields.length - 1; i >= 0; i-- ) {
+                if ( i == 0 && _kind( sf[0] ) < _kind( this._subfields[i][0] ) ) {
+                    this._subfields.splice( 0, 0, sf );
+                    return true;
+                } else if ( _kind( this._subfields[i][0] ) <= _kind( sf[0] )  ) {
+                    this._subfields.splice( i + 1, 0, sf );
+                    return true;
+                }
+            }
+
+            this._subfields.push(sf);
+            return true;
+        },
+
+        subfield: function(code, val) {
+            var sf = '';
+            for(var i = 0; i<this._subfields.length; i++) {
+                if( this._subfields[i][0] == code ) {
+                    sf = this._subfields[i];
+                    if( val != null ) {
+                        sf[1] = val;
+                    }
+                    return sf[1];
+                }
+            }
+            return false;
+        },
+
+        toXML: function() {
+            // decide if it's controlfield of datafield
+            if( this._tagnumber == '000') {
+                return '<leader>' + _escape( this._subfields[0][1] ) + '</leader>';
+            } else if ( this._tagnumber < '010' ) {
+                return '<controlfield tag="' + this._tagnumber + '">' + _escape( this._subfields[0][1] ) + '</controlfield>';
+            } else {
+                var result = '<datafield tag="' + this._tagnumber + '"';
+                result += ' ind1="' + this._indicators[0] + '"';
+                result += ' ind2="' + this._indicators[1] + '">';
+                for( var i = 0; i< this._subfields.length; i++) {
+                    result += '<subfield code="' + this._subfields[i][0] + '">';
+                    result += _escape( this._subfields[i][1] );
+                    result += '</subfield>';
+                }
+                result += '</datafield>';
+
+                return result;
+            }
+        }
+    } );
+
+    return MARC;
+} );
diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/preferences.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/preferences.js
new file mode 100644 (file)
index 0000000..5cf81da
--- /dev/null
@@ -0,0 +1,49 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+define( function() {
+    var Preferences = {
+        Load: function( borrowernumber ) {
+            if ( borrowernumber == null ) return;
+
+            var saved_prefs;
+            try {
+                saved_prefs = JSON.parse( localStorage[ 'cateditor_preferences_' + borrowernumber ] );
+            } catch (e) {}
+
+            Preferences.user = $.extend( {
+                // Preference defaults
+                fieldWidgets: true,
+                font: 'monospace',
+                fontSize: '1em',
+                macros: {},
+                selected_search_targets: {},
+            }, saved_prefs );
+        },
+
+        Save: function( borrowernumber ) {
+            if ( !borrowernumber ) return;
+            if ( !Preferences.user ) Preferences.Load(borrowernumber);
+
+            localStorage[ 'cateditor_preferences_' + borrowernumber ] = JSON.stringify(Preferences.user);
+        },
+    };
+
+    return Preferences;
+} );
diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/resources.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/resources.js
new file mode 100644 (file)
index 0000000..5be97d6
--- /dev/null
@@ -0,0 +1,38 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+define( [ 'module' ], function( module ) {
+    var _allResources = [];
+
+    var Resources = {
+        GetAll: function() {
+            return $.when.call( null, _allResources );
+        }
+    };
+
+    function _res( name, deferred ) {
+        Resources[name] = deferred;
+        _allResources.push(deferred);
+    }
+
+    _res( 'marc21/xml/006', $.get( module.config().themelang + '/data/marc21_field_006.xml' ) );
+    _res( 'marc21/xml/008', $.get( module.config().themelang + '/data/marc21_field_008.xml' ) );
+
+    return Resources;
+} );
diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/search.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/search.js
new file mode 100644 (file)
index 0000000..341da39
--- /dev/null
@@ -0,0 +1,114 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+define( [ 'marc-record' ], function( MARC ) {
+    var _options;
+    var _records = {};
+    var _last;
+
+    var _pqfMapping = {
+        author: '1=1004', // s=al',
+        cn_dewey: '1=13',
+        cn_lc: '1=16',
+        date: '1=30', // r=r',
+        isbn: '1=7',
+        issn: '1=8',
+        lccn: '1=9',
+        local_number: '1=12',
+        music_identifier: '1=51',
+        standard_identifier: '1=1007',
+        subject: '1=21', // s=al',
+        term: '1=1016', // t=l,r s=al',
+        title: '1=4', // s=al',
+    }
+
+    var Search = {
+        Init: function( options ) {
+            _options = options;
+        },
+        JoinTerms: function( terms ) {
+            var q = '';
+
+            $.each( terms, function( i, term ) {
+                var term = '@attr ' + _pqfMapping[ term[0] ] + ' "' + term[1].replace( '"', '\\"' ) + '"'
+
+                if ( q ) {
+                    q = '@and ' + q + ' ' + term;
+                } else {
+                    q = term;
+                }
+            } );
+
+            return q;
+        },
+        Run: function( servers, q, options ) {
+            options = $.extend( {
+                offset: 0,
+                page_size: 20,
+            }, _options, options );
+
+            Search.includedServers = [];
+            _records = {};
+            _last = {
+                servers: servers,
+                q: q,
+                options: options,
+            };
+
+            $.each( servers, function ( id, info ) {
+                if ( info.checked ) Search.includedServers.push( id );
+            } );
+
+            $.get(
+                '/cgi-bin/koha/svc/cataloguing/metasearch',
+                {
+                    q: q,
+                    servers: Search.includedServers.join( ',' ),
+                    offset: options.offset,
+                    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 );
+                        hit.record = record;
+                    } );
+
+                    _options.onresults( data );
+                } )
+                .fail( function( error ) {
+                    _options.onerror( error );
+                } );
+
+            return true;
+        },
+        Fetch: function( options ) {
+            if ( !_last ) return;
+            $.extend( _last.options, options );
+            Search.Run( _last.servers, _last.q, _last.options );
+        }
+    };
+
+    return Search;
+} );
diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/text-marc.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/text-marc.js
new file mode 100644 (file)
index 0000000..ba471e6
--- /dev/null
@@ -0,0 +1,106 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+define( [ 'marc-record' ], function( MARC ) {
+    // Convert any characters for display
+    function _sanitize( text ) {
+        return text.replace( '$', '{dollar}' );
+    }
+
+    // Undo conversion
+    function _desanitize( text ) {
+        return text.replace( '{dollar}', '$' );
+    }
+    return {
+        RecordToText: function( record ) {
+            var lines = [];
+            var fields = record.fields();
+
+            for ( var i = 0; i < fields.length; i++ ) {
+                var field = fields[i];
+
+                if ( field.isControlField() ) {
+                    lines.push( field.tagnumber() + ' ' + _sanitize( field.subfield( '@' ) ) );
+                } else {
+                    var result = [ field.tagnumber() + ' ' ];
+
+                    result.push( field.indicator(0) == ' ' ? '_' : field.indicator(0), ' ' );
+                    result.push( field.indicator(1) == ' ' ? '_' : field.indicator(1), ' ' );
+
+                    $.each( field.subfields(), function( i, subfield ) {
+                        result.push( '$' + subfield[0] + ' ' + _sanitize( subfield[1] ) );
+                    } );
+
+                    lines.push( result.join('') );
+                }
+            }
+
+            return lines.join('\n');
+        },
+
+        TextToRecord: function( text ) {
+            var record = new MARC.Record();
+            var errors = [];
+
+            $.each( text.split('\n'), function( i, line ) {
+                var tagNumber = line.match( /^([A-Za-z0-9]{3}) / );
+
+                if ( !tagNumber ) {
+                    errors.push( { type: 'noTag', line: i } );
+                    return;
+                }
+                tagNumber = tagNumber[1];
+
+                if ( tagNumber < '010' ) {
+                    var field = new MARC.Field( tagNumber, ' ', ' ', [ [ '@', _desanitize( line.substring( 4 ) ) ] ] );
+                    field.sourceLine = i;
+                    record.addField( field );
+                } else {
+                    var indicators = line.match( /^... ([0-9A-Za-z_]) ([0-9A-Za-z_])/ );
+                    if ( !indicators ) {
+                        errors.push( { type: 'noIndicators', line: i } );
+                        return;
+                    }
+
+                    var field = new MARC.Field( tagNumber, ( indicators[1] == '_' ? ' ' : indicators[1] ), ( indicators[2] == '_' ? ' ' : indicators[2] ), [] );
+
+                    var matcher = /[$|ǂ‡]([a-z0-9%]) /g;
+                    var match;
+
+                    var subfields = [];
+
+                    while ( ( match = matcher.exec(line) ) ) {
+                        subfields.push( { code: match[1], ch: match.index } );
+                    }
+
+                    $.each( subfields, function( i, subfield ) {
+                        var next = subfields[ i + 1 ];
+
+                        field.addSubfield( [ subfield.code, _desanitize( line.substring( subfield.ch + 3, next ? next.ch : line.length ) ) ] );
+                    } );
+
+                    field.sourceLine = i;
+                    record.addField( field );
+                }
+            } );
+
+            return errors.length ? { errors: errors } : record;
+        }
+    };
+} );
diff --git a/koha-tmpl/intranet-tmpl/lib/koha/cateditor/widget.js b/koha-tmpl/intranet-tmpl/lib/koha/cateditor/widget.js
new file mode 100644 (file)
index 0000000..35d4727
--- /dev/null
@@ -0,0 +1,310 @@
+/**
+ * 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 <http://www.gnu.org/licenses>.
+ */
+
+define( [ 'resources' ], function( Resources ) {
+    var _widgets = {};
+
+    var Widget = {
+        Register: function( tagfield, widget ) {
+            _widgets[tagfield] = widget;
+        },
+
+        PadNum: function( number, length ) {
+            var result = number.toString();
+
+            while ( result.length < length ) result = '0' + result;
+
+            return result;
+        },
+
+        PadString: function( result, length ) {
+            while ( result.length < length ) result = ' ' + result;
+
+            return result;
+        },
+
+        PadStringRight: function( result, length ) {
+            result = '' + result;
+            while ( result.length < length ) result += ' ';
+
+            return result;
+        },
+
+        Base: {
+            // Marker utils
+            clearToText: function() {
+                var range = this.mark.find();
+                if ( this.text == null ) throw new Error('Tried to clear widget with no text');
+                this.mark.doc.replaceRange( this.text, range.from, range.to, 'widget.clearToText' );
+            },
+
+            reCreate: function() {
+                this.postCreate( this.node, this.mark );
+            },
+
+            // Fixed field utils
+            bindFixed: function( sel, start, end ) {
+                var $node = $( this.node ).find( sel );
+                $node.val( this.getFixed( start, end ) );
+
+                var widget = this;
+                var $collapsed = $( '<span class="fixed-collapsed" title="' + $node.attr('title') + '">' + $node.val() + '</span>' ).insertAfter( $node );
+
+                function show() {
+                    $collapsed.hide();
+                    $node.val( widget.getFixed( start, end ).replace(/\s+$/, '') );
+                    $node.show();
+                    $node[0].focus();
+                }
+
+                function hide() {
+                    $node.hide();
+                    $collapsed.text( Widget.PadStringRight( $node.val(), end - start ) ).show();
+                }
+
+                $node.on( 'change keyup', function() {
+                    widget.setFixed( start, end, $node.val(), '+input' );
+                } ).focus( show ).blur( hide );
+
+                hide();
+
+                $collapsed.click( show );
+            },
+
+            getFixed: function( start, end ) {
+                return this.text.substring( start, end );
+            },
+
+            setFixed: function( start, end, value, source ) {
+                this.setText( this.text.substring( 0, start ) + Widget.PadStringRight( value.toString().substr( 0, end - start ), end - start ) + this.text.substring( end ), source );
+            },
+
+            setText: function( text, source ) {
+                if ( source == '+input' ) this.mark.doc.cm.addLineClass( this.mark.find().from.line, 'wrapper', 'modified-line' );
+                this.text = text;
+                this.editor.startNotify();
+            },
+
+            createFromXML: function( resourceId ) {
+                var widget = this;
+
+                Resources[resourceId].done( function( xml ) {
+                    $(widget.node).find('.widget-loading').remove();
+                    var $matSelect = $('<select class="material-select"></select>').appendTo(widget.node);
+                    var $contents = $('<span class="material-contents"/>').appendTo(widget.node);
+                    var materialInfo = {};
+
+                    $('Tagfield', xml).children('Material').each( function() {
+                        $matSelect.append( '<option value="' + $(this).attr('id') + '">' + $(this).attr('id') + ' - ' + $(this).children('name').text() + '</option>' );
+
+                        materialInfo[ $(this).attr('id') ] = this;
+                    } );
+
+                    $matSelect.change( function() {
+                        widget.loadXMLMaterial( materialInfo[ $matSelect.val() ] );
+                    } ).change();
+                } );
+            },
+
+            loadXMLMaterial: function( materialInfo ) {
+                var $contents = $(this.node).children('.material-contents');
+                $contents.empty();
+
+                var widget = this;
+
+                $(materialInfo).children('Position').each( function() {
+                    var match = $(this).attr('pos').match(/(\d+)(?:-(\d+))?/);
+                    if (!match) return;
+
+                    var start = parseInt(match[1]);
+                    var end = ( match[2] ? parseInt(match[2]) : start ) + 1;
+                    var $input;
+                    var $values = $(this).children('Value');
+
+                    if ($values.length == 0) {
+                        $contents.append( '<span title="' + $(this).children('name').text() + '">' + widget.getFixed(start, end) + '</span>' );
+                        return;
+                    }
+
+                    if ( match[2] ) {
+                        $input = $( '<input name="f' + Widget.PadNum(start, 2) + '" title="' + $(this).children('name').text() + '" maxlength="' + (end - start) + '" />' );
+                    } else {
+                        $input = $( '<select name="f' + Widget.PadNum(start, 2) + '" title="' + $(this).children('name').text() + '"></select>' );
+
+                        $values.each( function() {
+                            $input.append( '<option value="' + $(this).attr('code') + '">' + $(this).attr('code') + ' - ' + $(this).children('description').text() + '</option>' );
+                        } );
+                    }
+
+                    $contents.append( $input );
+                    widget.bindFixed( $input, start, end );
+                } );
+            },
+
+            nodeChanged: function() {
+                this.mark.changed();
+                var widget = this;
+
+                var $inputs = $(this.node).find('input, select');
+                if ( !$inputs.length ) return;
+
+                $inputs.off('keydown.marc-tab');
+                var editor = widget.editor;
+
+                $inputs.each( function( i ) {
+                    $(this).on( 'keydown.marc-tab', function( e ) {
+                        if ( e.which != 9 ) return; // Tab
+
+                        var span = widget.mark.find();
+                        var cur = editor.cm.getCursor();
+
+                        if ( e.shiftKey ) {
+                            if ( i > 0 ) {
+                                $inputs.eq(i - 1).trigger( 'focus' );
+                            } else {
+                                editor.cm.setCursor( span.from );
+                                // FIXME: ugly hack
+                                editor.cm.options.extraKeys['Shift-Tab']( editor.cm );
+                                editor.focus();
+                            }
+                        } else {
+                            if ( i < $inputs.length - 1 ) {
+                                $inputs.eq(i + 1).trigger( 'focus' );
+                            } else {
+                                editor.cm.setCursor( span.to );
+                                editor.focus();
+                            }
+                        }
+
+                        return false;
+                    } );
+                } );
+            },
+
+            // Template utils
+            insertTemplate: function( sel ) {
+                var wsOnly = /^\s*$/;
+                $( sel ).contents().clone().each( function() {
+                    if ( this.nodeType == Node.TEXT_NODE ) {
+                        this.data = this.data.replace( /^\s+|\s+$/g, '' );
+                    }
+                } ).appendTo( this.node );
+            },
+        },
+
+        ActivateAt: function( editor, cur, idx ) {
+            var marks = editor.findMarksAt( cur );
+            if ( !marks.length ) return false;
+
+            var $input = $(marks[0].widget.node).find('input, select').eq(idx || 0);
+            if ( !$input.length ) return false;
+
+            $input.focus();
+            return true;
+        },
+
+        Notify: function( editor ) {
+            $.each( editor.cm.getAllMarks(), function( undef, mark ) {
+                if ( mark.widget && mark.widget.notify ) mark.widget.notify();
+            } );
+        },
+
+        UpdateLine: function( editor, line ) {
+            var info = editor.getLineInfo( { line: line, ch: 0 } );
+            var lineh = editor.cm.getLineHandle( line );
+            if ( !lineh ) return;
+
+            if ( !info ) {
+                if ( lineh.markedSpans ) {
+                    $.each( lineh.markedSpans, function ( undef, span ) {
+                        var mark = span.marker;
+                        if ( !mark.widget ) return;
+
+                        mark.widget.clearToText();
+                    } );
+                }
+                return;
+            }
+
+            var subfields = [];
+
+            var end = editor.cm.getLine( line ).length;
+            if ( info.tagNumber < '010' ) {
+                if ( end >= 4 ) subfields.push( { code: '@', from: 4, to: end } );
+            } else {
+                for ( var i = 0; i < info.subfields.length; i++ ) {
+                    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 ) {
+                var id = info.tagNumber + subfield.code;
+                var marks = editor.cm.findMarksAt( { line: line, ch: subfield.from } );
+
+                if ( marks.length ) {
+                    if ( marks[0].id == id ) {
+                        return;
+                    } else {
+                        marks[0].widget.clearToText();
+                    }
+                }
+
+                if ( !_widgets[id] ) return;
+                var fullBase = $.extend( Object.create( Widget.Base ), _widgets[id] );
+                var widget = Object.create( fullBase );
+
+                if ( subfield.from == subfield.to ) {
+                    editor.cm.replaceRange( widget.makeTemplate ? widget.makeTemplate() : '<empty>', { line: line, ch: subfield.from }, null, 'marcWidgetPrefill' );
+                    return; // We'll do the actual work when the change event is triggered again
+                }
+
+                var text = editor.cm.getRange( { line: line, ch: subfield.from }, { line: line, ch: subfield.to } );
+
+                widget.text = text;
+                var node = widget.init();
+
+                var mark = editor.cm.markText( { line: line, ch: subfield.from }, { line: line, ch: subfield.to }, {
+                    atomic: true,
+                    inclusiveLeft: false,
+                    inclusiveRight: false,
+                    replacedWith: node,
+                } );
+
+                mark.id = id;
+                mark.widget = widget;
+
+                widget.node = node;
+                widget.mark = mark;
+                widget.editor = editor;
+
+                if ( widget.postCreate ) {
+                    widget.postCreate();
+                }
+
+                widget.nodeChanged();
+            } );
+        },
+    };
+
+    return Widget;
+} );
diff --git a/koha-tmpl/intranet-tmpl/prog/en/css/cateditor.css b/koha-tmpl/intranet-tmpl/prog/en/css/cateditor.css
new file mode 100644 (file)
index 0000000..04258a5
--- /dev/null
@@ -0,0 +1,434 @@
+/*> Infrastructure */
+body {
+    padding: 0;
+}
+
+#loading {
+    background-color: #FFF;
+    cursor: wait;
+    height: 100%;
+    left: 0;
+    opacity: .7;
+    position: fixed;
+    top: 0;
+    width: 100%;
+    z-index: 1000;
+}
+
+#loading div {
+    background : transparent url(../../img/loading.gif) top left no-repeat;
+    font-size : 175%;
+    font-weight: bold;
+    height: 2em;
+    left: 50%;
+    margin: -1em 0 0 -2.5em;
+    padding-left : 50px;
+    position: absolute;
+    top: 50%;
+    width: 15em;
+}
+
+#alerts-container {
+    font-size: 12px;
+}
+
+#alerts-container h3 {
+    font-size: inherit;
+}
+
+#alerts-container > ul {
+    padding: 0;
+}
+
+#alerts-container > ul > li {
+    border-bottom: 1px solid #DDD;
+    display: block;
+    padding: 4px 0;
+}
+
+#alerts-container > ul > li:first-child {
+    padding-top: 0;
+}
+
+#alerts-container > ul > li:last-child {
+    border-bottom: none;
+    padding-bottom: 0;
+}
+
+/*> MARC editor */
+#editor .CodeMirror {
+    line-height: 1.2;
+}
+
+.cm-tagnumber {
+    color: #080;
+    font-weight: bold;
+}
+
+.cm-bad-tagnumber {
+    color: #A20;
+    font-weight: bold;
+}
+
+.cm-indicator {
+    color: #884;
+}
+
+.cm-subfieldcode {
+    background-color: #F4F4F4;
+    color: #187848;
+    border-radius: 3px 8px 8px 3px;
+    border-right: 2px solid white;
+    font-weight: bold;
+    margin-right: -2px;
+}
+
+.cm-bad-subfieldcode {
+    background-color: #FFD9D9;
+    color: #482828;
+    border-radius: 3px 8px 8px 3px;
+    font-weight: bold;
+}
+
+.cm-end-space {
+    background-color: #DDDDBB;
+}
+
+#editor .modified-line-gutter {
+    width: 10px;
+}
+
+#editor .modified-line {
+    background: #F8F8F8;
+    border-left: 5px solid black;
+    margin-left: -10px;
+    padding-left: 5px;
+}
+
+#editor .CodeMirror-gutters {
+    background: transparent;
+    border-right: none;
+}
+
+/*> MARC editor widgets */
+
+#editor .subfield-widget {
+    color: #538200;
+    border: solid 2px #538200;
+    border-radius: 6px;
+    font-family: inherit;
+    line-height: 2.75;
+    margin: 3px 0;
+    padding: 4px;
+}
+
+#editor .subfield-widget select, #editor .subfield-widget input {
+    height: 1.5em;
+    vertical-align: middle;
+}
+
+#editor .subfield-widget select:focus {
+    outline: 2px #83A230 solid;
+}
+
+#editor .fixed-widget input {
+    width: 4em;
+}
+
+#editor .fixed-widget select {
+    width: 3em;
+}
+
+#editor .fixed-widget .material-select {
+    width: 4.5em;
+    margin-right: .5em;
+}
+
+#editor .fixed-collapsed {
+    display: inline-block;
+    margin: 0 .25em;
+    text-align: center;
+    text-decoration: underline;
+}
+
+#editor .hidden-widget {
+    color: #999999;
+    border: solid 2px #AAAAAA;
+    line-height: 2;
+    padding: 2px;
+}
+
+.structure-error {
+    background: #FFEEEE;
+    font-size: 0.9em;
+    line-height: 1.5;
+    margin: .5em;
+    padding: 0 .5em;
+}
+
+.structure-error i {
+    vertical-align: text-bottom;
+}
+
+#statusbar {
+    background-color: #F4F8F9;
+    border: solid 2px #b9d8d9;
+    border-bottom-style: none;
+    border-radius: 6px 6px 0 0;
+    height: 18px;
+    margin-bottom: -32px;
+    overflow: auto;
+    padding: 4px;
+    padding-bottom: 0;
+}
+
+#statusbar #status-tag-info, #statusbar #status-subfield-info {
+    float: left;
+    overflow: hidden;
+    padding-right: 2%;
+    width: 48%;
+}
+
+#record-info .label {
+    float: none;
+}
+
+#record-info .label + span {
+    display: block;
+    padding-left: 1em;
+}
+
+/*> Search */
+
+#advanced-search-ui, #search-results-ui, #macro-ui {
+    padding: 5px;
+    width: 90%;
+}
+
+.modal-body {
+    max-height: none;
+    padding: 0;
+}
+
+#quicksearch-overlay {
+    background: rgba(255, 255, 255, .9);
+    border: 2px solid #CC8877;
+    border-radius: 5px;
+    -moz-box-sizing: border-box;
+    -webkit-box-sizing: border-box;
+    box-sizing: border-box;
+    color: #664444;
+    position: relative;
+    vertical-align: middle;
+}
+
+#quicksearch-overlay h3 {
+    font-size: 1.5em%;
+    margin: 0;
+    text-align: center;
+    padding: 50px 5px;
+}
+
+#quicksearch-overlay p {
+    bottom: 0;
+    font-size: .8em;
+    overflow: hidden;
+    padding: 8px 15px;
+    position: absolute;
+    text-align: center;
+}
+
+#quicksearch input, #quicksearch a {
+    font-size: 1.2em;
+    padding: 3px 0;
+    width: 96%; /* I have no idea why this is necessary */
+}
+
+#show-advanced-search {
+    display: block;
+    margin-top: .3em;
+}
+
+#advanced-search-fields {
+    -moz-column-width: 26em;
+    -webkit-column-width: 26em;
+    column-width: 26em;
+    margin: 0;
+    padding: 0;
+}
+
+#advanced-search-fields li {
+    display: block;
+    list-style-type: none;
+}
+
+#advanced-search-fields label {
+    display: inline-block;
+    font-weight: bold;
+    padding: 1em 1em 1em 0;
+    width: 10em;
+    text-align: right;
+}
+
+#advanced-search-fields input {
+    display: inline-block;
+    margin: 0px auto;
+    width: 14em;
+}
+
+.icon-loading {
+    display: inline-block;
+    height: 16px;
+    width: 16px;
+    background: transparent url("../../img/spinner-small.gif") top left no-repeat;
+    padding: -1px;
+    vertical-align: text-top;
+}
+
+/*> Search results */
+
+#search-serversinfo li {
+    list-style-type: none;
+}
+
+#search-serversinfo .search-toggle-server {
+    margin-right: 5px;
+}
+
+#searchresults table {
+    width: 100%;
+}
+
+.sourcecol {
+    width: 50px;
+}
+
+.results-info {
+    height: 100px;
+    overflow: auto;
+}
+
+.toolscol {
+    padding: 0;
+    width: 100px;
+}
+
+.toolscol ul {
+    margin: 0;
+    padding: 0;
+}
+
+#searchresults .toolscol li {
+    list-style-type: none;
+    list-style-image: none;
+}
+
+.toolscol a {
+    border-bottom: 1px solid #BCBCBC;
+    display: block;
+    padding: 0 1em;
+    line-height: 24px;
+}
+
+.marccol {
+    font-family: monospace;
+    height: auto;
+    white-space: pre-wrap;
+}
+
+#searchresults {
+    position: relative;
+}
+
+#search-overlay {
+    background: white;
+    bottom: 0;
+    font-size: 2em;
+    left: 0;
+    opacity: .7;
+    padding: 2em;
+    position: absolute;
+    right: 0;
+    text-align: center;
+    top: 0;
+    z-index: 9001;
+}
+
+/*> Macros */
+
+#macro-ui .CodeMirror {
+    width: 100%;
+}
+
+#macro-save-message {
+    color: #666;
+    font-size: 13px;
+    float: right;
+    line-height: 26px;
+}
+
+#macro-list > li {
+    border: 2px solid #F0F0F0;
+    border-radius: 6px;
+    display: block;
+    font-size: 115%;
+}
+
+#macro-list > li + li {
+    margin-top: -2px;
+}
+
+#macro-list .active {
+    background: #EDF4F6;
+    border-color: none;
+}
+
+#macro-list a {
+    display: block;
+    padding: 6px;
+}
+
+#macro-list a:focus {
+    outline: none;
+}
+
+.macro-info {
+    background-color: #F4F4F4;
+    display: none;
+    margin: 0;
+    padding: 10px;
+    text-align: right;
+}
+
+.macro-info li {
+    color: #666;
+    font-size: 75%;
+    list-style-type: none;
+}
+
+.macro-info .label {
+    clear: left;
+    font-weight: bold;
+    float: left;
+}
+
+#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%;
+}
index d33580a..11781bc 100644 (file)
@@ -418,13 +418,18 @@ dd {
        font-weight : normal;
 }
 
-div#toolbar {
+.btn-toolbar {
        background-color : #EDF4F6;
      padding: 5px 5px 5px 5px;
       border-radius: 5px 5px 0 0;
     border: 1px solid #E6F0F2;
 }
 
+.btn-toolbar .yui-menu-button button,
+.btn-toolbar .yui-button-button button {
+       line-height : 1.7em;
+}
+
 ul.toolbar {
        padding-left : 0;
 }
@@ -2412,8 +2417,8 @@ video {
     background-position:-48px -166px;
 }
 
-#toolbar .btn,
-#toolbar .dropdown-menu {
+.btn-toolbar .btn,
+.btn-toolbar .dropdown-menu {
     font-size: 13px;
 }
 a.btn:link,
diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-ui.inc
new file mode 100644 (file)
index 0000000..89a11ec
--- /dev/null
@@ -0,0 +1,1051 @@
+<script src="/intranet-tmpl/lib/codemirror/codemirror-compressed.js"></script>
+<script src="/intranet-tmpl/lib/filesaver.js"></script>
+<script src="/intranet-tmpl/lib/koha/cateditor/marc-mode.js"></script>
+<script src="/intranet-tmpl/lib/require.js"></script>
+<script>
+require.config( {
+    baseUrl: '/intranet-tmpl/lib/koha/cateditor/',
+    config: {
+        resources: {
+            themelang: '[% themelang %]',
+        },
+    },
+    waitSeconds: 30,
+} );
+</script>
+
+[% IF marcflavour == 'MARC21' %]
+[% PROCESS 'cateditor-widgets-marc21.inc' %]
+[% ELSE %]
+<script>var editorWidgets = {};</script>
+[% END %]
+
+<script>
+require( [ 'koha-backend', 'search', 'macros', 'marc-editor', 'marc-record', 'preferences', 'resources', 'text-marc', 'widget' ], function( KohaBackend, Search, Macros, MARCEditor, MARC, Preferences, Resources, TextMARC, Widget ) {
+    var z3950Servers = {
+        'koha:biblioserver': {
+            name: _("Local catalog"),
+            recordtype: 'biblio',
+            checked: false,
+        },
+        [%- FOREACH server = z3950_servers -%]
+            [% server.id %]: {
+                name: '[% server.servername %]',
+                recordtype: '[% server.recordtype %]',
+                checked: [% server.checked ? 'true' : 'false' %],
+            },
+        [%- END -%]
+    };
+
+    // The columns that should show up in a search, in order, and keyed by the corresponding <metadata> tag in the XSL and Pazpar2 config
+    var z3950Labels = [
+               [ "local_number", _("Local number") ],
+               [ "title", _("Title") ],
+               [ "series", _("Series title") ],
+               [ "author", _("Author") ],
+               [ "lccn", _("LCCN") ],
+               [ "isbn", _("ISBN") ],
+               [ "issn", _("ISSN") ],
+               [ "medium", _("Medium") ],
+               [ "edition", _("Edition") ],
+               [ "notes", _("Notes") ],
+    ];
+
+    var state = {
+        backend: '',
+        saveBackend: 'catalog',
+        recordID: undefined
+    };
+
+    var editor;
+    var macroEditor;
+
+    function makeAuthorisedValueWidgets( frameworkCode ) {
+        $.each( KohaBackend.GetAllTagsInfo( frameworkCode ), function( tag, tagInfo ) {
+            $.each( tagInfo.subfields, function( subfield, subfieldInfo ) {
+                if ( !subfieldInfo.authorised_value ) return;
+                var authvals = KohaBackend.GetAuthorisedValues( subfieldInfo.authorised_value );
+                if ( !authvals ) return;
+
+                var defaultvalue = subfield.defaultvalue || authvals[0].value;
+
+                Widget.Register( tag + subfield, {
+                    init: function() {
+                        var $result = $( '<span class="subfield-widget"></span>' );
+
+                        return $result[0];
+                    },
+                    postCreate: function() {
+                        this.setText( defaultvalue );
+
+                        $( '<select></select>' ).appendTo( this.node );
+                        var $node = $( this.node ).find( 'select' );
+                        $.each( authvals, function( undef, authval ) {
+                            $node.append( '<option value="' + authval.value + '"' + (authval.value == defaultvalue ? ' selected="selected"' : '') + '>' + authval.lib + '</option>' );
+                        } );
+                        $node.val( this.text );
+
+                        $node.change( $.proxy( function() {
+                            this.setText( $node.val() );
+                        }, this ) );
+                    },
+                    makeTemplate: function() {
+                        return defaultvalue;
+                    },
+                } );
+            } );
+        } );
+    }
+
+    function bindGlobalKeys() {
+        shortcut.add( 'ctrl+s', function(event) {
+            $( '#save-record' ).click();
+
+            event.preventDefault();
+        } );
+
+        shortcut.add( 'alt+ctrl+k', function(event) {
+            if ( Search.IsAvailable() ) $( '#search-by-keywords' ).focus();
+
+            return false;
+        } );
+
+        shortcut.add( 'alt+ctrl+a', function(event) {
+            if ( Search.IsAvailable() ) $( '#search-by-author' ).focus();
+
+            return false;
+        } );
+
+        shortcut.add( 'alt+ctrl+i', function(event) {
+            if ( Search.IsAvailable() ) $( '#search-by-isbn' ).focus();
+
+            return false;
+        } );
+
+        shortcut.add( 'alt+ctrl+t', function(event) {
+            if ( Search.IsAvailable() ) $( '#search-by-title' ).focus();
+
+            return false;
+        } );
+
+        shortcut.add( 'ctrl+h', function() {
+            var field = editor.getCurrentField();
+
+            if ( !field ) return;
+
+            window.open( getFieldHelpURL( field.tag ) );
+        } );
+
+        $('#quicksearch .search-box').each( function() {
+            shortcut.add( 'enter', $.proxy( function() {
+                var terms = [];
+
+                $('#quicksearch .search-box').each( function() {
+                    if ( !this.value ) return;
+
+                    terms.push( [ $(this).data('qualifier'), this.value ] );
+                } );
+
+                if ( !terms.length ) return;
+
+                if ( Search.Run( z3950Servers, Search.JoinTerms(terms) ) ) {
+                    $("#search-overlay").show();
+                    showResultsBox();
+                }
+
+                return false;
+            }, this), { target: this, type: 'keypress' } );
+        } );
+    }
+
+    function getFieldHelpURL( tag ) {
+        [% IF ( marcflavour == 'MARC21' ) %]
+            if ( tag == '000' ) {
+                return "http://www.loc.gov/marc/bibliographic/bdleader.html";
+            } else if ( tag < '900' ) {
+                return "http://www.loc.gov/marc/bibliographic/bd" + tag + ".html";
+            } else {
+                return "http://www.loc.gov/marc/bibliographic/bd9xx.html";
+            }
+        [% ELSIF ( marcflavour == 'UNIMARC' ) %]
+            /* http://archive.ifla.org/VI/3/p1996-1/ is an outdated version of UNIMARC, but
+               seems to be the only version available that can be linked to per tag.  More recent
+               versions of the UNIMARC standard are available on the IFLA website only as
+               PDFs!
+            */
+            if ( tag == '000' ) {
+               return  "http://archive.ifla.org/VI/3/p1996-1/uni.htm";
+            } else {
+                var first = tag[0];
+                var url = "http://archive.ifla.org/VI/3/p1996-1/uni" + first + ".htm#";
+                if ( first == '0' ) url += "b";
+                if ( first != '9' ) url += field;
+
+                return url;
+            }
+        [% END %]
+    }
+
+    // Record loading
+    var backends = {
+       'new': {
+            titleForRecord: _("Editing new record"),
+            get: function( id, callback ) {
+                record = new MARC.Record();
+                KohaBackend.FillRecord( '', record );
+
+                callback( record );
+            },
+        },
+        'new-full': {
+            titleForRecord: _("Editing new full record"),
+            get: function( id, callback ) {
+                record = new MARC.Record();
+                KohaBackend.FillRecord( '', record, true );
+
+                callback( record );
+            },
+        },
+        'catalog': {
+            titleForRecord: _("Editing catalog record #{ID}"),
+            links: [
+                { title: _("view"), href: "/cgi-bin/koha/catalogue/detail.pl?biblionumber={ID}" },
+                { title: _("edit items"), href: "/cgi-bin/koha/cataloguing/additem.pl?biblionumber={ID}" },
+            ],
+            saveLabel: _("Save to catalog"),
+            get: function( id, callback ) {
+                if ( !id ) return false;
+
+                KohaBackend.GetRecord( id, callback );
+            },
+            save: function( id, record, done ) {
+                function finishCb( data ) {
+                    done( { error: data.error, newRecord: data.marcxml && data.marcxml[0], newId: data.biblionumber && [ 'catalog', data.biblionumber ] } );
+                }
+
+                if ( id ) {
+                    KohaBackend.SaveRecord( id, record, finishCb );
+                } else {
+                    KohaBackend.CreateRecord( record, finishCb );
+                }
+            }
+        },
+        'iso2709': {
+            saveLabel: _("Save as ISO2709 (.mrc) file"),
+            save: function( id, record, done ) {
+                saveAs( new Blob( [record.toISO2709()], { 'type': 'application/octet-stream;charset=utf-8' } ), 'record.mrc' );
+
+                done( {} );
+            }
+        },
+        'marcxml': {
+            saveLabel: _("Save as MARCXML (.xml) file"),
+            save: function( id, record, done ) {
+                saveAs( new Blob( [record.toXML()], { 'type': 'application/octet-stream;charset=utf-8' } ), 'record.xml' );
+
+                done( {} );
+            }
+        },
+        'search': {
+            titleForRecord: _("Editing search result"),
+            get: function( id, callback ) {
+                if ( !id ) return false;
+                if ( !backends.search.records[ id ] ) {
+                    callback( { error: _( "Invalid record" ) } );
+                    return false;
+                }
+
+                callback( backends.search.records[ id ] );
+            },
+            records: {},
+        },
+    };
+
+    function setSource(parts) {
+        state.backend = parts[0];
+        state.recordID = parts[1];
+        state.canSave = backends[ state.backend ].save != null;
+        state.saveBackend = state.canSave ? state.backend : 'catalog';
+
+        var backend = backends[state.backend];
+
+        document.location.hash = '#' + parts[0] + '/' + parts[1];
+
+        $('#title').text( backend.titleForRecord.replace( '{ID}', parts[1] ) );
+
+        $.each( backend.links || [], function( i, link ) {
+            $('#title').append(' <a target="_blank" href="' + link.href.replace( '{ID}', parts[1] ) + '">(' + link.title + ')</a>' );
+        } );
+        $( 'title', document.head ).html( _("Koha &rsaquo; Cataloging &rsaquo; ") + backend.titleForRecord.replace( '{ID}', parts[1] ) );
+        $('#save-record span').text( backends[ state.saveBackend ].saveLabel );
+    }
+
+    function saveRecord( recid, editor, callback ) {
+        var parts = recid.split('/');
+        if ( parts.length != 2 ) return false;
+
+        if ( !backends[ parts[0] ] || !backends[ parts[0] ].save ) return false;
+
+        editor.removeErrors();
+        var record = editor.getRecord();
+
+        if ( record.errors ) {
+            state.saving = false;
+            callback( { error: 'syntax', errors: record.errors } );
+            return;
+        }
+
+        var errors = KohaBackend.ValidateRecord( '', record );
+        if ( errors.length ) {
+            state.saving = false;
+            callback( { error: 'invalid', errors: errors } );
+            return;
+        }
+
+        backends[ parts[0] ].save( parts[1], record, function(data) {
+            state.saving = false;
+
+            if (data.newRecord) {
+                var record = new MARC.Record();
+                record.loadMARCXML(data.newRecord);
+                editor.displayRecord( record );
+            }
+
+            if (data.newId) {
+                setSource(data.newId);
+            } else {
+                setSource( [ state.backend, state.recordID ] );
+            }
+
+            if (callback) callback( data );
+        } );
+    }
+
+    function loadRecord( recid, editor, callback ) {
+        var parts = recid.split('/');
+        if ( parts.length != 2 ) return false;
+
+        if ( !backends[ parts[0] ] || !backends[ parts[0] ].get ) return false;
+
+        backends[ parts[0] ].get( parts[1], function( record ) {
+            if ( !record.error ) {
+                editor.displayRecord( record );
+                editor.focus();
+            }
+
+            if (callback) callback(record);
+        } );
+
+        return true;
+    }
+
+    function openRecord( recid, editor, callback ) {
+        return loadRecord( recid, editor, function ( record ) {
+            setSource( recid.split('/') );
+
+            if (callback) callback( record );
+        } );
+    }
+
+    // Search functions
+    function showAdvancedSearch() {
+        $('#advanced-search-servers').empty();
+        $.each( z3950Servers, function( server_id, server ) {
+            $('#advanced-search-servers').append( '<li data-server-id="' + server_id + '"><input class="search-toggle-server" type="checkbox"' + ( server.checked ? ' checked="checked">' : '>' ) + server.name + '</li>' );
+        } );
+        $('#advanced-search-ui').modal('show');
+    }
+
+    function startAdvancedSearch() {
+        var terms = [];
+
+        $('#advanced-search-ui .search-box').each( function() {
+            if ( !this.value ) return;
+
+            terms.push( [ $(this).data('qualifier'), this.value ] );
+        } );
+
+        if ( !terms.length ) return;
+
+        if ( Search.Run( z3950Servers, Search.JoinTerms(terms) ) ) {
+            $('#advanced-search-ui').modal('hide');
+            $("#search-overlay").show();
+            showResultsBox();
+        }
+    }
+
+    function showResultsBox(data) {
+        $('#search-top-pages, #search-bottom-pages').find('.pagination').empty();
+        $('#searchresults thead tr').empty();
+        $('#searchresults tbody').empty();
+        $('#search-serversinfo').empty().append('<li>' + _("Loading...") + '</li>');
+        $('#search-results-ui').modal('show');
+    }
+
+    function showSearchSorting( sort_key, sort_direction ) {
+        var $th = $('#searchresults thead tr th[data-sort-label="' + sort_key + '"]');
+        $th.parent().find( 'th[data-sort-label]' ).attr( 'class', 'sorting' );
+
+        if ( sort_direction == 'asc' ) {
+            direction = 'asc';
+            $th.attr( 'class', 'sorting_asc' );
+        } else {
+            direction = 'desc';
+            $th.attr( 'class', 'sorting_desc' );
+        }
+    }
+
+    function showSearchResults( editor, data ) {
+        backends.search.records = {};
+
+        $('#searchresults thead tr').empty();
+        $('#searchresults tbody').empty();
+        $('#search-serversinfo').empty();
+
+        $.each( z3950Servers, function( server_id, server ) {
+            var num_fetched = data.num_fetched[server_id];
+
+            if ( data.errors[server_id] ) {
+                num_fetched = data.errors[server_id];
+            } else if ( num_fetched == null ) {
+                num_fetched = '-';
+            } else if ( num_fetched < data.num_hits[server_id] ) {
+                num_fetched += '+';
+            }
+
+            $('#search-serversinfo').append( '<li data-server-id="' + server_id + '"><input class="search-toggle-server" type="checkbox"' + ( server.checked ? ' checked="checked">' : '>' ) + server.name + ' (' + num_fetched + ')' + '</li>' );
+        } );
+
+        var seenColumns = {};
+
+        $.each( data.hits, function( undef, hit ) {
+            $.each( hit.metadata, function(key) {
+                seenColumns[key] = true;
+            } );
+        } );
+
+        $('#searchresults thead tr').append('<th>' + _("Source") + '</th>');
+
+        $.each( z3950Labels, function( undef, label ) {
+            if ( seenColumns[ label[0] ] ) {
+                $('#searchresults thead tr').append( '<th class="sorting" data-sort-label="' + label[0] + '">' + label[1] + '</th>' );
+            }
+        } );
+
+        showSearchSorting( data.sort_key, data.sort_direction );
+
+        $('#searchresults thead tr').append('<th>' + _("Tools") + '</th>');
+
+        $.each( data.hits, function( undef, hit ) {
+            backends.search.records[ hit.server + ':' + hit.index ] = hit.record;
+            hit.id = 'search/' + hit.server + ':' + hit.index;
+
+            var result = '<tr>';
+            result += '<td class="sourcecol">' + z3950Servers[ hit.server ].name + '</td>';
+
+            $.each( z3950Labels, function( undef, label ) {
+                if ( !seenColumns[ label[0] ] ) return;
+
+                if ( hit.metadata[ label[0] ] ) {
+                    result += '<td class="infocol">' + hit.metadata[ label[0] ] + '</td>';
+                } else {
+                    result += '<td class="infocol">&nbsp;</td>';
+                }
+            } );
+
+            result += '<td class="toolscol"><ul><li><a href="#" class="marc-link">' + _("View MARC") + '</a></li>';
+            result += '<li><a href="#" class="open-link">' + _("Import") + '</a></li>';
+            if ( state.canSave ) result += '<li><a href="#" class="substitute-link" title="' + _("Replace the current record's contents") + '">' + _("Substitute") + '</a></li>';
+            result += '</ul></td></tr>';
+
+            var $tr = $( result );
+            $tr.find( '.marc-link' ).click( function() {
+                var $info_columns = $tr.find( '.infocol' );
+                var $marc_column = $tr.find( '.marccol' );
+
+                if ( !$marc_column.length ) {
+                    $marc_column = $( '<td class="marccol" colspan="' + $info_columns.length + '"></td>' ).insertAfter( $info_columns.eq(-1) ).hide();
+                    CodeMirror.runMode( TextMARC.RecordToText( hit.record ), 'marc', $marc_column[0] );
+                }
+
+                if ( $marc_column.is(':visible') ) {
+                    $tr.find('.marc-link').text( _("View MARC") );
+                    $info_columns.show();
+                    $marc_column.hide();
+                } else {
+                    $tr.find('.marc-link').text( _("Hide MARC") );
+                    $marc_column.show();
+                    $info_columns.hide();
+                }
+
+                return false;
+            } );
+            $tr.find( '.open-link' ).click( function() {
+                $( '#search-results-ui' ).modal('hide');
+                openRecord( hit.id, editor );
+
+                return false;
+            } );
+            $tr.find( '.substitute-link' ).click( function() {
+                $( '#search-results-ui' ).modal('hide');
+                loadRecord( hit.id, editor );
+
+                return false;
+            } );
+            $('#searchresults tbody').append( $tr );
+        } );
+
+        var pages = [];
+        var cur_page = data.offset / data.page_size;
+        var max_page = Math.ceil( data.total_fetched / data.page_size ) - 1;
+
+        if ( cur_page != 0 ) {
+            pages.push( '<li><a class="search-nav" href="#" data-offset="' + (data.offset - data.page_size) + '">&laquo; ' + _("Previous") + '</a></li>' );
+        }
+
+        for ( var page = Math.max( 0, cur_page - 9 ); page <= Math.min( max_page, cur_page + 9 ); page++ ) {
+            if ( page == cur_page ) {
+                pages.push( ' <li class="active"><a href="#">' + ( page + 1 ) + '</a></li>' );
+            } else {
+                pages.push( ' <li><a class="search-nav" href="#" data-offset="' + ( page * data.page_size ) + '">' + ( page + 1 ) + '</a></li>' );
+            }
+        }
+
+        if ( cur_page < max_page ) {
+            pages.push( ' <li><a class="search-nav" href="#" data-offset="' + (data.offset + data.page_size) + '">' + _("Next") + ' &raquo;</a></li>' );
+        }
+
+        if ( pages.length > 1 ) $( '#search-top-pages, #search-bottom-pages' ).find( '.pagination' ).html( '<ul>' + pages.join( '' ) + '</ul>');
+
+        var $overlay = $('#search-overlay');
+        $overlay.find('span').text(_("Loading"));
+        $overlay.find('.bar').css( { display: 'block', width: 100 * ( 1 - data.activeclients / Search.includedServers.length ) + '%' } );
+
+        if ( data.activeclients ) {
+            $overlay.find('.bar').css( { display: 'block', width: 100 * ( 1 - data.activeclients / Search.includedServers.length ) + '%' } );
+            $overlay.show();
+        } else {
+            $overlay.find('.bar').css( { display: 'block', width: '100%' } );
+            $overlay.fadeOut();
+        }
+    }
+
+    function invalidateSearchResults() {
+        var $overlay = $('#search-overlay');
+        $overlay.find('span').text(_("Search expired, please try again"));
+        $overlay.find('.bar').css( { display: 'none' } );
+        $overlay.show();
+    }
+
+    function handleSearchError(error) {
+        if (error.code == 1) {
+            invalidateSearchResults();
+            Search.Reconnect();
+        } else {
+            humanMsg.displayMsg( _("<h3>Internal search error</h3>") + '<p>' + error + '</p>' + _("<p>Please <b>refresh</b> the page and try again."), { className: 'humanError' } );
+        }
+    }
+
+    function handleSearchInitError(error) {
+        $('#quicksearch-overlay').fadeIn().find('p').text(error);
+    }
+
+    // Preference functions
+    function showPreference( pref ) {
+        var value = Preferences.user[pref];
+
+        switch (pref) {
+            case 'fieldWidgets':
+                $( '#set-field-widgets' ).text( value ? _("Show fields verbatim") : _("Show helpers for fixed and coded fields") );
+                break;
+            case 'font':
+                $( '#editor .CodeMirror' ).css( { fontFamily: value } );
+                editor.refresh();
+                break;
+            case 'fontSize':
+                $( '#editor .CodeMirror' ).css( { fontSize: value } );
+                editor.refresh();
+                break;
+            case 'macros':
+                showSavedMacros();
+                break;
+            case 'selected_search_targets':
+                $.each( z3950Servers, function( server_id, server ) {
+                    var saved_val = Preferences.user.selected_search_targets[server_id];
+
+                    if ( saved_val != null ) server.checked = saved_val;
+                } );
+                break;
+        }
+    }
+
+    function bindPreference( editor, pref ) {
+        function _addHandler( sel, event, handler ) {
+            $( sel ).on( event, function (e) {
+                e.preventDefault();
+                handler( e, Preferences.user[pref] );
+                Preferences.Save( [% USER_INFO.0.borrowernumber %] );
+                showPreference(pref);
+            } );
+        }
+
+        switch (pref) {
+            case 'fieldWidgets':
+                _addHandler( '#set-field-widgets', 'click', function( e, oldValue ) {
+                    editor.setUseWidgets( Preferences.user.fieldWidgets = !Preferences.user.fieldWidgets );
+                } );
+                break;
+            case 'font':
+                _addHandler( '#prefs-menu .set-font', 'click', function( e, oldValue ) {
+                    Preferences.user.font = $( e.target ).css( 'font-family' );
+                } );
+                break;
+            case 'fontSize':
+                _addHandler( '#prefs-menu .set-fontSize', 'click', function( e, oldValue ) {
+                    Preferences.user.fontSize = $( e.target ).css( 'font-size' );
+                } );
+                break;
+            case 'selected_search_targets':
+                $( document ).on( 'change', 'input.search-toggle-server', function() {
+                    var server_id = $( this ).parent().data('server-id');
+                    Preferences.user.selected_search_targets[server_id] = this.checked;
+                    Preferences.Save( [% USER_INFO.0.borrowernumber %] );
+                } );
+                break;
+        }
+    }
+
+    function displayPreferences( editor ) {
+        $.each( Preferences.user, function( pref, value ) {
+            showPreference( pref );
+            bindPreference( editor, pref );
+        } );
+    }
+
+    //> Macro functions
+    function loadMacro( name ) {
+        $( '#macro-list li' ).removeClass( 'active' );
+        macroEditor.activeMacro = name;
+
+        if ( !name ) {
+            macroEditor.setValue( '' );
+            return;
+        }
+
+        $( '#macro-list li[data-name="' + name + '"]' ).addClass( 'active' );
+        var macro = Preferences.user.macros[name];
+        macroEditor.setValue( macro.contents );
+        $( '#macro-format' ).val( macro.format || 'its' );
+        if ( macro.history ) macroEditor.setHistory( macro.history );
+    }
+
+    function storeMacro( name, macro ) {
+        if ( macro ) {
+            Preferences.user.macros[name] = macro;
+        } else {
+            delete Preferences.user.macros[name];
+        }
+
+        Preferences.Save( [% USER_INFO.0.borrowernumber %] );
+    }
+
+    function showSavedMacros( macros ) {
+        var scrollTop = $('#macro-list').scrollTop();
+        $( '#macro-list' ).empty();
+        var macro_list = $.map( Preferences.user.macros, function( macro, name ) {
+            return $.extend( { name: name }, macro );
+        } );
+        macro_list.sort( function( a, b ) {
+            return a.name.localeCompare(b.name);
+        } );
+        $.each( macro_list, function( undef, macro ) {
+            var $li = $( '<li data-name="' + macro.name + '"><a href="#">' + macro.name + '</a><ol class="macro-info"></ol></li>' );
+            $li.click( function() {
+                loadMacro(macro.name);
+                return false;
+            } );
+            if ( macro.name == macroEditor.activeMacro ) $li.addClass( 'active' );
+            var modified = macro.modified && new Date(macro.modified);
+            $li.find( '.macro-info' ).append(
+                '<li><span class="label">' + _("Last changed:") + '</span>' +
+                ( modified ? modified.toLocaleFormat() : _("never") ) + '</li>'
+            );
+            $('#macro-list').append($li);
+        } );
+        var $new_li = $( '<li class="new-macro"><a href="#">' + _("New macro...") + '</a></li>' );
+        $new_li.click( function() {
+            // TODO: make this a bit less retro
+            var name = prompt(_("Please enter the name for the new macro:"));
+            if (!name) return;
+
+            if ( !Preferences.user.macros[name] ) storeMacro( name, { format: "rancor", contents: "" } );
+            showSavedMacros();
+            loadMacro( name );
+        } );
+        $('#macro-list').append($new_li);
+        $('#macro-list').scrollTop(scrollTop);
+    }
+
+    function saveMacro() {
+        var name = macroEditor.activeMacro;
+
+        if ( !name || macroEditor.savedGeneration == macroEditor.changeGeneration() ) return;
+
+        macroEditor.savedGeneration = macroEditor.changeGeneration();
+        storeMacro( name, { contents: macroEditor.getValue(), modified: (new Date()).valueOf(), history: macroEditor.getHistory(), format: $('#macro-format').val() } );
+        $('#macro-save-message').text(_("Saved"));
+        showSavedMacros();
+    }
+
+    $(document).ready( function() {
+        // Editor setup
+        editor = new MARCEditor( {
+            onCursorActivity: function() {
+                $('#status-tag-info').empty();
+                $('#status-subfield-info').empty();
+
+                var field = editor.getCurrentField();
+                var cur = editor.getCursor();
+
+                if ( !field ) return;
+
+                var taginfo = KohaBackend.GetTagInfo( '', field.tag );
+                $('#status-tag-info').html( '<strong>' + field.tag + ':</strong> ' );
+
+                if ( taginfo ) {
+                    $('#status-tag-info').append( '<a href="' + getFieldHelpURL( field.tag ) + '" target="_blank" class="show-field-help" title="' + _("Show help for this tag") + '">[?]</a> '  + taginfo.lib );
+
+                    var subfield = field.getSubfieldAt( cur.ch );
+                    if ( !subfield ) return;
+
+                    var subfieldinfo = taginfo.subfields[ subfield.code ];
+                    $('#status-subfield-info').html( '<strong>$' + subfield.code + ':</strong> ' );
+
+                    if ( subfieldinfo ) {
+                        $('#status-subfield-info').append( subfieldinfo.lib );
+                    } else {
+                        $('#status-subfield-info').append( '<em>' + _("Unknown subfield") + '</em>' );
+                    }
+                } else {
+                    $('#status-tag-info').append( '<em>' + _("Unknown tag") + '</em>' );
+                }
+            },
+            position: function (elt) { $(elt).insertAfter('#toolbar') },
+        } );
+
+        macroEditor = CodeMirror(
+            $('#macro-editor')[0],
+            {
+                mode: 'null',
+                lineNumbers: true,
+            }
+        );
+
+        // Automatically detect resizes and change the height of the editor and position of modals.
+        var resizeTimer = null;
+        $( window ).resize( function() {
+            if ( resizeTimer == null ) resizeTimer = setTimeout( function() {
+                resizeTimer = null;
+
+                var pos = $('#editor .CodeMirror').position();
+                $('#editor .CodeMirror').height( $(window).height() - pos.top - 24 - $('#changelanguage').height() ); // 24 is hardcoded value but works well
+
+                $('.modal-body').each( function() {
+                    $(this).height( $(window).height() * .8 - $(this).prevAll('.modal-header').height() );
+                } );
+            }, 100);
+
+            $("#advanced-search-ui, #search-results-ui, #macro-ui").css( {
+                marginLeft: function() {
+                    return -($(this).width() / 2);
+                }
+            } );
+
+        } ).resize();
+
+        var saveableBackends = [];
+        $.each( backends, function( id, backend ) {
+            if ( backend.save ) saveableBackends.push( [ backend.saveLabel, id ] );
+        } );
+        saveableBackends.sort();
+        $.each( saveableBackends, function( undef, backend ) {
+            $( '#save-dropdown' ).append( '<li><a href="#" data-backend="' + backend[1] + '">' + backend[0] + '</a></li>' );
+        } );
+
+        var macro_format_list = $.map( Macros.formats, function( format, name ) {
+            return $.extend( { name: name }, format );
+        } );
+        macro_format_list.sort( function( a, b ) {
+            return a.description.localeCompare(b.description);
+        } );
+        $.each( macro_format_list, function() {
+            $('#macro-format').append( '<option value="' + this.name + '">' + this.description + '</option>' );
+        } );
+
+        // Click bindings
+        $( '#save-record, #save-dropdown a' ).click( function() {
+            $( '#save-record' ).find('i').attr( 'class', 'icon-loading' ).siblings( 'span' ).text( _("Saving...") );
+
+            function finishCb(result) {
+                if ( result.error == 'syntax' ) {
+                    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 ) {
+                    humanMsg.displayAlert( _("Record saved "), { className: 'humanSuccess' } );
+                }
+
+                $.each( result.errors || [], function( undef, error ) {
+                    switch ( error.type ) {
+                        case 'noTag':
+                            editor.addError( error.line, _("Invalid tag number") );
+                            break;
+                        case 'noIndicators':
+                            editor.addError( error.line, _("Invalid indicators") );
+                            break;
+                        case 'missingTag':
+                            editor.addError( null, _("Missing mandatory tag: ") + error.tag );
+                            break;
+                        case 'missingSubfield':
+                            if ( error.subfield == '@' ) {
+                                editor.addError( error.line, _("Missing control field contents") );
+                            } else {
+                                editor.addError( error.line, _("Missing mandatory subfield: $") + error.subfield );
+                            }
+                            break;
+                        case 'unrepeatableTag':
+                            editor.addError( error.line, _("Tag ") + error.tag + _(" cannot be repeated") );
+                            break;
+                        case 'unrepeatableSubfield':
+                            editor.addError( error.line, _("Subfield $") + error.subfield + _(" cannot be repeated") );
+                            break;
+                    }
+                } );
+
+                $( '#save-record' ).find('i').attr( 'class', 'icon-hdd' );
+
+                if ( result.error ) {
+                    // Reset backend info
+                    setSource( [ state.backend, state.recordID ] );
+                }
+            }
+
+            var backend = $( this ).data( 'backend' ) || ( state.saveBackend );
+            if ( state.backend == backend ) {
+                saveRecord( backend + '/' + state.recordID, editor, finishCb );
+            } else {
+                saveRecord( backend + '/', editor, finishCb );
+            }
+
+            return false;
+        } );
+
+        $('#import-records').click( function() {
+            $('#import-records-input')
+                .off('change')
+                .change( function() {
+                    if ( !this.files || !this.files.length ) return;
+
+                    var file = this.files[0];
+                    var reader = new FileReader();
+
+                    reader.onload = function() {
+                        var record = new MARC.Record();
+
+                        if ( /\.mrc$/.test( file.name ) ) {
+                            record.loadISO2709( reader.result );
+                        } else if ( /\.xml$/.test( file.name ) ) {
+                            record.loadMARCXML( reader.result );
+                        } else {
+                            humanMsg.displayAlert( _("Unknown record type, cannot import"), { className: 'humanError' } );
+                            return;
+                        }
+
+                        editor.displayRecord( record );
+                    };
+
+                    reader.readAsText( file );
+                } )
+                .click();
+
+            return false;
+        } );
+
+        $('#open-macros').click( function() {
+            $('#macro-ui').modal('show');
+
+            return false;
+        } );
+
+        $('#run-macro').click( function() {
+            var result = Macros.Run( editor, $('#macro-format').val(), macroEditor.getValue() );
+
+            if ( !result.errors.length ) {
+                $('#macro-ui').modal('hide');
+                return false;
+            }
+
+            var errors = [];
+            $.each( result.errors, function() {
+                var error = '<b>' + _("Line ") + (this.line + 1) + ':</b> ';
+
+                switch ( this.error ) {
+                    case 'failed': error += _("failed to run"); break;
+                    case 'unrecognized': error += _("unrecognized command"); break;
+                }
+
+                errors.push(error);
+            } );
+
+            humanMsg.displayMsg( _("<h3>Failed to run macro:</h3>") + '<ul><li>' + errors.join('</li><li>') + '</li></ul>', { className: 'humanError' } );
+
+            return false;
+        } );
+
+        $('#delete-macro').click( function() {
+            if ( !macroEditor.activeMacro || !confirm( _("Are you sure you want to delete this macro?") ) ) return;
+
+            storeMacro( macroEditor.activeMacro, undefined );
+            showSavedMacros();
+            loadMacro( undefined );
+
+            return false;
+        } );
+
+        var saveTimeout;
+        macroEditor.on( 'change', function( cm, change ) {
+            $('#macro-save-message').empty();
+            if ( change.origin == 'setValue' ) return;
+
+            if ( saveTimeout ) clearTimeout( saveTimeout );
+            saveTimeout = setTimeout( function() {
+                saveMacro();
+
+                saveTimeout = null;
+            }, 500 );
+        } );
+
+        $( '#switch-editor' ).click( function() {
+            if ( !confirm( _("Any changes will not be saved. Continue?") ) ) return;
+
+            $.cookie( 'catalogue_editor_[% USER_INFO.0.borrowernumber %]', 'basic', { expires: 365, path: '/' } );
+
+            if ( state.backend == 'catalog' ) {
+                window.location = '/cgi-bin/koha/cataloguing/addbiblio.pl?biblionumber=' + state.recordID;
+            } else if ( state.backend == 'new' ) {
+                window.location = '/cgi-bin/koha/cataloguing/addbiblio.pl';
+            } else {
+                humanMsg.displayAlert( _("Cannot open this record in the basic editor"), { className: 'humanError' } );
+            }
+        } );
+
+        $( '#show-advanced-search' ).click( function() {
+            showAdvancedSearch();
+
+            return false;
+        } );
+
+        $('#advanced-search').submit( function() {
+            startAdvancedSearch();
+
+            return false;
+        } );
+
+        $( document ).on( 'click', 'a.search-nav', function() {
+            $("#search-overlay").show();
+            Search.Fetch( { offset: $( this ).data( 'offset' ) } );
+            return false;
+        });
+
+        $( document ).on( 'click', 'th[data-sort-label]', function() {
+            $("#search-overlay").show();
+            var direction;
+
+            if ( $( this ).hasClass( 'sorting_asc' ) ) {
+                direction = 'desc';
+            } else {
+                direction = 'asc';
+            }
+
+            showSearchSorting( $( this ).data( 'sort-label' ), direction );
+
+            Search.Fetch( { sort_key: $( this ).data( 'sort-label' ), sort_direction: direction } );
+            return false;
+        });
+
+        $( document ).on( 'change', 'input.search-toggle-server', function() {
+            var server = z3950Servers[ $( this ).parent().data('server-id') ];
+            server.checked = this.checked;
+
+            if ( $('#search-results-ui').is( ':visible' ) ) {
+                $("#search-overlay").show();
+                Search.Fetch();
+            }
+        } );
+
+        // Key bindings
+        bindGlobalKeys();
+
+        // Setup UI
+        $("#advanced-search-ui, #search-results-ui, #macro-ui").each( function() {
+            $(this).modal({ show: false });
+        } );
+
+        var $quicksearch = $('#quicksearch fieldset');
+        $('<div id="quicksearch-overlay"><h3>' + _("Search unavailable") + '</h3> <p></p></div>').css({
+            position: 'absolute',
+            top: $quicksearch.offset().top,
+            left: $quicksearch.offset().left,
+            height: $quicksearch.outerHeight(),
+            width: $quicksearch.outerWidth(),
+        }).appendTo(document.body).hide();
+
+        var prevAlerts = [];
+        humanMsg.logMsg = function(msg, options) {
+            $('#show-alerts').popover('hide');
+            prevAlerts.unshift('<li>' + msg + '</li>');
+            prevAlerts.splice(5, 999); // Truncate old messages
+        };
+
+        $('#show-alerts').popover({
+            html: true,
+            placement: 'bottom',
+            content: function() {
+                return '<div id="alerts-container"><ul>' + prevAlerts.join('') + '</ul></div>';
+            },
+        });
+        $('#new-record' ).click( function() {
+            openRecord( 'new/', editor );
+            return false;
+        } );
+
+        // Start editor
+        Preferences.Load( [% USER_INFO.0.borrowernumber || 0 %] );
+        displayPreferences(editor);
+        makeAuthorisedValueWidgets( '' );
+        Search.Init( {
+            page_size: 20,
+            onresults: function(data) { showSearchResults( editor, data ) },
+            onerror: handleSearchError,
+        } );
+
+        function finishCb( data ) {
+            if ( data.error ) openRecord( 'new/', editor, finishCb );
+
+            Resources.GetAll().done( function() {
+                $("#loading").hide();
+                editor.focus();
+            } );
+        }
+
+        if ( "[% auth_forwarded_hash %]" ) {
+            document.location.hash = "[% auth_forwarded_hash %]";
+        }
+
+        if ( !document.location.hash || !openRecord( document.location.hash.slice(1), editor, finishCb ) ) {
+            openRecord( 'new/', editor, finishCb );
+        }
+    } );
+} )();
+
+</script>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-widgets-marc21.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/cateditor-widgets-marc21.inc
new file mode 100644 (file)
index 0000000..bfba1ba
--- /dev/null
@@ -0,0 +1,158 @@
+<div id="editor-widget-templates" style="display:none">
+    <div id="widget-leader">
+        Leader:&nbsp;<span title="Record length (autogenerated)">#####</span>
+        <select name="f5" title="Record status">
+            <option value="a">a - Increase in encoding level</option>
+            <option value="c">c - Corrected or revised</option>
+            <option value="d">d - Deleted</option>
+            <option value="n">n - New</option>
+            <option value="p">p - Increase in encoding level from prepublication</option>
+        </select>
+        <select name="f6" title="Type of record">
+            <option value="a">a - Language material</option>
+            <option value="c">c - Notated music</option>
+            <option value="d">d - Manuscript notated music</option>
+            <option value="e">e - Cartographic material</option>
+            <option value="f">f - Manuscript cartographic material</option>
+            <option value="g">g - Projected medium</option>
+            <option value="i">i - Nonmusical sound recording</option>
+            <option value="j">j - Musical sound recording</option>
+            <option value="k">k - Two-dimensional nonprojectable graphic</option>
+            <option value="m">m - Computer file</option>
+            <option value="o">o - Kit</option>
+            <option value="p">p - Mixed materials</option>
+            <option value="r">r - Three-dimensional artifact or naturally occurring object</option>
+            <option value="t">t - Manuscript language material</option>
+        </select>
+        <select name="f7" title="Bibliographic level">
+            <option value="a">a - Monographic component part</option>
+            <option value="b">b - Serial component part</option>
+            <option value="c">c - Collection</option>
+            <option value="d">d - Subunit</option>
+            <option value="i">i - Integrating resource</option>
+            <option value="m">m - Monograph/item</option>
+            <option value="s">s - Serial</option>
+        </select>
+        <select name="f8" title="Type of control">
+                <option value=" ">_ - No specific type</option>
+                <option value="a">a - Archival</option>
+        </select>
+        <span title="Encoding (forced Unicode)">a</span>
+        <span title="Indicator/subfield lengths">22</span>
+        <span title="Data base address (autogenerated)">#####</span>
+        <select name="f17" title="Encoding level">
+            <option value=" ">_ - Full level</option>
+            <option value="1">1 - Full level, material not examined</option>
+            <option value="2">2 - Less-than-full level, material not examined</option>
+            <option value="3">3 - Abbreviated level</option>
+            <option value="4">4 - Core level</option>
+            <option value="5">5 - Partial (preliminary) level</option>
+            <option value="7">7 - Minimal level</option>
+            <option value="8">8 - Prepublication level</option>
+            <option value="u">u - Unknown</option>
+            <option value="z">z - Not applicable</option>
+        </select>
+        <select name="f18" title="Descriptive cataloging form">
+            <option value=" ">_ - Non-ISBD</option>
+            <option value="a">a - AACR 2</option>
+            <option value="c">c - ISBD punctuation omitted</option>
+            <option value="i">i - ISBD punctuation included</option>
+            <option value="u">u - Unknown</option>
+        </select>
+        <select name="f19" title="Multipart record resource level">
+            <option value=" ">_ - Not specified or not applicable</option>
+            <option value="a">a - Set</option>
+            <option value="b">b - Part with independent title</option>
+            <option value="c">c - Part with dependent title</option>
+        </select>
+        <span title="Length of directory elements">4500</span>
+    </div>
+</div>
+
+<script>
+
+/**
+ * Each widget should provide one to three methods:
+ *   init( text ): Returns the DOM node for this widget.
+ *   postCreate( node, mark ): Optional, called once the mark has been created
+ *                             and the node shown. Bind event handlers here.
+ *   makeTemplate(): Optional, should return some sane default contents for a
+ *                   newly created field/subfield. '<empty>' will be used if this
+ *                   method is unset.
+ *
+ * Following the Koha convention, control fields are defined as tags with a
+ * single subfield, '@'.
+ */
+
+require( [ 'widget' ], function( Widget ) {
+    Widget.Register( '000@', {
+        makeTemplate: function() {
+            return '     nam a22     7a 4500';
+        },
+        init: function() {
+            var $result = $( '<span class="subfield-widget fixed-widget"></span>' );
+
+            return $result[0];
+        },
+        postCreate: function() {
+            // Clear the length and directory start fields; these are unnecessary for MARCXML and will be filled in upon USMARC export
+            this.setFixed( 0, 5, '     ' );
+            this.setFixed( 9, 17, 'a22     ' );
+            this.setFixed( 20, 24, '4500' );
+
+            this.insertTemplate( '#widget-leader' );
+
+            this.bindFixed( '[name=f5]', 5, 6 );
+            this.bindFixed( '[name=f6]', 6, 7 );
+            this.bindFixed( '[name=f7]', 7, 8 );
+            this.bindFixed( '[name=f8]', 8, 9 );
+            this.bindFixed( '[name=f17]', 17, 18 );
+            this.bindFixed( '[name=f18]', 18, 19 );
+            this.bindFixed( '[name=f19]', 19, 20 );
+        },
+    } );
+
+    Widget.Register( '005@', {
+        init: function() {
+            var $result = $( '<span class="subfield-widget fixed-widget">Updated: </span>' );
+
+            return $result[0];
+        },
+        postCreate: function( node, mark ) {
+            var parts = this.text.match( /(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d)/ );
+
+            if ( parts ) {
+                var dateVal = new Date(
+                    parseInt( parts[1] ), // Year
+                    parseInt( parts[2] ) - 1, // Month (0-11)
+                    parseInt( parts[3] ), // Day
+                    parseInt( parts[4] ), // Hour
+                    parseInt( parts[5] ), // Minute
+                    parseInt( parts[6] ), // Second
+                    parseInt( parts[7] ) * 100 // Millisecond
+                );
+
+                $( this.node ).append( dateVal.toLocaleString() );
+            } else {
+                $( this.node ).append( '<span class="hint">unset</span>' );
+            }
+        }
+    } );
+
+    Widget.Register( '008@', {
+        makeTemplate: function() {
+            var now = new Date();
+            return Widget.PadNum( now.getYear() % 100, 2 ) + Widget.PadNum( now.getMonth() + 1, 2 ) + Widget.PadNum( now.getDate(), 2 ) + "b        xxu||||| |||| 00| 0 [% DefaultLanguageField008 %] d";
+        },
+        init: function() {
+            var $result = $( '<span class="subfield-widget fixed-widget">Fixed data:<span class="hint widget-loading">Loading...</span></span>' );
+
+            return $result[0];
+        },
+        postCreate: function( node, mark ) {
+            this.createFromXML( 'marc21/xml/008' );
+        }
+    } );
+} );
+
+</script>
index 3b2e2f7..0482478 100644 (file)
@@ -7,6 +7,7 @@
 [% IF ( circulation ) %]<li class="active">[% ELSE %]<li>[% END %]<a title="Circulation" href="/cgi-bin/koha/admin/preferences.pl?tab=circulation">Circulation</a></li>
 [% IF ( enhanced_content ) %]<li class="active">[% ELSE %]<li>[% END %]<a title="Enhanced content settings" href="/cgi-bin/koha/admin/preferences.pl?tab=enhanced_content">Enhanced content</a></li>
 [% IF ( i18n_l10n ) %]<li class="active">[% ELSE %]<li>[% END %]<a title="Internationalization and localization" href="/cgi-bin/koha/admin/preferences.pl?tab=i18n_l10n">I18N/L10N</a></li>
+[% IF ( labs ) %]<li class="active">[% ELSE %]<li>[% END %]<a title="Experimental features" href="/cgi-bin/koha/admin/preferences.pl?tab=labs">Labs</a></li>
 [% IF ( local_use ) %]<li class="active">[% ELSE %]<li>[% END %]<a href="/cgi-bin/koha/admin/systempreferences.pl">Local use</a></li>
 [% IF ( logs ) %]<li class="active">[% ELSE %]<li>[% END %]<a title="Transaction logs" href="/cgi-bin/koha/admin/preferences.pl?tab=logs">Logs</a></li>
 [% IF ( opac ) %]<li class="active">[% ELSE %]<li>[% END %]<a title="Online Public Access Catalog" href="/cgi-bin/koha/admin/preferences.pl?tab=opac">OPAC</a></li>
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/labs.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/labs.pref
new file mode 100644 (file)
index 0000000..c6dc285
--- /dev/null
@@ -0,0 +1,11 @@
+Labs:
+    All:
+        -
+            - pref: EnableAdvancedCatalogingEditor
+              default: 0
+              choices:
+                  yes: Enable
+                  no: "Don't enable"
+            - the advanced cataloging editor.
+            - "<br/> NOTE:"
+            - This feature is currently experimental, and may have bugs that cause corruption of records. Please help us test it and report any bugs, but do so at your own risk.
index f1bcc33..32d3d06 100644 (file)
             redirect("just_save", tab);
             return false;
         });
-    });
+
+        $( '#switcheditor' ).click( function() {
+            var breedingid = [% breedingid || "null" %];
+
+            if ( !confirm( breedingid ? _("This record cannot be transferred to the advanced editor. Continue?") : _("Any changes will not be saved. Continue?") ) ) return false;
+
+            $.cookie( 'catalogue_editor_[% USER_INFO.0.borrowernumber %]', 'advanced', { expires: 365, path: '/' } );
+
+            var biblionumber = [% biblionumber || "null" %];
+
+            if ( biblionumber ) {
+                window.location = '/cgi-bin/koha/cataloguing/editor.pl#catalog:' + biblionumber;
+            } else {
+                window.location = '/cgi-bin/koha/cataloguing/editor.pl';
+            }
+
+            return false;
+        } );
+
+       });
 
 function redirect(dest){
     $("#redirect").attr("value",dest);
@@ -462,6 +481,9 @@ function Changefwk(FwkList) {
 
     [% UNLESS (circborrowernumber) %][%# Hide in fast cataloging %]
         <div class="btn-group"><a class="btn btn-small" href="#" id="z3950search"><i class="fa fa-search"></i> Z39.50/SRU search</a></div>
+        [% IF Koha.Preference( 'EnableAdvancedCatalogingEditor' ) == 1 %]
+            <div class="btn-group"><a href="#" id="switcheditor" class="btn btn-small">Switch to advanced editor</a></div>
+        [% END %]
         [% IF (biblionumber) %]
             [% IF ( BiblioDefaultViewmarc ) %]
                 <div class="btn-group">
index 35f35e9..9ef3543 100644 (file)
@@ -1,3 +1,4 @@
+[% USE Koha %]
 [% INCLUDE 'doc-head-open.inc' %]
 <title>Koha &rsaquo; Cataloging</title>
 [% INCLUDE 'greybox.inc' %]
             e.preventDefault();
             MergeItems();
         });
+
+        $("#useadvanced").click(function(){
+            $.cookie( 'catalogue_editor_[% USER_INFO.0.borrowernumber %]', 'advanced', { expires: 365, path: '/' } );
+            return true;
+        });
      });
 
     /* this function open a popup to search on z3950 server.  */
@@ -70,6 +76,9 @@
 
 [% IF ( CAN_user_editcatalogue_edit_catalogue ) %]
   <div id="toolbar" class="btn-toolbar">
+        [% IF Koha.Preference( 'EnableAdvancedCatalogingEditor' ) == 1 %]
+            <a id="useadvanced" href="/cgi-bin/koha/cataloguing/editor.pl" class="btn btn-small"><i class="icon-edit"></i> Advanced editor</a>
+        [% END %]
         <div class="btn-group">
             <button class="btn btn-small dropdown-toggle" data-toggle="dropdown"><i class="fa fa-plus"></i> New record <span class="caret"></span></button>
             <ul class="dropdown-menu">
diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/editor.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/cataloguing/editor.tt
new file mode 100644 (file)
index 0000000..0991dde
--- /dev/null
@@ -0,0 +1,234 @@
+[% INCLUDE 'doc-head-open.inc' %]
+<title>Koha &rsaquo; Cataloging &rsaquo; Editor</title>
+[% INCLUDE 'doc-head-close.inc' %]
+<link rel="stylesheet" href="[% themelang %]/css/cateditor.css" />
+<link rel="stylesheet" href="[% themelang %]/css/datatables.css" />
+<link rel="stylesheet" href="/intranet-tmpl/lib/codemirror/codemirror.css" />
+<link rel="stylesheet" href="[% themelang %]/css/humanmsg.css" />
+<script src="[% interface %]/lib/jquery/plugins/humanmsg.js" type="text/javascript"></script>
+[% IF ( bidi ) %]
+   <link rel="stylesheet" type="text/css" href="[% themelang %]/css/right-to-left.css" />
+[% END %]
+</head>
+<body id="cat_addbiblio" class="cat">
+
+   <div id="loading">
+       <div>Loading, please wait...</div>
+   </div>
+
+[% INCLUDE 'header.inc' %]
+
+<div id="breadcrumbs"><a href="/cgi-bin/koha/mainpage.pl">Home</a> &rsaquo; <a href="/cgi-bin/koha/cataloguing/addbooks.pl">Cataloging</a> &rsaquo; Editor</div>
+
+<div id="doc3" class="yui-t2">
+<div id="bd">
+
+<h1 id="title">Cataloging editor</h1>
+
+<div id="yui-main"><div class="yui-b">
+
+<div id="editor">
+    <input id="import-records-input" type="file" style="display: none">
+    <div id="toolbar" class="btn-toolbar">
+        <button class="btn btn-small" id="new-record" title="Open fresh record"><i class="icon-plus"></i> <span>New record</span></button>
+        <div class="btn-group">
+            <button class="btn btn-small" id="save-record" title="Save current record (Ctrl-S)"><i class="icon-hdd"></i> <span>Save</span></button>
+            <button class="btn btn-small dropdown-toggle" data-toggle="dropdown">
+            <span class="caret"></span>
+            </button>
+            <ul class="dropdown-menu" id="save-dropdown">
+            </ul>
+        </div>
+        <button class="btn btn-small" id="import-records" title="Import an ISO2709 or MARCXML record"><i class="icon-upload"></i> <span>Import record...</span></button>
+        <button class="btn btn-small" id="open-macros" title="Run and edit macros"><i class="icon-play"></i> <span>Macros...</span></button>
+        <div class="btn-group">
+            <button class="btn btn-small dropdown-toggle" data-toggle="dropdown"><i class="icon-cog"></i> Settings <span class="caret"></span></button>
+            <ul id="prefs-menu" class="dropdown-menu">
+                <li><a id="switch-editor" href="#">Switch to basic editor</a></li>
+                <li><a id="set-field-widgets" href="#"></a></li>
+                <li class="divider"></li>
+                <li><a class="set-fontSize" style="font-size: .92em" href="#">Small text</a></li>
+                <li><a class="set-fontSize" style="font-size: 1em" href="#">Normal text</a></li>
+                <li><a class="set-fontSize" style="font-size: 1.08em" href="#">Large text</a></li>
+                <li><a class="set-fontSize" style="font-size: 1.18em" href="#">Huge text</a></li>
+                <li class="divider"></li>
+                <li><a class="set-font" style="font-family: monospace" href="#">Default font</a></li>
+                <li><a class="set-font" style="font-family: 'Courier New'" href="#">Courier New</a></li>
+                <li><a class="set-font" style="font-family: peep" href="#">peep</a></li>
+            </ul>
+        </div>
+        <button class="btn btn-small" id="show-alerts" title="Previous alerts"><i class="icon-info-sign"></i> Alerts <span class="caret"></span></button>
+    </div>
+    [%# CodeMirror instance will be inserted here %]
+    <div id="statusbar">
+        <div id="status-tag-info">
+        </div>
+        <div id="status-subfield-info">
+        </div>
+    </div>
+</div>
+
+</div></div>
+
+<div class="yui-b" id="sidebar">
+
+<h3>Search</h3>
+<form id="quicksearch">
+    <fieldset class="brief">
+    <ol>
+        <li><label for="search-by-keywords">Keywords:</label></li>
+        <li><input class="search-box" data-qualifier="term" id="search-by-keywords" placeholder="(Ctrl-Alt-K)" /></li>
+        <li><label for="search-by-author">Author:</label></li>
+        <li><input class="search-box" data-qualifier="author" id="search-by-author" placeholder="(Ctrl-Alt-A)" /></li>
+        <li><label for="search-by-isbn">ISBN:</label></li>
+        <li><input class="search-box" data-qualifier="isbn" id="search-by-isbn" placeholder="(Ctrl-Alt-I)" /></li>
+        <li><label for="search-by-title">Title:</label></li>
+        <li><input class="search-box" data-qualifier="title" id="search-by-title" placeholder="(Ctrl-Alt-T)" /></li>
+        <li><a href="#" id="show-advanced-search" title="Show advanced search (Ctrl-Alt-S)">Advanced &raquo;</a></li>
+    </fieldset>
+</form>
+
+</div>
+
+</div>
+</div>
+
+<div id="advanced-search-ui" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="advanced-search-title" aria-hidden="true">
+
+<div class="modal-header">
+    <button type="button" class="closebtn" data-dismiss="modal" aria-hidden="true">×</button>
+    <h3 id="advanced-search-title">Advanced search</h3>
+</div>
+
+<form id="advanced-search" class="modal-body">
+    <div class="span3">
+        <div id="search-facets">
+            <ul>
+                <li>Servers:<ul id="advanced-search-servers"></ul></li>
+            </ul>
+        </div>
+    </div>
+    <div class="span9">
+        <div id="toolbar" class="btn-toolbar">
+            <button class="btn btn-small" type="submit"><i class="icon-search"></i> <span>Search</span></button>
+            <button class="btn btn-small" type="reset"><i class="icon-remove"></i> <span>Clear</span></button>
+        </div>
+        <ul id="advanced-search-fields">
+            <li>
+                <label for="advanced-search-by-author">Author:</label>
+                <input class="search-box" data-qualifier="author" id="advanced-search-by-author" />
+            </li>
+            <li>
+                <label for="advanced-search-by-control-number">Control number:</label>
+                <input class="search-box" data-qualifier="local_number" id="advanced-search-by-control-number" />
+            </li>
+            <li>
+                <label for="advanced-search-by-dewey">Dewey number:</label>
+                <input class="search-box" data-qualifier="cn_dewey" id="advanced-search-by-dewey" />
+            </li>
+            <li>
+                <label for="advanced-search-by-isbn">ISBN:</label>
+                <input class="search-box" data-qualifier="isbn" id="advanced-search-by-isbn" />
+            </li>
+            <li>
+                <label for="advanced-search-by-issn">ISSN:</label>
+                <input class="search-box" data-qualifier="issn" id="advanced-search-by-issn" />
+            </li>
+            <li>
+                <label for="advanced-search-by-lccn">LCCN:</label>
+                <input class="search-box" data-qualifier="lccn" id="advanced-search-by-lccn" />
+            </li>
+            <li>
+                <label for="advanced-search-by-lc-number">LC call number:</label>
+                <input class="search-box" data-qualifier="cn_lc" id="advanced-search-by-lc-number" />
+            </li>
+            <li>
+                <label for="advanced-search-by-publisher-number">Publisher number:</label>
+                <input class="search-box" data-qualifier="music_identifier" id="advanced-search-by-publisher-number" />
+            </li>
+            <li>
+                <label for="advanced-search-by-standard-number">Standard number:</label>
+                <input class="search-box" data-qualifier="standard_identifier" id="advanced-search-by-standard-number" />
+            </li>
+            <li>
+                <label for="advanced-search-by-subject">Subject:</label>
+                <input class="search-box" data-qualifier="subject" id="advanced-search-by-subject" />
+            </li>
+            <li>
+                <label for="advanced-search-by-publication-date">Publication date:</label>
+                <input class="search-box" data-qualifier="date" id="advanced-search-by-publication-date" />
+            </li>
+            <li>
+                <label for="advanced-search-by-title">Title:</label>
+                <input class="search-box" data-qualifier="title" id="advanced-search-by-title" />
+            </li>
+        </ul>
+    </div>
+</form>
+
+</div>
+
+<div id="search-results-ui" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="search-results-title" aria-hidden="true">
+
+<div class="modal-header">
+    <button type="button" class="closebtn" data-dismiss="modal" aria-hidden="true">×</button>
+    <h3 id="search-results-title">Results</h3>
+</div>
+
+<div class="modal-body row-fluid">
+    <div class="span3">
+        <div id="search-facets">
+            <ul>
+                <li>Servers:<ul id="search-serversinfo"></ul></li>
+            </ul>
+        </div>
+    </div>
+    <div class="span9">
+        <div id="searchresults">
+            <div id="search-top-pages">
+                <div class="pagination pagination-small">
+                </div>
+            </div>
+
+            <table>
+                <thead>
+                    <tr></tr>
+                </thead>
+                <tbody></tbody>
+            </table>
+
+            <div id="search-bottom-pages">
+                <div class="pagination pagination-small">
+                </div>
+            </div>
+        </div>
+    </div>
+    <div id="search-overlay"><span>Loading...</span><div class="progress progress-striped active"><div class="bar" style="width: 100%"></div></div></div>
+</div>
+
+</div>
+
+<div id="macro-ui" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="macro-title" aria-hidden="true">
+
+<div class="modal-header">
+    <button type="button" class="closebtn" data-dismiss="modal" aria-hidden="true">×</button>
+    <h3 id="macro-title">Macros</h3>
+</div>
+
+<div class="modal-body row-fluid">
+    <div class="span3"><ul id="macro-list"></ul></div>
+    <div class="span9" id="macro-editor">
+        <div id="macro-toolbar" class="btn-toolbar">
+            <button class="btn btn-small" id="run-macro" title="Run and edit macros"><i class="icon-play"></i> Run macro</button>
+            <button class="btn btn-small" id="delete-macro" title="Delete macro"><i class="icon-remove"></i> Delete macro</button>
+            <label for="macro-format">Format: </label> <select id="macro-format"></select>
+            <div id="macro-save-message"></div>
+        </div>
+    </div>
+</div>
+
+</div>
+
+[% PROCESS 'cateditor-ui.inc' %]
+
+[% INCLUDE 'intranet-bottom.inc' %]
diff --git a/svc/cataloguing/framework b/svc/cataloguing/framework
new file mode 100755 (executable)
index 0000000..49d341d
--- /dev/null
@@ -0,0 +1,77 @@
+#!/usr/bin/perl
+
+use Modern::Perl '2009';
+
+use CGI;
+use C4::Branch;
+use C4::ClassSource;
+use C4::Context;
+use C4::Biblio;
+use C4::Service;
+use Koha::Database;
+
+my ( $query, $response ) = C4::Service->init( editcatalogue => 'edit_catalogue' );
+
+my $frameworkcode = $query->param( 'frameworkcode' ) // '';
+
+my $tagslib = GetMarcStructure( 1, $frameworkcode );
+
+my @tags;
+
+foreach my $tag ( sort keys %$tagslib ) {
+    my $taglib = $tagslib->{$tag};
+    my $taginfo = { map { $_, $taglib->{$_} } grep { length $_ > 1 } keys %$taglib };
+    $taginfo->{subfields} = [ map { [ $_, $taglib->{$_} ] } grep { length $_ == 1 } sort keys %$taglib ];
+
+    push @tags, [ $tag, $taginfo ];
+}
+
+my $schema = Koha::Database->new->schema;
+my $authorised_values = {};
+
+$authorised_values->{branches} = [];
+my $onlymine=C4::Context->preference('IndependentBranches') &&
+        C4::Context->userenv &&
+        C4::Context->userenv->{flags} % 2 == 0 &&
+        C4::Context->userenv->{branch};
+my $branches = GetBranches($onlymine);
+foreach my $thisbranch ( sort keys %$branches ) {
+    push @{ $authorised_values->{branches} }, { value => $thisbranch, lib => $branches->{$thisbranch}->{'branchname'} };
+}
+
+$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();
+
+my $default_source = C4::Context->preference("DefaultClassificationSource");
+
+foreach my $class_source (sort keys %$class_sources) {
+    next unless $class_sources->{$class_source}->{'used'} or
+                ($class_source eq $default_source);
+    push @{ $authorised_values->{cn_source} }, { value => $class_source, lib => $class_sources->{$class_source}->{'description'} };
+}
+
+my $branch_limit = C4::Context->userenv ? C4::Context->userenv->{"branch"} : "";
+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 );
+
+C4::Service->return_success( $response );
diff --git a/svc/cataloguing/metasearch b/svc/cataloguing/metasearch
new file mode 100755 (executable)
index 0000000..110732f
--- /dev/null
@@ -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 <http://www.gnu.org/licenses>.
+
+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 );