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