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';
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 = [
22 export enum CatalogSearchState {
28 export class FacetFilter {
33 constructor(cls: string, name: string, value: string) {
34 this.facetClass = cls;
35 this.facetName = name;
36 this.facetValue = value;
39 equals(filter: FacetFilter): boolean {
41 this.facetClass === filter.facetClass &&
42 this.facetName === filter.facetName &&
43 this.facetValue === filter.facetValue
47 clone(): FacetFilter {
48 return new FacetFilter(
49 this.facetClass, this.facetName, this.facetValue);
53 export class CatalogSearchResults {
64 export class CatalogBrowseContext {
72 this.fieldClass = 'title';
75 isSearchable(): boolean {
78 this.fieldClass !== ''
82 clone(): CatalogBrowseContext {
83 const ctx = new CatalogBrowseContext();
84 ctx.value = this.value;
85 ctx.pivot = this.pivot;
86 ctx.fieldClass = this.fieldClass;
90 equals(ctx: CatalogBrowseContext): boolean {
91 return ctx.value === this.value && ctx.fieldClass === this.fieldClass;
95 export class CatalogMarcContext {
103 this.subfields = [''];
108 this.tags[0] !== '' &&
109 this.values[0] !== ''
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);
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);
128 export class CatalogIdentContext {
140 && this.queryType !== ''
144 clone(): CatalogIdentContext {
145 const ctx = new CatalogIdentContext();
146 ctx.value = this.value;
147 ctx.queryType = this.queryType;
151 equals(ctx: CatalogIdentContext): boolean {
152 return ctx.value === this.value && ctx.queryType === this.queryType;
156 export class CatalogCnBrowseContext {
158 // offset in pages from base browse term
159 // e.g. -2 means 2 pages back (alphabetically) from the original search.
162 // Maintain a separate page size limit since it will generally
163 // differ from other search page sizes.
169 this.limit = 5; // UI will modify
173 return this.value !== '' && this.value !== undefined;
176 clone(): CatalogCnBrowseContext {
177 const ctx = new CatalogCnBrowseContext();
178 ctx.value = this.value;
179 ctx.offset = this.offset;
180 ctx.limit = this.limit;
184 equals(ctx: CatalogCnBrowseContext): boolean {
185 return ctx.value === this.value;
189 export class CatalogTermContext {
190 fieldClass: string[];
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.
202 // True when searching for metarecords
203 groupByMetarecord: boolean;
205 // Filter results by records which link to this metarecord ID.
206 fromMetarecord: number;
208 hasBrowseEntry: string; // "entryId,fieldId"
209 browseEntry: IdlObject;
212 dateOp: string; // before, after, between, is
214 excludeElectronic = false;
218 this.fieldClass = ['keyword'];
219 this.matchOp = ['contains'];
221 this.facetFilters = [];
222 this.copyLocations = [''];
224 this.hasBrowseEntry = '';
228 this.fromMetarecord = null;
230 // Apply empty string values for each ccvm filter
231 this.ccvmFilters = {};
232 CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
235 clone(): CatalogTermContext {
236 const ctx = new CatalogTermContext();
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;
250 ctx.facetFilters = this.facetFilters.map(f => f.clone());
252 ctx.ccvmFilters = {};
253 Object.keys(this.ccvmFilters).forEach(
254 key => ctx.ccvmFilters[key] = this.ccvmFilters[key]);
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
272 ctx.facetFilters, this.facetFilters, (a, b) => a.equals(b))
273 && Object.keys(this.ccvmFilters).length ===
274 Object.keys(ctx.ccvmFilters).length
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])) {
292 // True when grouping by metarecord but not when displaying the
293 // contents of a metarecord.
294 isMetarecordSearch(): boolean {
296 this.isSearchable() &&
297 this.groupByMetarecord &&
298 this.fromMetarecord === null
302 isSearchable(): boolean {
305 || this.hasBrowseEntry !== ''
306 || this.fromMetarecord !== null
310 hasFacet(facet: FacetFilter): boolean {
312 this.facetFilters.filter(f => f.equals(facet))[0]
316 removeFacet(facet: FacetFilter): void {
317 this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
320 addFacet(facet: FacetFilter): void {
321 if (!this.hasFacet(facet)) {
322 this.facetFilters.push(facet);
326 toggleFacet(facet: FacetFilter): void {
327 if (this.hasFacet(facet)) {
328 this.removeFacet(facet);
330 this.facetFilters.push(facet);
337 // Not an angular service.
338 // It's conceviable there could be multiple contexts.
339 export class CatalogSearchContext {
341 // Attributes that are used across different contexts.
345 searchOrg: IdlObject;
349 termSearch: CatalogTermContext;
350 marcSearch: CatalogMarcContext;
351 identSearch: CatalogIdentContext;
352 browseSearch: CatalogBrowseContext;
353 cnBrowseSearch: CatalogCnBrowseContext;
355 // Result from most recent search.
356 result: CatalogSearchResults;
357 searchState: CatalogSearchState = CatalogSearchState.PENDING;
359 // fetch and show extra holdings data, etc.
360 showResultExtras = false;
362 // List of IDs in page/offset context.
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[]}} = {};
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();
385 // Performs a deep clone of the search context as-is.
386 clone(): CatalogSearchContext {
387 const ctx = new CatalogSearchContext();
389 ctx.sort = this.sort;
390 ctx.isStaff = this.isStaff;
391 ctx.global = this.global;
393 // OK to share since the org object won't be changing.
394 ctx.searchOrg = this.searchOrg;
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();
405 equals(ctx: CatalogSearchContext): boolean {
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
418 * Return search context to its default state, resetting search
419 * parameters and clearing any cached result data.
422 this.pager.offset = 0;
424 this.showBasket = false;
425 this.result = new CatalogSearchResults();
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();
436 isSearchable(): boolean {
439 this.termSearch.isSearchable() ||
440 this.marcSearch.isSearchable() ||
441 this.identSearch.isSearchable() ||
442 this.browseSearch.isSearchable()
446 // List of result IDs for the current page of data.
447 currentResultIds(): number[] {
449 const max = Math.min(
450 this.pager.offset + this.pager.limit,
451 this.pager.resultCount
453 for (let idx = this.pager.offset; idx < max; idx++) {
454 ids.push(this.resultIds[idx]);
459 addResultId(id: number, resultIdx: number ): void {
460 this.resultIds[resultIdx + this.pager.offset] = Number(id);
463 // Return the record at the requested index.
464 resultIdAt(index: number): number {
465 return this.resultIds[index] || null;
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) {
478 compileMarcSearchArgs(): any {
479 const searches: any = [];
480 const ms = this.marcSearch;
482 ms.values.forEach((val, idx) => {
486 // "_" is the wildcard subfield for the API.
487 subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
497 limit : this.pager.limit,
498 offset : this.pager.offset,
499 org_unit: this.searchOrg.id()
503 args.depth = this.org.root().ou_type().depth();
507 const parts = this.sort.split(/\./);
508 args.sort = parts[0]; // title, author, etc.
509 if (parts[1]) { args.sort_dir = 'descending'; }
515 compileIdentSearchQuery(): string {
516 const str = ' site(' + this.searchOrg.shortname() + ')';
518 this.identSearch.queryType + ':' + this.identSearch.value;
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];
529 // Bookplates are filters but may be displayed as regular
530 // text search indexes.
531 if (fieldClass === 'bookplate') { return ''; }
533 if (fieldClass === 'jtitle') { fieldClass = 'title'; }
536 if (!query) { return str; }
538 if (idx > 0) { str += ' ' + joinOp + ' '; }
541 if (fieldClass) { str += fieldClass + ':'; }
545 query = this.addQuotes(this.stripQuotes(query));
548 query = '-' + this.addQuotes(this.stripQuotes(query));
551 query = '^' + this.stripAnchors(query) + '$';
554 query = this.addQuotes('^' +
555 this.stripAnchors(this.stripQuotes(query)));
559 return str + query + ')';
562 stripQuotes(query: string): string {
563 return query.replace(/"/g, '');
566 stripAnchors(query: string): string {
567 return query.replace(/[\^$]/g, '');
570 addQuotes(query: string): string {
571 if (query.match(/ /)) {
572 return '"' + query + '"';
577 compileTermSearchQuery(): string {
578 const ts = this.termSearch;
585 if (ts.onReserveFilter) {
587 if (ts.onReserveFilterNegated) {
590 str += 'on_reserve(' + this.searchOrg.id() + ')';
593 if (ts.excludeElectronic) {
594 str += '-search_format(electronic)';
598 // e.g. title, title.descending
599 const parts = this.sort.split(/\./);
600 if (parts[1]) { str += ' #descending'; }
601 str += ' sort(' + parts[0] + ')';
604 if (ts.date1 && ts.dateOp) {
607 str += ` date1(${ts.date1})`;
610 str += ` before(${ts.date1})`;
613 str += ` after(${ts.date1})`;
617 str += ` between(${ts.date1},${ts.date2})`;
622 str = str.trimStart();
625 // Compile boolean sub-query components
626 if (str.length) { str += ' '; }
627 const qcount = ts.query.length;
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);
634 if (qcount > 1) { str += ')'; }
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})`;
647 // Journal Title queries means performing a title search
648 // with a filter. Filters are global, so append to the front
650 if (ts.fieldClass.filter(fc => fc === 'jtitle').length > 0) {
651 str = 'bib_level(s) ' + str;
654 if (ts.hasBrowseEntry) {
655 // stored as a comma-separated string of "entryId,fieldId"
656 str += ` has_browse_entry(${ts.hasBrowseEntry})`;
659 if (ts.fromMetarecord) {
660 str += ` from_metarecord(${ts.fromMetarecord})`;
664 str += ' search_format(' + ts.format + ')';
669 this.org.root().ou_type().depth() + ')';
672 if (ts.copyLocations[0] !== '') {
673 str += ' locations(' + ts.copyLocations + ')';
676 str += ' site(' + this.searchOrg.shortname() + ')';
678 Object.keys(ts.ccvmFilters).forEach(field => {
679 if (ts.ccvmFilters[field][0] !== '') {
680 str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
684 ts.facetFilters.forEach(f => {
685 str += ' ' + f.facetClass + '|'
686 + f.facetName + '[' + f.facetValue + ']';
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
697 scrub(searchType: string): void {
699 switch (searchType) {
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 = [];
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;
720 this.marcSearch.reset();
721 this.browseSearch.reset();
722 this.termSearch.reset();
723 this.cnBrowseSearch.reset();
727 this.browseSearch.reset();
728 this.termSearch.reset();
729 this.identSearch.reset();
730 this.cnBrowseSearch.reset();
734 this.marcSearch.reset();
735 this.termSearch.reset();
736 this.identSearch.reset();
737 this.cnBrowseSearch.reset();
738 this.browseSearch.pivot = null;
742 this.marcSearch.reset();
743 this.termSearch.reset();
744 this.identSearch.reset();
745 this.browseSearch.reset();
746 this.cnBrowseSearch.offset = 0;