@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,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
|
+
}
|