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