Bug 30719: (QA follow-up) Squash:
[koha.git] / koha-tmpl / intranet-tmpl / prog / js / ill-batch-modal.js
1 (function () {
2     // Bail if there aren't any metadata enrichment plugins installed
3     if (typeof metadata_enrichment_services === 'undefined') {
4         console.log('No metadata enrichment plugins found.')
5         return;
6     }
7
8     window.addEventListener('load', onload);
9
10     // Delay between API requests
11     var debounceDelay = 1000;
12
13     // Elements we work frequently with
14     var textarea = document.getElementById("identifiers_input");
15     var nameInput = document.getElementById("name");
16     var cardnumberInput = document.getElementById("batchcardnumber");
17     var branchcodeSelect = document.getElementById("branchcode");
18     var processButton = document.getElementById("process_button");
19     var createButton = document.getElementById("button_create_batch");
20     var finishButton = document.getElementById("button_finish");
21     var batchItemsDisplay = document.getElementById("add_batch_items");
22     var createProgressTotal = document.getElementById("processed_total");
23     var createProgressCount = document.getElementById("processed_count");
24     var createProgressFailed = document.getElementById("processed_failed");
25     var createProgressBar = document.getElementById("processed_progress_bar");
26     var identifierTable = document.getElementById('identifier-table');
27     var createRequestsButton = document.getElementById('create-requests-button');
28     var statusesSelect = document.getElementById('statuscode');
29     var cancelButton = document.getElementById('lhs').querySelector('button');
30     var cancelButtonOriginalText = cancelButton.innerHTML;
31
32     // We need a data structure keyed on identifier type, which tells us how to parse that
33     // identifier type and what services can get its metadata. We receive an array of
34     // available services
35     var supportedIdentifiers = {};
36     metadata_enrichment_services.forEach(function (service) {
37         // Iterate the identifiers that this service supports
38         Object.keys(service.identifiers_supported).forEach(function (idType) {
39             if (!supportedIdentifiers[idType]) {
40                 supportedIdentifiers[idType] = [];
41             }
42             supportedIdentifiers[idType].push(service);
43         });
44     });
45
46     // An object for when we're creating a new batch
47     var emptyBatch = {
48         name: '',
49         backend: null,
50         cardnumber: '',
51         branchcode: '',
52         statuscode: 'NEW'
53     };
54
55     // The object that holds the batch we're working with
56     // It's a proxy so we can update portions of the UI
57     // upon changes
58     var batch = new Proxy(
59         { data: {} },
60         {
61             get: function (obj, prop) {
62                 return obj[prop];
63             },
64             set: function (obj, prop, value) {
65                 obj[prop] = value;
66                 manageBatchItemsDisplay();
67                 updateBatchInputs();
68                 disableCardnumberInput();
69                 displayPatronName();
70                 updateStatusesSelect();
71             }
72         }
73     );
74
75     // The object that holds the contents of the table
76     // It's a proxy so we can make it automatically redraw the
77     // table upon changes
78     var tableContent = new Proxy(
79         { data: [] },
80         {
81             get: function (obj, prop) {
82                 return obj[prop];
83             },
84             set: function (obj, prop, value) {
85                 obj[prop] = value;
86                 updateTable();
87                 updateRowCount();
88                 updateProcessTotals();
89                 checkAvailability();
90             }
91         }
92     );
93
94     // The object that holds the contents of the table
95     // It's a proxy so we can update portions of the UI
96     // upon changes
97     var statuses = new Proxy(
98         { data: [] },
99         {
100             get: function (obj, prop) {
101                 return obj[prop];
102             },
103             set: function (obj, prop, value) {
104                 obj[prop] = value;
105                 updateStatusesSelect();
106             }
107         }
108     );
109
110     var progressTotals = new Proxy(
111         {
112             data: {}
113         },
114         {
115             get: function (obj, prop) {
116                 return obj[prop];
117             },
118             set: function (obj, prop, value) {
119                 obj[prop] = value;
120                 showCreateRequestsButton();
121             }
122         }
123     );
124
125     // Keep track of submission API calls that are in progress
126     // so we don't duplicate them
127     var submissionSent = {};
128
129     // Keep track of availability API calls that are in progress
130     // so we don't duplicate them
131     var availabilitySent = {};
132
133     // Are we updating an existing batch
134     var isUpdate = false;
135
136     // The datatable
137     var table;
138     var tableEl = document.getElementById('identifier-table');
139
140     // The element that potentially holds the ID of the batch
141     // we're working with
142     var idEl = document.getElementById('ill-batch-details');
143     var batchId = null;
144     var backend = null;
145
146     function onload() {
147         $('#ill-batch-modal').on('shown.bs.modal', function () {
148             init();
149             patronAutocomplete();
150             batchInputsEventListeners();
151             createButtonEventListener();
152             createRequestsButtonEventListener();
153             moreLessEventListener();
154             removeRowEventListener();
155         });
156         $('#ill-batch-modal').on('hidden.bs.modal', function () {
157             // Reset our state when we close the modal
158             // TODO: need to also reset progress bar and already processed identifiers
159             delete idEl.dataset.batchId;
160             delete idEl.dataset.backend;
161             batchId = null;
162             tableEl.style.display = 'none';
163             tableContent.data = [];
164             progressTotals.data = {
165                 total: 0,
166                 count: 0,
167                 failed: 0
168             };
169             textarea.value = '';
170             batch.data = {};
171             cancelButton.innerHTML = cancelButtonOriginalText;
172             // Remove event listeners we created
173             removeEventListeners();
174         });
175     };
176
177     function init() {
178         batchId = idEl.dataset.batchId;
179         backend = idEl.dataset.backend;
180         emptyBatch.backend = backend;
181         progressTotals.data = {
182             total: 0,
183             count: 0,
184             failed: 0
185         };
186         if (batchId) {
187             fetchBatch();
188             isUpdate = true;
189             setModalHeading();
190         } else {
191             batch.data = emptyBatch;
192             setModalHeading();
193         }
194         fetchStatuses();
195         finishButtonEventListener();
196         processButtonEventListener();
197         identifierTextareaEventListener();
198         displaySupportedIdentifiers();
199         createButtonEventListener();
200         updateRowCount();
201     };
202
203     function initPostCreate() {
204         disableCreateButton();
205         cancelButton.innerHTML = ill_batch_create_cancel_button;
206     };
207
208     function setFinishButton() {
209         if (batch.data.patron) {
210             finishButton.removeAttribute('disabled');
211         }
212     };
213
214     function setModalHeading() {
215         var heading = document.getElementById('ill-batch-modal-label');
216         heading.textContent = isUpdate ? ill_batch_update : ill_batch_add;
217     }
218
219     // Identify items that have metadata and therefore can have a local request
220     // created, and do so
221     function requestRequestable() {
222         createRequestsButton.setAttribute('disabled', true);
223         createRequestsButton.setAttribute('aria-disabled', true);
224         setFinishButton();
225         var toCheck = tableContent.data;
226         toCheck.forEach(function (row) {
227             if (
228                 !row.requestId &&
229                 Object.keys(row.metadata).length > 0 &&
230                 !submissionSent[row.value]
231             ) {
232                 submissionSent[row.value] = 1;
233                 makeLocalSubmission(row.value, row.metadata);
234             }
235         });
236     };
237
238     // Identify items that can have their availability checked, and do it
239     function checkAvailability() {
240         // Only proceed if we've got services that can check availability
241         if (!batch_availability_services || batch_availability_services.length === 0) return;
242         var toCheck = tableContent.data;
243         toCheck.forEach(function (row) {
244             if (
245                 !row.url &&
246                 Object.keys(row.metadata).length > 0 &&
247                 !availabilitySent[row.value]
248             ) {
249                 availabilitySent[row.value] = 1;
250                 getAvailability(row.value, row.metadata);
251             }
252         });
253     };
254
255     // Check availability services for immediate availability, if found,
256     // create a link in the table linking to the item
257     function getAvailability(identifier, metadata) {
258         // Prep the metadata for passing to the availability plugins
259         let availability_object = {};
260         if (metadata.issn) availability_object['issn'] = metadata.issn;
261         if (metadata.doi) availability_object['doi'] = metadata.doi;
262         if (metadata.pubmedid) availability_object['pubmedid'] = metadata.pubmedid;
263         var prepped = encodeURIComponent(base64EncodeUnicode(JSON.stringify(availability_object)));
264         for (i = 0; i < batch_availability_services.length; i++) {
265             var service = batch_availability_services[i];
266             window.doApiRequest(
267                 service.endpoint + prepped
268             )
269                 .then(function (response) {
270                     return response.json();
271                 })
272                 .then(function (data) {
273                     if (data.results.search_results && data.results.search_results.length > 0) {
274                         var result = data.results.search_results[0];
275                         tableContent.data = tableContent.data.map(function (row) {
276                             if (row.value === identifier) {
277                                 row.url = result.url;
278                                 row.availabilitySupplier = service.name;
279                             }
280                             return row;
281                         });
282                     }
283                 });
284         }
285     };
286
287     // Help btoa with > 8 bit strings
288     // Shamelessly grabbed from: https://www.base64encoder.io/javascript/
289     function base64EncodeUnicode(str) {
290         // First we escape the string using encodeURIComponent to get the UTF-8 encoding of the characters,
291         // then we convert the percent encodings into raw bytes, and finally feed it to btoa() function.
292         utf8Bytes = encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function(match, p1) {
293                 return String.fromCharCode('0x' + p1);
294         });
295
296         return btoa(utf8Bytes);
297     };
298
299     // Create a local submission and update our local state
300     // upon success
301     function makeLocalSubmission(identifier, metadata) {
302
303         // Prepare extended_attributes in array format for POST
304         var extended_attributes = [];
305         for (const [key, value] of Object.entries(metadata)) {
306             extended_attributes.push({"type":key, "value":value});
307         }
308
309         var payload = {
310             batch_id: batchId,
311             ill_backend_id: batch.data.backend,
312             patron_id: batch.data.patron.borrowernumber,
313             library_id: batch.data.library_id,
314             extended_attributes: extended_attributes
315         };
316         window.doCreateSubmission(payload)
317             .then(function (response) {
318                 return response.json();
319             })
320             .then(function (data) {
321                 tableContent.data = tableContent.data.map(function (row) {
322                     if (row.value === identifier) {
323                         row.requestId = data.ill_request_id;
324                         row.requestStatus = data.status;
325                     }
326                     return row;
327                 });
328             })
329             .catch(function () {
330                 window.handleApiError(ill_batch_api_request_fail);
331             });
332     };
333
334     function updateProcessTotals() {
335         var init = {
336             total: 0,
337             count: 0,
338             failed: 0
339         };
340         progressTotals.data = init;
341         var toUpdate = progressTotals.data;
342         tableContent.data.forEach(function (row) {
343             toUpdate.total++;
344             if (Object.keys(row.metadata).length > 0 || row.failed.length > 0) {
345                 toUpdate.count++;
346             }
347             if (Object.keys(row.failed).length > 0) {
348                 toUpdate.failed++;
349             }
350         });
351         createProgressTotal.innerHTML = toUpdate.total;
352         createProgressCount.innerHTML = toUpdate.count;
353         createProgressFailed.innerHTML = toUpdate.failed;
354         var percentDone = Math.ceil((toUpdate.count / toUpdate.total) * 100);
355         createProgressBar.setAttribute('aria-valuenow', percentDone);
356         createProgressBar.innerHTML = percentDone + '%';
357         createProgressBar.style.width = percentDone + '%';
358         progressTotals.data = toUpdate;
359     };
360
361     function displayPatronName() {
362         var span = document.getElementById('patron_link');
363         if (batch.data.patron) {
364             var link = createPatronLink();
365             span.appendChild(link);
366         } else {
367             if (span.children.length > 0) {
368                 span.removeChild(span.firstChild);
369             }
370         }
371     };
372
373     function updateStatusesSelect() {
374         while (statusesSelect.options.length > 0) {
375             statusesSelect.remove(0);
376         }
377         statuses.data.forEach(function (status) {
378             var option = document.createElement('option')
379             option.value = status.code;
380             option.text = status.name;
381             if (batch.data.batch_id && batch.data.statuscode === status.code) {
382                 option.selected = true;
383             }
384             statusesSelect.add(option);
385         });
386         if (isUpdate) {
387             statusesSelect.parentElement.style.display = 'block';
388         }
389     };
390
391     function removeEventListeners() {
392         textarea.removeEventListener('paste', processButtonState);
393         textarea.removeEventListener('keyup', processButtonState);
394         processButton.removeEventListener('click', processIdentifiers);
395         nameInput.removeEventListener('keyup', createButtonState);
396         cardnumberInput.removeEventListener('keyup', createButtonState);
397         branchcodeSelect.removeEventListener('change', createButtonState);
398         createButton.removeEventListener('click', createBatch);
399         identifierTable.removeEventListener('click', toggleMetadata);
400         identifierTable.removeEventListener('click', removeRow);
401         createRequestsButton.remove('click', requestRequestable);
402     };
403
404     function finishButtonEventListener() {
405         finishButton.addEventListener('click', doFinish);
406     };
407
408     function identifierTextareaEventListener() {
409         textarea.addEventListener('paste', textareaUpdate);
410         textarea.addEventListener('keyup', textareaUpdate);
411     };
412
413     function processButtonEventListener() {
414         processButton.addEventListener('click', processIdentifiers);
415     };
416
417     function createRequestsButtonEventListener() {
418         createRequestsButton.addEventListener('click', requestRequestable);
419     };
420
421     function createButtonEventListener() {
422         createButton.addEventListener('click', createBatch);
423     };
424
425     function batchInputsEventListeners() {
426         nameInput.addEventListener('keyup', createButtonState);
427         cardnumberInput.addEventListener('keyup', createButtonState);
428         branchcodeSelect.addEventListener('change', createButtonState);
429     };
430
431     function moreLessEventListener() {
432         identifierTable.addEventListener('click', toggleMetadata);
433     };
434
435     function removeRowEventListener() {
436         identifierTable.addEventListener('click', removeRow);
437     };
438
439     function textareaUpdate() {
440         processButtonState();
441         updateRowCount();
442     };
443
444     function processButtonState() {
445         if (textarea.value.length > 0) {
446             processButton.removeAttribute('disabled');
447             processButton.removeAttribute('aria-disabled');
448         } else {
449             processButton.setAttribute('disabled', true);
450             processButton.setAttribute('aria-disabled', true);
451         }
452     };
453
454     function disableCardnumberInput() {
455         if (batch.data.patron) {
456             cardnumberInput.setAttribute('disabled', true);
457             cardnumberInput.setAttribute('aria-disabled', true);
458         } else {
459             cardnumberInput.removeAttribute('disabled');
460             cardnumberInput.removeAttribute('aria-disabled');
461         }
462     };
463
464     function createButtonState() {
465         if (
466             nameInput.value.length > 0 &&
467             cardnumberInput.value.length > 0 &&
468             branchcodeSelect.selectedOptions.length === 1
469         ) {
470             createButton.removeAttribute('disabled');
471             createButton.setAttribute('display', 'inline-block');
472         } else {
473             createButton.setAttribute('disabled', 1);
474             createButton.setAttribute('display', 'none');
475         }
476     };
477
478     function doFinish() {
479         updateBatch()
480             .then(function () {
481                 $('#ill-batch-modal').modal({ show: false });
482                 location.href = '/cgi-bin/koha/ill/ill-requests.pl?batch_id=' + batch.data.batch_id;
483             });
484     };
485
486     // Get all batch statuses
487     function fetchStatuses() {
488         window.doApiRequest('/api/v1/illbatchstatuses')
489             .then(function (response) {
490                 return response.json();
491             })
492             .then(function (jsoned) {
493                 statuses.data = jsoned;
494             })
495             .catch(function (e) {
496                 window.handleApiError(ill_batch_statuses_api_fail);
497             });
498     };
499
500     // Get the batch
501     function fetchBatch() {
502         window.doBatchApiRequest("/" + batchId)
503             .then(function (response) {
504                 return response.json();
505             })
506             .then(function (jsoned) {
507                 batch.data = {
508                     batch_id: jsoned.batch_id,
509                     name: jsoned.name,
510                     backend: jsoned.backend,
511                     cardnumber: jsoned.cardnumber,
512                     library_id: jsoned.library_id,
513                     statuscode: jsoned.statuscode
514                 }
515                 return jsoned;
516             })
517             .then(function (data) {
518                 batch.data = data;
519             })
520             .catch(function () {
521                 window.handleApiError(ill_batch_api_fail);
522             });
523     };
524
525     function createBatch() {
526         var selectedBranchcode = branchcodeSelect.selectedOptions[0].value;
527         var selectedStatuscode = statusesSelect.selectedOptions[0].value;
528         return doBatchApiRequest('', {
529             method: 'POST',
530             headers: {
531                 'Content-type': 'application/json'
532             },
533             body: JSON.stringify({
534                 name: nameInput.value,
535                 backend: backend,
536                 cardnumber: cardnumberInput.value,
537                 library_id: selectedBranchcode,
538                 statuscode: selectedStatuscode
539             })
540         })
541             .then(function (response) {
542                 if ( response.ok ) {
543                     return response.json();
544                 }
545                 return Promise.reject(response);
546             })
547             .then(function (body) {
548                 batchId = body.batch_id;
549                 batch.data = {
550                     batch_id: body.batch_id,
551                     name: body.name,
552                     backend: body.backend,
553                     cardnumber: body.patron.cardnumber,
554                     library_id: body.library_id,
555                     statuscode: body.statuscode,
556                     patron: body.patron,
557                     status: body.status
558                 };
559                 initPostCreate();
560             })
561             .catch(function (response) {
562                 response.json().then((json) => {
563                     if( json.error ) {
564                         handleApiError(json.error);
565                     } else {
566                         handleApiError(ill_batch_create_api_fail);
567                     }
568                 })
569             });
570     };
571
572     function updateBatch() {
573         var selectedBranchcode = branchcodeSelect.selectedOptions[0].value;
574         var selectedStatuscode = statusesSelect.selectedOptions[0].value;
575         return doBatchApiRequest('/' + batch.data.batch_id, {
576             method: 'PUT',
577             headers: {
578                 'Content-type': 'application/json'
579             },
580             body: JSON.stringify({
581                 name: nameInput.value,
582                 backend: batch.data.backend,
583                 cardnumber: batch.data.patron.cardnumber,
584                 library_id: selectedBranchcode,
585                 statuscode: selectedStatuscode
586             })
587         })
588             .catch(function () {
589                 handleApiError(ill_batch_update_api_fail);
590             });
591     };
592
593     function displaySupportedIdentifiers() {
594         var names = Object.keys(supportedIdentifiers).map(function (identifier) {
595             return window['ill_batch_' + identifier];
596         });
597         var displayEl = document.getElementById('supported_identifiers');
598         displayEl.textContent = names.length > 0 ? names.join(', ') : ill_batch_none;
599     }
600
601     function updateRowCount() {
602         var textEl = document.getElementById('row_count_value');
603         var val = textarea.value.trim();
604         var cnt = 0;
605         if (val.length > 0) {
606             cnt = val.split(/\n/).length;
607         }
608         textEl.textContent = cnt;
609     }
610
611     function showProgress() {
612         var el = document.getElementById('create-progress');
613         el.style.display = 'block';
614     }
615
616     function showCreateRequestsButton() {
617         var data = progressTotals.data;
618         var el = document.getElementById('create-requests');
619         el.style.display = (data.total > 0 && data.count === data.total) ? 'flex' : 'none';
620     }
621
622     async function processIdentifiers() {
623         var content = textarea.value;
624         hideErrors();
625         if (content.length === 0) return;
626
627         disableProcessButton();
628         var label = document.getElementById('progress-label').firstChild;
629         label.innerHTML = ill_batch_retrieving_metadata;
630         showProgress();
631
632         // Errors encountered when processing
633         var processErrors = {};
634
635         // Prepare the content, including trimming each row
636         var contentArr = content.split(/\n/);
637         var trimmed = contentArr.map(function (row) {
638             return row.trim();
639         });
640
641         var parsed = [];
642
643         trimmed.forEach(function (identifier) {
644             var match = identifyIdentifier(identifier);
645             // If this identifier is not identifiable or
646             // looks like more than one type, we can't be sure
647             // what it is
648             if (match.length != 1) {
649                 parsed.push({
650                     type: 'unknown',
651                     value: identifier
652                 });
653             } else {
654                 parsed.push(match[0]);
655             }
656         });
657
658         var unknownIdentifiers = parsed
659             .filter(function (parse) {
660                 if (parse.type == 'unknown') {
661                     return parse;
662                 }
663             })
664             .map(function (filtered) {
665                 return filtered.value;
666             });
667
668         if (unknownIdentifiers.length > 0) {
669             processErrors.badidentifiers = {
670                 element: 'badids',
671                 values: unknownIdentifiers.join(', ')
672             };
673         };
674
675         // Deduping
676         var deduped = [];
677         var dupes = {};
678         parsed.forEach(function (row) {
679             var value = row.value;
680             var alreadyInDeduped = deduped.filter(function (d) {
681                 return d.value === value;
682             });
683             if (alreadyInDeduped.length > 0 && !dupes[value]) {
684                 dupes[value] = 1;
685             } else if (alreadyInDeduped.length === 0) {
686                 row.metadata = {};
687                 row.failed = {};
688                 row.requestId = null;
689                 deduped.push(row);
690             }
691         });
692         // Update duplicate error if dupes were found
693         if (Object.keys(dupes).length > 0) {
694             processErrors.duplicates = {
695                 element: 'dupelist',
696                 values: Object.keys(dupes).join(', ')
697             };
698         }
699
700         // Display any errors
701         displayErrors(processErrors);
702
703         // Now build and display the table
704         if (!table) {
705             buildTable();
706         }
707
708         // We may be appending new values to an existing table,
709         // in which case, ensure we don't create duplicates
710         var tabIdentifiers = tableContent.data.map(function (tabId) {
711             return tabId.value;
712         });
713         var notInTable = deduped.filter(function (ded) {
714             if (!tabIdentifiers.includes(ded.value)) {
715                 return ded;
716            }
717         });
718         if (notInTable.length > 0) {
719             tableContent.data = tableContent.data.concat(notInTable);
720         }
721
722         // Populate metadata for those records that need it
723         var newData = tableContent.data;
724         for (var i = 0; i < tableContent.data.length; i++) {
725             var row = tableContent.data[i];
726             // Skip rows that don't need populating
727             if (
728                 Object.keys(tableContent.data[i].metadata).length > 0 ||
729                 Object.keys(tableContent.data[i].failed).length > 0
730             ) continue;
731             var identifier = { type: row.type, value: row.value };
732             try {
733                 var populated = await populateMetadata(identifier);
734                 row.metadata = populated.results.result || {};
735             } catch (e) {
736                 row.failed = ill_populate_failed;
737             }
738             newData[i] = row;
739             tableContent.data = newData;
740         }
741     }
742
743     function disableProcessButton() {
744         processButton.setAttribute('disabled', true);
745         processButton.setAttribute('aria-disabled', true);
746     }
747
748     function disableCreateButton() {
749         createButton.setAttribute('disabled', true);
750         createButton.setAttribute('aria-disabled', true);
751     }
752
753     async function populateMetadata(identifier) {
754         // All services that support this identifier type
755         var services = supportedIdentifiers[identifier.type];
756         // Check each service and use the first results we get, if any
757         for (var i = 0; i < services.length; i++) {
758             var service = services[i];
759             var endpoint = '/api/v1/contrib/' + service.api_namespace + service.search_endpoint + '?' + identifier.type + '=' + identifier.value;
760             var metadata = await getMetadata(endpoint);
761             if (metadata.errors.length === 0) {
762                 var parsed = await parseMetadata(metadata, service);
763                 if (parsed.errors.length > 0) {
764                     throw Error(metadata.errors.map(function (error) {
765                         return error.message;
766                     }).join(', '));
767                 }
768                 return parsed;
769             }
770         }
771     };
772
773     async function getMetadata(endpoint) {
774         var response = await debounce(doApiRequest)(endpoint);
775         return response.json();
776     };
777
778     async function parseMetadata(metadata, service) {
779         var endpoint = '/api/v1/contrib/' + service.api_namespace + service.ill_parse_endpoint;
780         var response = await doApiRequest(endpoint, {
781             method: 'POST',
782             headers: {
783                 'Content-type': 'application/json'
784             },
785             body: JSON.stringify(metadata)
786         });
787         return response.json();
788     }
789
790     // A render function for identifier type
791     function createIdentifierType(data) {
792         return window['ill_batch_' + data];
793     };
794
795     // Get an item's title
796     function getTitle(meta) {
797         if (meta.article_title && meta.article_title.length > 0) {
798             return 'article_title';
799             return {
800                 prop: 'article_title',
801                 value: meta.article_title
802             };
803         } else if (meta.title && meta.title.length > 0) {
804             return 'title';
805             return {
806                 prop: 'title',
807                 value: meta.title
808             };
809         }
810     };
811
812     // Create a metadata row
813     function createMetadataRow(data, meta, prop) {
814         if (!meta[prop]) return;
815
816         var div = document.createElement('div');
817         div.classList.add('metadata-row');
818         var label = document.createElement('span');
819         label.classList.add('metadata-label');
820         label.innerText = ill_batch_metadata[prop] + ': ';
821
822         // Add a link to the availability URL if appropriate
823         var value;
824         if (!data.url) {
825             value = document.createElement('span');
826         } else {
827             value = document.createElement('a');
828             value.setAttribute('href', data.url);
829             value.setAttribute('target', '_blank');
830             value.setAttribute('title', ill_batch_available_via + ' ' + data.availabilitySupplier);
831         }
832         value.classList.add('metadata-value');
833         value.innerText = meta[prop];
834         div.appendChild(label);
835         div.appendChild(value);
836
837         return div;
838     }
839
840     // A render function for displaying metadata
841     function createMetadata(x, y, data) {
842         // If the fetch failed
843         if (data.failed.length > 0) {
844             return data.failed;
845         }
846
847         // If we've not yet got any metadata back
848         if (Object.keys(data.metadata).length === 0) {
849             return ill_populate_waiting;
850         }
851
852         var core = ['doi', 'pmid', 'issn', 'title', 'year', 'issue', 'pages', 'publisher', 'article_title', 'article_author', 'volume'];
853         var meta = data.metadata;
854
855         var container = document.createElement('div');
856         container.classList.add('metadata-container');
857
858         // Create the title row
859         var title = getTitle(meta);
860         if (title) {
861             // Remove the title element from the props
862             // we're about to iterate
863             core = core.filter(function (i) {
864                 return i !== title;
865             });
866             var titleRow = createMetadataRow(data, meta, title);
867             container.appendChild(titleRow);
868         }
869
870         var remainder = document.createElement('div');
871         remainder.classList.add('metadata-remainder');
872         remainder.style.display = 'none';
873         // Create the remaining rows
874         core.sort().forEach(function (prop) {
875             var div = createMetadataRow(data, meta, prop);
876             if (div) {
877                 remainder.appendChild(div);
878             }
879         });
880         container.appendChild(remainder);
881
882         // Add a more/less toggle
883         var firstField = container.firstChild;
884         var moreLess = document.createElement('div');
885         moreLess.classList.add('more-less');
886         var moreLessLink = document.createElement('a');
887         moreLessLink.setAttribute('href', '#');
888         moreLessLink.classList.add('more-less-link');
889         moreLessLink.innerText = ' [' + ill_batch_metadata_more + ']';
890         moreLess.appendChild(moreLessLink);
891         firstField.appendChild(moreLess);
892
893         return container.outerHTML;
894     };
895
896     function removeRow(ev) {
897         if (ev.target.className.includes('remove-row')) {
898             if (!confirm(ill_batch_item_remove)) return;
899             // Find the parent row
900             var ancestor = ev.target.closest('tr');
901             var identifier = ancestor.querySelector('.identifier').innerText;
902             tableContent.data = tableContent.data.filter(function (row) {
903                 return row.value !== identifier;
904             });
905         }
906     }
907
908     function toggleMetadata(ev) {
909         if (ev.target.className === 'more-less-link') {
910             // Find the element we need to show
911             var ancestor = ev.target.closest('.metadata-container');
912             var meta = ancestor.querySelector('.metadata-remainder');
913
914             // Display or hide based on its current state
915             var display = window.getComputedStyle(meta).display;
916
917             meta.style.display = display === 'block' ? 'none' : 'block';
918
919             // Update the More / Less text
920             ev.target.innerText = ' [ ' + (display === 'none' ? ill_batch_metadata_less : ill_batch_metadata_more) + ' ]';
921         }
922     }
923
924     // A render function for the link to a request ID
925     function createRequestId(x, y, data) {
926         return data.requestId || '-';
927     }
928
929     function createRequestStatus(x, y, data) {
930         return data.requestStatus || '-';
931     }
932
933     function buildTable(identifiers) {
934         table = KohaTable('identifier-table', {
935             processing: true,
936             deferRender: true,
937             ordering: false,
938             paging: false,
939             searching: false,
940             autoWidth: false,
941             columns: [
942                 {
943                     data: 'type',
944                     width: '13%',
945                     render: createIdentifierType
946                 },
947                 {
948                     data: 'value',
949                     width: '25%',
950                     className: 'identifier'
951                 },
952                 {
953                     data: 'metadata',
954                     render: createMetadata
955                 },
956                 {
957                     data: 'requestId',
958                     width: '6.5%',
959                     render: createRequestId
960                 },
961                 {
962                     data: 'requestStatus',
963                     width: '6.5%',
964                     render: createRequestStatus
965                 },
966                 {
967                     width: '18%',
968                     render: createActions,
969                     className: 'action-column noExport'
970                 }
971             ],
972             createdRow: function (row, data) {
973                 if (data.failed.length > 0) {
974                     row.classList.add('fetch-failed');
975                 }
976             }
977         });
978     }
979
980     function createActions(x, y, data) {
981         return '<button type="button" aria-label='+ ill_button_remove + (data.requestId ? ' disabled aria-disabled="true"' : '') + ' class="btn btn-xs btn-danger remove-row">' + ill_button_remove + '</button>';
982     }
983
984     // Redraw the table
985     function updateTable() {
986         if (!table) return;
987         tableEl.style.display = tableContent.data.length > 0 ? 'table' : 'none';
988         tableEl.style.width = '100%';
989         table.api()
990             .clear()
991             .rows.add(tableContent.data)
992             .draw();
993     };
994
995     function identifyIdentifier(identifier) {
996         var matches = [];
997
998         // Iterate our available services to see if any can identify this identifier
999         Object.keys(supportedIdentifiers).forEach(function (identifierType) {
1000             // Since all the services supporting this identifier type should use the same
1001             // regex to identify it, we can just use the first
1002             var service = supportedIdentifiers[identifierType][0];
1003             var regex = new RegExp(service.identifiers_supported[identifierType].regex);
1004             var match = identifier.match(regex);
1005             if (match && match.groups && match.groups.identifier) {
1006                 matches.push({
1007                     type: identifierType,
1008                     value: match.groups.identifier
1009                 });
1010             }
1011         });
1012         return matches;
1013     }
1014
1015     function displayErrors(errors) {
1016         var keys = Object.keys(errors);
1017         if (keys.length > 0) {
1018             keys.forEach(function (key) {
1019                 var el = document.getElementById(errors[key].element);
1020                 el.textContent = errors[key].values;
1021                 el.style.display = 'inline';
1022                 var container = document.getElementById(key);
1023                 container.style.display = 'block';
1024             });
1025             var el = document.getElementById('textarea-errors');
1026             el.style.display = 'flex';
1027         }
1028     }
1029
1030     function hideErrors() {
1031         var dupelist = document.getElementById('dupelist');
1032         var badids = document.getElementById('badids');
1033         dupelist.textContent = '';
1034         dupelist.parentElement.style.display = 'none';
1035         badids.textContent = '';
1036         badids.parentElement.style.display = 'none';
1037         var tae = document.getElementById('textarea-errors');
1038         tae.style.display = 'none';
1039     }
1040
1041     function manageBatchItemsDisplay() {
1042         batchItemsDisplay.style.display = batch.data.batch_id ? 'block' : 'none'
1043     };
1044
1045     function updateBatchInputs() {
1046         nameInput.value = batch.data.name || '';
1047         cardnumberInput.value = batch.data.cardnumber || '';
1048         branchcodeSelect.value = batch.data.library_id || '';
1049     }
1050
1051     function debounce(func) {
1052         var timeout;
1053         return function (...args) {
1054             return new Promise(function (resolve) {
1055                 if (timeout) {
1056                     clearTimeout(timeout);
1057                 }
1058                 timeout = setTimeout(function () {
1059                     return resolve(func(...args));
1060                 }, debounceDelay);
1061             });
1062         }
1063     }
1064
1065     function patronAutocomplete() {
1066         patron_autocomplete(
1067             $('#batch-form #batchcardnumber'),
1068             {
1069               'on-select-callback': function( event, ui ) {
1070                 $("#batch-form #batchcardnumber").val( ui.item.cardnumber );
1071                 return false;
1072               }
1073             }
1074           );
1075     };
1076
1077     function createPatronLink() {
1078         if (!batch.data.patron) return;
1079         var patron = batch.data.patron;
1080         var a = document.createElement('a');
1081         var href = '/cgi-bin/koha/members/moremember.pl?borrowernumber=' + patron.borrowernumber;
1082         var text = patron.surname + ' (' + patron.cardnumber + ')';
1083         a.setAttribute('title', ill_borrower_details);
1084         a.setAttribute('href', href);
1085         a.textContent = text;
1086         return a;
1087     };
1088
1089 })();