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