LP1860044 Angular catalog search result highlights
[working/Evergreen.git] / Open-ILS / src / eg2 / src / app / share / catalog / bib-record.service.ts
1 import {Injectable} from '@angular/core';
2 import {Observable, from} from 'rxjs';
3 import {mergeMap, map, tap} 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
10 export const NAMESPACE_MAPS = {
11     'mods':     'http://www.loc.gov/mods/v3',
12     'biblio':   'http://open-ils.org/spec/biblio/v1',
13     'holdings': 'http://open-ils.org/spec/holdings/v1',
14     'indexing': 'http://open-ils.org/spec/indexing/v1'
15 };
16
17 export const HOLDINGS_XPATH =
18     '/holdings:holdings/holdings:counts/holdings:count';
19
20
21 export class BibRecordSummary {
22     id: number; // == record.id() for convenience
23     metabibId: number; // If present, this is a metabib summary
24     metabibRecords: number[]; // all constituent bib records
25     orgId: number;
26     orgDepth: number;
27     record: IdlObject;
28     display: any;
29     attributes: any;
30     holdingsSummary: any;
31     holdCount: number;
32     bibCallNumber: string;
33     net: NetService;
34     displayHighlights: {[name: string]: string | string[]} = {};
35
36     constructor(record: IdlObject, orgId: number, orgDepth: number) {
37         this.id = Number(record.id());
38         this.record = record;
39         this.orgId = orgId;
40         this.orgDepth = orgDepth;
41         this.display = {};
42         this.attributes = {};
43         this.bibCallNumber = null;
44         this.metabibRecords = [];
45     }
46
47     ingest() {
48         this.compileDisplayFields();
49         this.compileRecordAttrs();
50
51         // Normalize some data for JS consistency
52         this.record.creator(Number(this.record.creator()));
53         this.record.editor(Number(this.record.editor()));
54     }
55
56     compileDisplayFields() {
57         this.record.flat_display_entries().forEach(entry => {
58             if (entry.multi() === 't') {
59                 if (this.display[entry.name()]) {
60                     this.display[entry.name()].push(entry.value());
61                 } else {
62                     this.display[entry.name()] = [entry.value()];
63                 }
64             } else {
65                 this.display[entry.name()] = entry.value();
66             }
67         });
68     }
69
70     compileRecordAttrs() {
71         // Any attr can be multi-valued.
72         this.record.mattrs().forEach(attr => {
73             if (this.attributes[attr.attr()]) {
74                 // Avoid dupes
75                 if (this.attributes[attr.attr()].indexOf(attr.value()) < 0) {
76                     this.attributes[attr.attr()].push(attr.value());
77                 }
78             } else {
79                 this.attributes[attr.attr()] = [attr.value()];
80             }
81         });
82     }
83
84     // Get -> Set -> Return bib hold count
85     getHoldCount(): Promise<number> {
86
87         if (Number.isInteger(this.holdCount)) {
88             return Promise.resolve(this.holdCount);
89         }
90
91         let method = 'open-ils.circ.bre.holds.count';
92         let target = this.id;
93
94         if (this.metabibId) {
95             method = 'open-ils.circ.mmr.holds.count';
96             target = this.metabibId;
97         }
98
99         return this.net.request(
100             'open-ils.circ', method, target
101         ).toPromise().then(count => this.holdCount = count);
102     }
103
104     // Get -> Set -> Return bib-level call number
105     getBibCallNumber(): Promise<string> {
106
107         if (this.bibCallNumber !== null) {
108             return Promise.resolve(this.bibCallNumber);
109         }
110
111         // TODO labelClass = cat.default_classification_scheme YAOUS
112         const labelClass = 1;
113
114         return this.net.request(
115             'open-ils.cat',
116             'open-ils.cat.biblio.record.marc_cn.retrieve',
117             this.id, labelClass
118         ).toPromise().then(cnArray => {
119             if (cnArray && cnArray.length > 0) {
120                 const key1 = Object.keys(cnArray[0])[0];
121                 this.bibCallNumber = cnArray[0][key1];
122             } else {
123                 this.bibCallNumber = '';
124             }
125             return this.bibCallNumber;
126         });
127     }
128 }
129
130 @Injectable()
131 export class BibRecordService {
132
133     // Cache of bib editor / creator objects
134     // Assumption is this list will be limited in size.
135     userCache: {[id: number]: IdlObject};
136
137     constructor(
138         private idl: IdlService,
139         private net: NetService,
140         private org: OrgService,
141         private unapi: UnapiService,
142         private pcrud: PcrudService
143     ) {
144         this.userCache = {};
145     }
146
147     // Avoid fetching the MARC blob by specifying which fields on the
148     // bre to select.  Note that fleshed fields are implicitly selected.
149     fetchableBreFields(): string[] {
150         return this.idl.classes.bre.fields
151             .filter(f => !f.virtual && f.name !== 'marc')
152             .map(f => f.name);
153     }
154
155     // Note when multiple IDs are provided, responses are emitted in order
156     // of receipt, not necessarily in the requested ID order.
157     getBibSummary(bibIds: number | number[],
158         orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
159
160         const ids = [].concat(bibIds);
161
162         if (ids.length === 0) {
163             return from([]);
164         }
165
166         return this.pcrud.search('bre', {id: ids},
167             {   flesh: 1,
168                 flesh_fields: {bre: ['flat_display_entries', 'mattrs']},
169                 select: {bre : this.fetchableBreFields()}
170             },
171             {anonymous: true} // skip unneccesary auth
172         ).pipe(mergeMap(bib => {
173             const summary = new BibRecordSummary(bib, orgId, orgDepth);
174             summary.net = this.net; // inject
175             summary.ingest();
176             return this.getHoldingsSummary(bib.id(), orgId, orgDepth)
177             .then(holdingsSummary => {
178                 summary.holdingsSummary = holdingsSummary;
179                 return summary;
180             });
181         }));
182     }
183
184     // A Metabib Summary is a BibRecordSummary with the lead record as
185     // its core bib record plus attributes (e.g. formats) from related
186     // records.
187     getMetabibSummary(metabibIds: number | number[],
188         orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
189
190         const ids = [].concat(metabibIds);
191
192         if (ids.length === 0) {
193             return from([]);
194         }
195
196         return this.pcrud.search('mmr', {id: ids},
197             {flesh: 1, flesh_fields: {mmr: ['source_maps']}},
198             {anonymous: true}
199         ).pipe(mergeMap(mmr => this.compileMetabib(mmr, orgId, orgDepth)));
200     }
201
202     // 'metabib' must have its "source_maps" field fleshed.
203     // Get bib summaries for all related bib records so we can
204     // extract data that must be appended to the master record summary.
205     compileMetabib(metabib: IdlObject,
206         orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
207
208         // TODO: Create an API similar to the one that builds a combined
209         // mods blob for metarecords, except using display fields, etc.
210         // For now, this seems to get the job done.
211
212         // Non-master records
213         const relatedBibIds = metabib.source_maps()
214             .map(m => m.source())
215             .filter(id => id !== metabib.master_record());
216
217         let observer;
218         const observable = new Observable<BibRecordSummary>(o => observer = o);
219
220         // NOTE: getBibSummary calls getHoldingsSummary against
221         // the bib record unnecessarily.  It's called again below.
222         // Reconsider this approach (see also note above about API).
223         this.getBibSummary(metabib.master_record(), orgId, orgDepth)
224         .subscribe(summary => {
225             summary.metabibId = Number(metabib.id());
226             summary.metabibRecords =
227                 metabib.source_maps().map(m => Number(m.source()));
228
229             let promise;
230
231             if (relatedBibIds.length > 0) {
232
233                 // Grab data for MR bib summary augmentation
234                 promise = this.pcrud.search('mraf', {id: relatedBibIds})
235                     .pipe(tap(attr => summary.record.mattrs().push(attr)))
236                     .toPromise();
237             } else {
238
239                 // Metarecord has only one constituent bib.
240                 promise = Promise.resolve();
241             }
242
243             promise.then(() => {
244
245                 // Re-compile with augmented data
246                 summary.compileRecordAttrs();
247
248                 // Fetch holdings data for the metarecord
249                 this.getHoldingsSummary(metabib.id(), orgId, orgDepth, true)
250                 .then(holdingsSummary => {
251                     summary.holdingsSummary = holdingsSummary;
252                     observer.next(summary);
253                     observer.complete();
254                 });
255             });
256         });
257
258         return observable;
259     }
260
261     // Flesh the creator and editor fields.
262     // Handling this separately lets us pull from the cache and
263     // avoids the requirement that the main bib query use a staff
264     // (VIEW_USER) auth token.
265     fleshBibUsers(records: IdlObject[]): Promise<void> {
266
267         const search = [];
268
269         records.forEach(rec => {
270             ['creator', 'editor'].forEach(field => {
271                 const id = rec[field]();
272                 if (Number.isInteger(id)) {
273                     if (this.userCache[id]) {
274                         rec[field](this.userCache[id]);
275                     } else if (!search.includes(id)) {
276                         search.push(id);
277                     }
278                 }
279             });
280         });
281
282         if (search.length === 0) {
283             return Promise.resolve();
284         }
285
286         return this.pcrud.search('au', {id: search})
287         .pipe(map(user => {
288             this.userCache[user.id()] = user;
289             records.forEach(rec => {
290                 if (user.id() === rec.creator()) {
291                     rec.creator(user);
292                 }
293                 if (user.id() === rec.editor()) {
294                     rec.editor(user);
295                 }
296             });
297         })).toPromise();
298     }
299
300     getHoldingsSummary(recordId: number,
301         orgId: number, orgDepth: number, isMetarecord?: boolean): Promise<any> {
302
303         const holdingsSummary = [];
304
305         return this.unapi.getAsXmlDocument({
306             target: isMetarecord ? 'mmr' : 'bre',
307             id: recordId,
308             extras: '{holdings_xml}',
309             format: 'holdings_xml',
310             orgId: orgId,
311             depth: orgDepth
312         }).then(xmlDoc => {
313
314             // namespace resolver
315             const resolver: any = (prefix: string): string => {
316                 return NAMESPACE_MAPS[prefix] || null;
317             };
318
319             // Extract the holdings data from the unapi xml doc
320             const result = xmlDoc.evaluate(HOLDINGS_XPATH,
321                 xmlDoc, resolver, XPathResult.ANY_TYPE, null);
322
323             let node;
324             while (node = result.iterateNext()) {
325                 const counts = {type : node.getAttribute('type')};
326                 ['depth', 'org_unit', 'transcendant',
327                     'available', 'count', 'unshadow'].forEach(field => {
328                     counts[field] = Number(node.getAttribute(field));
329                 });
330                 holdingsSummary.push(counts);
331             }
332
333             return holdingsSummary;
334         });
335     }
336 }
337
338