Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions HANDOVER_CDL_WP_EINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ Companion document: [`HANDOVER_QWAC.md`](HANDOVER_QWAC.md).
Check every content claim against this. From `src/generated/TypeMappings.ts` in
this repo:

| Supported question types | Registered but **not** convertible | Structural |
|---|---|---|
| `text` (alias `string`), `integer` (alias `int`), `decimal`, `date`, `time`, `note`, `select_one`, `select_multiple` | `select_one_from_file`, `select_multiple_from_file` | `begin_group` / `end_group` |
| Supported question types | Structural |
|---|---|
| `text` (alias `string`), `integer` (alias `int`), `decimal`, `range`, `date`, `time`, `note`, `select_one`, `select_multiple`, `select_one_from_file`, `select_multiple_from_file` (registered vocabularies only, e.g. `iso_3166_1.csv`) | `begin_group` / `end_group` |

Anything else — `rank`, `range`, `calculate`, `image`, `audio`, `geopoint`,
Anything else — `rank`, `calculate`, `image`, `audio`, `geopoint`,
`dateTime` — is **not registered at all**: the converters reject such a form
rather than approximating it. Same for unregistered appearances, identifiers over
the sanitisation limits, group nesting deeper than three levels, and selects
Expand Down Expand Up @@ -68,9 +68,10 @@ Everything below was checked against the working copy at the time of writing.
`src/content/pages/xlsform-standard.mdx` (919 lines) documents, as if usable:
`## Fragetypen` → `### Rang` (line 226), `### Bereich` (253),
`### Mehrfachauswahl aus Datei` (187), `## Berechnung` (523), plus
`### Externe CSV-Daten` (395). None of those convert: `rank`, `range` and
`calculate` are not in the registry at all, and `select_*_from_file` is
registered as *not* convertible.
`### Externe CSV-Daten` (395). `rank` and `calculate` are not in the registry
at all. `range` converts (since formtransform#33), but LimeSurvey enforces only
its bounds, not its `step`. `select_*_from_file` converts only with a
registered vocabulary, not an arbitrary external CSV.

`src/content/pages/fragetypen.mdx` is in better shape — it pulls live examples
from qwacback via `<QuestionTypeBlock exampleId="…" />` — but its ids
Expand Down
12 changes: 5 additions & 7 deletions HANDOVER_QWAC.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,13 +130,11 @@ pinned to one version. The plan holds, but:
`lstsv2xlsform` now exist, which the brief predates.
- **Coverage handoff:** survey2ddi's qwacback equivalence test now lives in
formtransform (`tests/live/qwacback/`, formtransform#14). Against qwacback
`main` (`c99de96`), 13 of 15 types match. The two that differ change
qwacback's DDI when it swaps converters:
- `range`: qwacback emits numeric/`contin`, formtransform text (unregistered
type, formtransform#33).
- `note`: qwacback emits a `<var>`, formtransform none. This is intended: a
note stores no response, so formtransform folds it into
`<preQTxt>`/`<notes>`.
`main` (`c99de96`), 14 of 15 types match (`range` since formtransform#33).
The one that differs changes qwacback's DDI when it swaps converters:
`note`. qwacback emits a `<var>` for it, formtransform none. This is
intended: a note stores no response, so formtransform folds it into
`<preQTxt>`/`<notes>`.

qwacback's own converter tests go with the Go converter.

Expand Down
12 changes: 12 additions & 0 deletions codegen/emit_ts.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ def generate_typescript(registry: dict[str, Any], output: Path):
" answerClass?: 'A' | 'SQ' | null;",
" requiresListName?: boolean;",
" dateFormat?: string;",
" /** XLSForm `parameters` keys this type reads, with their defaults. */",
" parameters?: Record<string, string>;",
" /** XLSForm parameter key → LimeSurvey question attribute. */",
" parameterAttributes?: Record<string, string>;",
" /** Set `attribute` to 1 when all `whenWhole` parameters are whole numbers. */",
" integerOnly?: { attribute: string; whenWhole: string[] };",
"}",
"",
"export const TYPE_MAPPINGS: Record<string, TypeMapping> = {",
Expand All @@ -53,6 +59,12 @@ def emit(name: str, kind: str, data: dict):
lines.append(" requiresListName: true,")
if ls.get("dateFormat"):
lines.append(f" dateFormat: {json.dumps(ls['dateFormat'])},")
if xls.get("parameters"):
lines.append(f" parameters: {json.dumps(xls['parameters'])},")
if ls.get("parameterAttributes"):
lines.append(f" parameterAttributes: {json.dumps(ls['parameterAttributes'])},")
if ls.get("integerOnly"):
lines.append(f" integerOnly: {json.dumps(ls['integerOnly'])},")
lines.append(" },")

kind_map = {
Expand Down
32 changes: 32 additions & 0 deletions registry/entities/range/ddi.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" ?>
<codeBook xmlns="ddi:codebook:2_5" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="ddi:codebook:2_5 https://ddialliance.org/Specification/DDI-Codebook/2.5/XMLSchema/codebook.xsd" version="2.5">
<stdyDscr>
<citation>
<titlStmt>
<titl>range</titl>
</titlStmt>
<prodStmt>
<prodDate date="2026-09-24">2026-09-24</prodDate>
</prodStmt>
</citation>
</stdyDscr>
<fileDscr ID="F1" URI="data.csv">
<fileTxt>
<fileName>data.csv</fileName>
<dimensns>
<caseQnty>0</caseQnty>
</dimensns>
<fileType>Comma-separated values (CSV)</fileType>
<format>text/csv</format>
</fileTxt>
</fileDscr>
<dataDscr>
<var ID="V_zufriedenheit" name="zufriedenheit" intrvl="contin" files="F1">
<qstn responseDomainType="numeric">
<qstnLit>Wie zufrieden sind Sie insgesamt? (0 = gar nicht, 10 = voll)</qstnLit>
</qstn>
<concept>Wie zufrieden sind Sie insgesamt? (0 = gar nicht, 10 = voll)</concept>
<varFormat type="numeric" schema="other"/>
</var>
</dataDscr>
</codeBook>
53 changes: 53 additions & 0 deletions registry/entities/range/definition.jsonld
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"@context": {
"@version": 1.1,
"@vocab": "https://civic-data.de/survey/",
"skos": "http://www.w3.org/2004/02/skos/core#",
"type": "https://civic-data.de/survey/type/",
"variant": "https://civic-data.de/survey/variant/",
"composite": "https://civic-data.de/survey/composite/"
},
"@graph": [
{
"@id": "type:range",
"@type": "QuestionType",
"skos:prefLabel": "Range",
"xlsform": {
"typeString": "range",
"parameters": {
"start": "1",
"end": "10",
"step": "1"
}
},
"limesurvey": {
"typeCode": "N",
"supportsOther": false,
"answerClass": null,
"parameterAttributes": {
"start": "min_num_value_n",
"end": "max_num_value_n"
},
"integerOnly": {
"attribute": "num_value_int_only",
"whenWhole": ["start", "step"]
}
},
"ddi": {
"intrvl": "contin",
"formatType": "numeric",
"responseDomainType": "numeric"
},
"kobo": {
"nativeSupport": true
},
"constraints": {
"maxNameLength": 20,
"namePattern": "^[a-zA-Z0-9]+$"
},
"exampleDir": "registry/entities/range",
"examplePath": "registry/entities/range/xlsform.json",
"useWhen": "When collecting a number on a bounded scale set with `parameters` (`start`, `end`, `step`), e.g. a 0–100 percentage or a 1–10 rating. LimeSurvey enforces the bounds and, for whole-number steps, integers; it does not enforce the step itself."
}
]
}
11 changes: 11 additions & 0 deletions registry/entities/range/fixtures/xlsform.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"survey": [
{
"type": "range",
"name": "zufriedenheit",
"label": "Wie zufrieden sind Sie insgesamt? (0 = gar nicht, 10 = voll)",
"parameters": "start=0 end=10 step=1"
}
],
"choices": []
}
7 changes: 7 additions & 0 deletions registry/entities/range/tsv.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class type/scale name relevance text help language validation em_validation_q mandatory other default same_default hidden hide_tip min_num_value_n max_num_value_n num_value_int_only
S language 1 en en
S format 1 A en
S shownoanswer 1 N en
SL surveyls_title 1 Untitled Survey en
G Questions 1 Questions en
Q N zufriedenheit 1 Wie zufrieden sind Sie insgesamt? (0 = gar nicht, 10 = voll) en 1 0 10 1
25 changes: 25 additions & 0 deletions registry/schema.jsonld
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@
"ls-transformer"
],
"description": "True for select_* variants where the type cell needs a choice list name."
},
"parameters": {
"type": "object",
"required": false,
"consumedBy": [
"ls-transformer",
"docs"
],
"description": "Keys this type reads from the XLSForm `parameters` column (`start=0 end=100 step=5`), mapped to the value used when the row omits the key (pyxform's defaults)."
}
}
},
Expand Down Expand Up @@ -161,6 +170,22 @@
"consumedBy": [
"docs"
]
},
"parameterAttributes": {
"type": "object",
"required": false,
"consumedBy": [
"ls-transformer"
],
"description": "XLSForm parameter key → LS question attribute that receives its value, e.g. `range`'s `start` → `min_num_value_n`. Defaults from `xlsform.parameters` apply."
},
"integerOnly": {
"type": "object",
"required": false,
"consumedBy": [
"ls-transformer"
],
"description": "`{attribute, whenWhole}`: set LS `attribute` to `1` when every parameter in `whenWhole` is a whole number, so the question accepts integers only (e.g. `range` with integer `start` and `step`)."
}
}
},
Expand Down
1 change: 1 addition & 0 deletions skills/cdl-survey-types/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Generated from the [`formtransform`](https://github.com/) — the single source
| `decimal` | Decimal/Float | When collecting open decimal numeric values without predefined categories (e.g. height in metres, temperature). |
| `integer` | Integer | When collecting open integer values without predefined categories, where respondents provide a raw number (e.g. age, household size). |
| `note` | Note (Display Text) | When displaying non-interactive informational text, instructions, or section headers that require no respondent answer. |
| `range` | Range | When collecting a number on a bounded scale set with `parameters` (`start`, `end`, `step`), e.g. a 0–100 percentage or a 1–10 rating. LimeSurvey enforces the bounds and, for whole-number steps, integers; it does not enforce the step itself. |
| `select_multiple` | Select Multiple | When options are not mutually exclusive and respondents may select any number of applicable answers (e.g. languages spoken, devices owned). |
| `select_multiple_from_file` | Select Multiple (from file) | When selecting from a long non-exclusive list of options maintained as a controlled vocabulary, allowing multiple selections. |
| `select_one` | Select One | When answer categories are exhaustive and mutually exclusive, and exactly one option should be selected (e.g. education level, employment status). |
Expand Down
6 changes: 6 additions & 0 deletions skills/cdl-survey-types/references/question-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@

**Constraints:** variable `name` ≤ 20 chars, `^[a-zA-Z0-9]+$`

## `range` — Range

**Use when:** When collecting a number on a bounded scale set with `parameters` (`start`, `end`, `step`), e.g. a 0–100 percentage or a 1–10 rating. LimeSurvey enforces the bounds and, for whole-number steps, integers; it does not enforce the step itself.

**Constraints:** variable `name` ≤ 20 chars, `^[a-zA-Z0-9]+$`

## `select_multiple` — Select Multiple

**Use when:** When options are not mutually exclusive and respondents may select any number of applicable answers (e.g. languages spoken, devices owned).
Expand Down
4 changes: 4 additions & 0 deletions src/generated/DdiMappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const TYPE_MAP: Record<string, string> = {
integer: "integer",
int: "integer",
note: "note",
range: "range",
select_multiple: "select_multiple",
select_one: "select_one",
text: "text",
Expand All @@ -51,6 +52,7 @@ export const DDI_TYPE_MAP: Record<string, DdiTypeInfo> = {
date: ["discrete", "character"],
decimal: ["contin", "numeric"],
integer: ["contin", "numeric"],
range: ["contin", "numeric"],
select_multiple: ["discrete", "numeric"],
select_one: ["discrete", "numeric"],
text: ["discrete", "character"],
Expand All @@ -64,6 +66,7 @@ export const RESPONSE_DOMAIN_MAP: Record<string, string> = {
date: "text",
decimal: "numeric",
integer: "numeric",
range: "numeric",
select_multiple: "multiple",
select_one: "category",
text: "text",
Expand All @@ -77,6 +80,7 @@ export const MEASURE_MAP: Record<string, string> = {
date: "interval",
decimal: "ratio",
integer: "ratio",
range: "ratio",
select_multiple: "nominal",
select_one: "nominal",
time: "interval",
Expand Down
10 changes: 10 additions & 0 deletions src/generated/QuestionTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ export const QUESTION_TYPES = {
typeString: "note",
constraints: {"maxNameLength": 20, "namePattern": "^[a-zA-Z0-9]+$"},
},
range: {
id: "type:range",
label: "Range",
kind: "question",
useWhen: "When collecting a number on a bounded scale set with `parameters` (`start`, `end`, `step`), e.g. a 0–100 percentage or a 1–10 rating. LimeSurvey enforces the bounds and, for whole-number steps, integers; it does not enforce the step itself.",
isVariant: false,
isComposite: false,
typeString: "range",
constraints: {"maxNameLength": 20, "namePattern": "^[a-zA-Z0-9]+$"},
},
select_multiple: {
id: "type:select_multiple",
label: "Select Multiple",
Expand Down
14 changes: 14 additions & 0 deletions src/generated/TypeMappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export interface TypeMapping {
answerClass?: 'A' | 'SQ' | null;
requiresListName?: boolean;
dateFormat?: string;
/** XLSForm `parameters` keys this type reads, with their defaults. */
parameters?: Record<string, string>;
/** XLSForm parameter key → LimeSurvey question attribute. */
parameterAttributes?: Record<string, string>;
/** Set `attribute` to 1 when all `whenWhole` parameters are whole numbers. */
integerOnly?: { attribute: string; whenWhole: string[] };
}

export const TYPE_MAPPINGS: Record<string, TypeMapping> = {
Expand Down Expand Up @@ -64,6 +70,14 @@ export const TYPE_MAPPINGS: Record<string, TypeMapping> = {
limeSurveyType: "X",
supported: true,
},
range: {
kind: "question",
limeSurveyType: "N",
supported: true,
parameters: {"start": "1", "end": "10", "step": "1"},
parameterAttributes: {"start": "min_num_value_n", "end": "max_num_value_n"},
integerOnly: {"attribute": "num_value_int_only", "whenWhole": ["start", "step"]},
},
select_multiple: {
kind: "question",
limeSurveyType: "M",
Expand Down
15 changes: 14 additions & 1 deletion src/lstsv/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ interface TSVRow {
// questions — controls whether the widget shows a date, a time, or both.
// Sourced from the registry's limesurvey.dateFormat; empty elsewhere.
date_format?: string;
// LimeSurvey numeric-input (N) attributes, from the XLSForm `parameters`
// column via the registry (limesurvey.parameterAttributes / integerOnly).
// Only `range` sets them.
min_num_value_n?: string;
max_num_value_n?: string;
num_value_int_only?: string;
}

export class TSVGenerator {
Expand Down Expand Up @@ -57,7 +63,14 @@ export class TSVGenerator {
// Attribute columns are only emitted when at least one row carries them,
// so surveys that don't use a given attribute keep a lean TSV. The order
// here is the column order in the output.
for (const attr of ['cssclass', 'hide_tip', 'date_format'] as const) {
for (const attr of [
'cssclass',
'hide_tip',
'date_format',
'min_num_value_n',
'max_num_value_n',
'num_value_int_only',
] as const) {
if (this.rows.some((r) => r[attr])) {
headers.push(attr);
}
Expand Down
8 changes: 7 additions & 1 deletion src/pipelines/lstsv2xlsform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ 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 |
| type L/M/N/D/S/X, plus F | `select_one` / `select_multiple` / `integer`\|`decimal`\|`range` / `date`\|`time` / `text` / `note`, plus grid | `N` is lossy — see below |
| `N` with `min_num_value_n` + `max_num_value_n` | `range`, `parameters` rebuilt from the bounds | registry `limesurvey.parameterAttributes`; see below for `step` |
| 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 |
Expand Down Expand Up @@ -93,6 +94,11 @@ listed here must match exactly.
identical bar the key). No signal exists to read back without changing the
LimeSurvey side. `lstsvRowsToXlsform` emits the canonical `decimal`;
`integer` fixtures round-trip with `type` differing.

An `N` with both bound attributes is read back as `range` (the only type
that writes them). Its `step` is not stored: `num_value_int_only=1` comes
back as `step=1`, so `start=0 end=100 step=5` returns as
`start=0 end=100 step=1`, and a fractional step is dropped.
2. **Choice `list_name` is synthesized, not recovered.** Applies to *every*
`select_one`/`select_multiple`, not just grids: the authored list name
(`"select_one quelle"`) is never written to the TSV at all — only the
Expand Down
Loading
Loading