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 ADDED
@@ -0,0 +1,228 @@
1
+ [![npm version](https://img.shields.io/npm/v/xlsform2lstsv)](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
+ }