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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cht-conf",
3
- "version": "6.5.1",
3
+ "version": "6.6.0",
4
4
  "description": "Configure CHT deployments",
5
5
  "main": "./src/lib/main.js",
6
6
  "engines": {
@@ -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.transformer, options.enketo);
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,
@@ -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
+ };