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