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
213 this.fieldClass = ['keyword'];
214 this.matchOp = ['contains'];
216 this.facetFilters = [];
217 this.copyLocations = [''];
219 this.hasBrowseEntry = '';
223 this.fromMetarecord = null;
225 // Apply empty string values for each ccvm filter
226 this.ccvmFilters = {};
227 CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
230 clone(): CatalogTermContext {
231 const ctx = new CatalogTermContext();
233 ctx.query = [].concat(this.query);
234 ctx.fieldClass = [].concat(this.fieldClass);
235 ctx.matchOp = [].concat(this.matchOp);
236 ctx.joinOp = [].concat(this.joinOp);
237 ctx.copyLocations = [].concat(this.copyLocations);
238 ctx.format = this.format;
239 ctx.hasBrowseEntry = this.hasBrowseEntry;
240 ctx.date1 = this.date1;
241 ctx.date2 = this.date2;
242 ctx.dateOp = this.dateOp;
243 ctx.fromMetarecord = this.fromMetarecord;
245 ctx.facetFilters = this.facetFilters.map(f => f.clone());
247 ctx.ccvmFilters = {};
248 Object.keys(this.ccvmFilters).forEach(
249 key => ctx.ccvmFilters[key] = this.ccvmFilters[key]);
254 equals(ctx: CatalogTermContext): boolean {
255 if ( ArrayUtil.equals(ctx.query, this.query)
256 && ArrayUtil.equals(ctx.fieldClass, this.fieldClass)
257 && ArrayUtil.equals(ctx.matchOp, this.matchOp)
258 && ArrayUtil.equals(ctx.joinOp, this.joinOp)
259 && ArrayUtil.equals(ctx.copyLocations, this.copyLocations)
260 && ctx.format === this.format
261 && ctx.hasBrowseEntry === this.hasBrowseEntry
262 && ctx.date1 === this.date1
263 && ctx.date2 === this.date2
264 && ctx.dateOp === this.dateOp
265 && ctx.fromMetarecord === this.fromMetarecord
267 ctx.facetFilters, this.facetFilters, (a, b) => a.equals(b))
268 && Object.keys(this.ccvmFilters).length ===
269 Object.keys(ctx.ccvmFilters).length
272 // So far so good, compare ccvm hash contents
273 let mismatch = false;
274 Object.keys(this.ccvmFilters).forEach(key => {
275 if (!ArrayUtil.equals(this.ccvmFilters[key], ctx.ccvmFilters[key])) {
287 // True when grouping by metarecord but not when displaying the
288 // contents of a metarecord.
289 isMetarecordSearch(): boolean {
291 this.isSearchable() &&
292 this.groupByMetarecord &&
293 this.fromMetarecord === null
297 isSearchable(): boolean {
300 || this.hasBrowseEntry !== ''
301 || this.fromMetarecord !== null
305 hasFacet(facet: FacetFilter): boolean {
307 this.facetFilters.filter(f => f.equals(facet))[0]
311 removeFacet(facet: FacetFilter): void {
312 this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
315 addFacet(facet: FacetFilter): void {
316 if (!this.hasFacet(facet)) {
317 this.facetFilters.push(facet);
321 toggleFacet(facet: FacetFilter): void {
322 if (this.hasFacet(facet)) {
323 this.removeFacet(facet);
325 this.facetFilters.push(facet);
332 // Not an angular service.
333 // It's conceviable there could be multiple contexts.
334 export class CatalogSearchContext {
336 // Attributes that are used across different contexts.
340 searchOrg: IdlObject;
343 termSearch: CatalogTermContext;
344 marcSearch: CatalogMarcContext;
345 identSearch: CatalogIdentContext;
346 browseSearch: CatalogBrowseContext;
347 cnBrowseSearch: CatalogCnBrowseContext;
349 // Result from most recent search.
350 result: CatalogSearchResults;
351 searchState: CatalogSearchState = CatalogSearchState.PENDING;
353 // List of IDs in page/offset context.
356 // If a bib ID is provided, instruct the search code to
357 // only fetch field highlight data for a single record instead
358 // of all search results.
359 getHighlightsFor: number;
360 highlightData: {[id: number]: {[field: string]: string | string[]}} = {};
367 this.pager = new Pager();
368 this.termSearch = new CatalogTermContext();
369 this.marcSearch = new CatalogMarcContext();
370 this.identSearch = new CatalogIdentContext();
371 this.browseSearch = new CatalogBrowseContext();
372 this.cnBrowseSearch = new CatalogCnBrowseContext();
376 // Performs a deep clone of the search context as-is.
377 clone(): CatalogSearchContext {
378 const ctx = new CatalogSearchContext();
380 ctx.sort = this.sort;
381 ctx.isStaff = this.isStaff;
382 ctx.global = this.global;
384 // OK to share since the org object won't be changing.
385 ctx.searchOrg = this.searchOrg;
387 ctx.termSearch = this.termSearch.clone();
388 ctx.marcSearch = this.marcSearch.clone();
389 ctx.identSearch = this.identSearch.clone();
390 ctx.browseSearch = this.browseSearch.clone();
391 ctx.cnBrowseSearch = this.cnBrowseSearch.clone();
396 equals(ctx: CatalogSearchContext): boolean {
398 this.termSearch.equals(ctx.termSearch)
399 && this.marcSearch.equals(ctx.marcSearch)
400 && this.identSearch.equals(ctx.identSearch)
401 && this.browseSearch.equals(ctx.browseSearch)
402 && this.cnBrowseSearch.equals(ctx.cnBrowseSearch)
403 && this.sort === ctx.sort
404 && this.global === ctx.global
409 * Return search context to its default state, resetting search
410 * parameters and clearing any cached result data.
413 this.pager.offset = 0;
415 this.showBasket = false;
416 this.result = new CatalogSearchResults();
418 this.highlightData = {};
419 this.searchState = CatalogSearchState.PENDING;
420 this.termSearch.reset();
421 this.marcSearch.reset();
422 this.identSearch.reset();
423 this.browseSearch.reset();
424 this.cnBrowseSearch.reset();
427 isSearchable(): boolean {
430 this.termSearch.isSearchable() ||
431 this.marcSearch.isSearchable() ||
432 this.identSearch.isSearchable() ||
433 this.browseSearch.isSearchable()
437 // List of result IDs for the current page of data.
438 currentResultIds(): number[] {
440 const max = Math.min(
441 this.pager.offset + this.pager.limit,
442 this.pager.resultCount
444 for (let idx = this.pager.offset; idx < max; idx++) {
445 ids.push(this.resultIds[idx]);
450 addResultId(id: number, resultIdx: number ): void {
451 this.resultIds[resultIdx + this.pager.offset] = Number(id);
454 // Return the record at the requested index.
455 resultIdAt(index: number): number {
456 return this.resultIds[index] || null;
459 // Return the index of the requested record
460 indexForResult(id: number): number {
461 for (let i = 0; i < this.resultIds.length; i++) {
462 if (this.resultIds[i] === id) {
469 compileMarcSearchArgs(): any {
470 const searches: any = [];
471 const ms = this.marcSearch;
473 ms.values.forEach((val, idx) => {
477 // "_" is the wildcard subfield for the API.
478 subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
488 limit : this.pager.limit,
489 offset : this.pager.offset,
490 org_unit: this.searchOrg.id()
494 const parts = this.sort.split(/\./);
495 args.sort = parts[0]; // title, author, etc.
496 if (parts[1]) { args.sort_dir = 'descending'; }
502 compileIdentSearchQuery(): string {
503 const str = ' site(' + this.searchOrg.shortname() + ')';
505 this.identSearch.queryType + ':' + this.identSearch.value;
509 compileBoolQuerySet(idx: number): string {
510 const ts = this.termSearch;
511 let query = ts.query[idx];
512 const joinOp = ts.joinOp[idx];
513 const matchOp = ts.matchOp[idx];
514 const fieldClass = ts.fieldClass[idx];
517 if (!query) { return str; }
519 if (idx > 0) { str += ' ' + joinOp + ' '; }
522 if (fieldClass) { str += fieldClass + ':'; }
526 query = this.addQuotes(this.stripQuotes(query));
529 query = '-' + this.addQuotes(this.stripQuotes(query));
532 query = '^' + this.stripAnchors(query) + '$';
535 query = this.addQuotes('^' +
536 this.stripAnchors(this.stripQuotes(query)));
540 return str + query + ')';
543 stripQuotes(query: string): string {
544 return query.replace(/"/g, '');
547 stripAnchors(query: string): string {
548 return query.replace(/[\^\$]/g, '');
551 addQuotes(query: string): string {
552 if (query.match(/ /)) {
553 return '"' + query + '"';
558 compileTermSearchQuery(): string {
559 const ts = this.termSearch;
567 // e.g. title, title.descending
568 const parts = this.sort.split(/\./);
569 if (parts[1]) { str += ' #descending'; }
570 str += ' sort(' + parts[0] + ')';
573 if (ts.date1 && ts.dateOp) {
576 str += ` date1(${ts.date1})`;
579 str += ` before(${ts.date1})`;
582 str += ` after(${ts.date1})`;
586 str += ` between(${ts.date1},${ts.date2})`;
592 // Compile boolean sub-query components
593 if (str.length) { str += ' '; }
594 const qcount = ts.query.length;
596 // if we multiple boolean query components, wrap them in parens.
597 if (qcount > 1) { str += '('; }
598 ts.query.forEach((q, idx) => {
599 str += this.compileBoolQuerySet(idx);
601 if (qcount > 1) { str += ')'; }
604 if (ts.hasBrowseEntry) {
605 // stored as a comma-separated string of "entryId,fieldId"
606 str += ` has_browse_entry(${ts.hasBrowseEntry})`;
609 if (ts.fromMetarecord) {
610 str += ` from_metarecord(${ts.fromMetarecord})`;
614 str += ' format(' + ts.format + ')';
619 this.org.root().ou_type().depth() + ')';
622 if (ts.copyLocations[0] !== '') {
623 str += ' locations(' + ts.copyLocations + ')';
626 str += ' site(' + this.searchOrg.shortname() + ')';
628 Object.keys(ts.ccvmFilters).forEach(field => {
629 if (ts.ccvmFilters[field][0] !== '') {
630 str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
634 ts.facetFilters.forEach(f => {
635 str += ' ' + f.facetClass + '|'
636 + f.facetName + '[' + f.facetValue + ']';
642 // A search context can collect enough data for multiple search
643 // types to be searchable (e.g. users navigate through parts of a
644 // search form). Calling this method and providing a search type
645 // ensures the context is cleared of any data unrelated to the
647 scrub(searchType: string): void {
649 switch (searchType) {
651 case 'term': // AKA keyword search
652 this.marcSearch.reset();
653 this.browseSearch.reset();
654 this.identSearch.reset();
655 this.cnBrowseSearch.reset();
656 this.termSearch.hasBrowseEntry = '';
657 this.termSearch.browseEntry = null;
658 this.termSearch.fromMetarecord = null;
659 this.termSearch.facetFilters = [];
663 this.marcSearch.reset();
664 this.browseSearch.reset();
665 this.termSearch.reset();
666 this.cnBrowseSearch.reset();
670 this.browseSearch.reset();
671 this.termSearch.reset();
672 this.identSearch.reset();
673 this.cnBrowseSearch.reset();
677 this.marcSearch.reset();
678 this.termSearch.reset();
679 this.identSearch.reset();
680 this.cnBrowseSearch.reset();
681 this.browseSearch.pivot = null;
685 this.marcSearch.reset();
686 this.termSearch.reset();
687 this.identSearch.reset();
688 this.browseSearch.reset();
689 this.cnBrowseSearch.offset = 0;