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