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';
21 const TEMPLATE_SETTING_NAME = 'eg.cat.vandelay.import.templates';
23 const TEMPLATE_ATTRS = [
31 'autoOverlayAcqCopies',
32 'autoOverlayOnOrderCopies',
33 'autoOverlayOrgUnitCopies',
34 'selectedHoldingsProfile',
35 'selectedMergeProfile',
36 'selectedFallThruMergeProfile',
37 'selectedTrashGroups',
41 interface ImportOptions {
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;
52 fall_through_merge_profile?: any;
53 strip_field_groups?: number[];
54 match_quality_ratio: number;
59 templateUrl: 'import.component.html'
61 export class ImportComponent implements OnInit, AfterViewInit, OnDestroy {
64 selectedQueue: ComboboxEntry; // freetext enabled
66 // used for applying a default queue ID value when we have
67 // a load-time queue before the queue combobox entries exist.
70 bibTrashGroups: IdlObject[];
71 selectedTrashGroups: number[];
73 activeQueueId: number;
74 selectedBucket: number;
75 selectedBibSource: number;
76 selectedMatchSet: number;
77 selectedHoldingsProfile: number;
78 selectedMergeProfile: number;
79 selectedFallThruMergeProfile: number;
82 defaultMatchSet: string;
84 importNonMatching: boolean;
85 mergeOnExact: boolean;
86 mergeOnSingleMatch: boolean;
87 mergeOnBestMatch: boolean;
88 minQualityRatio: number;
89 autoOverlayAcqCopies: boolean;
90 autoOverlayOnOrderCopies: boolean;
91 autoOverlayOrgUnitCopies: boolean;
93 // True after the first upload, then remains true.
94 showProgress: boolean;
96 // Upload in progress.
99 // True only after successful upload
100 uploadComplete: boolean;
102 // Upload / processsing session key
103 // Generated by the server
106 // Optional enqueue/import tracker session name.
109 selectedTemplate: string;
110 formTemplates: {[name: string]: any};
111 newTemplateName: string;
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;
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;
139 @ViewChild('dupeQueueAlert', { static: true })
140 private dupeQueueAlert: AlertDialogComponent;
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
155 this.minQualityRatio = 0;
156 this.selectedBibSource = 1; // default to system local
157 this.recordType = 'bib';
158 this.bibTrashGroups = [];
159 this.formTemplates = {};
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;
167 // Incomplete import selection, clear it.
168 this.vandelay.importSelection = null;
172 this.recordType = queue.queue_type();
173 this.startQueueId = queue.id();
174 this.selectedMatchSet = queue.match_set();
176 if (this.recordType === 'bib') {
177 this.selectedBucket = queue.match_bucket();
178 this.selectedHoldingsProfile = queue.item_attr_def();
183 applyImportSelection() {
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.
190 const queue = this.vandelay.importSelection.queue;
192 // Incomplete import selection, clear it.
193 this.vandelay.importSelection = null;
197 if (this.queueSelector) {
198 this.queueSelector.selectedId = this.startQueueId;
199 this.selectedQueue = {id: queue.id(), label: queue.name()};
202 if (this.matchSetSelector) {
203 this.matchSetSelector.selectedId = this.selectedMatchSet;
206 if (this.recordType === 'bib') {
207 if (this.holdingsProfileSelector) {
208 this.holdingsProfileSelector.selectedId = this.selectedHoldingsProfile;
210 if (this.bucketSelector) {
211 this.bucketSelector.selectedId = this.selectedBucket;
217 this.applyDefaults();
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());
226 this.loadStartupData();
230 // Always clear the import selection when navigating away from
232 this.clearSelection();
235 importSelection(): VandelayImportSelection {
236 return this.vandelay.importSelection;
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.
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']),
260 return Promise.all(promises);
264 this.store.getItem(TEMPLATE_SETTING_NAME).then(
266 this.formTemplates = templates || {};
268 Object.keys(this.formTemplates).forEach(name => {
269 if (this.formTemplates[name].default) {
270 this.selectedTemplate = name;
277 formatTemplateEntries(): ComboboxEntry[] {
280 Object.keys(this.formTemplates || {}).forEach(
281 name => entries.push({id: name, label: name}));
286 // Format typeahead data sets
287 formatEntries(etype: string): ComboboxEntry[] {
288 const rtype = this.recordType;
293 return (this.vandelay.bibSources || []).map(
295 return {id: s.id(), label: s.source()};
299 list = this.vandelay.bibBuckets;
303 list = (this.vandelay.allQueues[rtype] || [])
304 .filter(q => q.complete() === 'f');
308 list = this.vandelay.matchSets[rtype];
311 case 'importItemDefs':
312 list = this.vandelay.importItemAttrDefs;
315 case 'mergeProfiles':
316 list = this.vandelay.mergeProfiles;
320 return (list || []).map(item => {
321 return {id: item.id(), label: item.name()};
325 selectEntry($event: ComboboxEntry, etype: string) {
326 const id = $event ? $event.id : null;
330 this.recordType = id;
334 this.selectedBibSource = id;
338 this.selectedBucket = id;
342 this.selectedMatchSet = id;
345 case 'importItemDefs':
346 this.selectedHoldingsProfile = id;
349 case 'mergeProfiles':
350 this.selectedMergeProfile = id;
353 case 'FallThruMergeProfile':
354 this.selectedFallThruMergeProfile = id;
359 fileSelected($event) {
360 this.selectedFile = $event.target.files[0];
363 // Required form data varies depending on context.
364 hasNeededData(): boolean {
365 if (this.vandelay.importSelection) {
366 return this.importActionSelected();
368 return this.selectedQueue &&
369 Boolean(this.recordType) && Boolean(this.selectedFile);
373 importActionSelected(): boolean {
374 return this.importNonMatching
376 || this.mergeOnSingleMatch
377 || this.mergeOnBestMatch;
380 // 1. create queue if necessary
381 // 2. upload MARC file
382 // 3. Enqueue MARC records
385 this.sessionKey = null;
386 this.showProgress = true;
387 this.isUploading = true;
388 this.uploadComplete = false;
389 this.resetProgressBars();
394 this.activeQueueId = queueId;
395 return this.uploadFile();
397 err => Promise.reject('queue create failed')
399 ok => this.processSpool(),
400 err => Promise.reject('process spool failed')
402 ok => this.importRecords(),
403 err => Promise.reject('import records failed')
406 this.isUploading = false;
407 this.uploadComplete = true;
410 console.log('file upload failed: ', err);
411 this.isUploading = false;
412 this.resetProgressBars();
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});
424 // Extract selected queue ID or create a new queue when requested.
425 resolveQueue(): Promise<number> {
427 if (this.selectedQueue.freetext) {
428 // Free text queue selector means create a new entry.
429 // TODO: first check for name dupes
431 return this.vandelay.createQueue(
432 this.selectedQueue.label,
434 this.selectedHoldingsProfile,
435 this.selectedMatchSet,
440 const evt = this.evt.parse(err);
442 if (evt.textcode.match(/QUEUE_EXISTS/)) {
443 this.dupeQueueAlert.open();
445 alert(evt); // server error
449 return Promise.reject('Queue Create Failed');
453 return Promise.resolve(this.selectedQueue.id);
457 uploadFile(): Promise<any> {
459 if (this.vandelay.importSelection) {
460 // Nothing to upload when processing pre-queued records.
461 return Promise.resolve();
464 const formData: FormData = new FormData();
466 formData.append('ses', this.auth.token());
467 formData.append('marc_upload',
468 this.selectedFile, this.selectedFile.name);
470 if (this.selectedBibSource) {
471 formData.append('bib_source', '' + this.selectedBibSource);
474 const req = new HttpRequest('POST', VANDELAY_UPLOAD_PATH, formData,
475 {reportProgress: true, responseType: 'text'});
477 return this.http.request(req).pipe(tap(
479 if (evt.type === HttpEventType.UploadProgress) {
480 this.uploadProgress.update(
481 {value: evt.loaded, max: evt.total});
483 } else if (evt instanceof HttpResponse) {
484 this.sessionKey = evt.body as string;
486 'Vandelay file uploaded OK with key ' + this.sessionKey);
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())
526 this.enqueueProgress.update({
527 // enqueue API only tracks actions performed
529 value: trkr.actions_performed()
532 err => { console.log(err); reject(); },
534 this.enqueueProgress.update({max: 1, value: 1});
543 importRecords(): Promise<any> {
545 if (!this.importActionSelected()) {
546 return Promise.resolve();
549 const selection = this.vandelay.importSelection;
551 if (selection && !selection.importQueue) {
552 return this.importRecordQueue(selection.recordIds);
554 return this.importRecordQueue();
558 importRecordQueue(recIds?: number[]): Promise<any> {
559 const rtype = this.recordType === 'bib' ? 'bib' : 'auth';
561 let method = `open-ils.vandelay.${rtype}_queue.import`;
562 const options: ImportOptions = this.compileImportOptions();
564 let target: number | number[] = this.activeQueueId;
565 if (recIds && recIds.length) {
566 method = `open-ils.vandelay.${rtype}_record.list.import`;
570 return new Promise((resolve, reject) => {
571 this.net.request('open-ils.vandelay',
572 method, this.auth.token(), target, options)
575 const e = this.evt.parse(tracker);
576 if (e) { console.error(e); return reject(); }
578 // Spooling is in progress, track the results.
579 this.vandelay.pollSessionTracker(tracker.id())
582 this.importProgress.update({
583 max: trkr.total_actions(),
584 value: trkr.actions_performed()
587 err => { console.log(err); reject(); },
589 this.importProgress.update({max: 1, value: 1});
598 compileImportOptions(): ImportOptions {
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,
616 if (this.vandelay.importSelection) {
617 options.overlay_map = this.vandelay.importSelection.overlayMap;
624 this.vandelay.importSelection = null;
625 this.startQueueId = null;
629 console.log('opening queue ' + this.activeQueueId);
635 TEMPLATE_ATTRS.forEach(key => template[key] = this[key]);
637 console.debug('Saving import profile', template);
639 this.formTemplates[this.selectedTemplate] = template;
640 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
643 markTemplateDefault() {
645 Object.keys(this.formTemplates).forEach(
646 name => delete this.formTemplates.default
649 this.formTemplates[this.selectedTemplate].default = true;
651 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
654 templateSelectorChange(entry: ComboboxEntry) {
657 this.selectedTemplate = '';
661 this.selectedTemplate = entry.label; // label == name
663 if (entry.freetext) {
664 // User is entering a new template name.
669 // User selected an existing template, apply it to the form.
671 const template = this.formTemplates[entry.id];
673 // Copy the template values into "this"
674 TEMPLATE_ATTRS.forEach(key => this[key] = template[key]);
676 // Some values must be manually passed to the combobox'es
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);
689 delete this.formTemplates[this.selectedTemplate];
690 this.formTemplateSelector.selected = null;
691 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);