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.
355 this.pager = new Pager();
356 this.termSearch = new CatalogTermContext();
357 this.marcSearch = new CatalogMarcContext();
358 this.identSearch = new CatalogIdentContext();
359 this.browseSearch = new CatalogBrowseContext();
360 this.cnBrowseSearch = new CatalogCnBrowseContext();
364 // Performs a deep clone of the search context as-is.
365 clone(): CatalogSearchContext {
366 const ctx = new CatalogSearchContext();
368 ctx.sort = this.sort;
369 ctx.isStaff = this.isStaff;
370 ctx.global = this.global;
372 // OK to share since the org object won't be changing.
373 ctx.searchOrg = this.searchOrg;
375 ctx.termSearch = this.termSearch.clone();
376 ctx.marcSearch = this.marcSearch.clone();
377 ctx.identSearch = this.identSearch.clone();
378 ctx.browseSearch = this.browseSearch.clone();
379 ctx.cnBrowseSearch = this.cnBrowseSearch.clone();
384 equals(ctx: CatalogSearchContext): boolean {
386 this.termSearch.equals(ctx.termSearch)
387 && this.marcSearch.equals(ctx.marcSearch)
388 && this.identSearch.equals(ctx.identSearch)
389 && this.browseSearch.equals(ctx.browseSearch)
390 && this.cnBrowseSearch.equals(ctx.cnBrowseSearch)
391 && this.sort === ctx.sort
392 && this.global === ctx.global
397 * Return search context to its default state, resetting search
398 * parameters and clearing any cached result data.
401 this.pager.offset = 0;
403 this.showBasket = false;
404 this.result = new CatalogSearchResults();
406 this.searchState = CatalogSearchState.PENDING;
407 this.termSearch.reset();
408 this.marcSearch.reset();
409 this.identSearch.reset();
410 this.browseSearch.reset();
411 this.cnBrowseSearch.reset();
414 isSearchable(): boolean {
417 this.termSearch.isSearchable() ||
418 this.marcSearch.isSearchable() ||
419 this.identSearch.isSearchable() ||
420 this.browseSearch.isSearchable()
424 // List of result IDs for the current page of data.
425 currentResultIds(): number[] {
427 const max = Math.min(
428 this.pager.offset + this.pager.limit,
429 this.pager.resultCount
431 for (let idx = this.pager.offset; idx < max; idx++) {
432 ids.push(this.resultIds[idx]);
437 addResultId(id: number, resultIdx: number ): void {
438 this.resultIds[resultIdx + this.pager.offset] = Number(id);
441 // Return the record at the requested index.
442 resultIdAt(index: number): number {
443 return this.resultIds[index] || null;
446 // Return the index of the requested record
447 indexForResult(id: number): number {
448 for (let i = 0; i < this.resultIds.length; i++) {
449 if (this.resultIds[i] === id) {
456 compileMarcSearchArgs(): any {
457 const searches: any = [];
458 const ms = this.marcSearch;
460 ms.values.forEach((val, idx) => {
464 // "_" is the wildcard subfield for the API.
465 subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
475 limit : this.pager.limit,
476 offset : this.pager.offset,
477 org_unit: this.searchOrg.id()
481 const parts = this.sort.split(/\./);
482 args.sort = parts[0]; // title, author, etc.
483 if (parts[1]) { args.sort_dir = 'descending'; }
489 compileIdentSearchQuery(): string {
490 const str = ' site(' + this.searchOrg.shortname() + ')';
492 this.identSearch.queryType + ':' + this.identSearch.value;
496 compileBoolQuerySet(idx: number): string {
497 const ts = this.termSearch;
498 let query = ts.query[idx];
499 const joinOp = ts.joinOp[idx];
500 const matchOp = ts.matchOp[idx];
501 const fieldClass = ts.fieldClass[idx];
504 if (!query) { return str; }
506 if (idx > 0) { str += ' ' + joinOp + ' '; }
509 if (fieldClass) { str += fieldClass + ':'; }
513 query = this.addQuotes(this.stripQuotes(query));
516 query = '-' + this.addQuotes(this.stripQuotes(query));
519 query = '^' + this.stripAnchors(query) + '$';
522 query = this.addQuotes('^' +
523 this.stripAnchors(this.stripQuotes(query)));
527 return str + query + ')';
530 stripQuotes(query: string): string {
531 return query.replace(/"/g, '');
534 stripAnchors(query: string): string {
535 return query.replace(/[\^\$]/g, '');
538 addQuotes(query: string): string {
539 if (query.match(/ /)) {
540 return '"' + query + '"';
545 compileTermSearchQuery(): string {
546 const ts = this.termSearch;
554 // e.g. title, title.descending
555 const parts = this.sort.split(/\./);
556 if (parts[1]) { str += ' #descending'; }
557 str += ' sort(' + parts[0] + ')';
560 if (ts.date1 && ts.dateOp) {
563 str += ` date1(${ts.date1})`;
566 str += ` before(${ts.date1})`;
569 str += ` after(${ts.date1})`;
573 str += ` between(${ts.date1},${ts.date2})`;
579 // Compile boolean sub-query components
580 if (str.length) { str += ' '; }
581 const qcount = ts.query.length;
583 // if we multiple boolean query components, wrap them in parens.
584 if (qcount > 1) { str += '('; }
585 ts.query.forEach((q, idx) => {
586 str += this.compileBoolQuerySet(idx);
588 if (qcount > 1) { str += ')'; }
591 if (ts.hasBrowseEntry) {
592 // stored as a comma-separated string of "entryId,fieldId"
593 str += ` has_browse_entry(${ts.hasBrowseEntry})`;
596 if (ts.fromMetarecord) {
597 str += ` from_metarecord(${ts.fromMetarecord})`;
601 str += ' format(' + ts.format + ')';
606 this.org.root().ou_type().depth() + ')';
609 if (ts.copyLocations[0] !== '') {
610 str += ' locations(' + ts.copyLocations + ')';
613 str += ' site(' + this.searchOrg.shortname() + ')';
615 Object.keys(ts.ccvmFilters).forEach(field => {
616 if (ts.ccvmFilters[field][0] !== '') {
617 str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
621 ts.facetFilters.forEach(f => {
622 str += ' ' + f.facetClass + '|'
623 + f.facetName + '[' + f.facetValue + ']';
629 // A search context can collect enough data for multiple search
630 // types to be searchable (e.g. users navigate through parts of a
631 // search form). Calling this method and providing a search type
632 // ensures the context is cleared of any data unrelated to the
634 scrub(searchType: string): void {
636 switch (searchType) {
638 case 'term': // AKA keyword search
639 this.marcSearch.reset();
640 this.browseSearch.reset();
641 this.identSearch.reset();
642 this.cnBrowseSearch.reset();
643 this.termSearch.hasBrowseEntry = '';
644 this.termSearch.browseEntry = null;
645 this.termSearch.fromMetarecord = null;
646 this.termSearch.facetFilters = [];
650 this.marcSearch.reset();
651 this.browseSearch.reset();
652 this.termSearch.reset();
653 this.cnBrowseSearch.reset();
657 this.browseSearch.reset();
658 this.termSearch.reset();
659 this.identSearch.reset();
660 this.cnBrowseSearch.reset();
664 this.marcSearch.reset();
665 this.termSearch.reset();
666 this.identSearch.reset();
667 this.cnBrowseSearch.reset();
668 this.browseSearch.pivot = null;
672 this.marcSearch.reset();
673 this.termSearch.reset();
674 this.identSearch.reset();
675 this.browseSearch.reset();
676 this.cnBrowseSearch.offset = 0;