xlsform2lstsv 0.2.2 → 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 ❌
@@ -38,7 +38,7 @@ function sanitizeName(name) {
38
38
  * @returns The transpiled LimeSurvey expression string
39
39
  * @throws Error if an unsupported node structure is encountered
40
40
  */
41
- function transpile(node, lookupAnswerCode) {
41
+ function transpile(node, ctx) {
42
42
  if (!node)
43
43
  return '';
44
44
  // https://getodk.github.io/xforms-spec/#xpath-functions
@@ -46,29 +46,30 @@ function transpile(node, lookupAnswerCode) {
46
46
  if (node.id) {
47
47
  switch (node.id) {
48
48
  case 'count':
49
- return `count(${node.args?.map(arg => transpile(arg, lookupAnswerCode)).join(', ') || ''})`;
49
+ return `count(${node.args?.map(arg => transpile(arg, ctx)).join(', ') || ''})`;
50
50
  case 'concat':
51
- return node.args?.map(arg => transpile(arg, lookupAnswerCode)).join(' + ') || '';
51
+ return node.args?.map(arg => transpile(arg, ctx)).join(' + ') || '';
52
52
  case 'regex':
53
- return `regexMatch(${node.args?.map(arg => transpile(arg, lookupAnswerCode)).join(', ') || ''})`;
53
+ return `regexMatch(${node.args?.map(arg => transpile(arg, ctx)).join(', ') || ''})`;
54
54
  case 'contains':
55
55
  // Custom handling for contains
56
56
  if (node.args?.length === 2) {
57
- return `contains(${transpile(node.args[0], lookupAnswerCode)}, ${transpile(node.args[1], lookupAnswerCode)})`;
57
+ return `contains(${transpile(node.args[0], ctx)}, ${transpile(node.args[1], ctx)})`;
58
58
  }
59
59
  break;
60
60
  case 'selected':
61
- // Handle selected(${field}, 'value') -> (field=="value")
61
+ // Handle selected(${field}, 'value')
62
62
  if (node.args?.length === 2) {
63
63
  const fieldArg = node.args[0];
64
64
  const valueArg = node.args[1];
65
- const fieldName = transpile(fieldArg, lookupAnswerCode);
66
- let value = transpile(valueArg, lookupAnswerCode);
67
- // 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
68
68
  value = value.replace(/^['"]|['"]$/g, "");
69
69
  const sanitizedField = sanitizeName(fieldName);
70
- if (lookupAnswerCode) {
71
- value = lookupAnswerCode(sanitizedField, value);
70
+ // Use buildSelectedExpr if available (handles select_one vs select_multiple)
71
+ if (ctx?.buildSelectedExpr) {
72
+ return ctx.buildSelectedExpr(sanitizedField, value);
72
73
  }
73
74
  return `(${sanitizedField}=="${value}")`;
74
75
  }
@@ -76,66 +77,66 @@ function transpile(node, lookupAnswerCode) {
76
77
  case 'string':
77
78
  // string() function - just return the argument
78
79
  if (node.args?.length === 1) {
79
- return transpile(node.args[0], lookupAnswerCode);
80
+ return transpile(node.args[0], ctx);
80
81
  }
81
82
  break;
82
83
  case 'number':
83
84
  // number() function - just return the argument
84
85
  if (node.args?.length === 1) {
85
- return transpile(node.args[0], lookupAnswerCode);
86
+ return transpile(node.args[0], ctx);
86
87
  }
87
88
  break;
88
89
  case 'floor':
89
90
  if (node.args?.length === 1) {
90
- return `floor(${transpile(node.args[0], lookupAnswerCode)})`;
91
+ return `floor(${transpile(node.args[0], ctx)})`;
91
92
  }
92
93
  break;
93
94
  case 'ceiling':
94
95
  if (node.args?.length === 1) {
95
- return `ceil(${transpile(node.args[0], lookupAnswerCode)})`;
96
+ return `ceil(${transpile(node.args[0], ctx)})`;
96
97
  }
97
98
  break;
98
99
  case 'round':
99
100
  if (node.args?.length === 1) {
100
- return `round(${transpile(node.args[0], lookupAnswerCode)})`;
101
+ return `round(${transpile(node.args[0], ctx)})`;
101
102
  }
102
103
  break;
103
104
  case 'sum':
104
105
  if (node.args?.length === 1) {
105
- return `sum(${transpile(node.args[0], lookupAnswerCode)})`;
106
+ return `sum(${transpile(node.args[0], ctx)})`;
106
107
  }
107
108
  break;
108
109
  case 'substring':
109
110
  if (node.args && node.args.length >= 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) : '';
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) : '';
113
114
  return `substr(${stringArg}, ${startArg}${lengthArg ? ', ' + lengthArg : ''})`;
114
115
  }
115
116
  break;
116
117
  case 'string-length':
117
118
  if (node.args?.length === 1) {
118
- return `strlen(${transpile(node.args[0], lookupAnswerCode)})`;
119
+ return `strlen(${transpile(node.args[0], ctx)})`;
119
120
  }
120
121
  break;
121
122
  case 'starts-with':
122
123
  if (node.args?.length === 2) {
123
- return `startsWith(${transpile(node.args[0], lookupAnswerCode)}, ${transpile(node.args[1], lookupAnswerCode)})`;
124
+ return `startsWith(${transpile(node.args[0], ctx)}, ${transpile(node.args[1], ctx)})`;
124
125
  }
125
126
  break;
126
127
  case 'ends-with':
127
128
  if (node.args?.length === 2) {
128
- return `endsWith(${transpile(node.args[0], lookupAnswerCode)}, ${transpile(node.args[1], lookupAnswerCode)})`;
129
+ return `endsWith(${transpile(node.args[0], ctx)}, ${transpile(node.args[1], ctx)})`;
129
130
  }
130
131
  break;
131
132
  case 'not':
132
133
  if (node.args?.length === 1) {
133
- return `!(${transpile(node.args[0], lookupAnswerCode)})`;
134
+ return `!(${transpile(node.args[0], ctx)})`;
134
135
  }
135
136
  break;
136
137
  case 'if':
137
138
  if (node.args?.length === 3) {
138
- return `(${transpile(node.args[0], lookupAnswerCode)} ? ${transpile(node.args[1], lookupAnswerCode)} : ${transpile(node.args[2], lookupAnswerCode)})`;
139
+ return `(${transpile(node.args[0], ctx)} ? ${transpile(node.args[1], ctx)} : ${transpile(node.args[2], ctx)})`;
139
140
  }
140
141
  break;
141
142
  case 'today':
@@ -146,15 +147,16 @@ function transpile(node, lookupAnswerCode) {
146
147
  throw new Error(`Unsupported function: ${node.id}`);
147
148
  }
148
149
  }
149
- // https://getodk.github.io/xforms-spec/#xpath-operators
150
+ // https://getodk.github.io/xforms-spec/#xpath-operators
150
151
  // to https://www.limesurvey.org/manual/ExpressionScript_-_Presentation (see syntax)
151
152
  if (node.type) {
153
+ const lookupAnswerCode = ctx?.lookupAnswerCode;
152
154
  switch (node.type) {
153
155
  // Comparison operators
154
156
  case '<=':
155
- return `${transpile(node.left, lookupAnswerCode)} <= ${transpile(node.right, lookupAnswerCode)}`;
157
+ return `${transpile(node.left, ctx)} <= ${transpile(node.right, ctx)}`;
156
158
  case '>=':
157
- return `${transpile(node.left, lookupAnswerCode)} >= ${transpile(node.right, lookupAnswerCode)}`;
159
+ return `${transpile(node.left, ctx)} >= ${transpile(node.right, ctx)}`;
158
160
  case '=':
159
161
  case '==': {
160
162
  const leftNode = node.left;
@@ -167,7 +169,7 @@ function transpile(node, lookupAnswerCode) {
167
169
  return `${fieldName} == "${rewritten}"`;
168
170
  }
169
171
  }
170
- return `${transpile(leftNode, lookupAnswerCode)} == ${transpile(rightNode, lookupAnswerCode)}`;
172
+ return `${transpile(leftNode, ctx)} == ${transpile(rightNode, ctx)}`;
171
173
  }
172
174
  case '!=': {
173
175
  const leftNode = node.left;
@@ -180,28 +182,28 @@ function transpile(node, lookupAnswerCode) {
180
182
  return `${fieldName} != "${rewritten}"`;
181
183
  }
182
184
  }
183
- return `${transpile(leftNode, lookupAnswerCode)} != ${transpile(rightNode, lookupAnswerCode)}`;
185
+ return `${transpile(leftNode, ctx)} != ${transpile(rightNode, ctx)}`;
184
186
  }
185
187
  case '<':
186
- return `${transpile(node.left, lookupAnswerCode)} < ${transpile(node.right, lookupAnswerCode)}`;
188
+ return `${transpile(node.left, ctx)} < ${transpile(node.right, ctx)}`;
187
189
  case '>':
188
- return `${transpile(node.left, lookupAnswerCode)} > ${transpile(node.right, lookupAnswerCode)}`;
190
+ return `${transpile(node.left, ctx)} > ${transpile(node.right, ctx)}`;
189
191
  // Arithmetic operators
190
192
  case '+':
191
- return `${transpile(node.left, lookupAnswerCode)} + ${transpile(node.right, lookupAnswerCode)}`;
193
+ return `${transpile(node.left, ctx)} + ${transpile(node.right, ctx)}`;
192
194
  case '-':
193
- return `${transpile(node.left, lookupAnswerCode)} - ${transpile(node.right, lookupAnswerCode)}`;
195
+ return `${transpile(node.left, ctx)} - ${transpile(node.right, ctx)}`;
194
196
  case '*':
195
- return `${transpile(node.left, lookupAnswerCode)} * ${transpile(node.right, lookupAnswerCode)}`;
197
+ return `${transpile(node.left, ctx)} * ${transpile(node.right, ctx)}`;
196
198
  case 'div':
197
- return `${transpile(node.left, lookupAnswerCode)} / ${transpile(node.right, lookupAnswerCode)}`;
199
+ return `${transpile(node.left, ctx)} / ${transpile(node.right, ctx)}`;
198
200
  case 'mod':
199
- return `${transpile(node.left, lookupAnswerCode)} % ${transpile(node.right, lookupAnswerCode)}`;
201
+ return `${transpile(node.left, ctx)} % ${transpile(node.right, ctx)}`;
200
202
  // Logical operators
201
203
  case 'and':
202
- return `${transpile(node.left, lookupAnswerCode)} and ${transpile(node.right, lookupAnswerCode)}`;
204
+ return `${transpile(node.left, ctx)} and ${transpile(node.right, ctx)}`;
203
205
  case 'or':
204
- return `${transpile(node.left, lookupAnswerCode)} or ${transpile(node.right, lookupAnswerCode)}`;
206
+ return `${transpile(node.left, ctx)} or ${transpile(node.right, ctx)}`;
205
207
  // Unsupported operators
206
208
  case '|':
207
209
  case '/':
@@ -251,7 +253,7 @@ function transpile(node, lookupAnswerCode) {
251
253
  * @param xpathExpr - The XPath expression to convert
252
254
  * @returns LimeSurvey Expression Manager syntax, or null if conversion fails
253
255
  */
254
- export async function xpathToLimeSurvey(xpathExpr, lookupAnswerCode) {
256
+ export async function xpathToLimeSurvey(xpathExpr, ctx) {
255
257
  if (!xpathExpr || xpathExpr.trim() === '') {
256
258
  return '1'; // Default relevance expression
257
259
  }
@@ -273,7 +275,7 @@ export async function xpathToLimeSurvey(xpathExpr, lookupAnswerCode) {
273
275
  throw new Error('js-xpath module does not export parse function');
274
276
  }
275
277
  const parsed = jxpath.parse(processedExpr);
276
- return transpile(parsed, lookupAnswerCode);
278
+ return transpile(parsed, ctx);
277
279
  }
278
280
  catch (error) {
279
281
  console.error(`Transpilation error: ${error.message}`);
@@ -417,14 +419,14 @@ function parseRegexMatchArguments(argsString) {
417
419
  * @param xpath - The XPath relevance expression
418
420
  * @returns LimeSurvey Expression Manager syntax
419
421
  */
420
- export async function convertRelevance(xpathExpr, lookupAnswerCode) {
422
+ export async function convertRelevance(xpathExpr, ctx) {
421
423
  if (!xpathExpr)
422
424
  return '1';
423
425
  // Preprocess: normalize operators to lowercase for jsxpath compatibility
424
426
  let normalizedXPath = xpathExpr
425
427
  .replace(/\bAND\b/gi, 'and')
426
428
  .replace(/\bOR\b/gi, 'or');
427
- const result = await xpathToLimeSurvey(normalizedXPath, lookupAnswerCode);
429
+ const result = await xpathToLimeSurvey(normalizedXPath, ctx);
428
430
  // Handle edge case: selected() with just {field} (without $)
429
431
  if (result && result.includes('selected(')) {
430
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 = [
@@ -51,6 +51,7 @@ export class XLSFormToTSVConverter {
51
51
  this.groupContentBuffer = [];
52
52
  this.answerCodeMap = new Map();
53
53
  this.questionToListMap = new Map();
54
+ this.questionBaseTypeMap = new Map();
54
55
  }
55
56
  /**
56
57
  * Get the current configuration
@@ -201,11 +202,13 @@ export class XLSFormToTSVConverter {
201
202
  }
202
203
  buildQuestionToListMap(surveyData) {
203
204
  this.questionToListMap = new Map();
205
+ this.questionBaseTypeMap = new Map();
204
206
  for (const row of surveyData) {
205
207
  const typeInfo = this.parseType(row.type || '');
206
208
  if (typeInfo.listName && row.name) {
207
209
  const sanitizedName = this.sanitizeName(row.name.trim());
208
210
  this.questionToListMap.set(sanitizedName, typeInfo.listName);
211
+ this.questionBaseTypeMap.set(sanitizedName, typeInfo.base);
209
212
  }
210
213
  }
211
214
  }
@@ -537,6 +540,21 @@ export class XLSFormToTSVConverter {
537
540
  sanitizeAnswerCode(code) {
538
541
  return this.fieldSanitizer.sanitizeAnswerCode(code);
539
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
+ }
540
558
  async addGroup(row) {
541
559
  // Auto-generate name if missing (matches LimeSurvey behavior)
542
560
  const groupName = row.name && row.name.trim() !== ''
@@ -609,21 +627,38 @@ export class XLSFormToTSVConverter {
609
627
  }
610
628
  // Notes have special handling
611
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
+ }
612
636
  // Add main question for each language
613
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) || '');
614
649
  this.bufferRow({
615
650
  class: 'Q',
616
651
  'type/scale': isNote ? 'X' : lsType.type,
617
652
  name: questionName,
618
653
  relevance: await this.convertRelevance(row.relevant),
619
- text: this.getLanguageSpecificValue(row.label, lang) || questionName,
620
- help: this.getLanguageSpecificValue(row.hint, lang) || '',
654
+ text,
655
+ help,
621
656
  language: lang,
622
657
  validation: "",
623
- em_validation_q: isNote ? "" : await convertConstraint(row.constraint || ""),
624
- mandatory: isNote ? '' : (row.required === 'yes' || row.required === 'true' ? 'Y' : ''),
625
- other: isNote ? '' : (lsType.other ? 'Y' : ''),
626
- 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 || ''),
627
662
  same_default: ''
628
663
  });
629
664
  }
@@ -784,20 +819,35 @@ export class XLSFormToTSVConverter {
784
819
  }
785
820
  }
786
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
+ }
787
834
  async convertRelevance(relevant) {
788
835
  if (!relevant)
789
836
  return '1';
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
- });
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);
802
852
  }
803
853
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xlsform2lstsv",
3
- "version": "0.2.2",
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",