diff --git a/src/cli.ts b/src/cli.ts index 9479091..d868914 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,7 +7,7 @@ import { lstsvToDataCsv, lstsvToDdiXml } from './pipelines/lstsv2ddi/index.js'; import { lstsvToXlsform } from './pipelines/lstsv2xlsform/index.js'; import type { Submission } from './pipelines/xlsform2ddi/index.js'; import { XLSFormToTSVConverter } from './pipelines/xlsform2lstsv/index.js'; -import type { XLSFormData } from './config/types.js'; +import type { XLSFormData } from './xlsform/types.js'; import { XLSValidator } from './xlsform/validate.js'; import type { SubsetTarget } from './xlsform/validate.js'; import { diff --git a/src/cliShared.ts b/src/cliShared.ts index 3e7f31e..dedd316 100644 --- a/src/cliShared.ts +++ b/src/cliShared.ts @@ -6,7 +6,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { parseArgs, ParseArgsConfig } from 'node:util'; -import { XLSFormData } from './config/types.js'; +import { XLSFormData } from './xlsform/types.js'; import type { BuildDdiOptions } from './ddi/codebook.js'; import { buildDataCsv, diff --git a/src/config/ConfigManager.ts b/src/config/ConfigManager.ts index ee16341..eeb2589 100644 --- a/src/config/ConfigManager.ts +++ b/src/config/ConfigManager.ts @@ -1,64 +1,34 @@ -import { deepMerge } from '../utils/helpers.js'; - -import { ConversionConfig, defaultConfig } from './types.js'; +import { resolveConfig } from './resolveConfig.js'; +import { LstsvConfig } from './types.js'; export { ConversionConfig } from './types.js'; +/** + * @deprecated Use {@link resolveConfig}. Kept as a thin wrapper so existing + * consumers keep working; it will go with the public-API cleanup. + */ export class ConfigManager { - private config: ConversionConfig; - - constructor(config?: Partial) { - this.config = this.mergeConfig(config || {}); - } + private config: Readonly; - private mergeConfig( - partialConfig: Partial, - ): ConversionConfig { - return deepMerge(structuredClone(defaultConfig), partialConfig); + constructor(config?: Partial) { + this.config = resolveConfig(config); } - getConfig(): ConversionConfig { + getConfig(): Readonly { return this.config; } - getDefaults(): ConversionConfig['defaults'] { + getDefaults(): LstsvConfig['defaults'] { return this.config.defaults; } - getAdvancedOptions() { - return { - autoCreateGroups: true, // Always auto-create groups (hardcoded) - handleRepeats: this.config.handleRepeats ?? 'warn', - debugLogging: this.config.debugLogging ?? false, - }; + /** Replaces the options: `partialConfig` merged over the defaults (as before). */ + updateConfig(partialConfig: Partial): void { + this.config = resolveConfig(partialConfig); } - /** - * Update configuration at runtime - */ - updateConfig(partialConfig: Partial): void { - this.config = this.mergeConfig(partialConfig); - } - - /** - * Validate configuration - */ + /** Validation now happens in {@link resolveConfig}; kept for compatibility. */ validateConfig(): void { - const { defaults } = this.config; - - // Validate handleRepeats if provided - if ( - this.config.handleRepeats && - !['warn', 'error', 'ignore'].includes(this.config.handleRepeats) - ) { - throw new Error( - `Invalid handleRepeats option: ${this.config.handleRepeats}`, - ); - } - - // Validate defaults - if (!defaults.language || defaults.language.length !== 2) { - throw new Error('defaults.language must be a 2-character language code'); - } + resolveConfig(this.config); } } diff --git a/src/config/resolveConfig.ts b/src/config/resolveConfig.ts new file mode 100644 index 0000000..774116a --- /dev/null +++ b/src/config/resolveConfig.ts @@ -0,0 +1,27 @@ +import { deepMerge } from '../utils/helpers.js'; + +import { defaultConfig, LstsvConfig } from './types.js'; + +const REPEAT_MODES = ['warn', 'error', 'ignore']; + +/** + * Merge a partial config over the defaults and validate it, in one step. The + * result is frozen: a conversion reads its options, it never changes them. + */ +export function resolveConfig( + partial: Partial = {}, +): Readonly { + const config = deepMerge(structuredClone(defaultConfig), partial); + + if (config.handleRepeats && !REPEAT_MODES.includes(config.handleRepeats)) { + throw new Error(`Invalid handleRepeats option: ${config.handleRepeats}`); + } + // The whole xlsform2lstsv pipeline handles 2-letter codes only + // (isValidLanguageCode), although convention:languageTagging allows BCP 47 + // tags such as fr-BE; tracked separately. + if (!config.defaults.language || config.defaults.language.length !== 2) { + throw new Error('defaults.language must be a 2-character language code'); + } + + return Object.freeze(config); +} diff --git a/src/config/types.ts b/src/config/types.ts index dde6380..dff545f 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,65 +1,25 @@ /** - * Represents a row in the survey section of an XLSForm + * Configuration for the XLSForm → LimeSurvey TSV conversion. + * + * The XLSForm row types used to live here; they are in `src/xlsform/types.ts` + * now and re-exported below for compatibility. */ -export interface SurveyRow { - type?: string; - name?: string; - label?: string | Record; - hint?: string | Record; - required?: string; - relevant?: string; - constraint?: string; - constraint_message?: string; - calculation?: string; - default?: string; - _languages?: string[]; - [key: string]: unknown; -} - -/** - * Represents a row in the choices section of an XLSForm - */ -export interface ChoiceRow { - list_name?: string; - name?: string; - label?: string | Record; - filter?: string; - _languages?: string[]; - [key: string]: unknown; -} - -/** - * Represents a row in the settings section of an XLSForm - */ -export interface SettingsRow { - form_title?: string; - form_id?: string; - default_language?: string; - style?: string; - [key: string]: unknown; -} +export type { + SurveyRow, + ChoiceRow, + SettingsRow, + XLSFormData, +} from '../xlsform/types.js'; /** - * Result type returned by XLS/XLSX loaders + * Options for the XLSForm → LimeSurvey TSV conversion (`xlsform2lstsv`). The + * other directions take their own option objects. */ -export interface XLSFormData { - surveyData: SurveyRow[]; - choicesData: ChoiceRow[]; - settingsData: SettingsRow[]; - hasSurveySheet: boolean; - hasChoicesSheet: boolean; - hasSettingsSheet: boolean; -} - -export interface ConversionConfig { - /** - * How to handle repeats: 'warn', 'error', or 'ignore' (default: 'warn') - */ +export interface LstsvConfig { + /** @deprecated Never read; accepted so existing callers keep compiling. */ handleRepeats?: 'warn' | 'error' | 'ignore'; - /** - * Enable debug logging (default: false) - */ + /** @deprecated Never read; accepted so existing callers keep compiling. */ debugLogging?: boolean; /** @@ -110,7 +70,7 @@ export interface ConversionConfig { /** * Default configuration with sensible defaults */ -export const defaultConfig: ConversionConfig = { +export const defaultConfig: LstsvConfig = { handleRepeats: 'warn', debugLogging: false, convertWelcomeNote: true, @@ -127,3 +87,6 @@ export const defaultConfig: ConversionConfig = { description: '', }, }; + +/** @deprecated Use {@link LstsvConfig}. */ +export type ConversionConfig = LstsvConfig; diff --git a/src/fileChoices.ts b/src/fileChoices.ts index 77c8d82..1c5c600 100644 --- a/src/fileChoices.ts +++ b/src/fileChoices.ts @@ -11,7 +11,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { ChoiceRow, SurveyRow } from './config/types.js'; +import type { ChoiceRow, SurveyRow } from './xlsform/types.js'; import { parseVocabCsv, referencedVocabFiles } from './vocab.js'; /** diff --git a/src/generateFixtures.ts b/src/generateFixtures.ts index e98be76..1885df0 100644 --- a/src/generateFixtures.ts +++ b/src/generateFixtures.ts @@ -18,12 +18,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; -import { - SurveyRow, - ChoiceRow, - SettingsRow, - ConversionConfig, -} from './config/types.js'; +import { LstsvConfig } from './config/types.js'; +import { SurveyRow, ChoiceRow, SettingsRow } from './xlsform/types.js'; import { XLSLoader } from './xlsform/loader.js'; import { XLSFormToTSVConverter } from './pipelines/xlsform2lstsv/index.js'; @@ -58,7 +54,7 @@ function cleanOutputDirectory(dir: string): void { async function generateTSVFromFixture( fixturePath: string, outputPath: string, - config: Partial = {}, + config: Partial = {}, ): Promise { console.log(`Processing: ${path.basename(fixturePath)}`); diff --git a/src/index.ts b/src/index.ts index ecdf886..936ff31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,3 +83,11 @@ export type { // ── Config ───────────────────────────────────────────────────────────── export { ConfigManager, ConversionConfig } from './config/ConfigManager.js'; export { defaultConfig } from './config/types.js'; +export type { LstsvConfig } from './config/types.js'; +export { resolveConfig } from './config/resolveConfig.js'; +export type { + SurveyRow, + ChoiceRow, + SettingsRow, + XLSFormData, +} from './xlsform/types.js'; diff --git a/src/pipelines/lstsv2xlsform/toXlsform.ts b/src/pipelines/lstsv2xlsform/toXlsform.ts index a50ef2b..f84ef86 100644 --- a/src/pipelines/lstsv2xlsform/toXlsform.ts +++ b/src/pipelines/lstsv2xlsform/toXlsform.ts @@ -25,7 +25,7 @@ */ import { defaultConfig } from '../../config/types.js'; -import type { SurveyRow, ChoiceRow, SettingsRow } from '../../config/types.js'; +import type { SurveyRow, ChoiceRow, SettingsRow } from '../../xlsform/types.js'; import { APPEARANCES } from '../../generated/Appearances.js'; import { TYPE_MAPPINGS } from '../../generated/TypeMappings.js'; import { EXCLUSIVE_RULE } from '../../xlsform/exclusive.js'; diff --git a/src/pipelines/xlsform2lstsv/choiceManager.ts b/src/pipelines/xlsform2lstsv/choiceManager.ts index 22a631a..fb32808 100644 --- a/src/pipelines/xlsform2lstsv/choiceManager.ts +++ b/src/pipelines/xlsform2lstsv/choiceManager.ts @@ -1,4 +1,4 @@ -import { ChoiceRow, SurveyRow } from '../../config/types.js'; +import { ChoiceRow, SurveyRow } from '../../xlsform/types.js'; import { normalizeName } from '../../xlsform/identifiers.js'; import { FieldSanitizer } from '../../xlsform/sanitize.js'; import { TypeInfo } from './typeMapper.js'; diff --git a/src/pipelines/xlsform2lstsv/fieldNameHandler.ts b/src/pipelines/xlsform2lstsv/fieldNameHandler.ts index 297c9f3..44242d9 100644 --- a/src/pipelines/xlsform2lstsv/fieldNameHandler.ts +++ b/src/pipelines/xlsform2lstsv/fieldNameHandler.ts @@ -1,4 +1,4 @@ -import { SurveyRow } from '../../config/types.js'; +import { SurveyRow } from '../../xlsform/types.js'; import { normalizeName } from '../../xlsform/identifiers.js'; import { FieldSanitizer } from '../../xlsform/sanitize.js'; diff --git a/src/pipelines/xlsform2lstsv/groupEmitter.ts b/src/pipelines/xlsform2lstsv/groupEmitter.ts index ede2098..017a074 100644 --- a/src/pipelines/xlsform2lstsv/groupEmitter.ts +++ b/src/pipelines/xlsform2lstsv/groupEmitter.ts @@ -1,4 +1,4 @@ -import { SurveyRow } from '../../config/types.js'; +import { SurveyRow } from '../../xlsform/types.js'; import { ConfigManager } from '../../config/ConfigManager.js'; import { RowEmitter } from './rowEmitter.js'; import { LanguageHandler } from './languageHandler.js'; diff --git a/src/pipelines/xlsform2lstsv/groupProcessor.ts b/src/pipelines/xlsform2lstsv/groupProcessor.ts index 3595f53..095c5c1 100644 --- a/src/pipelines/xlsform2lstsv/groupProcessor.ts +++ b/src/pipelines/xlsform2lstsv/groupProcessor.ts @@ -1,4 +1,4 @@ -import { SurveyRow } from '../../config/types.js'; +import { SurveyRow } from '../../xlsform/types.js'; import { ConfigManager } from '../../config/ConfigManager.js'; import { SKIP_TYPES } from './constants.js'; diff --git a/src/pipelines/xlsform2lstsv/index.ts b/src/pipelines/xlsform2lstsv/index.ts index a6ba2da..5091c94 100644 --- a/src/pipelines/xlsform2lstsv/index.ts +++ b/src/pipelines/xlsform2lstsv/index.ts @@ -1,5 +1,6 @@ -import { ConfigManager, ConversionConfig } from '../../config/ConfigManager.js'; -import { SurveyRow, ChoiceRow, SettingsRow } from '../../config/types.js'; +import { ConfigManager } from '../../config/ConfigManager.js'; +import type { LstsvConfig } from '../../config/types.js'; +import { SurveyRow, ChoiceRow, SettingsRow } from '../../xlsform/types.js'; import { FieldSanitizer } from '../../xlsform/sanitize.js'; import { TSVGenerator } from '../../lstsv/serialize.js'; import { TypeMapper, TYPE_MAPPINGS } from './typeMapper.js'; @@ -58,9 +59,8 @@ export class XLSFormToTSVConverter { private surveySettingsEmitter: SurveySettingsEmitter; private surveyDataCache: SurveyRow[] = []; - constructor(config?: Partial) { + constructor(config?: Partial) { this.configManager = new ConfigManager(config); - this.configManager.validateConfig(); this.fieldSanitizer = new FieldSanitizer(); this.choiceManager = new ChoiceManager(this.fieldSanitizer); @@ -135,14 +135,14 @@ export class XLSFormToTSVConverter { /** * Get the current configuration */ - getConfig(): ConversionConfig { + getConfig(): Readonly { return this.configManager.getConfig(); } /** * Update configuration at runtime */ - updateConfig(partialConfig: Partial): void { + updateConfig(partialConfig: Partial): void { this.configManager.updateConfig(partialConfig); } @@ -216,9 +216,8 @@ export class XLSFormToTSVConverter { return xfType === 'begin_group'; }); - // If no groups, add a default group - const advancedOptions = this.configManager.getAdvancedOptions(); - if (!hasGroups && advancedOptions.autoCreateGroups) { + // LimeSurvey needs every question in a group; add one if the form has none. + if (!hasGroups) { this.groupEmitter.addDefaultGroup(); } diff --git a/src/pipelines/xlsform2lstsv/languageHandler.ts b/src/pipelines/xlsform2lstsv/languageHandler.ts index e9954f3..06ce7c4 100644 --- a/src/pipelines/xlsform2lstsv/languageHandler.ts +++ b/src/pipelines/xlsform2lstsv/languageHandler.ts @@ -1,4 +1,4 @@ -import { SurveyRow, ChoiceRow, SettingsRow } from '../../config/types.js'; +import { SurveyRow, ChoiceRow, SettingsRow } from '../../xlsform/types.js'; import { getBaseLanguage } from '../../utils/languageUtils.js'; import { markdownToHtml } from '../../utils/markdownRenderer.js'; import { ConfigManager } from '../../config/ConfigManager.js'; diff --git a/src/pipelines/xlsform2lstsv/matrixHandler.ts b/src/pipelines/xlsform2lstsv/matrixHandler.ts index 9c05a97..1330951 100644 --- a/src/pipelines/xlsform2lstsv/matrixHandler.ts +++ b/src/pipelines/xlsform2lstsv/matrixHandler.ts @@ -1,4 +1,4 @@ -import { SurveyRow } from '../../config/types.js'; +import { SurveyRow } from '../../xlsform/types.js'; import { TypeInfo } from './typeMapper.js'; import { RowEmitter } from './rowEmitter.js'; import { LanguageHandler } from './languageHandler.js'; diff --git a/src/pipelines/xlsform2lstsv/otherPatternDetector.ts b/src/pipelines/xlsform2lstsv/otherPatternDetector.ts index 10e0968..bc821a9 100644 --- a/src/pipelines/xlsform2lstsv/otherPatternDetector.ts +++ b/src/pipelines/xlsform2lstsv/otherPatternDetector.ts @@ -1,4 +1,4 @@ -import { SurveyRow, ChoiceRow } from '../../config/types.js'; +import { SurveyRow, ChoiceRow } from '../../xlsform/types.js'; import { TypeInfo } from './typeMapper.js'; import { ChoiceManager } from './choiceManager.js'; import { LanguageHandler } from './languageHandler.js'; diff --git a/src/pipelines/xlsform2lstsv/surveySettingsEmitter.ts b/src/pipelines/xlsform2lstsv/surveySettingsEmitter.ts index e6c88d7..2104a52 100644 --- a/src/pipelines/xlsform2lstsv/surveySettingsEmitter.ts +++ b/src/pipelines/xlsform2lstsv/surveySettingsEmitter.ts @@ -1,4 +1,4 @@ -import { SurveyRow, SettingsRow } from '../../config/types.js'; +import { SurveyRow, SettingsRow } from '../../xlsform/types.js'; import { RowEmitter } from './rowEmitter.js'; import { LanguageHandler } from './languageHandler.js'; import { ConfigManager } from '../../config/ConfigManager.js'; diff --git a/src/vocab.ts b/src/vocab.ts index d0e253e..93781be 100644 --- a/src/vocab.ts +++ b/src/vocab.ts @@ -6,7 +6,7 @@ * the caller's to read and pass through {@link parseVocabCsv}; the node-only * `fileChoices.ts` does that from disk for the CLI. */ -import type { ChoiceRow, SurveyRow } from './config/types.js'; +import type { ChoiceRow, SurveyRow } from './xlsform/types.js'; import { VOCABULARY_OPTIONS } from './generated/VocabularyOptions.js'; import { parseCsvRecords } from './responseFile.js'; diff --git a/src/xlsform/exclusive.ts b/src/xlsform/exclusive.ts index 6af6df9..bcd2b28 100644 --- a/src/xlsform/exclusive.ts +++ b/src/xlsform/exclusive.ts @@ -5,7 +5,7 @@ * `exclude_all_others` question attribute. */ import conventions from '../generated/conventions.js'; -import type { ChoiceRow } from '../config/types.js'; +import type { ChoiceRow } from './types.js'; export const EXCLUSIVE_RULE = conventions.conventions.exclusiveChoice; diff --git a/src/xlsform/loader.ts b/src/xlsform/loader.ts index 27122d2..96a0fcd 100644 --- a/src/xlsform/loader.ts +++ b/src/xlsform/loader.ts @@ -1,11 +1,6 @@ import * as XLSX from 'xlsx'; -import { - SurveyRow, - ChoiceRow, - SettingsRow, - XLSFormData, -} from '../config/types.js'; +import { SurveyRow, ChoiceRow, SettingsRow, XLSFormData } from './types.js'; import { extractBaseColumnName, extractLanguageCode, diff --git a/src/xlsform/parser.ts b/src/xlsform/parser.ts index 5f04d52..c6dfdff 100644 --- a/src/xlsform/parser.ts +++ b/src/xlsform/parser.ts @@ -3,7 +3,7 @@ */ import { ConversionConfig } from '../config/ConfigManager.js'; -import type { ChoiceRow } from '../config/types.js'; +import type { ChoiceRow } from './types.js'; import { XLSLoader } from './loader.js'; diff --git a/src/xlsform/types.ts b/src/xlsform/types.ts new file mode 100644 index 0000000..0d28ae1 --- /dev/null +++ b/src/xlsform/types.ts @@ -0,0 +1,54 @@ +/** XLSForm data as the loaders produce it: one object per sheet row. */ + +/** + * Represents a row in the survey section of an XLSForm + */ +export interface SurveyRow { + type?: string; + name?: string; + label?: string | Record; + hint?: string | Record; + required?: string; + relevant?: string; + constraint?: string; + constraint_message?: string; + calculation?: string; + default?: string; + _languages?: string[]; + [key: string]: unknown; +} + +/** + * Represents a row in the choices section of an XLSForm + */ +export interface ChoiceRow { + list_name?: string; + name?: string; + label?: string | Record; + filter?: string; + _languages?: string[]; + [key: string]: unknown; +} + +/** + * Represents a row in the settings section of an XLSForm + */ +export interface SettingsRow { + form_title?: string; + form_id?: string; + default_language?: string; + style?: string; + [key: string]: unknown; +} + +/** + * Result type returned by XLS/XLSX loaders + */ +export interface XLSFormData { + surveyData: SurveyRow[]; + choicesData: ChoiceRow[]; + settingsData: SettingsRow[]; + hasSurveySheet: boolean; + hasChoicesSheet: boolean; + hasSettingsSheet: boolean; +} diff --git a/src/xlsform/validate.ts b/src/xlsform/validate.ts index ff1b2e5..3672309 100644 --- a/src/xlsform/validate.ts +++ b/src/xlsform/validate.ts @@ -2,7 +2,7 @@ import conventions from '../generated/conventions.js'; import { APPEARANCES } from '../generated/Appearances.js'; import { TYPE_MAPPINGS } from '../generated/TypeMappings.js'; -import { SurveyRow, ChoiceRow } from '../config/types.js'; +import { SurveyRow, ChoiceRow } from './types.js'; import { registeredVocabFiles } from '../vocab.js'; import { EXCLUSIVE_RULE, exclusiveCell, isExclusive } from './exclusive.js'; diff --git a/tests/ts/contract/fullRoundtrip.test.ts b/tests/ts/contract/fullRoundtrip.test.ts index caa1451..74052be 100644 --- a/tests/ts/contract/fullRoundtrip.test.ts +++ b/tests/ts/contract/fullRoundtrip.test.ts @@ -11,7 +11,7 @@ import { describe, test, expect } from 'vitest'; import { XLSFormToTSVConverter } from '../../../src/pipelines/xlsform2lstsv/index.js'; import { lstsvToXlsform } from '../../../src/pipelines/lstsv2xlsform/index.js'; -import type { SurveyRow, ChoiceRow } from '../../../src/config/types.js'; +import type { SurveyRow, ChoiceRow } from '../../../src/xlsform/types.js'; async function roundTrip( survey: SurveyRow[], diff --git a/tests/ts/unit/markdownLabels.test.ts b/tests/ts/unit/markdownLabels.test.ts index 25baab8..48a3b9f 100644 --- a/tests/ts/unit/markdownLabels.test.ts +++ b/tests/ts/unit/markdownLabels.test.ts @@ -1,5 +1,8 @@ import { describe, test, expect } from 'vitest'; -import { markdownToHtml, htmlToMarkdown } from '../../../src/utils/markdownRenderer'; +import { + markdownToHtml, + htmlToMarkdown, +} from '../../../src/utils/markdownRenderer'; import { convertAndParse, findRowByName, findRowsByClass } from './helpers'; // ============================================================== diff --git a/tests/ts/unit/resolveConfig.test.ts b/tests/ts/unit/resolveConfig.test.ts new file mode 100644 index 0000000..c14b870 --- /dev/null +++ b/tests/ts/unit/resolveConfig.test.ts @@ -0,0 +1,31 @@ +/** resolveConfig merges over the defaults, validates, and freezes (#64). */ +import { describe, test, expect } from 'vitest'; + +import { resolveConfig, defaultConfig } from '../../../src/index.js'; + +describe('resolveConfig', () => { + test('merges nested options over the defaults', () => { + const c = resolveConfig({ + hideNoAnswer: false, + defaults: { language: 'de' } as never, + }); + expect(c.hideNoAnswer).toBe(false); + expect(c.defaults.language).toBe('de'); + expect(c.defaults.groupName).toBe(defaultConfig.defaults.groupName); + }); + + test('returns a frozen object and leaves the defaults untouched', () => { + const c = resolveConfig(); + expect(Object.isFrozen(c)).toBe(true); + expect(c).not.toBe(defaultConfig); + }); + + test('rejects invalid options', () => { + expect(() => resolveConfig({ handleRepeats: 'skip' as never })).toThrow( + /handleRepeats/, + ); + expect(() => + resolveConfig({ defaults: { language: 'deu' } as never }), + ).toThrow(/2-character/); + }); +});