LP1819745 Ang staff result page link repairs
[evergreen-equinox.git] / Open-ILS / src / eg2 / src / app / share / catalog / search-context.ts
index e4e64b2..ef0fd55 100644 (file)
@@ -1,7 +1,21 @@
 import {OrgService} from '@eg/core/org.service';
 import {IdlObject} from '@eg/core/idl.service';
 import {Pager} from '@eg/share/util/pager';
-import {Params} from '@angular/router';
+
+// CCVM's we care about in a catalog context
+// Don't fetch them all because there are a lot.
+export const CATALOG_CCVM_FILTERS = [
+    'item_type',
+    'item_form',
+    'item_lang',
+    'audience',
+    'audience_group',
+    'vr_format',
+    'bib_level',
+    'lit_form',
+    'search_format',
+    'icon_format'
+];
 
 export enum CatalogSearchState {
     PENDING,
@@ -29,32 +43,200 @@ export class FacetFilter {
     }
 }
 
-// Not an angular service.
-// It's conceviable there could be multiple contexts.
-export class CatalogSearchContext {
+export class CatalogSearchResults {
+    ids: number[];
+    count: number;
+    [misc: string]: any;
 
-    // Search options and filters
-    available = false;
-    global = false;
-    sort: string;
+    constructor() {
+        this.ids = [];
+        this.count = 0;
+    }
+}
+
+export class CatalogBrowseContext {
+    value: string;
+    pivot: number;
+    fieldClass: string;
+
+    reset() {
+        this.value = '';
+        this.pivot = null;
+        this.fieldClass = 'title';
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.value !== '' &&
+            this.fieldClass !== ''
+        );
+    }
+}
+
+export class CatalogMarcContext {
+    tags: string[];
+    subfields: string[];
+    values: string[];
+
+    reset() {
+        this.tags = [''];
+        this.values = [''];
+        this.subfields = [''];
+    }
+
+    isSearchable() {
+        return (
+            this.tags[0] !== '' &&
+            this.values[0] !== ''
+        );
+    }
+
+}
+
+export class CatalogIdentContext {
+    value: string;
+    queryType: string;
+
+    reset() {
+        this.value = '';
+        this.queryType = '';
+    }
+
+    isSearchable() {
+        return (
+            this.value !== ''
+            && this.queryType !== ''
+        );
+    }
+
+}
+
+export class CatalogCnBrowseContext {
+    value: string;
+    // offset in pages from base browse term
+    // e.g. -2 means 2 pages back (alphabetically) from the original search.
+    offset: number;
+
+    reset() {
+        this.value = '';
+        this.offset = 0;
+    }
+
+    isSearchable() {
+        return this.value !== '';
+    }
+}
+
+export class CatalogTermContext {
     fieldClass: string[];
     query: string[];
-    identQuery: string;
-    identQueryType: string; // isbn, issn, etc.
     joinOp: string[];
     matchOp: string[];
     format: string;
-    searchOrg: IdlObject;
+    available = false;
     ccvmFilters: {[ccvmCode: string]: string[]};
     facetFilters: FacetFilter[];
+    copyLocations: string[]; // ID's, but treated as strings in the UI.
+
+    // True when searching for metarecords
+    groupByMetarecord: boolean;
+
+    // Filter results by records which link to this metarecord ID.
+    fromMetarecord: number;
+
+    hasBrowseEntry: string; // "entryId,fieldId"
+    browseEntry: IdlObject;
+    date1: number;
+    date2: number;
+    dateOp: string; // before, after, between, is
+
+    reset() {
+        this.query = [''];
+        this.fieldClass  = ['keyword'];
+        this.matchOp = ['contains'];
+        this.joinOp = [''];
+        this.facetFilters = [];
+        this.copyLocations = [''];
+        this.format = '';
+        this.hasBrowseEntry = '';
+        this.date1 = null;
+        this.date2 = null;
+        this.dateOp = 'is';
+        this.fromMetarecord = null;
+
+        // Apply empty string values for each ccvm filter
+        this.ccvmFilters = {};
+        CATALOG_CCVM_FILTERS.forEach(code => this.ccvmFilters[code] = ['']);
+    }
+
+    // True when grouping by metarecord but not when displaying the
+    // contents of a metarecord.
+    isMetarecordSearch(): boolean {
+        return (
+            this.isSearchable() &&
+            this.groupByMetarecord &&
+            this.fromMetarecord === null
+        );
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.query[0] !== ''
+            || this.hasBrowseEntry !== ''
+            || this.fromMetarecord !== null
+        );
+    }
+
+    hasFacet(facet: FacetFilter): boolean {
+        return Boolean(
+            this.facetFilters.filter(f => f.equals(facet))[0]
+        );
+    }
+
+    removeFacet(facet: FacetFilter): void {
+        this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
+    }
+
+    addFacet(facet: FacetFilter): void {
+        if (!this.hasFacet(facet)) {
+            this.facetFilters.push(facet);
+        }
+    }
+
+    toggleFacet(facet: FacetFilter): void {
+        if (this.hasFacet(facet)) {
+            this.removeFacet(facet);
+        } else {
+            this.facetFilters.push(facet);
+        }
+    }
+}
+
+
+
+// Not an angular service.
+// It's conceviable there could be multiple contexts.
+export class CatalogSearchContext {
+
+    // Attributes that are used across different contexts.
+    sort: string;
     isStaff: boolean;
+    showBasket: boolean;
+    searchOrg: IdlObject;
+    global: boolean;
+
+    termSearch: CatalogTermContext;
+    marcSearch: CatalogMarcContext;
+    identSearch: CatalogIdentContext;
+    browseSearch: CatalogBrowseContext;
+    cnBrowseSearch: CatalogCnBrowseContext;
 
     // Result from most recent search.
-    result: any = {};
+    result: CatalogSearchResults;
     searchState: CatalogSearchState = CatalogSearchState.PENDING;
 
     // List of IDs in page/offset context.
-    resultIds: number[] = [];
+    resultIds: number[];
 
     // Utility stuff
     pager: Pager;
@@ -62,9 +244,41 @@ export class CatalogSearchContext {
 
     constructor() {
         this.pager = new Pager();
+        this.termSearch = new CatalogTermContext();
+        this.marcSearch = new CatalogMarcContext();
+        this.identSearch = new CatalogIdentContext();
+        this.browseSearch = new CatalogBrowseContext();
+        this.cnBrowseSearch = new CatalogCnBrowseContext();
         this.reset();
     }
 
+    /**
+     * Return search context to its default state, resetting search
+     * parameters and clearing any cached result data.
+     */
+    reset(): void {
+        this.pager.offset = 0;
+        this.sort = '';
+        this.showBasket = false;
+        this.result = new CatalogSearchResults();
+        this.resultIds = [];
+        this.searchState = CatalogSearchState.PENDING;
+        this.termSearch.reset();
+        this.marcSearch.reset();
+        this.identSearch.reset();
+        this.browseSearch.reset();
+    }
+
+    isSearchable(): boolean {
+        return (
+            this.showBasket ||
+            this.termSearch.isSearchable() ||
+            this.marcSearch.isSearchable() ||
+            this.identSearch.isSearchable() ||
+            this.browseSearch.isSearchable()
+        );
+    }
+
     // List of result IDs for the current page of data.
     currentResultIds(): number[] {
         const ids = [];
@@ -79,7 +293,7 @@ export class CatalogSearchContext {
     }
 
     addResultId(id: number, resultIdx: number ): void {
-        this.resultIds[resultIdx + this.pager.offset] = id;
+        this.resultIds[resultIdx + this.pager.offset] = Number(id);
     }
 
     // Return the record at the requested index.
@@ -97,119 +311,52 @@ export class CatalogSearchContext {
         return null;
     }
 
-    /**
-     * Return search context to its default state, resetting search
-     * parameters and clearing any cached result data.
-     * This does not reset global filters like limit-to-available
-     * search-global, or search-org.
-     */
-    reset(): void {
-        this.pager.offset = 0;
-        this.format = '';
-        this.sort = '';
-        this.query = [''];
-        this.identQuery = null;
-        this.identQueryType = 'identifier|isbn';
-        this.fieldClass  = ['keyword'];
-        this.matchOp = ['contains'];
-        this.joinOp = [''];
-        this.ccvmFilters = {};
-        this.facetFilters = [];
-        this.result = {};
-        this.resultIds = [];
-        this.searchState = CatalogSearchState.PENDING;
-    }
-
-    isSearchable(): boolean {
-
-        if (this.identQuery && this.identQueryType) {
-            return true;
-        }
-
-        return this.query.length
-            && this.query[0] !== ''
-            && this.searchOrg !== null;
-    }
-
-    compileSearch(): string {
-        let str = '';
+    compileMarcSearchArgs(): any {
+        const searches: any = [];
+        const ms = this.marcSearch;
+
+        ms.values.forEach((val, idx) => {
+            if (val !== '') {
+                searches.push({
+                    restrict: [{
+                        // "_" is the wildcard subfield for the API.
+                        subfield: ms.subfields[idx] ? ms.subfields[idx] : '_',
+                        tag: ms.tags[idx]
+                    }],
+                    term: ms.values[idx]
+                });
+            }
+        });
 
-        if (this.available) {
-            str += '#available';
-        }
+        const args: any = {
+            searches: searches,
+            limit : this.pager.limit,
+            offset : this.pager.offset,
+            org_unit: this.searchOrg.id()
+        };
 
         if (this.sort) {
-            // e.g. title, title.descending
             const parts = this.sort.split(/\./);
-            if (parts[1]) { str += ' #descending'; }
-            str += ' sort(' + parts[0] + ')';
-        }
-
-        if (this.identQuery && this.identQueryType) {
-            if (str) { str += ' '; }
-            str += this.identQueryType + ':' + this.identQuery;
-
-        } else {
-
-            // -------
-            // Compile boolean sub-query components
-            if (str.length) { str += ' '; }
-            const qcount = this.query.length;
-
-            // if we multiple boolean query components, wrap them in parens.
-            if (qcount > 1) { str += '('; }
-            this.query.forEach((q, idx) => {
-                str += this.compileBoolQuerySet(idx);
-            });
-            if (qcount > 1) { str += ')'; }
-            // -------
+            args.sort = parts[0]; // title, author, etc.
+            if (parts[1]) { args.sort_dir = 'descending'; }
         }
 
-        if (this.format) {
-            str += ' format(' + this.format + ')';
-        }
-
-        if (this.global) {
-            str += ' depth(' +
-                this.org.root().ou_type().depth() + ')';
-        }
-
-        str += ' site(' + this.searchOrg.shortname() + ')';
-
-        Object.keys(this.ccvmFilters).forEach(field => {
-            if (this.ccvmFilters[field][0] !== '') {
-                str += ' ' + field + '(' + this.ccvmFilters[field] + ')';
-            }
-        });
-
-        this.facetFilters.forEach(f => {
-            str += ' ' + f.facetClass + '|'
-                + f.facetName + '[' + f.facetValue + ']';
-        });
-
-        return str;
+        return args;
     }
 
-    stripQuotes(query: string): string {
-        return query.replace(/"/g, '');
+    compileIdentSearchQuery(): string {
+        const str = ' site(' + this.searchOrg.shortname() + ')';
+        return str + ' ' +
+            this.identSearch.queryType + ':' + this.identSearch.value;
     }
 
-    stripAnchors(query: string): string {
-        return query.replace(/[\^\$]/g, '');
-    }
-
-    addQuotes(query: string): string {
-        if (query.match(/ /)) {
-            return '"' + query + '"';
-        }
-        return query;
-    }
 
     compileBoolQuerySet(idx: number): string {
-        let query = this.query[idx];
-        const joinOp = this.joinOp[idx];
-        const matchOp = this.matchOp[idx];
-        const fieldClass = this.fieldClass[idx];
+        const ts = this.termSearch;
+        let query = ts.query[idx];
+        const joinOp = ts.joinOp[idx];
+        const matchOp = ts.matchOp[idx];
+        const fieldClass = ts.fieldClass[idx];
 
         let str = '';
         if (!query) { return str; }
@@ -238,29 +385,103 @@ export class CatalogSearchContext {
         return str + query + ')';
     }
 
-    hasFacet(facet: FacetFilter): boolean {
-        return Boolean(
-            this.facetFilters.filter(f => f.equals(facet))[0]
-        );
+    stripQuotes(query: string): string {
+        return query.replace(/"/g, '');
     }
 
-    removeFacet(facet: FacetFilter): void {
-        this.facetFilters = this.facetFilters.filter(f => !f.equals(facet));
+    stripAnchors(query: string): string {
+        return query.replace(/[\^\$]/g, '');
     }
 
-    addFacet(facet: FacetFilter): void {
-        if (!this.hasFacet(facet)) {
-            this.facetFilters.push(facet);
+    addQuotes(query: string): string {
+        if (query.match(/ /)) {
+            return '"' + query + '"';
         }
+        return query;
     }
 
-    toggleFacet(facet: FacetFilter): void {
-        if (this.hasFacet(facet)) {
-            this.removeFacet(facet);
-        } else {
-            this.facetFilters.push(facet);
+    compileTermSearchQuery(): string {
+        const ts = this.termSearch;
+        let str = '';
+
+        if (ts.available) {
+            str += '#available';
         }
+
+        if (this.sort) {
+            // e.g. title, title.descending
+            const parts = this.sort.split(/\./);
+            if (parts[1]) { str += ' #descending'; }
+            str += ' sort(' + parts[0] + ')';
+        }
+
+        if (ts.date1 && ts.dateOp) {
+            switch (ts.dateOp) {
+                case 'is':
+                    str += ` date1(${ts.date1})`;
+                    break;
+                case 'before':
+                    str += ` before(${ts.date1})`;
+                    break;
+                case 'after':
+                    str += ` after(${ts.date1})`;
+                    break;
+                case 'between':
+                    if (ts.date2) {
+                        str += ` between(${ts.date1},${ts.date2})`;
+                    }
+            }
+        }
+
+        // -------
+        // Compile boolean sub-query components
+        if (str.length) { str += ' '; }
+        const qcount = ts.query.length;
+
+        // if we multiple boolean query components, wrap them in parens.
+        if (qcount > 1) { str += '('; }
+        ts.query.forEach((q, idx) => {
+            str += this.compileBoolQuerySet(idx);
+        });
+        if (qcount > 1) { str += ')'; }
+        // -------
+
+        if (ts.hasBrowseEntry) {
+            // stored as a comma-separated string of "entryId,fieldId"
+            str += ` has_browse_entry(${ts.hasBrowseEntry})`;
+        }
+
+        if (ts.fromMetarecord) {
+            str += ` from_metarecord(${ts.fromMetarecord})`;
+        }
+
+        if (ts.format) {
+            str += ' format(' + ts.format + ')';
+        }
+
+        if (this.global) {
+            str += ' depth(' +
+                this.org.root().ou_type().depth() + ')';
+        }
+
+        if (ts.copyLocations[0] !== '') {
+            str += ' locations(' + ts.copyLocations + ')';
+        }
+
+        str += ' site(' + this.searchOrg.shortname() + ')';
+
+        Object.keys(ts.ccvmFilters).forEach(field => {
+            if (ts.ccvmFilters[field][0] !== '') {
+                str += ' ' + field + '(' + ts.ccvmFilters[field] + ')';
+            }
+        });
+
+        ts.facetFilters.forEach(f => {
+            str += ' ' + f.facetClass + '|'
+                + f.facetName + '[' + f.facetValue + ']';
+        });
+
+        return str;
     }
 }
 
-