cht-conf 6.5.1 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/fn/convert-contact-forms.js +18 -21
- package/src/lib/convert-forms/index.js +6 -2
- package/src/lib/forms-utils.js +3 -1
- package/src/lib/validate-forms.js +11 -1
- package/src/lib/validation/form/README.md +1 -1
- package/src/lib/validation/form/validate-field-paths.js +148 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [6.6.0](https://github.com/medic/cht-conf/compare/v6.5.2...v6.6.0) (2026-08-21)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **#804:** support linting form field paths during validation ([#805](https://github.com/medic/cht-conf/issues/805)) ([30853a7](https://github.com/medic/cht-conf/commit/30853a795658e626a533871d67023be91ada7801)), closes [#804](https://github.com/medic/cht-conf/issues/804)
|
|
7
|
+
|
|
8
|
+
## [6.5.2](https://github.com/medic/cht-conf/compare/v6.5.1...v6.5.2) (2026-08-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **#832:** fix contact form xml parsing when init has sub-group ([#835](https://github.com/medic/cht-conf/issues/835)) ([e4a11ce](https://github.com/medic/cht-conf/commit/e4a11ced5a48019718299600a122fcd70067b38a))
|
|
14
|
+
|
|
1
15
|
## [6.5.1](https://github.com/medic/cht-conf/compare/v6.5.0...v6.5.1) (2026-07-15)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -2,6 +2,23 @@ const convertForms = require('../lib/convert-forms').execute;
|
|
|
2
2
|
const environment = require('../lib/environment');
|
|
3
3
|
const fs = require('../lib/sync-fs');
|
|
4
4
|
const { CONTACT_FORMS_PATH } = require('../lib/project-paths');
|
|
5
|
+
const { getNode, XPATH_BODY } = require('../lib/forms-utils');
|
|
6
|
+
|
|
7
|
+
const getBodyGroup = (xmlDoc, ref) => getNode(xmlDoc, `${XPATH_BODY}//group[@ref="${ref}"]`);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders the contact fields on the init page instead of on a page of their own, by making the
|
|
11
|
+
* <contact> body group the last child of the <init> group. Only the body group is moved: the
|
|
12
|
+
* contact data must stay at /data/contact for the webapp to save the doc correctly.
|
|
13
|
+
*/
|
|
14
|
+
const moveContactGroupIntoInit = (xmlDoc) => {
|
|
15
|
+
const initGroup = getBodyGroup(xmlDoc, '/data/init');
|
|
16
|
+
const contactGroup = getBodyGroup(xmlDoc, '/data/contact');
|
|
17
|
+
|
|
18
|
+
if (initGroup && contactGroup) {
|
|
19
|
+
initGroup.appendChild(contactGroup);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
5
22
|
|
|
6
23
|
const convertContactForm = (forms) => {
|
|
7
24
|
const dir = `${environment.pathToProject}/${CONTACT_FORMS_PATH}`;
|
|
@@ -20,6 +37,7 @@ const convertContactForm = (forms) => {
|
|
|
20
37
|
return convertForms(environment.pathToProject, 'contact', {
|
|
21
38
|
enketo: true,
|
|
22
39
|
forms: forms,
|
|
40
|
+
domTransformer: moveContactGroupIntoInit,
|
|
23
41
|
transformer: (xml, path) => {
|
|
24
42
|
const type = path.replace(/.*\/(.*?)(-(create|edit))?\.xml.swp$/, '$1');
|
|
25
43
|
|
|
@@ -60,27 +78,6 @@ const convertContactForm = (forms) => {
|
|
|
60
78
|
}
|
|
61
79
|
}
|
|
62
80
|
|
|
63
|
-
if (xml.includes('ref="/data/contact"')) {
|
|
64
|
-
const groupRegex = name => new RegExp(`(\\s*)<group(\\s.*)?\\sref="${name}"(\\s.*)?>[^]*?</group>`);
|
|
65
|
-
let matchedBlock;
|
|
66
|
-
|
|
67
|
-
if (xml.match(groupRegex('/data/init'))) {
|
|
68
|
-
xml = xml.replace(groupRegex('/data/contact'), match => {
|
|
69
|
-
matchedBlock = match;
|
|
70
|
-
return '';
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
if (matchedBlock) {
|
|
74
|
-
const stripTrailingGroup = s => s.replace(/[\r\n\s]*<\/group>$/, '');
|
|
75
|
-
xml = xml.replace(groupRegex('/data/init'), match => {
|
|
76
|
-
return stripTrailingGroup(match) +
|
|
77
|
-
stripTrailingGroup(matchedBlock).replace(/\n/g, '\n ') +
|
|
78
|
-
'\n </group>\n </group>';
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
81
|
return xml;
|
|
85
82
|
},
|
|
86
83
|
});
|
|
@@ -53,7 +53,7 @@ const execute = async (projectDir, subDirectory, options = {}) => {
|
|
|
53
53
|
try {
|
|
54
54
|
await xls2xform(escapeWhitespacesInPath(sourcePath), escapeWhitespacesInPath(xmlSwpPath), xls);
|
|
55
55
|
const hiddenFields = await getHiddenFields(`${fs.withoutExtension(sourcePath)}.properties.json`);
|
|
56
|
-
fixXml(xmlSwpPath, hiddenFields, options
|
|
56
|
+
fixXml(xmlSwpPath, hiddenFields, options);
|
|
57
57
|
} catch (e) {
|
|
58
58
|
nodeFs.rmSync(xmlSwpPath, { force: true });
|
|
59
59
|
throw e;
|
|
@@ -123,7 +123,7 @@ const xls2xform = async (sourcePath, targetPath, xlsxFileName) => {
|
|
|
123
123
|
|
|
124
124
|
// here we fix the form content in arcane ways. Seeing as we have out own fork
|
|
125
125
|
// of pyxform, we should probably be doing this fixing there.
|
|
126
|
-
const fixXml = (path, hiddenFields, transformer, enketo) => {
|
|
126
|
+
const fixXml = (path, hiddenFields, { transformer, domTransformer, enketo } = {}) => {
|
|
127
127
|
// This is not how you should modify XML, but we have reasonable control over
|
|
128
128
|
// the input and so far this works OK. Keep an eye on the tests, and any
|
|
129
129
|
// future changes to the output of xls2xform.
|
|
@@ -159,6 +159,10 @@ const fixXml = (path, hiddenFields, transformer, enketo) => {
|
|
|
159
159
|
addRepeatCount(xmlDoc);
|
|
160
160
|
handleDbDocRefs(xmlDoc);
|
|
161
161
|
|
|
162
|
+
if (domTransformer) {
|
|
163
|
+
domTransformer(xmlDoc, path);
|
|
164
|
+
}
|
|
165
|
+
|
|
162
166
|
const xmlString = serializer.serializeToString(xmlDoc);
|
|
163
167
|
xml = xmlFormat(xmlString, {
|
|
164
168
|
collapseContent: true,
|
package/src/lib/forms-utils.js
CHANGED
|
@@ -81,9 +81,11 @@ module.exports = {
|
|
|
81
81
|
/**
|
|
82
82
|
* Returns the `bind` nodes for the given form XML.
|
|
83
83
|
* @param {Document} xmlDoc the form XML document
|
|
84
|
+
* @param {string} filter the form XML bind nodes (optional).
|
|
85
|
+
* Example: [not(@nodeset = "/data/inputs/user/contact_id")]
|
|
84
86
|
* @returns {Element}
|
|
85
87
|
*/
|
|
86
|
-
getBindNodes: xmlDoc => getNodes(xmlDoc, `${XPATH_MODEL}/bind`),
|
|
88
|
+
getBindNodes: (xmlDoc, filter = '') => getNodes(xmlDoc, `${XPATH_MODEL}/bind${filter}`),
|
|
87
89
|
|
|
88
90
|
/**
|
|
89
91
|
* Returns the `instance` node with the given ID for the given form XML.
|
|
@@ -35,6 +35,14 @@ const validations = fs.readdir(VALIDATIONS_PATH)
|
|
|
35
35
|
return a.name.localeCompare(b.name);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
const getPropsData = (propsJson) => {
|
|
39
|
+
if(!fs.exists(propsJson) || !fs.statSync(propsJson).isFile()){
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return fs.readJson(propsJson) || {};
|
|
44
|
+
};
|
|
45
|
+
|
|
38
46
|
module.exports = async (projectDir, subDirectory, options={}) => {
|
|
39
47
|
|
|
40
48
|
const formsDir = getFormDir(projectDir, subDirectory);
|
|
@@ -53,13 +61,15 @@ module.exports = async (projectDir, subDirectory, options={}) => {
|
|
|
53
61
|
for(const fileName of fileNames) {
|
|
54
62
|
log.info(`Validating form: ${fileName}…`);
|
|
55
63
|
|
|
56
|
-
const { xformPath } = getFormFilePaths(formsDir, fileName);
|
|
64
|
+
const { xformPath, baseFileName } = getFormFilePaths(formsDir, fileName);
|
|
57
65
|
const xml = fs.read(xformPath);
|
|
66
|
+
const propsData = getPropsData(`${formsDir}/${baseFileName}.properties.json`);
|
|
58
67
|
|
|
59
68
|
const valParams = {
|
|
60
69
|
xformPath,
|
|
61
70
|
xmlStr: xml,
|
|
62
71
|
xmlDoc: domParser.parseFromString(xml),
|
|
72
|
+
propsData,
|
|
63
73
|
apiVersion,
|
|
64
74
|
};
|
|
65
75
|
for(const validation of validations) {
|
|
@@ -20,7 +20,7 @@ module.exports = {
|
|
|
20
20
|
|-------------------------|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
21
21
|
| `requiresInstance` | Optional, defaults to `true` | The validation needs the user to have provided a instance location, e.g. via `--local` or `--instance` |
|
|
22
22
|
| `skipFurtherValidation` | Optional, defaults to `false` | When `true`, additional validations will not be performed on a form that fails the current validation. The goal is to avoid unnecessary noise in the validation log for forms that are grossly invalid (e.g. XML files that are not actually xForms at all). |
|
|
23
|
-
| `execute(opts)` | Required | The function that is run when the validation is executed. The available ops are:<br>* `xformPath` - the file path to the form<br>* `xmlStr` - string value of the form XML<br>* `xmlDoc` - Document value of the form XML<br>* `apiVersion` - the version of the CHT instance (if connection information was provided) or `null` |
|
|
23
|
+
| `execute(opts)` | Required | The function that is run when the validation is executed. The available ops are:<br>* `xformPath` - the file path to the form<br>* `xmlStr` - string value of the form XML<br>* `xmlDoc` - Document value of the form XML<br>* `propsData` - JSON contents of the form XML's properties file<br>* `apiVersion` - the version of the CHT instance (if connection information was provided) or `null` |
|
|
24
24
|
|
|
25
25
|
## Result
|
|
26
26
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const { getBindNodes } = require('../../forms-utils');
|
|
2
|
+
const joi = require('joi');
|
|
3
|
+
|
|
4
|
+
const XML_ATT_NODESET = 'nodeset';
|
|
5
|
+
|
|
6
|
+
const schema = joi.object({
|
|
7
|
+
warn_length: joi.number().integer().min(0).when('error_length', {
|
|
8
|
+
is: joi.number().required().greater(0),
|
|
9
|
+
then: joi.number().less(joi.ref('error_length')),
|
|
10
|
+
}).optional(),
|
|
11
|
+
error_length: joi.number().integer().min(0).optional(),
|
|
12
|
+
ignore_list: joi.array().items(joi.string().pattern(/[`'"]/, { invert: true })).optional().default([]),
|
|
13
|
+
reserved_list: joi.array().items(joi.string().pattern(/[`'"]/, { invert: true })).optional().default([]),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function formatFeedbackMsg(title, items, footer) {
|
|
17
|
+
return `${title}\n${items.join('\n')}\n${footer}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function checkListOverlap(ignoreList, reservedList) {
|
|
21
|
+
if (!ignoreList.length || !reservedList.length) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const reservedSet = new Set(reservedList);
|
|
26
|
+
const overlap = ignoreList.filter(x => reservedSet.has(x));
|
|
27
|
+
|
|
28
|
+
if (overlap.length > 0) {
|
|
29
|
+
throw new Error(formatFeedbackMsg(
|
|
30
|
+
'Overlap between reserved and ignore lists:',
|
|
31
|
+
overlap,
|
|
32
|
+
'Please remove where appropriate.'
|
|
33
|
+
));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validatePropDataOrThrow(props) {
|
|
38
|
+
const { error, value } = schema.validate(props, { abortEarly: false });
|
|
39
|
+
if (error) {
|
|
40
|
+
throw new Error(error.details.map(d => d.message).join('; '));
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildPath(entry) {
|
|
46
|
+
return `/data/${entry.replace(/^\/?(data\/)?/, '')}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function unpackProps(props) {
|
|
50
|
+
const warnLength = props['warn_length'];
|
|
51
|
+
const errorLength = props['error_length'];
|
|
52
|
+
const ignoreList = props['ignore_list'].map(buildPath);
|
|
53
|
+
const reservedList = props['reserved_list'].map(buildPath);
|
|
54
|
+
|
|
55
|
+
return { warnLength, errorLength, ignoreList, reservedList };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildExclusionPath(list) {
|
|
59
|
+
if (!list.length) {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
const conditions = list.map(v => `@${XML_ATT_NODESET} = "${v}"`).join(' or ');
|
|
63
|
+
return `[not(${conditions})]`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getFilteredBindNodes(xmlDoc, ignoreList) {
|
|
67
|
+
return getBindNodes(xmlDoc, buildExclusionPath(ignoreList));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getPathsWithLength(bindNodePaths, targetLength) {
|
|
71
|
+
if (!targetLength) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return bindNodePaths.filter(path => path.length >= targetLength);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateReservedNodes(bindNodePaths, reserved) {
|
|
79
|
+
const reservedNodes = bindNodePaths.filter(path => reserved.includes(path));
|
|
80
|
+
if (reservedNodes.length > 0) {
|
|
81
|
+
throw new Error(formatFeedbackMsg(
|
|
82
|
+
'The following reserved entries were found in the form:',
|
|
83
|
+
reservedNodes,
|
|
84
|
+
'Please remove or rename as appropriate.'
|
|
85
|
+
));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateWarnLength(bindNodePaths, warnLength) {
|
|
90
|
+
const warnLengthPaths = getPathsWithLength(bindNodePaths, warnLength);
|
|
91
|
+
if (warnLengthPaths.length > 0) {
|
|
92
|
+
return formatFeedbackMsg(
|
|
93
|
+
`The following vars are longer than the acceptable var length (${warnLength}):`,
|
|
94
|
+
warnLengthPaths,
|
|
95
|
+
'Please consider simplifying nesting or removing verbosity.'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateErrorLength(bindNodePaths, errorLength) {
|
|
101
|
+
const errorLengthPaths = getPathsWithLength(bindNodePaths, errorLength);
|
|
102
|
+
if (errorLengthPaths.length > 0) {
|
|
103
|
+
throw new Error(formatFeedbackMsg(
|
|
104
|
+
`The following vars are longer than the acceptable var length (${errorLength}):`,
|
|
105
|
+
errorLengthPaths,
|
|
106
|
+
'Please simplify nesting or remove verbosity.'
|
|
107
|
+
));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function validateFieldPaths(xmlDoc, props) {
|
|
112
|
+
const validatedProps = validatePropDataOrThrow(props);
|
|
113
|
+
|
|
114
|
+
const { warnLength, errorLength, ignoreList, reservedList } = unpackProps(validatedProps);
|
|
115
|
+
|
|
116
|
+
checkListOverlap(ignoreList, reservedList);
|
|
117
|
+
const bindNodes = getFilteredBindNodes(xmlDoc, ignoreList);
|
|
118
|
+
const bindNodePaths = bindNodes.map(node => node.getAttribute(XML_ATT_NODESET));
|
|
119
|
+
validateReservedNodes(bindNodePaths, reservedList);
|
|
120
|
+
validateErrorLength(bindNodePaths, errorLength);
|
|
121
|
+
const warning = validateWarnLength(bindNodePaths, warnLength);
|
|
122
|
+
|
|
123
|
+
return warning ? [ warning ] : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function execute({ xmlDoc, propsData }) {
|
|
127
|
+
const warnings = [];
|
|
128
|
+
const errors = [];
|
|
129
|
+
try {
|
|
130
|
+
const fieldPathLinting = propsData.field_path_linting;
|
|
131
|
+
if (!fieldPathLinting || Object.keys(fieldPathLinting).length === 0) {
|
|
132
|
+
return { warnings, errors };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
warnings.push(...validateFieldPaths(xmlDoc, fieldPathLinting));
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
errors.push(e.message);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { warnings, errors };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
requiresInstance: false,
|
|
146
|
+
skipFurtherValidation: false,
|
|
147
|
+
execute
|
|
148
|
+
};
|