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