xlsform2lstsv 0.2.1 → 0.2.3
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 +1 -1
- package/dist/converters/xpathTranspiler.js +78 -44
- package/dist/processors/TypeMapper.js +1 -0
- package/dist/xlsformConverter.js +118 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Convert XLSForm surveys to LimeSurvey TSV format.
|
|
|
35
35
|
- its a complex task to ensure the transpiler covers everything and we currently cannot guarantee error free/complete transpiling
|
|
36
36
|
|
|
37
37
|
- constraint_message ❌
|
|
38
|
-
- XLSForms Calculation
|
|
38
|
+
- XLSForms Calculation ✅ (`calculate` type → LimeSurvey Equation question `*`; `${var}` references in labels/hints converted to EM `{var}` syntax)
|
|
39
39
|
- XLSForms Trigger ❌
|
|
40
40
|
- Repeats ❌
|
|
41
41
|
- LimeSurvey Assessments ❌
|
|
@@ -7,6 +7,12 @@
|
|
|
7
7
|
* The transpiler uses js-xpath library to parse XPath expressions into AST,
|
|
8
8
|
* then recursively transforms the AST nodes to LimeSurvey-compatible syntax.
|
|
9
9
|
*/
|
|
10
|
+
function isVariableRef(node) {
|
|
11
|
+
return !!(node.steps && node.steps.length > 0 && node.steps[0].name);
|
|
12
|
+
}
|
|
13
|
+
function isStringLiteral(node) {
|
|
14
|
+
return typeof node.value === 'string';
|
|
15
|
+
}
|
|
10
16
|
/**
|
|
11
17
|
* Sanitize field names by removing underscores and hyphens to match LimeSurvey's naming conventions
|
|
12
18
|
*/
|
|
@@ -32,7 +38,7 @@ function sanitizeName(name) {
|
|
|
32
38
|
* @returns The transpiled LimeSurvey expression string
|
|
33
39
|
* @throws Error if an unsupported node structure is encountered
|
|
34
40
|
*/
|
|
35
|
-
function transpile(node) {
|
|
41
|
+
function transpile(node, ctx) {
|
|
36
42
|
if (!node)
|
|
37
43
|
return '';
|
|
38
44
|
// https://getodk.github.io/xforms-spec/#xpath-functions
|
|
@@ -40,92 +46,97 @@ function transpile(node) {
|
|
|
40
46
|
if (node.id) {
|
|
41
47
|
switch (node.id) {
|
|
42
48
|
case 'count':
|
|
43
|
-
return `count(${node.args?.map(arg => transpile(arg)).join(', ') || ''})`;
|
|
49
|
+
return `count(${node.args?.map(arg => transpile(arg, ctx)).join(', ') || ''})`;
|
|
44
50
|
case 'concat':
|
|
45
|
-
return node.args?.map(arg => transpile(arg)).join(' + ') || '';
|
|
51
|
+
return node.args?.map(arg => transpile(arg, ctx)).join(' + ') || '';
|
|
46
52
|
case 'regex':
|
|
47
|
-
return `regexMatch(${node.args?.map(arg => transpile(arg)).join(', ') || ''})`;
|
|
53
|
+
return `regexMatch(${node.args?.map(arg => transpile(arg, ctx)).join(', ') || ''})`;
|
|
48
54
|
case 'contains':
|
|
49
55
|
// Custom handling for contains
|
|
50
56
|
if (node.args?.length === 2) {
|
|
51
|
-
return `contains(${transpile(node.args[0])}, ${transpile(node.args[1])})`;
|
|
57
|
+
return `contains(${transpile(node.args[0], ctx)}, ${transpile(node.args[1], ctx)})`;
|
|
52
58
|
}
|
|
53
59
|
break;
|
|
54
60
|
case 'selected':
|
|
55
|
-
// Handle selected(${field}, 'value')
|
|
61
|
+
// Handle selected(${field}, 'value')
|
|
56
62
|
if (node.args?.length === 2) {
|
|
57
63
|
const fieldArg = node.args[0];
|
|
58
64
|
const valueArg = node.args[1];
|
|
59
|
-
const fieldName = transpile(fieldArg);
|
|
60
|
-
let value = transpile(valueArg);
|
|
61
|
-
// Remove any existing quotes
|
|
65
|
+
const fieldName = transpile(fieldArg, ctx);
|
|
66
|
+
let value = transpile(valueArg, ctx);
|
|
67
|
+
// Remove any existing quotes
|
|
62
68
|
value = value.replace(/^['"]|['"]$/g, "");
|
|
63
|
-
|
|
69
|
+
const sanitizedField = sanitizeName(fieldName);
|
|
70
|
+
// Use buildSelectedExpr if available (handles select_one vs select_multiple)
|
|
71
|
+
if (ctx?.buildSelectedExpr) {
|
|
72
|
+
return ctx.buildSelectedExpr(sanitizedField, value);
|
|
73
|
+
}
|
|
74
|
+
return `(${sanitizedField}=="${value}")`;
|
|
64
75
|
}
|
|
65
76
|
break;
|
|
66
77
|
case 'string':
|
|
67
78
|
// string() function - just return the argument
|
|
68
79
|
if (node.args?.length === 1) {
|
|
69
|
-
return transpile(node.args[0]);
|
|
80
|
+
return transpile(node.args[0], ctx);
|
|
70
81
|
}
|
|
71
82
|
break;
|
|
72
83
|
case 'number':
|
|
73
84
|
// number() function - just return the argument
|
|
74
85
|
if (node.args?.length === 1) {
|
|
75
|
-
return transpile(node.args[0]);
|
|
86
|
+
return transpile(node.args[0], ctx);
|
|
76
87
|
}
|
|
77
88
|
break;
|
|
78
89
|
case 'floor':
|
|
79
90
|
if (node.args?.length === 1) {
|
|
80
|
-
return `floor(${transpile(node.args[0])})`;
|
|
91
|
+
return `floor(${transpile(node.args[0], ctx)})`;
|
|
81
92
|
}
|
|
82
93
|
break;
|
|
83
94
|
case 'ceiling':
|
|
84
95
|
if (node.args?.length === 1) {
|
|
85
|
-
return `ceil(${transpile(node.args[0])})`;
|
|
96
|
+
return `ceil(${transpile(node.args[0], ctx)})`;
|
|
86
97
|
}
|
|
87
98
|
break;
|
|
88
99
|
case 'round':
|
|
89
100
|
if (node.args?.length === 1) {
|
|
90
|
-
return `round(${transpile(node.args[0])})`;
|
|
101
|
+
return `round(${transpile(node.args[0], ctx)})`;
|
|
91
102
|
}
|
|
92
103
|
break;
|
|
93
104
|
case 'sum':
|
|
94
105
|
if (node.args?.length === 1) {
|
|
95
|
-
return `sum(${transpile(node.args[0])})`;
|
|
106
|
+
return `sum(${transpile(node.args[0], ctx)})`;
|
|
96
107
|
}
|
|
97
108
|
break;
|
|
98
109
|
case 'substring':
|
|
99
110
|
if (node.args && node.args.length >= 2) {
|
|
100
|
-
const stringArg = transpile(node.args[0]);
|
|
101
|
-
const startArg = transpile(node.args[1]);
|
|
102
|
-
const lengthArg = node.args.length > 2 ? transpile(node.args[2]) : '';
|
|
111
|
+
const stringArg = transpile(node.args[0], ctx);
|
|
112
|
+
const startArg = transpile(node.args[1], ctx);
|
|
113
|
+
const lengthArg = node.args.length > 2 ? transpile(node.args[2], ctx) : '';
|
|
103
114
|
return `substr(${stringArg}, ${startArg}${lengthArg ? ', ' + lengthArg : ''})`;
|
|
104
115
|
}
|
|
105
116
|
break;
|
|
106
117
|
case 'string-length':
|
|
107
118
|
if (node.args?.length === 1) {
|
|
108
|
-
return `strlen(${transpile(node.args[0])})`;
|
|
119
|
+
return `strlen(${transpile(node.args[0], ctx)})`;
|
|
109
120
|
}
|
|
110
121
|
break;
|
|
111
122
|
case 'starts-with':
|
|
112
123
|
if (node.args?.length === 2) {
|
|
113
|
-
return `startsWith(${transpile(node.args[0])}, ${transpile(node.args[1])})`;
|
|
124
|
+
return `startsWith(${transpile(node.args[0], ctx)}, ${transpile(node.args[1], ctx)})`;
|
|
114
125
|
}
|
|
115
126
|
break;
|
|
116
127
|
case 'ends-with':
|
|
117
128
|
if (node.args?.length === 2) {
|
|
118
|
-
return `endsWith(${transpile(node.args[0])}, ${transpile(node.args[1])})`;
|
|
129
|
+
return `endsWith(${transpile(node.args[0], ctx)}, ${transpile(node.args[1], ctx)})`;
|
|
119
130
|
}
|
|
120
131
|
break;
|
|
121
132
|
case 'not':
|
|
122
133
|
if (node.args?.length === 1) {
|
|
123
|
-
return `!(${transpile(node.args[0])})`;
|
|
134
|
+
return `!(${transpile(node.args[0], ctx)})`;
|
|
124
135
|
}
|
|
125
136
|
break;
|
|
126
137
|
case 'if':
|
|
127
138
|
if (node.args?.length === 3) {
|
|
128
|
-
return `(${transpile(node.args[0])} ? ${transpile(node.args[1])} : ${transpile(node.args[2])})`;
|
|
139
|
+
return `(${transpile(node.args[0], ctx)} ? ${transpile(node.args[1], ctx)} : ${transpile(node.args[2], ctx)})`;
|
|
129
140
|
}
|
|
130
141
|
break;
|
|
131
142
|
case 'today':
|
|
@@ -136,40 +147,63 @@ function transpile(node) {
|
|
|
136
147
|
throw new Error(`Unsupported function: ${node.id}`);
|
|
137
148
|
}
|
|
138
149
|
}
|
|
139
|
-
// https://getodk.github.io/xforms-spec/#xpath-operators
|
|
150
|
+
// https://getodk.github.io/xforms-spec/#xpath-operators
|
|
140
151
|
// to https://www.limesurvey.org/manual/ExpressionScript_-_Presentation (see syntax)
|
|
141
152
|
if (node.type) {
|
|
153
|
+
const lookupAnswerCode = ctx?.lookupAnswerCode;
|
|
142
154
|
switch (node.type) {
|
|
143
155
|
// Comparison operators
|
|
144
156
|
case '<=':
|
|
145
|
-
return `${transpile(node.left)} <= ${transpile(node.right)}`;
|
|
157
|
+
return `${transpile(node.left, ctx)} <= ${transpile(node.right, ctx)}`;
|
|
146
158
|
case '>=':
|
|
147
|
-
return `${transpile(node.left)} >= ${transpile(node.right)}`;
|
|
159
|
+
return `${transpile(node.left, ctx)} >= ${transpile(node.right, ctx)}`;
|
|
148
160
|
case '=':
|
|
149
|
-
case '==':
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
161
|
+
case '==': {
|
|
162
|
+
const leftNode = node.left;
|
|
163
|
+
const rightNode = node.right;
|
|
164
|
+
if (lookupAnswerCode && isVariableRef(leftNode) && isStringLiteral(rightNode)) {
|
|
165
|
+
const fieldName = sanitizeName(leftNode.steps[0].name);
|
|
166
|
+
const rawValue = rightNode.value;
|
|
167
|
+
const rewritten = lookupAnswerCode(fieldName, rawValue);
|
|
168
|
+
if (rewritten !== rawValue) {
|
|
169
|
+
return `${fieldName} == "${rewritten}"`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return `${transpile(leftNode, ctx)} == ${transpile(rightNode, ctx)}`;
|
|
173
|
+
}
|
|
174
|
+
case '!=': {
|
|
175
|
+
const leftNode = node.left;
|
|
176
|
+
const rightNode = node.right;
|
|
177
|
+
if (lookupAnswerCode && isVariableRef(leftNode) && isStringLiteral(rightNode)) {
|
|
178
|
+
const fieldName = sanitizeName(leftNode.steps[0].name);
|
|
179
|
+
const rawValue = rightNode.value;
|
|
180
|
+
const rewritten = lookupAnswerCode(fieldName, rawValue);
|
|
181
|
+
if (rewritten !== rawValue) {
|
|
182
|
+
return `${fieldName} != "${rewritten}"`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return `${transpile(leftNode, ctx)} != ${transpile(rightNode, ctx)}`;
|
|
186
|
+
}
|
|
153
187
|
case '<':
|
|
154
|
-
return `${transpile(node.left)} < ${transpile(node.right)}`;
|
|
188
|
+
return `${transpile(node.left, ctx)} < ${transpile(node.right, ctx)}`;
|
|
155
189
|
case '>':
|
|
156
|
-
return `${transpile(node.left)} > ${transpile(node.right)}`;
|
|
190
|
+
return `${transpile(node.left, ctx)} > ${transpile(node.right, ctx)}`;
|
|
157
191
|
// Arithmetic operators
|
|
158
192
|
case '+':
|
|
159
|
-
return `${transpile(node.left)} + ${transpile(node.right)}`;
|
|
193
|
+
return `${transpile(node.left, ctx)} + ${transpile(node.right, ctx)}`;
|
|
160
194
|
case '-':
|
|
161
|
-
return `${transpile(node.left)} - ${transpile(node.right)}`;
|
|
195
|
+
return `${transpile(node.left, ctx)} - ${transpile(node.right, ctx)}`;
|
|
162
196
|
case '*':
|
|
163
|
-
return `${transpile(node.left)} * ${transpile(node.right)}`;
|
|
197
|
+
return `${transpile(node.left, ctx)} * ${transpile(node.right, ctx)}`;
|
|
164
198
|
case 'div':
|
|
165
|
-
return `${transpile(node.left)} / ${transpile(node.right)}`;
|
|
199
|
+
return `${transpile(node.left, ctx)} / ${transpile(node.right, ctx)}`;
|
|
166
200
|
case 'mod':
|
|
167
|
-
return `${transpile(node.left)} % ${transpile(node.right)}`;
|
|
201
|
+
return `${transpile(node.left, ctx)} % ${transpile(node.right, ctx)}`;
|
|
168
202
|
// Logical operators
|
|
169
203
|
case 'and':
|
|
170
|
-
return `${transpile(node.left)} and ${transpile(node.right)}`;
|
|
204
|
+
return `${transpile(node.left, ctx)} and ${transpile(node.right, ctx)}`;
|
|
171
205
|
case 'or':
|
|
172
|
-
return `${transpile(node.left)} or ${transpile(node.right)}`;
|
|
206
|
+
return `${transpile(node.left, ctx)} or ${transpile(node.right, ctx)}`;
|
|
173
207
|
// Unsupported operators
|
|
174
208
|
case '|':
|
|
175
209
|
case '/':
|
|
@@ -219,7 +253,7 @@ function transpile(node) {
|
|
|
219
253
|
* @param xpathExpr - The XPath expression to convert
|
|
220
254
|
* @returns LimeSurvey Expression Manager syntax, or null if conversion fails
|
|
221
255
|
*/
|
|
222
|
-
export async function xpathToLimeSurvey(xpathExpr) {
|
|
256
|
+
export async function xpathToLimeSurvey(xpathExpr, ctx) {
|
|
223
257
|
if (!xpathExpr || xpathExpr.trim() === '') {
|
|
224
258
|
return '1'; // Default relevance expression
|
|
225
259
|
}
|
|
@@ -241,7 +275,7 @@ export async function xpathToLimeSurvey(xpathExpr) {
|
|
|
241
275
|
throw new Error('js-xpath module does not export parse function');
|
|
242
276
|
}
|
|
243
277
|
const parsed = jxpath.parse(processedExpr);
|
|
244
|
-
return transpile(parsed);
|
|
278
|
+
return transpile(parsed, ctx);
|
|
245
279
|
}
|
|
246
280
|
catch (error) {
|
|
247
281
|
console.error(`Transpilation error: ${error.message}`);
|
|
@@ -385,14 +419,14 @@ function parseRegexMatchArguments(argsString) {
|
|
|
385
419
|
* @param xpath - The XPath relevance expression
|
|
386
420
|
* @returns LimeSurvey Expression Manager syntax
|
|
387
421
|
*/
|
|
388
|
-
export async function convertRelevance(xpathExpr) {
|
|
422
|
+
export async function convertRelevance(xpathExpr, ctx) {
|
|
389
423
|
if (!xpathExpr)
|
|
390
424
|
return '1';
|
|
391
425
|
// Preprocess: normalize operators to lowercase for jsxpath compatibility
|
|
392
426
|
let normalizedXPath = xpathExpr
|
|
393
427
|
.replace(/\bAND\b/gi, 'and')
|
|
394
428
|
.replace(/\bOR\b/gi, 'or');
|
|
395
|
-
const result = await xpathToLimeSurvey(normalizedXPath);
|
|
429
|
+
const result = await xpathToLimeSurvey(normalizedXPath, ctx);
|
|
396
430
|
// Handle edge case: selected() with just {field} (without $)
|
|
397
431
|
if (result && result.includes('selected(')) {
|
|
398
432
|
return result.replace(/selected\s*\(\s*\{(\w+)\}\s*,\s*["']([^'"]+)["']\s*\)/g, (_match, fieldName, value) => {
|
|
@@ -20,6 +20,7 @@ export const TYPE_MAPPINGS = {
|
|
|
20
20
|
select_multiple: { limeSurveyType: 'M', supportsOther: true, answerClass: 'SQ' },
|
|
21
21
|
// Other types
|
|
22
22
|
note: { limeSurveyType: 'X' },
|
|
23
|
+
calculate: { limeSurveyType: '*' },
|
|
23
24
|
rank: { limeSurveyType: 'R', answerClass: 'A', supportsOther: true },
|
|
24
25
|
};
|
|
25
26
|
export class TypeMapper {
|
package/dist/xlsformConverter.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ConfigManager } from './config/ConfigManager.js';
|
|
2
|
-
import { convertRelevance, convertConstraint } from './converters/xpathTranspiler.js';
|
|
2
|
+
import { convertRelevance, convertConstraint, xpathToLimeSurvey } from './converters/xpathTranspiler.js';
|
|
3
3
|
import { FieldSanitizer } from './processors/FieldSanitizer.js';
|
|
4
4
|
import { TSVGenerator } from './processors/TSVGenerator.js';
|
|
5
5
|
import { TypeMapper } from './processors/TypeMapper.js';
|
|
@@ -7,7 +7,7 @@ import { getBaseLanguage } from './utils/languageUtils.js';
|
|
|
7
7
|
// Metadata types that should be silently skipped (no visual representation)
|
|
8
8
|
const SKIP_TYPES = [
|
|
9
9
|
'start', 'end', 'today', 'deviceid', 'username',
|
|
10
|
-
'
|
|
10
|
+
'hidden', 'audit'
|
|
11
11
|
];
|
|
12
12
|
// Unimplemented XLSForm types that should raise an error
|
|
13
13
|
const UNIMPLEMENTED_TYPES = [
|
|
@@ -49,6 +49,9 @@ export class XLSFormToTSVConverter {
|
|
|
49
49
|
this.inMatrix = false;
|
|
50
50
|
this.matrixListName = null;
|
|
51
51
|
this.groupContentBuffer = [];
|
|
52
|
+
this.answerCodeMap = new Map();
|
|
53
|
+
this.questionToListMap = new Map();
|
|
54
|
+
this.questionBaseTypeMap = new Map();
|
|
52
55
|
}
|
|
53
56
|
/**
|
|
54
57
|
* Get the current configuration
|
|
@@ -84,6 +87,9 @@ export class XLSFormToTSVConverter {
|
|
|
84
87
|
this.detectAvailableLanguages(surveyData, choicesData, settingsData);
|
|
85
88
|
// Build choices map
|
|
86
89
|
this.buildChoicesMap(choicesData);
|
|
90
|
+
// Build answer code and question-to-list maps for relevance rewriting
|
|
91
|
+
this.buildAnswerCodeMap();
|
|
92
|
+
this.buildQuestionToListMap(surveyData);
|
|
87
93
|
// Add survey row (class S)
|
|
88
94
|
this.addSurveyRow(settingsData[0] || {});
|
|
89
95
|
// Check if we need a default group (if no groups are defined)
|
|
@@ -161,6 +167,51 @@ export class XLSFormToTSVConverter {
|
|
|
161
167
|
this.choicesMap.get(listName).push(choice);
|
|
162
168
|
}
|
|
163
169
|
}
|
|
170
|
+
buildAnswerCodeMap() {
|
|
171
|
+
this.answerCodeMap = new Map();
|
|
172
|
+
for (const [listName, choices] of this.choicesMap) {
|
|
173
|
+
const codeMap = new Map();
|
|
174
|
+
const sanitized = [];
|
|
175
|
+
for (const choice of choices) {
|
|
176
|
+
const raw = choice.name?.trim() || '';
|
|
177
|
+
sanitized.push(raw ? this.sanitizeAnswerCode(raw) : '');
|
|
178
|
+
}
|
|
179
|
+
const used = new Set();
|
|
180
|
+
for (let i = 0; i < sanitized.length; i++) {
|
|
181
|
+
if (!sanitized[i])
|
|
182
|
+
continue;
|
|
183
|
+
let name = sanitized[i];
|
|
184
|
+
if (used.has(name)) {
|
|
185
|
+
let counter = 1;
|
|
186
|
+
let candidate;
|
|
187
|
+
do {
|
|
188
|
+
const suffix = String(counter);
|
|
189
|
+
candidate = name.substring(0, 5 - suffix.length) + suffix;
|
|
190
|
+
counter++;
|
|
191
|
+
} while (used.has(candidate));
|
|
192
|
+
sanitized[i] = candidate;
|
|
193
|
+
}
|
|
194
|
+
used.add(sanitized[i]);
|
|
195
|
+
const originalName = choices[i].name?.trim() || '';
|
|
196
|
+
if (originalName) {
|
|
197
|
+
codeMap.set(originalName, sanitized[i]);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
this.answerCodeMap.set(listName, codeMap);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
buildQuestionToListMap(surveyData) {
|
|
204
|
+
this.questionToListMap = new Map();
|
|
205
|
+
this.questionBaseTypeMap = new Map();
|
|
206
|
+
for (const row of surveyData) {
|
|
207
|
+
const typeInfo = this.parseType(row.type || '');
|
|
208
|
+
if (typeInfo.listName && row.name) {
|
|
209
|
+
const sanitizedName = this.sanitizeName(row.name.trim());
|
|
210
|
+
this.questionToListMap.set(sanitizedName, typeInfo.listName);
|
|
211
|
+
this.questionBaseTypeMap.set(sanitizedName, typeInfo.base);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
164
215
|
addDefaultGroup() {
|
|
165
216
|
// Add a default group for surveys without explicit groups
|
|
166
217
|
const defaults = this.configManager.getDefaults();
|
|
@@ -489,6 +540,21 @@ export class XLSFormToTSVConverter {
|
|
|
489
540
|
sanitizeAnswerCode(code) {
|
|
490
541
|
return this.fieldSanitizer.sanitizeAnswerCode(code);
|
|
491
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* Convert ${varname} references in text to LimeSurvey EM syntax {sanitizedname}.
|
|
545
|
+
*/
|
|
546
|
+
convertVariableReferences(text) {
|
|
547
|
+
return text.replace(/\$\{([^}]+)\}/g, (_, name) => {
|
|
548
|
+
const sanitized = name.replace(/[_-]/g, '');
|
|
549
|
+
return `{${sanitized}}`;
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Transpile an XLSForm calculation expression to a LimeSurvey EM expression.
|
|
554
|
+
*/
|
|
555
|
+
async convertCalculation(calculation) {
|
|
556
|
+
return await xpathToLimeSurvey(calculation);
|
|
557
|
+
}
|
|
492
558
|
async addGroup(row) {
|
|
493
559
|
// Auto-generate name if missing (matches LimeSurvey behavior)
|
|
494
560
|
const groupName = row.name && row.name.trim() !== ''
|
|
@@ -561,21 +627,38 @@ export class XLSFormToTSVConverter {
|
|
|
561
627
|
}
|
|
562
628
|
// Notes have special handling
|
|
563
629
|
const isNote = xfTypeInfo.base === 'note';
|
|
630
|
+
const isCalculate = xfTypeInfo.base === 'calculate';
|
|
631
|
+
// For calculate type, transpile the calculation expression to EM syntax
|
|
632
|
+
let calculationExpr = '';
|
|
633
|
+
if (isCalculate && row.calculation) {
|
|
634
|
+
calculationExpr = await this.convertCalculation(row.calculation);
|
|
635
|
+
}
|
|
564
636
|
// Add main question for each language
|
|
565
637
|
for (const lang of this.availableLanguages) {
|
|
638
|
+
let text;
|
|
639
|
+
if (isCalculate) {
|
|
640
|
+
// Equation question: the EM expression wrapped in {} IS the question text
|
|
641
|
+
text = `{${calculationExpr}}`;
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
text = this.getLanguageSpecificValue(row.label, lang) || questionName;
|
|
645
|
+
}
|
|
646
|
+
// Convert ${var} references to EM {var} syntax in text and help
|
|
647
|
+
text = this.convertVariableReferences(text);
|
|
648
|
+
const help = this.convertVariableReferences(this.getLanguageSpecificValue(row.hint, lang) || '');
|
|
566
649
|
this.bufferRow({
|
|
567
650
|
class: 'Q',
|
|
568
651
|
'type/scale': isNote ? 'X' : lsType.type,
|
|
569
652
|
name: questionName,
|
|
570
653
|
relevance: await this.convertRelevance(row.relevant),
|
|
571
|
-
text
|
|
572
|
-
help
|
|
654
|
+
text,
|
|
655
|
+
help,
|
|
573
656
|
language: lang,
|
|
574
657
|
validation: "",
|
|
575
|
-
em_validation_q: isNote ? "" : await convertConstraint(row.constraint || ""),
|
|
576
|
-
mandatory: isNote ? '' : (row.required === 'yes' || row.required === 'true' ? 'Y' : ''),
|
|
577
|
-
other: isNote ? '' : (lsType.other ? 'Y' : ''),
|
|
578
|
-
default: isNote ? '' : (row.default || ''),
|
|
658
|
+
em_validation_q: (isNote || isCalculate) ? "" : await convertConstraint(row.constraint || ""),
|
|
659
|
+
mandatory: (isNote || isCalculate) ? '' : (row.required === 'yes' || row.required === 'true' ? 'Y' : ''),
|
|
660
|
+
other: (isNote || isCalculate) ? '' : (lsType.other ? 'Y' : ''),
|
|
661
|
+
default: (isNote || isCalculate) ? '' : (row.default || ''),
|
|
579
662
|
same_default: ''
|
|
580
663
|
});
|
|
581
664
|
}
|
|
@@ -736,9 +819,35 @@ export class XLSFormToTSVConverter {
|
|
|
736
819
|
}
|
|
737
820
|
}
|
|
738
821
|
}
|
|
822
|
+
lookupAnswerCode(fieldName, choiceValue) {
|
|
823
|
+
// Truncate to 20 chars to match converter's field sanitization
|
|
824
|
+
// (the transpiler only removes _/- but doesn't truncate)
|
|
825
|
+
const truncated = fieldName.length > 20 ? fieldName.substring(0, 20) : fieldName;
|
|
826
|
+
const listName = this.questionToListMap.get(truncated);
|
|
827
|
+
if (!listName)
|
|
828
|
+
return { code: choiceValue, listName: undefined };
|
|
829
|
+
const codeMap = this.answerCodeMap.get(listName);
|
|
830
|
+
if (!codeMap)
|
|
831
|
+
return { code: choiceValue, listName };
|
|
832
|
+
return { code: codeMap.get(choiceValue) ?? choiceValue, listName };
|
|
833
|
+
}
|
|
739
834
|
async convertRelevance(relevant) {
|
|
740
835
|
if (!relevant)
|
|
741
836
|
return '1';
|
|
742
|
-
|
|
837
|
+
const ctx = {
|
|
838
|
+
lookupAnswerCode: (fieldName, choiceValue) => {
|
|
839
|
+
return this.lookupAnswerCode(fieldName, choiceValue).code;
|
|
840
|
+
},
|
|
841
|
+
buildSelectedExpr: (fieldName, choiceValue) => {
|
|
842
|
+
const truncated = fieldName.length > 20 ? fieldName.substring(0, 20) : fieldName;
|
|
843
|
+
const { code } = this.lookupAnswerCode(fieldName, choiceValue);
|
|
844
|
+
const baseType = this.questionBaseTypeMap.get(truncated);
|
|
845
|
+
if (baseType === 'select_multiple') {
|
|
846
|
+
return `(${truncated}_${code}.NAOK == "Y")`;
|
|
847
|
+
}
|
|
848
|
+
return `(${truncated}.NAOK=="${code}")`;
|
|
849
|
+
},
|
|
850
|
+
};
|
|
851
|
+
return await convertRelevance(relevant, ctx);
|
|
743
852
|
}
|
|
744
853
|
}
|