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 .tool-versions
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
nodejs 20.15.0
java corretto-19.0.1.10.1
aws-sam-cli 1.135.0
aws-sam-cli 1.148.0
python 3.12.2
uv 0.9.5
4 changes: 2 additions & 2 deletions api/dependencies/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api/dependencies/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dc-api-dependencies",
"version": "2.8.1",
"version": "2.9.0",
"description": "NUL Digital Collections API Dependencies",
"repository": "https://github.com/nulib/dc-api-v2",
"author": "nulib",
Expand Down
4 changes: 2 additions & 2 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dc-api-build",
"version": "2.8.1",
"version": "2.9.0",
"description": "NUL Digital Collections API Build Environment",
"repository": "https://github.com/nulib/dc-api-v2",
"author": "nulib",
Expand Down
96 changes: 94 additions & 2 deletions api/src/api/response/iiif/manifest.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
const { IIIFBuilder } = require("iiif-builder");
const { dcApiEndpoint, dcUrl } = require("../../../environment");
const {
dcApiEndpoint,
dcUrl,
openSearchEndpoint,
} = require("../../../environment");
const { transformError } = require("../error");
const { getFileSet } = require("../../opensearch");
const {
addSupplementingAnnotationToCanvas,
addTranscriptionAnnotationsToCanvas,
addThumbnailToCanvas,
buildAnnotationBody,
buildImageResourceId,
Expand All @@ -18,7 +24,7 @@ const {
} = require("./presentation-api/placeholder-canvas");
const { nulLogo, provider } = require("./presentation-api/provider");

function transform(response) {
async function transform(response, options = {}) {
if (response.statusCode === 200) {
const builder = new IIIFBuilder();

Expand All @@ -27,6 +33,9 @@ function transform(response) {

const manifestId = `${dcApiEndpoint()}/works/${source.id}?as=iiif`;

const transcriptionMap = await fetchFileSetTranscriptions(source, options);
const transcriptionPages = {};

const normalizedFlatManifestObj = builder.createManifest(
manifestId,
(manifest) => {
Expand Down Expand Up @@ -64,6 +73,22 @@ function transform(response) {
if (!isAuxiliary && fileSet.webvtt) {
addSupplementingAnnotationToCanvas(canvas, canvasId, fileSet);
}

/** Add transcription annotations */
const transcriptions = transcriptionMap[fileSet.id];
if (
source.work_type === "Image" &&
fileSet.role === "Access" &&
transcriptions?.length
) {
const pageId = `${canvasId}/annotations/page/0`;
addTranscriptionAnnotationsToCanvas(
canvas,
canvasId,
transcriptions
);
transcriptionPages[pageId] = transcriptions;
}
});
}

Expand Down Expand Up @@ -275,6 +300,22 @@ function transform(response) {
primaryFileSet
);
}

/** Add transcription annotations */
const transcriptions = transcriptionMap[primaryFileSet.id];
if (
source.work_type === "Image" &&
primaryFileSet.role === "Access" &&
transcriptions?.length
) {
const pageId = `${canvasId}/annotations/page/0`;
addTranscriptionAnnotationsToCanvas(
canvas,
canvasId,
transcriptions
);
transcriptionPages[pageId] = transcriptions;
}
});
}
);
Expand Down Expand Up @@ -320,6 +361,19 @@ function transform(response) {
}
}
}

/** Re-do transcription text in annotation bodies as it's getting stripped somehow */
const annotationPages = jsonManifest.items[i]?.annotations || [];
annotationPages.forEach((page) => {
const pageTranscriptions = transcriptionPages[page.id];
if (!pageTranscriptions?.length) return;
page.items?.forEach((annotation, idx) => {
const sourceTranscription = pageTranscriptions[idx];
if (!sourceTranscription) return;
if (!annotation.body) annotation.body = {};
annotation.body.value = getTranscriptionContent(sourceTranscription);
});
});
}

jsonManifest.provider = [provider];
Expand All @@ -338,4 +392,42 @@ function transform(response) {
return transformError(response);
}

async function fetchFileSetTranscriptions(source, options) {
if (source.work_type !== "Image") return {};
if (!openSearchEndpoint()) return {};

const candidates = (source.file_sets || []).filter(
(file_set) => file_set.role === "Access" && file_set.id
);

const allowPrivate = options.allowPrivate || false;
const allowUnpublished = options.allowUnpublished || false;

const results = await Promise.all(
candidates.map(async (file_set) => {
const response = await getFileSet(file_set.id, {
allowPrivate,
allowUnpublished,
});
if (response.statusCode !== 200) return null;
const body = JSON.parse(response.body);
const annotations =
body?._source?.annotations?.filter(
(annotation) => annotation.type === "transcription"
) || [];
if (annotations.length === 0) return null;
return { id: file_set.id, annotations };
})
);

return results
.filter(Boolean)
.reduce((acc, { id, annotations }) => ({ ...acc, [id]: annotations }), {});
}

function getTranscriptionContent(annotation = {}) {
const value = annotation.content ?? "";
return typeof value === "string" ? value : "";
}

module.exports = { transform };
63 changes: 63 additions & 0 deletions api/src/api/response/iiif/presentation-api/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,26 @@ function addSupplementingAnnotationToCanvas(canvas, canvasId, fileSet) {
);
}

function addTranscriptionAnnotationsToCanvas(canvas, canvasId, transcriptions) {
const validTranscriptions = (transcriptions || []).filter(
hasTranscriptionContent
);
if (validTranscriptions.length === 0) return;

canvas.createAnnotationPage(
(pageId = `${canvasId}/annotations/page/0`),
(annotationPageBuilder) => {
annotationPageBuilder.addLabel("Transcription", "en");
validTranscriptions.forEach((annotation, index) => {
annotationPageBuilder.createAnnotation(
buildTranscriptionAnnotation({ annotation, canvasId, pageId, index })
);
});
},
true
);
}

function addThumbnailToCanvas(canvas, fileSet) {
if (fileSet.representative_image_url) {
canvas.addThumbnail({
Expand Down Expand Up @@ -84,6 +104,47 @@ function buildSupplementingAnnotation({ canvasId, fileSet }) {
};
}

function buildTranscriptionAnnotation({ annotation, canvasId, pageId, index }) {
return {
id: `${pageId}/a${index}`,
type: "Annotation",
motivation: "commenting",
body: buildTranscriptionBody(annotation),
target: canvasId,
};
}

function buildTranscriptionBody(annotation) {
const value = getTranscriptionContent(annotation);

const body = {
type: "TextualBody",
value: value,
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;
}

function normalizeLanguages(value) {
if (!value) return [];
if (Array.isArray(value)) return value.filter(Boolean);
return [value];
}

function getTranscriptionContent(annotation = {}) {
return typeof annotation.content === "string" ? annotation.content : "";
}

function hasTranscriptionContent(annotation) {
return getTranscriptionContent(annotation) !== "";
}

function isAltFormat(mimeType) {
const acceptedTypes = [
"application/pdf",
Expand All @@ -107,13 +168,15 @@ function isPDF(mimeType) {

module.exports = {
addSupplementingAnnotationToCanvas,
addTranscriptionAnnotationsToCanvas,
addThumbnailToCanvas,
annotationType,
buildAnnotationBody,
buildAnnotationBodyId,
buildImageResourceId,
buildImageService,
buildSupplementingAnnotation,
buildTranscriptionAnnotation,
isAltFormat,
isAudioVideo,
isImage,
Expand Down
71 changes: 71 additions & 0 deletions api/src/handlers/get-annotation-by-id.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const { wrap } = require("./middleware");
const { search, getFileSet } = require("../api/opensearch");
const { prefix, appInfo } = require("../environment");
const { transformError } = require("../api/response/error");

/**
* Retrieves a single annotation by id
*/
exports.handler = wrap(async (event) => {
const annotationId = event.pathParameters.id;

const searchBody = {
size: 1,
_source: ["id"],
query: {
bool: {
should: [
{ term: { "annotations.id.keyword": annotationId } },
{ term: { "annotations.id": annotationId } },
],
minimum_should_match: 1,
},
},
};

const searchResponse = await search(
prefix("dc-v2-file-set"),
JSON.stringify(searchBody)
);

if (searchResponse.statusCode !== 200) {
return transformError(searchResponse);
}

const searchPayload = JSON.parse(searchResponse.body);
const hit = searchPayload?.hits?.hits?.[0];
if (!hit) return transformError({ statusCode: 404 });

const fileSetId = hit?._source?.id || hit?._id;
if (!fileSetId) return transformError({ statusCode: 404 });

const allowPrivate =
event.userToken.isSuperUser() || event.userToken.isReadingRoom();
const allowUnpublished = event.userToken.isSuperUser();
const fileSetResponse = await getFileSet(fileSetId, {
allowPrivate,
allowUnpublished,
});

if (fileSetResponse.statusCode !== 200) {
return transformError(fileSetResponse);
}

const fileSetPayload = JSON.parse(fileSetResponse.body);
const annotation = fileSetPayload?._source?.annotations?.find(
(item) => item.id === annotationId
);

if (!annotation) return transformError({ statusCode: 404 });

return {
statusCode: 200,
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
data: annotation,
info: appInfo(),
}),
};
});
33 changes: 33 additions & 0 deletions api/src/handlers/get-file-set-annotations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const { wrap } = require("./middleware");
const { getFileSet } = require("../api/opensearch");
const { appInfo } = require("../environment");
const opensearchResponse = require("../api/response/opensearch");

/**
* Returns annotations for a FileSet
*/
exports.handler = wrap(async (event) => {
const id = event.pathParameters.id;
const allowPrivate =
event.userToken.isSuperUser() || event.userToken.isReadingRoom();
const allowUnpublished = event.userToken.isSuperUser();

const esResponse = await getFileSet(id, { allowPrivate, allowUnpublished });
if (esResponse.statusCode !== 200) {
return await opensearchResponse.transform(esResponse);
}

const body = JSON.parse(esResponse.body);
const annotations = body?._source?.annotations ?? null;

return {
statusCode: 200,
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
data: annotations,
info: appInfo(),
}),
};
});
Loading