Converts a LimeSurvey structure TSV back into an XLSForm
({ survey, choices, settings }, and .xlsx via the writer) — the reverse of
xlsform2lstsv. Entry point: lstsvToXlsform(tsv, opts) in
index.ts, which runs the same validateLstsvSubset reverse-subset
check as lstsv2ddi (shared, exported from ../../lstsv/validate.ts) before
reconstructing: input outside the transformable subset is rejected, not
guessed at.
It does not reuse the DDI Variable model — that one is intentionally lossy
(drops relevance, constraint, required, appearance, hint). XLSForm needs all of
those, so this module reconstructs them directly.
| File | Role |
|---|---|
index.ts |
lstsvToXlsform(tsv, opts) — parse + reverse-subset validation + reconstruct |
toXlsform.ts |
lstsvRowsToXlsform(rows) — the reconstruction itself |
emParser.ts |
tokenizer + Pratt parser for the bounded EM dialect → AST |
reverseExpressions.ts |
AST → XPath serializer; reverseRelevance / reverseConstraint + the SelectContext builder |
languageNames.ts |
ISO code → English exonym, to rebuild default_language ("German (de)") from the bare code the TSV carries |
lstsvRowsToXlsform is two-pass: collect per-(key, language) label maps first
(order-independent), pre-scan to build the global selected()-reconstruction
context (any question's relevance may reference any select_multiple in the
document, not just one in its own group), then walk base-language rows into
groups → questions → survey/choices rows.
CLI: formtransform lstsv2xlsform <input.tsv>.
| LimeSurvey input | XLSForm output | Notes |
|---|---|---|
| type L/M/N/D/S/X, plus F | select_one / select_multiple / integer|decimal / date|time / text / note, plus grid |
N is lossy — see below |
| A / SQ rows | choices (list_name = question name) |
codes exact under strict validation |
other=Y |
re-add the other choice + ${base}_other companion + relevance |
shares OTHER_CODE/OTHER_SUFFIX/otherLabelFor with lstsv/toVariables.ts |
cssclass=cdlvocab-<id> |
select_*_from_file <id>.csv, inlined A rows dropped |
shares vocabFromCssClass with the DDI path |
mandatory=Y / default |
required: yes / default |
direct |
type override ! / T |
appearance: minimal / multiline |
inverts APPEARANCES[*].lsTypeOverride — registry-driven, not hardcoded |
| F + SQ + A | begin_group appearance=table-list + child select_ones |
inverts the grid → F encoding |
| SL welcome/end text | note named welcome / end |
inverts note promotion |
| per-language rows | label::<lang> / hint::<lang> |
direct |
| S rows | settings (default_language, form_title, style) |
direct |
relevance, em_validation_q |
relevant, constraint |
reverse-transpiled — see below |
label / list-nolabel matrix appearances (the non-table-list matrix
pattern) are not handled — no fixture uses them.
The forward transpiler (../xlsform2lstsv/xpathTranspiler.ts) emits a constrained
subset of LimeSurvey Expression Manager syntax, so the reverser only has to
invert that dialect — anything outside it throws.
Validated against the real forward transpiler as an oracle, not hand-written
expectations: forward(xpath) → em, reverse(em) → xpath',
forward(xpath') → em', assert em === em'. Covers equality/inequality,
comparisons, and/or, not(), both selected() forms, and every function in
the forward dialect (round, floor→ceiling, substring, string-length,
starts-with, ends-with, normalize-space, contains, today(), now()).
selected() vs plain equality. For select_one this needs no field-type
lookup: (name.NAOK=='v') (parens + .NAOK) is a syntactically unambiguous
marker that forward only emits via selected() — a plain equality never gets
.NAOK or the wrapping parens. Only the select_multiple compound form
((name_code.NAOK=='Y')) needs context — a map of ${question}_${code} →
{question, code} built from the already-reconstructed survey, passed in as
SelectContext, mirroring the information the forward path's own
buildSelectedExpr needed.
Constraint goes through the same parseEm + nodeToXPath pipeline, with two
differences: self (the current question's value in constraint EM) maps to .
in XPath rather than a ${...} reference, and no SelectContext is needed —
forward calls convertConstraint without one, so selected() falls back to
plain (field=="value") and the reverser reconstructs plain equality, the
correct context-free counterpart.
Rejected alternative: having forward stash the original XPath in a question
attribute. It would make round-tripping our own output trivial but does nothing
for TSVs produced elsewhere (and stashing was rejected for the other label
too).
These are the fields where a round-trip is not byte-exact. Anything not listed here must match exactly.
N→integervsdecimal— unrecoverable. Both map to LimeSurveyNwith no distinguishing attribute anywhere (TYPE_MAPPINGSentries are identical bar the key). No signal exists to read back without changing the LimeSurvey side.lstsvRowsToXlsformemits the canonicaldecimal;integerfixtures round-trip withtypediffering.- Choice
list_nameis synthesized, not recovered. Applies to everyselect_one/select_multiple, not just grids: the authored list name ("select_one quelle") is never written to the TSV at all — only the flattened A/SQ rows are. The reconstruction reuses the question's own name as the list name ("select_one aufmerksam"), or the array's name for a grid (shared across its subquestions). Valid XLSForm, same choices/order/codes, different but internally consistent list identifier. concat()collapses one-way. Forward maps XPathconcat(a,b)to EMa + b— identical to numeric+. Reversing+always yields XPath arithmetic+;concat()is never reconstructed.- Non-grid group machine names are best-effort. A grid's machine name is
recoverable (from its
Fquestion's own name — exact). An explicit non-grid group has none in the TSV, soslugifyGroupNamefalls back to: lowercase, strip non-alphanumeric, join the first 4 words,'group'if empty. - Markdown vs HTML in labels is not distinguishable.
htmlToMarkdown()(../../utils/markdownRenderer.ts) reverses the HTML constructsmarkedproduces (<strong>,<em>,<a>,<code>,<p>,<ul>/<ol>/<li>,<h1>–<h6>,<blockquote>,<hr>,<pre><code>) back to markdown, and is applied unconditionally to any label/hint/group-label/welcome/end-note string containing HTML markup. Nothing records whether the original was authored as markdown or as HTML, so authored HTML is also converted — the correct behaviour for this direction, but not a literal round-trip. - Multi-language is implemented but lightly tested. Per-language
S/SL/Q/Grows merge intolabel::<lang>-style object maps (keyed correctly even thoughGrows put the label innameand the hint intext— the reverse ofQrows). Covered by unit tests, not by any fixture (none is multilingual). In particular the G-translation-block edge case — LimeSurvey re-blocks base-language rows before each additional language, per group flush rather than globally — is simplified to a single global pass. A multi-group multilingual survey could break this. regexMatchargument order is assumed.convertConstraint()'s early-return path for a bareregexMatch(...)literal in the original XPath (an escape hatch for EM-flavoured syntax) swaps the arguments —regexMatch(pattern, field)— relative to the generictranspile()path'sregexMatch(field, pattern). Once transpiled both look identical in the TSV, so which one applied cannot be determined on the way back;reverseConstraintalways assumes the generic (unswapped) order. No fixture uses either form.calculationis not reversed — out of scope.convertCalculationreuses the sametranspile()as relevance, so the reverser's core would apply directly, but thecalculateXLSForm type is not registered in the registry:processRow's allowlist throws on it beforeconvertCalculationis ever called. No TSV this tool produces contains a calculation question, so there is nothing to reverse.
Resolved (kept here so the questions don't get re-litigated): date subtypes
are fully recoverable — TYPE_MAPPINGS[type].dateFormat is a genuine 1:1 hint
(time sets HH:MM, date sets none), read back from the TSV's date_format
column, and datetime no longer exists as a separate type (consolidated into
date). Names and answer codes need no reversal at all: strict validation
(XLSValidator.validateNamesAndCodes, default-on) means an XLSForm's names are
already LimeSurvey-legal, so the TSV carries them verbatim — there is no
sanitization to undo.
| Suite | Covers |
|---|---|
src/test/pipelines/lstsv2xlsform/toXlsform.test.ts |
feature-level reconstruction (20 cases) |
src/test/pipelines/lstsv2xlsform/reverseExpressions.test.ts |
relevance and constraint each table-round-tripped through the real forward transpiler as an oracle, plus direct checks on the rejection paths |
src/test/contract/lstsv2xlsformRoundtrip.test.ts |
all 13 registry fixtures: committed tsv.tsv → XLSForm vs the original xlsform.json, with losses 1 and 2 above normalized away and everything else compared exactly |
src/test/contract/fullRoundtrip.test.ts |
constraint, required, default and both selected() forms through the real xlsform2lstsv → lstsv2xlsform pipeline — no fixture uses them, and this is the only coverage that isn't hand-built row arrays |