diff --git a/config/production.env b/config/production.env index 90a5a83d..cd7e9955 100644 --- a/config/production.env +++ b/config/production.env @@ -23,4 +23,4 @@ HIDE_NYPL_SOURCE= BIB_HAS_VOLUMES_THRESHOLD=0.8 BIB_HAS_DATES_THRESHOLD=0.8 -BROWSE_INDEX=browse-prod-2025-09-22 +BROWSE_INDEX=browse-prod-2026-03-03 diff --git a/lib/contributors.js b/lib/contributors.js index 0f79f93e..0e7321de 100644 --- a/lib/contributors.js +++ b/lib/contributors.js @@ -28,47 +28,35 @@ module.exports = function (app, _private = null) { return app.esClient.search(body, process.env.BROWSE_INDEX) .then((resp) => { - return { + const contributorList = [] + const workingResponse = { '@type': 'contributorList', page: params.page, per_page: params.per_page, totalResults: resp.hits?.total?.value, - contributors: resp.hits?.hits?.reduce((workingResponse, hit) => { + contributors: resp.hits?.hits?.map((hit) => { if (hit.matched_queries?.[0] === 'preferredTerm' || hit.matched_queries?.[0] === 'preferredTermPrefix') { // if match is on preferredTerm, use that regardless of variant matches - const { name, role } = parseNameAndRole(hit._source.preferredTerm) - - let contributorData = workingResponse.find(item => item.termLabel === name) - - if (!contributorData) { - contributorData = { - '@type': 'preferredTerm', - termLabel: name - } - workingResponse.push(contributorData) - } - - if (role) { - // just add the role count to the top level response - const roleCount = { role, count: hit._source.count } - if (!contributorData.roleCounts) { - contributorData.roleCounts = [] - } - contributorData.roleCounts.push(roleCount) - } else { - // top-level contributor object - contributorData.count = hit._source.count - contributorData.broaderTerms = hit._source.broaderTerms?.map((term) => ({ termLabel: term })) - contributorData.narrowerTerms = hit._source.narrowerTerms?.map((term) => ({ termLabel: term })) - contributorData.seeAlso = hit._source.seeAlso?.map((term) => ({ termLabel: term })) - contributorData.earlierHeadings = hit._source.earlierHeadings?.map((term) => ({ termLabel: term })) - contributorData.laterHeadings = hit._source.laterHeadings?.map((term) => ({ termLabel: term })) - contributorData.uri = hit._source.uri + const name = hit._source.preferredTerm + + contributorList.push(name) + + return { + '@type': 'preferredTerm', + termLabel: name, + count: hit._source.count, + broaderTerms: hit._source.broaderTerms?.map((term) => ({ termLabel: term })), + narrowerTerms: hit._source.narrowerTerms?.map((term) => ({ termLabel: term })), + seeAlso: hit._source.seeAlso?.map((term) => ({ termLabel: term })), + earlierHeadings: hit._source.earlierHeadings?.map((term) => ({ termLabel: term })), + laterHeadings: hit._source.laterHeadings?.map((term) => ({ termLabel: term })), + uri: hit._source.uri, + roleCounts: [] } } else { // Match was on a variant- use that in the response const matchedVariantTerm = hit.inner_hits.variants.hits.hits[0]._source.variant - const variantData = { + return { '@type': 'variant', termLabel: matchedVariantTerm, preferredTerms: [ @@ -78,13 +66,29 @@ module.exports = function (app, _private = null) { } ] } - - workingResponse.push(variantData) } + }) + } + + // Get the counts of roles for each contributor in the response object + return app.esClient.search(buildElasticRoleCountQuery(contributorList), process.env.RESOURCES_INDEX) + .then((resp) => { + const contributorRoleCounts = {} + resp.aggregations?.contributor_role?.buckets?.forEach((agg) => { + const { name, role } = parseNameAndRole(agg.key) + if (!contributorRoleCounts[name]) { + contributorRoleCounts[name] = [] + } + + contributorRoleCounts[name].push({ role, count: agg.doc_count }) + }) + + workingResponse.contributors.forEach((contributor) => { + contributor.roleCounts = contributorRoleCounts[contributor.termLabel] + }) return workingResponse - }, []) - } + }) }) } @@ -95,6 +99,33 @@ module.exports = function (app, _private = null) { } } +/** + * Builds an aggregation query that checks the resource index for counts on the contributorRoleLiteral field for a list of contributors. + */ +const buildElasticRoleCountQuery = function (contributorList) { + return { + size: 0, + query: { + terms: { + contributorRoleLiteral: contributorList + } + }, + aggs: { + contributor_role: { + terms: { + script: { + source: 'def results = []; for (val in doc["contributorRoleLiteral"]) { int pos = val.indexOf("||"); if (pos != -1) { String name = val.substring(0, pos); if (params.targets.contains(name)) { results.add(val); } } } return results;', + params: { + targets: contributorList + } + }, + size: 1000 + } + } + } + } +} + /** * Given GET params, returns a plainobject with `from`, `size`, `query`, * `sort`, and any other params necessary to perform the ES query based diff --git a/lib/elasticsearch/elastic-query-builder.js b/lib/elasticsearch/elastic-query-builder.js index bf377261..24f217f3 100644 --- a/lib/elasticsearch/elastic-query-builder.js +++ b/lib/elasticsearch/elastic-query-builder.js @@ -44,6 +44,9 @@ class ElasticQueryBuilder { // Add user filters: this.applyFilters() + // if a list of ids is specified, return those ids + this.applyMultipleIdMatch() + // Apply global clauses: // Hide specific nypl-sources when configured to do so: this.applyHiddenNyplSources() @@ -595,6 +598,22 @@ class ElasticQueryBuilder { }) } + /** + * If a list of ids is provided in the request, return only those items + */ + applyMultipleIdMatch () { + if (!this.request.params.ids) return + + this.query.addMust({ + bool: { + should: [ + { terms: { 'identifierV2.value': this.request.params.ids } }, + { terms: { uri: this.request.params.ids } } + ] + } + }) + } + /** * Examine request for user-filters. When found, add them to query. */ diff --git a/lib/resources.js b/lib/resources.js index 9aa083e2..462ae779 100644 --- a/lib/resources.js +++ b/lib/resources.js @@ -88,6 +88,7 @@ const parseSearchParams = function (params, overrideParams = {}) { role: { type: 'string' }, merge_checkin_card_items: { type: 'boolean', default: true }, include_item_aggregations: { type: 'boolean', default: true }, + ids: { type: 'string-list' }, ...overrideParams }) } diff --git a/test/elastic-query-builder.test.js b/test/elastic-query-builder.test.js index 1ab25a5c..4206aa5b 100644 --- a/test/elastic-query-builder.test.js +++ b/test/elastic-query-builder.test.js @@ -216,6 +216,20 @@ describe('ElasticQueryBuilder', () => { }) }) + describe('multiple id query', () => { + it('supports ids=x,y,z', () => { + const request = new ApiRequest({ q: '', ids: ['id_a', 'id_b'] }) + const inst = ElasticQueryBuilder.forApiRequest(request) + + // Expect multiple term/prefix matches on identifier fields: + expect(inst.query.toJson()).to.nested + .include({ 'bool.must[0].bool.should[0].terms.identifierV2\\.value[0]': 'id_a' }) + .include({ 'bool.must[0].bool.should[0].terms.identifierV2\\.value[1]': 'id_b' }) + .include({ 'bool.must[0].bool.should[1].terms.uri[0]': 'id_a' }) + .include({ 'bool.must[0].bool.should[1].terms.uri[1]': 'id_b' }) + }) + }) + describe('search_scope callnumber', () => { it('generates a "callnumber" query', () => { // including leading and trailing whitespace to validate that query is trimmed