Bug 11559: Rancor: advanced cataloging interface
[koha.git] / koha-tmpl / intranet-tmpl / lib / koha / cateditor / marc-editor.js
1 /**
2  * Copyright 2015 ByWater Solutions
3  *
4  * This file is part of Koha.
5  *
6  * Koha is free software; you can redistribute it and/or modify it
7  * under the terms of the GNU General Public License as published by
8  * the Free Software Foundation; either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * Koha is distributed in the hope that it will be useful, but
12  * WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14  * GNU General Public License for more details.
15  *
16  * You should have received a copy of the GNU General Public License
17  * along with Koha; if not, see <http://www.gnu.org/licenses>.
18  */
19
20 define( [ 'marc-record', 'koha-backend', 'preferences', 'text-marc', 'widget' ], function( MARC, KohaBackend, Preferences, TextMARC, Widget ) {
21     var NOTIFY_TIMEOUT = 250;
22
23     function editorCursorActivity( cm ) {
24         var editor = cm.marceditor;
25         var field = editor.getCurrentField();
26         if ( !field ) return;
27
28         // Set overwrite mode for tag numbers/indicators and contents of fixed fields
29         if ( field.isControlField || cm.getCursor().ch < 8 ) {
30             cm.toggleOverwrite(true);
31         } else {
32             cm.toggleOverwrite(false);
33         }
34
35         editor.onCursorActivity();
36     }
37
38     // This function exists to prevent inserting or partially deleting text that belongs to a
39     // widget. The 'marcAware' change source exists for other parts of the editor code to bypass
40     // this check.
41     function editorBeforeChange( cm, change ) {
42         var editor = cm.marceditor;
43         if ( editor.textMode || change.origin == 'marcAware' || change.origin == 'widget.clearToText' ) return;
44
45         // FIXME: Should only cancel changes if this is a control field/subfield widget
46         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
47
48         if ( change.from.ch == change.to.ch - 1 && cm.findMarksAt( { line: change.from.line, ch: change.from.ch + 1 } ).length ) {
49             change.cancel();
50         } else if ( change.from.ch == change.to.ch && cm.findMarksAt(change.from).length && !change.text[0].match(/^[$|ǂ‡]$/) ) {
51             change.cancel();
52         }
53     }
54
55     function editorChanges( cm, changes ) {
56         var editor = cm.marceditor;
57         if ( editor.textMode ) return;
58
59         for (var i = 0; i < changes.length; i++) {
60             var change = changes[i];
61
62             var origin = change.from.line;
63             var newTo = CodeMirror.changeEnd(change);
64
65             for (var delLine = origin; delLine <= change.to.line; delLine++) {
66                 // Line deleted; currently nothing to do
67             }
68
69             for (var line = origin; line <= newTo.line; line++) {
70                 if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( cm.marceditor, line );
71                 if ( change.origin != 'setValue' && change.origin != 'marcWidgetPrefill' && change.origin != 'widget.clearToText' ) cm.addLineClass( line, 'wrapper', 'modified-line' );
72             }
73         }
74
75         Widget.ActivateAt( cm, cm.getCursor() );
76         cm.marceditor.startNotify();
77     }
78
79     // Editor helper functions
80     function activateTabPosition( cm, pos, idx ) {
81         // Allow tabbing to as-yet-nonexistent positions
82         var lenDiff = pos.ch - cm.getLine( pos.line ).length;
83         if ( lenDiff > 0 ) {
84             var extra = '';
85             while ( lenDiff-- > 0 ) extra += ' ';
86             if ( pos.prefill ) extra += pos.prefill;
87             cm.replaceRange( extra, { line: pos.line } );
88         }
89
90         cm.setCursor( pos );
91         Widget.ActivateAt( cm, pos, idx );
92     }
93
94     function getTabPositions( editor, cur ) {
95         cur = cur || editor.cm.getCursor();
96         var field = editor.getFieldAt( cur.line );
97
98         if ( field ) {
99             if ( field.isControlField ) {
100                 var positions = [ { ch: 0 }, { ch: 4 } ];
101
102                 $.each( positions, function( undef, pos ) {
103                     pos.line = cur.line;
104                 } );
105
106                 return positions;
107             } else {
108                 var positions = [ { ch: 0 }, { ch: 4, prefill: '_' }, { ch: 6, prefill: '_' } ];
109
110                 $.each( positions, function( undef, pos ) {
111                     pos.line = cur.line;
112                 } );
113                 $.each( field.getSubfields(), function( undef, subfield ) {
114                     positions.push( { line: cur.line, ch: subfield.contentsStart } );
115                 } );
116
117                 // Allow to tab to start of empty field
118                 if ( field.getSubfields().length == 0 ) {
119                     positions.push( { line: cur.line, ch: 8 } );
120                 }
121
122                 return positions;
123             }
124         } else {
125             return [];
126         }
127     }
128
129     var _editorKeys = {
130         Enter: function( cm ) {
131             var cursor = cm.getCursor();
132             cm.replaceRange( '\n', { line: cursor.line }, null, 'marcAware' );
133             cm.setCursor( { line: cursor.line + 1, ch: 0 } );
134         },
135
136         'Ctrl-X': function( cm ) {
137             // Delete line (or cut)
138             if ( cm.somethingSelected() ) return true;
139
140             var field = cm.marceditor.getCurrentField();
141             if ( field ) field.delete();
142         },
143
144         'Shift-Ctrl-X': function( cm ) {
145             // Delete subfield
146             var field = cm.marceditor.getCurrentField();
147             if ( !field ) return;
148
149             var subfield = field.getSubfieldAt( cm.getCursor().ch );
150             if ( subfield ) subfield.delete();
151         },
152
153         Tab: function( cm ) {
154             // Move through parts of tag/fixed fields
155             var positions = getTabPositions( cm.marceditor );
156             var cur = cm.getCursor();
157
158             for ( var i = 0; i < positions.length; i++ ) {
159                 if ( positions[i].ch > cur.ch ) {
160                     activateTabPosition( cm, positions[i] );
161                     return false;
162                 }
163             }
164
165             cm.setCursor( { line: cur.line + 1, ch: 0 } );
166         },
167
168         'Shift-Tab': function( cm ) {
169             // Move backwards through parts of tag/fixed fields
170             var positions = getTabPositions( cm.marceditor );
171             var cur = cm.getCursor();
172
173             for ( var i = positions.length - 1; i >= 0; i-- ) {
174                 if ( positions[i].ch < cur.ch ) {
175                     activateTabPosition( cm, positions[i] );
176                     return false;
177                 }
178             }
179
180             if ( cur.line == 0 ) return;
181
182             var prevPositions = getTabPositions( cm.marceditor, { line: cur.line - 1, ch: cm.getLine( cur.line - 1 ).length } );
183
184             if ( prevPositions.length ) {
185                 activateTabPosition( cm, prevPositions[ prevPositions.length - 1 ], -1 );
186             } else {
187                 cm.setCursor( { line: cur.line - 1, ch: 0 } );
188             }
189         },
190
191         'Ctrl-D': function( cm ) {
192             // Insert subfield delimiter
193             // This will be extended later to allow either a configurable subfield delimiter or just
194             // make it be the double cross.
195             var cur = cm.getCursor();
196
197             cm.replaceRange( "$", cur, null );
198         },
199     };
200
201     // The objects below are part of a field/subfield manipulation API, accessed through the base
202     // editor object.
203     //
204     // Each one is tied to a particular line; this means that using a field or subfield object after
205     // any other changes to the record will cause entertaining explosions. The objects are meant to
206     // be temporary, and should only be reused with great care. The macro code does this only
207     // because it is careful to dispose of the object after any other updates.
208     //
209     // Note, however, tha you can continue to use a field object after changing subfields. It's just
210     // the subfield objects that become invalid.
211
212     // This is an exception raised by the EditorSubfield and EditorField when an invalid change is
213     // attempted.
214     function FieldError(line, message) {
215         this.line = line;
216         this.message = message;
217     };
218
219     FieldError.prototype.toString = function() {
220         return 'FieldError(' + this.line + ', "' + this.message + '")';
221     };
222
223     // This is the temporary object for a particular subfield in a field. Any change to any other
224     // subfields will invalidate this subfield object.
225     function EditorSubfield( field, index, start, end ) {
226         this.field = field;
227         this.index = index;
228         this.start = start;
229         this.end = end;
230
231         if ( this.field.isControlField ) {
232             this.contentsStart = start;
233             this.code = '@';
234         } else {
235             this.contentsStart = start + 3;
236             this.code =  this.field.contents.substr( this.start + 1, 1 );
237         }
238
239         this.cm = field.cm;
240
241         var marks = this.cm.findMarksAt( { line: field.line, ch: this.contentsStart } );
242         if ( marks[0] && marks[0].widget ) {
243             this.widget = marks[0].widget;
244
245             this.text = this.widget.text;
246             this.setText = this.widget.setText;
247             this.getFixed = this.widget.getFixed;
248             this.setFixed = this.widget.setFixed;
249         } else {
250             this.widget = null;
251             this.text = this.field.contents.substr( this.contentsStart, end - this.contentsStart );
252         }
253     };
254
255     $.extend( EditorSubfield.prototype, {
256         _invalid: function() {
257             return this.field._subfieldsInvalid();
258         },
259
260         focus: function() {
261             this.cm.setCursor( { line: this.field.line, ch: this.contentsStart } );
262         },
263         focusEnd: function() {
264             this.cm.setCursor( { line: this.field.line, ch: this.end } );
265         },
266         getText: function() {
267             return this.text;
268         },
269         setText: function( text ) {
270             if ( !this._invalid() ) throw new FieldError( this.field.line, 'subfield invalid' );
271             this.cm.replaceRange( text, { line: this.field.line, ch: this.contentsStart }, { line: this.field.line, ch: this.end }, 'marcAware' );
272             this.field._invalidateSubfields();
273         },
274     } );
275
276     function EditorField( editor, line ) {
277         this.editor = editor;
278         this.line = line;
279
280         this.cm = editor.cm;
281
282         this._updateInfo();
283         this.tag = this.contents.substr( 0, 3 );
284         this.isControlField = ( this.tag < '010' );
285
286         if ( this.isControlField ) {
287             this._ind1 = this.contents.substr( 4, 1 );
288             this._ind2 = this.contents.substr( 6, 1 );
289         } else {
290             this._ind1 = null;
291             this._ind2 = null;
292         }
293
294         this.subfields = null;
295     }
296
297     $.extend( EditorField.prototype, {
298         _subfieldsInvalid: function() {
299             return !this.subfields;
300         },
301         _invalidateSubfields: function() {
302             this._subfields = null;
303         },
304
305         _updateInfo: function() {
306             this.info = this.editor.getLineInfo( { line: this.line, ch: 0 } );
307             if ( this.info == null ) throw new FieldError( 'Invalid field' );
308             this.contents = this.info.contents;
309         },
310         _scanSubfields: function() {
311             this._updateInfo();
312
313             if ( this.isControlField ) {
314                 this._subfields = [ new EditorSubfield( this, 0, 4, this.contents.length ) ];
315             } else {
316                 var field = this;
317                 var subfields = this.info.subfields;
318                 this._subfields = [];
319
320                 for (var i = 0; i < this.info.subfields.length; i++) {
321                     var end = i == subfields.length - 1 ? this.contents.length : subfields[i+1].ch;
322
323                     this._subfields.push( new EditorSubfield( this, i, subfields[i].ch, end ) );
324                 }
325             }
326         },
327
328         delete: function() {
329             this.cm.replaceRange( "", { line: this.line, ch: 0 }, { line: this.line + 1, ch: 0 }, 'marcAware' );
330         },
331         focus: function() {
332             this.cm.setCursor( { line: this.line, ch: 0 } );
333
334             return this;
335         },
336
337         getText: function() {
338             var result = '';
339
340             $.each( this.getSubfields(), function() {
341                 if ( this.code != '@' ) result += '$' + this.code + ' ';
342
343                 result += this.getText();
344             } );
345
346             return result;
347         },
348         setText: function( text ) {
349             var indicator_match = /^([_ 0-9])([_ 0-9])\$/.exec( text );
350             if ( indicator_match ) {
351                 text = text.substr(2);
352                 this.setIndicator1( indicator_match[1] );
353                 this.setIndicator2( indicator_match[2] );
354             }
355
356             this.cm.replaceRange( text, { line: this.line, ch: this.isControlField ? 4 : 8 }, { line: this.line }, 'marcAware' );
357             this._invalidateSubfields();
358
359             return this;
360         },
361
362         getIndicator1: function() {
363             return this._ind1;
364         },
365         getIndicator2: function() {
366             return this._ind2;
367         },
368         setIndicator1: function(val) {
369             if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field');
370
371             this._ind1 = ( !val || val == ' ' ) ? '_' : val;
372             this.cm.replaceRange( this._ind1, { line: this.line, ch: 4 }, { line: this.line, ch: 5 }, 'marcAware' );
373
374             return this;
375         },
376         setIndicator2: function(val) {
377             if ( this.isControlField ) throw new FieldError('Cannot set indicators on control field');
378
379             this._ind2 = ( !val || val == ' ' ) ? '_' : val;
380             this.cm.replaceRange( this._ind2, { line: this.line, ch: 6 }, { line: this.line, ch: 7 }, 'marcAware' );
381
382             return this;
383         },
384
385         appendSubfield: function( code ) {
386             if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field');
387
388             this._invalidateSubfields();
389             this.cm.replaceRange( '$' + code + ' ', { line: this.line }, null, 'marcAware' );
390             var subfields = this.getSubfields();
391
392             return subfields[ subfields.length - 1 ];
393         },
394         insertSubfield: function( code, position ) {
395             if ( this.isControlField ) throw new FieldError('Cannot add subfields to control field');
396
397             position = position || 0;
398
399             var subfields = this.getSubfields();
400             this._invalidateSubfields();
401             this.cm.replaceRange( '$' + code + ' ', { line: this.line, ch: subfields[position] ? subfields[position].start : null }, null, 'marcAware' );
402             subfields = this.getSubfields();
403
404             return subfields[ position ];
405         },
406         getSubfields: function( code ) {
407             if ( !this._subfields ) this._scanSubfields();
408             if ( code == null ) return this._subfields;
409
410             var result = [];
411
412             $.each( this._subfields, function() {
413                 if ( code == null || this.code == code ) result.push(this);
414             } );
415
416             return result;
417         },
418         getFirstSubfield: function( code ) {
419             var result = this.getSubfields( code );
420
421             return ( result && result.length ) ? result[0] : null;
422         },
423         getSubfieldAt: function( ch ) {
424             var subfields = this.getSubfields();
425
426             for (var i = 0; i < subfields.length; i++) {
427                 if ( subfields[i].start < ch && subfields[i].end >= ch ) return subfields[i];
428             }
429         },
430     } );
431
432     function MARCEditor( options ) {
433         this.cm = CodeMirror(
434             options.position,
435             {
436                 extraKeys: _editorKeys,
437                 gutters: [
438                     'modified-line-gutter',
439                 ],
440                 lineWrapping: true,
441                 mode: {
442                     name: 'marc',
443                     nonRepeatableTags: KohaBackend.GetTagsBy( '', 'repeatable', '0' ),
444                     nonRepeatableSubfields: KohaBackend.GetSubfieldsBy( '', 'repeatable', '0' )
445                 }
446             }
447         );
448         this.cm.marceditor = this;
449
450         this.cm.on( 'beforeChange', editorBeforeChange );
451         this.cm.on( 'changes', editorChanges );
452         this.cm.on( 'cursorActivity', editorCursorActivity );
453
454         this.onCursorActivity = options.onCursorActivity;
455
456         this.subscribers = [];
457         this.subscribe( function( marceditor ) {
458             Widget.Notify( marceditor );
459         } );
460     }
461
462     MARCEditor.FieldError = FieldError;
463
464     $.extend( MARCEditor.prototype, {
465         setUseWidgets: function( val ) {
466             if ( val ) {
467                 for ( var line = 0; line <= this.cm.lastLine(); line++ ) {
468                     Widget.UpdateLine( this, line );
469                 }
470             } else {
471                 $.each( this.cm.getAllMarks(), function( undef, mark ) {
472                     if ( mark.widget ) mark.widget.clearToText();
473                 } );
474             }
475         },
476
477         focus: function() {
478             this.cm.focus();
479         },
480
481         getCursor: function() {
482             return this.cm.getCursor();
483         },
484
485         refresh: function() {
486             this.cm.refresh();
487         },
488
489         displayRecord: function( record ) {
490             this.cm.setValue( TextMARC.RecordToText(record) );
491         },
492
493         getRecord: function() {
494             this.textMode = true;
495
496             $.each( this.cm.getAllMarks(), function( undef, mark ) {
497                 if ( mark.widget ) mark.widget.clearToText();
498             } );
499             var record = TextMARC.TextToRecord( this.cm.getValue() );
500             for ( var line = 0; line <= this.cm.lastLine(); line++ ) {
501                 if ( Preferences.user.fieldWidgets ) Widget.UpdateLine( this, line );
502             }
503
504             this.textMode = false;
505
506             return record;
507         },
508
509         getLineInfo: function( pos ) {
510             var contents = this.cm.getLine( pos.line );
511             if ( contents == null ) return {};
512
513             var tagNumber = contents.match( /^([A-Za-z0-9]{3})/ );
514
515             if ( !tagNumber ) return null; // No tag at all on this line
516             tagNumber = tagNumber[1];
517
518             if ( tagNumber < '010' ) return { tagNumber: tagNumber, contents: contents }; // No current subfield
519
520             var matcher = /[$|ǂ‡]([a-z0-9%]) /g;
521             var match;
522
523             var subfields = [];
524             var currentSubfield;
525
526             while ( ( match = matcher.exec(contents) ) ) {
527                 subfields.push( { code: match[1], ch: match.index } );
528                 if ( match.index < pos.ch ) currentSubfield = match[1];
529             }
530
531             return { tagNumber: tagNumber, subfields: subfields, currentSubfield: currentSubfield, contents: contents };
532         },
533
534         addError: function( line, error ) {
535             var found = false;
536             var options = {};
537
538             if ( line == null ) {
539                 line = 0;
540                 options.above = true;
541             }
542
543             $.each( this.cm.getLineHandle(line).widgets || [], function( undef, widget ) {
544                 if ( !widget.isErrorMarker ) return;
545
546                 found = true;
547
548                 $( widget.node ).append( '; ' + error );
549                 widget.changed();
550
551                 return false;
552             } );
553
554             if ( found ) return;
555
556             var node = $( '<div class="structure-error"><i class="icon-remove"></i> ' + error + '</div>' )[0];
557             var widget = this.cm.addLineWidget( line, node, options );
558
559             widget.node = node;
560             widget.isErrorMarker = true;
561         },
562
563         removeErrors: function() {
564             for ( var line = 0; line < this.cm.lineCount(); line++ ) {
565                 $.each( this.cm.getLineHandle( line ).widgets || [], function( undef, lineWidget ) {
566                     if ( lineWidget.isErrorMarker ) lineWidget.clear();
567                 } );
568             }
569         },
570
571         startNotify: function() {
572             if ( this.notifyTimeout ) clearTimeout( this.notifyTimeout );
573             this.notifyTimeout = setTimeout( $.proxy( function() {
574                 this.notifyAll();
575
576                 this.notifyTimeout = null;
577             }, this ), NOTIFY_TIMEOUT );
578         },
579
580         notifyAll: function() {
581             $.each( this.subscribers, $.proxy( function( undef, subscriber ) {
582                 subscriber(this);
583             }, this ) );
584         },
585
586         subscribe: function( subscriber ) {
587             this.subscribers.push( subscriber );
588         },
589
590         createField: function( tag, line ) {
591             var contents = tag + ( tag < '010' ? ' ' : ' _ _ ' );
592
593             if ( line > this.cm.lastLine() ) {
594                 contents = '\n' + contents;
595             } else {
596                 contents = contents + '\n';
597             }
598
599             this.cm.replaceRange( contents, { line: line, ch: 0 }, null, 'marcAware' );
600
601             return new EditorField( this, line );
602         },
603
604         createFieldOrdered: function( tag ) {
605             var line, contents;
606
607             for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
608                 if ( contents && contents.substr(0, 3) > tag ) break;
609             }
610
611             return this.createField( tag, line );
612         },
613
614         createFieldGrouped: function( tag ) {
615             // Control fields should be inserted in actual order, whereas other fields should be
616             // inserted grouped
617             if ( tag < '010' ) return this.createFieldOrdered( tag );
618
619             var line, contents;
620
621             for ( line = 0; (contents = this.cm.getLine(line)); line++ ) {
622                 if ( contents && contents[0] > tag[0] ) break;
623             }
624
625             return this.createField( tag, line );
626         },
627
628         getFieldAt: function( line ) {
629             try {
630                 return new EditorField( this, line );
631             } catch (e) {
632                 return null;
633             }
634         },
635
636         getCurrentField: function() {
637             return this.getFieldAt( this.cm.getCursor().line );
638         },
639
640         getFields: function( tag ) {
641             var result = [];
642
643             if ( tag != null ) tag += ' ';
644
645             for ( var line = 0; line < this.cm.lineCount(); line++ ) {
646                 if ( tag && this.cm.getLine(line).substr( 0, 4 ) != tag ) continue;
647
648                 // If this throws a FieldError, pretend it doesn't exist
649                 try {
650                     result.push( new EditorField( this, line ) );
651                 } catch (e) {
652                     if ( !( e instanceof FieldError ) ) throw e;
653                 }
654             }
655
656             return result;
657         },
658
659         getFirstField: function( tag ) {
660             var result = this.getFields( tag );
661
662             return ( result && result.length ) ? result[0] : null;
663         },
664
665         getAllFields: function( tag ) {
666             return this.getFields( null );
667         },
668     } );
669
670     return MARCEditor;
671 } );