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