cht-conf 5.6.0 → 6.0.1
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 +19 -0
- package/CHANGELOG.md +65 -0
- package/Dockerfile +3 -12
- package/README.md +2 -1
- package/bin/README.md +7 -0
- package/bin/xls2xform-medic +0 -0
- package/bin/xls2xform-medic.cmd +2 -0
- package/package.json +5 -3
- package/src/fn/convert-contact-forms.js +1 -2
- package/src/lib/convert-forms/handle-db-doc-ref.js +49 -0
- package/src/lib/convert-forms/handle-form-id.js +58 -0
- package/src/lib/convert-forms/handle-media.js +89 -0
- package/src/lib/convert-forms/handle-no-label-placeholders.js +23 -0
- package/src/lib/convert-forms/handle-repeat.js +83 -0
- package/src/lib/convert-forms/index.js +196 -0
- package/src/lib/exec-promise.js +1 -1
- package/src/lib/forms-utils.js +24 -11
- package/src/lib/upload-forms.js +24 -18
- package/src/lib/convert-forms.js +0 -166
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,68 @@
|
|
|
1
|
+
## [6.0.1](https://github.com/medic/cht-conf/compare/v6.0.0...v6.0.1) (2026-01-14)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **#785:** add `xls2xform-medic.cmd` file for Windows ([2686ce5](https://github.com/medic/cht-conf/commit/2686ce54b2aafa2bfec387f285458f9d9481f2a8)), closes [#785](https://github.com/medic/cht-conf/issues/785)
|
|
7
|
+
|
|
8
|
+
# [6.0.0](https://github.com/medic/cht-conf/compare/v5.6.0...v6.0.0) (2026-01-12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
* 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))
|
|
12
|
+
* 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))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
## BREAKING CHANGES
|
|
16
|
+
|
|
17
|
+
### New bundled version of pyxform
|
|
18
|
+
|
|
19
|
+
#### Local pyxform installation is no longer required
|
|
20
|
+
|
|
21
|
+
Previously, the `pyxform` utility (required for converting form `xlsx` files to the `xml` xform format for upload) needed to be manually installed in the user's environment. Now `pyxform` (version `v4.0.0-medic`) is bundled with cht-conf and installed automatically when cht-conf is installed. The user simply needs Python `3.12+` available in their environment.
|
|
22
|
+
|
|
23
|
+
Existing cht-conf users can safely uninstall any local `pyxform` installation. cht-conf will only use the bundled instance of `pyxform`.
|
|
24
|
+
|
|
25
|
+
#### New pyxform version may require change to forms
|
|
26
|
+
|
|
27
|
+
The new version of `pyxform` brings with it a number of importiant changes that may require updating existing form `xlsx` files for compatibility. The known changes include:
|
|
28
|
+
|
|
29
|
+
##### Modified Functionality
|
|
30
|
+
- Attribute ordering in the generated XML file will now be deterministic.
|
|
31
|
+
- Re-converting an unmodified form should not result in any changes to the form's xml file.
|
|
32
|
+
- When converting a modified form, the only lines that will be changed in the form xml are ones that are directly related to the changes in the xlsx.
|
|
33
|
+
- Question fields are now required to have a label or hint. (Previously, pyxform would just print a warning if a question did not have a label or hint.)
|
|
34
|
+
- If you try to convert a form with a question field with an empty label (and no hint) it will fail with an error like: `The survey element named 'world' has no label or hint. `
|
|
35
|
+
- For fields that should not be displayed to the user, consider using the `caluculate` type when you have a `calculation` expression or a `default` value. If the field value is not coming from the `calculation` or `default` columns, the `hidden` type can be used without a label/hint. This is particularly useful for input data fields or fields populated by the contact selector.
|
|
36
|
+
- Empty groups/repeats (without any child fields) now result in an error.
|
|
37
|
+
- Duplicate choice names now result in an error by default. However, support can be re-enabled via [the `allow_choice_duplicates` setting](https://xlsform.org/en/#choice-names).
|
|
38
|
+
- pyxform will now warn when there are missing translation columns for any of the languages in the form.
|
|
39
|
+
|
|
40
|
+
##### Added functionality
|
|
41
|
+
|
|
42
|
+
- [`trigger` columns](https://docs.getodk.org/form-logic/#triggering-calculations-on-value-change) are now supported, allowing for calculations to be automatically re-evaluated when an unrelated value changes!
|
|
43
|
+
- You can now reference `${paths}` in _labels_ on the `choices` sheet.
|
|
44
|
+
- Support for [randomizing choice order](https://docs.getodk.org/form-question-types/#randomizing-choice-order) in select questions via the `randomize=true` parameter has been added.
|
|
45
|
+
|
|
46
|
+
* 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))
|
|
47
|
+
|
|
48
|
+
### Consistent naming of form identifier required
|
|
49
|
+
|
|
50
|
+
When there is a missmatch between the `form_id` in the xlsx, the _file name_ for the xml, and the `internalId` from the properties file, cht-conf will now raise an _error_ when you run the `convert-*-forms` action.
|
|
51
|
+
|
|
52
|
+
Now it is not required to specify the form identifier at all (in the `xlsx`, `xml`, or properties files). cht-conf will automatically set the form identifier based on the name of the xlsx file and the form type.
|
|
53
|
+
|
|
54
|
+
For cases where existing forms are now triggering an error:
|
|
55
|
+
|
|
56
|
+
- `contact` forms - remove any custom identifiers specified in the xlsx or properties file for `contact` forms.
|
|
57
|
+
- `app` forms - if a custom value (that does not match the xlsx file name) is currently specified as the `form_id` in the xlsx or the `internalId` in the properties file, the _file name_ of the form's xlsx/xml file should be updated to match the custom identifier value.
|
|
58
|
+
- The renamed form will be seen as a "new" form on the CHT instance (even though it will produce reports with the same `form` value). So, be sure to remove the old version of the form from the instance before renaming and re-uploading the updated form.
|
|
59
|
+
- Do not update the custom identifier value to match the form's _file name_. That will result in new reports being written with a different `form` value and can produce data consistancy issues.
|
|
60
|
+
- `training` forms - as with the `app` forms, if there is a missmatch in the identifier of a `training` form the _file name_ of the training form xlsx/xml file should be updated to match the custom identifier.
|
|
61
|
+
- Be sure to remove the old version of the form from the instance before renaming and re-uploading the updated form.
|
|
62
|
+
- Do not update the custom identifier value to match the form's _file name_. That will result in the form being treated as a totally new training and users would be prompted to redo the training even if they had previously completed it.
|
|
63
|
+
|
|
64
|
+
* 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))
|
|
65
|
+
|
|
1
66
|
# [5.6.0](https://github.com/medic/cht-conf/compare/v5.5.0...v5.6.0) (2025-12-15)
|
|
2
67
|
|
|
3
68
|
|
package/Dockerfile
CHANGED
|
@@ -7,23 +7,14 @@ RUN apt update \
|
|
|
7
7
|
curl \
|
|
8
8
|
git \
|
|
9
9
|
openssh-client \
|
|
10
|
-
python3
|
|
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
|

|
|
3
4
|

|
|
4
5
|
[](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-
|
|
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": "
|
|
3
|
+
"version": "6.0.1",
|
|
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
|
|
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
|
|
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>`;
|
package/src/lib/exec-promise.js
CHANGED
package/src/lib/forms-utils.js
CHANGED
|
@@ -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
|
};
|
package/src/lib/upload-forms.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
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(', ')}`);
|
package/src/lib/convert-forms.js
DELETED
|
@@ -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>`;
|