LP1880726 MARC Batch edit Angular port
authorBill Erickson <berickxx@gmail.com>
Thu, 21 May 2020 16:06:38 +0000 (12:06 -0400)
committerBill Erickson <berickxx@gmail.com>
Fri, 11 Sep 2020 17:53:12 +0000 (13:53 -0400)
Angular port of the MARC Batch Edit interface.  Under the covers, each
bib record is now modified within its own transaction to avoid
long-running transactions that can potentially lock database rows needed
by other processes.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Mike Rylander <mrylander@gmail.com>

12 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/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..d3cbb13
--- /dev/null
@@ -0,0 +1,187 @@
+<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 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..84c7f35
--- /dev/null
@@ -0,0 +1,269 @@
+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';
+
+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;
+    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();
+    }
+
+    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);
+        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);
+        });
+    }
+}
+
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 d4b2770..b352378 100644 (file)
@@ -9,6 +9,10 @@ const routes: Routes = [
     path: 'authority',
     loadChildren: () =>
       import('./authority/authority.module').then(m => m.AuthorityModule)
+  }, {
+    path: 'marcbatch',
+    loadChildren: () =>
+      import('./marcbatch/marcbatch.module').then(m => m.MarcBatchModule)
   }
 ];
 
index a36ce00..7725341 100644 (file)
             <span class="material-icons" aria-hidden="true">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" aria-hidden="true">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 4cfcfbd..36376a0 100644 (file)
@@ -240,12 +240,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;
 
@@ -265,14 +270,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;
 
@@ -294,6 +305,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
                 },
@@ -311,6 +323,7 @@ sub template_overlay_container {
                         batch_edit_progress => {
                             complete => 1,
                             success  => 'f',
+                            total     => $num_total,
                             succeeded => $num_succeeded,
                             failed    => $num_failed,
                         }
@@ -321,19 +334,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,
                 }
@@ -348,6 +365,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 1856498..bf1ad50 100644 (file)
@@ -1,4 +1,3 @@
---002.schema.config.sql:
 INSERT INTO config.bib_source (id, quality, source, transcendant, can_have_copies) VALUES 
     (1, 90, oils_i18n_gettext(1, 'oclc', 'cbs', 'source'), FALSE, TRUE);
 INSERT INTO config.bib_source (id, quality, source, transcendant, can_have_copies) VALUES 
index 48b808f..ac25e56 100644 (file)
             </a>
           </li>
           <li>
-            <a href="./cat/catalog/batchEdit" target="_self">
+            <a href="/eg2/staff/cat/marcbatch">
               <span class="glyphicon glyphicon-edit" aria-hidden="true"></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..b97b578
--- /dev/null
@@ -0,0 +1,3 @@
+MARC Batch Edit UI Angular Port
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The MARC Batch Edit interface has been ported to Angular.