LP1880726 MARC Batch edit Angular port user/berick/lp1880726-marc-batch-angular
authorBill Erickson <berickxx@gmail.com>
Thu, 21 May 2020 16:06:38 +0000 (12:06 -0400)
committerBill Erickson <berickxx@gmail.com>
Tue, 26 May 2020 20:39:45 +0000 (16:39 -0400)
Ports the MARC Batch Edit interface to Angular.  Port includes a new
feature (Per-Record Transactions) which allows for large batches to be
processed with each bib record in its own transaction.  This mitigates
potential conflicts with other cataloging activities.  This setting is
saved in a new workstation preference.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
13 files changed:
Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/marcbatch/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/routing.module.ts
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/eg2/src/app/staff/share/marc-edit/marcrecord.ts
Open-ILS/src/perlmods/lib/OpenILS/Application/Cat.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/TemplateBatchBibUpdate.pm
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.data.marcbatch-prefs.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/navbar.tt2
docs/RELEASE_NOTES_NEXT/Cataloging/marcbatch-ang-port.adoc [new file with mode: 0644]

diff --git a/Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.component.html b/Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.component.html
new file mode 100644 (file)
index 0000000..f7a00ee
--- /dev/null
@@ -0,0 +1,208 @@
+<eg-staff-banner bannerText="MARC Batch Edit" i18n-bannerText></eg-staff-banner>
+
+<div class="row">
+  <div class="col-lg-7 common-form striped-odd">
+    <ng-container *ngFor="let rule of templateRules; let idx = index">
+      <hr *ngIf="idx > 0"/>
+      <div class="row mb-2">
+        <div class="col-lg-3 font-weight-bold" i18n>Rule Setup</div>
+        <div class="col-lg-4 font-weight-bold" i18n>Data</div>
+        <div class="col-lg-5 font-weight-bold" i18n>Help</div>
+      </div>
+      <div class="row mb-2">
+        <div class="col-lg-3" i18n>Action (Rule Type)</div>
+        <div class="col-lg-4">
+          <select class="form-control" [(ngModel)]="rule.ruleType"
+            (change)="rulesetToRecord()">
+            <option value='r' i18n>Replace</option>
+            <option value='a' i18n>Add</option>
+            <option value='d' i18n>Delete</option>
+          </select>
+        </div>
+        <div class="col-lg-5" i18n>How to change the existing record.</div>
+      </div>
+      <div class="row mb-2">
+        <div class="col-lg-3" i18n>MARC Tag</div>
+        <div class="col-lg-4">
+          <input type="text" class="form-control" maxlength="3"
+            (change)="rulesetToRecord(true)" [(ngModel)]="rule.marcTag"/>
+        </div>
+        <div class="col-lg-5" i18n>
+          Three characters, no spaces, no indicators, etc. eg: 245
+        </div>
+      </div>
+      <div class="row mb-2">
+        <div class="col-lg-3" i18n>Subfields (optional)</div>
+        <div class="col-lg-4">
+          <input type="text" class="form-control" 
+            (change)="rulesetToRecord(true)" [(ngModel)]="rule.marcSubfields"/>
+        </div>
+        <div class="col-lg-5" i18n>No spaces, no delimiters, eg: abcnp</div>
+      </div>
+      <div class="row mb-2">
+        <div class="col-lg-3" i18n>MARC Data</div>
+        <div class="col-lg-4">
+          <input type="text" class="form-control" 
+            (change)="rulesetToRecord()" [(ngModel)]="rule.marcData"/>
+        </div>
+        <div class="col-lg-5" i18n>
+          MARC-Breaker formatted data with indicators and subfield delimiters, 
+          eg: 245 04$aThe End
+        </div>
+      </div>
+      <div class="row mt-3 mb-2 pt-2 border-top">
+        <div class="col-lg-12 justify-content-center d-flex">
+          <div class="font-weight-bold" i18n>
+            Advanced Matching Restriction (Optional)
+          </div>
+        </div>
+      </div>
+      <div class="row mb-2">
+        <div class="col-lg-3" i18n>Subfield</div>
+        <div class="col-lg-4">
+          <input type="text" class="form-control" 
+            (change)="rulesetToRecord()" [(ngModel)]="rule.advSubfield"/>
+        </div>
+        <div class="col-lg-5" i18n>
+          A single subfield code, no delimiters, eg: a
+        </div>
+      </div>
+      <div class="row mb-2">
+        <div class="col-lg-3" i18n>Expression</div>
+        <div class="col-lg-4">
+          <input type="text" class="form-control" 
+            (change)="rulesetToRecord()" [(ngModel)]="rule.advRegex"/>
+        </div>
+        <div class="col-lg-5" i18n>
+          See the 
+          <a target="_blank" 
+            href="https://perldoc.perl.org/perlre.html#Regular-Expressions">
+            Perl documentation
+          </a> for an explanation of Regular Expressions.
+        </div>
+      </div>
+      <div class="row mb-2">
+        <div class="col-lg-12 d-flex justify-content-end">
+          <button class="btn btn-outline-danger label-with-material-icon"
+            (click)="removeRule(idx)" i18n>
+            <span>Remove this Merge Rule</span>
+            <span class="material-icons ml-2">delete</span>
+          </button>
+        </div>
+      </div>
+    </ng-container>
+    <div class="row mb-2">
+      <div class="col-lg-6">
+        <button class="btn btn-outline-dark label-with-material-icon" 
+          (click)="addRule()">
+          <span i18n>Add a New Merge Rule</span>
+          <span class="material-icons ml-2">arrow_downward</span>
+        </button>
+      </div>
+    </div>
+  </div>
+  <div class="col-lg-5">
+    <div class="row pb-2 pt-2 border">
+      <div class="col-lg-12">
+        <div class="font-weight-bold" i18n>Merge Template Preview</div>
+        <div>
+          <textarea class="form-control" [ngModel]="breaker()" 
+            disabled rows="{{breakerRows()}}"></textarea>
+        </div>
+      </div>
+    </div>
+    <div class="row mt-2">
+      <div class="col-lg-3" i18n>Record Source: </div>
+      <div class="col-lg-6">
+        <select class="form-control" [(ngModel)]="source">
+          <option value='b' i18n>Bucket</option>
+          <option value='c' i18n>CSV File</option>
+          <option value='r' i18n>Bib Record ID</option>
+        </select>
+      </div>
+    </div>
+    <div class="row mt-2 pt-2 pb-2 border">
+      <ng-container *ngIf="source == 'b'">
+        <div class="col-lg-3" i18n>Bucket named: </div>
+        <div class="col-lg-6">
+          <eg-combobox [entries]="buckets" (onChange)="bucketChanged($event)">
+          </eg-combobox>
+        </div>
+      </ng-container>
+      <ng-container *ngIf="source == 'r'">
+        <div class="col-lg-3" i18n>Record ID: </div>
+        <div class="col-lg-3">
+          <input type="text" class="form-control" [(ngModel)]="recordId"/>
+        </div>
+      </ng-container>
+      <ng-container *ngIf="source == 'c'">
+        <div class="col-lg-12">
+          <div class="row">
+            <div class="col-lg-3" i18n>Column: </div>
+            <div class="col-lg-3 d-flex">
+              <input type="number" class="form-control" [(ngModel)]="csvColumn"/>
+              <span class="pl-2" i18n> of </span>
+            </div>
+            <div class="col-lg-6">
+              <input type="file" class="form-control"
+                #fileSelector (change)="fileSelected($event)"/>
+            </div>
+          </div>
+          <div class="row pt-2">
+            <div class="col-lg-12" i18n>
+              Columns are numbered starting at 0. For instance, when looking 
+              at a CSV file in Excel, the column labeled A is the same as 
+              column 0, and the column labeled B is the same as column 1.
+            </div>
+          </div>
+        </div>
+      </ng-container>
+    </div>
+    <div class="row mt-2">
+      <div class="col-lg-12">
+        <div class="form-check form-check-inline">
+          <input class="form-check-input" type="checkbox" 
+            (change)="setPerXactPref()"
+            id="xact-checkbox" [(ngModel)]="xactPerRecord"/>
+          <label class="form-check-label" for="xact-checkbox" i18n>
+            Per-Record Transactions?
+          </label>
+        </div>
+      </div>
+    </div>
+    <div class="row mt-2">
+      <div class="col-lg-12" i18n>
+        By default, all records are processed within a single database
+        transaction.  However, processing large batches of records
+        within a single transaction can negatively impact a running
+        system as it may conflict with other cataloging activities.
+        Use Per-Record transactions to mitigate these conflicts.
+      </div>
+    </div>
+    <div class="row mt-2 pt-2 pb-2 border">
+      <div class="col-lg-12">
+        <button class="btn btn-outline-dark" 
+          [disabled]="disableSave()" (click)="process()" i18n>Go!</button>
+      </div>
+    </div>
+    <div class="row mt-2 p-2" *ngIf="processing">
+      <div class="col-lg-10 offset-lg-1">
+        <eg-progress-inline [max]="progressMax" [value]="progressValue">
+        </eg-progress-inline>
+      </div>
+    </div>
+    <div class="row mt-2 p-2" *ngIf="!processing && progressMax">
+      <div class="col-lg-12 alert alert-success">
+        <div i18n>Processing Complete</div>
+        <div class="row">
+          <div class="col-lg-3" i18n>Success count: </div>
+          <div class="col-lg-3">{{this.numSucceeded}}</div>
+        </div>
+        <div class="row">
+          <div class="col-lg-3" i18n>Failed count: </div>
+          <div class="col-lg-3">{{this.numFailed}}</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.component.ts
new file mode 100644 (file)
index 0000000..cdc5161
--- /dev/null
@@ -0,0 +1,284 @@
+import {Component, OnInit, ViewChild, Renderer2} from '@angular/core';
+import {Router, ActivatedRoute, ParamMap} from '@angular/router';
+import {HttpClient} from '@angular/common/http';
+import {tap} from 'rxjs/operators';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {MarcRecord, MarcField} from '@eg/staff/share/marc-edit/marcrecord';
+import {AnonCacheService} from '@eg/share/util/anon-cache.service';
+import {ServerStoreService} from '@eg/core/server-store.service';
+
+const SESSION_POLL_INTERVAL = 2; // seconds
+const MERGE_TEMPLATE_PATH = '/opac/extras/merge_template';
+const PREF_XACT_PER_RECORD = 'eg.cat.marcbatch.xact_per_record';
+
+interface TemplateRule {
+    ruleType: 'r' | 'a' | 'd';
+    marcTag?: string;
+    marcSubfields?: string;
+    marcData?: string;
+    advSubfield?: string;
+    advRegex?: string;
+}
+
+@Component({
+  templateUrl: 'marcbatch.component.html'
+})
+export class MarcBatchComponent implements OnInit {
+
+    session: string;
+    source: 'b' | 'c' | 'r' = 'b';
+    buckets: ComboboxEntry[];
+    bucket: number;
+    recordId: number;
+    csvColumn = 0;
+    csvFile: File;
+    xactPerRecord = false;
+    templateRules: TemplateRule[] = [];
+    record: MarcRecord;
+
+    processing = false;
+    progressMax: number = null;
+    progressValue: number = null;
+    numSucceeded = 0;
+    numFailed = 0;
+
+    constructor(
+        private router: Router,
+        private route: ActivatedRoute,
+        private http: HttpClient,
+        private renderer: Renderer2,
+        private net: NetService,
+        private pcrud: PcrudService,
+        private auth: AuthService,
+        private store: ServerStoreService,
+        private cache: AnonCacheService
+    ) {}
+
+    ngOnInit() {
+        this.load();
+    }
+
+    load() {
+        this.addRule();
+        this.getBuckets();
+
+        this.store.getItem(PREF_XACT_PER_RECORD)
+        .then(value => this.xactPerRecord = value);
+    }
+
+    rulesetToRecord(resetRuleData?: boolean) {
+        this.record = new MarcRecord();
+
+        this.templateRules.forEach(rule => {
+
+            if (!rule.marcTag) { return; }
+
+            let ruleText = rule.marcTag + (rule.marcSubfields || '');
+            if (rule.advSubfield) {
+                ruleText +=
+                    `[${rule.advSubfield || ''} ~ ${rule.advRegex || ''}]`;
+            }
+
+            // Merge behavior is encoded in the 905 field.
+            const ruleTag = this.record.newField({
+                tag: '905',
+                ind1: ' ',
+                ind2: ' ',
+                subfields: [[rule.ruleType, ruleText, 0]]
+            });
+
+            this.record.insertOrderedFields(ruleTag);
+
+            if (rule.ruleType === 'd') {
+                rule.marcData = '';
+                return;
+            }
+
+            const dataRec = new MarcRecord();
+            if (resetRuleData || !rule.marcData) {
+
+                // Build a new value for the 'MARC Data' field based on
+                // changes to the selected tag or subfields.
+
+                const subfields = rule.marcSubfields ?
+                    rule.marcSubfields.split('').map((sf, idx) => [sf, '', idx])
+                    : [];
+
+                dataRec.appendFields(
+                    dataRec.newField({
+                        tag: rule.marcTag,
+                        ind1: ' ',
+                        ind2: ' ',
+                        subfields: subfields
+                    })
+                );
+
+                console.log(dataRec.toBreaker());
+                rule.marcData = dataRec.toBreaker().split(/\n/)[1];
+
+            } else {
+
+                // Absorb the breaker data already in the 'MARC Data' field
+                // so it can be added to the template record in progress.
+
+                dataRec.breakerText = rule.marcData;
+                dataRec.absorbBreakerChanges();
+            }
+
+            this.record.appendFields(dataRec.fields[0]);
+        });
+    }
+
+    breakerRows(): number {
+        if (this.record) {
+            const breaker = this.record.toBreaker();
+            if (breaker) {
+                return breaker.split(/\n/).length + 1;
+            }
+        }
+        return 3;
+    }
+
+    breaker(): string {
+        return this.record ? this.record.toBreaker() : '';
+    }
+
+    addRule() {
+        this.templateRules.push({ruleType: 'r'});
+    }
+
+    removeRule(idx: number) {
+        this.templateRules.splice(idx, 1);
+    }
+
+    getBuckets(): Promise<any> {
+        if (this.buckets) { return Promise.resolve(); }
+
+        return this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.container.retrieve_by_class',
+            this.auth.token(), this.auth.user().id(),
+            'biblio', ['staff_client', 'vandelay_queue']
+
+        ).pipe(tap(buckets => {
+            this.buckets = buckets
+            .sort((b1, b2) => b1.name() < b2.name() ? -1 : 1)
+            .map(b => ({id: b.id(), label: b.name()}));
+
+        })).toPromise();
+    }
+
+    bucketChanged(entry: ComboboxEntry) {
+        this.bucket = entry ? entry.id : null;
+    }
+
+    fileSelected($event) {
+       this.csvFile = $event.target.files[0];
+    }
+
+    disableSave(): boolean {
+        if (!this.record || !this.source || this.processing) {
+            return true;
+        }
+
+        if (this.source === 'b') {
+            return !this.bucket;
+
+        } else if (this.source === 'c') {
+            return (!this.csvColumn || !this.csvFile);
+
+        } else if (this.source === 'r') {
+            return !this.recordId;
+        }
+    }
+
+    process() {
+        this.processing = true;
+        this.progressValue = null;
+        this.progressMax = null;
+        this.numSucceeded = 0;
+        this.numFailed = 0;
+        this.setReplaceMode();
+        this.postForm().then(_ => this.pollProgress());
+    }
+
+    setReplaceMode() {
+        if (this.record.subfield('905', 'r').length === 0) {
+            // Force replace mode w/ no-op replace rule.
+            this.record.appendFields(
+                this.record.newField({
+                    tag : '905',
+                    ind1 : ' ',
+                    ind2 : ' ',
+                    subfields : [['r', '901c']]
+                })
+            );
+        }
+    }
+
+    postForm(): Promise<any> {
+
+        const formData: FormData = new FormData();
+        formData.append('ses', this.auth.token());
+        formData.append('skipui', '1');
+        formData.append('template', this.record.toXml());
+        formData.append('recordSource', this.source);
+        if (this.xactPerRecord) {
+            formData.append('xactPerRecord', '1');
+        }
+
+        if (this.source === 'b') {
+            formData.append('containerid', this.bucket + '');
+
+        } else if (this.source === 'c') {
+            formData.append('idcolumn', this.csvColumn + '');
+            formData.append('idfile', this.csvFile, this.csvFile.name);
+
+        } else if (this.source === 'r') {
+            formData.append('recid', this.recordId + '');
+        }
+
+        return this.http.post(
+            MERGE_TEMPLATE_PATH, formData, {responseType: 'text'})
+        .pipe(tap(cacheKey => this.session = cacheKey))
+        .toPromise();
+    }
+
+    pollProgress(): Promise<any> {
+        console.debug('Polling session ', this.session);
+
+        return this.cache.getItem(this.session, 'batch_edit_progress')
+        .then(progress => {
+            // {"success":"t","complete":1,"failed":0,"succeeded":252}
+
+            if (!progress) {
+                console.error('No batch edit session found for ', this.session);
+                return;
+            }
+
+            this.progressValue = progress.succeeded;
+            this.progressMax = progress.total;
+            this.numSucceeded = progress.succeeded;
+            this.numFailed = progress.failed;
+
+            if (progress.complete) {
+                this.processing = false;
+                return;
+            }
+
+            setTimeout(() => this.pollProgress(), SESSION_POLL_INTERVAL * 1000);
+        });
+    }
+
+    setPerXactPref() {
+        if (this.xactPerRecord) {
+            this.store.setItem(PREF_XACT_PER_RECORD, true);
+        } else {
+            this.store.removeItem(PREF_XACT_PER_RECORD);
+        }
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/marcbatch/marcbatch.module.ts
new file mode 100644 (file)
index 0000000..bf81553
--- /dev/null
@@ -0,0 +1,23 @@
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
+import {MarcBatchRoutingModule} from './routing.module';
+import {MarcBatchComponent} from './marcbatch.component';
+import {HttpClientModule} from '@angular/common/http';
+
+@NgModule({
+  declarations: [
+    MarcBatchComponent
+  ],
+  imports: [
+    StaffCommonModule,
+    HttpClientModule,
+    CommonWidgetsModule,
+    MarcBatchRoutingModule
+  ],
+  providers: [
+  ]
+})
+
+export class MarcBatchModule {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/marcbatch/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/marcbatch/routing.module.ts
new file mode 100644 (file)
index 0000000..bb268e6
--- /dev/null
@@ -0,0 +1,17 @@
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {MarcBatchComponent} from './marcbatch.component';
+
+const routes: Routes = [{
+    path: '',
+    component: MarcBatchComponent
+}];
+
+@NgModule({
+  imports: [RouterModule.forChild(routes)],
+  exports: [RouterModule],
+  providers: []
+})
+
+export class MarcBatchRoutingModule {}
+
index 67fb59b..60d87b5 100644 (file)
@@ -7,6 +7,9 @@ const routes: Routes = [
   }, {
     path: 'authority',
     loadChildren: '@eg/staff/cat/authority/authority.module#AuthorityModule'
+  }, {
+    path: 'marcbatch',
+    loadChildren: '@eg/staff/cat/marcbatch/marcbatch.module#MarcBatchModule'
   }
 ];
 
index 5310b5b..59b7ac2 100644 (file)
             <span class="material-icons">import_export</span>
             <span i18n>MARC Batch Import/Export</span>
           </a>
-          <a href="/eg/staff/cat/catalog/batchEdit" class="dropdown-item">
+          <a routerLink="/staff/cat/marcbatch" class="dropdown-item">
             <span class="material-icons">format_paint</span>
             <span i18n>MARC Batch Edit</span>
           </a>
index d49f4ef..493eaad 100644 (file)
@@ -60,7 +60,7 @@ export class MarcRecord {
         this.record.fields = f;
     }
 
-    constructor(xml: string) {
+    constructor(xml?: string) {
         this.record = new MARC21.Record({marcxml: xml, delimiter: DELIMITER});
         this.breakerText = this.record.toBreaker();
         this.fixedFieldChange = new EventEmitter<string>();
@@ -113,6 +113,11 @@ export class MarcRecord {
         return this.record.field(spec, wantArray);
     }
 
+    appendFields(...newFields: MarcField[]) {
+        this.record.appendFields.apply(this.record, newFields);
+        this.stampFieldIds();
+    }
+
     insertFieldsBefore(field: MarcField, ...newFields: MarcField[]) {
         this.record.insertFieldsBefore.apply(
             this.record, [field].concat(newFields));
@@ -186,5 +191,10 @@ export class MarcRecord {
         subfields.forEach(sf => root.push([].concat(sf)));
         return root;
     }
+
+    // Returns a list of values for the tag + subfield combo
+    subfield(tag: string, subfield: string): string {
+        return this.record.subfield(tag, subfield);
+    }
 }
 
index 4c4d977..1df2d6e 100644 (file)
@@ -238,12 +238,17 @@ __PACKAGE__->register_method(
         @param auth The authtoken
         @param container The container, um, containing the records to be updated by the template
         @param template The overlay template, or nothing and the method will look for a negative bib id in the container
+        @param options Hash of options; currently supports:
+            xact_per_record: Apply updates to each bib record within its own transaction.
         @return Cache key to check for status of the container overlay
     #
 );
 
 sub template_overlay_container {
-    my($self, $conn, $auth, $container, $template) = @_;
+    my($self, $conn, $auth, $container, $template, $options) = @_;
+    $options ||= {};
+    my $xact_per_rec = $options->{xact_per_record};
+
     my $e = new_editor(authtoken=>$auth, xact=>1);
     return $e->die_event unless $e->checkauth;
 
@@ -263,14 +268,20 @@ sub template_overlay_container {
         $template = $e->retrieve_biblio_record_entry( $titem->target_biblio_record_entry )->marc;
     }
 
+    my $num_total = scalar(@$items);
     my $num_failed = 0;
     my $num_succeeded = 0;
 
     $conn->respond_complete(
-        $actor->request('open-ils.actor.anon_cache.set_value', $auth, batch_edit_progress => {})->gather(1)
+        $actor->request('open-ils.actor.anon_cache.set_value', $auth, 
+            batch_edit_progress => {total => $num_total})->gather(1)
     ) if ($actor);
 
+    # read-only up to here.
+    $e->rollback if $xact_per_rec;
+
     for my $item ( @$items ) {
+        $e->xact_begin if $xact_per_rec;
         my $rec = $e->retrieve_biblio_record_entry($item->target_biblio_record_entry);
         next unless $rec;
 
@@ -291,6 +302,7 @@ sub template_overlay_container {
             $actor->request(
                 'open-ils.actor.anon_cache.set_value', $auth,
                 batch_edit_progress => {
+                    total     => $num_total,
                     succeeded => $num_succeeded,
                     failed    => $num_failed
                 },
@@ -308,6 +320,7 @@ sub template_overlay_container {
                         batch_edit_progress => {
                             complete => 1,
                             success  => 'f',
+                            total     => $num_total,
                             succeeded => $num_succeeded,
                             failed    => $num_failed,
                         }
@@ -318,19 +331,23 @@ sub template_overlay_container {
                 }
             }
         }
+        $e->xact_commit if $xact_per_rec;
     }
 
     if ($titem && !$num_failed) {
+        $e->xact_begin if $xact_per_rec;
         return $e->die_event unless ($e->delete_container_biblio_record_entry_bucket_item($titem));
+        $e->xact_commit if $xact_per_rec;
     }
 
-    if ($e->commit) {
+    if ($xact_per_rec || $e->commit) {
         if ($actor) {
             $actor->request(
                 'open-ils.actor.anon_cache.set_value', $auth,
                 batch_edit_progress => {
                     complete => 1,
                     success  => 't',
+                    total     => $num_total,
                     succeeded => $num_succeeded,
                     failed    => $num_failed,
                 }
@@ -345,6 +362,7 @@ sub template_overlay_container {
                 batch_edit_progress => {
                     complete => 1,
                     success  => 'f',
+                    total     => $num_total,
                     succeeded => $num_succeeded,
                     failed    => $num_failed,
                 }
index 27a03e1..ae91b36 100644 (file)
@@ -4,7 +4,7 @@ use warnings;
 use bytes;
 
 use Apache2::Log;
-use Apache2::Const -compile => qw(OK REDIRECT DECLINED NOT_FOUND :log);
+use Apache2::Const -compile => qw(OK REDIRECT DECLINED NOT_FOUND HTTP_BAD_REQUEST :log);
 use APR::Const    -compile => qw(:error SUCCESS);
 use APR::Table;
 
@@ -56,14 +56,21 @@ sub handler {
     my $cgi = new CGI;
 
     my $authid = $cgi->cookie('ses') || $cgi->param('ses');
+
+    # Avoid sending the HTML to the caller.  Final response will
+    # will just be the cache key or HTTP_BAD_REQUEST on error.
+    my $skipui = $cgi->param('skipui');
+
     my $usr = verify_login($authid);
-    return show_template($r) unless ($usr);
+    return show_template($r, $skipui) unless ($usr);
 
     my $template = $cgi->param('template');
-    return show_template($r) unless ($template);
+    return show_template($r, $skipui) unless ($template);
 
 
     my $rsource = $cgi->param('recordSource');
+    my $xact_per = $cgi->param('xactPerRecord');
+
     # find some IDs ...
     my @records;
 
@@ -118,7 +125,7 @@ sub handler {
     unless (@records) {
         $e->request('open-ils.cstore.transaction.rollback')->gather(1);
         $e->disconnect;
-        return show_template($r);
+        return show_template($r, $skipui);
     }
 
     # we have a template and some record ids, so...
@@ -173,10 +180,12 @@ sub handler {
     # fire the background bucket processor
     my $cache_key = OpenSRF::AppSession
         ->create('open-ils.cat')
-        ->request('open-ils.cat.container.template_overlay.background', $authid, $bucket->id)
+        ->request('open-ils.cat.container.template_overlay.background', 
+            $authid, $bucket->id, undef, {xact_per_record => $xact_per})
         ->gather(1);
 
-    return show_processing_template($r, $bucket->id, \@records, $cache_key);
+    return show_processing_template(
+      $r, $bucket->id, \@records, $cache_key, $skipui);
 }
 
 sub verify_login {
@@ -201,6 +210,13 @@ sub show_processing_template {
     my $bid = shift;
     my $recs = shift;
     my $cache_key = shift;
+    my $skipui = shift;
+
+    if ($skipui) {
+        $r->content_type('text/plain');
+        $r->print($cache_key);
+        return Apache2::Const::OK;
+    }
 
     my $rec_string = @$recs;
 
@@ -366,6 +382,11 @@ HTML
 
 sub show_template {
     my $r = shift;
+    my $skipui = shift;
+
+    # Makes no sense to call the API in such a way that the caller
+    # is returned the UI code if skipui is set.
+    return Apache2::Const::HTTP_BAD_REQUEST if $skipui;
 
     $r->content_type('text/html');
     $r->print(<<'HTML');
index b6959bb..4f29979 100644 (file)
@@ -20414,4 +20414,13 @@ VALUES (
     )
 );
 
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.cat.marcbatch.xact_per_record', 'gui', 'bool',
+    oils_i18n_gettext(
+        'eg.cat.marcbatch.xact_per_record',
+        'MARC Batch Edit Uses Per-Record Transactions',
+        'cwst', 'label'
+    )
+);
 
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.marcbatch-prefs.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.marcbatch-prefs.sql
new file mode 100644 (file)
index 0000000..e19f0c1
--- /dev/null
@@ -0,0 +1,15 @@
+BEGIN;
+
+-- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.cat.marcbatch.xact_per_record', 'gui', 'bool',
+    oils_i18n_gettext(
+        'eg.cat.marcbatch.xact_per_record',
+        'MARC Batch Edit Uses Per-Record Transactions',
+        'cwst', 'label'
+    )
+);
+
+COMMIT;
index 220c3d1..4de364c 100644 (file)
             </a>
           </li>
           <li>
-            <a href="./cat/catalog/batchEdit" target="_self">
+            <a href="/eg2/staff/cat/marcbatch">
               <span class="glyphicon glyphicon-edit"></span>
               [% l('MARC Batch Edit') %]
             </a>
diff --git a/docs/RELEASE_NOTES_NEXT/Cataloging/marcbatch-ang-port.adoc b/docs/RELEASE_NOTES_NEXT/Cataloging/marcbatch-ang-port.adoc
new file mode 100644 (file)
index 0000000..367946d
--- /dev/null
@@ -0,0 +1,8 @@
+MARC Batch Edit UI Angular Port
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The MARC Batch Edit interface has been ported to Angular.  Port includes a
+new feature (Per-Record Transactions) which allows for large batches to be 
+processed with each bib record in it own transaction.  This mitigates
+potential conflicts with other cataloging activities.  This setting is 
+saved in a new workstation preference.
+