3b50f61e4306fe02e0e72a84e84c1e20628c87b7
[working/Evergreen.git] / Open-ILS / src / eg2 / src / app / share / catalog / catalog.service.ts
1 import {Injectable, EventEmitter} from '@angular/core';
2 import {Observable} from 'rxjs';
3 import {map, tap, finalize} from 'rxjs/operators';
4 import {OrgService} from '@eg/core/org.service';
5 import {UnapiService} from '@eg/share/catalog/unapi.service';
6 import {IdlService, IdlObject} from '@eg/core/idl.service';
7 import {NetService} from '@eg/core/net.service';
8 import {PcrudService} from '@eg/core/pcrud.service';
9 import {CatalogSearchContext, CatalogSearchState} from './search-context';
10 import {BibRecordService, BibRecordSummary} from './bib-record.service';
11 import {BasketService} from './basket.service';
12 import {CATALOG_CCVM_FILTERS} from './search-context';
13
14 @Injectable()
15 export class CatalogService {
16
17     ccvmMap: {[ccvm: string]: IdlObject[]} = {};
18     cmfMap: {[cmf: string]: IdlObject} = {};
19     copyLocations: IdlObject[];
20
21     // Keep a reference to the most recently retrieved facet data,
22     // since facet data is consistent across a given search.
23     // No need to re-fetch with every page of search data.
24     lastFacetData: any;
25     lastFacetKey: string;
26
27     // Allow anyone to watch for completed searches.
28     onSearchComplete: EventEmitter<CatalogSearchContext>;
29
30     constructor(
31         private idl: IdlService,
32         private net: NetService,
33         private org: OrgService,
34         private unapi: UnapiService,
35         private pcrud: PcrudService,
36         private bibService: BibRecordService,
37         private basket: BasketService
38     ) {
39         this.onSearchComplete = new EventEmitter<CatalogSearchContext>();
40
41     }
42
43     search(ctx: CatalogSearchContext): Promise<void> {
44         ctx.searchState = CatalogSearchState.SEARCHING;
45
46         if (ctx.showBasket) {
47             return this.basketSearch(ctx);
48         } else if (ctx.marcSearch.isSearchable()) {
49             return this.marcSearch(ctx);
50         } else if (ctx.identSearch.isSearchable() &&
51             ctx.identSearch.queryType === 'item_barcode') {
52             return this.barcodeSearch(ctx);
53         } else {
54             return this.termSearch(ctx);
55         }
56     }
57
58     barcodeSearch(ctx: CatalogSearchContext): Promise<void> {
59         return this.net.request(
60             'open-ils.search',
61             'open-ils.search.multi_home.bib_ids.by_barcode',
62             ctx.identSearch.value
63         ).toPromise().then(ids => {
64             const result = {
65                 count: ids.length,
66                 ids: ids.map(id => [id])
67             };
68
69             this.applyResultData(ctx, result);
70             ctx.searchState = CatalogSearchState.COMPLETE;
71             this.onSearchComplete.emit(ctx);
72         });
73     }
74
75     // "Search" the basket by loading the IDs and treating
76     // them like a standard query search results set.
77     basketSearch(ctx: CatalogSearchContext): Promise<void> {
78
79         return this.basket.getRecordIds().then(ids => {
80
81             // Map our list of IDs into a search results object
82             // the search context can understand.
83             const result = {
84                 count: ids.length,
85                 ids: ids.map(id => [id])
86             };
87
88             this.applyResultData(ctx, result);
89             ctx.searchState = CatalogSearchState.COMPLETE;
90             this.onSearchComplete.emit(ctx);
91         });
92     }
93
94     marcSearch(ctx: CatalogSearchContext): Promise<void> {
95         let method = 'open-ils.search.biblio.marc';
96         if (ctx.isStaff) { method += '.staff'; }
97
98         const queryStruct = ctx.compileMarcSearchArgs();
99
100         return this.net.request('open-ils.search', method, queryStruct)
101         .toPromise().then(result => {
102             // Match the query search return format
103             result.ids = result.ids.map(id => [id]);
104
105             this.applyResultData(ctx, result);
106             ctx.searchState = CatalogSearchState.COMPLETE;
107             this.onSearchComplete.emit(ctx);
108         });
109     }
110
111     termSearch(ctx: CatalogSearchContext): Promise<void> {
112
113         let method = 'open-ils.search.biblio.multiclass.query';
114         let fullQuery;
115
116         if (ctx.identSearch.isSearchable()) {
117             fullQuery = ctx.compileIdentSearchQuery();
118
119         } else {
120             fullQuery = ctx.compileTermSearchQuery();
121
122             if (ctx.termSearch.groupByMetarecord
123                 && !ctx.termSearch.fromMetarecord) {
124                 method = 'open-ils.search.metabib.multiclass.query';
125             }
126
127             if (ctx.termSearch.hasBrowseEntry) {
128                 this.fetchBrowseEntry(ctx);
129             }
130         }
131
132         console.debug(`search query: ${fullQuery}`);
133
134         if (ctx.isStaff) {
135             method += '.staff';
136         }
137
138         return this.net.request(
139             'open-ils.search', method, {
140                 limit : ctx.pager.limit + 1,
141                 offset : ctx.pager.offset
142             }, fullQuery, true
143         ).toPromise()
144         .then(result => this.applyResultData(ctx, result))
145         .then(_ => this.fetchFieldHighlights(ctx))
146         .then(_ => {
147             ctx.searchState = CatalogSearchState.COMPLETE;
148             this.onSearchComplete.emit(ctx);
149         });
150     }
151
152     // When showing titles linked to a browse entry, fetch
153     // the entry data as well so the UI can display it.
154     fetchBrowseEntry(ctx: CatalogSearchContext) {
155         const ts = ctx.termSearch;
156
157         const parts = ts.hasBrowseEntry.split(',');
158         const mbeId = parts[0];
159         const cmfId = parts[1];
160
161         this.pcrud.retrieve('mbe', mbeId)
162         .subscribe(mbe => ctx.termSearch.browseEntry = mbe);
163     }
164
165     applyResultData(ctx: CatalogSearchContext, result: any): void {
166         ctx.result = result;
167         ctx.pager.resultCount = result.count;
168
169         // records[] tracks the current page of bib summaries.
170         result.records = [];
171
172         // If this is a new search, reset the result IDs collection.
173         if (this.lastFacetKey !== result.facet_key) {
174             ctx.resultIds = [];
175         }
176
177         result.ids.forEach((blob, idx) => ctx.addResultId(blob[0], idx));
178     }
179
180     // Appends records to the search result set as they arrive.
181     // Returns a void promise once all records have been retrieved
182     fetchBibSummaries(ctx: CatalogSearchContext): Promise<void> {
183
184         const depth = ctx.global ?
185             ctx.org.root().ou_type().depth() :
186             ctx.searchOrg.ou_type().depth();
187
188         const isMeta = ctx.termSearch.isMetarecordSearch();
189
190         let observable: Observable<BibRecordSummary>;
191
192         if (isMeta) {
193             observable = this.bibService.getMetabibSummary(
194                 ctx.currentResultIds(), ctx.searchOrg.id(), depth);
195         } else {
196             observable = this.bibService.getBibSummary(
197                 ctx.currentResultIds(), ctx.searchOrg.id(), depth);
198         }
199
200         return observable.pipe(map(summary => {
201             // Responses are not necessarily returned in request-ID order.
202             let idx;
203             if (isMeta) {
204                 idx = ctx.currentResultIds().indexOf(summary.metabibId);
205             } else {
206                 idx = ctx.currentResultIds().indexOf(summary.id);
207             }
208
209             if (ctx.result.records) {
210                 // May be reset when quickly navigating results.
211                 ctx.result.records[idx] = summary;
212             }
213
214             if (ctx.highlightData[summary.id]) {
215                 summary.displayHighlights = ctx.highlightData[summary.id];
216             }
217         })).toPromise();
218     }
219
220     fetchFieldHighlights(ctx: CatalogSearchContext): Promise<any> {
221
222         let hlMap;
223
224         // Extract the highlight map.  Not all searches have them.
225         if ((hlMap = ctx.result)            &&
226             (hlMap = hlMap.global_summary)  &&
227             (hlMap = hlMap.query_struct)    &&
228             (hlMap = hlMap.additional_data) &&
229             (hlMap = hlMap.highlight_map)   &&
230             (Object.keys(hlMap).length > 0)) {
231         } else { return Promise.resolve(); }
232
233         let ids;
234         if (ctx.getHighlightsFor) {
235             ids = [ctx.getHighlightsFor];
236         } else {
237             // ctx.currentResultIds() returns bib IDs or metabib IDs
238             // depending on the search type.  If we have metabib IDs, map
239             // them to bib IDs for highlighting.
240             ids = ctx.currentResultIds();
241             if (ctx.termSearch.groupByMetarecord) {
242                 ids = ids.map(mrId =>
243                     ctx.result.records.filter(r => mrId === r.metabibId)[0].id
244                 );
245             }
246         }
247
248         return this.net.requestWithParamList( // API is list-based
249             'open-ils.search',
250             'open-ils.search.fetch.metabib.display_field.highlight',
251             [hlMap].concat(ids)
252         ).pipe(map(fields => {
253
254             if (fields.length === 0) { return; }
255
256             // Each 'fields' collection is an array of display field
257             // values whose text is augmented with highlighting markup.
258             const highlights = ctx.highlightData[fields[0].source] = {};
259
260             fields.forEach(field => {
261                 const dfMap = this.cmfMap[field.field].display_field_map();
262                 if (!dfMap) { return; } // pretty sure this can't happen.
263
264                 if (dfMap.multi() === 't') {
265                     if (!highlights[dfMap.name()]) {
266                         highlights[dfMap.name()] = [];
267                     }
268                     (highlights[dfMap.name()] as string[]).push(field.highlight);
269                 } else {
270                     highlights[dfMap.name()] = field.highlight;
271                 }
272             });
273
274         })).toPromise();
275     }
276
277     fetchFacets(ctx: CatalogSearchContext): Promise<void> {
278
279         if (!ctx.result) {
280             return Promise.reject('Cannot fetch facets without results');
281         }
282
283         if (!ctx.result.facet_key) {
284             return Promise.resolve();
285         }
286
287         if (this.lastFacetKey === ctx.result.facet_key) {
288             ctx.result.facetData = this.lastFacetData;
289             return Promise.resolve();
290         }
291
292         return new Promise((resolve, reject) => {
293             this.net.request('open-ils.search',
294                 'open-ils.search.facet_cache.retrieve',
295                 ctx.result.facet_key
296             ).subscribe(facets => {
297                 const facetData = {};
298                 Object.keys(facets).forEach(cmfId => {
299                     const facetHash = facets[cmfId];
300                     const cmf = this.cmfMap[cmfId];
301
302                     const cmfData = [];
303                     Object.keys(facetHash).forEach(value => {
304                         const count = facetHash[value];
305                         cmfData.push({value : value, count : count});
306                     });
307
308                     if (!facetData[cmf.field_class()]) {
309                         facetData[cmf.field_class()] = {};
310                     }
311
312                     facetData[cmf.field_class()][cmf.name()] = {
313                         cmfLabel : cmf.label(),
314                         valueList : cmfData.sort((a, b) => {
315                             if (a.count > b.count) { return -1; }
316                             if (a.count < b.count) { return 1; }
317                             // secondary alpha sort on display value
318                             return a.value < b.value ? -1 : 1;
319                         })
320                     };
321                 });
322
323                 this.lastFacetKey = ctx.result.facet_key;
324                 this.lastFacetData = ctx.result.facetData = facetData;
325                 resolve();
326             });
327         });
328     }
329
330     fetchCcvms(): Promise<void> {
331
332         if (Object.keys(this.ccvmMap).length) {
333             return Promise.resolve();
334         }
335
336         return new Promise((resolve, reject) => {
337             this.pcrud.search('ccvm',
338                 {ctype : CATALOG_CCVM_FILTERS}, {},
339                 {atomic: true, anonymous: true}
340             ).subscribe(list => {
341                 this.compileCcvms(list);
342                 resolve();
343             });
344         });
345     }
346
347     compileCcvms(ccvms: IdlObject[]): void {
348         ccvms.forEach(ccvm => {
349             if (!this.ccvmMap[ccvm.ctype()]) {
350                 this.ccvmMap[ccvm.ctype()] = [];
351             }
352             this.ccvmMap[ccvm.ctype()].push(ccvm);
353         });
354
355         Object.keys(this.ccvmMap).forEach(cType => {
356             this.ccvmMap[cType] =
357                 this.ccvmMap[cType].sort((a, b) => {
358                     return a.value() < b.value() ? -1 : 1;
359                 });
360         });
361     }
362
363     iconFormatLabel(code: string): string {
364         if (this.ccvmMap) {
365             const ccvm = this.ccvmMap.icon_format.filter(
366                 format => format.code() === code)[0];
367             if (ccvm) {
368                 return ccvm.search_label();
369             }
370         }
371     }
372
373     fetchCmfs(): Promise<void> {
374         if (Object.keys(this.cmfMap).length) {
375             return Promise.resolve();
376         }
377
378         return new Promise((resolve, reject) => {
379             this.pcrud.search('cmf',
380                 {'-or': [{facet_field : 't'}, {display_field: 't'}]},
381                 {flesh: 1, flesh_fields: {cmf: ['display_field_map']}},
382                 {atomic: true, anonymous: true}
383             ).subscribe(
384                 cmfs => {
385                     cmfs.forEach(c => this.cmfMap[c.id()] = c);
386                     resolve();
387                 }
388             );
389         });
390     }
391
392     fetchCopyLocations(contextOrg: number | IdlObject): Promise<any> {
393         const orgIds = this.org.fullPath(contextOrg, true);
394         this.copyLocations = [];
395
396         return this.pcrud.search('acpl',
397             {deleted: 'f', opac_visible: 't', owning_lib: orgIds},
398             {order_by: {acpl: 'name'}},
399             {anonymous: true}
400         ).pipe(tap(loc => this.copyLocations.push(loc))).toPromise();
401     }
402
403     browse(ctx: CatalogSearchContext): Observable<any> {
404         ctx.searchState = CatalogSearchState.SEARCHING;
405         const bs = ctx.browseSearch;
406
407         let method = 'open-ils.search.browse';
408         if (ctx.isStaff) {
409             method += '.staff';
410         }
411
412         return this.net.request(
413             'open-ils.search',
414             'open-ils.search.browse.staff', {
415                 browse_class: bs.fieldClass,
416                 term: bs.value,
417                 limit : ctx.pager.limit,
418                 pivot: bs.pivot,
419                 org_unit: ctx.searchOrg.id()
420             }
421         ).pipe(
422             tap(result => ctx.searchState = CatalogSearchState.COMPLETE),
423             finalize(() => this.onSearchComplete.emit(ctx))
424         );
425     }
426
427     cnBrowse(ctx: CatalogSearchContext): Observable<any> {
428         ctx.searchState = CatalogSearchState.SEARCHING;
429         const cbs = ctx.cnBrowseSearch;
430
431         return this.net.request(
432             'open-ils.supercat',
433             'open-ils.supercat.call_number.browse',
434             cbs.value, ctx.searchOrg.shortname(), ctx.pager.limit, cbs.offset
435         ).pipe(tap(result => ctx.searchState = CatalogSearchState.COMPLETE));
436     }
437 }