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