]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.ts
LP1948035 Angular 12 updates
[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     @ViewChild('queueSelector') private queueSelector: ComboboxComponent;
137     @ViewChild('bucketSelector') private bucketSelector: ComboboxComponent;
138
139     @ViewChild('dupeQueueAlert', { static: true })
140         private dupeQueueAlert: AlertDialogComponent;
141
142     constructor(
143         private http: HttpClient,
144         private toast: ToastService,
145         private evt: EventService,
146         private net: NetService,
147         private auth: AuthService,
148         private org: OrgService,
149         private store: ServerStoreService,
150         private vandelay: VandelayService
151     ) {
152     }
153
154     applyDefaults() {
155         this.minQualityRatio = 0;
156         this.selectedBibSource = 1; // default to system local
157         this.recordType = 'bib';
158         this.bibTrashGroups = [];
159         this.formTemplates = {};
160
161         if (this.vandelay.importSelection) {
162             // Apply start-id values to our comboboxes based on our
163             // import selection values.
164             const queue = this.vandelay.importSelection.queue;
165
166             if (!queue) {
167                 // Incomplete import selection, clear it.
168                 this.vandelay.importSelection = null;
169                 return;
170             }
171
172             this.recordType = queue.queue_type();
173             this.startQueueId = queue.id();
174             this.selectedMatchSet = queue.match_set();
175
176             if (this.recordType === 'bib') {
177                 this.selectedBucket = queue.match_bucket();
178                 this.selectedHoldingsProfile = queue.item_attr_def();
179             }
180         }
181     }
182
183     applyImportSelection() {
184
185         // Depending on when the comboboxes are rendered, the start-id values
186         // applied in applyDefaults() may not have any affect.  Ensure we
187         // get the initial values we want by manually setting selectedIds
188         // to the rendered combobox as well.
189
190         const queue = this.vandelay.importSelection.queue;
191         if (!queue) {
192             // Incomplete import selection, clear it.
193             this.vandelay.importSelection = null;
194             return;
195         }
196
197         if (this.queueSelector) {
198             this.queueSelector.selectedId = this.startQueueId;
199             this.selectedQueue = {id: queue.id(), label: queue.name()};
200         }
201
202         if (this.matchSetSelector) {
203             this.matchSetSelector.selectedId = this.selectedMatchSet;
204         }
205
206         if (this.recordType === 'bib') {
207             if (this.holdingsProfileSelector) {
208                 this.holdingsProfileSelector.selectedId = this.selectedHoldingsProfile;
209             }
210             if (this.bucketSelector) {
211                 this.bucketSelector.selectedId = this.selectedBucket;
212             }
213         }
214     }
215
216     ngOnInit() {
217         this.applyDefaults();
218     }
219
220     ngAfterViewInit() {
221         if (this.vandelay.importSelection) {
222             // setTimeout() is not required here, but it helps to
223             // avoid expression-changed-after-checking console messages.
224             setTimeout(() => this.applyImportSelection());
225         }
226         this.loadStartupData();
227     }
228
229     ngOnDestroy() {
230         // Always clear the import selection when navigating away from
231         // the import page.
232         this.clearSelection();
233     }
234
235     importSelection(): VandelayImportSelection {
236         return this.vandelay.importSelection;
237     }
238
239     loadStartupData(): Promise<any> {
240         // Note displaying and manipulating a progress dialog inside
241         // the AfterViewInit cycle leads to errors because the child
242         // component is modifed after dirty checking.
243
244         const promises = [
245             this.vandelay.getMergeProfiles(),
246             this.vandelay.getAllQueues('bib'),
247             this.vandelay.getAllQueues('authority'),
248             this.vandelay.getMatchSets('bib'),
249             this.vandelay.getMatchSets('authority'),
250             this.vandelay.getBibBuckets(),
251             this.vandelay.getBibSources(),
252             this.vandelay.getItemImportDefs(),
253             this.vandelay.getBibTrashGroups().then(
254                 groups => this.bibTrashGroups = groups),
255             this.org.settings(['vandelay.default_match_set']).then(
256                 s => this.defaultMatchSet = s['vandelay.default_match_set']),
257             this.loadTemplates()
258         ];
259
260         return Promise.all(promises);
261     }
262
263     loadTemplates() {
264         this.store.getItem(TEMPLATE_SETTING_NAME).then(
265             templates => {
266                 this.formTemplates = templates || {};
267
268                 Object.keys(this.formTemplates).forEach(name => {
269                     if (this.formTemplates[name].default) {
270                         this.selectedTemplate = name;
271                     }
272                 });
273             }
274         );
275     }
276
277     formatTemplateEntries(): ComboboxEntry[] {
278         const entries = [];
279
280         Object.keys(this.formTemplates || {}).forEach(
281             name => entries.push({id: name, label: name}));
282
283         return entries;
284     }
285
286     // Format typeahead data sets
287     formatEntries(etype: string): ComboboxEntry[] {
288         const rtype = this.recordType;
289         let list;
290
291         switch (etype) {
292             case 'bibSources':
293                 return (this.vandelay.bibSources || []).map(
294                     s => {
295                         return {id: s.id(), label: s.source()};
296                     });
297
298             case 'bibBuckets':
299                 list = this.vandelay.bibBuckets;
300                 break;
301
302             case 'activeQueues':
303                 list = (this.vandelay.allQueues[rtype] || [])
304                         .filter(q => q.complete() === 'f');
305                 break;
306
307             case 'matchSets':
308                 list = this.vandelay.matchSets[rtype];
309                 break;
310
311             case 'importItemDefs':
312                 list = this.vandelay.importItemAttrDefs;
313                 break;
314
315             case 'mergeProfiles':
316                 list = this.vandelay.mergeProfiles;
317                 break;
318         }
319
320         return (list || []).map(item => {
321             return {id: item.id(), label: item.name()};
322         });
323     }
324
325     selectEntry($event: ComboboxEntry, etype: string) {
326         const id = $event ? $event.id : null;
327
328         switch (etype) {
329             case 'recordType':
330                 this.recordType = id;
331                 break;
332
333             case 'bibSources':
334                 this.selectedBibSource = id;
335                 break;
336
337             case 'bibBuckets':
338                 this.selectedBucket = id;
339                 break;
340
341             case 'matchSets':
342                 this.selectedMatchSet = id;
343                 break;
344
345             case 'importItemDefs':
346                 this.selectedHoldingsProfile = id;
347                 break;
348
349             case 'mergeProfiles':
350                 this.selectedMergeProfile = id;
351                 break;
352
353             case 'FallThruMergeProfile':
354                 this.selectedFallThruMergeProfile = id;
355                 break;
356         }
357     }
358
359     fileSelected($event) {
360        this.selectedFile = $event.target.files[0];
361     }
362
363     // Required form data varies depending on context.
364     hasNeededData(): boolean {
365         if (this.vandelay.importSelection) {
366             return this.importActionSelected();
367         } else {
368             return this.selectedQueue &&
369                 Boolean(this.recordType) && Boolean(this.selectedFile);
370         }
371     }
372
373     importActionSelected(): boolean {
374         return this.importNonMatching
375             || this.mergeOnExact
376             || this.mergeOnSingleMatch
377             || this.mergeOnBestMatch;
378     }
379
380     // 1. create queue if necessary
381     // 2. upload MARC file
382     // 3. Enqueue MARC records
383     // 4. Import records
384     upload() {
385         this.sessionKey = null;
386         this.showProgress = true;
387         this.isUploading = true;
388         this.uploadComplete = false;
389         this.resetProgressBars();
390
391         this.resolveQueue()
392         .then(
393             queueId => {
394                 this.activeQueueId = queueId;
395                 return this.uploadFile();
396             },
397             err => Promise.reject('queue create failed')
398         ).then(
399             ok => this.processSpool(),
400             err => Promise.reject('process spool failed')
401         ).then(
402             ok => this.importRecords(),
403             err => Promise.reject('import records failed')
404         ).then(
405             ok => {
406                 this.isUploading = false;
407                 this.uploadComplete = true;
408             },
409             err => {
410                 console.log('file upload failed: ', err);
411                 this.isUploading = false;
412                 this.resetProgressBars();
413
414             }
415         );
416     }
417
418     resetProgressBars() {
419         this.uploadProgress.update({value: 0, max: 1});
420         this.enqueueProgress.update({value: 0, max: 1});
421         this.importProgress.update({value: 0, max: 1});
422     }
423
424     // Extract selected queue ID or create a new queue when requested.
425     resolveQueue(): Promise<number> {
426
427         if (this.selectedQueue.freetext) {
428             // Free text queue selector means create a new entry.
429             // TODO: first check for name dupes
430
431             return this.vandelay.createQueue(
432                 this.selectedQueue.label,
433                 this.recordType,
434                 this.selectedHoldingsProfile,
435                 this.selectedMatchSet,
436                 this.selectedBucket
437             ).then(
438                 id => id,
439                 err => {
440                     const evt = this.evt.parse(err);
441                     if (evt) {
442                         if (evt.textcode.match(/QUEUE_EXISTS/)) {
443                             this.dupeQueueAlert.open();
444                         } else {
445                             alert(evt); // server error
446                         }
447                     }
448
449                     return Promise.reject('Queue Create Failed');
450                 }
451             );
452         } else {
453             return Promise.resolve(this.selectedQueue.id);
454         }
455     }
456
457     uploadFile(): Promise<any> {
458
459         if (this.vandelay.importSelection) {
460             // Nothing to upload when processing pre-queued records.
461             return Promise.resolve();
462         }
463
464         const formData: FormData = new FormData();
465
466         formData.append('ses', this.auth.token());
467         formData.append('marc_upload',
468             this.selectedFile, this.selectedFile.name);
469
470         if (this.selectedBibSource) {
471             formData.append('bib_source', '' + this.selectedBibSource);
472         }
473
474         const req = new HttpRequest('POST', VANDELAY_UPLOAD_PATH, formData,
475             {reportProgress: true, responseType: 'text'});
476
477         return this.http.request(req).pipe(tap(
478             evt => {
479                 if (evt.type === HttpEventType.UploadProgress) {
480                     this.uploadProgress.update(
481                         {value: evt.loaded, max: evt.total});
482
483                 } else if (evt instanceof HttpResponse) {
484                     this.sessionKey = evt.body as string;
485                     console.log(
486                         'Vandelay file uploaded OK with key ' + this.sessionKey);
487                 }
488             },
489
490             (err: HttpErrorResponse) => {
491                 console.error(err);
492                 this.toast.danger(err.error);
493             }
494         )).toPromise();
495     }
496
497     processSpool():  Promise<any> {
498
499         if (this.vandelay.importSelection) {
500             // Nothing to enqueue when processing pre-queued records
501             return Promise.resolve();
502         }
503
504         let spoolType = this.recordType;
505         if (this.recordType === 'authority') {
506             spoolType = 'auth';
507         }
508
509         const method = `open-ils.vandelay.${spoolType}.process_spool`;
510
511         return new Promise((resolve, reject) => {
512             this.net.request(
513                 'open-ils.vandelay', method,
514                 this.auth.token(), this.sessionKey, this.activeQueueId,
515                 null, null, this.selectedBibSource,
516                 (this.sessionName || null), true
517             ).subscribe(
518                 tracker => {
519                     const e = this.evt.parse(tracker);
520                     if (e) { console.error(e); return reject(); }
521
522                     // Spooling is in progress, track the results.
523                     this.vandelay.pollSessionTracker(tracker.id())
524                     .subscribe(
525                         trkr => {
526                             this.enqueueProgress.update({
527                                 // enqueue API only tracks actions performed
528                                 max: null,
529                                 value: trkr.actions_performed()
530                             });
531                         },
532                         err => { console.log(err); reject(); },
533                         () => {
534                             this.enqueueProgress.update({max: 1, value: 1});
535                             resolve(null);
536                         }
537                     );
538                 }
539             );
540         });
541     }
542
543     importRecords(): Promise<any> {
544
545         if (!this.importActionSelected()) {
546             return Promise.resolve();
547         }
548
549         const selection = this.vandelay.importSelection;
550
551         if (selection && !selection.importQueue) {
552             return this.importRecordQueue(selection.recordIds);
553         } else {
554             return this.importRecordQueue();
555         }
556     }
557
558     importRecordQueue(recIds?: number[]): Promise<any> {
559         const rtype = this.recordType === 'bib' ? 'bib' : 'auth';
560
561         let method = `open-ils.vandelay.${rtype}_queue.import`;
562         const options: ImportOptions = this.compileImportOptions();
563
564         let target: number | number[] = this.activeQueueId;
565         if (recIds && recIds.length) {
566             method = `open-ils.vandelay.${rtype}_record.list.import`;
567             target = recIds;
568         }
569
570         return new Promise((resolve, reject) => {
571             this.net.request('open-ils.vandelay',
572                 method, this.auth.token(), target, options)
573             .subscribe(
574                 tracker => {
575                     const e = this.evt.parse(tracker);
576                     if (e) { console.error(e); return reject(); }
577
578                     // Spooling is in progress, track the results.
579                     this.vandelay.pollSessionTracker(tracker.id())
580                     .subscribe(
581                         trkr => {
582                             this.importProgress.update({
583                                 max: trkr.total_actions(),
584                                 value: trkr.actions_performed()
585                             });
586                         },
587                         err => { console.log(err); reject(); },
588                         () => {
589                             this.importProgress.update({max: 1, value: 1});
590                             resolve(null);
591                         }
592                     );
593                 }
594             );
595         });
596     }
597
598     compileImportOptions(): ImportOptions {
599
600         const options: ImportOptions = {
601             session_key: this.sessionKey,
602             import_no_match: this.importNonMatching,
603             auto_overlay_exact: this.mergeOnExact,
604             auto_overlay_best_match: this.mergeOnBestMatch,
605             auto_overlay_1match: this.mergeOnSingleMatch,
606             opp_acq_copy_overlay: this.autoOverlayAcqCopies,
607             opp_oo_cat_copy_overlay: this.autoOverlayOnOrderCopies,
608             auto_overlay_org_unit_copies: this.autoOverlayOrgUnitCopies,
609             merge_profile: this.selectedMergeProfile,
610             fall_through_merge_profile: this.selectedFallThruMergeProfile,
611             strip_field_groups: this.selectedTrashGroups,
612             match_quality_ratio: this.minQualityRatio,
613             exit_early: true
614         };
615
616         if (this.vandelay.importSelection) {
617             options.overlay_map = this.vandelay.importSelection.overlayMap;
618         }
619
620         return options;
621     }
622
623     clearSelection() {
624         this.vandelay.importSelection = null;
625         this.startQueueId = null;
626     }
627
628     openQueue() {
629         console.log('opening queue ' + this.activeQueueId);
630     }
631
632     saveTemplate() {
633
634         const template = {};
635         TEMPLATE_ATTRS.forEach(key => template[key] = this[key]);
636
637         console.debug('Saving import profile', template);
638
639         this.formTemplates[this.selectedTemplate] = template;
640         return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
641     }
642
643     markTemplateDefault() {
644
645         Object.keys(this.formTemplates).forEach(
646             name => delete this.formTemplates.default
647         );
648
649         this.formTemplates[this.selectedTemplate].default = true;
650
651         return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
652     }
653
654     templateSelectorChange(entry: ComboboxEntry) {
655
656         if (!entry) {
657             this.selectedTemplate = '';
658             return;
659         }
660
661         this.selectedTemplate = entry.label; // label == name
662
663         if (entry.freetext) {
664             // User is entering a new template name.
665             // Nothing to apply.
666             return;
667         }
668
669         // User selected an existing template, apply it to the form.
670
671         const template = this.formTemplates[entry.id];
672
673         // Copy the template values into "this"
674         TEMPLATE_ATTRS.forEach(key => this[key] = template[key]);
675
676         // Some values must be manually passed to the combobox'es
677
678         this.recordTypeSelector.applyEntryId(this.recordType);
679         this.bibSourceSelector.applyEntryId(this.selectedBibSource);
680         this.matchSetSelector.applyEntryId(this.selectedMatchSet);
681         this.holdingsProfileSelector
682             .applyEntryId(this.selectedHoldingsProfile);
683         this.mergeProfileSelector.applyEntryId(this.selectedMergeProfile);
684         this.fallThruMergeProfileSelector
685             .applyEntryId(this.selectedFallThruMergeProfile);
686     }
687
688     deleteTemplate() {
689         delete this.formTemplates[this.selectedTemplate];
690         this.formTemplateSelector.selected = null;
691         return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
692     }
693 }
694