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 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') -> (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 and use double 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
- return `(${sanitizeName(fieldName)}=="${value}")`;
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
- return `${transpile(node.left)} == ${transpile(node.right)}`;
151
- case '!=':
152
- return `${transpile(node.left)} != ${transpile(node.right)}`;
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 {
@@ -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
- 'calculate', 'hidden', 'audit'
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: this.getLanguageSpecificValue(row.label, lang) || questionName,
572
- help: this.getLanguageSpecificValue(row.hint, lang) || '',
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
- return await convertRelevance(relevant);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xlsform2lstsv",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Convert XLSForm surveys to LimeSurvey TSV format",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",