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
4 changes: 4 additions & 0 deletions api/src/api/opensearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ async function getWorkFileSets(workId, opts = {}) {
const {
allowPrivate = false,
allowUnpublished = false,
annotationsQuery = null,
role = null,
source = null,
sortBy = null,
Expand All @@ -52,6 +53,9 @@ async function getWorkFileSets(workId, opts = {}) {
if (role) {
mustClauses.push({ term: { role: role } });
}
if (annotationsQuery) {
mustClauses.push({ match: { "annotations.content": annotationsQuery } });
}

const searchBody = {
size: 10000,
Expand Down
6 changes: 6 additions & 0 deletions api/src/api/response/iiif/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,12 @@ async function transform(response, options = {}) {
}
}

jsonManifest.service = [
{
id: `${dcApiEndpoint()}/works/${source.id}/search?as=iiif`,
type: "SearchService2",
},
];
jsonManifest.provider = [provider];
jsonManifest.logo = [nulLogo];
const navPlace = buildNavPlace(source);
Expand Down
2 changes: 2 additions & 0 deletions api/src/api/response/iiif/presentation-api/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,10 @@ module.exports = {
buildImageService,
buildSupplementingAnnotation,
buildTranscriptionAnnotation,
getTranscriptionContent,
isAltFormat,
isAudioVideo,
isImage,
isPDF,
normalizeLanguages,
};
103 changes: 103 additions & 0 deletions api/src/api/response/iiif/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const { dcApiEndpoint } = require("../../../environment");
const { getWorkFileSets } = require("../../opensearch");
const {
getTranscriptionContent,
normalizeLanguages,
} = require("./presentation-api/items");

function extractSnippet(content, q, contextChars = 100) {
const idx = content.toLowerCase().indexOf(q.toLowerCase());
if (idx === -1) return null;
const start = Math.max(0, idx - contextChars);
const end = Math.min(content.length, idx + q.length + contextChars);
let snippet = content.slice(start, end).trim();
if (start > 0) snippet = "..." + snippet;
if (end < content.length) snippet = snippet + "...";
return snippet;
}

function buildSearchAnnotationBody(annotation, snippet) {
const body = {
type: "TextualBody",
value: snippet,
format: "text/plain",
};
const languages = normalizeLanguages(annotation.language);
if (languages.length === 1) {
body.language = languages[0];
} else if (languages.length > 1) {
body.language = languages;
}
return body;
}

async function transform(workId, q, opts = {}) {
const { allowPrivate = false, allowUnpublished = false } = opts;

const manifestId = `${dcApiEndpoint()}/works/${workId}?as=iiif`;
const searchId = `${dcApiEndpoint()}/works/${workId}/search?as=iiif&q=${encodeURIComponent(
q
)}`;

const response = await getWorkFileSets(workId, {
allowPrivate,
allowUnpublished,
annotationsQuery: q,
role: "Access",
source: ["id", "annotations", "group_with"],
sortBy: "rank",
});

const fileSets =
response.statusCode === 200
? JSON.parse(response.body).hits.hits.map((h) => h._source)
: [];

// Replicate manifest.js grouping: ungrouped file sets use their own id as key
const fileSetGroups = {};
fileSets.forEach((fs) => {
const key = fs.group_with || fs.id;
if (!fileSetGroups[key]) fileSetGroups[key] = [];
fileSetGroups[key].push(fs);
});

const items = [];

Object.entries(fileSetGroups).forEach(([groupKey, groupFileSets], index) => {
const canvasId = `${manifestId}/canvas/${index}`;

// Primary file set is the one whose id matches the group key (same as manifest.js)
const primary =
groupFileSets.find((fs) => fs.id === groupKey) || groupFileSets[0];
if (!primary?.annotations) return;

primary.annotations
.filter((ann) => ann.type === "transcription")
.forEach((ann) => {
const content = getTranscriptionContent(ann);
const snippet = extractSnippet(content, q);
if (!snippet) return;

items.push({
id: `${canvasId}/annotation/${ann.id}`,
type: "Annotation",
motivation: "supplementing",
body: buildSearchAnnotationBody(ann, snippet),
target: canvasId,
});
});
});

return {
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
"@context": "http://iiif.io/api/search/2/context.json",
id: searchId,
type: "AnnotationPage",
items,
}),
};
}

module.exports = { transform };
32 changes: 32 additions & 0 deletions api/src/handlers/get-work-search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const { getWork } = require("../api/opensearch");
const iiifSearchResponse = require("../api/response/iiif/search");
const { wrap } = require("./middleware");

exports.handler = wrap(async (event) => {
const id = event.pathParameters.id;
const { as, q } = event.queryStringParameters;

const allowPrivate =
event.userToken.isSuperUser() ||
event.userToken.isReadingRoom() ||
event.userToken.hasEntitlement(id);
const allowUnpublished =
event.userToken.isSuperUser() || event.userToken.hasEntitlement(id);

if (as !== "iiif" || !q?.trim()) {
return {
statusCode: 400,
body: JSON.stringify({
message: "Request must include ?as=iiif&q={query}",
}),
};
}

const workResponse = await getWork(id, { allowPrivate, allowUnpublished });
if (workResponse.statusCode !== 200) return workResponse;

return iiifSearchResponse.transform(id, q, {
allowPrivate,
allowUnpublished,
});
});
24 changes: 24 additions & 0 deletions api/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,30 @@ Resources:
ApiId: !Ref dcApi
Path: /works/{id}/thumbnail
Method: HEAD
getWorkSearchFunction:
Type: AWS::Serverless::Function
Condition: DeployAPI
Properties:
Handler: handlers/get-work-search.handler
Description: IIIF Search 2.0 for a Work's transcription annotations.
#* Layers:
#* - !Ref apiDependencies
Policies:
- !Ref SecretsPolicy
- !Ref readIndexPolicy
Events:
WorkApiGet:
Type: HttpApi
Properties:
ApiId: !Ref dcApi
Path: /works/{id}/search
Method: GET
WorkApiHead:
Type: HttpApi
Properties:
ApiId: !Ref dcApi
Path: /works/{id}/search
Method: HEAD
getSimilarFunction:
Type: AWS::Serverless::Function
Condition: DeployAPI
Expand Down
4 changes: 4 additions & 0 deletions api/test/integration/get-work-by-id.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ describe("Retrieve work by id", () => {
"http://iiif.io/api/presentation/3/context.json"
);
expect(resultBody.label.none[0]).to.eq("Canary Record TEST 1");
expect(resultBody.service).to.deep.include({
id: `${process.env.DC_API_ENDPOINT}/works/1234/search?as=iiif`,
type: "SearchService2",
});
});

it("will retrieve a private, unpublished work document with an entitlement", async () => {
Expand Down
Loading
Loading