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.
167 return this.value !== '' && this.value !== undefined;
170 clone(): CatalogCnBrowseContext {
171 const ctx = new CatalogCnBrowseContext();
172 ctx.value = this.value;
173 ctx.offset = this.offset;
177 equals(ctx: CatalogCnBrowseContext): boolean {
178 return ctx.value === this.value;
182 export class CatalogTermContext {
183 fieldClass: string[];
189 ccvmFilters: {[ccvmCode: string]: string[]};
190 facetFilters: FacetFilter[];
191 copyLocations: string[]; // ID's, but treated as strings in the UI.
193 // True when searching for metarecords
194 groupByMetarecord: boolean;
196 // Filter results by records which link to this metarecord ID.
197 fromMetarecord: number;
199 hasBrowseEntry: string; // "entryId,fieldId"
200 browseEntry: IdlObject;
203 dateOp: string; // before, after, between, is
207 this.fieldClass = ['keyword'];
208 this.matchOp = ['contains'];
210 this.facetFilters = [];
211 this.copyLocations = [''];
213 this.hasBrowseEntry = '';
217 this.fromMetarecord = null;
219 // Apply empty string values for each ccvm filter
220 this.ccvmFilters = {};
221 CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
224 clone(): CatalogTermContext {
225 const ctx = new CatalogTermContext();
227 ctx.query = [].concat(this.query);
228 ctx.fieldClass = [].concat(this.fieldClass);
229 ctx.matchOp = [].concat(this.matchOp);
230 ctx.joinOp = [].concat(this.joinOp);
231 ctx.copyLocations = [].concat(this.copyLocations);
232 ctx.format = this.format;
233 ctx.hasBrowseEntry = this.hasBrowseEntry;
234 ctx.date1 = this.date1;
235 ctx.date2 = this.date2;
236 ctx.dateOp = this.dateOp;
237 ctx.fromMetarecord = this.fromMetarecord;
239 ctx.facetFilters = this.facetFilters.map(f => f.clone());
241 ctx.ccvmFilters = {};
242 Object.keys(this.ccvmFilters).forEach(
243 key => ctx.ccvmFilters[key] = this.ccvmFilters[key]);
248 equals(ctx: CatalogTermContext): boolean {
249 if ( ArrayUtil.equals(ctx.query, this.query)
250 && ArrayUtil.equals(ctx.fieldClass, this.fieldClass)
251 && ArrayUtil.equals(ctx.matchOp, this.matchOp)
252 && ArrayUtil.equals(ctx.joinOp, this.joinOp)
253 && ArrayUtil.equals(ctx.copyLocations, this.copyLocations)
254 && ctx.format === this.format
255 && ctx.hasBrowseEntry === this.hasBrowseEntry
256 && ctx.date1 === this.date1
257 && ctx.date2 === this.date2
258 && ctx.dateOp === this.dateOp
259 && ctx.fromMetarecord === this.fromMetarecord
261 ctx.facetFilters, this.facetFilters, (a, b) => a.equals(b))
262 && Object.keys(this.ccvmFilters).length ===
263 Object.keys(ctx.ccvmFilters).length
266 // So far so good, compare ccvm hash contents
267 let mismatch = false;
268 Object.keys(this.ccvmFilters).forEach(key => {
269 if (!ArrayUtil.equals(this.ccvmFilters[key], ctx.ccvmFilters[key])) {
281 // True when grouping by metarecord but not when displaying the
282 // contents of a metarecord.
283 isMetarecordSearch(): boolean {
285 this.isSearchable() &&
286 this.groupByMetarecord &&
287 this.fromMetarecord === null
291 isSearchable(): boolean {
294 || this.hasBrowseEntry !== ''
295 || this.fromMetarecord !== null
299 hasFacet(facet: FacetFilter): boolean {
301 this.facetFilters.filter(f => f.equals(facet))[0]
305 removeFacet(facet: FacetFilter): void {
306 this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
309 addFacet(facet: FacetFilter): void {
310 if (!this.hasFacet(facet)) {
311 this.facetFilters.push(facet);
315 toggleFacet(facet: FacetFilter): void {
316 if (this.hasFacet(facet)) {
317 this.removeFacet(facet);
319 this.facetFilters.push(facet);
326 // Not an angular service.
327 // It's conceviable there could be multiple contexts.
328 export class CatalogSearchContext {
330 // Attributes that are used across different contexts.
334 searchOrg: IdlObject;
337 termSearch: CatalogTermContext;
338 marcSearch: CatalogMarcContext;
339 identSearch: CatalogIdentContext;
340 browseSearch: CatalogBrowseContext;
341 cnBrowseSearch: CatalogCnBrowseContext;
343 // Result from most recent search.
344 result: CatalogSearchResults;
345 searchState: CatalogSearchState = CatalogSearchState.PENDING;
347 // List of IDs in page/offset context.
350 // If a bib ID is provided, instruct the search code to
351 // only fetch field highlight data for a single record instead
352 // of all search results.
353 getHighlightsFor: number;
354 highlightData: {[id: number]: {[field: string]: string | string[]}} = {};
361 this.pager = new Pager();
362 this.termSearch = new CatalogTermContext();
363 this.marcSearch = new CatalogMarcContext();
364 this.identSearch = new CatalogIdentContext();
365 this.browseSearch = new CatalogBrowseContext();
366 this.cnBrowseSearch = new CatalogCnBrowseContext();
370 // Performs a deep clone of the search context as-is.
371 clone(): CatalogSearchContext {
372 const ctx = new CatalogSearchContext();
374 ctx.sort = this.sort;
375 ctx.isStaff = this.isStaff;
376 ctx.global = this.global;
378 // OK to share since the org object won't be changing.
379 ctx.searchOrg = this.searchOrg;
381 ctx.termSearch = this.termSearch.clone();
382 ctx.marcSearch = this.marcSearch.clone();
383 ctx.identSearch = this.identSearch.clone();
384 ctx.browseSearch = this.browseSearch.clone();
385 ctx.cnBrowseSearch = this.cnBrowseSearch.clone();
390 equals(ctx: CatalogSearchContext): boolean {
392 this.termSearch.equals(ctx.termSearch)
393 && this.marcSearch.equals(ctx.marcSearch)
394 && this.identSearch.equals(ctx.identSearch)
395 && this.browseSearch.equals(ctx.browseSearch)
396 && this.cnBrowseSearch.equals(ctx.cnBrowseSearch)
397 && this.sort === ctx.sort
398 && this.global === ctx.global
403 * Return search context to its default state, resetting search
404 * parameters and clearing any cached result data.
407 this.pager.offset = 0;
409 this.showBasket = false;
410 this.result = new CatalogSearchResults();
412 this.highlightData = {};
413 this.searchState = CatalogSearchState.PENDING;
414 this.termSearch.reset();
415 this.marcSearch.reset();
416 this.identSearch.reset();
417 this.browseSearch.reset();
418 this.cnBrowseSearch.reset();
421 isSearchable(): boolean {
424 this.termSearch.isSearchable() ||
425 this.marcSearch.isSearchable() ||
426 this.identSearch.isSearchable() ||
427 this.browseSearch.isSearchable()
431 // List of result IDs for the current page of data.
432 currentResultIds(): number[] {
434 const max = Math.min(
435 this.pager.offset + this.pager.limit,
436 this.pager.resultCount
438 for (let idx = this.pager.offset; idx < max; idx++) {
439 ids.push(this.resultIds[idx]);
444 addResultId(id: number, resultIdx: number ): void {
445 this.resultIds[resultIdx + this.pager.offset] = Number(id);
448 // Return the record at the requested index.
449 resultIdAt(index: number): number {
450 return this.resultIds[index] || null;
453 // Return the index of the requested record
454 indexForResult(id: number): number {
455 for (let i = 0; i < this.resultIds.length; i++) {
456 if (this.resultIds[i] === id) {
463 compileMarcSearchArgs(): any {
464 const searches: any = [];
465 const ms = this.marcSearch;
467 ms.values.forEach((val, idx) => {
471 // "_" is the wildcard subfield for the API.
472 subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
482 limit : this.pager.limit,
483 offset : this.pager.offset,
484 org_unit: this.searchOrg.id()
488 const parts = this.sort.split(/\./);
489 args.sort = parts[0]; // title, author, etc.
490 if (parts[1]) { args.sort_dir = 'descending'; }
496 compileIdentSearchQuery(): string {
497 const str = ' site(' + this.searchOrg.shortname() + ')';
499 this.identSearch.queryType + ':' + this.identSearch.value;
503 compileBoolQuerySet(idx: number): string {
504 const ts = this.termSearch;
505 let query = ts.query[idx];
506 const joinOp = ts.joinOp[idx];
507 const matchOp = ts.matchOp[idx];
508 const fieldClass = ts.fieldClass[idx];
511 if (!query) { return str; }
513 if (idx > 0) { str += ' ' + joinOp + ' '; }
516 if (fieldClass) { str += fieldClass + ':'; }
520 query = this.addQuotes(this.stripQuotes(query));
523 query = '-' + this.addQuotes(this.stripQuotes(query));
526 query = '^' + this.stripAnchors(query) + '$';
529 query = this.addQuotes('^' +
530 this.stripAnchors(this.stripQuotes(query)));
534 return str + query + ')';
537 stripQuotes(query: string): string {
538 return query.replace(/"/g, '');
541 stripAnchors(query: string): string {
542 return query.replace(/[\^\$]/g, '');
545 addQuotes(query: string): string {
546 if (query.match(/ /)) {
547 return '"' + query + '"';
552 compileTermSearchQuery(): string {
553 const ts = this.termSearch;
561 // e.g. title, title.descending
562 const parts = this.sort.split(/\./);
563 if (parts[1]) { str += ' #descending'; }
564 str += ' sort(' + parts[0] + ')';
567 if (ts.date1 && ts.dateOp) {
570 str += ` date1(${ts.date1})`;
573 str += ` before(${ts.date1})`;
576 str += ` after(${ts.date1})`;
580 str += ` between(${ts.date1},${ts.date2})`;
586 // Compile boolean sub-query components
587 if (str.length) { str += ' '; }
588 const qcount = ts.query.length;
590 // if we multiple boolean query components, wrap them in parens.
591 if (qcount > 1) { str += '('; }
592 ts.query.forEach((q, idx) => {
593 str += this.compileBoolQuerySet(idx);
595 if (qcount > 1) { str += ')'; }
598 if (ts.hasBrowseEntry) {
599 // stored as a comma-separated string of "entryId,fieldId"
600 str += ` has_browse_entry(${ts.hasBrowseEntry})`;
603 if (ts.fromMetarecord) {
604 str += ` from_metarecord(${ts.fromMetarecord})`;
608 str += ' format(' + ts.format + ')';
613 this.org.root().ou_type().depth() + ')';
616 if (ts.copyLocations[0] !== '') {
617 str += ' locations(' + ts.copyLocations + ')';
620 str += ' site(' + this.searchOrg.shortname() + ')';
622 Object.keys(ts.ccvmFilters).forEach(field => {
623 if (ts.ccvmFilters[field][0] !== '') {
624 str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
628 ts.facetFilters.forEach(f => {
629 str += ' ' + f.facetClass + '|'
630 + f.facetName + '[' + f.facetValue + ']';
636 // A search context can collect enough data for multiple search
637 // types to be searchable (e.g. users navigate through parts of a
638 // search form). Calling this method and providing a search type
639 // ensures the context is cleared of any data unrelated to the
641 scrub(searchType: string): void {
643 switch (searchType) {
645 case 'term': // AKA keyword search
646 this.marcSearch.reset();
647 this.browseSearch.reset();
648 this.identSearch.reset();
649 this.cnBrowseSearch.reset();
650 this.termSearch.hasBrowseEntry = '';
651 this.termSearch.browseEntry = null;
652 this.termSearch.fromMetarecord = null;
653 this.termSearch.facetFilters = [];
657 this.marcSearch.reset();
658 this.browseSearch.reset();
659 this.termSearch.reset();
660 this.cnBrowseSearch.reset();
664 this.browseSearch.reset();
665 this.termSearch.reset();
666 this.identSearch.reset();
667 this.cnBrowseSearch.reset();
671 this.marcSearch.reset();
672 this.termSearch.reset();
673 this.identSearch.reset();
674 this.cnBrowseSearch.reset();
675 this.browseSearch.pivot = null;
679 this.marcSearch.reset();
680 this.termSearch.reset();
681 this.identSearch.reset();
682 this.browseSearch.reset();
683 this.cnBrowseSearch.offset = 0;