From 8777877cd9ccdc45fbc8286e4fcc272931d3b1a9 Mon Sep 17 00:00:00 2001 From: Bill Erickson Date: Thu, 28 Jun 2018 18:23:39 -0400 Subject: [PATCH] LP#1779158 Ang6 Vandelay UI Port Port of the MARC Import/Export UI from a Dojo-driven interface to a Angular(6) interface. Includes an additional UI called "Recent Imports" which displays Vandelay session tracker information for both active sessions and those within the selected time frame. Active sessions are updated regularly to display progress to the user. Includes grid persist key workstation settings. Signed-off-by: Bill Erickson Signed-off-by: Dan Wells --- Open-ILS/src/eg2/src/app/share/grid/grid.ts | 2 +- .../src/eg2/src/app/staff/cat/routing.module.ts | 15 + .../cat/vandelay/display-attrs.component.html | 17 + .../staff/cat/vandelay/display-attrs.component.ts | 35 ++ .../app/staff/cat/vandelay/export.component.html | 119 +++++ .../src/app/staff/cat/vandelay/export.component.ts | 138 ++++++ .../cat/vandelay/holdings-profiles.component.ts | 9 + .../app/staff/cat/vandelay/import.component.html | 234 ++++++++++ .../src/app/staff/cat/vandelay/import.component.ts | 491 +++++++++++++++++++++ .../vandelay/match-set-expression.component.html | 69 +++ .../cat/vandelay/match-set-expression.component.ts | 219 +++++++++ .../cat/vandelay/match-set-list.component.html | 37 ++ .../staff/cat/vandelay/match-set-list.component.ts | 78 ++++ .../vandelay/match-set-new-point.component.html | 77 ++++ .../cat/vandelay/match-set-new-point.component.ts | 65 +++ .../cat/vandelay/match-set-quality.component.html | 27 ++ .../cat/vandelay/match-set-quality.component.ts | 105 +++++ .../staff/cat/vandelay/match-set.component.html | 36 ++ .../app/staff/cat/vandelay/match-set.component.ts | 51 +++ .../staff/cat/vandelay/merge-profiles.component.ts | 9 + .../staff/cat/vandelay/queue-items.component.html | 19 + .../staff/cat/vandelay/queue-items.component.ts | 60 +++ .../staff/cat/vandelay/queue-list.component.html | 36 ++ .../app/staff/cat/vandelay/queue-list.component.ts | 102 +++++ .../app/staff/cat/vandelay/queue.component.html | 152 +++++++ .../src/app/staff/cat/vandelay/queue.component.ts | 250 +++++++++++ .../vandelay/queued-record-matches.component.html | 96 ++++ .../vandelay/queued-record-matches.component.ts | 153 +++++++ .../cat/vandelay/queued-record.component.html | 31 ++ .../staff/cat/vandelay/queued-record.component.ts | 42 ++ .../cat/vandelay/recent-imports.component.html | 67 +++ .../staff/cat/vandelay/recent-imports.component.ts | 140 ++++++ .../staff/cat/vandelay/record-items.component.html | 6 + .../staff/cat/vandelay/record-items.component.ts | 37 ++ .../src/app/staff/cat/vandelay/routing.module.ts | 75 ++++ .../app/staff/cat/vandelay/vandelay.component.html | 44 ++ .../app/staff/cat/vandelay/vandelay.component.ts | 34 ++ .../src/app/staff/cat/vandelay/vandelay.module.ts | 61 +++ .../src/app/staff/cat/vandelay/vandelay.service.ts | 343 ++++++++++++++ Open-ILS/src/eg2/src/app/staff/nav.component.html | 2 +- Open-ILS/src/eg2/src/app/staff/routing.module.ts | 3 + .../buckets/record-bucket-dialog.component.html | 3 +- .../buckets/record-bucket-dialog.component.ts | 51 ++- .../perlmods/lib/OpenILS/Application/Vandelay.pm | 28 +- Open-ILS/src/sql/Pg/950.data.seed-values.sql | 59 +++ .../upgrade/XXXX.data.vandelay-grid-settings.sql | 66 +++ Open-ILS/src/templates/staff/navbar.tt2 | 2 +- 47 files changed, 3781 insertions(+), 14 deletions(-) create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/holdings-profiles.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/merge-profiles.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/routing.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.html create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts create mode 100644 Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.service.ts create mode 100644 Open-ILS/src/sql/Pg/upgrade/XXXX.data.vandelay-grid-settings.sql diff --git a/Open-ILS/src/eg2/src/app/share/grid/grid.ts b/Open-ILS/src/eg2/src/app/share/grid/grid.ts index 6701941046..37bb188c72 100644 --- a/Open-ILS/src/eg2/src/app/share/grid/grid.ts +++ b/Open-ILS/src/eg2/src/app/share/grid/grid.ts @@ -396,7 +396,7 @@ export class GridRowSelector { export interface GridRowFlairEntry { icon: string; // name of material icon - title: string; // tooltip string + title?: string; // tooltip string } export class GridColumnPersistConf { diff --git a/Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts new file mode 100644 index 0000000000..a923b46822 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts @@ -0,0 +1,15 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; + +const routes: Routes = [ + { path: 'vandelay', + loadChildren: '@eg/staff/cat/vandelay/vandelay.module#VandelayModule' + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) + +export class CatRoutingModule {} diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.html new file mode 100644 index 0000000000..78a86ed34c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.html @@ -0,0 +1,17 @@ + + + + +
+ +
+
+
+ + +
+ +
+
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.ts new file mode 100644 index 0000000000..6cb13afd96 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.ts @@ -0,0 +1,35 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + templateUrl: 'display-attrs.component.html' +}) +export class DisplayAttrsComponent { + + attrType: string; + + constructor( + private router: Router, + private route: ActivatedRoute) { + + this.route.paramMap.subscribe((params: ParamMap) => { + this.attrType = params.get('atype'); + }); + } + + // Changing a tab in the UI means changing the route. + // Changing the route ultimately results in changing the tab. + onTabChange(evt: NgbTabChangeEvent) { + this.attrType = evt.nextId; + + // prevent tab changing until after route navigation + evt.preventDefault(); + + const url = + `/staff/cat/vandelay/display_attrs/${this.attrType}`; + + this.router.navigate([url]); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.html new file mode 100644 index 0000000000..020e09748b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.html @@ -0,0 +1,119 @@ +

Export Records

+ +
+
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ +
+
+ +
+
+
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.ts new file mode 100644 index 0000000000..253cfcb454 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.ts @@ -0,0 +1,138 @@ +import {Component, AfterViewInit, ViewChild, Renderer2} from '@angular/core'; +import {NgbPanelChangeEvent} from '@ng-bootstrap/ng-bootstrap'; +import {HttpClient, HttpRequest, HttpEventType} from '@angular/common/http'; +import {HttpResponse, HttpErrorResponse} from '@angular/common/http'; +import {saveAs} from 'file-saver/FileSaver'; +import {AuthService} from '@eg/core/auth.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component'; +import {VandelayService, VANDELAY_EXPORT_PATH} from './vandelay.service'; + + +@Component({ + templateUrl: 'export.component.html' +}) +export class ExportComponent implements AfterViewInit { + + recordSource: string; + fieldNumber: number; + selectedFile: File; + recordId: number; + bucketId: number; + recordType: string; + recordFormat: string; + recordEncoding: string; + includeHoldings: boolean; + isExporting: boolean; + + @ViewChild('fileSelector') private fileSelector; + @ViewChild('exportProgress') + private exportProgress: ProgressInlineComponent; + + constructor( + private renderer: Renderer2, + private http: HttpClient, + private toast: ToastService, + private auth: AuthService + ) { + this.recordType = 'biblio'; + this.recordFormat = 'USMARC'; + this.recordEncoding = 'UTF-8'; + this.includeHoldings = false; + } + + ngAfterViewInit() { + this.renderer.selectRootElement('#csv-input').focus(); + } + + sourceChange($event: NgbPanelChangeEvent) { + this.recordSource = $event.panelId; + + if ($event.nextState) { // panel opened + + // give the panel a chance to render before focusing input + setTimeout(() => { + this.renderer.selectRootElement( + `#${this.recordSource}-input`).focus(); + }) + } + } + + fileSelected($event) { + this.selectedFile = $event.target.files[0]; + } + + hasNeededData(): boolean { + return Boolean( + this.selectedFile || this.recordId || this.bucketId + ); + } + + exportRecords() { + this.isExporting = true; + this.exportProgress.update({value: 0}); + + const formData: FormData = new FormData(); + + formData.append('ses', this.auth.token()); + formData.append('rectype', this.recordType); + formData.append('encoding', this.recordEncoding); + formData.append('format', this.recordFormat); + + if (this.includeHoldings) { + formData.append('holdings', '1'); + } + + switch (this.recordSource) { + + case 'csv': + formData.append('idcolumn', ''+this.fieldNumber); + formData.append('idfile', + this.selectedFile, this.selectedFile.name); + break; + + case 'record-id': + formData.append('id', ''+this.recordId); + break; + + case 'bucket-id': + formData.append('containerid', ''+this.bucketId); + break; + } + + this.sendExportRequest(formData); + } + + sendExportRequest(formData: FormData) { + + const fileName = `export.${this.recordType}.` + + `${this.recordEncoding}.${this.recordFormat}`; + + const req = new HttpRequest('POST', VANDELAY_EXPORT_PATH, + formData, {reportProgress: true, responseType: 'text'}); + + this.http.request(req).subscribe( + evt => { + console.log(evt); + if (evt.type === HttpEventType.DownloadProgress) { + // File size not reported by server in advance. + this.exportProgress.update({value: evt.loaded}); + + } else if (evt instanceof HttpResponse) { + + saveAs(new Blob([evt.body], + {type: 'application/octet-stream'}), fileName); + + this.isExporting = false; + } + }, + + (err: HttpErrorResponse) => { + console.error(err); + this.toast.danger(err.error); + this.isExporting = false; + } + ); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/holdings-profiles.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/holdings-profiles.component.ts new file mode 100644 index 0000000000..3a342ddc1b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/holdings-profiles.component.ts @@ -0,0 +1,9 @@ +import {Component} from '@angular/core'; + +@Component({ + template: `` +}) +export class HoldingsProfilesComponent { + constructor() {} +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.html new file mode 100644 index 0000000000..c85233254c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.html @@ -0,0 +1,234 @@ +
+
+ +
+
+ +

MARC File Upload

+
+
+
+ +
+
+ + + + + +
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+ +
+
+ + +
+
+
+ +
+
+
+
+ +
+
+ + +
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ No Groups Configured +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + Importing {{importSelection().recordIds.length}} Record(s) + + Importing Queue {{importSelection().queue.name()}} +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.ts new file mode 100644 index 0000000000..3b36f6a341 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.ts @@ -0,0 +1,491 @@ +import {Component, OnInit, AfterViewInit, Input, ViewChild, OnDestroy} from '@angular/core'; +import {tap} from 'rxjs/operators/tap'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {EventService} from '@eg/core/event.service'; +import {OrgService} from '@eg/core/org.service'; +import {AuthService} from '@eg/core/auth.service'; +import {ToastService} from '@eg/share/toast/toast.service'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {VandelayService, VandelayImportSelection, + VANDELAY_UPLOAD_PATH} from './vandelay.service'; +import {HttpClient, HttpRequest, HttpEventType} from '@angular/common/http'; +import {HttpResponse, HttpErrorResponse} from '@angular/common/http'; +import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component'; +import {Subject} from 'rxjs/Subject'; + +interface ImportOptions { + session_key: string; + overlay_map?: {[qrId: number]: /* breId */ number}; + import_no_match?: boolean; + auto_overlay_exact?: boolean; + auto_overlay_best_match?: boolean; + auto_overlay_1match?: boolean; + opp_acq_copy_overlay?: boolean; + merge_profile?: any; + fall_through_merge_profile?: any; + strip_field_groups?: number[]; + exit_early: boolean; +} + +@Component({ + templateUrl: 'import.component.html' +}) +export class ImportComponent implements OnInit, AfterViewInit, OnDestroy { + + recordType: string; + selectedQueue: ComboboxEntry; // freetext enabled + + // used for applying a default queue ID value when we have + // a load-time queue before the queue combobox entries exist. + startQueueId: number; + + bibTrashGroups: IdlObject[]; + selectedTrashGroups: number[]; + + activeQueueId: number; + selectedBucket: number; + selectedBibSource: number; + selectedMatchSet: number; + selectedHoldingsProfile: number; + selectedMergeProfile: number; + selectedFallThruMergeProfile: number; + selectedFile: File; + + defaultMatchSet: string; + + importNonMatching: boolean; + mergeOnExact: boolean; + mergeOnSingleMatch: boolean; + mergeOnBestMatch: boolean; + minQualityRatio: number; + autoOverlayAcqCopies: boolean; + + // True after the first upload, then remains true. + showProgress: boolean; + + // Upload in progress. + isUploading: boolean; + + // True only after successful upload + uploadComplete: boolean; + + // Upload / processsing session key + // Generated by the server + sessionKey: string; + + // Optional enqueue/import tracker session name. + sessionName: string; + + @ViewChild('fileSelector') private fileSelector; + @ViewChild('uploadProgress') + private uploadProgress: ProgressInlineComponent; + @ViewChild('enqueueProgress') + private enqueueProgress: ProgressInlineComponent; + @ViewChild('importProgress') + private importProgress: ProgressInlineComponent; + + constructor( + private http: HttpClient, + private toast: ToastService, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private org: OrgService, + private vandelay: VandelayService + ) { + this.applyDefaults(); + } + + applyDefaults() { + this.minQualityRatio = 0; + this.selectedBibSource = 1; // default to system local + this.recordType = 'bib'; + this.bibTrashGroups = []; + + if (this.vandelay.importSelection) { + + if (!this.vandelay.importSelection.queue) { + // Incomplete import selection, clear it. + this.vandelay.importSelection = null; + return; + } + + const queue = this.vandelay.importSelection.queue; + this.recordType = queue.queue_type(); + this.selectedMatchSet = queue.match_set(); + + // This will be propagated to selectedQueue as a combobox + // entry via the combobox + this.startQueueId = queue.id(); + + if (this.recordType === 'bib') { + this.selectedBucket = queue.match_bucket(); + this.selectedHoldingsProfile = queue.item_attr_def(); + } + } + } + + ngOnInit() {} + + ngAfterViewInit() { + this.loadStartupData(); + } + + ngOnDestroy() { + // If we successfully completed the most recent + // upload/import assume the importSelection can be cleared. + if (this.uploadComplete) { + this.clearSelection(); + } + } + + importSelection(): VandelayImportSelection { + return this.vandelay.importSelection; + } + + loadStartupData(): Promise { + // Note displaying and manipulating a progress dialog inside + // the AfterViewInit cycle leads to errors because the child + // component is modifed after dirty checking. + + const promises = [ + this.vandelay.getMergeProfiles(), + this.vandelay.getAllQueues('bib'), + this.vandelay.getAllQueues('authority'), + this.vandelay.getMatchSets('bib'), + this.vandelay.getMatchSets('authority'), + this.vandelay.getBibBuckets(), + this.vandelay.getBibSources(), + this.vandelay.getItemImportDefs(), + this.vandelay.getBibTrashGroups().then( + groups => this.bibTrashGroups = groups), + this.org.settings(['vandelay.default_match_set']).then( + s => this.defaultMatchSet = s['vandelay.default_match_set']) + ]; + + return Promise.all(promises); + } + + // Format typeahead data sets + formatEntries(etype: string): ComboboxEntry[] { + const rtype = this.recordType; + let list; + + switch (etype) { + case 'bibSources': + return (this.vandelay.bibSources || []).map( + s => { return {id: s.id(), label: s.source()}; }); + + case 'bibBuckets': + list = this.vandelay.bibBuckets; + break; + + case 'allQueues': + list = this.vandelay.allQueues[rtype]; + break; + + case 'matchSets': + list = this.vandelay.matchSets[rtype]; + break; + + case 'importItemDefs': + list = this.vandelay.importItemAttrDefs; + break; + + case 'mergeProfiles': + list = this.vandelay.mergeProfiles; + break; + } + + return (list || []).map(item => { + return {id: item.id(), label: item.name()}; + }); + } + + selectEntry($event: ComboboxEntry, etype: string) { + const id = $event ? $event.id : null; + + switch (etype) { + case 'recordType': + this.recordType = id; + + case 'bibSources': + this.selectedBibSource = id; + break; + + case 'bibBuckets': + this.selectedBucket = id; + break; + + case 'matchSets': + this.selectedMatchSet = id; + break; + + case 'importItemDefs': + this.selectedHoldingsProfile = id; + break; + + case 'mergeProfiles': + this.selectedMergeProfile = id; + break; + + case 'FallThruMergeProfile': + this.selectedFallThruMergeProfile = id; + break; + } + } + + fileSelected($event) { + this.selectedFile = $event.target.files[0]; + } + + // Required form data varies depending on context. + hasNeededData(): boolean { + if (this.vandelay.importSelection) { + return this.importActionSelected(); + } else { + return this.selectedQueue + && Boolean(this.recordType) && Boolean(this.selectedFile) + } + } + + importActionSelected(): boolean { + return this.importNonMatching + || this.mergeOnExact + || this.mergeOnSingleMatch + || this.mergeOnBestMatch; + } + + // 1. create queue if necessary + // 2. upload MARC file + // 3. Enqueue MARC records + // 4. Import records + upload() { + this.sessionKey = null; + this.showProgress = true; + this.isUploading = true; + this.uploadComplete = false; + this.resetProgressBars(); + + this.resolveQueue() + .then( + queueId => { + this.activeQueueId = queueId; + return this.uploadFile(); + }, + err => Promise.reject('queue create failed') + ).then( + ok => this.processSpool(), + err => Promise.reject('process spool failed') + ).then( + ok => this.importRecords(), + err => Promise.reject('import records failed') + ).then( + ok => { + this.isUploading = false; + this.uploadComplete = true; + }, + err => { + console.log('file upload failed: ', err); + this.isUploading = false; + this.resetProgressBars(); + + } + ); + } + + resetProgressBars() { + this.uploadProgress.update({value: 0, max: 1}); + this.enqueueProgress.update({value: 0, max: 1}); + this.importProgress.update({value: 0, max: 1}); + } + + // Extract selected queue ID or create a new queue when requested. + resolveQueue(): Promise { + + if (this.selectedQueue.freetext) { + // Free text queue selector means create a new entry. + // TODO: first check for name dupes + + return this.vandelay.createQueue( + this.selectedQueue.label, + this.recordType, + this.selectedHoldingsProfile, + this.selectedMatchSet, + this.selectedBucket + ); + + } else { + return Promise.resolve(this.selectedQueue.id); + } + } + + uploadFile(): Promise { + + if (this.vandelay.importSelection) { + // Nothing to upload when processing pre-queued records. + return Promise.resolve(); + } + + const formData: FormData = new FormData(); + + formData.append('ses', this.auth.token()); + formData.append('marc_upload', + this.selectedFile, this.selectedFile.name); + + if (this.selectedBibSource) { + formData.append('bib_source', ''+this.selectedBibSource); + } + + const req = new HttpRequest('POST', VANDELAY_UPLOAD_PATH, formData, + {reportProgress: true, responseType: 'text'}); + + return this.http.request(req).pipe(tap( + evt => { + if (evt.type === HttpEventType.UploadProgress) { + this.uploadProgress.update( + {value: evt.loaded, max: evt.total}); + + } else if (evt instanceof HttpResponse) { + this.sessionKey = evt.body as string; + console.log( + 'Vandelay file uploaded OK with key '+this.sessionKey); + } + }, + + (err: HttpErrorResponse) => { + console.error(err); + this.toast.danger(err.error); + } + )).toPromise(); + } + + processSpool(): Promise { + + if (this.vandelay.importSelection) { + // Nothing to enqueue when processing pre-queued records + return Promise.resolve(); + } + + const method = `open-ils.vandelay.${this.recordType}.process_spool`; + + return new Promise((resolve, reject) => { + this.net.request( + 'open-ils.vandelay', method, + this.auth.token(), this.sessionKey, this.activeQueueId, + null, null, this.selectedBibSource, + (this.sessionName || null), true + ).subscribe( + tracker => { + const e = this.evt.parse(tracker); + if (e) { console.error(e); return reject(); } + + // Spooling is in progress, track the results. + this.vandelay.pollSessionTracker(tracker.id()) + .subscribe( + trkr => { + this.enqueueProgress.update({ + // enqueue API only tracks actions performed + max: null, + value: trkr.actions_performed() + }); + }, + err => { console.log(err); reject(); }, + () => { + this.enqueueProgress.update({max: 1, value: 1}); + resolve(); + } + ); + } + ); + }); + } + + importRecords(): Promise { + + if (!this.importActionSelected()) { + return Promise.resolve(); + } + + const selection = this.vandelay.importSelection; + + if (selection && !selection.importQueue) { + return this.importRecordQueue(selection.recordIds); + } else { + return this.importRecordQueue(); + } + } + + importRecordQueue(recIds?: number[]): Promise { + const rtype = this.recordType === 'bib' ? 'bib' : 'auth'; + + let method = `open-ils.vandelay.${rtype}_queue.import`; + const options: ImportOptions = this.compileImportOptions(); + + let target: number | number[] = this.activeQueueId; + if (recIds && recIds.length) { + method = `open-ils.vandelay.${rtype}_record.list.import`; + target = recIds; + } + + return new Promise((resolve, reject) => { + this.net.request('open-ils.vandelay', + method, this.auth.token(), target, options) + .subscribe( + tracker => { + const e = this.evt.parse(tracker); + if (e) { console.error(e); return reject(); } + + // Spooling is in progress, track the results. + this.vandelay.pollSessionTracker(tracker.id()) + .subscribe( + trkr => { + this.importProgress.update({ + max: trkr.total_actions(), + value: trkr.actions_performed() + }); + }, + err => { console.log(err); reject(); }, + () => { + this.importProgress.update({max: 1, value: 1}); + resolve(); + } + ); + } + ); + }); + } + + compileImportOptions(): ImportOptions { + + const options: ImportOptions = { + session_key: this.sessionKey, + import_no_match: this.importNonMatching, + auto_overlay_exact: this.mergeOnExact, + auto_overlay_best_match: this.mergeOnBestMatch, + auto_overlay_1match: this.mergeOnSingleMatch, + opp_acq_copy_overlay: this.autoOverlayAcqCopies, + merge_profile: this.selectedMergeProfile, + fall_through_merge_profile: this.selectedFallThruMergeProfile, + strip_field_groups: this.selectedTrashGroups, + exit_early: true + }; + + if (this.vandelay.importSelection) { + options.overlay_map = this.vandelay.importSelection.overlayMap; + } + + return options; + } + + clearSelection() { + this.vandelay.importSelection = null; + this.startQueueId = null; + } + + openQueue() { + console.log('opening queue ' + this.activeQueueId); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.html new file mode 100644 index 0000000000..fe7d8171c9 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.html @@ -0,0 +1,69 @@ + + + NOT + Normalized Heading + {{point.bool_op()}}{{point.svf()}}{{point.tag()}} + ‡{{point.subfield()}} + | Match score {{point.quality()}} + + + + +
+
+
+ + Your Expression: {{expressionAsString()}} + +
+
+ Add New: + + + + +
+ + +
+ +
+
+
    +
  1. Define a new match point using the above fields.
  2. +
  3. Select a boolean node in the tree.
  4. +
  5. Click the "Add..." button to add the new matchpoint + as a child of the selected node.
  6. +
+
+
+
+ +
+ + +
+
+ + +
+
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.ts new file mode 100644 index 0000000000..991206853d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.ts @@ -0,0 +1,219 @@ +import {Component, OnInit, ViewChild, AfterViewInit, Input} from '@angular/core'; +import {IdlObject, IdlService} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {OrgService} from '@eg/core/org.service'; +import {Tree, TreeNode} from '@eg/share/tree/tree'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; +import {StringService} from '@eg/share/string/string.service'; +import {MatchSetNewPointComponent} from './match-set-new-point.component'; + +@Component({ + selector: 'eg-match-set-expression', + templateUrl: 'match-set-expression.component.html' +}) +export class MatchSetExpressionComponent implements OnInit { + + // Match set arrives from parent async. + matchSet_: IdlObject; + @Input() set matchSet(ms: IdlObject) { + this.matchSet_ = ms; + if (ms && !this.initDone) { + this.matchSetType = ms.mtype(); + this.initDone = true; + this.refreshTree(); + } + } + + tree: Tree; + initDone: boolean; + matchSetType: string; + changesMade: boolean; + + // Current type of new match point + newPointType: string; + newId: number; + + @ViewChild('newPoint') newPoint: MatchSetNewPointComponent; + + constructor( + private idl: IdlService, + private pcrud: PcrudService, + private net: NetService, + private auth: AuthService, + private org: OrgService, + private strings: StringService + ) { + this.newId = -1; + } + + ngOnInit() {} + + refreshTree(): Promise { + if (!this.matchSet_) { return Promise.resolve(); } + + return this.pcrud.search('vmsp', + {match_set: this.matchSet_.id()}, {}, + {atomic: true, authoritative: true} + ).toPromise().then(points => this.ingestMatchPoints(points)); + } + + ingestMatchPoints(points: IdlObject[]) { + const nodes = []; + const idmap: any = {}; + + // massage data, create tree nodes + points.forEach(point => { + + point.negate(point.negate() === 't' ? true : false); + point.heading(point.heading() === 't' ? true : false); + point.children([]); + + const node = new TreeNode({ + id: point.id(), + expanded: true, + callerData: {point: point} + }); + idmap[node.id + ''] = node; + this.setNodeLabel(node, point).then(() => nodes.push(node)); + }); + + // apply the tree parent/child relationships + points.forEach(point => { + const node = idmap[point.id() + '']; + if (point.parent()) { + idmap[point.parent() + ''].children.push(node); + } else { + this.tree = new Tree(node); + } + }); + } + + setNodeLabel(node: TreeNode, point: IdlObject): Promise { + if (node.label) { return Promise.resolve(null); } + return Promise.all([ + this.getPointLabel(point, true).then(txt => node.label = txt), + this.getPointLabel(point, false).then( + txt => node.callerData.slimLabel = txt) + ]); + } + + getPointLabel(point: IdlObject, showmatch?: boolean): Promise { + return this.strings.interpolate( + 'staff.cat.vandelay.matchpoint.label', + {point: point, showmatch: showmatch} + ); + } + + nodeClicked(node: TreeNode) {} + + deleteNode() { + this.changesMade = true; + const node = this.tree.selectedNode() + this.tree.removeNode(node); + } + + hasSelectedNode(): boolean { + return Boolean(this.tree.selectedNode()); + } + + selectedIsBool(): boolean { + if (this.tree) { + const node = this.tree.selectedNode(); + return node && node.callerData.point.bool_op(); + } + return false; + } + + addChildNode() { + this.changesMade = true; + + const pnode = this.tree.selectedNode(); + const point = this.idl.create('vmsp'); + point.id(this.newId--); + point.isnew(true); + point.parent(pnode.id); + point.match_set(this.matchSet_.id()); + point.children([]); + + const ptype = this.newPoint.values.pointType; + + if (ptype === 'bool') { + point.bool_op(this.newPoint.values.boolOp); + + } else { + + if (ptype == 'attr') { + point.svf(this.newPoint.values.recordAttr); + + } else if (ptype == 'marc') { + point.tag(this.newPoint.values.marcTag); + point.subfield(this.newPoint.values.marcSf); + } else if (ptype == 'heading') { + point.heading(true); + } + + point.negate(this.newPoint.values.negate); + point.quality(this.newPoint.values.matchScore); + } + + const node: TreeNode = new TreeNode({ + id: point.id(), + callerData: {point: point} + }); + + // Match points are added to the DB only when the tree is saved. + this.setNodeLabel(node, point).then(() => pnode.children.push(node)); + } + + expressionAsString(): string { + if (!this.tree) { return ''; } + + const renderNode = (node: TreeNode): string => { + if (!node) { return ''; } + + if (node.children.length) { + return '(' + node.children.map(renderNode).join( + ' ' + node.callerData.slimLabel + ' ') + ')' + } else if (!node.callerData.point.bool_op()) { + return node.callerData.slimLabel; + } else { + return '()'; + } + } + + return renderNode(this.tree.rootNode); + } + + // Server API deletes and recreates the tree on update. + // It manages parent/child relationships via the children array. + // We only need send the current tree in a form the API recognizes. + saveTree(): Promise { + + + const compileTree = (node?: TreeNode) => { + + if (!node) { node = this.tree.rootNode; } + + const point = node.callerData.point; + + node.children.forEach(child => + point.children().push(compileTree(child))); + + return point; + }; + + const rootPoint: IdlObject = compileTree(); + + return this.net.request( + 'open-ils.vandelay', + 'open-ils.vandelay.match_set.update', + this.auth.token(), this.matchSet_.id(), rootPoint + ).toPromise().then( + ok =>this.refreshTree(), + err => console.error(err) + ); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.html new file mode 100644 index 0000000000..7674be239d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.html @@ -0,0 +1,37 @@ + +
+
+
+
+ Owner +
+ + +
+
+
+ + + + {{row.name()}} + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts new file mode 100644 index 0000000000..e20c954be2 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts @@ -0,0 +1,78 @@ +import {Component, AfterViewInit, ViewChild} from '@angular/core'; +import {Router} from '@angular/router'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {OrgService} from '@eg/core/org.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridColumn} from '@eg/share/grid/grid'; +import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component'; + +@Component({ + templateUrl: 'match-set-list.component.html' +}) +export class MatchSetListComponent implements AfterViewInit { + + contextOrg: IdlObject; + gridSource: GridDataSource; + deleteSelected: (rows: IdlObject[]) => void; + createNew: () => void; + @ViewChild('grid') grid: GridComponent; + @ViewChild('editDialog') editDialog: FmRecordEditorComponent; + + constructor( + private router: Router, + private pcrud: PcrudService, + private auth: AuthService, + private org: OrgService) { + + this.gridSource = new GridDataSource(); + this.contextOrg = this.org.get(this.auth.user().ws_ou()); + + this.gridSource.getRows = (pager: Pager) => { + const orgs = this.org.ancestors(this.contextOrg, true); + return this.pcrud.search('vms', {owner: orgs}, { + order_by: {vms: ['name']}, + limit: pager.limit, + offset: pager.offset + }); + } + + this.createNew = () => { + this.editDialog.mode = 'create'; + this.editDialog.open({size: 'lg'}).then( + ok => this.grid.reload(), + err => {} + ); + }; + + this.deleteSelected = (matchSets: IdlObject[]) => { + matchSets.forEach(matchSet => matchSet.isdeleted(true)); + this.pcrud.autoApply(matchSets).subscribe( + val => console.debug('deleted: ' + val), + err => {}, + () => this.grid.reload() + ); + }; + } + + ngAfterViewInit() { + this.grid.onRowActivate.subscribe( + (matchSet: IdlObject) => { + this.editDialog.mode = 'update'; + this.editDialog.recId = matchSet.id(); + this.editDialog.open({size: 'lg'}).then( + ok => this.grid.reload(), + err => {} + ); + } + ); + } + + orgOnChange(org: IdlObject) { + this.contextOrg = org; + this.grid.reload(); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.html new file mode 100644 index 0000000000..4ffa40c819 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.html @@ -0,0 +1,77 @@ +
+
+ +
+
Record Attribute:
+
+ + +
+
+
+ +
+
Tag:
+
+ +
+
+
+
Subfield ‡:
+
+ +
+
+
+ +
+
Normalized Heading:
+
+ +
+
+
+ +
+
Match Score:
+
+ +
+
+ +
+
Negate:
+
+ +
+
+
+
+ +
+
Operator:
+
+ +
+
+
+ +
+
Value:
+
+ +
+
+
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.ts new file mode 100644 index 0000000000..6298981cff --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.ts @@ -0,0 +1,65 @@ +import {Component, OnInit, ViewChild, Output, Input} from '@angular/core'; +import {IdlObject, IdlService} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {ComboboxEntry} from '@eg/share/combobox/combobox.component'; + +// Can be used to create match_set_point's and match_set_quality's +export class MatchSetPointValues { + pointType: string; + recordAttr: string; + matchScore: number; + negate: boolean; + marcTag: string; + marcSf: string; + heading: string; + boolOp: string; + value: string; +} + +@Component({ + selector: 'eg-match-set-new-point', + templateUrl: 'match-set-new-point.component.html' +}) +export class MatchSetNewPointComponent implements OnInit { + + public values: MatchSetPointValues; + + bibAttrDefs: IdlObject[]; + bibAttrDefEntries: ComboboxEntry[]; + + // defining a new match_set_quality + @Input() isForQuality: boolean; + + // biblio, authority, quality + @Input() set pointType(type_: string) { + this.values.pointType = type_; + this.values.recordAttr = ''; + this.values.matchScore = 1; + this.values.negate = false; + this.values.marcTag = ''; + this.values.marcSf = ''; + this.values.boolOp = 'AND'; + this.values.value = ''; + } + + constructor( + private idl: IdlService, + private pcrud: PcrudService + ) { + this.values = new MatchSetPointValues(); + this.bibAttrDefs = []; + this.bibAttrDefEntries = []; + } + + ngOnInit() { + this.pcrud.retrieveAll('crad', {order_by: {crad: 'label'}}) + .subscribe(attr => { + this.bibAttrDefs.push(attr); + this.bibAttrDefEntries.push({id: attr.name(), label: attr.label()}); + }); + } + + setNewPointType(type_: string) { + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.html new file mode 100644 index 0000000000..5229ddf3ac --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.html @@ -0,0 +1,27 @@ +
+
+
+ Add New: + + +
+ + +
+ + +
+
+
+ + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.ts new file mode 100644 index 0000000000..b2409c196c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.ts @@ -0,0 +1,105 @@ +import {Component, OnInit, ViewChild, AfterViewInit, Input} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import {IdlObject, IdlService} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {OrgService} from '@eg/core/org.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {Pager} from '@eg/share/util/pager'; +import {MatchSetNewPointComponent} from './match-set-new-point.component'; + +@Component({ + selector: 'eg-match-set-quality', + templateUrl: 'match-set-quality.component.html' +}) +export class MatchSetQualityComponent implements OnInit { + + // Match set arrives from parent async. + matchSet_: IdlObject; + @Input() set matchSet(ms: IdlObject) { + this.matchSet_ = ms; + if (ms) { + this.matchSetType = ms.mtype(); + if (this.grid) { + this.grid.reload(); + } + } + } + + newPointType: string; + matchSetType: string; + dataSource: GridDataSource; + @ViewChild('newPoint') newPoint: MatchSetNewPointComponent; + @ViewChild('grid') grid: GridComponent; + deleteSelected: (rows: IdlObject[]) => void; + + constructor( + private idl: IdlService, + private pcrud: PcrudService, + private net: NetService, + private auth: AuthService, + private org: OrgService + ) { + + this.dataSource = new GridDataSource(); + this.dataSource.getRows = (pager: Pager, sort: any[]) => { + + if (!this.matchSet_) { + return Observable.of(); + } + + const orderBy: any = {}; + if (sort.length) { + orderBy.vmsq = sort[0].name + ' ' + sort[0].dir; + } + + const searchOps = { + offset: pager.offset, + limit: pager.limit, + order_by: orderBy + }; + + const search = {match_set: this.matchSet_.id()}; + return this.pcrud.search('vmsq', search, searchOps); + } + + this.deleteSelected = (rows: any[]) => { + this.pcrud.remove(rows).subscribe( + ok => console.log('deleted ', ok), + err => console.error(err), + () => this.grid.reload() + ); + }; + } + + ngOnInit() {} + + addQuality() { + const quality = this.idl.create('vmsq'); + const values = this.newPoint.values; + + quality.match_set(this.matchSet_.id()); + quality.quality(values.matchScore); + quality.value(values.value); + + if (values.recordAttr) { + quality.svf(values.recordAttr); + } else { + quality.tag(values.marcTag); + quality.subfield(values.marcSf); + } + + this.pcrud.create(quality).subscribe( + ok => console.debug('created ', ok), + err => console.error(err), + () => { + this.newPointType = null; + this.grid.reload(); + } + ); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.html new file mode 100644 index 0000000000..fd69cf93bd --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.html @@ -0,0 +1,36 @@ +
+
+
+
Match Set Summary
+
+
+
Match Set Name:
+
{{matchSet.name()}}
+
+
+
Owning Library:
+
{{matchSet.owner().shortname()}}
+
+
+
Type:
+
{{matchSet.mtype()}}
+
+
+
+
+
+ + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.ts new file mode 100644 index 0000000000..15a19aaa42 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.ts @@ -0,0 +1,51 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; +import {IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {OrgService} from '@eg/core/org.service'; + +@Component({ + templateUrl: 'match-set.component.html' +}) +export class MatchSetComponent implements OnInit { + + matchSet: IdlObject; + matchSetId: number; + matchSetTab: string; + + constructor( + private router: Router, + private route: ActivatedRoute, + private pcrud: PcrudService, + private org: OrgService + ) { + this.route.paramMap.subscribe((params: ParamMap) => { + this.matchSetId = +params.get('id'); + this.matchSetTab = params.get('matchSetTab'); + }); + } + + ngOnInit() { + this.pcrud.retrieve('vms', this.matchSetId) + .toPromise().then(ms => { + ms.owner(this.org.get(ms.owner())); + this.matchSet = ms; + }); + } + + // Changing a tab in the UI means changing the route. + // Changing the route ultimately results in changing the tab. + onTabChange(evt: NgbTabChangeEvent) { + this.matchSetTab = evt.nextId; + + // prevent tab changing until after route navigation + evt.preventDefault(); + + const url = + `/staff/cat/vandelay/match_sets/${this.matchSetId}/${this.matchSetTab}`; + + this.router.navigate([url]); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/merge-profiles.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/merge-profiles.component.ts new file mode 100644 index 0000000000..2059b618cb --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/merge-profiles.component.ts @@ -0,0 +1,9 @@ +import {Component} from '@angular/core'; + +@Component({ + template: `` +}) +export class MergeProfilesComponent { + constructor() {} +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html new file mode 100644 index 0000000000..8bc896f5b6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html @@ -0,0 +1,19 @@ +
+
+ +
+
+ + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts new file mode 100644 index 0000000000..d72a81bfa8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts @@ -0,0 +1,60 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import {map} from 'rxjs/operators/map'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {VandelayService} from './vandelay.service'; + +@Component({ + templateUrl: 'queue-items.component.html' +}) +export class QueueItemsComponent { + + queueType: string; + queueId: number; + filterImportErrors: boolean; + limitToImportErrors: (checked: boolean) => void; + + gridSource: GridDataSource; + @ViewChild('itemsGrid') itemsGrid: GridComponent; + + constructor( + private router: Router, + private route: ActivatedRoute, + private net: NetService, + private auth: AuthService, + private vandelay: VandelayService) { + + this.route.paramMap.subscribe((params: ParamMap) => { + this.queueId = +params.get('id'); + this.queueType = params.get('qtype'); + }); + + this.gridSource = new GridDataSource(); + + // queue API does not support sorting + this.gridSource.getRows = (pager: Pager) => { + return this.net.request( + 'open-ils.vandelay', + 'open-ils.vandelay.import_item.queue.retrieve', + this.auth.token(), this.queueId, { + with_import_error: this.filterImportErrors, + offset: pager.offset, + limit: pager.limit + } + ); + }; + + this.limitToImportErrors = (checked: boolean) => { + this.filterImportErrors = checked; + this.itemsGrid.reload(); + } + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.html new file mode 100644 index 0000000000..6aface5418 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.html @@ -0,0 +1,36 @@ +
+

Select a Queue To Inspect

+
+
+ +
+
+ + + + + +
+
+
+ + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.ts new file mode 100644 index 0000000000..888c8a57f4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.ts @@ -0,0 +1,102 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import {map} from 'rxjs/operators/map'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridColumn} from '@eg/share/grid/grid'; +import {VandelayService} from './vandelay.service'; + +@Component({ + templateUrl: 'queue-list.component.html' +}) +export class QueueListComponent { + + queueType: string; // bib / auth / bib-acq + queueSource: GridDataSource; + deleteSelected: (rows: IdlObject[]) => void; + + // points to the currently active grid. + queueGrid: GridComponent; + + @ViewChild('bibQueueGrid') bibQueueGrid: GridComponent; + @ViewChild('authQueueGrid') authQueueGrid: GridComponent; + + constructor( + private router: Router, + private route: ActivatedRoute, + private net: NetService, + private auth: AuthService, + private vandelay: VandelayService) { + + this.queueType = 'bib'; + this.queueSource = new GridDataSource(); + + // Reset queue grid offset + this.vandelay.queuePageOffset = 0; + + // queue API does not support sorting + this.queueSource.getRows = (pager: Pager) => { + return this.loadQueues(pager); + } + + this.deleteSelected = (queues: IdlObject[]) => { + + // Serialize the deletes, especially if there are many of them + // because they can be bulky calls + const qtype = this.queueType; + const method = `open-ils.vandelay.${qtype}_queue.delete`; + + const deleteNext = (queues: IdlObject[], idx: number) => { + const queue = queues[idx]; + if (!queue) { + this.currentGrid().reload(); + return Promise.resolve(); + } + + return this.net.request('open-ils.vandelay', + method, this.auth.token(), queue.id() + ).toPromise().then(() => deleteNext(queues, ++idx)); + } + + deleteNext(queues, 0); + }; + } + + currentGrid(): GridComponent { + // The active grid changes along with the queue type. + // The inactive grid will be set to null. + return this.bibQueueGrid || this.authQueueGrid; + } + + rowActivated(queue) { + const url = `/staff/cat/vandelay/queue/${this.queueType}/${queue.id()}`; + this.router.navigate([url]); + } + + queueTypeChanged($event) { + this.queueType = $event.id; + this.queueSource.reset(); + } + + + loadQueues(pager: Pager): Observable { + + if (!this.queueType) { + return Observable.of(); + } + + const qtype = this.queueType.match(/bib/) ? 'bib' : 'authority'; + const method = `open-ils.vandelay.${qtype}_queue.owner.retrieve`; + + return this.net.request('open-ils.vandelay', + method, this.auth.token(), null, null, + {offset: pager.offset, limit: pager.limit} + ); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html new file mode 100644 index 0000000000..bd991070ef --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html @@ -0,0 +1,152 @@ + + + + + + + + +

Queue {{queueSummary.queue.name()}}

+
+
+
+
Queue Summary
+
    +
  • +
    +
    Records in Queue:
    +
    {{queueSummary.total}}
    +
    Items in Queue:
    +
    {{queueSummary.total_items}}
    +
    +
  • +
  • +
    +
    Records Imported:
    +
    {{queueSummary.imported}}
    +
    Items Imported:
    +
    {{queueSummary.total_items_imported}}
    +
    +
  • +
  • +
    +
    Records Import Failures:
    +
    {{queueSummary.rec_import_errors}}
    +
    Item Import Failures:
    +
    {{queueSummary.item_import_errors}}
    +
    +
  • +
+
+
+
+
+
Queue Actions
+ +
+
+
+
+ + + + ({{row.matches.length}}) + {{hasOverlayTarget(row.id) ? '*' : ''}} + + + + +
+ {{row.import_error}} +
+
+ Items ({{row.error_items.length}}) +
+
+ + + + {{row.imported_as}} + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts new file mode 100644 index 0000000000..a6f67c34d1 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts @@ -0,0 +1,250 @@ +import {Component, OnInit, AfterViewInit, ViewChild} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import {map} from 'rxjs/operators/map'; +import {filter} from 'rxjs/operators/filter'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component'; +import {ProgressDialogComponent} from '@eg/share/dialog/progress.component'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridColumn} from '@eg/share/grid/grid'; +import {VandelayService, VandelayImportSelection, + VANDELAY_EXPORT_PATH} from './vandelay.service'; + +@Component({ + templateUrl: 'queue.component.html' +}) +export class QueueComponent implements OnInit, AfterViewInit { + + queueId: number; + queueType: string; // bib / authority + queueSource: GridDataSource; + queuedRecClass: string; + queueSummary: any; + + filters = { + matches: false, + nonImported: false, + withErrors: false + }; + + limitToMatches: (checked: boolean) => void; + limitToNonImported: (checked: boolean) => void; + limitToImportErrors: (checked: boolean) => void; + + // keep a local copy for convenience + attrDefs: IdlObject[]; + + @ViewChild('queueGrid') queueGrid: GridComponent; + @ViewChild('confirmDelDlg') confirmDelDlg: ConfirmDialogComponent; + @ViewChild('progressDlg') progressDlg: ProgressDialogComponent; + + constructor( + private router: Router, + private route: ActivatedRoute, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private vandelay: VandelayService) { + + this.route.paramMap.subscribe((params: ParamMap) => { + this.queueType = params.get('qtype'); + this.queueId = +params.get('id'); + }); + + this.queueSource = new GridDataSource(); + this.queueSource.getRows = (pager: Pager) => { + this.vandelay.queuePageOffset = pager.offset; + return this.loadQueueRecords(pager); + }; + + this.limitToMatches = (checked: boolean) => { + this.filters.matches = checked; + this.queueGrid.reload(); + }; + + this.limitToNonImported = (checked: boolean) => { + this.filters.nonImported = checked; + this.queueGrid.reload(); + }; + + this.limitToImportErrors = (checked: boolean) => { + this.filters.withErrors = checked; + this.queueGrid.reload(); + }; + } + + ngOnInit() { + } + + queuePageOffset(): number { + return this.vandelay.queuePageOffset; + } + + ngAfterViewInit() { + if (this.queueType) { + this.applyQueueType(); + if (this.queueId) { + this.loadQueueSummary(); + } + } + } + + openRecord(row: any) { + const url = + `/staff/cat/vandelay/queue/${this.queueType}/${this.queueId}/record/${row.id}/marc`; + this.router.navigate([url]); + } + + applyQueueType() { + this.queuedRecClass = this.queueType.match(/bib/) ? 'vqbr' : 'vqar'; + this.vandelay.getAttrDefs(this.queueType).then( + attrs => { + this.attrDefs = attrs; + // Add grid columns for record attributes + attrs.forEach(attr => { + const col = new GridColumn(); + col.name = attr.code(), + col.label = attr.description(), + col.datatype = 'string'; + this.queueGrid.context.columnSet.add(col); + }); + + // Reapply the grid configuration now that we've + // dynamically added columns. + this.queueGrid.context.applyGridConfig(); + } + ); + } + + qtypeShort(): string { + return this.queueType === 'bib' ? 'bib' : 'auth'; + } + + loadQueueSummary(): Promise { + const method = + `open-ils.vandelay.${this.qtypeShort()}_queue.summary.retrieve`; + + return this.net.request( + 'open-ils.vandelay', method, this.auth.token(), this.queueId) + .toPromise().then(sum => this.queueSummary = sum); + } + + loadQueueRecords(pager: Pager): Observable { + + const options = { + clear_marc: true, + offset: pager.offset, + limit: pager.limit, + flesh_import_items: true, + non_imported: this.filters.nonImported, + with_import_error: this.filters.withErrors + } + + return this.vandelay.getQueuedRecords( + this.queueId, this.queueType, options, this.filters.matches).pipe( + filter(rec => { + // avoid sending mishapen data to the grid + // this happens (among other reasons) when the grid + // no longer exists + const e = this.evt.parse(rec); + if (e) { console.error(e); return false; } + return true; + }), + map(rec => { + const recHash: any = { + id: rec.id(), + import_error: rec.import_error(), + error_detail: rec.error_detail(), + import_time: rec.import_time(), + imported_as: rec.imported_as(), + import_items: rec.import_items(), + error_items: rec.import_items().filter(i => i.import_error()), + matches: rec.matches() + }; + + // Link the record attribute values to the root record + // object so the grid can find them. + rec.attributes().forEach(attr => { + const def = + this.attrDefs.filter(d => d.id() === attr.field())[0]; + recHash[def.code()] = attr.attr_value(); + }); + + return recHash; + })); + } + + findOrCreateImportSelection() { + let selection = this.vandelay.importSelection; + if (!selection) { + selection = new VandelayImportSelection(); + this.vandelay.importSelection = selection; + } + selection.queue = this.queueSummary.queue; + return selection; + } + + hasOverlayTarget(rid: number): boolean { + return this.vandelay.importSelection && + Boolean(this.vandelay.importSelection.overlayMap[rid]); + } + + importSelected() { + const rows = this.queueGrid.context.getSelectedRows(); + if (rows.length) { + const selection = this.findOrCreateImportSelection(); + selection.recordIds = rows.map(row => row.id); + console.log('importing: ', this.vandelay.importSelection); + this.router.navigate(['/staff/cat/vandelay/import']); + } + } + + importAll() { + const selection = this.findOrCreateImportSelection(); + selection.importQueue = true; + this.router.navigate(['/staff/cat/vandelay/import']); + } + + deleteQueue() { + this.confirmDelDlg.open().then( + yes => { + this.progressDlg.open(); + return this.net.request( + 'open-ils.vandelay', + `open-ils.vandelay.${this.qtypeShort()}_queue.delete`, + this.auth.token(), this.queueId + ).toPromise(); + }, + no => { + this.progressDlg.close(); + return Promise.reject('delete failed'); + } + ).then( + resp => { + this.progressDlg.close(); + const e = this.evt.parse(resp); + if (e) { + console.error(e); + alert(e); + } else { + // Jump back to the main queue page. + this.router.navigate(['/staff/cat/vandelay/queue']); + } + }, + err => { + this.progressDlg.close(); + } + ); + } + + exportNonImported() { + this.vandelay.exportQueue(this.queueSummary.queue, true); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.html new file mode 100644 index 0000000000..db72a9aa24 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.html @@ -0,0 +1,96 @@ + + + + {{row.eg_record}} + + + + + + check_circle + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.ts new file mode 100644 index 0000000000..74e70f1f9e --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.ts @@ -0,0 +1,153 @@ +import {Component, Input, OnInit, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Observable} from 'rxjs/Observable'; +import 'rxjs/add/observable/of'; +import {map} from 'rxjs/operators/map'; +import {Pager} from '@eg/share/util/pager'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource, GridColumn} from '@eg/share/grid/grid'; +import {IdlObject} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service'; +import {VandelayService, VandelayImportSelection} from './vandelay.service'; + +@Component({ + selector: 'eg-queued-record-matches', + templateUrl: 'queued-record-matches.component.html' +}) +export class QueuedRecordMatchesComponent implements OnInit { + + @Input() queueType: string; + @Input() recordId: number; + @ViewChild('bibGrid') bibGrid: GridComponent; + @ViewChild('authGrid') authGrid: GridComponent; + + queuedRecord: IdlObject; + bibDataSource: GridDataSource; + authDataSource: GridDataSource; + markOverlayTarget: (rows: any[]) => any; + matchRowClick: (row: any) => void; + matchMap: {[id: number]: IdlObject}; + + constructor( + private router: Router, + private route: ActivatedRoute, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private pcrud: PcrudService, + private bib: BibRecordService, + private vandelay: VandelayService) { + + this.bibDataSource = new GridDataSource(); + this.authDataSource = new GridDataSource(); + + this.bibDataSource.getRows = (pager: Pager) => { + return this.getBibMatchRows(pager); + } + + /* TODO + this.authDataSource.getRows = (pager: Pager) => { + } + */ + + // Mark or un-mark as row as the merge target on row click + this.matchRowClick = (row: any) => { + this.toggleMergeTarget(row.id); + } + } + + toggleMergeTarget(matchId: number) { + + if (this.isOverlayTarget(matchId)) { + + // clear selection on secondary click; + delete this.vandelay.importSelection.overlayMap[this.recordId]; + + } else { + // Add to selection. + // Start a new one if necessary, which will be adopted + // and completed by the queue UI before import. + + let selection = this.vandelay.importSelection; + if (!selection) { + selection = new VandelayImportSelection(); + this.vandelay.importSelection = selection; + } + const match = this.matchMap[matchId]; + selection.overlayMap[this.recordId] = match.eg_record(); + } + } + + isOverlayTarget(matchId: number): boolean { + const selection = this.vandelay.importSelection; + if (selection) { + const match = this.matchMap[matchId]; + return selection.overlayMap[this.recordId] === match.eg_record(); + } + return false; + } + + ngOnInit() {} + + // This thing is a nesty beast -- clean it up + getBibMatchRows(pager: Pager): Observable { + + return new Observable(observer => { + + this.getQueuedRecord().then(() => { + + const matches = this.queuedRecord.matches(); + const recIds = []; + this.matchMap = {}; + matches.forEach(m => { + this.matchMap[m.id()] = m; + if (!recIds.includes(m.eg_record())) { + recIds.push(m.eg_record()); + } + }); + + const bibSummaries: {[id: number]: BibRecordSummary} = {}; + this.bib.getBibSummary(recIds).subscribe( + summary => bibSummaries[summary.id] = summary, + err => {}, + () => { + this.bib.fleshBibUsers( + Object.values(bibSummaries).map(sum => sum.record) + ).then(() => { + matches.forEach(match => { + const row = { + id: match.id(), + eg_record: match.eg_record(), + bre_quality: match.quality(), + vqbr_quality: this.queuedRecord.quality(), + match_score: match.match_score(), + bib_summary: bibSummaries[match.eg_record()] + } + + observer.next(row); + }); + + observer.complete(); + }); + } + ); + }); + }); + } + + getQueuedRecord(): Promise { + if (this.queuedRecord) { + return Promise.resolve(''); + } + let idlClass = this.queueType === 'bib' ? 'vqbr' : 'vqar'; + const flesh = {flesh: 1, flesh_fields: {}}; + flesh.flesh_fields[idlClass] = ['matches']; + return this.pcrud.retrieve(idlClass, this.recordId, flesh) + .toPromise().then(rec => this.queuedRecord = rec); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.html new file mode 100644 index 0000000000..d9e85347e0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.html @@ -0,0 +1,31 @@ + +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.ts new file mode 100644 index 0000000000..3a37be74c8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.ts @@ -0,0 +1,42 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {NgbTabset, NgbTabChangeEvent} from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + templateUrl: 'queued-record.component.html' +}) +export class QueuedRecordComponent { + + queueId: number; + queueType: string; + recordId: number; + recordTab: string; + + constructor( + private router: Router, + private route: ActivatedRoute) { + + this.route.paramMap.subscribe((params: ParamMap) => { + this.queueId = +params.get('id'); + this.recordId = +params.get('recordId'); + this.queueType = params.get('qtype'); + this.recordTab = params.get('recordTab'); + }); + } + + // Changing a tab in the UI means changing the route. + // Changing the route ultimately results in changing the tab. + onTabChange(evt: NgbTabChangeEvent) { + this.recordTab = evt.nextId; + + // prevent tab changing until after route navigation + evt.preventDefault(); + + const url = + `/staff/cat/vandelay/queue/${this.queueType}/${this.queueId}` + + `/record/${this.recordId}/${this.recordTab}`; + + this.router.navigate([url]); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.html new file mode 100644 index 0000000000..6654ac49a6 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.html @@ -0,0 +1,67 @@ +
+
+
+
+ Show Sessions Since: +
+ + +
+
+
+ +
+
+
+
+ No Import Sessions To Display +
+
+
+
+ +
+
+
+
+
+ + {{tracker.create_time() | date:'short'}} : + {{tracker.name()}} + +
+
+
+
+
+ + + +
+
+ + + + Queue {{tracker.queue().name()}} + + + Enqueuing... + Importing... + Active + Complete + Error + + thumb_up + +
+
+
+
+
+
+ diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.ts new file mode 100644 index 0000000000..ad7b0588e0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.ts @@ -0,0 +1,140 @@ +import {Component, OnInit} from '@angular/core'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {VandelayService} from './vandelay.service'; + +@Component({ + templateUrl: 'recent-imports.component.html' +}) + +export class RecentImportsComponent implements OnInit { + + trackers: IdlObject[]; + refreshInterval = 2000; // ms + sinceDate: string; + pollTimeout: any; + + constructor( + private idl: IdlService, + private auth: AuthService, + private pcrud: PcrudService, + private vandelay: VandelayService + ) { + this.trackers = []; + } + + ngOnInit() { + // Default to showing all trackers created today. + const d = new Date(); + d.setHours(0); + d.setMinutes(0); + d.setSeconds(0); + this.sinceDate = d.toISOString(); + + this.pollTrackers(); + } + + dateFilterChange(iso: string) { + if (iso) { + this.sinceDate = iso; + if (this.pollTimeout) { + clearTimeout(this.pollTimeout); + this.pollTimeout = null; + } + this.trackers = []; + this.pollTrackers(); + } + } + + pollTrackers() { + + // Report on recent trackers for this workstation and for the + // logged in user. Always show active trackers regardless + // of sinceDate. + const query: any = { + '-and': [ + { + '-or': [ + {workstation: this.auth.user().wsid()}, + {usr: this.auth.user().id()} + ], + }, { + '-or': [ + {create_time: {'>=': this.sinceDate}}, + {state: 'active'} + ] + } + ] + }; + + this.pcrud.search('vst', query, {order_by: {vst: 'create_time'}}) + .subscribe( + tracker => { + // The screen flickers less if the tracker array is + // updated inline instead of rebuilt every time. + + const existing = + this.trackers.filter(t => t.id() === tracker.id())[0]; + + if (existing) { + existing.update_time(tracker.update_time()); + existing.state(tracker.state()); + existing.total_actions(tracker.total_actions()); + existing.actions_performed(tracker.actions_performed()); + } else { + + // Only show the import tracker when both an enqueue + // and import tracker exist for a given session. + const sameSes = this.trackers.filter( + t => t.session_key() === tracker.session_key())[0]; + + if (sameSes) { + if (sameSes.action_type() === 'enqueue') { + // Remove the enqueueu tracker + + for (let idx = 0; idx < this.trackers.length; idx++) { + const trkr = this.trackers[idx]; + if (trkr.id() === sameSes.id()) { + console.debug( + `removing tracker ${trkr.id()} from the list`); + this.trackers.splice(idx, 1); + break; + } + } + } else if (sameSes.action_type() === 'import') { + // Avoid adding the new enqueue tracker + return; + } + } + + console.debug(`adding tracker ${tracker.id()} to list`); + + this.trackers.unshift(tracker); + this.fleshTrackerQueue(tracker); + } + }, + err => {}, + () => { + const active = + this.trackers.filter(t => t.state() === 'active'); + + // Continue updating the display with updated tracker + // data as long as we have any active trackers. + if (active.length > 0) { + this.pollTimeout = setTimeout( + () => this.pollTrackers(), this.refreshInterval); + } else { + this.pollTimeout = null; + } + } + ); + } + + fleshTrackerQueue(tracker: IdlObject) { + const qClass = tracker.record_type() === 'bib' ? 'vbq' : 'vaq'; + this.pcrud.retrieve(qClass, tracker.queue()) + .subscribe(queue => tracker.queue(queue)); + } + +} diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.html new file mode 100644 index 0000000000..012a579a1b --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.html @@ -0,0 +1,6 @@ + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.ts new file mode 100644 index 0000000000..9852a640e8 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.ts @@ -0,0 +1,37 @@ +import {Component, Input, ViewChild} from '@angular/core'; +import {Pager} from '@eg/share/util/pager'; +import {IdlObject} from '@eg/core/idl.service'; +import {NetService} from '@eg/core/net.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {AuthService} from '@eg/core/auth.service'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {GridDataSource} from '@eg/share/grid/grid'; +import {VandelayService} from './vandelay.service'; + +@Component({ + selector: 'eg-queued-record-items', + templateUrl: 'record-items.component.html' +}) +export class RecordItemsComponent { + + @Input() recordId: number; + + gridSource: GridDataSource; + @ViewChild('itemsGrid') itemsGrid: GridComponent; + + constructor( + private net: NetService, + private auth: AuthService, + private pcrud: PcrudService, + private vandelay: VandelayService) { + + this.gridSource = new GridDataSource(); + + // queue API does not support sorting + this.gridSource.getRows = (pager: Pager) => { + return this.pcrud.search('vii', + {record: this.recordId}, {order_by: {vii: ['id']}}); + }; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/routing.module.ts new file mode 100644 index 0000000000..707b92b9ca --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/routing.module.ts @@ -0,0 +1,75 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {VandelayComponent} from './vandelay.component'; +import {ImportComponent} from './import.component'; +import {ExportComponent} from './export.component'; +import {QueueListComponent} from './queue-list.component'; +import {QueueComponent} from './queue.component'; +import {QueuedRecordComponent} from './queued-record.component'; +import {DisplayAttrsComponent} from './display-attrs.component'; +import {MergeProfilesComponent} from './merge-profiles.component'; +import {HoldingsProfilesComponent} from './holdings-profiles.component'; +import {QueueItemsComponent} from './queue-items.component'; +import {MatchSetListComponent} from './match-set-list.component'; +import {MatchSetComponent} from './match-set.component'; +import {RecentImportsComponent} from './recent-imports.component'; + +const routes: Routes = [{ + path: '', + component: VandelayComponent, + children: [{ + path: '', + pathMatch: 'full', + redirectTo: 'import' + }, { + path: 'import', + component: ImportComponent + }, { + path: 'export', + component: ExportComponent + }, { + path: 'queue', + component: QueueListComponent + }, { + path: 'queue/:qtype/:id', + component: QueueComponent + }, { + path: 'queue/:qtype/:id/record/:recordId', + component: QueuedRecordComponent + }, { + path: 'queue/:qtype/:id/record/:recordId/:recordTab', + component: QueuedRecordComponent + }, { + path: 'queue/:qtype/:id/items', + component: QueueItemsComponent + }, { + path: 'display_attrs', + component: DisplayAttrsComponent + }, { + path: 'display_attrs/:atype', + component: DisplayAttrsComponent + }, { + path: 'merge_profiles', + component: MergeProfilesComponent + }, { + path: 'holdings_profiles', + component: HoldingsProfilesComponent + }, { + path: 'match_sets', + component: MatchSetListComponent + }, { + path: 'match_sets/:id/:matchSetTab', + component: MatchSetComponent + }, { + path: 'active_imports', + component: RecentImportsComponent + }] +}]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [] +}) + +export class VandelayRoutingModule {} diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.html b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.html new file mode 100644 index 0000000000..a81472d674 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.html @@ -0,0 +1,44 @@ + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.ts new file mode 100644 index 0000000000..0bfad42d5c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.ts @@ -0,0 +1,34 @@ +import {Component, OnInit, AfterViewInit, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, NavigationEnd} from "@angular/router"; +import {take} from 'rxjs/operators/take'; +import {VandelayService} from './vandelay.service'; +import {IdlObject} from '@eg/core/idl.service'; + +@Component({ + templateUrl: 'vandelay.component.html' +}) +export class VandelayComponent implements OnInit, AfterViewInit { + tab: string; + + constructor( + private router: Router, + private route: ActivatedRoute, + private vandelay: VandelayService) { + + // As the parent component of the vandelay route tree, our + // activated route never changes. Instead, listen for global + // route events, then ask for the first segement of the first + // child, which will be the tab name. + this.router.events.subscribe(routeEvent => { + if (routeEvent instanceof NavigationEnd) { + this.route.firstChild.url.pipe(take(1)) + .subscribe(segments => this.tab = segments[0].path); + } + }); + } + + ngOnInit() {} + + ngAfterViewInit() {} +} + diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts new file mode 100644 index 0000000000..9bbfd46a26 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts @@ -0,0 +1,61 @@ +import {NgModule} from '@angular/core'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module'; +import {HttpClientModule} from '@angular/common/http'; +import {TreeModule} from '@eg/share/tree/tree.module'; +import {VandelayRoutingModule} from './routing.module'; +import {VandelayService} from './vandelay.service'; +import {VandelayComponent} from './vandelay.component'; +import {ImportComponent} from './import.component'; +import {ExportComponent} from './export.component'; +import {QueueComponent} from './queue.component'; +import {QueueListComponent} from './queue-list.component'; +import {QueuedRecordComponent} from './queued-record.component'; +import {QueuedRecordMatchesComponent} from './queued-record-matches.component'; +import {DisplayAttrsComponent} from './display-attrs.component'; +import {MergeProfilesComponent} from './merge-profiles.component'; +import {HoldingsProfilesComponent} from './holdings-profiles.component'; +import {QueueItemsComponent} from './queue-items.component'; +import {RecordItemsComponent} from './record-items.component'; +import {MatchSetListComponent} from './match-set-list.component'; +import {MatchSetComponent} from './match-set.component'; +import {MatchSetExpressionComponent} from './match-set-expression.component'; +import {MatchSetQualityComponent} from './match-set-quality.component'; +import {MatchSetNewPointComponent} from './match-set-new-point.component'; +import {RecentImportsComponent} from './recent-imports.component'; + +@NgModule({ + declarations: [ + VandelayComponent, + ImportComponent, + ExportComponent, + QueueComponent, + QueueListComponent, + QueuedRecordComponent, + QueuedRecordMatchesComponent, + DisplayAttrsComponent, + MergeProfilesComponent, + HoldingsProfilesComponent, + QueueItemsComponent, + RecordItemsComponent, + MatchSetListComponent, + MatchSetComponent, + MatchSetExpressionComponent, + MatchSetQualityComponent, + MatchSetNewPointComponent, + RecentImportsComponent + ], + imports: [ + TreeModule, + StaffCommonModule, + CatalogCommonModule, + VandelayRoutingModule, + HttpClientModule, + ], + providers: [ + VandelayService + ] +}) + +export class VandelayModule { +} diff --git a/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.service.ts b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.service.ts new file mode 100644 index 0000000000..7a6d6405e4 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.service.ts @@ -0,0 +1,343 @@ +import {Injectable, EventEmitter} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {tap} from 'rxjs/operators/tap'; +import {map} from 'rxjs/operators/map'; +import {HttpClient} from '@angular/common/http'; +import {saveAs} from 'file-saver/FileSaver'; +import {IdlService, IdlObject} from '@eg/core/idl.service'; +import {OrgService} from '@eg/core/org.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {PermService} from '@eg/core/perm.service'; +import {EventService} from '@eg/core/event.service'; +import {ProgressDialogComponent} from '@eg/share/dialog/progress.component'; + +export const VANDELAY_EXPORT_PATH = '/exporter'; +export const VANDELAY_UPLOAD_PATH = '/vandelay-upload'; + +export class VandelayImportSelection { + recordIds: number[]; + queue: IdlObject; + importQueue: boolean; // import the whole queue + overlayMap: {[qrId: number]: /* breId */ number}; + + constructor() { + this.recordIds = []; + this.overlayMap = {}; + } +} + +@Injectable() +export class VandelayService { + + allQueues: {[qtype: string]: IdlObject[]}; + activeQueues: {[qtype: string]: IdlObject[]}; + attrDefs: {[atype: string]: IdlObject[]}; + bibSources: IdlObject[]; + bibBuckets: IdlObject[]; + copyStatuses: IdlObject[]; + matchSets: {[stype: string]: IdlObject[]}; + importItemAttrDefs: IdlObject[]; + bibTrashGroups: IdlObject[]; + mergeProfiles: IdlObject[]; + + // Used for tracking records between the queue page and + // the import page. Fields managed externally. + importSelection: VandelayImportSelection; + + // Track the last grid offset in the queue page so we + // can return the user to the same page of data after + // going to the matches page. + queuePageOffset: number; + + constructor( + private http: HttpClient, + private idl: IdlService, + private org: OrgService, + private evt: EventService, + private net: NetService, + private auth: AuthService, + private pcrud: PcrudService, + private perm: PermService + ) { + this.attrDefs = {}; + this.activeQueues = {}; + this.allQueues = {}; + this.matchSets = {}; + this.importSelection = null; + this.queuePageOffset = 0; + } + + getAttrDefs(dtype: string): Promise { + if (this.attrDefs[dtype]) { + return Promise.resolve(this.attrDefs[dtype]); + } + const cls = (dtype === 'bib') ? 'vqbrad' : 'vqarad'; + const orderBy = {}; + orderBy[cls] = 'id' + return this.pcrud.retrieveAll(cls, + {order_by: orderBy}, {atomic: true}).toPromise() + .then(list => { + this.attrDefs[dtype] = list; + return list; + }); + } + + getMergeProfiles(): Promise { + if (this.mergeProfiles) { + return Promise.resolve(this.mergeProfiles); + } + + const owners = this.org.ancestors(this.auth.user().ws_ou(), true); + return this.pcrud.search('vmp', + {owner: owners}, {order_by: {vmp: ['name']}}, {atomic: true}) + .toPromise().then(profiles => { + this.mergeProfiles = profiles; + return profiles; + }); + } + + // Returns a promise resolved with the list of queues. + // Also emits the onQueueListUpdate event so listeners + // can detect queue content changes. + getAllQueues(qtype: string): Promise { + if (this.allQueues[qtype]) { + return Promise.resolve(this.allQueues[qtype]); + } else { + this.allQueues[qtype] = []; + } + + // could be a big list, invoke in streaming mode + return this.net.request( + 'open-ils.vandelay', + `open-ils.vandelay.${qtype}_queue.owner.retrieve`, + this.auth.token() + ).pipe(tap( + queue => this.allQueues[qtype].push(queue) + )).toPromise().then(() => this.allQueues[qtype]); + } + + + // Returns a promise resolved with the list of queues. + // Also emits the onQueueListUpdate event so listeners + // can detect queue content changes. + getActiveQueues(qtype: string): Promise { + if (this.activeQueues[qtype]) { + return Promise.resolve(this.activeQueues[qtype]); + } else { + this.activeQueues[qtype] = []; + } + + // could be a big list, invoke in streaming mode + return this.net.request( + 'open-ils.vandelay', + `open-ils.vandelay.${qtype}_queue.owner.retrieve`, + this.auth.token(), null, {complete: 'f'} + ).pipe(tap( + queue => this.activeQueues[qtype].push(queue) + )).toPromise().then(() => this.activeQueues[qtype]); + } + + getBibSources(): Promise { + if (this.bibSources) { + return Promise.resolve(this.bibSources); + } + + return this.pcrud.retrieveAll('cbs', + {order_by: {cbs: 'id'}}, + {atomic: true} + ).toPromise().then(sources => { + this.bibSources = sources; + return sources; + }); + } + + getItemImportDefs(): Promise { + if (this.importItemAttrDefs) { + return Promise.resolve(this.importItemAttrDefs); + } + + const owners = this.org.ancestors(this.auth.user().ws_ou(), true); + return this.pcrud.search('viiad', {owner: owners}, {}, {atomic: true}) + .toPromise().then(defs => { + this.importItemAttrDefs = defs; + return defs; + }); + } + + // todo: differentiate between biblio and authority a la queue api + getMatchSets(mtype: string): Promise { + + const mstype = mtype.match(/bib/) ? 'biblio' : 'authority'; + + if (this.matchSets[mtype]) { + return Promise.resolve(this.matchSets[mtype]); + } else { + this.matchSets[mtype] = []; + } + + const owners = this.org.ancestors(this.auth.user().ws_ou(), true); + + return this.pcrud.search('vms', + {owner: owners, mtype: mstype}, {}, {atomic: true}) + .toPromise().then(sets => { + this.matchSets[mtype] = sets; + return sets; + }); + } + + getBibBuckets(): Promise { + if (this.bibBuckets) { + return Promise.resolve(this.bibBuckets); + } + + const bkts = []; + return this.net.request( + 'open-ils.actor', + 'open-ils.actor.container.retrieve_by_class', + this.auth.token(), this.auth.user().id(), 'biblio', 'staff_client' + //).pipe(tap(bkt => bkts.push(bkt))).toPromise().then(() => bkts); + ).toPromise().then(bkts => { + this.bibBuckets = bkts; + return bkts; + }); + } + + getCopyStatuses(): Promise { + if (this.copyStatuses) { + return Promise.resolve(this.copyStatuses); + } + return this.pcrud.retrieveAll('ccs', {}, {atomic: true}) + .toPromise().then(stats => { + this.copyStatuses = stats; + return stats; + }); + } + + getBibTrashGroups(): Promise { + if (this.bibTrashGroups) { + return Promise.resolve(this.bibTrashGroups); + } + + const owners = this.org.ancestors(this.auth.user().ws_ou(), true); + + return this.pcrud.search('vibtg', + {always_apply : 'f', owner: owners}, + {vibtg : ['label']}, + {atomic: true} + ).toPromise().then(groups => { + this.bibTrashGroups = groups; + return groups; + }); + } + + + // Create a queue and return the ID of the new queue via promise. + createQueue( + queueName: string, + recordType: string, + importDefId: number, + matchSet: number, + matchBucket: number): Promise { + + const method = `open-ils.vandelay.${recordType}_queue.create`; + + let qType = recordType; + if (recordType.match(/acq/)) { + let qType = 'acq'; + } + + return new Promise((resolve, reject) => { + this.net.request( + 'open-ils.vandelay', method, + this.auth.token(), queueName, null, qType, + matchSet, importDefId, matchBucket + ).subscribe(queue => { + const e = this.evt.parse(queue); + if (e) { + alert(e); + reject(e); + } else { + resolve(queue.id()); + } + }); + }); + } + + getQueuedRecords(queueId: number, queueType: string, + options?: any, limitToMatches?: boolean): Observable { + + const qtype = queueType.match(/bib/) ? 'bib' : 'auth'; + + let method = + `open-ils.vandelay.${qtype}_queue.records.retrieve`; + + if (limitToMatches) { + method = + `open-ils.vandelay.${qtype}_queue.records.matches.retrieve`; + } + + return this.net.request('open-ils.vandelay', + method, this.auth.token(), queueId, options); + } + + // Download a queue as a MARC file. + exportQueue(queue: IdlObject, nonImported?: boolean) { + + const etype = queue.queue_type().match(/auth/) ? 'auth' : 'bib'; + + let url = + `${VANDELAY_EXPORT_PATH}?type=${etype}&queueid=${queue.id()}` + + let saveName = queue.name(); + + if (nonImported) { + url += '&nonimported=1'; + saveName += '_nonimported'; + } + + saveName += '.mrc'; + + this.http.get(url, {responseType: 'text'}).subscribe( + data => { + saveAs( + new Blob([data], {type: 'application/octet-stream'}), + saveName + ); + }, + err => { + console.error(err); + } + ); + } + + // Poll every 2 seconds for session tracker updates so long + // as the session tracker is active. + // Returns an Observable of tracker objects. + pollSessionTracker(id: number): Observable { + return new Observable(observer => { + this.getNextSessionTracker(id, observer); + }); + } + + getNextSessionTracker(id: number, observer: any) { + + // No need for this to be an authoritative call. + // It will complete eventually regardless. + this.pcrud.retrieve('vst', id).subscribe( + tracker => { + if (tracker && tracker.state() === 'active') { + observer.next(tracker); + setTimeout(() => + this.getNextSessionTracker(id, observer), 2000); + } else { + console.debug( + `Vandelay session tracker ${id} is ${tracker.state()}`); + observer.complete(); + } + } + ); + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.html b/Open-ILS/src/eg2/src/app/staff/nav.component.html index b43d8e79da..92209218e6 100644 --- a/Open-ILS/src/eg2/src/app/staff/nav.component.html +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -184,7 +184,7 @@ cloud_download Import Record from Z39.50 - + import_export MARC Batch Import/Export diff --git a/Open-ILS/src/eg2/src/app/staff/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/routing.module.ts index b515f389af..6f20336660 100644 --- a/Open-ILS/src/eg2/src/app/staff/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/routing.module.ts @@ -31,6 +31,9 @@ const routes: Routes = [{ path: 'circ', loadChildren : '@eg/staff/circ/routing.module#CircRoutingModule' }, { + path: 'cat', + loadChildren : '@eg/staff/cat/routing.module#CatRoutingModule' + }, { path: 'catalog', loadChildren : '@eg/staff/catalog/catalog.module#CatalogModule' }, { diff --git a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html index f5e4c94652..4399111883 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html @@ -1,6 +1,7 @@