--- /dev/null
+<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>
--- /dev/null
+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);
+ });
+ }
+}
+
--- /dev/null
+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 {
+}
--- /dev/null
+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 {}
+
path: 'authority',
loadChildren: () =>
import('./authority/authority.module').then(m => m.AuthorityModule)
+ }, {
+ path: 'marcbatch',
+ loadChildren: () =>
+ import('./marcbatch/marcbatch.module').then(m => m.MarcBatchModule)
}
];
<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>
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>();
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));
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);
+ }
}
@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;
$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;
$actor->request(
'open-ils.actor.anon_cache.set_value', $auth,
batch_edit_progress => {
+ total => $num_total,
succeeded => $num_succeeded,
failed => $num_failed
},
batch_edit_progress => {
complete => 1,
success => 'f',
+ total => $num_total,
succeeded => $num_succeeded,
failed => $num_failed,
}
}
}
}
+ $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,
}
batch_edit_progress => {
complete => 1,
success => 'f',
+ total => $num_total,
succeeded => $num_succeeded,
failed => $num_failed,
}
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;
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;
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...
# 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 {
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;
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');
---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
</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>
--- /dev/null
+MARC Batch Edit UI Angular Port
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+The MARC Batch Edit interface has been ported to Angular.