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,95 @@
1
+ import fastifyPlugin from "fastify-plugin";
2
+
3
+ import { makeResponsePostSchema, returnError, makeResponseSchema } from "#utils/routeUtils.js";
4
+ import { objectHasKey, getFirstNonEmptyPair, visibleLog } from "#utils/utils.js";
5
+
6
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
7
+ /** @typedef {import("#types").Manifests2InstanceType} Manifests2InstanceType */
8
+ /** @typedef {import("#types").Manifests3InstanceType} Manifests3InstanceType */
9
+
10
+ /**
11
+ * @param {FastifyInstanceType} fastify
12
+ * @param {object} options
13
+ * @param {Function} done
14
+ */
15
+ function manifestsRoutes(fastify, options, done) {
16
+ const
17
+ /** @type {Manifests2InstanceType} */
18
+ manifests2 = fastify.manifests2,
19
+ /** @type {Manifests3InstanceType} */
20
+ manifests3 = fastify.manifests3,
21
+ iiifPresentationVersionSchema = fastify.schemasBase.getSchema("presentation"),
22
+ responsePostSchema = makeResponsePostSchema(fastify),
23
+ collectionSchema = makeResponseSchema(fastify, fastify.schemasPresentation2.getSchema("collection"));
24
+
25
+ ///////////////////////////////////////////////
26
+ // read routes
27
+
28
+ fastify.get(
29
+ "/manifests/:iiifPresentationVersion",
30
+ {
31
+ schema: {
32
+ params: {
33
+ type: "object",
34
+ properties: {
35
+ iiifPresentationVersion: iiifPresentationVersionSchema
36
+ }
37
+ },
38
+ response: collectionSchema,
39
+ }
40
+ },
41
+ async (request, reply) => {
42
+ const { iiifPresentationVersion } = request.params;
43
+ try {
44
+ return iiifPresentationVersion === 2
45
+ ? await manifests2.getManifests()
46
+ : manifests3.notImplementedError();
47
+ } catch (err) {
48
+ returnError(request, reply, err);
49
+ }
50
+ }
51
+ )
52
+
53
+ ///////////////////////////////////////////////
54
+ // insert routes
55
+
56
+ fastify.post(
57
+ "/manifests/:iiifPresentationVersion/create",
58
+ {
59
+ schema: {
60
+ params: {
61
+ type: "object",
62
+ properties: {
63
+ iiifPresentationVersion: iiifPresentationVersionSchema
64
+ }
65
+ },
66
+ body: { $ref: fastify.schemasRoutes.getSchema("manifest2Or3") },
67
+ response: responsePostSchema
68
+ }
69
+ },
70
+ async (request, reply) => {
71
+ const
72
+ { iiifPresentationVersion } = request.params,
73
+ manifestData = request.body;
74
+ try {
75
+ if ( objectHasKey(manifestData, "uri") ) {
76
+ return iiifPresentationVersion === 2
77
+ ? await manifests2.insertManifestFromUri(manifestData.uri)
78
+ : manifests3.notImplementedError();
79
+ } else {
80
+ // visibleLog(await manifests2.insertManifest(manifestData));
81
+ return iiifPresentationVersion === 2
82
+ ? await manifests2.insertManifest(manifestData)
83
+ : manifests3.notImplementedError();
84
+ }
85
+ } catch (err) {
86
+ returnError(request, reply, err, request.body);
87
+ }
88
+ }
89
+ );
90
+
91
+ done();
92
+ }
93
+
94
+ export default fastifyPlugin(manifestsRoutes);
95
+
@@ -0,0 +1,69 @@
1
+ import test from "node:test";
2
+
3
+ import build from "#src/app.js";
4
+ import { visibleLog } from "#utils/utils.js";
5
+ import { getManifestShortId } from "#utils/iiif2Utils.js";
6
+ import { testPostRouteCurry, injectPost, injectTestManifest, assertStatusCode, assertObjectKeys} from "#utils/testUtils.js";
7
+
8
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
9
+ /** @typedef {import("#types").NodeTestType} NodeTestType */
10
+
11
+ test("test manifests Routes", async (t) => {
12
+ const
13
+ fastify = await build("test"),
14
+ testPostRoute = testPostRouteCurry(fastify),
15
+ testPostRouteCreate = testPostRoute("insert"),
16
+ testPostRouteCreateSuccess = testPostRouteCreate(true),
17
+ testPostRouteCreateFailure = testPostRouteCreate(false),
18
+ {
19
+ manifest2Valid,
20
+ manifest2Invalid,
21
+ manifest2ValidUri,
22
+ manifest2InvalidUri,
23
+ } = fastify.fileServer;
24
+
25
+ await fastify.ready();
26
+ // close the app after running the tests
27
+ t.after(async () => await fastify.close());
28
+ // after each subtest has run, delete all database records
29
+ t.afterEach(async() => fastify.emptyCollections());
30
+
31
+ // NOTE: it is necessary to run the app because internally there are fetches to external data.
32
+ try {
33
+ await fastify.listen({ port: process.env.APP_PORT });
34
+ } catch (err) {
35
+ console.log("FASTIFY ERROR", err);
36
+ throw err;
37
+ }
38
+
39
+ await t.test("test route /manifests/:iiifPresentationVersion/create", async (t) => {
40
+ const data = [
41
+ [ [manifest2Valid, manifest2ValidUri], testPostRouteCreateSuccess ],
42
+ [ [manifest2Invalid, manifest2InvalidUri], testPostRouteCreateFailure ]
43
+ ];
44
+ for ( let i=0; i<data.length; i++ ) {
45
+ const [ testData, func ] = data.at(i);
46
+ for ( let j=0; j<testData.length; j++ ) {
47
+ const payload = testData.at(j);
48
+ await func(t, "/manifests/2/create", payload);
49
+ // for some reason, it is necessary to call `emptyCollections` explicitly here to avoid a JSONSchema validation error.
50
+ await fastify.emptyCollections();
51
+ }
52
+ }
53
+ })
54
+
55
+ await t.test("test route /manifests/:iiifPresentationVersion/collection", async (t) => {
56
+ await injectTestManifest(fastify, t, manifest2Valid);
57
+ const r = await fastify.inject({
58
+ method: "GET",
59
+ url: "/manifests/2"
60
+ });
61
+ assertStatusCode(t, r, 200);
62
+ assertObjectKeys(
63
+ t,
64
+ JSON.parse(r.body),
65
+ [ "@context", "@id", "@type", "members", "label" ]
66
+ );
67
+ })
68
+
69
+ })
@@ -0,0 +1,141 @@
1
+ import fastifyPlugin from "fastify-plugin"
2
+
3
+ import { pathToUrl, ajvCompile, inspectObj, getFirstNonEmptyPair } from "#utils/utils.js";
4
+ import { returnError, makeResponsePostSchema } from "#utils/routeUtils.js";
5
+
6
+ /** @typedef {import("#types").Manifests2InstanceType} Manifests2InstanceType */
7
+ /** @typedef {import("#types").Manifests3InstanceType} Manifests3InstanceType */
8
+ /** @typedef {import("#types").Annotations2InstanceType} Annotations2InstanceType */
9
+ /** @typedef {import("#types").Annotations3InstanceType} Annotations3InstanceType */
10
+
11
+ /**
12
+ * @param {FastifyInstanceType} fastify
13
+ * @param {object} options
14
+ * @param {Function} done
15
+ */
16
+ function commonRoutes(fastify, options, done) {
17
+ const
18
+ /** @type {Annotations2InstanceType} */
19
+ annotations2 = fastify.annotations2,
20
+ /** @type {Annotations3InstanceType} */
21
+ annotations3 = fastify.annotations3,
22
+ /** @type {Manifests2InstanceType} */
23
+ manifests2 = fastify.manifests2,
24
+ /** @type {Manifests3InstanceType} */
25
+ manifests3 = fastify.manifests3,
26
+ iiifPresentationVersionSchema = fastify.schemasBase.getSchema("presentation"),
27
+ iiifSearchApiVersionSchema = fastify.schemasBase.getSchema("search"),
28
+ iiifAnnotationListSchema = fastify.schemasPresentation2.getSchema("annotationList"),
29
+ routeDeleteSchema = fastify.schemasRoutes.getSchema("routeDelete"),
30
+ responsePostSchema = makeResponsePostSchema(fastify),
31
+ validatorRouteAnnotationDeleteSchema = ajvCompile(fastify.schemasResolver(
32
+ fastify.schemasRoutes.getSchema("routeAnnotationDelete")
33
+ )),
34
+ validatorRouteManifestDeleteSchema = ajvCompile(fastify.schemasResolver(
35
+ fastify.schemasRoutes.getSchema("routeManifestDelete")
36
+ ));
37
+
38
+ fastify.get(
39
+ "/search-api/:iiifSearchVersion/manifests/:manifestShortId/search",
40
+ {
41
+ schema: {
42
+ params: {
43
+ type: "object",
44
+ properties: {
45
+ iiifSearchVersion: iiifSearchApiVersionSchema,
46
+ manifestShortId: { type: "string" },
47
+ },
48
+ },
49
+ querystring: {
50
+ type: "object",
51
+ properties: {
52
+ q: { type: "string" },
53
+ motivation: {
54
+ type: "string",
55
+ enum: ["painting", "non-painting", "commenting", "describing", "tagging", "linking"]
56
+ }
57
+ }
58
+ },
59
+ response: {
60
+ 200: iiifAnnotationListSchema
61
+ }
62
+ }
63
+ },
64
+ async (request, reply) => {
65
+ const
66
+ queryUrl = pathToUrl(request.url),
67
+ { iiifSearchVersion, manifestShortId } = request.params,
68
+ { q, motivation } = request.query;
69
+
70
+ if ( iiifSearchVersion===1 ) {
71
+ return await annotations2.search(queryUrl, manifestShortId, q, motivation);
72
+ } else {
73
+ annotations3.notImplementedError();
74
+ }
75
+ }
76
+ );
77
+
78
+ /////////////////////////////////////////////
79
+ // DELETE routes
80
+
81
+ /**
82
+ * manifest and annotations work in the same manner, so we group them here.
83
+ * we add a custom `preHandler` to ensure that `queryString` follows the proper schema.
84
+ */
85
+ fastify.delete(
86
+ "/:collectionName/:iiifPresentationVersion/delete",
87
+ {
88
+ schema: {
89
+ params: {
90
+ type: "object",
91
+ properties: {
92
+ collectionName: { type: "string", enum: [ "annotations", "manifests" ] },
93
+ iiifPresentationVersion: iiifPresentationVersionSchema
94
+ }
95
+ },
96
+ queryString: routeDeleteSchema,
97
+ response: responsePostSchema,
98
+ },
99
+ preValidation: async (request, reply) => {
100
+ // implement a custom validation hook: depending on the value of `collectionName`, run different schema validations.
101
+ const
102
+ { collectionName } = request.params,
103
+ query = request.query,
104
+ validator =
105
+ collectionName==="annotations"
106
+ ? validatorRouteAnnotationDeleteSchema
107
+ : validatorRouteManifestDeleteSchema,
108
+ error = new Error(`Error validating DELETE route on collection '${collectionName}' with queryString '${inspectObj(query)}'`);
109
+
110
+ if ( !validator(query) ) {
111
+ returnError(request, reply, error, {}, 400);
112
+ }
113
+ return;
114
+ }
115
+ },
116
+ async (request, reply) => {
117
+ const
118
+ { collectionName, iiifPresentationVersion } = request.params,
119
+ [ deleteKey, deleteVal ] = getFirstNonEmptyPair(request.query);
120
+
121
+ try {
122
+ if ( collectionName==="annotations" ) {
123
+ return iiifPresentationVersion === 2
124
+ ? await annotations2.deleteAnnotations(deleteKey, deleteVal)
125
+ : annotations3.notImplementedError();
126
+ } else {
127
+ return iiifPresentationVersion === 2
128
+ ? await manifests2.deleteManifest(deleteKey, deleteVal)
129
+ : manifests3.notImplementedError();
130
+ }
131
+ } catch (err) {
132
+ returnError(request, reply, err, request.body);
133
+ }
134
+
135
+ }
136
+ )
137
+
138
+ done();
139
+ }
140
+
141
+ export default fastifyPlugin(commonRoutes);
@@ -0,0 +1,117 @@
1
+ import test from "node:test";
2
+
3
+ import { v4 as uuid4 } from "uuid";
4
+
5
+ import build from "#src/app.js";
6
+ import { getRandomItem } from "#utils/utils.js";
7
+ import { getManifestShortId } from "#utils/iiif2Utils.js";
8
+ import { testPostRouteCurry, testDeleteRouteCurry, injectPost, injectTestManifest, injectTestAnnotations, assertErrorValidResponse, assertDeleteValidResponse } from "#utils/testUtils.js";
9
+
10
+ /** @typedef {import("#types").FastifyInstanceType} FastifyInstanceType */
11
+ /** @typedef {import("#types").NodeTestType} NodeTestType */
12
+
13
+ test("test common routes", async (t) => {
14
+ const
15
+ /** @type {FastifyInstanceType} */
16
+ fastify = await build("test"),
17
+ testPostRoute = testPostRouteCurry(fastify),
18
+ testDeleteRoute = testDeleteRouteCurry(fastify),
19
+ { manifest2Valid, annotationList } = fastify.fileServer;
20
+
21
+ await fastify.ready();
22
+ // close the app after running the tests
23
+ t.after(async () => await fastify.close());
24
+ // after each subtest has run, delete all database records
25
+ t.afterEach(async () => fastify.emptyCollections());
26
+
27
+ // NOTE: it is necessary to run the app because internally there are fetches to external data.
28
+ try {
29
+ await fastify.listen({ port: process.env.APP_PORT });
30
+ } catch (err) {
31
+ console.log("FASTIFY ERROR", err);
32
+ throw err;
33
+ }
34
+
35
+ ////////////////////////////////////////////////
36
+ // DELETE routes
37
+
38
+ await t.test("test route /:collectionName/:iiifPresentationVersion/delete", async (t) => {
39
+
40
+ await t.test("test preValidation hook for queryString validation", async (t) => {
41
+ const data = [
42
+ ["/manifests/2/delete?canvasUri=xxx", false], // canvasUri is only allowed if `collectionName==="annotations"` => will fail.
43
+ ["/manifests/2/delete?manifestShortId=xxx", true]
44
+ ];
45
+ for ( let i=0; i<data.length; i++ ) {
46
+ const [url, expectSuccess] = data.at(i);
47
+ const r = await fastify.inject({
48
+ method: "DELETE",
49
+ url: url
50
+ })
51
+ expectSuccess
52
+ ? assertDeleteValidResponse(t, r)
53
+ : assertErrorValidResponse(t, r, 400);
54
+ }
55
+ });
56
+
57
+ await t.test("test route /manifests/:iiifPresentationVersion/delete", async (t) => {
58
+ const
59
+ manifest = manifest2Valid,
60
+ deleteQuery = [
61
+ [ "uri", manifest["@id"] ],
62
+ [ "manifestShortId", getManifestShortId(manifest["@id"]) ]
63
+ ];
64
+
65
+ for ( let i=0; i<deleteQuery.length; i++ ) {
66
+ const [deleteBy, deleteKey] = deleteQuery.at(i);
67
+ await injectTestManifest(fastify, t, manifest);
68
+ await testDeleteRoute(t, `/manifests/2/delete?${deleteBy}=${deleteKey}`, 1);
69
+ await fastify.emptyCollections();
70
+ }
71
+ });
72
+
73
+ await t.test("test route /annotations/:iiifPresentationVersion/delete", async (t) => {
74
+ const deletePipeline = async (validFilter) =>
75
+ // validFilter is true => delete data that exists in the db (test that deletions are done correctly),
76
+ // validFilter is false => delete data that doesn't exist (test that nothing is deleted by accident)
77
+ await Promise.all(
78
+ // all 3 possible ways to delete data
79
+ ["manifestShortId", "canvasUri", "uri"].map(
80
+ async (deleteBy) =>
81
+ await t.test(`validFilter: ${validFilter}, deleteBy: ${deleteBy}`, async (t) => {
82
+
83
+ await injectTestAnnotations(fastify, t, annotationList);
84
+ const
85
+ annotations = await fastify.mongo.db.collection("annotations2").find({}).toArray(),
86
+ deleteKey =
87
+ validFilter
88
+ ? deleteBy==="uri"
89
+ ? getRandomItem(annotations.map((a) => a["@id"]))
90
+ : deleteBy==="canvasUri"
91
+ ? getRandomItem(annotations.map((a) => a.on.full))
92
+ : getRandomItem(annotations.map((a) => a.on.manifestShortId))
93
+ : `invalid-filter-${uuid4()}`,
94
+ expectedDeletedCount =
95
+ validFilter
96
+ ? deleteBy==="uri"
97
+ ? annotations.filter((a) => a["@id"]===deleteKey).length
98
+ : deleteBy==="canvasUri"
99
+ ? annotations.filter((a) => a.on.full===deleteKey).length
100
+ : annotations.filter((a) => a.on.manifestShortId===deleteKey).length
101
+ : 0;
102
+
103
+ await testDeleteRoute(t, `/annotations/2/delete?${deleteBy}=${deleteKey}`, expectedDeletedCount);
104
+ })
105
+ )
106
+ );
107
+
108
+ await deletePipeline(true);
109
+ await deletePipeline(false);
110
+ });
111
+
112
+ })
113
+
114
+
115
+ return;
116
+ })
117
+
@@ -0,0 +1,196 @@
1
+ import { v4 as uuid4 } from "uuid";
2
+
3
+ import { maybeToArray, getHash, isNullish, isObject } from "#utils/utils.js";
4
+ import { IIIF_PRESENTATION_2, IIIF_PRESENTATION_2_CONTEXT } from "#utils/iiifUtils.js";
5
+
6
+ /** @typedef {import("#types").MongoCollectionType} MongoCollectionType */
7
+
8
+ // IIIF PRESENTATION 2.1 RECOMMENDED URI PATTERNS https://iiif.io/api/presentation/2.1/#a-summary-of-recommended-uri-patterns
9
+ //
10
+ // Collection {scheme}://{host}/{prefix}/collection/{name}
11
+ // Manifest {scheme}://{host}/{prefix}/{identifier}/manifest
12
+ // Sequence {scheme}://{host}/{prefix}/{identifier}/sequence/{name}
13
+ // Canvas {scheme}://{host}/{prefix}/{identifier}/canvas/{name}
14
+ // Annotation (incl images) {scheme}://{host}/{prefix}/{identifier}/annotation/{name}
15
+ // AnnotationList {scheme}://{host}/{prefix}/{identifier}/list/{name}
16
+ // Range {scheme}://{host}/{prefix}/{identifier}/range/{name}
17
+ // Layer {scheme}://{host}/{prefix}/{identifier}/layer/{name}
18
+ // Content {scheme}://{host}/{prefix}/{identifier}/res/{name}.{format}
19
+
20
+ /**
21
+ * extract a manifest's short ID from a IIIF URI.
22
+ * NOTE if the `iiifUri` doesn' follow IIIF 2.x recommendations, the quality of geneated IDs is really degraded : 2 canvases URI from the same manifest will generate a different hash.
23
+ * inspired by : https://github.com/glenrobson/SimpleAnnotationServer/blob/dc7c8c6de9f4693c678643db2a996a49eebfcbb0/src/main/java/uk/org/llgc/annotation/store/data/Manifest.java#L123C16-L123C26
24
+ * @param {string} iiifUri
25
+ * @returns {string}
26
+ */
27
+ const getManifestShortId = (iiifUri) => {
28
+ const keywords = ["manifest", "manifest.json", "sequence", "canvas", "annotation", "list", "range", "layer", "res"]
29
+ let manifestShortId;
30
+
31
+ const iiifUriArr = iiifUri.split("/");
32
+
33
+ // if it follows the IIIF recommended URI patterns
34
+ for ( let i=0; i < keywords.length; i++ ) {
35
+ if ( iiifUriArr.includes(keywords[i]) ) {
36
+ manifestShortId = iiifUriArr.at( iiifUriArr.indexOf(keywords[i]) - 1 );
37
+ break;
38
+ }
39
+ }
40
+ // fallback if no manifestShortId was found: return a string representation of the URI's hash.
41
+ manifestShortId = manifestShortId || getHash(iiifUri);
42
+ return manifestShortId;
43
+ }
44
+
45
+ /**
46
+ * extract the ID of a canvas from a canvas' URI.
47
+ * NOTE this only really works if `canvasUri` follows IIIF URI patterns.
48
+ * @param {string} canvasUri
49
+ * @returns {string}
50
+ */
51
+ const getCanvasShortId = (canvasUri) =>
52
+ // IIIF compliant
53
+ canvasUri.includes("/canvas/")
54
+ ? canvasUri.split("/").at(-1).replace(".json", "")
55
+ : getHash(canvasUri);
56
+
57
+ /**
58
+ * get the `on` of an annotation.
59
+ * reimplemented from SAS: https://github.com/glenrobson/SimpleAnnotationServer/blob/dc7c8c6de9f4693c678643db2a996a49eebfcbb0/src/main/java/uk/org/llgc/annotation/store/AnnotationUtils.java#L147
60
+ * @param {object} annotation
61
+ * @returns {string}
62
+ */
63
+ const getAnnotationTarget = (annotation) => {
64
+ const target = annotation.on; // either string or SpecificResource
65
+ let targetOut;
66
+
67
+ if ( typeof(target) === "string" ) {
68
+ // remove the fragment if necesary to get the full Canvas Id
69
+ const hashIdx = target.indexOf("#");
70
+ targetOut = hashIdx === -1
71
+ ? target
72
+ : target.substring(0, hashIdx);
73
+
74
+ } else {
75
+ // it's a SpecificResource => get the full image's id.
76
+ targetOut = target["full"];
77
+ }
78
+ if ( isNullish(targetOut) ) {
79
+ throw new Error(`${getAnnotationTarget.name}: 'annotation.on' is not a valid IIIF 2.1 annotation target (with annotation=${target})`)
80
+ }
81
+ return targetOut;
82
+ }
83
+
84
+ /**
85
+ * convert the annotation's `on` to a SpecificResource
86
+ * reimplemented from SAS: https://github.com/glenrobson/SimpleAnnotationServer/blob/dc7c8c6de9f4693c678643db2a996a49eebfcbb0/src/main/java/uk/org/llgc/annotation/store/AnnotationUtils.java#L123-L135
87
+ */
88
+ const makeTarget = (annotation) => {
89
+ const
90
+ // must be either string or SpecificResource
91
+ target = annotation.on,
92
+ // error that will raise is `target` can't be processed
93
+ err = new Error(`${makeTarget.name}: could not make target for annotation: 'annotation.on' must be an URI or an object with 'annotation.on.@type==="oa:SpecificResource"' and 'annotation.on.@id' must be a string URI`, { info: annotation });
94
+
95
+ let specificResource;
96
+
97
+ // convert to SpecificResource if it's not aldready the case
98
+ if ( typeof(target) === "string" && !isNullish(target) ) {
99
+ let [full, fragment] = target.split("#");
100
+ specificResource = {
101
+ "@type": "oa:SpecificResource",
102
+ full: full,
103
+ selector: {
104
+ "@type": "oa:FragmentSelector",
105
+ value: fragment
106
+ }
107
+ }
108
+ } else if ( isObject(target) ) {
109
+ // if 'target' is an object but not a specificresource, raise.
110
+ if ( target["@type"] === "oa:SpecificResource" && !isNullish(target["full"]) ) {
111
+ specificResource = target;
112
+ // the received specificResource `selector` may have its type specified using the key `type`. correct it to `@type`.
113
+ if ( isObject(target.selector) && Object.keys(target.selector).includes("type") ) {
114
+ target.selector["@type"] = target.selector.type;
115
+ delete target.selector.type;
116
+ }
117
+ } else {
118
+ throw err
119
+ }
120
+ } else {
121
+ throw err
122
+ }
123
+
124
+ return specificResource
125
+ }
126
+
127
+ /**
128
+ * generate the annotation's ID from its `@id` key (if defined)
129
+ * reimplementated from SAS: https://github.com/glenrobson/SimpleAnnotationServer/blob/dc7c8c6de9f4693c678643db2a996a49eebfcbb0/src/main/java/uk/org/llgc/annotation/store/AnnotationUtils.java#L90-L97
130
+ * NOTE this should never fail, but results will only be reliable if the `annotation.on` follows the IIIF 2.1 canvas URI scheme
131
+ */
132
+ const makeAnnotationId = (annotation, manifestShortId) => {
133
+ const
134
+ target = getAnnotationTarget(annotation),
135
+ canvasId = getCanvasShortId(target);
136
+ // if manifestShortId hasn't aldready been extracted, re-extract it
137
+ manifestShortId = manifestShortId || getManifestShortId(target);
138
+
139
+ if ( isNullish(manifestShortId) || isNullish(canvasId) ) {
140
+ throw new Error(`${makeAnnotationId.name}: could not make an 'annotationId' (with manifestShortId=${manifestShortId}, annotation=${annotation})`)
141
+ }
142
+
143
+ return annotationUri(manifestShortId, canvasId);
144
+ }
145
+
146
+ /**
147
+ * @example "127.0.0.1:3000/data/2/wit9_man11_anno165/annotation/c26_abda6e3c-2926-4495-9787-cb3f3588e47c"
148
+ * @param {string} manifestShortId
149
+ * @param {string} canvasId
150
+ * @returns {string}
151
+ */
152
+ const annotationUri = (manifestShortId, canvasId) =>
153
+ `${process.env.APP_BASE_URL}/data/${IIIF_PRESENTATION_2}/${manifestShortId}/annotation/${canvasId}_${uuid4()}`;
154
+
155
+ const manifestUri = (manifestShortId) =>
156
+ `${process.env.APP_BASE_URL}/data/${IIIF_PRESENTATION_2}/${manifestShortId}/manifest.json`;
157
+
158
+ /**
159
+ * if `canvasUri` follows the recommended IIIF 2.1 recommended URI pattern, convert it to a JSON manifest URI.
160
+ * @param {string} canvasUri
161
+ * @returns {string} : the manifest URI
162
+ */
163
+ const canvasUriToManifestUri = (canvasUri) =>
164
+ canvasUri.split("/").slice(0,-2).join("/") + "/manifest.json";
165
+
166
+ /**
167
+ *
168
+ * @param {object[]} resources: the annotatons
169
+ * @param {string?} annotationListId: the AnnotationList's '@id' key
170
+ * @param {string?} label: optional description
171
+ * @returns {object}
172
+ */
173
+ const toAnnotationList = (resources, annotationListId, label) => {
174
+ const annotationList = {
175
+ ...IIIF_PRESENTATION_2_CONTEXT,
176
+ "@type": "sc:AnnotationList",
177
+ "@id": annotationListId || "", // NOTE: MUST be defined according to IIIF presentation API (but not always defined in SAS)
178
+ resources: resources
179
+ }
180
+ if ( label ) {
181
+ annotationList.label = label
182
+ }
183
+ return annotationList;
184
+ }
185
+
186
+ export {
187
+ makeTarget,
188
+ makeAnnotationId,
189
+ annotationUri,
190
+ manifestUri,
191
+ toAnnotationList,
192
+ getManifestShortId,
193
+ getCanvasShortId,
194
+ getAnnotationTarget,
195
+ canvasUriToManifestUri,
196
+ }