--- /dev/null
+<!-- how should we do i18n with eg-staff-banner? -->
+<div class="lead alert alert-primary text-center pt-1 pb-1">
+ <span class="align-middle">
+ {batches, plural, =0 {Attempts for All Batches} =1 {Attempts for Batch ID {{batches}}} other {Attempts for Batch IDs {{batches}}}}
+ </span>
+</div>
+
+<eg-title i18n-prefix prefix="Batch Attempts">
+</eg-title>
+
+<eg-grid #grid idlClass="uvuv"
+ persistKey="catalog.link_checker.attempt"
+ [dataSource]="dataSource"
+ (rowSelectionChange)="gridSelectionChange($event)"
+ hideFields="url"
+ [sortable]="true" [filterable]="true" [allowNamedFilterSets]="true"
+ [migrateLegacyFilterSets]="'url_verify'">
+
+ <eg-grid-column path="id">
+ </eg-grid-column>
+ <eg-grid-column path="url.item.target_biblio_record_entry.simple_record.title">
+ </eg-grid-column>
+ <eg-grid-column path="url.item.target_biblio_record_entry.simple_record.author">
+ </eg-grid-column>
+ <eg-grid-column path="url" label="URL ID" i18n-label>
+ </eg-grid-column>
+ <eg-grid-column path="url.full_url">
+ </eg-grid-column>
+ <eg-grid-column path="url.tag">
+ </eg-grid-column>
+ <eg-grid-column path="url.subfield">
+ </eg-grid-column>
+ <eg-grid-column path="url.ord">
+ </eg-grid-column>
+ <eg-grid-column path="attempt" label="Batch ID" i18n-label>
+ </eg-grid-column>
+</eg-grid>
--- /dev/null
+import * as Moment from 'moment-timezone';
+import {ActivatedRoute} from '@angular/router';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {Component, OnInit, ViewChild, Input, TemplateRef} from '@angular/core';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {FormGroup, FormControl} from '@angular/forms';
+import {FormatService} from '@eg/core/format.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridColumn, GridRowFlairEntry, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {GridFlatDataService} from '@eg/share/grid/grid-flat-data.service';
+import {HtmlToTxtService} from '@eg/share/util/htmltotxt.service';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NgbDate} from '@ng-bootstrap/ng-bootstrap';
+import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
+import {OrgService} from '@eg/core/org.service';
+import {Pager} from '@eg/share/util/pager';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {PermService} from '@eg/core/perm.service';
+import {PrintService} from '@eg/share/print/print.service';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {map, take} from 'rxjs/operators';
+import {timer as observableTimer, Observable, of} from 'rxjs';
+
+@Component({
+ templateUrl: 'attempts.component.html'
+})
+export class LinkCheckerAttemptsComponent implements OnInit {
+
+ batches: number[] = [];
+
+ batchesIdlClass = 'uvva';
+ batchesSessionField = 'session';
+
+ attemptsIdlClass = 'uvuv';
+ attemptsSortField = 'id';
+ attemptsBatchField = 'attempt';
+ attemptsFleshFields = {
+ 'uvuv' : ['url','attempt'],
+ 'uvu' : ['item','url_selector','redirect_from'],
+ 'uvsbrem' : ['target_biblio_record_entry'],
+ 'bre' : ['simple_record']
+ };
+ attemptsFleshDepth = 4;
+ attemptsIdlClassDef: any;
+ attemptsPKeyField: string;
+
+ attemptsPermaCrud: any;
+ attemptsPerms: string;
+
+ @ViewChild('grid', { static: true }) grid: GridComponent;
+ dataSource: GridDataSource = new GridDataSource();
+ noSelectedRows: boolean;
+ oneSelectedRow: boolean;
+
+ constructor(
+ private auth: AuthService,
+ private flatData: GridFlatDataService,
+ private format: FormatService,
+ private idl: IdlService,
+ private org: OrgService,
+ private pcrud: PcrudService,
+ private perm: PermService,
+ private route: ActivatedRoute,
+ ) {}
+
+ ngOnInit() {
+ this.route.queryParams.subscribe( params => {
+ if (params.batches) {
+ this.batches = JSON.parse( params.batches );
+ }
+ if (params.sessions) {
+ if (this.batches.length === 0) {
+ // TODO: make initDataSource wait for this optional parameter to be fielded when available
+ // Maybe it would be easier to have the dialog caller convert sessions to batches instead.
+ // For now, this is a kludge to prevent empty-in list error if/when grid loads before batches is populated.
+ this.batches.push(-1);
+ }
+ var batchSearch = {};
+ batchSearch[ this.batchesSessionField ] = JSON.parse( params.sessions );
+ this.pcrud.search(this.batchesIdlClass,batchSearch).subscribe(
+ (batch) => {
+ if (this.batches.length === 1 && this.batches[0] === -1) {
+ this.batches = []; // undo our kludge, otherwise -1 would be in the dialog title
+ }
+ this.batches.push( batch.id() );
+ },
+ (err) => {
+ console.log('pcrud.search.uvs err', err);
+ },
+ () => {
+ this.batches = Array.from( new Set( this.batches ) );
+ this.grid.reload();
+ }
+ );
+ }
+ });
+
+ this.attemptsIdlClassDef = this.idl.classes[this.attemptsIdlClass];
+ this.attemptsPKeyField = this.attemptsIdlClassDef.pkey || 'id';
+
+ this.attemptsPermaCrud = this.attemptsIdlClassDef.permacrud || {};
+ if (this.attemptsPermaCrud.retrieve) {
+ this.attemptsPerms = this.attemptsPermaCrud.retrieve.perms;
+ }
+
+ this.initDataSource();
+ this.gridSelectionChange( [] );
+ }
+
+ gridSelectionChange(keys: string[]) {
+ this.noSelectedRows = (keys.length === 0);
+ this.oneSelectedRow = (keys.length === 1);
+ var rows = this.grid.context.getSelectedRows();
+ }
+
+ initDataSource() {
+ this.dataSource.getRows = (pager: Pager, sort: any[]) => {
+
+ let query: any = {}
+
+ if (this.batches) {
+ query[this.attemptsBatchField] = this.batches;
+ }
+
+ let query_filters = [];
+ Object.keys(this.dataSource.filters).forEach(key => {
+ query_filters = query_filters.concat( this.dataSource.filters[key] );
+ });
+
+ if (query_filters.length > 0) {
+ query['-and'] = query_filters;
+ }
+
+ return this.flatData.getRows(
+ this.grid.context, query, pager, sort);
+ };
+ }
+
+}
--- /dev/null
+
+<eg-staff-banner bannerText="Link Checker" i18n-bannerText>
+</eg-staff-banner>
+<eg-title i18n-prefix prefix="Link Checker">
+</eg-title>
+
+<ng-container>
+ <div class="row">
+ <div class="col-lg-6">
+ <ng-container>
+ <eg-org-family-select
+ [limitPerms]="viewPerms"
+ [selectedOrgId]="contextOrg.id()"
+ [(ngModel)]="searchOrgs"
+ (ngModelChange)="grid.reload()">
+ </eg-org-family-select>
+ </ng-container>
+ </div>
+ </div>
+ <hr/>
+</ng-container>
+
+<eg-grid #grid idlClass="uvsa"
+ persistKey="catalog.link_checker"
+ [dataSource]="dataSource"
+ (rowSelectionChange)="gridSelectionChange($event)"
+ autoGeneratedColumnOrder="session_id,name,batch_id,search,start_time,finish_time"
+ hideFields="create_time,creator,usr,container,id,owning_lib"
+ [sortable]="true" [filterable]="true"
+ [allowNamedFilterSets]="true"
+ [migrateLegacyFilterSets]="'url_verify'">
+ <eg-grid-toolbar-button
+ label="New Session" i18n-label
+ [disabled]="!canCreateSession"
+ (onClick)="newSessionWrapper()">
+ </eg-grid-toolbar-button>
+
+ <eg-grid-toolbar-button
+ label="View Session URLs" i18n-label
+ [disabled]="noSelectedRows"
+ (onClick)="viewSessionUrls()">
+ </eg-grid-toolbar-button>
+
+ <eg-grid-toolbar-button
+ label="View Session Attempts" i18n-label
+ [disabled]="!onlyBatchesSelected"
+ (onClick)="viewSessionAttempts()">
+ </eg-grid-toolbar-button>
+
+ <eg-grid-toolbar-button
+ label="View Batch Attempts" i18n-label
+ [disabled]="!onlyBatchesSelected"
+ (onClick)="viewBatchAttempts()">
+ </eg-grid-toolbar-button>
+
+ <eg-grid-toolbar-action
+ label="Clone Selected Session" i18n-label
+ [disabled]="!oneSelectedRow"
+ (onClick)="cloneSelectedSession()">
+ </eg-grid-toolbar-action>
+
+ <eg-grid-toolbar-action isSeparator="true"></eg-grid-toolbar-action>
+
+ <eg-grid-toolbar-action
+ label="Delete Selected Sessions" i18n-label
+ [disabled]="noSelectedRows"
+ (onClick)="deleteSelectedSessions()">
+ </eg-grid-toolbar-action>
+
+</eg-grid>
+
+<eg-new-session-dialog #newSessionDialog>
+</eg-new-session-dialog>
+
+<eg-string #createSuccessString text="New session created." i18n-text></eg-string>
+<eg-string #createFailedString text="Session creation failed." i18n-text></eg-string>
+
+<eg-confirm-dialog #deleteSessionConfirmDialog
+ i18n-dialogTitle i18n-dialogBody
+ dialogTitle="Session Deletion"
+ dialogBody="Delete selected session(s) and associated URLs, batches, and batch attempts?">
+</eg-confirm-dialog>
+
--- /dev/null
+import * as Moment from 'moment-timezone';
+import {Router, ActivatedRoute} from '@angular/router';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {Component, OnInit, ViewChild, Input, TemplateRef} from '@angular/core';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {NewSessionDialogComponent} from './new-session-dialog.component';
+import {FormGroup, FormControl} from '@angular/forms';
+import {FormatService} from '@eg/core/format.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridColumn, GridRowFlairEntry, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {GridFlatDataService} from '@eg/share/grid/grid-flat-data.service';
+import {HtmlToTxtService} from '@eg/share/util/htmltotxt.service';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {NgbDate} from '@ng-bootstrap/ng-bootstrap';
+import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
+import {OrgService} from '@eg/core/org.service';
+import {Pager} from '@eg/share/util/pager';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {PermService} from '@eg/core/perm.service';
+import {PrintService} from '@eg/share/print/print.service';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {map, take} from 'rxjs/operators';
+import {timer as observableTimer, Observable, of} from 'rxjs';
+
+@Component({
+ templateUrl: 'linkchecker.component.html'
+})
+export class LinkCheckerComponent implements OnInit {
+
+ viewIdlClass = 'uvsa';
+ viewSortField = 'name';
+ viewOrgField = 'owning_lib';
+ viewFleshFields = { 'uvsa' : ['creator','owning_lib','container','usr'] };
+ viewFleshDepth = 1;
+ viewIdlClassDef: any;
+ viewPKeyField: string;
+
+ sessionIdlClass = 'uvs';
+ sessionIdlClassDef: any;
+
+ batchIdlClass = 'uvva';
+ batchIdlClassDef: any;
+
+ canCreateSession: boolean;
+ canCreateBatch: boolean;
+ viewPermaCrud: any;
+
+ dataSource: GridDataSource = new GridDataSource();
+ contextOrg: IdlObject;
+ searchOrgs: OrgFamily;
+ viewPerms: string;
+
+ @ViewChild('grid', { static: true }) grid: GridComponent;
+ noSelectedRows: boolean;
+ oneSelectedRow: boolean;
+ onlyBatchesSelected: boolean;
+
+ @ViewChild('newSessionDialog', { static: true }) newSessionDialog: NewSessionDialogComponent;
+ @ViewChild('createSuccessString', { static: false }) createSuccessString: StringComponent;
+ @ViewChild('createFailedString', { static: false }) createFailedString: StringComponent;
+ @ViewChild('deleteSessionConfirmDialog', { static: true }) deleteSessionConfirmDialog: ConfirmDialogComponent;
+
+ constructor(
+ private auth: AuthService,
+ private flatData: GridFlatDataService,
+ private format: FormatService,
+ private idl: IdlService,
+ private net: NetService,
+ private org: OrgService,
+ private pcrud: PcrudService,
+ private perm: PermService,
+ private route: ActivatedRoute,
+ private router: Router,
+ private toast: ToastService,
+ ) {}
+
+ ngOnInit() {
+
+ this.sessionIdlClassDef = this.idl.classes[this.sessionIdlClass];
+ this.batchIdlClassDef = this.idl.classes[this.batchIdlClass];
+ this.viewIdlClassDef = this.idl.classes[this.viewIdlClass];
+ this.viewPKeyField = this.viewIdlClassDef.pkey || 'id';
+
+ this.viewPermaCrud = this.viewIdlClassDef.permacrud || {};
+ if (this.viewPermaCrud.retrieve) {
+ this.viewPerms = this.viewPermaCrud.retrieve.perms;
+ }
+
+ const contextOrg = this.route.snapshot.queryParamMap.get('contextOrg');
+ this.applyOrgValues(Number(contextOrg));
+
+ this.checkCreateSessionPerms();
+ this.checkCreateBatchPerms();
+
+ this.initDataSource();
+ this.gridSelectionChange( [] );
+ }
+
+ viewSessionUrls() {
+ var rows = this.grid.context.getSelectedRows();
+ const ids = Array.from( new Set( rows.map(x => Number(x.session_id)) ) );
+ this.router.navigate(['/staff/cat/linkchecker/urls/'], { queryParams: { sessions: JSON.stringify(ids) } });
+ }
+
+ viewSessionAttempts() {
+ var rows = this.grid.context.getSelectedRows();
+ const ids = Array.from( new Set( rows.map(x => Number(x.session_id)) ) );
+ this.router.navigate(['/staff/cat/linkchecker/attempts/'], { queryParams: { sessions: JSON.stringify(ids) } });
+ }
+
+ viewBatchAttempts() {
+ var rows = this.grid.context.getSelectedRows();
+ const ids = Array.from( new Set( rows.map(x => Number(x.batch_id)) ) );
+ this.router.navigate(['/staff/cat/linkchecker/attempts/'], { queryParams: { batches: JSON.stringify(ids) } });
+ }
+
+ applyOrgValues(orgId?: number) {
+ this.contextOrg = this.org.get(orgId) || this.org.get(this.auth.user().ws_ou()) || this.org.root();
+ this.searchOrgs = {primaryOrgId: this.contextOrg.id()};
+ }
+
+ gridSelectionChange(keys: string[]) {
+ var rows = this.grid.context.getSelectedRows();
+
+ this.noSelectedRows = (rows.length === 0);
+ this.oneSelectedRow = (rows.length === 1);
+ this.onlyBatchesSelected = ! this.noSelectedRows;
+
+ rows.forEach(row => {
+ if (!row.batch_id) {
+ this.onlyBatchesSelected = false;
+ }
+ });
+ }
+
+ checkCreateSessionPerms() {
+ this.canCreateSession = false;
+ const pc = this.sessionIdlClassDef.permacrud || {};
+ const perms = pc.create ? pc.create.perms : [];
+ if (perms.length === 0) { return; }
+
+ this.perm.hasWorkPermAt(perms, true).then(permMap => {
+ Object.keys(permMap).forEach(key => {
+ if (permMap[key].length > 0) {
+ this.canCreateSession = true;
+ }
+ });
+ });
+ }
+
+ checkCreateBatchPerms() {
+ this.canCreateBatch = false;
+ const pc = this.batchIdlClassDef.permacrud || {};
+ const perms = pc.create ? pc.create.perms : [];
+ if (perms.length === 0) { return; }
+
+ this.perm.hasWorkPermAt(perms, true).then(permMap => {
+ Object.keys(permMap).forEach(key => {
+ if (permMap[key].length > 0) {
+ this.canCreateBatch = true;
+ }
+ });
+ });
+ }
+
+ newSessionWrapper(optionalSessionToClone) {
+ this.newSessionDialog.sessionToClone = optionalSessionToClone;
+ this.newSessionDialog.open({size: 'lg'}).subscribe( (res) => {
+ console.log('dialog res', res);
+ if (res && res['sessionId']) {
+ if (res['viewURLs']) {
+ this.router.navigate(['/staff/cat/linkchecker/urls/'],
+ { queryParams: { sessions: JSON.stringify([ Number(res['sessionId']) ]) } });
+ } else if (res['viewAttempts']) {
+ this.router.navigate(['/staff/cat/linkchecker/attempts/'],
+ { queryParams: { sessions: JSON.stringify([ Number(res['sessionId']) ]) } });
+ } else {
+ this.grid.reload();
+ }
+ }
+ });
+ }
+
+ cloneSelectedSession() {
+ var rows = this.grid.context.getSelectedRows();
+ if (rows.length !== 1) { return; }
+ this.newSessionWrapper(rows[0]);
+ }
+
+ deleteSelectedSessions() {
+ var rows = this.grid.context.getSelectedRows();
+ if (rows.length === 0) { return; }
+
+ this.deleteSessionConfirmDialog.open().subscribe(doIt => {
+ if (!doIt) { return; }
+
+ var session_ids = rows.map(r => r.session_id );
+
+ var that = this;
+ function delete_next(ids) {
+ var id = ids.pop();
+ if (id) {
+ that.net.request(
+ 'open-ils.url_verify',
+ 'open-ils.url_verify.session.delete',
+ that.auth.token(),
+ id,
+ ).subscribe(
+ (res) => {
+ console.log('session.delete res', res);
+ // toast
+ },
+ (err) => {
+ console.log('session.delete err', err);
+ // toast
+ },
+ () => {
+ console.log('session.delete finis');
+ delete_next(ids);
+ }
+ );
+ } else {
+ setTimeout( () => { that.grid.reload(); } );
+ }
+ }
+
+ delete_next(session_ids);
+
+ });
+ }
+
+ initDataSource() {
+ this.dataSource.getRows = (pager: Pager, sort: any[]) => {
+
+ let query: any = {}
+
+ if (this.searchOrgs || this.contextOrg) {
+ query[this.viewOrgField] =
+ this.searchOrgs.orgIds || [this.contextOrg.id()];
+ }
+
+ let query_filters = [];
+ Object.keys(this.dataSource.filters).forEach(key => {
+ query_filters = query_filters.concat( this.dataSource.filters[key] );
+ });
+
+ if (query_filters.length > 0) {
+ query['-and'] = query_filters;
+ }
+
+ return this.flatData.getRows(
+ this.grid.context, query, pager, sort);
+ };
+ }
+}
--- /dev/null
+import {NgModule} from '@angular/core';
+import {StaffCommonModule} from '@eg/staff/common.module';
+import {AdminCommonModule} from '@eg/staff/admin/common.module';
+import {CommonWidgetsModule} from '@eg/share/common-widgets.module';
+import {LinkCheckerRoutingModule} from './routing.module';
+import {LinkCheckerComponent} from './linkchecker.component';
+import {LinkCheckerUrlsComponent} from './urls.component';
+import {LinkCheckerAttemptsComponent} from './attempts.component';
+import {NewSessionDialogComponent} from './new-session-dialog.component';
+import {HttpClientModule} from '@angular/common/http';
+import {OrgFamilySelectModule} from '@eg/share/org-family-select/org-family-select.module';
+
+@NgModule({
+ declarations: [
+ LinkCheckerComponent,
+ LinkCheckerUrlsComponent,
+ LinkCheckerAttemptsComponent,
+ NewSessionDialogComponent
+ ],
+ imports: [
+ StaffCommonModule,
+ AdminCommonModule,
+ HttpClientModule,
+ CommonWidgetsModule,
+ OrgFamilySelectModule,
+ LinkCheckerRoutingModule
+ ],
+ providers: [
+ ]
+})
+
+export class LinkCheckerModule {
+}
--- /dev/null
+<eg-progress-dialog dialogTitle="{{progressText}}" #progress></eg-progress-dialog>
+<eg-alert-dialog #fail i18n-dialogBody
+ dialogBody="Could not create a new session.">
+</eg-alert-dialog>
+<ng-template #dialogContent>
+ <div class="modal-header bg-info">
+ <h4 class="modal-title">
+ <span i18n>New Link Checker Session</span>
+ </h4>
+ <button type="button" class="close"
+ i18n-aria-label aria-label="Close" (click)="close(false)">
+ <span aria-hidden="true">×</span>
+ </button>
+</div>
+<div class="modal-body">
+ <form #newSessionForm="ngForm" role="form" class="form-validated common-form striped-odd">
+ <div class="form-group row">
+ <div class="col-lg-3">
+ <label for="session_name" i18n>Name</label>
+ </div>
+ <div class="col-lg-9">
+ <input
+ class="form-control"
+ id="session_name" name="session_name"
+ type="text" pattern="[\s\S]*\S[\s\S]*"
+ placeholder="Name..." i18n-placeholder
+ required="true"
+ (ngModelChange)="sessionNameModelChanged.next($event)"
+ [ngModel]="sessionName"/>
+ <div *ngIf="nameCollision" class="alert alert-warning" i18n>
+ Session Name already in use for the Owning Library.
+ </div>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-lg-3">
+ <label for="owning_lib" i18n>Owning Library</label>
+ </div>
+ <div class="col-lg-9">
+ <eg-org-select
+ placeholder="Owning Library..."
+ i18n-placeholder
+ domId="owningLibrary"
+ required="true"
+ (onChange)="applyOwningLibrary($event)"
+ [applyOrgId]="sessionOwningLibrary">
+ </eg-org-select>
+ <!--[limitPerms]="modePerms[mode]">-->
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-lg-3">
+ <label for="search_scope" i18n>Search Scope</label>
+ </div>
+ <div class="col-lg-9">
+ <eg-org-select
+ placeholder="Search Scope..."
+ i18n-placeholder
+ domId="searchScope"
+ (onChange)="applySearchScope($event)">
+ </eg-org-select>
+ <!--[limitPerms]="modePerms[mode]">-->
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-lg-3">
+ <label for="saved_searches" i18n>Saved Searches</label>
+ </div>
+ <div class="col-lg-9">
+ <eg-combobox #savedSearchSelector
+ domId="savedSearches" name="saved_searches"
+ placeholder="Saved Searches..." i18n-placeholder
+ [entries]="savedSearchEntries"
+ (onChange)="applySavedSearch($event)">
+ </eg-combobox>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-lg-3">
+ <label for="session_search" i18n>Search</label>
+ </div>
+ <div class="col-lg-9">
+ <input
+ class="form-control"
+ id="session_search" name="session_search"
+ type="text"
+ placeholder="Search..." i18n-placeholder
+ required="true"
+ (ngModelChange)="applySessionSearch($event)"
+ [(ngModel)]="sessionSearch"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <div class="col-lg-3">
+ <label i18n>URL Selectors</label>
+ </div>
+ <div class="col-lg-9">
+ <div class="row" *ngFor="let t of selectorModels['tag']; let idx = index; trackBy:trackByIdx">
+ <div class="col-lg-2">
+ <label for="selector_tag_{{idx}}" i18n>Tag</label>
+ </div>
+ <div class="col-lg-3">
+ <input
+ class="form-control"
+ name="selector_tag_{{idx}}"
+ type="text"
+ required="true"
+ [(ngModel)]="selectorModels['tag'][idx]"
+ placeholder="856..." i18n-placeholder />
+ </div>
+ <div class="col-lg-2">
+ <label for="selector_subfields_{{idx}}" i18n>Subfields</label>
+ </div>
+ <div class="col-lg-3">
+ <input
+ class="form-control"
+ name="selector_subfields_{{idx}}"
+ type="text"
+ required="true"
+ [(ngModel)]="selectorModels['subfields'][idx]"
+ placeholder="u..." i18n-placeholder />
+ </div>
+ <div class="col-lg-2">
+ <button class="btn btn-sm material-icon-button"
+ (click)="addSelectorRow(idx + 1)"
+ i18n-title title="Add Selector Row">
+ <span class="material-icons">add_circle_outline</span>
+ </button>
+ <button class="btn btn-sm material-icon-button"
+ [disabled]="selectorModels['tag'].length < 2"
+ (click)="delSelectorRow(idx)"
+ i18n-title title="Remove Selector Row">
+ <span class="material-icons">remove_circle_outline</span>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </form>
+</div>
+<div class="modal-footer">
+ <button type="button" class="btn btn-success" [disabled]="nameCollision || newSessionForm.invalid"
+ (click)="createNewSession({'fullAuto':true})" i18n>Create Session and test all URLs</button>
+ <button type="button" class="btn btn-success" [disabled]="nameCollision || newSessionForm.invalid"
+ (click)="createNewSession({'viewURLs':true})" i18n>Create Session</button>
+ <button type="button" class="btn btn-secondary"
+ (click)="close(false)" i18n>Cancel</button>
+</div>
+</ng-template>
--- /dev/null
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry, ComboboxComponent} from '@eg/share/combobox/combobox.component';
+import {Component, Input, OnInit, ViewChild, Renderer2} from '@angular/core';
+import {DialogComponent} from '@eg/share/dialog/dialog.component';
+import {EventService} from '@eg/core/event.service';
+import {FormControl} from '@angular/forms';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {NgForm} from '@angular/forms';
+import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {StringComponent} from '@eg/share/string/string.component';
+import {Subject, Subscription, Observable, from, EMPTY, throwError} from 'rxjs';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {debounceTime, distinctUntilChanged, switchMap, takeLast, finalize} from 'rxjs/operators';
+
+@Component({
+ selector: 'eg-new-session-dialog',
+ templateUrl: './new-session-dialog.component.html'
+})
+
+export class NewSessionDialogComponent extends DialogComponent implements OnInit {
+
+ @Input() sessionToClone: IdlObject; // not really a "session", but a combined session/batch view
+
+ progressText: string = '';
+
+ savedSearchIdlClass = 'asq';
+
+ encounteredError: boolean = false;
+ nameCollision: boolean = false;
+
+ subscriptions: Subscription[] = [];
+
+ sessionId: number;
+ sessionName: string = '';
+ sessionNameModelChanged: Subject<string> = new Subject<string>();
+
+ sessionOwningLibrary: IdlObject;
+ sessionSearchScope: IdlObject;
+ sessionSearch: string = '';
+ sessionSavedSearch: number = null;
+
+ selectorModels: any = {
+ 'tag' : [],
+ 'subfields' : []
+ }
+ savedSearchEntries: ComboboxEntry[] = [];
+ savedSearchObjectCache: any = {};
+
+ @ViewChild('newSessionForm', { static: false}) newSessionForm: NgForm;
+ @ViewChild('savedSearchSelector', { static: true}) savedSearchSelector: ComboboxComponent;
+ @ViewChild('fail', { static: true }) private fail: AlertDialogComponent;
+ @ViewChild('progress', { static: true }) private progress: ProgressDialogComponent;
+
+ constructor(
+ private modal: NgbModal,
+ private auth: AuthService,
+ private evt: EventService,
+ private net: NetService,
+ private toast: ToastService,
+ private idl: IdlService,
+ private pcrud: PcrudService,
+ private renderer: Renderer2,
+ ) {
+ super(modal);
+ }
+
+ ngOnInit() {
+
+ this.subscriptions.push( this.onOpen$.subscribe(
+ _ => {
+ this.stopProgressMeter();
+ if (this.sessionToClone) {
+ this.sessionName = 'Copy of ' + this.sessionToClone.name();
+ this.sessionOwningLibrary = this.sessionToClone.owning_lib().id();
+ this.sessionSearch = this.sessionToClone.search();
+ this.selectorModels = { 'tag' : ['856'], 'subfields' : ['u'] };
+ this.pcrud.search('uvus', {'session':this.sessionToClone.session_id()},{},{'atomic':true}).subscribe(
+ (list) => {
+ console.log('list',list);
+ list.forEach( (s,idx) => {
+ let xpath = s.xpath();
+ this.selectorModels.tag[idx] = xpath.match(/tag='(\d+)'/)[1];
+ this.selectorModels.subfields[idx] = '';
+ let matches = xpath.matchAll(/code='(.)'/g);
+ for (const match of matches) {
+ this.selectorModels.subfields[idx] += match[1];
+ }
+ console.log('idx',idx);
+ console.log('xpath',xpath);
+ console.log('tag',this.selectorModels.tag[idx]);
+ console.log('subfields',this.selectorModels.subfields[idx]);
+ });
+ }
+ );
+ }
+ const el = this.renderer.selectRootElement('#session_name');
+ if (el) { el.focus(); el.select(); }
+ }
+ ));
+
+ this.sessionOwningLibrary = this.auth.user().ws_ou();
+ this.selectorModels['tag'][0] = '856';
+ this.selectorModels['subfields'][0] = 'u';
+
+ this.subscriptions.push(
+ this.pcrud.retrieveAll(this.savedSearchIdlClass).subscribe(search => {
+ this.savedSearchEntries.push({id: search.id(), label: search.label()});
+ this.savedSearchObjectCache[ search.id() ] = search;
+ }
+ ));
+
+ this.subscriptions.push(
+ this.sessionNameModelChanged
+ .pipe(
+ debounceTime(300),
+ distinctUntilChanged()
+ )
+ .subscribe( newText => {
+ this.sessionName = newText;
+ this.nameCollision = false;
+ this.subscriptions.push(
+ this.pcrud.search('uvs',{
+ owning_lib: this.sessionOwningLibrary,
+ name: this.sessionName},{})
+ .subscribe(
+ result => { this.nameCollision = true; }
+ )
+ );
+ })
+ );
+ console.log('new-session-dialog this', this);
+ }
+
+ ngOnDestroy() {
+ this.subscriptions.forEach((subscription) => {
+ subscription.unsubscribe();
+ });
+ }
+
+ applyOwningLibrary(p: any) {
+ // [applyOrgId]="sessionOwningLibrary" is working fine
+ }
+
+ applySearchScope(p: any) {
+ // [applyOrgId]="sessionSearchScope" was not working fine.
+ // This also preserves null's, which is important since we'll
+ // reapply this scope if applicable after applying a saved
+ // search.
+ this.sessionSearchScope = p;
+ if (p) {
+ this.sessionSearch = this.sessionSearch.replace(
+ /^(.*)(site\(.+?\))(.*)$/,
+ "$1site(" + p.shortname() + ")$3"
+ );
+ if (! this.sessionSearch.match(/site\(.+?\)/)) {
+ this.sessionSearch += ' site(' + p.shortname() + ')'
+ }
+ this.applySessionSearch( this.sessionSearch );
+ }
+ }
+
+ applySavedSearch(p: any) {
+ var obj = this.savedSearchObjectCache[p.id];
+ if (obj) {
+ this.sessionSearch = obj.query_text();
+ this.applySearchScope( this.sessionSearchScope );
+ }
+ }
+
+ applySessionSearch(p: any) {
+ }
+
+ // https://stackoverflow.com/questions/42322968/angular2-dynamic-input-field-lose-focus-when-input-changes
+ trackByIdx(index: any, item: any) {
+ return index;
+ }
+
+ addSelectorRow(index: number): void {
+ this.selectorModels['tag'].splice(index, 0, '');
+ this.selectorModels['subfields'].splice(index, 0, '');
+ }
+
+ delSelectorRow(index: number): void {
+ this.selectorModels['tag'].splice(index, 1);
+ this.selectorModels['subfields'].splice(index, 1);
+ }
+
+ stopProgressMeter() {
+ this.progress.close();
+ this.progress.reset();
+ }
+
+ resetProgressMeter(s: string) {
+ this.progressText = s;
+ this.progress.reset();
+ }
+
+ startProgressMeter(s: string) {
+ this.progressText = s;
+ this.progress.reset();
+ this.progress.open();
+ }
+
+ createNewSession(options) {
+ ///////////////////////////////////////////////
+ this.startProgressMeter($localize`Creating session...`);
+ this.subscriptions.push(this.net.request(
+ 'open-ils.url_verify',
+ 'open-ils.url_verify.session.create',
+ this.auth.token(),
+ this.sessionName,
+ this.sessionSearch,
+ this.sessionOwningLibrary
+ ).subscribe(
+ (res) => {
+ if (this.evt.parse(res)) {
+ console.error('session.create ils_event',res);
+ this.fail.open();
+ this.stopProgressMeter();
+ this.close(false);
+ } else {
+ this.sessionId = res;
+ options['sessionId'] = res;
+ /////////////////////////////////////////////////////
+ this.resetProgressMeter($localize`Creating URL selectors...`);
+ this.subscriptions.push(
+ this.createUrlSelectors().subscribe(
+ (res2) => {
+ if (this.evt.parse(res2)) {
+ console.error('url_selector.create error',res2);
+ this.fail.open();
+ this.stopProgressMeter();
+ this.close(false);
+ } else {
+ console.log('url_selector',res2);
+ }
+ },
+ (err2) => {
+ console.error('url_selector.create error',err2);
+ this.fail.open();
+ this.stopProgressMeter();
+ this.close(false);
+ },
+ () => {
+ ////////////////////////////////////////////////////////////
+ this.resetProgressMeter($localize`Searching and extracting URLs...`);
+ this.subscriptions.push(this.net.request(
+ 'open-ils.url_verify',
+ 'open-ils.url_verify.session.search_and_extract',
+ this.auth.token(),
+ this.sessionId
+ ).subscribe(
+ (res3) => {
+ console.log('res3',res3);
+ if (!this.progress.hasMax()) {
+ // first response returned by the API is the number of search results
+ // We'll become a determinate progress meter for this section
+ this.progress.update({max: res3, value: 0});
+ } else {
+ // subsequent responses are the number of URLs extracted from each search result
+ this.progress.increment();
+ }
+ },
+ (err3) => {
+ console.log('err3',err3);
+ this.stopProgressMeter();
+ this.close(false);
+ },
+ () => {
+ if (options['fullAuto']) {
+ options['viewURLs'] = false;
+ options['viewAttempts'] = true;
+ /////////////////////////////////////////////
+ this.resetProgressMeter($localize`Verifying URLs...`);
+ this.subscriptions.push(this.net.request(
+ 'open-ils.url_verify',
+ 'open-ils.url_verify.session.verify',
+ this.auth.token(),
+ this.sessionId
+ ).subscribe(
+ (res4) => {
+ console.log('res4',res4);
+ this.progress.update({max: res4['url_count'], value: res4['total_processed']});
+ },
+ (err4) => {
+ this.stopProgressMeter();
+ console.log('err4',err4);
+ this.close(false);
+ },
+ () => {
+ this.nameCollision = true;
+ this.stopProgressMeter();
+ this.close(options);
+ }
+ ));
+ } else {
+ this.nameCollision = true;
+ this.stopProgressMeter();
+ this.close(options);
+ }
+ }
+ ));
+ }
+ )
+ );
+ }
+ },
+ (err) => {
+ console.error('session.create error',err);
+ this.fail.open();
+ this.stopProgressMeter();
+ this.close(false);
+ }
+ ));
+ }
+
+ createUrlSelectors(): Observable<any> {
+ // Examples:
+ //*[@tag='856']/*[@code='u']
+ //*[@tag='956']/*[@code='a' or @code='b' or @code='c']
+ console.log('createUrlSelectors');
+ var xpaths: string[] = [];
+ var selectors: IdlObject[] = [];
+ for (var i = 0; i < this.selectorModels['tag'].length; i++) {
+ let tag = this.selectorModels['tag'][i];
+ let subfields = this.selectorModels['subfields'][i];
+ let xpath = "//*[@tag='" + tag + "']/*[" + subfields.split('').map( e => "@code='" + e + "'" ).join(' or ') + ']';
+ xpaths.push(xpath);
+ }
+ xpaths = Array.from( new Set( xpaths ) ); // dedupe
+ selectors = xpaths.map( _xpath => {
+ var uvus = this.idl.create('uvus');
+ uvus.isnew(true);
+ uvus.session(this.sessionId);
+ uvus.xpath( _xpath );
+ return uvus;
+ });
+ return this.pcrud.create(selectors);
+ }
+
+}
--- /dev/null
+import {NgModule} from '@angular/core';
+import {RouterModule, Routes} from '@angular/router';
+import {LinkCheckerComponent} from './linkchecker.component';
+import {LinkCheckerUrlsComponent} from './urls.component';
+import {LinkCheckerAttemptsComponent} from './attempts.component';
+
+const routes: Routes = [{
+ path: '',
+ component: LinkCheckerComponent
+}, {
+ path: 'urls',
+ component: LinkCheckerUrlsComponent
+}, {
+ path: 'attempts',
+ component: LinkCheckerAttemptsComponent
+}];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule],
+ providers: []
+})
+
+export class LinkCheckerRoutingModule {}
+
--- /dev/null
+<!-- how should we do i18n with eg-staff-banner? -->
+<div class="lead alert alert-primary text-center pt-1 pb-1">
+ <span class="align-middle">
+ {sessions, plural, =0 {URLs for All Sessions} =1 {URLs for Session ID {{sessions}}} other {URLs for Session IDs {{sessions}}}}
+ </span>
+</div>
+
+<eg-title i18n-prefix prefix="Session URLs">
+</eg-title>
+
+<eg-grid #grid idlClass="uvu"
+ persistKey="catalog.link_checker.url"
+ [dataSource]="dataSource"
+ autoGeneratedColumnOrder="id,session,tag,subfield,url,redirect_from"
+ hideFields="item,ord,scheme,host,domain,tld,path,page,query,fragment"
+ (rowSelectionChange)="gridSelectionChange($event)"
+ [sortable]="true" [filterable]="true" [allowNamedFilterSets]="true"
+ [migrateLegacyFilterSets]="'url_verify'">
+
+ <eg-grid-toolbar-button
+ label="Verify Selected URLs" i18n-label
+ adjacentSubsequentLabel="This will create a new Batch for the Session."
+ i18n-adjacentSubsequentLabel
+ [disabled]="noSelectedRows"
+ (onClick)="verifySelectedUrls()">
+ </eg-grid-toolbar-button>
+ <eg-grid-column path="item.target_biblio_record_entry.simple_record.title">
+ </eg-grid-column>
+ <eg-grid-column path="item.target_biblio_record_entry.simple_record.author">
+ </eg-grid-column>
+</eg-grid>
+
+<eg-progress-dialog dialogTitle="{{progressText}}" #progress></eg-progress-dialog>
--- /dev/null
+import * as Moment from 'moment-timezone';
+import {Router, ActivatedRoute} from '@angular/router';
+import {AuthService} from '@eg/core/auth.service';
+import {ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {Component, OnInit, ViewChild, Input, TemplateRef} from '@angular/core';
+import {ConfirmDialogComponent} from '@eg/share/dialog/confirm.component';
+import {DateSelectComponent} from '@eg/share/date-select/date-select.component';
+import {FmRecordEditorComponent} from '@eg/share/fm-editor/fm-editor.component';
+import {FormGroup, FormControl} from '@angular/forms';
+import {FormatService} from '@eg/core/format.service';
+import {GridComponent} from '@eg/share/grid/grid.component';
+import {GridDataSource, GridColumn, GridRowFlairEntry, GridCellTextGenerator} from '@eg/share/grid/grid';
+import {GridFlatDataService} from '@eg/share/grid/grid-flat-data.service';
+import {HtmlToTxtService} from '@eg/share/util/htmltotxt.service';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {NgbDate} from '@ng-bootstrap/ng-bootstrap';
+import {OrgFamily} from '@eg/share/org-family-select/org-family-select.component';
+import {OrgService} from '@eg/core/org.service';
+import {Pager} from '@eg/share/util/pager';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {PermService} from '@eg/core/perm.service';
+import {PrintService} from '@eg/share/print/print.service';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {SampleDataService} from '@eg/share/util/sample-data.service';
+import {StringComponent} from '@eg/share/string/string.component';
+import {StringService} from '@eg/share/string/string.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {map, take} from 'rxjs/operators';
+import {timer as observableTimer, Observable, of} from 'rxjs';
+
+@Component({
+ templateUrl: 'urls.component.html'
+})
+export class LinkCheckerUrlsComponent implements OnInit {
+
+ sessions: number[];
+ newBatches: number[] = [];
+ urlsIdlClass = 'uvu';
+ urlsSortField = 'name';
+ urlsSessionField = 'session';
+ urlsFleshFields = {
+ 'uvu' : ['item','url_selector'],
+ 'uvsbrem' : ['target_biblio_record_entry'],
+ 'bre' : ['simple_record']
+ };
+ urlsFleshDepth = 3;
+ urlsIdlClassDef: any;
+ urlsPKeyField: string;
+
+ urlsPermaCrud: any;
+ urlsPerms: string;
+
+ @ViewChild('progress', { static: true }) private progress: ProgressDialogComponent;
+ progressText: string = '';
+
+ @ViewChild('grid', { static: true }) grid: GridComponent;
+ dataSource: GridDataSource = new GridDataSource();
+ noSelectedRows: boolean;
+ oneSelectedRow: boolean;
+
+ constructor(
+ private auth: AuthService,
+ private flatData: GridFlatDataService,
+ private format: FormatService,
+ private idl: IdlService,
+ private net: NetService,
+ private org: OrgService,
+ private pcrud: PcrudService,
+ private perm: PermService,
+ private route: ActivatedRoute,
+ private router: Router,
+ ) {}
+
+ ngOnInit() {
+ this.route.queryParams.subscribe( params => {
+ if (params.sessions) {
+ this.sessions = JSON.parse( params.sessions );
+ this.grid.reload();
+ }
+ });
+
+ this.urlsIdlClassDef = this.idl.classes[this.urlsIdlClass];
+ this.urlsPKeyField = this.urlsIdlClassDef.pkey || 'id';
+
+ this.urlsPermaCrud = this.urlsIdlClassDef.permacrud || {};
+ if (this.urlsPermaCrud.retrieve) {
+ this.urlsPerms = this.urlsPermaCrud.retrieve.perms;
+ }
+
+ this.initDataSource();
+ this.gridSelectionChange( [] );
+ }
+
+ gridSelectionChange(keys: string[]) {
+ this.noSelectedRows = (keys.length === 0);
+ this.oneSelectedRow = (keys.length === 1);
+ var rows = this.grid.context.getSelectedRows();
+ }
+
+ initDataSource() {
+ this.dataSource.getRows = (pager: Pager, sort: any[]) => {
+
+ let query: any = {}
+
+ if (this.sessions) {
+ query[this.urlsSessionField] = this.sessions;
+ }
+
+ let query_filters = [];
+ Object.keys(this.dataSource.filters).forEach(key => {
+ query_filters = query_filters.concat( this.dataSource.filters[key] );
+ });
+
+ if (query_filters.length > 0) {
+ query['-and'] = query_filters;
+ }
+
+ return this.flatData.getRows(
+ this.grid.context, query, pager, sort);
+ };
+ }
+
+ stopProgressMeter() {
+ this.progress.close();
+ this.progress.reset();
+ }
+
+ resetProgressMeter(s: string) {
+ this.progressText = s;
+ this.progress.reset();
+ }
+
+ startProgressMeter(s: string) {
+ this.progressText = s;
+ this.progress.reset();
+ this.progress.open();
+ }
+
+ verifyUrlsFilteredForSession(rows,ses_ids) {
+ var ses_id = ses_ids.pop();
+ if (ses_id) {
+ this.resetProgressMeter($localize`Verifying selected URLs for Session ${ses_id}...`);
+ console.log('Verifying selected URLs for Session ' + ses_id);
+ this.net.request(
+ 'open-ils.url_verify',
+ 'open-ils.url_verify.session.verify',
+ this.auth.token(),
+ ses_id,
+ rows.filter( url => url.session() === ses_id ).map( url => url.id() )
+ ).subscribe(
+ (res) => {
+ console.log('res',res);
+ this.progress.update({max: res['url_count'], value: res['total_processed']});
+ if (res['attempt']) { // last response
+ this.newBatches.push(res.attempt.id());
+ }
+ },
+ (err) => {
+ this.stopProgressMeter();
+ console.log('err',err);
+ },
+ () => {
+ this.verifyUrlsFilteredForSession(rows,ses_ids);
+ }
+ );
+ } else {
+ console.log('go to attempts page');
+ this.stopProgressMeter();
+ this.router.navigate(['/staff/cat/linkchecker/attempts/'], {
+ queryParams: { batches: JSON.stringify( Array.from( new Set( this.newBatches ) ) ) } });
+ }
+ }
+
+ verifySelectedUrls() {
+ const rows = this.grid.context.getSelectedRows();
+ let session_ids = Array.from( new Set( rows.map(x => Number(x.session())) ) );
+ this.startProgressMeter($localize`Verifying selected URLs for Sessions ${session_ids}...`);
+ this.verifyUrlsFilteredForSession(rows,session_ids);
+ }
+}
loadChildren: () =>
import('./authority/authority.module').then(m => m.AuthorityModule)
}, {
+ path: 'linkchecker',
+ loadChildren: () =>
+ import('./linkchecker/linkchecker.module').then(m => m.LinkCheckerModule)
+ }, {
path: 'marcbatch',
loadChildren: () =>
import('./marcbatch/marcbatch.module').then(m => m.MarcBatchModule)
<span i18n>MARC Batch Edit</span>
</a>
<div class="dropdown-divider"></div>
- <a href="/eg/staff/cat/catalog/verifyURLs" ngbDropdownItem class="dropdown-item">
+ <a routerLink="/staff/cat/linkchecker" ngbDropdownItem class="dropdown-item">
<span class="material-icons" aria-hidden="true">link</span>
<span i18n>Link Checker</span>
</a>