diff --git a/codegen/schematron.py b/codegen/schematron.py
index f2fbcd1..dce2c03 100644
--- a/codegen/schematron.py
+++ b/codegen/schematron.py
@@ -208,11 +208,11 @@ def generate_schematron(registry: dict[str, Any], output: Path) -> None:
{subject_rules}
"""
- # At most one note of each cdl: type per element and language (on stdyDscr,
- # where the subject names what a note is about: per subject too).
+ # At most one note of each cdl: type per element, subject and language
+ # (a subject names what the note is about: a column, a row).
cdl_note_uniqueness = """\
- has more than one note of one cdl: type in one language.
+ has more than one note of one cdl: type in one language.
The study has more than one note of one cdl: type about one subject in one language.
diff --git a/ddi-validation/schematron/ddi_custom_rules.sch b/ddi-validation/schematron/ddi_custom_rules.sch
index 88dd66d..bee93cf 100644
--- a/ddi-validation/schematron/ddi_custom_rules.sch
+++ b/ddi-validation/schematron/ddi_custom_rules.sch
@@ -216,26 +216,32 @@
- Note type "" is not in the CDL vocabulary (cdl:appearance, cdl:constraint, cdl:constraint_message, cdl:default, cdl:exclusive, cdl:hint, cdl:language, cdl:list, cdl:no_label, cdl:note_names, cdl:or_other, cdl:other_label, cdl:parameters, cdl:position, cdl:relevant, cdl:required, cdl:row, cdl:row_appearance, cdl:row_hint, cdl:row_label, cdl:row_relevant, cdl:setting).
+ Note type "" is not in the CDL vocabulary (cdl:appearance, cdl:choice_column, cdl:column, cdl:constraint, cdl:constraint_message, cdl:default, cdl:exclusive, cdl:hint, cdl:language, cdl:list, cdl:no_label, cdl:note_names, cdl:or_other, cdl:other_label, cdl:parameters, cdl:position, cdl:relevant, cdl:required, cdl:row, cdl:row_appearance, cdl:row_column, cdl:row_hint, cdl:row_label, cdl:row_relevant, cdl:setting).
+ A cdl:choice_column note needs a subject: the name of what it holds.
+ A cdl:column note needs a subject: the name of what it holds.
A cdl:constraint note needs subject="xlsform-xpath": its text is an expression in that syntax.
A cdl:language note needs a subject: the name of what it holds.
A cdl:position note needs a subject: the name of what it holds.
A cdl:relevant note needs subject="xlsform-xpath": its text is an expression in that syntax.
A cdl:row note needs a subject: the name of what it holds.
A cdl:row_appearance note needs a subject: the name of what it holds.
+ A cdl:row_column note needs a subject: the name of what it holds.
A cdl:row_hint note needs a subject: the name of what it holds.
A cdl:row_label note needs a subject: the name of what it holds.
A cdl:row_relevant note needs a subject: the name of what it holds.
A cdl:setting note needs a subject: the name of what it holds.
- Note type "" is not in the CDL vocabulary (cdl:appearance, cdl:constraint, cdl:constraint_message, cdl:default, cdl:exclusive, cdl:hint, cdl:language, cdl:list, cdl:no_label, cdl:note_names, cdl:or_other, cdl:other_label, cdl:parameters, cdl:position, cdl:relevant, cdl:required, cdl:row, cdl:row_appearance, cdl:row_hint, cdl:row_label, cdl:row_relevant, cdl:setting).
+ Note type "" is not in the CDL vocabulary (cdl:appearance, cdl:choice_column, cdl:column, cdl:constraint, cdl:constraint_message, cdl:default, cdl:exclusive, cdl:hint, cdl:language, cdl:list, cdl:no_label, cdl:note_names, cdl:or_other, cdl:other_label, cdl:parameters, cdl:position, cdl:relevant, cdl:required, cdl:row, cdl:row_appearance, cdl:row_column, cdl:row_hint, cdl:row_label, cdl:row_relevant, cdl:setting).
+ A cdl:choice_column note needs a subject: the name of what it holds.
+ A cdl:column note needs a subject: the name of what it holds.
A cdl:constraint note needs subject="xlsform-xpath": its text is an expression in that syntax.
A cdl:language note needs a subject: the name of what it holds.
A cdl:position note needs a subject: the name of what it holds.
A cdl:relevant note needs subject="xlsform-xpath": its text is an expression in that syntax.
A cdl:row note needs a subject: the name of what it holds.
A cdl:row_appearance note needs a subject: the name of what it holds.
+ A cdl:row_column note needs a subject: the name of what it holds.
A cdl:row_hint note needs a subject: the name of what it holds.
A cdl:row_label note needs a subject: the name of what it holds.
A cdl:row_relevant note needs a subject: the name of what it holds.
@@ -245,13 +251,13 @@
- has more than one note of one cdl: type in one language.
+ has more than one note of one cdl: type in one language.
The study has more than one note of one cdl: type about one subject in one language.
- has more than one note of one cdl: type in one language.
+ has more than one note of one cdl: type in one language.
The study has more than one note of one cdl: type about one subject in one language.
diff --git a/registry/conventions/ddiFields.jsonld b/registry/conventions/ddiFields.jsonld
index 8f1848f..cf5f11a 100644
--- a/registry/conventions/ddiFields.jsonld
+++ b/registry/conventions/ddiFields.jsonld
@@ -187,6 +187,32 @@
"varGrp[@type='section' or @type='grid']"
],
"text": "yes: the group has no label; its txt is its name, for readers that show one"
+ },
+ "column": {
+ "type": "cdl:column",
+ "on": [
+ "var",
+ "varGrp"
+ ],
+ "subject": "the column, as the sheet names it (media::image::Deutsch (de))",
+ "text": "the cell of a survey column the model doesn't lift (liftedColumns)"
+ },
+ "choice_column": {
+ "type": "cdl:choice_column",
+ "on": [
+ "var",
+ "varGrp"
+ ],
+ "subject": "the choice's code, a space, the column",
+ "text": "the cell of a choices column the model doesn't lift; on the first question that uses the list"
+ },
+ "row_column": {
+ "type": "cdl:row_column",
+ "on": [
+ "stdyDscr"
+ ],
+ "subject": "the row's name, a space, the column",
+ "text": "the cell of a column the model doesn't lift, of a note row or cdl:row"
}
},
"fields": {
@@ -208,7 +234,7 @@
"note": "On a var (a select_multiple's or pair's on its varGrp), and on a section; a grid's table-list is its type='grid', so only a grid appearance other than that is noted."
},
"ItemBase.row": {
- "loss": "Columns the model does not lift (media, calculation, …) are not carried."
+ "loss": "The source row itself; its columns the model doesn't lift are `columns`."
},
"GroupItem.kind": {
"ddi": "a group is a varGrp"
@@ -268,7 +294,7 @@
},
"InstrumentChoice.row": {
"ddi": "exclusive: a cdl:exclusive note on the select_multiple's varGrp listing the codes",
- "note": "The choice sheet's other columns are not carried."
+ "note": "Its other columns are `columns`."
},
"Instrument.languages": {
"ddi": "xml:lang siblings of every text (convention:languageTagging); a language's own name in a cdl:language note"
@@ -285,7 +311,37 @@
},
"Instrument.body": {
"ddi": "dataDscr: varGrp and var, in survey order; note rows in preQTxt (names: cdl:note_names) or as stdyDscr notes type='instruction'; rows without data as cdl:row; their fields in cdl:row_*; where the stdyDscr ones are: cdl:position"
+ },
+ "ItemBase.columns": {
+ "cdlNote": "cdl:column",
+ "note": "On the var (a select_multiple's or pair's varGrp) or the group's varGrp; a note's or data-less row's as cdl:row_column."
+ },
+ "InstrumentChoice.columns": {
+ "cdlNote": "cdl:choice_column"
}
+ },
+ "liftedColumns": {
+ "description": "The sheet columns the model reads into its own fields (a column with a language suffix, label::de, by its base). Every other non-empty column is an item's or choice's `columns`, carried as a cdl:column note (#160). A column starting with `_` is the loader's, not the form's.",
+ "survey": [
+ "type",
+ "name",
+ "label",
+ "hint",
+ "guidance_hint",
+ "relevant",
+ "appearance",
+ "constraint",
+ "constraint_message",
+ "required",
+ "default",
+ "parameters"
+ ],
+ "choices": [
+ "list_name",
+ "name",
+ "label",
+ "exclusive"
+ ]
}
}
}
diff --git a/src/conventions/columns.ts b/src/conventions/columns.ts
new file mode 100644
index 0000000..55414b0
--- /dev/null
+++ b/src/conventions/columns.ts
@@ -0,0 +1,49 @@
+/**
+ * The sheet columns the model doesn't lift (`convention:ddiFields`
+ * `liftedColumns`, #160): kept by name, so a codebook can carry them and an
+ * XLSForm get them back.
+ */
+import conventions from '../generated/conventions.js';
+
+const LIFTED = conventions.conventions.ddiFields.liftedColumns;
+
+type Sheet = 'survey' | 'choices';
+
+const liftedBy: Record> = {
+ survey: new Set(LIFTED.survey),
+ choices: new Set(LIFTED.choices),
+};
+
+function cellText(value: unknown): string {
+ if (typeof value === 'string') return value.trim();
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ return '';
+}
+
+/**
+ * A row's columns the model doesn't lift, non-empty, by column name. A
+ * `{ lang: text }` cell is one column per language (`media::image::de`). A
+ * column starting with `_` is the loader's, not the form's.
+ */
+export function otherColumns(
+ row: Record,
+ sheet: Sheet,
+): Record {
+ const out: Record = {};
+ for (const [key, value] of Object.entries(row)) {
+ if (key.startsWith('_') || liftedBy[sheet].has(key.split('::')[0])) {
+ continue;
+ }
+ const cells: Array<[string, unknown]> =
+ value !== null && typeof value === 'object'
+ ? Object.entries(value).map(([lang, v]) => [`${key}::${lang}`, v])
+ : [[key, value]];
+ for (const [column, cell] of cells) {
+ const text = cellText(cell);
+ if (text) out[column] = text;
+ }
+ }
+ return out;
+}
diff --git a/src/ddi/codebook.ts b/src/ddi/codebook.ts
index 0756e19..7c08f33 100644
--- a/src/ddi/codebook.ts
+++ b/src/ddi/codebook.ts
@@ -26,6 +26,7 @@ import conventions from '../generated/conventions.js';
import { Choice, DdiGroup, Translations, Variable } from './types.js';
import { localizedChild, textsOf } from './translations.js';
import {
+ addChoiceColumnNotes,
addExclusiveNote,
addFieldNotes,
addGroupFieldNotes,
@@ -203,6 +204,7 @@ function addVarElement(parent: XmlElement, spec: AddVarSpec): XmlElement {
addLogicNotes(varEl, spec.logic.v);
addFieldNotes(varEl, spec.logic.v);
addListNote(varEl, spec.logic.v, spec.opts?.listDefault ?? name);
+ addChoiceColumnNotes(varEl, spec.logic.v, spec.logic.ctx.listsWritten);
}
addNoteNames(varEl, spec.opts?.noteNames);
@@ -296,6 +298,7 @@ function addGroupLogic(
addFieldNotes(grpEl, v);
addExclusiveNote(grpEl, v.choices);
addListNote(grpEl, v, v.name);
+ addChoiceColumnNotes(grpEl, v, ctx.listsWritten);
}
/** A semi-open pair written with the `or_other` shorthand (#160). */
diff --git a/src/ddi/fields.ts b/src/ddi/fields.ts
index f011d36..bdeaab4 100644
--- a/src/ddi/fields.ts
+++ b/src/ddi/fields.ts
@@ -35,6 +35,7 @@ export function addFieldNotes(el: XmlElement, v: Variable): void {
if (v.parameters) {
el.textChild('notes', v.parameters, { type: NOTES.parameters.type });
}
+ addColumnNotes(el, v.columns);
}
/** A `select_multiple`'s exclusive choices as one `cdl:exclusive` note. */
@@ -66,6 +67,7 @@ export function addGroupFieldNotes(
if (group.unlabelled) {
el.textChild('notes', 'yes', { type: NOTES.no_label.type });
}
+ addColumnNotes(el, group.columns);
}
/** Settings in a standard element: `titl`, `IDNo`, `verStmt/version`. */
@@ -142,9 +144,10 @@ export function addRowLabel(stdy: XmlElement, v: Variable): void {
}
}
-/** A note row's or data-less row's hint, relevant and appearance, by its name. */
+/** A note row's or data-less row's hint, relevant, appearance and columns, by its name. */
export function addRowFieldNotes(stdy: XmlElement, v: Variable): void {
const subject = v.name;
+ addColumnNotes(stdy, v.columns, NOTES.row_column.type, `${subject} `);
if (v.hint) {
localizedChild(stdy, 'notes', v.hint, textsOf(v, 'hint'), {
type: NOTES.row_hint.type,
@@ -165,6 +168,42 @@ export function addRowFieldNotes(stdy: XmlElement, v: Variable): void {
}
}
+/**
+ * Columns the model doesn't lift (#160), one note each with the column's
+ * name as its subject (`prefix` first: the row or choice it belongs to).
+ */
+function addColumnNotes(
+ el: XmlElement,
+ columns: Record | undefined,
+ type: string = NOTES.column.type,
+ prefix = '',
+): void {
+ for (const [column, cell] of Object.entries(columns ?? {})) {
+ el.textChild('notes', cell, { type, subject: prefix + column });
+ }
+}
+
+/**
+ * The choices' columns the model doesn't lift (`cdl:choice_column`), on the
+ * first question that uses the list: `written` has the lists already done.
+ */
+export function addChoiceColumnNotes(
+ el: XmlElement,
+ v: Variable,
+ written: Set,
+): void {
+ if (!v.listName || written.has(v.listName)) return;
+ written.add(v.listName);
+ for (const choice of v.choices) {
+ addColumnNotes(
+ el,
+ choice.columns,
+ NOTES.choice_column.type,
+ `${choice.name} `,
+ );
+ }
+}
+
/** The names of the note rows a lead-in text joins (`cdl:note_names`). */
export function addNoteNames(el: XmlElement, names: string[] | undefined) {
if (names?.length) {
diff --git a/src/ddi/fromInstrument.ts b/src/ddi/fromInstrument.ts
index fcc826b..6419acd 100644
--- a/src/ddi/fromInstrument.ts
+++ b/src/ddi/fromInstrument.ts
@@ -353,6 +353,7 @@ function pushRow(q: QuestionItem, ctx: GroupContext, state: ProjectState) {
...(q.relevant.trim() ? { relevant: q.relevant.trim() } : {}),
...(q.appearance ? { appearance: q.appearance } : {}),
...(translations ? { translations } : {}),
+ ...columnsOf(q),
group: ctx.path,
groupLabel: ctx.label,
groupAppearance: ctx.appearance,
@@ -363,6 +364,15 @@ function pushRow(q: QuestionItem, ctx: GroupContext, state: ProjectState) {
});
}
+/** An item's or choice's columns the model doesn't lift, absent when none. */
+function columnsOf(x: { columns?: Record }): {
+ columns?: Record;
+} {
+ return x.columns && Object.keys(x.columns).length
+ ? { columns: x.columns }
+ : {};
+}
+
/** A row with no data column: a metadata row, a matrix header. */
function isRow(q: QuestionItem): boolean {
if (!q.name) return false;
@@ -422,6 +432,7 @@ function pushQuestion(
...logicOf(q, state.lang),
...(translations ? { translations } : {}),
...(added ? { orOther: otherOrigin(q) } : {}),
+ ...columnsOf(q),
});
if (other) pushCompanion(q, stdType, other, ctx, state);
@@ -449,6 +460,7 @@ function project(items: Item[], ctx: GroupContext, state: ProjectState): void {
...(Object.values(item.label).some((t) => t.trim())
? {}
: { unlabelled: true }),
+ ...columnsOf(item),
};
project(
item.children,
@@ -546,6 +558,7 @@ export function choicesFromInstrument(
label: pick(c.label, lang),
...(translations ? { translations } : {}),
...(isExclusive(c.row) ? { exclusive: true } : {}),
+ ...columnsOf(c),
};
}),
]),
diff --git a/src/ddi/logic.ts b/src/ddi/logic.ts
index a8891df..f6a85a4 100644
--- a/src/ddi/logic.ts
+++ b/src/ddi/logic.ts
@@ -44,6 +44,8 @@ export interface LogicContext {
ids: Map;
/** Each data question's position in the form, from 1 (`qstn/@seqNo`). */
seqNo: Map;
+ /** Lists whose choices' columns are written already (#160). */
+ listsWritten: Set;
}
/** The languages a variable's texts come in: its base and translations. */
@@ -66,6 +68,7 @@ export function logicContext(
.filter((v) => v.type !== 'note')
.map((v, i): [string, number] => [v.name, i + 1]),
),
+ listsWritten: new Set(),
};
}
diff --git a/src/ddi/types.ts b/src/ddi/types.ts
index 486dfc5..1f75c66 100644
--- a/src/ddi/types.ts
+++ b/src/ddi/types.ts
@@ -21,6 +21,8 @@ export interface Choice {
translations?: Translations;
/** Marked `exclusive` (convention:exclusiveChoice): a `cdl:exclusive` note. */
exclusive?: boolean;
+ /** The choices sheet's other columns: `cdl:choice_column` notes (#160). */
+ columns?: Record;
}
/**
@@ -43,6 +45,8 @@ export interface DdiGroup {
hint: string;
/** It has no label of its own: `label` is its name (`cdl:no_label`, #160). */
unlabelled?: boolean;
+ /** Its columns the model doesn't lift: `cdl:column` notes (#160). */
+ columns?: Record;
/** `hint` in the form's other languages. */
hintTranslations?: Translations;
}
@@ -115,6 +119,11 @@ export interface Variable {
orOther?: 'shorthand' | 'added';
/** The `or_other` companion the projection added, not an authored row. */
synthesized?: boolean;
+ /**
+ * Its columns the model doesn't lift: `cdl:column` notes, a row's without
+ * data or a note's `cdl:row_column` (#160).
+ */
+ columns?: Record;
/**
* A row with no data column (a metadata row, a matrix header): its type
* cell, a `cdl:row` note (#160).
diff --git a/src/generated/conventions.json b/src/generated/conventions.json
index 27056b1..dcc16b9 100644
--- a/src/generated/conventions.json
+++ b/src/generated/conventions.json
@@ -159,6 +159,32 @@
"varGrp[@type='section' or @type='grid']"
],
"text": "yes: the group has no label; its txt is its name, for readers that show one"
+ },
+ "column": {
+ "type": "cdl:column",
+ "on": [
+ "var",
+ "varGrp"
+ ],
+ "subject": "the column, as the sheet names it (media::image::Deutsch (de))",
+ "text": "the cell of a survey column the model doesn't lift (liftedColumns)"
+ },
+ "choice_column": {
+ "type": "cdl:choice_column",
+ "on": [
+ "var",
+ "varGrp"
+ ],
+ "subject": "the choice's code, a space, the column",
+ "text": "the cell of a choices column the model doesn't lift; on the first question that uses the list"
+ },
+ "row_column": {
+ "type": "cdl:row_column",
+ "on": [
+ "stdyDscr"
+ ],
+ "subject": "the row's name, a space, the column",
+ "text": "the cell of a column the model doesn't lift, of a note row or cdl:row"
}
},
"fields": {
@@ -180,7 +206,7 @@
"note": "On a var (a select_multiple's or pair's on its varGrp), and on a section; a grid's table-list is its type='grid', so only a grid appearance other than that is noted."
},
"ItemBase.row": {
- "loss": "Columns the model does not lift (media, calculation, …) are not carried."
+ "loss": "The source row itself; its columns the model doesn't lift are `columns`."
},
"GroupItem.kind": {
"ddi": "a group is a varGrp"
@@ -240,7 +266,7 @@
},
"InstrumentChoice.row": {
"ddi": "exclusive: a cdl:exclusive note on the select_multiple's varGrp listing the codes",
- "note": "The choice sheet's other columns are not carried."
+ "note": "Its other columns are `columns`."
},
"Instrument.languages": {
"ddi": "xml:lang siblings of every text (convention:languageTagging); a language's own name in a cdl:language note"
@@ -257,7 +283,37 @@
},
"Instrument.body": {
"ddi": "dataDscr: varGrp and var, in survey order; note rows in preQTxt (names: cdl:note_names) or as stdyDscr notes type='instruction'; rows without data as cdl:row; their fields in cdl:row_*; where the stdyDscr ones are: cdl:position"
+ },
+ "ItemBase.columns": {
+ "cdlNote": "cdl:column",
+ "note": "On the var (a select_multiple's or pair's varGrp) or the group's varGrp; a note's or data-less row's as cdl:row_column."
+ },
+ "InstrumentChoice.columns": {
+ "cdlNote": "cdl:choice_column"
}
+ },
+ "liftedColumns": {
+ "description": "The sheet columns the model reads into its own fields (a column with a language suffix, label::de, by its base). Every other non-empty column is an item's or choice's `columns`, carried as a cdl:column note (#160). A column starting with `_` is the loader's, not the form's.",
+ "survey": [
+ "type",
+ "name",
+ "label",
+ "hint",
+ "guidance_hint",
+ "relevant",
+ "appearance",
+ "constraint",
+ "constraint_message",
+ "required",
+ "default",
+ "parameters"
+ ],
+ "choices": [
+ "list_name",
+ "name",
+ "label",
+ "exclusive"
+ ]
}
},
"exclusiveChoice": {
diff --git a/src/generated/conventions.ts b/src/generated/conventions.ts
index 4e3bfb4..b948436 100644
--- a/src/generated/conventions.ts
+++ b/src/generated/conventions.ts
@@ -168,6 +168,32 @@ const conventions = {
"varGrp[@type='section' or @type='grid']"
],
"text": "yes: the group has no label; its txt is its name, for readers that show one"
+ },
+ "column": {
+ "type": "cdl:column",
+ "on": [
+ "var",
+ "varGrp"
+ ],
+ "subject": "the column, as the sheet names it (media::image::Deutsch (de))",
+ "text": "the cell of a survey column the model doesn't lift (liftedColumns)"
+ },
+ "choice_column": {
+ "type": "cdl:choice_column",
+ "on": [
+ "var",
+ "varGrp"
+ ],
+ "subject": "the choice's code, a space, the column",
+ "text": "the cell of a choices column the model doesn't lift; on the first question that uses the list"
+ },
+ "row_column": {
+ "type": "cdl:row_column",
+ "on": [
+ "stdyDscr"
+ ],
+ "subject": "the row's name, a space, the column",
+ "text": "the cell of a column the model doesn't lift, of a note row or cdl:row"
}
},
"fields": {
@@ -189,7 +215,7 @@ const conventions = {
"note": "On a var (a select_multiple's or pair's on its varGrp), and on a section; a grid's table-list is its type='grid', so only a grid appearance other than that is noted."
},
"ItemBase.row": {
- "loss": "Columns the model does not lift (media, calculation, …) are not carried."
+ "loss": "The source row itself; its columns the model doesn't lift are `columns`."
},
"GroupItem.kind": {
"ddi": "a group is a varGrp"
@@ -249,7 +275,7 @@ const conventions = {
},
"InstrumentChoice.row": {
"ddi": "exclusive: a cdl:exclusive note on the select_multiple's varGrp listing the codes",
- "note": "The choice sheet's other columns are not carried."
+ "note": "Its other columns are `columns`."
},
"Instrument.languages": {
"ddi": "xml:lang siblings of every text (convention:languageTagging); a language's own name in a cdl:language note"
@@ -266,7 +292,37 @@ const conventions = {
},
"Instrument.body": {
"ddi": "dataDscr: varGrp and var, in survey order; note rows in preQTxt (names: cdl:note_names) or as stdyDscr notes type='instruction'; rows without data as cdl:row; their fields in cdl:row_*; where the stdyDscr ones are: cdl:position"
+ },
+ "ItemBase.columns": {
+ "cdlNote": "cdl:column",
+ "note": "On the var (a select_multiple's or pair's varGrp) or the group's varGrp; a note's or data-less row's as cdl:row_column."
+ },
+ "InstrumentChoice.columns": {
+ "cdlNote": "cdl:choice_column"
}
+ },
+ "liftedColumns": {
+ "description": "The sheet columns the model reads into its own fields (a column with a language suffix, label::de, by its base). Every other non-empty column is an item's or choice's `columns`, carried as a cdl:column note (#160). A column starting with `_` is the loader's, not the form's.",
+ "survey": [
+ "type",
+ "name",
+ "label",
+ "hint",
+ "guidance_hint",
+ "relevant",
+ "appearance",
+ "constraint",
+ "constraint_message",
+ "required",
+ "default",
+ "parameters"
+ ],
+ "choices": [
+ "list_name",
+ "name",
+ "label",
+ "exclusive"
+ ]
}
},
"exclusiveChoice": {
diff --git a/src/instrument/fromDdi.ts b/src/instrument/fromDdi.ts
index 8c90407..a26fa74 100644
--- a/src/instrument/fromDdi.ts
+++ b/src/instrument/fromDdi.ts
@@ -223,6 +223,48 @@ function emptyQuestion(name: string): QuestionItem {
};
}
+/** Columns the model doesn't lift (`cdl:column`), by column name. */
+function columnsOf(
+ node: XmlNode,
+ type: string = FIELDS.column.type,
+): Record {
+ const out: Record = {};
+ for (const note of notesOf(node, type)) {
+ const subject = note.attrs['subject'];
+ if (subject) out[subject] = textContent(note).trim();
+ }
+ return out;
+}
+
+/** `columns`, absent when there are none. */
+function withColumns(columns: Record) {
+ return Object.keys(columns).length ? { columns } : {};
+}
+
+/** Split ` ` subjects (a row's or choice's columns) by first. */
+function columnsBy(
+ node: XmlNode,
+ type: string,
+): Map> {
+ const out = new Map>();
+ for (const [subject, cell] of Object.entries(columnsOf(node, type))) {
+ const at = subject.indexOf(' ');
+ if (at < 1) continue;
+ const key = subject.slice(0, at);
+ out.set(key, { ...out.get(key), [subject.slice(at + 1)]: cell });
+ }
+ return out;
+}
+
+/** The choices' columns (`cdl:choice_column`) onto the list's choices. */
+function readChoiceColumns(node: XmlNode, list: string, state: ReadState) {
+ const byCode = columnsBy(node, FIELDS.choice_column.type);
+ for (const choice of state.lists[list] ?? []) {
+ const columns = byCode.get(choice.name);
+ if (columns) choice.columns = { ...choice.columns, ...columns };
+ }
+}
+
/** The logic and field notes on a `var` or a question's `varGrp`. */
function readNotes(q: QuestionItem, node: XmlNode, state: ReadState): void {
q.relevant = noteText(node, LOGIC.relevant.type, state);
@@ -240,6 +282,8 @@ function readNotes(q: QuestionItem, node: XmlNode, state: ReadState): void {
q.default = noteText(node, FIELDS.default.type, state);
q.appearance = noteText(node, FIELDS.appearance.type, state);
q.parameters = noteText(node, FIELDS.parameters.type, state);
+ const columns = columnsOf(node);
+ if (Object.keys(columns).length) q.columns = columns;
}
/** `qstn`'s texts: label, hint, guidance hint. */
@@ -332,6 +376,7 @@ function plainQuestion(
(c) => !orOther || c.name !== OTHER_CODE,
);
q.list = listFor(choices, listName(v, named, state), state);
+ readChoiceColumns(v, q.list, state);
}
q.orOther = orOther;
if (q.type === 'range') q.parameters = rangeParameters(v, q.parameters);
@@ -387,6 +432,7 @@ function multiQuestion(
choices.push({ name: OTHER_CODE, label, row: {} });
}
q.list = listFor(choices, listName(grp, name, state), state);
+ readChoiceColumns(grp, q.list, state);
q.rawType = rawTypeOf(q);
return q;
}
@@ -613,6 +659,7 @@ function groupItem(id: string, state: ReadState): GroupItem {
noteText(grp, FIELDS.appearance.type, state) ||
(grid ? GRID_APPEARANCE : ''),
row: {},
+ ...withColumns(columnsOf(grp)),
children: lead,
closed: true,
};
@@ -812,6 +859,7 @@ function readRowFields(
const hints = bySubject(stdy, FIELDS.row_hint.type);
const relevants = bySubject(stdy, FIELDS.row_relevant.type);
const appearances = bySubject(stdy, FIELDS.row_appearance.type);
+ const columns = columnsBy(stdy, FIELDS.row_column.type);
const visit = (list: Item[]) => {
for (const item of list) {
if (item.kind === 'group') {
@@ -822,6 +870,8 @@ function readRowFields(
if (hint) item.hint = texts(hint, state);
item.relevant ||= subjectText(relevants, item.name);
item.appearance ||= subjectText(appearances, item.name);
+ const own = columns.get(item.name);
+ if (own) item.columns = own;
}
};
visit(items);
diff --git a/src/instrument/fromXlsform.ts b/src/instrument/fromXlsform.ts
index a59e2ba..77d0e53 100644
--- a/src/instrument/fromXlsform.ts
+++ b/src/instrument/fromXlsform.ts
@@ -3,6 +3,7 @@
* sees: `label::` columns (JSON fixtures, hand-built rows) and the
* loader's `{ lang: text }` cells.
*/
+import { otherColumns } from '../conventions/columns.js';
import { extractLanguageCode, languageTagOf } from '../utils/languageUtils.js';
import type {
GroupItem,
@@ -90,9 +91,15 @@ function baseFields(row: Row) {
relevant: cellString(row['relevant']).trim(),
appearance: cellString(row['appearance']).trim().toLowerCase(),
row,
+ ...withColumns(otherColumns(row, 'survey')),
};
}
+/** `columns`, absent when there are none. */
+function withColumns(columns: Record) {
+ return Object.keys(columns).length ? { columns } : {};
+}
+
function question(row: Row, rawType: string): QuestionItem {
const [type, ...rest] = rawType.split(/\s+/);
const fromFile = type.endsWith('_from_file');
@@ -156,6 +163,7 @@ function parseLists(choices: Row[]): Record {
name: cellString(row['name']),
label: readText(row, 'label'),
row,
+ ...withColumns(otherColumns(row, 'choices')),
});
}
return lists;
diff --git a/src/instrument/types.ts b/src/instrument/types.ts
index b25cd3d..fd7b3be 100644
--- a/src/instrument/types.ts
+++ b/src/instrument/types.ts
@@ -24,6 +24,11 @@ export interface InstrumentChoice {
label: Text;
/** The source row, for columns the model doesn't lift yet. */
row: Record;
+ /**
+ * The choices sheet's other columns, by column name (`media::image`): an
+ * XLSForm's; other sources have none (#160).
+ */
+ columns?: Record;
}
/** Fields every item has. */
@@ -38,6 +43,12 @@ interface ItemBase {
appearance: string;
/** The source row, for columns the model doesn't lift yet. */
row: Record;
+ /**
+ * The survey sheet's columns the model doesn't lift, by column name
+ * (`media::image::Deutsch (de)`, `choice_filter`): an XLSForm's; other
+ * sources have none (#160).
+ */
+ columns?: Record;
}
export interface GroupItem extends ItemBase {
diff --git a/src/pipelines/ddi2xlsform/README.md b/src/pipelines/ddi2xlsform/README.md
index 12e3419..d57b6a5 100644
--- a/src/pipelines/ddi2xlsform/README.md
+++ b/src/pipelines/ddi2xlsform/README.md
@@ -37,6 +37,7 @@ Standard DDI first, `cdl:` notes where DDI has no element
| exclusive | `cdl:exclusive` on the select_multiple's `varGrp` |
| settings | `titl` (+ `parTitl` per language), `IDNo`, `verStmt/version`, `codeBook/@xml:lang`, every other setting a `cdl:setting` |
| language columns | `xml:lang`; the form's name for each (`label::Deutsch (de)`) in `cdl:language` |
+| every other column (media, `choice_filter`, `read_only`, `$kuid`, …) | `cdl:column` (subject: the column) on the `var` / `varGrp`; a choice's `cdl:choice_column` (subject: ` `) on the first question using the list; a note's or data-less row's `cdl:row_column` on `stdyDscr` |
## Input it accepts
@@ -61,9 +62,8 @@ out):
1. **Rows no registry type covers** (`calculate`, …) and **groups with
nothing in them**.
-2. **Columns the model doesn't lift**: on the survey sheet (media,
- `choice_filter`, …), on the choices sheet every column but `exclusive`.
- Settings that are neither a string nor a number.
+2. **Settings that are neither a string nor a number**, a column of an
+ `end_group` row, and the case of an `appearance` (it comes back lowercase).
3. **Whitespace in the type cell** comes back as one space.
## Tests
diff --git a/src/xlsform/fromInstrument.ts b/src/xlsform/fromInstrument.ts
index 7e37e65..5f21882 100644
--- a/src/xlsform/fromInstrument.ts
+++ b/src/xlsform/fromInstrument.ts
@@ -220,6 +220,7 @@ function emitGroup(group: GroupItem, ctx: EmitCtx): void {
if (hint) row.hint = htmlLabel(hint);
if (group.appearance) row.appearance = group.appearance;
if (group.relevant) row.relevant = group.relevant;
+ Object.assign(row, group.columns);
ctx.survey.push(row);
emitItems(group.children, ctx);
ctx.survey.push({ type: 'end_group' });
@@ -290,6 +291,8 @@ function questionRow(q: QuestionItem, ctx: EmitCtx): SurveyRow {
['constraint', q.constraint],
];
for (const [column, value] of plain) if (value) row[column] = value;
+ // The columns the model doesn't lift, as the form had them (#160).
+ Object.assign(row, q.columns);
return row;
}
@@ -320,6 +323,7 @@ function emitChoiceList(
...(exclusive.has(choice.name) || isExclusive(choice.row)
? { [EXCLUSIVE_RULE.choicesColumn]: EXCLUSIVE_RULE.trueValues[0] }
: {}),
+ ...choice.columns,
});
}
}
diff --git a/tests/ts/contract/canonicalInstrument.ts b/tests/ts/contract/canonicalInstrument.ts
index 1db0bfd..8139d86 100644
--- a/tests/ts/contract/canonicalInstrument.ts
+++ b/tests/ts/contract/canonicalInstrument.ts
@@ -78,6 +78,7 @@ function question(q: QuestionItem, ctx: Ctx): Canon {
default: q.default,
appearance: q.appearance,
parameters: q.parameters.trim(),
+ columns: q.columns ?? {},
};
}
@@ -93,12 +94,17 @@ function items(list: Item[], ctx: Ctx): Canon[] {
hint: text(item.hint, ctx),
relevant: item.relevant.trim(),
appearance: item.appearance,
+ columns: item.columns ?? {},
children: items(item.children, ctx),
});
} else if (!kept(item)) {
continue;
} else if (isMetadataType(ALIASES[item.type] ?? item.type)) {
- out.push({ metadata: item.name, type: item.type });
+ out.push({
+ metadata: item.name,
+ type: item.type,
+ columns: item.columns ?? {},
+ });
} else if (item.type === 'note') {
out.push({
note: item.name,
@@ -106,6 +112,7 @@ function items(list: Item[], ctx: Ctx): Canon[] {
hint: text(item.hint, ctx),
relevant: item.relevant.trim(),
appearance: item.appearance,
+ columns: item.columns ?? {},
});
} else {
out.push(question(item, ctx));
@@ -142,6 +149,7 @@ function lists(ctx: Ctx): Canon {
name: c.name,
label: text(c.label, ctx),
...(isExclusive(c.row) ? { exclusive: true } : {}),
+ columns: c.columns ?? {},
},
);
}
diff --git a/tests/ts/contract/ddiRoundtripGenerated.test.ts b/tests/ts/contract/ddiRoundtripGenerated.test.ts
index fa8a429..4e47723 100644
--- a/tests/ts/contract/ddiRoundtripGenerated.test.ts
+++ b/tests/ts/contract/ddiRoundtripGenerated.test.ts
@@ -192,6 +192,8 @@ const block: fc.Arbitrary = fc.oneof(
const form = fc.record({
multilingual: fc.boolean(),
+ /** Columns the model doesn't lift, on some rows of both sheets. */
+ columns: fc.boolean(),
settings: fc.record({
form_title: fc.option(word, { nil: undefined }),
form_id: fc.option(fc.constantFrom('f1', 'survey_2'), { nil: undefined }),
@@ -203,6 +205,33 @@ const form = fc.record({
type Form = typeof form extends fc.Arbitrary ? F : never;
+/**
+ * Columns the model doesn't lift, on every other row: media (per language
+ * in a multilingual form), `read_only`, `choice_filter`, a choice filter.
+ */
+function addColumns(survey: Row[], choices: Row[], multilingual: boolean) {
+ const media = (row: Row, file: string) => {
+ if (multilingual) {
+ row[`media::image::${LANGS[0]}`] = `${file}-de.png`;
+ row[`media::image::${LANGS[1]}`] = `${file}-en.png`;
+ } else row['media::image'] = `${file}.png`;
+ };
+ survey.forEach((row, i) => {
+ const type = String(row['type']);
+ if (type === 'end_group') return;
+ if (i % 2 === 0) media(row, String(row['name']));
+ if (type === 'text' && i % 3 === 0) row['read_only'] = 'yes';
+ if (type.startsWith('select_one') && i % 3 === 1) {
+ row['choice_filter'] = "filter = 'a'";
+ }
+ });
+ choices.forEach((row, i) => {
+ if (i % 2 === 0)
+ media(row, `${String(row['list_name'])}_${String(row['name'])}`);
+ if (i % 3 === 0) row['filter'] = 'a';
+ });
+}
+
/** Serialize a generated form to XLSForm rows. */
function sheets(f: Form) {
const survey: Row[] = [];
@@ -360,6 +389,7 @@ function sheets(f: Form) {
survey.push({ type: 'end_group' });
}
}
+ if (f.columns) addColumns(survey, choices, f.multilingual);
const settings: Row = Object.fromEntries(
Object.entries(f.settings).filter(([, v]) => v !== undefined),
);
diff --git a/tests/ts/unit/ddi/fromDdi.test.ts b/tests/ts/unit/ddi/fromDdi.test.ts
index 1281e55..f9bd0c7 100644
--- a/tests/ts/unit/ddi/fromDdi.test.ts
+++ b/tests/ts/unit/ddi/fromDdi.test.ts
@@ -181,3 +181,61 @@ describe('cells as authored (#160)', () => {
]);
});
});
+
+describe("columns the model doesn't lift (#160)", () => {
+ const survey = [
+ { type: 'begin_group', name: 'g', label: 'G', intent: 'field-list' },
+ {
+ type: 'select_one yn',
+ name: 'q',
+ label: 'Q',
+ choice_filter: "f = 'a'",
+ 'media::image::Deutsch (de)': 'q.png',
+ },
+ { type: 'note', name: 'n', label: 'N', 'media::audio': 'n.mp3' },
+ { type: 'end_group' },
+ ];
+ const choices = [
+ {
+ list_name: 'yn',
+ name: 'y',
+ label: 'Yes',
+ f: 'a',
+ 'media::image': 'y.png',
+ },
+ { list_name: 'yn', name: 'n', label: 'No' },
+ ];
+ const xml = buildDdiXml(survey, choices, { prodDate: '2020-01-01' });
+
+ test('are cdl:column, cdl:choice_column and cdl:row_column notes', () => {
+ expect(xml).toContain(
+ `f = 'a'`,
+ );
+ expect(xml).toContain(
+ 'q.png',
+ );
+ expect(xml).toContain(
+ 'y.png',
+ );
+ expect(xml).toContain(
+ 'n.mp3',
+ );
+ expect(xml).toContain(
+ 'field-list',
+ );
+ });
+
+ test('come back as the form had them', () => {
+ const back = ddiToXlsform(xml);
+ expect(back.survey[0]).toMatchObject({ intent: 'field-list' });
+ expect(back.survey[1]).toMatchObject({
+ choice_filter: "f = 'a'",
+ 'media::image::Deutsch (de)': 'q.png',
+ });
+ expect(back.survey[2]).toMatchObject({
+ name: 'n',
+ 'media::audio': 'n.mp3',
+ });
+ expect(back.choices[0]).toMatchObject({ f: 'a', 'media::image': 'y.png' });
+ });
+});