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