@getodk/xforms-engine 0.16.1 → 0.17.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 (80) hide show
  1. package/dist/client/InputNode.d.ts +8 -4
  2. package/dist/client/MarkdownNode.d.ts +3 -0
  3. package/dist/client/NoteNode.d.ts +6 -2
  4. package/dist/client/form/FormInstanceConfig.d.ts +4 -0
  5. package/dist/client/form/LoadFormResult.d.ts +5 -14
  6. package/dist/client/form/ResetFormInstance.d.ts +13 -0
  7. package/dist/entrypoints/FormResult/BaseFormResult.d.ts +1 -0
  8. package/dist/entrypoints/FormResult/BaseInstantiableFormResult.d.ts +2 -0
  9. package/dist/entrypoints/FormResult/FormFailureResult.d.ts +2 -0
  10. package/dist/entrypoints/createPotentiallyClientOwnedReactiveScope.d.ts +19 -0
  11. package/dist/index.js +21681 -25500
  12. package/dist/index.js.map +1 -1
  13. package/dist/instance/PrimaryInstance.d.ts +4 -1
  14. package/dist/instance/internal-api/AttributeContext.d.ts +1 -0
  15. package/dist/instance/internal-api/InstanceConfig.d.ts +2 -1
  16. package/dist/instance/internal-api/InstanceValueContext.d.ts +1 -0
  17. package/dist/instance/markdown/MarkdownNode.d.ts +14 -9
  18. package/dist/integration/xpath/static-dom/StaticDocument.d.ts +2 -0
  19. package/dist/lib/codecs/{Geopoint/Geopoint.d.ts → geolocation/Geolocation.d.ts} +11 -15
  20. package/dist/lib/codecs/geolocation/Geopoint.d.ts +7 -0
  21. package/dist/lib/codecs/geolocation/Geoshape.d.ts +7 -0
  22. package/dist/lib/codecs/geolocation/Geotrace.d.ts +7 -0
  23. package/dist/lib/codecs/geolocation/createGeolocationValueCodec.d.ts +3 -0
  24. package/dist/lib/codecs/getSharedValueCodec.d.ts +7 -5
  25. package/dist/lib/reactivity/text/createTextRange.d.ts +0 -2
  26. package/dist/parse/XFormDOM.d.ts +7 -1
  27. package/dist/parse/body/appearance/inputAppearanceParser.d.ts +1 -1
  28. package/dist/parse/model/ActionDefinition.d.ts +1 -1
  29. package/dist/parse/model/BindPreloadDefinition.d.ts +2 -1
  30. package/dist/parse/model/ModelActionMap.d.ts +3 -2
  31. package/dist/parse/model/ModelDefinition.d.ts +3 -5
  32. package/dist/parse/model/SecondaryInstance/sources/CSVExternalSecondaryInstance.d.ts +0 -17
  33. package/dist/parse/model/SecondaryInstance/sources/external-instance-csv-parser.d.ts +8 -0
  34. package/dist/parse/model/TranslationDefinitionMap.d.ts +4 -0
  35. package/dist/solid.js +21407 -25226
  36. package/dist/solid.js.map +1 -1
  37. package/package.json +2 -2
  38. package/src/client/InputNode.ts +11 -3
  39. package/src/client/MarkdownNode.ts +3 -0
  40. package/src/client/NoteNode.ts +9 -1
  41. package/src/client/form/FormInstanceConfig.ts +6 -0
  42. package/src/client/form/LoadFormResult.ts +5 -17
  43. package/src/client/form/ResetFormInstance.ts +17 -0
  44. package/src/entrypoints/FormInstance.ts +2 -0
  45. package/src/entrypoints/FormResult/BaseFormResult.ts +1 -0
  46. package/src/entrypoints/FormResult/BaseInstantiableFormResult.ts +10 -1
  47. package/src/entrypoints/FormResult/FormFailureResult.ts +3 -0
  48. package/src/entrypoints/createPotentiallyClientOwnedReactiveScope.ts +30 -0
  49. package/src/entrypoints/loadForm.ts +1 -31
  50. package/src/instance/InputControl.ts +3 -5
  51. package/src/instance/PrimaryInstance.ts +17 -2
  52. package/src/instance/attachments/buildAttributes.ts +21 -1
  53. package/src/instance/internal-api/AttributeContext.ts +1 -0
  54. package/src/instance/internal-api/InstanceConfig.ts +3 -0
  55. package/src/instance/internal-api/InstanceValueContext.ts +1 -0
  56. package/src/instance/markdown/MarkdownNode.ts +19 -7
  57. package/src/instance/text/markdownFormat.ts +4 -3
  58. package/src/integration/xpath/static-dom/StaticDocument.ts +2 -0
  59. package/src/lib/codecs/{Geopoint/Geopoint.ts → geolocation/Geolocation.ts} +43 -24
  60. package/src/lib/codecs/geolocation/Geopoint.ts +15 -0
  61. package/src/lib/codecs/geolocation/Geoshape.ts +36 -0
  62. package/src/lib/codecs/geolocation/Geotrace.ts +36 -0
  63. package/src/lib/codecs/geolocation/createGeolocationValueCodec.ts +18 -0
  64. package/src/lib/codecs/getSharedValueCodec.ts +37 -11
  65. package/src/lib/reactivity/createInstanceValueState.ts +64 -34
  66. package/src/lib/reactivity/text/createTextRange.ts +71 -45
  67. package/src/parse/XFormDOM.ts +22 -2
  68. package/src/parse/model/ActionDefinition.ts +6 -6
  69. package/src/parse/model/BindDefinition.ts +1 -1
  70. package/src/parse/model/BindPreloadDefinition.ts +21 -14
  71. package/src/parse/model/ModelActionMap.ts +30 -13
  72. package/src/parse/model/ModelDefinition.ts +5 -10
  73. package/src/parse/model/RootDefinition.ts +2 -1
  74. package/src/parse/model/SecondaryInstance/sources/CSVExternalSecondaryInstance.ts +2 -184
  75. package/src/parse/model/SecondaryInstance/sources/external-instance-csv-parser.ts +185 -0
  76. package/src/parse/model/TranslationDefinitionMap.ts +23 -0
  77. package/dist/lib/codecs/Geopoint/GeopointValueCodec.d.ts +0 -5
  78. package/dist/parse/model/generateItextChunks.d.ts +0 -5
  79. package/src/lib/codecs/Geopoint/GeopointValueCodec.ts +0 -20
  80. package/src/parse/model/generateItextChunks.ts +0 -61
@@ -10,8 +10,13 @@ import type {
10
10
  } from '@getodk/common/types/dom.ts';
11
11
  import { DefaultEvaluator } from '@getodk/xpath';
12
12
 
13
+ export const SET_VALUE_LOCAL_NAME = 'setvalue';
14
+ export const SET_GEOPOINT_LOCAL_NAME = 'odk:setgeopoint';
15
+
13
16
  interface DOMBindElement extends KnownAttributeLocalNamedElement<'bind', 'nodeset'> {}
14
- interface DOMSetValueElement extends KnownAttributeLocalNamedElement<'setvalue', 'event'> {}
17
+ export interface DOMSetValueElement extends KnownAttributeLocalNamedElement<'setvalue', 'event'> {}
18
+ export interface DOMSetGeopointElement
19
+ extends KnownAttributeLocalNamedElement<'odk:setgeopoint', 'event'> {}
15
20
 
16
21
  const getMetaElement = (primaryInstanceRoot: Element): Element | null => {
17
22
  for (const child of primaryInstanceRoot.children) {
@@ -325,6 +330,15 @@ export class XFormDOM {
325
330
  return new this(sourceXML, { isNormalized: false });
326
331
  }
327
332
 
333
+ isInstanceID = (nodeset: string) => {
334
+ const meta = getMetaElement(this.primaryInstanceRoot);
335
+ const instanceId = meta && getMetaChildElement(meta, 'instanceID');
336
+ return (
337
+ instanceId &&
338
+ nodeset === `/${this.primaryInstanceRoot.nodeName}/${meta.nodeName}/${instanceId.nodeName}`
339
+ );
340
+ };
341
+
328
342
  protected readonly normalizedXML: string;
329
343
 
330
344
  // Commonly accessed landmark nodes
@@ -338,6 +352,7 @@ export class XFormDOM {
338
352
  readonly model: Element;
339
353
  readonly binds: readonly DOMBindElement[];
340
354
  readonly setValues: readonly DOMSetValueElement[];
355
+ readonly setGeopoints: readonly DOMSetGeopointElement[];
341
356
  readonly primaryInstance: Element;
342
357
  readonly primaryInstanceRoot: Element;
343
358
 
@@ -371,11 +386,15 @@ export class XFormDOM {
371
386
  }
372
387
  );
373
388
  const setValues: readonly DOMSetValueElement[] = evaluator.evaluateNodes<DOMSetValueElement>(
374
- './xf:setvalue[@event]',
389
+ `./xf:${SET_VALUE_LOCAL_NAME}[@event]`,
375
390
  {
376
391
  contextNode: model,
377
392
  }
378
393
  );
394
+ const setGeopoints: readonly DOMSetGeopointElement[] =
395
+ evaluator.evaluateNodes<DOMSetGeopointElement>(`./${SET_GEOPOINT_LOCAL_NAME}[@event]`, {
396
+ contextNode: model,
397
+ });
379
398
 
380
399
  const instances = evaluator.evaluateNodes<DOMInstanceElement>('./xf:instance', {
381
400
  contextNode: model,
@@ -426,6 +445,7 @@ export class XFormDOM {
426
445
  this.model = model;
427
446
  this.binds = binds;
428
447
  this.setValues = setValues;
448
+ this.setGeopoints = setGeopoints;
429
449
  this.primaryInstance = primaryInstance;
430
450
  this.primaryInstanceRoot = primaryInstanceRoot;
431
451
  this.itextTranslationElements = itextTranslationElements;
@@ -4,12 +4,12 @@ import { type XFormEvent, XFORM_EVENT } from './Event.ts';
4
4
  import type { ModelDefinition } from './ModelDefinition.ts';
5
5
 
6
6
  export class ActionDefinition {
7
- static getRef(model: ModelDefinition, setValueElement: Element): string | null {
8
- if (setValueElement.hasAttribute('ref')) {
9
- return setValueElement.getAttribute('ref') ?? null;
7
+ static getRef(model: ModelDefinition, element: Element): string | null {
8
+ if (element.hasAttribute('ref')) {
9
+ return element.getAttribute('ref') ?? null;
10
10
  }
11
- if (setValueElement.hasAttribute('bind')) {
12
- const bindId = setValueElement.getAttribute('bind');
11
+ if (element.hasAttribute('bind')) {
12
+ const bindId = element.getAttribute('bind');
13
13
  const bindDefinition = Array.from(model.binds.values()).find((definition) => {
14
14
  return definition.bindElement.getAttribute('id') === bindId;
15
15
  });
@@ -58,7 +58,7 @@ export class ActionDefinition {
58
58
  const ref = ActionDefinition.getRef(model, element);
59
59
  if (!ref) {
60
60
  throw new Error(
61
- 'Invalid setvalue element - you must define either "ref" or "bind" attribute'
61
+ `Invalid ${element.localName} element - you must define either "ref" or "bind" attribute`
62
62
  );
63
63
  }
64
64
  this.ref = ref;
@@ -82,7 +82,7 @@ export class BindDefinition<T extends BindType = BindType> extends DependencyCon
82
82
  const parentNodeset = nodeset.replace(/\/[^/]+$/, '');
83
83
 
84
84
  this.parentNodeset = parentNodeset.length > 1 ? parentNodeset : null;
85
- this.preload = BindPreloadDefinition.from(bindElement);
85
+ this.preload = BindPreloadDefinition.from(this, bindElement);
86
86
  this.calculate = BindComputationExpression.forComputation(this, 'calculate');
87
87
  this.readonly = BindComputationExpression.forComputation(this, 'readonly');
88
88
  this.relevant = BindComputationExpression.forComputation(this, 'relevant');
@@ -2,6 +2,7 @@ import { JAVAROSA_NAMESPACE_URI } from '@getodk/common/constants/xmlns.ts';
2
2
  import type { PartiallyKnownString } from '@getodk/common/types/string/PartiallyKnownString.ts';
3
3
  import type { AttributeContext } from '../../instance/internal-api/AttributeContext.ts';
4
4
  import type { InstanceValueContext } from '../../instance/internal-api/InstanceValueContext.ts';
5
+ import type { BindDefinition } from './BindDefinition.ts';
5
6
  import type { BindElement } from './BindElement.ts';
6
7
  import { XFORM_EVENT, type XFormEvent } from './Event.ts';
7
8
 
@@ -71,20 +72,29 @@ const getPreloadInput = (bindElement: BindElement): AnyPreloadInput | null => {
71
72
  * - {@link parameter}, an associated `jr:preloadParams` value
72
73
  */
73
74
  export class BindPreloadDefinition<Type extends PreloadType> implements PreloadInput<Type> {
74
- static from(bindElement: BindElement): AnyBindPreloadDefinition | null {
75
+ static from(
76
+ definition: BindDefinition,
77
+ bindElement: BindElement
78
+ ): AnyBindPreloadDefinition | null {
75
79
  const input = getPreloadInput(bindElement);
76
80
 
77
81
  if (input == null) {
78
82
  return null;
79
83
  }
80
84
 
81
- return new this(input);
85
+ const type = input.type;
86
+ const parameter = input.parameter;
87
+ let event;
88
+ if (definition.form.xformDOM.isInstanceID(bindElement.getAttribute('nodeset'))) {
89
+ event = XFORM_EVENT.odkInstanceLoad;
90
+ } else if (type === 'timestamp' && parameter === 'end') {
91
+ event = XFORM_EVENT.xformsRevalidate;
92
+ } else {
93
+ event = XFORM_EVENT.odkInstanceFirstLoad;
94
+ }
95
+ return new this(type, parameter, event);
82
96
  }
83
97
 
84
- readonly type: Type;
85
- readonly parameter: PreloadParameter<Type>;
86
- readonly event: XFormEvent;
87
-
88
98
  getValue(context: AttributeContext | InstanceValueContext): string | undefined {
89
99
  if (this.type === 'uid') {
90
100
  return context.evaluator.evaluateString(PRELOAD_UID_EXPRESSION);
@@ -113,14 +123,11 @@ export class BindPreloadDefinition<Type extends PreloadType> implements PreloadI
113
123
  return;
114
124
  }
115
125
 
116
- private constructor(input: PreloadInput<Type>) {
117
- this.type = input.type;
118
- this.parameter = input.parameter;
119
- this.event =
120
- this.type === 'timestamp' && this.parameter === 'end'
121
- ? XFORM_EVENT.xformsRevalidate
122
- : XFORM_EVENT.odkInstanceFirstLoad;
123
- }
126
+ private constructor(
127
+ readonly type: Type,
128
+ readonly parameter: PreloadParameter<Type>,
129
+ readonly event: XFormEvent
130
+ ) {}
124
131
  }
125
132
 
126
133
  // prettier-ignore
@@ -1,10 +1,16 @@
1
+ import {
2
+ type DOMSetGeopointElement,
3
+ type DOMSetValueElement,
4
+ SET_GEOPOINT_LOCAL_NAME,
5
+ SET_VALUE_LOCAL_NAME,
6
+ } from '../XFormDOM.ts';
1
7
  import { ActionDefinition } from './ActionDefinition.ts';
2
8
  import { XFORM_EVENT } from './Event.ts';
3
9
  import type { ModelDefinition } from './ModelDefinition.ts';
4
10
 
5
11
  const REPEAT_REGEX = /(\[[^\]]*\])/gm;
6
12
 
7
- export class ModelActionMap extends Map<string, ActionDefinition> {
13
+ export class ModelActionMap extends Map<string, ActionDefinition[]> {
8
14
  static fromModel(model: ModelDefinition): ModelActionMap {
9
15
  return new this(model);
10
16
  }
@@ -14,24 +20,35 @@ export class ModelActionMap extends Map<string, ActionDefinition> {
14
20
  }
15
21
 
16
22
  protected constructor(model: ModelDefinition) {
17
- super(
18
- model.form.xformDOM.setValues.map((setValueElement) => {
19
- const action = new ActionDefinition(model, setValueElement);
20
- if (action.events.includes(XFORM_EVENT.odkNewRepeat)) {
21
- throw new Error('Model contains "setvalue" element with "odk-new-repeat" event');
22
- }
23
- const key = ModelActionMap.getKey(action.ref);
24
- return [key, action];
25
- })
26
- );
23
+ super();
24
+ this.addAll(model, model.form.xformDOM.setValues, SET_VALUE_LOCAL_NAME);
25
+ this.addAll(model, model.form.xformDOM.setGeopoints, SET_GEOPOINT_LOCAL_NAME);
27
26
  }
28
27
 
29
- override get(ref: string): ActionDefinition | undefined {
28
+ override get(ref: string): ActionDefinition[] | undefined {
30
29
  return super.get(ModelActionMap.getKey(ref));
31
30
  }
32
31
 
32
+ private addAll(
33
+ model: ModelDefinition,
34
+ elements: readonly DOMSetGeopointElement[] | readonly DOMSetValueElement[],
35
+ type: string
36
+ ) {
37
+ for (const element of elements) {
38
+ const action = new ActionDefinition(model, element);
39
+ if (action.events.includes(XFORM_EVENT.odkNewRepeat)) {
40
+ throw new Error(`Model contains "${type}" element with "odk-new-repeat" event`);
41
+ }
42
+ this.add(action);
43
+ }
44
+ }
45
+
33
46
  add(action: ActionDefinition) {
34
47
  const key = ModelActionMap.getKey(action.ref);
35
- this.set(key, action);
48
+ if (this.has(key)) {
49
+ this.get(key)!.push(action);
50
+ } else {
51
+ this.set(key, [action]);
52
+ }
36
53
  }
37
54
  }
@@ -1,10 +1,8 @@
1
1
  import type { ActiveLanguage } from '../../client/FormLanguage.ts';
2
2
  import { ErrorProductionDesignPendingError } from '../../error/ErrorProductionDesignPendingError.ts';
3
3
  import type { StaticDocument } from '../../integration/xpath/static-dom/StaticDocument.ts';
4
- import { TextChunkExpression } from '../expression/TextChunkExpression.ts';
5
4
  import { parseStaticDocumentFromDOMSubtree } from '../shared/parseStaticDocumentFromDOMSubtree.ts';
6
5
  import type { XFormDefinition } from '../XFormDefinition.ts';
7
- import { generateItextChunks, type ChunkExpressionsByItextId } from './generateItextChunks.ts';
8
6
  import { ItextTranslationsDefinition } from './ItextTranslationsDefinition.ts';
9
7
  import { ModelActionMap } from './ModelActionMap.ts';
10
8
  import { ModelBindMap } from './ModelBindMap.ts';
@@ -13,6 +11,7 @@ import type { NodeDefinitionMap } from './nodeDefinitionMap.ts';
13
11
  import { nodeDefinitionMap } from './nodeDefinitionMap.ts';
14
12
  import { RootDefinition } from './RootDefinition.ts';
15
13
  import { SubmissionDefinition } from './SubmissionDefinition.ts';
14
+ import { TranslationDefinitionMap } from './TranslationDefinitionMap.ts';
16
15
 
17
16
  type XformsRevalidateListener = () => void;
18
17
 
@@ -23,7 +22,7 @@ export class ModelDefinition {
23
22
  readonly nodes: NodeDefinitionMap;
24
23
  readonly instance: StaticDocument;
25
24
  readonly itextTranslations: ItextTranslationsDefinition;
26
- readonly itextChunks: Map<string, ChunkExpressionsByItextId>;
25
+ readonly itextElements: Map<string, Map<string, Element>>;
27
26
  readonly xformsRevalidateListeners: Map<string, XformsRevalidateListener>;
28
27
 
29
28
  constructor(readonly form: XFormDefinition) {
@@ -37,7 +36,7 @@ export class ModelDefinition {
37
36
  this.root = new RootDefinition(form, this, submission, form.body.classes);
38
37
  this.nodes = nodeDefinitionMap(this.root);
39
38
  this.itextTranslations = ItextTranslationsDefinition.from(form.xformDOM);
40
- this.itextChunks = generateItextChunks(form.xformDOM.itextTranslationElements);
39
+ this.itextElements = new TranslationDefinitionMap(form.xformDOM.itextTranslationElements);
41
40
  this.xformsRevalidateListeners = new Map();
42
41
  }
43
42
 
@@ -69,12 +68,8 @@ export class ModelDefinition {
69
68
  this.xformsRevalidateListeners.forEach((listener: XformsRevalidateListener) => listener());
70
69
  }
71
70
 
72
- getTranslationChunks(
73
- itextId: string,
74
- activeLanguage: ActiveLanguage
75
- ): ReadonlyArray<TextChunkExpression<'string'>> {
76
- const languageMap = this.itextChunks.get(activeLanguage.language);
77
- return languageMap?.get(itextId) ?? [];
71
+ getItextElement(activeLanguage: ActiveLanguage, itextId: string): Element | undefined {
72
+ return this.itextElements.get(activeLanguage.language)?.get(itextId);
78
73
  }
79
74
 
80
75
  toJSON() {
@@ -3,6 +3,7 @@ import { NamespaceDeclarationMap } from '../../lib/names/NamespaceDeclarationMap
3
3
  import { QualifiedName } from '../../lib/names/QualifiedName.ts';
4
4
  import type { AnyBodyElementDefinition, BodyClassList } from '../body/BodyDefinition.ts';
5
5
  import type { XFormDefinition } from '../XFormDefinition.ts';
6
+ import { SET_GEOPOINT_LOCAL_NAME, SET_VALUE_LOCAL_NAME } from '../XFormDOM.ts';
6
7
  import { ActionDefinition } from './ActionDefinition.ts';
7
8
  import { AttributeDefinitionMap } from './AttributeDefinitionMap.ts';
8
9
  import { GroupDefinition } from './GroupDefinition.ts';
@@ -63,7 +64,7 @@ export class RootDefinition extends NodeDefinition<'root'> {
63
64
  return;
64
65
  }
65
66
  for (const child of bodyElement.element.children) {
66
- if (child.nodeName === 'setvalue') {
67
+ if (child.nodeName === SET_VALUE_LOCAL_NAME || child.nodeName === SET_GEOPOINT_LOCAL_NAME) {
67
68
  const action = new ActionDefinition(this.model, child, source);
68
69
  this.model.actions.add(action);
69
70
  }
@@ -1,83 +1,9 @@
1
- import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL.ts';
2
- import * as papa from 'papaparse';
3
- import { ErrorProductionDesignPendingError } from '../../../../error/ErrorProductionDesignPendingError.ts';
4
1
  import type { StaticElementOptions } from '../../../../integration/xpath/static-dom/StaticElement.ts';
5
2
  import { defineSecondaryInstance } from '../defineSecondaryInstance.ts';
6
3
  import type { SecondaryInstanceDefinition } from '../SecondaryInstancesDefinition.ts';
4
+ import { parseItems } from './external-instance-csv-parser.ts';
7
5
  import { ExternalSecondaryInstanceSource } from './ExternalSecondaryInstanceSource.ts';
8
6
 
9
- type CSVColumn = string;
10
- type CSVRow = readonly CSVColumn[];
11
-
12
- type AssertCSVRow = (columns: unknown) => asserts columns is CSVRow;
13
-
14
- /**
15
- * Based on {@link https://github.com/getodk/central-frontend/commit/29cebcc870c9be70ab0d222e3349e34639045d19}
16
- *
17
- * Central performs this check for header and rows. A comment is included there for the header check, but the logic is the same in both cases.
18
- */
19
- const rejectNullCharacters = (cell: string) => {
20
- if (cell.includes('\0')) {
21
- throw new ErrorProductionDesignPendingError(`Failed to parse CSV: null character`);
22
- }
23
- };
24
-
25
- const assertCSVRow: AssertCSVRow = (columns) => {
26
- if (!Array.isArray(columns)) {
27
- throw new ErrorProductionDesignPendingError('Failed to parse CSV columns');
28
- }
29
-
30
- for (const [index, column] of columns.entries()) {
31
- if (typeof column !== 'string') {
32
- throw new ErrorProductionDesignPendingError(`Failed to parse CSV column at index ${index}`);
33
- }
34
-
35
- rejectNullCharacters(column);
36
- }
37
- };
38
-
39
- type AssertPapaparseSuccess = (
40
- resourceURL: JRResourceURL,
41
- errors: readonly papa.ParseError[]
42
- ) => asserts errors is readonly [];
43
-
44
- const assertPapaparseSuccess: AssertPapaparseSuccess = (resourceURL, errors) => {
45
- if (errors.length > 0) {
46
- const cause = new AggregateError(errors);
47
- throw new ErrorProductionDesignPendingError(
48
- `Failed to parse CSV external secondary instance ${resourceURL.href}`,
49
- { cause }
50
- );
51
- }
52
- };
53
-
54
- interface ParsedCSVHeader {
55
- readonly columns: CSVRow;
56
- readonly errors: readonly [];
57
- readonly meta: papa.ParseMeta;
58
- }
59
-
60
- interface ParseCSVOptions {
61
- readonly columns: readonly string[];
62
- readonly delimiter: string;
63
- }
64
-
65
- const stripTrailingEmptyCells = (columns: CSVRow, row: CSVRow): CSVRow => {
66
- const result = row.slice();
67
-
68
- while (result.length > columns.length && result.at(-1) === '') {
69
- result.pop();
70
- }
71
-
72
- return result;
73
- };
74
-
75
- interface ParsedCSVRows {
76
- readonly rows: readonly CSVRow[];
77
- readonly errors: readonly [];
78
- readonly meta: papa.ParseMeta;
79
- }
80
-
81
7
  interface CSVExternalSecondaryInstanceItemColumn {
82
8
  readonly columnName: string;
83
9
  readonly cellValue: string;
@@ -120,116 +46,8 @@ const csvExternalSecondaryInstanceDefinition = (
120
46
  };
121
47
 
122
48
  export class CSVExternalSecondaryInstanceSource extends ExternalSecondaryInstanceSource<'csv'> {
123
- /**
124
- * Based on
125
- * {@link https://github.com/getodk/central-frontend/blob/42c9277709e593480d1462e28b4be5f1364532b7/src/util/csv.js#L79} (and {@link https://github.com/getodk/central-frontend/blob/42c9277709e593480d1462e28b4be5f1364532b7/src/util/csv.js#L13}).
126
- *
127
- * The most significant deviations at time of writing:
128
- *
129
- * - we have already retrieved the CSV resource, so we are parsing the raw CSV data directly.
130
- * - we have no need for asynchronous/streaming parsing at this point in the
131
- * form initialization process, so we can dispense with those details of the
132
- * {@link papa | papaparse} API/config.
133
- */
134
- private parseCSVHeader(csvData: string): ParsedCSVHeader {
135
- const { data, errors, meta } = papa.parse(csvData, {
136
- delimitersToGuess: [',', ';', '\t', '|'],
137
- download: false,
138
- preview: 1,
139
- });
140
- const [columns = []] = data;
141
-
142
- assertCSVRow(columns);
143
- assertPapaparseSuccess(this.resourceURL, errors);
144
-
145
- return {
146
- errors,
147
- meta,
148
- columns,
149
- };
150
- }
151
-
152
- /**
153
- * Largely based on {@link https://github.com/getodk/central-frontend/blob/42c9277709e593480d1462e28b4be5f1364532b7/src/util/csv.js#L170}
154
- */
155
- private parseCSVRows(csvData: string, options: ParseCSVOptions): ParsedCSVRows {
156
- const { columns, delimiter } = options;
157
- const { data, errors, meta } = papa.parse(csvData, {
158
- delimiter,
159
- download: false,
160
- });
161
-
162
- assertPapaparseSuccess(this.resourceURL, errors);
163
-
164
- const rowData = data.slice(1);
165
- const lastRowIndex = rowData.length - 1;
166
-
167
- let stripLastRow = false;
168
-
169
- const rows = rowData.map((values, index) => {
170
- assertCSVRow(values);
171
-
172
- const rowIndex = index + 1;
173
-
174
- // Central: Remove trailing empty cells.
175
- const row = stripTrailingEmptyCells(columns, values);
176
-
177
- // Central: Skip trailing empty rows and do not check them for warnings.
178
- // Throw for an empty row that is not trailing.
179
- if (row.every((cell) => cell === '')) {
180
- if (index === lastRowIndex) {
181
- stripLastRow = true;
182
- } else {
183
- throw new ErrorProductionDesignPendingError(
184
- `Failed to parse CSV row ${rowIndex}: unexpected empty row`
185
- );
186
- }
187
- }
188
-
189
- // Central: Throw if there are too many cells.
190
- if (row.length > columns.length) {
191
- throw new ErrorProductionDesignPendingError(
192
- `Failed to parse CSV row ${rowIndex}: expected ${columns.length} columns, got ${row.length}`
193
- );
194
- }
195
-
196
- return row;
197
- });
198
-
199
- if (stripLastRow) {
200
- rows.pop();
201
- }
202
-
203
- return {
204
- errors,
205
- meta,
206
- rows,
207
- };
208
- }
209
-
210
- private toItems(
211
- columns: CSVRow,
212
- rows: readonly CSVRow[]
213
- ): readonly CSVExternalSecondaryInstanceItem[] {
214
- return rows.map((row) => {
215
- return columns.map((columnName, index) => {
216
- return {
217
- columnName,
218
- cellValue: row[index] ?? '',
219
- };
220
- });
221
- });
222
- }
223
-
224
49
  parseDefinition(): SecondaryInstanceDefinition {
225
- const csvData = this.resource.data.replace(/[\n\r]+$/, '');
226
- const { columns, meta } = this.parseCSVHeader(csvData);
227
- const { rows } = this.parseCSVRows(csvData, {
228
- columns,
229
- delimiter: meta.delimiter,
230
- });
231
- const items = this.toItems(columns, rows);
232
-
50
+ const items = parseItems(this.resourceURL, this.resource.data);
233
51
  return csvExternalSecondaryInstanceDefinition(this.instanceId, items);
234
52
  }
235
53
  }
@@ -0,0 +1,185 @@
1
+ import type { JRResourceURL } from '@getodk/common/jr-resources/JRResourceURL';
2
+ import * as papa from 'papaparse';
3
+ import { ErrorProductionDesignPendingError } from '../../../../error/ErrorProductionDesignPendingError';
4
+
5
+ type CSVColumn = string;
6
+ type CSVRow = readonly CSVColumn[];
7
+ type AssertCSVRow = (resourceURL: JRResourceURL, columns: unknown) => asserts columns is CSVRow;
8
+
9
+ interface ParsedCSVHeader {
10
+ readonly columns: CSVRow;
11
+ readonly errors: readonly [];
12
+ readonly meta: papa.ParseMeta;
13
+ }
14
+
15
+ interface ParseCSVOptions {
16
+ readonly columns: readonly string[];
17
+ readonly delimiter: string;
18
+ }
19
+
20
+ interface ParsedCSVRows {
21
+ readonly rows: readonly CSVRow[];
22
+ readonly errors: readonly [];
23
+ readonly meta: papa.ParseMeta;
24
+ }
25
+
26
+ interface CSVExternalSecondaryInstanceItemColumn {
27
+ readonly columnName: string;
28
+ readonly cellValue: string;
29
+ }
30
+
31
+ type CSVExternalSecondaryInstanceItem = readonly CSVExternalSecondaryInstanceItemColumn[];
32
+
33
+ /**
34
+ * Based on {@link https://github.com/getodk/central-frontend/commit/29cebcc870c9be70ab0d222e3349e34639045d19}
35
+ *
36
+ * Central performs this check for header and rows. A comment is included there for the header check, but the logic is the same in both cases.
37
+ */
38
+ const rejectNullCharacters = (resourceURL: JRResourceURL, cell: string) => {
39
+ if (cell.includes('\0')) {
40
+ throw new ErrorProductionDesignPendingError(
41
+ `Failed to parse CSV ${resourceURL.href}: null character`
42
+ );
43
+ }
44
+ };
45
+
46
+ const stripTrailingEmptyCells = (columns: CSVRow, row: CSVRow): CSVRow => {
47
+ const result = row.slice();
48
+
49
+ while (result.length > columns.length && result.at(-1) === '') {
50
+ result.pop();
51
+ }
52
+
53
+ return result;
54
+ };
55
+
56
+ const assertCSVRow: AssertCSVRow = (resourceURL: JRResourceURL, columns) => {
57
+ if (!Array.isArray(columns)) {
58
+ throw new ErrorProductionDesignPendingError(
59
+ `Failed to parse CSV ${resourceURL.href}: invalid columns`
60
+ );
61
+ }
62
+
63
+ for (const [index, column] of columns.entries()) {
64
+ if (typeof column !== 'string') {
65
+ throw new ErrorProductionDesignPendingError(
66
+ `Failed to parse CSV ${resourceURL.href}: invalid column at ${index}`
67
+ );
68
+ }
69
+
70
+ rejectNullCharacters(resourceURL, column);
71
+ }
72
+ };
73
+
74
+ type AssertPapaparseSuccess = (
75
+ resourceURL: JRResourceURL,
76
+ errors: readonly papa.ParseError[]
77
+ ) => asserts errors is readonly [];
78
+
79
+ const assertPapaparseSuccess: AssertPapaparseSuccess = (resourceURL, errors) => {
80
+ if (errors.length > 0) {
81
+ const cause = new AggregateError(errors);
82
+ throw new ErrorProductionDesignPendingError(`Failed to parse CSV ${resourceURL.href}`, {
83
+ cause,
84
+ });
85
+ }
86
+ };
87
+
88
+ /**
89
+ * Largely based on {@link https://github.com/getodk/central-frontend/blob/42c9277709e593480d1462e28b4be5f1364532b7/src/util/csv.js#L170}
90
+ */
91
+ const parseCSVRows = (
92
+ resourceURL: JRResourceURL,
93
+ csvData: string,
94
+ options: ParseCSVOptions
95
+ ): ParsedCSVRows => {
96
+ const { columns, delimiter } = options;
97
+ const { data, errors, meta } = papa.parse(csvData, {
98
+ delimiter,
99
+ download: false,
100
+ });
101
+
102
+ assertPapaparseSuccess(resourceURL, errors);
103
+
104
+ const rowData = data.slice(1);
105
+
106
+ const rows = rowData.map((values, index) => {
107
+ assertCSVRow(resourceURL, values);
108
+
109
+ const rowIndex = index + 1;
110
+
111
+ // Central: Remove trailing empty cells.
112
+ const row = stripTrailingEmptyCells(columns, values);
113
+
114
+ // Central: Throw if there are too many cells.
115
+ if (row.length > columns.length) {
116
+ throw new ErrorProductionDesignPendingError(
117
+ `Failed to parse CSV ${resourceURL.href}: row ${rowIndex}, expected ${columns.length} columns, got ${row.length}`
118
+ );
119
+ }
120
+
121
+ return row;
122
+ });
123
+
124
+ return {
125
+ errors,
126
+ meta,
127
+ rows,
128
+ };
129
+ };
130
+
131
+ const toItems = (
132
+ columns: CSVRow,
133
+ rows: readonly CSVRow[]
134
+ ): readonly CSVExternalSecondaryInstanceItem[] => {
135
+ return rows.map((row) => {
136
+ return columns.map((columnName, index) => {
137
+ return {
138
+ columnName,
139
+ cellValue: row[index] ?? '',
140
+ };
141
+ });
142
+ });
143
+ };
144
+
145
+ /**
146
+ * Based on
147
+ * {@link https://github.com/getodk/central-frontend/blob/42c9277709e593480d1462e28b4be5f1364532b7/src/util/csv.js#L79} (and {@link https://github.com/getodk/central-frontend/blob/42c9277709e593480d1462e28b4be5f1364532b7/src/util/csv.js#L13}).
148
+ *
149
+ * The most significant deviations at time of writing:
150
+ *
151
+ * - we have already retrieved the CSV resource, so we are parsing the raw CSV data directly.
152
+ * - we have no need for asynchronous/streaming parsing at this point in the
153
+ * form initialization process, so we can dispense with those details of the
154
+ * {@link papa | papaparse} API/config.
155
+ */
156
+ const parseCSVHeader = (resourceURL: JRResourceURL, csvData: string): ParsedCSVHeader => {
157
+ const { data, errors, meta } = papa.parse(csvData, {
158
+ delimitersToGuess: [',', ';', '\t', '|'],
159
+ download: false,
160
+ preview: 1,
161
+ });
162
+ const [columns = []] = data;
163
+
164
+ assertCSVRow(resourceURL, columns);
165
+ assertPapaparseSuccess(resourceURL, errors);
166
+
167
+ return {
168
+ errors,
169
+ meta,
170
+ columns,
171
+ };
172
+ };
173
+
174
+ export const parseItems = (
175
+ resourceURL: JRResourceURL,
176
+ data: string
177
+ ): readonly CSVExternalSecondaryInstanceItem[] => {
178
+ const csvData = data.replace(/[\n\r]+$/, '');
179
+ const { columns, meta } = parseCSVHeader(resourceURL, csvData);
180
+ const { rows } = parseCSVRows(resourceURL, csvData, {
181
+ columns,
182
+ delimiter: meta.delimiter,
183
+ });
184
+ return toItems(columns, rows);
185
+ };