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