xlsform2lstsv 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -0
- package/dist/config/ConfigManager.js +43 -0
- package/dist/config/types.js +13 -0
- package/dist/converters/xpathTranspiler.js +403 -0
- package/dist/generateFixtures.js +123 -0
- package/dist/index.js +10 -0
- package/dist/processors/FieldSanitizer.js +21 -0
- package/dist/processors/TSVGenerator.js +52 -0
- package/dist/processors/TypeMapper.js +55 -0
- package/dist/processors/XLSFormParser.js +32 -0
- package/dist/processors/XLSLoader.js +109 -0
- package/dist/processors/XLSValidator.js +121 -0
- package/dist/utils/helpers.js +42 -0
- package/dist/utils/languageUtils.js +141 -0
- package/dist/xlsformConverter.js +721 -0
- package/package.json +76 -0
|
@@ -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
|
+
}
|