LP1852782 MARC editor authority linking support
authorBill Erickson <berickxx@gmail.com>
Mon, 16 Dec 2019 15:40:01 +0000 (10:40 -0500)
committerBill Erickson <berickxx@gmail.com>
Fri, 21 Feb 2020 16:44:38 +0000 (11:44 -0500)
Adds authority browse UI for controlled bib tags, with support for
applying headings for found authorities.

Adds 3 new open-ils.cat APIs for managing the authority browse and
linking logic, lifted from the AngJS MARC editor.

open-ils.cat.authority.validate.bib_field
open-ils.cat.authority.bib_field.linking_browse
open-ils.cat.authority.bib_field.overlay_authority

Adds new "Show As Heading" and "Show As MARC" options allowing staff to
see the main headings, see from, and see alsos as human-friendly text or
as the raw MARC data.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
16 files changed:
Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts
Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts
Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm
Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2
Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2

index 2c59548..de4d29c 100644 (file)
@@ -9,7 +9,7 @@
       </ng-container>
       <span *ngIf="fromBibQueue" i18n>Add Records from queue #{{fromBibQueue}} to Bucket</span>
     </h4>
-    <button type="button" class="close" 
+    <button type="button" class="close"
       i18n-aria-label aria-label="Close" (click)="close()">
       <span aria-hidden="true">&times;</span>
     </button>
     <div class="row">
       <div class="col-lg-3 font-weight-bold" i18n>Name of existing bucket</div>
       <div class="col-lg-5">
-        <eg-combobox [entries]="formatBucketEntries()" 
+        <eg-combobox [entries]="formatBucketEntries()"
           (onChange)="bucketChanged($event)"
           placeholder="Existing Bucket..." i18n-placeholder>
         </eg-combobox>
       </div>
       <div class="col-lg-4">
-        <button class="btn btn-info" (click)="addToSelected()" i18n 
+        <button class="btn btn-info" (click)="addToSelected()" i18n
           [disabled]="!selectedBucket">
           Add To Selected Bucket
         </button>
     <div class="row mt-3">
       <div class="col-lg-3 font-weight-bold" i18n>Name of new bucket</div>
       <div class="col-lg-5">
-        <input type="text" class="form-control" 
+        <input type="text" class="form-control"
           placeholder="New Bucket Name..."
           i18n-placeholder
           [(ngModel)]="newBucketName"/>
       </div>
       <div class="col-lg-4">
-        <button class="btn btn-info" (click)="addToNew()" i18n 
+        <button class="btn btn-info" (click)="addToNew()" i18n
           [disabled]="!newBucketName">
           Add To New Bucket
         </button>
@@ -48,7 +48,7 @@
     <div class="row mt-3">
       <div class="col-lg-3 font-weight-bold" i18n>New bucket description</div>
       <div class="col-lg-5">
-        <textarea size="3" type="text" class="form-control" 
+        <textarea size="3" type="text" class="form-control"
           placeholder="Optional New Bucket Description..."
           i18n-placeholder
           [(ngModel)]="newBucketDesc">
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html
new file mode 100644 (file)
index 0000000..c0e9558
--- /dev/null
@@ -0,0 +1,116 @@
+
+<!-- display a single heading as MARC -->
+<ng-template #fieldAsMarc let-field="field">
+  <span>{{field.tag}} {{field.ind1}} {{field.ind2}}</span>
+  <span *ngFor="let sf of field.subfields">
+    <span class="text-danger" i18n>‡</span>{{sf[0]}} {{sf[1]}}
+  </span>
+</ng-template>
+
+<!-- display a single heading as MARC or as the human friendlier string -->
+<ng-template #headingField let-field="field" let-from="from" let-also="also">
+  <button class="btn btn-sm btn-outline-info p-1 mr-1" 
+    (click)="applyHeading(field)" i18n>Apply</button>
+  <ng-container *ngIf="showAs == 'heading'">
+    <span *ngIf="from" i18n>See From: {{field.heading}}</span>
+    <span *ngIf="also" i18n>See Also: {{field.heading}}</span>
+    <span *ngIf="!from && !also" i18n>{{field.heading}}</span>
+  </ng-container>
+  <ng-container *ngIf="showAs == 'marc'">
+    <ng-container
+      *ngTemplateOutlet="fieldAsMarc;context:{field:field}">
+    </ng-container>
+  </ng-container>
+</ng-template>
+
+<ng-template #dialogContent>
+  <div class="modal-header bg-info">
+    <h4 class="modal-title" i18n>Manage Authority Links</h4>
+    <button type="button" class="close"
+      i18n-aria-label aria-label="Close" (click)="close()">
+      <span aria-hidden="true">&times;</span>
+    </button>
+  </div>
+  <div class="modal-body">
+    <div class="row border-bottom border-secondary p-2 d-flex">
+      <div class="flex-1 font-weight-bold p-1 pl-2 pt-2 ml-2">
+        <div>{{bibField.tag}} {{bibField.ind1}} {{bibField.ind2}}</div>
+
+        <div *ngFor="let sf of bibField.subfields">
+          <div class="form-check form-check-inline">
+            <input class="form-check-input" id="search-subfield-{{sf[0]}}" 
+              type="checkbox" [disabled]="!isControlledBibSf(sf[0])"
+              [(ngModel)]="selectedSubfields[sf[0]]" 
+              (change)="getPage(pager.offset)"/>
+
+            <span class="text-danger" i18n>‡</span>
+
+            <label class="form-check-label" for="search-subfield-{{sf[0]}}" i18n>
+              {{sf[0]}} {{sf[1]}}
+            </label>
+          </div>
+        </div>
+      </div>
+      <div class="ml-2 p-1">
+        <div class="mb-1" i18n>Create new authority from this field</div>
+        <div>
+          <button class="btn btn-outline-info" [disabled]="true">
+            Immediately
+          </button>
+          <button class="btn btn-outline-info ml-2" [disabled]="true">
+            Create and Edit
+          </button>
+        </div>
+      </div>
+    </div>
+    <div class="row border-bottom border-secondary p-2 d-flex">
+      <div class="flex-1">
+        <button class="btn btn-outline-dark" [disabled]="pager.offset == 0"
+          (click)="getPage(0)" i18n>Start</button>
+        <button class="btn btn-outline-dark ml-2"
+          (click)="getPage(-1)" i18n>Previous</button>
+        <button class="btn btn-outline-dark ml-2"
+          (click)="getPage(1)" i18n>Next</button>
+      </div>
+      <div class="pt-2 mb-2">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="radio" value="heading"
+            [(ngModel)]="showAs" name='show-as-heading' id="show-as-heading">
+          <label class="form-check-label" for="show-as-heading" i18n>Show As Heading</label>
+        </div>
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="radio" value="marc"
+            [(ngModel)]="showAs" name='show-as-heading' id="show-as-marc">
+          <label class="form-check-label" for="show-as-marc" i18n>Show As MARC</label>
+        </div>
+      </div>
+    </div>
+    <ul *ngFor="let entry of browseData">
+      <li class="d-flex">
+        <div class="flex-1">
+          <ng-container
+            *ngTemplateOutlet="headingField;context:{field:entry.main_heading}">
+          </ng-container>
+        </div>
+        <div class="font-italic" i18n-title i18n
+          title="Authority Record ID {{entry.authority_id}}">
+          #{{entry.authority_id}}
+        </div>
+      </li>
+      <ul *ngFor="let from of entry.see_froms">
+        <li i18n>
+         <ng-container
+          *ngTemplateOutlet="headingField;context:{field:from, from:true}">
+         </ng-container>
+        </li>
+      </ul>
+      <ul *ngFor="let also of entry.see_alsos">
+        <li i18n>
+         <ng-container
+          *ngTemplateOutlet="headingField;context:{field:also, also:true}">
+         </ng-container>
+        </li>
+      </ul>
+    </ul>
+  </div>
+</ng-template>
diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts
new file mode 100644 (file)
index 0000000..2015ec1
--- /dev/null
@@ -0,0 +1,134 @@
+import {Component, Input, Output, OnInit, EventEmitter} from '@angular/core';
+import {NetService} from '@eg/core/net.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {MarcField} from './marcrecord';
+import {Pager} from '@eg/share/util/pager';
+
+/**
+ * MARC Authority Linking Dialog
+ */
+
+@Component({
+  selector: 'eg-authority-linking-dialog',
+  templateUrl: './authority-linking-dialog.component.html'
+})
+
+export class AuthorityLinkingDialogComponent
+    extends DialogComponent implements OnInit {
+
+    @Input() bibField: MarcField;
+    @Input() thesauri: string = null;
+    @Input() controlSet: number = null;
+    @Input() pager: Pager;
+
+    browseData: any[] = [];
+
+    // If false, show the raw MARC field data.
+    showAs: 'heading' | 'marc' = 'heading';
+
+    authMeta: any;
+
+    selectedSubfields: string[] = [];
+
+    constructor(
+        private modal: NgbModal,
+        private pcrud: PcrudService,
+        private net: NetService) {
+        super(modal);
+    }
+
+    ngOnInit() {
+        if (!this.pager) {
+            this.pager = new Pager();
+            this.pager.limit = 5;
+        }
+
+        this.onOpen$.subscribe(_ => this.initData());
+    }
+
+    fieldHash(field?: MarcField): any {
+        if (!field) { field = this.bibField; }
+
+        return {
+            tag: field.tag,
+            ind1: field.ind1,
+            ind2: field.ind2,
+            subfields: field.subfields.map(sf => [sf[0], sf[1]])
+        };
+    }
+
+    initData() {
+
+       this.pager.offset = 0;
+
+       this.pcrud.search('acsbf',
+            {tag: this.bibField.tag},
+            {flesh: 1, flesh_fields: {acsbf: ['authority_field']}},
+            {atomic:  true, anonymous: true}
+
+        ).subscribe(bibMetas => {
+            if (bibMetas.length === 0) { return; }
+
+            let bibMeta;
+            if (this.controlSet) {
+                bibMeta = bibMetas.filter(b =>
+                    this.controlSet === +b.authority_field().control_set());
+            } else {
+                bibMeta = bibMetas[0];
+            }
+
+            if (bibMeta) {
+                this.authMeta = bibMeta.authority_field();
+                this.bibField.subfields.forEach(sf =>
+                    this.selectedSubfields[sf[0]] =
+                        this.isControlledBibSf(sf[0])
+                );
+            }
+
+            this.getPage(0);
+        });
+    }
+
+    getPage(direction: number) {
+        this.browseData = [];
+
+        if (direction > 0) {
+            this.pager.offset++;
+        } else if (direction < 0) {
+            this.pager.offset--;
+        } else {
+            this.pager.offset = 0;
+        }
+
+        const hash = this.fieldHash();
+
+        // Only search the selected subfields
+        hash.subfields =
+            hash.subfields.filter(sf => this.selectedSubfields[sf[0]]);
+
+        if (hash.subfields.length === 0) { return; }
+
+        this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.authority.bib_field.linking_browse',
+            hash, this.pager.limit,
+            this.pager.offset, this.thesauri
+        ).subscribe(entry => this.browseData.push(entry));
+    }
+
+    applyHeading(authField: MarcField) {
+        this.net.request(
+            'open-ils.cat',
+            'open-ils.cat.authority.bib_field.overlay_authority',
+            this.fieldHash(), this.fieldHash(authField), this.controlSet
+        ).subscribe(field => this.close(field));
+    }
+
+    isControlledBibSf(sf: string): boolean {
+        return this.authMeta ?
+            this.authMeta.sf_list().includes(sf) : false;
+    }
+}
+
index e21bb84..4778fbe 100644 (file)
@@ -8,15 +8,19 @@ div[contenteditable] {
    min-height: calc(1.5em + .75rem + 2px);
 }
 
-.sf-delimiter { 
+.sf-delimiter {
   /* match angjs color */
-  color: rgb(0, 0, 255)!important; 
+  color: rgb(0, 0, 255)!important;
   /* snuggle up to my subfield code */
-  margin-right: -0.5rem; 
+  margin-right: -0.5rem;
 }
 
-.sf-code { 
+.sf-code {
   /* match angjs color */
-  color: rgb(0, 0, 255)!important; 
+  color: rgb(0, 0, 255)!important;
+}
+
+.auth-invalid {
+  color: rgb(255, 0, 0)!important;
 }
 
index 07b6776..cba8925 100644 (file)
@@ -1,6 +1,6 @@
 
-<!-- 
-Some context menus have additional static options.  
+<!--
+Some context menus have additional static options.
 Track their labels here.
 -->
 <eg-string #add006 text="Add 006" i18n-text></eg-string>
@@ -12,9 +12,10 @@ Track their labels here.
 
 <ng-container *ngIf="bigText">
   <div contenteditable
-    id='{{randId}}' 
+    id='{{randId}}'
     spellcheck="false"
     class="d-inline-block text-dark text-break {{moreClasses}}"
+    [ngClass]="{'auth-invalid': isAuthInvalid()}"
     [attr.tabindex]="fieldText ? -1 : ''"
     [attr.aria-label]="ariaLabel"
     [egContextMenu]="contextMenuEntries()"
@@ -27,13 +28,14 @@ Track their labels here.
 </ng-container>
 
 <ng-container *ngIf="!bigText">
-  <input 
-    id='{{randId}}' 
+  <input
+    id='{{randId}}'
     spellcheck="false"
     class="text-dark rounded-0 form-control {{moreClasses}}"
-    [size]="inputSize()" 
+    [ngClass]="{'auth-invalid': isAuthInvalid()}"
+    [size]="inputSize()"
     [maxlength]="maxLength || ''"
-    [disabled]="fieldText" 
+    [disabled]="fieldText"
     [attr.tabindex]="fieldText ? -1 : ''"
     [attr.aria-label]="ariaLabel"
     [egContextMenu]="contextMenuEntries()"
index 2eeadf1..88be84c 100644 (file)
@@ -597,6 +597,36 @@ export class EditableContentComponent
         // Context menus can steal focus.
         this.context.requestFieldFocus(this.context.lastFocused);
     }
+
+    isAuthInvalid(): boolean {
+        return (
+            this.fieldType === 'sfv' &&
+            this.field.authChecked &&
+            !this.field.authValid
+        );
+    }
+
+    isAuthValid(): boolean {
+        return (
+            this.fieldType === 'sfv' &&
+            this.field.authChecked &&
+            this.field.authValid
+        );
+    }
+
+    isLastSubfieldValue(): boolean {
+        if (this.fieldType === 'sfv') {
+            const myIdx = this.subfield[2];
+            for (let idx = 0; idx < this.field.subfields.length; idx++) {
+                if (idx > myIdx) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        return false;
+    }
 }
 
 
index ae7af49..16a8cae 100644 (file)
@@ -22,6 +22,10 @@ export class UndoRedoAction {
 
     // Which stack do we toss this on once it's been applied?
     isRedo: boolean;
+
+    // Grouped actions are tracked as multiple undo / redo actions, but
+    // are done and un-done as a unit.
+    groupSize?: number;
 }
 
 export class TextUndoRedoAction extends UndoRedoAction {
@@ -88,7 +92,9 @@ export class MarcEditContext {
     requestFieldFocus(req: FieldFocusRequest) {
         // timeout allows for new components to be built before the
         // focus request is emitted.
-        setTimeout(() => this.fieldFocusRequest.emit(req));
+        if (req) {
+            setTimeout(() => this.fieldFocusRequest.emit(req));
+        }
     }
 
     resetUndos() {
@@ -97,18 +103,75 @@ export class MarcEditContext {
     }
 
     requestUndo() {
-        const undo = this.undoStack.shift();
-        if (undo) {
-            undo.isRedo = false;
-            this.distributeUndoRedo(undo);
-        }
+        let remaining = null;
+
+        do {
+            const action = this.undoStack.shift();
+            if (!action) { return; }
+
+            if (remaining === null) {
+                remaining = action.groupSize || 1;
+            }
+            remaining--;
+
+            action.isRedo = false;
+            this.distributeUndoRedo(action);
+
+        } while (remaining > 0);
     }
 
     requestRedo() {
-        const redo = this.redoStack.shift();
-        if (redo) {
-            redo.isRedo = true;
-            this.distributeUndoRedo(redo);
+        let remaining = null;
+
+        do {
+            const action = this.redoStack.shift();
+            if (!action) { return; }
+
+            if (remaining === null) {
+                remaining = action.groupSize || 1;
+            }
+            remaining--;
+
+            action.isRedo = true;
+            this.distributeUndoRedo(action);
+
+        } while (remaining > 0);
+    }
+
+    // Calculate stack action count taking groupSize (atomic action
+    // sets) into consideration.
+    stackCount(stack: UndoRedoAction[]): number {
+        let size = 0;
+        let skip = 0;
+
+        stack.forEach(action => {
+            if (action.groupSize > 1) {
+                if (skip) { return; }
+                skip = 1;
+            } else {
+                skip = 0;
+            }
+            size++;
+        });
+
+        return size;
+    }
+
+    undoCount(): number {
+        return this.stackCount(this.undoStack);
+    }
+
+    redoCount(): number {
+        return this.stackCount(this.redoStack);
+    }
+
+    // Stamp the most recent 'size' entries in the undo stack
+    // as being an atomic undo/redo set.
+    setUndoGroupSize(size: number) {
+        for (let idx = 0; idx < size; idx++) {
+            if (this.undoStack[idx]) {
+                this.undoStack[idx].groupSize = size;
+            }
         }
     }
 
index c7bbaba..e34928d 100644 (file)
@@ -8,6 +8,7 @@ import {FixedFieldsEditorComponent} from './fixed-fields-editor.component';
 import {FixedFieldComponent} from './fixed-field.component';
 import {TagTableService} from './tagtable.service';
 import {EditableContentComponent} from './editable-content.component';
+import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
 
 @NgModule({
     declarations: [
@@ -16,7 +17,8 @@ import {EditableContentComponent} from './editable-content.component';
         MarcFlatEditorComponent,
         FixedFieldsEditorComponent,
         FixedFieldComponent,
-        EditableContentComponent
+        EditableContentComponent,
+        AuthorityLinkingDialogComponent
     ],
     imports: [
         StaffCommonModule,
index cdc99aa..d49f4ef 100644 (file)
@@ -19,6 +19,10 @@ export interface MarcField {
     ind2?: string;
     subfields?: MarcSubfield[];
 
+    // For authority validation
+    authValid: boolean;
+    authChecked: boolean;
+
     // Fields are immutable when it comes to controlfield vs.
     // data field.  Stamp the value when stamping field IDs.
     isCtrlField: boolean;
index 5c90f28..e3c973c 100644 (file)
@@ -6,3 +6,10 @@
   border-bottom: 1px solid gray;
   */
 }
+
+.link-button .material-icons {
+  font-size: 17px;
+  display: inline-flex;
+  vertical-align: middle;
+  align-items: center;
+}
index a7ca33f..68e0f68 100644 (file)
@@ -7,6 +7,7 @@
   </div>
 </ng-container>
 
+<eg-authority-linking-dialog #authLinker></eg-authority-linking-dialog>
 
 <ng-template #subfieldChunk let-field="field" let-subfield="subfield">
 
 
 </ng-template>
 
+<ng-template #postSubfieldsChunk let-field="field">
+
+  <ng-container *ngIf="isControlledBibTag(field.tag)">
+    <button class="btn btn-sm btn-info link-button"
+      (click)="openLinkerDialog(field)">
+      <span class="material-icons">link</span>
+    </button>
+  </ng-container>
+
+  <ng-container *ngIf="field.authChecked">
+    <span class="pl-2 pt-2">
+      <span *ngIf="field.authValid"
+        title="Authority Validation Succeeded" i18n-title
+        class="material-icons label-with-material-icon text-success">
+        check_circle_outline
+      </span>
+      <span *ngIf="!field.authValid"
+        title="Authority Validation Failed" i18n-title
+        class="material-icons label-with-material-icon text-danger">
+        error_outline
+      </span>
+    </span>
+  </ng-container>
+</ng-template>
+
 <ng-container *ngIf="dataLoaded">
   <div class="mt-3 text-monospace"
     (contextmenu)="$event.preventDefault()">
         <div><button class="btn btn-outline-dark"
           (click)="showHelp = !showHelp" i18n>Help</button></div>
         <div class="mt-2"><button class="btn btn-outline-dark"
-          [disabled]="true"
           (click)="validate()" i18n>Validate</button></div>
         <div class="mt-2">
-          <button type="button" class="btn btn-outline-info" 
+          <button type="button" class="btn btn-outline-info"
             [disabled]="undoCount() < 1" (click)="undo()">
             Undo <span class="badge badge-info">{{undoCount()}}</span>
           </button>
-          <button type="button" class="btn btn-outline-info ml-2" 
+          <button type="button" class="btn btn-outline-info ml-2"
             [disabled]="redoCount() < 1" (click)="redo()">
             Redo <span class="badge badge-info">{{redoCount()}}</span>
           </button>
         </div>
         <div class="mt-2">
           <div class="form-check">
-            <input class="form-check-input" type="checkbox" 
+            <input class="form-check-input" type="checkbox"
               (change)="stackSubfieldsChange()"
               [(ngModel)]="stackSubfields" id="stack-subfields-{{randId}}">
             <label class="form-check-label" for="stack-subfields-{{randId}}">
 
         <!-- when not stacking subfields, render them inline -->
         <ng-container *ngIf="!stackSubfields">
-          <ng-container *ngFor="let subfield of field.subfields">
-            <ng-container 
+          <ng-container *ngFor="let subfield of field.subfields; let last = last">
+            <ng-container
               *ngTemplateOutlet="subfieldChunk;context:{field:field,subfield:subfield}">
             </ng-container>
+            <ng-container *ngIf="last">
+              <ng-container
+                *ngTemplateOutlet="postSubfieldsChunk;context:{field:field}">
+              </ng-container>
+            </ng-container>
           </ng-container>
         </ng-container>
+
       </div>
 
-      <!-- when stacking subfields, each subfield gets its own row 
+      <!-- when stacking subfields, each subfield gets its own row
         preceeded by a placeholder for the tag as a way to 'tab' right -->
       <ng-container *ngIf="stackSubfields">
         <div class="form-inline" *ngFor="let subfield of field.subfields">
           <eg-marc-editable-content fieldText="   " moreClasses="p-1 invisible">
           </eg-marc-editable-content>
-          <ng-container 
+          <ng-container
             *ngTemplateOutlet="subfieldChunk;context:{field:field,subfield:subfield}">
           </ng-container>
         </div>
index a1a7d55..480c7ea 100644 (file)
@@ -1,12 +1,14 @@
 import {Component, Input, Output, OnInit, AfterViewInit, EventEmitter,
-    OnDestroy} from '@angular/core';
+    ViewChild, OnDestroy} from '@angular/core';
 import {filter} from 'rxjs/operators';
 import {IdlService} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
 import {OrgService} from '@eg/core/org.service';
 import {ServerStoreService} from '@eg/core/server-store.service';
 import {TagTableService} from './tagtable.service';
 import {MarcRecord, MarcField} from './marcrecord';
 import {MarcEditContext} from './editor-context';
+import {AuthorityLinkingDialogComponent} from './authority-linking-dialog.component';
 
 
 /**
@@ -28,9 +30,14 @@ export class MarcRichEditorComponent implements OnInit {
     showHelp: boolean;
     randId = Math.floor(Math.random() * 100000);
     stackSubfields: boolean;
+    controlledBibTags: string[] = [];
+
+    @ViewChild('authLinker', {static: false})
+        authLinker: AuthorityLinkingDialogComponent;
 
     constructor(
         private idl: IdlService,
+        private net: NetService,
         private org: OrgService,
         private store: ServerStoreService,
         private tagTable: TagTableService
@@ -57,7 +64,9 @@ export class MarcRichEditorComponent implements OnInit {
         return Promise.all([
             this.tagTable.loadTagTable({marcRecordType: this.context.recordType}),
             this.tagTable.getFfPosTable(this.record.recordType()),
-            this.tagTable.getFfValueTable(this.record.recordType())
+            this.tagTable.getFfValueTable(this.record.recordType()),
+            this.tagTable.getControlledBibTags().then(
+                tags => this.controlledBibTags = tags)
         ]).then(_ =>
             // setTimeout forces all of our sub-components to rerender
             // themselves each time init() is called.  Without this,
@@ -77,11 +86,11 @@ export class MarcRichEditorComponent implements OnInit {
     }
 
     undoCount(): number {
-        return this.context.undoStack.length;
+        return this.context.undoCount();
     }
 
     redoCount(): number {
-        return this.context.redoStack.length;
+        return this.context.redoCount();
     }
 
     undo() {
@@ -99,6 +108,52 @@ export class MarcRichEditorComponent implements OnInit {
     dataFields(): MarcField[] {
         return this.record.fields.filter(f => !f.isCtrlField);
     }
+
+    validate() {
+        const fields = [];
+        this.record.fields.filter(f => this.isControlledBibTag(f.tag))
+
+        .forEach(f => {
+            f.authValid = false;
+            fields.push({
+                id: f.fieldId, // ignored and echoed by server
+                tag: f.tag,
+                ind1: f.ind1,
+                ind2: f.ind2,
+                subfields: f.subfields.map(sf => ({code: sf[0], value: sf[1]}))
+            });
+        });
+
+        this.net.request('open-ils.cat',
+            'open-ils.cat.authority.validate.bib_field', fields)
+        .subscribe(checkedField => {
+            const bibField = this.record.fields
+                .filter(f => f.fieldId === +checkedField.id)[0];
+
+            bibField.authChecked = true;
+            bibField.authValid = checkedField.valid;
+        });
+    }
+
+    isControlledBibTag(tag: string): boolean {
+        return this.controlledBibTags && this.controlledBibTags.includes(tag);
+    }
+
+    openLinkerDialog(field: MarcField) {
+        this.authLinker.bibField = field;
+        this.authLinker.open({size: 'lg'}).subscribe(newField => {
+            if (!newField) { return; }
+
+            // Performs an insert followed by a delete, so the two
+            // fields can be tracked separately for undo/redo actions.
+            const marcField = this.record.newField(newField);
+            this.context.insertField(field, marcField);
+            this.context.deleteField(field);
+
+            // Mark the insert and delete as an atomic undo/redo action.
+            this.context.setUndoGroupSize(2);
+        });
+    }
 }
 
 
index e3571b1..3d2fc73 100644 (file)
@@ -1,5 +1,6 @@
 import {Injectable, EventEmitter} from '@angular/core';
-import {map, tap} from 'rxjs/operators';
+import {Observable} from 'rxjs';
+import {map, tap, distinct} from 'rxjs/operators';
 import {StoreService} from '@eg/core/store.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {AuthService} from '@eg/core/auth.service';
@@ -25,6 +26,7 @@ export class TagTableService {
     tagMap: {[tag: string]: any} = {};
     ffPosMap: {[rtype: string]: any[]} = {};
     ffValueMap: {[rtype: string]: any} = {};
+    controlledBibTags: string[];
 
     extractedValuesCache:
         {[valueType: string]: {[which: string]: any}} = {};
@@ -269,6 +271,20 @@ export class TagTableService {
 
         return this.toCache('ffvalues', recordType, fieldCode, values);
     }
+
+    getControlledBibTags(): Promise<string[]> {
+        if (this.controlledBibTags) {
+            return Promise.resolve(this.controlledBibTags);
+        }
+
+        this.controlledBibTags = [];
+        return this.pcrud.retrieveAll('acsbf', {select: ['tag']})
+        .pipe(
+            map(field => field.tag()),
+            distinct(),
+            map(tag => this.controlledBibTags.push(tag))
+        ).toPromise().then(_ => this.controlledBibTags);
+    }
 }
 
 
index 66c6c4a..f601c0e 100644 (file)
@@ -1,5 +1,7 @@
 package OpenILS::Application::Cat::Authority;
 use strict; use warnings;
+use MARC::Record;
+use MARC::File::XML (BinaryEncoding => 'utf8', RecordFormat => 'USMARC');
 use base qw/OpenILS::Application/;
 use OpenILS::Utils::CStoreEditor q/:funcs/;
 use OpenILS::Application::Cat::AuthCommon;
@@ -347,4 +349,335 @@ sub retrieve_acsaf {
     return undef;
 }
 
+__PACKAGE__->register_method(
+    method => "bib_field_overlay_authority_field",
+    api_name => "open-ils.cat.authority.bib_field.overlay_authority",
+    api_level => 1,
+    stream => 1,
+    argc => 2,
+    signature => {
+        desc => q/Given a bib field hash and an authority field hash,
+            merge the authority data for controlled fields into the 
+            bib field./,
+        params => [
+            {name => 'Bib Field', 
+                desc => '{tag:., ind1:., ind2:.,subfields:[[code, value],...]}'},
+            {name => 'Authority Field', 
+                desc => '{tag:., ind1:., ind2:.,subfields:[[code, value],...]}'},
+            {name => 'Control Set ID',
+                desc => q/Optional control set limiter.  If no control set
+                    is provided, the first matching authority field
+                    definition will be used./}
+        ],
+        return => q/The modified bib field/
+    }
+);
+
+# Returns the first found field.
+sub get_auth_field {
+    my ($atag, $cset_id) = @_;
+
+    my $e = new_editor();
+
+    my $where = {tag => $atag};
+
+    $where->{control_set} = $cset_id if $cset_id;
+
+    return $e->search_authority_control_set_authority_field($where)->[0];
+}
+
+sub bib_field_overlay_authority_field {
+    my ($self, $client, $bib_field, $auth_field, $cset_id) = @_;
+
+    return $bib_field unless $bib_field && $auth_field;
+
+    my $btag = $bib_field->{'tag'};
+    my $atag = $auth_field->{'tag'};
+
+    # Find the controlled subfields.  Here we assume the authority
+    # field provided should be used as the source of which subfields
+    # are controlled.  If passed a set of bib and auth data that are
+    # not consistent with the control set, it may produce unexpected
+    # results.
+    my $sf_list = '';
+    my $acsaf = get_auth_field($atag, $cset_id);
+
+    if ($acsaf) {
+        $sf_list = $acsaf->sf_list;
+
+    } else {
+
+        # Handle 4XX and 5XX
+        (my $alt_atag = $atag) =~ s/^./1/;
+        $acsaf = get_auth_field($alt_atag, $cset_id) if $alt_atag ne $atag;
+
+        $sf_list = $acsaf->sf_list if $acsaf;
+    }
+
+    my $subfields = [];
+    my $auth_sf_zero;
+
+    # Add the controlled authority subfields
+    for my $sf (@{$auth_field->{subfields}}) {
+        my $c = $sf->[0]; # subfield code
+        my $v = $sf->[1]; # subfield value
+
+        if ($c eq '0') {
+            $auth_sf_zero = $v;
+
+        } elsif (index($sf_list, $c) > -1) {
+            push(@$subfields, [$c, $v]);
+        }
+    }
+
+    # Add the uncontrolled bib subfields
+    for my $sf (@{$bib_field->{subfields}}) {
+        my $c = $sf->[0]; # subfield code
+        my $v = $sf->[1]; # subfield value
+
+        # Discard the bib '0' since the link is no longer valid, 
+        # given we're replacing the contents of the field.
+        if (index($sf_list, $c) < 0 && $c ne '0') {
+            push(@$subfields, [$c, $v]);
+        }
+    }
+
+    # The data on this authority field may link to yet 
+    # another authority record.  Track that in our bib field
+    # as the last subfield;
+    push(@$subfields, ['0', $auth_sf_zero]) if $auth_sf_zero;
+
+    my $new_bib_field = {
+        tag => $bib_field->{tag},
+        ind1 => $auth_field->{'ind1'},
+        ind2 => $auth_field->{'ind2'},
+        subfields => $subfields
+    };
+
+    $new_bib_field->{ind1} = $auth_field->{'ind2'} 
+        if $atag eq '130' && $btag eq '130';
+
+    return $new_bib_field;
+}
+
+__PACKAGE__->register_method(
+    method    => "validate_bib_fields",
+    api_name  => "open-ils.cat.authority.validate.bib_field",
+    stream => 1,
+    signature => {
+        desc => q/Returns a stream of bib field objects with a 'valid'
+        attribute added, set to 1 or 0, indicating whether the field
+        has a matching authority entry.  If no control set ID is provided
+        all configured control sets will be tested.  Testing will stop
+        with the first positive validation./,
+        params => [
+            {type => 'object', name => 'Bib Fields',
+                description => q/
+                    List of objects like this 
+                    {
+                        tag: tag, 
+                        ind1: ind1, 
+                        ind2: ind2, 
+                        subfields: [[code, value], ...]
+                    }
+
+                    For example:
+srfsh# request open-ils.cat open-ils.cat.authority.validate.bib_field
+  [{"tag":"600","ind1":"", "ind2":"", "subfields":[["a","shakespeare william"], ...]}]
+                /
+            },
+            {type => 'number', name => 'Optional Control Set ID'},
+        ]
+    }
+);
+
+# for stub records sent to 
+# open-ils.cat.authority.simple_heading
+my $auth_leader = '00000czm a2200205Ka 4500';
+
+sub validate_bib_fields {
+    my ($self, $client, $bib_fields, $control_set) = @_;
+
+    $bib_fields = [$bib_fields] unless ref $bib_fields eq 'ARRAY';
+
+    my $e = new_editor();
+
+    for my $bib_field (@$bib_fields) {
+
+        $bib_field->{valid} = 0;
+
+        my $where = {'+acsbf' => {tag => $bib_field->{tag}}};
+        $where->{'+acsaf'} = {control_set => $control_set} if $control_set;
+
+        my $auth_field_list = $e->json_query({
+            select => {
+                acsbf => ['authority_field'],
+                acsaf => ['id', 'tag', 'sf_list', 'control_set']
+            },
+            from => {acsbf => {acsaf => {}}},
+            where => $where
+        });
+
+        my @seen_subfields;
+        for my $auth_field (@$auth_field_list) {
+
+            my $sf_list = $auth_field->{sf_list};
+
+            # Some auth fields have the same sf_list values.  Track the
+            # ones we've already tested.
+            next if grep {$_ eq $sf_list} @seen_subfields;
+
+            push(@seen_subfields, $sf_list);
+
+            my @sf_values;
+            for my $subfield (@{$bib_field->{subfields}}) {
+                my $code = $subfield->[0];
+                my $value = $subfield->[1];
+
+                next unless defined $value && $value ne '';
+
+                # is this a controlled subfield?
+                next unless index($sf_list, $code) > -1;
+
+                push(@sf_values, $code, $value);
+            }
+
+            next unless @sf_values;
+
+            my $record = MARC::Record->new;
+            $record->leader($auth_leader);
+
+            my $field = MARC::Field->new($auth_field->{tag},
+                $bib_field->{ind1}, $bib_field->{ind2}, @sf_values);
+
+            $record->append_fields($field);
+
+            my $match = $U->simplereq(
+                'open-ils.cat', 
+                'open-ils.cat.authority.simple_heading.from_xml',
+                $record->as_xml_record, $control_set);
+
+            if ($match) {
+                $bib_field->{valid} = 1;
+                $bib_field->{authority_record} = $match;
+                $bib_field->{authority_field} = $auth_field->{id};
+                $bib_field->{control_set} = $auth_field->{control_set};
+                last;
+            }
+        }
+
+        # Present our findings.
+        $client->respond($bib_field);
+    }
+
+    return undef;
+}
+
+
+__PACKAGE__->register_method(
+    method    => "bib_field_authority_linking_browse",
+    api_name  => "open-ils.cat.authority.bib_field.linking_browse",
+    stream => 1,
+    signature => {
+        desc => q/Returns a stream of authority record blobs including
+            information on its main heading and its see froms and see 
+            alsos, based on an axis-based browse search.  This was
+            initially created to move some MARC editor authority linking 
+            logic to the server.  The browse axis is derived from the
+            bib field data provided.
+        ...
+        /,
+        params => [
+            {type => 'object', name => 'MARC Field hash {tag:.,ind1:.,ind2:,subfields:[[code,value],.]}'},
+            {type => 'number', name => 'Page size / limit'},
+            {type => 'number', name => 'Page offset'},
+            {type => 'string', name => 'Optional thesauri, comma separated'}
+        ]
+    }
+);
+
+sub get_heading_string {
+    my $field = shift;
+
+    my $heading = '';
+    for my $subfield ($field->subfields) {
+        $heading .= ' --' if index('xyz', $subfield->[0]) > -1;
+        $heading .= ' ' if $heading;
+        $heading .= $subfield->[1];
+    }
+
+    return $heading;
+}
+
+# Turns a MARC::Field into a hash and adds the field's heading string.
+sub hashify_field {
+    my $field = shift;
+    return {
+        heading => get_heading_string($field),
+        tag => $field->tag,
+        ind1 => $field->indicator(1),
+        ind2 => $field->indicator(2),
+        subfields => [$field->subfields]
+    };
+}
+
+sub bib_field_authority_linking_browse {
+    my ($self, $client, $bib_field, $limit, $offset, $thesauri) = @_;
+
+    $offset ||= 0;
+    $limit ||= 5;
+    $thesauri ||= '';
+    my $e = new_editor();
+
+    return [] unless $bib_field;
+
+    my $term = join(' ', map {$_->[0]} @{$bib_field->{subfields}});
+
+    return [] unless $term;
+
+    my $axis = $e->json_query({
+        select => {abaafm => ['axis']},
+        from => {acsbf => {acsaf => {join => 'abaafm'}}},
+        where => {'+acsbf' => {tag => $bib_field->{tag}}}
+    })->[0];
+
+    return [] unless $axis && ($axis = $axis->{axis});
+
+    # See https://bugs.launchpad.net/evergreen/+bug/1403098
+    my $are_ids = $U->simplereq(
+        'open-ils.supercat',
+        'open-ils.supercat.authority.browse_center.by_axis.refs',
+        $axis, $term, $offset, $limit, $thesauri);
+
+    for my $are_id (@$are_ids) {
+
+        my $are = $e->retrieve_authority_record_entry($are_id);
+        my $rec = MARC::Record->new_from_xml($are->marc, 'UTF-8');
+
+        my $main_field = $rec->field('1..');
+        my $auth_org_field = $rec->field('003');
+        my $auth_org = $auth_org_field ? $auth_org_field->data : undef;
+
+        my $resp = {
+            authority_id => $are_id,
+            main_heading => hashify_field($main_field),
+            auth_org => $auth_org,
+            see_alsos => [],
+            see_froms => []
+        };
+
+        for my $also_field ($rec->field('5..')) {
+            push(@{$resp->{see_alsos}}, hashify_field($also_field));
+        }
+
+        for my $from_field ($rec->field('4..')) {
+            push(@{$resp->{see_froms}}, hashify_field($from_field));
+        }
+
+        $client->respond($resp);
+    }
+
+    return undef;
+}
+
 1;
index b529722..9ca0685 100644 (file)
@@ -18,7 +18,7 @@
             {{main.heading}}
             (<span style="font-family: 'Lucida Console', Monaco, monospace;">
                 <span ng-repeat="sf in main.headingField.subfields">
-                    <span class="marcsfcodedelimiter">‡{{sf.0}}</span> {{sf.1}}
+                    <span class="marcsfcodedelimiter">‡{{sf[0]}}</span> {{sf[1]}}
                 </span>
             </span>)
         </div>
@@ -29,7 +29,7 @@
                     [% l('See from: [_1]', '{{seefrom.heading}}') %]
                     (<span style="font-family: 'Lucida Console', Monaco, monospace;">
                         <span ng-repeat="sf in seefrom.headingField.subfields">
-                            <span class="marcsfcodedelimiter">‡{{sf.0}}</span> {{sf.1}}
+                            <span class="marcsfcodedelimiter">‡{{sf[0]}}</span> {{sf[1]}}
                         </span>
                     </span>)
                 </div>
@@ -40,7 +40,7 @@
                     [% l('See also from: [_1]', '{{seealso.heading}}') %]
                     (<span style="font-family: 'Lucida Console', Monaco, monospace;">
                         <span ng-repeat="sf in seealso.headingField.subfields">
-                            <span class="marcsfcodedelimiter">‡{{sf.0}}</span> {{sf.1}}
+                            <span class="marcsfcodedelimiter">‡{{sf[0]}}</span> {{sf[1]}}
                         </span>
                     </span>)
                 </div>
index 6dd5731..6c31eb5 100644 (file)
@@ -2,7 +2,7 @@
     <div class="row form-inline" style="font-family: 'Lucida Console', Monaco, monospace;">
         {{bibField.tag}} {{bibField.ind1}}{{bibField.ind2}} 
         <div ng-repeat="sf in bibField.subfields">
-            <span class="marcsfcodedelimiter">‡{{sf.0}}</span> {{sf.1}}
+            <span class="marcsfcodedelimiter">‡{{sf[0]}}</span> {{sf[1]}}
             <input type="checkbox" ng-model="sf.selected" ng-if="sf.selectable" />
         </div>
     </div>