@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,28 @@
1
+ import fastifyPlugin from "fastify-plugin";
2
+
3
+ import CollectionAbstract from "#data/collectionAbstract.js";
4
+
5
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
6
+
7
+ /** @typedef {Annotations3} Annotations3InstanceType */
8
+
9
+ /**
10
+ * @extends {CollectionAbstract}
11
+ */
12
+ class Annotations3 extends CollectionAbstract {
13
+ /**
14
+ * @param {FastifyInstanceType} fastify
15
+ */
16
+ constructor(fastify) {
17
+ super(fastify, "annotations3");
18
+ }
19
+
20
+ }
21
+
22
+ export default fastifyPlugin((fastify, options, done) => {
23
+ fastify.decorate("annotations3", new Annotations3(fastify));
24
+ done();
25
+ }, {
26
+ name: "annotations3",
27
+ dependencies: [ "manifests3" ]
28
+ })
@@ -0,0 +1,335 @@
1
+ import fastifyPlugin from "fastify-plugin"
2
+
3
+ import { STRICT_MODE } from "#constants";
4
+ import { pathToAiiinotatePublicUrl, objectHasKey, maybeToArray, throwIfKeyUndefined, throwIfValueError, getFirstNonEmptyPair, visibleLog } from "#utils/utils.js";
5
+ import { makeResponseSchema, makeResponsePostSchema, returnError, addPagination } from "#utils/routeUtils.js";
6
+
7
+
8
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
9
+ /** @typedef {import("#types").Annotations2InstanceType} Annotations2InstanceType */
10
+ /** @typedef {import("#types").Annotations3InstanceType} Annotations3InstanceType */
11
+
12
+ /**
13
+ * validate an annotation, annotationPage or annotationList: that is, ensure it fits the IIIF presentation API
14
+ * @param {2|3} iiifPresentationVersion
15
+ * @param {object} annotationData
16
+ * @param {boolean} isListOrPage: it's an annotationList or annotationPage instead of a regular annotation.
17
+ */
18
+ const validateAnnotationVersion = (iiifPresentationVersion, annotationData, isListOrPage=false) => {
19
+ // object keys are always strings, so we need to convert to string (https://stackoverflow.com/questions/3633362)
20
+ iiifPresentationVersion = iiifPresentationVersion.toString();
21
+ const expectedTypeKeys = {
22
+ "2": "@type",
23
+ "3": "type"
24
+ };
25
+ throwIfKeyUndefined(expectedTypeKeys, iiifPresentationVersion);
26
+ const expectedTypeKey = expectedTypeKeys[iiifPresentationVersion];
27
+ throwIfKeyUndefined(annotationData, expectedTypeKey);
28
+ const expectedTypeVal = (
29
+ isListOrPage
30
+ ? { "2": "sc:AnnotationList", "3":"AnnotationPage" }
31
+ : { "2": "oa:Annotation", "3": "Annotation" }
32
+ )[iiifPresentationVersion];
33
+ throwIfValueError(annotationData, expectedTypeKey, expectedTypeVal);
34
+ }
35
+
36
+ /**
37
+ * `annotationArray` is an array of AnnotationLists or AnnotationPages, depending on the IIIF Presentaion API version.
38
+ * assert that it indeed the case, raise otherwise
39
+ * @param {2|3} iiifPresentationVersion
40
+ * @param {object[]} annotationArray
41
+ * @returns {void}
42
+ */
43
+ const validateAnnotationArrayVersion = (iiifPresentationVersion, annotationArray) =>
44
+ annotationArray.map(annotationData => validateAnnotationVersion(iiifPresentationVersion, annotationData, true));
45
+
46
+ /**
47
+ * @param {import("#types").InsertResponseArrayType} insertResponseArray
48
+ * @returns {import("#types").InsertResponseType}
49
+ */
50
+ const reduceInsertResponseArray = (insertResponseArray) => ({
51
+ insertedCount: insertResponseArray.reduce((acc, r) => acc+r.insertedCount, 0),
52
+ insertedIds: insertResponseArray.reduce((acc, r) => acc.concat(r.insertedIds), [])
53
+ })
54
+
55
+
56
+ /**
57
+ * Encapsulates the routes
58
+ * @param {FastifyInstanceType} fastify Encapsulated Fastify Instance
59
+ * @param {object} options plugin options, refer to https://fastify.dev/docs/latest/Reference/Plugins/#plugin-options
60
+ * @param {Function} done
61
+ */
62
+ function annotationsRoutes(fastify, options, done) {
63
+ const
64
+ /** @type {Annotations2InstanceType} */
65
+ annotations2 = fastify.annotations2,
66
+ /** @type {Annotations3InstanceType} */
67
+ annotations3 = fastify.annotations3,
68
+ routeAnnotation2Or3Schema = fastify.schemasRoutes.getSchema("routeAnnotation2Or3"),
69
+ routeAnnotationCreateManySchema = fastify.schemasRoutes.getSchema("routeAnnotationCreateMany"),
70
+ routeAnnotationFilterSchema = fastify.schemasRoutes.getSchema("routeAnnotationFilter"),
71
+ routeResponseCountSchema = fastify.schemasRoutes.getSchema("routeResponseCount"),
72
+ iiifPresentationVersionSchema = fastify.schemasBase.getSchema("presentation"),
73
+ iiifAnnotationListSchema = fastify.schemasPresentation2.getSchema("annotationList"),
74
+ iiifAnnotation2ArraySchema = fastify.schemasPresentation2.getSchema("annotationArray"),
75
+ iiifAnnotation2Schema = fastify.schemasPresentation2.getSchema("annotation"),
76
+ responsePostSchema = makeResponsePostSchema(fastify);
77
+
78
+ /////////////////////////////////////////////////////////
79
+ // get routes
80
+
81
+ /** get all annotations by a canvas URI */
82
+ fastify.get(
83
+ "/annotations/:iiifPresentationVersion/search",
84
+ {
85
+ schema: {
86
+ params: {
87
+ type: "object",
88
+ properties: {
89
+ iiifPresentationVersion: iiifPresentationVersionSchema
90
+ }
91
+ },
92
+ querystring: addPagination({
93
+ type: "object",
94
+ properties: {
95
+ canvasUri: { type: "string" },
96
+ }
97
+ }),
98
+ response: makeResponseSchema(
99
+ fastify,
100
+ {
101
+ oneOf: [
102
+ fastify.schemasResolver(iiifAnnotationListSchema),
103
+ fastify.schemasResolver(iiifAnnotation2ArraySchema)
104
+ ]
105
+ },
106
+ )
107
+ },
108
+ },
109
+ async (request, reply) => {
110
+ const
111
+ queryUrl = pathToAiiinotatePublicUrl(request.url),
112
+ { iiifPresentationVersion } = request.params,
113
+ { canvasUri, page, pageSize } = request.query;
114
+
115
+ try {
116
+ if (iiifPresentationVersion === 2) {
117
+ return await annotations2.findByCanvasUri({
118
+ queryUrl,
119
+ canvasUri,
120
+ page,
121
+ pageSize
122
+ });
123
+ } else {
124
+ annotations3.notImplementedError();
125
+ }
126
+ } catch (err) {
127
+ returnError(request, reply, err);
128
+ }
129
+ }
130
+ );
131
+
132
+ fastify.get(
133
+ "/annotations/:iiifPresentationVersion/count",
134
+ {
135
+ schema: {
136
+ params: {
137
+ type: "object",
138
+ properties: {
139
+ iiifPresentationVersion: iiifPresentationVersionSchema
140
+ }
141
+ },
142
+ querystring: routeAnnotationFilterSchema,
143
+ response: makeResponseSchema(
144
+ fastify, routeResponseCountSchema
145
+ )
146
+ }
147
+ },
148
+ async (request, reply) => {
149
+ const
150
+ { iiifPresentationVersion } = request.params,
151
+ [ filterKey, filterVal ] = getFirstNonEmptyPair(request.query);
152
+ try {
153
+ return iiifPresentationVersion === 2
154
+ ? await annotations2.count(filterKey, filterVal)
155
+ : annotations3.notImplementedError();
156
+ } catch (err) {
157
+ returnError(request, reply, err);
158
+ }
159
+ }
160
+ )
161
+
162
+ /** retrieve a single annotation by its "@id"|"id". this route defers an annotation */
163
+ fastify.get(
164
+ "/data/:iiifPresentationVersion/:manifestShortId/annotation/:annotationShortId",
165
+ {
166
+ schema: {
167
+ params: {
168
+ type: "object",
169
+ properties: {
170
+ iiifPresentationVersion: iiifPresentationVersionSchema,
171
+ manifestShortId: { type: "string" },
172
+ annotationShortId: { type: "string" },
173
+ }
174
+ },
175
+ response: makeResponseSchema(
176
+ fastify,
177
+ fastify.schemasResolver(iiifAnnotation2Schema)
178
+ )
179
+ }
180
+ },
181
+ async (request, reply) => {
182
+ const
183
+ annotationUri = pathToAiiinotatePublicUrl(request.url),
184
+ { iiifPresentationVersion } = request.params;
185
+ try {
186
+ return iiifPresentationVersion === 2
187
+ ? annotations2.findById(annotationUri)
188
+ : annotations3.notImplementedError();
189
+ } catch (err) {
190
+ returnError(request, reply, err);
191
+ }
192
+ }
193
+ )
194
+
195
+ /////////////////////////////////////////////////////////
196
+ // create/update routes
197
+
198
+ /** create or update a single annotation from an annotation object */
199
+ fastify.post(
200
+ "/annotations/:iiifPresentationVersion/:action",
201
+ {
202
+ schema: {
203
+ params: {
204
+ type: "object",
205
+ properties: {
206
+ iiifPresentationVersion: iiifPresentationVersionSchema,
207
+ action: { type: "string", enum: [ "create", "update" ] }
208
+ }
209
+ },
210
+ // NOTE: `throwOnCanvasIndexError` is only implemented if `action==="create"` (see preValidation)
211
+ querystring: {
212
+ type: "object",
213
+ properties: {
214
+ throwOnCanvasIndexError: { type: "boolean", default: STRICT_MODE },
215
+ throwOnXywhError: { type: "boolean", default: STRICT_MODE },
216
+ }
217
+ },
218
+ preValidation: async (request, reply) => {
219
+ const
220
+ { action } = request.params,
221
+ { throwOnCanvasIndexError } = request.querystring;
222
+ if (action==="update" && throwOnCanvasIndexError) {
223
+ returnError(request, reply, "'throwOnCanvasIndexError' is only allowed when ':action' is 'create'.");
224
+ }
225
+ return;
226
+ },
227
+ body: routeAnnotation2Or3Schema,
228
+ response: responsePostSchema
229
+ },
230
+ },
231
+ async (request, reply) => {
232
+ const
233
+ { iiifPresentationVersion, action } = request.params,
234
+ { throwOnCanvasIndexError, throwOnXywhError } = request.query,
235
+ annotation = request.body;
236
+
237
+ try {
238
+ validateAnnotationVersion(iiifPresentationVersion, annotation);
239
+ // insert or update
240
+ if (iiifPresentationVersion === 2) {
241
+ return action==="create"
242
+ ? await annotations2.insertAnnotation(annotation, throwOnCanvasIndexError, throwOnXywhError)
243
+ : await annotations2.updateAnnotation(annotation, throwOnCanvasIndexError, throwOnXywhError);
244
+ } else {
245
+ annotations3.notImplementedError();
246
+ }
247
+
248
+ } catch (err) {
249
+ returnError(request, reply, err, request.body);
250
+ }
251
+ }
252
+ )
253
+
254
+ /**
255
+ * create several annotations from:
256
+ * - an annotationList (if iiifPresentationVersion === 2)
257
+ * - an annotationPage (if iiifPresentationVersion === 3)
258
+ * - an URI to an annotationList or annotationPage
259
+ * - or an Array of any of the previous
260
+ *
261
+ * NOTE that POST body size is limited to 1MB, so your query might be rejected. body size is limited by:
262
+ * - fastify's `bodyLimit` (https://fastify.dev/docs/latest/Reference/Server/#bodylimit)
263
+ * - nginx's `client_max_body_size` (https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size)
264
+ * - any other server's max body size.
265
+ * both fastify and nginx limit body size defaults to to 1MB.
266
+ */
267
+ fastify.post(
268
+ "/annotations/:iiifPresentationVersion/createMany",
269
+ {
270
+ schema: {
271
+ params: {
272
+ type: "object",
273
+ properties: {
274
+ iiifPresentationVersion: iiifPresentationVersionSchema
275
+ }
276
+ },
277
+ querystring: {
278
+ type: "object",
279
+ properties: {
280
+ throwOnCanvasIndexError: { type: "boolean", default: STRICT_MODE },
281
+ throwOnXywhError: { type: "boolean", default: STRICT_MODE },
282
+ }
283
+ },
284
+ body: routeAnnotationCreateManySchema,
285
+ response: responsePostSchema
286
+ }
287
+ },
288
+ async (request, reply) => {
289
+ const
290
+ { iiifPresentationVersion } = request.params,
291
+ { throwOnCanvasIndexError, throwOnXywhError } = request.query,
292
+ body = maybeToArray(request.body), // convert to an array to have a homogeneous data structure
293
+ insertResponseArray = [];
294
+
295
+ // data to actually insert (body with resolved URIs, if the body is `annotationListOrPageUri` or `annotationListOrPageUriArray`)
296
+ let annotationsArray = [];
297
+
298
+ try {
299
+ // if we received `annotationListOrPageUri` or `annotationListOrPageUriArray`, fetch objects
300
+ const asUri = body.find(item => objectHasKey(item, "uri")) !== undefined;
301
+ if (asUri) {
302
+ annotationsArray = await Promise.all(
303
+ body.map(async (item) =>
304
+ fetch(item.uri).then(r => r.json()))
305
+ );
306
+ } else {
307
+ annotationsArray = body;
308
+ }
309
+
310
+ validateAnnotationArrayVersion(iiifPresentationVersion, annotationsArray);
311
+
312
+ // insert
313
+ if (iiifPresentationVersion === 2) {
314
+ await Promise.all(annotationsArray.map(
315
+ async (annotationList) => {
316
+ const r = await annotations2.insertAnnotationList(annotationList, throwOnCanvasIndexError, throwOnXywhError);
317
+ insertResponseArray.push(r);
318
+ }
319
+ ));
320
+ return reduceInsertResponseArray(insertResponseArray);
321
+ } else {
322
+ annotations3.notImplementedError();
323
+ }
324
+
325
+ } catch (err) {
326
+ returnError(request, reply, err, request.body);
327
+ }
328
+ }
329
+ )
330
+
331
+ done();
332
+
333
+ }
334
+
335
+ export default fastifyPlugin(annotationsRoutes);
@@ -0,0 +1,271 @@
1
+ import test from "node:test";
2
+
3
+ import build from "#src/app.js";
4
+
5
+ import { v4 as uuid4 } from "uuid";
6
+
7
+ import { inspectObj, isObject, getRandomItem, visibleLog } from "#utils/utils.js"
8
+ import { testPostRouteCurry, testDeleteRouteCurry, injectTestAnnotations, injectPost, injectGet } from "#utils/testUtils.js";
9
+ import { PUBLIC_URL, PORT, HOST } from "#constants";
10
+
11
+
12
+ /** @typedef {import("#types").NodeTestType} NodeTestType */
13
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
14
+ /** @typedef {import("#types").FastifyReplyType} FastifyReplyType */
15
+ /** @typedef {import("#types").DataOperationsType} DataOperationsType */
16
+
17
+ test("test annotation Routes", async (t) => {
18
+
19
+ const
20
+ fastify = await build("test"),
21
+ testPostRoute = testPostRouteCurry(fastify),
22
+ testPostRouteCreate = testPostRoute("insert"),
23
+ testPostRouteUpdate = testPostRoute("update"),
24
+ testDeleteRoute = testDeleteRouteCurry(fastify),
25
+ testPostRouteCreateSuccess = testPostRouteCreate(true),
26
+ testPostRouteCreateFailure = testPostRouteCreate(false),
27
+ testPostRouteUpdateSuccess = testPostRouteUpdate(true),
28
+ testPostRouteUpdateFailure = testPostRouteUpdate(false),
29
+ {
30
+ annotationListUri,
31
+ annotationListUriArray,
32
+ annotationList,
33
+ annotationListArray,
34
+ annotationListUriInvalid,
35
+ annotationListUriArrayInvalid
36
+ } = fastify.fixtures;
37
+
38
+ await fastify.ready();
39
+ // close the app after running the tests
40
+ t.after(async () => await fastify.close());
41
+ // after each subtest has run, delete all database records
42
+ t.afterEach(async() => fastify.emptyCollections());
43
+
44
+ // NOTE: it is necessary to run the app because internally there are fetches to external data.
45
+ try {
46
+ await fastify.listen({ port: PORT, host: HOST });
47
+ } catch (err) {
48
+ console.log("FASTIFY ERROR", err);
49
+ throw err;
50
+ }
51
+
52
+ await t.test("test route /annotations/:iiifPresentationVersion/createMany", async (t) => {
53
+ // truncate the contents of `annotationListArray` to avoid an `fst_err_ctp_body_too_large` error
54
+ // `https://fastify.dev/docs/latest/Reference/Errors/#fst_err_ctp_body_too_large`
55
+ const annotationListArrayLimit = annotationListArray.map(a => {
56
+ a.resources = a.resources.length > 500 ? a.resources.slice(0,500) : a.resources
57
+ return a;
58
+ });
59
+
60
+ // test basic inserts
61
+ //NOTE: we can't do Promise.all because it causes a data race that can cause a failure of unique constraints (i.e., on manifests '@id')
62
+ let data = [
63
+ [[ annotationListUri, annotationListUriArray, annotationList, annotationListArrayLimit ], testPostRouteCreateSuccess ],
64
+ [[ annotationListUriInvalid, annotationListUriArrayInvalid ], testPostRouteCreateFailure ]
65
+ ];
66
+ for (let i=0; i<data.length; i++) {
67
+ let [ testData, func ] = data.at(i);
68
+ for (let i=0; i<testData.length; i++) {
69
+ await func(t, "/annotations/2/createMany?throwOnXywhError=true", testData.at(i));
70
+ }
71
+ }
72
+
73
+ // test that `throwOnCanvasIndexError` throws errors when it's supposed to
74
+ const annotationListWithTargetErrors = structuredClone(annotationList);
75
+ // replace the annotation.on with an uuid => should trigger an error.https://www.cinefil.com/film/mulholland-drive
76
+ annotationListWithTargetErrors.resources =
77
+ annotationListWithTargetErrors
78
+ .resources
79
+ .map((annotation) => {
80
+ // if annotation.on is a string, the annotation should have a fragment
81
+ annotation.on = `https://test/${uuid4()}#xywh=100,100,300,300`;
82
+ return annotation
83
+ });
84
+
85
+ data = [[ testPostRouteCreateFailure, true ], [ testPostRouteCreateSuccess, false ]];
86
+ for (let i=0; i<data.length; i++) {
87
+ const [ func, v ] = data.at(i);
88
+ func(t, `/annotations/2/createMany?throwOnCanvasIndexError=${v}`, annotationListWithTargetErrors)
89
+ }
90
+ })
91
+
92
+ await t.test("test route /annotations/:iiifPresentationVersion/create", async (t) => {
93
+ //NOTE: we can't do Promise.all because it causes a data race that can cause a failure of unique constraints (i.e., on manifests '@id')
94
+ let data = [
95
+ [ fastify.fixtures.annotations2Valid, testPostRouteCreateSuccess ],
96
+ [ fastify.fixtures.annotations2Invalid, testPostRouteCreateFailure ],
97
+ ]
98
+ for (const [ testData, func ] of data) {
99
+ for (let i=0; i<testData.length; i++) {
100
+ await func(t, "/annotations/2/create?throwOnXywhError=true", testData.at(i));
101
+ }
102
+ };
103
+
104
+ // test SVG to XYWH conversion
105
+ // 1. insert and assert there was no mistake
106
+ // 2. retrieve the inserted annotation and check its xywh coordinates.
107
+ for (const ann of fastify.fixtures.annotations2SvgValid) {
108
+ let r, rBody;
109
+ r = await injectPost(fastify, "/annotations/2/create", ann);
110
+ rBody = await JSON.parse(r.body);
111
+ t.assert.deepStrictEqual(r.statusCode, 200);
112
+ t.assert.deepStrictEqual(rBody.insertedIds.length, 1);
113
+ const annId = JSON.parse(r.body).insertedIds[0];
114
+ r = await injectGet(fastify, annId);
115
+ rBody = await JSON.parse(r.body); // the annotation we just inserted
116
+ rBody.on.map(target => {
117
+ t.assert.deepStrictEqual(Array.isArray(target.xywh), true);
118
+ t.assert.deepStrictEqual(target.xywh.every((i) => !isNaN(i)), true);
119
+ })
120
+ }
121
+
122
+ // test throwOnCanvasIndexError
123
+ let annotationError;
124
+ annotationError = structuredClone(fastify.fixtures.annotations2Valid.at(0));
125
+ annotationError.on = `https://test/${uuid4()}#xywh=100,100,300,300`;
126
+ data = [[ testPostRouteCreateFailure, true ], [ testPostRouteCreateSuccess, false ]];
127
+ for (let i=0; i<data.length; i++) {
128
+ const [ func, v ] = data.at(i);
129
+ await func(t, `/annotations/2/create?throwOnCanvasIndexError=${v}`, annotationError)
130
+ }
131
+
132
+ // test throwOnXywhError. SvgSelectors are not supported, so this should fail
133
+ annotationError = structuredClone(fastify.fixtures.annotations2Valid.at(0));
134
+ // NOTE : this is an SVG selector and not a CSS selector !!!!
135
+ annotationError.on = {
136
+ "@type": "oa:SpecificResource",
137
+ "full": "https://aikon.enpc.fr/aikon/iiif/v2/wit9_man11_anno165/canvas/c11.json",
138
+ selector: {
139
+ "@context": "http://iiif.io/api/annex/openannotation/context.json",
140
+ "@type": "iiif:ImageApiSelector",
141
+ "region": "50,50,1250,1850" }
142
+ }
143
+ data = [[ testPostRouteCreateFailure, true ], [ testPostRouteCreateSuccess, false ]];
144
+ for (let i=0; i<data.length; i++) {
145
+ const [ func, v ] = data.at(i);
146
+ await func(t, `/annotations/2/create?throwOnXywhError=${v}`, annotationError);
147
+ }
148
+
149
+ })
150
+
151
+ await t.test("test route /annotations/:iiifPresentationVersion/update", async (t) => {
152
+ const updatePipeline = async (annotation, success) => {
153
+ // update the annotation
154
+ const
155
+ newLabel = `label-${uuid4()}`,
156
+ newBody = {
157
+ "@type": "cnt:ContentAsText",
158
+ format: "text/html",
159
+ value: "<p>What a grand pleasure it is to have updated this annotation !</p>"
160
+ };
161
+ annotation.label = newLabel;
162
+ annotation.resource = newBody;
163
+ if (!success) {
164
+ annotation["@type"] = "invalidType";
165
+ }
166
+ success
167
+ ? await testPostRouteUpdateSuccess(t, "/annotations/2/update", annotation)
168
+ : await testPostRouteUpdateFailure(t, "/annotations/2/update", annotation);
169
+ }
170
+
171
+ // insert valid documents and retrieve an annotation to update.
172
+ const
173
+ [ insertedCount, insertedIds ] = await injectTestAnnotations(fastify, t, annotationList),
174
+ idToUpdate = getRandomItem(insertedIds), // get a random item
175
+ annotation = await fastify.mongo.db.collection("annotations2").findOne(
176
+ { "@id": idToUpdate },
177
+ { projection: { _id: 0 } }
178
+ );
179
+
180
+ await updatePipeline(annotation, true);
181
+ await updatePipeline(annotation, false);
182
+ return;
183
+ });
184
+
185
+ // NOTE: this route handles pagination, but we don't test it here. the pagination module is aldready tested in `search-api`
186
+ await t.test("test route /annotations/:iiifPresentationVersion/search", async (t) => {
187
+ await injectTestAnnotations(fastify, t, annotationList);
188
+ const
189
+ annotationCollection = await fastify.mongo.db.collection("annotations2").find().toArray(),
190
+ annotation = getRandomItem(annotationCollection),
191
+ canvasId = getRandomItem(annotation.on).full,
192
+ expectedCount = annotationCollection
193
+ .filter((annotation) => annotation.on.map(target => target.full).includes(canvasId))
194
+ .length,
195
+ pageSize = expectedCount + 1, // all results will always be in 1 page
196
+ r = await fastify.inject({
197
+ method: "GET",
198
+ url: `/annotations/2/search?canvasUri=${canvasId}&page=1&pageSize=${pageSize}`
199
+ }),
200
+ body = await r.json();
201
+
202
+ t.assert.deepStrictEqual(r.statusCode, 200);
203
+ t.assert.deepStrictEqual(Array.isArray(body?.resources), true);
204
+ t.assert.deepStrictEqual(body.resources.length, expectedCount);
205
+ })
206
+
207
+ await t.test("test route /data/:iiifPresentationVersion/:manifestShortId/annotation/:annotationShortId", async (t) => {
208
+ await injectTestAnnotations(fastify, t, annotationList);
209
+ const annotationId = await getRandomItem(
210
+ await fastify.mongo.db.collection("annotations2").find().toArray()
211
+ )["@id"];
212
+ await Promise.all(
213
+ // if shouldExist, search an annotation that exists, otherwise, search an annotation that does not exist. test accordingly.
214
+ [ true, false ].map(async (shouldExist) => {
215
+ const
216
+ annotationIdQuery =
217
+ shouldExist
218
+ ? annotationId.replace(PUBLIC_URL, "")
219
+ : annotationId.replace(PUBLIC_URL, "") + "string_that_does_not_exist_in_the_db",
220
+ r = await fastify.inject({
221
+ method: "GET",
222
+ url: annotationIdQuery
223
+ }),
224
+ body = await r.json();
225
+
226
+ t.assert.deepStrictEqual(r.statusCode, 200);
227
+ if (shouldExist) {
228
+ t.assert.deepStrictEqual(body["@id"]===annotationId, true);
229
+ } else {
230
+ t.assert.deepStrictEqual(Object.keys(body).length === 0, true);
231
+ }
232
+ })
233
+ )
234
+ })
235
+
236
+ await t.test("test route /annotations/:iiifPresentationVersion/count", async (t) => {
237
+ const expectedOnCount = (annotationArray, onKey, expectedOnVal) =>
238
+ annotationArray.filter((anno) => anno.on.some(x => x[onKey] === expectedOnVal)).length
239
+
240
+ await injectTestAnnotations(fastify, t, annotationList);
241
+ const
242
+ annotationArray = await fastify.mongo.db.collection("annotations2").find().toArray(),
243
+ annotationId = getRandomItem(annotationArray)["@id"],
244
+ canvasUri = getRandomItem(annotationArray).on[0].full,
245
+ manifestShortId = getRandomItem(annotationArray).on[0].manifestShortId,
246
+ expectedAnnotationIdCount = 1,
247
+ expectedCanvasUriCount = expectedOnCount(annotationArray, "full", canvasUri),
248
+ expectedManifestShortIdCount = expectedOnCount(annotationArray, "manifestShortId", manifestShortId),
249
+ mapper = [
250
+ [ "uri", annotationId, expectedAnnotationIdCount ],
251
+ [ "canvasUri", canvasUri, expectedCanvasUriCount ],
252
+ [ "manifestShortId", manifestShortId, expectedManifestShortIdCount ]
253
+ ];
254
+
255
+ await Promise.all(
256
+ mapper.map(async ([ filterKey, filterVal, expectedCount ]) => {
257
+ const
258
+ r = await fastify.inject({
259
+ method: "GET",
260
+ url: `/annotations/2/count?${filterKey}=${filterVal}`
261
+ }),
262
+ body = await r.json();
263
+ t.assert.deepStrictEqual(r.statusCode, 200);
264
+ t.assert.deepStrictEqual(body.count, expectedCount);
265
+ })
266
+ )
267
+
268
+ })
269
+
270
+ return
271
+ })