@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.
Files changed (104) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +206 -0
  3. package/cli/export.js +118 -0
  4. package/cli/import.js +111 -0
  5. package/cli/index.js +45 -0
  6. package/cli/migrate.js +131 -0
  7. package/cli/serve.js +29 -0
  8. package/cli/utils/fastifyClient.js +86 -0
  9. package/cli/utils/io.js +225 -0
  10. package/cli/utils/mongoClient.js +21 -0
  11. package/cli/utils/progressbar.js +86 -0
  12. package/cli/xywhToInt.js +99 -0
  13. package/config/.env.template +48 -0
  14. package/docker/Dockerfile +39 -0
  15. package/docker/README.md +5 -0
  16. package/docker/docker-compose.yaml +49 -0
  17. package/docker/docker.sh +43 -0
  18. package/docker/docker_aiiinotate_import.sh +62 -0
  19. package/docs/api.md +381 -0
  20. package/docs/cli.md +132 -0
  21. package/docs/dev_documentation/dev_architecture.md +88 -0
  22. package/docs/dev_documentation/dev_db.md +58 -0
  23. package/docs/dev_documentation/dev_iiif_compatibility.md +43 -0
  24. package/docs/dev_documentation/dev_notes_quirks_and_troubleshooting.md +143 -0
  25. package/docs/docker.md +98 -0
  26. package/docs/includes/report_benchmark_aiiinotate_2026-05-28-02:50:48_7steps.png +0 -0
  27. package/docs/scalability.md +34 -0
  28. package/docs/specifications/0_w3c_open_annotations.md +332 -0
  29. package/docs/specifications/1_w3c_web_annotations.md +577 -0
  30. package/docs/specifications/2_iiif_apis.md +428 -0
  31. package/docs/specifications/3_iiif_annotations.md +103 -0
  32. package/docs/specifications/4_search_api.md +135 -0
  33. package/docs/specifications/5_sas.md +119 -0
  34. package/docs/specifications/6_mirador.md +119 -0
  35. package/docs/specifications/7_aikon.md +137 -0
  36. package/docs/specifications/include/presentation_2.0.webp +0 -0
  37. package/docs/specifications/include/presentation_2.0_white.png +0 -0
  38. package/docs/specifications/include/presentation_3.0.png +0 -0
  39. package/docs/specifications/include/presentation_3.0_resize.png +0 -0
  40. package/eslint.config.js +30 -0
  41. package/migrations/baseConfig.js +57 -0
  42. package/migrations/manageIndex.js +55 -0
  43. package/migrations/migrate-mongo-config-main.js +8 -0
  44. package/migrations/migrate-mongo-config-test.js +8 -0
  45. package/migrations/migrationScripts/20250825185706-collections.js +48 -0
  46. package/migrations/migrationScripts/20250826194832-annotations2-schema.js +42 -0
  47. package/migrations/migrationScripts/20250904080710-annotations2-indexes.js +69 -0
  48. package/migrations/migrationScripts/20251002141951-manifests2-schema.js +43 -0
  49. package/migrations/migrationScripts/20251006212110-manifests2-indexes.js +35 -0
  50. package/migrations/migrationTemplate.js +25 -0
  51. package/package.json +82 -0
  52. package/scripts/get_version.py +12 -0
  53. package/scripts/run.sh +36 -0
  54. package/scripts/setup_mongodb.sh +70 -0
  55. package/scripts/setup_node.sh +15 -0
  56. package/scripts/update_version.py +30 -0
  57. package/scripts/utils.sh +65 -0
  58. package/src/app.js +116 -0
  59. package/src/constants.js +73 -0
  60. package/src/data/annotations/annotations2.js +681 -0
  61. package/src/data/annotations/annotations3.js +28 -0
  62. package/src/data/annotations/routes.js +335 -0
  63. package/src/data/annotations/routes.test.js +271 -0
  64. package/src/data/collectionAbstract.js +283 -0
  65. package/src/data/index.js +29 -0
  66. package/src/data/manifests/manifests2.js +378 -0
  67. package/src/data/manifests/manifests2.test.js +53 -0
  68. package/src/data/manifests/manifests3.js +23 -0
  69. package/src/data/manifests/routes.js +122 -0
  70. package/src/data/manifests/routes.test.js +70 -0
  71. package/src/data/routes.js +181 -0
  72. package/src/data/routes.test.js +166 -0
  73. package/src/db/index.js +50 -0
  74. package/src/fixtures/annotations.js +41 -0
  75. package/src/fixtures/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
  76. package/src/fixtures/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
  77. package/src/fixtures/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
  78. package/src/fixtures/data/annotations2Invalid.jsonld +39 -0
  79. package/src/fixtures/data/annotations2SvgValid.jsonld +81 -0
  80. package/src/fixtures/data/annotations2Valid.jsonld +39 -0
  81. package/src/fixtures/data/bnf_invalid_manifest.json +2806 -0
  82. package/src/fixtures/data/bnf_valid_manifest.json +2817 -0
  83. package/src/fixtures/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
  84. package/src/fixtures/generate.js +181 -0
  85. package/src/fixtures/index.js +69 -0
  86. package/src/fixtures/manifests.js +14 -0
  87. package/src/fixtures/utils.js +37 -0
  88. package/src/schemas/index.js +100 -0
  89. package/src/schemas/schemasBase.js +19 -0
  90. package/src/schemas/schemasPresentation2.js +410 -0
  91. package/src/schemas/schemasPresentation3.js +33 -0
  92. package/src/schemas/schemasResolver.js +71 -0
  93. package/src/schemas/schemasRoutes.js +318 -0
  94. package/src/server.js +25 -0
  95. package/src/types.js +97 -0
  96. package/src/utils/iiif2Utils.js +332 -0
  97. package/src/utils/iiif2Utils.test.js +146 -0
  98. package/src/utils/iiif3Utils.js +0 -0
  99. package/src/utils/iiifUtils.js +18 -0
  100. package/src/utils/logger.js +119 -0
  101. package/src/utils/routeUtils.js +137 -0
  102. package/src/utils/svg.js +417 -0
  103. package/src/utils/testUtils.js +289 -0
  104. 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;