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.
- package/LICENSE +661 -0
- package/README.md +61 -0
- package/cli/import.js +142 -0
- package/cli/index.js +26 -0
- package/cli/io.js +105 -0
- package/cli/migrate.js +123 -0
- package/cli/mongoClient.js +11 -0
- package/docs/architecture.md +88 -0
- package/docs/db.md +38 -0
- package/docs/dev_iiif_compatibility.md +43 -0
- package/docs/endpoints.md +48 -0
- package/docs/progress.md +159 -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 +396 -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 +27 -0
- package/migrations/baseConfig.js +56 -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 +41 -0
- package/migrations/migrationScripts/20250826194832-annotations2-canvas-index.js +31 -0
- package/migrations/migrationScripts/20250904080710-annotations2-schema.js +42 -0
- package/migrations/migrationScripts/20251002141951-manifest2-schema.js +43 -0
- package/migrations/migrationScripts/20251006212110-manifest-unique-index.js +29 -0
- package/migrations/migrationScripts/20251028115614-annotations2-id-index.js +27 -0
- package/migrations/migrationTemplate.js +25 -0
- package/package.json +78 -0
- package/run.sh +70 -0
- package/scripts/_migrations.sh +79 -0
- package/scripts/_setup.js +31 -0
- package/scripts/setup_mongodb.sh +61 -0
- package/scripts/setup_mongodb_migrate.sh +17 -0
- package/scripts/setup_node.sh +15 -0
- package/scripts/utils.sh +192 -0
- package/setup.sh +20 -0
- package/src/app.js +113 -0
- package/src/config/.env.template +22 -0
- package/src/data/annotations/annotations2.js +419 -0
- package/src/data/annotations/annotations3.js +32 -0
- package/src/data/annotations/routes.js +271 -0
- package/src/data/annotations/routes.test.js +180 -0
- package/src/data/collectionAbstract.js +270 -0
- package/src/data/index.js +29 -0
- package/src/data/manifests/manifests2.js +305 -0
- package/src/data/manifests/manifests2.test.js +53 -0
- package/src/data/manifests/manifests3.js +23 -0
- package/src/data/manifests/routes.js +95 -0
- package/src/data/manifests/routes.test.js +69 -0
- package/src/data/routes.js +141 -0
- package/src/data/routes.test.js +117 -0
- package/src/data/utils/iiif2Utils.js +196 -0
- package/src/data/utils/iiif2Utils.test.js +98 -0
- package/src/data/utils/iiif3Utils.js +0 -0
- package/src/data/utils/iiifUtils.js +18 -0
- package/src/data/utils/routeUtils.js +109 -0
- package/src/data/utils/testUtils.js +253 -0
- package/src/data/utils/utils.js +231 -0
- package/src/db/index.js +48 -0
- package/src/fileServer/annotations.js +39 -0
- package/src/fileServer/data/annotationList_aikon_wit9_man11_anno165_all.jsonld +827 -0
- package/src/fileServer/data/annotationList_vhs_wit250_man250_anno250_all.jsonld +37514 -0
- package/src/fileServer/data/annotationList_vhs_wit253_man253_anno253_all.jsonld +20111 -0
- package/src/fileServer/data/annotations2Invalid.jsonld +39 -0
- package/src/fileServer/data/annotations2Valid.jsonld +39 -0
- package/src/fileServer/data/bnf_invalid_manifest.json +2806 -0
- package/src/fileServer/data/bnf_valid_manifest.json +2817 -0
- package/src/fileServer/data/vhs_wit253_man253_anno253_anno-24.json +1 -0
- package/src/fileServer/index.js +64 -0
- package/src/fileServer/manifests.js +14 -0
- package/src/fileServer/utils.js +35 -0
- package/src/schemas/index.js +20 -0
- package/src/schemas/schemasBase.js +47 -0
- package/src/schemas/schemasPresentation2.js +417 -0
- package/src/schemas/schemasPresentation3.js +57 -0
- package/src/schemas/schemasResolver.js +71 -0
- package/src/schemas/schemasRoutes.js +277 -0
- package/src/server.js +22 -0
- 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
|
+
})
|