LP#1779158 Ang6 Vandelay UI Port
authorBill Erickson <berickxx@gmail.com>
Thu, 28 Jun 2018 22:23:39 +0000 (18:23 -0400)
committerDan Wells <dbw2@calvin.edu>
Tue, 19 Feb 2019 22:55:48 +0000 (17:55 -0500)
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 <berickxx@gmail.com>
Signed-off-by: Dan Wells <dbw2@calvin.edu>
47 files changed:
Open-ILS/src/eg2/src/app/share/grid/grid.ts
Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/display-attrs.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/export.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/holdings-profiles.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/import.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-expression.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-list.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-new-point.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set-quality.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/match-set.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/merge-profiles.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-items.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue-list.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queue.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record-matches.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/queued-record.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/recent-imports.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/record-items.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/vandelay/vandelay.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/eg2/src/app/staff/routing.module.ts
Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.html
Open-ILS/src/eg2/src/app/staff/share/buckets/record-bucket-dialog.component.ts
Open-ILS/src/perlmods/lib/OpenILS/Application/Vandelay.pm
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.data.vandelay-grid-settings.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/navbar.tt2

index 6701941..37bb188 100644 (file)
@@ -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 (file)
index 0000000..a923b46
--- /dev/null
@@ -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 (file)
index 0000000..78a86ed
--- /dev/null
@@ -0,0 +1,17 @@
+
+<ngb-tabset #tabs [activeId]="attrType" (tabChange)="onTabChange($event)">
+       <ngb-tab title="Bibliographic Attributes" i18n-title id="bib">
+               <ng-template ngbTabContent>
+      <div class="mt-3">
+        <eg-admin-page idlClass="vqbrad"></eg-admin-page>
+      </div>
+               </ng-template>
+       </ngb-tab>
+       <ngb-tab title="Authority Attributes" i18n-title id="authority">
+               <ng-template ngbTabContent>
+      <div class="mt-3">
+        <eg-admin-page idlClass="vqarad"></eg-admin-page>
+      </div>
+               </ng-template>
+       </ngb-tab>
+</ngb-tabset>
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 (file)
index 0000000..6cb13af
--- /dev/null
@@ -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 (file)
index 0000000..020e097
--- /dev/null
@@ -0,0 +1,119 @@
+<h2 i18n>Export Records</h2>
+
+<div class="common-form striped-even form-validated">
+  <div class="row">
+    <div class="col-lg-6">
+      <div class="row"><label>Select a Record Source</label></div>
+      <ngb-accordion [closeOthers]="true" activeIds="csv" 
+        (panelChange)="sourceChange($event)">
+        <ngb-panel id="csv" title="CSV File">
+          <ng-template ngbPanelContent>
+            <div class="row">
+              <div class="col-lg-6">
+                <label i18n>Use Field Number</label>
+              </div>
+              <div class="col-lg-6">
+                <input id='csv-input' type="number" class="form-control" 
+                  [(ngModel)]="fieldNumber"
+                  i18n-placeholder placeholder="Starts at 0..."/>
+              </div>
+            </div>
+            <div class="row">
+              <div class="col-lg-6">
+                <label i18n>From CSV file</label>
+              </div>
+              <div class="col-lg-6">
+                <input #fileSelector (change)="fileSelected($event)" 
+                  class="form-control" type="file"/>
+              </div>
+            </div>
+          </ng-template>
+        </ngb-panel>
+        <ngb-panel id="record-id" title="Record ID">
+          <ng-template ngbPanelContent>
+            <div class="row">
+              <div class="col-lg-6">
+                <label i18n>Record ID</label>
+              </div>
+              <div class="col-lg-6">
+                <input id='record-id-input' type="number" 
+                  class="form-control" [(ngModel)]="recordId"/>
+              </div>
+            </div>
+          </ng-template>
+        </ngb-panel>
+        <ngb-panel id="bucket-id" title="Bucket">
+          <ng-template ngbPanelContent>
+            <div class="row">
+              <div class="col-lg-6">
+                <label i18n>Bucket ID</label>
+              </div>
+              <div class="col-lg-6">
+                <input id='bucket-id-input' type="number" 
+                  class="form-control" [(ngModel)]="bucketId"/>
+              </div>
+            </div>
+          </ng-template>
+        </ngb-panel>
+      </ngb-accordion>
+    </div><!-- col -->
+    <div class="col-lg-6">
+      <div class="row">
+        <div class="col-lg-6">
+          <label i18n>Record Type</label>
+        </div>
+        <div class="col-lg-6">
+          <select class="form-control" [(ngModel)]="recordType">
+            <option i18n value="biblio">Bibliographic Records</option>
+            <option i18n value="authority">Authority Records</option>
+          </select>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-lg-6">
+          <label i18n>Record Format</label>
+        </div>
+        <div class="col-lg-6">
+          <select class="form-control" [(ngModel)]="recordFormat">
+            <option i18n value="USMARC">MARC21</option>
+            <option i18n value="UNIMARC">UNIMARC</option>
+            <option i18n value="XML">MARC XML</option>
+            <option i18n value="BRE">Evergreen Record Entry</option>
+          </select>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-lg-6">
+          <label i18n>Record Encoding</label>
+        </div>
+        <div class="col-lg-6">
+          <select class="form-control" [(ngModel)]="recordEncoding">
+            <option i18n value="UTF-8">UTF-8</option>
+            <option i18n value="MARC8">MARC8</option>
+          </select>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-lg-6">
+          <label i18n>Include holdings in Bibliographic Records</label>
+        </div>
+        <div class="col-lg-6">
+          <input class="form-check-input" type="checkbox" [(ngModel)]="includeHoldings">
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-lg-10 offset-lg-1">
+          <button class="btn btn-success btn-lg btn-block font-weight-bold"
+            [disabled]="isExporting || !hasNeededData()" 
+            (click)="exportRecords()" i18n>Export</button>
+        </div>
+      </div>
+      <div class="row" [hidden]="!isExporting">
+        <div class="col-lg-10 offset-lg-1">
+          <eg-progress-inline #exportProgress></eg-progress-inline>
+        </div>
+      </div>
+    </div><!-- left col -->
+  </div><!-- row -->
+</div>
+
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 (file)
index 0000000..253cfcb
--- /dev/null
@@ -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 (file)
index 0000000..3a342dd
--- /dev/null
@@ -0,0 +1,9 @@
+import {Component} from '@angular/core';
+
+@Component({
+    template: `<eg-admin-page idlClass="viiad"></eg-admin-page>`
+})
+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 (file)
index 0000000..c852332
--- /dev/null
@@ -0,0 +1,234 @@
+<div class="row mb-3" *ngIf="importSelection()">
+  <div class="col-lg-2" *ngIf="selectedQueue">
+    <button class="btn btn-info label-with-material-icon"
+      routerLink="/staff/cat/vandelay/queue/{{recordType}}/{{selectedQueue.id}}">
+      <span class="material-icons">arrow_back</span>
+      <span i18n>Return to Queue</span>
+    </button>
+  </div>
+</div>
+
+<h2 i18n>MARC File Upload</h2>
+<div class="common-form striped-odd form-validated ml-3 mr-3">
+  <div class="row">
+    <div class="col-lg-3">
+      <label i18n>Record Type</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox (onChange)="selectEntry($event, 'recordType')"
+        [disabled]="importSelection()"
+        [required]="true"
+        [startId]="recordType" placeholder="Record Type..." i18n-placeholder>
+        <eg-combobox-entry entryId="bib" entryLabel="Bibliographic Records" 
+          i18n-entryLabel></eg-combobox-entry>
+        <eg-combobox-entry entryId="authority" entryLabel="Authority Records" 
+          i18n-entryLabel></eg-combobox-entry>
+        <eg-combobox-entry entryId="bib-acq" entryLabel="Acquisitions Records" 
+          i18n-entryLabel></eg-combobox-entry>
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3">
+      <label i18n>Select a Record Source</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox [entries]="formatEntries('bibSources')" 
+        (onChange)="selectEntry($event, 'bibSources')"
+        [startId]="selectedBibSource"
+        placeholder="Record Source..." i18n-placeholder>
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label i18n>Select or Create a Qeueue</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox [entries]="formatEntries('allQueues')"
+        [startId]="startQueueId"
+        [startIdFiresOnChange]="true"
+        [disabled]="startQueueId"
+        (onChange)="selectedQueue=$event" i18n-placeholder
+        [required]="true"
+        [allowFreeText]="true" placeholder="Select or Create a Queue...">
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3">
+      <label i18n>Limit Matches to Bucket</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox [entries]="formatEntries('bibBuckets')" 
+        [startId]="selectedBucket"
+        [disabled]="(selectedQueue && !selectedQueue.freetext) || importSelection()"
+        (onChange)="selectEntry($event, 'bibBuckets')"
+        placeholder="Buckets..." i18n-placeholder></eg-combobox>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label i18n>Record Match Set</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox [entries]="formatEntries('matchSets')" 
+        [disabled]="(selectedQueue && !selectedQueue.freetext) || importSelection()"
+        [startId]="selectedMatchSet || defaultMatchSet"
+        (onChange)="selectEntry($event, 'matchSets')"
+        placeholder="Match Set..." i18n-placeholder></eg-combobox>
+    </div>
+    <div class="col-lg-3"><label i18n>Import Non-Matching Records</label></div>
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        [(ngModel)]="importNonMatching">
+    </div>
+ </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label i18n>Holdings Import Profile</label>
+    </div>
+    <div class="col-lg-3"> <!-- TODO disable for authority -->
+      <eg-combobox [entries]="formatEntries('importItemDefs')"
+        [startId]="selectedHoldingsProfile"
+        [disabled]="(selectedQueue && !selectedQueue.freetext) || importSelection()"
+        (onChange)="selectEntry($event, 'importItemDefs')"
+        placeholder="Holdings Import Profile..." i18n-placeholder>
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3"><label i18n>Merge On Exact Match (901c)</label></div>
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        [(ngModel)]="mergeOnExact">
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label i18n>Merge Profile</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox [entries]="formatEntries('mergeProfiles')"
+        (onChange)="selectEntry($event, 'mergeProfiles')"
+        placeholder="Merge Profile..." i18n-placeholder>
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3"><label i18n>Merge On Single Match</label></div>
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        [(ngModel)]="mergeOnSingleMatch">
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label i18n>Insufficient Quality Fall-Through Profile</label></div>
+    <div class="col-lg-3">
+      <eg-combobox [entries]="formatEntries('mergeProfiles')"
+        (onChange)="selectEntry($event, 'FallThruMergeProfile')"
+        placeholder="Fall-Through Merge Profile..." i18n-placeholder>
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3"><label i18n>Merge On Best Match</label></div>
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        [(ngModel)]="mergeOnBestMatch">
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label i18n>Best/Single Match Minimum Quality Ratio</label></div>
+    <div class="col-lg-3">
+      <input type="number" step="0.1" 
+        class="form-control" [(ngModel)]="minQualityRatio">
+    </div>
+    <div class="col-lg-3">
+      <label i18n>Auto-overlay In-process Acquisitions Copies</label></div>
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        [(ngModel)]="autoOverlayAcqCopies">
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label i18n>Optional Session Name:</label>
+    </div>
+    <div class="col-lg-3">
+      <input [(ngModel)]="sessionName" class="form-control" type="text"
+        i18n-placeholder placeholder="Session Name..."/>
+    </div>
+    <div class="col-lg-3">
+      <label i18n>Remove MARC Field Groups</label>
+    </div>
+    <div class="col-lg-3" *ngIf="bibTrashGroups.length == 0">
+        <span i18n class="font-italic">No Groups Configured</span>
+    </div>
+    <div class="col-lg-3" *ngIf="bibTrashGroups.length">
+      <select multiple [(ngModel)]="selectedTrashGroups" 
+        class="form-control" size="3">
+        <option *ngFor="let grp of bibTrashGroups" 
+          value="{{grp.id()}}">{{grp.label()}}</option>
+      </select>
+    </div>
+  </div>
+  <div class="row" *ngIf="!importSelection()">
+    <div class="col-lg-3">
+      <label i18n>File to Upload:</label>
+    </div>
+    <div class="col-lg-3">
+      <input #fileSelector (change)="fileSelected($event)" 
+        required class="form-control" type="file"/>
+    </div>
+  </div>
+  <div class="row" *ngIf="importSelection()">
+    <div class="col-lg-3">
+      <label>Import Selected</label>
+    </div>
+    <div class="col-lg-3">
+      <span *ngIf="!importSelection().importQueue" i18n>
+        Importing {{importSelection().recordIds.length}} Record(s)</span>
+      <span *ngIf="importSelection().importQueue" i18n>
+        Importing Queue {{importSelection().queue.name()}}</span>
+    </div>
+    <div class="col-lg-3">
+      <button class="btn btn-outline-info ml-2" (click)="clearSelection()" i18n>
+        Clear Selection
+      </button>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-6 offset-lg-3">
+      <button class="btn btn-success btn-lg btn-block font-weight-bold"
+        [disabled]="isUploading || !hasNeededData()" 
+        (click)="upload()" i18n>Upload</button>
+    </div>
+  </div>
+  <!-- hide instead of *ngIf so ViewChild can find the progress bars -->
+  <div class="row" [hidden]="!showProgress || importSelection()">
+    <div class="col-lg-3">
+      <label i18n>Upload Progress</label>
+    </div>
+    <div class="col-lg-6">
+      <eg-progress-inline #uploadProgress></eg-progress-inline>
+    </div>
+  </div>
+  <div class="row" [hidden]="!showProgress || importSelection()">
+    <div class="col-lg-3">
+      <label i18n>Enqueue Progress</label>
+    </div>
+    <div class="col-lg-6">
+      <eg-progress-inline #enqueueProgress></eg-progress-inline>
+    </div>
+  </div>
+  <div class="row" [hidden]="!showProgress">
+    <div class="col-lg-3">
+      <label i18n>Import Progress</label>
+    </div>
+    <div class="col-lg-6">
+      <eg-progress-inline #importProgress></eg-progress-inline>
+    </div>
+  </div>
+  <div class="row" [hidden]="!uploadComplete">
+    <div class="col-lg-6 offset-lg-3">
+      <button class="btn btn-info btn-lg btn-block font-weight-bold"
+        routerLink="/staff/cat/vandelay/queue/{{recordType}}/{{activeQueueId}}"
+        i18n>Go To Queue</button>
+    </div>
+  </div>
+</div>
+
+
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 (file)
index 0000000..3b36f6a
--- /dev/null
@@ -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<any> {
+        // 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<number> {
+
+        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<any> {
+
+        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<any> {
+
+        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<any> {
+
+        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<any> {
+        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 (file)
index 0000000..fe7d817
--- /dev/null
@@ -0,0 +1,69 @@
+<ng-template #nodeStrTmpl let-point="point" let-showmatch="showmatch" i18n>
+  <ng-container *ngIf="point">
+    <span *ngIf="point.negate()">NOT </span>
+    <span *ngIf="point.heading()">Normalized Heading</span>
+    <span>{{point.bool_op()}}{{point.svf()}}{{point.tag()}}</span>
+    <span *ngIf="point.subfield()"> ‡{{point.subfield()}}</span>
+    <span *ngIf="showmatch && !point.bool_op()"> | Match score {{point.quality()}}</span>
+  </ng-container>
+</ng-template>
+<eg-string key="staff.cat.vandelay.matchpoint.label" 
+  [template]="nodeStrTmpl"></eg-string>
+
+<div class="row mt-2">
+  <div class="col-lg-7">
+    <div class="row ml-2">
+      <span class="text-white bg-dark p-2" i18n>
+        Your Expression: {{expressionAsString()}}
+      </span>
+    </div>
+    <div class="row ml-2 mt-4">
+      <span class="mr-2" i18n>Add New:</span>
+      <button class="btn btn-outline-dark mr-2" *ngIf="matchSetType=='biblio'"
+        (click)="newPointType='attr'" i18n>Record Attribute</button>
+      <button class="btn btn-outline-dark mr-2" 
+        (click)="newPointType='marc'" i18n>MARC Tag and Subfield</button>
+      <button class="btn btn-outline-dark mr-2" *ngIf="matchSetType=='authority'"
+        (click)="newPointType='heading'" i18n>Normalized Authority Heading</button>
+      <button class="btn btn-outline-dark mr-2" 
+        (click)="newPointType='bool'" i18n>Boolean Operator</button>
+    </div>
+    <eg-match-set-new-point #newPoint [pointType]="newPointType">
+    </eg-match-set-new-point>
+    <div class="row mt-2 ml-2" *ngIf="newPointType">
+      <button class="btn btn-success" (click)="addChildNode()" 
+        [disabled]="!selectedIsBool()" i18n>
+        Add To Selected Node
+      </button>
+    </div>
+    <div class="row mt-2 ml-2 font-italic" *ngIf="newPointType">
+      <ol i18n>
+        <li>Define a new match point using the above fields.</li>
+        <li>Select a boolean node in the tree.</li>
+        <li>Click the "Add..." button to add the new matchpoint
+          as a child of the selected node.</li>
+      </ol>
+    </div>
+  </div>
+  <div class="col-lg-5">
+    <ng-container *ngIf="tree">
+      <div class="d-flex">
+        <button class="btn btn-warning mr-1" (click)="deleteNode()" 
+          [disabled]="!hasSelectedNode()" i18n>
+          Remove Selected Node
+        </button>
+        <button class="btn btn-success mr-1" (click)="saveTree()"
+          [disabled]="!changesMade" i18n>
+          Save Changes 
+        </button>
+      </div>
+      <div class="pt-2">
+        <eg-tree
+          [tree]="tree" 
+          (nodeClicked)="nodeClicked($event)">
+        </eg-tree>
+      </div>
+    </ng-container>
+  </div>
+</div>
+
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 (file)
index 0000000..9912068
--- /dev/null
@@ -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<any> {
+        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<any> {
+        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<string> {
+        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<any> {
+
+
+        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 (file)
index 0000000..7674be2
--- /dev/null
@@ -0,0 +1,37 @@
+
+<div class="d-flex mb-3">
+  <div>
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <span class="input-group-text">Owner</span>
+      </div>
+      <eg-org-select
+        [initialOrg]="contextOrg"
+        (onChange)="orgOnChange($event)">
+      </eg-org-select>
+    </div>
+  </div>
+</div>
+
+<ng-template #nameTmpl let-row="row">
+  <a routerLink="/staff/cat/vandelay/match_sets/{{row.id()}}/editor">
+    {{row.name()}}
+  </a> 
+</ng-template>
+
+<eg-grid #grid [dataSource]="gridSource"
+  persistKey="cat.vandelay.match_set.list"
+  idlClass="vms" [dataSource]="queueSource">
+  <eg-grid-toolbar-button label="New Match Set" i18n-label [action]="createNew">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label 
+    [action]="deleteSelected"></eg-grid-toolbar-action>
+  <eg-grid-column name="name" [cellTemplate]="nameTmpl">
+  </eg-grid-column>
+</eg-grid>
+
+<eg-fm-record-editor #editDialog idlClass="vms">
+</eg-fm-record-editor>
+
+
+
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 (file)
index 0000000..e20c954
--- /dev/null
@@ -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 (file)
index 0000000..4ffa40c
--- /dev/null
@@ -0,0 +1,77 @@
+<div class="row ml-2 mt-4 p-2 border border-secondary" *ngIf="values.pointType">
+  <div class="col-lg-12 common-form striped-odd form-validated">
+    <ng-container *ngIf="values.pointType=='attr'">
+      <div class="row mb-1">
+        <div class="col-lg-3" i18n>Record Attribute:</div>
+        <div class="col-lg-4">
+          <eg-combobox [entries]="bibAttrDefEntries"
+            [required]="true" 
+            (onChange)="values.recordAttr=$event ? $event.id : ''"
+            placeholder="Record Attribute..." i18n-placeholder>                       
+          </eg-combobox>  
+        </div>
+      </div>
+    </ng-container>
+    <ng-container *ngIf="values.pointType=='marc'">
+      <div class="row mb-1">
+        <div class="col-lg-3" i18n>Tag:</div>
+        <div class="col-lg-2">
+          <input required type="text" class="form-control" [(ngModel)]="values.marcTag"/>
+        </div>
+      </div>
+      <div class="row mb-1">
+        <div class="col-lg-3" i18n>Subfield ‡:</div>
+        <div class="col-lg-2">
+          <input required type="text" class="form-control" [(ngModel)]="values.marcSf"/>
+        </div>
+      </div>
+    </ng-container>
+    <ng-container *ngIf="values.pointType=='heading'">
+      <div class="row mb-1">
+        <div class="col-lg-3" i18n>Normalized Heading:</div>
+        <div class="col-lg-2">
+          <input type="checkbox" class="form-check-input" checked disabled/>
+        </div>
+      </div>
+    </ng-container>
+    <ng-container *ngIf="values.pointType!='bool'">
+      <div class="row mb-1">
+        <div class="col-lg-3">Match Score:</div>
+        <div class="col-lg-2">
+          <input required type="number" class="form-control" 
+            [(ngModel)]="values.matchScore" step="0.1"/>
+        </div>
+      </div>
+      <ng-container *ngIf="!isForQuality">
+        <div class="row mb-1">
+          <div class="col-lg-3">Negate:</div>
+          <div class="col-lg-2">
+            <input type="checkbox" 
+              class="form-check-input" [(ngModel)]="values.negate"/>
+          </div>
+        </div>
+      </ng-container>
+    </ng-container>
+    <ng-container *ngIf="values.pointType=='bool'">
+      <div class="row mb-1">
+        <div class="col-lg-3">Operator:</div>
+        <div class="col-lg-2">
+          <select class="form-control" [(ngModel)]="values.boolOp">
+            <option value='AND' i18n>AND</option>
+            <option value='OR' i18n>OR</option>
+          </select>
+        </div>
+      </div>
+    </ng-container>
+    <ng-container *ngIf="isForQuality">
+      <div class="row mb-1">
+        <div class="col-lg-3" i18n>Value:</div>
+        <div class="col-lg-2">
+          <input type="text" class="form-control" required 
+            [(ngModel)]="values.value"/>
+        </div>
+      </div>
+    </ng-container>
+  </div>
+</div>
+
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 (file)
index 0000000..6298981
--- /dev/null
@@ -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 (file)
index 0000000..5229ddf
--- /dev/null
@@ -0,0 +1,27 @@
+<div class="row mt-2">
+  <div class="col-lg-7">
+    <div class="row ml-2 mt-4">
+      <span class="mr-2" i18n>Add New:</span>
+      <button class="btn btn-outline-dark mr-2" *ngIf="matchSetType=='biblio'"
+        (click)="newPointType='attr'" i18n>Record Attribute</button>
+      <button class="btn btn-outline-dark mr-2" 
+        (click)="newPointType='marc'" i18n>MARC Tag and Subfield</button>
+    </div>
+    <eg-match-set-new-point #newPoint 
+      [pointType]="newPointType" [isForQuality]="true">
+    </eg-match-set-new-point>
+    <div class="row mt-2 ml-2" *ngIf="newPointType">
+      <button class="btn btn-success mr-2" 
+        (click)="addQuality()" i18n>Add</button>
+      <button class="btn btn-warning"
+        (click)="newPointType=null" i18n>Cancel</button>
+    </div>
+  </div>
+</div>
+
+<eg-grid idlClass="vmsq" [dataSource]="dataSource" #grid
+  persistKey="staff.cat.vandelay.match_set.quality">
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label 
+    [action]="deleteSelected"></eg-grid-toolbar-action>
+</eg-grid>
+
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 (file)
index 0000000..b2409c1
--- /dev/null
@@ -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 (file)
index 0000000..fd69cf9
--- /dev/null
@@ -0,0 +1,36 @@
+<div class="row pb-2" *ngIf="matchSet">
+  <div class="col-lg-4">
+    <div class="card tight-card">
+      <h5 class="card-header" i18n>Match Set Summary</h5>
+      <div class="card-body">
+        <div class="row">
+          <div class="col-lg-6" i18n>Match Set Name:</div>
+          <div class="col-lg-6">{{matchSet.name()}}</div>
+        </div>
+        <div class="row">
+          <div class="col-lg-6" i18n>Owning Library:</div>
+          <div class="col-lg-6">{{matchSet.owner().shortname()}}</div>
+        </div>
+        <div class="row">
+          <div class="col-lg-6" i18n>Type:</div>
+          <div class="col-lg-6">{{matchSet.mtype()}}</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<ngb-tabset [activeId]="matchSetTab" (tabChange)="onTabChange($event)">
+       <ngb-tab title="Match Set Editor" i18n-title id="editor">
+               <ng-template ngbTabContent>
+      <eg-match-set-expression [matchSet]="matchSet">
+      </eg-match-set-expression>
+               </ng-template>
+       </ngb-tab>
+       <ngb-tab title="Match Set Quality Metrics" i18n-title id="quality">
+               <ng-template ngbTabContent>
+      <eg-match-set-quality [matchSet]="matchSet">
+      </eg-match-set-quality>
+               </ng-template>
+       </ngb-tab>
+</ngb-tabset>
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 (file)
index 0000000..15a19aa
--- /dev/null
@@ -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 (file)
index 0000000..2059b61
--- /dev/null
@@ -0,0 +1,9 @@
+import {Component} from '@angular/core';
+
+@Component({
+    template: `<eg-admin-page idlClass="vmp"></eg-admin-page>`
+})
+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 (file)
index 0000000..8bc896f
--- /dev/null
@@ -0,0 +1,19 @@
+<div class="row mb-3">
+  <div class="col-lg-2">
+    <button class="btn btn-info label-with-material-icon"
+      routerLink="/staff/cat/vandelay/queue/{{queueType}}/{{queueId}}">
+      <span class="material-icons">arrow_back</span>
+      <span i18n>Return to Queue</span>
+    </button>
+  </div>
+</div>
+
+<eg-grid #itemsGrid 
+  showFields="record,import_error,imported_as,import_time,owning_lib,call_number,barcode"
+  persistKey="cat.vandelay.queue.items"
+  idlClass="vii" [dataSource]="gridSource">
+  <eg-grid-toolbar-checkbox [onChange]="limitToImportErrors"
+    i18n-label label="Limit to Import Failures"></eg-grid-toolbar-checkbox>
+
+</eg-grid>
+
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 (file)
index 0000000..d72a81b
--- /dev/null
@@ -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 (file)
index 0000000..6aface5
--- /dev/null
@@ -0,0 +1,36 @@
+<div class="import-form">
+  <h2 i18n>Select a Queue To Inspect</h2>
+  <div class="row flex">
+    <div>
+      <label i18n>Queue Type</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox (onChange)="queueTypeChanged($event)"
+        [startId]="queueType"
+        placeholder="Queue Type..." i18n-placeholder>
+        <eg-combobox-entry entryId="bib" entryLabel="Bibliographic Records" 
+          i18n-entryLabel></eg-combobox-entry>
+        <eg-combobox-entry entryId="auth" entryLabel="Authority Records" 
+          i18n-entryLabel></eg-combobox-entry>
+        <eg-combobox-entry entryId="bib-acq" entryLabel="Acquisitions Records" 
+          i18n-entryLabel></eg-combobox-entry>
+      </eg-combobox>
+    </div>
+  </div>
+</div>
+
+<eg-grid *ngIf="queueType=='bib'" #bibQueueGrid 
+  persistKey="cat.vandelay.queue.list.bib"
+  (onRowActivate)="rowActivated($event)"
+  idlClass="vbq" [dataSource]="queueSource">
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label 
+    [action]="deleteSelected"></eg-grid-toolbar-action>
+</eg-grid>
+
+<eg-grid *ngIf="queueType=='auth'" #authQueueGrid 
+  persistKey="cat.vandelay.queue.list.auth"
+  (onRowActivate)="rowActivated($event)"
+  idlClass="vaq" [dataSource]="queueSource">
+</eg-grid>
+
+
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 (file)
index 0000000..888c8a5
--- /dev/null
@@ -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<any> {
+
+        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 (file)
index 0000000..bd99107
--- /dev/null
@@ -0,0 +1,152 @@
+
+<eg-progress-dialog #progressDlg
+  dialogTitle="Deleting Queue..." i18n-dialogTitle></eg-progress-dialog>
+
+<ng-container *ngIf="queueSummary && queueSummary.queue">
+
+  <eg-confirm-dialog 
+    #confirmDelDlg
+    i18n-dialogTitle i18n-dialogBody
+    dialogTitle="Confirm Delete"
+    dialogBody="Delete Queue {{queueSummary.queue.name()}}?">
+  </eg-confirm-dialog>
+
+  <h2 i18n>Queue {{queueSummary.queue.name()}}</h2>
+  <div class="row pb-2">
+    <div class="col-lg-6">
+      <div class="card tight-card">
+        <h5 class="card-header" i18n>Queue Summary</h5>
+        <ul class="list-group list-group-flush">
+          <li class="list-group-item">
+            <div class="d-flex">
+              <div class="flex-3" i18n>Records in Queue:</div>
+              <div class="flex-1">{{queueSummary.total}}</div>
+              <div class="flex-3" i18n>Items in Queue:</div>
+              <div class="flex-1">{{queueSummary.total_items}}</div>
+            </div>
+          </li>
+          <li class="list-group-item">
+            <div class="d-flex">
+              <div class="flex-3" i18n>Records Imported:</div>
+              <div class="flex-1">{{queueSummary.imported}}</div>
+              <div class="flex-3" i18n>Items Imported:</div>
+              <div class="flex-1">{{queueSummary.total_items_imported}}</div>
+            </div>
+          </li>
+          <li class="list-group-item">
+            <div class="d-flex">
+              <div class="flex-3" i18n>Records Import Failures:</div>
+              <div class="flex-1">{{queueSummary.rec_import_errors}}</div>
+              <div class="flex-3" i18n>Item Import Failures:</div>
+              <div class="flex-1">{{queueSummary.item_import_errors}}</div>
+            </div>
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="col-lg-6">
+      <div class="card tight-card">
+        <h5 class="card-header" i18n>Queue Actions</h5>
+        <ul class="list-group list-group-flush">
+          <li class="list-group-item">
+            <div class="d-flex">
+              <div class="flex-1">
+                <a [routerLink]="" (click)="importSelected()" 
+                  i18n>Import Selected Records</a>
+              </div>
+              <div class="flex-1">
+                <a [routerLink]="" (click)="importAll()" i18n>Import All Records</a>
+              </div>
+            </div>
+          </li>
+          <li class="list-group-item">
+            <div class="d-flex">
+              <div class="flex-1">
+                <a i18n
+                  routerLink="/staff/cat/vandelay/queue/{{queueType}}/{{queueId}}/items">
+                    View Import Items
+                </a>
+              </div>
+              <div class="flex-1">
+                <a [routerLink]="" (click)="exportNonImported()" 
+                  i18n>Export Non-Imported Records</a>
+              </div>
+            </div>
+          </li>
+          <li class="list-group-item">
+            <div class="d-flex">
+              <eg-record-bucket-dialog #bucketDialog [queueId]="queueId">
+              </eg-record-bucket-dialog>
+              <div class="flex-1">
+                <a [routerLink]="" (click)="bucketDialog.open({size:'lg'})" i18n>
+                  Copy Queue To Bucket
+                </a>
+              </div>
+              <div class="flex-1">
+                <a [routerLink]="" (click)="deleteQueue()" i18n>Delete Queue</a>
+              </div>
+            </div>
+          </li>
+        </ul>
+      </div>
+    </div>
+  </div>
+</ng-container>
+
+<ng-template #matchesTmpl let-row="row">
+  <a i18n [ngClass]="{'font-weight-bold': hasOverlayTarget(row.id)}"
+    routerLink="/staff/cat/vandelay/queue/{{queueType}}/{{queueId}}/record/{{row.id}}/matches">
+      ({{row.matches.length}})
+      {{hasOverlayTarget(row.id) ? '*' : ''}}
+  </a>
+</ng-template>
+
+<ng-template #errorsTmpl let-row="row">
+  <div *ngIf="row.error_detail">
+    <b class="text-danger" title="{{row.error_detail}}">{{row.import_error}}</b>
+  </div>
+  <div *ngIf="row.error_items.length">
+    <b class="text-danger">Items ({{row.error_items.length}})</b>
+  </div>
+</ng-template>
+
+<ng-template #importedAsTmpl let-row="row">
+  <a routerLink="/staff/catalog/record/{{row.imported_as}}">
+    {{row.imported_as}}
+  </a>
+</ng-template>
+
+
+<!-- 
+Most columns are generated programmatically from queued record attribute
+definitions.  Hide a number of stock record attributes by default
+because there are a lot of them.
+-->
+
+<eg-grid #queueGrid [dataSource]="queueSource"
+  persistKey="cat.vandelay.queue.{{queueType}}"
+  (onRowActivate)="openRecord($event)"
+  [pageOffset]="queuePageOffset()"
+  hideFields="language,pagination,price,rec_identifier,eg_tcn_source,eg_identifier,item_barcode,zsource">
+
+  <eg-grid-toolbar-checkbox i18n-label label="Records With Matches"
+    [onChange]="limitToMatches"></eg-grid-toolbar-checkbox>
+
+  <eg-grid-toolbar-checkbox i18n-label label="Non-Imported Records"
+    [onChange]="limitToNonImported"></eg-grid-toolbar-checkbox>
+
+  <eg-grid-toolbar-checkbox i18n-label label="Records with Import Errors"
+    [onChange]="limitToImportErrors"></eg-grid-toolbar-checkbox>
+
+  <eg-grid-column name="id" [index]="true" 
+    [hidden]="true"></eg-grid-column>
+  <eg-grid-column i18n-label label="Matches" 
+    name="+matches" [cellTemplate]="matchesTmpl"></eg-grid-column>
+  <eg-grid-column name="import_error" i18n-label 
+    label="Import Errors" [cellTemplate]="errorsTmpl"></eg-grid-column>
+  <eg-grid-column name="import_time" i18n-label 
+    label="Import Date" datatype="timestamp"></eg-grid-column>
+  <eg-grid-column name="imported_as" i18n-label 
+    label="Imported As" [cellTemplate]="importedAsTmpl"></eg-grid-column>
+</eg-grid>
+
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 (file)
index 0000000..a6f67c3
--- /dev/null
@@ -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<any> {
+        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<any> {
+
+        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 (file)
index 0000000..db72a9a
--- /dev/null
@@ -0,0 +1,96 @@
+
+<ng-template #bibIdTemplate let-row="row">
+  <a routerLink="/staff/catalog/record/{{row.eg_record}}/marc_view" i18n>
+    {{row.eg_record}}
+  </a>
+</ng-template>
+
+<ng-template #targetTemplate let-row="row">
+  <ng-container *ngIf="isOverlayTarget(row.id)">
+    <span i18n-title title="Selected Merge Target" 
+      class="material-icons">check_circle</span>
+  </ng-container>
+</ng-template>
+
+<ng-container *ngIf="queueType == 'bib'">
+  <eg-grid #bibGrid [dataSource]="bibDataSource" 
+    (onRowClick)="matchRowClick($event)"
+    [disableMultiSelect]="true">
+    <!--
+    <eg-grid-toolbar-action i18n-label label="Mark As Overlay Target"
+      [action]="markOverlayTarget">
+    </eg-grid-toolbar-action>
+    -->
+    <eg-grid-column name="id" [index]="true" [hidden]="true" 
+      i18n-label label="Match ID">
+    </eg-grid-column>
+    <eg-grid-column name="selected" i18n-label label="Merge Target"
+      [cellTemplate]="targetTemplate">
+    </eg-grid-column>
+    <eg-grid-column name="eg_record" i18n-label label="Record ID"
+      [cellTemplate]="bibIdTemplate">
+    </eg-grid-column>
+    <eg-grid-column name="match_score" i18n-label label="Match Score">
+    </eg-grid-column>
+    <eg-grid-column name="bre_quality" i18n-label label="Matched Record Quality">
+    </eg-grid-column>
+    <eg-grid-column name="vqbr_quality" i18n-label label="Queued Record Quality">
+    </eg-grid-column>
+    <eg-grid-column path="bib_summary.display.title" i18n-label label="Title">
+    </eg-grid-column>
+    <eg-grid-column path="bib_summary.record.creator.usrname" 
+      i18n-label label="Creator">
+    </eg-grid-column>
+    <eg-grid-column path="bib_summary.record.create_date" datatype="timestamp"
+      i18n-label label="Create Date">
+    </eg-grid-column>
+    <eg-grid-column path="bib_summary.record.editor.usrname" 
+      i18n-label label="Editor">
+    </eg-grid-column>
+    <eg-grid-column path="bib_summary.record.edit_date" datatype="timestamp"
+      i18n-label label="Edit Date">
+    </eg-grid-column>
+  </eg-grid>
+</ng-container>
+<ng-container *ngIf="queueType == 'authority'">
+  <eg-grid #authGrid [dataSource]="authDataSource">
+    <eg-grid-column name="id" [index]="true" [hidden]="true" 
+      i18n-label label="Match ID">
+    </eg-grid-column>
+  </eg-grid>
+</ng-container>
+
+
+<!--
+{
+name: '[% l('Merge Target') %]',
+get: vlGetOverlayTargetSelector,
+formatter : vlFormatOverlayTargetSelector,
+},
+{name: '[% l('ID') %]', field:'id'},
+{   name: '[% l('View MARC') %]',
+get: vlGetViewMARC,
+formatter : vlFormatViewMatchMARC
+},
+{name: '[% l('Match Score') %]', field:'match_score'},
+{name: '[% l('Queued Record Quality') %]', field:'rec_quality'},
+{name: '[% l('Matched Record Quality') %]', field:'match_quality'},
+{name: '[% l('Creator') %]', get: vlGetCreator},
+{name: '[% l('Create Date') %]', field:'create_date', get: vlGetDateTimeField},
+{name: '[% l('Last Edit Date') %]', field:'edit_date', get: vlGetDateTimeField},
+{name: '[% l('Source') %]', field:'source'},
+]]
+}];
+
+if (recordType == 'auth') {
+vlMatchGridLayout[0].cells[0].push(
+{name: '[% l("Heading") %]', field:'heading'}
+);
+} else {
+vlMatchGridLayout[0].cells[0].push(
+{name: '[% l('TCN Source') %]', field:'tcn_source'},
+{name: '[% l('TCN Value') %]', field:'tcn_value'}
+);
+}
+-->
+
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 (file)
index 0000000..74e70f1
--- /dev/null
@@ -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<any> {
+
+        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<any> {
+        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 (file)
index 0000000..d9e8534
--- /dev/null
@@ -0,0 +1,31 @@
+
+<div class="row mb-3">
+  <div class="col-lg-2">
+    <button class="btn btn-info label-with-material-icon"
+      routerLink="/staff/cat/vandelay/queue/{{queueType}}/{{queueId}}">
+      <span class="material-icons">arrow_back</span>
+      <span i18n>Return to Queue</span>
+    </button>
+  </div>
+</div>
+
+<ngb-tabset #recordTabs [activeId]="recordTab" (tabChange)="onTabChange($event)">
+       <ngb-tab title="Queued Record MARC" i18n-title id="marc">
+               <ng-template ngbTabContent>
+      <eg-marc-html [recordId]="recordId" [recordType]="'vandelay-'+queueType">
+      </eg-marc-html>
+               </ng-template>
+       </ngb-tab>
+       <ngb-tab title="Record Matches" i18n-title id="matches">
+               <ng-template ngbTabContent>
+      <eg-queued-record-matches [recordId]="recordId" [queueType]="queueType">
+      </eg-queued-record-matches>
+               </ng-template>
+       </ngb-tab>
+       <ngb-tab title="Import Items" i18n-title id="items">
+               <ng-template ngbTabContent>
+      <eg-queued-record-items [recordId]="recordId">
+      </eg-queued-record-items>
+               </ng-template>
+       </ngb-tab>
+</ngb-tabset>
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 (file)
index 0000000..3a37be7
--- /dev/null
@@ -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 (file)
index 0000000..6654ac4
--- /dev/null
@@ -0,0 +1,67 @@
+<div class="row mb-2">
+  <div class="col-lg-6">
+    <div class="input-group">
+      <div class="input-group-prepend">
+        <span class="input-group-text" i18n>Show Sessions Since: </span>
+      </div>
+      <eg-date-select 
+        [initialIso]="sinceDate"
+        (onChangeAsIso)="dateFilterChange($event)">
+      </eg-date-select>
+    </div>
+  </div>
+</div>
+
+<div *ngIf="trackers.length == 0">
+    <div class="row">
+        <div class="col-lg-6">
+            <div class="alert alert-info">
+                <span i18n>No Import Sessions To Display</span>
+            </div>
+        </div>
+    </div>
+</div>
+
+  <div class="row mb-4" *ngFor="let tracker of trackers">
+    <div class="col-lg-12">
+      <div class="card tight-card">
+        <div class="card-header">
+          <div class="panel-title">
+            <span i18n>
+              {{tracker.create_time() | date:'short'}} : 
+              <span class="font-weight-bold">{{tracker.name()}}</span>
+            </span>
+          </div>
+        </div>
+        <div class="card-body">
+          <div class="row">
+            <div class="col-lg-6">
+              <!-- ensure the progress shows 100% when complete -->
+              <eg-progress-inline 
+                [max]="tracker.state() == 'complete' ? tracker.actions_performed() : tracker.total_actions() || null"
+                [value]="tracker.actions_performed()">
+              </eg-progress-inline>
+            </div>
+            <div class="col-lg-6">
+              <!-- .id (not .id()) check to see if it's fleshed yet -->
+              <span i18n *ngIf="tracker.queue().id">
+                <a class="font-weight-bold"
+                  routerLink="/staff/cat/vandelay/queue/{{tracker.record_type()}}/{{tracker.queue().id()}}">
+                  Queue {{tracker.queue().name()}}
+                </a>
+              </span>
+              <span class="pl-2" *ngIf="tracker.action_type() == 'enqueue'" i18n>Enqueuing... </span>
+              <span class="pl-2" *ngIf="tracker.action_type() == 'import'" i18n>Importing... </span>
+              <span *ngIf="tracker.state() == 'active'" i18n>Active</span>
+              <span *ngIf="tracker.state() == 'complete'" i18n>Complete</span>
+              <span *ngIf="tracker.state() == 'error'" i18n>Error</span>
+              <span class='pl-3' *ngIf="tracker.state() == 'complete'">
+                <span class="material-icons text-success">thumb_up</span>
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
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 (file)
index 0000000..ad7b058
--- /dev/null
@@ -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 (file)
index 0000000..012a579
--- /dev/null
@@ -0,0 +1,6 @@
+<eg-grid #itemsGrid 
+  showFields="record,import_error,imported_as,import_time,owning_lib,call_number,barcode"
+  persistKey="cat.vandelay.queue.bib.items"
+  idlClass="vii" [dataSource]="gridSource">
+</eg-grid>
+
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 (file)
index 0000000..9852a64
--- /dev/null
@@ -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 (file)
index 0000000..707b92b
--- /dev/null
@@ -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 (file)
index 0000000..a81472d
--- /dev/null
@@ -0,0 +1,44 @@
+
+<ul class="nav nav-pills nav-fill pb-4">
+  <li class="nav-item">
+    <a class="nav-link" [ngClass]="{active: tab=='export'}" 
+      routerLink="/staff/cat/vandelay/export" i18n>Export</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [ngClass]="{active: tab=='import'}" 
+      routerLink="/staff/cat/vandelay/import" i18n>Import</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [ngClass]="{active: tab=='queue'}" 
+      routerLink="/staff/cat/vandelay/queue" i18n>Inspect Queue</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [ngClass]="{active: tab=='display_attrs'}" 
+      routerLink="/staff/cat/vandelay/display_attrs/bib" 
+        i18n>Record Display Attributes</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [ngClass]="{active: tab=='merge_profiles'}" 
+      routerLink="/staff/cat/vandelay/merge_profiles" 
+        i18n>Merge / Overlay Profiles</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [ngClass]="{active: tab=='match_sets'}" 
+      routerLink="/staff/cat/vandelay/match_sets" 
+        i18n>Record Match Sets</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [ngClass]="{active: tab=='holdings_profiles'}" 
+      routerLink="/staff/cat/vandelay/holdings_profiles" 
+      i18n>Holdings Import Profiles</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link" [ngClass]="{active: tab=='active_imports'}" 
+      routerLink="/staff/cat/vandelay/active_imports" 
+      i18n>Recent Imports</a>
+  </li>
+</ul>
+
+<!-- load nav-specific page -->
+<router-outlet></router-outlet>
+
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 (file)
index 0000000..0bfad42
--- /dev/null
@@ -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 (file)
index 0000000..9bbfd46
--- /dev/null
@@ -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 (file)
index 0000000..7a6d640
--- /dev/null
@@ -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<IdlObject[]> {
+        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<IdlObject[]> {
+        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<IdlObject[]> {
+        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<IdlObject[]> {
+        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<IdlObject[]> {
+        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<IdlObject[]> {
+        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<IdlObject[]> {
+    
+        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<IdlObject[]> {
+        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<any> {
+        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<any> {
+        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<number> {
+
+        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<any> {
+
+        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<IdlObject> {
+        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();
+                }
+            }
+        );
+    }
+}
+
index b43d8e7..9220921 100644 (file)
             <span class="material-icons">cloud_download</span>
             <span i18n>Import Record from Z39.50</span>
           </a>
-          <a href="/eg/staff/cat/catalog/vandelay" class="dropdown-item">
+          <a routerLink="/staff/cat/vandelay/import" class="dropdown-item">
             <span class="material-icons">import_export</span>
             <span i18n>MARC Batch Import/Export</span>
           </a>
index b515f38..6f20336 100644 (file)
@@ -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'
   }, {
index f5e4c94..4399111 100644 (file)
@@ -1,6 +1,7 @@
 <ng-template #dialogContent>
   <div class="modal-header bg-info">
-    <h4 class="modal-title" i18n>Add To Record #{{recId}} to Bucket</h4>
+    <h4 class="modal-title" *ngIf="recId" i18n>Add To Record #{{recId}} to Bucket</h4>
+    <h4 class="modal-title" *ngIf="qId" i18n>Add Records from queue #{{qId}} to Bucket</h4>
     <button type="button" class="close" 
       i18n-aria-label aria-label="Close" 
       (click)="dismiss('cross_click')">
index 1f127b4..eb99aec 100644 (file)
@@ -24,11 +24,19 @@ export class RecordBucketDialogComponent
     newBucketDesc: string;
     buckets: any[];
 
+    @Input() bucketType: string;
+
     recId: number;
     @Input() set recordId(id: number) {
         this.recId = id;
     }
 
+    // Add items from a (vandelay) bib queue to a bucket
+    qId: number;
+    @Input() set queueId(id: number) {
+        this.qId = id;
+    }
+
     constructor(
         private modal: NgbModal, // required for passing to parent
         private renderer: Renderer2,
@@ -41,6 +49,12 @@ export class RecordBucketDialogComponent
     }
 
     ngOnInit() {
+        
+        if (this.qId) {
+            this.bucketType = 'vandelay_queue';
+        } else {
+            this.bucketType = 'staff_client';
+        }
 
         this.onOpen$.subscribe(ok => {
             // Reset data on dialog open
@@ -53,7 +67,7 @@ export class RecordBucketDialogComponent
                 'open-ils.actor',
                 'open-ils.actor.container.retrieve_by_class.authoritative',
                 this.auth.token(), this.auth.user().id(),
-                'biblio', 'staff_client'
+                'biblio', this.bucketType
             ).subscribe(buckets => this.buckets = buckets);
         });
     }
@@ -69,7 +83,7 @@ export class RecordBucketDialogComponent
         bucket.owner(this.auth.user().id());
         bucket.name(this.newBucketName);
         bucket.description(this.newBucketDesc);
-        bucket.btype('staff_client');
+        bucket.btype(this.bucketType);
 
         this.net.request(
             'open-ils.actor',
@@ -80,6 +94,11 @@ export class RecordBucketDialogComponent
             if (evt) {
                 this.toast.danger(evt.desc);
             } else {
+                // make it find-able to the queue-add method which
+                // requires the bucket name.
+                bucket.id(bktId);
+                this.buckets.push(bucket);
+
                 this.addToBucket(bktId);
             }
         });
@@ -87,8 +106,16 @@ export class RecordBucketDialogComponent
 
     // Add the record to the selected existing bucket
     addToBucket(id: number) {
+        if (this.recId) {
+            this.addRecordToBucket(id);
+        } else if (this.qId) {
+            this.addQueueToBucket(id);
+        }
+    }
+
+    addRecordToBucket(bucketId: number) {
         const item = this.idl.create('cbrebi');
-        item.bucket(id);
+        item.bucket(bucketId);
         item.target_biblio_record_entry(this.recId);
         this.net.request(
             'open-ils.actor',
@@ -103,6 +130,24 @@ export class RecordBucketDialogComponent
             }
         });
     }
+
+    addQueueToBucket(bucketId: number) {
+        const bucket = this.buckets.filter(b => b.id() === bucketId)[0];
+        if (!bucket) { return; }
+
+        this.net.request(
+            'open-ils.vandelay',
+            'open-ils.vandelay.bib_queue.to_bucket',
+            this.auth.token(), this.qId, bucket.name()
+        ).toPromise().then(resp => {
+            const evt = this.evt.parse(resp);
+            if (evt) {
+                this.toast.danger(evt.toString());
+            } else {
+                this.close();
+            }
+        });
+    }
 }
 
 
index 9f11bd8..4ee6b28 100644 (file)
@@ -532,17 +532,25 @@ sub retrieve_queued_records {
 
         } elsif( $$options{with_item_import_error} and $type eq 'bib') {
 
-            $query->{from} = {$class => 'vii'};
+            $query->{from} = {$class => {'vii' => {}}};
             $query->{where}->{'+vii'} = {import_error => {'!=' => undef}};
         }
     }
 
     if($self->api_name =~ /matches/) {
         # find only records that have matches
-        $query->{from} = {$class => {$mclass => {type => 'right'}}};
+        if (ref $query->{from}) {
+            $query->{from}{$class}{$mclass} = {type => 'right'};
+        } else {
+            $query->{from} = {$class => {$mclass => {type => 'right'}}};
+        }
     } else {
         # join to mclass for sorting (see below)
-        $query->{from} = {$class => {$mclass => {type => 'left'}}};
+        if (ref $query->{from}) {
+            $query->{from}{$class}{$mclass} = {type => 'left'};
+        } else {
+            $query->{from} = {$class => {$mclass => {type => 'left'}}};
+        }
     }
 
     # order by the matched bib records to group like queued records
@@ -915,7 +923,7 @@ sub create_session_tracker {
         # if other trackers exist for this key, adopt the name
         my $existing = 
             $e->search_vandelay_session_tracker({session_key => $key})->[0];
-        $name = $existing->name if $name;
+        $name = $existing->name if $existing;
 
     } else {
         # anonymous tracker
@@ -1536,7 +1544,7 @@ __PACKAGE__->register_method(
 );
 
 sub owner_queue_retrieve {
-    my($self, $conn, $auth, $owner_id, $filters) = @_;
+    my($self, $conn, $auth, $owner_id, $filters, $pager) = @_;
     my $e = new_editor(authtoken => $auth, xact => 1);
     return $e->die_event unless $e->checkauth;
     $owner_id = $e->requestor->id; # XXX add support for viewing other's queues?
@@ -1545,12 +1553,18 @@ sub owner_queue_retrieve {
     my $search = {owner => $owner_id};
     $search->{$_} = $filters->{$_} for keys %$filters;
 
+    my %paging;
+    if ($pager) {
+        $paging{limit} = $pager->{limit} || 1000;
+        $paging{offset} = $pager->{offset} || 0;
+    }
+
     if($self->{record_type} eq 'bib') {
         $queues = $e->search_vandelay_bib_queue(
-            [$search, {order_by => {vbq => 'evergreen.lowercase(name)'}}]);
+            [$search, {%paging, order_by => {vbq => 'evergreen.lowercase(name)'}}]);
     } else {
         $queues = $e->search_vandelay_authority_queue(
-            [$search, {order_by => {vaq => 'evergreen.lowercase(name)'}}]);
+            [$search, {%paging, order_by => {vaq => 'evergreen.lowercase(name)'}}]);
     }
     $conn->respond($_) for @$queues;
     $e->rollback;
index 26c9569..e8ef9ed 100644 (file)
@@ -19420,6 +19420,65 @@ VALUES (
     )
 );
 
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.grid.cat.vandelay.queue.bib', 'gui', 'object',
+    oils_i18n_gettext(
+        'eg.grid.cat.vandelay.queue.bib',
+        'Grid Config: Vandelay Bib Queue',
+        'cwst', 'label'
+    )
+), (
+    'eg.grid.cat.vandelay.queue.auth', 'gui', 'object',
+    oils_i18n_gettext(
+        'eg.grid.cat.vandelay.queue.auth',
+        'Grid Config: Vandelay Authority Queue',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.match_set.list', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.match_set.list',
+        'Grid Config: Vandelay Match Sets',
+        'cwst', 'label'
+    )
+), (
+    'staff.cat.vandelay.match_set.quality', 'gui', 'object',
+    oils_i18n_gettext(
+        'staff.cat.vandelay.match_set.quality',
+        'Grid Config: Vandelay Match Quality Metrics',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.queue.items', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.queue.items',
+        'Grid Config: Vandelay Queue Import Items',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.queue.list.bib', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.queue.list.bib',
+        'Grid Config: Vandelay Bib Queue List',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.queue.bib.items', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.queue.bib.items',
+        'Grid Config: Vandelay Bib Items',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.queue.list.auth', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.queue.list.auth',
+        'Grid Config: Vandelay Authority Queue List',
+        'cwst', 'label'
+    )
+);
+
 
 INSERT into config.org_unit_setting_type (name, label, description, datatype) 
 VALUES ( 
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.vandelay-grid-settings.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.vandelay-grid-settings.sql
new file mode 100644 (file)
index 0000000..4eb2206
--- /dev/null
@@ -0,0 +1,66 @@
+BEGIN;
+
+--SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.grid.cat.vandelay.queue.bib', 'gui', 'object',
+    oils_i18n_gettext(
+        'eg.grid.cat.vandelay.queue.bib',
+        'Grid Config: Vandelay Bib Queue',
+        'cwst', 'label'
+    )
+), (
+    'eg.grid.cat.vandelay.queue.auth', 'gui', 'object',
+    oils_i18n_gettext(
+        'eg.grid.cat.vandelay.queue.auth',
+        'Grid Config: Vandelay Authority Queue',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.match_set.list', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.match_set.list',
+        'Grid Config: Vandelay Match Sets',
+        'cwst', 'label'
+    )
+), (
+    'staff.cat.vandelay.match_set.quality', 'gui', 'object',
+    oils_i18n_gettext(
+        'staff.cat.vandelay.match_set.quality',
+        'Grid Config: Vandelay Match Quality Metrics',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.queue.items', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.queue.items',
+        'Grid Config: Vandelay Queue Import Items',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.queue.list.bib', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.queue.list.bib',
+        'Grid Config: Vandelay Bib Queue List',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.queue.bib.items', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.queue.bib.items',
+        'Grid Config: Vandelay Bib Items',
+        'cwst', 'label'
+    )
+), (
+    'cat.vandelay.queue.list.auth', 'gui', 'object',
+    oils_i18n_gettext(
+        'cat.vandelay.queue.list.auth',
+        'Grid Config: Vandelay Authority Queue List',
+        'cwst', 'label'
+    )
+);
+
+COMMIT;
+
+
index ee48e6b..eb1d471 100644 (file)
             </a>
           </li>
           <li>
-            <a href="./cat/catalog/vandelay" target="_self">
+            <a href="/eg2/staff/cat/vandelay/import">
               <span class="glyphicon glyphicon-transfer"></span>
               [% l('MARC Batch Import/Export') %]
             </a>