LP1806087 Group formats and editions
authorBill Erickson <berickxx@gmail.com>
Tue, 18 Dec 2018 18:15:56 +0000 (13:15 -0500)
committerBill Erickson <berickxx@gmail.com>
Thu, 10 Jan 2019 17:24:06 +0000 (12:24 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/share/catalog/bib-record.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog-url.service.ts
Open-ILS/src/eg2/src/app/share/catalog/catalog.service.ts
Open-ILS/src/eg2/src/app/share/catalog/search-context.ts
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.html
Open-ILS/src/eg2/src/app/staff/catalog/result/record.component.ts
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.html
Open-ILS/src/eg2/src/app/staff/catalog/search-form.component.ts

index 1be2c88..e934ae6 100644 (file)
@@ -3899,18 +3899,25 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="record" reltype="has_a" key="id" map="" class="bre"/>
                </links>
        </class>
-       <class id="mmr" controller="open-ils.cstore" oils_obj:fieldmapper="metabib::metarecord" oils_persist:tablename="metabib.metarecord" reporter:label="Metarecord">
+       <class id="mmr" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="metabib::metarecord" oils_persist:tablename="metabib.metarecord" reporter:label="Metarecord">
                <fields oils_persist:primary="id" oils_persist:sequence="metabib.metarecord_id_seq">
                        <field name="fingerprint"  reporter:datatype="text"/>
                        <field name="id" reporter:datatype="id" />
                        <field name="master_record" reporter:datatype="link"/>
                        <field name="mods"  reporter:datatype="text"/>
                        <field name="source_records" oils_persist:virtual="true" reporter:datatype="link"/>
+                       <field name="source_maps" oils_persist:virtual="true" reporter:datatype="link"/>
                </fields>
                <links>
                        <link field="master_record" reltype="has_a" key="id" map="" class="bre"/>
                        <link field="source_records" reltype="has_many" key="metarecord" map="source" class="mmrsm"/>
+                       <link field="source_maps" reltype="has_many" key="metarecord" class="mmrsm"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="cnal" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="config::net_access_level" oils_persist:tablename="config.net_access_level" reporter:label="Net Access Level">
                <fields oils_persist:primary="id" oils_persist:sequence="config.net_access_level_id_seq">
@@ -3943,7 +3950,7 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
             </actions>
         </permacrud>
        </class>
-       <class id="mmrsm" controller="open-ils.cstore" oils_obj:fieldmapper="metabib::metarecord_source_map" oils_persist:tablename="metabib.metarecord_source_map" oils_persist:field_safe="true" reporter:label="Metarecord Source Map">
+       <class id="mmrsm" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="metabib::metarecord_source_map" oils_persist:tablename="metabib.metarecord_source_map" oils_persist:field_safe="true" reporter:label="Metarecord Source Map">
                <fields oils_persist:primary="id" oils_persist:sequence="metabib.metarecord_source_map_id_seq">
                        <field name="id" reporter:datatype="id" />
                        <field name="metarecord" reporter:datatype="link"/>
@@ -3953,6 +3960,11 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        <link field="source" reltype="has_a" key="id" map="" class="bre"/>
                        <link field="metarecord" reltype="has_a" key="id" map="" class="mmr"/>
                </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve/>
+                       </actions>
+               </permacrud>
        </class>
        <class id="mde" controller="open-ils.cstore open-ils.pcrud" 
                        oils_obj:fieldmapper="metabib::display_entry" 
index 19924d9..6b00e79 100644 (file)
@@ -3,6 +3,7 @@ import {Observable} from 'rxjs/Observable';
 import {mergeMap} from 'rxjs/operators/mergeMap';
 import {from} from 'rxjs/observable/from';
 import {map} from 'rxjs/operators/map';
+import {tap} from 'rxjs/operators/tap';
 import {OrgService} from '@eg/core/org.service';
 import {UnapiService} from '@eg/share/catalog/unapi.service';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
@@ -22,6 +23,7 @@ export const HOLDINGS_XPATH =
 
 export class BibRecordSummary {
     id: number; // == record.id() for convenience
+    metabibId: number; // If present, this is a metabib summary
     orgId: number;
     orgDepth: number;
     record: IdlObject;
@@ -69,7 +71,10 @@ export class BibRecordSummary {
         // Any attr can be multi-valued.
         this.record.mattrs().forEach(attr => {
             if (this.attributes[attr.attr()]) {
-                this.attributes[attr.attr()].push(attr.value());
+                // Avoid dupes
+                if (this.attributes[attr.attr()].indexOf(attr.value()) < 0) {
+                    this.attributes[attr.attr()].push(attr.value());
+                }
             } else {
                 this.attributes[attr.attr()] = [attr.value()];
             }
@@ -83,9 +88,16 @@ export class BibRecordSummary {
             return Promise.resolve(this.holdCount);
         }
 
+        let method = 'open-ils.circ.bre.holds.count';
+        let target = this.id;
+
+        if (this.metabibId) {
+            method = 'open-ils.circ.mmr.holds.count';
+            target = this.metabibId;
+        }
+
         return this.net.request(
-            'open-ils.circ',
-            'open-ils.circ.bre.holds.count', this.id
+            'open-ils.circ', method, target
         ).toPromise().then(count => this.holdCount = count);
     }
 
@@ -133,7 +145,7 @@ export class BibRecordService {
     }
 
     // Avoid fetching the MARC blob by specifying which fields on the
-    // bre to select.  Note that fleshed fields are explicitly selected.
+    // bre to select.  Note that fleshed fields are implicitly selected.
     fetchableBreFields(): string[] {
         return this.idl.classes.bre.fields
             .filter(f => !f.virtual && f.name !== 'marc')
@@ -169,6 +181,81 @@ export class BibRecordService {
         }));
     }
 
+    // A Metabib Summary is a BibRecordSummary with the lead record as 
+    // its core bib record plus attributes (e.g. formats) from related 
+    // records.
+    getMetabibSummary(metabibIds: number | number[], 
+        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
+
+        const ids = [].concat(metabibIds);
+
+        if (ids.length === 0) {
+            return from([]);
+        }
+
+        return this.pcrud.search('mmr', {id: ids}, 
+            {flesh: 1, flesh_fields: {mmr: ['source_maps']}}, 
+            {anonymous: true}
+        ).pipe(mergeMap(mmr => this.compileMetabib(mmr, orgId, orgDepth)));
+    }
+
+    // 'metabib' must have its "source_maps" field fleshed.
+    // Get bib summaries for all related bib records so we can 
+    // extract data that must be appended to the master record summary.
+    compileMetabib(metabib: IdlObject, 
+        orgId?: number, orgDepth?: number): Observable<BibRecordSummary> {
+
+        // TODO: Create an API similar to the one that builds a combined
+        // mods blob for metarecords, except using display fields, etc.
+        // For now, this seems to get the job done.
+
+        // Non-master records
+        const relatedBibIds = metabib.source_maps()
+            .map(map => map.source())
+            .filter(id => id !== metabib.master_record());
+
+        let observer;
+        const observable = new Observable<BibRecordSummary>(o => observer = o);
+
+        // NOTE: getBibSummary calls getHoldingsSummary against
+        // the bib record unnecessarily.  It's called again below.
+        // Reconsider this approach (see also note above about API).
+        this.getBibSummary(metabib.master_record(), orgId, orgDepth)
+        .subscribe(summary => {
+            summary.metabibId = metabib.id();
+
+            let promise;
+
+            if (relatedBibIds.length > 0) {
+
+                // Grab data for MR bib summary augmentation
+                promise = this.pcrud.search('mraf', {id: relatedBibIds})
+                    .pipe(tap(attr => summary.record.mattrs().push(attr)))
+                    .toPromise();
+            } else {
+
+                // Metarecord has only one constituent bib.
+                promise = Promise.resolve();
+            }
+
+            promise.then(() => {
+
+                // Re-compile with augmented data
+                summary.compileRecordAttrs();
+
+                // Fetch holdings data for the metarecord
+                this.getHoldingsSummary(metabib.id(), orgId, orgDepth, true)
+                .then(holdingsSummary => {
+                    summary.holdingsSummary = holdingsSummary;
+                    observer.next(summary);
+                    observer.complete();
+                });
+            });
+        });
+
+        return observable;
+    }
+
     // Flesh the creator and editor fields.
     // Handling this separately lets us pull from the cache and
     // avoids the requirement that the main bib query use a staff
@@ -209,12 +296,12 @@ export class BibRecordService {
     }
 
     getHoldingsSummary(recordId: number,
-        orgId: number, orgDepth: number): Promise<any> {
+        orgId: number, orgDepth: number, isMetarecord?: boolean): Promise<any> {
 
         const holdingsSummary = [];
 
         return this.unapi.getAsXmlDocument({
-            target: 'bre',
+            target: isMetarecord ? 'mmr' : 'bre',
             id: recordId,
             extras: '{holdings_xml}',
             format: 'holdings_xml',
index 60c3128..cfec1d9 100644 (file)
@@ -80,7 +80,8 @@ export class CatalogUrlService {
             params.joinOp = [];
             params.matchOp = [];
 
-            ['format', 'available', 'hasBrowseEntry', 'date1', 'date2', 'dateOp']
+            ['format', 'available', 'hasBrowseEntry', 'date1', 
+                'date2', 'dateOp', 'isMetarecord', 'fromMetarecord']
             .forEach(field => {
                 if (ts[field]) {
                     params[field] = ts[field];
@@ -195,7 +196,8 @@ export class CatalogUrlService {
         } else if (params.has('query')) {
 
             // Scalars
-            ['format', 'available', 'date1', 'date2', 'dateOp']
+            ['format', 'available', 'date1', 'date2', 
+                'dateOp', 'isMetarecord', 'fromMetarecord']
             .forEach(field => {
                 if (params.has(field)) {
                     ts[field] = params.get(field);
index 93eb964..c741acb 100644 (file)
@@ -114,7 +114,6 @@ export class CatalogService {
         let fullQuery;
 
         if (ctx.identSearch.isSearchable()) {
-            console.log('IDENT IS SEARCHABLE');
             fullQuery = ctx.compileIdentSearchQuery();
         } else {
             fullQuery = ctx.compileTermSearchQuery();
@@ -123,6 +122,12 @@ export class CatalogService {
         console.debug(`search query: ${fullQuery}`);
 
         let method = 'open-ils.search.biblio.multiclass.query';
+        if (ctx.termSearch.isSearchable() 
+            && ctx.termSearch.isMetarecord
+            && !ctx.termSearch.fromMetarecord) {
+            method = 'open-ils.search.metabib.multiclass.query';
+        }
+
         if (ctx.isStaff) {
             method += '.staff';
         }
@@ -166,11 +171,33 @@ export class CatalogService {
             ctx.org.root().ou_type().depth() :
             ctx.searchOrg.ou_type().depth();
 
-        return this.bibService.getBibSummary(
-            ctx.currentResultIds(), ctx.searchOrg.id(), depth)
-        .pipe(map(summary => {
+        // Term search, looking for metarecords, but no 
+        // specific metarecord has been selected for display.
+        const isMeta = (
+            ctx.termSearch.isSearchable() && 
+            ctx.termSearch.isMetarecord &&
+            !ctx.termSearch.fromMetarecord
+        );
+
+        let observable: Observable<BibRecordSummary>;
+        
+        if (isMeta) {
+            observable = this.bibService.getMetabibSummary(
+                ctx.currentResultIds(), ctx.searchOrg.id(), depth);
+        } else {
+            observable = this.bibService.getBibSummary(
+                ctx.currentResultIds(), ctx.searchOrg.id(), depth);
+        }
+
+        return observable.pipe(map(summary => {
             // Responses are not necessarily returned in request-ID order.
-            const idx = ctx.currentResultIds().indexOf(summary.record.id());
+            let idx;
+            if (isMeta) {
+                idx = ctx.currentResultIds().indexOf(summary.metabibId);
+            } else {
+                idx = ctx.currentResultIds().indexOf(summary.id);
+            }
+
             if (ctx.result.records) {
                 // May be reset when quickly navigating results.
                 ctx.result.records[idx] = summary;
index e4d65d4..56ab527 100644 (file)
@@ -122,7 +122,13 @@ export class CatalogTermContext {
     ccvmFilters: {[ccvmCode: string]: string[]};
     facetFilters: FacetFilter[];
     copyLocations: string[]; // ID's, but treated as strings in the UI.
-    isMetarecord: boolean; // TODO
+
+    // True when searching for metarecords
+    isMetarecord: boolean;
+
+    // Filter results by records which link to this metarecord ID.
+    fromMetarecord: number;
+
     hasBrowseEntry: string; // "entryId,fieldId"
     date1: number;
     date2: number;
@@ -140,6 +146,7 @@ export class CatalogTermContext {
         this.date1 = null;
         this.date2 = null;
         this.dateOp = 'is';
+        this.fromMetarecord = null;
 
         // Apply empty string values for each ccvm filter
         this.ccvmFilters = {};
@@ -150,6 +157,7 @@ export class CatalogTermContext {
         return (
             this.query[0] !== ''
             || this.hasBrowseEntry !== ''
+            || this.fromMetarecord !== null
         );
     }
 
@@ -416,6 +424,10 @@ export class CatalogSearchContext {
             str += ` has_browse_entry(${ts.hasBrowseEntry})`;
         }
 
+        if (ts.fromMetarecord) {
+            str += ` from_metarecord(${ts.fromMetarecord})`;
+        }
+
         if (ts.format) {
             str += ' format(' + ts.format + ')';
         }
index 54b79c1..ddfacc0 100644 (file)
@@ -32,7 +32,7 @@
             <div class="col-lg-12 font-weight-bold">
               <!-- nbsp allows the column to take shape when no value exists -->
               <a href="javascript:void(0)"
-                (click)="navigatToRecord(summary.id)">
+                (click)="navigatToRecord(summary)">
                 {{summary.display.title || '&nbsp;'}}
               </a>
             </div>
           </div>
           <div class="row pt-2">
             <div class="col-lg-12">
-              <!-- only shows the first icon format -->
-              <span *ngIf="summary.attributes.icon_format && summary.attributes.icon_format[0]">
-                <img class="pr-1"
-                  src="/images/format_icons/icon_format/{{summary.attributes.icon_format[0]}}.png"/>
-                <span>{{iconFormatLabel(summary.attributes.icon_format[0])}}</span>
-              </span>
+              <ng-container *ngIf="summary.attributes.icon_format && summary.attributes.icon_format[0]">
+                <ng-container *ngFor="let icon of summary.attributes.icon_format">
+                <span class="pr-1">
+                  <img class="pr-1"
+                    src="/images/format_icons/icon_format/{{icon}}.png"/>
+                  <span>{{iconFormatLabel(icon)}}</span>
+                </span>
+                </ng-container>
+              </ng-container>
               <span class='pl-1'>{{summary.display.edition}}</span>
               <span class='pl-1'>{{summary.display.pubdate}}</span>
             </div>
             <div class="float-right">
               <span>
                 <button (click)="placeHold()"
+                  [disabled]="summary.metabibId"
                   class="btn btn-sm btn-success label-with-material-icon small-text-1">
                   <span class="material-icons">check</span>
                   <span i18n>Place Hold</span>
index 5224c38..5e6d25b 100644 (file)
@@ -76,11 +76,17 @@ export class ResultRecordComponent implements OnInit, OnDestroy {
     /**
      * Propagate the search params along when navigating to each record.
      */
-    navigatToRecord(id: number) {
+    navigatToRecord(summary: BibRecordSummary) {
         const params = this.catUrl.toUrlParams(this.searchContext);
 
+        if (summary.metabibId) {
+            this.searchContext.termSearch.fromMetarecord = summary.metabibId;
+            this.staffCat.search();
+            return;
+        }
+
         this.router.navigate(
-          ['/staff/catalog/record/' + id], {queryParams: params});
+          ['/staff/catalog/record/' + summary.id], {queryParams: params});
     }
 
     toggleBasketEntry() {
index 8b6cc8c..4327e8d 100644 (file)
@@ -112,7 +112,7 @@ TODO focus search input
                 </div>
                 <div class="checkbox pl-3">
                   <label>
-                    <input type="checkbox" [disabled]="true" 
+                    <input type="checkbox"
                       [(ngModel)]="context.termSearch.isMetarecord"/>
                     <span class="pl-1" i18n>Group Formats/Editions</span>
                   </label>
index a558011..049b677 100644 (file)
@@ -193,6 +193,7 @@ export class SearchFormComponent implements OnInit, AfterViewInit {
                 this.context.browseSearch.reset();
                 this.context.identSearch.reset();
                 this.context.termSearch.hasBrowseEntry = '';
+                this.context.termSearch.fromMetarecord = null;
                 this.staffCat.search();
                 break;