cht-conf 5.6.0 → 6.0.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/.mocharc.js ADDED
@@ -0,0 +1,19 @@
1
+ const chai = require('chai');
2
+ chai.use(require('chai-as-promised'));
3
+ chai.use(require('chai-exclude'));
4
+ chai.use(require('chai-shallow-deep-equal'));
5
+ chai.use(require('sinon-chai'));
6
+ chai.use(require('chai-xml'));
7
+
8
+ module.exports = {
9
+ allowUncaught: false,
10
+ asyncOnly: false,
11
+ exclude: '../../test/e2e/**/*.spec.js',
12
+ exit: true,
13
+ color: true,
14
+ fullTrace: true,
15
+ recursive: true,
16
+ reporter: 'spec',
17
+ // These tests get run from build/test
18
+ spec: '../../test/**/*.spec.js',
19
+ };
package/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ # [6.0.0](https://github.com/medic/cht-conf/compare/v5.6.0...v6.0.0) (2026-01-12)
2
+
3
+
4
+ * feat([#537](https://github.com/medic/cht-conf/issues/537))!: error when form internalId does not match file name ([#776](https://github.com/medic/cht-conf/issues/776)) ([f921f1e](https://github.com/medic/cht-conf/commit/f921f1e1c6c0e32f8d2a6b5f00c9700eab338700))
5
+ * feat([#613](https://github.com/medic/cht-conf/issues/613))!: uplift pyxform to `v4.0.0-medic` ([#709](https://github.com/medic/cht-conf/issues/709)) ([d78296d](https://github.com/medic/cht-conf/commit/d78296d4df7a43699a1d7a78584be4b8cc3967f9))
6
+
7
+
8
+ ### BREAKING CHANGES
9
+
10
+ * Requires consistent naming of form internalId.
11
+ * New version of pyxform is bundled with additional form
12
+ verifications.
13
+
1
14
  # [5.6.0](https://github.com/medic/cht-conf/compare/v5.5.0...v5.6.0) (2025-12-15)
2
15
 
3
16
 
package/Dockerfile CHANGED
@@ -7,23 +7,14 @@ RUN apt update \
7
7
  curl \
8
8
  git \
9
9
  openssh-client \
10
- python3-pip \
11
- python3-setuptools \
12
- python3-venv \
13
- python3-wheel \
10
+ python3 \
14
11
  xsltproc \
15
12
  # Remove chromium to save space. We only installed it to get the transitive dependencies that are needed
16
13
  # when running tests with puppeteer. (puppeteer-chromium-resolver will always download its own version of chromium)
17
14
  && apt remove -y chromium \
18
- && rm -rf /var/lib/apt/lists/*
15
+ && rm -rf /var/lib/apt/lists/* \
16
+ && npm install -g cht-conf
19
17
 
20
- ENV VIRTUAL_ENV=/opt/venv
21
- RUN python3 -m venv "$VIRTUAL_ENV"
22
- ENV PATH="$VIRTUAL_ENV/bin:$PATH"
23
-
24
- RUN python3 -m pip install git+https://github.com/medic/pyxform.git@medic-conf-1.17#egg=pyxform-medic
25
-
26
- RUN npm install -g cht-conf
27
18
 
28
19
  # Using the 1000:1000 user is recommended for VSCode dev containers
29
20
  # https://code.visualstudio.com/remote/advancedcontainers/add-nonroot-user
package/README.md CHANGED
@@ -1,4 +1,5 @@
1
1
  # CHT Conf
2
+
2
3
  ![GitHub license](https://img.shields.io/github/license/medic/cht-conf)
3
4
  ![GitHub release](https://img.shields.io/github/v/release/medic/cht-conf)
4
5
  [![Discourse](https://img.shields.io/badge/discourse-join-blue)](https://forum.communityhealthtoolkit.org/)
@@ -9,7 +10,7 @@ Instructions on how to use this tool can be found on [the CHT documentation site
9
10
 
10
11
  ## Copyright
11
12
 
12
- Copyright 2013-2025 Medic Mobile, Inc. <hello@medic.org>
13
+ Copyright 2013-2026 Medic Mobile, Inc. <hello@medic.org>
13
14
 
14
15
  ## License
15
16
 
package/bin/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # cht-conf binaries
2
+
3
+ ## xls2xform-medic
4
+
5
+ This is a [custom version](https://github.com/medic/pyxform) of [pyxform](https://github.com/XLSForm/pyxform).
6
+
7
+ The `xls2xform-medic` binary [is packaged](https://docs.communityhealthtoolkit.org/community/contributing/code/releasing/pyxform/) and included here for convenience.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cht-conf",
3
- "version": "5.6.0",
3
+ "version": "6.0.0",
4
4
  "description": "Configure CHT deployments",
5
5
  "main": "./src/lib/main.js",
6
6
  "engines": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "clean": "rm -rf ./build/",
11
11
  "eslint": "eslint 'src/**/*.js' test/*.js 'test/**/*.js'",
12
- "test": "npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha \"../../test/**/*.spec.js\" --exclude \"../../test/e2e/**/*.spec.js\"",
12
+ "test": "npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha",
13
13
  "test-e2e": "mocha --config test/e2e/.mocharc.js",
14
14
  "semantic-release": "semantic-release"
15
15
  },
@@ -68,6 +68,7 @@
68
68
  "terser-webpack-plugin": "^5.3.11",
69
69
  "uuid": "^11.1.0",
70
70
  "webpack": "^5.102.1",
71
+ "xml-formatter": "^3.6.7",
71
72
  "xpath": "0.0.34"
72
73
  },
73
74
  "devDependencies": {
@@ -92,6 +93,7 @@
92
93
  "pouchdb-adapter-memory": "^9.0.0",
93
94
  "rewire": "^9.0.1",
94
95
  "semantic-release": "^25.0.2",
95
- "sinon": "^21.0.0"
96
+ "sinon": "^21.0.0",
97
+ "sinon-chai": "^3.7.0"
96
98
  }
97
99
  }
@@ -19,10 +19,9 @@ const convertContactForm = (forms) => {
19
19
 
20
20
  return convertForms(environment.pathToProject, 'contact', {
21
21
  enketo: true,
22
- force_data_node: 'data',
23
22
  forms: forms,
24
23
  transformer: (xml, path) => {
25
- const type = path.replace(/.*\/(.*?)(-(create|edit))?\.xml/, '$1');
24
+ const type = path.replace(/.*\/(.*?)(-(create|edit))?\.xml.swp$/, '$1');
26
25
 
27
26
  if (PLACE_TYPES) {
28
27
  xml = xml
@@ -0,0 +1,49 @@
1
+ const {
2
+ getNodes,
3
+ XPATH_MODEL,
4
+ SIMPLE_XPATH_PATTERN,
5
+ getPrimaryInstanceNodeChildPath,
6
+ getNode
7
+ } = require('../forms-utils');
8
+
9
+ const getNodesWithDbDocRef = xmlDoc => {
10
+ return getNodes(xmlDoc, `${XPATH_MODEL}/instance//*[@db-doc-ref]`)
11
+ .filter(node => node.getAttribute('db-doc-ref').trim() !== '');
12
+ };
13
+
14
+ const isFormReference = (primaryInstanceNode, dbDocRef) => dbDocRef === `/${primaryInstanceNode.getAttribute('id')}`;
15
+ const isRelativePath = dbDocRef => !dbDocRef.startsWith('/') && SIMPLE_XPATH_PATTERN.test(dbDocRef);
16
+
17
+ const getCompatibleDbDocRef = (xmlDoc, node) => {
18
+ const currentDbDocRef = node.getAttribute('db-doc-ref').trim();
19
+ const absolutePath = getPrimaryInstanceNodeChildPath(node);
20
+ const [, dataInstanceName] = absolutePath.split('/');
21
+ const primaryInstanceNode = getNode(xmlDoc, `${XPATH_MODEL}/instance/${dataInstanceName}`);
22
+ if (isFormReference(primaryInstanceNode, currentDbDocRef)) {
23
+ return `/${dataInstanceName}`;
24
+ }
25
+ if (isRelativePath(currentDbDocRef)) {
26
+ const targetNode = getNode(node, currentDbDocRef);
27
+ if (targetNode) {
28
+ return getPrimaryInstanceNodeChildPath(targetNode);
29
+ }
30
+ }
31
+
32
+ return currentDbDocRef;
33
+ };
34
+
35
+ module.exports = {
36
+ /**
37
+ * Pyxform is now using relative paths for any field expressions inside a repeat. The instance::db-doc-ref
38
+ * functionality in cht-core does not handle relative paths properly. So we replace these with absolute paths.
39
+ * Additionally, the way to set your form field to the _id value for document currently being written is to set the
40
+ * db-doc-ref to "/<form_id>". Technically the `form_id` was the node name of the primary instance in the XForm xml.
41
+ * However, Pyxform now just uses `data` as the node name of the primary instance node for all forms. So, the logic
42
+ * in cht-core for handling db-doc-ref="/<form_id>" breaks. This function also replaces any db-doc-ref attributes
43
+ * that reference the form's own id with the correct node name of the primary instance node.
44
+ */
45
+ handleDbDocRefs: (xmlDoc) => {
46
+ getNodesWithDbDocRef(xmlDoc)
47
+ .forEach((node) => node.setAttribute('db-doc-ref', getCompatibleDbDocRef(xmlDoc, node)));
48
+ }
49
+ };
@@ -0,0 +1,58 @@
1
+ const { getPrimaryInstanceNode } = require('../forms-utils');
2
+
3
+ const CONTACT_SUB_DIR = 'contact';
4
+ const FORM_PREFIXES = new Set([CONTACT_SUB_DIR, 'training']);
5
+ /*
6
+ subDir - the name of the directory containing the form
7
+ fileName - the name of the form file (without extension)
8
+ formName - the base name of the form (without action suffix)
9
+ */
10
+ const PATH_PATTERN = /^.*\/(?<subDir>[^/]+)\/(?<fileName>(?<formName>[^/]+?)(-(?<action>create|edit))?)\.xml.swp$/;
11
+
12
+ const getPrefix = subDirectory => FORM_PREFIXES.has(subDirectory) ? `${subDirectory}:` : '';
13
+ const getSuffix = (subDirectory, action) => {
14
+ if (!action) {
15
+ return '';
16
+ }
17
+ return `${subDirectory === CONTACT_SUB_DIR ? ':' : '-'}${action}`;
18
+ };
19
+
20
+ module.exports = {
21
+ /**
22
+ * Ensures the form_id in the XML matches the expected id derived from the form's file name. If the xml for a
23
+ * contact/training form has an id that matches the file name but does not have the `contact:` or `training:`
24
+ * prefix, it updates the id to the expected format. Throws an error if there is a mismatch that cannot be resolved.
25
+ */
26
+ handleFormId: (xmlDoc, path) => {
27
+ const { groups: {
28
+ subDir,
29
+ fileName,
30
+ formName,
31
+ action
32
+ } } = path.match(PATH_PATTERN);
33
+ const idFromPath = `${getPrefix(subDir)}${formName}${getSuffix(subDir, action)}`;
34
+ const dataNode = Array
35
+ .from(getPrimaryInstanceNode(xmlDoc).childNodes)
36
+ .find(node => node.nodeType === 1);
37
+ const idFromXml = dataNode.getAttribute('id');
38
+
39
+ if (idFromXml === idFromPath || idFromXml === idFromPath.replace(formName, 'PLACE_TYPE')) {
40
+ // We already have the correct id
41
+ return;
42
+ }
43
+ // If the form_id is empty on the xlsx settings tab, pyxform will set the filename.
44
+ // For contact/training forms, we want to update the default id to match the proper format.
45
+ if (idFromXml === fileName && FORM_PREFIXES.has(subDir)) {
46
+ dataNode.setAttribute('id', idFromPath);
47
+ const smsPrefix = dataNode.getAttribute('prefix');
48
+ dataNode.setAttribute('prefix', smsPrefix.replace(idFromXml, idFromPath));
49
+ return;
50
+ }
51
+
52
+ throw new Error(`The file name for the form [${
53
+ fileName
54
+ }] does not match the form_id in the xlsx [${
55
+ idFromXml
56
+ }]. Rename the form xlsx/xml files to match the form_id.`);
57
+ }
58
+ };
@@ -0,0 +1,89 @@
1
+ const { getNodes, removeNode, XPATH_MODEL, XPATH_BODY, getNode, getInstanceNode } = require('../forms-utils');
2
+
3
+ const getDynamicDefaultNode = (xmlDoc) => (ref) => {
4
+ return getNode(
5
+ xmlDoc,
6
+ `${XPATH_MODEL}/setvalue[@ref='${ref}' and string-length(@value) > 0 and @event="odk-instance-first-load"]`
7
+ );
8
+ };
9
+
10
+ const moveDynamicDefaultValueToInstance = (xmlDoc) => (setValueNode) => {
11
+ const ref = setValueNode.getAttribute('ref');
12
+ const instanceNode = getNode(xmlDoc, `${XPATH_MODEL}/instance[1]${ref}`);
13
+ if (!instanceNode) {
14
+ return;
15
+ }
16
+
17
+ instanceNode.textContent = setValueNode.getAttribute('value');
18
+ removeNode(setValueNode);
19
+ };
20
+
21
+ const createItemForInstanceNode = (xmlDoc, parentNode) => (itemNode) => {
22
+ const children = Array.from(itemNode.childNodes);
23
+ const itextIdNode = children.find(n => n.nodeName === 'itextId');
24
+ const nameNode = children.find(n => n.nodeName === 'name');
25
+ if (!itextIdNode || !nameNode) {
26
+ return;
27
+ }
28
+
29
+ const itemElem = xmlDoc.createElement('item');
30
+ const labelElem = xmlDoc.createElement('label');
31
+ labelElem.setAttribute('ref', `jr:itext('${itextIdNode.textContent}')`);
32
+ itemElem.appendChild(labelElem);
33
+ const valueElem = xmlDoc.createElement('value');
34
+ valueElem.textContent = nameNode.textContent;
35
+ itemElem.appendChild(valueElem);
36
+ parentNode.appendChild(itemElem);
37
+ };
38
+
39
+ const insertSelectItemsWithMedia = (xmlDoc, instanceNode) => (itemSetNode) => {
40
+ const { parentNode } = itemSetNode;
41
+ getNodes(instanceNode, 'root/item')
42
+ .forEach(createItemForInstanceNode(xmlDoc, parentNode));
43
+ removeNode(itemSetNode);
44
+ };
45
+
46
+ const insertMediaSelectItemsForInstance = (xmlDoc) => (instanceNode) => {
47
+ const instanceId = instanceNode.getAttribute('id');
48
+ getNodes(xmlDoc, `${XPATH_BODY}//itemset[@nodeset="instance('${instanceId}')/root/item"]`)
49
+ .forEach(insertSelectItemsWithMedia(xmlDoc, instanceNode));
50
+ };
51
+
52
+ module.exports = {
53
+ /**
54
+ * When setting a base64 image string as a default value in a form, pyxform thinks it is "dynamic". This causes the
55
+ * value to not be displayed properly. So, in cases were a default value is being used for a display-base64-image
56
+ * field, this function will move the default value into the instance node where it will be treated as a static value.
57
+ * https://github.com/XLSForm/pyxform/issues/495
58
+ */
59
+ replaceBase64ImageDynamicDefaults: (xmlDoc) => {
60
+ getNodes(xmlDoc, `${XPATH_BODY}//input[contains(@appearance, 'display-base64-image')]`)
61
+ .map(node => node.getAttribute('ref'))
62
+ .map(getDynamicDefaultNode(xmlDoc))
63
+ .filter(Boolean)
64
+ .forEach(moveDynamicDefaultValueToInstance(xmlDoc));
65
+ },
66
+
67
+ /**
68
+ * Selects no longer include all item elements in the body, but instead the choices data is stored centrally as an
69
+ * instance and referenced in the body as an itemset. Unfortunately, this approach breaks the CHT logic for properly
70
+ * updating the media src urls associated with the choices. So, for these selects, we replace the itemset with the
71
+ * actual item elements, which the CHT logic can then handle as before.
72
+ *
73
+ * This is a temporary workaround until we can update the CHT logic to properly handle itemsets with media.
74
+ * https://github.com/XLSForm/pyxform/pull/614
75
+ */
76
+ replaceItemSetsWithMedia: (xmlDoc) => {
77
+ const mediaXpath = `${XPATH_MODEL}/itext/translation/text[value[@form="image" or @form="audio" or @form="video"]]`;
78
+ const allInstanceIdsWithMedia = getNodes(xmlDoc, mediaXpath)
79
+ .map(textNode => textNode.getAttribute('id'))
80
+ .map(id => id.match(/^(.+)-\d+$/))
81
+ .filter(Boolean)
82
+ .map(([, match]) => match);
83
+ const instanceNodes = Array
84
+ .from(new Set(allInstanceIdsWithMedia))
85
+ .map(instanceId => getInstanceNode(xmlDoc, instanceId))
86
+ .filter(Boolean);
87
+ instanceNodes.forEach(insertMediaSelectItemsForInstance(xmlDoc));
88
+ }
89
+ };
@@ -0,0 +1,23 @@
1
+ const { getNodes, removeNode, XPATH_MODEL, XPATH_BODY } = require('../forms-utils');
2
+
3
+ module.exports = {
4
+ /**
5
+ * Removes the "NO_LABEL" labels placeholder. XLSForm does not allow converting a field without a label, so the CHT
6
+ * convention is to use a "NO_LABEL" placeholder value.
7
+ */
8
+ removeNoLabelNodes: (xmlDoc) => {
9
+ const noLabelItextNodes = getNodes(
10
+ xmlDoc,
11
+ `${XPATH_MODEL}/itext/translation//text[count(*)=1 and (value="NO_LABEL" or value="DELETE_THIS_LINE")]`
12
+ );
13
+ const noLabelNodeIds = Array.from(new Set(noLabelItextNodes.map(textNode => textNode.getAttribute('id'))));
14
+ noLabelNodeIds
15
+ .flatMap(id => getNodes(xmlDoc, `${XPATH_BODY}//label[@ref="jr:itext('${id}')"]`))
16
+ .forEach(removeNode);
17
+ noLabelItextNodes.forEach(removeNode);
18
+
19
+ // Remove any additional NO_LABEL values from translation nodes that have other (multimedia) values
20
+ getNodes(xmlDoc, `${XPATH_MODEL}/itext/translation//value[text()="NO_LABEL" or text()="DELETE_THIS_LINE"]`)
21
+ .forEach(removeNode);
22
+ }
23
+ };
@@ -0,0 +1,83 @@
1
+ const { getNode, getNodes, removeNode, XPATH_MODEL, XPATH_BODY, SIMPLE_XPATH_PATTERN } = require('../forms-utils');
2
+
3
+ const repeatCountPath = (nodeset) => `${nodeset}_count`;
4
+
5
+ const repeatBodyNodeNeedsCountField = (bodyNode) => {
6
+ const nodeset = bodyNode
7
+ .getAttribute('nodeset')
8
+ .trim();
9
+ const jrCount = bodyNode
10
+ .getAttribute('jr:count')
11
+ .trim();
12
+ return nodeset && jrCount && jrCount !== repeatCountPath(nodeset) && SIMPLE_XPATH_PATTERN.test(jrCount);
13
+ };
14
+
15
+ const repeatCountDoesNotExist = (xmlDoc) => (bodyNode) => {
16
+ const path = repeatCountPath(bodyNode.getAttribute('nodeset'));
17
+ const existingField = getNode(xmlDoc, `${XPATH_MODEL}/instance${path}`);
18
+ return !existingField;
19
+ };
20
+
21
+ const addRepeatCountToInstance = (xmlDoc) => (bodyNode) => {
22
+ const path = repeatCountPath(bodyNode.getAttribute('nodeset'));
23
+ const pathSegments = path.split('/');
24
+ const fieldName = pathSegments.pop();
25
+ const parentPath = pathSegments.join('/');
26
+ const parentNode = getNode(xmlDoc, `${XPATH_MODEL}/instance${parentPath}`);
27
+ if (!parentNode) {
28
+ return false;
29
+ }
30
+ const newFieldNode = xmlDoc.createElement(fieldName);
31
+ parentNode.appendChild(newFieldNode);
32
+ return true;
33
+ };
34
+
35
+ const addRepeatCountBind = (xmlDoc) => (bodyNode) => {
36
+ const nodeset = repeatCountPath(bodyNode.getAttribute('nodeset'));
37
+ const bindNode = xmlDoc.createElement('bind');
38
+ bindNode.setAttribute('nodeset', nodeset);
39
+ bindNode.setAttribute('type', 'string');
40
+ bindNode.setAttribute('readonly', 'true()');
41
+ bindNode.setAttribute('calculate', bodyNode.getAttribute('jr:count'));
42
+ const modelNode = getNode(xmlDoc, XPATH_MODEL);
43
+ modelNode.appendChild(bindNode);
44
+ return bodyNode;
45
+ };
46
+
47
+ const updateJrCount = (bodyNode) => {
48
+ const jrCount = repeatCountPath(bodyNode.getAttribute('nodeset'));
49
+ bodyNode.setAttribute('jr:count', jrCount);
50
+ return bodyNode;
51
+ };
52
+
53
+ module.exports = {
54
+ /**
55
+ * Pyxform now will not always generate a `${repeat_name}_count` field for repeats with a dynamic repeat_count.
56
+ * When the repeat_count is a simple reference to another field Pyxform omits generating the `${repeat_name}_count`
57
+ * field and instead just directly references the other field in the jr:count attribute of the repeat in the body.
58
+ * Unfortunately, this can cause buggy behavior in Enketo. So, to ensure consistent behavior, we add the
59
+ * `${repeat_name}_count` field back into the instance model and update the jr:count attribute to reference it. *
60
+ * https://github.com/XLSForm/pyxform/issues/576
61
+ */
62
+ addRepeatCount: (xmlDoc) => {
63
+ getNodes(xmlDoc, `${XPATH_BODY}//repeat[@jr:count]`)
64
+ .filter(repeatBodyNodeNeedsCountField)
65
+ .filter(repeatCountDoesNotExist(xmlDoc))
66
+ .filter(addRepeatCountToInstance(xmlDoc))
67
+ .map(addRepeatCountBind(xmlDoc))
68
+ .forEach(updateJrCount);
69
+ },
70
+
71
+ /**
72
+ * Pyxform now inserts an initial entry into repeats directly in the instance model even when there is no
73
+ * repeat count. This causes the form to load with an entry already present in the repeat (as if the user already
74
+ * clicked the `+` button to add one). To maintain previous behavior, we remove this initial entry.
75
+ * https://github.com/XLSForm/pyxform/issues/182
76
+ */
77
+ removeExtraRepeatInstance: (xmlDoc) => {
78
+ getNodes(xmlDoc, `${XPATH_MODEL}/instance//*[@jr:template=""]`)
79
+ .map(({ parentNode, nodeName }) => getNode(parentNode, `./${nodeName}[not(@jr:template="")]`))
80
+ .filter(Boolean)
81
+ .forEach(removeNode);
82
+ }
83
+ };
@@ -0,0 +1,196 @@
1
+ const argsFormFilter = require('../args-form-filter');
2
+ const exec = require('../exec-promise');
3
+ const fs = require('../sync-fs');
4
+ const nodeFs = require('node:fs');
5
+ const {
6
+ getFormDir,
7
+ escapeWhitespacesInPath,
8
+ } = require('../forms-utils');
9
+ const { info, trace, warn, LEVEL_NONE } = require('../log');
10
+ const path = require('node:path');
11
+ const { DOMParser, XMLSerializer } = require('@xmldom/xmldom');
12
+ const xmlFormat = require('xml-formatter');
13
+ const { replaceBase64ImageDynamicDefaults, replaceItemSetsWithMedia } = require('./handle-media');
14
+ const { removeNoLabelNodes } = require('./handle-no-label-placeholders');
15
+ const { removeExtraRepeatInstance, addRepeatCount } = require('./handle-repeat');
16
+ const { handleDbDocRefs } = require('./handle-db-doc-ref');
17
+ const { handleFormId } = require('./handle-form-id');
18
+
19
+ const domParser = new DOMParser();
20
+ const serializer = new XMLSerializer();
21
+ const XLS2XFORM = path.join(__dirname, '..', '..', '..', 'bin', 'xls2xform-medic');
22
+
23
+ const FORM_EXTENSION = '.xlsx';
24
+ const formFileMatcher = (fileName) => {
25
+ if (fileName.endsWith(FORM_EXTENSION) &&
26
+ !fileName.startsWith('~$') // ignore Excel "owner files"
27
+ && fileName !== 'PLACE_TYPE-create.xlsx' && fileName !== 'PLACE_TYPE-edit.xlsx') {
28
+ return fileName.slice(0, fileName.length - FORM_EXTENSION.length);
29
+ }
30
+ return null;
31
+ };
32
+
33
+ const execute = async (projectDir, subDirectory, options = {}) => {
34
+ const formsDir = getFormDir(projectDir, subDirectory);
35
+
36
+ if (!fs.exists(formsDir)) {
37
+ warn(`Forms dir not found: ${formsDir}`);
38
+ return;
39
+ }
40
+
41
+ const filesToConvert = argsFormFilter(formsDir, FORM_EXTENSION, options)
42
+ .filter(name => formFileMatcher(name));
43
+
44
+ for (const xls of filesToConvert) {
45
+ const sourcePath = `${formsDir}/${xls}`;
46
+ const formPathNoExt = fs.withoutExtension(sourcePath);
47
+ const xmlSwpPath = `${formPathNoExt}.xml.swp`;
48
+ const targetPath = `${formPathNoExt}.xml`;
49
+
50
+ info('Converting form', sourcePath, '…');
51
+ nodeFs.rmSync(targetPath, { force: true });
52
+
53
+ try {
54
+ await xls2xform(escapeWhitespacesInPath(sourcePath), escapeWhitespacesInPath(xmlSwpPath), xls);
55
+ const hiddenFields = await getHiddenFields(`${fs.withoutExtension(sourcePath)}.properties.json`);
56
+ fixXml(xmlSwpPath, hiddenFields, options.transformer, options.enketo);
57
+ } catch (e) {
58
+ nodeFs.rmSync(xmlSwpPath, { force: true });
59
+ throw e;
60
+ }
61
+ nodeFs.renameSync(xmlSwpPath, targetPath);
62
+
63
+ trace('Converted form', sourcePath);
64
+ }
65
+ };
66
+
67
+ module.exports = {
68
+ SUPPORTED_EXTENSIONS: [FORM_EXTENSION],
69
+ formFileMatcher,
70
+ execute
71
+ };
72
+
73
+ const PYXFORM_EMPTY_GROUP_ERROR = '\'NoneType\' object is not iterable';
74
+ const PYXFORM_CODES = {
75
+ OK: 100,
76
+ WARNING: 101,
77
+ // ERROR: 999
78
+ };
79
+
80
+ const pyxformErrorMessage = (e) => {
81
+ const errorMsg = typeof e === `string` ? e : e.message || JSON.stringify(e);
82
+ if (PYXFORM_EMPTY_GROUP_ERROR === errorMsg) {
83
+ // Pyxform does not gracefully handle empty groups https://github.com/XLSForm/pyxform/issues/754
84
+ return `Check the form for an empty group or repeat.`;
85
+ }
86
+ return errorMsg;
87
+ };
88
+
89
+ const xls2xform = async (sourcePath, targetPath, xlsxFileName) => {
90
+ const result = await exec([XLS2XFORM, '--skip_validate', '--json', sourcePath, targetPath], LEVEL_NONE)
91
+ .then((output) => {
92
+ const entries = output
93
+ .split('\n')
94
+ .map(s => s.trim())
95
+ .filter(Boolean);
96
+ // It is possible for pyxform dependencies to log to stdout, so we take the last line
97
+ entries
98
+ .slice(0, -1)
99
+ .forEach(line => warn(line));
100
+ return JSON.parse(entries.at(-1));
101
+ })
102
+ .catch(e => {
103
+ throw new Error(`There was a problem executing xls2xform. Make sure you have Python 3.10+ installed.\n${
104
+ pyxformErrorMessage(e)
105
+ }`);
106
+ });
107
+ const {
108
+ code,
109
+ warnings = [],
110
+ } = result;
111
+
112
+ switch (code) {
113
+ case PYXFORM_CODES.OK:
114
+ break;
115
+ case PYXFORM_CODES.WARNING:
116
+ warn(`Converted ${xlsxFileName} with warnings:`);
117
+ warnings.forEach(w => warn(w));
118
+ break;
119
+ default:
120
+ throw new Error(`Could not convert ${xlsxFileName}: ${pyxformErrorMessage(result)}`);
121
+ }
122
+ };
123
+
124
+ // here we fix the form content in arcane ways. Seeing as we have out own fork
125
+ // of pyxform, we should probably be doing this fixing there.
126
+ const fixXml = (path, hiddenFields, transformer, enketo) => {
127
+ // This is not how you should modify XML, but we have reasonable control over
128
+ // the input and so far this works OK. Keep an eye on the tests, and any
129
+ // future changes to the output of xls2xform.
130
+ let xml = fs.read(path)
131
+
132
+ // The following copies behaviour from old bash scripts, and will create a
133
+ // second <meta> element if one already existed. We may want to actually
134
+ // merge the two instead.
135
+ .replace(/<inputs>/, META_XML_SECTION);
136
+
137
+ // Enketo _may_ not work with forms which define a default language - see
138
+ // https://github.com/medic/cht-core/issues/3174
139
+ if (enketo) {
140
+ xml = xml.replaceAll('default="true()"', '');
141
+ }
142
+
143
+ if (hiddenFields) {
144
+ const r = new RegExp(`<(${hiddenFields.join('|')})(/?)>`, 'g');
145
+ xml = xml.replace(r, '<$1 tag="hidden"$2>');
146
+ }
147
+
148
+ // Check for deprecations
149
+ if (xml.includes('repeat-relevant')) {
150
+ warn('From webapp version 2.14.0, repeat-relevant is no longer required. See https://github.com/medic/cht-core/issues/3449 for more info.');
151
+ }
152
+ const xmlDoc = domParser.parseFromString(xml);
153
+
154
+ handleFormId(xmlDoc, path);
155
+ replaceItemSetsWithMedia(xmlDoc);
156
+ replaceBase64ImageDynamicDefaults(xmlDoc);
157
+ removeNoLabelNodes(xmlDoc);
158
+ removeExtraRepeatInstance(xmlDoc);
159
+ addRepeatCount(xmlDoc);
160
+ handleDbDocRefs(xmlDoc);
161
+
162
+ const xmlString = serializer.serializeToString(xmlDoc);
163
+ xml = xmlFormat(xmlString, {
164
+ collapseContent: true,
165
+ forceSelfClosingEmptyTag: true,
166
+ indentation: ' ',
167
+ ignoredPaths: [
168
+ 'value' // Avoid trimming whitespace in the label text.
169
+ ],
170
+ lineSeparator: '\n'
171
+ }).replaceAll(/\s+<\/value>/g, '</value>'); // Ignoring the 'value' path results in extra trailing whitespace
172
+
173
+ if (transformer) {
174
+ xml = transformer(xml, path);
175
+ }
176
+
177
+ fs.write(path, xml);
178
+ };
179
+
180
+ function getHiddenFields(propsJson) {
181
+ if (fs.exists(propsJson)) {
182
+ return fs.readJson(propsJson).hidden_fields;
183
+ }
184
+
185
+ return [];
186
+ }
187
+
188
+ const META_XML_SECTION = `<inputs>
189
+ <meta>
190
+ <location>
191
+ <lat/>
192
+ <long/>
193
+ <error/>
194
+ <message/>
195
+ </location>
196
+ </meta>`;
@@ -25,7 +25,7 @@ module.exports = (args, logLevel=log.level) => new Promise((resolve, reject) =>
25
25
  reject(stderr);
26
26
  }
27
27
  else {
28
- resolve(stdout);
28
+ resolve(stdout || stderr);
29
29
  }
30
30
  });
31
31
 
@@ -2,6 +2,7 @@ const xpath = require('xpath');
2
2
  const fs = require('./sync-fs');
3
3
 
4
4
  const XPATH_MODEL = '/h:html/h:head/model';
5
+ const XPATH_BODY = '/h:html/h:body';
5
6
 
6
7
  const getNode = (currentNode, path) =>
7
8
  xpath.parse(path).select1({ node: currentNode, allowAnyNamespaceForNoPrefix: true });
@@ -18,6 +19,15 @@ const getFullNodePath = (childNode) => {
18
19
  };
19
20
 
20
21
  module.exports = {
22
+ XPATH_MODEL,
23
+ XPATH_BODY,
24
+
25
+ /**
26
+ * Matches XPath expressions that are only paths to a node (either absolute or relative) without any
27
+ * predicates, functions, operators, etc.
28
+ */
29
+ SIMPLE_XPATH_PATTERN: /^[/\w.-]+$/,
30
+
21
31
  /**
22
32
  * Get the full path of the form, or null if the path doesn't exist.
23
33
  * @returns {string|null}
@@ -46,6 +56,12 @@ module.exports = {
46
56
  };
47
57
  },
48
58
 
59
+ /**
60
+ * Removes the given XML node from its parent. The xmldom library does not support node.remove(), so we use
61
+ * parentNode.removeChild(node).
62
+ */
63
+ removeNode: node => node.parentNode.removeChild(node), // NOSONAR
64
+
49
65
  /**
50
66
  * Returns the node from the form XML specified by the given XPath.
51
67
  * @param {Element} currentNode the current node in the form XML document
@@ -69,6 +85,14 @@ module.exports = {
69
85
  */
70
86
  getBindNodes: xmlDoc => getNodes(xmlDoc, `${XPATH_MODEL}/bind`),
71
87
 
88
+ /**
89
+ * Returns the `instance` node with the given ID for the given form XML.
90
+ * @param {Document} xmlDoc the form XML document
91
+ * @param instanceId the id of the instance
92
+ * @returns {Element} the selected node or `undefined` if not found
93
+ */
94
+ getInstanceNode: (xmlDoc, instanceId) => getNode(xmlDoc, `${XPATH_MODEL}/instance[@id='${instanceId}']`),
95
+
72
96
  /**
73
97
  * Returns the primary (first) `instance` node for the given form XML.
74
98
  * @param {Document} xmlDoc the form XML document
@@ -103,21 +127,10 @@ module.exports = {
103
127
  readTitleFrom: xml =>
104
128
  xml.substring(xml.indexOf('<h:title>') + 9, xml.indexOf('</h:title>')),
105
129
 
106
- /**
107
- * Get the ID of the form
108
- * @param {string} xml the XML string
109
- * @returns {string}
110
- */
111
- readIdFrom: xml =>
112
- xml.match(/<model>[^]*<\/model>/)[0]
113
- .match(/<instance>[^]*<\/instance>/)[0]
114
- .match(/id="([^"]*)"/)[1],
115
-
116
130
  /**
117
131
  * Escape whitespaces in a path.
118
132
  * @param {string} path the path string
119
133
  * @returns {string}
120
134
  */
121
135
  escapeWhitespacesInPath: path => path.replace(/(\s+)/g, '\\$1'),
122
-
123
136
  };
@@ -11,7 +11,6 @@ const {
11
11
  getFormDir,
12
12
  getFormFilePaths,
13
13
  readTitleFrom,
14
- readIdFrom
15
14
  } = require('./forms-utils');
16
15
 
17
16
  const SUPPORTED_PROPERTIES = ['context', 'icon', 'title', 'xml2sms', 'subject_key', 'hidden_fields'];
@@ -60,7 +59,7 @@ const execute = async (projectDir, subDirectory, options) => {
60
59
  log.info(`Preparing form for upload: ${fileName}…`);
61
60
 
62
61
  const { baseFileName, mediaDir, xformPath, filePath } = getFormFilePaths(formsDir, fileName);
63
- const baseDocId = (options.id_prefix || '') + baseFileName.replace(/-/g, ':');
62
+ const internalId = (options.id_prefix || '') + baseFileName.replaceAll('-', ':');
64
63
 
65
64
  const mediaDirExists = fs.exists(mediaDir);
66
65
  if (!mediaDirExists) {
@@ -75,12 +74,7 @@ const execute = async (projectDir, subDirectory, options) => {
75
74
  sha256: hashSum.digest('hex'),
76
75
  };
77
76
 
78
- const internalId = readIdFrom(xml);
79
- if (internalId !== baseDocId) {
80
- log.warn('DEPRECATED', 'Form:', fileName, 'Bad ID set in XML. Expected:', baseDocId, 'but saw:', internalId, ' Support for setting these values differently will be dropped. Please see https://github.com/medic/cht-core/issues/3342.');
81
- }
82
-
83
- const docId = `form:${baseDocId}`;
77
+ const docId = `form:${internalId}`;
84
78
  const doc = {
85
79
  _id: docId,
86
80
  type: 'form',
@@ -91,7 +85,7 @@ const execute = async (projectDir, subDirectory, options) => {
91
85
  };
92
86
 
93
87
  const propertiesPath = `${formsDir}/${baseFileName}${FORM_PROPERTIES_EXTENSION}`;
94
- updateFromPropertiesFile(doc, propertiesPath, PROPERTIES);
88
+ updateFromPropertiesFile(baseFileName, doc, propertiesPath, PROPERTIES);
95
89
 
96
90
  doc._attachments = mediaDirExists ? attachmentsFromDir(mediaDir) : {};
97
91
  doc._attachments.xml = attachmentFromFile(xformPath);
@@ -118,24 +112,36 @@ module.exports = {
118
112
  execute
119
113
  };
120
114
 
121
- const updateFromPropertiesFile = (doc, path, supported_properties) => {
115
+ const updateFromPropertiesFile = (baseFileName, doc, path, supported_properties) => {
122
116
  if (fs.exists(path)) {
123
117
 
124
118
  const ignoredKeys = [];
125
119
  const properties = fs.readJson(path);
126
120
 
127
- Object.keys(properties).forEach(key => {
128
- if (typeof properties[key] !== 'undefined') {
121
+ Object
122
+ .keys(properties)
123
+ .forEach(key => {
124
+ if (properties[key] === undefined) {
125
+ return;
126
+ }
129
127
  if (supported_properties.includes(key)) {
130
128
  doc[key] = properties[key];
131
- } else if (key === 'internalId') {
129
+ return;
130
+ }
131
+ if (key === 'internalId') {
132
132
  log.warn(`DEPRECATED: ${path}. Please do not manually set internalId in .properties.json for new projects. Support for configuring this value will be dropped. Please see https://github.com/medic/cht-core/issues/3342.`);
133
- doc.internalId = properties.internalId;
134
- } else {
135
- ignoredKeys.push(key);
133
+ if (doc.internalId === properties.internalId) {
134
+ return;
135
+ }
136
+ throw new Error(`The file name for the form [${
137
+ baseFileName
138
+ }] does not match the internalId in the ${baseFileName}.properties.json [${
139
+ properties.internalId
140
+ }]. Rename the form xlsx/xml files to match the internalId.`);
136
141
  }
137
- }
138
- });
142
+
143
+ ignoredKeys.push(key);
144
+ });
139
145
 
140
146
  if (ignoredKeys.length) {
141
147
  log.warn(`Ignoring unknown properties in ${path}: ${ignoredKeys.join(', ')}`);
@@ -1,166 +0,0 @@
1
- const { execSync } = require('child_process');
2
-
3
- const argsFormFilter = require('./args-form-filter');
4
- const exec = require('./exec-promise');
5
- const fs = require('./sync-fs');
6
- const { getFormDir, escapeWhitespacesInPath } = require('./forms-utils');
7
- const { info, trace, warn } = require('./log');
8
-
9
- const XLS2XFORM = 'xls2xform-medic';
10
- const INSTALLATION_INSTRUCTIONS = `\nE To install the latest pyxform, try one of the following:
11
- E
12
- E Ubuntu
13
- E sudo python -m pip install git+https://github.com/medic/pyxform.git@medic-conf-1.17#egg=pyxform-medic
14
- E OSX
15
- E pip install git+https://github.com/medic/pyxform.git@medic-conf-1.17#egg=pyxform-medic
16
- E Windows (as Administrator)
17
- E python -m pip install git+https://github.com/medic/pyxform.git@medic-conf-1.17#egg=pyxform-medic --upgrade`;
18
- const UPDATE_INSTRUCTIONS = `\nE To remove the old version of pyxform:
19
- E
20
- E pip uninstall pyxform-medic
21
- E` + INSTALLATION_INSTRUCTIONS;
22
-
23
- const FORM_EXTENSION = '.xlsx';
24
- const formFileMatcher = (fileName) => {
25
- if (fileName.endsWith(FORM_EXTENSION) &&
26
- !fileName.startsWith('~$') // ignore Excel "owner files"
27
- && fileName !== 'PLACE_TYPE-create.xlsx' && fileName !== 'PLACE_TYPE-edit.xlsx') {
28
- return fileName.slice(0, fileName.length - FORM_EXTENSION.length);
29
- }
30
- return null;
31
- };
32
-
33
- const execute = async (projectDir, subDirectory, options) => {
34
- if (!options) {
35
- options = {};
36
- }
37
-
38
- const formsDir = getFormDir(projectDir, subDirectory);
39
-
40
- if (!fs.exists(formsDir)) {
41
- warn(`Forms dir not found: ${formsDir}`);
42
- return Promise.resolve();
43
- }
44
-
45
- const filesToConvert = argsFormFilter(formsDir, FORM_EXTENSION, options)
46
- .filter(name => formFileMatcher(name));
47
-
48
- for (const xls of filesToConvert) {
49
- const originalSourcePath = `${formsDir}/${xls}`;
50
- let sourcePath;
51
-
52
- if (options.force_data_node) {
53
- const temporaryPath = `${fs.mkdtemp()}/${options.force_data_node}.xlsx`;
54
- fs.copy(originalSourcePath, temporaryPath);
55
- sourcePath = temporaryPath;
56
- } else {
57
- sourcePath = originalSourcePath;
58
- }
59
-
60
- const targetPath = `${fs.withoutExtension(originalSourcePath)}.xml`;
61
-
62
- info('Converting form', originalSourcePath, '…');
63
-
64
- await xls2xform(escapeWhitespacesInPath(sourcePath), escapeWhitespacesInPath(targetPath));
65
- const hiddenFields = await getHiddenFields(`${fs.withoutExtension(originalSourcePath)}.properties.json`);
66
- await fixXml(targetPath, hiddenFields, options.transformer, options.enketo);
67
- trace('Converted form', originalSourcePath);
68
- }
69
- };
70
-
71
- module.exports = {
72
- SUPPORTED_EXTENSIONS: [FORM_EXTENSION],
73
- formFileMatcher,
74
- execute
75
- };
76
-
77
- const xls2xform = (sourcePath, targetPath) =>
78
- exec([XLS2XFORM, '--skip_validate', sourcePath, targetPath])
79
- .catch(e => {
80
- if (executableAvailable()) {
81
- if (e.includes('unrecognized arguments: --skip_validate')) {
82
- throw new Error('Your xls2xform installation appears to be out of date.' + UPDATE_INSTRUCTIONS);
83
- } else {
84
- throw e;
85
- }
86
- } else {
87
- throw new Error(
88
- 'There was a problem executing xls2xform. It may not be installed.' + INSTALLATION_INSTRUCTIONS
89
- );
90
- }
91
- });
92
-
93
- // here we fix the form content in arcane ways. Seeing as we have out own fork
94
- // of pyxform, we should probably be doing this fixing there.
95
- const fixXml = (path, hiddenFields, transformer, enketo) => {
96
- // This is not how you should modify XML, but we have reasonable control over
97
- // the input and so far this works OK. Keep an eye on the tests, and any
98
- // future changes to the output of xls2xform.
99
- let xml = fs.read(path)
100
-
101
- // The following copies behaviour from old bash scripts, and will create a
102
- // second <meta> element if one already existed. We may want to actually
103
- // merge the two instead.
104
- .replace(/<inputs>/, META_XML_SECTION)
105
-
106
- // XLSForm does not allow converting a field without a label, so we use
107
- // the placeholder NO_LABEL.
108
- .replace(/NO_LABEL/g, '')
109
-
110
- // No comment.
111
- .replace(/.*DELETE_THIS_LINE.*(\r|\n)/g, '')
112
- ;
113
-
114
- // Enketo _may_ not work with forms which define a default language - see
115
- // https://github.com/medic/cht-core/issues/3174
116
- if (enketo) {
117
- xml = xml.replace(/ default="true\(\)"/g, '');
118
- }
119
-
120
- if (hiddenFields) {
121
- const r = new RegExp(`<(${hiddenFields.join('|')})(/?)>`, 'g');
122
- xml = xml.replace(r, '<$1 tag="hidden"$2>');
123
- }
124
-
125
- if (transformer) {
126
- xml = transformer(xml, path);
127
- }
128
-
129
-
130
- // Check for deprecations
131
- if (xml.includes('repeat-relevant')) {
132
- warn('From webapp version 2.14.0, repeat-relevant is no longer required. See https://github.com/medic/cht-core/issues/3449 for more info.');
133
- }
134
-
135
- fs.write(path, xml);
136
- };
137
-
138
- function getHiddenFields(propsJson) {
139
- if (!fs.exists(propsJson)) {
140
- return [];
141
- }
142
- else {
143
- return fs.readJson(propsJson).hidden_fields;
144
- }
145
- }
146
-
147
- function executableAvailable() {
148
- try {
149
- execSync(`${XLS2XFORM} -h`, {
150
- stdio: ['ignore', 'ignore', 'ignore'],
151
- });
152
- return true;
153
- } catch (e) {
154
- return false;
155
- }
156
- }
157
-
158
- const META_XML_SECTION = `<inputs>
159
- <meta>
160
- <location>
161
- <lat/>
162
- <long/>
163
- <error/>
164
- <message/>
165
- </location>
166
- </meta>`;