From 260401bd19ab32a80c1cf456233d78419c586308 Mon Sep 17 00:00:00 2001 From: Ian O'Connor Date: Mon, 2 Mar 2026 12:04:38 -0500 Subject: [PATCH 1/6] Support multiple ids --- lib/elasticsearch/elastic-query-builder.js | 19 +++++++++++++++++++ lib/resources.js | 1 + test/elastic-query-builder.test.js | 14 ++++++++++++++ 3 files changed, 34 insertions(+) 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 From fc995e061db5f59906b845c40139f6f74c4c52c4 Mon Sep 17 00:00:00 2001 From: Ian O'Connor Date: Tue, 3 Mar 2026 10:12:00 -0500 Subject: [PATCH 2/6] Update prod browse index --- config/production.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From d01865c4bc89030ef72d8dc77f390c3d7123eda7 Mon Sep 17 00:00:00 2001 From: Ian O'Connor Date: Thu, 5 Mar 2026 13:11:34 -0500 Subject: [PATCH 3/6] Fetch roles and counts dynamically for contributors --- lib/contributors.js | 101 +++++++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 35 deletions(-) diff --git a/lib/contributors.js b/lib/contributors.js index 0f79f93e..4244dc3c 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][role] = 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 From 13493b2f169e0bfb3494099cac6cfc920834e87d Mon Sep 17 00:00:00 2001 From: Ian O'Connor Date: Thu, 5 Mar 2026 13:24:32 -0500 Subject: [PATCH 4/6] Fix painless script --- lib/contributors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/contributors.js b/lib/contributors.js index 4244dc3c..b050df1c 100644 --- a/lib/contributors.js +++ b/lib/contributors.js @@ -114,7 +114,7 @@ const buildElasticRoleCountQuery = function (contributorList) { 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;', + 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 } From c494be57edf8034f2cae8581d68af96be7f65963 Mon Sep 17 00:00:00 2001 From: Ian O'Connor Date: Thu, 5 Mar 2026 13:44:08 -0500 Subject: [PATCH 5/6] Fix roleCount format --- lib/contributors.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/contributors.js b/lib/contributors.js index b050df1c..28c1770c 100644 --- a/lib/contributors.js +++ b/lib/contributors.js @@ -77,10 +77,10 @@ module.exports = function (app, _private = null) { resp.aggregations?.contributor_role?.buckets?.forEach((agg) => { const { name, role } = parseNameAndRole(agg.key) if (!contributorRoleCounts[name]) { - contributorRoleCounts[name] = {} + contributorRoleCounts[name] = [] } - contributorRoleCounts[name][role] = agg.doc_count + contributorRoleCounts[name].push( { role, count: agg.doc_count } ) }) workingResponse.contributors.forEach((contributor) => { From 5dd7b019846bfed14a52ac7adeed003437ec7c7b Mon Sep 17 00:00:00 2001 From: Ian O'Connor Date: Thu, 5 Mar 2026 14:33:15 -0500 Subject: [PATCH 6/6] Fix format --- lib/contributors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/contributors.js b/lib/contributors.js index 28c1770c..0e7321de 100644 --- a/lib/contributors.js +++ b/lib/contributors.js @@ -80,7 +80,7 @@ module.exports = function (app, _private = null) { contributorRoleCounts[name] = [] } - contributorRoleCounts[name].push( { role, count: agg.doc_count } ) + contributorRoleCounts[name].push({ role, count: agg.doc_count }) }) workingResponse.contributors.forEach((contributor) => {