cozy-iiif 0.1.5 → 0.1.6

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/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Manifest, Canvas, ImageService2, ImageService3, IIIFExternalWebResource, Collection, Range } from '@iiif/presentation-3';
1
+ import { Manifest, Canvas, ImageService2, ImageService3, IIIFExternalWebResource, Collection, Range, AnnotationPage } from '@iiif/presentation-3';
2
2
  export type CozyParseResult = {
3
3
  type: 'collection';
4
4
  url: string;
@@ -60,6 +60,7 @@ export interface CozyCanvas {
60
60
  readonly width: number;
61
61
  readonly height: number;
62
62
  readonly images: CozyImageResource[];
63
+ readonly annotations: AnnotationPage[];
63
64
  getLabel(locale?: string): string;
64
65
  getMetadata(locale?: string): CozyMetadata[];
65
66
  getThumbnailURL(minSize?: number): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cozy-iiif",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",
@@ -34,6 +34,7 @@
34
34
  "dependencies": {
35
35
  "@iiif/parser": "^2.1.7",
36
36
  "@iiif/presentation-3": "^2.2.3",
37
- "p-throttle": "^7.0.0"
37
+ "p-throttle": "^7.0.0",
38
+ "uuid": "^11.1.0"
38
39
  }
39
40
  }
package/src/Cozy.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Canvas, Collection, Manifest, Range } from '@iiif/presentation-3';
2
2
  import { convertPresentation2 } from '@iiif/parser/presentation-2';
3
3
  import { Traverse } from '@iiif/parser';
4
+ import * as Helpers from './helpers';
4
5
  import {
5
6
  getImages,
6
7
  getLabel,
@@ -21,123 +22,121 @@ import type {
21
22
  ImageServiceResource
22
23
  } from './types';
23
24
 
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
- }
25
+ const parseURL = async (input: string): Promise<CozyParseResult> => {
26
+ try {
27
+ new URL(input);
28
+ } catch {
29
+ return {
30
+ type: 'error',
31
+ code: 'INVALID_URL',
32
+ message: 'The provided input is not a valid URL'
33
+ };
34
+ }
36
35
 
37
- let response: Response;
36
+ let response: Response;
38
37
 
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) {
38
+ try {
39
+ response = await fetch(input);
40
+ if (!response.ok) {
49
41
  return {
50
42
  type: 'error',
51
- code: 'FETCH_ERROR',
52
- message: error instanceof Error ? error.message : 'Failed to fetch resource'
53
- };
43
+ code: 'INVALID_HTTP_RESPONSE',
44
+ message: `Server responded: HTTP ${response.status} ${response.statusText ? `(${response.statusText})` : ''}`
45
+ }
54
46
  }
47
+ } catch (error) {
48
+ return {
49
+ type: 'error',
50
+ code: 'FETCH_ERROR',
51
+ message: error instanceof Error ? error.message : 'Failed to fetch resource'
52
+ };
53
+ }
55
54
 
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
- }
55
+ const contentType = response.headers.get('content-type');
56
+
57
+ if (contentType?.startsWith('image/')) {
58
+ return {
59
+ type: 'plain-image',
60
+ url: input
61
+ };
62
+ }
64
63
 
65
- if (contentType?.includes('text/html')) {
66
- return {
67
- type: 'webpage',
68
- url: input
69
- };
70
- }
64
+ if (contentType?.includes('text/html')) {
65
+ return {
66
+ type: 'webpage',
67
+ url: input
68
+ };
69
+ }
71
70
 
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
- }
71
+ try {
72
+ const json = await response.json();
73
+ return parse(json, input);
74
+ } catch {
75
+ return {
76
+ type: 'error',
77
+ code: 'UNSUPPORTED_FORMAT',
78
+ message: 'Could not parse resource'
79
+ };
80
+ }
81
+ }
96
82
 
97
- if (context.includes('presentation/2') || context.includes('presentation/3')) {
98
- const majorVersion = context.includes('presentation/2') ? 2 : 3;
83
+ const parse = (json: any, url?: string): CozyParseResult => {
84
+ const context = Array.isArray(json['@context'])
85
+ ? json['@context'].find(str => str.includes('iiif.io/api/'))
86
+ : json['@context'];
99
87
 
100
- const type = getPropertyValue(json, 'type');
88
+ if (!context) {
89
+ return {
90
+ type: 'error',
91
+ code: 'INVALID_MANIFEST',
92
+ message: 'Missing @context'
93
+ }
94
+ };
101
95
 
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
- }
96
+ const id = getPropertyValue<string>(json, 'id');
125
97
 
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
- };
98
+ if (!id) {
99
+ return {
100
+ type: 'error',
101
+ code: 'INVALID_MANIFEST',
102
+ message: 'Missing id property'
137
103
  }
104
+ }
105
+
106
+ if (context.includes('presentation/2') || context.includes('presentation/3')) {
107
+ const majorVersion = context.includes('presentation/2') ? 2 : 3;
108
+
109
+ const type = getPropertyValue(json, 'type');
138
110
 
111
+ return type.includes('Collection') ? {
112
+ type: 'collection',
113
+ url: url || id,
114
+ resource: parseCollectionResource(json, majorVersion)
115
+ } : {
116
+ type: 'manifest',
117
+ url: url || id,
118
+ resource: parseManifestResource(json, majorVersion)
119
+ };
120
+ }
121
+
122
+ if (context.includes('image/2') || context.includes('image/3')) {
123
+ const resource = parseImageResource(json);
124
+ return resource ? {
125
+ type: 'iiif-image',
126
+ url: url || id,
127
+ resource
128
+ } : {
129
+ type: 'error',
130
+ code: 'INVALID_MANIFEST',
131
+ message: 'Invalid image service definition'
132
+ }
139
133
  }
140
134
 
135
+ return {
136
+ type: 'error',
137
+ code: 'INVALID_MANIFEST',
138
+ message: 'JSON resource is not a recognized IIIF format'
139
+ };
141
140
  }
142
141
 
143
142
  const parseCollectionResource = (resource: any, majorVersion: number): CozyCollection => {
@@ -194,6 +193,7 @@ const parseManifestResource = (resource: any, majorVersion: number): CozyManifes
194
193
  width: c.width,
195
194
  height: c.height,
196
195
  images,
196
+ annotations: (c.annotations || []),
197
197
  getLabel: getLabel(c),
198
198
  getMetadata: getMetadata(c),
199
199
  getThumbnailURL: getThumbnailURL(c, images)
@@ -259,4 +259,6 @@ const parseImageResource = (resource: any) => {
259
259
  serviceUrl: normalizeServiceUrl(getPropertyValue<string>(resource, 'id'))
260
260
  } as ImageServiceResource;
261
261
  }
262
- }
262
+ }
263
+
264
+ export const Cozy = { parse, parseURL, Helpers };
@@ -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
+