aiiinotate 0.8.5 → 0.9.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/README.md CHANGED
@@ -176,6 +176,20 @@ npm run migrate -- <command> <arguments?>
176
176
  npm run cli -- import <arguments>
177
177
  ```
178
178
 
179
+ ---
180
+
181
+ ## Test coverage
182
+
183
+ aiiinotate is well tested: **over 90% test coverage** on all files !
184
+
185
+ ```
186
+ ℹ ----------------------------------------------------------------------------------------
187
+ ℹ file | line % | branch % | funcs % | uncovered lines
188
+ ℹ ----------------------------------------------------------------------------------------
189
+ ℹ all files | 90.02 | 79.43 | 78.73 |
190
+ ℹ ----------------------------------------------------------------------------------------
191
+ ```
192
+
179
193
  ---
180
194
 
181
195
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiiinotate",
3
- "version": "0.8.5",
3
+ "version": "0.9.0",
4
4
  "description": "a fast IIIF-compliant annotation server",
5
5
  "main": "./cli/index.js",
6
6
  "type": "module",
@@ -18,8 +18,7 @@
18
18
  "lint": "npx eslint --fix",
19
19
  "migrate": "npm run cli -- migrate",
20
20
  "update_version": "python3 scripts/update_version.py",
21
- "get_version": "python3 scripts/get_version.py",
22
- "ttt": "echo 'HELLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLO'"
21
+ "get_version": "python3 scripts/get_version.py"
23
22
  },
24
23
  "pre-commit": [
25
24
  "lint"
package/scripts/run.sh CHANGED
@@ -27,7 +27,7 @@ case "$SCRIPT" in
27
27
  nodemon --watch ./src --exec "bash -c '$DOTENVX_BIN run -f $ENV_PATH -- node $ROOT_DIR/cli/index.js serve dev'";
28
28
  ;;
29
29
  test)
30
- "$DOTENVX_BIN" run -f "$ENV_PATH" -- node --test --test-isolation=none
30
+ "$DOTENVX_BIN" run -f "$ENV_PATH" -- node --test --test-isolation=none --experimental-test-coverage
31
31
  ;;
32
32
  *)
33
33
  echo "Unknown run mode: $SCRIPT. Exiting...";
@@ -7,7 +7,7 @@ import fastifyPlugin from "fastify-plugin";
7
7
  import CollectionAbstract from "#data/collectionAbstract.js";
8
8
  import { STRICT_MODE } from "#constants";
9
9
  import { IIIF_PRESENTATION_2_CONTEXT } from "#utils/iiifUtils.js";
10
- import { ajvCompile, objectHasKey, isNullish, maybeToArray, visibleLog, memoize } from "#utils/utils.js";
10
+ import { ajvCompile, objectHasKey, isNullish, maybeToArray, visibleLog, memoize, getFirstNonEmptyPair } from "#utils/utils.js";
11
11
  import { getManifestShortId, makeTarget, makeAnnotationId, toAnnotationList, canvasUriToManifestUri } from "#utils/iiif2Utils.js";
12
12
  import { PAGE_SIZE } from "#constants";
13
13
 
@@ -62,7 +62,7 @@ class Annotations2 extends CollectionAbstract {
62
62
 
63
63
 
64
64
  /**
65
- * @type {() => Promise<number>}
65
+ * @type {(object) => Promise<number>}
66
66
  * cache the number of documents corresponding to a paginated query in a JS cache
67
67
  * a simple cache avoids rerunning a count to get the total number of documents for each page of a paginated query
68
68
  * see: https://dev.to/codewithjohnson/the-power-of-a-simple-cache-system-with-javascript-map-3j01
@@ -76,15 +76,28 @@ class Annotations2 extends CollectionAbstract {
76
76
  * @returns
77
77
  */
78
78
  #expandRouteAnnotationFilter(filterKey, filterVal) {
79
- const allowedFilterKeys = [ "uri", "manifestShortId", "canvasUri" ];
79
+ const allowedFilterKeys = [ "uri", "manifestShortId", "canvasUri", "tag" ];
80
80
  if ( !allowedFilterKeys.includes(filterKey) ) {
81
81
  throw new Error(`${this.funcname(this.#expandRouteAnnotationFilter)}: expected one of ${allowedFilterKeys} for param 'deleteKey', got '${filterKey}'`)
82
82
  }
83
- return filterKey==="uri"
84
- ? { "@id": filterVal }
85
- : filterKey==="canvasUri"
86
- ? { "on.full": filterVal }
87
- : { "on.manifestShortId": filterVal };
83
+ const map = {
84
+ uri: { "@id": filterVal },
85
+ canvasUri: { "on.full": filterVal },
86
+ manifestShortId: { "on.manifestShortId": filterVal },
87
+ tag: {
88
+ $and: [
89
+ {
90
+ // schema accepts both oa:Tag and Tag
91
+ $or: [
92
+ {"resource.@type": "oa:Tag"},
93
+ {"resource.@type": "Tag"}
94
+ ]
95
+ },
96
+ { "resource.chars": filterVal }
97
+ ]
98
+ }
99
+ }
100
+ return map[filterKey];
88
101
  }
89
102
 
90
103
  /**
@@ -412,16 +425,30 @@ class Annotations2 extends CollectionAbstract {
412
425
  // delete
413
426
 
414
427
  /**
415
- * @param {AnnotationsDeleteKeyType} deleteKey - what deleteVal describes: an annotation's '@id', a manifest's URI...
416
- * @param {string} deleteVal - deletion key
428
+ * @param {Object<string,string>} deleteFilter - filter for the annotations to delete
417
429
  * @returns {Promise<DeleteResponseType>}
418
430
  */
419
- async deleteAnnotations(deleteKey, deleteVal) {
431
+ async deleteAnnotations(deleteFilter) {
432
+ const err = (message) => this.deleteError(`${this.funcName(this.deleteAnnotations)}: ${message}`);
420
433
  try {
421
- const deleteFilter = this.#expandRouteAnnotationFilter(deleteKey, deleteVal);
422
- return this.delete(deleteFilter);
434
+ let expandedDeleteFilter;
435
+ if ( Object.keys(deleteFilter).includes("tag") ) {
436
+ // should be validated by the route's JSONSchema, but just in case.
437
+ if ( ! Object.keys(deleteFilter).includes("manifestShortId") ) {
438
+ throw err("Cannot delete by \"tag\" without also filtering by \"manifestShortId\" !")
439
+ }
440
+ const expand = (k) => this.#expandRouteAnnotationFilter(k, deleteFilter[k]);
441
+ expandedDeleteFilter = {
442
+ ...expand("tag"),
443
+ ...expand("manifestShortId")
444
+ }
445
+ } else {
446
+ const [deleteKey, deleteVal] = getFirstNonEmptyPair(deleteFilter);
447
+ expandedDeleteFilter = this.#expandRouteAnnotationFilter(deleteKey, deleteVal);
448
+ }
449
+ return this.delete(expandedDeleteFilter);
423
450
  } catch (err) {
424
- throw this.deleteError(`${this.funcName(this.deleteAnnotations)}: ${err.message}`)
451
+ throw err(err.message);
425
452
  }
426
453
  }
427
454
 
@@ -3,7 +3,7 @@ import fastifyPlugin from "fastify-plugin";
3
3
  import CollectionAbstract from "#data/collectionAbstract.js";
4
4
  import { getManifestShortId } from "#utils/iiif2Utils.js";
5
5
  import { formatInsertResponse } from "#utils/routeUtils.js";
6
- import { inspectObj, visibleLog, ajvCompile } from "#utils/utils.js";
6
+ import { inspectObj, visibleLog, ajvCompile, memoize } from "#utils/utils.js";
7
7
  import { IIIF_PRESENTATION_2_CONTEXT } from "#utils/iiifUtils.js";
8
8
  import { BASE_URL } from "#constants";
9
9
 
@@ -46,6 +46,23 @@ class Manifests2 extends CollectionAbstract {
46
46
  /////////////////////////////////////////////
47
47
  // utils
48
48
 
49
+ /**
50
+ * fetch the array of canvasIds for a single manifest.
51
+ * since, in AIKON after a RegionExtraction, an annotation insert
52
+ * is done once per canvas, and for each annotation insert, we
53
+ * fetch the canvas index, we memoize the canvas list for each manifest URI
54
+ * to avoid multiplying database calls.
55
+ * @type {(string) => Promise<string[]>}
56
+ */
57
+ #memoizeGetManifestCanvasIds = memoize(async (manifestUri) => {
58
+ const doc = await this.collection
59
+ .findOne(
60
+ { "@id": manifestUri },
61
+ { projection: { canvasIds: 1, _id: 0 } } // findOne is enough, there's only one manifest per URI
62
+ );
63
+ return doc?.canvasIds ?? [];
64
+ }, 60_000);
65
+
49
66
  /**
50
67
  * NOTE: PERFORMANCE: using AJV validation is MUCH FASTER than doing manual verifications (-25% execution time for the test suite)
51
68
  * @param {object} manifest
@@ -295,23 +312,18 @@ class Manifests2 extends CollectionAbstract {
295
312
  * @returns {Promise<number?>}
296
313
  */
297
314
  async getCanvasIdx(manifestUri, canvasUri) {
298
- // NOTE: PERFORMANCE increases using `aggregate` with `$indexOfArray` to find the index of `canvasUri`: up to 30% faster execution of the app's test suite:
315
+ // old method without memoization.
299
316
  // - with `aggregate`, ~2800ms for the whole test suite to run.
300
317
  // - with a native `coll.findOne()` and then getting the canvas ID manually (`arr.indexOf`), ~4000ms for the whole test suite to run.
301
318
  // https://www.mongodb.com/docs/manual/aggregation/
302
319
  // https://www.mongodb.com/docs/manual/reference/operator/aggregation/indexOfArray/
303
- /**
304
- * @type { { _id: MongoObjectId, index: number } | null }
305
- * if `cursor.next() => null`, no document was found.
306
- * otherwise the index is returned (-1 if `canvasIdx` was not found in the document)
307
- */
308
- const r = await this.collection.aggregate([
309
- { $match: { "@id": manifestUri } },
310
- { $project: { index: { $indexOfArray: ["$canvasIds", canvasUri] } } }
311
- ]).next();
312
- return r === null
313
- ? undefined
314
- : r.index !== -1 ? r.index : undefined;
320
+ // const r = await this.collection.aggregate([
321
+ // { $match: { "@id": manifestUri } },
322
+ // { $project: { index: { $indexOfArray: ["$canvasIds", canvasUri] } } }
323
+ // ]).next();
324
+ const r = await this.#memoizeGetManifestCanvasIds(manifestUri);
325
+ const index = r.indexOf(canvasUri);
326
+ return index !== -1 ? index : undefined
315
327
  }
316
328
 
317
329
  /**
@@ -29,7 +29,7 @@ function commonRoutes(fastify, options, done) {
29
29
  routeDeleteSchema = fastify.schemasRoutes.getSchema("routeDelete"),
30
30
  responsePostSchema = makeResponsePostSchema(fastify),
31
31
  validatorRouteAnnotationDeleteSchema = ajvCompile(fastify.schemasResolver(
32
- fastify.schemasRoutes.getSchema("routeAnnotationFilter")
32
+ fastify.schemasRoutes.getSchema("routeAnnotationDeleteFilter")
33
33
  )),
34
34
  validatorRouteManifestDeleteSchema = ajvCompile(fastify.schemasResolver(
35
35
  fastify.schemasRoutes.getSchema("routeManifestFilter")
@@ -147,7 +147,6 @@ function commonRoutes(fastify, options, done) {
147
147
  ? validatorRouteAnnotationDeleteSchema
148
148
  : validatorRouteManifestDeleteSchema,
149
149
  error = new Error(`Error validating DELETE route on collection '${collectionName}' with queryString '${inspectObj(query)}'`);
150
-
151
150
  if ( !validator(query) ) {
152
151
  returnError(request, reply, error, {}, 400);
153
152
  }
@@ -155,16 +154,16 @@ function commonRoutes(fastify, options, done) {
155
154
  }
156
155
  },
157
156
  async (request, reply) => {
158
- const
159
- { collectionName, iiifPresentationVersion } = request.params,
160
- [ deleteKey, deleteVal ] = getFirstNonEmptyPair(request.query);
157
+ const { collectionName, iiifPresentationVersion } = request.params;
161
158
 
162
159
  try {
163
160
  if ( collectionName==="annotations" ) {
161
+ const deleteFilter = request.query;
164
162
  return iiifPresentationVersion === 2
165
- ? await annotations2.deleteAnnotations(deleteKey, deleteVal)
163
+ ? await annotations2.deleteAnnotations(deleteFilter)
166
164
  : annotations3.notImplementedError();
167
165
  } else {
166
+ const [ deleteKey, deleteVal ] = getFirstNonEmptyPair(request.query);
168
167
  return iiifPresentationVersion === 2
169
168
  ? await manifests2.deleteManifest(deleteKey, deleteVal)
170
169
  : manifests3.notImplementedError();
@@ -58,7 +58,10 @@ test("test common routes", async (t) => {
58
58
  await t.test("test preValidation hook for queryString validation", async (t) => {
59
59
  const data = [
60
60
  ["/manifests/2/delete?canvasUri=xxx", false], // canvasUri is only allowed if `collectionName==="annotations"` => will fail.
61
- ["/manifests/2/delete?manifestShortId=xxx", true]
61
+ ["/annotations/2/delete?tag=xxx", false], // if using tag, manifestShortId must also be defined
62
+ ["/manifests/2/delete?tag=xxx&manifestShortId=xxx", false], // tag is not allowed with `manifests`
63
+ ["/annotations/2/delete?tag=xxx&manifestShortId=xxx", true],
64
+ ["/manifests/2/delete?manifestShortId=xxx", true],
62
65
  ];
63
66
  for ( let i=0; i<data.length; i++ ) {
64
67
  const [url, expectSuccess] = data.at(i);
@@ -125,6 +128,33 @@ test("test common routes", async (t) => {
125
128
 
126
129
  await deletePipeline(true);
127
130
  await deletePipeline(false);
131
+ await fastify.emptyCollections();
132
+
133
+ await t.test("test route /annotations/:iiifPresentationVersion/delete with param 'tag'", async (t) => {
134
+ const
135
+ tags = ["tag1", "tag2", "tag3"],
136
+ // outputs one of the tags at random
137
+ selectTag = () => tags[Math.floor(Math.random() * 3)],
138
+ testTag = selectTag(),
139
+ // avoid changes to the global annotationList by cloning it.
140
+ annotationListCopy = structuredClone(annotationList),
141
+ // all annotations in the anno list are on the same manifest
142
+ manifestShortId = getManifestShortId(annotationList.resources[0].on);
143
+
144
+ annotationListCopy.resources = annotationList.resources.map((anno) => {
145
+ anno.resource = {
146
+ "@type": "oa:Tag",
147
+ "chars": selectTag()
148
+ }
149
+ return anno;
150
+ })
151
+ const expectedDeletedCount = annotationListCopy.resources.filter((anno) =>
152
+ anno.resource["@type"]==="oa:Tag"
153
+ && anno.resource["chars"]===testTag
154
+ ).length;
155
+ await injectTestAnnotations(fastify, t, annotationList);
156
+ await testDeleteRoute(t, `/annotations/2/delete?manifestShortId=${manifestShortId}&tag=${testTag}`, expectedDeletedCount);
157
+ })
128
158
  });
129
159
 
130
160
  })
@@ -165,6 +165,22 @@ function addSchemas(fastify, options, done) {
165
165
  ]
166
166
  })
167
167
 
168
+ // to delete an annotation by tag, you must also provide a manifestShortId (to avoid deleting all annotations with this tag in the entire db)
169
+ fastify.addSchema({
170
+ $id: makeSchemaUri("routeAnnotationFilterTag"),
171
+ type: "object",
172
+ required: ["tag", "manifestShortId"],
173
+ properties: {
174
+ manifestShortId: {
175
+ type: "string", description: "delete all annotations for a single manifest"
176
+ },
177
+ tag: {
178
+ type: "string", description: "delete allannotations for a single tag"
179
+ }
180
+ },
181
+ additionalProperties: false
182
+ })
183
+
168
184
  // for annotations: key-value pairs to filter a manifests collection by
169
185
  fastify.addSchema({
170
186
  $id: makeSchemaUri("routeAnnotationFilter"),
@@ -172,21 +188,33 @@ function addSchemas(fastify, options, done) {
172
188
  {
173
189
  type: "object",
174
190
  required: ["uri"],
175
- properties: { uri: { type: "string", description: "delete the annotation with this '@id'" } }
191
+ properties: { uri: { type: "string", description: "delete the annotation with this '@id'" } },
192
+ additionalProperties: false
176
193
  },
177
194
  {
178
195
  type: "object",
179
196
  required: ["manifestShortId"],
180
- properties: { manifestShortId: { type: "string", description: "delete all annotations for a single manifest" } }
197
+ properties: { manifestShortId: { type: "string", description: "delete all annotations for a single manifest" } },
198
+ additionalProperties: false
181
199
  },
182
200
  {
183
201
  type: "object",
184
202
  required: ["canvasUri"],
185
- properties: { canvasUri: { type: "string", description: "delete all annotations for a single canvas" } }
203
+ properties: { canvasUri: { type: "string", description: "delete all annotations for a single canvas" } },
204
+ additionalProperties: false
186
205
  }
187
206
  ]
188
207
  })
189
208
 
209
+ // delete either by tag+manifestShortId, or by annotation URI, manifestShortId, canvasUri
210
+ fastify.addSchema({
211
+ $id: makeSchemaUri("routeAnnotationDeleteFilter"),
212
+ oneOf: [
213
+ { $ref: makeSchemaUri("routeAnnotationFilterTag") },
214
+ { $ref: makeSchemaUri("routeAnnotationFilter") },
215
+ ]
216
+ })
217
+
190
218
  ////////////////////////////////////////////////////////
191
219
  // MANIFESTS ROUTES
192
220
 
@@ -211,12 +239,14 @@ function addSchemas(fastify, options, done) {
211
239
  {
212
240
  type: "object",
213
241
  required: ["uri"],
214
- properties: { uri: { type: "string" } }
242
+ properties: { uri: { type: "string" } },
243
+ additionalProperties: false
215
244
  },
216
245
  {
217
246
  type: "object",
218
247
  required: ["manifestShortId"],
219
- properties: { manifestShortId: { type: "string" } }
248
+ properties: { manifestShortId: { type: "string" } },
249
+ additionalProperties: false
220
250
  }
221
251
  ]
222
252
  });