xlsform2lstsv 0.2.1 → 0.2.2
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.
|
@@ -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, lookupAnswerCode) {
|
|
36
42
|
if (!node)
|
|
37
43
|
return '';
|
|
38
44
|
// https://getodk.github.io/xforms-spec/#xpath-functions
|
|
@@ -40,15 +46,15 @@ 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, lookupAnswerCode)).join(', ') || ''})`;
|
|
44
50
|
case 'concat':
|
|
45
|
-
return node.args?.map(arg => transpile(arg)).join(' + ') || '';
|
|
51
|
+
return node.args?.map(arg => transpile(arg, lookupAnswerCode)).join(' + ') || '';
|
|
46
52
|
case 'regex':
|
|
47
|
-
return `regexMatch(${node.args?.map(arg => transpile(arg)).join(', ') || ''})`;
|
|
53
|
+
return `regexMatch(${node.args?.map(arg => transpile(arg, lookupAnswerCode)).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], lookupAnswerCode)}, ${transpile(node.args[1], lookupAnswerCode)})`;
|
|
52
58
|
}
|
|
53
59
|
break;
|
|
54
60
|
case 'selected':
|
|
@@ -56,76 +62,80 @@ function transpile(node) {
|
|
|
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);
|
|
65
|
+
const fieldName = transpile(fieldArg, lookupAnswerCode);
|
|
66
|
+
let value = transpile(valueArg, lookupAnswerCode);
|
|
61
67
|
// Remove any existing quotes and use double quotes
|
|
62
68
|
value = value.replace(/^['"]|['"]$/g, "");
|
|
63
|
-
|
|
69
|
+
const sanitizedField = sanitizeName(fieldName);
|
|
70
|
+
if (lookupAnswerCode) {
|
|
71
|
+
value = lookupAnswerCode(sanitizedField, value);
|
|
72
|
+
}
|
|
73
|
+
return `(${sanitizedField}=="${value}")`;
|
|
64
74
|
}
|
|
65
75
|
break;
|
|
66
76
|
case 'string':
|
|
67
77
|
// string() function - just return the argument
|
|
68
78
|
if (node.args?.length === 1) {
|
|
69
|
-
return transpile(node.args[0]);
|
|
79
|
+
return transpile(node.args[0], lookupAnswerCode);
|
|
70
80
|
}
|
|
71
81
|
break;
|
|
72
82
|
case 'number':
|
|
73
83
|
// number() function - just return the argument
|
|
74
84
|
if (node.args?.length === 1) {
|
|
75
|
-
return transpile(node.args[0]);
|
|
85
|
+
return transpile(node.args[0], lookupAnswerCode);
|
|
76
86
|
}
|
|
77
87
|
break;
|
|
78
88
|
case 'floor':
|
|
79
89
|
if (node.args?.length === 1) {
|
|
80
|
-
return `floor(${transpile(node.args[0])})`;
|
|
90
|
+
return `floor(${transpile(node.args[0], lookupAnswerCode)})`;
|
|
81
91
|
}
|
|
82
92
|
break;
|
|
83
93
|
case 'ceiling':
|
|
84
94
|
if (node.args?.length === 1) {
|
|
85
|
-
return `ceil(${transpile(node.args[0])})`;
|
|
95
|
+
return `ceil(${transpile(node.args[0], lookupAnswerCode)})`;
|
|
86
96
|
}
|
|
87
97
|
break;
|
|
88
98
|
case 'round':
|
|
89
99
|
if (node.args?.length === 1) {
|
|
90
|
-
return `round(${transpile(node.args[0])})`;
|
|
100
|
+
return `round(${transpile(node.args[0], lookupAnswerCode)})`;
|
|
91
101
|
}
|
|
92
102
|
break;
|
|
93
103
|
case 'sum':
|
|
94
104
|
if (node.args?.length === 1) {
|
|
95
|
-
return `sum(${transpile(node.args[0])})`;
|
|
105
|
+
return `sum(${transpile(node.args[0], lookupAnswerCode)})`;
|
|
96
106
|
}
|
|
97
107
|
break;
|
|
98
108
|
case 'substring':
|
|
99
109
|
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]) : '';
|
|
110
|
+
const stringArg = transpile(node.args[0], lookupAnswerCode);
|
|
111
|
+
const startArg = transpile(node.args[1], lookupAnswerCode);
|
|
112
|
+
const lengthArg = node.args.length > 2 ? transpile(node.args[2], lookupAnswerCode) : '';
|
|
103
113
|
return `substr(${stringArg}, ${startArg}${lengthArg ? ', ' + lengthArg : ''})`;
|
|
104
114
|
}
|
|
105
115
|
break;
|
|
106
116
|
case 'string-length':
|
|
107
117
|
if (node.args?.length === 1) {
|
|
108
|
-
return `strlen(${transpile(node.args[0])})`;
|
|
118
|
+
return `strlen(${transpile(node.args[0], lookupAnswerCode)})`;
|
|
109
119
|
}
|
|
110
120
|
break;
|
|
111
121
|
case 'starts-with':
|
|
112
122
|
if (node.args?.length === 2) {
|
|
113
|
-
return `startsWith(${transpile(node.args[0])}, ${transpile(node.args[1])})`;
|
|
123
|
+
return `startsWith(${transpile(node.args[0], lookupAnswerCode)}, ${transpile(node.args[1], lookupAnswerCode)})`;
|
|
114
124
|
}
|
|
115
125
|
break;
|
|
116
126
|
case 'ends-with':
|
|
117
127
|
if (node.args?.length === 2) {
|
|
118
|
-
return `endsWith(${transpile(node.args[0])}, ${transpile(node.args[1])})`;
|
|
128
|
+
return `endsWith(${transpile(node.args[0], lookupAnswerCode)}, ${transpile(node.args[1], lookupAnswerCode)})`;
|
|
119
129
|
}
|
|
120
130
|
break;
|
|
121
131
|
case 'not':
|
|
122
132
|
if (node.args?.length === 1) {
|
|
123
|
-
return `!(${transpile(node.args[0])})`;
|
|
133
|
+
return `!(${transpile(node.args[0], lookupAnswerCode)})`;
|
|
124
134
|
}
|
|
125
135
|
break;
|
|
126
136
|
case 'if':
|
|
127
137
|
if (node.args?.length === 3) {
|
|
128
|
-
return `(${transpile(node.args[0])} ? ${transpile(node.args[1])} : ${transpile(node.args[2])})`;
|
|
138
|
+
return `(${transpile(node.args[0], lookupAnswerCode)} ? ${transpile(node.args[1], lookupAnswerCode)} : ${transpile(node.args[2], lookupAnswerCode)})`;
|
|
129
139
|
}
|
|
130
140
|
break;
|
|
131
141
|
case 'today':
|
|
@@ -142,34 +152,56 @@ function transpile(node) {
|
|
|
142
152
|
switch (node.type) {
|
|
143
153
|
// Comparison operators
|
|
144
154
|
case '<=':
|
|
145
|
-
return `${transpile(node.left)} <= ${transpile(node.right)}`;
|
|
155
|
+
return `${transpile(node.left, lookupAnswerCode)} <= ${transpile(node.right, lookupAnswerCode)}`;
|
|
146
156
|
case '>=':
|
|
147
|
-
return `${transpile(node.left)} >= ${transpile(node.right)}`;
|
|
157
|
+
return `${transpile(node.left, lookupAnswerCode)} >= ${transpile(node.right, lookupAnswerCode)}`;
|
|
148
158
|
case '=':
|
|
149
|
-
case '==':
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
case '==': {
|
|
160
|
+
const leftNode = node.left;
|
|
161
|
+
const rightNode = node.right;
|
|
162
|
+
if (lookupAnswerCode && isVariableRef(leftNode) && isStringLiteral(rightNode)) {
|
|
163
|
+
const fieldName = sanitizeName(leftNode.steps[0].name);
|
|
164
|
+
const rawValue = rightNode.value;
|
|
165
|
+
const rewritten = lookupAnswerCode(fieldName, rawValue);
|
|
166
|
+
if (rewritten !== rawValue) {
|
|
167
|
+
return `${fieldName} == "${rewritten}"`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return `${transpile(leftNode, lookupAnswerCode)} == ${transpile(rightNode, lookupAnswerCode)}`;
|
|
171
|
+
}
|
|
172
|
+
case '!=': {
|
|
173
|
+
const leftNode = node.left;
|
|
174
|
+
const rightNode = node.right;
|
|
175
|
+
if (lookupAnswerCode && isVariableRef(leftNode) && isStringLiteral(rightNode)) {
|
|
176
|
+
const fieldName = sanitizeName(leftNode.steps[0].name);
|
|
177
|
+
const rawValue = rightNode.value;
|
|
178
|
+
const rewritten = lookupAnswerCode(fieldName, rawValue);
|
|
179
|
+
if (rewritten !== rawValue) {
|
|
180
|
+
return `${fieldName} != "${rewritten}"`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return `${transpile(leftNode, lookupAnswerCode)} != ${transpile(rightNode, lookupAnswerCode)}`;
|
|
184
|
+
}
|
|
153
185
|
case '<':
|
|
154
|
-
return `${transpile(node.left)} < ${transpile(node.right)}`;
|
|
186
|
+
return `${transpile(node.left, lookupAnswerCode)} < ${transpile(node.right, lookupAnswerCode)}`;
|
|
155
187
|
case '>':
|
|
156
|
-
return `${transpile(node.left)} > ${transpile(node.right)}`;
|
|
188
|
+
return `${transpile(node.left, lookupAnswerCode)} > ${transpile(node.right, lookupAnswerCode)}`;
|
|
157
189
|
// Arithmetic operators
|
|
158
190
|
case '+':
|
|
159
|
-
return `${transpile(node.left)} + ${transpile(node.right)}`;
|
|
191
|
+
return `${transpile(node.left, lookupAnswerCode)} + ${transpile(node.right, lookupAnswerCode)}`;
|
|
160
192
|
case '-':
|
|
161
|
-
return `${transpile(node.left)} - ${transpile(node.right)}`;
|
|
193
|
+
return `${transpile(node.left, lookupAnswerCode)} - ${transpile(node.right, lookupAnswerCode)}`;
|
|
162
194
|
case '*':
|
|
163
|
-
return `${transpile(node.left)} * ${transpile(node.right)}`;
|
|
195
|
+
return `${transpile(node.left, lookupAnswerCode)} * ${transpile(node.right, lookupAnswerCode)}`;
|
|
164
196
|
case 'div':
|
|
165
|
-
return `${transpile(node.left)} / ${transpile(node.right)}`;
|
|
197
|
+
return `${transpile(node.left, lookupAnswerCode)} / ${transpile(node.right, lookupAnswerCode)}`;
|
|
166
198
|
case 'mod':
|
|
167
|
-
return `${transpile(node.left)} % ${transpile(node.right)}`;
|
|
199
|
+
return `${transpile(node.left, lookupAnswerCode)} % ${transpile(node.right, lookupAnswerCode)}`;
|
|
168
200
|
// Logical operators
|
|
169
201
|
case 'and':
|
|
170
|
-
return `${transpile(node.left)} and ${transpile(node.right)}`;
|
|
202
|
+
return `${transpile(node.left, lookupAnswerCode)} and ${transpile(node.right, lookupAnswerCode)}`;
|
|
171
203
|
case 'or':
|
|
172
|
-
return `${transpile(node.left)} or ${transpile(node.right)}`;
|
|
204
|
+
return `${transpile(node.left, lookupAnswerCode)} or ${transpile(node.right, lookupAnswerCode)}`;
|
|
173
205
|
// Unsupported operators
|
|
174
206
|
case '|':
|
|
175
207
|
case '/':
|
|
@@ -219,7 +251,7 @@ function transpile(node) {
|
|
|
219
251
|
* @param xpathExpr - The XPath expression to convert
|
|
220
252
|
* @returns LimeSurvey Expression Manager syntax, or null if conversion fails
|
|
221
253
|
*/
|
|
222
|
-
export async function xpathToLimeSurvey(xpathExpr) {
|
|
254
|
+
export async function xpathToLimeSurvey(xpathExpr, lookupAnswerCode) {
|
|
223
255
|
if (!xpathExpr || xpathExpr.trim() === '') {
|
|
224
256
|
return '1'; // Default relevance expression
|
|
225
257
|
}
|
|
@@ -241,7 +273,7 @@ export async function xpathToLimeSurvey(xpathExpr) {
|
|
|
241
273
|
throw new Error('js-xpath module does not export parse function');
|
|
242
274
|
}
|
|
243
275
|
const parsed = jxpath.parse(processedExpr);
|
|
244
|
-
return transpile(parsed);
|
|
276
|
+
return transpile(parsed, lookupAnswerCode);
|
|
245
277
|
}
|
|
246
278
|
catch (error) {
|
|
247
279
|
console.error(`Transpilation error: ${error.message}`);
|
|
@@ -385,14 +417,14 @@ function parseRegexMatchArguments(argsString) {
|
|
|
385
417
|
* @param xpath - The XPath relevance expression
|
|
386
418
|
* @returns LimeSurvey Expression Manager syntax
|
|
387
419
|
*/
|
|
388
|
-
export async function convertRelevance(xpathExpr) {
|
|
420
|
+
export async function convertRelevance(xpathExpr, lookupAnswerCode) {
|
|
389
421
|
if (!xpathExpr)
|
|
390
422
|
return '1';
|
|
391
423
|
// Preprocess: normalize operators to lowercase for jsxpath compatibility
|
|
392
424
|
let normalizedXPath = xpathExpr
|
|
393
425
|
.replace(/\bAND\b/gi, 'and')
|
|
394
426
|
.replace(/\bOR\b/gi, 'or');
|
|
395
|
-
const result = await xpathToLimeSurvey(normalizedXPath);
|
|
427
|
+
const result = await xpathToLimeSurvey(normalizedXPath, lookupAnswerCode);
|
|
396
428
|
// Handle edge case: selected() with just {field} (without $)
|
|
397
429
|
if (result && result.includes('selected(')) {
|
|
398
430
|
return result.replace(/selected\s*\(\s*\{(\w+)\}\s*,\s*["']([^'"]+)["']\s*\)/g, (_match, fieldName, value) => {
|
package/dist/xlsformConverter.js
CHANGED
|
@@ -49,6 +49,8 @@ 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();
|
|
52
54
|
}
|
|
53
55
|
/**
|
|
54
56
|
* Get the current configuration
|
|
@@ -84,6 +86,9 @@ export class XLSFormToTSVConverter {
|
|
|
84
86
|
this.detectAvailableLanguages(surveyData, choicesData, settingsData);
|
|
85
87
|
// Build choices map
|
|
86
88
|
this.buildChoicesMap(choicesData);
|
|
89
|
+
// Build answer code and question-to-list maps for relevance rewriting
|
|
90
|
+
this.buildAnswerCodeMap();
|
|
91
|
+
this.buildQuestionToListMap(surveyData);
|
|
87
92
|
// Add survey row (class S)
|
|
88
93
|
this.addSurveyRow(settingsData[0] || {});
|
|
89
94
|
// Check if we need a default group (if no groups are defined)
|
|
@@ -161,6 +166,49 @@ export class XLSFormToTSVConverter {
|
|
|
161
166
|
this.choicesMap.get(listName).push(choice);
|
|
162
167
|
}
|
|
163
168
|
}
|
|
169
|
+
buildAnswerCodeMap() {
|
|
170
|
+
this.answerCodeMap = new Map();
|
|
171
|
+
for (const [listName, choices] of this.choicesMap) {
|
|
172
|
+
const codeMap = new Map();
|
|
173
|
+
const sanitized = [];
|
|
174
|
+
for (const choice of choices) {
|
|
175
|
+
const raw = choice.name?.trim() || '';
|
|
176
|
+
sanitized.push(raw ? this.sanitizeAnswerCode(raw) : '');
|
|
177
|
+
}
|
|
178
|
+
const used = new Set();
|
|
179
|
+
for (let i = 0; i < sanitized.length; i++) {
|
|
180
|
+
if (!sanitized[i])
|
|
181
|
+
continue;
|
|
182
|
+
let name = sanitized[i];
|
|
183
|
+
if (used.has(name)) {
|
|
184
|
+
let counter = 1;
|
|
185
|
+
let candidate;
|
|
186
|
+
do {
|
|
187
|
+
const suffix = String(counter);
|
|
188
|
+
candidate = name.substring(0, 5 - suffix.length) + suffix;
|
|
189
|
+
counter++;
|
|
190
|
+
} while (used.has(candidate));
|
|
191
|
+
sanitized[i] = candidate;
|
|
192
|
+
}
|
|
193
|
+
used.add(sanitized[i]);
|
|
194
|
+
const originalName = choices[i].name?.trim() || '';
|
|
195
|
+
if (originalName) {
|
|
196
|
+
codeMap.set(originalName, sanitized[i]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
this.answerCodeMap.set(listName, codeMap);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
buildQuestionToListMap(surveyData) {
|
|
203
|
+
this.questionToListMap = new Map();
|
|
204
|
+
for (const row of surveyData) {
|
|
205
|
+
const typeInfo = this.parseType(row.type || '');
|
|
206
|
+
if (typeInfo.listName && row.name) {
|
|
207
|
+
const sanitizedName = this.sanitizeName(row.name.trim());
|
|
208
|
+
this.questionToListMap.set(sanitizedName, typeInfo.listName);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
164
212
|
addDefaultGroup() {
|
|
165
213
|
// Add a default group for surveys without explicit groups
|
|
166
214
|
const defaults = this.configManager.getDefaults();
|
|
@@ -739,6 +787,17 @@ export class XLSFormToTSVConverter {
|
|
|
739
787
|
async convertRelevance(relevant) {
|
|
740
788
|
if (!relevant)
|
|
741
789
|
return '1';
|
|
742
|
-
return await convertRelevance(relevant)
|
|
790
|
+
return await convertRelevance(relevant, (questionName, choiceValue) => {
|
|
791
|
+
// Truncate to 20 chars to match converter's field sanitization
|
|
792
|
+
// (the transpiler only removes _/- but doesn't truncate)
|
|
793
|
+
const truncated = questionName.length > 20 ? questionName.substring(0, 20) : questionName;
|
|
794
|
+
const listName = this.questionToListMap.get(truncated);
|
|
795
|
+
if (!listName)
|
|
796
|
+
return choiceValue;
|
|
797
|
+
const codeMap = this.answerCodeMap.get(listName);
|
|
798
|
+
if (!codeMap)
|
|
799
|
+
return choiceValue;
|
|
800
|
+
return codeMap.get(choiceValue) ?? choiceValue;
|
|
801
|
+
});
|
|
743
802
|
}
|
|
744
803
|
}
|