aiiinotate 0.2.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 (88) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +61 -0
  3. package/cli/import.js +142 -0
  4. package/cli/index.js +26 -0
  5. package/cli/io.js +105 -0
  6. package/cli/migrate.js +123 -0
  7. package/cli/mongoClient.js +11 -0
  8. package/docs/architecture.md +88 -0
  9. package/docs/db.md +38 -0
  10. package/docs/dev_iiif_compatibility.md +43 -0
  11. package/docs/endpoints.md +48 -0
  12. package/docs/progress.md +159 -0
  13. package/docs/specifications/0_w3c_open_annotations.md +332 -0
  14. package/docs/specifications/1_w3c_web_annotations.md +577 -0
  15. package/docs/specifications/2_iiif_apis.md +396 -0
  16. package/docs/specifications/3_iiif_annotations.md +103 -0
  17. package/docs/specifications/4_search_api.md +135 -0
  18. package/docs/specifications/5_sas.md +119 -0
  19. package/docs/specifications/6_mirador.md +119 -0
  20. package/docs/specifications/7_aikon.md +137 -0
  21. package/docs/specifications/include/presentation_2.0.webp +0 -0
  22. package/docs/specifications/include/presentation_2.0_white.png +0 -0
  23. package/docs/specifications/include/presentation_3.0.png +0 -0
  24. package/docs/specifications/include/presentation_3.0_resize.png +0 -0
  25. package/eslint.config.js +27 -0
  26. package/migrations/baseConfig.js +56 -0
  27. package/migrations/manageIndex.js +55 -0
  28. package/migrations/migrate-mongo-config-main.js +8 -0
  29. package/migrations/migrate-mongo-config-test.js +8 -0
  30. package/migrations/migrationScripts/20250825185706-collections.js +41 -0
  31. package/migrations/migrationScripts/20250826194832-annotations2-canvas-index.js +31 -0
  32. package/migrations/migrationScripts/20250904080710-annotations2-schema.js +42 -0
  33. package/migrations/migrationScripts/20251002141951-manifest2-schema.js +43 -0
  34. package/migrations/migrationScripts/20251006212110-manifest-unique-index.js +29 -0
  35. package/migrations/migrationScripts/20251028115614-annotations2-id-index.js +27 -0
  36. package/migrations/migrationTemplate.js +25 -0
  37. package/package.json +78 -0
  38. package/run.sh +70 -0
  39. package/scripts/_migrations.sh +79 -0
  40. package/scripts/_setup.js +31 -0
  41. package/scripts/setup_mongodb.sh +61 -0
  42. package/scripts/setup_mongodb_migrate.sh +17 -0
  43. package/scripts/setup_node.sh +15 -0
  44. package/scripts/utils.sh +192 -0
  45. package/setup.sh +20 -0
  46. package/src/app.js +113 -0
  47. package/src/config/.env.template +22 -0
  48. package/src/data/annotations/annotations2.js +419 -0
  49. package/src/data/annotations/annotations3.js +32 -0
  50. package/src/data/annotations/routes.js +271 -0
  51. package/src/data/annotations/routes.test.js +180 -0
  52. package/src/data/collectionAbstract.js +270 -0
  53. package/src/data/index.js +29 -0
  54. package/src/data/manifests/manifests2.js +305 -0
  55. package/src/data/manifests/manifests2.test.js +53 -0
  56. package/src/data/manifests/manifests3.js +23 -0
  57. package/src/data/manifests/routes.js +95 -0
  58. package/src/data/manifests/routes.test.js +69 -0
  59. package/src/data/routes.js +141 -0
  60. package/src/data/routes.test.js +117 -0
  61. package/src/data/utils/iiif2Utils.js +196 -0
  62. package/src/data/utils/iiif2Utils.test.js +98 -0
  63. package/src/data/utils/iiif3Utils.js +0 -0
  64. package/src/data/utils/iiifUtils.js +18 -0
  65. package/src/data/utils/routeUtils.js +109 -0
  66. package/src/data/utils/testUtils.js +253 -0
  67. package/src/data/utils/utils.js +231 -0
  68. package/src/db/index.js +48 -0
  69. package/src/fileServer/annotations.js +39 -0
  70. package/src/fileServer/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
  71. package/src/fileServer/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
  72. package/src/fileServer/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
  73. package/src/fileServer/data/annotations2Invalid.jsonld +39 -0
  74. package/src/fileServer/data/annotations2Valid.jsonld +39 -0
  75. package/src/fileServer/data/bnf_invalid_manifest.json +2806 -0
  76. package/src/fileServer/data/bnf_valid_manifest.json +2817 -0
  77. package/src/fileServer/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
  78. package/src/fileServer/index.js +64 -0
  79. package/src/fileServer/manifests.js +14 -0
  80. package/src/fileServer/utils.js +35 -0
  81. package/src/schemas/index.js +20 -0
  82. package/src/schemas/schemasBase.js +47 -0
  83. package/src/schemas/schemasPresentation2.js +417 -0
  84. package/src/schemas/schemasPresentation3.js +57 -0
  85. package/src/schemas/schemasResolver.js +71 -0
  86. package/src/schemas/schemasRoutes.js +277 -0
  87. package/src/server.js +22 -0
  88. package/src/types.js +93 -0
@@ -0,0 +1,270 @@
1
+ import { maybeToArray, inspectObj } from "#utils/utils.js"
2
+ import { formatInsertResponse, formatDeleteResponse, formatUpdateResponse } from "#src/data/utils/routeUtils.js";
3
+
4
+ /** @typedef {import("#types").MongoDbType} MongoDbType */
5
+ /** @typedef {import("#types").MongoClientType} MongoClientType */
6
+ /** @typedef {import("#types").IiifPresentationVersionType} IiifPresentationVersionType */
7
+ /** @typedef {import("#types").MongoCollectionType} MongoCollectionType */
8
+ /** @typedef {import("#types").CollectionNamesType} CollectionNamesType */
9
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
10
+ /** @typedef {import("#types").DataOperationsType } DataOperationsType */
11
+
12
+ const allowedCollectionNames = ["manifests2", "manifests3", "annotations2", "annotations3"];
13
+
14
+ class CollectionAbstractError extends Error {
15
+ /**
16
+ * @param {string?} collectionName: name of the collection we're working on
17
+ * @param {string} message: error message
18
+ * @param {DataOperationsType} operation: to describe the type of database interaction
19
+ * @param {object} errInfo: extra info to display for the error
20
+ */
21
+ constructor(collectionName, operation, message, errInfo) {
22
+ const
23
+ collInfo = collectionName ? `on collection '${collectionName}',` : "",
24
+ operationInfo = operation ? `when performing operation '${operation.toLocaleLowerCase()}'`: "";
25
+ super(`CollectionAbstractError: ${collInfo} ${operationInfo}: ${message}`);
26
+ this.info = errInfo;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @param {string?} collectionName
32
+ * @returns {Function}
33
+ */
34
+ const errorConstructor = (collectionName) =>
35
+ /**
36
+ * @param {string?} operation
37
+ * @returns {Function}
38
+ */
39
+ (operation) =>
40
+ /**
41
+ *
42
+ * @param {string} message
43
+ * @param {object} errInfo
44
+ * @returns {CollectionAbstractError}
45
+ */
46
+ (message, errInfo) =>
47
+ new CollectionAbstractError(collectionName, operation, message, errInfo);
48
+
49
+ // an error with no collection name or info.
50
+ const abstractError = errorConstructor(undefined)(undefined);
51
+
52
+ /**
53
+ * abstract class defining common processes to interact with a mongo collectios: inserts, updates, errors...
54
+ * this class contains agnostic methods and data that can be applied to any collection.
55
+ * @class
56
+ * @constructor
57
+ * @public
58
+ */
59
+ class CollectionAbstract {
60
+ /**
61
+ * @param {FastifyInstanceType} fastify
62
+ * @param {CollectionNamesType} collectionName
63
+ */
64
+ constructor(fastify, collectionName) {
65
+
66
+ if ( !allowedCollectionNames.includes(collectionName) ) {
67
+ throw new abstractError(`invalid 'collectionName': expected one of ${allowedCollectionNames}, got '${collectionName}'`);
68
+ }
69
+
70
+ const collectionOptions =
71
+ collectionName === "annotations2"
72
+ ? { validator: { $jsonSchema: fastify.schemasPresentation2.getSchema("annotation") } }
73
+ : collectionName === "annotations3"
74
+ ? { validator: { /** TODO */ } }
75
+ : collectionName === "manifests2"
76
+ ? { validator: { $jsonSchema: fastify.schemasPresentation2.getSchema("manifestMongo") } }
77
+ // else: manifets3.
78
+ : { validator: { /** TODO */ }};
79
+
80
+ const iiifPresentationVersion = collectionName.endsWith("2") ? 2 : 3;
81
+
82
+ /** @type {FastifyInstanceType} */
83
+ this.fastify = fastify;
84
+ /** @type {MongoClientType} */
85
+ this.client = fastify.mongo.client;
86
+ /** @type {MongoDbType} */
87
+ this.db = fastify.mongo.db;
88
+ /** @type {MongoCollectionType} */
89
+ this.collection = this.db.collection(collectionName, collectionOptions);
90
+ /** @type {IiifPresentationVersionType} */
91
+ this.iiifPresentationVersion = iiifPresentationVersion;
92
+
93
+ /** @type {Function(string?) => Function(string, object) => Error} */
94
+ this.errorConstructor = errorConstructor(collectionName);
95
+ /** @type {Function(string, object?) => Error} */
96
+ this.errorNoAction = this.errorConstructor(undefined);
97
+ // create this.error(Read|Insert|Update|Delete), properties that will be used to throw the proper error.
98
+ [ "read", "insert", "update", "delete" ].forEach((op) =>
99
+ /** @type {Function(string,object?) => Error} */
100
+ this[`${op}Error`] = errorConstructor(collectionName)(op)
101
+ )
102
+ }
103
+
104
+ //////////////////////////////////////
105
+ // UTILS
106
+
107
+ /** @returns {string} */
108
+ className() {
109
+ return this.constructor.name;
110
+ }
111
+
112
+ /** @param {Function} func */
113
+ funcName(func) {
114
+ if ( typeof func !== "function" ) {
115
+ throw new Error(`${this.className()}.${this.funcName.name} : expected 'func' to be a function, got '${typeof func}' (func = ${func})`);
116
+ }
117
+ return `${this.className()}.${func.name}`
118
+ }
119
+
120
+ /**
121
+ * resolve internal mongo '_id' fields to iiif '@id' fields
122
+ * @param {MongoCollectionType} collection
123
+ * @param {string | string[]} mongoIds
124
+ * @returns {Promise<string[]>}
125
+ */
126
+ async getIiifIdsFromMongoIds(mongoIds) {
127
+ mongoIds = maybeToArray(mongoIds);
128
+ const key = this.iiifPresentationVersion === 2 ? "@id" : "id";
129
+ const collectionIds = await this.collection.find(
130
+ { _id: { $in: mongoIds } },
131
+ { projection: { [key]: 1 } }
132
+ ).toArray();
133
+ return collectionIds.map(a => a[key]);
134
+ }
135
+
136
+ //////////////////////////////////////
137
+ // RESPONSES: what is sent from collection classes
138
+ // to routes and other consumers of the class after an insert/update/delete.
139
+
140
+ /**
141
+ * make a uniform response format for insertOne and insertMany
142
+ * @param {MongoInsertResultType} mongoRes
143
+ * @returns {Promise<InsertResponseType>}
144
+ */
145
+ async makeInsertResponse(mongoRes) {
146
+ // retrieve the "@id"s
147
+ const insertedIds = await this.getIiifIdsFromMongoIds(
148
+ // MongoInsertOneResultType and MongoInsertManyResultType have a different structureex
149
+ mongoRes.insertedId || Object.values(mongoRes.insertedIds)
150
+ );
151
+ return formatInsertResponse(insertedIds);
152
+ }
153
+
154
+ /**
155
+ * @param {MongoUpdateResultType} mongoRes
156
+ * @returns {Promise<UpdateResponseType>}
157
+ */
158
+ async makeUpdateResponse(mongoRes) {
159
+ if (mongoRes.upsertedId) {
160
+ // only 1 entry can be upserted by a mongo query => extract the 1st upserted @id from the mongo @id.
161
+ const upsertedIds = await this.getIiifIdsFromMongoIds(
162
+ mongoRes.upsertedId
163
+ );
164
+ mongoRes.upsertedId = upsertedIds.length ? upsertedIds[0] : mongoRes.upsertedId;
165
+ }
166
+ return formatUpdateResponse(mongoRes);
167
+ }
168
+
169
+ /**
170
+ * throw an error with just the object describing the error data (and not the stack or anything else).
171
+ * used to propagate write errors to routes.
172
+ * @param {DataOperationsType} operation: describes the database operation
173
+ * @param {import("mongodb").MongoServerError} err: the mongo error
174
+ */
175
+ throwMongoError(operation, err) {
176
+ throw this.errorConstructor(operation)(err.message, err.errorResponse);
177
+ }
178
+
179
+ //////////////////////////////////////
180
+ // INSERT/UPDATE
181
+
182
+ /**
183
+ * insert a single document `doc` into `this.collection`.
184
+ * no validation or checking is done here. obviously, `doc` must fit the JsonSchema defined for `this.collection`.
185
+ * @private
186
+ * @param {object} doc
187
+ * @returns {Promise<InsertResponseType>}
188
+ */
189
+ async insertOne(doc) {
190
+ try {
191
+ const result = await this.collection.insertOne(doc);
192
+ return this.makeInsertResponse(result);
193
+ } catch (err) {
194
+ this.throwMongoError("insert", err);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * insert documents from an array of documents.
200
+ * no validation or checking is done here. obviously, documents in `docArr` must fit the JsonSchema defined for `this.collection`.
201
+ * @param {object[]} docArr
202
+ * @returns {Promise<InsertResponseType>}
203
+ */
204
+ async insertMany(docArr) {
205
+ try {
206
+ const result = await this.collection.insertMany(docArr);
207
+ return this.makeInsertResponse(result);
208
+ } catch (err) {
209
+ this.throwMongoError("insert", err);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * update a single document, targeted by an unique identifier (should be "@id" for iiif 3, "id" otherwise).
215
+ * @param {object} query: query targeting a document
216
+ * @param {object} update: the updated document.
217
+ * @returns {Promise<UpdateResponseType>}
218
+ */
219
+ async updateOne(query, update){
220
+ try {
221
+ const result = await this.collection.updateOne(query, update);
222
+ return this.makeUpdateResponse(result);
223
+ } catch (err) {
224
+ this.throwMongoError("update", err)
225
+ }
226
+ }
227
+
228
+ //////////////////////////////////////
229
+ // DELETE
230
+
231
+ /**
232
+ * delete all objects that match `queryObj` from `this.collection`
233
+ * NOTE: if nothing is deleted, it's not an error, we return: { deletedCount: 0 }
234
+ * @param {object} queryObj
235
+ * @returns {Promise<DeleteResponseType>}
236
+ */
237
+ async delete(queryObj) {
238
+ try {
239
+ const deleteResult = await this.collection.deleteMany(queryObj);
240
+ return formatDeleteResponse(deleteResult);
241
+ } catch (err) {
242
+ this.throwMongoError("delete", err);
243
+ }
244
+ }
245
+
246
+ //////////////////////////////////////
247
+ // READ
248
+
249
+ /**
250
+ * true if `queryObj` matches at least 1 document, false otherwise.
251
+ * @param {object} queryObj
252
+ * @returns {Promise<boolean>}
253
+ */
254
+ async exists(queryObj) {
255
+ const r = await this.collection.countDocuments(queryObj, { limit: 1 });
256
+ return r === 1;
257
+ }
258
+
259
+ /**
260
+ * count the number of documents that match `queryObj`.
261
+ * @param {object} queryObj
262
+ * @returns {Promise<number>}
263
+ */
264
+ count(queryObj) {
265
+ return this.collection.countDocuments(queryObj);
266
+ }
267
+
268
+ }
269
+
270
+ export default CollectionAbstract;
@@ -0,0 +1,29 @@
1
+ import fastifyPlugin from "fastify-plugin"
2
+
3
+ import Annotations2 from "#annotations/annotations2.js";
4
+ import Annotations3 from "#annotations/annotations3.js";
5
+ import Manifests2 from "#manifests/manifests2.js";
6
+ import Manifests3 from "#manifests/manifests3.js";
7
+ import annotationsRoutes from "#annotations/routes.js";
8
+ import manifestsRoutes from "#manifests/routes.js";
9
+ import commonRoutes from "#data/routes.js";
10
+
11
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
12
+
13
+ /**
14
+ * @param {FastifyInstanceType} fastify
15
+ * @param {object} options
16
+ */
17
+ function data(fastify, options, done) {
18
+
19
+ fastify.register(Manifests2);
20
+ fastify.register(Manifests3);
21
+ fastify.register(Annotations2);
22
+ fastify.register(Annotations3);
23
+ fastify.register(annotationsRoutes);
24
+ fastify.register(manifestsRoutes);
25
+ fastify.register(commonRoutes);
26
+ done();
27
+ }
28
+
29
+ export default fastifyPlugin(data);
@@ -0,0 +1,305 @@
1
+ import fastifyPlugin from "fastify-plugin";
2
+
3
+ import CollectionAbstract from "#data/collectionAbstract.js";
4
+ import { getManifestShortId, manifestUri } from "#utils/iiif2Utils.js";
5
+ import { formatInsertResponse } from "#src/data/utils/routeUtils.js";
6
+ import { inspectObj, visibleLog, ajvCompile } from "#utils/utils.js";
7
+ import { IIIF_PRESENTATION_2_CONTEXT } from "#utils/iiifUtils.js";
8
+
9
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
10
+ /** @typedef {import("#types").MongoObjectId} MongoObjectId */
11
+ /** @typedef {import("#types").MongoInsertResultType} MongoInsertResultType */
12
+ /** @typedef {import("#types").MongoUpdateResultType} MongoUpdateResultType */
13
+ /** @typedef {import("#types").MongoDeleteResultType} MongoDeleteResultType */
14
+ /** @typedef {import("#types").InsertResponseType} InsertResponseType */
15
+ /** @typedef {import("#types").UpdateResponseType} UpdateResponseType */
16
+ /** @typedef {import("#types").DeleteResponseType} DeleteResponseType */
17
+ /** @typedef {import("#types").DataOperationsType } DataOperationsType */
18
+ /** @typedef {import("#types").AnnotationsDeleteKeyType } AnnotationsDeleteKeyType */
19
+ /** @typedef {import("#types").Manifest2InternalType } Manifest2InternalType */
20
+ /** @typedef {import("#types").AjvValidateFunctionType} AjvValidateFunctionType */
21
+ /** @typedef {import("#types").IiifCollection2Type} IiifCollection2Type */
22
+
23
+ /** @typedef {Manifests2} Manifests2InstanceType */
24
+
25
+
26
+ /**
27
+ * @class
28
+ * @constructor
29
+ * @public
30
+ * @extends {CollectionAbstract}
31
+ */
32
+ class Manifests2 extends CollectionAbstract {
33
+ /**
34
+ * @param {FastifyInstanceType} fastify
35
+ */
36
+ constructor(fastify) {
37
+ super(fastify, "manifests2");
38
+
39
+ /** @type {AjvValidateFunctionType} */
40
+ this.validatorManifest = ajvCompile(fastify.schemasResolver(
41
+ fastify.schemasPresentation2.getSchema("manifestPublic")
42
+ ));
43
+ }
44
+
45
+ /////////////////////////////////////////////
46
+ // utils
47
+
48
+ /**
49
+ * NOTE: PERFORMANCE: using AJV validation is MUCH FASTER than doing manual verifications (-25% execution time for the test suite)
50
+ * @param {object} manifest
51
+ * @returns {void}
52
+ */
53
+ #validateManifest(manifest) {
54
+ if ( !this.validatorManifest(manifest) ) {
55
+ throw this.insertError("validateManifest: invalid manifest structure", manifest);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * convert a manifest to internal data model
61
+ *
62
+ * NOTE: there is no need to reprocess "@id"s: the whole manifest is not stored in the database, so we need to keep all urls and references to external data intact.
63
+ * NOTE: only the 1st (default) sequence is processed. other optional sequences MUST be referenced from the manifest (not embedded). in practice, they are rare, so we don´t process them.
64
+ * see: https://iiif.io/api/presentation/2.1/#sequence
65
+ *
66
+ * @param {object} manifest
67
+ * @returns {Manifest2InternalType}
68
+ */
69
+ #cleanManifest(manifest) {
70
+ return {
71
+ "@id": manifest["@id"],
72
+ "@type": "sc:Manifest",
73
+ manifestShortId: getManifestShortId(manifest["@id"]),
74
+ canvasIds: manifest.sequences[0].canvases.map((canvas) => canvas["@id"])
75
+ };
76
+ }
77
+
78
+ /**
79
+ * validation + cleaning pipeline
80
+ * @param {object} manifest
81
+ * @returns {object}
82
+ */
83
+ #validateAndCleanManifest(manifest) {
84
+ this.#validateManifest(manifest);
85
+ return this.#cleanManifest(manifest);
86
+ }
87
+
88
+ /**
89
+ * fetch a manifest and return it as an object
90
+ * @param {string} manifestUri
91
+ * @returns
92
+ */
93
+ async #fetchManifestFromUri(manifestUri) {
94
+ try {
95
+ const r = await fetch(manifestUri);
96
+ return await r.json();
97
+ } catch (err) {
98
+ throw this.insertError(`error fetching manifest with URI '${manifestUri}'`);
99
+ }
100
+ }
101
+
102
+ /////////////////////////////////////////////
103
+ // write
104
+
105
+ /**
106
+ * save a single manifest to database.
107
+ * @param {object} manifest - a IIIF manifest
108
+ * @returns {Promise<InsertResponseType>}
109
+ */
110
+ async insertManifest(manifest) {
111
+ manifest = this.#validateAndCleanManifest(manifest);
112
+ const manifestExists = await this.exists({ "@id": manifest["@id"] });
113
+ if ( !manifestExists ) {
114
+ return this.insertOne(manifest);
115
+ } else {
116
+ return formatInsertResponse([],[manifest["@id"]])
117
+ }
118
+ }
119
+
120
+ /**
121
+ * insert several manifests to database
122
+ * if `throwOnError===false`, don't raise if there is an error: instead, insert as many documents as possible.
123
+ * see the docs of `insertManifestsFromUriArray` for more info.
124
+ *
125
+ * @param {object[]} manifestArray - array of manifests
126
+ * @returns {Promise<InsertResponseType>}
127
+ */
128
+ async insertManifestArray(manifestArray, throwOnError=true) {
129
+ // build 2 arrays, one of the manifests that pass validation, one of the @ids of the manifests with errors, mapped to an error message.
130
+ let
131
+ cleanManifestArray = [],
132
+ invalidManifestArray = [];
133
+ manifestArray.map((manifest) => {
134
+ try {
135
+ cleanManifestArray.push(this.#validateAndCleanManifest(manifest));
136
+ } catch (err) {
137
+ if ( throwOnError ) {
138
+ throw err;
139
+ }
140
+ // returns a mapping of `{manifestId: errorMessage}`
141
+ invalidManifestArray.push({ [manifest["@id"]]: err.message });
142
+ }
143
+ });
144
+
145
+ // find which manifests are not aldready in the DB, to avoid a unique constraint error.
146
+ // `preExistingIds` = all @ids that are in `cleanManifestArray` that are aldready in the database
147
+ const
148
+ mongoResponse =
149
+ await this.collection.find(
150
+ { "@id": {
151
+ $in: cleanManifestArray.map((manifest) => manifest["@id"])
152
+ }},
153
+ { projection: { "@id": 1 } }
154
+ )
155
+ .toArray(),
156
+ preExistingIds = mongoResponse.map((r) => r["@id"]);
157
+
158
+ // insert. if there has been an error but error-throwing was disabled, complete the response object with description of the errors
159
+ cleanManifestArray = cleanManifestArray.filter((manifest) => !preExistingIds.includes(manifest["@id"]))
160
+ if ( cleanManifestArray.length ) {
161
+ const result = await this.insertMany(cleanManifestArray);
162
+ result.preExistingIds = preExistingIds;
163
+ result.rejectedIds = invalidManifestArray;
164
+ return result;
165
+
166
+ } else {
167
+ return formatInsertResponse(
168
+ [],
169
+ preExistingIds,
170
+ [],
171
+ invalidManifestArray
172
+ );
173
+ }
174
+ }
175
+
176
+ /**
177
+ * insert a manifest from an URI
178
+ * @param {string} manifestUri
179
+ * @returns {Promise<InsertResponseType>}
180
+ */
181
+ async insertManifestFromUri(manifestUri) {
182
+ try {
183
+ const manifest = await this.#fetchManifestFromUri(manifestUri);
184
+ return this.insertManifest(manifest);
185
+ } catch (err) {
186
+ throw this.insertError(`error inserting manifest with URI '${manifestUri}' because of error: ${err.message}`);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * from an array of URIs, insert many manifests.
192
+ *
193
+ * if `throwOnError===false`, the function will not throw. instead, it will try to insert as much as possible, and the response will give info on the failure cases.
194
+ * the first use case for this behaviour is to index all manifests related to an array of annotations. given that we reconstruct
195
+ * manifest URIs from canvas URIs manually, there may always be an error. we want to insert a manifest when possible, and return an error othersise.
196
+ *
197
+ * @param {string[]} manifestUriArray
198
+ * @param {boolean} throwOnError
199
+ * @returns {Promise<InsertResponseType>}
200
+ */
201
+ async insertManifestsFromUriArray(manifestUriArray, throwOnError=true) {
202
+ // PERFORMANCE ~2850ms
203
+ const
204
+ fetchErrorIds = [],
205
+ manifestArray = [];
206
+ await Promise.all(
207
+ manifestUriArray.map(async (manifestUri) => {
208
+ try {
209
+ manifestArray.push(await this.#fetchManifestFromUri(manifestUri));
210
+ } catch (err) {
211
+ if ( throwOnError ) {
212
+ throw err;
213
+ }
214
+ fetchErrorIds.push(manifestUri);
215
+ }
216
+ })
217
+ );
218
+ const result = await this.insertManifestArray(manifestArray, throwOnError);
219
+ // if there has been an error but error-throwing was disabled, complete the response object with description of the errors
220
+ if ( !throwOnError ) {
221
+ result.fetchErrorIds = fetchErrorIds;
222
+ }
223
+ return result;
224
+ }
225
+
226
+ /////////////////////////////////////////////
227
+ // delete
228
+
229
+ /**
230
+ * @param {"manifestShortId"|"uri"} deleteKey = what deleteId describes: a manifest URI or its short ID
231
+ * @param {string} deleteVal - data to delete
232
+ * @returns {Promise<DeleteResponseType>}
233
+ */
234
+ // NOTE: could be refactored with `annotations2.delete`: both functions are the same, only the filter changes
235
+ async deleteManifest(deleteKey, deleteVal) {
236
+ const allowedDeleteKey = ["uri", "manifestShortId"];
237
+ if ( !allowedDeleteKey.includes(deleteKey) ) {
238
+ throw this.deleteError(`${this.funcName(this.deleteManifest)}: expected one of ${allowedDeleteKey}, got '${deleteKey}'`);
239
+ }
240
+
241
+ const deleteFilter =
242
+ deleteKey==="uri"
243
+ ? { "@id": deleteVal }
244
+ : { manifestShortId: deleteVal };
245
+
246
+ return this.delete(deleteFilter);
247
+ }
248
+
249
+ /////////////////////////////////////////////
250
+ // read
251
+
252
+ /**
253
+ * return the position of `canvasUri` within the manifest with ID `manifestUri`,
254
+ * or return `undefined` if the canvas is not found in the manifest, or the manifest is not indexed.
255
+ * @param {string} manifestUri
256
+ * @param {string} canvasUri
257
+ * @returns {Promise<number?>}
258
+ */
259
+ async getCanvasIdx(manifestUri, canvasUri) {
260
+ // NOTE: PERFORMANCE increases using `aggregate` with `$indexOfArray` to find the index of `canvasUri`: up to 30% faster execution of the app's test suite:
261
+ // - with `aggregate`, ~2800ms for the whole test suite to run.
262
+ // - with a native `coll.findOne()` and then getting the canvas ID manually (`arr.indexOf`), ~4000ms for the whole test suite to run.
263
+ // https://www.mongodb.com/docs/manual/aggregation/
264
+ // https://www.mongodb.com/docs/manual/reference/operator/aggregation/indexOfArray/
265
+ /**
266
+ * @type { { _id: MongoObjectId, index: number } | null }
267
+ * if `cursor.next() => null`, no document was found.
268
+ * otherwise the index is returned (-1 if `canvasIdx` was not found in the document)
269
+ */
270
+ const r = await this.collection.aggregate([
271
+ { $match: { "@id": manifestUri } },
272
+ { $project: { index: { $indexOfArray: ["$canvasIds", canvasUri] } } }
273
+ ]).next();
274
+ return r === null
275
+ ? undefined
276
+ : r.index !== -1 ? r.index : undefined;
277
+ }
278
+
279
+ /**
280
+ * return a collection of all manifests in the database.
281
+ * @returns {Promise<IiifCollection2Type>}
282
+ */
283
+ async getManifests() {
284
+
285
+ const manifestIndex = await this.collection.find(
286
+ {},
287
+ { projection: { "@id": 1, "@type": 1, _id: 0 } }
288
+ ).toArray();
289
+ return {
290
+ ...IIIF_PRESENTATION_2_CONTEXT,
291
+ "@type": "sc:Collection",
292
+ "@id": `${process.env.APP_BASE_URL}/manifests/2`,
293
+ label: "Collection of all manifests indexed in the annotation server",
294
+ members: manifestIndex
295
+ }
296
+ }
297
+ }
298
+
299
+ export default fastifyPlugin((fastify, options, done) => {
300
+ fastify.decorate("manifests2", new Manifests2(fastify));
301
+ done();
302
+ }, {
303
+ name: "manifests2",
304
+ })
305
+
@@ -0,0 +1,53 @@
1
+ import test from "node:test";
2
+
3
+ import build from "#src/app.js";
4
+
5
+ import { assertObjectKeysInsert } from "#utils/testUtils.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
+ test("test Manifests2 module", async (t) => {
13
+ const
14
+ fastify = await build("test"),
15
+ {
16
+ manifest2Valid,
17
+ manifest2ValidUri,
18
+ manifest2Invalid,
19
+ manifest2InvalidUri
20
+ } = fastify.fileServer;
21
+
22
+ await fastify.ready();
23
+ // close the app after running the tests
24
+ t.after(async () => await fastify.close());
25
+ // after each subtest has run, delete all database records
26
+ t.afterEach(async() => fastify.emptyCollections());
27
+
28
+ // NOTE: it is necessary to run the app because internally there are fetches to external data.
29
+ try {
30
+ await fastify.listen({ port: process.env.APP_PORT });
31
+ } catch (err) {
32
+ console.log("FASTIFY ERROR", err);
33
+ throw err;
34
+ }
35
+
36
+ await t.test("test Manifests2.insertManifest", async (t) => {
37
+ const r = await fastify.manifests2.insertManifest(manifest2Valid);
38
+ assertObjectKeysInsert(t, r);
39
+
40
+ // insertion should fail. since we are not inserting through HTTPs, we can't test error response keys
41
+ await t.assert.rejects(fastify.manifests2.insertManifest(manifest2Invalid));
42
+ })
43
+
44
+ await t.test("test Manifests2.insertManifestFromUri", async (t) => {
45
+ const r = await fastify.manifests2.insertManifestFromUri(manifest2ValidUri.uri);
46
+ assertObjectKeysInsert(t, r);
47
+
48
+ // insertion should fail. since we are not inserting through HTTPs, we can't test error response keys
49
+ await t.assert.rejects(fastify.manifests2.insertManifest(manifest2InvalidUri.uri));
50
+ })
51
+
52
+ return
53
+ })
@@ -0,0 +1,23 @@
1
+ import fastifyPlugin from "fastify-plugin";
2
+
3
+ import CollectionAbstract from "#data/collectionAbstract.js";
4
+
5
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
6
+
7
+ /** @typedef {Manifests3} Manifests3InstanceType */
8
+
9
+ class Manifests3 extends CollectionAbstract {
10
+ /**
11
+ * @param {FastifyInstanceType} fastify
12
+ */
13
+ constructor(fastify) {
14
+ super(fastify, "manifests3");
15
+ }
16
+ }
17
+
18
+ export default fastifyPlugin((fastify, options, done) => {
19
+ fastify.decorate("manifests3", new Manifests3(fastify));
20
+ done();
21
+ }, {
22
+ name: "manifests3",
23
+ })