org: null,
limit: null,
offset: null,
- copyLocations: null
+ copyLocations: null,
+ browsePivot: null,
+ hasBrowseEntry: null
};
params.org = context.searchOrg.id();
// These fields can be copied directly into place
['format', 'sort', 'available', 'global', 'identQuery',
- 'identQueryType', 'basket']
+ 'identQueryType', 'basket', 'browsePivot', 'hasBrowseEntry']
.forEach(field => {
if (context[field]) {
// Only propagate applied values to the URL.
// These fields can be copied directly into place
['format', 'sort', 'available', 'global', 'identQuery',
- 'identQueryType', 'basket']
+ 'identQueryType', 'basket', 'browsePivot', 'hasBrowseEntry']
.forEach(field => {
const val = params.get(field);
if (val !== null) {
{anonymous: true}
).pipe(tap(loc => this.copyLocations.push(loc))).toPromise()
}
+
+ browse(ctx: CatalogSearchContext): Observable<any> {
+ ctx.searchState = CatalogSearchState.SEARCHING;
+
+ let method = 'open-ils.search.browse';
+ if (ctx.isStaff) {
+ method += '.staff';
+ }
+
+ return this.net.request(
+ 'open-ils.search',
+ 'open-ils.search.browse.staff', {
+ browse_class: ctx.fieldClass[0],
+ term: ctx.query[0],
+ limit : ctx.pager.limit,
+ pivot: ctx.browsePivot,
+ org_unit: ctx.searchOrg.id()
+ }
+ ).pipe(tap(result => {
+ ctx.searchState = CatalogSearchState.COMPLETE;
+ }));
+ }
}
isStaff: boolean;
basket = false;
copyLocations: string[]; // ID's, but treated as strings in the UI.
+ browsePivot: number;
+ hasBrowseEntry: string; // "entryId,fieldId"
// Result from most recent search.
result: any = {};
this.copyLocations = [''];
}
+ // Returns true if we have enough information to perform a search.
isSearchable(): boolean {
if (this.basket) {
return true;
}
- return this.query.length
+ if (this.searchOrg === null) {
+ return false;
+ }
+
+ if (this.hasBrowseEntry) {
+ return true;
+ }
+
+ return this.query.length && this.query[0] !== '';
+ }
+
+ // Returns true if we have enough information to perform a browse.
+ isBrowsable(): boolean {
+ return this.fieldClass.length
+ && this.fieldClass[0] !== ''
+ && this.query.length
&& this.query[0] !== ''
&& this.searchOrg !== null;
}
// -------
}
+ if (this.hasBrowseEntry) {
+ // stored as a comma-separated string of "entryId,fieldId"
+ str += ` has_browse_entry(${this.hasBrowseEntry})`;
+ }
+
if (this.format) {
str += ' format(' + this.format + ')';
}
--- /dev/null
+
+<eg-catalog-browse-form></eg-catalog-browse-form>
+
+<eg-catalog-browse-results><eg-catalog-browse-results>
+
--- /dev/null
+import {Component, OnInit} from '@angular/core';
+import {StaffCatalogService} from './catalog.service';
+
+@Component({
+ templateUrl: 'browse.component.html'
+})
+export class BrowseComponent implements OnInit {
+
+ constructor(
+ private staffCat: StaffCatalogService
+ ) {}
+
+ ngOnInit() {
+ // A SearchContext provides all the data needed for browse.
+ this.staffCat.createContext();
+ }
+}
+
--- /dev/null
+<div id='staffcat-browse-form' class='pb-2 mb-3 row'>
+ <div class="col-lg-10 form-inline">
+ <label for="field-class" i18n>Browse for</label>
+ <select class="form-control ml-2" name="field-class"
+ [(ngModel)]="searchContext.fieldClass[0]">
+ <option i18n value='title'>Title</option>
+ <option i18n value='author'>Author</option>
+ <option i18n value='subject'>Subject</option>
+ <option i18n value='series'>Series</option>
+ </select>
+ <label for="query" class="ml-2"> starting with </label>
+ <input type="text" class="form-control ml-2"
+ id='browse-term-input'
+ [(ngModel)]="searchContext.query[0]"
+ (keyup.enter)="formEnter('query')"
+ placeholder="Browse for..."/>
+ <label for="browse-org" class="ml-2"> in </label>
+ <eg-org-select name="browse-org" class="ml-2"
+ (onChange)="orgOnChange($event)"
+ [initialOrg]="searchContext.searchOrg"
+ [placeholder]="'Library'" >
+ </eg-org-select>
+ <button class="btn btn-success ml-2" type="button"
+ [disabled]="searchIsActive()"
+ (click)="searchContext.pager.offset=0; browseByForm()" i18n>
+ Browse
+ </button>
+ </div>
+ <div class="col-lg-2">
+ <div class="float-right">
+ <button class="btn btn-info"
+ type="button" (click)="goToSearch()" i18n>Search</button>
+ </div>
+ </div>
+</div>
+
--- /dev/null
+import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
+import {Router} from '@angular/router';
+import {IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {StaffCatalogService} from '../catalog.service';
+
+@Component({
+ selector: 'eg-catalog-browse-form',
+ templateUrl: 'form.component.html'
+})
+export class BrowseFormComponent implements OnInit, AfterViewInit {
+
+ searchContext: CatalogSearchContext;
+ ccvmMap: {[ccvm: string]: IdlObject[]} = {};
+ cmfMap: {[cmf: string]: IdlObject} = {};
+
+ constructor(
+ private renderer: Renderer2,
+ private router: Router,
+ private org: OrgService,
+ private cat: CatalogService,
+ private staffCat: StaffCatalogService
+ ) {
+ }
+
+ ngOnInit() {
+ this.ccvmMap = this.cat.ccvmMap;
+ this.cmfMap = this.cat.cmfMap;
+ this.searchContext = this.staffCat.searchContext;
+ }
+
+ ngAfterViewInit() {
+ this.renderer.selectRootElement('#browse-term-input').focus();
+ }
+
+ orgName(orgId: number): string {
+ return this.org.get(orgId).shortname();
+ }
+
+ formEnter(source) {
+ this.searchContext.pager.offset = 0;
+ this.browseByForm();
+ }
+
+ browseByForm(): void {
+ this.staffCat.browse();
+ }
+
+ searchIsActive(): boolean {
+ return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+ }
+
+ goToSearch() {
+ this.router.navigate(['/staff/catalog/search']);
+ }
+
+ orgOnChange = (org: IdlObject): void => {
+ this.searchContext.searchOrg = org;
+ }
+}
+
+
--- /dev/null
+
+<!-- search results progress bar -->
+<div class="row" *ngIf="browseIsActive()">
+ <div class="col-lg-6 offset-lg-3 pt-3">
+ <div class="progress">
+ <div class="progress-bar progress-bar-striped active w-100"
+ role="progressbar" aria-valuenow="100"
+ aria-valuemin="0" aria-valuemax="100">
+ <span class="sr-only" i18n>Searching..</span>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- no items found -->
+<div *ngIf="browseIsDone() && !browseHasResults()">
+ <div class="row pt-3">
+ <div class="col-lg-6 offset-lg-3">
+ <div class="alert alert-warning">
+ <span i18n>No Maching Items Were Found</span>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- header, pager, and list of records -->
+<div id="staff-catalog-browse-results-container" *ngIf="browseHasResults()">
+
+ <div class="row mb-2">
+ <div class="col-lg-3">
+ <button class="btn btn-primary" (click)="prevPage()">Back</button>
+ <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
+ </div>
+ </div>
+
+ <div class="row" *ngFor="let result of results">
+ <div *ngIf="result.value"
+ class="col-lg-12 card tight-card mb-2 bg-light">
+ <div class="col-lg-8">
+ <div class="card-body">
+ <ng-container *ngIf="result.sources > 0">
+ <a (click)="searchByBrowseEntry(result)" href="javascript:void(0)">
+ {{result.value}} ({{result.sources}})
+ </a>
+ </ng-container>
+ <ng-container *ngIf="result.sources == 0">
+ <span>{{result.value}}</span>
+ </ng-container>
+ <div class="row" *ngFor="let heading of result.compiledHeadings">
+ <div class="col-lg-10 offset-lg-1" i18n>
+ <span class="font-italic">
+ <ng-container *ngIf="!heading.type || heading.type == 'variant'">
+ See
+ </ng-container>
+ <ng-container *ngIf="heading.type == 'broader'">
+ Broader term
+ </ng-container>
+ <ng-container *ngIf="heading.type == 'narrower'">
+ Narrower term
+ </ng-container>
+ <ng-container *ngIf="heading.type == 'other'">
+ Related term
+ </ng-container>
+ </span>
+ <a (click)="newBrowseFromHeading(heading)" href="javascript:void(0)">
+ {{heading.heading}} ({{heading.target_count}})
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="row mb-2">
+ <div class="col-lg-3">
+ <button class="btn btn-primary" (click)="prevPage()">Back</button>
+ <button class="btn btn-primary ml-3" (click)="nextPage()">Next</button>
+ </div>
+ </div>
+
+</div>
+
+
--- /dev/null
+import {Component, OnInit, Input} from '@angular/core';
+import {Observable} from 'rxjs/Observable';
+import {Subscription} from 'rxjs/Subscription';
+import {map, switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {ActivatedRoute, ParamMap} from '@angular/router';
+import {CatalogService} from '@eg/share/catalog/catalog.service';
+import {BibRecordService} from '@eg/share/catalog/bib-record.service';
+import {CatalogUrlService} from '@eg/share/catalog/catalog-url.service';
+import {CatalogSearchContext, CatalogSearchState} from '@eg/share/catalog/search-context';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {StaffCatalogService} from '../catalog.service';
+import {IdlObject} from '@eg/core/idl.service';
+
+@Component({
+ selector: 'eg-catalog-browse-results',
+ templateUrl: 'results.component.html'
+})
+export class BrowseResultsComponent implements OnInit {
+
+ searchContext: CatalogSearchContext;
+ results: any[];
+
+ constructor(
+ private route: ActivatedRoute,
+ private pcrud: PcrudService,
+ private cat: CatalogService,
+ private bib: BibRecordService,
+ private catUrl: CatalogUrlService,
+ private staffCat: StaffCatalogService
+ ) {}
+
+ ngOnInit() {
+ this.searchContext = this.staffCat.searchContext;
+ this.route.queryParamMap.subscribe((params: ParamMap) => {
+ this.browseByUrl(params);
+ });
+ }
+
+ browseByUrl(params: ParamMap): void {
+ this.catUrl.applyUrlParams(this.searchContext, params);
+
+ // SearchContext applies a default fieldClass value of 'keyword'.
+ // Replace with 'title', since there is no 'keyword' browse.
+ if (this.searchContext.fieldClass[0] === 'keyword') {
+ this.searchContext.fieldClass = ['title'];
+ }
+
+ if (this.searchContext.isBrowsable()) {
+ this.results = [];
+ this.cat.browse(this.searchContext)
+ .subscribe(result => this.addResult(result))
+ }
+ }
+
+ addResult(result: any) {
+
+ result.compiledHeadings = [];
+
+ // Avoi dupe headings per see
+ const seen: any = {};
+
+ result.sees.forEach(sees => {
+ if (!sees.control_set) { return; }
+
+ sees.headings.forEach(headingStruct => {
+ const fieldId = Object.keys(headingStruct)[0];
+ const heading = headingStruct[fieldId][0];
+
+ const inList = result.list_authorities.filter(
+ id => Number(id) === Number(heading.target))[0]
+
+ if ( heading.target
+ && heading.main_entry
+ && heading.target_count
+ && !inList
+ && !seen[heading.target]) {
+
+ seen[heading.target] = true;
+
+ result.compiledHeadings.push({
+ heading: heading.heading,
+ target: heading.target,
+ target_count: heading.target_count,
+ type: heading.type
+ });
+ }
+ });
+ });
+
+ this.results.push(result);
+ }
+
+ browseIsDone(): boolean {
+ return this.searchContext.searchState === CatalogSearchState.COMPLETE;
+ }
+
+ browseIsActive(): boolean {
+ return this.searchContext.searchState === CatalogSearchState.SEARCHING;
+ }
+
+ browseHasResults(): boolean {
+ return this.browseIsDone() && this.results.length > 0;
+ }
+
+ prevPage() {
+ const firstResult = this.results[0];
+ if (firstResult) {
+ this.searchContext.browsePivot = firstResult.pivot_point;
+ this.staffCat.browse();
+ }
+ }
+
+ nextPage() {
+ const lastResult = this.results[this.results.length - 1];
+ if (lastResult) {
+ this.searchContext.browsePivot = lastResult.pivot_point;
+ this.staffCat.browse();
+ }
+ }
+
+ searchByBrowseEntry(result) {
+
+ // avoid propagating the browse query to the search form
+ this.searchContext.query[0] = '';
+
+ this.searchContext.hasBrowseEntry =
+ result.browse_entry + ',' + result.fields;
+ this.staffCat.search();
+ }
+
+ // NOTE: to test unauthorized heading display in concerto
+ // browse for author = kab
+ newBrowseFromHeading(heading) {
+ this.searchContext.query[0] = heading.heading;
+ this.staffCat.browse();
+ }
+}
+
+
import {HoldService} from '@eg/staff/share/hold.service';
import {PartsComponent} from './record/parts.component';
import {PartMergeDialogComponent} from './record/part-merge-dialog.component';
+import {BrowseComponent} from './browse.component';
+import {BrowseFormComponent} from './browse/form.component';
+import {BrowseResultsComponent} from './browse/results.component';
@NgModule({
declarations: [
BasketActionsComponent,
HoldComponent,
PartsComponent,
- PartMergeDialogComponent
+ PartMergeDialogComponent,
+ BrowseComponent,
+ BrowseFormComponent,
+ BrowseResultsComponent
],
imports: [
StaffCommonModule,
['/staff/catalog/search'], {queryParams: params});
}
+ /**
+ * Redirect to the browse results page while propagating the current
+ * browse paramters into the URL. Let the browse results component
+ * execute the actual browse.
+ */
+ browse(): void {
+ if (!this.searchContext.isBrowsable()) { return; }
+
+ const params = this.catUrl.toUrlParams(this.searchContext);
+
+ // Force a new browse every time this method is called, even if
+ // it's the same as the active browse. Since router navigation
+ // exits early when the route + params is identical, add a
+ // random token to the route params to force a full navigation.
+ // This also resolves a problem where only removing secondary+
+ // versions of a query param fail to cause a route navigation.
+ // (E.g. going from two query= params to one).
+ params.ridx = '' + this.routeIndex++;
+
+ this.router.navigate(
+ ['/staff/catalog/browse'], {queryParams: params});
+ }
}
import {RecordComponent} from './record/record.component';
import {CatalogResolver} from './resolver.service';
import {HoldComponent} from './hold/hold.component';
+import {BrowseComponent} from './browse.component';
const routes: Routes = [{
path: '',
}, {
path: 'record/:id/:tab',
component: RecordComponent
- }]
+ }]}, {
+ // Browse is a top-level UI
+ path: 'browse',
+ component: BrowseComponent,
+ resolve: {catResolver : CatalogResolver},
}];
@NgModule({
<div id='staffcat-search-form' class='pb-2 mb-3'>
<div class="row"
*ngFor="let q of searchContext.query; let idx = index; trackBy:trackByIdx">
- <div class="col-lg-9 d-flex">
+ <div class="col-lg-8 d-flex">
<div class="flex-1">
<div *ngIf="idx == 0">
<select class="form-control" [(ngModel)]="searchContext.format">
</button>
</div>
</div><!-- col -->
- <div class="col-lg-3">
+ <div class="col-lg-4">
<div *ngIf="idx == 0" class="float-right">
<button class="btn btn-success mr-1" type="button"
[disabled]="searchIsActive()"
- (click)="searchContext.pager.offset=0;searchByForm()">
+ (click)="searchContext.pager.offset=0;searchByForm()" i18n>
Search
</button>
<button class="btn btn-warning mr-1" type="button"
[disabled]="searchIsActive()"
- (click)="searchContext.reset()">
+ (click)="searchContext.reset()" i18n>
Clear Form
</button>
<button class="btn btn-outline-secondary" type="button"
*ngIf="!showAdvanced()"
[disabled]="searchIsActive()"
- (click)="toggleAdvancedSearch()">
+ (click)="toggleAdvancedSearch()" i18n>
More Filters
</button>
<button class="btn btn-outline-secondary" type="button"
*ngIf="showAdvanced()"
- (click)="toggleAdvancedSearch()">
+ (click)="toggleAdvancedSearch()" i18n>
Hide Filters
</button>
+ <button class="btn btn-info ml-1" type="button"
+ (click)="goToBrowse()" i18n>
+ Browse
+ </button>
</div>
</div>
</div><!-- row -->
<div class="row">
- <div class="col-lg-9 d-flex">
+ <div class="col-lg-8 d-flex">
<div class="flex-1">
<eg-org-select
(onChange)="orgOnChange($event)"
<!-- alignment -->
</div>
</div>
- <div class="col-lg-3">
+ <div class="col-lg-4">
<eg-catalog-basket-actions></eg-catalog-basket-actions>
</div>
</div>
import {Component, OnInit, AfterViewInit, Renderer2} from '@angular/core';
+import {Router} from '@angular/router';
import {IdlObject} from '@eg/core/idl.service';
import {OrgService} from '@eg/core/org.service';
import {CatalogService} from '@eg/share/catalog/catalog.service';
constructor(
private renderer: Renderer2,
+ private router: Router,
private org: OrgService,
private cat: CatalogService,
private staffCat: StaffCatalogService
// Start with advanced search options open
// if any filters are active.
this.showAdvancedSearch = this.hasAdvancedOptions();
-
}
ngAfterViewInit() {
return this.searchContext.searchState === CatalogSearchState.SEARCHING;
}
+ goToBrowse() {
+ this.router.navigate(['/staff/catalog/browse']);
+ }
}
use OpenILS::Application::Search::Zips;
use OpenILS::Application::Search::CNBrowse;
use OpenILS::Application::Search::Serial;
+use OpenILS::Application::Search::Browse;
use OpenILS::Application::AppUtils;
sub child_init {
OpenILS::Application::Search::Z3950->child_init;
+ OpenILS::Application::Search::Browse->child_init;
}
--- /dev/null
+package OpenILS::Application::Search::Browse;
+use base qw/OpenILS::Application/;
+use strict; use warnings;
+
+# Most of this code is copied directly from ../../WWW/EGCatLoader/Browse.pm
+# and modified to be API-compatible.
+
+use Digest::MD5 qw/md5_hex/;
+use Apache2::Const -compile => qw/OK/;
+use MARC::Record;
+use List::Util qw/first/;
+
+use OpenSRF::Utils::Logger qw/$logger/;
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use OpenILS::Utils::Normalize qw/search_normalize/;
+use OpenILS::Application::AppUtils;
+use OpenSRF::Utils::JSON;
+use OpenSRF::Utils::Cache;
+use OpenSRF::Utils::SettingsClient;
+
+my $U = 'OpenILS::Application::AppUtils';
+my $browse_cache;
+my $browse_timeout;
+
+sub initialize { return 1; }
+
+sub child_init {
+ if (not defined $browse_cache) {
+ my $conf = new OpenSRF::Utils::SettingsClient;
+
+ $browse_timeout = $conf->config_value(
+ "apps", "open-ils.search", "app_settings", "cache_timeout"
+ ) || 300;
+ $browse_cache = new OpenSRF::Utils::Cache("global");
+ }
+}
+
+__PACKAGE__->register_method(
+ method => "browse",
+ api_name => "open-ils.search.browse.staff",
+ stream => 1,
+ signature => {
+ desc => q/Bib + authority browse/,
+ params => [{
+ params => {
+ name => 'Browse Parameters',
+ desc => q/Hash of arguments:
+ browse_class
+ -- title, author, subject, series
+ term
+ -- term to browse for
+ org_unit
+ -- context org unit ID
+ copy_location_group
+ -- copy location filter ID
+ limit
+ -- return this many results
+ pivot
+ -- browse entry ID
+ /
+ }
+ }]
+ }
+);
+
+__PACKAGE__->register_method(
+ method => "browse",
+ api_name => "open-ils.search.browse",
+ stream => 1,
+ signature => {
+ desc => q/See open-ils.search.browse.staff/
+ }
+);
+
+sub browse {
+ my ($self, $client, $params) = @_;
+
+ $params->{staff} = 1 if $self->api_name =~ /staff/;
+ my ($cache_key, @params) = prepare_browse_parameters($params);
+
+ my $results = $browse_cache->get_cache($cache_key);
+
+ if (!$results) {
+ $results =
+ new_editor()->json_query({from => ['metabib.browse', @params]});
+ if ($results) {
+ $browse_cache->put_cache($cache_key, $results, $browse_timeout);
+ }
+ }
+
+ my ($warning, $alternative) =
+ leading_article_test($params->{browse_class}, $params->{term});
+
+ for my $result (@$results) {
+ $result->{leading_article_warning} = $warning;
+ $result->{leading_article_alternative} = $alternative;
+ flesh_browse_results([$result]);
+ $client->respond($result);
+ }
+
+ return undef;
+}
+
+
+# Returns cache key and a list of parameters for DB proc metabib.browse().
+sub prepare_browse_parameters {
+ my ($params) = @_;
+
+ no warnings 'uninitialized';
+
+ my @params = (
+ $params->{browse_class},
+ $params->{term},
+ $params->{org_unit},
+ $params->{copy_location_group},
+ $params->{staff} ? 't' : 'f',
+ $params->{pivot},
+ $params->{limit} || 10
+ );
+
+ return (
+ "oils_browse_" . md5_hex(OpenSRF::Utils::JSON->perl2JSON(\@params)),
+ @params
+ );
+}
+
+sub leading_article_test {
+ my ($browse_class, $bterm) = @_;
+
+ my $flag_name = "opac.browse.warnable_regexp_per_class";
+ my $flag = new_editor()->retrieve_config_global_flag($flag_name);
+
+ return unless $flag->enabled eq 't';
+
+ my $map;
+ my $warning;
+ my $alternative;
+
+ eval { $map = OpenSRF::Utils::JSON->JSON2perl($flag->value); };
+ if ($@) {
+ $logger->warn("cgf '$flag_name' enabled but value is invalid JSON? $@");
+ return;
+ }
+
+ # Don't crash over any of the things that could go wrong in here:
+ eval {
+ if ($map->{$browse_class}) {
+ if ($bterm =~ qr/$map->{$browse_class}/i) {
+ $warning = 1;
+ ($alternative = $bterm) =~ s/$map->{$browse_class}//;
+ }
+ }
+ };
+
+ if ($@) {
+ $logger->warn("cgf '$flag_name' has valid JSON in value, but: $@");
+ }
+
+ return ($warning, $alternative);
+}
+
+# flesh_browse_results() attaches data from authority records. It
+# changes $results and returns 1 for success, undef for failure
+# $results must be an arrayref of result rows from the DB's metabib.browse()
+sub flesh_browse_results {
+ my ($results) = @_;
+
+ for my $authority_field_name ( qw/authorities sees/ ) {
+ for my $r (@$results) {
+ # Turn comma-seprated strings of numbers in "authorities" and "sees"
+ # columns into arrays.
+ if ($r->{$authority_field_name}) {
+ $r->{$authority_field_name} = [split /,/, $r->{$authority_field_name}];
+ } else {
+ $r->{$authority_field_name} = [];
+ }
+ $r->{"list_$authority_field_name"} = [ @{$r->{$authority_field_name} } ];
+ }
+
+ # Group them in one arrray, not worrying about dupes because we're about
+ # to use them in an IN () comparison in a SQL query.
+ my @auth_ids = map { @{$_->{$authority_field_name}} } @$results;
+
+ if (@auth_ids) {
+ # Get all linked authority records themselves
+ my $linked = new_editor()->json_query({
+ select => {
+ are => [qw/id marc control_set/],
+ aalink => [{column => "target", transform => "array_agg",
+ aggregate => 1}]
+ },
+ from => {
+ are => {
+ aalink => {
+ type => "left",
+ fkey => "id", field => "source"
+ }
+ }
+ },
+ where => {"+are" => {id => \@auth_ids}}
+ }) or return;
+
+ map_authority_headings_to_results(
+ $linked, $results, \@auth_ids, $authority_field_name);
+ }
+ }
+
+ return 1;
+}
+
+sub map_authority_headings_to_results {
+ my ($linked, $results, $auth_ids, $authority_field_name) = @_;
+
+ # Use the linked authority records' control sets to find and pick
+ # out non-main-entry headings. Build the headings and make a
+ # combined data structure for the template's use.
+ my %linked_headings_by_auth_id = map {
+ $_->{id} => find_authority_headings_and_notes($_)
+ } @$linked;
+
+ # Avoid sending the full MARC blobs to the caller.
+ delete $_->{marc} for @$linked;
+
+ # Graft this authority heading data onto our main result set at the
+ # named column, either "authorities" or "sees".
+ foreach my $row (@$results) {
+ $row->{$authority_field_name} = [
+ map { $linked_headings_by_auth_id{$_} } @{$row->{$authority_field_name}}
+ ];
+ }
+
+ # Get linked-bib counts for each of those authorities, and put THAT
+ # information into place in the data structure.
+ my $counts = new_editor()->json_query({
+ select => {
+ abl => [
+ {column => "id", transform => "count",
+ alias => "count", aggregate => 1},
+ "authority"
+ ]
+ },
+ from => {abl => {}},
+ where => {
+ "+abl" => {
+ authority => [
+ @$auth_ids,
+ $U->unique_unnested_numbers(map { $_->{target} } @$linked)
+ ]
+ }
+ }
+ }) or return;
+
+ my %auth_counts = map { $_->{authority} => $_->{count} } @$counts;
+
+ # Soooo nesty! We look for places where we'll need a count of bibs
+ # linked to an authority record, and put it there for the template to find.
+ for my $row (@$results) {
+ for my $auth (@{$row->{$authority_field_name}}) {
+ if ($auth->{headings}) {
+ for my $outer_heading (@{$auth->{headings}}) {
+ for my $heading_blob (@{(values %$outer_heading)[0]}) {
+ if ($heading_blob->{target}) {
+ $heading_blob->{target_count} =
+ $auth_counts{$heading_blob->{target}};
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+
+# TOOD consider locale-aware caching
+sub get_acsaf {
+ my $control_set = shift;
+
+ my $acs = new_editor()
+ ->search_authority_control_set_authority_field(
+ {control_set => $control_set}
+ );
+
+ return { map { $_->id => $_ } @$acs };
+}
+
+sub find_authority_headings_and_notes {
+ my ($row) = @_;
+
+ my $acsaf_table = get_acsaf($row->{control_set});
+
+ $row->{headings} = [];
+
+ my $record;
+ eval {
+ $record = new_from_xml MARC::Record($row->{marc});
+ };
+
+ if ($@) {
+ $logger->warn("Problem with MARC from authority record #" .
+ $row->{id} . ": $@");
+ return $row; # We're called in map(), so we must move on without
+ # a fuss.
+ }
+
+ extract_public_general_notes($record, $row);
+
+ # extract headings from the main authority record along with their
+ # types
+ my $parsed_headings = new_editor()->json_query({
+ from => ['authority.extract_headings', $row->{marc}]
+ });
+ my %heading_type_map = ();
+ if ($parsed_headings) {
+ foreach my $h (@$parsed_headings) {
+ $heading_type_map{$h->{normalized_heading}} =
+ $h->{purpose} eq 'variant' ? 'variant' :
+ $h->{purpose} eq 'related' ? $h->{related_type} :
+ '';
+ }
+ }
+
+ # By applying grep in this way, we get acsaf objects that *have* and
+ # therefore *aren't* main entries, which is what we want.
+ foreach my $acsaf (values(%$acsaf_table)) {
+ my @fields = $record->field($acsaf->tag);
+ my %sf_lookup = map { $_ => 1 } split("", $acsaf->display_sf_list);
+ my @headings;
+
+ foreach my $field (@fields) {
+ my $h = { main_entry => ( $acsaf->main_entry ? 0 : 1 ),
+ heading => get_authority_heading($field, \%sf_lookup, $acsaf->joiner) };
+
+ my $norm = search_normalize($h->{heading});
+ if (exists $heading_type_map{$norm}) {
+ $h->{type} = $heading_type_map{$norm};
+ }
+ # XXX I was getting "target" from authority.authority_linking, but
+ # that makes no sense: that table can only tell you that one
+ # authority record as a whole points at another record. It does
+ # not record when a specific *field* in one authority record
+ # points to another record (not that it makes much sense for
+ # one authority record to have links to multiple others, but I can't
+ # say there definitely aren't cases for that).
+ $h->{target} = $2
+ if ($field->subfield('0') || "") =~ /(^|\))(\d+)$/;
+
+ # The target is the row id if this is a main entry...
+ $h->{target} = $row->{id} if $h->{main_entry};
+
+ push @headings, $h;
+ }
+
+ push @{$row->{headings}}, {$acsaf->id => \@headings} if @headings;
+ }
+
+ return $row;
+}
+
+
+# Break out any Public General Notes (field 680) for display. These are
+# sometimes (erroneously?) called "scope notes." I say erroneously,
+# tentatively, because LoC doesn't seem to document a "scope notes"
+# field for authority records, while it does so for classification
+# records, which are something else. But I am not a librarian.
+sub extract_public_general_notes {
+ my ($record, $row) = @_;
+
+ # Make a list of strings, each string being a concatentation of any
+ # subfields 'i', '5', or 'a' from one field 680, in order of appearance.
+ $row->{notes} = [
+ map {
+ join(
+ " ",
+ map { $_->[1] } grep { $_->[0] =~ /[i5a]/ } $_->subfields
+ )
+ } $record->field('680')
+ ];
+}
+
+sub get_authority_heading {
+ my ($field, $sf_lookup, $joiner) = @_;
+
+ $joiner ||= ' ';
+
+ return join(
+ $joiner,
+ map { $_->[1] } grep { $sf_lookup->{$_->[0]} } $field->subfields
+ );
+}
+
+1;