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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ View and edit information about a specific Work in the Index.
3. View JSON response at `https://USER_PREFIX.dev.rdc.library.northwestern.edu:3002/works/[WORK_ID]`
4. View IIIF Manifest JSON response at `https://USER_PREFIX.dev.rdc.library.northwestern.edu:3002/works/[WORK_ID]?as=iiif`

### IIIF content search

IIIF Presentation responses expose [IIIF Content Search 2.0](https://iiif.io/api/search/2.0/) services for transcription annotations:

- Work manifests include a `SearchService2` entry for `https://USER_PREFIX.dev.rdc.library.northwestern.edu:3002/works/[WORK_ID]/search?as=iiif`
- File set canvases include a `SearchService2` entry for `https://USER_PREFIX.dev.rdc.library.northwestern.edu:3002/file-sets/[FILE_SET_ID]/search?as=iiif`

To search transcription text, include a non-empty `q` parameter:

```shell
curl "https://USER_PREFIX.dev.rdc.library.northwestern.edu:3002/works/[WORK_ID]/search?as=iiif&q=[QUERY]"
curl "https://USER_PREFIX.dev.rdc.library.northwestern.edu:3002/file-sets/[FILE_SET_ID]/search?as=iiif&q=[QUERY]"
```

Both endpoints return a IIIF `AnnotationPage` whose `items` target the matching work canvas or file set canvas. Requests without `as=iiif` or a non-empty `q` return `400`.

For help debugging/inspecting, JavaScript `console` messages are written to: `dc-api-v2/dc-api.log`

### DC
Expand Down
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.10.10",
"version": "2.11.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.10.10",
"version": "2.11.0",
"description": "NUL Digital Collections API Build Environment",
"repository": "https://github.com/nulib/dc-api-v2",
"author": "nulib",
Expand Down
6 changes: 6 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,11 @@ async function getWorkFileSets(workId, opts = {}) {
if (role) {
mustClauses.push({ term: { role: role } });
}
if (annotationsQuery) {
mustClauses.push({
match_phrase: { "annotations.content": annotationsQuery },
});
}

const searchBody = {
size: 10000,
Expand Down
35 changes: 30 additions & 5 deletions api/src/api/pagination.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ const {
} = require("lz-string");
const { defaultSearchSize } = require("../environment");

const encodeFields = ["query", "size", "sort", "fields", "_source"];
const encodeFields = [
"query",
"size",
"sort",
"fields",
"collapse",
"aggs",
"_source",
];

async function decodeSearchToken(token) {
return JSON.parse(await decompress(token));
Expand All @@ -17,6 +25,7 @@ async function encodeSearchToken(models, body, format, options) {
token.body[field] = body[field];
}
}
if (token.body.aggs?._pagination) delete token.body.aggs._pagination;
return await compress(JSON.stringify(token));
}

Expand Down Expand Up @@ -55,6 +64,13 @@ class Paginator {
this.options = options;
}

async pageResponseInfo(responseBody, opts = {}) {
return this.pageInfo(responseBody.hits.total.value, {
aggregatedCount: responseBody.hits.collapsed?.value,
...opts,
});
}

async pageInfo(count, opts = {}) {
let url = new URL(this.route, this.baseUrl);
let searchToken;
Expand All @@ -74,15 +90,18 @@ class Paginator {
}

const queryStringParameters =
this.options?.parameterOverrides || this.options?.queryStringParameters;
this.options?.parameterOverrides ||
this.options?.queryStringParameters ||
{};
if (typeof queryStringParameters === "object") {
for (const param in queryStringParameters) {
url.searchParams.set(param, queryStringParameters[param]);
}
}

const prev = prevPage(this.body, count);
const next = nextPage(this.body, count);
const aggregatedCount = opts?.aggregatedCount || count;
const prev = prevPage(this.body, aggregatedCount);
const next = nextPage(this.body, aggregatedCount);
url.searchParams.delete("from");

let result = {
Expand All @@ -91,9 +110,15 @@ class Paginator {
limit: size(this.body),
offset: from(this.body),
total_hits: count,
total_pages: maxPage(this.body, count),
total_pages: maxPage(this.body, aggregatedCount),
format: this.format,
};
if (this.body.collapse) {
result.collapsed_by = {
field: this.body.collapse.field,
total_hits: aggregatedCount,
};
}
if (opts.includeOptions) {
result.options = this.options;
}
Expand Down
14 changes: 13 additions & 1 deletion api/src/api/request/pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,20 @@ module.exports = class RequestPipeline {
return this;
}

addCardinality() {
if (this.searchContext.collapse) {
this.searchContext.aggs ||= {};
this.searchContext.aggs.__pagination = {
cardinality: {
field: this.searchContext.collapse.field,
},
};
}
return this;
}

toJson() {
this.addNeuralModelId();
this.addNeuralModelId().addCardinality();
return JSON.stringify(sortJson(this.searchContext));
}
};
79 changes: 19 additions & 60 deletions api/src/api/response/iiif/annotations.js
Original file line number Diff line number Diff line change
@@ -1,69 +1,28 @@
const { dcApiEndpoint } = require("../../../environment");
const { getWorkFileSets } = require("../../opensearch");
const {
buildAnnotationTarget,
buildSearchAnnotationBody,
} = require("./search-helpers");

async function transform(response, options = {}) {
const body = JSON.parse(response.body);
const fileSet = body._source;
const annotations = fileSet?.annotations ?? [];

const workId = fileSet.work_id;
const fileSetId = body._id;
const fileSetIndex = await getFileSetIndex(workId, fileSetId, options);

const canvasId = `${dcApiEndpoint()}/works/${workId}?as=iiif/canvas/${fileSetIndex}`;
const annotationPageId = `${dcApiEndpoint()}/file-sets/${
fileSet.id
}/annotations?as=iiif`;

// Build annotation items - filter for transcriptions only
// We currently will only have one annotation and it's a transcription
const items = annotations
.filter((annotation) => annotation.type === "transcription")
.map((annotation, idx) => {
const annotationId = `${annotationPageId}/a${idx}`;
return {
id: annotationId,
type: "Annotation",
motivation: "commenting",
body: {
type: "TextualBody",
value: annotation.content,
format: "text/plain",
language: annotation.language || "en",
},
target: canvasId,
};
});

const annotationPage = {
"@context": "http://iiif.io/api/presentation/3/context.json",
id: annotationPageId,
type: "AnnotationPage",
items: items,
};
function transform(annotation, fileSet) {
const canvasId = `${dcApiEndpoint()}/file-sets/${fileSet.id}?as=iiif`;
const annotationId = `${dcApiEndpoint()}/annotations/${
annotation.id
}?as=iiif`;

return {
statusCode: 200,
headers: {
"content-type": "application/json",
},
body: JSON.stringify(annotationPage),
headers: { "content-type": "application/json" },
body: JSON.stringify({
"@context": "http://iiif.io/api/presentation/3/context.json",
id: annotationId,
type: "Annotation",
// We have hardcoded motivations here, but in the future we may want to make this more dynamic based on the annotation type
motivation: ["contentState", "commenting"],
body: buildSearchAnnotationBody(annotation),
target: buildAnnotationTarget(canvasId, fileSet.work_id),
}),
};
}

async function getFileSetIndex(workId, fileSetId, options) {
const fileSetsResponse = await getWorkFileSets(workId, {
allowPrivate: options.allowPrivate,
allowUnpublished: options.allowUnpublished,
role: "Access",
sortBy: "rank",
});

const fileSetBody = JSON.parse(fileSetsResponse.body);
const hits = fileSetBody?.hits?.hits || [];

const index = hits.findIndex((hit) => hit._source.id === fileSetId);

return index;
}
module.exports = { transform };
Loading
Loading