lp1993824: linkchecker; UI
authorJason Etheridge <jason@EquinoxOLI.org>
Sat, 4 Mar 2023 15:45:11 +0000 (10:45 -0500)
committerJason Etheridge <phasefx@gmail.com>
Sun, 14 May 2023 13:03:47 +0000 (09:03 -0400)
Signed-off-by: Jason Etheridge <jason@EquinoxOLI.org>
12 files changed:
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/attempts.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/attempts.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/new-session-dialog.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/new-session-dialog.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/urls.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/cat/linkchecker/urls.component.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

diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/attempts.component.html b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/attempts.component.html
new file mode 100644 (file)
index 0000000..69c22fc
--- /dev/null
@@ -0,0 +1,37 @@
+<!-- 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>
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/attempts.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/attempts.component.ts
new file mode 100644 (file)
index 0000000..b80296b
--- /dev/null
@@ -0,0 +1,146 @@
+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);
+        };
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.component.html b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.component.html
new file mode 100644 (file)
index 0000000..870e4d0
--- /dev/null
@@ -0,0 +1,83 @@
+
+<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>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.component.ts
new file mode 100644 (file)
index 0000000..3df2e17
--- /dev/null
@@ -0,0 +1,263 @@
+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);
+        };
+    }
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/linkchecker.module.ts
new file mode 100644 (file)
index 0000000..ea1622a
--- /dev/null
@@ -0,0 +1,33 @@
+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 {
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/new-session-dialog.component.html b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/new-session-dialog.component.html
new file mode 100644 (file)
index 0000000..dfbe236
--- /dev/null
@@ -0,0 +1,149 @@
+<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">&times;</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>
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/new-session-dialog.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/new-session-dialog.component.ts
new file mode 100644 (file)
index 0000000..62420b5
--- /dev/null
@@ -0,0 +1,345 @@
+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);
+    }
+
+}
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/routing.module.ts
new file mode 100644 (file)
index 0000000..761236e
--- /dev/null
@@ -0,0 +1,25 @@
+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 {}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/urls.component.html b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/urls.component.html
new file mode 100644 (file)
index 0000000..49e766b
--- /dev/null
@@ -0,0 +1,33 @@
+<!-- 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>
diff --git a/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/urls.component.ts b/Open-ILS/src/eg2/src/app/staff/cat/linkchecker/urls.component.ts
new file mode 100644 (file)
index 0000000..02abdea
--- /dev/null
@@ -0,0 +1,181 @@
+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);
+    }
+}
index cc0d4eb..65319b6 100644 (file)
@@ -11,6 +11,10 @@ const routes: Routes = [
     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)
index 7054ed6..faf8c5f 100644 (file)
             <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>