diff --git a/src/lstsv/columns.ts b/src/lstsv/columns.ts new file mode 100644 index 0000000..6516dd6 --- /dev/null +++ b/src/lstsv/columns.ts @@ -0,0 +1,61 @@ +/** + * The LimeSurvey structure-TSV columns this library writes: the single list + * the row type and the header order are derived from. + * + * https://www.limesurvey.org/manual/Tab_Separated_Value_survey_structure + */ + +/** Always written, in this order. `hidden` is optional per row. */ +export const BASE_COLUMNS = [ + 'class', + 'type/scale', + 'name', + 'relevance', + 'text', + 'help', + 'language', + 'validation', + 'em_validation_q', + 'mandatory', + 'other', + 'default', + 'same_default', + 'hidden', +] as const; + +/** + * LimeSurvey question-attribute columns, written only when some row sets them + * (a survey that uses none keeps a lean TSV), in this order. + * + * - `cssclass`: vocabulary provenance on `select_*_from_file` questions + * (`cdlvocab-`, convention:externalCodeList). A registered attribute, + * so it survives import; an unregistered custom column would be dropped. + * - `hide_tip`: `1` suppresses LimeSurvey's stock per-question tip + * (config `hideQuestionTips`). + * - `date_format`: date/time widget format, from the registry's + * `limesurvey.dateFormat`. + * - `min_num_value_n`, `max_num_value_n`, `num_value_int_only`: numeric input + * bounds from the XLSForm `parameters` column (`range`), via the registry's + * `limesurvey.parameterAttributes` / `integerOnly`. + * - `exclude_all_others`: exclusive answers of a multiple choice + * (convention:exclusiveChoice). + * + * A registry attribute missing here would be dropped silently; a test checks + * the registry against this list. + */ +export const ATTRIBUTE_COLUMNS = [ + 'cssclass', + 'hide_tip', + 'date_format', + 'min_num_value_n', + 'max_num_value_n', + 'num_value_int_only', + 'exclude_all_others', +] as const; + +export type BaseColumn = (typeof BASE_COLUMNS)[number]; +export type AttributeColumn = (typeof ATTRIBUTE_COLUMNS)[number]; + +/** One TSV row: every base column except `hidden` is set; attributes are optional. */ +export type TSVRow = Record, string> & + Partial>; diff --git a/src/lstsv/serialize.ts b/src/lstsv/serialize.ts index b2cc178..763f6d4 100644 --- a/src/lstsv/serialize.ts +++ b/src/lstsv/serialize.ts @@ -1,41 +1,7 @@ -interface TSVRow { - class: string; - 'type/scale': string; - name: string; - relevance: string; - text: string; - help: string; - language: string; - validation: string; - em_validation_q: string; - mandatory: string; - other: string; - default: string; - same_default: string; - hidden?: string; - // LimeSurvey `cssclass` question attribute. Used only on select_*_from_file - // questions to carry vocabulary provenance (`cdlvocab-`); empty elsewhere. - // A registered attribute, so it survives import and is queryable (an - // unregistered custom column would be dropped). - cssclass?: string; - // LimeSurvey `hide_tip` question attribute ('1' to suppress the stock - // per-question tip). Emitted for every real question in this ecosystem; - // empty elsewhere. See ConversionConfig.hideQuestionTips. - hide_tip?: string; - // LimeSurvey `date_format` question attribute for date/time (type D) - // questions — controls whether the widget shows a date, a time, or both. - // Sourced from the registry's limesurvey.dateFormat; empty elsewhere. - date_format?: string; - // LimeSurvey numeric-input (N) attributes, from the XLSForm `parameters` - // column via the registry (limesurvey.parameterAttributes / integerOnly). - // Only `range` sets them. - min_num_value_n?: string; - max_num_value_n?: string; - num_value_int_only?: string; - // Multiple choice (M): answer codes that exclude all others, `;`-joined - // (convention:exclusiveChoice, from the choices sheet's `exclusive` column). - exclude_all_others?: string; -} +import { ATTRIBUTE_COLUMNS, BASE_COLUMNS } from './columns.js'; +import type { TSVRow } from './columns.js'; + +export type { TSVRow } from './columns.js'; export class TSVGenerator { private rows: TSVRow[] = []; @@ -46,46 +12,16 @@ export class TSVGenerator { // https://www.limesurvey.org/manual/Tab_Separated_Value_survey_structure generateTSV(): string { - const headers = [ - 'class', - 'type/scale', - 'name', - 'relevance', - 'text', - 'help', - 'language', - 'validation', - 'em_validation_q', - 'mandatory', - 'other', - 'default', - 'same_default', - 'hidden', + // Attribute columns only appear when at least one row sets them. + const headers: (keyof TSVRow)[] = [ + ...BASE_COLUMNS, + ...ATTRIBUTE_COLUMNS.filter((attr) => this.rows.some((r) => r[attr])), ]; - // Attribute columns are only emitted when at least one row carries them, - // so surveys that don't use a given attribute keep a lean TSV. The order - // here is the column order in the output. - for (const attr of [ - 'cssclass', - 'hide_tip', - 'date_format', - 'min_num_value_n', - 'max_num_value_n', - 'num_value_int_only', - 'exclude_all_others', - ] as const) { - if (this.rows.some((r) => r[attr])) { - headers.push(attr); - } - } - const lines = [headers.join('\t')]; for (const row of this.rows) { - const values = headers.map((h) => - this.escapeForTSV(row[h as keyof TSVRow] ?? ''), - ); + const values = headers.map((h) => this.escapeForTSV(row[h] ?? '')); lines.push(values.join('\t')); } @@ -94,8 +30,6 @@ export class TSVGenerator { private escapeForTSV(value: string): string { // Escape tabs, newlines, and wrap in quotes if needed - if (typeof value !== 'string') value = String(value); - // Replace newlines with
for LimeSurvey compatibility. // LimeSurvey's TSV importer parses line-by-line and does not handle // RFC 4180 multi-line quoted fields. HTML breaks render correctly diff --git a/src/pipelines/xlsform2lstsv/constants.ts b/src/pipelines/xlsform2lstsv/constants.ts index fc4b77f..14af589 100644 --- a/src/pipelines/xlsform2lstsv/constants.ts +++ b/src/pipelines/xlsform2lstsv/constants.ts @@ -12,30 +12,8 @@ export const UNIMPLEMENTED_TYPES: string[] = Object.entries(TYPE_MAPPINGS) .filter(([k, v]) => !v.supported && k !== 'begin_group' && k !== 'end_group') .map(([k]) => k); -// TSV row data interface -export interface TSVRowData { - class: string; - 'type/scale': string; - name: string; - relevance: string; - text: string; - help: string; - language: string; - validation: string; - em_validation_q: string; - mandatory: string; - other: string; - default: string; - same_default: string; - hidden?: string; - cssclass?: string; - hide_tip?: string; - date_format?: string; - min_num_value_n?: string; - max_num_value_n?: string; - num_value_int_only?: string; - exclude_all_others?: string; -} +/** A TSV row as the converter builds it (the serializer's row type). */ +export type { TSVRow as TSVRowData } from '../../lstsv/columns.js'; // Internal state interfaces export interface GroupStackItem { diff --git a/tests/ts/unit/lstsv/columns.test.ts b/tests/ts/unit/lstsv/columns.test.ts new file mode 100644 index 0000000..50f7575 --- /dev/null +++ b/tests/ts/unit/lstsv/columns.test.ts @@ -0,0 +1,52 @@ +/** + * #65: the serializer writes only columns in ATTRIBUTE_COLUMNS, so a question + * attribute the registry introduces must be listed there, or it is dropped. + */ +import { describe, expect, test } from 'vitest'; + +import { ATTRIBUTE_COLUMNS } from '../../../../src/lstsv/columns.js'; +import { TSVGenerator } from '../../../../src/lstsv/serialize.js'; +import { TYPE_MAPPINGS } from '../../../../src/generated/TypeMappings.js'; +import { EXCLUSIVE_RULE } from '../../../../src/conventions/exclusive.js'; + +function registryAttributes(): string[] { + const attrs = new Set([EXCLUSIVE_RULE.limesurveyAttribute]); + for (const m of Object.values(TYPE_MAPPINGS)) { + if (m.dateFormat) attrs.add('date_format'); + for (const a of Object.values(m.parameterAttributes ?? {})) attrs.add(a); + if (m.integerOnly) attrs.add(m.integerOnly.attribute); + } + return [...attrs]; +} + +describe('TSV columns', () => { + test('every LimeSurvey attribute the registry names is a column', () => { + const missing = registryAttributes().filter( + (a) => !(ATTRIBUTE_COLUMNS as readonly string[]).includes(a), + ); + expect(missing).toEqual([]); + }); + + test('attribute columns appear only when used, in list order', () => { + const g = new TSVGenerator(); + const base = { + class: 'Q', + 'type/scale': 'M', + name: 'q', + relevance: '1', + text: 'Q', + help: '', + language: 'en', + validation: '', + em_validation_q: '', + mandatory: '', + other: '', + default: '', + same_default: '', + }; + g.addRow({ ...base, exclude_all_others: 'none', cssclass: 'x' }); + const header = g.generateTSV().split('\n')[0].split('\t'); + expect(header.slice(-2)).toEqual(['cssclass', 'exclude_all_others']); + expect(header).not.toContain('date_format'); + }); +});