]> git.evergreen-ils.org Git - working/Evergreen.git/blob - Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
LP1837478 Angular Catalog Recent Searches & Templates
[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     // Utility stuff
351     pager: Pager;
352     org: OrgService;
353
354     constructor() {
355         this.pager = new Pager();
356         this.termSearch = new CatalogTermContext();
357         this.marcSearch = new CatalogMarcContext();
358         this.identSearch = new CatalogIdentContext();
359         this.browseSearch = new CatalogBrowseContext();
360         this.cnBrowseSearch = new CatalogCnBrowseContext();
361         this.reset();
362     }
363
364     // Performs a deep clone of the search context as-is.
365     clone(): CatalogSearchContext {
366         const ctx = new CatalogSearchContext();
367
368         ctx.sort = this.sort;
369         ctx.isStaff = this.isStaff;
370         ctx.global = this.global;
371
372         // OK to share since the org object won't be changing.
373         ctx.searchOrg = this.searchOrg;
374
375         ctx.termSearch = this.termSearch.clone();
376         ctx.marcSearch = this.marcSearch.clone();
377         ctx.identSearch = this.identSearch.clone();
378         ctx.browseSearch = this.browseSearch.clone();
379         ctx.cnBrowseSearch = this.cnBrowseSearch.clone();
380
381         return ctx;
382     }
383
384     equals(ctx: CatalogSearchContext): boolean {
385         return (
386             this.termSearch.equals(ctx.termSearch)
387             && this.marcSearch.equals(ctx.marcSearch)
388             && this.identSearch.equals(ctx.identSearch)
389             && this.browseSearch.equals(ctx.browseSearch)
390             && this.cnBrowseSearch.equals(ctx.cnBrowseSearch)
391             && this.sort === ctx.sort
392             && this.global === ctx.global
393         );
394     }
395
396     /**
397      * Return search context to its default state, resetting search
398      * parameters and clearing any cached result data.
399      */
400     reset(): void {
401         this.pager.offset = 0;
402         this.sort = '';
403         this.showBasket = false;
404         this.result = new CatalogSearchResults();
405         this.resultIds = [];
406         this.searchState = CatalogSearchState.PENDING;
407         this.termSearch.reset();
408         this.marcSearch.reset();
409         this.identSearch.reset();
410         this.browseSearch.reset();
411         this.cnBrowseSearch.reset();
412     }
413
414     isSearchable(): boolean {
415         return (
416             this.showBasket ||
417             this.termSearch.isSearchable() ||
418             this.marcSearch.isSearchable() ||
419             this.identSearch.isSearchable() ||
420             this.browseSearch.isSearchable()
421         );
422     }
423
424     // List of result IDs for the current page of data.
425     currentResultIds(): number[] {
426         const ids = [];
427         const max = Math.min(
428             this.pager.offset + this.pager.limit,
429             this.pager.resultCount
430         );
431         for (let idx = this.pager.offset; idx < max; idx++) {
432             ids.push(this.resultIds[idx]);
433         }
434         return ids;
435     }
436
437     addResultId(id: number, resultIdx: number ): void {
438         this.resultIds[resultIdx + this.pager.offset] = Number(id);
439     }
440
441     // Return the record at the requested index.
442     resultIdAt(index: number): number {
443         return this.resultIds[index] || null;
444     }
445
446     // Return the index of the requested record
447     indexForResult(id: number): number {
448         for (let i = 0; i < this.resultIds.length; i++) {
449             if (this.resultIds[i] === id) {
450                 return i;
451             }
452         }
453         return null;
454     }
455
456     compileMarcSearchArgs(): any {
457         const searches: any = [];
458         const ms = this.marcSearch;
459
460         ms.values.forEach((val, idx) => {
461             if (val !== '') {
462                 searches.push({
463                     restrict: [{
464                         // "_" is the wildcard subfield for the API.
465                         subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
466                         tag: ms.tags[idx]
467                     }],
468                     term: ms.values[idx]
469                 });
470             }
471         });
472
473         const args: any = {
474             searches: searches,
475             limit : this.pager.limit,
476             offset : this.pager.offset,
477             org_unit: this.searchOrg.id()
478         };
479
480         if (this.sort) {
481             const parts = this.sort.split(/\./);
482             args.sort = parts[0]; // title, author, etc.
483             if (parts[1]) { args.sort_dir = 'descending'; }
484         }
485
486         return args;
487     }
488
489     compileIdentSearchQuery(): string {
490         const str = ' site(' + this.searchOrg.shortname() + ')';
491         return str + ' ' +
492             this.identSearch.queryType + ':' + this.identSearch.value;
493     }
494
495
496     compileBoolQuerySet(idx: number): string {
497         const ts = this.termSearch;
498         let query = ts.query[idx];
499         const joinOp = ts.joinOp[idx];
500         const matchOp = ts.matchOp[idx];
501         const fieldClass = ts.fieldClass[idx];
502
503         let str = '';
504         if (!query) { return str; }
505
506         if (idx > 0) { str += ' ' + joinOp + ' '; }
507
508         str += '(';
509         if (fieldClass) { str += fieldClass + ':'; }
510
511         switch (matchOp) {
512             case 'phrase':
513                 query = this.addQuotes(this.stripQuotes(query));
514                 break;
515             case 'nocontains':
516                 query = '-' + this.addQuotes(this.stripQuotes(query));
517                 break;
518             case 'exact':
519                 query = '^' + this.stripAnchors(query) + '$';
520                 break;
521             case 'starts':
522                 query = this.addQuotes('^' +
523                     this.stripAnchors(this.stripQuotes(query)));
524                 break;
525         }
526
527         return str + query + ')';
528     }
529
530     stripQuotes(query: string): string {
531         return query.replace(/"/g, '');
532     }
533
534     stripAnchors(query: string): string {
535         return query.replace(/[\^\$]/g, '');
536     }
537
538     addQuotes(query: string): string {
539         if (query.match(/ /)) {
540             return '"' + query + '"';
541         }
542         return query;
543     }
544
545     compileTermSearchQuery(): string {
546         const ts = this.termSearch;
547         let str = '';
548
549         if (ts.available) {
550             str += '#available';
551         }
552
553         if (this.sort) {
554             // e.g. title, title.descending
555             const parts = this.sort.split(/\./);
556             if (parts[1]) { str += ' #descending'; }
557             str += ' sort(' + parts[0] + ')';
558         }
559
560         if (ts.date1 && ts.dateOp) {
561             switch (ts.dateOp) {
562                 case 'is':
563                     str += ` date1(${ts.date1})`;
564                     break;
565                 case 'before':
566                     str += ` before(${ts.date1})`;
567                     break;
568                 case 'after':
569                     str += ` after(${ts.date1})`;
570                     break;
571                 case 'between':
572                     if (ts.date2) {
573                         str += ` between(${ts.date1},${ts.date2})`;
574                     }
575             }
576         }
577
578         // -------
579         // Compile boolean sub-query components
580         if (str.length) { str += ' '; }
581         const qcount = ts.query.length;
582
583         // if we multiple boolean query components, wrap them in parens.
584         if (qcount > 1) { str += '('; }
585         ts.query.forEach((q, idx) => {
586             str += this.compileBoolQuerySet(idx);
587         });
588         if (qcount > 1) { str += ')'; }
589         // -------
590
591         if (ts.hasBrowseEntry) {
592             // stored as a comma-separated string of "entryId,fieldId"
593             str += ` has_browse_entry(${ts.hasBrowseEntry})`;
594         }
595
596         if (ts.fromMetarecord) {
597             str += ` from_metarecord(${ts.fromMetarecord})`;
598         }
599
600         if (ts.format) {
601             str += ' format(' + ts.format + ')';
602         }
603
604         if (this.global) {
605             str += ' depth(' +
606                 this.org.root().ou_type().depth() + ')';
607         }
608
609         if (ts.copyLocations[0] !== '') {
610             str += ' locations(' + ts.copyLocations + ')';
611         }
612
613         str += ' site(' + this.searchOrg.shortname() + ')';
614
615         Object.keys(ts.ccvmFilters).forEach(field => {
616             if (ts.ccvmFilters[field][0] !== '') {
617                 str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
618             }
619         });
620
621         ts.facetFilters.forEach(f => {
622             str += ' ' + f.facetClass + '|'
623                 + f.facetName + '[' + f.facetValue + ']';
624         });
625
626         return str;
627     }
628
629     // A search context can collect enough data for multiple search
630     // types to be searchable (e.g. users navigate through parts of a
631     // search form).  Calling this method and providing a search type
632     // ensures the context is cleared of any data unrelated to the
633     // desired type.
634     scrub(searchType: string): void {
635
636         switch (searchType) {
637
638             case 'term': // AKA keyword search
639                 this.marcSearch.reset();
640                 this.browseSearch.reset();
641                 this.identSearch.reset();
642                 this.cnBrowseSearch.reset();
643                 this.termSearch.hasBrowseEntry = '';
644                 this.termSearch.browseEntry = null;
645                 this.termSearch.fromMetarecord = null;
646                 this.termSearch.facetFilters = [];
647                 break;
648
649             case 'ident':
650                 this.marcSearch.reset();
651                 this.browseSearch.reset();
652                 this.termSearch.reset();
653                 this.cnBrowseSearch.reset();
654                 break;
655
656             case 'marc':
657                 this.browseSearch.reset();
658                 this.termSearch.reset();
659                 this.identSearch.reset();
660                 this.cnBrowseSearch.reset();
661                 break;
662
663             case 'browse':
664                 this.marcSearch.reset();
665                 this.termSearch.reset();
666                 this.identSearch.reset();
667                 this.cnBrowseSearch.reset();
668                 this.browseSearch.pivot = null;
669                 break;
670
671             case 'cnbrowse':
672                 this.marcSearch.reset();
673                 this.termSearch.reset();
674                 this.identSearch.reset();
675                 this.browseSearch.reset();
676                 this.cnBrowseSearch.offset = 0;
677                 break;
678         }
679     }
680 }
681