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' }); + }); +});