xlsform2lstsv 0.2.3 → 0.4.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.
@@ -3,7 +3,9 @@ import { convertRelevance, convertConstraint, xpathToLimeSurvey } from './conver
3
3
  import { FieldSanitizer } from './processors/FieldSanitizer.js';
4
4
  import { TSVGenerator } from './processors/TSVGenerator.js';
5
5
  import { TypeMapper } from './processors/TypeMapper.js';
6
+ import { deduplicateNames } from './utils/helpers.js';
6
7
  import { getBaseLanguage } from './utils/languageUtils.js';
8
+ import { markdownToHtml } from './utils/markdownRenderer.js';
7
9
  // Metadata types that should be silently skipped (no visual representation)
8
10
  const SKIP_TYPES = [
9
11
  'start', 'end', 'today', 'deviceid', 'username',
@@ -22,14 +24,18 @@ const UNIMPLEMENTED_TYPES = [
22
24
  'begin_repeat',
23
25
  'end_repeat'
24
26
  ];
25
- // Appearances handled without warning: label, list-nolabel (matrix), multiline (→T), likert (no-op), field-list (no-op with format=A)
27
+ // Appearances handled without warning: label, list-nolabel (matrix), multiline (→T), minimal (→!), likert (no-op), field-list (no-op: groups already map to LS groups; format=G used when style=pages)
26
28
  const UNSUPPORTED_APPEARANCES = [
27
- 'minimal', 'quick', 'no-calendar', 'month-year', 'year',
29
+ 'quick', 'no-calendar', 'month-year', 'year',
28
30
  'horizontal-compact', 'horizontal', 'compact', 'quickcompact',
29
31
  'table-list', 'signature', 'draw', 'map', 'quick map',
30
32
  ];
31
33
  export class XLSFormToTSVConverter {
32
34
  constructor(config) {
35
+ this.welcomeNote = null;
36
+ this.endNote = null;
37
+ this.messageOnlyGroups = new Set();
38
+ this.surveyDataCache = [];
33
39
  this.configManager = new ConfigManager(config);
34
40
  this.configManager.validateConfig();
35
41
  this.fieldSanitizer = new FieldSanitizer();
@@ -53,6 +59,46 @@ export class XLSFormToTSVConverter {
53
59
  this.questionToListMap = new Map();
54
60
  this.questionBaseTypeMap = new Map();
55
61
  }
62
+ // ── Row helpers ──────────────────────────────────────────────────────
63
+ /**
64
+ * Build a TSVRowData with sensible defaults. Only `class` and `name` are required;
65
+ * all other fields default to empty strings (relevance defaults to '1').
66
+ */
67
+ row(fields) {
68
+ return {
69
+ 'type/scale': '',
70
+ relevance: '1',
71
+ text: '',
72
+ help: '',
73
+ language: this.baseLanguage,
74
+ validation: '',
75
+ em_validation_q: '',
76
+ mandatory: '',
77
+ other: '',
78
+ default: '',
79
+ same_default: '',
80
+ ...fields,
81
+ };
82
+ }
83
+ /**
84
+ * Emit a buffered row for each available language. The callback receives the
85
+ * language code and returns the language-varying fields; common fields like
86
+ * `class` and `name` should be included in the callback return.
87
+ */
88
+ emitForEachLanguage(buildRow, target) {
89
+ if (!target)
90
+ target = 'buffer';
91
+ for (const lang of this.availableLanguages) {
92
+ const row = this.row({ language: lang, ...buildRow(lang) });
93
+ if (target === 'direct') {
94
+ this.tsvGenerator.addRow(row);
95
+ }
96
+ else {
97
+ this.bufferRow(row);
98
+ }
99
+ }
100
+ }
101
+ // ── Public API ───────────────────────────────────────────────────────
56
102
  /**
57
103
  * Get the current configuration
58
104
  */
@@ -79,14 +125,32 @@ export class XLSFormToTSVConverter {
79
125
  this.inMatrix = false;
80
126
  this.matrixListName = null;
81
127
  this.groupContentBuffer = [];
128
+ this.welcomeNote = null;
129
+ this.endNote = null;
130
+ // Pre-scan for welcome/end notes (must happen before group identification)
131
+ const config = this.configManager.getConfig();
132
+ for (const row of surveyData) {
133
+ const type = (row.type || '').trim();
134
+ const name = (row.name || '').trim().toLowerCase();
135
+ if (config.convertWelcomeNote && type === 'note' && name === 'welcome')
136
+ this.welcomeNote = row;
137
+ if (config.convertEndNote && type === 'note' && name === 'end')
138
+ this.endNote = row;
139
+ }
82
140
  // Pre-scan to identify parent-only groups (no direct questions, only child groups)
83
141
  this.parentOnlyGroups = this.identifyParentOnlyGroups(surveyData);
142
+ // Pre-scan to identify groups whose only content is a welcome/end note
143
+ this.messageOnlyGroups = this.identifyMessageOnlyGroups(surveyData);
144
+ // Cache survey data for pattern detection
145
+ this.surveyDataCache = surveyData;
84
146
  // Set base language from settings first
85
147
  this.baseLanguage = getBaseLanguage(settingsData[0] || {});
86
148
  // Detect available languages from survey data (will use baseLanguage for ordering)
87
149
  this.detectAvailableLanguages(surveyData, choicesData, settingsData);
88
150
  // Build choices map
89
151
  this.buildChoicesMap(choicesData);
152
+ // Pre-scan: register all field names to detect and resolve collisions
153
+ this.registerFieldNames(surveyData);
90
154
  // Build answer code and question-to-list maps for relevance rewriting
91
155
  this.buildAnswerCodeMap();
92
156
  this.buildQuestionToListMap(surveyData);
@@ -113,6 +177,55 @@ export class XLSFormToTSVConverter {
113
177
  // Generate TSV
114
178
  return this.tsvGenerator.generateTSV();
115
179
  }
180
+ // ── Other question pattern detection ─────────────────────────────────
181
+ /**
182
+ * Check if a question has a corresponding "_other" question with relevance targeting its "other" option.
183
+ * Returns true if pattern is found, and also removes the "other" choice from the choices list if present.
184
+ */
185
+ hasOtherQuestionPattern(currentRow, surveyData) {
186
+ const currentName = currentRow.name?.trim();
187
+ if (!currentName)
188
+ return false;
189
+ const otherQuestionName = `${currentName}_other`;
190
+ const sanitizedCurrentName = this.sanitizeName(currentName);
191
+ for (const row of surveyData) {
192
+ if (row.name?.trim() !== otherQuestionName || !row.relevant)
193
+ continue;
194
+ const relevance = row.relevant.trim();
195
+ // Pattern: ${question_name} = 'other' or ${question_name} == 'other'
196
+ const patterns = [currentName, sanitizedCurrentName].flatMap(n => [
197
+ new RegExp(`\\$\\{${n}\\}.*=.*['"]other['"]`),
198
+ new RegExp(`\\$\\{${n}\\}.*==.*['"]other['"]`),
199
+ ]);
200
+ if (patterns.some(p => p.test(relevance))) {
201
+ const xfTypeInfo = this.parseType(currentRow.type || '');
202
+ this.removeOtherChoiceFromList(currentRow, xfTypeInfo);
203
+ return true;
204
+ }
205
+ }
206
+ return false;
207
+ }
208
+ /**
209
+ * Remove the "other" choice from the choices list for a question.
210
+ * Prevents duplicate "other" options when using the _other question pattern.
211
+ */
212
+ removeOtherChoiceFromList(row, typeInfo) {
213
+ if (!typeInfo.listName)
214
+ return;
215
+ const choices = this.choicesMap.get(typeInfo.listName);
216
+ if (!choices)
217
+ return;
218
+ const otherNames = new Set(['other', '_other', 'other_option', 'other_choice']);
219
+ const filteredChoices = choices.filter(choice => {
220
+ const choiceName = choice.name?.trim().toLowerCase() || '';
221
+ return !otherNames.has(choiceName);
222
+ });
223
+ if (filteredChoices.length < choices.length) {
224
+ console.log(`Removed "other" choice(s) from list "${typeInfo.listName}" for question "${row.name}" when using _other question pattern`);
225
+ this.choicesMap.set(typeInfo.listName, filteredChoices);
226
+ }
227
+ }
228
+ // ── Language detection ────────────────────────────────────────────────
116
229
  detectAvailableLanguages(surveyData, choicesData, settingsData) {
117
230
  const languageCodes = new Set();
118
231
  // Check survey data for language codes
@@ -120,24 +233,36 @@ export class XLSFormToTSVConverter {
120
233
  if (row._languages) {
121
234
  row._languages.forEach((lang) => languageCodes.add(lang));
122
235
  }
236
+ else {
237
+ for (const field of [row.label, row.hint]) {
238
+ if (typeof field === 'object' && field !== null) {
239
+ for (const lang of Object.keys(field))
240
+ languageCodes.add(lang);
241
+ }
242
+ }
243
+ }
123
244
  }
124
245
  // Check choices data for language codes
125
246
  for (const row of choicesData) {
126
247
  if (row._languages) {
127
248
  row._languages.forEach((lang) => languageCodes.add(lang));
128
249
  }
250
+ else {
251
+ if (typeof row.label === 'object' && row.label !== null) {
252
+ for (const lang of Object.keys(row.label))
253
+ languageCodes.add(lang);
254
+ }
255
+ }
129
256
  }
130
257
  // Check settings data for language-specific fields
131
258
  const settings = settingsData[0] || {};
132
259
  for (const [, value] of Object.entries(settings)) {
133
260
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
134
- // This looks like a language-specific field (e.g., {en: '...', es: '...'})
135
261
  for (const lang of Object.keys(value)) {
136
262
  languageCodes.add(lang);
137
263
  }
138
264
  }
139
265
  }
140
- // If no language-specific columns detected, use single language mode
141
266
  // Only use multiple languages if we actually have language-specific data
142
267
  const hasLanguageSpecificData = surveyData.some(row => row._languages ||
143
268
  (typeof row.label === 'object' && row.label !== null) ||
@@ -145,7 +270,6 @@ export class XLSFormToTSVConverter {
145
270
  (typeof row.label === 'object' && row.label !== null)) || Object.values(settings).some(value => typeof value === 'object' && value !== null && !Array.isArray(value));
146
271
  if (hasLanguageSpecificData && languageCodes.size > 0) {
147
272
  const languagesArray = Array.from(languageCodes);
148
- // Ensure default language comes first, then sort the rest alphabetically
149
273
  const defaultLang = this.baseLanguage;
150
274
  this.availableLanguages = [
151
275
  defaultLang,
@@ -156,6 +280,7 @@ export class XLSFormToTSVConverter {
156
280
  this.availableLanguages = ['en'];
157
281
  }
158
282
  }
283
+ // ── Map building ─────────────────────────────────────────────────────
159
284
  buildChoicesMap(choices) {
160
285
  for (const choice of choices) {
161
286
  const listName = choice.list_name;
@@ -171,30 +296,15 @@ export class XLSFormToTSVConverter {
171
296
  this.answerCodeMap = new Map();
172
297
  for (const [listName, choices] of this.choicesMap) {
173
298
  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]);
299
+ const sanitized = choices.map(c => {
300
+ const raw = c.name?.trim() || '';
301
+ return raw ? this.sanitizeAnswerCode(raw) : '';
302
+ });
303
+ const deduplicated = deduplicateNames(sanitized, 5);
304
+ for (let i = 0; i < choices.length; i++) {
195
305
  const originalName = choices[i].name?.trim() || '';
196
- if (originalName) {
197
- codeMap.set(originalName, sanitized[i]);
306
+ if (originalName && deduplicated[i]) {
307
+ codeMap.set(originalName, deduplicated[i]);
198
308
  }
199
309
  }
200
310
  this.answerCodeMap.set(listName, codeMap);
@@ -212,153 +322,54 @@ export class XLSFormToTSVConverter {
212
322
  }
213
323
  }
214
324
  }
215
- addDefaultGroup() {
216
- // Add a default group for surveys without explicit groups
217
- const defaults = this.configManager.getDefaults();
218
- const groupName = defaults.groupName;
219
- this.currentGroup = groupName;
220
- this.tsvGenerator.addRow({
221
- class: 'G',
222
- 'type/scale': '',
223
- name: groupName,
224
- relevance: '1',
225
- text: groupName,
226
- help: '',
227
- language: defaults.language,
228
- validation: '',
229
- em_validation_q: '',
230
- mandatory: '',
231
- other: '',
232
- default: '',
233
- same_default: ''
234
- });
235
- this.groupSeq++;
236
- }
237
- addAutoGroupForOrphans() {
238
- const groupName = `G${this.groupSeq}`;
239
- this.groupSeq++;
240
- this.currentGroup = groupName;
241
- const groupSeqKey = String(this.groupSeq);
242
- for (const lang of this.availableLanguages) {
243
- this.tsvGenerator.addRow({
244
- class: 'G',
245
- 'type/scale': groupSeqKey,
246
- name: groupName,
247
- relevance: '1',
248
- text: groupName,
249
- help: '',
250
- language: lang,
251
- validation: '',
252
- em_validation_q: '',
253
- mandatory: '',
254
- other: '',
255
- default: '',
256
- same_default: ''
257
- });
258
- }
259
- }
260
- addSurveyRow(settings) {
261
- // Add survey-level settings as individual S rows
262
- // Each survey property gets its own row with name=property_name, text=value
263
- const defaults = this.configManager.getDefaults();
264
- const surveyTitle = settings.form_title || defaults.surveyTitle;
265
- // Add base language (class S - survey settings)
266
- this.tsvGenerator.addRow({
267
- class: 'S',
268
- 'type/scale': '',
269
- name: 'language',
270
- relevance: '1',
271
- text: this.baseLanguage,
272
- help: '',
273
- language: this.baseLanguage,
274
- validation: '',
275
- em_validation_q: '',
276
- mandatory: '',
277
- other: '',
278
- default: '',
279
- same_default: ''
280
- });
281
- // Add additional languages declaration (class S - survey settings)
282
- // This tells LimeSurvey which additional languages should be available
283
- if (this.availableLanguages.length > 1) {
284
- const additionalLanguages = this.availableLanguages.filter(lang => lang !== this.baseLanguage).join(' ');
285
- this.tsvGenerator.addRow({
286
- class: 'S',
287
- 'type/scale': '',
288
- name: 'additional_languages',
289
- relevance: '1',
290
- text: additionalLanguages,
291
- help: '',
292
- language: this.baseLanguage,
293
- validation: '',
294
- em_validation_q: '',
295
- mandatory: '',
296
- other: '',
297
- default: '',
298
- same_default: ''
299
- });
300
- }
301
- // Set survey format to "All in one" (all groups/questions on one page)
302
- this.tsvGenerator.addRow({
303
- class: 'S',
304
- 'type/scale': '',
305
- name: 'format',
306
- relevance: '1',
307
- text: 'A',
308
- help: '',
309
- language: this.baseLanguage,
310
- validation: '',
311
- em_validation_q: '',
312
- mandatory: '',
313
- other: '',
314
- default: '',
315
- same_default: ''
316
- });
317
- // First, add the default language row according to LimeSurvey spec
318
- // This should be the first SL row for the default language
319
- const defaultLanguage = this.baseLanguage;
320
- // Add default language row (class SL - survey language settings)
321
- this.tsvGenerator.addRow({
322
- class: 'SL',
323
- 'type/scale': '',
324
- name: 'surveyls_title',
325
- relevance: '1',
326
- text: this.getLanguageSpecificValue(settings.form_title, defaultLanguage) || surveyTitle,
327
- help: '',
328
- language: defaultLanguage,
329
- validation: '',
330
- em_validation_q: '',
331
- mandatory: '',
332
- other: '',
333
- default: '',
334
- same_default: ''
335
- });
336
- // Then add rows for all other available languages (after default language)
337
- const otherLanguages = this.availableLanguages.filter(lang => lang !== defaultLanguage);
338
- // Sort other languages alphabetically for consistency
339
- otherLanguages.sort();
340
- for (const lang of otherLanguages) {
341
- this.tsvGenerator.addRow({
342
- class: 'SL',
343
- 'type/scale': '',
344
- name: 'surveyls_title',
345
- relevance: '1',
346
- text: this.getLanguageSpecificValue(settings.form_title, lang) || surveyTitle,
347
- help: '',
348
- language: lang,
349
- validation: '',
350
- em_validation_q: "",
351
- mandatory: '',
352
- other: '',
353
- default: '',
354
- same_default: ''
355
- });
325
+ // ── Pre-scanning ─────────────────────────────────────────────────────
326
+ /**
327
+ * Pre-scan survey data to identify groups whose only direct content is a
328
+ * welcome or end note. These groups are silently suppressed.
329
+ */
330
+ identifyMessageOnlyGroups(surveyData) {
331
+ const messageOnly = new Set();
332
+ const stack = [];
333
+ const groupInfo = new Map();
334
+ for (const row of surveyData) {
335
+ const type = (row.type || '').trim();
336
+ const baseType = type.split(/\s+/)[0];
337
+ if (type === 'begin_group' || type === 'begin group') {
338
+ const name = (row.name || '').trim();
339
+ stack.push(name);
340
+ groupInfo.set(name, { hasMessageNote: false, hasOtherContent: false });
341
+ }
342
+ else if (type === 'end_group' || type === 'end group') {
343
+ const name = stack.pop();
344
+ if (name !== undefined) {
345
+ const info = groupInfo.get(name);
346
+ if (info && info.hasMessageNote && !info.hasOtherContent) {
347
+ messageOnly.add(name);
348
+ }
349
+ }
350
+ }
351
+ else if (type && stack.length > 0 && !SKIP_TYPES.includes(baseType)) {
352
+ const groupName = stack[stack.length - 1];
353
+ const info = groupInfo.get(groupName);
354
+ if (info) {
355
+ const rowName = (row.name || '').trim().toLowerCase();
356
+ const cfg = this.configManager.getConfig();
357
+ const isMessageNote = type === 'note' && ((cfg.convertWelcomeNote && rowName === 'welcome') ||
358
+ (cfg.convertEndNote && rowName === 'end'));
359
+ if (isMessageNote) {
360
+ info.hasMessageNote = true;
361
+ }
362
+ else {
363
+ info.hasOtherContent = true;
364
+ }
365
+ }
366
+ }
356
367
  }
368
+ return messageOnly;
357
369
  }
358
370
  /**
359
371
  * Pre-scan survey data to identify parent-only groups.
360
372
  * A parent-only group contains no direct questions — only child groups.
361
- * These will be flattened into note questions in the first child group.
362
373
  */
363
374
  identifyParentOnlyGroups(surveyData) {
364
375
  const parentOnly = new Set();
@@ -379,7 +390,6 @@ export class XLSFormToTSVConverter {
379
390
  }
380
391
  }
381
392
  else if (type && !SKIP_TYPES.includes(baseType)) {
382
- // Any non-skip, non-group row counts as direct content
383
393
  if (stack.length > 0) {
384
394
  hasDirectContent.set(stack[stack.length - 1], true);
385
395
  }
@@ -387,107 +397,50 @@ export class XLSFormToTSVConverter {
387
397
  }
388
398
  return parentOnly;
389
399
  }
390
- async processRow(row) {
391
- const xfType = (row.type || '').trim();
392
- if (!xfType)
393
- return;
394
- // Check for unimplemented types (extract base type first, before any spaces)
395
- const baseType = xfType.split(/\s+/)[0];
396
- // Silently skip metadata types
397
- if (SKIP_TYPES.includes(baseType)) {
398
- return;
399
- }
400
- // Other unimplemented types throw errors
401
- if (UNIMPLEMENTED_TYPES.includes(baseType)) {
402
- throw new Error(`Unimplemented XLSForm type: '${baseType}'. This type is not currently supported.`);
403
- }
404
- if (xfType === 'begin_group' || xfType === 'begin group') {
405
- this.flushMatrix();
406
- const originalName = (row.name || '').trim();
407
- const sanitizedName = originalName
408
- ? this.sanitizeName(originalName)
409
- : `G${this.groupSeq}`;
410
- if (this.parentOnlyGroups.has(originalName)) {
411
- // Parent-only group: save label as pending note, don't emit G row
412
- this.groupStack.push({ originalName, sanitizedName, emittedAsGroup: false });
413
- this.pendingGroupNotes.push(row);
414
- }
415
- else {
416
- // Regular group: flush buffered content from previous group, then emit G row
417
- this.groupStack.push({ originalName, sanitizedName, emittedAsGroup: true });
418
- this.flushGroupContent();
419
- await this.addGroup(row);
420
- // Emit pending parent-only group notes as note questions in this group
421
- await this.emitPendingGroupNotes();
422
- }
423
- return;
424
- }
425
- if (xfType === 'end_group' || xfType === 'end group') {
426
- this.flushMatrix();
427
- this.groupStack.pop();
428
- // Restore currentGroup to nearest ancestor that was emitted as a group
429
- this.currentGroup = null;
430
- for (let i = this.groupStack.length - 1; i >= 0; i--) {
431
- if (this.groupStack[i].emittedAsGroup) {
432
- this.currentGroup = this.groupStack[i].sanitizedName;
433
- break;
434
- }
435
- }
436
- return;
437
- }
438
- // Auto-create a group for questions outside any explicit group.
439
- // LimeSurvey requires every question to belong to a group.
440
- if (this.currentGroup === null && this.groupStack.length === 0) {
441
- this.flushGroupContent();
442
- this.addAutoGroupForOrphans();
400
+ // ── Field name handling ──────────────────────────────────────────────
401
+ /**
402
+ * Pre-scan all survey rows and register every field name with the sanitizer,
403
+ * so that collisions after sanitization + truncation are detected early.
404
+ */
405
+ registerFieldNames(surveyData) {
406
+ this.fieldSanitizer.resetNames();
407
+ for (const row of surveyData) {
408
+ const type = (row.type || '').trim();
409
+ if (type === 'end_group' || type === 'end_repeat')
410
+ continue;
411
+ const name = row.name?.trim();
412
+ if (!name)
413
+ continue;
414
+ this.fieldSanitizer.sanitizeNameUnique(name);
443
415
  }
444
- // Handle notes and questions
445
- await this.addQuestion(row);
416
+ }
417
+ sanitizeName(name) {
418
+ const stripped = name.replace(/[_-]/g, '');
419
+ return this.fieldSanitizer.resolveStrippedName(stripped);
420
+ }
421
+ sanitizeAnswerCode(code) {
422
+ return this.fieldSanitizer.sanitizeAnswerCode(code);
446
423
  }
447
424
  /**
448
- * Emit pending parent-only group labels as note questions (type X).
449
- * Called after a child group's G row is emitted.
425
+ * Convert ${varname} references in text to LimeSurvey EM syntax {sanitizedname}.
450
426
  */
451
- async emitPendingGroupNotes() {
452
- for (const noteRow of this.pendingGroupNotes) {
453
- const noteName = noteRow.name && noteRow.name.trim() !== ''
454
- ? this.sanitizeName(noteRow.name.trim())
455
- : `GN${this.questionSeq}`;
456
- this.questionSeq++;
457
- for (const lang of this.availableLanguages) {
458
- this.bufferRow({
459
- class: 'Q',
460
- 'type/scale': 'X',
461
- name: noteName,
462
- relevance: await this.convertRelevance(noteRow.relevant),
463
- text: this.getLanguageSpecificValue(noteRow.label, lang) || noteName,
464
- help: this.getLanguageSpecificValue(noteRow.hint, lang) || '',
465
- language: lang,
466
- validation: '',
467
- em_validation_q: '',
468
- mandatory: '',
469
- other: '',
470
- default: '',
471
- same_default: ''
472
- });
473
- }
474
- }
475
- this.pendingGroupNotes = [];
427
+ convertVariableReferences(text) {
428
+ return text.replace(/\$\{([^}]+)\}/g, (_, name) => {
429
+ const sanitized = this.sanitizeName(name);
430
+ return `{${sanitized}}`;
431
+ });
476
432
  }
433
+ // ── Label rendering ──────────────────────────────────────────────────
477
434
  getLanguageSpecificValue(value, languageCode) {
478
435
  if (!value)
479
436
  return undefined;
480
- // If it's already a string, return it
481
- if (typeof value === 'string') {
437
+ if (typeof value === 'string')
482
438
  return value;
483
- }
484
- // If it's an object with language codes, get the specific language
485
439
  if (typeof value === 'object' && value !== null) {
486
440
  const valueObj = value;
487
441
  if (languageCode in valueObj) {
488
442
  return valueObj[languageCode];
489
443
  }
490
- // If it doesn't have the specific language, try to get any available language
491
444
  for (const lang of this.availableLanguages) {
492
445
  if (lang in valueObj)
493
446
  return valueObj[lang];
@@ -495,34 +448,36 @@ export class XLSFormToTSVConverter {
495
448
  }
496
449
  return undefined;
497
450
  }
451
+ /**
452
+ * Resolve a multilingual label/hint value for the given language and optionally
453
+ * convert it from markdown to HTML. Falls back to `fallback` when no value is found.
454
+ */
455
+ renderLabel(value, lang, fallback = '') {
456
+ const raw = this.getLanguageSpecificValue(value, lang) || fallback;
457
+ return this.configManager.getConfig().convertMarkdown ? markdownToHtml(raw) : raw;
458
+ }
459
+ // ── Buffering / flushing ─────────────────────────────────────────────
498
460
  /**
499
461
  * Buffer a Q/SQ/A row for later language-grouped output.
500
462
  * LimeSurvey's TSV importer uses a question_order counter ($qseq) that gets
501
463
  * reset when it encounters a translation of a previously-seen question.
502
- * With interleaved languages (Q de, Q en, Q de, Q en), the counter resets
503
- * after each translation, giving all subsequent questions order=0.
504
- * By outputting all base-language rows first, the counter increments correctly,
505
- * and translation rows just look up their stored values.
464
+ * By outputting all base-language rows first, the counter increments correctly.
506
465
  */
507
466
  bufferRow(row) {
508
467
  this.groupContentBuffer.push(row);
509
468
  }
510
469
  /**
511
470
  * Flush buffered group content, outputting base language rows first,
512
- * then each additional language. This preserves insertion order within
513
- * each language while ensuring LimeSurvey's question_order counter
514
- * increments correctly.
471
+ * then each additional language.
515
472
  */
516
473
  flushGroupContent() {
517
474
  if (this.groupContentBuffer.length === 0)
518
475
  return;
519
- // Output base language rows first (preserving insertion order)
520
476
  for (const row of this.groupContentBuffer) {
521
477
  if (row.language === this.baseLanguage) {
522
478
  this.tsvGenerator.addRow(row);
523
479
  }
524
480
  }
525
- // Then output each additional language (preserving insertion order)
526
481
  for (const lang of this.availableLanguages) {
527
482
  if (lang === this.baseLanguage)
528
483
  continue;
@@ -534,58 +489,172 @@ export class XLSFormToTSVConverter {
534
489
  }
535
490
  this.groupContentBuffer = [];
536
491
  }
537
- sanitizeName(name) {
538
- return this.fieldSanitizer.sanitizeName(name);
539
- }
540
- sanitizeAnswerCode(code) {
541
- return this.fieldSanitizer.sanitizeAnswerCode(code);
492
+ // ── Survey settings (S/SL rows) ─────────────────────────────────────
493
+ addSurveyRow(settings) {
494
+ const defaults = this.configManager.getDefaults();
495
+ const surveyTitle = settings.form_title || defaults.surveyTitle;
496
+ // S rows: language, additional_languages, format
497
+ this.tsvGenerator.addRow(this.row({
498
+ class: 'S', name: 'language', text: this.baseLanguage,
499
+ }));
500
+ if (this.availableLanguages.length > 1) {
501
+ const additionalLanguages = this.availableLanguages.filter(lang => lang !== this.baseLanguage).join(' ');
502
+ this.tsvGenerator.addRow(this.row({
503
+ class: 'S', name: 'additional_languages', text: additionalLanguages,
504
+ }));
505
+ }
506
+ const surveyFormat = (settings.style || '').trim().toLowerCase() === 'pages' ? 'G' : 'A';
507
+ this.tsvGenerator.addRow(this.row({
508
+ class: 'S', name: 'format', text: surveyFormat,
509
+ }));
510
+ // SL rows: survey title + welcome/end text, base language first then others
511
+ const emitSLRows = (lang) => {
512
+ this.tsvGenerator.addRow(this.row({
513
+ class: 'SL', name: 'surveyls_title', language: lang,
514
+ text: this.renderLabel(settings.form_title, lang, surveyTitle),
515
+ }));
516
+ this.addSLMessageRows(lang);
517
+ };
518
+ emitSLRows(this.baseLanguage);
519
+ for (const lang of this.availableLanguages.filter(l => l !== this.baseLanguage).sort()) {
520
+ emitSLRows(lang);
521
+ }
542
522
  }
543
523
  /**
544
- * Convert ${varname} references in text to LimeSurvey EM syntax {sanitizedname}.
524
+ * Emit surveyls_welcometext and surveyls_endtext SL rows for a given language.
545
525
  */
546
- convertVariableReferences(text) {
547
- return text.replace(/\$\{([^}]+)\}/g, (_, name) => {
548
- const sanitized = name.replace(/[_-]/g, '');
549
- return `{${sanitized}}`;
550
- });
526
+ addSLMessageRows(lang) {
527
+ if (this.welcomeNote) {
528
+ this.tsvGenerator.addRow(this.row({
529
+ class: 'SL', name: 'surveyls_welcometext', language: lang,
530
+ text: this.renderLabel(this.welcomeNote.label, lang),
531
+ }));
532
+ }
533
+ if (this.endNote) {
534
+ this.tsvGenerator.addRow(this.row({
535
+ class: 'SL', name: 'surveyls_endtext', language: lang,
536
+ text: this.renderLabel(this.endNote.label, lang),
537
+ }));
538
+ }
551
539
  }
552
- /**
553
- * Transpile an XLSForm calculation expression to a LimeSurvey EM expression.
554
- */
555
- async convertCalculation(calculation) {
556
- return await xpathToLimeSurvey(calculation);
540
+ // ── Group handling ───────────────────────────────────────────────────
541
+ addDefaultGroup() {
542
+ const defaults = this.configManager.getDefaults();
543
+ const groupName = defaults.groupName;
544
+ this.currentGroup = groupName;
545
+ this.tsvGenerator.addRow(this.row({
546
+ class: 'G', name: groupName, text: groupName, language: defaults.language,
547
+ }));
548
+ this.groupSeq++;
549
+ }
550
+ addAutoGroupForOrphans() {
551
+ const groupName = `G${this.groupSeq}`;
552
+ this.groupSeq++;
553
+ this.currentGroup = groupName;
554
+ const groupSeqKey = String(this.groupSeq);
555
+ this.emitForEachLanguage(() => ({
556
+ class: 'G', 'type/scale': groupSeqKey, name: groupName, text: groupName,
557
+ }), 'direct');
557
558
  }
558
559
  async addGroup(row) {
559
- // Auto-generate name if missing (matches LimeSurvey behavior)
560
560
  const groupName = row.name && row.name.trim() !== ''
561
561
  ? this.sanitizeName(row.name.trim())
562
562
  : `G${this.groupSeq}`;
563
563
  this.groupSeq++;
564
564
  this.currentGroup = groupName;
565
- // Groups support relevance but not validation.
566
- // LimeSurvey's TSV importer matches group translations across languages
567
- // using the type/scale column as a stable group sequence key. Without it,
568
- // an auto-counter resets on each language change and mismatches groups.
569
- // We set type/scale to the group sequence number to ensure correct matching.
565
+ // type/scale is used as a stable group sequence key for LimeSurvey's TSV importer
566
+ // to correctly match group translations across languages.
570
567
  const groupSeqKey = String(this.groupSeq);
571
- for (const lang of this.availableLanguages) {
572
- this.tsvGenerator.addRow({
573
- class: 'G',
574
- 'type/scale': groupSeqKey,
575
- name: groupName,
576
- relevance: await this.convertRelevance(row.relevant),
577
- text: this.getLanguageSpecificValue(row.label, lang) || groupName,
578
- help: this.getLanguageSpecificValue(row.hint, lang) || '',
579
- language: lang,
580
- validation: '',
581
- em_validation_q: "",
582
- mandatory: '',
583
- other: '',
584
- default: '',
585
- same_default: ''
586
- });
568
+ const relevance = await this.convertRelevance(row.relevant);
569
+ this.emitForEachLanguage(lang => ({
570
+ class: 'G', 'type/scale': groupSeqKey,
571
+ name: this.renderLabel(row.label, lang, groupName),
572
+ relevance,
573
+ text: this.renderLabel(row.hint, lang),
574
+ }), 'direct');
575
+ }
576
+ /**
577
+ * Emit pending parent-only group labels as note questions (type X).
578
+ */
579
+ async emitPendingGroupNotes() {
580
+ for (const noteRow of this.pendingGroupNotes) {
581
+ const noteName = noteRow.name && noteRow.name.trim() !== ''
582
+ ? this.sanitizeName(noteRow.name.trim())
583
+ : `GN${this.questionSeq}`;
584
+ this.questionSeq++;
585
+ const relevance = await this.convertRelevance(noteRow.relevant);
586
+ this.emitForEachLanguage(lang => ({
587
+ class: 'Q', 'type/scale': 'X', name: noteName,
588
+ relevance,
589
+ text: this.renderLabel(noteRow.label, lang, noteName),
590
+ help: this.renderLabel(noteRow.hint, lang),
591
+ }));
592
+ }
593
+ this.pendingGroupNotes = [];
594
+ }
595
+ // ── Row processing ───────────────────────────────────────────────────
596
+ async processRow(row) {
597
+ const xfType = (row.type || '').trim();
598
+ if (!xfType)
599
+ return;
600
+ const baseType = xfType.split(/\s+/)[0];
601
+ // Silently skip metadata types
602
+ if (SKIP_TYPES.includes(baseType))
603
+ return;
604
+ // Skip notes that have been promoted to welcome/end messages
605
+ if (xfType === 'note') {
606
+ const name = (row.name || '').trim().toLowerCase();
607
+ const cfg = this.configManager.getConfig();
608
+ if (cfg.convertWelcomeNote && name === 'welcome')
609
+ return;
610
+ if (cfg.convertEndNote && name === 'end')
611
+ return;
612
+ }
613
+ // Other unimplemented types throw errors
614
+ if (UNIMPLEMENTED_TYPES.includes(baseType)) {
615
+ throw new Error(`Unimplemented XLSForm type: '${baseType}'. This type is not currently supported.`);
616
+ }
617
+ if (xfType === 'begin_group' || xfType === 'begin group') {
618
+ this.flushMatrix();
619
+ const originalName = (row.name || '').trim();
620
+ const sanitizedName = originalName
621
+ ? this.sanitizeName(originalName)
622
+ : `G${this.groupSeq}`;
623
+ if (this.messageOnlyGroups.has(originalName)) {
624
+ this.groupStack.push({ originalName, sanitizedName, emittedAsGroup: false });
625
+ }
626
+ else if (this.parentOnlyGroups.has(originalName)) {
627
+ this.groupStack.push({ originalName, sanitizedName, emittedAsGroup: false });
628
+ this.pendingGroupNotes.push(row);
629
+ }
630
+ else {
631
+ this.groupStack.push({ originalName, sanitizedName, emittedAsGroup: true });
632
+ this.flushGroupContent();
633
+ await this.addGroup(row);
634
+ await this.emitPendingGroupNotes();
635
+ }
636
+ return;
637
+ }
638
+ if (xfType === 'end_group' || xfType === 'end group') {
639
+ this.flushMatrix();
640
+ this.groupStack.pop();
641
+ this.currentGroup = null;
642
+ for (let i = this.groupStack.length - 1; i >= 0; i--) {
643
+ if (this.groupStack[i].emittedAsGroup) {
644
+ this.currentGroup = this.groupStack[i].sanitizedName;
645
+ break;
646
+ }
647
+ }
648
+ return;
649
+ }
650
+ // Auto-create a group for questions outside any explicit group.
651
+ if (this.currentGroup === null && this.groupStack.length === 0) {
652
+ this.flushGroupContent();
653
+ this.addAutoGroupForOrphans();
587
654
  }
655
+ await this.addQuestion(row);
588
656
  }
657
+ // ── Question emission ────────────────────────────────────────────────
589
658
  async addQuestion(row) {
590
659
  const xfTypeInfo = this.parseType(row.type || '');
591
660
  const appearance = typeof row['appearance'] === 'string' ? row['appearance'].trim() : '';
@@ -604,14 +673,14 @@ export class XLSFormToTSVConverter {
604
673
  this.flushMatrix();
605
674
  // Warn on unsupported appearances
606
675
  if (appearance) {
607
- const parts = appearance.split(/\s+/);
608
- for (const part of parts) {
609
- if (UNSUPPORTED_APPEARANCES.includes(part)) {
676
+ for (const part of appearance.split(/\s+/)) {
677
+ const isUnsupported = UNSUPPORTED_APPEARANCES.includes(part)
678
+ || (part === 'minimal' && xfTypeInfo.base !== 'select_one');
679
+ if (isUnsupported) {
610
680
  console.warn(`Unsupported appearance "${part}" on question "${row.name}" will be ignored`);
611
681
  }
612
682
  }
613
683
  }
614
- // Auto-generate name if missing (matches LimeSurvey behavior)
615
684
  const questionName = row.name && row.name.trim() !== ''
616
685
  ? this.sanitizeName(row.name.trim())
617
686
  : `Q${this.questionSeq}`;
@@ -620,48 +689,44 @@ export class XLSFormToTSVConverter {
620
689
  // Appearance-based type overrides
621
690
  if (appearance) {
622
691
  const parts = appearance.split(/\s+/);
623
- // multiline text → Long free text (T)
624
692
  if (parts.includes('multiline') && (xfTypeInfo.base === 'text' || xfTypeInfo.base === 'string')) {
625
693
  lsType.type = 'T';
626
694
  }
695
+ if (parts.includes('minimal') && xfTypeInfo.base === 'select_one') {
696
+ lsType.type = '!';
697
+ }
627
698
  }
628
- // Notes have special handling
629
699
  const isNote = xfTypeInfo.base === 'note';
630
700
  const isCalculate = xfTypeInfo.base === 'calculate';
631
- // For calculate type, transpile the calculation expression to EM syntax
701
+ const isNoteOrCalc = isNote || isCalculate;
632
702
  let calculationExpr = '';
633
703
  if (isCalculate && row.calculation) {
634
704
  calculationExpr = await this.convertCalculation(row.calculation);
635
705
  }
636
- // Add main question for each language
637
- for (const lang of this.availableLanguages) {
706
+ const relevance = await this.convertRelevance(row.relevant);
707
+ const emValidation = isNoteOrCalc ? '' : await convertConstraint(row.constraint || '');
708
+ const mandatory = isNoteOrCalc ? '' : (row.required === 'yes' || row.required === 'true' ? 'Y' : '');
709
+ const otherPattern = this.configManager.getConfig().convertOtherPattern ? this.hasOtherQuestionPattern(row, this.surveyDataCache) : false;
710
+ const other = isNoteOrCalc ? '' : ((lsType.other || otherPattern) ? 'Y' : '');
711
+ const defaultVal = isNoteOrCalc ? '' : (row.default || '');
712
+ this.emitForEachLanguage(lang => {
638
713
  let text;
639
714
  if (isCalculate) {
640
- // Equation question: the EM expression wrapped in {} IS the question text
641
715
  text = `{${calculationExpr}}`;
642
716
  }
643
717
  else {
644
- text = this.getLanguageSpecificValue(row.label, lang) || questionName;
718
+ text = this.renderLabel(row.label, lang, questionName);
645
719
  }
646
- // Convert ${var} references to EM {var} syntax in text and help
647
720
  text = this.convertVariableReferences(text);
648
- const help = this.convertVariableReferences(this.getLanguageSpecificValue(row.hint, lang) || '');
649
- this.bufferRow({
650
- class: 'Q',
651
- 'type/scale': isNote ? 'X' : lsType.type,
652
- name: questionName,
653
- relevance: await this.convertRelevance(row.relevant),
654
- text,
655
- help,
656
- language: lang,
657
- validation: "",
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 || ''),
662
- same_default: ''
663
- });
664
- }
721
+ const help = this.convertVariableReferences(this.renderLabel(row.hint, lang));
722
+ return {
723
+ class: 'Q', 'type/scale': isNote ? 'X' : lsType.type,
724
+ name: questionName, relevance, text, help,
725
+ em_validation_q: emValidation, mandatory, other,
726
+ default: defaultVal, same_default: '',
727
+ hidden: isCalculate ? '1' : '',
728
+ };
729
+ });
665
730
  // Reset answer sequence for this question
666
731
  this.answerSeq = 0;
667
732
  this.subquestionSeq = 0;
@@ -670,6 +735,7 @@ export class XLSFormToTSVConverter {
670
735
  this.addAnswers(xfTypeInfo, lsType);
671
736
  }
672
737
  }
738
+ // ── Matrix questions ─────────────────────────────────────────────────
673
739
  async addMatrixHeader(row, xfTypeInfo) {
674
740
  const questionName = row.name && row.name.trim() !== ''
675
741
  ? this.sanitizeName(row.name.trim())
@@ -678,47 +744,27 @@ export class XLSFormToTSVConverter {
678
744
  this.inMatrix = true;
679
745
  this.matrixListName = xfTypeInfo.listName;
680
746
  this.subquestionSeq = 0;
681
- // Emit Q row with type F (Array)
682
- for (const lang of this.availableLanguages) {
683
- this.bufferRow({
684
- class: 'Q',
685
- 'type/scale': 'F',
686
- name: questionName,
687
- relevance: await this.convertRelevance(row.relevant),
688
- text: this.getLanguageSpecificValue(row.label, lang) || questionName,
689
- help: this.getLanguageSpecificValue(row.hint, lang) || '',
690
- language: lang,
691
- validation: '',
692
- em_validation_q: '',
693
- mandatory: row.required === 'yes' || row.required === 'true' ? 'Y' : '',
694
- other: '',
695
- default: '',
696
- same_default: ''
697
- });
698
- }
747
+ const relevance = await this.convertRelevance(row.relevant);
748
+ const mandatory = row.required === 'yes' || row.required === 'true' ? 'Y' : '';
749
+ this.emitForEachLanguage(lang => ({
750
+ class: 'Q', 'type/scale': 'F', name: questionName,
751
+ relevance, mandatory,
752
+ text: this.renderLabel(row.label, lang, questionName),
753
+ help: this.renderLabel(row.hint, lang),
754
+ }));
699
755
  }
700
756
  async addMatrixSubquestion(row) {
701
757
  const sqName = row.name && row.name.trim() !== ''
702
758
  ? this.sanitizeName(row.name.trim())
703
759
  : `SQ${this.subquestionSeq}`;
704
760
  this.subquestionSeq++;
705
- for (const lang of this.availableLanguages) {
706
- this.bufferRow({
707
- class: 'SQ',
708
- 'type/scale': '',
709
- name: sqName,
710
- relevance: await this.convertRelevance(row.relevant),
711
- text: this.getLanguageSpecificValue(row.label, lang) || sqName,
712
- help: '',
713
- language: lang,
714
- validation: '',
715
- em_validation_q: '',
716
- mandatory: row.required === 'yes' || row.required === 'true' ? 'Y' : '',
717
- other: '',
718
- default: '',
719
- same_default: ''
720
- });
721
- }
761
+ const relevance = await this.convertRelevance(row.relevant);
762
+ const mandatory = row.required === 'yes' || row.required === 'true' ? 'Y' : '';
763
+ this.emitForEachLanguage(lang => ({
764
+ class: 'SQ', name: sqName,
765
+ relevance, mandatory,
766
+ text: this.renderLabel(row.label, lang, sqName),
767
+ }));
722
768
  }
723
769
  flushMatrix() {
724
770
  if (!this.inMatrix || !this.matrixListName) {
@@ -733,97 +779,51 @@ export class XLSFormToTSVConverter {
733
779
  const choiceName = choice.name && choice.name.trim() !== ''
734
780
  ? this.sanitizeAnswerCode(choice.name.trim())
735
781
  : `A${seq++}`;
736
- for (const lang of this.availableLanguages) {
737
- this.bufferRow({
738
- class: 'A',
739
- 'type/scale': '',
740
- name: choiceName,
741
- relevance: '',
742
- text: this.getLanguageSpecificValue(choice.label, lang) || choiceName,
743
- help: '',
744
- language: lang,
745
- validation: '',
746
- em_validation_q: '',
747
- mandatory: '',
748
- other: '',
749
- default: '',
750
- same_default: ''
751
- });
752
- }
782
+ this.emitForEachLanguage(lang => ({
783
+ class: 'A', name: choiceName, relevance: '',
784
+ text: this.renderLabel(choice.label, lang, choiceName),
785
+ }));
753
786
  }
754
787
  }
755
788
  this.inMatrix = false;
756
789
  this.matrixListName = null;
757
790
  }
758
- parseType(typeStr) {
759
- return this.typeMapper.parseType(typeStr);
760
- }
761
- mapType(xfTypeInfo) {
762
- return this.typeMapper.mapType(xfTypeInfo);
763
- }
791
+ // ── Answer emission ──────────────────────────────────────────────────
764
792
  addAnswers(xfTypeInfo, lsType) {
765
793
  const choices = this.choicesMap.get(xfTypeInfo.listName);
766
794
  if (!choices) {
767
795
  console.warn(`Choice list not found: ${xfTypeInfo.listName}`);
768
796
  return;
769
797
  }
770
- // Use the answer class from the type mapping
771
798
  const answerClass = lsType.answerClass || (xfTypeInfo.base === 'select_multiple' ? 'SQ' : 'A');
772
- // Pre-compute sanitized choice names
773
- const choiceNames = [];
774
- for (const choice of choices) {
799
+ // Pre-compute and deduplicate sanitized choice names
800
+ const rawNames = choices.map(choice => {
775
801
  const rawName = choice.name && choice.name.trim() !== '' ? choice.name.trim() : '';
776
- choiceNames.push(rawName
802
+ return rawName
777
803
  ? this.sanitizeAnswerCode(rawName)
778
- : (answerClass === 'SQ' ? `SQ${this.subquestionSeq++}` : `A${this.answerSeq++}`));
779
- }
780
- // Resolve duplicate names by appending a counter suffix
781
- const usedNames = new Set();
782
- for (let i = 0; i < choiceNames.length; i++) {
783
- let name = choiceNames[i];
784
- if (usedNames.has(name)) {
785
- let counter = 1;
786
- let candidate;
787
- do {
788
- const suffix = String(counter);
789
- candidate = name.substring(0, 5 - suffix.length) + suffix;
790
- counter++;
791
- } while (usedNames.has(candidate));
792
- console.warn(`Duplicate answer code "${name}" resolved to "${candidate}"`);
793
- choiceNames[i] = candidate;
794
- }
795
- usedNames.add(choiceNames[i]);
804
+ : (answerClass === 'SQ' ? `SQ${this.subquestionSeq++}` : `A${this.answerSeq++}`);
805
+ });
806
+ const choiceNames = deduplicateNames(rawNames, 5);
807
+ for (let i = 0; i < rawNames.length; i++) {
808
+ if (choiceNames[i] !== rawNames[i]) {
809
+ console.warn(`Duplicate answer code "${rawNames[i]}" resolved to "${choiceNames[i]}"`);
810
+ }
796
811
  }
797
812
  for (let i = 0; i < choices.length; i++) {
798
813
  const choice = choices[i];
799
814
  const choiceName = choiceNames[i];
800
- // Add answer for each language
801
- for (const lang of this.availableLanguages) {
802
- this.bufferRow({
803
- class: answerClass,
804
- 'type/scale': '',
805
- name: choiceName,
806
- relevance: choice.filter
807
- ? `({${this.currentGroup || 'parent'}} == "${choice.filter}")`
808
- : '',
809
- text: this.getLanguageSpecificValue(choice.label, lang) || choiceName,
810
- help: '',
811
- language: lang,
812
- validation: '',
813
- em_validation_q: '',
814
- mandatory: '',
815
- other: '',
816
- default: '',
817
- same_default: ''
818
- });
819
- }
815
+ this.emitForEachLanguage(lang => ({
816
+ class: answerClass, name: choiceName, relevance: '',
817
+ ...(choice.filter ? { relevance: `({${this.currentGroup || 'parent'}} == "${choice.filter}")` } : {}),
818
+ text: this.renderLabel(choice.label, lang, choiceName),
819
+ }));
820
820
  }
821
821
  }
822
+ // ── Expression transpilation ─────────────────────────────────────────
822
823
  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);
824
+ const stripped = fieldName.replace(/[_-]/g, '');
825
+ const resolved = this.fieldSanitizer.resolveStrippedName(stripped);
826
+ const listName = this.questionToListMap.get(resolved);
827
827
  if (!listName)
828
828
  return { code: choiceValue, listName: undefined };
829
829
  const codeMap = this.answerCodeMap.get(listName);
@@ -831,23 +831,38 @@ export class XLSFormToTSVConverter {
831
831
  return { code: choiceValue, listName };
832
832
  return { code: codeMap.get(choiceValue) ?? choiceValue, listName };
833
833
  }
834
- async convertRelevance(relevant) {
835
- if (!relevant)
836
- return '1';
837
- const ctx = {
834
+ buildTranspilerContext() {
835
+ return {
838
836
  lookupAnswerCode: (fieldName, choiceValue) => {
839
837
  return this.lookupAnswerCode(fieldName, choiceValue).code;
840
838
  },
839
+ getTruncatedFieldName: (fieldName) => {
840
+ return this.fieldSanitizer.resolveStrippedName(fieldName);
841
+ },
841
842
  buildSelectedExpr: (fieldName, choiceValue) => {
842
- const truncated = fieldName.length > 20 ? fieldName.substring(0, 20) : fieldName;
843
+ const resolved = this.fieldSanitizer.resolveStrippedName(fieldName);
843
844
  const { code } = this.lookupAnswerCode(fieldName, choiceValue);
844
- const baseType = this.questionBaseTypeMap.get(truncated);
845
+ const baseType = this.questionBaseTypeMap.get(resolved);
845
846
  if (baseType === 'select_multiple') {
846
- return `(${truncated}_${code}.NAOK == "Y")`;
847
+ return `(${resolved}_${code}.NAOK == 'Y')`;
847
848
  }
848
- return `(${truncated}.NAOK=="${code}")`;
849
+ return `(${resolved}.NAOK=='${code}')`;
849
850
  },
850
851
  };
851
- return await convertRelevance(relevant, ctx);
852
+ }
853
+ async convertRelevance(relevant) {
854
+ if (!relevant)
855
+ return '1';
856
+ return await convertRelevance(relevant, this.buildTranspilerContext());
857
+ }
858
+ async convertCalculation(calculation) {
859
+ return await xpathToLimeSurvey(calculation, this.buildTranspilerContext());
860
+ }
861
+ // ── Type helpers ─────────────────────────────────────────────────────
862
+ parseType(typeStr) {
863
+ return this.typeMapper.parseType(typeStr);
864
+ }
865
+ mapType(xfTypeInfo) {
866
+ return this.typeMapper.mapType(xfTypeInfo);
852
867
  }
853
868
  }