Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion config/production.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
101 changes: 66 additions & 35 deletions lib/contributors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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
}, [])
}
})
})
}

Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions lib/elasticsearch/elastic-query-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions lib/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
Expand Down
14 changes: 14 additions & 0 deletions test/elastic-query-builder.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down