f993b8ccbc162d60ca43f6606c21aa566e416dbe
[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 {ArrayUtil} from '@eg/share/util/array';
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     clone(): FacetFilter {
47         return new FacetFilter(
48             this.facetClass, this.facetName, this.facetValue);
49     }
50 }
51
52 export class CatalogSearchResults {
53     ids: number[];
54     count: number;
55     [misc: string]: any;
56
57     constructor() {
58         this.ids = [];
59         this.count = 0;
60     }
61 }
62
63 export class CatalogBrowseContext {
64     value: string;
65     pivot: number;
66     fieldClass: string;
67
68     reset() {
69         this.value = '';
70         this.pivot = null;
71         this.fieldClass = 'title';
72     }
73
74     isSearchable(): boolean {
75         return (
76             this.value !== '' &&
77             this.fieldClass !== ''
78         );
79     }
80
81     clone(): CatalogBrowseContext {
82         const ctx = new CatalogBrowseContext();
83         ctx.value = this.value;
84         ctx.pivot = this.pivot;
85         ctx.fieldClass = this.fieldClass;
86         return ctx;
87     }
88
89     equals(ctx: CatalogBrowseContext): boolean {
90         return ctx.value === this.value && ctx.fieldClass === this.fieldClass;
91     }
92 }
93
94 export class CatalogMarcContext {
95     tags: string[];
96     subfields: string[];
97     values: string[];
98
99     reset() {
100         this.tags = [''];
101         this.values = [''];
102         this.subfields = [''];
103     }
104
105     isSearchable() {
106         return (
107             this.tags[0] !== '' &&
108             this.values[0] !== ''
109         );
110     }
111
112     clone(): CatalogMarcContext {
113         const ctx = new CatalogMarcContext();
114         ctx.tags = [].concat(this.tags);
115         ctx.values = [].concat(this.values);
116         ctx.subfields = [].concat(this.subfields);
117         return ctx;
118     }
119
120     equals(ctx: CatalogMarcContext): boolean {
121         return ArrayUtil.equals(ctx.tags, this.tags)
122             && ArrayUtil.equals(ctx.values, this.values)
123             && ArrayUtil.equals(ctx.subfields, this.subfields);
124     }
125 }
126
127 export class CatalogIdentContext {
128     value: string;
129     queryType: string;
130
131     reset() {
132         this.value = '';
133         this.queryType = '';
134     }
135
136     isSearchable() {
137         return (
138             this.value !== ''
139             && this.queryType !== ''
140         );
141     }
142
143     clone(): CatalogIdentContext {
144         const ctx = new CatalogIdentContext();
145         ctx.value = this.value;
146         ctx.queryType = this.queryType;
147         return ctx;
148     }
149
150     equals(ctx: CatalogIdentContext): boolean {
151         return ctx.value === this.value && ctx.queryType === this.queryType;
152     }
153 }
154
155 export class CatalogCnBrowseContext {
156     value: string;
157     // offset in pages from base browse term
158     // e.g. -2 means 2 pages back (alphabetically) from the original search.
159     offset: number;
160
161     reset() {
162         this.value = '';
163         this.offset = 0;
164     }
165
166     isSearchable() {
167         return this.value !== '' && this.value !== undefined;
168     }
169
170     clone(): CatalogCnBrowseContext {
171         const ctx = new CatalogCnBrowseContext();
172         ctx.value = this.value;
173         ctx.offset = this.offset;
174         return ctx;
175     }
176
177     equals(ctx: CatalogCnBrowseContext): boolean {
178         return ctx.value === this.value;
179     }
180 }
181
182 export class CatalogTermContext {
183     fieldClass: string[];
184     query: string[];
185     joinOp: string[];
186     matchOp: string[];
187     format: string;
188     available = false;
189     ccvmFilters: {[ccvmCode: string]: string[]};
190     facetFilters: FacetFilter[];
191     copyLocations: string[]; // ID's, but treated as strings in the UI.
192
193     // True when searching for metarecords
194     groupByMetarecord: boolean;
195
196     // Filter results by records which link to this metarecord ID.
197     fromMetarecord: number;
198
199     hasBrowseEntry: string; // "entryId,fieldId"
200     browseEntry: IdlObject;
201     date1: number;
202     date2: number;
203     dateOp: string; // before, after, between, is
204
205     reset() {
206         this.query = [''];
207         this.fieldClass  = ['keyword'];
208         this.matchOp = ['contains'];
209         this.joinOp = [''];
210         this.facetFilters = [];
211         this.copyLocations = [''];
212         this.format = '';
213         this.hasBrowseEntry = '';
214         this.date1 = null;
215         this.date2 = null;
216         this.dateOp = 'is';
217         this.fromMetarecord = null;
218
219         // Apply empty string values for each ccvm filter
220         this.ccvmFilters = {};
221         CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
222     }
223
224     clone(): CatalogTermContext {
225         const ctx = new CatalogTermContext();
226
227         ctx.query = [].concat(this.query);
228         ctx.fieldClass = [].concat(this.fieldClass);
229         ctx.matchOp = [].concat(this.matchOp);
230         ctx.joinOp = [].concat(this.joinOp);
231         ctx.copyLocations = [].concat(this.copyLocations);
232         ctx.format = this.format;
233         ctx.hasBrowseEntry = this.hasBrowseEntry;
234         ctx.date1 = this.date1;
235         ctx.date2 = this.date2;
236         ctx.dateOp = this.dateOp;
237         ctx.fromMetarecord = this.fromMetarecord;
238
239         ctx.facetFilters = this.facetFilters.map(f => f.clone());
240
241         ctx.ccvmFilters = {};
242         Object.keys(this.ccvmFilters).forEach(
243             key => ctx.ccvmFilters[key] = this.ccvmFilters[key]);
244
245         return ctx;
246     }
247
248     equals(ctx: CatalogTermContext): boolean {
249         if (   ArrayUtil.equals(ctx.query, this.query)
250             && ArrayUtil.equals(ctx.fieldClass, this.fieldClass)
251             && ArrayUtil.equals(ctx.matchOp, this.matchOp)
252             && ArrayUtil.equals(ctx.joinOp, this.joinOp)
253             && ArrayUtil.equals(ctx.copyLocations, this.copyLocations)
254             && ctx.format === this.format
255             && ctx.hasBrowseEntry === this.hasBrowseEntry
256             && ctx.date1 === this.date1
257             && ctx.date2 === this.date2
258             && ctx.dateOp === this.dateOp
259             && ctx.fromMetarecord === this.fromMetarecord
260             && ArrayUtil.equals(
261                 ctx.facetFilters, this.facetFilters, (a, b) => a.equals(b))
262             && Object.keys(this.ccvmFilters).length ===
263                 Object.keys(ctx.ccvmFilters).length
264         ) {
265
266             // So far so good, compare ccvm hash contents
267             let mismatch = false;
268             Object.keys(this.ccvmFilters).forEach(key => {
269                 if (!ArrayUtil.equals(this.ccvmFilters[key], ctx.ccvmFilters[key])) {
270                     mismatch = true;
271                 }
272             });
273
274             return !mismatch;
275         }
276
277         return false;
278     }
279
280
281     // True when grouping by metarecord but not when displaying the
282     // contents of a metarecord.
283     isMetarecordSearch(): boolean {
284         return (
285             this.isSearchable() &&
286             this.groupByMetarecord &&
287             this.fromMetarecord === null
288         );
289     }
290
291     isSearchable(): boolean {
292         return (
293             this.query[0] !== ''
294             || this.hasBrowseEntry !== ''
295             || this.fromMetarecord !== null
296         );
297     }
298
299     hasFacet(facet: FacetFilter): boolean {
300         return Boolean(
301             this.facetFilters.filter(f => f.equals(facet))[0]
302         );
303     }
304
305     removeFacet(facet: FacetFilter): void {
306         this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
307     }
308
309     addFacet(facet: FacetFilter): void {
310         if (!this.hasFacet(facet)) {
311             this.facetFilters.push(facet);
312         }
313     }
314
315     toggleFacet(facet: FacetFilter): void {
316         if (this.hasFacet(facet)) {
317             this.removeFacet(facet);
318         } else {
319             this.facetFilters.push(facet);
320         }
321     }
322 }
323
324
325
326 // Not an angular service.
327 // It's conceviable there could be multiple contexts.
328 export class CatalogSearchContext {
329
330     // Attributes that are used across different contexts.
331     sort: string;
332     isStaff: boolean;
333     showBasket: boolean;
334     searchOrg: IdlObject;
335     global: boolean;
336
337     termSearch: CatalogTermContext;
338     marcSearch: CatalogMarcContext;
339     identSearch: CatalogIdentContext;
340     browseSearch: CatalogBrowseContext;
341     cnBrowseSearch: CatalogCnBrowseContext;
342
343     // Result from most recent search.
344     result: CatalogSearchResults;
345     searchState: CatalogSearchState = CatalogSearchState.PENDING;
346
347     // List of IDs in page/offset context.
348     resultIds: number[];
349
350     // If a bib ID is provided, instruct the search code to
351     // only fetch field highlight data for a single record instead
352     // of all search results.
353     getHighlightsFor: number;
354     highlightData: {[id: number]: {[field: string]: string | string[]}} = {};
355
356     // Utility stuff
357     pager: Pager;
358     org: OrgService;
359
360     constructor() {
361         this.pager = new Pager();
362         this.termSearch = new CatalogTermContext();
363         this.marcSearch = new CatalogMarcContext();
364         this.identSearch = new CatalogIdentContext();
365         this.browseSearch = new CatalogBrowseContext();
366         this.cnBrowseSearch = new CatalogCnBrowseContext();
367         this.reset();
368     }
369
370     // Performs a deep clone of the search context as-is.
371     clone(): CatalogSearchContext {
372         const ctx = new CatalogSearchContext();
373
374         ctx.sort = this.sort;
375         ctx.isStaff = this.isStaff;
376         ctx.global = this.global;
377
378         // OK to share since the org object won't be changing.
379         ctx.searchOrg = this.searchOrg;
380
381         ctx.termSearch = this.termSearch.clone();
382         ctx.marcSearch = this.marcSearch.clone();
383         ctx.identSearch = this.identSearch.clone();
384         ctx.browseSearch = this.browseSearch.clone();
385         ctx.cnBrowseSearch = this.cnBrowseSearch.clone();
386
387         return ctx;
388     }
389
390     equals(ctx: CatalogSearchContext): boolean {
391         return (
392             this.termSearch.equals(ctx.termSearch)
393             && this.marcSearch.equals(ctx.marcSearch)
394             && this.identSearch.equals(ctx.identSearch)
395             && this.browseSearch.equals(ctx.browseSearch)
396             && this.cnBrowseSearch.equals(ctx.cnBrowseSearch)
397             && this.sort === ctx.sort
398             && this.global === ctx.global
399         );
400     }
401
402     /**
403      * Return search context to its default state, resetting search
404      * parameters and clearing any cached result data.
405      */
406     reset(): void {
407         this.pager.offset = 0;
408         this.sort = '';
409         this.showBasket = false;
410         this.result = new CatalogSearchResults();
411         this.resultIds = [];
412         this.highlightData = {};
413         this.searchState = CatalogSearchState.PENDING;
414         this.termSearch.reset();
415         this.marcSearch.reset();
416         this.identSearch.reset();
417         this.browseSearch.reset();
418         this.cnBrowseSearch.reset();
419     }
420
421     isSearchable(): boolean {
422         return (
423             this.showBasket ||
424             this.termSearch.isSearchable() ||
425             this.marcSearch.isSearchable() ||
426             this.identSearch.isSearchable() ||
427             this.browseSearch.isSearchable()
428         );
429     }
430
431     // List of result IDs for the current page of data.
432     currentResultIds(): number[] {
433         const ids = [];
434         const max = Math.min(
435             this.pager.offset + this.pager.limit,
436             this.pager.resultCount
437         );
438         for (let idx = this.pager.offset; idx < max; idx++) {
439             ids.push(this.resultIds[idx]);
440         }
441         return ids;
442     }
443
444     addResultId(id: number, resultIdx: number ): void {
445         this.resultIds[resultIdx + this.pager.offset] = Number(id);
446     }
447
448     // Return the record at the requested index.
449     resultIdAt(index: number): number {
450         return this.resultIds[index] || null;
451     }
452
453     // Return the index of the requested record
454     indexForResult(id: number): number {
455         for (let i = 0; i < this.resultIds.length; i++) {
456             if (this.resultIds[i] === id) {
457                 return i;
458             }
459         }
460         return null;
461     }
462
463     compileMarcSearchArgs(): any {
464         const searches: any = [];
465         const ms = this.marcSearch;
466
467         ms.values.forEach((val, idx) => {
468             if (val !== '') {
469                 searches.push({
470                     restrict: [{
471                         // "_" is the wildcard subfield for the API.
472                         subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
473                         tag: ms.tags[idx]
474                     }],
475                     term: ms.values[idx]
476                 });
477             }
478         });
479
480         const args: any = {
481             searches: searches,
482             limit : this.pager.limit,
483             offset : this.pager.offset,
484             org_unit: this.searchOrg.id()
485         };
486
487         if (this.sort) {
488             const parts = this.sort.split(/\./);
489             args.sort = parts[0]; // title, author, etc.
490             if (parts[1]) { args.sort_dir = 'descending'; }
491         }
492
493         return args;
494     }
495
496     compileIdentSearchQuery(): string {
497         const str = ' site(' + this.searchOrg.shortname() + ')';
498         return str + ' ' +
499             this.identSearch.queryType + ':' + this.identSearch.value;
500     }
501
502
503     compileBoolQuerySet(idx: number): string {
504         const ts = this.termSearch;
505         let query = ts.query[idx];
506         const joinOp = ts.joinOp[idx];
507         const matchOp = ts.matchOp[idx];
508         const fieldClass = ts.fieldClass[idx];
509
510         let str = '';
511         if (!query) { return str; }
512
513         if (idx > 0) { str += ' ' + joinOp + ' '; }
514
515         str += '(';
516         if (fieldClass) { str += fieldClass + ':'; }
517
518         switch (matchOp) {
519             case 'phrase':
520                 query = this.addQuotes(this.stripQuotes(query));
521                 break;
522             case 'nocontains':
523                 query = '-' + this.addQuotes(this.stripQuotes(query));
524                 break;
525             case 'exact':
526                 query = '^' + this.stripAnchors(query) + '$';
527                 break;
528             case 'starts':
529                 query = this.addQuotes('^' +
530                     this.stripAnchors(this.stripQuotes(query)));
531                 break;
532         }
533
534         return str + query + ')';
535     }
536
537     stripQuotes(query: string): string {
538         return query.replace(/"/g, '');
539     }
540
541     stripAnchors(query: string): string {
542         return query.replace(/[\^\$]/g, '');
543     }
544
545     addQuotes(query: string): string {
546         if (query.match(/ /)) {
547             return '"' + query + '"';
548         }
549         return query;
550     }
551
552     compileTermSearchQuery(): string {
553         const ts = this.termSearch;
554         let str = '';
555
556         if (ts.available) {
557             str += '#available';
558         }
559
560         if (this.sort) {
561             // e.g. title, title.descending
562             const parts = this.sort.split(/\./);
563             if (parts[1]) { str += ' #descending'; }
564             str += ' sort(' + parts[0] + ')';
565         }
566
567         if (ts.date1 && ts.dateOp) {
568             switch (ts.dateOp) {
569                 case 'is':
570                     str += ` date1(${ts.date1})`;
571                     break;
572                 case 'before':
573                     str += ` before(${ts.date1})`;
574                     break;
575                 case 'after':
576                     str += ` after(${ts.date1})`;
577                     break;
578                 case 'between':
579                     if (ts.date2) {
580                         str += ` between(${ts.date1},${ts.date2})`;
581                     }
582             }
583         }
584
585         // -------
586         // Compile boolean sub-query components
587         if (str.length) { str += ' '; }
588         const qcount = ts.query.length;
589
590         // if we multiple boolean query components, wrap them in parens.
591         if (qcount > 1) { str += '('; }
592         ts.query.forEach((q, idx) => {
593             str += this.compileBoolQuerySet(idx);
594         });
595         if (qcount > 1) { str += ')'; }
596         // -------
597
598         if (ts.hasBrowseEntry) {
599             // stored as a comma-separated string of "entryId,fieldId"
600             str += ` has_browse_entry(${ts.hasBrowseEntry})`;
601         }
602
603         if (ts.fromMetarecord) {
604             str += ` from_metarecord(${ts.fromMetarecord})`;
605         }
606
607         if (ts.format) {
608             str += ' format(' + ts.format + ')';
609         }
610
611         if (this.global) {
612             str += ' depth(' +
613                 this.org.root().ou_type().depth() + ')';
614         }
615
616         if (ts.copyLocations[0] !== '') {
617             str += ' locations(' + ts.copyLocations + ')';
618         }
619
620         str += ' site(' + this.searchOrg.shortname() + ')';
621
622         Object.keys(ts.ccvmFilters).forEach(field => {
623             if (ts.ccvmFilters[field][0] !== '') {
624                 str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
625             }
626         });
627
628         ts.facetFilters.forEach(f => {
629             str += ' ' + f.facetClass + '|'
630                 + f.facetName + '[' + f.facetValue + ']';
631         });
632
633         return str;
634     }
635
636     // A search context can collect enough data for multiple search
637     // types to be searchable (e.g. users navigate through parts of a
638     // search form).  Calling this method and providing a search type
639     // ensures the context is cleared of any data unrelated to the
640     // desired type.
641     scrub(searchType: string): void {
642
643         switch (searchType) {
644
645             case 'term': // AKA keyword search
646                 this.marcSearch.reset();
647                 this.browseSearch.reset();
648                 this.identSearch.reset();
649                 this.cnBrowseSearch.reset();
650                 this.termSearch.hasBrowseEntry = '';
651                 this.termSearch.browseEntry = null;
652                 this.termSearch.fromMetarecord = null;
653                 this.termSearch.facetFilters = [];
654                 break;
655
656             case 'ident':
657                 this.marcSearch.reset();
658                 this.browseSearch.reset();
659                 this.termSearch.reset();
660                 this.cnBrowseSearch.reset();
661                 break;
662
663             case 'marc':
664                 this.browseSearch.reset();
665                 this.termSearch.reset();
666                 this.identSearch.reset();
667                 this.cnBrowseSearch.reset();
668                 break;
669
670             case 'browse':
671                 this.marcSearch.reset();
672                 this.termSearch.reset();
673                 this.identSearch.reset();
674                 this.cnBrowseSearch.reset();
675                 this.browseSearch.pivot = null;
676                 break;
677
678             case 'cnbrowse':
679                 this.marcSearch.reset();
680                 this.termSearch.reset();
681                 this.identSearch.reset();
682                 this.browseSearch.reset();
683                 this.cnBrowseSearch.offset = 0;
684                 break;
685         }
686     }
687 }
688