cozy-iiif 0.1.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-iiif",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "A developer-friendly collection of abstractions and utilities built on top of @iiif/presentation-3 and @iiif/parser",
5
5
  "license": "MIT",
6
6
  "author": "Rainer Simon",
@@ -24,6 +24,7 @@
24
24
  },
25
25
  "exports": {
26
26
  ".": "./dist/index.js",
27
+ "./helpers": "./dist/helpers/index.js",
27
28
  "./level-0": "./dist/level-0/index.js"
28
29
  },
29
30
  "devDependencies": {
@@ -34,6 +35,7 @@
34
35
  "dependencies": {
35
36
  "@iiif/parser": "^2.1.7",
36
37
  "@iiif/presentation-3": "^2.2.3",
37
- "p-throttle": "^7.0.0"
38
+ "p-throttle": "^7.0.0",
39
+ "uuid": "^11.1.0"
38
40
  }
39
41
  }
package/src/Cozy.ts CHANGED
@@ -21,123 +21,121 @@ import type {
21
21
  ImageServiceResource
22
22
  } from './types';
23
23
 
24
- export const Cozy = {
25
-
26
- parseURL: async (input: string): Promise<CozyParseResult> => {
27
- try {
28
- new URL(input);
29
- } catch {
30
- return {
31
- type: 'error',
32
- code: 'INVALID_URL',
33
- message: 'The provided input is not a valid URL'
34
- };
35
- }
24
+ const parseURL = async (input: string): Promise<CozyParseResult> => {
25
+ try {
26
+ new URL(input);
27
+ } catch {
28
+ return {
29
+ type: 'error',
30
+ code: 'INVALID_URL',
31
+ message: 'The provided input is not a valid URL'
32
+ };
33
+ }
36
34
 
37
- let response: Response;
35
+ let response: Response;
38
36
 
39
- try {
40
- response = await fetch(input);
41
- if (!response.ok) {
42
- return {
43
- type: 'error',
44
- code: 'INVALID_HTTP_RESPONSE',
45
- message: `Server responded: HTTP ${response.status} ${response.statusText ? `(${response.statusText})` : ''}`
46
- }
47
- }
48
- } catch (error) {
37
+ try {
38
+ response = await fetch(input);
39
+ if (!response.ok) {
49
40
  return {
50
41
  type: 'error',
51
- code: 'FETCH_ERROR',
52
- message: error instanceof Error ? error.message : 'Failed to fetch resource'
53
- };
42
+ code: 'INVALID_HTTP_RESPONSE',
43
+ message: `Server responded: HTTP ${response.status} ${response.statusText ? `(${response.statusText})` : ''}`
44
+ }
54
45
  }
46
+ } catch (error) {
47
+ return {
48
+ type: 'error',
49
+ code: 'FETCH_ERROR',
50
+ message: error instanceof Error ? error.message : 'Failed to fetch resource'
51
+ };
52
+ }
55
53
 
56
- const contentType = response.headers.get('content-type');
57
-
58
- if (contentType?.startsWith('image/')) {
59
- return {
60
- type: 'plain-image',
61
- url: input
62
- };
63
- }
54
+ const contentType = response.headers.get('content-type');
55
+
56
+ if (contentType?.startsWith('image/')) {
57
+ return {
58
+ type: 'plain-image',
59
+ url: input
60
+ };
61
+ }
64
62
 
65
- if (contentType?.includes('text/html')) {
66
- return {
67
- type: 'webpage',
68
- url: input
69
- };
70
- }
63
+ if (contentType?.includes('text/html')) {
64
+ return {
65
+ type: 'webpage',
66
+ url: input
67
+ };
68
+ }
71
69
 
72
- try {
73
- const json = await response.json();
74
-
75
- const context = Array.isArray(json['@context'])
76
- ? json['@context'].find(str => str.includes('iiif.io/api/'))
77
- : json['@context'];
78
-
79
- if (!context) {
80
- return {
81
- type: 'error',
82
- code: 'INVALID_MANIFEST',
83
- message: 'Missing @context'
84
- }
85
- };
86
-
87
- const id = getPropertyValue<string>(json, 'id');
88
-
89
- if (!id) {
90
- return {
91
- type: 'error',
92
- code: 'INVALID_MANIFEST',
93
- message: 'Missing id property'
94
- }
95
- }
70
+ try {
71
+ const json = await response.json();
72
+ return parse(json, input);
73
+ } catch {
74
+ return {
75
+ type: 'error',
76
+ code: 'UNSUPPORTED_FORMAT',
77
+ message: 'Could not parse resource'
78
+ };
79
+ }
80
+ }
96
81
 
97
- if (context.includes('presentation/2') || context.includes('presentation/3')) {
98
- const majorVersion = context.includes('presentation/2') ? 2 : 3;
82
+ const parse = (json: any, url?: string): CozyParseResult => {
83
+ const context = Array.isArray(json['@context'])
84
+ ? json['@context'].find(str => str.includes('iiif.io/api/'))
85
+ : json['@context'];
99
86
 
100
- const type = getPropertyValue(json, 'type');
87
+ if (!context) {
88
+ return {
89
+ type: 'error',
90
+ code: 'INVALID_MANIFEST',
91
+ message: 'Missing @context'
92
+ }
93
+ };
101
94
 
102
- return type.includes('Collection') ? {
103
- type: 'collection',
104
- url: input,
105
- resource: parseCollectionResource(json, majorVersion)
106
- } : {
107
- type: 'manifest',
108
- url: input,
109
- resource: parseManifestResource(json, majorVersion)
110
- };
111
- }
112
-
113
- if (context.includes('image/2') || context.includes('image/3')) {
114
- const resource = parseImageResource(json);
115
- return resource ? {
116
- type: 'iiif-image',
117
- url: input,
118
- resource
119
- } : {
120
- type: 'error',
121
- code: 'INVALID_MANIFEST',
122
- message: 'Invalid image service definition'
123
- }
124
- }
95
+ const id = getPropertyValue<string>(json, 'id');
125
96
 
126
- return {
127
- type: 'error',
128
- code: 'INVALID_MANIFEST',
129
- message: 'JSON resource is not a recognized IIIF format'
130
- };
131
- } catch {
132
- return {
133
- type: 'error',
134
- code: 'UNSUPPORTED_FORMAT',
135
- message: 'Could not parse resource'
136
- };
97
+ if (!id) {
98
+ return {
99
+ type: 'error',
100
+ code: 'INVALID_MANIFEST',
101
+ message: 'Missing id property'
137
102
  }
103
+ }
104
+
105
+ if (context.includes('presentation/2') || context.includes('presentation/3')) {
106
+ const majorVersion = context.includes('presentation/2') ? 2 : 3;
107
+
108
+ const type = getPropertyValue(json, 'type');
138
109
 
110
+ return type.includes('Collection') ? {
111
+ type: 'collection',
112
+ url: url || id,
113
+ resource: parseCollectionResource(json, majorVersion)
114
+ } : {
115
+ type: 'manifest',
116
+ url: url || id,
117
+ resource: parseManifestResource(json, majorVersion)
118
+ };
119
+ }
120
+
121
+ if (context.includes('image/2') || context.includes('image/3')) {
122
+ const resource = parseImageResource(json);
123
+ return resource ? {
124
+ type: 'iiif-image',
125
+ url: url || id,
126
+ resource
127
+ } : {
128
+ type: 'error',
129
+ code: 'INVALID_MANIFEST',
130
+ message: 'Invalid image service definition'
131
+ }
139
132
  }
140
133
 
134
+ return {
135
+ type: 'error',
136
+ code: 'INVALID_MANIFEST',
137
+ message: 'JSON resource is not a recognized IIIF format'
138
+ };
141
139
  }
142
140
 
143
141
  const parseCollectionResource = (resource: any, majorVersion: number): CozyCollection => {
@@ -194,6 +192,7 @@ const parseManifestResource = (resource: any, majorVersion: number): CozyManifes
194
192
  width: c.width,
195
193
  height: c.height,
196
194
  images,
195
+ annotations: (c.annotations || []),
197
196
  getLabel: getLabel(c),
198
197
  getMetadata: getMetadata(c),
199
198
  getThumbnailURL: getThumbnailURL(c, images)
@@ -259,4 +258,6 @@ const parseImageResource = (resource: any) => {
259
258
  serviceUrl: normalizeServiceUrl(getPropertyValue<string>(resource, 'id'))
260
259
  } as ImageServiceResource;
261
260
  }
262
- }
261
+ }
262
+
263
+ export const Cozy = { parse, parseURL };
@@ -0,0 +1,115 @@
1
+ import { v4 as uuidv4 } from 'uuid';
2
+ import type { Annotation } from '@iiif/presentation-3';
3
+ import type { CozyCanvas, CozyManifest } from '../types';
4
+
5
+ // Helper to escape special characters in strings used in RegExp
6
+ const escapeRegExp = (str: string) =>
7
+ str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
8
+
9
+ const getAnnotationPageId = (canvas: CozyCanvas, namespace?: string) => {
10
+ if (namespace) {
11
+ // Use naming convention `{canvas.id}/namespace/page/p{idx}`. This means we need
12
+ // to find the highest index currently in use. (Index starts with 1.)
13
+ const pages = canvas.annotations;
14
+ if (pages.length > 0) {
15
+ const pattern = new RegExp(`${escapeRegExp(canvas.id)}/${escapeRegExp(namespace)}/page/p(\\d+)$`);
16
+
17
+ const highestIdx = pages.reduce<number>((highest, page) => {
18
+ const match = page.id.match(pattern);
19
+ if (match && match[1]) {
20
+ const thisIndex = parseInt(match[1]);
21
+ return Math.max(highest, thisIndex);
22
+ } else {
23
+ return highest;
24
+ }
25
+ }, 1);
26
+
27
+ return `${canvas.id}/${namespace}/annotations/page/p${highestIdx}`;
28
+ } else {
29
+ return `${canvas.id}/${namespace}/annotations/page/p1`;
30
+ }
31
+ } else {
32
+ // Use UUIDs as a fallback naming convention
33
+ return `${canvas.id}/annotations/page/${uuidv4()}`;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Will blindy attach the annotations to this canvas.
39
+ */
40
+ const importAnnotationsToCanvas = (canvas: CozyCanvas, annotations: Annotation[], namespace?: string) => {
41
+ const page = {
42
+ id: getAnnotationPageId(canvas, namespace),
43
+ type: 'AnnotationPage',
44
+ items: annotations
45
+ }
46
+
47
+ return {
48
+ source: {
49
+ ...canvas.source,
50
+ annotations: [...canvas.annotations, page]
51
+ },
52
+ id: canvas.id,
53
+ width: canvas.width,
54
+ height: canvas.height,
55
+ images: [...canvas.images],
56
+ annotations: [...canvas.annotations, page],
57
+ getLabel: canvas.getLabel,
58
+ getMetadata: canvas.getMetadata,
59
+ getThumbnailURL: canvas.getThumbnailURL
60
+ } as CozyCanvas;
61
+ }
62
+
63
+ /**
64
+ * Will use 'source' information from the annotation targets to associate annotations with the right
65
+ * canvases.
66
+ */
67
+ const importAnnotationsToManifest = (manifest: CozyManifest, annotations: Annotation[], namespace?: string) => {
68
+ const getSource = (annotation: Annotation) => {
69
+ const target = annotation.target;
70
+ if (!target) return;
71
+
72
+ if (typeof target === 'string')
73
+ return target.substring(0, target.indexOf('#'));
74
+ else
75
+ return (target as any).source;
76
+ }
77
+
78
+ const bySource = annotations.reduce<Record<string, Annotation[]>>((acc, annotation) => {
79
+ const source = getSource(annotation);
80
+ if (!source) return acc;
81
+
82
+ if (!acc[source]) acc[source] = [];
83
+ acc[source].push(annotation);
84
+
85
+ return acc;
86
+ }, {});
87
+
88
+ const canvases = manifest.canvases.map(canvas => {
89
+ const toImport = bySource[canvas.id] || [];
90
+ return toImport.length > 0 ? importAnnotationsToCanvas(canvas, toImport, namespace) : canvas;
91
+ });
92
+
93
+ return {
94
+ source: {
95
+ ...manifest.source,
96
+ items: canvases.map(c => c.source)
97
+ },
98
+ id: manifest.id,
99
+ majorVersion: manifest.majorVersion,
100
+ canvases,
101
+ structure: manifest.structure,
102
+ getLabel: manifest.getLabel,
103
+ getMetadata: manifest.getMetadata,
104
+ getTableOfContents: manifest.getTableOfContents
105
+ }
106
+ }
107
+
108
+ export const importAnnotations = <T extends CozyManifest | CozyCanvas>(
109
+ resource: T,
110
+ annotations: Annotation[],
111
+ namespace?: string
112
+ ): T extends CozyCanvas ? CozyCanvas : CozyManifest =>
113
+ resource.source.type === 'Canvas'
114
+ ? importAnnotationsToCanvas(resource as CozyCanvas, annotations, namespace) as T extends CozyCanvas ? CozyCanvas : CozyManifest
115
+ : importAnnotationsToManifest(resource as CozyManifest, annotations, namespace) as T extends CozyCanvas ? CozyCanvas : CozyManifest;
@@ -0,0 +1 @@
1
+ export * from './import-annotations';
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Manifest, Canvas, ImageService2, ImageService3, IIIFExternalWebResource, Collection, Range } from '@iiif/presentation-3';
1
+ import type { Manifest, Canvas, ImageService2, ImageService3, IIIFExternalWebResource, Collection, Range, Annotation, AnnotationPage } from '@iiif/presentation-3';
2
2
 
3
3
  export type CozyParseResult =
4
4
  | { type: 'collection', url: string, resource: CozyCollection }
@@ -88,6 +88,8 @@ export interface CozyCanvas {
88
88
 
89
89
  readonly images: CozyImageResource[];
90
90
 
91
+ readonly annotations: AnnotationPage[];
92
+
91
93
  getLabel(locale?: string): string;
92
94
 
93
95
  getMetadata(locale?: string): CozyMetadata[];
package/test/Cozy.test.ts CHANGED
@@ -21,7 +21,7 @@ describe('Cozy', () => {
21
21
  const tableOfContents = manifest.getTableOfContents();
22
22
  expect(tableOfContents.length).toBe(1);
23
23
  expect(tableOfContents[0].children.length).toBe(14);
24
- })
24
+ });
25
25
 
26
26
  });
27
27
 
@@ -0,0 +1,135 @@
1
+ // Modified from https://iiif.io/api/cookbook/recipe/0021-tagging/
2
+ export const ANNOTATIONS = [{
3
+ id: 'https://iiif.io/api/cookbook/recipe/0021-tagging/annotation/p0001',
4
+ type: 'Annotation',
5
+ motivation: 'tagging',
6
+ body: {
7
+ type: 'TextualBody',
8
+ value: 'Test Annotation 1',
9
+ format: 'text/plain'
10
+ },
11
+ target: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/canvas/p1#xywh=265,661,1260,1239'
12
+ },{
13
+ id: 'https://iiif.io/api/cookbook/recipe/0021-tagging/annotation/p0002',
14
+ type: 'Annotation',
15
+ motivation: 'tagging',
16
+ body: {
17
+ type: 'TextualBody',
18
+ value: 'Test Annotation 2',
19
+ format: 'text/plain'
20
+ },
21
+ target: {
22
+ source: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/canvas/p2',
23
+ selector: {
24
+ type: 'FragmentSelector',
25
+ value: 'xywh=265,661,1260,1239'
26
+ }
27
+ }
28
+ }]
29
+
30
+ // https://iiif.io/api/cookbook/recipe/0001-mvm-image/
31
+ export const SINGLE_CANVAS_NO_ANNOTATIONS = {
32
+ '@context': 'http://iiif.io/api/presentation/3/context.json',
33
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/manifest.json',
34
+ type: 'Manifest',
35
+ label: {
36
+ en: [
37
+ 'Single Image Example'
38
+ ]
39
+ },
40
+ items: [
41
+ {
42
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/canvas/p1',
43
+ type: 'Canvas',
44
+ height: 1800,
45
+ width: 1200,
46
+ items: [
47
+ {
48
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/page/p1/1',
49
+ type: 'AnnotationPage',
50
+ items: [
51
+ {
52
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/annotation/p0001-image',
53
+ type: 'Annotation',
54
+ motivation: 'painting',
55
+ body: {
56
+ id: 'http://iiif.io/api/presentation/2.1/example/fixtures/resources/page1-full.png',
57
+ type: 'Image',
58
+ format: 'image/png',
59
+ height: 1800,
60
+ width: 1200
61
+ },
62
+ target: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/canvas/p1'
63
+ }
64
+ ]
65
+ }
66
+ ]
67
+ }
68
+ ]
69
+ }
70
+
71
+ export const TWO_CANVASES_NO_ANNOTATIONS = {
72
+ '@context': 'http://iiif.io/api/presentation/3/context.json',
73
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/manifest.json',
74
+ type: 'Manifest',
75
+ label: {
76
+ en: [
77
+ 'Single Image Example'
78
+ ]
79
+ },
80
+ items: [
81
+ {
82
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/canvas/p1',
83
+ type: 'Canvas',
84
+ height: 1800,
85
+ width: 1200,
86
+ items: [
87
+ {
88
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/page/p1/1',
89
+ type: 'AnnotationPage',
90
+ items: [
91
+ {
92
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/annotation/p0001-image',
93
+ type: 'Annotation',
94
+ motivation: 'painting',
95
+ body: {
96
+ id: 'http://iiif.io/api/presentation/2.1/example/fixtures/resources/page1-full.png',
97
+ type: 'Image',
98
+ format: 'image/png',
99
+ height: 1800,
100
+ width: 1200
101
+ },
102
+ target: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/canvas/p1'
103
+ }
104
+ ]
105
+ }
106
+ ]
107
+ }, {
108
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/canvas/p2',
109
+ type: 'Canvas',
110
+ height: 1800,
111
+ width: 1200,
112
+ items: [
113
+ {
114
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/page/p1/1',
115
+ type: 'AnnotationPage',
116
+ items: [
117
+ {
118
+ id: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/annotation/p0001-image',
119
+ type: 'Annotation',
120
+ motivation: 'painting',
121
+ body: {
122
+ id: 'http://iiif.io/api/presentation/2.1/example/fixtures/resources/page1-full.png',
123
+ type: 'Image',
124
+ format: 'image/png',
125
+ height: 1800,
126
+ width: 1200
127
+ },
128
+ target: 'https://iiif.io/api/cookbook/recipe/0001-mvm-image/canvas/p1'
129
+ }
130
+ ]
131
+ }
132
+ ]
133
+ }
134
+ ]
135
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Annotation } from '@iiif/presentation-3';
3
+ import { Cozy, CozyManifest } from '../../src';
4
+
5
+ import {
6
+ ANNOTATIONS,
7
+ SINGLE_CANVAS_NO_ANNOTATIONS,
8
+ TWO_CANVASES_NO_ANNOTATIONS
9
+ } from './fixtures';
10
+
11
+ describe('import-annotations', () => {
12
+
13
+ it('should insert a new page into a canvas with no annotatinos', () => {
14
+ const result = Cozy.parse(SINGLE_CANVAS_NO_ANNOTATIONS);
15
+
16
+ expect(result.type).toBe('manifest');
17
+ const manifest = (result as any).resource as CozyManifest;
18
+
19
+ expect(manifest.canvases.length).toBe(1);
20
+ const firstCanvas = manifest.canvases[0];
21
+
22
+ const annotations = ANNOTATIONS as Annotation[];
23
+
24
+ const modified = Cozy.Helpers.importAnnotations(firstCanvas, annotations)
25
+ expect(modified.annotations.length).toBe(1);
26
+ });
27
+
28
+ it('should correctly insert annotation pages into the test manifest', () => {
29
+ const result = Cozy.parse(TWO_CANVASES_NO_ANNOTATIONS);
30
+
31
+ expect(result.type).toBe('manifest');
32
+ const manifest = (result as any).resource as CozyManifest;
33
+
34
+ expect(manifest.canvases.length).toBe(2);
35
+ expect(manifest.canvases[0].annotations.length).toBe(0);
36
+ expect(manifest.canvases[1].annotations.length).toBe(0);
37
+
38
+ const annotations = ANNOTATIONS as Annotation[];
39
+
40
+ const modified = Cozy.Helpers.importAnnotations(manifest, annotations, 'cozy');
41
+ expect(modified.canvases[0].annotations.length).toBe(1);
42
+ expect(modified.canvases[1].annotations.length).toBe(1);
43
+ });
44
+
45
+ });
46
+
package/vite.config.ts CHANGED
@@ -8,6 +8,7 @@ export default defineConfig({
8
8
  lib: {
9
9
  entry: {
10
10
  'index': resolve(__dirname, 'src/index.ts'),
11
+ 'helpers/index': resolve(__dirname, 'src/helpers/index.ts'),
11
12
  'level-0/index': resolve(__dirname, 'src/level-0/index.ts'),
12
13
  },
13
14
  formats: ['es']