xlsform2lstsv 0.2.0
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 +228 -0
- package/dist/config/ConfigManager.js +43 -0
- package/dist/config/types.js +13 -0
- package/dist/converters/xpathTranspiler.js +403 -0
- package/dist/generateFixtures.js +123 -0
- package/dist/index.js +10 -0
- package/dist/processors/FieldSanitizer.js +21 -0
- package/dist/processors/TSVGenerator.js +52 -0
- package/dist/processors/TypeMapper.js +55 -0
- package/dist/processors/XLSFormParser.js +32 -0
- package/dist/processors/XLSLoader.js +109 -0
- package/dist/processors/XLSValidator.js +121 -0
- package/dist/utils/helpers.js +42 -0
- package/dist/utils/languageUtils.js +141 -0
- package/dist/xlsformConverter.js +721 -0
- package/package.json +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
[](https://www.npmjs.com/package/xlsform2lstsv)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# xlsform2lstsv
|
|
5
|
+
|
|
6
|
+
Convert XLSForm surveys to LimeSurvey TSV format.
|
|
7
|
+
|
|
8
|
+
[!WARNING]
|
|
9
|
+
|
|
10
|
+
- This package is still WIP and not all features of xlsform have been implemented and verified.
|
|
11
|
+
- While importing is tested in an automated fashion (see `scripts/test-compatibility-safe.ts`), this only verifies whether all questions were successfully imported, but not if e.g. validation and relevance expressions were transformed correctly. To be safe, always use the "Survey logic view" in the LimeSurvey GUI.
|
|
12
|
+
- If you want question and choice names to be the same in LimeSurvey, make them <=5 chars (this is a LimeSurvey requiremtn)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## Implemented features
|
|
16
|
+
|
|
17
|
+
- Question Types and Choices (see `src/processors/TypeMapper.ts` for how this library maps XLSForm types to LimeSurvey types)
|
|
18
|
+
- everything but the types specified in `UNIMPLEMENTED_TYPES` in `src/xlsformConverter.ts`
|
|
19
|
+
- record types ❌ (start, end, today, device_id, username, phonenumber, email)
|
|
20
|
+
|
|
21
|
+
- Settings sheet
|
|
22
|
+
- -> LS Survey Global Parameters (only name of survey) ✅
|
|
23
|
+
- -> Survey Language-Specific Parameters (default language is first row, other rows are extracted from label translations) ✅
|
|
24
|
+
|
|
25
|
+
- Question Groups ✅
|
|
26
|
+
- Group level relevance ✅
|
|
27
|
+
- Nested groups: LimeSurvey does not support nested groups. Parent-only groups (groups that contain only child groups and no direct questions) are automatically flattened — their label is converted to a note question (type X) in the first child group.
|
|
28
|
+
|
|
29
|
+
- Hints (normal) ✅
|
|
30
|
+
|
|
31
|
+
- `label` and `hint` translations ✅
|
|
32
|
+
|
|
33
|
+
- XPath -> ExpressionScript/EM 🟡
|
|
34
|
+
- see src/converters/xpathTranspiler.ts for how operators and functions are mapped
|
|
35
|
+
- its a complex task to ensure the transpiler covers everything and we currently cannot guarantee error free/complete transpiling
|
|
36
|
+
|
|
37
|
+
- constraint_message ❌
|
|
38
|
+
- XLSForms Calculation ❌
|
|
39
|
+
- XLSForms Trigger ❌
|
|
40
|
+
- Repeats ❌
|
|
41
|
+
- LimeSurvey Assessments ❌
|
|
42
|
+
- LimeSurvey Quotas ❌
|
|
43
|
+
- LimeSurvey Quota language settings ❌
|
|
44
|
+
- LimeSurvey Quota members ❌
|
|
45
|
+
- XLSForms Appearances 🟡
|
|
46
|
+
- `multiline` on text questions → LimeSurvey type `T` (Long free text) ✅
|
|
47
|
+
- `likert` on select_one → kept as `L` (no LimeSurvey visual equivalent) ✅
|
|
48
|
+
- `label`/`list-nolabel` → LimeSurvey matrix question type `F` ✅
|
|
49
|
+
- `field-list` on groups → silently ignored (format=A already shows everything on one page) ✅
|
|
50
|
+
- Other appearances (e.g. `minimal`, `compact`, `horizontal`) trigger a warning and are ignored
|
|
51
|
+
- Additional columns ❌
|
|
52
|
+
- guidance_hint ❌
|
|
53
|
+
|
|
54
|
+
## Transformation defaults and limitations
|
|
55
|
+
|
|
56
|
+
XLSForm and LimeSurvey differ in how they model surveys. Some information is lost or transformed during conversion, and some defaults are applied:
|
|
57
|
+
|
|
58
|
+
- **Survey format**: The output defaults to "All in one" mode (`format=A`), displaying all groups and questions on a single page.
|
|
59
|
+
- **Nested groups**: LimeSurvey does not support nested groups. Parent-only groups (containing only child groups, no direct questions) are flattened — their label becomes a note question (type X) in the first child group.
|
|
60
|
+
- **Field name truncation**: LimeSurvey limits question codes to 20 characters and answer codes to 5 characters. Longer names are truncated (underscores removed first, then cut to length).
|
|
61
|
+
- **Record/metadata types**: XLSForm `start`, `end`, `today`, `deviceid` etc. are silently skipped — LimeSurvey handles these internally.
|
|
62
|
+
- **Appearances**: Most XLSForm `appearance` values have no LimeSurvey equivalent and are ignored (a warning is logged). Supported appearances: `multiline` on text questions maps to type `T` (Long free text); `likert` on select_one is accepted silently (stays type `L`); `label`/`list-nolabel` is converted to LimeSurvey's matrix question type (`F`); `field-list` on groups is a no-op since format=A already shows everything on one page.
|
|
63
|
+
- **Multilingual row ordering**: Rows are grouped by language within each group (all base-language rows first, then translations) to work around a LimeSurvey TSV importer bug that resets question ordering counters on translation rows.
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install xlsform2lstsv
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Quick Start
|
|
72
|
+
|
|
73
|
+
The XFormParser provides direct XLS/XLSX file support:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
import { XFormParser } from 'xlsform2lstsv';
|
|
77
|
+
|
|
78
|
+
// Parse XLS/XLSX file and convert to TSV
|
|
79
|
+
const tsv = await XFormParser.convertXLSFileToTSV('path/to/survey.xlsx');
|
|
80
|
+
|
|
81
|
+
// Or parse XLS/XLSX data directly
|
|
82
|
+
const xlsxData = fs.readFileSync('path/to/survey.xlsx');
|
|
83
|
+
const tsv = await XFormParser.convertXLSDataToTSV(xlsxData);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Methods:**
|
|
87
|
+
- `convertXLSFileToTSV(filePath, config)`: Direct conversion from file
|
|
88
|
+
- `convertXLSDataToTSV(data, config)`: Direct conversion from buffer
|
|
89
|
+
- `parseXLSFile(filePath)`: Parse to structured arrays
|
|
90
|
+
- `parseXLSData(data)`: Parse buffer to structured arrays
|
|
91
|
+
|
|
92
|
+
### Using Arrays
|
|
93
|
+
|
|
94
|
+
A different entry point accepts XLSForm data as JavaScript arrays:
|
|
95
|
+
|
|
96
|
+
```typescript
|
|
97
|
+
import { XLSFormToTSVConverter } from 'xlsform2lstsv';
|
|
98
|
+
|
|
99
|
+
const converter = new XLSFormToTSVConverter();
|
|
100
|
+
const tsv = converter.convert(surveyData, choicesData, settingsData);
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Parameters:**
|
|
104
|
+
- `surveyData`: Array of survey rows (questions, groups, etc.)
|
|
105
|
+
- `choicesData`: Array of choice/option data
|
|
106
|
+
- `settingsData`: Array of survey settings
|
|
107
|
+
|
|
108
|
+
**Returns:** TSV string suitable for LimeSurvey import
|
|
109
|
+
|
|
110
|
+
## Development Setup
|
|
111
|
+
|
|
112
|
+
### Prerequisites
|
|
113
|
+
|
|
114
|
+
- see `package.json`
|
|
115
|
+
- Docker and Docker Compose (for integration testing)
|
|
116
|
+
- Python 3.9+ with uv package manager (for integration testing)
|
|
117
|
+
|
|
118
|
+
### Initial Setup
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# Clone repository
|
|
122
|
+
git clone https://github.com/CorrelAid/xlsform2lstsv.git
|
|
123
|
+
cd xlsform2lstsv
|
|
124
|
+
|
|
125
|
+
# Install Node.js dependencies
|
|
126
|
+
npm install
|
|
127
|
+
|
|
128
|
+
# Install Git hooks (automatic on npm install)
|
|
129
|
+
npx husky install
|
|
130
|
+
|
|
131
|
+
# Build the project
|
|
132
|
+
npm run build
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Development Tools
|
|
136
|
+
|
|
137
|
+
- **TypeScript**: Primary language
|
|
138
|
+
- **Vitest**: Unit testing framework
|
|
139
|
+
- **ESLint**: Code linting
|
|
140
|
+
- **Prettier**: Code formatting
|
|
141
|
+
- **Husky**: Git hooks management
|
|
142
|
+
- **Commitlint**: Commit message validation
|
|
143
|
+
|
|
144
|
+
## Development Workflow
|
|
145
|
+
|
|
146
|
+
### Unit Testing
|
|
147
|
+
|
|
148
|
+
**Running Tests**:
|
|
149
|
+
```bash
|
|
150
|
+
# Run all unit tests
|
|
151
|
+
npm test
|
|
152
|
+
|
|
153
|
+
# Run tests with watch mode
|
|
154
|
+
npm test -- --watch
|
|
155
|
+
|
|
156
|
+
# Run specific test file
|
|
157
|
+
npm test -- src/test/textTypes.test.ts
|
|
158
|
+
|
|
159
|
+
# Run tests with coverage report
|
|
160
|
+
npm test -- --coverage
|
|
161
|
+
|
|
162
|
+
# Debug specific test
|
|
163
|
+
npm test -- --debug src/test/numericTypes.test.ts
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
### Integration Testing
|
|
168
|
+
|
|
169
|
+
Integration tests verify that generated TSV files can be successfully imported into LimeSurvey.
|
|
170
|
+
|
|
171
|
+
To test all versions specified in `scripts/src/config/version.js`:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
npm run test-compatibility
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
To test specific versions, set the `SPECIFIC_VERSIONS` environment variable:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
SPECIFIC_VERSIONS="6.16.4,6.17.0" npm run test-compatibility
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
### Commit Message Format
|
|
185
|
+
|
|
186
|
+
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
|
187
|
+
|
|
188
|
+
## Releasing
|
|
189
|
+
|
|
190
|
+
Pushing a `v*` tag to GitHub triggers automatic npm publishing via GitHub Actions.
|
|
191
|
+
|
|
192
|
+
### Steps
|
|
193
|
+
|
|
194
|
+
1. **Bump the version**:
|
|
195
|
+
```bash
|
|
196
|
+
npm version patch # 0.1.0 → 0.1.1 (bug fixes)
|
|
197
|
+
npm version minor # 0.1.0 → 0.2.0 (new features)
|
|
198
|
+
npm version major # 0.1.0 → 1.0.0 (breaking changes)
|
|
199
|
+
```
|
|
200
|
+
This updates `package.json` and `package-lock.json`, creates a commit, and creates a `vX.Y.Z` tag.
|
|
201
|
+
|
|
202
|
+
2. **Push the commit and tag**:
|
|
203
|
+
```bash
|
|
204
|
+
git push && git push origin vX.Y.Z
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
3. **GitHub Actions** will build and publish the package to npm.
|
|
208
|
+
|
|
209
|
+
### Requirements
|
|
210
|
+
|
|
211
|
+
- `NPM_TOKEN` secret must be configured in the GitHub repository settings
|
|
212
|
+
- Tags must follow the `v*` pattern (e.g., `v0.2.0`)
|
|
213
|
+
|
|
214
|
+
## Limesurvey Resources
|
|
215
|
+
|
|
216
|
+
- Limesurvey TSV Import Code: https://github.com/LimeSurvey/LimeSurvey/blob/50870a0767a3b132344a195bcaa354be82eecddf/application/helpers/admin/import_helper.php#L3836
|
|
217
|
+
- Limesurvey DB Structure: https://github.com/LimeSurvey/LimeSurvey/blob/master/installer/create-database.php
|
|
218
|
+
- LimeSurvey Expressions:
|
|
219
|
+
- https://github.com/LimeSurvey/LimeSurvey/blob/0715c161c40d741da68fc670dd89d71026b37c07/application/helpers/expressions/em_core_helper.php
|
|
220
|
+
- https://www.limesurvey.org/manual/ExpressionScript_examples
|
|
221
|
+
- https://www.limesurvey.org/manual/ExpressionScript_-_Presentation
|
|
222
|
+
- https://www.limesurvey.org/manual/Expression_Manager
|
|
223
|
+
- https://www.limesurvey.org/manual/ExpressionScript_for_developers
|
|
224
|
+
- https://www.limesurvey.org/manual/Expression_Manager#Access_to_Variables
|
|
225
|
+
- https://www.limesurvey.org/manual/ExpressionScript_-_Presentation
|
|
226
|
+
- https://www.limesurvey.org/blog/tutorials/creating-limesurvey-questionnaires-in-micorsoft-excel
|
|
227
|
+
- https://www.limesurvey.org/manual/Tab_Separated_Value_survey_structure
|
|
228
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { deepMerge } from '../utils/helpers.js';
|
|
2
|
+
import { defaultConfig } from './types.js';
|
|
3
|
+
export class ConfigManager {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.config = this.mergeConfig(config || {});
|
|
6
|
+
}
|
|
7
|
+
mergeConfig(partialConfig) {
|
|
8
|
+
return deepMerge(structuredClone(defaultConfig), partialConfig);
|
|
9
|
+
}
|
|
10
|
+
getConfig() {
|
|
11
|
+
return this.config;
|
|
12
|
+
}
|
|
13
|
+
getDefaults() {
|
|
14
|
+
return this.config.defaults;
|
|
15
|
+
}
|
|
16
|
+
getAdvancedOptions() {
|
|
17
|
+
return {
|
|
18
|
+
autoCreateGroups: true, // Always auto-create groups (hardcoded)
|
|
19
|
+
handleRepeats: this.config.handleRepeats ?? 'warn',
|
|
20
|
+
debugLogging: this.config.debugLogging ?? false
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Update configuration at runtime
|
|
25
|
+
*/
|
|
26
|
+
updateConfig(partialConfig) {
|
|
27
|
+
this.config = this.mergeConfig(partialConfig);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Validate configuration
|
|
31
|
+
*/
|
|
32
|
+
validateConfig() {
|
|
33
|
+
const { defaults } = this.config;
|
|
34
|
+
// Validate handleRepeats if provided
|
|
35
|
+
if (this.config.handleRepeats && !['warn', 'error', 'ignore'].includes(this.config.handleRepeats)) {
|
|
36
|
+
throw new Error(`Invalid handleRepeats option: ${this.config.handleRepeats}`);
|
|
37
|
+
}
|
|
38
|
+
// Validate defaults
|
|
39
|
+
if (!defaults.language || defaults.language.length !== 2) {
|
|
40
|
+
throw new Error('defaults.language must be a 2-character language code');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default configuration with sensible defaults
|
|
3
|
+
*/
|
|
4
|
+
export const defaultConfig = {
|
|
5
|
+
handleRepeats: 'warn',
|
|
6
|
+
debugLogging: false,
|
|
7
|
+
defaults: {
|
|
8
|
+
language: 'en',
|
|
9
|
+
groupName: 'Questions',
|
|
10
|
+
surveyTitle: 'Untitled Survey',
|
|
11
|
+
description: ''
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XPath to LimeSurvey Expression Transpiler
|
|
3
|
+
*
|
|
4
|
+
* This module provides functions to transpile XPath expressions from XLSForm
|
|
5
|
+
* to LimeSurvey Expression Manager syntax using AST-based transformation.
|
|
6
|
+
*
|
|
7
|
+
* The transpiler uses js-xpath library to parse XPath expressions into AST,
|
|
8
|
+
* then recursively transforms the AST nodes to LimeSurvey-compatible syntax.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Sanitize field names by removing underscores and hyphens to match LimeSurvey's naming conventions
|
|
12
|
+
*/
|
|
13
|
+
function sanitizeName(name) {
|
|
14
|
+
return name.replace(/[_-]/g, '');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Transpiles jsxpath AST nodes to LimeSurvey expression syntax
|
|
18
|
+
*
|
|
19
|
+
* This function takes the Abstract Syntax Tree (AST) nodes produced by the jsxpath library
|
|
20
|
+
* and converts them to LimeSurvey-compatible expression syntax. The jsxpath library
|
|
21
|
+
* returns different node structures depending on the type of XPath expression:
|
|
22
|
+
*
|
|
23
|
+
* - Function calls: Objects with 'id' property (e.g., count(), concat(), regex())
|
|
24
|
+
* - Binary operations: Objects with 'type' property (e.g., <=, >=, =, and, or)
|
|
25
|
+
* - Variable references: Objects with 'steps' arrays containing axis/name info
|
|
26
|
+
* - Literal values: Objects with 'value' property containing the actual value
|
|
27
|
+
*
|
|
28
|
+
* The function recursively processes the AST, handling each node type appropriately
|
|
29
|
+
* and converting XPath syntax to LimeSurvey Expression Manager syntax.
|
|
30
|
+
*
|
|
31
|
+
* @param node - The AST node from jsxpath.parse()
|
|
32
|
+
* @returns The transpiled LimeSurvey expression string
|
|
33
|
+
* @throws Error if an unsupported node structure is encountered
|
|
34
|
+
*/
|
|
35
|
+
function transpile(node) {
|
|
36
|
+
if (!node)
|
|
37
|
+
return '';
|
|
38
|
+
// https://getodk.github.io/xforms-spec/#xpath-functions
|
|
39
|
+
// to https://www.limesurvey.org/manual/ExpressionScript_-_Presentation (see implemented functions)
|
|
40
|
+
if (node.id) {
|
|
41
|
+
switch (node.id) {
|
|
42
|
+
case 'count':
|
|
43
|
+
return `count(${node.args?.map(arg => transpile(arg)).join(', ') || ''})`;
|
|
44
|
+
case 'concat':
|
|
45
|
+
return node.args?.map(arg => transpile(arg)).join(' + ') || '';
|
|
46
|
+
case 'regex':
|
|
47
|
+
return `regexMatch(${node.args?.map(arg => transpile(arg)).join(', ') || ''})`;
|
|
48
|
+
case 'contains':
|
|
49
|
+
// Custom handling for contains
|
|
50
|
+
if (node.args?.length === 2) {
|
|
51
|
+
return `contains(${transpile(node.args[0])}, ${transpile(node.args[1])})`;
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
case 'selected':
|
|
55
|
+
// Handle selected(${field}, 'value') -> (field=="value")
|
|
56
|
+
if (node.args?.length === 2) {
|
|
57
|
+
const fieldArg = node.args[0];
|
|
58
|
+
const valueArg = node.args[1];
|
|
59
|
+
const fieldName = transpile(fieldArg);
|
|
60
|
+
let value = transpile(valueArg);
|
|
61
|
+
// Remove any existing quotes and use double quotes
|
|
62
|
+
value = value.replace(/^['"]|['"]$/g, "");
|
|
63
|
+
return `(${sanitizeName(fieldName)}=="${value}")`;
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
case 'string':
|
|
67
|
+
// string() function - just return the argument
|
|
68
|
+
if (node.args?.length === 1) {
|
|
69
|
+
return transpile(node.args[0]);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
case 'number':
|
|
73
|
+
// number() function - just return the argument
|
|
74
|
+
if (node.args?.length === 1) {
|
|
75
|
+
return transpile(node.args[0]);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
case 'floor':
|
|
79
|
+
if (node.args?.length === 1) {
|
|
80
|
+
return `floor(${transpile(node.args[0])})`;
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
case 'ceiling':
|
|
84
|
+
if (node.args?.length === 1) {
|
|
85
|
+
return `ceil(${transpile(node.args[0])})`;
|
|
86
|
+
}
|
|
87
|
+
break;
|
|
88
|
+
case 'round':
|
|
89
|
+
if (node.args?.length === 1) {
|
|
90
|
+
return `round(${transpile(node.args[0])})`;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
case 'sum':
|
|
94
|
+
if (node.args?.length === 1) {
|
|
95
|
+
return `sum(${transpile(node.args[0])})`;
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
case 'substring':
|
|
99
|
+
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]) : '';
|
|
103
|
+
return `substr(${stringArg}, ${startArg}${lengthArg ? ', ' + lengthArg : ''})`;
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
case 'string-length':
|
|
107
|
+
if (node.args?.length === 1) {
|
|
108
|
+
return `strlen(${transpile(node.args[0])})`;
|
|
109
|
+
}
|
|
110
|
+
break;
|
|
111
|
+
case 'starts-with':
|
|
112
|
+
if (node.args?.length === 2) {
|
|
113
|
+
return `startsWith(${transpile(node.args[0])}, ${transpile(node.args[1])})`;
|
|
114
|
+
}
|
|
115
|
+
break;
|
|
116
|
+
case 'ends-with':
|
|
117
|
+
if (node.args?.length === 2) {
|
|
118
|
+
return `endsWith(${transpile(node.args[0])}, ${transpile(node.args[1])})`;
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
case 'not':
|
|
122
|
+
if (node.args?.length === 1) {
|
|
123
|
+
return `!(${transpile(node.args[0])})`;
|
|
124
|
+
}
|
|
125
|
+
break;
|
|
126
|
+
case 'if':
|
|
127
|
+
if (node.args?.length === 3) {
|
|
128
|
+
return `(${transpile(node.args[0])} ? ${transpile(node.args[1])} : ${transpile(node.args[2])})`;
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
case 'today':
|
|
132
|
+
return 'today()';
|
|
133
|
+
case 'now':
|
|
134
|
+
return 'now()';
|
|
135
|
+
default:
|
|
136
|
+
throw new Error(`Unsupported function: ${node.id}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// https://getodk.github.io/xforms-spec/#xpath-operators
|
|
140
|
+
// to https://www.limesurvey.org/manual/ExpressionScript_-_Presentation (see syntax)
|
|
141
|
+
if (node.type) {
|
|
142
|
+
switch (node.type) {
|
|
143
|
+
// Comparison operators
|
|
144
|
+
case '<=':
|
|
145
|
+
return `${transpile(node.left)} <= ${transpile(node.right)}`;
|
|
146
|
+
case '>=':
|
|
147
|
+
return `${transpile(node.left)} >= ${transpile(node.right)}`;
|
|
148
|
+
case '=':
|
|
149
|
+
case '==':
|
|
150
|
+
return `${transpile(node.left)} == ${transpile(node.right)}`;
|
|
151
|
+
case '!=':
|
|
152
|
+
return `${transpile(node.left)} != ${transpile(node.right)}`;
|
|
153
|
+
case '<':
|
|
154
|
+
return `${transpile(node.left)} < ${transpile(node.right)}`;
|
|
155
|
+
case '>':
|
|
156
|
+
return `${transpile(node.left)} > ${transpile(node.right)}`;
|
|
157
|
+
// Arithmetic operators
|
|
158
|
+
case '+':
|
|
159
|
+
return `${transpile(node.left)} + ${transpile(node.right)}`;
|
|
160
|
+
case '-':
|
|
161
|
+
return `${transpile(node.left)} - ${transpile(node.right)}`;
|
|
162
|
+
case '*':
|
|
163
|
+
return `${transpile(node.left)} * ${transpile(node.right)}`;
|
|
164
|
+
case 'div':
|
|
165
|
+
return `${transpile(node.left)} / ${transpile(node.right)}`;
|
|
166
|
+
case 'mod':
|
|
167
|
+
return `${transpile(node.left)} % ${transpile(node.right)}`;
|
|
168
|
+
// Logical operators
|
|
169
|
+
case 'and':
|
|
170
|
+
return `${transpile(node.left)} and ${transpile(node.right)}`;
|
|
171
|
+
case 'or':
|
|
172
|
+
return `${transpile(node.left)} or ${transpile(node.right)}`;
|
|
173
|
+
// Unsupported operators
|
|
174
|
+
case '|':
|
|
175
|
+
case '/':
|
|
176
|
+
case '//':
|
|
177
|
+
case '[]':
|
|
178
|
+
case '..':
|
|
179
|
+
case '@':
|
|
180
|
+
case '::':
|
|
181
|
+
case ',':
|
|
182
|
+
throw new Error(`Unsupported XPath operator: ${node.type}`);
|
|
183
|
+
default:
|
|
184
|
+
throw new Error(`Unsupported operator: ${node.type}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Handle variable references (jsxpath returns step objects)
|
|
188
|
+
if (node.steps && node.steps.length > 0) {
|
|
189
|
+
const step = node.steps[0];
|
|
190
|
+
if (step.name) {
|
|
191
|
+
return sanitizeName(step.name);
|
|
192
|
+
}
|
|
193
|
+
// Handle self reference (.)
|
|
194
|
+
if (step.axis === 'self') {
|
|
195
|
+
return 'self';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Handle literal values
|
|
199
|
+
if (node.value !== undefined) {
|
|
200
|
+
// Handle numeric literals
|
|
201
|
+
if (typeof node.value === 'object' && node.value._ !== undefined) {
|
|
202
|
+
return node.value._;
|
|
203
|
+
}
|
|
204
|
+
// Handle string literals
|
|
205
|
+
if (typeof node.value === 'string') {
|
|
206
|
+
// Check if this is a string literal with quotes (valueDisplay contains the quoted version)
|
|
207
|
+
if (node.valueDisplay) {
|
|
208
|
+
return node.valueDisplay;
|
|
209
|
+
}
|
|
210
|
+
// For plain string values without quotes, return as-is
|
|
211
|
+
return node.value;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
throw new Error(`Unsupported node structure: ${JSON.stringify(node)}`);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Convert XPath expression to LimeSurvey Expression Manager syntax
|
|
218
|
+
*
|
|
219
|
+
* @param xpathExpr - The XPath expression to convert
|
|
220
|
+
* @returns LimeSurvey Expression Manager syntax, or null if conversion fails
|
|
221
|
+
*/
|
|
222
|
+
export async function xpathToLimeSurvey(xpathExpr) {
|
|
223
|
+
if (!xpathExpr || xpathExpr.trim() === '') {
|
|
224
|
+
return '1'; // Default relevance expression
|
|
225
|
+
}
|
|
226
|
+
// Preprocess XLSForm template syntax to standard XPath
|
|
227
|
+
let processedExpr = xpathExpr;
|
|
228
|
+
// Convert ${field} to field references (supports hyphens and other chars in names)
|
|
229
|
+
processedExpr = processedExpr.replace(/\$\{([^}]+)\}/g, (match, fieldName) => {
|
|
230
|
+
return sanitizeName(fieldName);
|
|
231
|
+
});
|
|
232
|
+
// Convert selected(${field}, 'value') to selected(field, 'value')
|
|
233
|
+
processedExpr = processedExpr.replace(/selected\(\s*\$\{([^}]+)\}\s*,\s*['"]([^'"]+)['"]\s*\)/g, (match, fieldName, value) => {
|
|
234
|
+
return `selected(${sanitizeName(fieldName)}, '${value}')`;
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
// Import js-xpath using dynamic import with CommonJS interop
|
|
238
|
+
const jxpathModule = await import('js-xpath');
|
|
239
|
+
const jxpath = jxpathModule.default || jxpathModule;
|
|
240
|
+
if (!jxpath || !jxpath.parse) {
|
|
241
|
+
throw new Error('js-xpath module does not export parse function');
|
|
242
|
+
}
|
|
243
|
+
const parsed = jxpath.parse(processedExpr);
|
|
244
|
+
return transpile(parsed);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
console.error(`Transpilation error: ${error.message}`);
|
|
248
|
+
return '1';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Convert XPath constraint to LimeSurvey validation pattern
|
|
253
|
+
*
|
|
254
|
+
* @param constraint - The XPath constraint expression
|
|
255
|
+
* @returns Validation pattern (regex or EM equation)
|
|
256
|
+
*/
|
|
257
|
+
export async function convertConstraint(constraint) {
|
|
258
|
+
if (!constraint)
|
|
259
|
+
return '';
|
|
260
|
+
try {
|
|
261
|
+
// Preprocess the expression to handle field references and special cases
|
|
262
|
+
let processedExpr = constraint;
|
|
263
|
+
// Convert ${field} to field references
|
|
264
|
+
processedExpr = processedExpr.replace(/\$\{(\w+)\}/g, (match, fieldName) => {
|
|
265
|
+
return sanitizeName(fieldName);
|
|
266
|
+
});
|
|
267
|
+
// Convert selected(${field}, 'value') to selected(field, 'value')
|
|
268
|
+
processedExpr = processedExpr.replace(/selected\(\s*\$\{(\w+)\}\s*,\s*['"]([^'"]+)['"]\s*\)/g, (match, fieldName, value) => {
|
|
269
|
+
return `selected(${sanitizeName(fieldName)}, '${value}')`;
|
|
270
|
+
});
|
|
271
|
+
// Special handling for regexMatch function
|
|
272
|
+
const regexMatchPattern = /regexMatch\(\s*([^)]+)\s*\)/;
|
|
273
|
+
const regexMatchMatch = processedExpr.match(regexMatchPattern);
|
|
274
|
+
if (regexMatchMatch) {
|
|
275
|
+
// Parse the regexMatch arguments
|
|
276
|
+
const args = parseRegexMatchArguments(regexMatchMatch[1]);
|
|
277
|
+
if (args.length >= 2) {
|
|
278
|
+
const [firstArg, secondArg] = args;
|
|
279
|
+
// Check if the first argument looks like a logical expression (contains operators)
|
|
280
|
+
const logicalOperators = ['>=', '<=', '>', '<', '=', '!=', 'and', 'or'];
|
|
281
|
+
const isLogicalExpression = logicalOperators.some(op => firstArg.includes(op) &&
|
|
282
|
+
// Make sure it's not part of a regex pattern (e.g., [0-9])
|
|
283
|
+
!(firstArg.includes('[') && firstArg.includes(']')));
|
|
284
|
+
if (isLogicalExpression) {
|
|
285
|
+
// Extract the logical expression and return it
|
|
286
|
+
return firstArg.replace(/^"|"$/g, ''); // Remove quotes
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// Check if this is a valid regexMatch call (pattern first, field second)
|
|
290
|
+
// If the second argument looks like a field reference and first like a pattern, handle it
|
|
291
|
+
const isFieldReference = secondArg === '.' || /^\w+$/.test(secondArg);
|
|
292
|
+
const looksLikePattern = firstArg.includes('^') || firstArg.includes('$') ||
|
|
293
|
+
(firstArg.includes('[') && firstArg.includes(']'));
|
|
294
|
+
if (isFieldReference && looksLikePattern) {
|
|
295
|
+
// Handle as a real regexMatch function
|
|
296
|
+
// Convert . to self in the field argument
|
|
297
|
+
const processedFieldArg = secondArg.replace(/\./g, 'self');
|
|
298
|
+
// Convert double quotes to single quotes for consistency
|
|
299
|
+
const processedPatternArg = firstArg.replace(/^"|"$/g, "'")
|
|
300
|
+
.replace(/\\'/g, "'"); // Handle escaped quotes
|
|
301
|
+
return `regexMatch(${processedPatternArg}, ${processedFieldArg})`;
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// Invalid argument order or unsupported pattern
|
|
305
|
+
return '';
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Parse and transpile using AST
|
|
311
|
+
const jxpathModule = await import('js-xpath');
|
|
312
|
+
const jxpath = jxpathModule.default || jxpathModule;
|
|
313
|
+
if (!jxpath || !jxpath.parse) {
|
|
314
|
+
throw new Error('js-xpath module does not export parse function');
|
|
315
|
+
}
|
|
316
|
+
const parsed = jxpath.parse(processedExpr);
|
|
317
|
+
if (!parsed) {
|
|
318
|
+
// If parsing fails but doesn't throw, we handle it explicitly.
|
|
319
|
+
throw new Error(`jxpath.parse returned null/undefined for constraint: "${processedExpr}"`);
|
|
320
|
+
}
|
|
321
|
+
const converted = transpile(parsed);
|
|
322
|
+
return converted;
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
console.error(`Constraint conversion error: ${error.message}`);
|
|
326
|
+
return '';
|
|
327
|
+
}
|
|
328
|
+
// If all else fails, return empty
|
|
329
|
+
return '';
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Parse arguments from a regexMatch function call
|
|
333
|
+
* Handles quoted strings and field references
|
|
334
|
+
*/
|
|
335
|
+
function parseRegexMatchArguments(argsString) {
|
|
336
|
+
const args = [];
|
|
337
|
+
let currentArg = '';
|
|
338
|
+
let inQuotes = false;
|
|
339
|
+
let quoteChar = '';
|
|
340
|
+
let parenDepth = 0;
|
|
341
|
+
for (let i = 0; i < argsString.length; i++) {
|
|
342
|
+
const char = argsString[i];
|
|
343
|
+
if ((char === '"' || char === "'") && (i === 0 || argsString[i - 1] !== '\\')) {
|
|
344
|
+
if (!inQuotes) {
|
|
345
|
+
// Start of quoted string
|
|
346
|
+
inQuotes = true;
|
|
347
|
+
quoteChar = char;
|
|
348
|
+
currentArg += char;
|
|
349
|
+
}
|
|
350
|
+
else if (char === quoteChar) {
|
|
351
|
+
// End of quoted string
|
|
352
|
+
inQuotes = false;
|
|
353
|
+
currentArg += char;
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
currentArg += char;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else if (char === '(' && !inQuotes) {
|
|
360
|
+
parenDepth++;
|
|
361
|
+
currentArg += char;
|
|
362
|
+
}
|
|
363
|
+
else if (char === ')' && !inQuotes) {
|
|
364
|
+
parenDepth--;
|
|
365
|
+
currentArg += char;
|
|
366
|
+
}
|
|
367
|
+
else if (char === ',' && !inQuotes && parenDepth === 0) {
|
|
368
|
+
// Argument separator
|
|
369
|
+
args.push(currentArg.trim());
|
|
370
|
+
currentArg = '';
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
currentArg += char;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Add the last argument
|
|
377
|
+
if (currentArg.trim()) {
|
|
378
|
+
args.push(currentArg.trim());
|
|
379
|
+
}
|
|
380
|
+
return args;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Convert XPath relevance expression to LimeSurvey Expression Manager syntax
|
|
384
|
+
*
|
|
385
|
+
* @param xpath - The XPath relevance expression
|
|
386
|
+
* @returns LimeSurvey Expression Manager syntax
|
|
387
|
+
*/
|
|
388
|
+
export async function convertRelevance(xpathExpr) {
|
|
389
|
+
if (!xpathExpr)
|
|
390
|
+
return '1';
|
|
391
|
+
// Preprocess: normalize operators to lowercase for jsxpath compatibility
|
|
392
|
+
let normalizedXPath = xpathExpr
|
|
393
|
+
.replace(/\bAND\b/gi, 'and')
|
|
394
|
+
.replace(/\bOR\b/gi, 'or');
|
|
395
|
+
const result = await xpathToLimeSurvey(normalizedXPath);
|
|
396
|
+
// Handle edge case: selected() with just {field} (without $)
|
|
397
|
+
if (result && result.includes('selected(')) {
|
|
398
|
+
return result.replace(/selected\s*\(\s*\{(\w+)\}\s*,\s*["']([^'"]+)["']\s*\)/g, (_match, fieldName, value) => {
|
|
399
|
+
return `(${sanitizeName(fieldName)}="${value}")`;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
return result || '1';
|
|
403
|
+
}
|