@getodk/xforms-engine 0.7.0 → 0.8.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 (69) hide show
  1. package/dist/client/RootNode.d.ts +4 -4
  2. package/dist/client/UploadNode.d.ts +53 -0
  3. package/dist/client/attachments/InstanceAttachmentMeta.d.ts +8 -0
  4. package/dist/client/attachments/InstanceAttachmentsConfig.d.ts +8 -0
  5. package/dist/client/form/FormInstanceConfig.d.ts +2 -0
  6. package/dist/client/hierarchy.d.ts +4 -5
  7. package/dist/client/index.d.ts +5 -2
  8. package/dist/client/node-types.d.ts +2 -3
  9. package/dist/client/serialization/InstanceData.d.ts +7 -5
  10. package/dist/client/serialization/InstancePayloadOptions.d.ts +3 -2
  11. package/dist/error/UploadValueTypeError.d.ts +8 -0
  12. package/dist/index.js +6603 -5598
  13. package/dist/index.js.map +1 -1
  14. package/dist/instance/PrimaryInstance.d.ts +2 -3
  15. package/dist/instance/UploadControl.d.ts +58 -0
  16. package/dist/instance/attachments/InstanceAttachment.d.ts +42 -0
  17. package/dist/instance/attachments/InstanceAttachmentsState.d.ts +9 -0
  18. package/dist/instance/hierarchy.d.ts +6 -7
  19. package/dist/instance/internal-api/InstanceAttachmentContext.d.ts +19 -0
  20. package/dist/instance/internal-api/InstanceConfig.d.ts +2 -0
  21. package/dist/instance/internal-api/serialization/ClientReactiveSerializableInstance.d.ts +2 -0
  22. package/dist/lib/reactivity/createInstanceAttachment.d.ts +8 -0
  23. package/dist/lib/resource-helpers.d.ts +2 -0
  24. package/dist/parse/body/BodyDefinition.d.ts +2 -1
  25. package/dist/parse/body/control/UploadControlDefinition.d.ts +2 -0
  26. package/dist/parse/model/BindTypeDefinition.d.ts +2 -7
  27. package/dist/solid.js +6600 -5596
  28. package/dist/solid.js.map +1 -1
  29. package/package.json +15 -14
  30. package/src/client/RootNode.ts +8 -3
  31. package/src/client/UploadNode.ts +78 -0
  32. package/src/client/attachments/InstanceAttachmentMeta.ts +10 -0
  33. package/src/client/attachments/InstanceAttachmentsConfig.ts +13 -0
  34. package/src/client/form/FormInstanceConfig.ts +3 -0
  35. package/src/client/hierarchy.ts +5 -8
  36. package/src/client/index.ts +4 -2
  37. package/src/client/node-types.ts +2 -5
  38. package/src/client/serialization/InstanceData.ts +14 -6
  39. package/src/client/serialization/InstancePayloadOptions.ts +3 -2
  40. package/src/entrypoints/FormInstance.ts +3 -2
  41. package/src/error/UploadValueTypeError.ts +13 -0
  42. package/src/instance/PrimaryInstance.ts +4 -5
  43. package/src/instance/UploadControl.ts +184 -0
  44. package/src/instance/attachments/InstanceAttachment.ts +69 -0
  45. package/src/instance/attachments/InstanceAttachmentsState.ts +18 -0
  46. package/src/instance/children/buildChildren.ts +5 -8
  47. package/src/instance/hierarchy.ts +6 -9
  48. package/src/instance/input/InstanceAttachmentMap.ts +33 -21
  49. package/src/instance/internal-api/InstanceAttachmentContext.ts +20 -0
  50. package/src/instance/internal-api/InstanceConfig.ts +3 -0
  51. package/src/instance/internal-api/serialization/ClientReactiveSerializableInstance.ts +2 -0
  52. package/src/lib/client-reactivity/instance-state/prepareInstancePayload.ts +72 -8
  53. package/src/lib/reactivity/createInstanceAttachment.ts +212 -0
  54. package/src/lib/resource-helpers.ts +33 -0
  55. package/src/parse/XFormDOM.ts +1 -3
  56. package/src/parse/body/BodyDefinition.ts +4 -0
  57. package/src/parse/body/control/UploadControlDefinition.ts +42 -0
  58. package/src/parse/model/BindDefinition.ts +1 -1
  59. package/src/parse/model/BindTypeDefinition.ts +68 -26
  60. package/src/parse/model/ModelBindMap.ts +0 -5
  61. package/src/parse/model/SecondaryInstance/sources/ExternalSecondaryInstanceResource.ts +1 -30
  62. package/dist/client/unsupported/UnsupportedControlNode.d.ts +0 -30
  63. package/dist/client/unsupported/UploadNode.d.ts +0 -9
  64. package/dist/instance/unsupported/UploadControl.d.ts +0 -34
  65. package/dist/lib/codecs/TempUnsupportedControlCodec.d.ts +0 -7
  66. package/src/client/unsupported/UnsupportedControlNode.ts +0 -36
  67. package/src/client/unsupported/UploadNode.ts +0 -14
  68. package/src/instance/unsupported/UploadControl.ts +0 -120
  69. package/src/lib/codecs/TempUnsupportedControlCodec.ts +0 -32
@@ -6,7 +6,7 @@ import type { RankDefinition } from '../../client/RankNode.ts';
6
6
  import type { SelectDefinition } from '../../client/SelectNode.ts';
7
7
  import type { SubtreeDefinition } from '../../client/SubtreeNode.ts';
8
8
  import type { TriggerNodeDefinition } from '../../client/TriggerNode.ts';
9
- import type { UploadNodeDefinition } from '../../client/unsupported/UploadNode.ts';
9
+ import type { UploadDefinition } from '../../client/UploadNode.ts';
10
10
  import { ErrorProductionDesignPendingError } from '../../error/ErrorProductionDesignPendingError.ts';
11
11
  import type { LeafNodeDefinition } from '../../parse/model/LeafNodeDefinition.ts';
12
12
  import { NoteNodeDefinition } from '../../parse/model/NoteNodeDefinition.ts';
@@ -28,7 +28,7 @@ import { RepeatRangeUncontrolled } from '../repeat/RepeatRangeUncontrolled.ts';
28
28
  import { SelectControl } from '../SelectControl.ts';
29
29
  import { Subtree } from '../Subtree.ts';
30
30
  import { TriggerControl } from '../TriggerControl.ts';
31
- import { UploadControl } from '../unsupported/UploadControl.ts';
31
+ import { UploadControl } from '../UploadControl.ts';
32
32
  import { childrenInitOptions } from './childrenInitOptions.ts';
33
33
 
34
34
  const isSubtreeDefinition = (
@@ -37,9 +37,6 @@ const isSubtreeDefinition = (
37
37
  return definition.bodyElement == null;
38
38
  };
39
39
 
40
- // prettier-ignore
41
- type AnyUnsupportedControlDefinition = UploadNodeDefinition;
42
-
43
40
  // prettier-ignore
44
41
  type ControlNodeDefinition =
45
42
  // eslint-disable-next-line @typescript-eslint/sort-type-constituents
@@ -48,7 +45,7 @@ type ControlNodeDefinition =
48
45
  | SelectDefinition
49
46
  | RankDefinition
50
47
  | TriggerNodeDefinition
51
- | AnyUnsupportedControlDefinition;
48
+ | UploadDefinition;
52
49
 
53
50
  type AnyLeafNodeDefinition = ControlNodeDefinition | ModelValueDefinition;
54
51
 
@@ -123,7 +120,7 @@ const isTriggerNodeDefinition = (
123
120
 
124
121
  const isUploadNodeDefinition = (
125
122
  definition: ControlNodeDefinition
126
- ): definition is UploadNodeDefinition => {
123
+ ): definition is UploadDefinition => {
127
124
  return definition.bodyElement.type === 'upload';
128
125
  };
129
126
 
@@ -192,7 +189,7 @@ export const buildChildren = (parent: GeneralParentNode): GeneralChildNode[] =>
192
189
  }
193
190
 
194
191
  if (isUploadNodeDefinition(leafChild)) {
195
- return new UploadControl(parent, instanceNode, leafChild);
192
+ return UploadControl.from(parent, instanceNode, leafChild);
196
193
  }
197
194
 
198
195
  throw new UnreachableError(leafChild);
@@ -4,6 +4,7 @@ import type { AnyModelValue } from './ModelValue.ts';
4
4
  import type { AnyNote } from './Note.ts';
5
5
  import type { PrimaryInstance } from './PrimaryInstance.ts';
6
6
  import type { AnyRangeControl } from './RangeControl.ts';
7
+ import type { RankControl } from './RankControl.ts';
7
8
  import type { RepeatInstance } from './repeat/RepeatInstance.ts';
8
9
  import type { RepeatRangeControlled } from './repeat/RepeatRangeControlled.ts';
9
10
  import type { RepeatRangeUncontrolled } from './repeat/RepeatRangeUncontrolled.ts';
@@ -11,14 +12,10 @@ import type { Root } from './Root.ts';
11
12
  import type { SelectControl } from './SelectControl.ts';
12
13
  import type { Subtree } from './Subtree.ts';
13
14
  import type { TriggerControl } from './TriggerControl.ts';
14
- import type { RankControl } from './RankControl.ts';
15
- import type { UploadControl } from './unsupported/UploadControl.ts';
15
+ import type { UploadControl } from './UploadControl.ts';
16
16
 
17
17
  export type RepeatRange = RepeatRangeControlled | RepeatRangeUncontrolled;
18
18
 
19
- // prettier-ignore
20
- export type AnyUnsupportedControl = UploadControl;
21
-
22
19
  // prettier-ignore
23
20
  export type AnyNode =
24
21
  // eslint-disable-next-line @typescript-eslint/sort-type-constituents
@@ -35,7 +32,7 @@ export type AnyNode =
35
32
  | RankControl
36
33
  | SelectControl
37
34
  | TriggerControl
38
- | AnyUnsupportedControl;
35
+ | UploadControl;
39
36
 
40
37
  // prettier-ignore
41
38
  export type AnyParentNode =
@@ -70,7 +67,7 @@ export type AnyChildNode =
70
67
  | RankControl
71
68
  | SelectControl
72
69
  | TriggerControl
73
- | AnyUnsupportedControl;
70
+ | UploadControl;
74
71
 
75
72
  // prettier-ignore
76
73
  export type GeneralChildNode =
@@ -85,7 +82,7 @@ export type GeneralChildNode =
85
82
  | RankControl
86
83
  | SelectControl
87
84
  | TriggerControl
88
- | AnyUnsupportedControl;
85
+ | UploadControl;
89
86
 
90
87
  // prettier-ignore
91
88
  export type AnyValueNode =
@@ -97,4 +94,4 @@ export type AnyValueNode =
97
94
  | RankControl
98
95
  | SelectControl
99
96
  | TriggerControl
100
- | AnyUnsupportedControl;
97
+ | UploadControl;
@@ -1,6 +1,9 @@
1
+ import { getBlobData } from '@getodk/common/lib/web-compat/blob.ts';
1
2
  import { INSTANCE_FILE_NAME } from '../../client/constants.ts';
2
3
  import type { ResolvableInstanceAttachmentsMap } from '../../client/form/EditFormInstance.ts';
3
4
  import { MalformedInstanceDataError } from '../../error/MalformedInstanceDataError.ts';
5
+ import { getResponseContentType } from '../../lib/resource-helpers.ts';
6
+ import type { FetchResourceResponse } from '../resource.ts';
4
7
 
5
8
  type InstanceAttachmentMapSourceEntry = readonly [key: string, value: FormDataEntryValue];
6
9
 
@@ -13,14 +16,35 @@ type InstanceAttachmentMapSources = readonly [
13
16
  ...InstanceAttachmentMapSource[],
14
17
  ];
15
18
 
19
+ const DEFAULT_ATTACHMENT_TYPE = 'application/octet-stream';
20
+
21
+ const resolveContentType = (response: FetchResourceResponse, blob: Blob): string => {
22
+ let result = blob.type;
23
+
24
+ if (result === '') {
25
+ result = getResponseContentType(response) ?? result;
26
+ }
27
+
28
+ if (result === '') {
29
+ return DEFAULT_ATTACHMENT_TYPE;
30
+ }
31
+
32
+ return result;
33
+ };
34
+
35
+ const resolveInstanceAttachmentFile = async (
36
+ response: FetchResourceResponse,
37
+ fileName: string
38
+ ): Promise<File> => {
39
+ const blob = await response.blob();
40
+ const blobData = await getBlobData(blob);
41
+
42
+ return new File([blobData], fileName, {
43
+ type: resolveContentType(response, blob),
44
+ });
45
+ };
46
+
16
47
  /**
17
- * @todo This currently short-circuits if there are actually any instance
18
- * attachments to resolve. As described below, much of the approach is pretty
19
- * naive now anyway, and none of it is really "ready" until we have something to
20
- * actually _use the instance attachments_ once they're resolved! When we are
21
- * ready, the functionality can be unblocked as in
22
- * {@link https://github.com/getodk/web-forms/commit/88ee1b91c1f68d53ce9ba551bab334852e1e60cd | this commit}.
23
- *
24
48
  * @todo Everything about this is incredibly naive! We should almost certainly
25
49
  * do _at least_ the following:
26
50
  *
@@ -37,22 +61,10 @@ type InstanceAttachmentMapSources = readonly [
37
61
  const resolveInstanceAttachmentMapSource = async (
38
62
  input: ResolvableInstanceAttachmentsMap
39
63
  ): Promise<InstanceAttachmentMapSource> => {
40
- const inputEntries = Array.from(input.entries());
41
-
42
- if (inputEntries.length > 0) {
43
- const fileNames = Array.from(input.keys());
44
- const errors = fileNames.map((fileName) => {
45
- return new Error(`Failed to resolve instance attachment with file name "${fileName}"`);
46
- });
47
-
48
- throw new AggregateError(errors, 'Not implemented: instance attachment resource resolution');
49
- }
50
-
51
64
  const entries = await Promise.all<InstanceAttachmentMapSourceEntry>(
52
- inputEntries.map(async ([fileName, resolveAttachment]) => {
65
+ Array.from(input.entries()).map(async ([fileName, resolveAttachment]) => {
53
66
  const response = await resolveAttachment();
54
- const blob = await response.blob();
55
- const value = new File([blob], fileName);
67
+ const value = await resolveInstanceAttachmentFile(response, fileName);
56
68
 
57
69
  return [fileName, value] as const;
58
70
  })
@@ -0,0 +1,20 @@
1
+ import type { Accessor } from 'solid-js';
2
+ import type { FormNodeID } from '../../client/identity.ts';
3
+ import type { StaticLeafElement } from '../../integration/xpath/static-dom/StaticElement.ts';
4
+ import type { ReactiveScope } from '../../lib/reactivity/scope.ts';
5
+ import type { InstanceAttachmentsState } from '../attachments/InstanceAttachmentsState.ts';
6
+ import type { InstanceConfig } from './InstanceConfig.ts';
7
+
8
+ interface InstanceAttachmentRootDocument {
9
+ readonly attachments: InstanceAttachmentsState;
10
+ }
11
+
12
+ export interface InstanceAttachmentContext {
13
+ readonly instanceConfig: InstanceConfig;
14
+ readonly scope: ReactiveScope;
15
+ readonly rootDocument: InstanceAttachmentRootDocument;
16
+ readonly nodeId: FormNodeID;
17
+ readonly instanceNode: StaticLeafElement | null;
18
+ readonly isRelevant: Accessor<boolean>;
19
+ readonly isAttached: Accessor<boolean>;
20
+ }
@@ -1,3 +1,4 @@
1
+ import type { InstanceAttachmentFileNameFactory } from '../../client/attachments/InstanceAttachmentsConfig.ts';
1
2
  import type { FormInstanceConfig } from '../../client/form/FormInstanceConfig.ts';
2
3
  import type { OpaqueReactiveObjectFactory } from '../../client/OpaqueReactiveObjectFactory.ts';
3
4
 
@@ -6,4 +7,6 @@ export interface InstanceConfig {
6
7
  * @see {@link FormInstanceConfig.stateFactory}
7
8
  */
8
9
  readonly clientStateFactory: OpaqueReactiveObjectFactory;
10
+
11
+ readonly computeAttachmentName: InstanceAttachmentFileNameFactory;
9
12
  }
@@ -1,5 +1,6 @@
1
1
  import type { SubmissionMeta } from '../../../client/submission/SubmissionMeta.ts';
2
2
  import type { AncestorNodeValidationState } from '../../../client/validation.ts';
3
+ import type { InstanceAttachmentsState } from '../../attachments/InstanceAttachmentsState.ts';
3
4
  import type { Root } from '../../Root.ts';
4
5
  import type {
5
6
  ClientReactiveSerializableParentNode,
@@ -16,5 +17,6 @@ export interface ClientReactiveSerializableInstance
16
17
  readonly definition: ClientReactiveSerializableInstanceDefinition;
17
18
  readonly root: Root;
18
19
  readonly parent: null;
20
+ readonly attachments: InstanceAttachmentsState;
19
21
  readonly validationState: AncestorNodeValidationState;
20
22
  }
@@ -1,4 +1,5 @@
1
1
  import { UnreachableError } from '@getodk/common/lib/error/UnreachableError.ts';
2
+ import { bestFitDecreasing } from 'bin-packer';
2
3
  import { INSTANCE_FILE_NAME, INSTANCE_FILE_TYPE } from '../../../client/constants.ts';
3
4
  import type { InstanceData as ClientInstanceData } from '../../../client/serialization/InstanceData.ts';
4
5
  import type { InstanceFile as ClientInstanceFile } from '../../../client/serialization/InstanceFile.ts';
@@ -10,8 +11,22 @@ import type {
10
11
  import type { InstancePayloadType } from '../../../client/serialization/InstancePayloadOptions.ts';
11
12
  import type { SubmissionMeta } from '../../../client/submission/SubmissionMeta.ts';
12
13
  import type { DescendantNodeViolationReference } from '../../../client/validation.ts';
14
+ import { ErrorProductionDesignPendingError } from '../../../error/ErrorProductionDesignPendingError.ts';
15
+ import type { InstanceAttachmentsState } from '../../../instance/attachments/InstanceAttachmentsState.ts';
13
16
  import type { ClientReactiveSerializableInstance } from '../../../instance/internal-api/serialization/ClientReactiveSerializableInstance.ts';
14
17
 
18
+ const collectInstanceAttachmentFiles = (attachments: InstanceAttachmentsState): readonly File[] => {
19
+ const files = Array.from(attachments.entries()).map(([context, attachment]) => {
20
+ if (!context.isAttached() || !context.isRelevant()) {
21
+ return null;
22
+ }
23
+
24
+ return attachment.getValue();
25
+ });
26
+
27
+ return files.filter((file) => file != null);
28
+ };
29
+
15
30
  class InstanceFile extends File implements ClientInstanceFile {
16
31
  override readonly name = INSTANCE_FILE_NAME;
17
32
  override readonly type = INSTANCE_FILE_TYPE;
@@ -25,10 +40,26 @@ class InstanceFile extends File implements ClientInstanceFile {
25
40
  }
26
41
  }
27
42
 
43
+ type AssertFile = (value: FormDataEntryValue) => asserts value is File;
44
+
45
+ const assertFile: AssertFile = (value) => {
46
+ if (!(value instanceof File)) {
47
+ throw new ErrorProductionDesignPendingError('Expected an instance of File');
48
+ }
49
+ };
50
+
28
51
  type AssertInstanceData = (data: FormData) => asserts data is ClientInstanceData;
29
52
 
30
53
  const assertInstanceData: AssertInstanceData = (data) => {
31
- const instanceFile = data.get(INSTANCE_FILE_NAME);
54
+ let instanceFile: File | null = null;
55
+
56
+ for (const [key, value] of data.entries()) {
57
+ assertFile(value);
58
+
59
+ if (key === INSTANCE_FILE_NAME) {
60
+ instanceFile = value;
61
+ }
62
+ }
32
63
 
33
64
  if (!(instanceFile instanceof InstanceFile)) {
34
65
  throw new Error(`Invalid InstanceData`);
@@ -114,6 +145,43 @@ interface ChunkedInstancePayloadOptions {
114
145
  readonly maxSize: number;
115
146
  }
116
147
 
148
+ type PartitionedInstanceData = readonly [ClientInstanceData, ...ClientInstanceData[]];
149
+
150
+ const partitionInstanceData = (
151
+ instanceFile: InstanceFile,
152
+ attachments: readonly File[],
153
+ options: ChunkedInstancePayloadOptions
154
+ ): PartitionedInstanceData => {
155
+ const { maxSize } = options;
156
+ const maxAttachmentSize = maxSize - instanceFile.size;
157
+ const { bins, oversized } = bestFitDecreasing(
158
+ attachments,
159
+ (attachment) => {
160
+ return attachment.size;
161
+ },
162
+ maxAttachmentSize
163
+ );
164
+
165
+ const errors = oversized.map((attachment) => {
166
+ return new Error(
167
+ `Combined size of instance XML (${instanceFile.size}) and attachment (${attachment.size}) exceeds maxSize (${maxSize}).`
168
+ );
169
+ });
170
+
171
+ if (errors.length > 0) {
172
+ throw new AggregateError(errors, 'Failed to produce chunked instance payload');
173
+ }
174
+
175
+ const [
176
+ // Ensure at least one `InstanceData` is produced, in case there are no
177
+ // attachments present at all
178
+ head = InstanceData.from(instanceFile, []),
179
+ ...tail
180
+ ] = bins.map((bin) => InstanceData.from(instanceFile, bin));
181
+
182
+ return [head, ...tail];
183
+ };
184
+
117
185
  const chunkedInstancePayload = (
118
186
  validation: InstanceStateValidation,
119
187
  submissionMeta: SubmissionMeta,
@@ -121,17 +189,13 @@ const chunkedInstancePayload = (
121
189
  attachments: readonly File[],
122
190
  options: ChunkedInstancePayloadOptions
123
191
  ): ChunkedInstancePayload => {
124
- if (attachments.length > 0 || options.maxSize !== Infinity) {
125
- throw new Error('InstancePayload chunking pending implementation');
126
- }
127
-
128
- const data = InstanceData.from(instanceFile, attachments);
192
+ const data = partitionInstanceData(instanceFile, attachments, options);
129
193
 
130
194
  return {
131
195
  payloadType: 'chunked',
132
196
  ...validation,
133
197
  submissionMeta,
134
- data: [data],
198
+ data,
135
199
  };
136
200
  };
137
201
 
@@ -147,7 +211,7 @@ export const prepareInstancePayload = <PayloadType extends InstancePayloadType>(
147
211
  const validation = validateInstance(instanceRoot);
148
212
  const submissionMeta = instanceRoot.definition.submission;
149
213
  const instanceFile = new InstanceFile(instanceRoot);
150
- const attachments: readonly File[] = [];
214
+ const attachments = collectInstanceAttachmentFiles(instanceRoot.attachments);
151
215
 
152
216
  switch (options.payloadType) {
153
217
  case 'chunked':
@@ -0,0 +1,212 @@
1
+ import { createMemo, createSignal } from 'solid-js';
2
+ import type { FormNodeID } from '../../client/identity.ts';
3
+ import { ErrorProductionDesignPendingError } from '../../error/ErrorProductionDesignPendingError.ts';
4
+ import type { InstanceAttachmentFileName } from '../../instance/attachments/InstanceAttachment.ts';
5
+ import { InstanceAttachment } from '../../instance/attachments/InstanceAttachment.ts';
6
+ import type { InstanceAttachmentContext } from '../../instance/internal-api/InstanceAttachmentContext.ts';
7
+ import type { DecodeInstanceValue } from '../../instance/internal-api/InstanceValueContext.ts';
8
+ import type { SimpleAtomicStateSetter } from './types.ts';
9
+
10
+ type FileNameExtension = `.${string}`;
11
+
12
+ type AssertFileNameExtension = (value: string) => asserts value is FileNameExtension;
13
+
14
+ const assertFileNameExtension: AssertFileNameExtension = (value) => {
15
+ if (!value.startsWith('.')) {
16
+ throw new ErrorProductionDesignPendingError('Expected file name extension to start with "."');
17
+ }
18
+ };
19
+
20
+ interface SplitFileNameResult {
21
+ readonly basename: string;
22
+ readonly extension: FileNameExtension | null;
23
+ }
24
+
25
+ const EXTENSION_PATTERN = /\.[^.]+?$/;
26
+
27
+ interface SearchPatternResult extends Array<string> {
28
+ readonly 0?: string;
29
+ readonly index?: number;
30
+ }
31
+
32
+ const searchPattern = (pattern: RegExp, string: string): SearchPatternResult => {
33
+ return pattern.exec(string) ?? [];
34
+ };
35
+
36
+ const splitFileName = (fileName: string): SplitFileNameResult => {
37
+ const extensionMatches = searchPattern(EXTENSION_PATTERN, fileName);
38
+ const [extension = null] = extensionMatches;
39
+ const basename = fileName.slice(0, extensionMatches.index);
40
+
41
+ if (extension == null) {
42
+ return {
43
+ basename,
44
+ extension,
45
+ };
46
+ }
47
+
48
+ assertFileNameExtension(extension);
49
+
50
+ return {
51
+ basename,
52
+ extension,
53
+ };
54
+ };
55
+
56
+ export type InstanceAttachmentRuntimeValue = File | null;
57
+
58
+ export type InstanceAttachmentFormDataEntry = readonly [
59
+ key: InstanceAttachmentFileName,
60
+ value: NonNullable<InstanceAttachmentRuntimeValue>,
61
+ ];
62
+
63
+ interface InstanceAttachmentValueOptions {
64
+ readonly nodeId: FormNodeID;
65
+ readonly writtenAt: Date | null;
66
+ readonly file: InstanceAttachmentRuntimeValue;
67
+ }
68
+
69
+ interface BaseInstanceAttachmentState {
70
+ readonly computedName: string | null;
71
+ readonly intrinsicName: string | null;
72
+ readonly file: InstanceAttachmentRuntimeValue;
73
+ }
74
+
75
+ interface BlankInstanceAttachmentState extends BaseInstanceAttachmentState {
76
+ readonly computedName: null;
77
+ readonly intrinsicName: null;
78
+ readonly file: null;
79
+ }
80
+
81
+ interface NonBlankInstanceAttachmentState extends BaseInstanceAttachmentState {
82
+ readonly computedName: string | null;
83
+ readonly intrinsicName: string;
84
+ readonly file: NonNullable<InstanceAttachmentRuntimeValue>;
85
+ }
86
+
87
+ // prettier-ignore
88
+ type InstanceAttachmentState =
89
+ | BlankInstanceAttachmentState
90
+ | NonBlankInstanceAttachmentState;
91
+
92
+ const instanceAttachmentState = (
93
+ context: InstanceAttachmentContext,
94
+ options: InstanceAttachmentValueOptions
95
+ ): InstanceAttachmentState => {
96
+ const { nodeId, file, writtenAt } = options;
97
+
98
+ // No file -> no intrinsic name, no name to compute
99
+ if (file == null) {
100
+ return {
101
+ computedName: null,
102
+ intrinsicName: null,
103
+ file: null,
104
+ };
105
+ }
106
+
107
+ const intrinsicName = file.name;
108
+
109
+ // File exists, not written by client -> preserve instance input name
110
+ if (writtenAt == null) {
111
+ return {
112
+ computedName: null,
113
+ intrinsicName,
114
+ file,
115
+ };
116
+ }
117
+
118
+ // File was written by client, name is computed as configured by client
119
+ const { basename, extension } = splitFileName(intrinsicName);
120
+ const computedName = context.instanceConfig.computeAttachmentName({
121
+ nodeId,
122
+ writtenAt,
123
+ basename,
124
+ extension,
125
+ });
126
+
127
+ return {
128
+ computedName,
129
+ intrinsicName,
130
+ file,
131
+ };
132
+ };
133
+
134
+ export const createInstanceAttachment = (
135
+ context: InstanceAttachmentContext
136
+ ): InstanceAttachment => {
137
+ return context.scope.runTask(() => {
138
+ const { rootDocument, nodeId } = context;
139
+ const { attachments } = rootDocument;
140
+ const file = attachments.getInitialFileValue(context.instanceNode);
141
+ const initialState = instanceAttachmentState(context, {
142
+ nodeId,
143
+ file,
144
+ writtenAt: null,
145
+ });
146
+
147
+ const [getState, setState] = createSignal<InstanceAttachmentState>(initialState);
148
+
149
+ const decodeInstanceValue: DecodeInstanceValue = (value) => {
150
+ const { computedName, intrinsicName } = getState();
151
+
152
+ if (value === '') {
153
+ if (computedName != null || intrinsicName != null) {
154
+ throw new ErrorProductionDesignPendingError(
155
+ `Unexpected file name reference. Expected one of "${computedName}", "${intrinsicName}", got: ""`
156
+ );
157
+ }
158
+ }
159
+
160
+ if (value === intrinsicName) {
161
+ return computedName ?? intrinsicName;
162
+ }
163
+
164
+ if (value === computedName) {
165
+ return computedName;
166
+ }
167
+
168
+ throw new ErrorProductionDesignPendingError(
169
+ `Unexpected file name reference. Expected one of "${computedName}", "${intrinsicName}", got: "${value}"`
170
+ );
171
+ };
172
+
173
+ const getValue = createMemo(() => {
174
+ const { computedName, file: currentFile } = getState();
175
+
176
+ if (computedName == null) {
177
+ return currentFile;
178
+ }
179
+
180
+ return new File([currentFile], computedName, {
181
+ type: currentFile.type,
182
+ });
183
+ });
184
+ const setValue: SimpleAtomicStateSetter<InstanceAttachmentRuntimeValue> = (value) => {
185
+ const updatedState = instanceAttachmentState(context, {
186
+ nodeId,
187
+ file: value,
188
+ writtenAt: new Date(),
189
+ });
190
+
191
+ return setState(updatedState).file;
192
+ };
193
+ const valueState = [getValue, setValue] as const;
194
+
195
+ const getFileName = createMemo(() => {
196
+ const { computedName, intrinsicName } = getState();
197
+
198
+ return computedName ?? intrinsicName;
199
+ });
200
+ const getInstanceValue = createMemo(() => getFileName() ?? '');
201
+
202
+ return InstanceAttachment.init(context, {
203
+ getFileName,
204
+ getInstanceValue,
205
+ decodeInstanceValue,
206
+
207
+ getValue,
208
+ setValue,
209
+ valueState,
210
+ });
211
+ });
212
+ };
@@ -0,0 +1,33 @@
1
+ import type { FetchResourceResponse } from '../client/resources.ts';
2
+
3
+ const CONTENT_TYPE_CHARSET_PATTERN = /\s*;\s*charset\s*=.*$/;
4
+
5
+ const stripContentTypeCharset = (contentType: string): string => {
6
+ return contentType.replace(CONTENT_TYPE_CHARSET_PATTERN, '');
7
+ };
8
+
9
+ export const getResponseContentType = (response: FetchResourceResponse): string | null => {
10
+ const { headers } = response;
11
+
12
+ if (headers == null) {
13
+ return null;
14
+ }
15
+
16
+ const contentType = headers.get('content-type');
17
+
18
+ if (contentType != null) {
19
+ return stripContentTypeCharset(contentType);
20
+ }
21
+
22
+ if (headers instanceof Headers) {
23
+ return contentType;
24
+ }
25
+
26
+ for (const [header, value] of headers) {
27
+ if (header.toLowerCase() === 'content-type') {
28
+ return stripContentTypeCharset(value);
29
+ }
30
+ }
31
+
32
+ return null;
33
+ };
@@ -104,9 +104,7 @@ const normalizeDefaultMetaBindings = (
104
104
  let meta = getMetaElement(primaryInstanceRoot);
105
105
  let instanceID = getMetaChildElement(meta, 'instanceID');
106
106
 
107
- if (meta == null) {
108
- meta = createNamespacedChildElement(primaryInstanceRoot, OPENROSA_XFORMS_NAMESPACE_URI, 'meta');
109
- }
107
+ meta ??= createNamespacedChildElement(primaryInstanceRoot, OPENROSA_XFORMS_NAMESPACE_URI, 'meta');
110
108
 
111
109
  if (instanceID == null) {
112
110
  instanceID = createNamespacedChildElement(meta, meta.namespaceURI, 'instanceID');
@@ -207,6 +207,10 @@ export class BodyDefinition extends DependencyContext implements BodyElementPare
207
207
  return this.elementsByReference.get(reference) ?? null;
208
208
  }
209
209
 
210
+ getBodyElementType(reference: BodyElementReference): AnyBodyElementType | null {
211
+ return this.elementsByReference.getBodyElementType(reference);
212
+ }
213
+
210
214
  getChildElementDefinitions(
211
215
  form: XFormDefinition,
212
216
  parent: BodyElementParentContext,