Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/lstsv/columns.ts
Original file line number Diff line number Diff line change
@@ -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-<id>`, 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<Exclude<BaseColumn, 'hidden'>, string> &
Partial<Record<'hidden' | AttributeColumn, string>>;
84 changes: 9 additions & 75 deletions src/lstsv/serialize.ts
Original file line number Diff line number Diff line change
@@ -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-<id>`); 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[] = [];
Expand All @@ -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'));
}

Expand All @@ -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 <br /> 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
Expand Down
26 changes: 2 additions & 24 deletions src/pipelines/xlsform2lstsv/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions tests/ts/unit/lstsv/columns.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>([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');
});
});
Loading