From: Bill Erickson <berickxx@gmail.com> Date: Mon, 16 Dec 2019 15:40:01 +0000 (-0500) Subject: LP1852782 MARC editor authority linking support X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=90d93ea18314597f7a31a0450f8f7f652d26864e;p=evergreen%2Fequinox.git LP1852782 MARC editor authority linking support 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> --- diff --git a/Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html index 2c595487f6..de4d29cee8 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/buckets/bucket-dialog.component.html @@ -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">×</span> </button> @@ -18,13 +18,13 @@ <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> @@ -33,13 +33,13 @@ <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 index 0000000000..c0e9558433 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.html @@ -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">×</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 index 0000000000..2015ec187c --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/authority-linking-dialog.component.ts @@ -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; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css index e21bb843d8..4778fbe9f2 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.css @@ -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; } diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html index 07b67767a6..cba8925f03 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.html @@ -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()" diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts index 2eeadf16e4..88be84c592 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editable-content.component.ts @@ -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; + } } diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts index ae7af497c4..16a8caeecc 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/editor-context.ts @@ -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; + } } } diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts index c7bbaba481..e34928dd48 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marc-edit.module.ts @@ -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, diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts index cdc99aac4b..d49f4ef568 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts @@ -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; diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css index 5c90f28b9b..e3c973c1df 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.css @@ -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; +} diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html index a7ca33f082..68e0f68073 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.html @@ -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"> @@ -33,6 +34,31 @@ </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()"> @@ -44,21 +70,20 @@ <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}}"> @@ -149,21 +174,27 @@ <!-- 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> diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts index a1a7d552eb..480c7ea5b2 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/rich-editor.component.ts @@ -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); + }); + } } diff --git a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts index e3571b1322..3d2fc7370f 100644 --- a/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts +++ b/Open-ILS/src/eg2/src/app/staff/share/marc-edit/tagtable.service.ts @@ -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); + } } diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm index 66c6c4a9eb..f601c0e585 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Cat/Authority.pm @@ -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; diff --git a/Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2 b/Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2 index b52972233b..9ca06859d1 100644 --- a/Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2 +++ b/Open-ILS/src/templates/staff/cat/share/t_authority_browser.tt2 @@ -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> diff --git a/Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2 b/Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2 index 6dd57311c5..6c31eb5b1d 100644 --- a/Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2 +++ b/Open-ILS/src/templates/staff/cat/share/t_authority_linker.tt2 @@ -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>