xlsform2lstsv 0.2.3 → 0.4.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.
package/README.md CHANGED
@@ -1,4 +1,5 @@
1
1
  [![npm version](https://img.shields.io/npm/v/xlsform2lstsv)](https://www.npmjs.com/package/xlsform2lstsv)
2
+ [![AI-Assisted](https://img.shields.io/badge/AI--assisted-Claude%20Code-blueviolet?logo=anthropic&logoColor=white)](./AI_DISCLOSURE.md)
2
3
 
3
4
 
4
5
  # xlsform2lstsv
@@ -9,15 +10,109 @@ Convert XLSForm surveys to LimeSurvey TSV format.
9
10
 
10
11
  - This package is still WIP and not all features of xlsform have been implemented and verified.
11
12
  - While importing is tested in an automated fashion (see `scripts/test-compatibility-safe.ts`), this only verifies whether all questions were successfully imported, but not if e.g. validation and relevance expressions were transformed correctly. To be safe, always use the "Survey logic view" in the LimeSurvey GUI.
12
- - If you want question and choice names to be the same in LimeSurvey, make them <=5 chars (this is a LimeSurvey requiremtn)
13
+ - To keep question and choice names unchanged after conversion, use short alphanumeric IDs (≤ 20 chars for questions, 5 chars for choices) without underscores or hyphens.
13
14
 
14
15
 
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install xlsform2lstsv
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ The XFormParser provides direct XLS/XLSX file support:
25
+
26
+ ```typescript
27
+ import { XFormParser } from 'xlsform2lstsv';
28
+
29
+ // Parse XLS/XLSX file and convert to TSV
30
+ const tsv = await XFormParser.convertXLSFileToTSV('path/to/survey.xlsx');
31
+
32
+ // Or parse XLS/XLSX data directly
33
+ const xlsxData = fs.readFileSync('path/to/survey.xlsx');
34
+ const tsv = await XFormParser.convertXLSDataToTSV(xlsxData);
35
+ ```
36
+
37
+ **Methods:**
38
+ - `convertXLSFileToTSV(filePath, config)`: Direct conversion from file
39
+ - `convertXLSDataToTSV(data, config)`: Direct conversion from buffer
40
+ - `parseXLSFile(filePath)`: Parse to structured arrays
41
+ - `parseXLSData(data)`: Parse buffer to structured arrays
42
+
43
+ ### Using Arrays
44
+
45
+ A different entry point accepts XLSForm data as JavaScript arrays:
46
+
47
+ ```typescript
48
+ import { XLSFormToTSVConverter } from 'xlsform2lstsv';
49
+
50
+ const converter = new XLSFormToTSVConverter();
51
+ const tsv = converter.convert(surveyData, choicesData, settingsData);
52
+ ```
53
+
54
+ **Parameters:**
55
+ - `surveyData`: Array of survey rows (questions, groups, etc.)
56
+ - `choicesData`: Array of choice/option data
57
+ - `settingsData`: Array of survey settings
58
+
59
+ **Returns:** TSV string suitable for LimeSurvey import
60
+
61
+
62
+ ## Configuration
63
+
64
+ Both `XLSFormParser` and `XLSFormToTSVConverter` accept an optional config object:
65
+
66
+ ```typescript
67
+ const tsv = await XLSFormParser.convertXLSFileToTSV('survey.xlsx', {
68
+ handleRepeats: 'error',
69
+ debugLogging: true,
70
+ convertWelcomeNote: false,
71
+ defaults: { language: 'de', surveyTitle: 'My Survey' },
72
+ });
73
+ ```
74
+
75
+ | Option | Type | Default | Description |
76
+ |---|---|---|---|
77
+ | `convertWelcomeNote` | `boolean` | `true` | Promote a `note` named `welcome` to LimeSurvey's survey welcome text. |
78
+ | `convertEndNote` | `boolean` | `true` | Promote a `note` named `end` to LimeSurvey's survey end text. |
79
+ | `convertOtherPattern` | `boolean` | `true` | Auto-detect the `_other` question pattern and set `other=Y`. |
80
+ | `convertMarkdown` | `boolean` | `true` | Parse labels/hints as Markdown and convert to HTML. |
81
+
15
82
  ## Implemented features
16
83
 
17
84
  - Question Types and Choices (see `src/processors/TypeMapper.ts` for how this library maps XLSForm types to LimeSurvey types)
18
85
  - everything but the types specified in `UNIMPLEMENTED_TYPES` in `src/xlsformConverter.ts`
19
86
  - record types ❌ (start, end, today, device_id, username, phonenumber, email)
20
87
 
88
+ - **"Other" Option Handling** ✅
89
+ - **Explicit `or_other` modifier**: Add `or_other` to question type (e.g., `select_one colors or_other`) to enable the "other" option
90
+ - **Automatic pattern detection**: The converter automatically detects when you have:
91
+ - A main question (single or multiple choice)
92
+ - A follow-up question with the same name + `_other` suffix
93
+ - The follow-up question has relevance targeting the "other" option of the main question
94
+ - When this pattern is detected, the "other" choice is removed from the choices list and `other=Y` is set on the main question
95
+ - **Example pattern**:
96
+ ```
97
+ # Main question
98
+ type: select_one colors
99
+ name: favorite_color
100
+ label: What is your favorite color?
101
+
102
+ # Choices (including "other")
103
+ list_name: colors
104
+ name: red, label: Red
105
+ name: blue, label: Blue
106
+ name: other, label: Other
107
+
108
+ # Follow-up question for "other" specification
109
+ type: text
110
+ name: favorite_color_other # Same name + "_other" suffix
111
+ label: Please specify your favorite color
112
+ relevant: ${favorite_color} = 'other' # Targets the "other" option
113
+ ```
114
+ - **Result**: The "other" choice is automatically removed and `other=Y` is set on the main question
115
+
21
116
  - Settings sheet
22
117
  - -> LS Survey Global Parameters (only name of survey) ✅
23
118
  - -> Survey Language-Specific Parameters (default language is first row, other rows are extracted from label translations) ✅
@@ -46,7 +141,7 @@ Convert XLSForm surveys to LimeSurvey TSV format.
46
141
  - `multiline` on text questions → LimeSurvey type `T` (Long free text) ✅
47
142
  - `likert` on select_one → kept as `L` (no LimeSurvey visual equivalent) ✅
48
143
  - `label`/`list-nolabel` → LimeSurvey matrix question type `F` ✅
49
- - `field-list` on groups → silently ignored (format=A already shows everything on one page) ✅
144
+ - `field-list` on groups → each group becomes a separate page when `style=pages` is set (`format=G`); silently ignored otherwise
50
145
  - Other appearances (e.g. `minimal`, `compact`, `horizontal`) trigger a warning and are ignored
51
146
  - Additional columns ❌
52
147
  - guidance_hint ❌
@@ -55,57 +150,17 @@ Convert XLSForm surveys to LimeSurvey TSV format.
55
150
 
56
151
  XLSForm and LimeSurvey differ in how they model surveys. Some information is lost or transformed during conversion, and some defaults are applied:
57
152
 
58
- - **Survey format**: The output defaults to "All in one" mode (`format=A`), displaying all groups and questions on a single page.
153
+ - **Survey format**: The output defaults to "All in one" mode (`format=A`), displaying all groups and questions on a single page. If the settings sheet has `style=pages`, the format is set to `G` (group by group), so each group with `appearance=field-list` becomes a separate page — matching XLSForm's multi-page behaviour.
59
154
  - **Nested groups**: LimeSurvey does not support nested groups. Parent-only groups (containing only child groups, no direct questions) are flattened — their label becomes a note question (type X) in the first child group.
60
- - **Field name truncation**: LimeSurvey limits question codes to 20 characters and answer codes to 5 characters. Longer names are truncated (underscores removed first, then cut to length).
155
+ - **Field name sanitization**: LimeSurvey only allows alphanumeric question codes (max 20 characters) and answer codes (max 5 characters). Underscores and hyphens are stripped, then names are truncated to fit. If two fields end up with the same sanitized name, a numeric suffix is appended to the later one (e.g. `fieldname1`). **Recommendation:** to avoid renaming, use short IDs (≤ 20 chars for questions, ≤ 5 chars for choices) without underscores or hyphens — these will pass through unchanged.
61
156
  - **Record/metadata types**: XLSForm `start`, `end`, `today`, `deviceid` etc. are silently skipped — LimeSurvey handles these internally.
62
- - **Appearances**: Most XLSForm `appearance` values have no LimeSurvey equivalent and are ignored (a warning is logged). Supported appearances: `multiline` on text questions maps to type `T` (Long free text); `likert` on select_one is accepted silently (stays type `L`); `label`/`list-nolabel` is converted to LimeSurvey's matrix question type (`F`); `field-list` on groups is a no-op since format=A already shows everything on one page.
157
+ - **Reserved note names `welcome` and `end`**: A `note` question with `name=welcome` is promoted to the LimeSurvey survey welcome text (`surveyls_welcometext`) instead of appearing as a question. A `note` with `name=end` is promoted to the end text (`surveyls_endtext`). Both support multilingual labels. If either note is the sole content of a group, that wrapping group is silently suppressed (no group row is emitted). If the group also contains other questions, it is kept and the note is still promoted.
158
+ - **Appearances**: Most XLSForm `appearance` values have no LimeSurvey equivalent and are ignored (a warning is logged). Supported appearances: `multiline` on text questions maps to type `T` (Long free text); `likert` on select_one is accepted silently (stays type `L`); `label`/`list-nolabel` is converted to LimeSurvey's matrix question type (`F`); `field-list` on groups is silently ignored in `format=A` mode, or becomes a page boundary in `format=G` mode (when `style=pages` is set).
63
159
  - **Multilingual row ordering**: Rows are grouped by language within each group (all base-language rows first, then translations) to work around a LimeSurvey TSV importer bug that resets question ordering counters on translation rows.
64
160
 
65
- ## Installation
161
+ - **Lime survey** soft mandatory doesnt work only mandatory or not
66
162
 
67
- ```bash
68
- npm install xlsform2lstsv
69
- ```
70
-
71
- ## Quick Start
72
163
 
73
- The XFormParser provides direct XLS/XLSX file support:
74
-
75
- ```typescript
76
- import { XFormParser } from 'xlsform2lstsv';
77
-
78
- // Parse XLS/XLSX file and convert to TSV
79
- const tsv = await XFormParser.convertXLSFileToTSV('path/to/survey.xlsx');
80
-
81
- // Or parse XLS/XLSX data directly
82
- const xlsxData = fs.readFileSync('path/to/survey.xlsx');
83
- const tsv = await XFormParser.convertXLSDataToTSV(xlsxData);
84
- ```
85
-
86
- **Methods:**
87
- - `convertXLSFileToTSV(filePath, config)`: Direct conversion from file
88
- - `convertXLSDataToTSV(data, config)`: Direct conversion from buffer
89
- - `parseXLSFile(filePath)`: Parse to structured arrays
90
- - `parseXLSData(data)`: Parse buffer to structured arrays
91
-
92
- ### Using Arrays
93
-
94
- A different entry point accepts XLSForm data as JavaScript arrays:
95
-
96
- ```typescript
97
- import { XLSFormToTSVConverter } from 'xlsform2lstsv';
98
-
99
- const converter = new XLSFormToTSVConverter();
100
- const tsv = converter.convert(surveyData, choicesData, settingsData);
101
- ```
102
-
103
- **Parameters:**
104
- - `surveyData`: Array of survey rows (questions, groups, etc.)
105
- - `choicesData`: Array of choice/option data
106
- - `settingsData`: Array of survey settings
107
-
108
- **Returns:** TSV string suitable for LimeSurvey import
109
164
 
110
165
  ## Development Setup
111
166
 
@@ -180,6 +235,11 @@ To test specific versions, set the `SPECIFIC_VERSIONS` environment variable:
180
235
  SPECIFIC_VERSIONS="6.16.4,6.17.0" npm run test-compatibility
181
236
  ```
182
237
 
238
+ To test with current specified version:
239
+
240
+ ```bash
241
+ npm run test:integration
242
+ ```
183
243
 
184
244
  ### Commit Message Format
185
245
 
@@ -4,6 +4,10 @@
4
4
  export const defaultConfig = {
5
5
  handleRepeats: 'warn',
6
6
  debugLogging: false,
7
+ convertWelcomeNote: true,
8
+ convertEndNote: true,
9
+ convertOtherPattern: true,
10
+ convertMarkdown: true,
7
11
  defaults: {
8
12
  language: 'en',
9
13
  groupName: 'Questions',
@@ -129,14 +129,21 @@ function transpile(node, ctx) {
129
129
  return `endsWith(${transpile(node.args[0], ctx)}, ${transpile(node.args[1], ctx)})`;
130
130
  }
131
131
  break;
132
+ case 'normalize-space':
133
+ if (node.args?.length === 1) {
134
+ return `trim(${transpile(node.args[0], ctx)})`;
135
+ }
136
+ break;
132
137
  case 'not':
133
138
  if (node.args?.length === 1) {
134
139
  return `!(${transpile(node.args[0], ctx)})`;
135
140
  }
136
141
  break;
137
142
  case 'if':
143
+ // Use if() function instead of ternary (? :) because EM's ternary parser
144
+ // can misinterpret colons inside string literals (e.g. '2026-03-CHW: Chancenwerk')
138
145
  if (node.args?.length === 3) {
139
- return `(${transpile(node.args[0], ctx)} ? ${transpile(node.args[1], ctx)} : ${transpile(node.args[2], ctx)})`;
146
+ return `if(${transpile(node.args[0], ctx)}, ${transpile(node.args[1], ctx)}, ${transpile(node.args[2], ctx)})`;
140
147
  }
141
148
  break;
142
149
  case 'today':
@@ -166,7 +173,9 @@ function transpile(node, ctx) {
166
173
  const rawValue = rightNode.value;
167
174
  const rewritten = lookupAnswerCode(fieldName, rawValue);
168
175
  if (rewritten !== rawValue) {
169
- return `${fieldName} == "${rewritten}"`;
176
+ // Use truncated field name if available
177
+ const truncatedFieldName = ctx?.getTruncatedFieldName ? ctx.getTruncatedFieldName(fieldName) : fieldName;
178
+ return `${truncatedFieldName} == '${rewritten}'`;
170
179
  }
171
180
  }
172
181
  return `${transpile(leftNode, ctx)} == ${transpile(rightNode, ctx)}`;
@@ -179,7 +188,9 @@ function transpile(node, ctx) {
179
188
  const rawValue = rightNode.value;
180
189
  const rewritten = lookupAnswerCode(fieldName, rawValue);
181
190
  if (rewritten !== rawValue) {
182
- return `${fieldName} != "${rewritten}"`;
191
+ // Use truncated field name if available
192
+ const truncatedFieldName = ctx?.getTruncatedFieldName ? ctx.getTruncatedFieldName(fieldName) : fieldName;
193
+ return `${truncatedFieldName} != '${rewritten}'`;
183
194
  }
184
195
  }
185
196
  return `${transpile(leftNode, ctx)} != ${transpile(rightNode, ctx)}`;
@@ -222,7 +233,10 @@ function transpile(node, ctx) {
222
233
  if (node.steps && node.steps.length > 0) {
223
234
  const step = node.steps[0];
224
235
  if (step.name) {
225
- return sanitizeName(step.name);
236
+ const fieldName = sanitizeName(step.name);
237
+ // Use truncated field name if available in context
238
+ const truncatedFieldName = ctx?.getTruncatedFieldName ? ctx.getTruncatedFieldName(fieldName) : fieldName;
239
+ return truncatedFieldName;
226
240
  }
227
241
  // Handle self reference (.)
228
242
  if (step.axis === 'self') {
@@ -428,7 +442,7 @@ export async function convertRelevance(xpathExpr, ctx) {
428
442
  .replace(/\bOR\b/gi, 'or');
429
443
  const result = await xpathToLimeSurvey(normalizedXPath, ctx);
430
444
  // Handle edge case: selected() with just {field} (without $)
431
- if (result && result.includes('selected(')) {
445
+ if (result && typeof result === 'string' && result.includes('selected(')) {
432
446
  return result.replace(/selected\s*\(\s*\{(\w+)\}\s*,\s*["']([^'"]+)["']\s*\)/g, (_match, fieldName, value) => {
433
447
  return `(${sanitizeName(fieldName)}="${value}")`;
434
448
  });
@@ -31,7 +31,7 @@ function cleanOutputDirectory(dir) {
31
31
  }
32
32
  }
33
33
  }
34
- async function generateTSVFromFixture(fixturePath, outputPath) {
34
+ async function generateTSVFromFixture(fixturePath, outputPath, config = {}) {
35
35
  console.log(`Processing: ${path.basename(fixturePath)}`);
36
36
  // Read fixture
37
37
  const fixtureContent = fs.readFileSync(fixturePath, 'utf-8');
@@ -39,7 +39,7 @@ async function generateTSVFromFixture(fixturePath, outputPath) {
39
39
  // Convert to TSV with configuration that removes underscores but doesn't truncate field names
40
40
  // This matches LimeSurvey's behavior (removes underscores but allows longer field names)
41
41
  // Answer codes are already limited to 5 chars in the fixtures
42
- const converter = new XLSFormToTSVConverter({});
42
+ const converter = new XLSFormToTSVConverter(config);
43
43
  const tsv = await converter.convert(fixture.survey, fixture.choices, fixture.settings);
44
44
  // Write output
45
45
  fs.writeFileSync(outputPath, tsv, 'utf-8');
@@ -96,6 +96,24 @@ async function main() {
96
96
  }
97
97
  console.log('');
98
98
  }
99
+ // Generate settings variant: same fixture with all conversion settings disabled
100
+ const settingsFixturePath = path.join(FIXTURES_DIR, 'settings_survey.json');
101
+ if (fs.existsSync(settingsFixturePath)) {
102
+ const variantPath = path.join(OUTPUT_DIR, 'settings_survey_disabled.tsv');
103
+ try {
104
+ await generateTSVFromFixture(settingsFixturePath, variantPath, {
105
+ convertWelcomeNote: false,
106
+ convertEndNote: false,
107
+ convertOtherPattern: false,
108
+ convertMarkdown: false,
109
+ });
110
+ jsonSuccessCount++;
111
+ }
112
+ catch (error) {
113
+ console.error(' ✗ Error processing settings_survey_disabled:', error);
114
+ }
115
+ console.log('');
116
+ }
99
117
  // Generate TSV for each XLSX fixture
100
118
  // XLSX files may contain unimplemented types (e.g. range) — failures are logged but not fatal
101
119
  let xlsxSuccessCount = 0;
@@ -1,9 +1,72 @@
1
1
  import { sanitizeFieldName } from '../utils/helpers.js';
2
+ const MAX_FIELD_LENGTH = 20;
2
3
  export class FieldSanitizer {
3
- constructor() { }
4
+ constructor() {
5
+ /** Set of unique sanitized names already assigned */
6
+ this.usedNames = new Set();
7
+ /**
8
+ * Map from stripped name (underscores/hyphens removed, NOT truncated)
9
+ * to the unique sanitized name (truncated + deduplicated).
10
+ * Used by the transpiler to resolve variable references.
11
+ */
12
+ this.strippedToUnique = new Map();
13
+ }
14
+ /**
15
+ * Basic sanitization: remove underscores/hyphens and truncate to 20 chars.
16
+ * Does NOT check for duplicates. Use sanitizeNameUnique for that.
17
+ */
4
18
  sanitizeName(name) {
5
19
  return sanitizeFieldName(name);
6
20
  }
21
+ /**
22
+ * Sanitize a field name and ensure it is unique among all previously
23
+ * registered names. If a collision is detected after sanitization,
24
+ * a numeric suffix is appended (e.g. "fieldname1").
25
+ */
26
+ sanitizeNameUnique(name) {
27
+ const stripped = name.replace(/[_-]/g, '');
28
+ let truncated = stripped.length > MAX_FIELD_LENGTH
29
+ ? stripped.substring(0, MAX_FIELD_LENGTH)
30
+ : stripped;
31
+ if (!this.usedNames.has(truncated)) {
32
+ this.usedNames.add(truncated);
33
+ this.strippedToUnique.set(stripped, truncated);
34
+ return truncated;
35
+ }
36
+ // Collision detected — append a numeric suffix
37
+ let counter = 1;
38
+ let candidate;
39
+ do {
40
+ const suffix = String(counter);
41
+ candidate = truncated.substring(0, MAX_FIELD_LENGTH - suffix.length) + suffix;
42
+ counter++;
43
+ } while (this.usedNames.has(candidate));
44
+ this.usedNames.add(candidate);
45
+ this.strippedToUnique.set(stripped, candidate);
46
+ console.warn(`Field name "${name}" collides with an existing name after sanitization; renamed to "${candidate}"`);
47
+ return candidate;
48
+ }
49
+ /**
50
+ * Resolve a stripped field name (underscores already removed, not truncated)
51
+ * to its unique sanitized name. Falls back to simple truncation if the name
52
+ * was never registered.
53
+ */
54
+ resolveStrippedName(strippedName) {
55
+ const mapped = this.strippedToUnique.get(strippedName);
56
+ if (mapped)
57
+ return mapped;
58
+ // Fallback: truncate like normal (name was never registered)
59
+ return strippedName.length > MAX_FIELD_LENGTH
60
+ ? strippedName.substring(0, MAX_FIELD_LENGTH)
61
+ : strippedName;
62
+ }
63
+ /**
64
+ * Clear all registered names. Must be called at the start of each conversion.
65
+ */
66
+ resetNames() {
67
+ this.usedNames.clear();
68
+ this.strippedToUnique.clear();
69
+ }
7
70
  sanitizeAnswerCode(code) {
8
71
  // Answer codes in LimeSurvey have a 5-character limit
9
72
  let result = code;
@@ -20,11 +20,12 @@ export class TSVGenerator {
20
20
  'mandatory',
21
21
  'other',
22
22
  'default',
23
- 'same_default'
23
+ 'same_default',
24
+ 'hidden'
24
25
  ];
25
26
  const lines = [headers.join('\t')];
26
27
  for (const row of this.rows) {
27
- const values = headers.map((h) => this.escapeForTSV(row[h] || ''));
28
+ const values = headers.map((h) => this.escapeForTSV(row[h] ?? ''));
28
29
  lines.push(values.join('\t'));
29
30
  }
30
31
  return lines.join('\n');
@@ -23,6 +23,31 @@ export function deepMerge(target, ...sources) {
23
23
  function isObject(item) {
24
24
  return item !== null && typeof item === 'object' && !Array.isArray(item);
25
25
  }
26
+ /**
27
+ * Deduplicate a list of names by appending numeric suffixes on collision.
28
+ * Returns a new array with unique names, preserving order.
29
+ */
30
+ export function deduplicateNames(names, maxLength) {
31
+ const result = [...names];
32
+ const used = new Set();
33
+ for (let i = 0; i < result.length; i++) {
34
+ if (!result[i])
35
+ continue;
36
+ let name = result[i];
37
+ if (used.has(name)) {
38
+ let counter = 1;
39
+ let candidate;
40
+ do {
41
+ const suffix = String(counter);
42
+ candidate = name.substring(0, maxLength - suffix.length) + suffix;
43
+ counter++;
44
+ } while (used.has(candidate));
45
+ result[i] = candidate;
46
+ }
47
+ used.add(result[i]);
48
+ }
49
+ return result;
50
+ }
26
51
  /**
27
52
  * Sanitize field names for LimeSurvey compatibility
28
53
  * Removes underscores/hyphens and truncates to 20 characters (LimeSurvey question title limit)
@@ -0,0 +1,20 @@
1
+ import { marked } from 'marked';
2
+ /**
3
+ * Convert a markdown string to HTML for use in LimeSurvey text fields.
4
+ *
5
+ * - Block content (multiple paragraphs, lists, etc.) is returned as full HTML.
6
+ * - Single-paragraph content has its outer <p>…</p> stripped so that short
7
+ * labels remain inline strings rather than block elements.
8
+ * - Empty / non-string input is returned as-is.
9
+ */
10
+ export function markdownToHtml(text) {
11
+ if (!text)
12
+ return text;
13
+ const html = marked.parse(text).trim();
14
+ // Strip the wrapping <p>…</p> only when the output is a single paragraph
15
+ // (i.e. exactly one <p> tag). Multi-paragraph output keeps its structure.
16
+ if (html.startsWith('<p>') && html.endsWith('</p>') && (html.match(/<p>/g) || []).length === 1) {
17
+ return html.slice(3, -4);
18
+ }
19
+ return html;
20
+ }