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';
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 = [
21 export enum CatalogSearchState {
27 export class FacetFilter {
32 constructor(cls: string, name: string, value: string) {
33 this.facetClass = cls;
34 this.facetName = name;
35 this.facetValue = value;
38 equals(filter: FacetFilter): boolean {
40 this.facetClass === filter.facetClass &&
41 this.facetName === filter.facetName &&
42 this.facetValue === filter.facetValue
46 clone(): FacetFilter {
47 return new FacetFilter(
48 this.facetClass, this.facetName, this.facetValue);
52 export class CatalogSearchResults {
63 export class CatalogBrowseContext {
71 this.fieldClass = 'title';
74 isSearchable(): boolean {
77 this.fieldClass !== ''
81 clone(): CatalogBrowseContext {
82 const ctx = new CatalogBrowseContext();
83 ctx.value = this.value;
84 ctx.pivot = this.pivot;
85 ctx.fieldClass = this.fieldClass;
89 equals(ctx: CatalogBrowseContext): boolean {
90 return ctx.value === this.value && ctx.fieldClass === this.fieldClass;
94 export class CatalogMarcContext {
102 this.subfields = [''];
107 this.tags[0] !== '' &&
108 this.values[0] !== ''
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);
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);
127 export class CatalogIdentContext {
139 && this.queryType !== ''
143 clone(): CatalogIdentContext {
144 const ctx = new CatalogIdentContext();
145 ctx.value = this.value;
146 ctx.queryType = this.queryType;
150 equals(ctx: CatalogIdentContext): boolean {
151 return ctx.value === this.value && ctx.queryType === this.queryType;
155 export class CatalogCnBrowseContext {
157 // offset in pages from base browse term
158 // e.g. -2 means 2 pages back (alphabetically) from the original search.
161 // Maintain a separate page size limit since it will generally
162 // differ from other search page sizes.
168 this.limit = 5; // UI will modify
172 return this.value !== '' && this.value !== undefined;
175 clone(): CatalogCnBrowseContext {
176 const ctx = new CatalogCnBrowseContext();
177 ctx.value = this.value;
178 ctx.offset = this.offset;
179 ctx.limit = this.limit;
183 equals(ctx: CatalogCnBrowseContext): boolean {
184 return ctx.value === this.value;
188 export class CatalogTermContext {
189 fieldClass: string[];
195 ccvmFilters: {[ccvmCode: string]: string[]};
196 facetFilters: FacetFilter[];
197 copyLocations: string[]; // ID's, but treated as strings in the UI.
199 // True when searching for metarecords
200 groupByMetarecord: boolean;
202 // Filter results by records which link to this metarecord ID.
203 fromMetarecord: number;
205 hasBrowseEntry: string; // "entryId,fieldId"
206 browseEntry: IdlObject;
209 dateOp: string; // before, after, between, is
211 excludeElectronic = false;
215 this.fieldClass = ['keyword'];
216 this.matchOp = ['contains'];
218 this.facetFilters = [];
219 this.copyLocations = [''];
221 this.hasBrowseEntry = '';
225 this.fromMetarecord = null;
227 // Apply empty string values for each ccvm filter
228 this.ccvmFilters = {};
229 CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
232 clone(): CatalogTermContext {
233 const ctx = new CatalogTermContext();
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;
247 ctx.facetFilters = this.facetFilters.map(f => f.clone());
249 ctx.ccvmFilters = {};
250 Object.keys(this.ccvmFilters).forEach(
251 key => ctx.ccvmFilters[key] = this.ccvmFilters[key]);
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
269 ctx.facetFilters, this.facetFilters, (a, b) => a.equals(b))
270 && Object.keys(this.ccvmFilters).length ===
271 Object.keys(ctx.ccvmFilters).length
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])) {
289 // True when grouping by metarecord but not when displaying the
290 // contents of a metarecord.
291 isMetarecordSearch(): boolean {
293 this.isSearchable() &&
294 this.groupByMetarecord &&
295 this.fromMetarecord === null
299 isSearchable(): boolean {
302 || this.hasBrowseEntry !== ''
303 || this.fromMetarecord !== null
307 hasFacet(facet: FacetFilter): boolean {
309 this.facetFilters.filter(f => f.equals(facet))[0]
313 removeFacet(facet: FacetFilter): void {
314 this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
317 addFacet(facet: FacetFilter): void {
318 if (!this.hasFacet(facet)) {
319 this.facetFilters.push(facet);
323 toggleFacet(facet: FacetFilter): void {
324 if (this.hasFacet(facet)) {
325 this.removeFacet(facet);
327 this.facetFilters.push(facet);
334 // Not an angular service.
335 // It's conceviable there could be multiple contexts.
336 export class CatalogSearchContext {
338 // Attributes that are used across different contexts.
342 searchOrg: IdlObject;
345 termSearch: CatalogTermContext;
346 marcSearch: CatalogMarcContext;
347 identSearch: CatalogIdentContext;
348 browseSearch: CatalogBrowseContext;
349 cnBrowseSearch: CatalogCnBrowseContext;
351 // Result from most recent search.
352 result: CatalogSearchResults;
353 searchState: CatalogSearchState = CatalogSearchState.PENDING;
355 // List of IDs in page/offset context.
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[]}} = {};
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();
378 // Performs a deep clone of the search context as-is.
379 clone(): CatalogSearchContext {
380 const ctx = new CatalogSearchContext();
382 ctx.sort = this.sort;
383 ctx.isStaff = this.isStaff;
384 ctx.global = this.global;
386 // OK to share since the org object won't be changing.
387 ctx.searchOrg = this.searchOrg;
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();
398 equals(ctx: CatalogSearchContext): boolean {
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
411 * Return search context to its default state, resetting search
412 * parameters and clearing any cached result data.
415 this.pager.offset = 0;
417 this.showBasket = false;
418 this.result = new CatalogSearchResults();
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();
429 isSearchable(): boolean {
432 this.termSearch.isSearchable() ||
433 this.marcSearch.isSearchable() ||
434 this.identSearch.isSearchable() ||
435 this.browseSearch.isSearchable()
439 // List of result IDs for the current page of data.
440 currentResultIds(): number[] {
442 const max = Math.min(
443 this.pager.offset + this.pager.limit,
444 this.pager.resultCount
446 for (let idx = this.pager.offset; idx < max; idx++) {
447 ids.push(this.resultIds[idx]);
452 addResultId(id: number, resultIdx: number ): void {
453 this.resultIds[resultIdx + this.pager.offset] = Number(id);
456 // Return the record at the requested index.
457 resultIdAt(index: number): number {
458 return this.resultIds[index] || null;
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) {
471 compileMarcSearchArgs(): any {
472 const searches: any = [];
473 const ms = this.marcSearch;
475 ms.values.forEach((val, idx) => {
479 // "_" is the wildcard subfield for the API.
480 subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
490 limit : this.pager.limit,
491 offset : this.pager.offset,
492 org_unit: this.searchOrg.id()
496 const parts = this.sort.split(/\./);
497 args.sort = parts[0]; // title, author, etc.
498 if (parts[1]) { args.sort_dir = 'descending'; }
504 compileIdentSearchQuery(): string {
505 const str = ' site(' + this.searchOrg.shortname() + ')';
507 this.identSearch.queryType + ':' + this.identSearch.value;
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 let fieldClass = ts.fieldClass[idx];
518 // Bookplates are filters but may be displayed as regular
519 // text search indexes.
520 if (fieldClass === 'bookplate') { return ''; }
522 if (fieldClass === 'jtitle') { fieldClass = 'title'; }
525 if (!query) { return str; }
527 if (idx > 0) { str += ' ' + joinOp + ' '; }
530 if (fieldClass) { str += fieldClass + ':'; }
534 query = this.addQuotes(this.stripQuotes(query));
537 query = '-' + this.addQuotes(this.stripQuotes(query));
540 query = '^' + this.stripAnchors(query) + '$';
543 query = this.addQuotes('^' +
544 this.stripAnchors(this.stripQuotes(query)));
548 return str + query + ')';
551 stripQuotes(query: string): string {
552 return query.replace(/"/g, '');
555 stripAnchors(query: string): string {
556 return query.replace(/[\^\$]/g, '');
559 addQuotes(query: string): string {
560 if (query.match(/ /)) {
561 return '"' + query + '"';
566 compileTermSearchQuery(): string {
567 const ts = this.termSearch;
574 if (ts.excludeElectronic) {
575 str += '-search_format(electronic)';
579 // e.g. title, title.descending
580 const parts = this.sort.split(/\./);
581 if (parts[1]) { str += ' #descending'; }
582 str += ' sort(' + parts[0] + ')';
585 if (ts.date1 && ts.dateOp) {
588 str += ` date1(${ts.date1})`;
591 str += ` before(${ts.date1})`;
594 str += ` after(${ts.date1})`;
598 str += ` between(${ts.date1},${ts.date2})`;
604 // Compile boolean sub-query components
605 if (str.length) { str += ' '; }
606 const qcount = ts.query.length;
608 // if we multiple boolean query components, wrap them in parens.
609 if (qcount > 1) { str += '('; }
610 ts.query.forEach((q, idx) => {
611 str += this.compileBoolQuerySet(idx);
613 if (qcount > 1) { str += ')'; }
616 // Append bookplate queries as filters
617 ts.query.forEach((q, idx) => {
618 const space = str.length > 0 ? ' ' : '';
619 const query = ts.query[idx];
620 const fieldClass = ts.fieldClass[idx];
621 if (query && fieldClass === 'bookplate') {
622 str += `${space}copy_tag(*,${query})`;
626 // Journal Title queries means performing a title search
627 // with a filter. Filters are global, so append to the front
629 if (ts.fieldClass.filter(fc => fc === 'jtitle').length > 0) {
630 str = 'bib_level(s) ' + str;
633 if (ts.hasBrowseEntry) {
634 // stored as a comma-separated string of "entryId,fieldId"
635 str += ` has_browse_entry(${ts.hasBrowseEntry})`;
638 if (ts.fromMetarecord) {
639 str += ` from_metarecord(${ts.fromMetarecord})`;
643 str += ' search_format(' + ts.format + ')';
648 this.org.root().ou_type().depth() + ')';
651 if (ts.copyLocations[0] !== '') {
652 str += ' locations(' + ts.copyLocations + ')';
655 str += ' site(' + this.searchOrg.shortname() + ')';
657 Object.keys(ts.ccvmFilters).forEach(field => {
658 if (ts.ccvmFilters[field][0] !== '') {
659 str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
663 ts.facetFilters.forEach(f => {
664 str += ' ' + f.facetClass + '|'
665 + f.facetName + '[' + f.facetValue + ']';
671 // A search context can collect enough data for multiple search
672 // types to be searchable (e.g. users navigate through parts of a
673 // search form). Calling this method and providing a search type
674 // ensures the context is cleared of any data unrelated to the
676 scrub(searchType: string): void {
678 switch (searchType) {
680 case 'term': // AKA keyword search
681 this.marcSearch.reset();
682 this.browseSearch.reset();
683 this.identSearch.reset();
684 this.cnBrowseSearch.reset();
685 this.termSearch.hasBrowseEntry = '';
686 this.termSearch.browseEntry = null;
687 this.termSearch.fromMetarecord = null;
688 this.termSearch.facetFilters = [];
692 this.marcSearch.reset();
693 this.browseSearch.reset();
694 this.termSearch.reset();
695 this.cnBrowseSearch.reset();
699 this.browseSearch.reset();
700 this.termSearch.reset();
701 this.identSearch.reset();
702 this.cnBrowseSearch.reset();
706 this.marcSearch.reset();
707 this.termSearch.reset();
708 this.identSearch.reset();
709 this.cnBrowseSearch.reset();
710 this.browseSearch.pivot = null;
714 this.marcSearch.reset();
715 this.termSearch.reset();
716 this.identSearch.reset();
717 this.browseSearch.reset();
718 this.cnBrowseSearch.offset = 0;