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,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
|
+
}
|