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') private fileSelector;
114 @ViewChild('uploadProgress')
115 private uploadProgress: ProgressInlineComponent;
116 @ViewChild('enqueueProgress')
117 private enqueueProgress: ProgressInlineComponent;
118 @ViewChild('importProgress')
119 private importProgress: ProgressInlineComponent;
121 // Need these refs so values can be applied via external stimuli
122 @ViewChild('formTemplateSelector')
123 private formTemplateSelector: ComboboxComponent;
124 @ViewChild('recordTypeSelector')
125 private recordTypeSelector: ComboboxComponent;
126 @ViewChild('bibSourceSelector')
127 private bibSourceSelector: ComboboxComponent;
128 @ViewChild('matchSetSelector')
129 private matchSetSelector: ComboboxComponent;
130 @ViewChild('holdingsProfileSelector')
131 private holdingsProfileSelector: ComboboxComponent;
132 @ViewChild('mergeProfileSelector')
133 private mergeProfileSelector: ComboboxComponent;
134 @ViewChild('fallThruMergeProfileSelector')
135 private fallThruMergeProfileSelector: ComboboxComponent;
137 @ViewChild('dupeQueueAlert')
138 private dupeQueueAlert: AlertDialogComponent;
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
150 this.applyDefaults();
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) {
162 if (!this.vandelay.importSelection.queue) {
163 // Incomplete import selection, clear it.
164 this.vandelay.importSelection = null;
168 const queue = this.vandelay.importSelection.queue;
169 this.recordType = queue.queue_type();
170 this.selectedMatchSet = queue.match_set();
172 // This will be propagated to selectedQueue as a combobox
173 // entry via the combobox
174 this.startQueueId = queue.id();
176 if (this.recordType === 'bib') {
177 this.selectedBucket = queue.match_bucket();
178 this.selectedHoldingsProfile = queue.item_attr_def();
186 this.loadStartupData();
190 // Always clear the import selection when navigating away from
192 this.clearSelection();
195 importSelection(): VandelayImportSelection {
196 return this.vandelay.importSelection;
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.
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']),
220 return Promise.all(promises);
224 this.store.getItem(TEMPLATE_SETTING_NAME).then(
226 this.formTemplates = templates || {};
228 Object.keys(this.formTemplates).forEach(name => {
229 if (this.formTemplates[name].default) {
230 this.selectedTemplate = name;
237 formatTemplateEntries(): ComboboxEntry[] {
240 Object.keys(this.formTemplates || {}).forEach(
241 name => entries.push({id: name, label: name}));
246 // Format typeahead data sets
247 formatEntries(etype: string): ComboboxEntry[] {
248 const rtype = this.recordType;
253 return (this.vandelay.bibSources || []).map(
255 return {id: s.id(), label: s.source()};
259 list = this.vandelay.bibBuckets;
263 list = (this.vandelay.allQueues[rtype] || [])
264 .filter(q => q.complete() === 'f');
268 list = this.vandelay.matchSets[rtype];
271 case 'importItemDefs':
272 list = this.vandelay.importItemAttrDefs;
275 case 'mergeProfiles':
276 list = this.vandelay.mergeProfiles;
280 return (list || []).map(item => {
281 return {id: item.id(), label: item.name()};
285 selectEntry($event: ComboboxEntry, etype: string) {
286 const id = $event ? $event.id : null;
290 this.recordType = id;
294 this.selectedBibSource = id;
298 this.selectedBucket = id;
302 this.selectedMatchSet = id;
305 case 'importItemDefs':
306 this.selectedHoldingsProfile = id;
309 case 'mergeProfiles':
310 this.selectedMergeProfile = id;
313 case 'FallThruMergeProfile':
314 this.selectedFallThruMergeProfile = id;
319 fileSelected($event) {
320 this.selectedFile = $event.target.files[0];
323 // Required form data varies depending on context.
324 hasNeededData(): boolean {
325 if (this.vandelay.importSelection) {
326 return this.importActionSelected();
328 return this.selectedQueue &&
329 Boolean(this.recordType) && Boolean(this.selectedFile);
333 importActionSelected(): boolean {
334 return this.importNonMatching
336 || this.mergeOnSingleMatch
337 || this.mergeOnBestMatch;
340 // 1. create queue if necessary
341 // 2. upload MARC file
342 // 3. Enqueue MARC records
345 this.sessionKey = null;
346 this.showProgress = true;
347 this.isUploading = true;
348 this.uploadComplete = false;
349 this.resetProgressBars();
354 this.activeQueueId = queueId;
355 return this.uploadFile();
357 err => Promise.reject('queue create failed')
359 ok => this.processSpool(),
360 err => Promise.reject('process spool failed')
362 ok => this.importRecords(),
363 err => Promise.reject('import records failed')
366 this.isUploading = false;
367 this.uploadComplete = true;
370 console.log('file upload failed: ', err);
371 this.isUploading = false;
372 this.resetProgressBars();
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});
384 // Extract selected queue ID or create a new queue when requested.
385 resolveQueue(): Promise<number> {
387 if (this.selectedQueue.freetext) {
388 // Free text queue selector means create a new entry.
389 // TODO: first check for name dupes
391 return this.vandelay.createQueue(
392 this.selectedQueue.label,
394 this.selectedHoldingsProfile,
395 this.selectedMatchSet,
400 const evt = this.evt.parse(err);
402 if (evt.textcode.match(/QUEUE_EXISTS/)) {
403 this.dupeQueueAlert.open();
405 alert(evt); // server error
409 return Promise.reject('Queue Create Failed');
413 return Promise.resolve(this.selectedQueue.id);
417 uploadFile(): Promise<any> {
419 if (this.vandelay.importSelection) {
420 // Nothing to upload when processing pre-queued records.
421 return Promise.resolve();
424 const formData: FormData = new FormData();
426 formData.append('ses', this.auth.token());
427 formData.append('marc_upload',
428 this.selectedFile, this.selectedFile.name);
430 if (this.selectedBibSource) {
431 formData.append('bib_source', '' + this.selectedBibSource);
434 const req = new HttpRequest('POST', VANDELAY_UPLOAD_PATH, formData,
435 {reportProgress: true, responseType: 'text'});
437 return this.http.request(req).pipe(tap(
439 if (evt.type === HttpEventType.UploadProgress) {
440 this.uploadProgress.update(
441 {value: evt.loaded, max: evt.total});
443 } else if (evt instanceof HttpResponse) {
444 this.sessionKey = evt.body as string;
446 'Vandelay file uploaded OK with key ' + this.sessionKey);
450 (err: HttpErrorResponse) => {
452 this.toast.danger(err.error);
457 processSpool(): Promise<any> {
459 if (this.vandelay.importSelection) {
460 // Nothing to enqueue when processing pre-queued records
461 return Promise.resolve();
464 let spoolType = this.recordType;
465 if (this.recordType === 'authority') {
469 const method = `open-ils.vandelay.${spoolType}.process_spool`;
471 return new Promise((resolve, reject) => {
473 'open-ils.vandelay', method,
474 this.auth.token(), this.sessionKey, this.activeQueueId,
475 null, null, this.selectedBibSource,
476 (this.sessionName || null), true
479 const e = this.evt.parse(tracker);
480 if (e) { console.error(e); return reject(); }
482 // Spooling is in progress, track the results.
483 this.vandelay.pollSessionTracker(tracker.id())
486 this.enqueueProgress.update({
487 // enqueue API only tracks actions performed
489 value: trkr.actions_performed()
492 err => { console.log(err); reject(); },
494 this.enqueueProgress.update({max: 1, value: 1});
503 importRecords(): Promise<any> {
505 if (!this.importActionSelected()) {
506 return Promise.resolve();
509 const selection = this.vandelay.importSelection;
511 if (selection && !selection.importQueue) {
512 return this.importRecordQueue(selection.recordIds);
514 return this.importRecordQueue();
518 importRecordQueue(recIds?: number[]): Promise<any> {
519 const rtype = this.recordType === 'bib' ? 'bib' : 'auth';
521 let method = `open-ils.vandelay.${rtype}_queue.import`;
522 const options: ImportOptions = this.compileImportOptions();
524 let target: number | number[] = this.activeQueueId;
525 if (recIds && recIds.length) {
526 method = `open-ils.vandelay.${rtype}_record.list.import`;
530 return new Promise((resolve, reject) => {
531 this.net.request('open-ils.vandelay',
532 method, this.auth.token(), target, options)
535 const e = this.evt.parse(tracker);
536 if (e) { console.error(e); return reject(); }
538 // Spooling is in progress, track the results.
539 this.vandelay.pollSessionTracker(tracker.id())
542 this.importProgress.update({
543 max: trkr.total_actions(),
544 value: trkr.actions_performed()
547 err => { console.log(err); reject(); },
549 this.importProgress.update({max: 1, value: 1});
558 compileImportOptions(): ImportOptions {
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,
576 if (this.vandelay.importSelection) {
577 options.overlay_map = this.vandelay.importSelection.overlayMap;
584 this.vandelay.importSelection = null;
585 this.startQueueId = null;
589 console.log('opening queue ' + this.activeQueueId);
595 TEMPLATE_ATTRS.forEach(key => template[key] = this[key]);
597 console.debug('Saving import profile', template);
599 this.formTemplates[this.selectedTemplate] = template;
600 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
603 markTemplateDefault() {
605 Object.keys(this.formTemplates).forEach(
606 name => delete this.formTemplates.default
609 this.formTemplates[this.selectedTemplate].default = true;
611 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
614 templateSelectorChange(entry: ComboboxEntry) {
617 this.selectedTemplate = '';
621 this.selectedTemplate = entry.label; // label == name
623 if (entry.freetext) {
624 // User is entering a new template name.
629 // User selected an existing template, apply it to the form.
631 const template = this.formTemplates[entry.id];
633 // Copy the template values into "this"
634 TEMPLATE_ATTRS.forEach(key => this[key] = template[key]);
636 // Some values must be manually passed to the combobox'es
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);
649 delete this.formTemplates[this.selectedTemplate];
650 this.formTemplateSelector.selected = null;
651 return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);