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