LP1806087 Angular catalog browse UI + API
authorBill Erickson <berickxx@gmail.com>
Wed, 5 Dec 2018 17:26:33 +0000 (12:26 -0500)
committerBill Erickson <berickxx@gmail.com>
Thu, 10 Jan 2019 16:14:56 +0000 (11:14 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
16 files changed:
Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/catalog/catalog.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/staff/catalog/routing.module.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts
Open-ILS/src/perlmods/lib/OpenILS/Application/Search.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm [new file with mode: 0644]

index 71cfce2..116ab4a 100644 (file)
@@ -30,7 +30,9 @@ export class CatalogUrlService {
             org: null,
             limit: null,
             offset: null,
-            copyLocations: null
+            copyLocations: null,
+            browsePivot: null,
+            hasBrowseEntry: null
         };
 
         params.org = context.searchOrg.id();
@@ -42,7 +44,7 @@ export class CatalogUrlService {
 
         // These fields can be copied directly into place
         ['format', 'sort', 'available', 'global', 'identQuery', 
-            'identQueryType', 'basket']
+            'identQueryType', 'basket', 'browsePivot', 'hasBrowseEntry']
         .forEach(field => {
             if (context[field]) {
                 // Only propagate applied values to the URL.
@@ -106,7 +108,7 @@ export class CatalogUrlService {
 
         // These fields can be copied directly into place
         ['format', 'sort', 'available', 'global', 'identQuery', 
-            'identQueryType', 'basket']
+            'identQueryType', 'basket', 'browsePivot', 'hasBrowseEntry']
         .forEach(field => {
             const val = params.get(field);
             if (val !== null) {
index e7f3f27..aaa6b59 100644 (file)
@@ -266,4 +266,26 @@ export class CatalogService {
             {anonymous: true}
         ).pipe(tap(loc => this.copyLocations.push(loc))).toPromise()
     }
+
+    browse(ctx: CatalogSearchContext): Observable<any> {
+        ctx.searchState = CatalogSearchState.SEARCHING;
+
+        let method = 'open-ils.search.browse';
+        if (ctx.isStaff) {
+            method += '.staff';
+        }
+
+        return this.net.request(
+            'open-ils.search',
+            'open-ils.search.browse.staff', {
+                browse_class: ctx.fieldClass[0],
+                term: ctx.query[0],
+                limit : ctx.pager.limit,
+                pivot: ctx.browsePivot,
+                org_unit: ctx.searchOrg.id()
+            }
+        ).pipe(tap(result => {
+            ctx.searchState = CatalogSearchState.COMPLETE;
+        }));
+    }
 }
index 1807eac..5d676a1 100644 (file)
@@ -50,6 +50,8 @@ export class CatalogSearchContext {
     isStaff: boolean;
     basket = false;
     copyLocations: string[]; // ID's, but treated as strings in the UI.
+    browsePivot: number;
+    hasBrowseEntry: string; // "entryId,fieldId"
 
     // Result from most recent search.
     result: any = {};
@@ -124,6 +126,7 @@ export class CatalogSearchContext {
         this.copyLocations = [''];
     }
 
+    // Returns true if we have enough information to perform a search.
     isSearchable(): boolean {
 
         if (this.basket) {
@@ -134,7 +137,22 @@ export class CatalogSearchContext {
             return true;
         }
 
-        return this.query.length
+        if (this.searchOrg === null) {
+            return false;
+        }
+
+        if (this.hasBrowseEntry) {
+            return true;
+        }
+
+        return this.query.length && this.query[0] !== '';
+    }
+
+    // Returns true if we have enough information to perform a browse.
+    isBrowsable(): boolean {
+        return this.fieldClass.length
+            && this.fieldClass[0] !== ''
+            && this.query.length
             && this.query[0] !== ''
             && this.searchOrg !== null;
     }
@@ -173,6 +191,11 @@ export class CatalogSearchContext {
             // -------
         }
 
+        if (this.hasBrowseEntry) { 
+            // stored as a comma-separated string of "entryId,fieldId"
+            str += ` has_browse_entry(${this.hasBrowseEntry})`;
+        }
+
         if (this.format) {
             str += ' format(' + this.format + ')';
         }
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.html
new file mode 100644 (file)
index 0000000..8412143
--- /dev/null
@@ -0,0 +1,5 @@
+
+<eg-catalog-browse-form></eg-catalog-browse-form>
+
+<eg-catalog-browse-results><eg-catalog-browse-results>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse.component.ts
new file mode 100644 (file)
index 0000000..996f965
--- /dev/null
@@ -0,0 +1,18 @@
+import {Component, OnInit} from '@angular/core';
+import {StaffCatalogService} from './catalog.service';
+
+@Component({
+  templateUrl: 'browse.component.html'
+})
+export class BrowseComponent implements OnInit {
+
+    constructor(
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() {
+        // A SearchContext provides all the data needed for browse.
+        this.staffCat.createContext();
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.html
new file mode 100644 (file)
index 0000000..6dba250
--- /dev/null
@@ -0,0 +1,36 @@
+<div id='staffcat-browse-form' class='pb-2 mb-3 row'>
+  <div class="col-lg-10 form-inline">
+    <label for="field-class" i18n>Browse for</label>
+    <select class="form-control ml-2" name="field-class"
+      [(ngModel)]="searchContext.fieldClass[0]">
+      <option i18n value='title'>Title</option>
+      <option i18n value='author'>Author</option>
+      <option i18n value='subject'>Subject</option>
+      <option i18n value='series'>Series</option>
+    </select>
+    <label for="query" class="ml-2"> starting with </label>
+    <input type="text" class="form-control ml-2"
+      id='browse-term-input'
+      [(ngModel)]="searchContext.query[0]"
+      (keyup.enter)="formEnter('query')"
+      placeholder="Browse for..."/>
+    <label for="browse-org" class="ml-2"> in </label>
+    <eg-org-select name="browse-org" class="ml-2"
+       (onChange)="orgOnChange($event)"
+       [initialOrg]="searchContext.searchOrg"
+       [placeholder]="'Library'" >
+    </eg-org-select>
+    <button class="btn btn-success ml-2" type="button"
+      [disabled]="searchIsActive()"
+      (click)="searchContext.pager.offset=0; browseByForm()" i18n>
+       Browse 
+    </button>
+  </div>
+  <div class="col-lg-2">
+    <div class="float-right">
+      <button class="btn btn-info" 
+        type="button" (click)="goToSearch()" i18n>Search</button>
+    </div>
+  </div>
+</div>
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/form.component.ts
new file mode 100644 (file)
index 0000000..b9c4c8e
--- /dev/null
@@ -0,0 +1,64 @@
+import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
+import {Router} from '@angular/router';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {StaffCatalogService} from '../catalog.service';
+
+@Component({
+  selector: 'eg-catalog-browse-form',
+  templateUrl: 'form.component.html'
+})
+export class BrowseFormComponent implements OnInit, AfterViewInit {
+
+    searchContext: CatalogSearchContext;
+    ccvmMap: {[ccvm: string]: IdlObject[]} = {};
+    cmfMap: {[cmf: string]: IdlObject} = {};
+
+    constructor(
+        private renderer: Renderer2,
+        private router: Router,
+        private org: OrgService,
+        private cat: CatalogService,
+        private staffCat: StaffCatalogService
+    ) {
+    }
+
+    ngOnInit() {
+        this.ccvmMap = this.cat.ccvmMap;
+        this.cmfMap = this.cat.cmfMap;
+        this.searchContext = this.staffCat.searchContext;
+    }
+
+    ngAfterViewInit() {
+        this.renderer.selectRootElement('#browse-term-input').focus();
+    }
+
+    orgName(orgId: number): string {
+        return this.org.get(orgId).shortname();
+    }
+
+    formEnter(source) {
+        this.searchContext.pager.offset = 0;
+        this.browseByForm();
+    }
+
+    browseByForm(): void {
+        this.staffCat.browse();
+    }
+
+    searchIsActive(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+    }
+
+    goToSearch() {
+        this.router.navigate(['/staff/catalog/search']);
+    }
+
+    orgOnChange = (org: IdlObject): void => {
+        this.searchContext.searchOrg = org;
+    }
+}
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.html
new file mode 100644 (file)
index 0000000..fdbb054
--- /dev/null
@@ -0,0 +1,84 @@
+
+<!-- search results progress bar -->
+<div class="row" *ngIf="browseIsActive()">
+  <div class="col-lg-6 offset-lg-3 pt-3">
+    <div class="progress">
+      <div class="progress-bar progress-bar-striped active w-100"
+        role="progressbar" aria-valuenow="100" 
+        aria-valuemin="0" aria-valuemax="100">
+        <span class="sr-only" i18n>Searching..</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- no items found -->
+<div *ngIf="browseIsDone() && !browseHasResults()">
+  <div class="row pt-3">
+    <div class="col-lg-6 offset-lg-3">
+      <div class="alert alert-warning">
+        <span i18n>No Maching Items Were Found</span>
+      </div>
+    </div>
+  </div>
+</div>
+
+<!-- header, pager, and list of records -->
+<div id="staff-catalog-browse-results-container" *ngIf="browseHasResults()">
+
+  <div class="row mb-2">
+    <div class="col-lg-3">
+      <button class="btn btn-primary" (click)="prevPage()">Back</button>
+      <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
+    </div>
+  </div>
+
+  <div class="row" *ngFor="let result of results">
+    <div *ngIf="result.value" 
+      class="col-lg-12 card tight-card mb-2 bg-light">
+      <div class="col-lg-8">
+        <div class="card-body">
+          <ng-container *ngIf="result.sources > 0">
+            <a (click)="searchByBrowseEntry(result)" href="javascript:void(0)">
+                {{result.value}} ({{result.sources}})
+            </a>
+          </ng-container>
+          <ng-container *ngIf="result.sources == 0">
+            <span>{{result.value}}</span>
+          </ng-container>
+          <div class="row" *ngFor="let heading of result.compiledHeadings">
+            <div class="col-lg-10 offset-lg-1" i18n>
+              <span class="font-italic">
+                <ng-container *ngIf="!heading.type || heading.type == 'variant'">
+                    See
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'broader'">
+                    Broader term
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'narrower'">
+                    Narrower term
+                </ng-container>
+                <ng-container *ngIf="heading.type == 'other'">
+                    Related term
+                </ng-container>
+              </span>
+              <a (click)="newBrowseFromHeading(heading)" href="javascript:void(0)">
+                {{heading.heading}} ({{heading.target_count}})
+              </a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <div class="row mb-2">
+    <div class="col-lg-3">
+      <button class="btn btn-primary" (click)="prevPage()">Back</button>
+      <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
+    </div>
+  </div>
+
+</div>
+
+
diff --git a/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts b/Open-ILS/src/eg2/src/app/staff/catalog/browse/results.component.ts
new file mode 100644 (file)
index 0000000..f706cd5
--- /dev/null
@@ -0,0 +1,139 @@
+import {Component, OnInit, Input} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {Subscription} from 'rxjs/Subscription';
+import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {BibRecordService} from '@eg/share/catalog/bib-record.service';
+import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {StaffCatalogService} from '../catalog.service';
+import {IdlObject} from '@eg/core/idl.service';
+
+@Component({
+  selector: 'eg-catalog-browse-results',
+  templateUrl: 'results.component.html'
+})
+export class BrowseResultsComponent implements OnInit {
+
+    searchContext: CatalogSearchContext;
+    results: any[];
+
+    constructor(
+        private route: ActivatedRoute,
+        private pcrud: PcrudService,
+        private cat: CatalogService,
+        private bib: BibRecordService,
+        private catUrl: CatalogUrlService,
+        private staffCat: StaffCatalogService
+    ) {}
+
+    ngOnInit() {
+        this.searchContext = this.staffCat.searchContext;
+        this.route.queryParamMap.subscribe((params: ParamMap) => {
+            this.browseByUrl(params);
+        });
+    }
+
+    browseByUrl(params: ParamMap): void {
+        this.catUrl.applyUrlParams(this.searchContext, params);
+
+        // SearchContext applies a default fieldClass value of 'keyword'.
+        // Replace with 'title', since there is no 'keyword' browse.
+        if (this.searchContext.fieldClass[0] === 'keyword') {
+            this.searchContext.fieldClass = ['title'];
+        }
+
+        if (this.searchContext.isBrowsable()) {
+            this.results = [];
+            this.cat.browse(this.searchContext)
+                .subscribe(result => this.addResult(result))
+        }
+    }
+
+    addResult(result: any) {
+
+        result.compiledHeadings = [];
+
+        // Avoi dupe headings per see
+        const seen: any = {};
+
+        result.sees.forEach(sees => {
+            if (!sees.control_set) { return; }
+
+            sees.headings.forEach(headingStruct => {
+                const fieldId = Object.keys(headingStruct)[0];
+                const heading = headingStruct[fieldId][0];
+
+                const inList = result.list_authorities.filter(
+                    id => Number(id) === Number(heading.target))[0]
+
+                if (   heading.target 
+                    && heading.main_entry
+                    && heading.target_count 
+                    && !inList
+                    && !seen[heading.target]) {
+
+                    seen[heading.target] = true;
+
+                    result.compiledHeadings.push({
+                        heading: heading.heading,
+                        target: heading.target,
+                        target_count: heading.target_count,
+                        type: heading.type
+                    });
+                }
+            });
+        });
+
+        this.results.push(result);
+    }
+
+    browseIsDone(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.COMPLETE;
+    }
+
+    browseIsActive(): boolean {
+        return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+    }
+
+    browseHasResults(): boolean {
+        return this.browseIsDone() && this.results.length > 0;
+    }
+
+    prevPage() {
+        const firstResult = this.results[0];
+        if (firstResult) {
+            this.searchContext.browsePivot = firstResult.pivot_point;
+            this.staffCat.browse();
+        }
+    }
+
+    nextPage() {
+        const lastResult = this.results[this.results.length - 1];
+        if (lastResult) {
+            this.searchContext.browsePivot = lastResult.pivot_point;
+            this.staffCat.browse();
+        }
+    }
+
+    searchByBrowseEntry(result) { 
+
+        // avoid propagating the browse query to the search form
+        this.searchContext.query[0] = '';
+
+        this.searchContext.hasBrowseEntry = 
+            result.browse_entry + ',' + result.fields;
+        this.staffCat.search();
+    }
+
+    // NOTE: to test unauthorized heading display in concerto
+    // browse for author = kab
+    newBrowseFromHeading(heading) {
+        this.searchContext.query[0] = heading.heading;
+        this.staffCat.browse();
+    }
+}
+
+
index d5b0eeb..b083f67 100644 (file)
@@ -19,6 +19,9 @@ import {HoldComponent} from './hold/hold.component';
 import {HoldService} from '@eg/staff/share/hold.service';
 import {PartsComponent} from './record/parts.component';
 import {PartMergeDialogComponent} from './record/part-merge-dialog.component';
+import {BrowseComponent} from './browse.component';
+import {BrowseFormComponent} from './browse/form.component';
+import {BrowseResultsComponent} from './browse/results.component';
 
 @NgModule({
   declarations: [
@@ -35,7 +38,10 @@ import {PartMergeDialogComponent} from './record/part-merge-dialog.component';
     BasketActionsComponent,
     HoldComponent,
     PartsComponent,
-    PartMergeDialogComponent
+    PartMergeDialogComponent,
+    BrowseComponent,
+    BrowseFormComponent,
+    BrowseResultsComponent
   ],
   imports: [
     StaffCommonModule,
index 1e50d9b..681e159 100644 (file)
@@ -82,6 +82,28 @@ export class StaffCatalogService {
           ['/staff/catalog/search'], {queryParams: params});
     }
 
+    /**
+     * Redirect to the browse results page while propagating the current
+     * browse paramters into the URL.  Let the browse results component
+     * execute the actual browse.
+     */
+    browse(): void {
+        if (!this.searchContext.isBrowsable()) { return; }
+
+        const params = this.catUrl.toUrlParams(this.searchContext);
+
+        // Force a new browse every time this method is called, even if
+        // it's the same as the active browse.  Since router navigation
+        // exits early when the route + params is identical, add a
+        // random token to the route params to force a full navigation.
+        // This also resolves a problem where only removing secondary+
+        // versions of a query param fail to cause a route navigation.
+        // (E.g. going from two query= params to one).
+        params.ridx = '' + this.routeIndex++;
+
+        this.router.navigate(
+          ['/staff/catalog/browse'], {queryParams: params});
+    }
 }
 
 
index 7c76903..8bcef4f 100644 (file)
@@ -5,6 +5,7 @@ import {ResultsComponent} from './result/results.component';
 import {RecordComponent} from './record/record.component';
 import {CatalogResolver} from './resolver.service';
 import {HoldComponent} from './hold/hold.component';
+import {BrowseComponent} from './browse.component';
 
 const routes: Routes = [{
   path: '',
@@ -22,7 +23,11 @@ const routes: Routes = [{
   }, {
     path: 'record/:id/:tab',
     component: RecordComponent
-  }]
+  }]}, {
+  // Browse is a top-level UI
+  path: 'browse',
+  component: BrowseComponent,
+  resolve: {catResolver : CatalogResolver},
 }];
 
 @NgModule({
index 1ecc91e..cdcae0c 100644 (file)
@@ -4,7 +4,7 @@ TODO focus search input
 <div id='staffcat-search-form' class='pb-2 mb-3'>
   <div class="row"
     *ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
-    <div class="col-lg-9 d-flex">
+    <div class="col-lg-8 d-flex">
       <div class="flex-1">
         <div *ngIf="idx == 0">
           <select class="form-control" [(ngModel)]="searchContext.format">
@@ -71,35 +71,39 @@ TODO focus search input
         </button>
       </div>
     </div><!-- col -->
-    <div class="col-lg-3">
+    <div class="col-lg-4">
       <div *ngIf="idx == 0" class="float-right">
         <button class="btn btn-success mr-1" type="button"
           [disabled]="searchIsActive()"
-          (click)="searchContext.pager.offset=0;searchByForm()">
+          (click)="searchContext.pager.offset=0;searchByForm()" i18n>
           Search
         </button>
         <button class="btn btn-warning mr-1" type="button"
           [disabled]="searchIsActive()"
-          (click)="searchContext.reset()">
+          (click)="searchContext.reset()" i18n>
           Clear Form
         </button>
         <button class="btn btn-outline-secondary" type="button"
           *ngIf="!showAdvanced()"
           [disabled]="searchIsActive()"
-          (click)="toggleAdvancedSearch()">
+          (click)="toggleAdvancedSearch()" i18n>
           More Filters
         </button>
         <button class="btn btn-outline-secondary" type="button"
           *ngIf="showAdvanced()"
-          (click)="toggleAdvancedSearch()">
+          (click)="toggleAdvancedSearch()" i18n>
           Hide Filters
         </button>
+        <button class="btn btn-info ml-1" type="button" 
+            (click)="goToBrowse()" i18n>
+          Browse
+        </button>
       </div>
     </div>
   </div><!-- row -->
 
   <div class="row">
-    <div class="col-lg-9 d-flex">
+    <div class="col-lg-8 d-flex">
       <div class="flex-1">
         <eg-org-select 
           (onChange)="orgOnChange($event)"
@@ -148,7 +152,7 @@ TODO focus search input
         <!-- alignment -->
       </div>
     </div>
-    <div class="col-lg-3">
+    <div class="col-lg-4">
       <eg-catalog-basket-actions></eg-catalog-basket-actions>
     </div>
   </div>
index eb76086..417bb3e 100644 (file)
@@ -1,4 +1,5 @@
 import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
+import {Router} from '@angular/router';
 import {IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
 import {CatalogService} from '@eg/share/catalog/catalog.service';
@@ -20,6 +21,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
 
     constructor(
         private renderer: Renderer2,
+        private router: Router,
         private org: OrgService,
         private cat: CatalogService,
         private staffCat: StaffCatalogService
@@ -35,7 +37,6 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         // Start with advanced search options open
         // if any filters are active.
         this.showAdvancedSearch = this.hasAdvancedOptions();
-
     }
 
     ngAfterViewInit() {
@@ -165,6 +166,9 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
         return this.searchContext.searchState === CatalogSearchState.SEARCHING;
     }
 
+    goToBrowse() {
+        this.router.navigate(['/staff/catalog/browse']);
+    }
 }
 
 
index 9ebb6da..78d4a4e 100644 (file)
@@ -16,6 +16,7 @@ use OpenILS::Application::Search::Z3950;
 use OpenILS::Application::Search::Zips;
 use OpenILS::Application::Search::CNBrowse;
 use OpenILS::Application::Search::Serial;
+use OpenILS::Application::Search::Browse;
 
 
 use OpenILS::Application::AppUtils;
@@ -34,6 +35,7 @@ sub initialize {
 
 sub child_init {
     OpenILS::Application::Search::Z3950->child_init;
+    OpenILS::Application::Search::Browse->child_init;
 }
     
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Search/Browse.pm
new file mode 100644 (file)
index 0000000..803f08b
--- /dev/null
@@ -0,0 +1,392 @@
+package OpenILS::Application::Search::Browse;
+use base qw/OpenILS::Application/;
+use strict; use warnings;
+
+# Most of this code is copied directly from ../../WWW/EGCatLoader/Browse.pm
+# and modified to be API-compatible.
+
+use Digest::MD5 qw/md5_hex/;
+use Apache2::Const -compile => qw/OK/;
+use MARC::Record;
+use List::Util qw/first/;
+
+use OpenSRF::Utils::Logger qw/$logger/;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Utils::Normalize qw/search_normalize/;
+use OpenILS::Application::AppUtils;
+use OpenSRF::Utils::JSON;
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::SettingsClient;
+
+my $U = 'OpenILS::Application::AppUtils';
+my $browse_cache;
+my $browse_timeout;
+
+sub initialize { return 1; }
+
+sub child_init {
+    if (not defined $browse_cache) {
+        my $conf = new OpenSRF::Utils::SettingsClient;
+
+        $browse_timeout = $conf->config_value(
+            "apps", "open-ils.search", "app_settings", "cache_timeout"
+        ) || 300;
+        $browse_cache = new OpenSRF::Utils::Cache("global");
+    }
+}
+
+__PACKAGE__->register_method(
+    method      => "browse",
+    api_name    => "open-ils.search.browse.staff",
+    stream      => 1,
+    signature   => {
+        desc    => q/Bib + authority browse/,
+        params  => [{
+            params => {
+                name => 'Browse Parameters',
+                desc => q/Hash of arguments:
+                    browse_class
+                        -- title, author, subject, series
+                    term
+                        -- term to browse for
+                    org_unit
+                        -- context org unit ID
+                    copy_location_group
+                        -- copy location filter ID
+                    limit
+                        -- return this many results
+                    pivot
+                        -- browse entry ID
+                /
+            }
+        }]
+    }
+);
+
+__PACKAGE__->register_method(
+    method      => "browse",
+    api_name    => "open-ils.search.browse",
+    stream      => 1,
+    signature   => {
+        desc    => q/See open-ils.search.browse.staff/
+    }
+);
+
+sub browse {
+    my ($self, $client, $params) = @_;
+
+    $params->{staff} = 1 if $self->api_name =~ /staff/;
+    my ($cache_key, @params) = prepare_browse_parameters($params);
+
+    my $results = $browse_cache->get_cache($cache_key);
+
+    if (!$results) {
+        $results = 
+            new_editor()->json_query({from => ['metabib.browse', @params]});
+        if ($results) {
+            $browse_cache->put_cache($cache_key, $results, $browse_timeout);
+        }
+    }
+
+    my ($warning, $alternative) = 
+        leading_article_test($params->{browse_class}, $params->{term});
+
+    for my $result (@$results) {
+        $result->{leading_article_warning} = $warning;
+        $result->{leading_article_alternative} = $alternative;
+        flesh_browse_results([$result]);
+        $client->respond($result);
+    }
+
+    return undef;
+}
+
+
+# Returns cache key and a list of parameters for DB proc metabib.browse().
+sub prepare_browse_parameters {
+    my ($params) = @_;
+
+    no warnings 'uninitialized';
+
+    my @params = (
+        $params->{browse_class},
+        $params->{term},
+        $params->{org_unit},
+        $params->{copy_location_group},
+        $params->{staff} ? 't' : 'f',
+        $params->{pivot},
+        $params->{limit} || 10
+    );
+
+    return (
+        "oils_browse_" . md5_hex(OpenSRF::Utils::JSON->perl2JSON(\@params)),
+        @params
+    );
+}
+
+sub leading_article_test {
+    my ($browse_class, $bterm) = @_;
+
+    my $flag_name = "opac.browse.warnable_regexp_per_class";
+    my $flag = new_editor()->retrieve_config_global_flag($flag_name);
+
+    return unless $flag->enabled eq 't';
+
+    my $map;
+    my $warning;
+    my $alternative;
+
+    eval { $map = OpenSRF::Utils::JSON->JSON2perl($flag->value); };
+    if ($@) {
+        $logger->warn("cgf '$flag_name' enabled but value is invalid JSON? $@");
+        return;
+    }
+
+    # Don't crash over any of the things that could go wrong in here:
+    eval {
+        if ($map->{$browse_class}) {
+            if ($bterm =~ qr/$map->{$browse_class}/i) {
+                $warning = 1;
+                ($alternative = $bterm) =~ s/$map->{$browse_class}//;
+            }
+        }
+    };
+
+    if ($@) {
+        $logger->warn("cgf '$flag_name' has valid JSON in value, but: $@");
+    }
+
+    return ($warning, $alternative);
+}
+
+# flesh_browse_results() attaches data from authority records. It
+# changes $results and returns 1 for success, undef for failure
+# $results must be an arrayref of result rows from the DB's metabib.browse()
+sub flesh_browse_results {
+    my ($results) = @_;
+
+    for my $authority_field_name ( qw/authorities sees/ ) {
+        for my $r (@$results) {
+            # Turn comma-seprated strings of numbers in "authorities" and "sees"
+            # columns into arrays.
+            if ($r->{$authority_field_name}) {
+                $r->{$authority_field_name} = [split /,/, $r->{$authority_field_name}];
+            } else {
+                $r->{$authority_field_name} = [];
+            }
+            $r->{"list_$authority_field_name"} = [ @{$r->{$authority_field_name} } ];
+        }
+
+        # Group them in one arrray, not worrying about dupes because we're about
+        # to use them in an IN () comparison in a SQL query.
+        my @auth_ids = map { @{$_->{$authority_field_name}} } @$results;
+
+        if (@auth_ids) {
+            # Get all linked authority records themselves
+            my $linked = new_editor()->json_query({
+                select => {
+                    are => [qw/id marc control_set/],
+                    aalink => [{column => "target", transform => "array_agg",
+                        aggregate => 1}]
+                },
+                from => {
+                    are => {
+                        aalink => {
+                            type => "left",
+                            fkey => "id", field => "source"
+                        }
+                    }
+                },
+                where => {"+are" => {id => \@auth_ids}}
+            }) or return;
+
+            map_authority_headings_to_results(
+                $linked, $results, \@auth_ids, $authority_field_name);
+        }
+    }
+
+    return 1;
+}
+
+sub map_authority_headings_to_results {
+    my ($linked, $results, $auth_ids, $authority_field_name) = @_;
+
+    # Use the linked authority records' control sets to find and pick
+    # out non-main-entry headings. Build the headings and make a
+    # combined data structure for the template's use.
+    my %linked_headings_by_auth_id = map {
+        $_->{id} => find_authority_headings_and_notes($_)
+    } @$linked;
+
+    # Avoid sending the full MARC blobs to the caller.
+    delete $_->{marc} for @$linked;
+
+    # Graft this authority heading data onto our main result set at the
+    # named column, either "authorities" or "sees".
+    foreach my $row (@$results) {
+        $row->{$authority_field_name} = [
+            map { $linked_headings_by_auth_id{$_} } @{$row->{$authority_field_name}}
+        ];
+    }
+
+    # Get linked-bib counts for each of those authorities, and put THAT
+    # information into place in the data structure.
+    my $counts = new_editor()->json_query({
+        select => {
+            abl => [
+                {column => "id", transform => "count",
+                    alias => "count", aggregate => 1},
+                "authority"
+            ]
+        },
+        from => {abl => {}},
+        where => {
+            "+abl" => {
+                authority => [
+                    @$auth_ids,
+                    $U->unique_unnested_numbers(map { $_->{target} } @$linked)
+                ]
+            }
+        }
+    }) or return;
+
+    my %auth_counts = map { $_->{authority} => $_->{count} } @$counts;
+
+    # Soooo nesty!  We look for places where we'll need a count of bibs
+    # linked to an authority record, and put it there for the template to find.
+    for my $row (@$results) {
+        for my $auth (@{$row->{$authority_field_name}}) {
+            if ($auth->{headings}) {
+                for my $outer_heading (@{$auth->{headings}}) {
+                    for my $heading_blob (@{(values %$outer_heading)[0]}) {
+                        if ($heading_blob->{target}) {
+                            $heading_blob->{target_count} =
+                                $auth_counts{$heading_blob->{target}};
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+
+# TOOD consider locale-aware caching
+sub get_acsaf {
+    my $control_set = shift;
+
+    my $acs = new_editor()
+        ->search_authority_control_set_authority_field(
+            {control_set => $control_set}
+        );
+
+    return {  map { $_->id => $_ } @$acs };
+}
+
+sub find_authority_headings_and_notes {
+    my ($row) = @_;
+
+    my $acsaf_table = get_acsaf($row->{control_set});
+
+    $row->{headings} = [];
+
+    my $record;
+    eval {
+        $record = new_from_xml MARC::Record($row->{marc});
+    };
+
+    if ($@) {
+        $logger->warn("Problem with MARC from authority record #" .
+            $row->{id} . ": $@");
+        return $row;    # We're called in map(), so we must move on without
+                        # a fuss.
+    }
+
+    extract_public_general_notes($record, $row);
+
+    # extract headings from the main authority record along with their
+    # types
+    my $parsed_headings = new_editor()->json_query({
+        from => ['authority.extract_headings', $row->{marc}]
+    });
+    my %heading_type_map = ();
+    if ($parsed_headings) {
+        foreach my $h (@$parsed_headings) {
+            $heading_type_map{$h->{normalized_heading}} =
+                $h->{purpose} eq 'variant' ? 'variant' :
+                $h->{purpose} eq 'related' ? $h->{related_type} :
+                '';
+        }
+    }
+
+    # By applying grep in this way, we get acsaf objects that *have* and
+    # therefore *aren't* main entries, which is what we want.
+    foreach my $acsaf (values(%$acsaf_table)) {
+        my @fields = $record->field($acsaf->tag);
+        my %sf_lookup = map { $_ => 1 } split("", $acsaf->display_sf_list);
+        my @headings;
+
+        foreach my $field (@fields) {
+            my $h = { main_entry => ( $acsaf->main_entry ? 0 : 1 ),
+                      heading => get_authority_heading($field, \%sf_lookup, $acsaf->joiner) };
+
+            my $norm = search_normalize($h->{heading});
+            if (exists $heading_type_map{$norm}) {
+                $h->{type} = $heading_type_map{$norm};
+            }
+            # XXX I was getting "target" from authority.authority_linking, but
+            # that makes no sense: that table can only tell you that one
+            # authority record as a whole points at another record.  It does
+            # not record when a specific *field* in one authority record
+            # points to another record (not that it makes much sense for
+            # one authority record to have links to multiple others, but I can't
+            # say there definitely aren't cases for that).
+            $h->{target} = $2
+                if ($field->subfield('0') || "") =~ /(^|\))(\d+)$/;
+
+            # The target is the row id if this is a main entry...
+            $h->{target} = $row->{id} if $h->{main_entry};
+
+            push @headings, $h;
+        }
+
+        push @{$row->{headings}}, {$acsaf->id => \@headings} if @headings;
+    }
+
+    return $row;
+}
+
+
+# Break out any Public General Notes (field 680) for display. These are
+# sometimes (erroneously?) called "scope notes." I say erroneously,
+# tentatively, because LoC doesn't seem to document a "scope notes"
+# field for authority records, while it does so for classification
+# records, which are something else. But I am not a librarian.
+sub extract_public_general_notes {
+    my ($record, $row) = @_;
+
+    # Make a list of strings, each string being a concatentation of any
+    # subfields 'i', '5', or 'a' from one field 680, in order of appearance.
+    $row->{notes} = [
+        map {
+            join(
+                " ",
+                map { $_->[1] } grep { $_->[0] =~ /[i5a]/ } $_->subfields
+            )
+        } $record->field('680')
+    ];
+}
+
+sub get_authority_heading {
+    my ($field, $sf_lookup, $joiner) = @_;
+
+    $joiner ||= ' ';
+
+    return join(
+        $joiner,
+        map { $_->[1] } grep { $sf_lookup->{$_->[0]} } $field->subfields
+    );
+}
+
+1;