LP#1803406 Due date box in check out has display issues at wider resolutions
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / cat / vandelay / import.component.ts
1 import {Component, OnInit, AfterViewInit, Input,
2     ViewChild, OnDestroy} from '@angular/core';
3 import {Subject} from 'rxjs';
4 import {tap} from 'rxjs/operators';
5 import {IdlObject} from '@eg/core/idl.service';
6 import {NetService} from '@eg/core/net.service';
7 import {EventService} from '@eg/core/event.service';
8 import {OrgService} from '@eg/core/org.service';
9 import {AuthService} from '@eg/core/auth.service';
10 import {ToastService} from '@eg/share/toast/toast.service';
11 import {ComboboxComponent,
12     ComboboxEntry} from '@eg/share/combobox/combobox.component';
13 import {VandelayService, VandelayImportSelection,
14   VANDELAY_UPLOAD_PATH} from './vandelay.service';
15 import {HttpClient, HttpRequest, HttpEventType} from '@angular/common/http';
16 import {HttpResponse, HttpErrorResponse} from '@angular/common/http';
17 import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component';
18 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
19 import {ServerStoreService} from '@eg/core/server-store.service';
20
21 const TEMPLATE_SETTING_NAME = 'eg.cat.vandelay.import.templates';
22
23 const TEMPLATE_ATTRS = [
24     'recordType',
25     'selectedBibSource',
26     'selectedMatchSet',
27     'mergeOnExact',
28     'importNonMatching',
29     'mergeOnBestMatch',
30     'mergeOnSingleMatch',
31     'autoOverlayAcqCopies',
32     'autoOverlayOnOrderCopies',
33     'autoOverlayOrgUnitCopies',
34     'selectedHoldingsProfile',
35     'selectedMergeProfile',
36     'selectedFallThruMergeProfile',
37     'selectedTrashGroups',
38     'minQualityRatio'
39 ];
40
41 interface ImportOptions {
42     session_key: string;
43     overlay_map?: {[qrId: number]: /* breId */ number};
44     import_no_match?: boolean;
45     auto_overlay_exact?: boolean;
46     auto_overlay_best_match?: boolean;
47     auto_overlay_1match?: boolean;
48     opp_acq_copy_overlay?: boolean;
49     opp_oo_cat_copy_overlay?: boolean;
50     auto_overlay_org_unit_copies?: boolean;
51     merge_profile?: any;
52     fall_through_merge_profile?: any;
53     strip_field_groups?: number[];
54     match_quality_ratio: number;
55     exit_early: boolean;
56 }
57
58 @Component({
59   templateUrl: 'import.component.html'
60 })
61 export class ImportComponent implements OnInit, AfterViewInit, OnDestroy {
62
63     recordType: string;
64     selectedQueue: ComboboxEntry; // freetext enabled
65
66     // used for applying a default queue ID value when we have
67     // a load-time queue before the queue combobox entries exist.
68     startQueueId: number;
69
70     bibTrashGroups: IdlObject[];
71     selectedTrashGroups: number[];
72
73     activeQueueId: number;
74     selectedBucket: number;
75     selectedBibSource: number;
76     selectedMatchSet: number;
77     selectedHoldingsProfile: number;
78     selectedMergeProfile: number;
79     selectedFallThruMergeProfile: number;
80     selectedFile: File;
81
82     defaultMatchSet: string;
83
84     importNonMatching: boolean;
85     mergeOnExact: boolean;
86     mergeOnSingleMatch: boolean;
87     mergeOnBestMatch: boolean;
88     minQualityRatio: number;
89     autoOverlayAcqCopies: boolean;
90     autoOverlayOnOrderCopies: boolean;
91     autoOverlayOrgUnitCopies: boolean;
92
93     // True after the first upload, then remains true.
94     showProgress: boolean;
95
96     // Upload in progress.
97     isUploading: boolean;
98
99     // True only after successful upload
100     uploadComplete: boolean;
101
102     // Upload / processsing session key
103     // Generated by the server
104     sessionKey: string;
105
106     // Optional enqueue/import tracker session name.
107     sessionName: string;
108
109     selectedTemplate: string;
110     formTemplates: {[name: string]: any};
111     newTemplateName: string;
112
113     @ViewChild('fileSelector', { static: false }) private fileSelector;
114     @ViewChild('uploadProgress', { static: true })
115         private uploadProgress: ProgressInlineComponent;
116     @ViewChild('enqueueProgress', { static: true })
117         private enqueueProgress: ProgressInlineComponent;
118     @ViewChild('importProgress', { static: true })
119         private importProgress: ProgressInlineComponent;
120
121     // Need these refs so values can be applied via external stimuli
122     @ViewChild('formTemplateSelector', { static: true })
123         private formTemplateSelector: ComboboxComponent;
124     @ViewChild('recordTypeSelector', { static: true })
125         private recordTypeSelector: ComboboxComponent;
126     @ViewChild('bibSourceSelector', { static: true })
127         private bibSourceSelector: ComboboxComponent;
128     @ViewChild('matchSetSelector', { static: true })
129         private matchSetSelector: ComboboxComponent;
130     @ViewChild('holdingsProfileSelector', { static: true })
131         private holdingsProfileSelector: ComboboxComponent;
132     @ViewChild('mergeProfileSelector', { static: true })
133         private mergeProfileSelector: ComboboxComponent;
134     @ViewChild('fallThruMergeProfileSelector', { static: true })
135         private fallThruMergeProfileSelector: ComboboxComponent;
136
137     @ViewChild('dupeQueueAlert', { static: true })
138         private dupeQueueAlert: AlertDialogComponent;
139
140     constructor(
141         private http: HttpClient,
142         private toast: ToastService,
143         private evt: EventService,
144         private net: NetService,
145         private auth: AuthService,
146         private org: OrgService,
147         private store: ServerStoreService,
148         private vandelay: VandelayService
149     ) {
150         this.applyDefaults();
151     }
152
153     applyDefaults() {
154         this.minQualityRatio = 0;
155         this.selectedBibSource = 1; // default to system local
156         this.recordType = 'bib';
157         this.bibTrashGroups = [];
158         this.formTemplates = {};
159
160         if (this.vandelay.importSelection) {
161
162             if (!this.vandelay.importSelection.queue) {
163                 // Incomplete import selection, clear it.
164                 this.vandelay.importSelection = null;
165                 return;
166             }
167
168             const queue = this.vandelay.importSelection.queue;
169             this.recordType = queue.queue_type();
170             this.selectedMatchSet = queue.match_set();
171
172             // This will be propagated to selectedQueue as a combobox
173             // entry via the combobox
174             this.startQueueId = queue.id();
175
176             if (this.recordType === 'bib') {
177                 this.selectedBucket = queue.match_bucket();
178                 this.selectedHoldingsProfile = queue.item_attr_def();
179             }
180         }
181     }
182
183     ngOnInit() {}
184
185     ngAfterViewInit() {
186         this.loadStartupData();
187     }
188
189     ngOnDestroy() {
190         // Always clear the import selection when navigating away from
191         // the import page.
192         this.clearSelection();
193     }
194
195     importSelection(): VandelayImportSelection {
196         return this.vandelay.importSelection;
197     }
198
199     loadStartupData(): Promise<any> {
200         // Note displaying and manipulating a progress dialog inside
201         // the AfterViewInit cycle leads to errors because the child
202         // component is modifed after dirty checking.
203
204         const promises = [
205             this.vandelay.getMergeProfiles(),
206             this.vandelay.getAllQueues('bib'),
207             this.vandelay.getAllQueues('authority'),
208             this.vandelay.getMatchSets('bib'),
209             this.vandelay.getMatchSets('authority'),
210             this.vandelay.getBibBuckets(),
211             this.vandelay.getBibSources(),
212             this.vandelay.getItemImportDefs(),
213             this.vandelay.getBibTrashGroups().then(
214                 groups => this.bibTrashGroups = groups),
215             this.org.settings(['vandelay.default_match_set']).then(
216                 s => this.defaultMatchSet = s['vandelay.default_match_set']),
217             this.loadTemplates()
218         ];
219
220         return Promise.all(promises);
221     }
222
223     loadTemplates() {
224         this.store.getItem(TEMPLATE_SETTING_NAME).then(
225             templates => {
226                 this.formTemplates = templates || {};
227
228                 Object.keys(this.formTemplates).forEach(name => {
229                     if (this.formTemplates[name].default) {
230                         this.selectedTemplate = name;
231                     }
232                 });
233             }
234         );
235     }
236
237     formatTemplateEntries(): ComboboxEntry[] {
238         const entries = [];
239
240         Object.keys(this.formTemplates || {}).forEach(
241             name => entries.push({id: name, label: name}));
242
243         return entries;
244     }
245
246     // Format typeahead data sets
247     formatEntries(etype: string): ComboboxEntry[] {
248         const rtype = this.recordType;
249         let list;
250
251         switch (etype) {
252             case 'bibSources':
253                 return (this.vandelay.bibSources || []).map(
254                     s => {
255                         return {id: s.id(), label: s.source()};
256                     });
257
258             case 'bibBuckets':
259                 list = this.vandelay.bibBuckets;
260                 break;
261
262             case 'activeQueues':
263                 list = (this.vandelay.allQueues[rtype] || [])
264                         .filter(q => q.complete() === 'f');
265                 break;
266
267             case 'matchSets':
268                 list = this.vandelay.matchSets[rtype];
269                 break;
270
271             case 'importItemDefs':
272                 list = this.vandelay.importItemAttrDefs;
273                 break;
274
275             case 'mergeProfiles':
276                 list = this.vandelay.mergeProfiles;
277                 break;
278         }
279
280         return (list || []).map(item => {
281             return {id: item.id(), label: item.name()};
282         });
283     }
284
285     selectEntry($event: ComboboxEntry, etype: string) {
286         const id = $event ? $event.id : null;
287
288         switch (etype) {
289             case 'recordType':
290                 this.recordType = id;
291                 break;
292
293             case 'bibSources':
294                 this.selectedBibSource = id;
295                 break;
296
297             case 'bibBuckets':
298                 this.selectedBucket = id;
299                 break;
300
301             case 'matchSets':
302                 this.selectedMatchSet = id;
303                 break;
304
305             case 'importItemDefs':
306                 this.selectedHoldingsProfile = id;
307                 break;
308
309             case 'mergeProfiles':
310                 this.selectedMergeProfile = id;
311                 break;
312
313             case 'FallThruMergeProfile':
314                 this.selectedFallThruMergeProfile = id;
315                 break;
316         }
317     }
318
319     fileSelected($event) {
320        this.selectedFile = $event.target.files[0];
321     }
322
323     // Required form data varies depending on context.
324     hasNeededData(): boolean {
325         if (this.vandelay.importSelection) {
326             return this.importActionSelected();
327         } else {
328             return this.selectedQueue &&
329                 Boolean(this.recordType) && Boolean(this.selectedFile);
330         }
331     }
332
333     importActionSelected(): boolean {
334         return this.importNonMatching
335             || this.mergeOnExact
336             || this.mergeOnSingleMatch
337             || this.mergeOnBestMatch;
338     }
339
340     // 1. create queue if necessary
341     // 2. upload MARC file
342     // 3. Enqueue MARC records
343     // 4. Import records
344     upload() {
345         this.sessionKey = null;
346         this.showProgress = true;
347         this.isUploading = true;
348         this.uploadComplete = false;
349         this.resetProgressBars();
350
351         this.resolveQueue()
352         .then(
353             queueId => {
354                 this.activeQueueId = queueId;
355                 return this.uploadFile();
356             },
357             err => Promise.reject('queue create failed')
358         ).then(
359             ok => this.processSpool(),
360             err => Promise.reject('process spool failed')
361         ).then(
362             ok => this.importRecords(),
363             err => Promise.reject('import records failed')
364         ).then(
365             ok => {
366                 this.isUploading = false;
367                 this.uploadComplete = true;
368             },
369             err => {
370                 console.log('file upload failed: ', err);
371                 this.isUploading = false;
372                 this.resetProgressBars();
373
374             }
375         );
376     }
377
378     resetProgressBars() {
379         this.uploadProgress.update({value: 0, max: 1});
380         this.enqueueProgress.update({value: 0, max: 1});
381         this.importProgress.update({value: 0, max: 1});
382     }
383
384     // Extract selected queue ID or create a new queue when requested.
385     resolveQueue(): Promise<number> {
386
387         if (this.selectedQueue.freetext) {
388             // Free text queue selector means create a new entry.
389             // TODO: first check for name dupes
390
391             return this.vandelay.createQueue(
392                 this.selectedQueue.label,
393                 this.recordType,
394                 this.selectedHoldingsProfile,
395                 this.selectedMatchSet,
396                 this.selectedBucket
397             ).then(
398                 id => id,
399                 err => {
400                     const evt = this.evt.parse(err);
401                     if (evt) {
402                         if (evt.textcode.match(/QUEUE_EXISTS/)) {
403                             this.dupeQueueAlert.open();
404                         } else {
405                             alert(evt); // server error
406                         }
407                     }
408
409                     return Promise.reject('Queue Create Failed');
410                 }
411             );
412         } else {
413             return Promise.resolve(this.selectedQueue.id);
414         }
415     }
416
417     uploadFile(): Promise<any> {
418
419         if (this.vandelay.importSelection) {
420             // Nothing to upload when processing pre-queued records.
421             return Promise.resolve();
422         }
423
424         const formData: FormData = new FormData();
425
426         formData.append('ses', this.auth.token());
427         formData.append('marc_upload',
428             this.selectedFile, this.selectedFile.name);
429
430         if (this.selectedBibSource) {
431             formData.append('bib_source', '' + this.selectedBibSource);
432         }
433
434         const req = new HttpRequest('POST', VANDELAY_UPLOAD_PATH, formData,
435             {reportProgress: true, responseType: 'text'});
436
437         return this.http.request(req).pipe(tap(
438             evt => {
439                 if (evt.type === HttpEventType.UploadProgress) {
440                     this.uploadProgress.update(
441                         {value: evt.loaded, max: evt.total});
442
443                 } else if (evt instanceof HttpResponse) {
444                     this.sessionKey = evt.body as string;
445                     console.log(
446                         'Vandelay file uploaded OK with key ' + this.sessionKey);
447                 }
448             },
449
450             (err: HttpErrorResponse) => {
451                 console.error(err);
452                 this.toast.danger(err.error);
453             }
454         )).toPromise();
455     }
456
457     processSpool():  Promise<any> {
458
459         if (this.vandelay.importSelection) {
460             // Nothing to enqueue when processing pre-queued records
461             return Promise.resolve();
462         }
463
464         let spoolType = this.recordType;
465         if (this.recordType === 'authority') {
466             spoolType = 'auth';
467         }
468
469         const method = `open-ils.vandelay.${spoolType}.process_spool`;
470
471         return new Promise((resolve, reject) => {
472             this.net.request(
473                 'open-ils.vandelay', method,
474                 this.auth.token(), this.sessionKey, this.activeQueueId,
475                 null, null, this.selectedBibSource,
476                 (this.sessionName || null), true
477             ).subscribe(
478                 tracker => {
479                     const e = this.evt.parse(tracker);
480                     if (e) { console.error(e); return reject(); }
481
482                     // Spooling is in progress, track the results.
483                     this.vandelay.pollSessionTracker(tracker.id())
484                     .subscribe(
485                         trkr => {
486                             this.enqueueProgress.update({
487                                 // enqueue API only tracks actions performed
488                                 max: null,
489                                 value: trkr.actions_performed()
490                             });
491                         },
492                         err => { console.log(err); reject(); },
493                         () => {
494                             this.enqueueProgress.update({max: 1, value: 1});
495                             resolve();
496                         }
497                     );
498                 }
499             );
500         });
501     }
502
503     importRecords(): Promise<any> {
504
505         if (!this.importActionSelected()) {
506             return Promise.resolve();
507         }
508
509         const selection = this.vandelay.importSelection;
510
511         if (selection && !selection.importQueue) {
512             return this.importRecordQueue(selection.recordIds);
513         } else {
514             return this.importRecordQueue();
515         }
516     }
517
518     importRecordQueue(recIds?: number[]): Promise<any> {
519         const rtype = this.recordType === 'bib' ? 'bib' : 'auth';
520
521         let method = `open-ils.vandelay.${rtype}_queue.import`;
522         const options: ImportOptions = this.compileImportOptions();
523
524         let target: number | number[] = this.activeQueueId;
525         if (recIds && recIds.length) {
526             method = `open-ils.vandelay.${rtype}_record.list.import`;
527             target = recIds;
528         }
529
530         return new Promise((resolve, reject) => {
531             this.net.request('open-ils.vandelay',
532                 method, this.auth.token(), target, options)
533             .subscribe(
534                 tracker => {
535                     const e = this.evt.parse(tracker);
536                     if (e) { console.error(e); return reject(); }
537
538                     // Spooling is in progress, track the results.
539                     this.vandelay.pollSessionTracker(tracker.id())
540                     .subscribe(
541                         trkr => {
542                             this.importProgress.update({
543                                 max: trkr.total_actions(),
544                                 value: trkr.actions_performed()
545                             });
546                         },
547                         err => { console.log(err); reject(); },
548                         () => {
549                             this.importProgress.update({max: 1, value: 1});
550                             resolve();
551                         }
552                     );
553                 }
554             );
555         });
556     }
557
558     compileImportOptions(): ImportOptions {
559
560         const options: ImportOptions = {
561             session_key: this.sessionKey,
562             import_no_match: this.importNonMatching,
563             auto_overlay_exact: this.mergeOnExact,
564             auto_overlay_best_match: this.mergeOnBestMatch,
565             auto_overlay_1match: this.mergeOnSingleMatch,
566             opp_acq_copy_overlay: this.autoOverlayAcqCopies,
567             opp_oo_cat_copy_overlay: this.autoOverlayOnOrderCopies,
568             auto_overlay_org_unit_copies: this.autoOverlayOrgUnitCopies,
569             merge_profile: this.selectedMergeProfile,
570             fall_through_merge_profile: this.selectedFallThruMergeProfile,
571             strip_field_groups: this.selectedTrashGroups,
572             match_quality_ratio: this.minQualityRatio,
573             exit_early: true
574         };
575
576         if (this.vandelay.importSelection) {
577             options.overlay_map = this.vandelay.importSelection.overlayMap;
578         }
579
580         return options;
581     }
582
583     clearSelection() {
584         this.vandelay.importSelection = null;
585         this.startQueueId = null;
586     }
587
588     openQueue() {
589         console.log('opening queue ' + this.activeQueueId);
590     }
591
592     saveTemplate() {
593
594         const template = {};
595         TEMPLATE_ATTRS.forEach(key => template[key] = this[key]);
596
597         console.debug('Saving import profile', template);
598
599         this.formTemplates[this.selectedTemplate] = template;
600         return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
601     }
602
603     markTemplateDefault() {
604
605         Object.keys(this.formTemplates).forEach(
606             name => delete this.formTemplates.default
607         );
608
609         this.formTemplates[this.selectedTemplate].default = true;
610
611         return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
612     }
613
614     templateSelectorChange(entry: ComboboxEntry) {
615
616         if (!entry) {
617             this.selectedTemplate = '';
618             return;
619         }
620
621         this.selectedTemplate = entry.label; // label == name
622
623         if (entry.freetext) {
624             // User is entering a new template name.
625             // Nothing to apply.
626             return;
627         }
628
629         // User selected an existing template, apply it to the form.
630
631         const template = this.formTemplates[entry.id];
632
633         // Copy the template values into "this"
634         TEMPLATE_ATTRS.forEach(key => this[key] = template[key]);
635
636         // Some values must be manually passed to the combobox'es
637
638         this.recordTypeSelector.applyEntryId(this.recordType);
639         this.bibSourceSelector.applyEntryId(this.selectedBibSource);
640         this.matchSetSelector.applyEntryId(this.selectedMatchSet);
641         this.holdingsProfileSelector
642             .applyEntryId(this.selectedHoldingsProfile);
643         this.mergeProfileSelector.applyEntryId(this.selectedMergeProfile);
644         this.fallThruMergeProfileSelector
645             .applyEntryId(this.selectedFallThruMergeProfile);
646     }
647
648     deleteTemplate() {
649         delete this.formTemplates[this.selectedTemplate];
650         this.formTemplateSelector.selected = null;
651         return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
652     }
653 }
654