LP1910808 Staff catalog show call number
[evergreen-equinox.git] / Open-ILS / src / eg2 / src / app / share / catalog / catalog.service.ts
index 7c3a365..798f549 100644 (file)
@@ -1,6 +1,6 @@
-import {Injectable} from '@angular/core';
+import {Injectable, EventEmitter} from '@angular/core';
 import {Observable} from 'rxjs';
-import {mergeMap, map} from 'rxjs/operators';
+import {map, tap, finalize} from 'rxjs/operators';
 import {OrgService} from '@eg/core/org.service';
 import {UnapiService} from '@eg/share/catalog/unapi.service';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
@@ -8,27 +8,15 @@ import {NetService} from '@eg/core/net.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {CatalogSearchContext, CatalogSearchState} from './search-context';
 import {BibRecordService, BibRecordSummary} from './bib-record.service';
-
-// CCVM's we care about in a catalog context
-// Don't fetch them all because there are a lot.
-export const CATALOG_CCVM_FILTERS = [
-    'item_type',
-    'item_form',
-    'item_lang',
-    'audience',
-    'audience_group',
-    'vr_format',
-    'bib_level',
-    'lit_form',
-    'search_format',
-    'icon_format'
-];
+import {BasketService} from './basket.service';
+import {CATALOG_CCVM_FILTERS} from './search-context';
 
 @Injectable()
 export class CatalogService {
 
     ccvmMap: {[ccvm: string]: IdlObject[]} = {};
     cmfMap: {[cmf: string]: IdlObject} = {};
+    copyLocations: IdlObject[];
 
     // Keep a reference to the most recently retrieved facet data,
     // since facet data is consistent across a given search.
@@ -36,41 +24,168 @@ export class CatalogService {
     lastFacetData: any;
     lastFacetKey: string;
 
+    // Allow anyone to watch for completed searches.
+    onSearchComplete: EventEmitter<CatalogSearchContext>;
+
     constructor(
         private idl: IdlService,
         private net: NetService,
         private org: OrgService,
         private unapi: UnapiService,
         private pcrud: PcrudService,
-        private bibService: BibRecordService
-    ) {}
+        private bibService: BibRecordService,
+        private basket: BasketService
+    ) {
+        this.onSearchComplete = new EventEmitter<CatalogSearchContext>();
+
+    }
 
     search(ctx: CatalogSearchContext): Promise<void> {
         ctx.searchState = CatalogSearchState.SEARCHING;
 
-        const fullQuery = ctx.compileSearch();
+        if (ctx.showBasket) {
+            return this.basketSearch(ctx);
+        } else if (ctx.marcSearch.isSearchable()) {
+            return this.marcSearch(ctx);
+        } else if (ctx.identSearch.isSearchable() &&
+            ctx.identSearch.queryType === 'item_barcode') {
+            return this.barcodeSearch(ctx);
+        } else if (
+            ctx.isStaff &&
+            ctx.identSearch.isSearchable() &&
+            ctx.identSearch.queryType === 'identifier|tcn') {
+            return this.tcnStaffSearch(ctx);
+        } else {
+            return this.termSearch(ctx);
+        }
+    }
+
+    barcodeSearch(ctx: CatalogSearchContext): Promise<void> {
+        return this.net.request(
+            'open-ils.search',
+            'open-ils.search.multi_home.bib_ids.by_barcode',
+            ctx.identSearch.value
+        ).toPromise().then(ids => {
+            // API returns an event for not-found barcodes
+            if (!Array.isArray(ids)) { ids = []; }
+            const result = {
+                count: ids.length,
+                ids: ids.map(id => [id])
+            };
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
 
-        console.debug(`search query: ${fullQuery}`);
+    tcnStaffSearch(ctx: CatalogSearchContext): Promise<void> {
+        return this.net.request(
+            'open-ils.search',
+            'open-ils.search.biblio.tcn',
+            ctx.identSearch.value, 1
+        ).toPromise().then(result => {
+            result.ids =  result.ids.map(id => [id]);
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+
+    // "Search" the basket by loading the IDs and treating
+    // them like a standard query search results set.
+    basketSearch(ctx: CatalogSearchContext): Promise<void> {
+
+        return this.basket.getRecordIds().then(ids => {
+
+            const pageIds =
+                ids.slice(ctx.pager.offset, ctx.pager.limit + ctx.pager.offset);
+
+            // Map our list of IDs into a search results object
+            // the search context can understand.
+            const result = {
+                count: ids.length,
+                ids: pageIds.map(id => [id])
+            };
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+    marcSearch(ctx: CatalogSearchContext): Promise<void> {
+        let method = 'open-ils.search.biblio.marc';
+        if (ctx.isStaff) { method += '.staff'; }
+
+        const queryStruct = ctx.compileMarcSearchArgs();
+
+        return this.net.request('open-ils.search', method, queryStruct)
+        .toPromise().then(result => {
+            // Match the query search return format
+            result.ids = result.ids.map(id => [id]);
+
+            this.applyResultData(ctx, result);
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
+        });
+    }
+
+    termSearch(ctx: CatalogSearchContext): Promise<void> {
 
         let method = 'open-ils.search.biblio.multiclass.query';
+        let fullQuery;
+
+        if (ctx.identSearch.isSearchable()) {
+            fullQuery = ctx.compileIdentSearchQuery();
+
+        } else {
+            fullQuery = ctx.compileTermSearchQuery();
+
+            if (ctx.termSearch.groupByMetarecord
+                && !ctx.termSearch.fromMetarecord) {
+                method = 'open-ils.search.metabib.multiclass.query';
+            }
+
+            if (ctx.termSearch.hasBrowseEntry) {
+                this.fetchBrowseEntry(ctx);
+            }
+        }
+
+        console.debug(`search query: ${fullQuery}`);
+
         if (ctx.isStaff) {
             method += '.staff';
         }
 
-        return new Promise((resolve, reject) => {
-            this.net.request(
-                'open-ils.search', method, {
-                    limit : ctx.pager.limit + 1,
-                    offset : ctx.pager.offset
-                }, fullQuery, true
-            ).subscribe(result => {
-                this.applyResultData(ctx, result);
-                ctx.searchState = CatalogSearchState.COMPLETE;
-                resolve();
-            });
+        return this.net.request(
+            'open-ils.search', method, {
+                limit : ctx.pager.limit + 1,
+                offset : ctx.pager.offset
+            }, fullQuery, true
+        ).toPromise()
+        .then(result => this.applyResultData(ctx, result))
+        .then(_ => this.fetchFieldHighlights(ctx))
+        .then(_ => {
+            ctx.searchState = CatalogSearchState.COMPLETE;
+            this.onSearchComplete.emit(ctx);
         });
     }
 
+    // When showing titles linked to a browse entry, fetch
+    // the entry data as well so the UI can display it.
+    fetchBrowseEntry(ctx: CatalogSearchContext) {
+        const ts = ctx.termSearch;
+
+        const parts = ts.hasBrowseEntry.split(',');
+        const mbeId = parts[0];
+        const cmfId = parts[1];
+
+        this.pcrud.retrieve('mbe', mbeId)
+        .subscribe(mbe => ctx.termSearch.browseEntry = mbe);
+    }
+
     applyResultData(ctx: CatalogSearchContext, result: any): void {
         ctx.result = result;
         ctx.pager.resultCount = result.count;
@@ -90,19 +205,104 @@ export class CatalogService {
     // Returns a void promise once all records have been retrieved
     fetchBibSummaries(ctx: CatalogSearchContext): Promise<void> {
 
-        const depth = ctx.global ?
-            ctx.org.root().ou_type().depth() :
-            ctx.searchOrg.ou_type().depth();
+        const org = ctx.global ? ctx.org.root() : ctx.searchOrg;
+        const depth = org.ou_type().depth();
+
+        const isMeta = ctx.termSearch.isMetarecordSearch();
+
+        let observable: Observable<BibRecordSummary>;
+
+        const options: any = {pref_ou: ctx.prefOu};
 
-        return this.bibService.getBibSummary(
-            ctx.currentResultIds(), ctx.searchOrg.id(), depth)
-        .pipe(map(summary => {
+        if (ctx.showResultExtras) {
+            options.flesh_copies = true;
+            options.copy_depth = depth;
+            options.copy_limit = 5;
+            options.pref_ou = ctx.prefOu;
+        }
+
+        if (isMeta) {
+            observable = this.bibService.getMetabibSummaries(
+                ctx.currentResultIds(), ctx.searchOrg.id(), ctx.isStaff, options);
+        } else {
+            observable = this.bibService.getBibSummaries(
+                ctx.currentResultIds(), ctx.searchOrg.id(), ctx.isStaff, options);
+        }
+
+        return observable.pipe(map(summary => {
             // Responses are not necessarily returned in request-ID order.
-            const idx = ctx.currentResultIds().indexOf(summary.record.id());
+            let idx;
+            if (isMeta) {
+                idx = ctx.currentResultIds().indexOf(summary.metabibId);
+            } else {
+                idx = ctx.currentResultIds().indexOf(summary.id);
+            }
+
             if (ctx.result.records) {
                 // May be reset when quickly navigating results.
                 ctx.result.records[idx] = summary;
             }
+
+            if (ctx.highlightData[summary.id]) {
+                summary.displayHighlights = ctx.highlightData[summary.id];
+            }
+        })).toPromise();
+    }
+
+    fetchFieldHighlights(ctx: CatalogSearchContext): Promise<any> {
+
+        let hlMap;
+
+        // Extract the highlight map.  Not all searches have them.
+        if ((hlMap = ctx.result)            &&
+            (hlMap = hlMap.global_summary)  &&
+            (hlMap = hlMap.query_struct)    &&
+            (hlMap = hlMap.additional_data) &&
+            (hlMap = hlMap.highlight_map)   &&
+            (Object.keys(hlMap).length > 0)) {
+        } else { return Promise.resolve(); }
+
+        let ids;
+        if (ctx.getHighlightsFor) {
+            ids = [ctx.getHighlightsFor];
+        } else {
+            // ctx.currentResultIds() returns bib IDs or metabib IDs
+            // depending on the search type.  If we have metabib IDs, map
+            // them to bib IDs for highlighting.
+            ids = ctx.currentResultIds();
+            if (ctx.termSearch.groupByMetarecord) {
+                // The 4th slot in the result ID reports the master record
+                // for the metarecord in question.  Sometimes it's null?
+                ids = ctx.result.ids.map(id => id[4]).filter(id => id !== null);
+            }
+        }
+
+        return this.net.requestWithParamList( // API is list-based
+            'open-ils.search',
+            'open-ils.search.fetch.metabib.display_field.highlight',
+            [hlMap].concat(ids)
+        ).pipe(map(fields => {
+
+            if (fields.length === 0) { return; }
+
+            // Each 'fields' collection is an array of display field
+            // values whose text is augmented with highlighting markup.
+            const highlights = ctx.highlightData[fields[0].source] = {};
+
+            fields.forEach(field => {
+                const dfMap = this.cmfMap[field.field].display_field_map();
+                if (!dfMap) { return; } // pretty sure this can't happen.
+
+                if (dfMap.multi() === 't') {
+                    if (!highlights[dfMap.name()]) {
+                        highlights[dfMap.name()] = [];
+                    }
+                    (highlights[dfMap.name()] as string[]).push(field.highlight);
+                } else {
+                    highlights[dfMap.name()] = field.highlight;
+                }
+            });
+
         })).toPromise();
     }
 
@@ -112,6 +312,10 @@ export class CatalogService {
             return Promise.reject('Cannot fetch facets without results');
         }
 
+        if (!ctx.result.facet_key) {
+            return Promise.resolve();
+        }
+
         if (this.lastFacetKey === ctx.result.facet_key) {
             ctx.result.facetData = this.lastFacetData;
             return Promise.resolve();
@@ -188,16 +392,26 @@ export class CatalogService {
         });
     }
 
+    iconFormatLabel(code: string): string {
+        if (this.ccvmMap) {
+            const ccvm = this.ccvmMap.icon_format.filter(
+                format => format.code() === code)[0];
+            if (ccvm) {
+                return ccvm.search_label();
+            }
+        }
+    }
 
     fetchCmfs(): Promise<void> {
-        // At the moment, we only need facet CMFs.
         if (Object.keys(this.cmfMap).length) {
             return Promise.resolve();
         }
 
         return new Promise((resolve, reject) => {
             this.pcrud.search('cmf',
-                {facet_field : 't'}, {}, {atomic: true, anonymous: true}
+                {'-or': [{facet_field : 't'}, {display_field: 't'}]},
+                {flesh: 1, flesh_fields: {cmf: ['display_field_map']}},
+                {atomic: true, anonymous: true}
             ).subscribe(
                 cmfs => {
                     cmfs.forEach(c => this.cmfMap[c.id()] = c);
@@ -206,4 +420,59 @@ export class CatalogService {
             );
         });
     }
+
+    fetchCopyLocations(contextOrg: number | IdlObject): Promise<any> {
+        const contextOrgId: any = this.org.get(contextOrg).id();
+
+        // we ordinarily want the shelving locations associated with
+        // all ancestors and descendants of the context OU, but
+        // if the context OU is the root, we intentionally want
+        // only the ones owned by the root OU
+        const orgIds: any[] = contextOrgId === this.org.root().id()
+            ? [contextOrgId]
+            : this.org.fullPath(contextOrg, true);
+
+        this.copyLocations = [];
+
+        return this.pcrud.search('acpl',
+            {deleted: 'f', opac_visible: 't', owning_lib: orgIds},
+            {order_by: {acpl: 'name'}},
+            {anonymous: true}
+        ).pipe(tap(loc => this.copyLocations.push(loc))).toPromise();
+    }
+
+    browse(ctx: CatalogSearchContext): Observable<any> {
+        ctx.searchState = CatalogSearchState.SEARCHING;
+        const bs = ctx.browseSearch;
+
+        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: bs.fieldClass,
+                term: bs.value,
+                limit : ctx.pager.limit,
+                pivot: bs.pivot,
+                org_unit: ctx.searchOrg.id()
+            }
+        ).pipe(
+            tap(result => ctx.searchState = CatalogSearchState.COMPLETE),
+            finalize(() => this.onSearchComplete.emit(ctx))
+        );
+    }
+
+    cnBrowse(ctx: CatalogSearchContext): Observable<any> {
+        ctx.searchState = CatalogSearchState.SEARCHING;
+        const cbs = ctx.cnBrowseSearch;
+
+        return this.net.request(
+            'open-ils.supercat',
+            'open-ils.supercat.call_number.browse',
+            cbs.value, ctx.searchOrg.shortname(), cbs.limit, cbs.offset
+        ).pipe(tap(result => ctx.searchState = CatalogSearchState.COMPLETE));
+    }
 }