Skip to content

feat(xlsform2ddi): emit the DDI response-data CSV (buildDataCsv) - #8

Merged
jstet merged 1 commit into
mainfrom
feat/build-data-csv
Aug 19, 2026
Merged

feat(xlsform2ddi): emit the DDI response-data CSV (buildDataCsv)#8
jstet merged 1 commit into
mainfrom
feat/build-data-csv

Conversation

@jstet

@jstet jstet commented Aug 19, 2026

Copy link
Copy Markdown
Member

Closes #5.

What

buildDdiXml describes a dataset it could not produce: submissions fed <caseQnty> and the <fileDscr> filename, but nothing in the library wrote the data file. Consumers kept a Python runtime around for that one call — formtransform-app boots Pyodide and the survey2ddi wheel purely for survey2ddi_core.data.build_data_csv.

New src/pipelines/xlsform2ddi/data.ts:

Export Purpose
buildDataCsv(variables, submissions) RFC 4180 CSV — CRLF, minimal quoting, header row + submissions in input order
getDdiColumnNames(variables) DDI variable names in <var name=""> order
remapSubmissionsToDdi(variables, submissions) rows re-keyed onto those column names

Also exported from the package root: extractVariables, choicesByListFromRows, normalizeChoices — building a Variable[] to hand to buildDataCsv was otherwise impossible from outside the library.

const xml = buildDdiXml(survey, choices, { settings: settings[0], submissions });
const csv = buildDataCsv(
  extractVariables(survey, choicesByListFromRows(choices)),
  submissions,
);

The one hard contract: column order equals <var name=""> order

So schema↔data alignment is a zip, not a lookup. The column plan walks the same buckets dataDscr does — grid-group members, select_multiple binaries, _other patterns, standalone vars — which is why splitDataVars and its types are now exported from ddi/codebook.ts. Consequences:

  • select_multiple → one 0/1 column per choice, named <name>_<choice>
  • _other multi patterns drop the other binary and keep the _other text column, matching emitOtherPatternVars
  • note variables get no column
  • a unit test asserts header order == <var name=""> order on a survey exercising all four buckets, so the two walks cannot drift silently

Deliberate divergence from the Python emitter

survey2ddi_core.data.get_canonical_columns documents XML order but emits input order. Its CSV header therefore does not match its own XML once a grid, select_multiple, or _other pattern reorders the buckets (ddi_xml.py buckets exactly the way codebook.ts does). Fixed here rather than replicated.

Cell values are byte-identical to Python. Verified against the real survey2ddi_core.data module on a flat survey — quoting, CRLF, None → empty cell, space-joined multi-select expansion:

q_free,age,comment
"say ""hi""",30,"a,b"
"line1
line2",,

cmp clean. On a survey with a select_multiple the two differ in column order only, per the above.

Submission keys — no Variable.data_key

Issue #5 asked whether to add the Python data_key field. Not needed: readCell accepts either the bare question name or the slash-joined group path (group/name, nested groups joined) that Kobo's CSV export uses, bare name winning when a row carries both. That covers the Kobo identity case and the LimeSurvey flattening without a field the caller has to populate correctly.

Validation surface

None, per the issue's preferred option — the remap is mechanical and a malformed row is the caller's problem. No skipValidation toggle.

Testing

  • 18 new unit tests in tests/ts/unit/pipelines/xlsform2ddi/data.test.ts
  • full suite: 725 passed (48 files)
  • npm run validate green (lint, typecheck, format, knip, dup, arch, markdownlint)
  • README + src/pipelines/README.md document the emitter and the ordering contract

The Python byte-comparison was run locally against CorrelAid/survey2ddi but not committed — the repo has no survey2ddi Python dependency and adding one to pin a package this library is meant to replace seemed like the wrong trade. Happy to add it as a tests/live/ script if you want it in CI.

Next

Unblocks formtransform-app#10: drop Pyodide, the survey2ddi wheel, pyodide.config.json, scripts/setup-pyodide.mjs, src/lib/pyodide.*, and the pyodide + xlsform2lstsv deps. The full-mode Kobo branch becomes parseXLSData + buildDdiXml + buildDataCsv.

🤖 Generated with Claude Code

`buildDdiXml` describes a dataset it could not produce: given `submissions`
it used their count for `<caseQnty>` and a filename in `<fileDscr>`, but
nothing here wrote the data file itself. Consumers had to keep a Python
runtime around for `survey2ddi_core.data.build_data_csv` — the app boots
Pyodide and the `survey2ddi` wheel for that one call.

Adds `pipelines/xlsform2ddi/data.ts`:

- `buildDataCsv(variables, submissions)` — RFC 4180 CSV (CRLF, minimal
  quoting), header row of DDI variable names, submissions in input order.
- `getDdiColumnNames(variables)` / `remapSubmissionsToDdi(variables, rows)`
  for callers that write the file themselves.

The contract is that column order equals `<var name="">` order, so schema
and data align positionally. The column plan therefore walks the same
buckets `dataDscr` does — grid members, `select_multiple` binaries, `_other`
patterns, standalone vars — which required exporting `splitDataVars` and its
types from `ddi/codebook.ts`. A `select_multiple` expands to one `0`/`1`
column per choice, `_other` multi patterns drop the `other` binary in favour
of the text column (matching `emitOtherPatternVars`), and `note` variables
get no column.

Note this diverges from the Python `get_canonical_columns`, which documents
XML order but emits input order — its CSV header does not match its own XML
once a grid, `select_multiple` or `_other` pattern reorders the buckets. Cell
values are byte-identical to the Python emitter; only the column order is
fixed here.

Response rows are keyed by bare question name or by the slash-joined group
path (`group/name`) that Kobo's CSV export uses. Both are accepted, bare name
wins, so no `data_key` field is needed on `Variable`.

Also exports `extractVariables` / `choicesByListFromRows` / `normalizeChoices`
from the package root — building a `Variable[]` for `buildDataCsv` was
otherwise impossible from outside the library.

Closes #5

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add buildDataCsv(variables, submissions): string — response-data CSV emitter, currently the only thing keeping the app on Pyodide

1 participant