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,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
|
+
}
|