@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,289 @@
1
+ /**
2
+ * utilities and generally useful functions for tests.
3
+ */
4
+
5
+ import { visibleLog } from "#utils/utils.js";
6
+
7
+ /** @typedef {import("#types").NodeTestType} NodeTestType */
8
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
9
+ /** @typedef {import("#types").FastifyReplyType} FastifyReplyType */
10
+ /** @typedef {import("#types").DataOperationsType } DataOperationsType */
11
+
12
+ /**
13
+ * @param {NodeTestType} t
14
+ * @param {object} obj
15
+ * @param {Array} expectedKeys
16
+ * @returns {void}
17
+ */
18
+ const assertObjectKeys = (t, obj, expectedKeys) =>
19
+ t.assert.deepStrictEqual(
20
+ Object.keys(obj).sort(),
21
+ expectedKeys.sort()
22
+ );
23
+
24
+ /**
25
+ * insert responses MUST contain `insertedCount` and MAY contain ["insertedIds", "preExistingIds", "fetchErrorIds", "rejectedIds"]
26
+ * @param {NodeTestType} t
27
+ * @param {object} obj
28
+ * @returns {void}
29
+ */
30
+ const assertObjectKeysInsert = (t, obj) => {
31
+ t.assert.deepStrictEqual(
32
+ Object.keys(obj).every((k) => [ "insertedCount", "insertedIds", "preExistingIds", "fetchErrorIds", "rejectedIds" ].includes(k)),
33
+ true
34
+ );
35
+ t.assert.deepStrictEqual(
36
+ Object.keys(obj).includes("insertedCount"), true
37
+ )
38
+ }
39
+
40
+ /**
41
+ * @param {NodeTestType} t
42
+ * @param {object} obj
43
+ * @returns {void}
44
+ */
45
+ const assertObjectKeysUpdate = (t, obj) =>
46
+ assertObjectKeys(t, obj, [ "matchedCount", "modifiedCount", "upsertedCount", "upsertedId" ]);
47
+
48
+ /**
49
+ * @param {NodeTestType} t
50
+ * @param {object} obj
51
+ * @returns {void}
52
+ */
53
+ const assertObjectKeysDelete = (t, obj) =>
54
+ assertObjectKeys(t, obj, [ "deletedCount" ]);
55
+
56
+ /**
57
+ * @param {NodeTestType} t
58
+ * @param {object} obj
59
+ * @returns {void}
60
+ */
61
+ const assertObjectKeysError = (t, obj) =>
62
+ assertObjectKeys(t, obj, [ "message", "info", "method", "url", "requestBody" ]);
63
+
64
+ /**
65
+ * @param {NodeTestType} t
66
+ * @param {FastifyReplyType} r
67
+ * @param {number} expectedStatusCode
68
+ * @returns {void}
69
+ */
70
+ const assertStatusCode = (t, r, expectedStatusCode) =>
71
+ t.assert.deepStrictEqual(r.statusCode, expectedStatusCode);
72
+
73
+ /**
74
+ * @param {NodeTestType} t
75
+ * @param {FastifyReplyType} r
76
+ * @param {"insert"|"update"|"delete"|"error"} expectedResponse: keyword to define the response schema to test against.
77
+ * @returns {void}
78
+ */
79
+ const assertResponseKeys = (t, r, expectedResponse) =>
80
+ expectedResponse === "insert"
81
+ ? assertObjectKeysInsert(t, JSON.parse(r.body))
82
+ : expectedResponse === "update"
83
+ ? assertObjectKeysUpdate(t, JSON.parse(r.body))
84
+ : expectedResponse === "delete"
85
+ ? assertObjectKeysDelete(t, JSON.parse(r.body))
86
+ : assertObjectKeysError(t, JSON.parse(r.body));
87
+
88
+ /**
89
+ * @param {FastifyInstanceType} fastify
90
+ * @param {string} route
91
+ * @param {object} payload
92
+ * @returns {Promise<FastifyReplyType>}
93
+ */
94
+ const injectPost = (fastify, route, payload) =>
95
+ fastify.inject({
96
+ method: "POST",
97
+ url: route,
98
+ payload: payload,
99
+ });
100
+
101
+ /**
102
+ * @param {FastifyInstanceType} fastify
103
+ * @param {string} route
104
+ * @param {object} payload
105
+ * @returns {Promise<FastifyReplyType>}
106
+ */
107
+ const injectGet = (fastify, route, payload) =>
108
+ fastify.inject({
109
+ method: "GET",
110
+ url: route,
111
+ payload: payload || {}
112
+ });
113
+
114
+ /**
115
+ * @param {NodeTestType} t
116
+ * @param {FastifyReplyType} r
117
+ * @returns {void}
118
+ */
119
+ const assertPostInvalidResponse = (t, r) => {
120
+ assertStatusCode(t, r, 500);
121
+ assertResponseKeys(t, r, "error");
122
+ }
123
+
124
+ /**
125
+ * @param {NodeTestType} t
126
+ * @param {FastifyReplyType} r
127
+ * @returns {void}
128
+ */
129
+ const assertCreateValidResponse = (t, r) => {
130
+ assertStatusCode(t, r, 200);
131
+ assertResponseKeys(t, r, "insert");
132
+ }
133
+
134
+ /**
135
+ * @param {NodeTestType} t
136
+ * @param {FastifyReplyType} r
137
+ * @returns {void}
138
+ */
139
+ const assertUpdateValidResponse = (t,r) => {
140
+ assertStatusCode(t, r, 200);
141
+ assertResponseKeys(t, r, "update");
142
+ }
143
+
144
+ /**
145
+ * @param {NodeTestType} t
146
+ * @param {FastifyReplyType} r
147
+ * @returns {void}
148
+ */
149
+ const assertDeleteValidResponse = (t,r) => {
150
+ assertStatusCode(t, r, 200);
151
+ assertResponseKeys(t, r, "delete");
152
+ }
153
+
154
+ /**
155
+ * @param {NodeTestType} t
156
+ * @param {FastifyReplyType} r
157
+ * @param {number} expectedStatusCode
158
+ */
159
+ const assertErrorValidResponse = (t, r, expectedStatusCode=500) => {
160
+ assertObjectKeysError(t, JSON.parse(r.body));
161
+ assertStatusCode(t, r, expectedStatusCode);
162
+ };
163
+
164
+ /**
165
+ * curried function to test different operations with a post route.
166
+ * @param {FastifyInstanceType} fastify
167
+ */
168
+ const testPostRouteCurry = (fastify) =>
169
+ /** @param {DataOperationsType} op */
170
+ (op) =>
171
+ /** @param {boolean} success - if `true` test that the query succeeds. else, test that it fails */
172
+ (success) =>
173
+ /**
174
+ * @param {NodeTestType} t
175
+ * @param {string} route: example: /annotations/2/createMany
176
+ * @param {object} payload
177
+ */
178
+ async (t, route, payload) => {
179
+ const
180
+ r = await injectPost(fastify, route, payload),
181
+ funcInvalid = assertPostInvalidResponse;
182
+
183
+ let funcValid;
184
+ if (op==="insert") {
185
+ funcValid = assertCreateValidResponse;
186
+ } else if (op==="update") {
187
+ funcValid = assertUpdateValidResponse;
188
+ } else {
189
+ throw new Error(`testPostRouteCurry: unimplemented value of 'op': '${op}'.`)
190
+ }
191
+
192
+ success
193
+ ? funcValid(t, r)
194
+ : funcInvalid(t, r);
195
+ return;
196
+ }
197
+
198
+ const testDeleteRouteCurry =
199
+ /** @param {FastifyInstanceType} */
200
+ (fastify) =>
201
+ /**
202
+ * @param {NodeTestType} t
203
+ * @param {string} deleteRoute - route to delete data, with delete parameters embedded
204
+ * @param {number} expectedDeletedCount - number of documents that should be deleted
205
+ */
206
+ async (t, deleteRoute, expectedDeletedCount) => {
207
+ const r = await fastify.inject({
208
+ method: "DELETE",
209
+ url: deleteRoute
210
+ })
211
+ assertDeleteValidResponse(t, r);
212
+ t.assert.deepStrictEqual(JSON.parse(r.body).deletedCount, expectedDeletedCount);
213
+ }
214
+
215
+ /**
216
+ * inject a manifest into the database for test purposes
217
+ * @param {FastifyInstanceType} fastify
218
+ * @param {NodeTestType} t
219
+ * @param {object} manifest - the manifest to insert
220
+ * @returns {Promise<Array<number, Array<string>>>}
221
+ */
222
+ const injectTestManifest = async (fastify, t, manifest) => {
223
+ const
224
+ r = await injectPost(fastify, "/manifests/2/create", manifest),
225
+ { insertedCount, insertedIds } = JSON.parse(r.body);
226
+ t.assert.deepStrictEqual(insertedCount, 1);
227
+ return [ insertedCount, insertedIds ];
228
+ }
229
+
230
+ /**
231
+ * inject an annotationList into the database for test purposes
232
+ * @param {FastifyInstanceType} fastify
233
+ * @param {NodeTestType} t
234
+ * @param {object} annotationList - a IIIF 2.x annotation list
235
+ * @returns {Promise<Array<number, Array<string>>>}
236
+ */
237
+ const injectTestAnnotations = async (fastify, t, annotationList) => {
238
+ const
239
+ r = await injectPost(fastify, "/annotations/2/createMany", annotationList),
240
+ rBody = JSON.parse(r.body),
241
+ expectedInsertedCount = annotationList.resources.length,
242
+ { insertedCount, insertedIds } = rBody;
243
+ t.assert.deepStrictEqual(insertedCount, expectedInsertedCount);
244
+ return [ insertedCount, insertedIds ];
245
+ }
246
+
247
+ const testGetPaginated = async (fastify, t, iiifSearchVersion, route, expectedGetCount) => {
248
+ if (iiifSearchVersion===2) {
249
+ let
250
+ routeNextPage = route,
251
+ totalCount = 0,
252
+ r,
253
+ rBody;
254
+ while (routeNextPage) {
255
+ r = await injectGet(fastify, routeNextPage);
256
+ t.assert.deepStrictEqual(r.statusCode, 200);
257
+ rBody = JSON.parse(r.body);
258
+ totalCount += rBody.resources.length;
259
+ routeNextPage = rBody.next;
260
+ }
261
+ t.assert.deepStrictEqual(totalCount, expectedGetCount);
262
+
263
+ } else {
264
+ throw new Error(`testGetPaginated: not implemented for 'iiifSearchVersion=${iiifSearchVersion}'`);
265
+ }
266
+ }
267
+
268
+ export {
269
+ assertObjectKeys,
270
+ assertObjectKeysError,
271
+ assertObjectKeysInsert,
272
+ assertObjectKeysUpdate,
273
+ assertObjectKeysDelete,
274
+ assertStatusCode,
275
+ assertResponseKeys,
276
+ assertErrorValidResponse,
277
+ assertPostInvalidResponse,
278
+ assertCreateValidResponse,
279
+ assertUpdateValidResponse,
280
+ assertDeleteValidResponse,
281
+ injectPost,
282
+ injectGet,
283
+ injectTestManifest,
284
+ injectTestAnnotations,
285
+ testPostRouteCurry,
286
+ testDeleteRouteCurry,
287
+ testGetPaginated
288
+ }
289
+
@@ -0,0 +1,403 @@
1
+ import util from "node:util";
2
+ import Ajv from "ajv";
3
+
4
+ import logger from "#utils/logger.js";
5
+ import { BASE_URL, PUBLIC_URL } from "#constants";
6
+
7
+ /**
8
+ * @param {object} obj
9
+ * @param {string} key
10
+ * @returns {boolean}
11
+ */
12
+ const objectHasKey = (obj, key) =>
13
+ Object.keys(obj).includes(key);
14
+
15
+ const addKeyValueToObj = (obj, key, value) => {
16
+ obj[key] = value;
17
+ return obj;
18
+ }
19
+
20
+ const isNullOrUndefined = (v) => v == null;
21
+
22
+ const isNullish = (v) => v == null || !v.length;
23
+
24
+ /** o is an object but not an array. https://stackoverflow.com/a/44556453 */
25
+ const isObject = (o) => o?.constructor === Object;
26
+
27
+ const isNonEmptyArray = (a) => Array.isArray(a) && a.length;
28
+
29
+ /**
30
+ * extend objOut with a key-value pair fron objIn if a key is in objIn
31
+ * @param {object} objIn: the object that should contain key
32
+ * @param {object} objOut: the object to extend
33
+ * @param {string|number} key: the key in objIn.
34
+ * @param {string?} newKey: (optional) the name of the new key in objOut. if undefined, key is used.
35
+ */
36
+ const addKeyValueToObjIfHasKey = (objIn, objOut, key, newKey) =>
37
+ Object.keys(objIn).includes(key)
38
+ ? addKeyValueToObj(
39
+ objOut,
40
+ isNullOrUndefined(newKey) ? key : newKey,
41
+ objIn[key]
42
+ )
43
+ : objOut;
44
+
45
+
46
+ /**
47
+ * merge to objects (Dict) `objB` and `objA`.
48
+ * if `raiseOnConflict`, the two objects cannot have conflicting keys,
49
+ * otherwise, the values in `objB` overwrite those in objA.
50
+ *
51
+ * @param {object} objA
52
+ * @param {object} objB
53
+ * @param {boolean} raiseOnConflict
54
+ * @returns {object}
55
+ */
56
+ const mergeObjects = (objA, objB, raiseOnConflict=false) => {
57
+ if (!isObject(objA) || !isObject(objB)) {
58
+ throw new Error(`mergeObjects: objA and objB must be javascript objects (not arrays), got types '${typeof objA}' for objA and '${typeof objB}' for objB.`)
59
+ }
60
+ // avoid wird side effects
61
+ const objMerge = structuredClone(objA);
62
+ const objBMerger = structuredClone(objB);
63
+
64
+ for (const key of Object.keys(objBMerger)) {
65
+ if (raiseOnConflict && objMerge[key] != null) {
66
+ throw new Error(`mergeObjects: objA and objB have conflicting key: '${key}'.`)
67
+ }
68
+ objMerge[key] = objBMerger[key];
69
+ }
70
+ return objMerge;
71
+ }
72
+
73
+ /**
74
+ * hash generating function, copied from: https://stackoverflow.com/a/52171480
75
+ * @returns {string}
76
+ */
77
+ const getHash = (str, seed=0) => {
78
+ let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed;
79
+ for(let i = 0, ch; i < str.length; i++) {
80
+ ch = str.charCodeAt(i);
81
+ h1 = Math.imul(h1 ^ ch, 2654435761);
82
+ h2 = Math.imul(h2 ^ ch, 1597334677);
83
+ }
84
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507);
85
+ h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909);
86
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507);
87
+ h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909);
88
+ return String(4294967296 * (2097151 & h2) + (h1 >>> 0));
89
+ };
90
+
91
+ /**
92
+ * `obj` is an object of form `{ key1: val1, key2: val2, key3: val3 }`, where only a single key-value pair can be defined at the same time.
93
+ * return the only key-value pair that is not undefined.
94
+ *
95
+ * use case: in a route's querystring, there may be different options that are mutually exclusive (defined using JSONschema oneOf). in that case, return the only key-value pair that is not undefined.
96
+ * see: src/data/annotations2: '/annotations/:iiifPresentationVersion/delete'
97
+ *
98
+ * @param {Array<object>} obj
99
+ * @returns {Array<string, any>?}
100
+ */
101
+ const getFirstNonEmptyPair = (obj) => {
102
+ let [ key,val ] = [ undefined, undefined ];
103
+ [ key, val ] = Object.entries(obj).find(([ k,v ]) => v != null);
104
+ if (key!== undefined && val!==undefined) {
105
+ return [ key, val ];
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /**
111
+ * if obj[typeKey] !== expectedTypeVal, throw
112
+ * @param {object} obj
113
+ * @param {2|3} iiifPresentationVersion
114
+ * @param {string|number} typeKey
115
+ * @param {any} expectedTypeVal
116
+ */
117
+ const throwIfValueError = (obj, typeKey, expectedTypeVal) => {
118
+ if (obj[typeKey] !== expectedTypeVal) {
119
+ throw new Error(`expected value '${expectedTypeVal}' for key '${typeKey}', got: '${obj[typeKey]}' in object ${inspectObj(obj)}`);
120
+ };
121
+ }
122
+
123
+ /**
124
+ * if obj[key] is undefined, throw
125
+ * @param {object} obj
126
+ * @param {string|number} key
127
+ */
128
+ const throwIfKeyUndefined = (obj, key) => {
129
+ if (!objectHasKey(obj, key)) {
130
+ throw new Error(`key '${key}' not found in object ${inspectObj(obj)}`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * shallow-compare 2 arrays.
136
+ * @param {Array} a1
137
+ * @param {Array} a2
138
+ * @param {boolean} sort: if `true`, sort before comparing
139
+ * @returns {boolean}
140
+ */
141
+ const arrayEqualsShallow = (a1, a2, sort=false) => {
142
+ if (!Array.isArray(a1) || !Array.isArray(a2)) {
143
+ throw new Error(`Incorrect type: 'a1', 'a2' must be arrays, got '${typeof a1}' and '${typeof a2}' on a1='${a1}' and a2='${a2}'`)
144
+ }
145
+ if (sort) {
146
+ a1 = a1.sort();
147
+ a2 = a2.sort();
148
+ }
149
+ if (a1.length!==a2.length) {
150
+ return false;
151
+ }
152
+ return a1.every((el, i) => a2[i]===el);
153
+ }
154
+
155
+ /**
156
+ * convert object `x` to an array if it is not aldready an array.
157
+ * if `convertedFlag`, return [ obj, converted ]. `converted` is `true` if the object was converted to an array, false otherwise.
158
+ * @param {any} x
159
+ * @param {boolean} convertedFlag
160
+ * @returns {Array | Array<Array, boolean> }
161
+ */
162
+ const maybeToArray = (x, convertedFlag=false) =>
163
+ convertedFlag
164
+ ? Array.isArray(x) ? [ x, false ] : [[ x ], true ]
165
+ : Array.isArray(x) ? x : [ x ];
166
+
167
+ /**
168
+ * build a URL to this aiiinotate instance.
169
+ * if `publicUrl`, set the root to PUBLIC_URL. otherwise, set to BASE_URL
170
+ * @param {boolean} publicUrl
171
+ * @returns {(path: string) => string}
172
+ */
173
+ const pathToAiiinotateUrl = (publicUrl) =>
174
+ (path) =>
175
+ `${publicUrl ? PUBLIC_URL : BASE_URL}${path}`
176
+ const pathToAiiinotatePublicUrl = pathToAiiinotateUrl(true);
177
+ const pathToAiiinotateBaseUrl = pathToAiiinotateUrl(false);
178
+
179
+ /**
180
+ * display a detailed and nested view of an object. to be used with console.log.
181
+ * @param {any} obj - object to inspect
182
+ * @param {number} maxLines - maximum number of lines in string output. defaults to 100. set to -1 to print the whole output regardless of size.
183
+ * @returns
184
+ */
185
+ const inspectObj = (obj, maxLines=100) => {
186
+ const
187
+ str = util.inspect(obj, { showHidden: false, depth: null, colors: true }),
188
+ strArr = str.split("\n"),
189
+ strLen = strArr.length;
190
+ // if maxLines === -1, return the full `str`
191
+ if (maxLines < 0) {
192
+ return str;
193
+ }
194
+ // remove the middle lines if `str` is too long
195
+ if (strLen > maxLines) {
196
+ const
197
+ startProportion = 0.8,
198
+ startSlice = strArr.slice(0, Math.round(0.8 * maxLines)),
199
+ endSlice = strArr.slice(-Math.round((1-startProportion) * maxLines), -1);
200
+ return (
201
+ startSlice.join("\n")
202
+ + `\n ... inspectObj: ${strLen - maxLines} lines omitted ... \n`
203
+ + endSlice.join("\n")
204
+ )
205
+ }
206
+ return str;
207
+ }
208
+
209
+ /**
210
+ * return a random item from an array.
211
+ * @param {any[]} arr
212
+ * @returns {any}
213
+ */
214
+ const getRandomItem = (arr) =>
215
+ arr.at(Math.floor(Math.random() * arr.length));
216
+
217
+ /**
218
+ * round an xywh bounding box to integers.
219
+ * the bouding box should be within the image (otherwise, reading the XYWH bbox into a IIIF
220
+ * Image API URL will fail):
221
+ * - `x` and `y` should be positive integers => round them to larger integer
222
+ * - `x+w` and `y+h` should be smaller than the image's width and height
223
+ * => round them to the smallest integer.
224
+ * this really only matters if a bbox is on the boundaries of an image.
225
+ * @type {(xywh: number[]) => number[]}
226
+ */
227
+ const xywhToInt = ([ x,y,w,h ]) => (
228
+ // convert to int
229
+ [
230
+ Math.ceil(x),
231
+ Math.ceil(y),
232
+ Math.floor(w),
233
+ Math.floor(h)
234
+ ]
235
+ // 1st failsafe: value may be converted to `-0`, which will
236
+ // demand us to reupdate the documents everytime for some obscure reason
237
+ // we actually compare to `0`, since comparing to `0` and `-0` is the same
238
+ .map((v) => v===0 ? 0 : v)
239
+ // 2nd failsafe: use Math.trunc to ensure the returned number is an int.
240
+ .map(Math.trunc)
241
+ )
242
+
243
+ /**
244
+ * AJV instance to run JsonSchema compilation/validation anywhere in the app
245
+ * (not just in Fastify route definition and Mongo interactions).
246
+ * NOTE: this is a workaround since i could not get to access fastify's AJV instance, although fastify uses AJV internally.
247
+ */
248
+ const ajv = new Ajv({
249
+ removeAdditional: false,
250
+ useDefaults: true,
251
+ coerceTypes: true,
252
+ allErrors: true
253
+ })
254
+
255
+ /**
256
+ * wrapper for `ajv.compile`. `schema` must be a schema resolved using `fastify.schemasResolver()`
257
+ * NOTE: this function exists because using the native `ajv.compile` on fastify schemas (with `$id`, `$ref`...) causes tests to fail WITHOUT launching an error., making it very hard to debug
258
+ * @param {object} schema - jsonSchema
259
+ * @returns {import("#types").AjvValidateFunctionType}
260
+ */
261
+ const ajvCompile = (schema) => {
262
+ if (objectHasKey(schema, "$id") || objectHasKey(schema, "$ref")) {
263
+ const err = new Error(`'schema' has not been resolved. use 'fastify.schemasResolver()' to resolve the schema before compiling it, on schema: ${inspectObj(schema)}`);
264
+ // logging is necessary to be sure that the error will be displayed
265
+ logger.error(err.message);
266
+ throw err;
267
+ }
268
+ return ajv.compile(schema);
269
+ }
270
+
271
+ /**
272
+ * print in a box for debug purposes
273
+ * @param {any} data
274
+ * @param {string} prefix
275
+ */
276
+ const visibleLog = (data, prefix) => {
277
+ console.log("<".repeat(100));
278
+ if (prefix) console.log(prefix);
279
+ console.log(inspectObj(data));
280
+ console.log(">".repeat(100));
281
+ }
282
+
283
+ /**
284
+ * sort an object recursively.
285
+ * supported types are: Objects ({}), Arrays ([]), primitive types.
286
+ * @param {any} x
287
+ * @returns {any}
288
+ */
289
+ const recursiveSort = (x) => {
290
+ if (Array.isArray(x)) {
291
+ x = x.sort();
292
+ for (let i=0; i<x.length; i++) {
293
+ x[i] = recursiveSort(x[i]);
294
+ }
295
+ return x;
296
+ } else if (isObject(x)) {
297
+ const xSorted = {};
298
+ const keys = Object.keys(x).sort();
299
+ for (let i=0; i<keys.length; i++) {
300
+ let
301
+ k = keys[i],
302
+ v = x[k];
303
+ xSorted[k] = recursiveSort(v);
304
+ }
305
+ return xSorted;
306
+ }
307
+ return x;
308
+ }
309
+
310
+ /**
311
+ * Map cache with a timeout and a max size:
312
+ * - after `timeout` ms have passed, clears the cache of the key `n`.
313
+ * - to avoid the cache to grow unbounded, we define a max size after which
314
+ * we delete the greatest items in the cache.
315
+ *
316
+ * cache structure:
317
+ * Map<[key:string]: { timestamp: Date, promise: Promise }>
318
+ *
319
+ * NOTE: LIMITATIONS:
320
+ * - `memoize` converts `fn` to an async function to work with both
321
+ * sync/async patterns, remember to await !
322
+ * - the args applied to `fn` must be JSON-stringify-able: a primitive,
323
+ * an array, or an object, but not a function, a class or class instance
324
+ *
325
+ * adapted from: https://dev.to/codewithjohnson/the-power-of-a-simple-cache-system-with-javascript-map-3j01
326
+ *
327
+ * @param {Function} fn - the function whose result will be cached
328
+ * @param {number} timeout - timeout in ms to clear the cache of a newly assigned value
329
+ * @param {number} maxSize - maximm number of elements in the cache
330
+ * @returns {async Function} - a function that takes a value and caches its result.
331
+ */
332
+ const memoize = (fn, timeout = 2000, maxSize = 200) => {
333
+ if (timeout <= 0 || maxSize <= 0) {
334
+ throw new Error("memoize: 'timeout' and 'maxSize' must be greater than 0.")
335
+ }
336
+
337
+ // each key is mapped to an object storing the timestamp of cache item cration + the function result as a promise.
338
+ /** @type {Map<[key:string], { timestamp: Date, promise: Promise }>} */
339
+ const cache = new Map();
340
+
341
+ return async (...args) => {
342
+ // if cache.size > cacheSize, remove the oldest items.
343
+ const extraCount = cache.size - maxSize;
344
+ if (extraCount > 0) {
345
+ // get the `extraCount` oldest items in the cache.
346
+ const deleteItems = [ ...cache.entries() ] // convert iterator to array
347
+ .map(([ k, { timestamp }]) => [ k, timestamp ])
348
+ .sort((a, b) => a[1] - b[1])
349
+ .slice(0, extraCount);
350
+
351
+ deleteItems.forEach(([ k, _ ]) => cache.delete(k));
352
+ }
353
+
354
+ // stringify `args` to `key`, the key to find in the cache.
355
+ // since `map` is a key-value store, we need to stringify `args`
356
+ // to ensure it can be used as a key to ensure consistency,
357
+ // we sort our objects/array before stringifying
358
+ const key = JSON.stringify(recursiveSort(args));
359
+
360
+ // fetch result promise, from cache or by executing `fn`.
361
+ // instead of caching the result, we cache the promise:
362
+ // all concurrent callers awaiting the same key get the same promise
363
+ // instance, so `fn` is only ever called once per unique key,
364
+ // regardless of how many callers arrive before it resolves.
365
+ let promise;
366
+ if (cache.has(key)) {
367
+ promise = cache.get(key).promise;
368
+ } else {
369
+ promise = fn(...args);
370
+ cache.set(key, { timestamp: Date.now(), promise: promise });
371
+ // after `timeout` ms, clear the cache of `key`
372
+ setTimeout(
373
+ () => cache.delete(key),
374
+ timeout
375
+ )
376
+ }
377
+
378
+ return await promise;
379
+ }
380
+ }
381
+
382
+ export {
383
+ maybeToArray,
384
+ pathToAiiinotatePublicUrl,
385
+ pathToAiiinotateBaseUrl,
386
+ getHash,
387
+ isNullish,
388
+ isObject,
389
+ objectHasKey,
390
+ addKeyValueToObjIfHasKey,
391
+ getFirstNonEmptyPair,
392
+ inspectObj,
393
+ getRandomItem,
394
+ arrayEqualsShallow,
395
+ throwIfKeyUndefined,
396
+ throwIfValueError,
397
+ xywhToInt,
398
+ ajvCompile,
399
+ visibleLog,
400
+ isNonEmptyArray,
401
+ mergeObjects,
402
+ memoize,
403
+ }