1 import {Component, OnInit, AfterViewInit,
2 ViewChild, OnDestroy} from '@angular/core';
3 import {tap} from 'rxjs/operators';
4 import {IdlObject} from '@eg/core/idl.service';
5 import {NetService} from '@eg/core/net.service';
6 import {EventService} from '@eg/core/event.service';
7 import {OrgService} from '@eg/core/org.service';
8 import {AuthService} from '@eg/core/auth.service';
9 import {ToastService} from '@eg/share/toast/toast.service';
10 import {ComboboxComponent,
11 ComboboxEntry} from '@eg/share/combobox/combobox.component';
12 import {VandelayService, VandelayImportSelection,
13 VANDELAY_UPLOAD_PATH} from './vandelay.service';
14 import {HttpClient, HttpRequest, HttpEventType,
15 HttpResponse, HttpErrorResponse} from '@angular/common/http';
16 import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component';
17 import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
18 import {ServerStoreService} from '@eg/core/server-store.service';
20 const TEMPLATE_SETTING_NAME = 'eg.cat.vandelay.import.templates';
22 const TEMPLATE_ATTRS = [
30 'autoOverlayAcqCopies',
31 'autoOverlayOnOrderCopies',
32 'autoOverlayOrgUnitCopies',
33 'selectedHoldingsProfile',
34 'selectedMergeProfile',
35 'selectedFallThruMergeProfile',
36 'selectedTrashGroups',
40 interface ImportOptions {
42 overlay_map?: {[qrId: number]: /* breId */ number};
43 import_no_match?: boolean;
44 auto_overlay_exact?: boolean;
45 auto_overlay_best_match?: boolean;
46 auto_overlay_1match?: boolean;
47 opp_acq_copy_overlay?: boolean;
48 opp_oo_cat_copy_overlay?: boolean;
49 auto_overlay_org_unit_copies?: boolean;
51 fall_through_merge_profile?: any;
52 strip_field_groups?: number[];
53 match_quality_ratio: number;
58 templateUrl: 'import.component.html'
60 export class ImportComponent implements OnInit, AfterViewInit, OnDestroy {
63 selectedQueue: ComboboxEntry; // freetext enabled
65 // used for applying a default queue ID value when we have
66 // a load-time queue before the queue combobox entries exist.
69 bibTrashGroups: IdlObject[];
70 selectedTrashGroups: number[];
72 activeQueueId: number;
73 selectedBucket: number;
74 selectedBibSource: number;
75 selectedMatchSet: number;
76 selectedHoldingsProfile: number;
77 selectedMergeProfile: number;
78 selectedFallThruMergeProfile: number;
81 defaultMatchSet: string;
83 importNonMatching: boolean;
84 mergeOnExact: boolean;
85 mergeOnSingleMatch: boolean;
86 mergeOnBestMatch: boolean;
87 minQualityRatio: number;
88 autoOverlayAcqCopies: boolean;
89 autoOverlayOnOrderCopies: boolean;
90 autoOverlayOrgUnitCopies: boolean;
92 // True after the first upload, then remains true.
93 showProgress: boolean;
95 // Upload in progress.
98 // True only after successful upload
99 uploadComplete: boolean;
101 // Upload / processsing session key
102 // Generated by the server
105 // Optional enqueue/import tracker session name.
108 selectedTemplate: string;
109 formTemplates: {[name: string]: any};
110 newTemplateName: string;
112 @ViewChild('fileSelector', { static: false }) private fileSelector;
113 @ViewChild('uploadProgress', { static: true })
114 private uploadProgress: ProgressInlineComponent;
115 @ViewChild('enqueueProgress', { static: true })
116 private enqueueProgress: ProgressInlineComponent;
117 @ViewChild('importProgress', { static: true })
118 private importProgress: ProgressInlineComponent;
120 // Need these refs so values can be applied via external stimuli
121 @ViewChild('formTemplateSelector', { static: true })
122 private formTemplateSelector: ComboboxComponent;
123 @ViewChild('recordTypeSelector', { static: true })
124 private recordTypeSelector: ComboboxComponent;
125 @ViewChild('bibSourceSelector', { static: true })
126 private bibSourceSelector: ComboboxComponent;
127 @ViewChild('matchSetSelector', { static: true })
128 private matchSetSelector: ComboboxComponent;
129 @ViewChild('holdingsProfileSelector', { static: true })
130 private holdingsProfileSelector: ComboboxComponent;
131 @ViewChild('mergeProfileSelector', { static: true })
132 private mergeProfileSelector: ComboboxComponent;
133 @ViewChild('fallThruMergeProfileSelector', { static: true })
134 private fallThruMergeProfileSelector: ComboboxComponent;
135 @ViewChild('queueSelector') private queueSelector: ComboboxComponent;
136 @ViewChild('bucketSelector') private bucketSelector: ComboboxComponent;
138 @ViewChild('dupeQueueAlert', { static: true })
139 private dupeQueueAlert: AlertDialogComponent;
142 private http: HttpClient,
143 private toast: ToastService,
144 private evt: EventService,
145 private net: NetService,
146 private auth: AuthService,
147 private org: OrgService,
148 private store: ServerStoreService,
149 private vandelay: VandelayService
154 this.minQualityRatio = 0;
155 this.selectedBibSource = 1; // default to system local
156 this.recordType = 'bib';
157 this.bibTrashGroups = [];
158 this.formTemplates = {};
160 if (this.vandelay.importSelection) {
161 // Apply start-id values to our comboboxes based on our
162 // import selection values.
163 const queue = this.vandelay.importSelection.queue;
166 // Incomplete import selection, clear it.
167 this.vandelay.importSelection = null;
171 this.recordType = queue.queue_type();
172 this.startQueueId = queue.id();
173 this.selectedMatchSet = queue.match_set();
175 if (this.recordType === 'bib') {
176 this.selectedBucket = queue.match_bucket();
177 this.selectedHoldingsProfile = queue.item_attr_def();
182 applyImportSelection() {
184 // Depending on when the comboboxes are rendered, the start-id values
185 // applied in applyDefaults() may not have any affect. Ensure we
186 // get the initial values we want by manually setting selectedIds
187 // to the rendered combobox as well.
189 const queue = this.vandelay.importSelection.queue;
191 // Incomplete import selection, clear it.
192 this.vandelay.importSelection = null;
196 if (this.queueSelector) {
197 this.queueSelector.selectedId = this.startQueueId;
198 this.selectedQueue = {id: queue.id(), label: queue.name()};
201 if (this.matchSetSelector) {
202 this.matchSetSelector.selectedId = this.selectedMatchSet;
205 if (this.recordType === 'bib') {
206 if (this.holdingsProfileSelector) {
207 this.holdingsProfileSelector.selectedId = this.selectedHoldingsProfile;
209 if (this.bucketSelector) {
210 this.bucketSelector.selectedId = this.selectedBucket;
216 this.applyDefaults();
220 if (this.vandelay.importSelection) {
221 // setTimeout() is not required here, but it helps to
222 // avoid expression-changed-after-checking console messages.
223 setTimeout(() => this.applyImportSelection());
225 this.loadStartupData();
229 // Always clear the import selection when navigating away from
231 this.clearSelection();
234 importSelection(): VandelayImportSelection {
235 return this.vandelay.importSelection;
238 loadStartupData(): Promise<any> {
239 // Note displaying and manipulating a progress dialog inside
240 // the AfterViewInit cycle leads to errors because the child
241 // component is modifed after dirty checking.
244 this.vandelay.getMergeProfiles(),
245 this.vandelay.getAllQueues('bib'),
246 this.vandelay.getAllQueues('authority'),
247 this.vandelay.getMatchSets('bib'),
248 this.vandelay.getMatchSets('authority'),
249 this.vandelay.getBibBuckets(),
250 this.vandelay.getBibSources(),
251 this.vandelay.getItemImportDefs(),
252 this.vandelay.getBibTrashGroups().then(
253 groups => this.bibTrashGroups = groups),
254 this.org.settings(['vandelay.default_match_set']).then(
255 s => this.defaultMatchSet = s['vandelay.default_match_set']),
259 return Promise.all(promises);
263 this.store.getItem(TEMPLATE_SETTING_NAME).then(
265 this.formTemplates = templates || {};
267 Object.keys(this.formTemplates).forEach(name => {
268 if (this.formTemplates[name].default) {
269 this.selectedTemplate = name;
276 formatTemplateEntries(): ComboboxEntry[] {
279 Object.keys(this.formTemplates || {}).forEach(
280 name => entries.push({id: name, label: name}));
285 // Format typeahead data sets
286 formatEntries(etype: string): ComboboxEntry[] {
287 const rtype = this.recordType;
292 return (this.vandelay.bibSources || []).map(
294 return {id: s.id(), label: s.source()};
298 list = this.vandelay.bibBuckets;
302 list = (this.vandelay.allQueues[rtype] || [])
303 .filter(q => q.complete() === 'f');
307 list = this.vandelay.matchSets[rtype];
310 case 'importItemDefs':
311 list = this.vandelay.importItemAttrDefs;
314 case 'mergeProfiles':
315 list = this.vandelay.mergeProfiles;
319 return (list || []).map(item => {
320 return {id: item.id(), label: item.name()};
324 selectEntry($event: ComboboxEntry, etype: string) {
325 const id = $event ? $event.id : null;
329 this.recordType = id;
333 this.selectedBibSource = id;
337 this.selectedBucket = id;
341 this.selectedMatchSet = id;
344 case 'importItemDefs':
345 this.selectedHoldingsProfile = id;
348 case 'mergeProfiles':
349 this.selectedMergeProfile = id;
352 case 'FallThruMergeProfile':
353 this.selectedFallThruMergeProfile = id;
358 fileSelected($event) {
359 this.selectedFile = $event.target.files[0];
362 // Required form data varies depending on context.
363 hasNeededData(): boolean {
364 if (this.vandelay.importSelection) {
365 return this.importActionSelected();
367 return this.selectedQueue &&
368 Boolean(this.recordType) && Boolean(this.selectedFile);
372 importActionSelected(): boolean {
373 return this.importNonMatching
375 || this.mergeOnSingleMatch
376 || this.mergeOnBestMatch;
379 // 1. create queue if necessary
380 // 2. upload MARC file
381 // 3. Enqueue MARC records
384 this.sessionKey = null;
385 this.showProgress = true;
386 this.isUploading = true;
387 this.uploadComplete = false;
388 this.resetProgressBars();
393 this.activeQueueId = queueId;
394 return this.uploadFile();
396 err => Promise.reject('queue create failed')
398 ok => this.processSpool(),
399 err => Promise.reject('process spool failed')
401 ok => this.importRecords(),
402 err => Promise.reject('import records failed')
405 this.isUploading = false;
406 this.uploadComplete = true;
409 console.log('file upload failed: ', err);
410 this.isUploading = false;
411 this.resetProgressBars();
417 resetProgressBars() {
418 this.uploadProgress.update({value: 0, max: 1});
419 this.enqueueProgress.update({value: 0, max: 1});
420 this.importProgress.update({value: 0, max: 1});
423 // Extract selected queue ID or create a new queue when requested.
424 resolveQueue(): Promise<number> {
426 if (this.selectedQueue.freetext) {
427 // Free text queue selector means create a new entry.
428 // TODO: first check for name dupes
430 return this.vandelay.createQueue(
431 this.selectedQueue.label,
433 this.selectedHoldingsProfile,
434 this.selectedMatchSet,
439 const evt = this.evt.parse(err);
441 if (evt.textcode.match(/QUEUE_EXISTS/)) {
442 this.dupeQueueAlert.open();
444 alert(evt); // server error
448 return Promise.reject('Queue Create Failed');
452 return Promise.resolve(this.selectedQueue.id);
456 uploadFile(): Promise<any> {
458 if (this.vandelay.importSelection) {
459 // Nothing to upload when processing pre-queued records.
460 return Promise.resolve();
463 const formData: FormData = new FormData();
465 formData.append('ses', this.auth.token());
466 formData.append('marc_upload',
467 this.selectedFile, this.selectedFile.name);
469 if (this.selectedBibSource) {
470 formData.append('bib_source', '' + this.selectedBibSource);
473 const req = new HttpRequest('POST', VANDELAY_UPLOAD_PATH, formData,
474 {reportProgress: true, responseType: 'text'});
476 return this.http.request(req).pipe(tap(
478 if (evt.type === HttpEventType.UploadProgress) {
479 this.uploadProgress.update(
480 {value: evt.loaded, max: evt.total});
482 } else if (evt instanceof HttpResponse) {
483 this.sessionKey = evt.body as string;
485 'Vandelay file uploaded OK with key ' + this.sessionKey);
489 // eslint-disable-next-line rxjs/no-implicit-any-catch
490 (err: HttpErrorResponse) => {
492 this.toast.danger(err.error);
497 processSpool(): Promise<any> {
499 if (this.vandelay.importSelection) {
500 // Nothing to enqueue when processing pre-queued records
501 return Promise.resolve();
504 let spoolType = this.recordType;
505 if (this.recordType === 'authority') {
509 const method = `open-ils.vandelay.${spoolType}.process_spool`;
511 return new Promise((resolve, reject) => {
513 'open-ils.vandelay', method,
514 this.auth.token(), this.sessionKey, this.activeQueueId,
515 null, null, this.selectedBibSource,
516 (this.sessionName || null), true
519 const e = this.evt.parse(tracker);
520 if (e) { console.error(e); return reject(); }
522 // Spooling is in progress, track the results.
523 this.vandelay.pollSessionTracker(tracker.id())
524 // eslint-disable-next-line rxjs/no-nested-subscribe
527 this.enqueueProgress.update({
528 // enqueue API only tracks actions performed
530 value: trkr.actions_performed()
533 (err: unknown) => { console.log(err); reject(); },
535 this.enqueueProgress.update({max: 1, value: 1});
544 importRecords(): Promise<any> {
546 if (!this.importActionSelected()) {
547 return Promise.resolve();
550 const selection = this.vandelay.importSelection;
552 if (selection && !selection.importQueue) {
553 return this.importRecordQueue(selection.recordIds);
555 return this.importRecordQueue();
559 importRecordQueue(recIds?: number[]): Promise<any> {
560 const rtype = this.recordType === 'bib' ? 'bib' : 'auth';
562 let method = `open-ils.vandelay.${rtype}_queue.import`;
563 const options: ImportOptions = this.compileImportOptions();
565 let target: number | number[] = this.activeQueueId;
566 if (recIds && recIds.length) {
567 method = `open-ils.vandelay.${rtype}_record.list.import`;
571 return new Promise((resolve, reject) => {
572 this.net.request('open-ils.vandelay',
573 method, this.auth.token(), target, options)
576 const e = this.evt.parse(tracker);
577 if (e) { console.error(e); return reject(); }
579 // Spooling is in progress, track the results.
580 this.vandelay.pollSessionTracker(tracker.id())
581 // eslint-disable-next-line rxjs/no-nested-subscribe
584 this.importProgress.update({
585 max: trkr.total_actions(),
586 value: trkr.actions_performed()
589 (err: unknown) => { console.log(err); reject(); },
591 this.importProgress.update({max: 1, value: 1});
600 compileImportOptions(): ImportOptions {
602 const options: ImportOptions = {
603 session_key: this.sessionKey,
604 import_no_match: this.importNonMatching,
605 auto_overlay_exact: this.mergeOnExact,
606 auto_overlay_best_match: this.mergeOnBestMatch,
607 auto_overlay_1match: this.mergeOnSingleMatch,
608 opp_acq_copy_overlay: this.autoOverlayAcqCopies,
609 opp_oo_cat_copy_overlay: this.autoOverlayOnOrderCopies,
610 auto_overlay_org_unit_copies: this.autoOverlayOrgUnitCopies,
611 merge_profile: this.selectedMergeProfile,
612 fall_through_merge_profile: this.selectedFallThruMergeProfile,
613 strip_field_groups: this.selectedTrashGroups,
614 match_quality_ratio: this.minQualityRatio,
618 if (this.vandelay.importSelection) {
619 options.overlay_map = this.vandelay.importSelection.overlayMap;
626 this.vandelay.importSelection = null;
627 this.startQueueId = null;
631 console.log('opening queue ' + this.activeQueueId);
637 TEMPLATE_ATTRS.forEach(key => template[key] = this[key]);
639 console.debug('Saving import profile', template);
641 this.formTemplates[this.selectedTemplate] = template;
642 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
645 markTemplateDefault() {
647 Object.keys(this.formTemplates).forEach(
648 name => delete this.formTemplates.default
651 this.formTemplates[this.selectedTemplate].default = true;
653 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
656 templateSelectorChange(entry: ComboboxEntry) {
659 this.selectedTemplate = '';
663 this.selectedTemplate = entry.label; // label == name
665 if (entry.freetext) {
666 // User is entering a new template name.
671 // User selected an existing template, apply it to the form.
673 const template = this.formTemplates[entry.id];
675 // Copy the template values into "this"
676 TEMPLATE_ATTRS.forEach(key => this[key] = template[key]);
678 // Some values must be manually passed to the combobox'es
680 this.recordTypeSelector.applyEntryId(this.recordType);
681 this.bibSourceSelector.applyEntryId(this.selectedBibSource);
682 this.matchSetSelector.applyEntryId(this.selectedMatchSet);
683 this.holdingsProfileSelector
684 .applyEntryId(this.selectedHoldingsProfile);
685 this.mergeProfileSelector.applyEntryId(this.selectedMergeProfile);
686 this.fallThruMergeProfileSelector
687 .applyEntryId(this.selectedFallThruMergeProfile);
691 delete this.formTemplates[this.selectedTemplate];
692 this.formTemplateSelector.selected = null;
693 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);