xlsform2lstsv 0.2.0 → 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
- return `(${sanitizeName(fieldName)}=="${value}")`;
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
- return `${transpile(node.left)} == ${transpile(node.right)}`;
151
- case '!=':
152
- return `${transpile(node.left)} != ${transpile(node.right)}`;
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) => {
@@ -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();
@@ -686,11 +734,34 @@ export class XLSFormToTSVConverter {
686
734
  }
687
735
  // Use the answer class from the type mapping
688
736
  const answerClass = lsType.answerClass || (xfTypeInfo.base === 'select_multiple' ? 'SQ' : 'A');
737
+ // Pre-compute sanitized choice names
738
+ const choiceNames = [];
689
739
  for (const choice of choices) {
690
- // Auto-generate name if missing (matches LimeSurvey behavior)
691
- const choiceName = choice.name && choice.name.trim() !== ''
692
- ? this.sanitizeAnswerCode(choice.name.trim())
693
- : (answerClass === 'SQ' ? `SQ${this.subquestionSeq++}` : `A${this.answerSeq++}`);
740
+ const rawName = choice.name && choice.name.trim() !== '' ? choice.name.trim() : '';
741
+ choiceNames.push(rawName
742
+ ? this.sanitizeAnswerCode(rawName)
743
+ : (answerClass === 'SQ' ? `SQ${this.subquestionSeq++}` : `A${this.answerSeq++}`));
744
+ }
745
+ // Resolve duplicate names by appending a counter suffix
746
+ const usedNames = new Set();
747
+ for (let i = 0; i < choiceNames.length; i++) {
748
+ let name = choiceNames[i];
749
+ if (usedNames.has(name)) {
750
+ let counter = 1;
751
+ let candidate;
752
+ do {
753
+ const suffix = String(counter);
754
+ candidate = name.substring(0, 5 - suffix.length) + suffix;
755
+ counter++;
756
+ } while (usedNames.has(candidate));
757
+ console.warn(`Duplicate answer code "${name}" resolved to "${candidate}"`);
758
+ choiceNames[i] = candidate;
759
+ }
760
+ usedNames.add(choiceNames[i]);
761
+ }
762
+ for (let i = 0; i < choices.length; i++) {
763
+ const choice = choices[i];
764
+ const choiceName = choiceNames[i];
694
765
  // Add answer for each language
695
766
  for (const lang of this.availableLanguages) {
696
767
  this.bufferRow({
@@ -716,6 +787,17 @@ export class XLSFormToTSVConverter {
716
787
  async convertRelevance(relevant) {
717
788
  if (!relevant)
718
789
  return '1';
719
- 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
+ });
720
802
  }
721
803
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xlsform2lstsv",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Convert XLSForm surveys to LimeSurvey TSV format",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",