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.
- package/README.md +107 -47
- package/dist/config/types.js +4 -0
- package/dist/converters/xpathTranspiler.js +19 -5
- package/dist/generateFixtures.js +20 -2
- package/dist/processors/FieldSanitizer.js +64 -1
- package/dist/processors/TSVGenerator.js +3 -2
- package/dist/utils/helpers.js +25 -0
- package/dist/utils/markdownRenderer.js +20 -0
- package/dist/xlsformConverter.js +463 -448
- package/package.json +2 -1
package/dist/xlsformConverter.js
CHANGED
|
@@ -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
|
|
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
|
-
'
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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,
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
*
|
|
524
|
+
* Emit surveyls_welcometext and surveyls_endtext SL rows for a given language.
|
|
545
525
|
*/
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
//
|
|
566
|
-
//
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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.
|
|
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.
|
|
649
|
-
|
|
650
|
-
class: 'Q',
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
802
|
+
return rawName
|
|
777
803
|
? this.sanitizeAnswerCode(rawName)
|
|
778
|
-
: (answerClass === 'SQ' ? `SQ${this.subquestionSeq++}` : `A${this.answerSeq++}`)
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
const
|
|
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
|
-
|
|
835
|
-
|
|
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
|
|
843
|
+
const resolved = this.fieldSanitizer.resolveStrippedName(fieldName);
|
|
843
844
|
const { code } = this.lookupAnswerCode(fieldName, choiceValue);
|
|
844
|
-
const baseType = this.questionBaseTypeMap.get(
|
|
845
|
+
const baseType = this.questionBaseTypeMap.get(resolved);
|
|
845
846
|
if (baseType === 'select_multiple') {
|
|
846
|
-
return `(${
|
|
847
|
+
return `(${resolved}_${code}.NAOK == 'Y')`;
|
|
847
848
|
}
|
|
848
|
-
return `(${
|
|
849
|
+
return `(${resolved}.NAOK=='${code}')`;
|
|
849
850
|
},
|
|
850
851
|
};
|
|
851
|
-
|
|
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
|
}
|