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.
@@ -0,0 +1,721 @@
1
+ import { ConfigManager } from './config/ConfigManager.js';
2
+ import { convertRelevance, convertConstraint } from './converters/xpathTranspiler.js';
3
+ import { FieldSanitizer } from './processors/FieldSanitizer.js';
4
+ import { TSVGenerator } from './processors/TSVGenerator.js';
5
+ import { TypeMapper } from './processors/TypeMapper.js';
6
+ import { getBaseLanguage } from './utils/languageUtils.js';
7
+ // Metadata types that should be silently skipped (no visual representation)
8
+ const SKIP_TYPES = [
9
+ 'start', 'end', 'today', 'deviceid', 'username',
10
+ 'calculate', 'hidden', 'audit'
11
+ ];
12
+ // Unimplemented XLSForm types that should raise an error
13
+ const UNIMPLEMENTED_TYPES = [
14
+ 'geopoint', 'geotrace', 'geoshape', 'start-geopoint',
15
+ 'image', 'audio', 'video', 'file',
16
+ 'background-audio', 'csv-external', 'phonenumber', 'email',
17
+ 'barcode',
18
+ 'range', // Range questions don't exist in LimeSurvey
19
+ 'select_one_from_file', // External file loading not supported in LimeSurvey TSV import
20
+ 'select_multiple_from_file', // External file loading not supported in LimeSurvey TSV import
21
+ 'acknowledge', // Acknowledge type not supported in LimeSurvey TSV import
22
+ 'begin_repeat',
23
+ 'end_repeat'
24
+ ];
25
+ // Appearances handled without warning: label, list-nolabel (matrix), multiline (→T), likert (no-op), field-list (no-op with format=A)
26
+ const UNSUPPORTED_APPEARANCES = [
27
+ 'minimal', 'quick', 'no-calendar', 'month-year', 'year',
28
+ 'horizontal-compact', 'horizontal', 'compact', 'quickcompact',
29
+ 'table-list', 'signature', 'draw', 'map', 'quick map',
30
+ ];
31
+ export class XLSFormToTSVConverter {
32
+ constructor(config) {
33
+ this.configManager = new ConfigManager(config);
34
+ this.configManager.validateConfig();
35
+ this.fieldSanitizer = new FieldSanitizer();
36
+ this.typeMapper = new TypeMapper();
37
+ this.tsvGenerator = new TSVGenerator();
38
+ this.choicesMap = new Map();
39
+ this.currentGroup = null;
40
+ this.groupStack = [];
41
+ this.parentOnlyGroups = new Set();
42
+ this.pendingGroupNotes = [];
43
+ this.groupSeq = 0;
44
+ this.questionSeq = 0;
45
+ this.answerSeq = 0;
46
+ this.subquestionSeq = 0;
47
+ this.availableLanguages = ['en']; // Default to English
48
+ this.baseLanguage = 'en'; // Default to English
49
+ this.inMatrix = false;
50
+ this.matrixListName = null;
51
+ this.groupContentBuffer = [];
52
+ }
53
+ /**
54
+ * Get the current configuration
55
+ */
56
+ getConfig() {
57
+ return this.configManager.getConfig();
58
+ }
59
+ /**
60
+ * Update configuration at runtime
61
+ */
62
+ updateConfig(partialConfig) {
63
+ this.configManager.updateConfig(partialConfig);
64
+ }
65
+ async convert(surveyData, choicesData, settingsData) {
66
+ // Reset state
67
+ this.choicesMap.clear();
68
+ this.currentGroup = null;
69
+ this.groupStack = [];
70
+ this.pendingGroupNotes = [];
71
+ this.tsvGenerator.clear();
72
+ this.groupSeq = 0;
73
+ this.questionSeq = 0;
74
+ this.answerSeq = 0;
75
+ this.subquestionSeq = 0;
76
+ this.inMatrix = false;
77
+ this.matrixListName = null;
78
+ this.groupContentBuffer = [];
79
+ // Pre-scan to identify parent-only groups (no direct questions, only child groups)
80
+ this.parentOnlyGroups = this.identifyParentOnlyGroups(surveyData);
81
+ // Set base language from settings first
82
+ this.baseLanguage = getBaseLanguage(settingsData[0] || {});
83
+ // Detect available languages from survey data (will use baseLanguage for ordering)
84
+ this.detectAvailableLanguages(surveyData, choicesData, settingsData);
85
+ // Build choices map
86
+ this.buildChoicesMap(choicesData);
87
+ // Add survey row (class S)
88
+ this.addSurveyRow(settingsData[0] || {});
89
+ // Check if we need a default group (if no groups are defined)
90
+ const hasGroups = surveyData.some(row => {
91
+ const xfType = (row.type || '').trim();
92
+ return xfType === 'begin_group';
93
+ });
94
+ // If no groups, add a default group
95
+ const advancedOptions = this.configManager.getAdvancedOptions();
96
+ if (!hasGroups && advancedOptions.autoCreateGroups) {
97
+ this.addDefaultGroup();
98
+ }
99
+ // Process survey rows
100
+ for (const row of surveyData) {
101
+ await this.processRow(row);
102
+ }
103
+ // Flush any pending matrix at the end
104
+ this.flushMatrix();
105
+ // Flush remaining buffered group content
106
+ this.flushGroupContent();
107
+ // Generate TSV
108
+ return this.tsvGenerator.generateTSV();
109
+ }
110
+ detectAvailableLanguages(surveyData, choicesData, settingsData) {
111
+ const languageCodes = new Set();
112
+ // Check survey data for language codes
113
+ for (const row of surveyData) {
114
+ if (row._languages) {
115
+ row._languages.forEach((lang) => languageCodes.add(lang));
116
+ }
117
+ }
118
+ // Check choices data for language codes
119
+ for (const row of choicesData) {
120
+ if (row._languages) {
121
+ row._languages.forEach((lang) => languageCodes.add(lang));
122
+ }
123
+ }
124
+ // Check settings data for language-specific fields
125
+ const settings = settingsData[0] || {};
126
+ for (const [, value] of Object.entries(settings)) {
127
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
128
+ // This looks like a language-specific field (e.g., {en: '...', es: '...'})
129
+ for (const lang of Object.keys(value)) {
130
+ languageCodes.add(lang);
131
+ }
132
+ }
133
+ }
134
+ // If no language-specific columns detected, use single language mode
135
+ // Only use multiple languages if we actually have language-specific data
136
+ const hasLanguageSpecificData = surveyData.some(row => row._languages ||
137
+ (typeof row.label === 'object' && row.label !== null) ||
138
+ (typeof row.hint === 'object' && row.hint !== null)) || choicesData.some(row => row._languages ||
139
+ (typeof row.label === 'object' && row.label !== null)) || Object.values(settings).some(value => typeof value === 'object' && value !== null && !Array.isArray(value));
140
+ if (hasLanguageSpecificData && languageCodes.size > 0) {
141
+ const languagesArray = Array.from(languageCodes);
142
+ // Ensure default language comes first, then sort the rest alphabetically
143
+ const defaultLang = this.baseLanguage;
144
+ this.availableLanguages = [
145
+ defaultLang,
146
+ ...languagesArray.filter(lang => lang !== defaultLang).sort()
147
+ ];
148
+ }
149
+ else {
150
+ this.availableLanguages = ['en'];
151
+ }
152
+ }
153
+ buildChoicesMap(choices) {
154
+ for (const choice of choices) {
155
+ const listName = choice.list_name;
156
+ if (!listName)
157
+ continue;
158
+ if (!this.choicesMap.has(listName)) {
159
+ this.choicesMap.set(listName, []);
160
+ }
161
+ this.choicesMap.get(listName).push(choice);
162
+ }
163
+ }
164
+ addDefaultGroup() {
165
+ // Add a default group for surveys without explicit groups
166
+ const defaults = this.configManager.getDefaults();
167
+ const groupName = defaults.groupName;
168
+ this.currentGroup = groupName;
169
+ this.tsvGenerator.addRow({
170
+ class: 'G',
171
+ 'type/scale': '',
172
+ name: groupName,
173
+ relevance: '1',
174
+ text: groupName,
175
+ help: '',
176
+ language: defaults.language,
177
+ validation: '',
178
+ em_validation_q: '',
179
+ mandatory: '',
180
+ other: '',
181
+ default: '',
182
+ same_default: ''
183
+ });
184
+ this.groupSeq++;
185
+ }
186
+ addAutoGroupForOrphans() {
187
+ const groupName = `G${this.groupSeq}`;
188
+ this.groupSeq++;
189
+ this.currentGroup = groupName;
190
+ const groupSeqKey = String(this.groupSeq);
191
+ for (const lang of this.availableLanguages) {
192
+ this.tsvGenerator.addRow({
193
+ class: 'G',
194
+ 'type/scale': groupSeqKey,
195
+ name: groupName,
196
+ relevance: '1',
197
+ text: groupName,
198
+ help: '',
199
+ language: lang,
200
+ validation: '',
201
+ em_validation_q: '',
202
+ mandatory: '',
203
+ other: '',
204
+ default: '',
205
+ same_default: ''
206
+ });
207
+ }
208
+ }
209
+ addSurveyRow(settings) {
210
+ // Add survey-level settings as individual S rows
211
+ // Each survey property gets its own row with name=property_name, text=value
212
+ const defaults = this.configManager.getDefaults();
213
+ const surveyTitle = settings.form_title || defaults.surveyTitle;
214
+ // Add base language (class S - survey settings)
215
+ this.tsvGenerator.addRow({
216
+ class: 'S',
217
+ 'type/scale': '',
218
+ name: 'language',
219
+ relevance: '1',
220
+ text: this.baseLanguage,
221
+ help: '',
222
+ language: this.baseLanguage,
223
+ validation: '',
224
+ em_validation_q: '',
225
+ mandatory: '',
226
+ other: '',
227
+ default: '',
228
+ same_default: ''
229
+ });
230
+ // Add additional languages declaration (class S - survey settings)
231
+ // This tells LimeSurvey which additional languages should be available
232
+ if (this.availableLanguages.length > 1) {
233
+ const additionalLanguages = this.availableLanguages.filter(lang => lang !== this.baseLanguage).join(' ');
234
+ this.tsvGenerator.addRow({
235
+ class: 'S',
236
+ 'type/scale': '',
237
+ name: 'additional_languages',
238
+ relevance: '1',
239
+ text: additionalLanguages,
240
+ help: '',
241
+ language: this.baseLanguage,
242
+ validation: '',
243
+ em_validation_q: '',
244
+ mandatory: '',
245
+ other: '',
246
+ default: '',
247
+ same_default: ''
248
+ });
249
+ }
250
+ // Set survey format to "All in one" (all groups/questions on one page)
251
+ this.tsvGenerator.addRow({
252
+ class: 'S',
253
+ 'type/scale': '',
254
+ name: 'format',
255
+ relevance: '1',
256
+ text: 'A',
257
+ help: '',
258
+ language: this.baseLanguage,
259
+ validation: '',
260
+ em_validation_q: '',
261
+ mandatory: '',
262
+ other: '',
263
+ default: '',
264
+ same_default: ''
265
+ });
266
+ // First, add the default language row according to LimeSurvey spec
267
+ // This should be the first SL row for the default language
268
+ const defaultLanguage = this.baseLanguage;
269
+ // Add default language row (class SL - survey language settings)
270
+ this.tsvGenerator.addRow({
271
+ class: 'SL',
272
+ 'type/scale': '',
273
+ name: 'surveyls_title',
274
+ relevance: '1',
275
+ text: this.getLanguageSpecificValue(settings.form_title, defaultLanguage) || surveyTitle,
276
+ help: '',
277
+ language: defaultLanguage,
278
+ validation: '',
279
+ em_validation_q: '',
280
+ mandatory: '',
281
+ other: '',
282
+ default: '',
283
+ same_default: ''
284
+ });
285
+ // Then add rows for all other available languages (after default language)
286
+ const otherLanguages = this.availableLanguages.filter(lang => lang !== defaultLanguage);
287
+ // Sort other languages alphabetically for consistency
288
+ otherLanguages.sort();
289
+ for (const lang of otherLanguages) {
290
+ this.tsvGenerator.addRow({
291
+ class: 'SL',
292
+ 'type/scale': '',
293
+ name: 'surveyls_title',
294
+ relevance: '1',
295
+ text: this.getLanguageSpecificValue(settings.form_title, lang) || surveyTitle,
296
+ help: '',
297
+ language: lang,
298
+ validation: '',
299
+ em_validation_q: "",
300
+ mandatory: '',
301
+ other: '',
302
+ default: '',
303
+ same_default: ''
304
+ });
305
+ }
306
+ }
307
+ /**
308
+ * Pre-scan survey data to identify parent-only groups.
309
+ * A parent-only group contains no direct questions — only child groups.
310
+ * These will be flattened into note questions in the first child group.
311
+ */
312
+ identifyParentOnlyGroups(surveyData) {
313
+ const parentOnly = new Set();
314
+ const stack = [];
315
+ const hasDirectContent = new Map();
316
+ for (const row of surveyData) {
317
+ const type = (row.type || '').trim();
318
+ const baseType = type.split(/\s+/)[0];
319
+ if (type === 'begin_group' || type === 'begin group') {
320
+ const name = (row.name || '').trim();
321
+ stack.push(name);
322
+ hasDirectContent.set(name, false);
323
+ }
324
+ else if (type === 'end_group' || type === 'end group') {
325
+ const name = stack.pop();
326
+ if (name !== undefined && !hasDirectContent.get(name)) {
327
+ parentOnly.add(name);
328
+ }
329
+ }
330
+ else if (type && !SKIP_TYPES.includes(baseType)) {
331
+ // Any non-skip, non-group row counts as direct content
332
+ if (stack.length > 0) {
333
+ hasDirectContent.set(stack[stack.length - 1], true);
334
+ }
335
+ }
336
+ }
337
+ return parentOnly;
338
+ }
339
+ async processRow(row) {
340
+ const xfType = (row.type || '').trim();
341
+ if (!xfType)
342
+ return;
343
+ // Check for unimplemented types (extract base type first, before any spaces)
344
+ const baseType = xfType.split(/\s+/)[0];
345
+ // Silently skip metadata types
346
+ if (SKIP_TYPES.includes(baseType)) {
347
+ return;
348
+ }
349
+ // Other unimplemented types throw errors
350
+ if (UNIMPLEMENTED_TYPES.includes(baseType)) {
351
+ throw new Error(`Unimplemented XLSForm type: '${baseType}'. This type is not currently supported.`);
352
+ }
353
+ if (xfType === 'begin_group' || xfType === 'begin group') {
354
+ this.flushMatrix();
355
+ const originalName = (row.name || '').trim();
356
+ const sanitizedName = originalName
357
+ ? this.sanitizeName(originalName)
358
+ : `G${this.groupSeq}`;
359
+ if (this.parentOnlyGroups.has(originalName)) {
360
+ // Parent-only group: save label as pending note, don't emit G row
361
+ this.groupStack.push({ originalName, sanitizedName, emittedAsGroup: false });
362
+ this.pendingGroupNotes.push(row);
363
+ }
364
+ else {
365
+ // Regular group: flush buffered content from previous group, then emit G row
366
+ this.groupStack.push({ originalName, sanitizedName, emittedAsGroup: true });
367
+ this.flushGroupContent();
368
+ await this.addGroup(row);
369
+ // Emit pending parent-only group notes as note questions in this group
370
+ await this.emitPendingGroupNotes();
371
+ }
372
+ return;
373
+ }
374
+ if (xfType === 'end_group' || xfType === 'end group') {
375
+ this.flushMatrix();
376
+ this.groupStack.pop();
377
+ // Restore currentGroup to nearest ancestor that was emitted as a group
378
+ this.currentGroup = null;
379
+ for (let i = this.groupStack.length - 1; i >= 0; i--) {
380
+ if (this.groupStack[i].emittedAsGroup) {
381
+ this.currentGroup = this.groupStack[i].sanitizedName;
382
+ break;
383
+ }
384
+ }
385
+ return;
386
+ }
387
+ // Auto-create a group for questions outside any explicit group.
388
+ // LimeSurvey requires every question to belong to a group.
389
+ if (this.currentGroup === null && this.groupStack.length === 0) {
390
+ this.flushGroupContent();
391
+ this.addAutoGroupForOrphans();
392
+ }
393
+ // Handle notes and questions
394
+ await this.addQuestion(row);
395
+ }
396
+ /**
397
+ * Emit pending parent-only group labels as note questions (type X).
398
+ * Called after a child group's G row is emitted.
399
+ */
400
+ async emitPendingGroupNotes() {
401
+ for (const noteRow of this.pendingGroupNotes) {
402
+ const noteName = noteRow.name && noteRow.name.trim() !== ''
403
+ ? this.sanitizeName(noteRow.name.trim())
404
+ : `GN${this.questionSeq}`;
405
+ this.questionSeq++;
406
+ for (const lang of this.availableLanguages) {
407
+ this.bufferRow({
408
+ class: 'Q',
409
+ 'type/scale': 'X',
410
+ name: noteName,
411
+ relevance: await this.convertRelevance(noteRow.relevant),
412
+ text: this.getLanguageSpecificValue(noteRow.label, lang) || noteName,
413
+ help: this.getLanguageSpecificValue(noteRow.hint, lang) || '',
414
+ language: lang,
415
+ validation: '',
416
+ em_validation_q: '',
417
+ mandatory: '',
418
+ other: '',
419
+ default: '',
420
+ same_default: ''
421
+ });
422
+ }
423
+ }
424
+ this.pendingGroupNotes = [];
425
+ }
426
+ getLanguageSpecificValue(value, languageCode) {
427
+ if (!value)
428
+ return undefined;
429
+ // If it's already a string, return it
430
+ if (typeof value === 'string') {
431
+ return value;
432
+ }
433
+ // If it's an object with language codes, get the specific language
434
+ if (typeof value === 'object' && value !== null) {
435
+ const valueObj = value;
436
+ if (languageCode in valueObj) {
437
+ return valueObj[languageCode];
438
+ }
439
+ // If it doesn't have the specific language, try to get any available language
440
+ for (const lang of this.availableLanguages) {
441
+ if (lang in valueObj)
442
+ return valueObj[lang];
443
+ }
444
+ }
445
+ return undefined;
446
+ }
447
+ /**
448
+ * Buffer a Q/SQ/A row for later language-grouped output.
449
+ * LimeSurvey's TSV importer uses a question_order counter ($qseq) that gets
450
+ * reset when it encounters a translation of a previously-seen question.
451
+ * With interleaved languages (Q de, Q en, Q de, Q en), the counter resets
452
+ * after each translation, giving all subsequent questions order=0.
453
+ * By outputting all base-language rows first, the counter increments correctly,
454
+ * and translation rows just look up their stored values.
455
+ */
456
+ bufferRow(row) {
457
+ this.groupContentBuffer.push(row);
458
+ }
459
+ /**
460
+ * Flush buffered group content, outputting base language rows first,
461
+ * then each additional language. This preserves insertion order within
462
+ * each language while ensuring LimeSurvey's question_order counter
463
+ * increments correctly.
464
+ */
465
+ flushGroupContent() {
466
+ if (this.groupContentBuffer.length === 0)
467
+ return;
468
+ // Output base language rows first (preserving insertion order)
469
+ for (const row of this.groupContentBuffer) {
470
+ if (row.language === this.baseLanguage) {
471
+ this.tsvGenerator.addRow(row);
472
+ }
473
+ }
474
+ // Then output each additional language (preserving insertion order)
475
+ for (const lang of this.availableLanguages) {
476
+ if (lang === this.baseLanguage)
477
+ continue;
478
+ for (const row of this.groupContentBuffer) {
479
+ if (row.language === lang) {
480
+ this.tsvGenerator.addRow(row);
481
+ }
482
+ }
483
+ }
484
+ this.groupContentBuffer = [];
485
+ }
486
+ sanitizeName(name) {
487
+ return this.fieldSanitizer.sanitizeName(name);
488
+ }
489
+ sanitizeAnswerCode(code) {
490
+ return this.fieldSanitizer.sanitizeAnswerCode(code);
491
+ }
492
+ async addGroup(row) {
493
+ // Auto-generate name if missing (matches LimeSurvey behavior)
494
+ const groupName = row.name && row.name.trim() !== ''
495
+ ? this.sanitizeName(row.name.trim())
496
+ : `G${this.groupSeq}`;
497
+ this.groupSeq++;
498
+ this.currentGroup = groupName;
499
+ // Groups support relevance but not validation.
500
+ // LimeSurvey's TSV importer matches group translations across languages
501
+ // using the type/scale column as a stable group sequence key. Without it,
502
+ // an auto-counter resets on each language change and mismatches groups.
503
+ // We set type/scale to the group sequence number to ensure correct matching.
504
+ const groupSeqKey = String(this.groupSeq);
505
+ for (const lang of this.availableLanguages) {
506
+ this.tsvGenerator.addRow({
507
+ class: 'G',
508
+ 'type/scale': groupSeqKey,
509
+ name: groupName,
510
+ relevance: await this.convertRelevance(row.relevant),
511
+ text: this.getLanguageSpecificValue(row.label, lang) || groupName,
512
+ help: this.getLanguageSpecificValue(row.hint, lang) || '',
513
+ language: lang,
514
+ validation: '',
515
+ em_validation_q: "",
516
+ mandatory: '',
517
+ other: '',
518
+ default: '',
519
+ same_default: ''
520
+ });
521
+ }
522
+ }
523
+ async addQuestion(row) {
524
+ const xfTypeInfo = this.parseType(row.type || '');
525
+ const appearance = typeof row['appearance'] === 'string' ? row['appearance'].trim() : '';
526
+ // Matrix header: select_one with appearance "label"
527
+ if (appearance === 'label' && xfTypeInfo.base === 'select_one' && xfTypeInfo.listName) {
528
+ this.flushMatrix();
529
+ await this.addMatrixHeader(row, xfTypeInfo);
530
+ return;
531
+ }
532
+ // Matrix subquestion: select_one with appearance "list-nolabel" while in matrix mode
533
+ if (appearance === 'list-nolabel' && this.inMatrix && xfTypeInfo.base === 'select_one') {
534
+ await this.addMatrixSubquestion(row);
535
+ return;
536
+ }
537
+ // Non-matrix question: flush any pending matrix first
538
+ this.flushMatrix();
539
+ // Warn on unsupported appearances
540
+ if (appearance) {
541
+ const parts = appearance.split(/\s+/);
542
+ for (const part of parts) {
543
+ if (UNSUPPORTED_APPEARANCES.includes(part)) {
544
+ console.warn(`Unsupported appearance "${part}" on question "${row.name}" will be ignored`);
545
+ }
546
+ }
547
+ }
548
+ // Auto-generate name if missing (matches LimeSurvey behavior)
549
+ const questionName = row.name && row.name.trim() !== ''
550
+ ? this.sanitizeName(row.name.trim())
551
+ : `Q${this.questionSeq}`;
552
+ this.questionSeq++;
553
+ const lsType = this.mapType(xfTypeInfo);
554
+ // Appearance-based type overrides
555
+ if (appearance) {
556
+ const parts = appearance.split(/\s+/);
557
+ // multiline text → Long free text (T)
558
+ if (parts.includes('multiline') && (xfTypeInfo.base === 'text' || xfTypeInfo.base === 'string')) {
559
+ lsType.type = 'T';
560
+ }
561
+ }
562
+ // Notes have special handling
563
+ const isNote = xfTypeInfo.base === 'note';
564
+ // Add main question for each language
565
+ for (const lang of this.availableLanguages) {
566
+ this.bufferRow({
567
+ class: 'Q',
568
+ 'type/scale': isNote ? 'X' : lsType.type,
569
+ name: questionName,
570
+ relevance: await this.convertRelevance(row.relevant),
571
+ text: this.getLanguageSpecificValue(row.label, lang) || questionName,
572
+ help: this.getLanguageSpecificValue(row.hint, lang) || '',
573
+ language: lang,
574
+ validation: "",
575
+ em_validation_q: isNote ? "" : await convertConstraint(row.constraint || ""),
576
+ mandatory: isNote ? '' : (row.required === 'yes' || row.required === 'true' ? 'Y' : ''),
577
+ other: isNote ? '' : (lsType.other ? 'Y' : ''),
578
+ default: isNote ? '' : (row.default || ''),
579
+ same_default: ''
580
+ });
581
+ }
582
+ // Reset answer sequence for this question
583
+ this.answerSeq = 0;
584
+ this.subquestionSeq = 0;
585
+ // Add answers/subquestions for select types (notes don't have answers)
586
+ if (!isNote && xfTypeInfo.listName) {
587
+ this.addAnswers(xfTypeInfo, lsType);
588
+ }
589
+ }
590
+ async addMatrixHeader(row, xfTypeInfo) {
591
+ const questionName = row.name && row.name.trim() !== ''
592
+ ? this.sanitizeName(row.name.trim())
593
+ : `Q${this.questionSeq}`;
594
+ this.questionSeq++;
595
+ this.inMatrix = true;
596
+ this.matrixListName = xfTypeInfo.listName;
597
+ this.subquestionSeq = 0;
598
+ // Emit Q row with type F (Array)
599
+ for (const lang of this.availableLanguages) {
600
+ this.bufferRow({
601
+ class: 'Q',
602
+ 'type/scale': 'F',
603
+ name: questionName,
604
+ relevance: await this.convertRelevance(row.relevant),
605
+ text: this.getLanguageSpecificValue(row.label, lang) || questionName,
606
+ help: this.getLanguageSpecificValue(row.hint, lang) || '',
607
+ language: lang,
608
+ validation: '',
609
+ em_validation_q: '',
610
+ mandatory: row.required === 'yes' || row.required === 'true' ? 'Y' : '',
611
+ other: '',
612
+ default: '',
613
+ same_default: ''
614
+ });
615
+ }
616
+ }
617
+ async addMatrixSubquestion(row) {
618
+ const sqName = row.name && row.name.trim() !== ''
619
+ ? this.sanitizeName(row.name.trim())
620
+ : `SQ${this.subquestionSeq}`;
621
+ this.subquestionSeq++;
622
+ for (const lang of this.availableLanguages) {
623
+ this.bufferRow({
624
+ class: 'SQ',
625
+ 'type/scale': '',
626
+ name: sqName,
627
+ relevance: await this.convertRelevance(row.relevant),
628
+ text: this.getLanguageSpecificValue(row.label, lang) || sqName,
629
+ help: '',
630
+ language: lang,
631
+ validation: '',
632
+ em_validation_q: '',
633
+ mandatory: row.required === 'yes' || row.required === 'true' ? 'Y' : '',
634
+ other: '',
635
+ default: '',
636
+ same_default: ''
637
+ });
638
+ }
639
+ }
640
+ flushMatrix() {
641
+ if (!this.inMatrix || !this.matrixListName) {
642
+ this.inMatrix = false;
643
+ this.matrixListName = null;
644
+ return;
645
+ }
646
+ const choices = this.choicesMap.get(this.matrixListName);
647
+ if (choices) {
648
+ let seq = 0;
649
+ for (const choice of choices) {
650
+ const choiceName = choice.name && choice.name.trim() !== ''
651
+ ? this.sanitizeAnswerCode(choice.name.trim())
652
+ : `A${seq++}`;
653
+ for (const lang of this.availableLanguages) {
654
+ this.bufferRow({
655
+ class: 'A',
656
+ 'type/scale': '',
657
+ name: choiceName,
658
+ relevance: '',
659
+ text: this.getLanguageSpecificValue(choice.label, lang) || choiceName,
660
+ help: '',
661
+ language: lang,
662
+ validation: '',
663
+ em_validation_q: '',
664
+ mandatory: '',
665
+ other: '',
666
+ default: '',
667
+ same_default: ''
668
+ });
669
+ }
670
+ }
671
+ }
672
+ this.inMatrix = false;
673
+ this.matrixListName = null;
674
+ }
675
+ parseType(typeStr) {
676
+ return this.typeMapper.parseType(typeStr);
677
+ }
678
+ mapType(xfTypeInfo) {
679
+ return this.typeMapper.mapType(xfTypeInfo);
680
+ }
681
+ addAnswers(xfTypeInfo, lsType) {
682
+ const choices = this.choicesMap.get(xfTypeInfo.listName);
683
+ if (!choices) {
684
+ console.warn(`Choice list not found: ${xfTypeInfo.listName}`);
685
+ return;
686
+ }
687
+ // Use the answer class from the type mapping
688
+ const answerClass = lsType.answerClass || (xfTypeInfo.base === 'select_multiple' ? 'SQ' : 'A');
689
+ for (const choice of choices) {
690
+ // Auto-generate name if missing (matches LimeSurvey behavior)
691
+ const choiceName = choice.name && choice.name.trim() !== ''
692
+ ? this.sanitizeAnswerCode(choice.name.trim())
693
+ : (answerClass === 'SQ' ? `SQ${this.subquestionSeq++}` : `A${this.answerSeq++}`);
694
+ // Add answer for each language
695
+ for (const lang of this.availableLanguages) {
696
+ this.bufferRow({
697
+ class: answerClass,
698
+ 'type/scale': '',
699
+ name: choiceName,
700
+ relevance: choice.filter
701
+ ? `({${this.currentGroup || 'parent'}} == "${choice.filter}")`
702
+ : '',
703
+ text: this.getLanguageSpecificValue(choice.label, lang) || choiceName,
704
+ help: '',
705
+ language: lang,
706
+ validation: '',
707
+ em_validation_q: '',
708
+ mandatory: '',
709
+ other: '',
710
+ default: '',
711
+ same_default: ''
712
+ });
713
+ }
714
+ }
715
+ }
716
+ async convertRelevance(relevant) {
717
+ if (!relevant)
718
+ return '1';
719
+ return await convertRelevance(relevant);
720
+ }
721
+ }