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,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generate TSV files from XLSForm fixtures for integration testing.
4
+ *
5
+ * This script:
6
+ * 1. Reads XLSForm JSON and XLSX fixtures from docker_tests/fixtures/
7
+ * 2. Converts them to LimeSurvey TSV format
8
+ * 3. Saves output to docker_tests/integration/output/
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { fileURLToPath } from 'url';
13
+ import { XLSLoader } from './processors/XLSLoader.js';
14
+ import { XLSFormToTSVConverter } from './xlsformConverter.js';
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const FIXTURES_DIR = path.join(__dirname, '../docker_tests/fixtures');
17
+ const OUTPUT_DIR = path.join(__dirname, '../docker_tests/integration/output');
18
+ function ensureDirectoryExists(dir) {
19
+ if (!fs.existsSync(dir)) {
20
+ fs.mkdirSync(dir, { recursive: true });
21
+ }
22
+ }
23
+ function cleanOutputDirectory(dir) {
24
+ if (fs.existsSync(dir)) {
25
+ // Remove all .tsv files in the output directory
26
+ const files = fs.readdirSync(dir);
27
+ for (const file of files) {
28
+ if (file.endsWith('.tsv')) {
29
+ fs.unlinkSync(path.join(dir, file));
30
+ }
31
+ }
32
+ }
33
+ }
34
+ async function generateTSVFromFixture(fixturePath, outputPath) {
35
+ console.log(`Processing: ${path.basename(fixturePath)}`);
36
+ // Read fixture
37
+ const fixtureContent = fs.readFileSync(fixturePath, 'utf-8');
38
+ const fixture = JSON.parse(fixtureContent);
39
+ // Convert to TSV with configuration that removes underscores but doesn't truncate field names
40
+ // This matches LimeSurvey's behavior (removes underscores but allows longer field names)
41
+ // Answer codes are already limited to 5 chars in the fixtures
42
+ const converter = new XLSFormToTSVConverter({});
43
+ const tsv = await converter.convert(fixture.survey, fixture.choices, fixture.settings);
44
+ // Write output
45
+ fs.writeFileSync(outputPath, tsv, 'utf-8');
46
+ console.log(` → Generated: ${path.basename(outputPath)}`);
47
+ // Print stats
48
+ const lines = tsv.split('\n').filter(l => l.trim()).length;
49
+ console.log(` → ${lines} rows (including header)`);
50
+ }
51
+ async function generateTSVFromXLSX(xlsxPath, outputPath) {
52
+ console.log(`Processing: ${path.basename(xlsxPath)}`);
53
+ // Read and parse xlsx file
54
+ const fileData = fs.readFileSync(xlsxPath);
55
+ const { surveyData, choicesData, settingsData } = XLSLoader.parseXLSData(fileData, { skipValidation: true });
56
+ // Convert to TSV
57
+ const converter = new XLSFormToTSVConverter({});
58
+ const tsv = await converter.convert(surveyData, choicesData, settingsData);
59
+ // Write output
60
+ fs.writeFileSync(outputPath, tsv, 'utf-8');
61
+ console.log(` → Generated: ${path.basename(outputPath)}`);
62
+ // Print stats
63
+ const lines = tsv.split('\n').filter(l => l.trim()).length;
64
+ console.log(` → ${lines} rows (including header)`);
65
+ }
66
+ async function main() {
67
+ console.log('Generating TSV files from XLSForm fixtures...\n');
68
+ // Ensure output directory exists and clean old files
69
+ ensureDirectoryExists(OUTPUT_DIR);
70
+ cleanOutputDirectory(OUTPUT_DIR);
71
+ console.log('Cleaned output directory\n');
72
+ // Find all JSON fixtures
73
+ const jsonFiles = fs.readdirSync(FIXTURES_DIR)
74
+ .filter(file => file.endsWith('.json'))
75
+ .map(file => path.join(FIXTURES_DIR, file));
76
+ // Find all XLSX fixtures
77
+ const xlsxFiles = fs.readdirSync(FIXTURES_DIR)
78
+ .filter(file => file.endsWith('.xlsx'))
79
+ .map(file => path.join(FIXTURES_DIR, file));
80
+ const totalFiles = jsonFiles.length + xlsxFiles.length;
81
+ if (totalFiles === 0) {
82
+ console.error('No fixture files found in', FIXTURES_DIR);
83
+ process.exit(1);
84
+ }
85
+ // Generate TSV for each JSON fixture
86
+ let jsonSuccessCount = 0;
87
+ for (const fixturePath of jsonFiles) {
88
+ const baseName = path.basename(fixturePath, '.json');
89
+ const outputPath = path.join(OUTPUT_DIR, `${baseName}.tsv`);
90
+ try {
91
+ await generateTSVFromFixture(fixturePath, outputPath);
92
+ jsonSuccessCount++;
93
+ }
94
+ catch (error) {
95
+ console.error(` ✗ Error processing ${baseName}:`, error);
96
+ }
97
+ console.log('');
98
+ }
99
+ // Generate TSV for each XLSX fixture
100
+ // XLSX files may contain unimplemented types (e.g. range) — failures are logged but not fatal
101
+ let xlsxSuccessCount = 0;
102
+ for (const xlsxPath of xlsxFiles) {
103
+ const baseName = path.basename(xlsxPath, '.xlsx');
104
+ const outputPath = path.join(OUTPUT_DIR, `${baseName}.tsv`);
105
+ try {
106
+ await generateTSVFromXLSX(xlsxPath, outputPath);
107
+ xlsxSuccessCount++;
108
+ }
109
+ catch (error) {
110
+ console.warn(` ⚠ Skipped ${baseName}:`, error.message);
111
+ }
112
+ console.log('');
113
+ }
114
+ const totalSuccess = jsonSuccessCount + xlsxSuccessCount;
115
+ console.log(`\nFinal Summary: ${totalSuccess}/${totalFiles} files generated successfully`);
116
+ // Only fail if JSON fixtures (which should always succeed) had errors
117
+ if (jsonSuccessCount < jsonFiles.length) {
118
+ process.exit(1);
119
+ }
120
+ }
121
+ void (async () => {
122
+ await main();
123
+ })();
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { XLSFormToTSVConverter } from './xlsformConverter.js';
2
+ export { convertRelevance, convertConstraint, xpathToLimeSurvey } from './converters/xpathTranspiler.js';
3
+ export { ConfigManager } from './config/ConfigManager.js';
4
+ export { defaultConfig } from './config/types.js';
5
+ export { FieldSanitizer } from './processors/FieldSanitizer.js';
6
+ export { TypeMapper, TYPE_MAPPINGS } from './processors/TypeMapper.js';
7
+ export { TSVGenerator } from './processors/TSVGenerator.js';
8
+ export { XLSFormParser } from './processors/XLSFormParser.js';
9
+ export { XLSLoader } from './processors/XLSLoader.js';
10
+ export { XLSValidator } from './processors/XLSValidator.js';
@@ -0,0 +1,21 @@
1
+ import { sanitizeFieldName } from '../utils/helpers.js';
2
+ export class FieldSanitizer {
3
+ constructor() { }
4
+ sanitizeName(name) {
5
+ return sanitizeFieldName(name);
6
+ }
7
+ sanitizeAnswerCode(code) {
8
+ // Answer codes in LimeSurvey have a 5-character limit
9
+ let result = code;
10
+ // Always remove underscores and hyphens (LimeSurvey only allows alphanumeric)
11
+ result = result.replace(/[_-]/g, '');
12
+ // Limit to 5 characters (LimeSurvey answer codes limit)
13
+ const maxLength = 5;
14
+ if (result.length > maxLength) {
15
+ const truncated = result.substring(0, maxLength);
16
+ console.warn(`Answer code "${code}" exceeds maximum length of ${maxLength} characters and will be truncated to "${truncated}"`);
17
+ return truncated;
18
+ }
19
+ return result;
20
+ }
21
+ }
@@ -0,0 +1,52 @@
1
+ export class TSVGenerator {
2
+ constructor() {
3
+ this.rows = [];
4
+ }
5
+ addRow(row) {
6
+ this.rows.push(row);
7
+ }
8
+ // https://www.limesurvey.org/manual/Tab_Separated_Value_survey_structure
9
+ generateTSV() {
10
+ const headers = [
11
+ 'class',
12
+ 'type/scale',
13
+ 'name',
14
+ 'relevance',
15
+ 'text',
16
+ 'help',
17
+ 'language',
18
+ 'validation',
19
+ 'em_validation_q',
20
+ 'mandatory',
21
+ 'other',
22
+ 'default',
23
+ 'same_default'
24
+ ];
25
+ const lines = [headers.join('\t')];
26
+ for (const row of this.rows) {
27
+ const values = headers.map((h) => this.escapeForTSV(row[h] || ''));
28
+ lines.push(values.join('\t'));
29
+ }
30
+ return lines.join('\n');
31
+ }
32
+ escapeForTSV(value) {
33
+ // Escape tabs, newlines, and wrap in quotes if needed
34
+ if (typeof value !== 'string')
35
+ value = String(value);
36
+ // Replace newlines with <br /> for LimeSurvey compatibility.
37
+ // LimeSurvey's TSV importer parses line-by-line and does not handle
38
+ // RFC 4180 multi-line quoted fields. HTML breaks render correctly
39
+ // in LimeSurvey's question text and help fields.
40
+ value = value.replace(/\r\n/g, '<br />').replace(/\n/g, '<br />');
41
+ if (value.includes('\t') || value.includes('"')) {
42
+ return '"' + value.replace(/"/g, '""') + '"';
43
+ }
44
+ return value;
45
+ }
46
+ clear() {
47
+ this.rows = [];
48
+ }
49
+ getRowCount() {
50
+ return this.rows.length;
51
+ }
52
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @file Mapping between XLSForm question types and LimeSurvey question types.
3
+ * @description See https://xlsform.org/en/ref-table/#types and
4
+ * https://www.limesurvey.org/manual/Tab_Separated_Value_survey_structure
5
+ */
6
+ export const TYPE_MAPPINGS = {
7
+ // Text types
8
+ text: { limeSurveyType: 'S' },
9
+ string: { limeSurveyType: 'S' },
10
+ // Numeric types
11
+ integer: { limeSurveyType: 'N' },
12
+ int: { limeSurveyType: 'N' },
13
+ decimal: { limeSurveyType: 'N' },
14
+ // Date/time
15
+ date: { limeSurveyType: 'D' },
16
+ time: { limeSurveyType: 'D' },
17
+ datetime: { limeSurveyType: 'D' },
18
+ // Select types
19
+ select_one: { limeSurveyType: 'L', supportsOther: true, answerClass: 'A' },
20
+ select_multiple: { limeSurveyType: 'M', supportsOther: true, answerClass: 'SQ' },
21
+ // Other types
22
+ note: { limeSurveyType: 'X' },
23
+ rank: { limeSurveyType: 'R', answerClass: 'A', supportsOther: true },
24
+ };
25
+ export class TypeMapper {
26
+ parseType(typeStr) {
27
+ const parts = typeStr.split(/\s+/);
28
+ const base = parts[0];
29
+ let listName = null;
30
+ let orOther = false;
31
+ if (base === 'select_one' || base === 'select_multiple' || base === 'rank') {
32
+ listName = parts[1] || null;
33
+ orOther = parts.includes('or_other');
34
+ }
35
+ return { base, listName, orOther };
36
+ }
37
+ mapType(typeInfo) {
38
+ const mapping = TYPE_MAPPINGS[typeInfo.base];
39
+ if (!mapping) {
40
+ // Fallback to text type for unknown types
41
+ console.warn(`No type mapping found for "${typeInfo.base}", defaulting to text type`);
42
+ return { type: 'S' };
43
+ }
44
+ const result = {
45
+ type: mapping.limeSurveyType,
46
+ };
47
+ if (mapping.supportsOther && typeInfo.orOther) {
48
+ result.other = true;
49
+ }
50
+ if (mapping.answerClass) {
51
+ result.answerClass = mapping.answerClass;
52
+ }
53
+ return result;
54
+ }
55
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @file Main entrypoint of this library.
3
+ */
4
+ import { XLSLoader } from './XLSLoader.js';
5
+ export class XLSFormParser {
6
+ /**
7
+ * Convert XLS/XLSX file to TSV using the main converter
8
+ * @param filePath Path to XLS or XLSX file
9
+ * @param config Optional configuration
10
+ * @returns TSV string
11
+ */
12
+ static async convertXLSFileToTSV(filePath, config) {
13
+ const { XLSFormToTSVConverter } = await import('../xlsformConverter.js');
14
+ // Load data (validation is included by default)
15
+ const { surveyData, choicesData, settingsData } = XLSLoader.parseXLSFile(filePath);
16
+ const converter = new XLSFormToTSVConverter(config);
17
+ return await converter.convert(surveyData, choicesData, settingsData);
18
+ }
19
+ /**
20
+ * Convert XLS/XLSX data to TSV using the main converter
21
+ * @param data XLS or XLSX file data
22
+ * @param config Optional configuration
23
+ * @returns TSV string
24
+ */
25
+ static async convertXLSDataToTSV(data, config) {
26
+ const { XLSFormToTSVConverter } = await import('../xlsformConverter.js');
27
+ // Load data (validation is included by default)
28
+ const { surveyData, choicesData, settingsData } = XLSLoader.parseXLSData(data);
29
+ const converter = new XLSFormToTSVConverter(config);
30
+ return await converter.convert(surveyData, choicesData, settingsData);
31
+ }
32
+ }
@@ -0,0 +1,109 @@
1
+ import * as XLSX from 'xlsx';
2
+ import { extractBaseColumnName, extractLanguageCode, getLanguageCodesFromHeaders, validateLanguageCodes, isValidLanguageCode } from '../utils/languageUtils.js';
3
+ import { XLSValidator } from './XLSValidator.js';
4
+ export class XLSLoader {
5
+ /**
6
+ * Parse XLS/XLSX file and extract survey data with validation
7
+ * @param filePath Path to XLS or XLSX file
8
+ * @param options Validation options
9
+ * @returns Object containing validated survey, choices, and settings data
10
+ * @throws Error if required sheets or columns are missing
11
+ */
12
+ static parseXLSFile(filePath, options = {}) {
13
+ const workbook = XLSX.readFile(filePath);
14
+ return this.parseWorkbook(workbook, options);
15
+ }
16
+ /**
17
+ * Parse XLS/XLSX data (Buffer or ArrayBuffer) with validation
18
+ * @param data XLS or XLSX file data
19
+ * @param options Validation options
20
+ * @returns Object containing validated survey, choices, and settings data
21
+ * @throws Error if required sheets or columns are missing
22
+ */
23
+ static parseXLSData(data, options = {}) {
24
+ const workbook = XLSX.read(data);
25
+ return this.parseWorkbook(workbook, options);
26
+ }
27
+ /**
28
+ * Parse XLSX workbook with validation
29
+ * @param workbook XLSX workbook object
30
+ * @param options Validation options
31
+ * @returns Object containing validated survey, choices, and settings data
32
+ * @throws Error if required sheets or columns are missing
33
+ */
34
+ static parseWorkbook(workbook, options = {}) {
35
+ const surveyData = [];
36
+ const choicesData = [];
37
+ const settingsData = [];
38
+ // Track which sheets we've found
39
+ let hasSurveySheet = false;
40
+ let hasChoicesSheet = false;
41
+ let hasSettingsSheet = false;
42
+ // Process each sheet in the workbook
43
+ workbook.SheetNames.forEach(sheetName => {
44
+ const sheet = workbook.Sheets[sheetName];
45
+ const jsonData = XLSX.utils.sheet_to_json(sheet, { header: 1 });
46
+ if (!jsonData || jsonData.length === 0) {
47
+ return;
48
+ }
49
+ // Convert to objects with proper headers
50
+ const headers = jsonData[0];
51
+ const rows = jsonData.slice(1);
52
+ // Detect language codes from headers
53
+ const languageCodes = getLanguageCodesFromHeaders(headers);
54
+ // Validate language codes
55
+ const invalidLanguageCodes = validateLanguageCodes(languageCodes);
56
+ if (invalidLanguageCodes.length > 0) {
57
+ console.warn(`Warning: Invalid language codes detected in sheet "${sheetName}": ${invalidLanguageCodes.join(', ')}. These will be ignored. Valid language codes should be 2-letter IANA subtags (e.g., 'en', 'es', 'fr').`);
58
+ }
59
+ const sheetData = rows.map(row => {
60
+ const obj = {};
61
+ headers.forEach((header, index) => {
62
+ if (header && row[index] !== undefined) {
63
+ const baseColumn = extractBaseColumnName(header);
64
+ const langCode = extractLanguageCode(header);
65
+ // Store language-specific values in a structured way
66
+ if (langCode) {
67
+ // Only store valid language codes
68
+ if (isValidLanguageCode(langCode)) {
69
+ if (!obj[baseColumn]) {
70
+ obj[baseColumn] = {};
71
+ }
72
+ obj[baseColumn][langCode] = row[index];
73
+ }
74
+ }
75
+ else {
76
+ // Regular column
77
+ obj[header] = row[index];
78
+ }
79
+ }
80
+ });
81
+ // Store detected languages for this sheet (only valid ones)
82
+ const validLanguages = languageCodes.filter(isValidLanguageCode);
83
+ if (validLanguages.length > 0) {
84
+ obj._languages = validLanguages;
85
+ }
86
+ return obj;
87
+ });
88
+ // Match standard XLSForm sheet names (exact match, case-insensitive)
89
+ const lowerCaseSheetName = sheetName.toLowerCase().trim();
90
+ if (lowerCaseSheetName === 'survey') {
91
+ hasSurveySheet = true;
92
+ sheetData.forEach(row => surveyData.push(row));
93
+ }
94
+ else if (lowerCaseSheetName === 'choices' || lowerCaseSheetName === 'choice') {
95
+ hasChoicesSheet = true;
96
+ sheetData.forEach(row => choicesData.push(row));
97
+ }
98
+ else if (lowerCaseSheetName === 'settings' || lowerCaseSheetName === 'setting') {
99
+ hasSettingsSheet = true;
100
+ sheetData.forEach(row => settingsData.push(row));
101
+ }
102
+ });
103
+ // Validate by default, unless explicitly skipped
104
+ if (!options.skipValidation) {
105
+ XLSValidator.validateAll(surveyData, choicesData, hasSurveySheet, hasChoicesSheet);
106
+ }
107
+ return { surveyData, choicesData, settingsData, hasSurveySheet, hasChoicesSheet, hasSettingsSheet };
108
+ }
109
+ }
@@ -0,0 +1,121 @@
1
+ export class XLSValidator {
2
+ /**
3
+ * Validate that required sheets are present
4
+ * @param hasSurveySheet Whether survey sheet was found
5
+ * @param hasChoicesSheet Whether choices sheet was found
6
+ * @throws Error if required sheets are missing
7
+ */
8
+ static validateRequiredSheets(hasSurveySheet, hasChoicesSheet) {
9
+ const missingSheets = [];
10
+ if (!hasSurveySheet)
11
+ missingSheets.push('survey');
12
+ if (!hasChoicesSheet)
13
+ missingSheets.push('choices');
14
+ if (missingSheets.length > 0) {
15
+ throw new Error(`XLSX file is missing required sheets: ${missingSheets.join(', ')}. An XLSForm must contain survey and choices sheets.`);
16
+ }
17
+ }
18
+ /**
19
+ * Validate that survey sheet has required columns
20
+ * @param data Survey data
21
+ * @param sheetName Name of the survey sheet
22
+ * @throws Error if required columns are missing
23
+ */
24
+ static validateSurveySheetColumns(data, sheetName) {
25
+ if (data.length === 0) {
26
+ console.warn(`Warning: Survey sheet "${sheetName}" is empty.`);
27
+ return;
28
+ }
29
+ // Collect all column keys that appear across any row
30
+ const allColumns = new Set();
31
+ for (const row of data) {
32
+ if (row && typeof row === 'object') {
33
+ for (const key of Object.keys(row)) {
34
+ allColumns.add(key);
35
+ }
36
+ }
37
+ }
38
+ if (allColumns.size === 0) {
39
+ console.warn(`Warning: Survey sheet "${sheetName}" has no valid data rows.`);
40
+ return;
41
+ }
42
+ // Check for required columns across all rows
43
+ const requiredColumns = ['type', 'name', 'label'];
44
+ const missingColumns = requiredColumns.filter(col => !allColumns.has(col));
45
+ if (missingColumns.length > 0) {
46
+ throw new Error(`Survey sheet "${sheetName}" is missing required columns: ${missingColumns.join(', ')}. A survey sheet must contain type, name, and label columns.`);
47
+ }
48
+ // Warn about unexpected columns
49
+ const expectedColumns = ['type', 'name', 'label', 'hint', 'required', 'relevant', 'constraint', 'constraint_message', 'calculation', 'default', 'appearance', 'parameters'];
50
+ const unexpectedColumns = [...allColumns].filter(col => !expectedColumns.includes(col) && !col.startsWith('_'));
51
+ if (unexpectedColumns.length > 0) {
52
+ console.warn(`Warning: Survey sheet "${sheetName}" contains unexpected columns: ${unexpectedColumns.join(', ')}. These columns will be ignored.`);
53
+ }
54
+ }
55
+ /**
56
+ * Validate that choices sheet has required columns
57
+ * @param data Choices data
58
+ * @param sheetName Name of the choices sheet
59
+ * @throws Error if required columns are missing
60
+ */
61
+ static validateChoicesSheetColumns(data, sheetName) {
62
+ if (data.length === 0) {
63
+ console.warn(`Warning: Choices sheet "${sheetName}" is empty.`);
64
+ return;
65
+ }
66
+ // Collect all column keys that appear across any row
67
+ const allColumns = new Set();
68
+ for (const row of data) {
69
+ if (row && typeof row === 'object') {
70
+ for (const key of Object.keys(row)) {
71
+ allColumns.add(key);
72
+ }
73
+ }
74
+ }
75
+ if (allColumns.size === 0) {
76
+ console.warn(`Warning: Choices sheet "${sheetName}" has no valid data rows.`);
77
+ return;
78
+ }
79
+ // Check for required columns (handle both list_name and list name variations)
80
+ const hasListName = allColumns.has('list_name') || allColumns.has('list name');
81
+ const hasName = allColumns.has('name');
82
+ const hasLabel = allColumns.has('label');
83
+ const missingColumns = [];
84
+ if (!hasListName)
85
+ missingColumns.push('list_name');
86
+ if (!hasName)
87
+ missingColumns.push('name');
88
+ if (!hasLabel)
89
+ missingColumns.push('label');
90
+ if (missingColumns.length > 0) {
91
+ throw new Error(`Choices sheet "${sheetName}" is missing required columns: ${missingColumns.join(', ')}. A choices sheet must contain list_name, name, and label columns.`);
92
+ }
93
+ // Warn about unexpected columns
94
+ const expectedColumns = ['list_name', 'list name', 'name', 'label', 'filter'];
95
+ const unexpectedColumns = [...allColumns].filter(col => !expectedColumns.includes(col) && !col.startsWith('_'));
96
+ if (unexpectedColumns.length > 0) {
97
+ console.warn(`Warning: Choices sheet "${sheetName}" contains unexpected columns: ${unexpectedColumns.join(', ')}. These columns will be ignored.`);
98
+ }
99
+ }
100
+ /**
101
+ * Validate all sheets in the parsed data
102
+ * @param surveyData Survey data
103
+ * @param choicesData Choices data
104
+ * @param hasSurveySheet Whether survey sheet was found
105
+ * @param hasChoicesSheet Whether choices sheet was found
106
+ * @param surveySheetName Name of the survey sheet
107
+ * @param choicesSheetName Name of the choices sheet
108
+ */
109
+ static validateAll(surveyData, choicesData, hasSurveySheet, hasChoicesSheet, surveySheetName = 'survey', choicesSheetName = 'choices') {
110
+ // Validate required sheets
111
+ this.validateRequiredSheets(hasSurveySheet, hasChoicesSheet);
112
+ // Validate survey sheet columns
113
+ if (hasSurveySheet && surveyData.length > 0) {
114
+ this.validateSurveySheetColumns(surveyData, surveySheetName);
115
+ }
116
+ // Validate choices sheet columns
117
+ if (hasChoicesSheet && choicesData.length > 0) {
118
+ this.validateChoicesSheetColumns(choicesData, choicesSheetName);
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Deep merge objects - merges properties from source into target recursively
3
+ */
4
+ export function deepMerge(target, ...sources) {
5
+ if (!sources.length)
6
+ return target;
7
+ const source = sources.shift();
8
+ if (isObject(target) && isObject(source)) {
9
+ for (const key in source) {
10
+ if (isObject(source[key])) {
11
+ if (!target[key]) {
12
+ target[key] = {};
13
+ }
14
+ deepMerge(target[key], source[key]);
15
+ }
16
+ else {
17
+ target[key] = source[key];
18
+ }
19
+ }
20
+ }
21
+ return deepMerge(target, ...sources);
22
+ }
23
+ function isObject(item) {
24
+ return item !== null && typeof item === 'object' && !Array.isArray(item);
25
+ }
26
+ /**
27
+ * Sanitize field names for LimeSurvey compatibility
28
+ * Removes underscores/hyphens and truncates to 20 characters (LimeSurvey question title limit)
29
+ */
30
+ export function sanitizeFieldName(name) {
31
+ let result = name;
32
+ // Always remove underscores and hyphens (LimeSurvey only allows alphanumeric)
33
+ result = result.replace(/[_-]/g, '');
34
+ // Limit to 20 characters (LimeSurvey questions.title field limit)
35
+ const maxLength = 20;
36
+ if (result.length > maxLength) {
37
+ const truncated = result.substring(0, maxLength);
38
+ console.warn(`Field name "${name}" exceeds maximum length of ${maxLength} characters and will be truncated to "${truncated}"`);
39
+ return truncated;
40
+ }
41
+ return result;
42
+ }