aiiinotate 0.5.1 → 0.6.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 (28) hide show
  1. package/config/.env.template +3 -0
  2. package/docs/endpoints.md +34 -18
  3. package/docs/specifications/2_iiif_apis.md +32 -0
  4. package/migrations/migrationScripts/20250825185706-collections.js +17 -10
  5. package/migrations/migrationScripts/20250904080710-annotations2-indexes.js +69 -0
  6. package/migrations/migrationScripts/20251006212110-manifests2-indexes.js +35 -0
  7. package/package.json +5 -2
  8. package/src/data/annotations/annotations2.js +121 -39
  9. package/src/data/annotations/routes.js +11 -7
  10. package/src/data/annotations/routes.test.js +38 -30
  11. package/src/data/manifests/routes.js +0 -1
  12. package/src/data/routes.js +7 -5
  13. package/src/data/routes.test.js +16 -1
  14. package/src/fixtures/annotations.js +2 -0
  15. package/src/fixtures/data/annotations2SvgValid.jsonld +81 -0
  16. package/src/fixtures/index.js +2 -1
  17. package/src/schemas/schemasPresentation2.js +7 -1
  18. package/src/utils/iiif2Utils.js +158 -49
  19. package/src/utils/iiif2Utils.test.js +65 -18
  20. package/src/utils/routeUtils.js +26 -2
  21. package/src/utils/svg.js +416 -0
  22. package/src/utils/testUtils.js +32 -4
  23. package/src/utils/utils.js +97 -1
  24. package/migrations/migrationScripts/20250826194832-annotations2-canvas-index.js +0 -31
  25. package/migrations/migrationScripts/20251006212110-manifest-unique-index.js +0 -29
  26. package/migrations/migrationScripts/20251028115614-annotations2-id-index.js +0 -27
  27. /package/migrations/migrationScripts/{20250904080710-annotations2-schema.js → 20250826194832-annotations2-schema.js} +0 -0
  28. /package/migrations/migrationScripts/{20251002141951-manifest2-schema.js → 20251002141951-manifests2-schema.js} +0 -0
@@ -14,6 +14,9 @@ AIIINOTATE_HOST=127.0.0.1
14
14
  # HTTP scheme: HTTP or HTTPS. should be HTTP in dev and in docker
15
15
  AIIINOTATE_SCHEME=http
16
16
 
17
+ # max number of items to display per result page
18
+ PAGE_SIZE=5000
19
+
17
20
  # IGNORE
18
21
  AIIINOTATE_BASE_URL="$AIIINOTATE_SCHEME://$AIIINOTATE_HOST:$AIIINOTATE_PORT"
19
22
  # IGNORE
package/docs/endpoints.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## Introductory notes
4
4
 
5
+ ### Terminology
6
+
7
+ In the docs below,
8
+
9
+ - `Parameters` describes route parameters (dynamic segments of a route's URL).
10
+ - `Query` describes the query string in a key-value format
11
+
12
+ ### IIIF Version
13
+
5
14
  **aiiinotate** is meant to be able to handle both IIIF presentation APIs: the most common [2.x](https://iiif.io/api/presentation/2.1) and the more recent [3.x](https://iiif.io/api/presentation/3.0). Both APIs define a data structure for manifests, annotations, lists of annotations and collections of manifests.
6
15
 
7
16
  **HOWEVER, in aiiinotate, IIIF Presentation v2 and v3 data are isolated**: they form two separate collections, and no conversion is done between IIIF 2.x and 3.x data. This means that:
@@ -30,10 +39,10 @@ Implementation of the [IIIF Search API](https://iiif.io/api/search/2.0/), to sea
30
39
 
31
40
  #### Request
32
41
 
33
- - Variables:
42
+ - Parameters:
34
43
  - `iiif_version` (`2 | 3`): the IIIF aearch API version. 2 is for IIIF Presentation API 3.x, 1 is for IIIF Presentation API 2.x
35
44
  - `manifest_short_id` (`string`): the ID of the manifest. See the *IIIF URIs* section.
36
- - Parameters:
45
+ - Query:
37
46
  - `q` (`string`): query string.
38
47
  - if `iiif_version=1`, `q` is searched in the fields: `@id`, `resource.@id` or `resource.chars` fields
39
48
  - `motivation` (`painting | non-painting | commenting | describing | tagging | linking`): values for the `motivation` field of an annotation
@@ -41,7 +50,9 @@ Implementation of the [IIIF Search API](https://iiif.io/api/search/2.0/), to sea
41
50
  - `canvasMax` (`number`): a positive integer
42
51
  - `canvasMax` must be greater than `canvasMin`
43
52
  - if `canvasMax` is undefined, then we will only return the annotations that target a canvas at the `canvasMin` position in its manifest.
44
- - `onlyIds` (`boolean`): return just the value of `@id` fields of matched annotations as a `string[]` instead of returning all the annotations,
53
+ - `page` (`number`): results are paginated. This specifies the page number
54
+ - `pageSize` (`number`): number of annotations to display per page. Defaults to `process.env.PAGE_SIZE`.
55
+ - `onlyIds` (`boolean`): return just the value of `@id` fields of matched annotations as a `string[]` instead of returning all the annotations. If `onlyIds=true`, there is no pagination, `page` and `pageSize` won't have any effect.
45
56
 
46
57
  #### Response
47
58
 
@@ -73,10 +84,10 @@ DELETE /{collection_name}/{iiif_version}/delete
73
84
 
74
85
  #### Request
75
86
 
76
- - Variables
87
+ - Parameters:
77
88
  - `collection_name` (`annotations | manifests`): delete an annotation or a manifest
78
89
  - `iiif_version` (`2 | 3`): IIIF presentation version
79
- - Parameters:
90
+ - Query:
80
91
  - if `collection_name = manifests`:
81
92
  - `uri`: the full URI of the manifest to delete
82
93
  - `manifestShortId`: the manifest's identifier
@@ -105,7 +116,7 @@ Returns a Collection of all manifests in your **aiiinotate** instance.
105
116
 
106
117
  #### Request
107
118
 
108
- - Variables:
119
+ - Parameters:
109
120
  - `iiif_version` (`2 | 3`): the IIIF Presentation API version
110
121
 
111
122
  #### Response
@@ -122,7 +133,7 @@ POST /manifests/{iiif_version}/create
122
133
 
123
134
  #### Request
124
135
 
125
- - Variable:
136
+ - Parameters:
126
137
  - `iiif_version` (`2 | 3`): the IIIF Presentation API version of your manifest
127
138
  - Body (`JSON`): the manifest to index in the database
128
139
 
@@ -152,15 +163,20 @@ GET /annotations/{iiif_version}/search
152
163
 
153
164
  #### Request
154
165
 
155
- - Variables:
156
- - `iiif_version` (`2 | 3`): the IIIF Presentation API of your manifests
157
166
  - Parameters:
158
- - `uri` (`string`): the URI of the target canvas
159
- - `asAnnotationList` (`true | false`): format of the response
167
+ - `iiif_version` (`2 | 3`): the IIIF Presentation API of your manifests
168
+ - Query:
169
+ - `canvasUri` (`string`): the URI of the target canvas
170
+ - `page` (`number`): results are paginated. Specifies the page number.
171
+ - `pageSize` (`number`): number of items per page. Defaults to `process.env.PAGE_SIZE`.
160
172
 
161
173
  #### Response
162
174
 
163
- `Object[] | Object`: if `true`, return an array of annotations. Otherwise, return an `AnnotationList`.
175
+ Results are paginated.
176
+
177
+ ```
178
+ AnnotationList | AnnotationPage
179
+ ```
164
180
 
165
181
  ---
166
182
 
@@ -172,9 +188,9 @@ GET /annotations/{iiif_version}/count
172
188
 
173
189
  #### Request
174
190
 
175
- - Variables:
176
- - `iiif_version` (`2 | 3`): the IIIF Presentation API of your manifests
177
191
  - Parameters:
192
+ - `iiif_version` (`2 | 3`): the IIIF Presentation API of your manifests
193
+ - Query:
178
194
  - `uri` (`string`): the annotation's `@id`
179
195
  - `canvasUri` (`string`): the annotation's target canvas (`on.full`)
180
196
  - `manifestShortId` (`string`): the short ID of the annotation's target manifest (`on.manifestShortId`)
@@ -197,7 +213,7 @@ This route allows to query an annotation by its ID by defering its `@id | id` fi
197
213
 
198
214
  #### Request
199
215
 
200
- - Variables:
216
+ - Parameters:
201
217
  - `iiif_version` (`2 | 3`): the IIIF version of the annotation
202
218
  - `manifest_short_id` (`string`): the identifier of the manifest the annotation is related to
203
219
  - `annotation_short_id`: the unique part of the annotation URL
@@ -218,10 +234,10 @@ Create or update a single annotation
218
234
 
219
235
  #### Request
220
236
 
221
- - Variables:
237
+ - Parameters:
222
238
  - `iiif_version` (`2 | 3`): the IIIF version of the annotation
223
239
  - `action` (`create | update`): the action to perform: create or update an annotation
224
- - Parameters:
240
+ - Query:
225
241
  - `throwOnCanvasIndexError` (`boolean`): if there is an error fetching the related manifest, or getting a target canvas' index, throw an error.
226
242
  - Body (`Object`): a IIIF annotation that follows the IIIF Presentation API 2 or 3 (depending on the value of `iiif_version`)
227
243
 
@@ -263,7 +279,7 @@ Batch insert multiple annotations.
263
279
 
264
280
  #### Request
265
281
 
266
- - Parameters:
282
+ - Query:
267
283
  - `iiif_version` (`2 | 3`): the IIIF version of the annotation
268
284
  - Body: either:
269
285
  - a full `AnnotationList | AnnotationPage` embedded in the body (type must match `iiif_version`: AnnotationPage for IIIF 3, AnnotationList for IIIF 2).
@@ -394,3 +394,35 @@ Properties of the annotation list are:
394
394
  }
395
395
  ```
396
396
 
397
+ ### Pagination
398
+
399
+ A paginated resource can be for example a `Collection` or an `AnnotationList`.
400
+
401
+ - A paginated Resource (i.e., a `Collection`)
402
+ - MUST use `first` to link to the first page
403
+ - MUST NOT embed the corresponding list that is being pagiated
404
+ - A page (i.e., a single page of an `AnnotationList`)
405
+ - SHOULD use `within` to refer to its container resource
406
+ - MUST use `next` to provide a link to the next page, if it exists
407
+ - SHOULD use `prev` to provide a link to the previous page, if it exists
408
+ - MAY use `total` to list the total number of resources contained in all pages
409
+ - MAY use `startIndex` to document the index of the first item of the current page, starting from 0 (i.e., if you're on page 3 and there are 100 annotations per page, `startIndex: 300`)
410
+ - MAY inherit descriptive properties from the paged resource (i.e. the `logo` or `attribution`).
411
+
412
+ ```js
413
+ // the first page of an annotation list
414
+ {
415
+ "@context": "http://iiif.io/api/presentation/2/context.json",
416
+ "@id": "http://example.org/iiif/book1/list/l1",
417
+ "@type": "sc:AnnotationList",
418
+
419
+ "startIndex": 0,
420
+ "within": "http://example.org/iiif/book1/layer/transcription",
421
+ "next": "http://example.org/iiif/book1/list/l2",
422
+
423
+ "resources": [
424
+ // Annotations live here ...
425
+ ]
426
+ }
427
+ ```
428
+
@@ -6,6 +6,8 @@
6
6
  * - manifests2 : IIIF manifests following the IIIF 2.1 (and 2.0) presentation API
7
7
  */
8
8
 
9
+ import { inspectObj } from "#utils/utils.js";
10
+
9
11
  const collectionNames = [
10
12
  "annotations3",
11
13
  "annotations2",
@@ -19,10 +21,16 @@ const collectionNames = [
19
21
  * @returns {Promise<void>}
20
22
  */
21
23
  export const up = async (db, client) => {
22
- // See https://github.com/seppevs/migrate-mongo/#creating-a-new-migration-script
23
- collectionNames.forEach((colName) => {
24
- db.createCollection(colName);
25
- })
24
+ // check if a collection exists before recreating it, otherwise you get NameSpaceExists errors.
25
+ const existingCollectionNames =
26
+ ( await db.listCollections().toArray() ).map(coll => coll.name);
27
+
28
+ // create a mongo collection: https://github.com/seppevs/migrate-mongo/#creating-a-new-migration-script
29
+ for (const colName of collectionNames ) {
30
+ if ( !existingCollectionNames.includes(colName) ) {
31
+ db.createCollection(colName);
32
+ }
33
+ }
26
34
  };
27
35
 
28
36
  /**
@@ -31,11 +39,10 @@ export const up = async (db, client) => {
31
39
  * @returns {Promise<void>}
32
40
  */
33
41
  export const down = async (db, client) => {
34
- // Example:
35
- // await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}});
36
- collectionNames.forEach(async (colName) => {
37
- const collection = db.collection(colName);
38
- await collection.drop();
39
- })
42
+ // NOTE : here, `down` does NOT revert the migration: it would delete the collections and their contents, which we don't want. it's ok since this is the first migration.
43
+ // collectionNames.forEach(async (colName) => {
44
+ // const collection = db.collection(colName);
45
+ // await collection.drop();
46
+ // })
40
47
  }
41
48
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * create and manage indexes for the collection `annotations2`
3
+ */
4
+
5
+ import {createIndex, removeIndex} from "../manageIndex.js";
6
+
7
+
8
+ // all filters on arrays are MultiKey index => it NOT a Sort index, but an Equality index (useful for filters)
9
+ const indexes = [
10
+ {
11
+ colName: "annotations2",
12
+ indexSpec: { "@id": 1 },
13
+ indexOptions: { name: "annotationIdIndex" }
14
+ },
15
+ {
16
+ colName: "annotations2",
17
+ indexSpec: { "on.full": 1 },
18
+ indexOptions: { name: "canvasIdIndex" }
19
+ },
20
+ {
21
+ colName: "annotations2",
22
+ indexSpec: { "on.manifestUri": 1 },
23
+ indexOptions: { name: "manifestIdIndex" }
24
+ },
25
+ {
26
+ colName: "annotations2",
27
+ indexSpec: { "on.manifestShortId": 1 },
28
+ indexOptions: { name: "manifestShortIdIndex" }
29
+ },
30
+ {
31
+ colName: "annotations2",
32
+ indexSpec: { "on.canvasIdx": 1 },
33
+ indexOptions: { name: "canvasIdxIndex" }
34
+ },
35
+ {
36
+ colName: "annotations2",
37
+ indexSpec: { "on.resource.@id": 1 },
38
+ indexOptions: { name: "resourceIdIndex" }
39
+ },
40
+ {
41
+ colName: "annotations2",
42
+ indexSpec: { "on.resource.chars": 1 },
43
+ indexOptions: { name: "resourceCharsIndex" }
44
+ }
45
+ ]
46
+
47
+ /**
48
+ * @param {import('mongodb').Db} db
49
+ * @param {import('mongodb').MongoClient} client
50
+ * @returns {Promise<void>}
51
+ */
52
+ export const up = async (db, client) => {
53
+ for ( const { colName, indexSpec, indexOptions } of indexes ) {
54
+ const result = await createIndex(db, colName, indexSpec, indexOptions);
55
+ console.log("created index:", result);
56
+ }
57
+ };
58
+
59
+ /**
60
+ * @param {import('mongodb').Db} db
61
+ * @param {import('mongodb').MongoClient} client
62
+ * @returns {Promise<void>}
63
+ */
64
+ export const down = async (db, client) => {
65
+ for ( const { colName, indexSpec, indexOptions } of indexes ) {
66
+ const result = await removeIndex(db, colName, indexOptions);
67
+ console.log("dropped index:", result);
68
+ }
69
+ };
@@ -0,0 +1,35 @@
1
+ /** create indexes for manifests2 collection */
2
+
3
+ import {createIndex, removeIndex} from "../manageIndex.js";
4
+
5
+ const indexes = [
6
+ {
7
+ colName: "manifests2",
8
+ indexSpec: { "@id": 1 },
9
+ indexOptions: { name: "manifestIdIndex", unique: true }
10
+ },
11
+ ]
12
+
13
+ /**
14
+ * @param {import('mongodb').Db} db
15
+ * @param {import('mongodb').MongoClient} client
16
+ * @returns {Promise<void>}
17
+ */
18
+ export const up = async (db, client) => {
19
+ for ( const { colName, indexSpec, indexOptions } of indexes ) {
20
+ const result = await createIndex(db, colName, indexSpec, indexOptions);
21
+ console.log("created index:", result);
22
+ }
23
+ };
24
+
25
+ /**
26
+ * @param {import('mongodb').Db} db
27
+ * @param {import('mongodb').MongoClient} client
28
+ * @returns {Promise<void>}
29
+ */
30
+ export const down = async (db, client) => {
31
+ for ( const { colName, indexSpec, indexOptions } of indexes ) {
32
+ const result = await removeIndex(db, colName, indexOptions);
33
+ console.log("dropped index:", result);
34
+ }
35
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiiinotate",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "a fast IIIF-compliant annotation server",
5
5
  "main": "./cli/index.js",
6
6
  "type": "module",
@@ -18,7 +18,8 @@
18
18
  "test": "export AIIINOTATE_TESTING=true; sudo systemctl start mongod && dotenvx run -f ./config/.env -- node --test --test-isolation=none",
19
19
  "lint": "npx eslint --fix",
20
20
  "migrate": "npm run cli -- migrate",
21
- "update_version": "python3 scripts/update_version.py"
21
+ "update_version": "python3 scripts/update_version.py",
22
+ "get_version": "python3 scripts/get_version.py"
22
23
  },
23
24
  "pre-commit": [
24
25
  "lint"
@@ -61,9 +62,11 @@
61
62
  "@fastify/mongodb": "^9.0.2",
62
63
  "@fastify/swagger": "^9.5.2",
63
64
  "commander": "^14.0.0",
65
+ "fast-xml-parser": "^5.3.3",
64
66
  "fastify": "^5.5.0",
65
67
  "migrate-mongo": "^12.1.3",
66
68
  "mongodb": "^6.18.0",
69
+ "svg-path-bbox": "^2.1.0",
67
70
  "swagger-markdown": "^3.0.0",
68
71
  "uuid": "^11.1.0"
69
72
  },
@@ -6,10 +6,11 @@ import fastifyPlugin from "fastify-plugin";
6
6
 
7
7
  import CollectionAbstract from "#data/collectionAbstract.js";
8
8
  import { IIIF_PRESENTATION_2_CONTEXT } from "#utils/iiifUtils.js";
9
- import { ajvCompile, objectHasKey, isNullish, maybeToArray, inspectObj, visibleLog } from "#utils/utils.js";
9
+ import { ajvCompile, objectHasKey, isNullish, maybeToArray, inspectObj, visibleLog, memoize } from "#utils/utils.js";
10
10
  import { getManifestShortId, makeTarget, makeAnnotationId, toAnnotationList, canvasUriToManifestUri } from "#utils/iiif2Utils.js";
11
11
 
12
12
 
13
+ /** @typedef {import("mongodb").FindCursor} FindCursor */
13
14
  /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
14
15
  /** @typedef {import("#types").MongoObjectId} MongoObjectId */
15
16
  /** @typedef {import("#types").MongoInsertResultType} MongoInsertResultType */
@@ -58,6 +59,15 @@ class Annotations2 extends CollectionAbstract {
58
59
  ////////////////////////////////////////////////////////////////
59
60
  // utils
60
61
 
62
+
63
+ /**
64
+ * @type {() => Promise<number>}
65
+ * cache the number of documents corresponding to a paginated query in a JS cache
66
+ * a simple cache avoids rerunning a count to get the total number of documents for each page of a paginated query
67
+ * see: https://dev.to/codewithjohnson/the-power-of-a-simple-cache-system-with-javascript-map-3j01
68
+ */
69
+ #memoizePaginationTotalCount = memoize((x) => this.collection.countDocuments(x), 2000);
70
+
61
71
  /**
62
72
  * expand a pair of `filterKey`, `filterVal` following the schema `routeAnnotationFilter` into a proper filter for the `annotations2` collection.
63
73
  * @param {string} filterKey
@@ -93,7 +103,6 @@ class Annotations2 extends CollectionAbstract {
93
103
 
94
104
  // OA stores Textual Body content in `cnt:chars`, IIIF uses `chars`. `value` is sometimes also used
95
105
  resource.chars = resource.value || resource["cnt:chars"] || resource.chars; // may be undefined
96
- // delete the alternate keys
97
106
  [ "value", "cnt:chars" ].map((k) => {
98
107
  if ( Object.keys(resource).includes(k) ) {
99
108
  delete resource[k];
@@ -123,11 +132,11 @@ class Annotations2 extends CollectionAbstract {
123
132
  * @param {boolean} update - set to `true` if performing an update instead of an insert.
124
133
  * @returns {object}
125
134
  */
126
- #cleanAnnotation(annotation, update=false) {
135
+ async #cleanAnnotation(annotation, update=false) {
127
136
  // 1) extract ids and targets. convert the target to an array.
137
+ // we assume that all values of `annotationTargetArray` point to the same manifest => `manifestShortId` is extracted from the 1st target
128
138
  const
129
- annotationTargetArray = makeTarget(annotation),
130
- // we assume that all values of `annotationTargetArray` point to the same manifest => take the manifest of the 1st target
139
+ annotationTargetArray = await makeTarget(annotation),
131
140
  manifestShortId = annotationTargetArray[0].manifestShortId;
132
141
 
133
142
  // in updates, "@id" has aldready been extracted
@@ -176,13 +185,17 @@ class Annotations2 extends CollectionAbstract {
176
185
  * @param {object} annotationList
177
186
  * @returns {object[]}
178
187
  */
179
- #cleanAnnotationList(annotationList) {
188
+ async #cleanAnnotationList(annotationList) {
180
189
  // NOTE: if `this.#cleanAnnotationList` can only be accessed from annotations routes, then this check is useless (has aldready been performed).
181
190
  if ( this.validatorAnnotationList(annotationList) ) {
182
191
  this.errorNoAction("Annotations2.#cleanAnnotationList: could not recognize AnnotationList. see: https://iiif.io/api/presentation/2.1/#annotation-list.", annotationList)
183
192
  }
184
193
  //NOTE: using an arrow function is necessary to avoid losing the scope of `this`. otherwise, `this` is undefined in `#cleanAnnotation`.
185
- return annotationList.resources.map((ressource) => this.#cleanAnnotation(ressource))
194
+ return await Promise.all(
195
+ annotationList.resources.map(async (ressource) =>
196
+ await this.#cleanAnnotation(ressource)
197
+ )
198
+ )
186
199
  }
187
200
 
188
201
  /**
@@ -214,10 +227,6 @@ class Annotations2 extends CollectionAbstract {
214
227
  /** @type {string[]} concatenation of ids of newly inserted manifests and previously inserted manifests. */
215
228
  insertedManifestsIds = insertResponse.insertedIds.concat(insertResponse.preExistingIds || []);
216
229
 
217
- if ( throwOnCanvasIndexError && insertResponse.fetchErrorIds.length ) {
218
- visibleLog("THIS SHOULD NOT HAPPEN")
219
- }
220
-
221
230
  // 3. update annotations with info on manifest and canvas.
222
231
  // if canvasIdx is undefined, throw.
223
232
  annotationData = await Promise.all(
@@ -252,6 +261,57 @@ class Annotations2 extends CollectionAbstract {
252
261
  : annotationData;
253
262
  }
254
263
 
264
+ /**
265
+ * taking a filter document `queryFilter`, return an annotationList with paginated results
266
+ *
267
+ * params:
268
+ * - queryUrl: the `@id` of the annotationList.
269
+ * MUST be an URL to a route that sjupports pagination, in order to set `prev` and `next` in the annotationList.
270
+ * - queryFilter: the filter to apply to the collection
271
+ * - page: current page number
272
+ * - pageSize: number of annotations per page
273
+ * - label: title of the AnnotationList.
274
+ *
275
+ * NOTE: other/more performant forms of pagination than offset: https://medium.com/mongodb/mongodb-pagination-offset-based-vs-keyset-vs-pre-generated-result-pages-4177e05d88ec
276
+ *
277
+ * @param {{
278
+ * queryUrl: string,
279
+ * queryFilter: object,
280
+ * page: number,
281
+ * pageSize: number,
282
+ * label: string?
283
+ * }}
284
+ * @returns {object} - paginated annotation list
285
+ */
286
+ async #paginate({
287
+ queryUrl,
288
+ queryFilter,
289
+ page=1,
290
+ pageSize=process.env.PAGE_SIZE,
291
+ label=undefined
292
+ }) {
293
+ const totalCount = await this.#memoizePaginationTotalCount(queryFilter);
294
+
295
+ const skip = Math.max((page-1) * pageSize, 0); // number of queried items up until the previous page included.
296
+
297
+ const cursor = await this.find(queryFilter, {}, true);
298
+ const annotations = await cursor
299
+ .sort({ "@id": 1 })
300
+ .skip(skip)
301
+ .limit(pageSize)
302
+ .toArray();
303
+
304
+ const hasNext = page * pageSize <= totalCount;
305
+
306
+ return toAnnotationList({
307
+ resources: annotations,
308
+ annotationListId: queryUrl,
309
+ page: page,
310
+ hasNext: hasNext,
311
+ label: label
312
+ });
313
+ }
314
+
255
315
  ////////////////////////////////////////////////////////////////
256
316
  // insert / updates
257
317
 
@@ -267,7 +327,7 @@ class Annotations2 extends CollectionAbstract {
267
327
  * @returns {Promise<InsertResponseType>}
268
328
  */
269
329
  async insertAnnotation(annotation, throwOnCanvasIndexError=false) {
270
- annotation = this.#cleanAnnotation(annotation);
330
+ annotation = await this.#cleanAnnotation(annotation);
271
331
  annotation = await this.#insertManifestsAndGetCanvasIdx(annotation, throwOnCanvasIndexError);
272
332
  return this.insertOne(annotation);
273
333
  }
@@ -280,7 +340,7 @@ class Annotations2 extends CollectionAbstract {
280
340
  */
281
341
  async updateAnnotation(annotation) {
282
342
  // necessary: on insert, the `@id` received is modified by `this.#cleanAnnotationList`.
283
- annotation = this.#cleanAnnotation(annotation, true);
343
+ annotation = await this.#cleanAnnotation(annotation, true);
284
344
  const
285
345
  query = { "@id": annotation["@id"] },
286
346
  update = { $set: annotation };
@@ -300,7 +360,7 @@ class Annotations2 extends CollectionAbstract {
300
360
  */
301
361
  async insertAnnotationList(annotationList, throwOnCanvasIndexError) {
302
362
  let annotationArray;
303
- annotationArray = this.#cleanAnnotationList(annotationList);
363
+ annotationArray = await this.#cleanAnnotationList(annotationList);
304
364
  annotationArray = await this.#insertManifestsAndGetCanvasIdx(annotationArray, throwOnCanvasIndexError);
305
365
  return this.insertMany(annotationArray);
306
366
  }
@@ -331,11 +391,12 @@ class Annotations2 extends CollectionAbstract {
331
391
  * about projection: 0 removes the fields from the response, 1 incldes it (but exclude all others)
332
392
  * see: https://www.mongodb.com/docs/drivers/node/current/crud/query/project/#std-label-node-project
333
393
  * https://stackoverflow.com/questions/74447979/mongoservererror-cannot-do-exclusion-on-field-date-in-inclusion-projection
334
- * @param {object} queryObj
394
+ * @param {object} queryObj - the filter document
335
395
  * @param {object?} projectionObj - extra projection fields to tailor the reponse format
336
- * @returns {Promise<object[]>}
396
+ * @param {boolean} asCursor - return a cursor instead of an array of results
397
+ * @returns {Promise<object[] | FindCursor>}
337
398
  */
338
- async find(queryObj, projectionObj={}) {
399
+ async find(queryObj, projectionObj={}, asCursor=false) {
339
400
  // 1. construct the final projection object, knowing that we can't mix exclusive and inclusive projectin.
340
401
  // presence of `_id` will not cause projections to fail => remove it from values.
341
402
  const projectionValues =
@@ -362,9 +423,12 @@ class Annotations2 extends CollectionAbstract {
362
423
  }
363
424
 
364
425
  // 2. find, project and return
365
- return this.collection
366
- .find(queryObj, { projection: projectionObj })
367
- .toArray();
426
+ const cursor = this.collection.find(queryObj, { projection: projectionObj });
427
+ if ( !asCursor ) {
428
+ return cursor.toArray()
429
+ } else {
430
+ return cursor;
431
+ };
368
432
  }
369
433
 
370
434
  /**
@@ -400,6 +464,8 @@ class Annotations2 extends CollectionAbstract {
400
464
  * canvasMin: number?,
401
465
  * canvasMax: number?,
402
466
  * onlyIds: boolean
467
+ * page: number
468
+ * pageSize: number
403
469
  * }}
404
470
  * @returns {object} annotationList containing results
405
471
  */
@@ -410,15 +476,17 @@ class Annotations2 extends CollectionAbstract {
410
476
  motivation=undefined,
411
477
  canvasMin=undefined,
412
478
  canvasMax=undefined,
413
- onlyIds=false
479
+ onlyIds=false,
480
+ page=1,
481
+ pageSize=process.env.PAGE_SIZE
414
482
  }) {
415
483
  const
416
- queryBase = { "on.manifestShortId": manifestShortId },
417
- queryFilters = { $and: [] };
484
+ filtersBase = { "on.manifestShortId": manifestShortId },
485
+ filtersExtra = { $and: [] };
418
486
 
419
487
  // expand query parameters
420
488
  if ( q ) {
421
- queryFilters.$and.push({
489
+ filtersExtra.$and.push({
422
490
  $or: [
423
491
  { "@id": q },
424
492
  { "resource.@id": q },
@@ -427,7 +495,7 @@ class Annotations2 extends CollectionAbstract {
427
495
  });
428
496
  };
429
497
  if ( motivation ) {
430
- queryFilters.$and.push(
498
+ filtersExtra.$and.push(
431
499
  motivation === "non-painting"
432
500
  ? { motivation: { $ne: "sc:painting" } }
433
501
  : motivation === "painting"
@@ -438,10 +506,10 @@ class Annotations2 extends CollectionAbstract {
438
506
  if ( !isNaN(canvasMin) ) {
439
507
  // if canvasMax is undefined, then search for canvasIdx===canvasMin
440
508
  if ( !canvasMax ) {
441
- queryFilters.$and.push({ "on.canvasIdx": canvasMin })
509
+ filtersExtra.$and.push({ "on.canvasIdx": canvasMin })
442
510
  // if canvasMin and canvasMax, canvasIdx must be in [canvasMin, canvasMax] (inclusive).
443
511
  } else {
444
- queryFilters.$and.push({
512
+ filtersExtra.$and.push({
445
513
  $and: [
446
514
  { "on.canvasIdx": { $gte: canvasMin } },
447
515
  { "on.canvasIdx": { $lte: canvasMax } }
@@ -449,16 +517,22 @@ class Annotations2 extends CollectionAbstract {
449
517
  })
450
518
  }
451
519
  }
452
- const query =
453
- queryFilters.$and.length
454
- ? { ...queryBase, ...queryFilters }
455
- : queryBase;
520
+ const queryFilter =
521
+ filtersExtra.$and.length
522
+ ? { ...filtersBase, ...filtersExtra }
523
+ : filtersBase;
456
524
 
457
525
  if ( !onlyIds ) {
458
- const annotations = await this.find(query);
459
- return toAnnotationList(annotations, queryUrl, `search results for query ${queryUrl}`);
526
+ return await this.#paginate({
527
+ queryUrl,
528
+ queryFilter,
529
+ page,
530
+ pageSize,
531
+ label: `Paginated annotation List (page: ${page}, ${pageSize} items per page)`
532
+ })
460
533
  } else {
461
- return ( await this.find(query, { "@id": 1 }) )
534
+ // NOTE: there is no pagination if `onlyIds` is true
535
+ return ( await this.find(queryFilter, { "@id": 1 }) )
462
536
  .map((ann) => ann["@id"]);
463
537
  }
464
538
  }
@@ -469,11 +543,19 @@ class Annotations2 extends CollectionAbstract {
469
543
  * @param {boolean} asAnnotationList
470
544
  * @returns
471
545
  */
472
- async findByCanvasUri(queryUrl, canvasUri, asAnnotationList=false) {
473
- const annotations = await this.find({ "on.full": canvasUri });
474
- return asAnnotationList
475
- ? toAnnotationList(annotations, queryUrl, `annotations targeting canvas ${canvasUri}`)
476
- : annotations;
546
+ async findByCanvasUri({
547
+ queryUrl,
548
+ canvasUri,
549
+ page=1,
550
+ pageSize=process.env.PAGE_SIZE
551
+ }) {
552
+ return this.#paginate({
553
+ queryUrl,
554
+ queryFilter: { "on.full": canvasUri },
555
+ label: `annotations targeting canvas ${canvasUri}`,
556
+ page,
557
+ pageSize
558
+ });
477
559
  }
478
560
 
479
561
  /**