@paulhectork/aiiinotate 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +206 -0
- package/cli/export.js +118 -0
- package/cli/import.js +111 -0
- package/cli/index.js +45 -0
- package/cli/migrate.js +131 -0
- package/cli/serve.js +29 -0
- package/cli/utils/fastifyClient.js +86 -0
- package/cli/utils/io.js +225 -0
- package/cli/utils/mongoClient.js +21 -0
- package/cli/utils/progressbar.js +86 -0
- package/cli/xywhToInt.js +99 -0
- package/config/.env.template +48 -0
- package/docker/Dockerfile +39 -0
- package/docker/README.md +5 -0
- package/docker/docker-compose.yaml +49 -0
- package/docker/docker.sh +43 -0
- package/docker/docker_aiiinotate_import.sh +62 -0
- package/docs/api.md +381 -0
- package/docs/cli.md +132 -0
- package/docs/dev_documentation/dev_architecture.md +88 -0
- package/docs/dev_documentation/dev_db.md +58 -0
- package/docs/dev_documentation/dev_iiif_compatibility.md +43 -0
- package/docs/dev_documentation/dev_notes_quirks_and_troubleshooting.md +143 -0
- package/docs/docker.md +98 -0
- package/docs/includes/report_benchmark_aiiinotate_2026-05-28-02:50:48_7steps.png +0 -0
- package/docs/scalability.md +34 -0
- package/docs/specifications/0_w3c_open_annotations.md +332 -0
- package/docs/specifications/1_w3c_web_annotations.md +577 -0
- package/docs/specifications/2_iiif_apis.md +428 -0
- package/docs/specifications/3_iiif_annotations.md +103 -0
- package/docs/specifications/4_search_api.md +135 -0
- package/docs/specifications/5_sas.md +119 -0
- package/docs/specifications/6_mirador.md +119 -0
- package/docs/specifications/7_aikon.md +137 -0
- package/docs/specifications/include/presentation_2.0.webp +0 -0
- package/docs/specifications/include/presentation_2.0_white.png +0 -0
- package/docs/specifications/include/presentation_3.0.png +0 -0
- package/docs/specifications/include/presentation_3.0_resize.png +0 -0
- package/eslint.config.js +30 -0
- package/migrations/baseConfig.js +57 -0
- package/migrations/manageIndex.js +55 -0
- package/migrations/migrate-mongo-config-main.js +8 -0
- package/migrations/migrate-mongo-config-test.js +8 -0
- package/migrations/migrationScripts/20250825185706-collections.js +48 -0
- package/migrations/migrationScripts/20250826194832-annotations2-schema.js +42 -0
- package/migrations/migrationScripts/20250904080710-annotations2-indexes.js +69 -0
- package/migrations/migrationScripts/20251002141951-manifests2-schema.js +43 -0
- package/migrations/migrationScripts/20251006212110-manifests2-indexes.js +35 -0
- package/migrations/migrationTemplate.js +25 -0
- package/package.json +82 -0
- package/scripts/get_version.py +12 -0
- package/scripts/run.sh +36 -0
- package/scripts/setup_mongodb.sh +70 -0
- package/scripts/setup_node.sh +15 -0
- package/scripts/update_version.py +30 -0
- package/scripts/utils.sh +65 -0
- package/src/app.js +116 -0
- package/src/constants.js +73 -0
- package/src/data/annotations/annotations2.js +681 -0
- package/src/data/annotations/annotations3.js +28 -0
- package/src/data/annotations/routes.js +335 -0
- package/src/data/annotations/routes.test.js +271 -0
- package/src/data/collectionAbstract.js +283 -0
- package/src/data/index.js +29 -0
- package/src/data/manifests/manifests2.js +378 -0
- package/src/data/manifests/manifests2.test.js +53 -0
- package/src/data/manifests/manifests3.js +23 -0
- package/src/data/manifests/routes.js +122 -0
- package/src/data/manifests/routes.test.js +70 -0
- package/src/data/routes.js +181 -0
- package/src/data/routes.test.js +166 -0
- package/src/db/index.js +50 -0
- package/src/fixtures/annotations.js +41 -0
- package/src/fixtures/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
- package/src/fixtures/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
- package/src/fixtures/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
- package/src/fixtures/data/annotations2Invalid.jsonld +39 -0
- package/src/fixtures/data/annotations2SvgValid.jsonld +81 -0
- package/src/fixtures/data/annotations2Valid.jsonld +39 -0
- package/src/fixtures/data/bnf_invalid_manifest.json +2806 -0
- package/src/fixtures/data/bnf_valid_manifest.json +2817 -0
- package/src/fixtures/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
- package/src/fixtures/generate.js +181 -0
- package/src/fixtures/index.js +69 -0
- package/src/fixtures/manifests.js +14 -0
- package/src/fixtures/utils.js +37 -0
- package/src/schemas/index.js +100 -0
- package/src/schemas/schemasBase.js +19 -0
- package/src/schemas/schemasPresentation2.js +410 -0
- package/src/schemas/schemasPresentation3.js +33 -0
- package/src/schemas/schemasResolver.js +71 -0
- package/src/schemas/schemasRoutes.js +318 -0
- package/src/server.js +25 -0
- package/src/types.js +97 -0
- package/src/utils/iiif2Utils.js +332 -0
- package/src/utils/iiif2Utils.test.js +146 -0
- package/src/utils/iiif3Utils.js +0 -0
- package/src/utils/iiifUtils.js +18 -0
- package/src/utils/logger.js +119 -0
- package/src/utils/routeUtils.js +137 -0
- package/src/utils/svg.js +417 -0
- package/src/utils/testUtils.js +289 -0
- package/src/utils/utils.js +403 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { v4 as uuid4 } from "uuid";
|
|
2
|
+
|
|
3
|
+
import { maybeToArray, getHash, isNullish, isObject, xywhToInt, objectHasKey, visibleLog } from "#utils/utils.js";
|
|
4
|
+
import { IIIF_PRESENTATION_2, IIIF_PRESENTATION_2_CONTEXT } from "#utils/iiifUtils.js";
|
|
5
|
+
import { svgStringToXywh } from "#utils/svg.js";
|
|
6
|
+
import { PUBLIC_URL } from "#constants";
|
|
7
|
+
import logger from "#utils/logger.js";
|
|
8
|
+
|
|
9
|
+
/** @typedef {import("#types").MongoCollectionType} MongoCollectionType */
|
|
10
|
+
|
|
11
|
+
// IIIF PRESENTATION 2.1 RECOMMENDED URI PATTERNS https://iiif.io/api/presentation/2.1/#a-summary-of-recommended-uri-patterns
|
|
12
|
+
//
|
|
13
|
+
// Collection {scheme}://{host}/{prefix}/collection/{name}
|
|
14
|
+
// Manifest {scheme}://{host}/{prefix}/{identifier}/manifest
|
|
15
|
+
// Sequence {scheme}://{host}/{prefix}/{identifier}/sequence/{name}
|
|
16
|
+
// Canvas {scheme}://{host}/{prefix}/{identifier}/canvas/{name}
|
|
17
|
+
// Annotation (incl images) {scheme}://{host}/{prefix}/{identifier}/annotation/{name}
|
|
18
|
+
// AnnotationList {scheme}://{host}/{prefix}/{identifier}/list/{name}
|
|
19
|
+
// Range {scheme}://{host}/{prefix}/{identifier}/range/{name}
|
|
20
|
+
// Layer {scheme}://{host}/{prefix}/{identifier}/layer/{name}
|
|
21
|
+
// Content {scheme}://{host}/{prefix}/{identifier}/res/{name}.{format}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* extract a manifest's short ID from a IIIF URI.
|
|
25
|
+
* NOTE if the `iiifUri` doesn' follow IIIF 2.x recommendations, the quality of geneated IDs is really degraded : 2 canvases URI from the same manifest will generate a different hash.
|
|
26
|
+
* inspired by : https://github.com/glenrobson/SimpleAnnotationServer/blob/dc7c8c6de9f4693c678643db2a996a49eebfcbb0/src/main/java/uk/org/llgc/annotation/store/data/Manifest.java#L123C16-L123C26
|
|
27
|
+
* @param {string} iiifUri
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
const getManifestShortId = (iiifUri) => {
|
|
31
|
+
const keywords = [ "manifest", "manifest.json", "sequence", "canvas", "annotation", "list", "range", "layer", "res" ]
|
|
32
|
+
let manifestShortId;
|
|
33
|
+
|
|
34
|
+
const iiifUriArr = iiifUri.split("/");
|
|
35
|
+
|
|
36
|
+
// if it follows the IIIF recommended URI patterns
|
|
37
|
+
for (let i=0; i < keywords.length; i++) {
|
|
38
|
+
if (iiifUriArr.includes(keywords[i])) {
|
|
39
|
+
manifestShortId = iiifUriArr.at(iiifUriArr.indexOf(keywords[i]) - 1);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// fallback if no manifestShortId was found: return a string representation of the URI's hash.
|
|
44
|
+
manifestShortId = manifestShortId || getHash(iiifUri);
|
|
45
|
+
return manifestShortId;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* extract the ID of a canvas from a canvas' URI.
|
|
50
|
+
* NOTE this only really works if `canvasUri` follows IIIF URI patterns.
|
|
51
|
+
* @param {string} canvasUri
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
const getCanvasShortId = (canvasUri) =>
|
|
55
|
+
// IIIF compliant
|
|
56
|
+
canvasUri.includes("/canvas/")
|
|
57
|
+
? canvasUri.split("/").at(-1).replace(".json", "")
|
|
58
|
+
: getHash(canvasUri);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* get the `on` of an annotation.
|
|
62
|
+
* reimplemented from SAS: https://github.com/glenrobson/SimpleAnnotationServer/blob/dc7c8c6de9f4693c678643db2a996a49eebfcbb0/src/main/java/uk/org/llgc/annotation/store/AnnotationUtils.java#L147
|
|
63
|
+
* @param {object} annotation
|
|
64
|
+
* @returns {string[]}
|
|
65
|
+
*/
|
|
66
|
+
const getAnnotationTarget = (annotation) => {
|
|
67
|
+
/** annotation.on is converted to an array of targets => extract the target for each item of the array */
|
|
68
|
+
const getSingleAnnotationTarget = (_target) => {
|
|
69
|
+
let _targetOut;
|
|
70
|
+
|
|
71
|
+
if (typeof(_target) === "string") {
|
|
72
|
+
// remove the fragment if necesary to get the full Canvas Id
|
|
73
|
+
const hashIdx = _target.indexOf("#");
|
|
74
|
+
_targetOut = hashIdx === -1
|
|
75
|
+
? _target
|
|
76
|
+
: _target.substring(0, hashIdx);
|
|
77
|
+
|
|
78
|
+
} else {
|
|
79
|
+
// it's a SpecificResource => get the full image's id.
|
|
80
|
+
_targetOut = _target["full"];
|
|
81
|
+
}
|
|
82
|
+
if (isNullish(_targetOut)) {
|
|
83
|
+
throw new Error(`${getAnnotationTarget.name}: 'annotation.on' is not a valid IIIF 2.1 annotation target (with annotation=${_target})`)
|
|
84
|
+
}
|
|
85
|
+
return _targetOut;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return maybeToArray(annotation.on).map(getSingleAnnotationTarget);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @example "127.0.0.1:3000/data/2/wit9_man11_anno165/annotation/c26_abda6e3c-2926-4495-9787-cb3f3588e47c"
|
|
93
|
+
* @param {string} manifestShortId
|
|
94
|
+
* @param {string} canvasId
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
const toAiiinotateAnnotationUri = (manifestShortId, canvasId) =>
|
|
98
|
+
`${PUBLIC_URL}/data/${IIIF_PRESENTATION_2}/${manifestShortId}/annotation/${canvasId}_${uuid4()}`;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @example "127.0.0.1:3000/data/2/wit9_man11_anno165/manifest.json"
|
|
102
|
+
* @param {string} manifestShortId
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
const toAiiinotateManifestUri = (manifestShortId) =>
|
|
106
|
+
`${PUBLIC_URL}/data/${IIIF_PRESENTATION_2}/${manifestShortId}/manifest.json`;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* if `canvasUri` follows the recommended IIIF 2.1 recommended URI pattern, convert it to a JSON manifest URI.
|
|
110
|
+
* @param {string} canvasUri
|
|
111
|
+
* @returns {string} : the manifest URI
|
|
112
|
+
*/
|
|
113
|
+
const canvasUriToManifestUri = (canvasUri) =>
|
|
114
|
+
canvasUri.split("/").slice(0,-2).join("/") + "/manifest.json";
|
|
115
|
+
|
|
116
|
+
/** @returns {number[]?} */
|
|
117
|
+
const fragmentSelectorToXywh = (fragmentSelector) => {
|
|
118
|
+
// fragmentSelector is of format: `$rootUrl#xywh=number,number,number,number`
|
|
119
|
+
// => get the `number,number,number,number` part
|
|
120
|
+
const selectorValue = (fragmentSelector.value || "").split("xywh=");
|
|
121
|
+
const xywhString = selectorValue.length > 1 ? selectorValue[1] : ""
|
|
122
|
+
if (xywhString.length) {
|
|
123
|
+
return xywhString.split(",").map((x) => parseInt(x));
|
|
124
|
+
}
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @returns {Promise<number[]?>} */
|
|
129
|
+
const svgSelectorToXywh = (svgSelector) => {
|
|
130
|
+
try {
|
|
131
|
+
return svgStringToXywh(svgSelector.value);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger.warn(`could not build bounding box from SvgSelector because of error: ${err.message}`);
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @returns {Promise<number[]?>} */
|
|
139
|
+
const choiceSelectorToXywh = async (choiceSelector) => {
|
|
140
|
+
let xywh;
|
|
141
|
+
for (const selector of [ choiceSelector.item, choiceSelector.default ]) {
|
|
142
|
+
xywh = await selectorToXywh(selector);
|
|
143
|
+
if (xywh?.length) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return xywh;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** @returns {Promise<number[]?>} */
|
|
151
|
+
const selectorToXywh = async (selector) => {
|
|
152
|
+
const mapper = [
|
|
153
|
+
[ "oa:Choice", choiceSelectorToXywh ],
|
|
154
|
+
[ "oa:SvgSelector", svgSelectorToXywh ],
|
|
155
|
+
[ "oa:FragmentSelector", fragmentSelectorToXywh ],
|
|
156
|
+
[ "Choice", choiceSelectorToXywh ],
|
|
157
|
+
[ "SvgSelector", svgSelectorToXywh ],
|
|
158
|
+
[ "FragmentSelector", fragmentSelectorToXywh ],
|
|
159
|
+
]
|
|
160
|
+
let xywh;
|
|
161
|
+
for (const [ selectorName, func ] of mapper) {
|
|
162
|
+
if (selector["@type"] === selectorName) {
|
|
163
|
+
xywh = await func(selector);
|
|
164
|
+
if (Array.isArray(xywh) && xywh?.length) {
|
|
165
|
+
// ensure `xywh` contains only floats + round to upper integer.
|
|
166
|
+
xywh = xywhToInt(xywh);
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return xywh
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// NOTE: is this really useful ?
|
|
175
|
+
const normalizeSelectorType = (selector) => {
|
|
176
|
+
// the received specificResource `selector` may have its type specified using the key `type`. correct it to `@type`.
|
|
177
|
+
if (
|
|
178
|
+
isObject(selector) && Object.keys(selector).includes("type")
|
|
179
|
+
) {
|
|
180
|
+
selector["@type"] = selector.type;
|
|
181
|
+
delete selector.type;
|
|
182
|
+
}
|
|
183
|
+
return selector;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const stringToSpecificResource = (target) => {
|
|
187
|
+
let [ full, fragment ] = target.split("#");
|
|
188
|
+
return {
|
|
189
|
+
"@id": target,
|
|
190
|
+
"@type": "oa:SpecificResource",
|
|
191
|
+
full: full,
|
|
192
|
+
selector: {
|
|
193
|
+
"@type": "oa:FragmentSelector",
|
|
194
|
+
value: fragment
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* handle a single value of `annotation.on` (when annotation.on is an array).
|
|
201
|
+
* essentially, this function ensures the `target` is a `SpecificResource` and extracts useful fields.
|
|
202
|
+
* @returns {Promise<object>}
|
|
203
|
+
*/
|
|
204
|
+
const makeSingleTarget = async (target) => {
|
|
205
|
+
const err = new Error(`${makeSingleTarget.name}: could not make target for annotation: 'annotation.on' must be an URI, a SpecificResource or an array of SpecificResources. The key 'SpecificResource.full' MUST be defined.`, { info: target });
|
|
206
|
+
|
|
207
|
+
let specificResource;
|
|
208
|
+
|
|
209
|
+
// 1. convert to SpecificResource
|
|
210
|
+
if (typeof(target) === "string" && !isNullish(target)) {
|
|
211
|
+
specificResource = stringToSpecificResource(target);
|
|
212
|
+
} else if (isObject(target) && target["@type"] === "oa:SpecificResource" && !isNullish(target["full"])) {
|
|
213
|
+
specificResource = target;
|
|
214
|
+
specificResource.selector = normalizeSelectorType(specificResource.selector);
|
|
215
|
+
|
|
216
|
+
// if target is neither a string nor a SpecificResource, raise
|
|
217
|
+
} else {
|
|
218
|
+
throw err
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 2. extract relevant fields
|
|
222
|
+
// extract xywh coordinates and return them as [x:int, y:int, w:int, h:int]
|
|
223
|
+
// NOTE: xywh extraxction is only supported for FragmentSelector and SvgSelector (or an oa:Choice containing either).
|
|
224
|
+
specificResource.xywh = await selectorToXywh(specificResource.selector);
|
|
225
|
+
specificResource.manifestUri = canvasUriToManifestUri(specificResource.full);
|
|
226
|
+
specificResource.manifestShortId = getManifestShortId(specificResource.full);
|
|
227
|
+
|
|
228
|
+
return specificResource
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* convert the annotation's `on` to a SpecificResource
|
|
233
|
+
* reimplemented from SAS: https://github.com/glenrobson/SimpleAnnotationServer/blob/dc7c8c6de9f4693c678643db2a996a49eebfcbb0/src/main/java/uk/org/llgc/annotation/store/AnnotationUtils.java#L123-L135
|
|
234
|
+
* @param {object} annotation
|
|
235
|
+
* @returns {Promise<object[]>} - the array of targets extracted from 'annotation.on'
|
|
236
|
+
*/
|
|
237
|
+
const makeTarget = async (annotation) => {
|
|
238
|
+
return await Promise.all(
|
|
239
|
+
maybeToArray(annotation.on).map(async (target) => await makeSingleTarget(target))
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* generate the annotation's ID from its `@id` key (if defined)
|
|
245
|
+
* reimplementated from SAS: https://github.com/glenrobson/SimpleAnnotationServer/blob/dc7c8c6de9f4693c678643db2a996a49eebfcbb0/src/main/java/uk/org/llgc/annotation/store/AnnotationUtils.java#L90-L97
|
|
246
|
+
* NOTE this should never fail, but results will only be reliable if the `annotation.on` follows the IIIF 2.1 canvas URI scheme
|
|
247
|
+
*/
|
|
248
|
+
const makeAnnotationId = (annotation, manifestShortId) => {
|
|
249
|
+
// we consider that all targets point to the same canvas and manifest => extract canvas and manifest info from the 1st target.
|
|
250
|
+
const targetArray = getAnnotationTarget(annotation);
|
|
251
|
+
if (targetArray.length < 1) {
|
|
252
|
+
throw new Error(`${makeAnnotationId.name}: could not extract target from annotation`)
|
|
253
|
+
}
|
|
254
|
+
const
|
|
255
|
+
firstTarget = targetArray[0],
|
|
256
|
+
canvasId = getCanvasShortId(firstTarget);
|
|
257
|
+
// if manifestShortId hasn't aldready been extracted, re-extract it
|
|
258
|
+
manifestShortId = manifestShortId || getManifestShortId(firstTarget);
|
|
259
|
+
|
|
260
|
+
if (isNullish(manifestShortId) || isNullish(canvasId)) {
|
|
261
|
+
throw new Error(`${makeAnnotationId.name}: could not make an 'annotationId' (with manifestShortId=${manifestShortId}, annotation=${annotation})`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return toAiiinotateAnnotationUri(manifestShortId, canvasId);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* NOTE: for pagination to work, we assume some things about the anatomy of the route that uses `toAnnotationList`:
|
|
269
|
+
* - `annotationListId` must be the URL used to fetch annotations and generate the annotationList
|
|
270
|
+
* - the URL must have a `page` query parameter that handles pagination
|
|
271
|
+
*
|
|
272
|
+
* params:
|
|
273
|
+
* - resources: the annotatons
|
|
274
|
+
* - annotationListId: the AnnotationList's '@id' key
|
|
275
|
+
* - label: optional description
|
|
276
|
+
* - hasNext: there is a next page (for paginated results)
|
|
277
|
+
* - page: page number (for paginated results)
|
|
278
|
+
*
|
|
279
|
+
* @param {{
|
|
280
|
+
* resources: object[],
|
|
281
|
+
* annotationListId: string,
|
|
282
|
+
* page: number,
|
|
283
|
+
* hasNext: boolean,
|
|
284
|
+
* label: string?
|
|
285
|
+
* }}
|
|
286
|
+
* @returns {object} - the annotationList
|
|
287
|
+
*/
|
|
288
|
+
const toAnnotationList = ({
|
|
289
|
+
resources,
|
|
290
|
+
annotationListId,
|
|
291
|
+
page,
|
|
292
|
+
hasNext,
|
|
293
|
+
label
|
|
294
|
+
}) => {
|
|
295
|
+
if (isNullish(annotationListId)) {
|
|
296
|
+
throw new Error("toAnnotationList: 'annotationListId' must be defined");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const annotationList = {
|
|
300
|
+
...IIIF_PRESENTATION_2_CONTEXT,
|
|
301
|
+
"@type": "sc:AnnotationList",
|
|
302
|
+
"@id": annotationListId || "", // NOTE: MUST be defined according to IIIF presentation API (but not always defined in SAS)
|
|
303
|
+
resources: resources
|
|
304
|
+
}
|
|
305
|
+
if (label) {
|
|
306
|
+
annotationList.label = label
|
|
307
|
+
}
|
|
308
|
+
if (page) {
|
|
309
|
+
const annotationListUrl = new URL(annotationListId);
|
|
310
|
+
if (page > 1) {
|
|
311
|
+
annotationListUrl.searchParams.set("page", page-1);
|
|
312
|
+
annotationList.prev = annotationListUrl.href;
|
|
313
|
+
}
|
|
314
|
+
if (hasNext) {
|
|
315
|
+
annotationListUrl.searchParams.set("page", page+1);
|
|
316
|
+
annotationList.next = annotationListUrl.href;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return annotationList;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export {
|
|
323
|
+
makeTarget,
|
|
324
|
+
makeAnnotationId,
|
|
325
|
+
toAiiinotateAnnotationUri,
|
|
326
|
+
toAiiinotateManifestUri,
|
|
327
|
+
toAnnotationList,
|
|
328
|
+
getManifestShortId,
|
|
329
|
+
getCanvasShortId,
|
|
330
|
+
getAnnotationTarget,
|
|
331
|
+
canvasUriToManifestUri,
|
|
332
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
|
|
3
|
+
import { v4 as uuid4 } from "uuid";
|
|
4
|
+
|
|
5
|
+
import build from "#src/app.js";
|
|
6
|
+
import { getManifestShortId, getCanvasShortId, getAnnotationTarget, makeTarget, makeAnnotationId, toAnnotationList } from "#utils/iiif2Utils.js";
|
|
7
|
+
import { objectHasKey, visibleLog } from "#utils/utils.js";
|
|
8
|
+
import { PUBLIC_URL } from "#constants";
|
|
9
|
+
|
|
10
|
+
// hash-validating regex
|
|
11
|
+
const hashRgx = /^\d+$/;
|
|
12
|
+
|
|
13
|
+
test("test 'iiif2Utils' functions", async (t) => {
|
|
14
|
+
const
|
|
15
|
+
fastify = await build("test"),
|
|
16
|
+
{ annotations2Valid, annotations2Invalid } = fastify.fixtures;
|
|
17
|
+
|
|
18
|
+
await fastify.ready();
|
|
19
|
+
|
|
20
|
+
t.after(() => fastify.close());
|
|
21
|
+
|
|
22
|
+
t.test("test 'getManifestShortId'", (t) => {
|
|
23
|
+
const
|
|
24
|
+
s1 = `manifest_${uuid4()}`,
|
|
25
|
+
s2 = `extra_${uuid4()}`,
|
|
26
|
+
// urls for which the function will manage to extract `s1`
|
|
27
|
+
urlOk = [
|
|
28
|
+
`http://www.example.com/examplePrefix/${s1}/manifest`,
|
|
29
|
+
`http://www.example.com/examplePrefix/${s1}/manifest.json`,
|
|
30
|
+
`http://www.example.com/examplePrefix/${s1}/sequence/${s2}`,
|
|
31
|
+
`http://www.example.com/examplePrefix/${s1}/canvas/${s2}`,
|
|
32
|
+
`http://www.example.com/examplePrefix/${s1}/annotation/${s2}`,
|
|
33
|
+
`http://www.example.com/examplePrefix/${s1}/list/${s2}`,
|
|
34
|
+
`http://www.example.com/examplePrefix/${s1}/range/${s2}`,
|
|
35
|
+
`http://www.example.com/examplePrefix/${s1}/layer/${s2}`,
|
|
36
|
+
`http://www.example.com/examplePrefix/${s1}/res/${s2}.png`,
|
|
37
|
+
],
|
|
38
|
+
// urls for which `s1` won't be returned, and instead a hash will be returned
|
|
39
|
+
urlHash = [
|
|
40
|
+
`http://example.com/example/collection/${s2}`,
|
|
41
|
+
`http://example.com/${s1}`
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
urlOk.map((url) =>
|
|
45
|
+
t.assert.strictEqual(getManifestShortId(url), s1));
|
|
46
|
+
urlHash.map((url) =>
|
|
47
|
+
t.assert.strictEqual(hashRgx.test(getManifestShortId(url)), true));
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
t.test("test 'getCanvasShortId'", (t) => {
|
|
51
|
+
const
|
|
52
|
+
s1 = `manifest_${uuid4()}`,
|
|
53
|
+
s2 = `canvas_${uuid4()}`,
|
|
54
|
+
urlOk = [
|
|
55
|
+
`http://www.example.com/examplePrefix/${s1}/canvas/${s2}`,
|
|
56
|
+
`http://www.example.com/examplePrefix/${s1}/canvas/${s2}.json`,
|
|
57
|
+
|
|
58
|
+
],
|
|
59
|
+
urlHash = [
|
|
60
|
+
`http://example.com/example/${s2}`,
|
|
61
|
+
`http://example.com/${s2}`
|
|
62
|
+
]
|
|
63
|
+
urlOk.map((url) =>
|
|
64
|
+
t.assert.strictEqual(getCanvasShortId(url), s2));
|
|
65
|
+
urlHash.map((url) =>
|
|
66
|
+
t.assert.strictEqual(hashRgx.test(getCanvasShortId(url)), true));
|
|
67
|
+
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
await t.test("test 'makeTarget'", async (t) => {
|
|
71
|
+
await Promise.all(
|
|
72
|
+
annotations2Valid.map(async (annotation) =>
|
|
73
|
+
// `rejects` / `doesNotReject` works for aync functions,
|
|
74
|
+
// contrary to `throws` / `doesNotThrow`
|
|
75
|
+
// https://stackoverflow.com/a/35782749
|
|
76
|
+
t.assert.doesNotReject(async () => await makeTarget(annotation))
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
await Promise.all(
|
|
80
|
+
annotations2Invalid.map(async (annotation) =>
|
|
81
|
+
t.assert.rejects(async () => await makeTarget(annotation))
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
await t.test("test 'getAnnotationTarget'", (t) => {
|
|
87
|
+
annotations2Valid.map((annotation) =>
|
|
88
|
+
t.assert.doesNotThrow(() => getAnnotationTarget(annotation))
|
|
89
|
+
);
|
|
90
|
+
annotations2Invalid.map((annotation) =>
|
|
91
|
+
t.assert.throws(() => getAnnotationTarget(annotation), Error)
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await t.test("test 'makeAnnotationId'", (t) => {
|
|
96
|
+
// https://stackoverflow.com/a/6969486
|
|
97
|
+
const escapeRegExp = (string) =>
|
|
98
|
+
string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
|
|
99
|
+
|
|
100
|
+
const rgx = new RegExp(`^${escapeRegExp(PUBLIC_URL)}/data/2/[^(\\s|/)]+/annotation/[^\\.]+$`);
|
|
101
|
+
annotations2Valid.map((annotation) =>
|
|
102
|
+
t.assert.strictEqual(rgx.test(makeAnnotationId(annotation)), true));
|
|
103
|
+
annotations2Invalid.map((annotation) =>
|
|
104
|
+
t.assert.throws(() => makeAnnotationId(annotation)));
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await t.test("test 'toAnnotationList'", (t) => {
|
|
108
|
+
/**
|
|
109
|
+
* @param {"prev"|"next"} val
|
|
110
|
+
* @returns {(_annotationList: object) => string?} */
|
|
111
|
+
const getPage = (val) => {
|
|
112
|
+
if (![ "prev", "next" ].includes(val)) {
|
|
113
|
+
throw new Error(`'getPage': invalid value for 'val': '${val}'`);
|
|
114
|
+
}
|
|
115
|
+
return (_annotationList) => {
|
|
116
|
+
if (objectHasKey(annotationList, val)) {
|
|
117
|
+
return new URL(annotationList[val]).searchParams.get("page")
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const getPagePrev = getPage("prev");
|
|
123
|
+
const getPageNext = getPage("next");
|
|
124
|
+
|
|
125
|
+
const testUrl = new URL(`http://www.example.com/prefix/manifest-${uuid4()}/list/anno-list-${uuid4()}`);
|
|
126
|
+
const data = [
|
|
127
|
+
{ page: 3, hasNext: true, prevNum: "2", nextNum: "4" },
|
|
128
|
+
{ page: 1, hasNext: true, prevNum: undefined, nextNum: "2" },
|
|
129
|
+
{ page: 1, hasNext: false, prevNum: undefined, nextNum: undefined },
|
|
130
|
+
];
|
|
131
|
+
let annotationList;
|
|
132
|
+
|
|
133
|
+
for (const { page, hasNext, prevNum, nextNum } of data) {
|
|
134
|
+
annotationList = toAnnotationList({
|
|
135
|
+
resources: [],
|
|
136
|
+
annotationListId: testUrl.href,
|
|
137
|
+
page: page,
|
|
138
|
+
hasNext: hasNext,
|
|
139
|
+
});
|
|
140
|
+
t.assert.deepStrictEqual(getPagePrev(annotationList)==prevNum, true);
|
|
141
|
+
t.assert.deepStrictEqual(getPageNext(annotationList)==nextNum, true);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
})
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { getHash } from "#utils/utils.js";
|
|
2
|
+
|
|
3
|
+
const IIIF_PRESENTATION_2 = 2;
|
|
4
|
+
const IIIF_PRESENTATION_3 = 3;
|
|
5
|
+
const IIIF_SEARCH_1 = 1;
|
|
6
|
+
const IIIF_SEARCH_2 = 2;
|
|
7
|
+
const IIIF_PRESENTATION_2_CONTEXT = { "@context": "http://iiif.io/api/presentation/2/context.json" };
|
|
8
|
+
const IIIF_PRESENTATION_3_CONTEXT = { "@context": "http://iiif.io/api/presentation/3/context.json" };
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
IIIF_PRESENTATION_2,
|
|
13
|
+
IIIF_PRESENTATION_3,
|
|
14
|
+
IIIF_SEARCH_1,
|
|
15
|
+
IIIF_SEARCH_2,
|
|
16
|
+
IIIF_PRESENTATION_2_CONTEXT,
|
|
17
|
+
IIIF_PRESENTATION_3_CONTEXT,
|
|
18
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
|
|
4
|
+
import pino from "pino";
|
|
5
|
+
|
|
6
|
+
import { LOG_TARGET, LOG_DIR, LOG_LEVEL } from "#constants";
|
|
7
|
+
|
|
8
|
+
// a LOG_DIR is defined and it doesn't exist
|
|
9
|
+
if (LOG_DIR?.length && !fs.existsSync(LOG_DIR)) {
|
|
10
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const stdoutLogTarget = {
|
|
14
|
+
level: "debug",
|
|
15
|
+
target: "pino-pretty",
|
|
16
|
+
options: {
|
|
17
|
+
colorize: true,
|
|
18
|
+
singleLine: false,
|
|
19
|
+
translateTime: "SYS:standard",
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
const fileLogTargets = [
|
|
23
|
+
{
|
|
24
|
+
level: "debug",
|
|
25
|
+
target: "pino/file",
|
|
26
|
+
options: { destination: path.join(LOG_DIR, "aiiinotate_debug.log") },
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
level: "error",
|
|
30
|
+
target: "pino/file",
|
|
31
|
+
options: { destination: path.join(LOG_DIR, "aiiinotate_error.log") },
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const makePinoLogger = () => {
|
|
36
|
+
// disable logging entierly
|
|
37
|
+
if (LOG_TARGET==="off") {
|
|
38
|
+
return pino({ enabled: false });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// otherwise, generate a specific logger based on the value of "LOG_TARGET"
|
|
42
|
+
const logTarget = {
|
|
43
|
+
stdout: [ stdoutLogTarget ],
|
|
44
|
+
file: [ ...fileLogTargets ],
|
|
45
|
+
"stdout+file": [ stdoutLogTarget, ...fileLogTargets ]
|
|
46
|
+
}[LOG_TARGET];
|
|
47
|
+
|
|
48
|
+
return pino({
|
|
49
|
+
level: LOG_LEVEL,
|
|
50
|
+
transport: {
|
|
51
|
+
targets: logTarget,
|
|
52
|
+
},
|
|
53
|
+
serializers: {
|
|
54
|
+
req: (req) => ({
|
|
55
|
+
id: req.id,
|
|
56
|
+
method: req.method,
|
|
57
|
+
url: req.url,
|
|
58
|
+
}),
|
|
59
|
+
res: (res) => ({
|
|
60
|
+
statusCode: res.statusCode,
|
|
61
|
+
}),
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Wrapper for caller information
|
|
67
|
+
class Logger {
|
|
68
|
+
constructor() {
|
|
69
|
+
this.pinoLogger = makePinoLogger();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_getCaller() {
|
|
73
|
+
const stack = new Error().stack.split("\n");
|
|
74
|
+
const callerLine = stack[3]; // Skip Error, _getCaller, and the actual method
|
|
75
|
+
const match = callerLine.match(/at (.+) \((.+):(\d+):(\d+)\)/);
|
|
76
|
+
|
|
77
|
+
if (match) {
|
|
78
|
+
return {
|
|
79
|
+
funcName: match[1],
|
|
80
|
+
module: path.basename(match[2], path.extname(match[2])),
|
|
81
|
+
lineno: parseInt(match[3]),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { funcName: "unknown", module: "unknown", lineno: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
debug(message, meta = {}) {
|
|
89
|
+
const caller = this._getCaller();
|
|
90
|
+
this.pinoLogger.debug({ ...caller, ...meta }, message);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
info(message, meta = {}) {
|
|
94
|
+
const caller = this._getCaller();
|
|
95
|
+
this.pinoLogger.info({ ...caller, ...meta }, message);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
warn(message, meta = {}) {
|
|
99
|
+
const caller = this._getCaller();
|
|
100
|
+
this.pinoLogger.warn({ ...caller, ...meta }, message);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
error(message, meta = {}) {
|
|
104
|
+
const caller = this._getCaller();
|
|
105
|
+
this.pinoLogger.error({ ...caller, ...meta }, message);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
child(bindings) {
|
|
109
|
+
return this.pinoLogger.child(bindings);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
setLevel(level) {
|
|
113
|
+
this.pinoLogger.level = level;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const logger = new Logger()
|
|
118
|
+
|
|
119
|
+
export default logger;
|