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 +107 -47
- package/dist/config/types.js +4 -0
- package/dist/converters/xpathTranspiler.js +19 -5
- package/dist/generateFixtures.js +20 -2
- package/dist/processors/FieldSanitizer.js +64 -1
- package/dist/processors/TSVGenerator.js +3 -2
- package/dist/utils/helpers.js +25 -0
- package/dist/utils/markdownRenderer.js +20 -0
- package/dist/xlsformConverter.js +463 -448
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
[](https://www.npmjs.com/package/xlsform2lstsv)
|
|
2
|
+
[](./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
|
-
-
|
|
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 →
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
|
|
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
|
|
package/dist/config/types.js
CHANGED
|
@@ -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)}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/generateFixtures.js
CHANGED
|
@@ -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');
|
package/dist/utils/helpers.js
CHANGED
|
@@ -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
|
+
}
|