Coerce numbers for bib IDs in Angular staff catalog
[working/Evergreen.git] / Open-ILS / src / eg2 / src / app / share / catalog / search-context.ts
1 import {OrgService} from '@eg/core/org.service';
2 import {IdlObject} from '@eg/core/idl.service';
3 import {Pager} from '@eg/share/util/pager';
4 import {Params} from '@angular/router';
5
6 // CCVM's we care about in a catalog context
7 // Don't fetch them all because there are a lot.
8 export const CATALOG_CCVM_FILTERS = [
9     'item_type',
10     'item_form',
11     'item_lang',
12     'audience',
13     'audience_group',
14     'vr_format',
15     'bib_level',
16     'lit_form',
17     'search_format',
18     'icon_format'
19 ];
20
21 export enum CatalogSearchState {
22     PENDING,
23     SEARCHING,
24     COMPLETE
25 }
26
27 export class FacetFilter {
28     facetClass: string;
29     facetName: string;
30     facetValue: string;
31
32     constructor(cls: string, name: string, value: string) {
33         this.facetClass = cls;
34         this.facetName  = name;
35         this.facetValue = value;
36     }
37
38     equals(filter: FacetFilter): boolean {
39         return (
40             this.facetClass === filter.facetClass &&
41             this.facetName  === filter.facetName &&
42             this.facetValue === filter.facetValue
43         );
44     }
45 }
46
47 export class CatalogSearchResults {
48     ids: number[];
49     count: number;
50     [misc: string]: any;
51
52     constructor() {
53         this.ids = [];
54         this.count = 0;
55     }
56 }
57
58 export class CatalogBrowseContext {
59     value: string;
60     pivot: number;
61     fieldClass: string;
62
63     reset() {
64         this.value = '';
65         this.pivot = null;
66         this.fieldClass = 'title';
67     }
68
69     isSearchable(): boolean {
70         return (
71             this.value !== '' &&
72             this.fieldClass !== ''
73         );
74     }
75 }
76
77 export class CatalogMarcContext {
78     tags: string[];
79     subfields: string[];
80     values: string[];
81
82     reset() {
83         this.tags = [''];
84         this.values = [''];
85         this.subfields = [''];
86     }
87
88     isSearchable() {
89         return (
90             this.tags[0] !== '' &&
91             this.values[0] !== ''
92         );
93     }
94
95 }
96
97 export class CatalogIdentContext {
98     value: string;
99     queryType: string;
100
101     reset() {
102         this.value = '';
103         this.queryType = '';
104     }
105
106     isSearchable() {
107         return (
108             this.value !== ''
109             && this.queryType !== ''
110         );
111     }
112
113 }
114
115 export class CatalogTermContext {
116     fieldClass: string[];
117     query: string[];
118     joinOp: string[];
119     matchOp: string[];
120     format: string;
121     available = false;
122     ccvmFilters: {[ccvmCode: string]: string[]};
123     facetFilters: FacetFilter[];
124     copyLocations: string[]; // ID's, but treated as strings in the UI.
125
126     // True when searching for metarecords
127     groupByMetarecord: boolean;
128
129     // Filter results by records which link to this metarecord ID.
130     fromMetarecord: number;
131
132     hasBrowseEntry: string; // "entryId,fieldId"
133     browseEntry: IdlObject;
134     date1: number;
135     date2: number;
136     dateOp: string; // before, after, between, is
137
138     reset() {
139         this.query = [''];
140         this.fieldClass  = ['keyword'];
141         this.matchOp = ['contains'];
142         this.joinOp = [''];
143         this.facetFilters = [];
144         this.copyLocations = [''];
145         this.format = '';
146         this.hasBrowseEntry = '';
147         this.date1 = null;
148         this.date2 = null;
149         this.dateOp = 'is';
150         this.fromMetarecord = null;
151
152         // Apply empty string values for each ccvm filter
153         this.ccvmFilters = {};
154         CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
155     }
156
157     // True when grouping by metarecord but not when displaying the
158     // contents of a metarecord.
159     isMetarecordSearch(): boolean {
160         return (
161             this.isSearchable() &&
162             this.groupByMetarecord &&
163             this.fromMetarecord === null
164         );
165     }
166
167     isSearchable(): boolean {
168         return (
169             this.query[0] !== ''
170             || this.hasBrowseEntry !== ''
171             || this.fromMetarecord !== null
172         );
173     }
174
175     hasFacet(facet: FacetFilter): boolean {
176         return Boolean(
177             this.facetFilters.filter(f => f.equals(facet))[0]
178         );
179     }
180
181     removeFacet(facet: FacetFilter): void {
182         this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
183     }
184
185     addFacet(facet: FacetFilter): void {
186         if (!this.hasFacet(facet)) {
187             this.facetFilters.push(facet);
188         }
189     }
190
191     toggleFacet(facet: FacetFilter): void {
192         if (this.hasFacet(facet)) {
193             this.removeFacet(facet);
194         } else {
195             this.facetFilters.push(facet);
196         }
197     }
198 }
199
200
201
202 // Not an angular service.
203 // It's conceviable there could be multiple contexts.
204 export class CatalogSearchContext {
205
206     // Attributes that are used across different contexts.
207     sort: string;
208     isStaff: boolean;
209     showBasket: boolean;
210     searchOrg: IdlObject;
211     global: boolean;
212
213     termSearch: CatalogTermContext;
214     marcSearch: CatalogMarcContext;
215     identSearch: CatalogIdentContext;
216     browseSearch: CatalogBrowseContext;
217
218     // Result from most recent search.
219     result: CatalogSearchResults;
220     searchState: CatalogSearchState = CatalogSearchState.PENDING;
221
222     // List of IDs in page/offset context.
223     resultIds: number[];
224
225     // Utility stuff
226     pager: Pager;
227     org: OrgService;
228
229     constructor() {
230         this.pager = new Pager();
231         this.termSearch = new CatalogTermContext();
232         this.marcSearch = new CatalogMarcContext();
233         this.identSearch = new CatalogIdentContext();
234         this.browseSearch = new CatalogBrowseContext();
235         this.reset();
236     }
237
238     /**
239      * Return search context to its default state, resetting search
240      * parameters and clearing any cached result data.
241      */
242     reset(): void {
243         this.pager.offset = 0;
244         this.sort = '';
245         this.showBasket = false;
246         this.result = new CatalogSearchResults();
247         this.resultIds = [];
248         this.searchState = CatalogSearchState.PENDING;
249         this.termSearch.reset();
250         this.marcSearch.reset();
251         this.identSearch.reset();
252         this.browseSearch.reset();
253     }
254
255     isSearchable(): boolean {
256         return (
257             this.showBasket ||
258             this.termSearch.isSearchable() ||
259             this.marcSearch.isSearchable() ||
260             this.identSearch.isSearchable() ||
261             this.browseSearch.isSearchable()
262         );
263     }
264
265     // List of result IDs for the current page of data.
266     currentResultIds(): number[] {
267         const ids = [];
268         const max = Math.min(
269             this.pager.offset + this.pager.limit,
270             this.pager.resultCount
271         );
272         for (let idx = this.pager.offset; idx < max; idx++) {
273             ids.push(this.resultIds[idx]);
274         }
275         return ids;
276     }
277
278     addResultId(id: number, resultIdx: number ): void {
279         this.resultIds[resultIdx + this.pager.offset] = Number(id);
280     }
281
282     // Return the record at the requested index.
283     resultIdAt(index: number): number {
284         return this.resultIds[index] || null;
285     }
286
287     // Return the index of the requested record
288     indexForResult(id: number): number {
289         for (let i = 0; i < this.resultIds.length; i++) {
290             if (this.resultIds[i] === id) {
291                 return i;
292             }
293         }
294         return null;
295     }
296
297     compileMarcSearchArgs(): any {
298         const searches: any = [];
299         const ms = this.marcSearch;
300
301         ms.values.forEach((val, idx) => {
302             if (val !== '') {
303                 searches.push({
304                     restrict: [{
305                         // "_" is the wildcard subfield for the API.
306                         subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
307                         tag: ms.tags[idx]
308                     }],
309                     term: ms.values[idx]
310                 });
311             }
312         });
313
314         const args: any = {
315             searches: searches,
316             limit : this.pager.limit,
317             offset : this.pager.offset,
318             org_unit: this.searchOrg.id()
319         };
320
321         if (this.sort) {
322             const parts = this.sort.split(/\./);
323             args.sort = parts[0]; // title, author, etc.
324             if (parts[1]) { args.sort_dir = 'descending'; }
325         }
326
327         return args;
328     }
329
330     compileIdentSearchQuery(): string {
331         const str = ' site(' + this.searchOrg.shortname() + ')';
332         return str + ' ' +
333             this.identSearch.queryType + ':' + this.identSearch.value;
334     }
335
336
337     compileBoolQuerySet(idx: number): string {
338         const ts = this.termSearch;
339         let query = ts.query[idx];
340         const joinOp = ts.joinOp[idx];
341         const matchOp = ts.matchOp[idx];
342         const fieldClass = ts.fieldClass[idx];
343
344         let str = '';
345         if (!query) { return str; }
346
347         if (idx > 0) { str += ' ' + joinOp + ' '; }
348
349         str += '(';
350         if (fieldClass) { str += fieldClass + ':'; }
351
352         switch (matchOp) {
353             case 'phrase':
354                 query = this.addQuotes(this.stripQuotes(query));
355                 break;
356             case 'nocontains':
357                 query = '-' + this.addQuotes(this.stripQuotes(query));
358                 break;
359             case 'exact':
360                 query = '^' + this.stripAnchors(query) + '$';
361                 break;
362             case 'starts':
363                 query = this.addQuotes('^' +
364                     this.stripAnchors(this.stripQuotes(query)));
365                 break;
366         }
367
368         return str + query + ')';
369     }
370
371     stripQuotes(query: string): string {
372         return query.replace(/"/g, '');
373     }
374
375     stripAnchors(query: string): string {
376         return query.replace(/[\^\$]/g, '');
377     }
378
379     addQuotes(query: string): string {
380         if (query.match(/ /)) {
381             return '"' + query + '"';
382         }
383         return query;
384     }
385
386     compileTermSearchQuery(): string {
387         const ts = this.termSearch;
388         let str = '';
389
390         if (ts.available) {
391             str += '#available';
392         }
393
394         if (this.sort) {
395             // e.g. title, title.descending
396             const parts = this.sort.split(/\./);
397             if (parts[1]) { str += ' #descending'; }
398             str += ' sort(' + parts[0] + ')';
399         }
400
401         if (ts.date1 && ts.dateOp) {
402             switch (ts.dateOp) {
403                 case 'is':
404                     str += ` date1(${ts.date1})`;
405                     break;
406                 case 'before':
407                     str += ` before(${ts.date1})`;
408                     break;
409                 case 'after':
410                     str += ` after(${ts.date1})`;
411                     break;
412                 case 'between':
413                     if (ts.date2) {
414                         str += ` between(${ts.date1},${ts.date2})`;
415                     }
416             }
417         }
418
419         // -------
420         // Compile boolean sub-query components
421         if (str.length) { str += ' '; }
422         const qcount = ts.query.length;
423
424         // if we multiple boolean query components, wrap them in parens.
425         if (qcount > 1) { str += '('; }
426         ts.query.forEach((q, idx) => {
427             str += this.compileBoolQuerySet(idx);
428         });
429         if (qcount > 1) { str += ')'; }
430         // -------
431
432         if (ts.hasBrowseEntry) {
433             // stored as a comma-separated string of "entryId,fieldId"
434             str += ` has_browse_entry(${ts.hasBrowseEntry})`;
435         }
436
437         if (ts.fromMetarecord) {
438             str += ` from_metarecord(${ts.fromMetarecord})`;
439         }
440
441         if (ts.format) {
442             str += ' format(' + ts.format + ')';
443         }
444
445         if (this.global) {
446             str += ' depth(' +
447                 this.org.root().ou_type().depth() + ')';
448         }
449
450         if (ts.copyLocations[0] !== '') {
451             str += ' locations(' + ts.copyLocations + ')';
452         }
453
454         str += ' site(' + this.searchOrg.shortname() + ')';
455
456         Object.keys(ts.ccvmFilters).forEach(field => {
457             if (ts.ccvmFilters[field][0] !== '') {
458                 str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
459             }
460         });
461
462         ts.facetFilters.forEach(f => {
463             str += ' ' + f.facetClass + '|'
464                 + f.facetName + '[' + f.facetValue + ']';
465         });
466
467         return str;
468     }
469 }
470