Skip to content

Commit 401ca9c

Browse files
Merge pull request #363 from getodk/features/engine/upload-node
Engine support for instance attachments; Integrate image upload UI
2 parents 23d120d + a72bd2b commit 401ca9c

File tree

78 files changed

+3063
-484
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+3063
-484
lines changed

.changeset/legal-apples-train.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@getodk/web-forms': minor
3+
---
4+
5+
Support for uploading images

.changeset/witty-toes-win.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@getodk/xforms-engine': minor
3+
---
4+
5+
Support for instance attachments

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,7 @@ export default tseslint.config(
413413
'packages/*/playwright.config.ts',
414414
'packages/*/vite.config.ts',
415415
'packages/*/vitest.config.ts',
416+
'packages/common/src/lib/web-compat/url.ts',
416417
'packages/tree-sitter-xpath/vite.config.mts',
417418
'packages/xforms-engine/vite.*.config.ts',
418419
'packages/*/tools/**/*',
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<?xml version="1.0"?>
2+
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml"
3+
xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema"
4+
xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms"
5+
xmlns:odk="http://www.opendatakit.org/xforms">
6+
<h:head>
7+
<h:title>Image Capture</h:title>
8+
<model odk:xforms-version="1.0.0">
9+
<itext>
10+
<translation lang="English (en)">
11+
<text id="fruits:label">
12+
<value>Select fruits for the bird</value>
13+
</text>
14+
<text id="fruit:mango">
15+
<value>Mango</value>
16+
</text>
17+
<text id="fruit:apple">
18+
<value>Apple</value>
19+
</text>
20+
<text id="fruit:orange">
21+
<value>Orange</value>
22+
</text>
23+
<text id="/data/dog_image:label">
24+
<value>Upload a picture of a dog</value>
25+
</text>
26+
<text id="/data/cat_image:label">
27+
<value>Upload a picture of a cat</value>
28+
</text>
29+
<text id="/data/bird_image:label">
30+
<value>Upload a picture of a bird</value>
31+
</text>
32+
<text id="have_birds:label">
33+
<value>Do you have birds?</value>
34+
</text>
35+
<text id="have_birds:yes">
36+
<value>Yes</value>
37+
</text>
38+
<text id="have_birds:no">
39+
<value>No</value>
40+
</text>
41+
</translation>
42+
<translation lang="French (fr)">
43+
<text id="fruits:label">
44+
<value>Sélectionner des fruits pour l'oiseau</value>
45+
</text>
46+
<text id="fruit:mango">
47+
<value>Mangue</value>
48+
</text>
49+
<text id="fruit:apple">
50+
<value>Pomme</value>
51+
</text>
52+
<text id="fruit:orange">
53+
<value>Orange</value>
54+
</text>
55+
<text id="/data/dog_image:label">
56+
<value>Téléchargez une photo d'un chien</value>
57+
</text>
58+
<text id="/data/cat_image:label">
59+
<value>Télécharge une photo d'un chat</value>
60+
</text>
61+
<text id="/data/bird_image:label">
62+
<value>Télécharge une photo d'un oiseau</value>
63+
</text>
64+
<text id="have_birds:label">
65+
<value>Avez-vous des oiseaux?</value>
66+
</text>
67+
<text id="have_birds:yes">
68+
<value>Oui</value>
69+
</text>
70+
<text id="have_birds:no">
71+
<value>Non</value>
72+
</text>
73+
</translation>
74+
</itext>
75+
<instance>
76+
<data id="image_capture" version="2025020401">
77+
<cat_image/>
78+
<dog_image/>
79+
<have_birds/>
80+
<bird_image/>
81+
<fruits/>
82+
<meta>
83+
<instanceID/>
84+
</meta>
85+
</data>
86+
</instance>
87+
88+
<bind nodeset="/data/cat_image" type="binary" required="true()"/>
89+
<bind nodeset="/data/dog_image" type="binary"/>
90+
<bind nodeset="/data/have_birds" type="string" required="true()"/>
91+
<bind nodeset="/data/bird_image" type="binary" readonly=" /data/fruits != &quot;&quot;" relevant=" /data/have_birds ='yes'"/>
92+
<bind nodeset="/data/fruits" type="string" required="true()" relevant=" /data/have_birds ='yes'"/>
93+
<bind nodeset="/data/meta/instanceID" type="string" readonly="true()" jr:preload="uid"/>
94+
</model>
95+
</h:head>
96+
<h:body>
97+
<upload mediatype="image/*" ref="/data/cat_image">
98+
<label ref="jr:itext('/data/cat_image:label')"/>
99+
</upload>
100+
101+
<upload mediatype="image/*" ref="/data/dog_image">
102+
<label ref="jr:itext('/data/dog_image:label')"/>
103+
</upload>
104+
105+
<select1 ref="/data/have_birds">
106+
<label ref="jr:itext('have_birds:label')"/>
107+
<item>
108+
<value>yes</value>
109+
<label ref="jr:itext('have_birds:yes')"/>
110+
</item>
111+
<item>
112+
<value>no</value>
113+
<label ref="jr:itext('have_birds:no')"/>
114+
</item>
115+
</select1>
116+
117+
<upload mediatype="image/*" ref="/data/bird_image">
118+
<label ref="jr:itext('/data/bird_image:label')"/>
119+
<hint>(It becomes read-only when a fruit is selected)</hint>
120+
</upload>
121+
122+
<select ref="/data/fruits">
123+
<label ref="jr:itext('fruits:label')"/>
124+
<item>
125+
<value>mango</value>
126+
<label ref="jr:itext('fruit:mango')"/>
127+
</item>
128+
<item>
129+
<value>apple</value>
130+
<label ref="jr:itext('fruit:apple')"/>
131+
</item>
132+
<item>
133+
<value>orange</value>
134+
<label ref="jr:itext('fruit:orange')"/>
135+
</item>
136+
</select>
137+
</h:body>
138+
</h:html>
Lines changed: 108 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,67 @@
1+
import { UnreachableError } from '../error/UnreachableError.ts';
2+
3+
type BlobBehavior =
4+
| 'BLOB_BEHAVIOR_BROKEN_BY_DESIGN_REJECTION'
5+
| 'BLOB_BEHAVIOR_BROKEN_BY_DESIGN_TEXT_MISMATCH'
6+
| 'BLOB_BEHAVIOR_EXPECTED';
7+
8+
const detectBlobBehavior = async (): Promise<BlobBehavior> => {
9+
try {
10+
const blob = new Blob(['a']);
11+
const text = await blob.text();
12+
13+
if (text === 'a') {
14+
return 'BLOB_BEHAVIOR_EXPECTED';
15+
}
16+
17+
return 'BLOB_BEHAVIOR_BROKEN_BY_DESIGN_TEXT_MISMATCH';
18+
} catch {
19+
return 'BLOB_BEHAVIOR_BROKEN_BY_DESIGN_REJECTION';
20+
}
21+
};
22+
23+
export const BLOB_BEHAVIOR: BlobBehavior = await detectBlobBehavior();
24+
25+
const readBlobData = async (blob: Blob): Promise<ArrayBuffer> => {
26+
return new Promise<ArrayBuffer>((resolve, reject) => {
27+
let isDone = false;
28+
29+
const reader = new FileReader();
30+
31+
const complete = () => {
32+
if (isDone) {
33+
throw new Error('Cannot complete FileReader read twice!');
34+
}
35+
36+
isDone = true;
37+
38+
const { error, result } = reader;
39+
40+
reader.removeEventListener('error', complete);
41+
reader.removeEventListener('load', complete);
42+
43+
if (error != null) {
44+
reject(error);
45+
} else if (result instanceof ArrayBuffer) {
46+
resolve(result);
47+
} else {
48+
reject(new Error('Unknown FileReader state'));
49+
}
50+
};
51+
52+
reader.addEventListener('error', complete);
53+
reader.addEventListener('load', complete);
54+
reader.readAsArrayBuffer(blob);
55+
});
56+
};
57+
58+
const readBlobText = async (blob: Blob): Promise<string> => {
59+
const data = await readBlobData(blob);
60+
const decoder = new TextDecoder();
61+
62+
return decoder.decode(data);
63+
};
64+
165
/**
266
* Gets the text content of a {@link Blob} (or {@link File}).
367
*
@@ -8,53 +72,62 @@
872
*/
973
let getBlobText: (blob: Blob) => Promise<string>;
1074

11-
const isBlobBrokenByDesign = async (): Promise<boolean> => {
12-
try {
13-
const blob = new Blob(['a']);
14-
const text = await blob.text();
75+
let getBlobData: (blob: Blob) => Promise<ArrayBuffer>;
1576

16-
return text !== 'a';
17-
} catch {
18-
return true;
19-
}
20-
};
77+
if (BLOB_BEHAVIOR === 'BLOB_BEHAVIOR_EXPECTED') {
78+
getBlobText = (blob) => blob.text();
79+
getBlobData = (blob) => blob.arrayBuffer();
80+
} else {
81+
switch (BLOB_BEHAVIOR) {
82+
case 'BLOB_BEHAVIOR_BROKEN_BY_DESIGN_REJECTION':
83+
getBlobText = async (blob) => {
84+
try {
85+
const result = await blob.text();
2186

22-
if (await isBlobBrokenByDesign()) {
23-
getBlobText = (blob) => {
24-
return new Promise((resolve, reject) => {
25-
let isDone = false;
87+
return result;
88+
} catch {
89+
return readBlobText(blob);
90+
}
91+
};
2692

27-
const reader = new FileReader();
93+
getBlobData = async (blob) => {
94+
try {
95+
const result = await blob.arrayBuffer();
2896

29-
const complete = () => {
30-
if (isDone) {
31-
throw new Error('Cannot complete FileReader read twice!');
97+
return result;
98+
} catch {
99+
return readBlobData(blob);
32100
}
101+
};
33102

34-
isDone = true;
103+
break;
35104

36-
const { error, result } = reader;
105+
case 'BLOB_BEHAVIOR_BROKEN_BY_DESIGN_TEXT_MISMATCH':
106+
getBlobText = async (blob) => {
107+
try {
108+
const result = await readBlobText(blob);
37109

38-
if (typeof result === 'string') {
39-
resolve(result);
40-
} else if (error != null) {
41-
reject(error);
42-
} else {
43-
throw new Error('Unknown FileReader state');
110+
return result;
111+
} catch {
112+
return blob.text();
44113
}
114+
};
45115

46-
reader.removeEventListener('error', complete);
47-
reader.removeEventListener('load', complete);
116+
getBlobData = async (blob) => {
117+
try {
118+
const result = await readBlobData(blob);
119+
120+
return result;
121+
} catch {
122+
return blob.arrayBuffer();
123+
}
48124
};
49125

50-
reader.addEventListener('error', complete);
51-
reader.addEventListener('load', complete);
126+
break;
52127

53-
reader.readAsText(blob);
54-
});
55-
};
56-
} else {
57-
getBlobText = (blob) => blob.text();
128+
default:
129+
throw new UnreachableError(BLOB_BEHAVIOR);
130+
}
58131
}
59132

60-
export { getBlobText };
133+
export { getBlobData, getBlobText };
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Awaitable } from '../../../types/helpers';
2+
import { BLOB_BEHAVIOR, getBlobData, getBlobText } from './blob.ts';
3+
4+
type BuildFileResponse = (file: File, init?: ResponseInit) => Awaitable<Response>;
5+
6+
/**
7+
* @see {@link BLOB_BEHAVIOR}, {@link getBlobText}
8+
*/
9+
let buildFileResponse: BuildFileResponse;
10+
11+
if (BLOB_BEHAVIOR === 'BLOB_BEHAVIOR_EXPECTED') {
12+
buildFileResponse = (file, init) => new Response(file, init);
13+
} else {
14+
const buildFileResponseHeaders = (file: File, init?: ResponseInit): Headers => {
15+
const baseHeaders = init?.headers ?? [];
16+
17+
let headersInit: ReadonlyArray<[key: string, value: string]>;
18+
let contentType: string;
19+
20+
if (baseHeaders instanceof Headers) {
21+
headersInit = Array.from(baseHeaders.entries());
22+
contentType = baseHeaders.get('content-type') ?? file.type;
23+
} else {
24+
if (Array.isArray(baseHeaders)) {
25+
headersInit = baseHeaders;
26+
} else {
27+
headersInit = Object.entries(baseHeaders);
28+
}
29+
30+
contentType = file.type;
31+
32+
for (const [key, value] of headersInit) {
33+
if (key.toLowerCase() === 'content-type') {
34+
contentType = value;
35+
}
36+
}
37+
}
38+
39+
return new Headers([['content-type', contentType], ...headersInit]);
40+
};
41+
42+
buildFileResponse = async (file, init) => {
43+
const headers = buildFileResponseHeaders(file, init);
44+
const body = await getBlobData(file);
45+
46+
return new Response(body, {
47+
...init,
48+
headers,
49+
});
50+
};
51+
}
52+
53+
export { buildFileResponse };

0 commit comments

Comments
 (0)