cht-conf 3.11.0 → 3.13.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 +21 -0
- package/README.md +0 -1
- package/package.json +4 -3
- package/src/cli/usage.js +3 -0
- package/src/fn/move-contacts.js +2 -2
- package/src/fn/validate-app-forms.js +12 -0
- package/src/fn/validate-collect-forms.js +12 -0
- package/src/fn/validate-contact-forms.js +12 -0
- package/src/fn/watch-project.js +16 -0
- package/src/lib/environment.js +5 -2
- package/src/lib/forms-utils.js +47 -9
- package/src/lib/main.js +39 -11
- package/src/lib/upload-forms.js +0 -2
- package/src/lib/validate-forms.js +59 -39
- package/src/lib/validation/form/README.md +32 -0
- package/src/lib/validation/form/can-generate-xform.js +18 -0
- package/src/lib/validation/form/check-xpaths-exist.js +121 -0
- package/src/lib/validation/form/has-instance-id.js +14 -0
- package/src/lib/warn-upload-overwrite.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
# [3.13.0](https://github.com/medic/cht-conf/compare/v3.12.0...v3.13.0) (2022-07-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **#479:** validate simple XPaths in forms ([#486](https://github.com/medic/cht-conf/issues/486)) ([9df4a63](https://github.com/medic/cht-conf/commit/9df4a630e4bf69d6f6d102c6ae538f9e0bf10282)), closes [#479](https://github.com/medic/cht-conf/issues/479) [#479](https://github.com/medic/cht-conf/issues/479)
|
|
7
|
+
|
|
8
|
+
# [3.12.0](https://github.com/medic/cht-conf/compare/v3.11.1...v3.12.0) (2022-06-23)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **#484:** add actions for validating forms ([f2a6a8e](https://github.com/medic/cht-conf/commit/f2a6a8ef75561426ce2a6b542cdf1a255c869c6e)), closes [#484](https://github.com/medic/cht-conf/issues/484) [#481](https://github.com/medic/cht-conf/issues/481)
|
|
14
|
+
|
|
15
|
+
## [3.11.1](https://github.com/medic/cht-conf/compare/v3.11.0...v3.11.1) (2022-06-13)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **#482:** move-contacts usage docs ([5fa86c8](https://github.com/medic/cht-conf/commit/5fa86c8e98797d8920ee20f2e7f16c98aa969201)), closes [#482](https://github.com/medic/cht-conf/issues/482)
|
|
21
|
+
|
|
1
22
|
# [3.11.0](https://github.com/medic/cht-conf/compare/v3.10.2...v3.11.0) (2022-04-19)
|
|
2
23
|
|
|
3
24
|
|
package/README.md
CHANGED
|
@@ -300,7 +300,6 @@ Execute `npm test` to run static analysis checks and the test suite. Requires Do
|
|
|
300
300
|
1. Get the pull request reviewed and approved.
|
|
301
301
|
1. When doing the squash and merge, make sure that your commit message is clear and readable and follows the strict format described in the commit format section below. If the commit message does not comply, automatic release will fail.
|
|
302
302
|
1. In case you are planning to merge the pull request with a merge commit, make sure that every commit in your branch respects the format.
|
|
303
|
-
1. Announce the release on the [CHT forum](https://forum.communityhealthtoolkit.org), under the "Product - Releases" category.
|
|
304
303
|
|
|
305
304
|
### Commit format
|
|
306
305
|
The commit format should follow this [conventional-changelog angular preset](https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-angular). Examples are provided below.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cht-conf",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
4
4
|
"description": "Configure CHT deployments",
|
|
5
5
|
"main": "./src/lib/main.js",
|
|
6
6
|
"engines": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"eslint": "eslint 'src/**/*.js' test/*.js 'test/**/*.js'",
|
|
12
12
|
"docker-start-couchdb": "npm run docker-stop-couchdb && docker run -d -p 6984:5984 --rm --name cht-conf-couchdb couchdb:2.3.1 && sh test/scripts/wait_for_response_code.sh 6984 200 CouchDB",
|
|
13
13
|
"docker-stop-couchdb": "docker stop cht-conf-couchdb || true",
|
|
14
|
-
"test": "npm run eslint && npm run docker-start-couchdb && npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha --forbid-only ../../test/**/*.spec.js && cd ../.. && npm run docker-stop-couchdb",
|
|
14
|
+
"test": "npm run eslint && npm run docker-start-couchdb && npm run clean && mkdir -p build/test && cp -r test/data build/test/data && cd build/test && nyc --reporter=html mocha --forbid-only \"../../test/**/*.spec.js\" && cd ../.. && npm run docker-stop-couchdb",
|
|
15
15
|
"semantic-release": "semantic-release"
|
|
16
16
|
},
|
|
17
17
|
"bin": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"@hapi/joi": "^16.1.8",
|
|
39
39
|
"@medic/translation-checker": "^1.0.1",
|
|
40
40
|
"@parcel/watcher": "^2.0.5",
|
|
41
|
+
"@xmldom/xmldom": "^0.8.2",
|
|
41
42
|
"canonical-json": "0.0.4",
|
|
42
43
|
"csv-parse": "^4.16.0",
|
|
43
44
|
"dom-compare": "^0.6.0",
|
|
@@ -67,7 +68,7 @@
|
|
|
67
68
|
"terser-webpack-plugin": "^1.4.3",
|
|
68
69
|
"uuid": "^8.3.2",
|
|
69
70
|
"webpack": "^4.46.0",
|
|
70
|
-
"
|
|
71
|
+
"xpath": "0.0.32"
|
|
71
72
|
},
|
|
72
73
|
"devDependencies": {
|
|
73
74
|
"@commitlint/cli": "^13.2.0",
|
package/src/cli/usage.js
CHANGED
|
@@ -63,6 +63,9 @@ ${bold('OPTIONS')}
|
|
|
63
63
|
--skip-translation-check
|
|
64
64
|
Skips checking message translations
|
|
65
65
|
|
|
66
|
+
--skip-validate
|
|
67
|
+
Skips form validation
|
|
68
|
+
|
|
66
69
|
--force
|
|
67
70
|
CAN BE DANGEROUS! Passes yes to all commands and any where that would prompt to overwrite changes will overwrite automatically.
|
|
68
71
|
`);
|
package/src/fn/move-contacts.js
CHANGED
|
@@ -101,7 +101,7 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
|
|
|
101
101
|
|
|
102
102
|
if (contactIds.length === 0) {
|
|
103
103
|
usage();
|
|
104
|
-
throw Error('Action "move-contacts" is missing required list of
|
|
104
|
+
throw Error('Action "move-contacts" is missing required list of contacts to be moved');
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
if (!args.parent) {
|
|
@@ -137,7 +137,7 @@ ${bold('cht-conf\'s move-contacts action')}
|
|
|
137
137
|
When combined with 'upload-docs' this action effectively moves a contact from one place in the hierarchy to another.
|
|
138
138
|
|
|
139
139
|
${bold('USAGE')}
|
|
140
|
-
cht --local move-contacts -- --
|
|
140
|
+
cht --local move-contacts -- --contacts=<id1>,<id2> --parent=<parent_id>
|
|
141
141
|
|
|
142
142
|
${bold('OPTIONS')}
|
|
143
143
|
--contacts=<id1>,<id2>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const validateForms = require('../lib/validate-forms');
|
|
2
|
+
const environment = require('../lib/environment');
|
|
3
|
+
|
|
4
|
+
const validateAppForms = (forms) => {
|
|
5
|
+
return validateForms(environment.pathToProject, 'app', { forms });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
requiresInstance: false,
|
|
10
|
+
validateAppForms,
|
|
11
|
+
execute: () => validateAppForms(environment.extraArgs)
|
|
12
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const validateForms = require('../lib/validate-forms');
|
|
2
|
+
const environment = require('../lib/environment');
|
|
3
|
+
|
|
4
|
+
const validateCollectForms = (forms) => {
|
|
5
|
+
return validateForms(environment.pathToProject, 'collect', { forms });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
requiresInstance: false,
|
|
10
|
+
validateCollectForms,
|
|
11
|
+
execute: () => validateCollectForms(environment.extraArgs)
|
|
12
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const validateForms = require('../lib/validate-forms');
|
|
2
|
+
const environment = require('../lib/environment');
|
|
3
|
+
|
|
4
|
+
const validateContactForms = (forms) => {
|
|
5
|
+
return validateForms(environment.pathToProject, 'contact', { forms });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
requiresInstance: false,
|
|
10
|
+
validateContactForms,
|
|
11
|
+
execute: () => validateContactForms(environment.extraArgs)
|
|
12
|
+
};
|
package/src/fn/watch-project.js
CHANGED
|
@@ -5,6 +5,9 @@ const fs = require('fs');
|
|
|
5
5
|
const { error, warn, info } = require('../lib/log');
|
|
6
6
|
const Queue = require('queue-promise');
|
|
7
7
|
const watcher = require('@parcel/watcher');
|
|
8
|
+
const { validateAppForms } = require('./validate-app-forms');
|
|
9
|
+
const { validateContactForms } = require('./validate-contact-forms');
|
|
10
|
+
const { validateCollectForms } = require('./validate-collect-forms');
|
|
8
11
|
const { uploadAppForms } = require('./upload-app-forms');
|
|
9
12
|
const { uploadContactForms } = require('./upload-contact-forms');
|
|
10
13
|
const { uploadCollectForms } = require('./upload-collect-forms');
|
|
@@ -25,8 +28,17 @@ const watcherEvents = {
|
|
|
25
28
|
UpdateEvent: 'update'
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
const runValidation = (validation, forms) => {
|
|
32
|
+
if(environment.skipValidate) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
return validation(forms);
|
|
36
|
+
};
|
|
37
|
+
|
|
28
38
|
const uploadInitialState = async (api) => {
|
|
29
39
|
await uploadResources();
|
|
40
|
+
await runValidation(validateAppForms, environment.extraArgs);
|
|
41
|
+
await runValidation(validateContactForms, environment.extraArgs);
|
|
30
42
|
await uploadAppForms(environment.extraArgs);
|
|
31
43
|
await uploadContactForms(environment.extraArgs);
|
|
32
44
|
await uploadCustomTranslations();
|
|
@@ -82,6 +94,7 @@ const processAppForm = (eventType, fileName) => {
|
|
|
82
94
|
let form = uploadForms.formFileMatcher(fileName);
|
|
83
95
|
if (form) {
|
|
84
96
|
eventQueue.enqueue(async () => {
|
|
97
|
+
await runValidation(validateAppForms, [form]);
|
|
85
98
|
await uploadAppForms([form]);
|
|
86
99
|
return fileName;
|
|
87
100
|
});
|
|
@@ -103,6 +116,7 @@ const processAppFormMedia = (formMediaDir, fileName) => {
|
|
|
103
116
|
const form = uploadForms.formMediaMatcher(formMediaDir);
|
|
104
117
|
if (form) {
|
|
105
118
|
eventQueue.enqueue(async () => {
|
|
119
|
+
await runValidation(validateAppForms,[form]);
|
|
106
120
|
await uploadAppForms([form]);
|
|
107
121
|
return fileName;
|
|
108
122
|
});
|
|
@@ -127,6 +141,7 @@ const processContactForm = (eventType, fileName) => {
|
|
|
127
141
|
form = uploadForms.formFileMatcher(fileName);
|
|
128
142
|
if (form) {
|
|
129
143
|
eventQueue.enqueue(async () => {
|
|
144
|
+
await runValidation(validateContactForms,[form]);
|
|
130
145
|
await uploadContactForms([form]);
|
|
131
146
|
return fileName;
|
|
132
147
|
});
|
|
@@ -142,6 +157,7 @@ const processCollectForm = (eventType, fileName) => {
|
|
|
142
157
|
let form = uploadForms.formFileMatcher(fileName);
|
|
143
158
|
if (form) {
|
|
144
159
|
eventQueue.enqueue(async () => {
|
|
160
|
+
await runValidation(validateCollectForms,[form]);
|
|
145
161
|
await uploadCollectForms([form]);
|
|
146
162
|
return fileName;
|
|
147
163
|
});
|
package/src/lib/environment.js
CHANGED
|
@@ -12,7 +12,8 @@ const initialize = (
|
|
|
12
12
|
extraArgs,
|
|
13
13
|
apiUrl,
|
|
14
14
|
force,
|
|
15
|
-
skipTranslationCheck
|
|
15
|
+
skipTranslationCheck,
|
|
16
|
+
skipValidate
|
|
16
17
|
) => {
|
|
17
18
|
if (state.initialized) {
|
|
18
19
|
throw Error('environment is already initialized');
|
|
@@ -26,7 +27,8 @@ const initialize = (
|
|
|
26
27
|
isArchiveMode,
|
|
27
28
|
pathToProject,
|
|
28
29
|
force,
|
|
29
|
-
skipTranslationCheck
|
|
30
|
+
skipTranslationCheck,
|
|
31
|
+
skipValidate
|
|
30
32
|
});
|
|
31
33
|
};
|
|
32
34
|
|
|
@@ -51,6 +53,7 @@ module.exports = {
|
|
|
51
53
|
get apiUrl() { return getState('apiUrl'); },
|
|
52
54
|
get force() { return getState('force'); },
|
|
53
55
|
get skipTranslationCheck() { return getState('skipTranslationCheck'); },
|
|
56
|
+
get skipValidate() { return getState('skipValidate'); },
|
|
54
57
|
|
|
55
58
|
/**
|
|
56
59
|
* Return `true` if the environment **seems** to be production.
|
package/src/lib/forms-utils.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
const xpath = require('xpath');
|
|
1
2
|
const fs = require('./sync-fs');
|
|
2
3
|
|
|
4
|
+
const XPATH_MODEL = '/h:html/h:head/model';
|
|
5
|
+
|
|
6
|
+
const getNode = (currentNode, path) =>
|
|
7
|
+
xpath.parse(path).select1({ node: currentNode, allowAnyNamespaceForNoPrefix: true });
|
|
8
|
+
|
|
9
|
+
const getNodes = (currentNode, path) =>
|
|
10
|
+
xpath.parse(path).select({ node: currentNode, allowAnyNamespaceForNoPrefix: true });
|
|
11
|
+
|
|
3
12
|
module.exports = {
|
|
4
13
|
/**
|
|
5
14
|
* Get the full path of the form, or null if the path doesn't exist.
|
|
@@ -29,24 +38,53 @@ module.exports = {
|
|
|
29
38
|
};
|
|
30
39
|
},
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Returns the node from the form XML specified by the given XPath.
|
|
43
|
+
* @param {Element} currentNode the current node in the form XML document
|
|
44
|
+
* @param {string} path the XPath expression
|
|
45
|
+
* @returns {Element} the selected node or `undefined` if not found
|
|
46
|
+
*/
|
|
47
|
+
getNode,
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns the nodes from the form XML specified by the given XPath.
|
|
51
|
+
* @param {Element} currentNode the current node in the form XML document
|
|
52
|
+
* @param {string} path the XPath expression
|
|
53
|
+
* @returns {Element} the selected nodes or an empty array if none are found
|
|
54
|
+
*/
|
|
55
|
+
getNodes,
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns the `bind` nodes for the given form XML.
|
|
59
|
+
* @param {Document} xmlDoc the form XML document
|
|
60
|
+
* @returns {Element}
|
|
61
|
+
*/
|
|
62
|
+
getBindNodes: xmlDoc => getNodes(xmlDoc, `${XPATH_MODEL}/bind`),
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns the primary (first) `instance` node for the given form XML.
|
|
66
|
+
* @param {Document} xmlDoc the form XML document
|
|
67
|
+
* @returns {Element}
|
|
68
|
+
*/
|
|
69
|
+
getPrimaryInstanceNode: xmlDoc => getNode(xmlDoc, `${XPATH_MODEL}/instance`),
|
|
35
70
|
|
|
36
71
|
/**
|
|
37
72
|
* Check whether the XForm has the <instanceID/> tag.
|
|
38
|
-
* @param {string}
|
|
73
|
+
* @param {string} xmlDoc the XML document
|
|
39
74
|
* @returns {boolean}
|
|
40
75
|
*/
|
|
41
|
-
formHasInstanceId:
|
|
76
|
+
formHasInstanceId: xmlDoc => getNode(xmlDoc, `//meta/instanceID`) !== undefined,
|
|
42
77
|
|
|
78
|
+
// This isn't really how to parse XML, but we have fairly good control over the
|
|
79
|
+
// input and this code is working so far. This may break with changes to the
|
|
80
|
+
// formatting of output from xls2xform.
|
|
43
81
|
/**
|
|
44
82
|
* Get the title string inside the <h:title> tag
|
|
45
83
|
* @param {string} xml the XML string
|
|
46
84
|
* @returns {string}
|
|
47
85
|
*/
|
|
48
86
|
readTitleFrom: xml =>
|
|
49
|
-
|
|
87
|
+
xml.substring(xml.indexOf('<h:title>') + 9, xml.indexOf('</h:title>')),
|
|
50
88
|
|
|
51
89
|
/**
|
|
52
90
|
* Get the ID of the form
|
|
@@ -54,7 +92,7 @@ module.exports = {
|
|
|
54
92
|
* @returns {string}
|
|
55
93
|
*/
|
|
56
94
|
readIdFrom: xml =>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
95
|
+
xml.match(/<model>[^]*<\/model>/)[0]
|
|
96
|
+
.match(/<instance>[^]*<\/instance>/)[0]
|
|
97
|
+
.match(/id="([^"]*)"/)[1],
|
|
60
98
|
};
|
package/src/lib/main.js
CHANGED
|
@@ -21,6 +21,9 @@ const defaultActions = [
|
|
|
21
21
|
'convert-app-forms',
|
|
22
22
|
'convert-collect-forms',
|
|
23
23
|
'convert-contact-forms',
|
|
24
|
+
'validate-app-forms',
|
|
25
|
+
'validate-collect-forms',
|
|
26
|
+
'validate-contact-forms',
|
|
24
27
|
'backup-all-forms',
|
|
25
28
|
'delete-all-forms',
|
|
26
29
|
'upload-app-forms',
|
|
@@ -38,6 +41,9 @@ const defaultArchiveActions = [
|
|
|
38
41
|
'convert-app-forms',
|
|
39
42
|
'convert-collect-forms',
|
|
40
43
|
'convert-contact-forms',
|
|
44
|
+
'validate-app-forms',
|
|
45
|
+
'validate-collect-forms',
|
|
46
|
+
'validate-contact-forms',
|
|
41
47
|
'upload-app-forms',
|
|
42
48
|
'upload-collect-forms',
|
|
43
49
|
'upload-contact-forms',
|
|
@@ -123,19 +129,40 @@ module.exports = async (argv, env) => {
|
|
|
123
129
|
// Build up actions
|
|
124
130
|
//
|
|
125
131
|
let actions = cmdArgs._;
|
|
126
|
-
if (
|
|
132
|
+
if (actions.length) {
|
|
133
|
+
const unsupported = actions.filter(a => !supportedActions.includes(a));
|
|
134
|
+
if(unsupported.length) {
|
|
135
|
+
throw new Error(`Unsupported action(s): ${unsupported.join(' ')}`);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
127
138
|
actions = !cmdArgs.archive ? defaultActions : defaultArchiveActions;
|
|
128
139
|
}
|
|
129
140
|
|
|
130
|
-
const unsupported = actions.filter(a => !supportedActions.includes(a));
|
|
131
|
-
if(unsupported.length) {
|
|
132
|
-
throw new Error(`Unsupported action(s): ${unsupported.join(' ')}`);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
141
|
if (cmdArgs['skip-git-check']) {
|
|
136
142
|
actions = actions.filter(a => a !== 'check-git');
|
|
137
143
|
}
|
|
138
144
|
|
|
145
|
+
const skipValidate = cmdArgs['skip-validate'];
|
|
146
|
+
if(skipValidate) {
|
|
147
|
+
warn('Skipping all form validation.');
|
|
148
|
+
const validateActions = [
|
|
149
|
+
'validate-app-forms',
|
|
150
|
+
'validate-collect-forms',
|
|
151
|
+
'validate-contact-forms'
|
|
152
|
+
];
|
|
153
|
+
actions = actions.filter(action => !validateActions.includes(action));
|
|
154
|
+
} else {
|
|
155
|
+
const addFormValidationIfNecessary = (formType) => {
|
|
156
|
+
const updateFormsIndex = actions.indexOf(`upload-${formType}-forms`);
|
|
157
|
+
if (updateFormsIndex >= 0 && actions.indexOf(`validate-${formType}-forms`) < 0) {
|
|
158
|
+
actions.splice(updateFormsIndex, 0, `validate-${formType}-forms`);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
addFormValidationIfNecessary('app');
|
|
162
|
+
addFormValidationIfNecessary('collect');
|
|
163
|
+
addFormValidationIfNecessary('contact');
|
|
164
|
+
}
|
|
165
|
+
|
|
139
166
|
actions = actions.map(actionName => {
|
|
140
167
|
const action = require(`../fn/${actionName}`);
|
|
141
168
|
|
|
@@ -157,9 +184,9 @@ module.exports = async (argv, env) => {
|
|
|
157
184
|
//
|
|
158
185
|
const projectName = fs.path.basename(pathToProject);
|
|
159
186
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
187
|
+
const apiUrl = getApiUrl(cmdArgs, env);
|
|
188
|
+
const requiresInstance = actions.some(action => action.requiresInstance);
|
|
189
|
+
if (requiresInstance) {
|
|
163
190
|
if (!apiUrl) {
|
|
164
191
|
throw new Error('Failed to obtain a url to the API');
|
|
165
192
|
}
|
|
@@ -177,10 +204,11 @@ module.exports = async (argv, env) => {
|
|
|
177
204
|
extraArgs,
|
|
178
205
|
apiUrl,
|
|
179
206
|
cmdArgs.force,
|
|
180
|
-
cmdArgs['skip-translation-check']
|
|
207
|
+
cmdArgs['skip-translation-check'],
|
|
208
|
+
skipValidate
|
|
181
209
|
);
|
|
182
210
|
|
|
183
|
-
if (apiUrl) {
|
|
211
|
+
if (requiresInstance && apiUrl) {
|
|
184
212
|
await api().available();
|
|
185
213
|
}
|
|
186
214
|
|
package/src/lib/upload-forms.js
CHANGED
|
@@ -13,7 +13,6 @@ const {
|
|
|
13
13
|
readTitleFrom,
|
|
14
14
|
readIdFrom
|
|
15
15
|
} = require('./forms-utils');
|
|
16
|
-
const validateForms = require('./validate-forms');
|
|
17
16
|
|
|
18
17
|
const SUPPORTED_PROPERTIES = ['context', 'icon', 'title', 'xml2sms', 'subject_key', 'hidden_fields'];
|
|
19
18
|
const FORM_EXTENSTION = '.xml';
|
|
@@ -40,7 +39,6 @@ const formMediaMatcher = (formMediaDir) => {
|
|
|
40
39
|
};
|
|
41
40
|
|
|
42
41
|
const execute = async (projectDir, subDirectory, options) => {
|
|
43
|
-
await validateForms(projectDir, subDirectory, options);
|
|
44
42
|
const db = pouch();
|
|
45
43
|
if (!options) options = {};
|
|
46
44
|
const formsDir = getFormDir(projectDir, subDirectory);
|
|
@@ -1,64 +1,84 @@
|
|
|
1
|
-
const
|
|
1
|
+
const { DOMParser } = require('@xmldom/xmldom');
|
|
2
2
|
const argsFormFilter = require('./args-form-filter');
|
|
3
|
+
const environment = require('./environment');
|
|
3
4
|
const fs = require('./sync-fs');
|
|
4
5
|
const log = require('./log');
|
|
5
6
|
const {
|
|
6
7
|
getFormDir,
|
|
7
|
-
getFormFilePaths
|
|
8
|
-
formHasInstanceId
|
|
8
|
+
getFormFilePaths
|
|
9
9
|
} = require('./forms-utils');
|
|
10
10
|
|
|
11
|
+
const domParser = new DOMParser();
|
|
12
|
+
const VALIDATIONS_PATH = fs.path.resolve(__dirname, './validation/form');
|
|
13
|
+
const validations = fs.readdir(VALIDATIONS_PATH)
|
|
14
|
+
.filter(name => name.endsWith('.js'))
|
|
15
|
+
.map(validationName => {
|
|
16
|
+
const validation = require(fs.path.join(VALIDATIONS_PATH, validationName));
|
|
17
|
+
validation.name = validationName;
|
|
18
|
+
if(!Object.hasOwnProperty.call(validation, 'requiresInstance')) {
|
|
19
|
+
validation.requiresInstance = true;
|
|
20
|
+
}
|
|
21
|
+
if(!Object.hasOwnProperty.call(validation, 'skipFurtherValidation')) {
|
|
22
|
+
validation.skipFurtherValidation = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return validation;
|
|
26
|
+
})
|
|
27
|
+
.sort((a, b) => {
|
|
28
|
+
if(a.skipFurtherValidation && !b.skipFurtherValidation) {
|
|
29
|
+
return -1;
|
|
30
|
+
}
|
|
31
|
+
if(!a.skipFurtherValidation && b.skipFurtherValidation) {
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
return a.name.localeCompare(b.name);
|
|
35
|
+
});
|
|
36
|
+
|
|
11
37
|
module.exports = async (projectDir, subDirectory, options={}) => {
|
|
12
38
|
|
|
13
39
|
const formsDir = getFormDir(projectDir, subDirectory);
|
|
14
40
|
if(!formsDir) {
|
|
15
|
-
log.info(`Forms dir not found: ${
|
|
41
|
+
log.info(`Forms dir not found: ${projectDir}/forms/${subDirectory}`);
|
|
16
42
|
return;
|
|
17
43
|
}
|
|
18
44
|
|
|
19
|
-
|
|
20
|
-
let
|
|
45
|
+
const instanceProvided = environment.apiUrl;
|
|
46
|
+
let validationSkipped = false;
|
|
21
47
|
|
|
22
48
|
const fileNames = argsFormFilter(formsDir, '.xml', options);
|
|
23
|
-
|
|
49
|
+
|
|
50
|
+
let errorFound = false;
|
|
51
|
+
for(const fileName of fileNames) {
|
|
24
52
|
log.info(`Validating form: ${fileName}…`);
|
|
25
53
|
|
|
26
|
-
const { xformPath
|
|
54
|
+
const { xformPath } = getFormFilePaths(formsDir, fileName);
|
|
27
55
|
const xml = fs.read(xformPath);
|
|
28
56
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
const valParams = { xformPath, xmlStr: xml, xmlDoc: domParser.parseFromString(xml) };
|
|
58
|
+
for(const validation of validations) {
|
|
59
|
+
if(validation.requiresInstance && !instanceProvided) {
|
|
60
|
+
validationSkipped = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const output = await validation.execute(valParams);
|
|
65
|
+
if(output.warnings) {
|
|
66
|
+
output.warnings.forEach(warnMsg => log.warn(warnMsg));
|
|
67
|
+
}
|
|
68
|
+
if(output.errors && output.errors.length) {
|
|
69
|
+
output.errors.forEach(errorMsg => log.error(errorMsg));
|
|
70
|
+
errorFound = true;
|
|
71
|
+
if(validation.skipFurtherValidation) {
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
39
75
|
}
|
|
40
76
|
}
|
|
41
|
-
// Once all the fails were checked raise an exception if there were errors
|
|
42
|
-
let errors = [];
|
|
43
|
-
if (!validateFormsPassed) {
|
|
44
|
-
errors.push('One or more forms appears to have errors found by the API validation endpoint.');
|
|
45
|
-
}
|
|
46
|
-
if (!idValidationsPassed) {
|
|
47
|
-
errors.push('One or more forms appears to be missing <meta><instanceID/></meta> node.');
|
|
48
|
-
}
|
|
49
|
-
if (errors.length) {
|
|
50
|
-
// the blank spaces are a trick to align the errors in the log ;)
|
|
51
|
-
throw new Error(errors.join('\n '));
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
77
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if
|
|
59
|
-
|
|
60
|
-
'no form will be checked before push');
|
|
61
|
-
validateEndpointNotFoundLogged = true; // Just log the message once
|
|
78
|
+
if(validationSkipped) {
|
|
79
|
+
log.warn('Some validations have been skipped because they require a CHT instance.');
|
|
80
|
+
}
|
|
81
|
+
if(errorFound) {
|
|
82
|
+
throw new Error('One or more forms have failed validation.');
|
|
62
83
|
}
|
|
63
|
-
return resp;
|
|
64
84
|
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Form validations
|
|
2
|
+
|
|
3
|
+
Form validations should be added to this directory.
|
|
4
|
+
|
|
5
|
+
Their module should look something like this:
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
module.exports = {
|
|
9
|
+
requiresInstance: false,
|
|
10
|
+
skipFurtherValidation: true,
|
|
11
|
+
execute: async ({ xformPath, xmlStr }) => {
|
|
12
|
+
...
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Module Exports
|
|
18
|
+
|
|
19
|
+
| Field | Required | Notes |
|
|
20
|
+
|-------------------------|--------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
21
|
+
| `requiresInstance` | Optional, defaults to `true` | The validation needs the user to have provided a instance location, e.g. via `--local` or `--instance` |
|
|
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` | Required | The function that is run when the validation is executed. The provided input argument contains the form XML string. |
|
|
24
|
+
|
|
25
|
+
## Result
|
|
26
|
+
|
|
27
|
+
The result has the following format:
|
|
28
|
+
|
|
29
|
+
| Field | Notes |
|
|
30
|
+
|----------|----------------------------------------------------------------------------------------------------|
|
|
31
|
+
| warnings | Array containing the descriptions of any warnings. |
|
|
32
|
+
| errors | Array containing the descriptions of any errors. If this is empty, the form has passed validation. |
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const api = require('../../api');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
execute: async ({ xformPath, xmlStr }) => {
|
|
5
|
+
const warnings = [];
|
|
6
|
+
const errors = [];
|
|
7
|
+
try {
|
|
8
|
+
const resp = await api().formsValidate(xmlStr);
|
|
9
|
+
if (resp.formsValidateEndpointFound === false) {
|
|
10
|
+
warnings.push('Form validation endpoint not found in your version of CHT Core, no form will be checked before push');
|
|
11
|
+
}
|
|
12
|
+
} catch (err) {
|
|
13
|
+
errors.push(`Error found while validating "${xformPath}". Validation response: ${err.message}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return { errors, warnings };
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/*
|
|
2
|
+
An XPath expression that evaluates to an empty result (e.g. no node is found in the document that matches the
|
|
3
|
+
expression) is technically "valid" (in the sense that it is a properly formatted XPath expression).
|
|
4
|
+
|
|
5
|
+
However, if there is a "simple" XPath expression (one that just references a node via an absolute/relative path and does
|
|
6
|
+
not contain any dynamic processing) in a form that does not evaluate to an actual node in the form, that is almost
|
|
7
|
+
certainly an error (since it could never return any other result). This validation checks the simple XPaths in a form's
|
|
8
|
+
calculate, constraint, readonly, relevant, and required expressions and returns an error for any simple XPaths that
|
|
9
|
+
point to a non-existant node. More complex dynamic XPath expressions are not validated (since their results could vary
|
|
10
|
+
at runtime depending on the data in the form).
|
|
11
|
+
|
|
12
|
+
https://github.com/medic/cht-conf/issues/479
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { getNode, getBindNodes, getPrimaryInstanceNode } = require('../../forms-utils');
|
|
16
|
+
|
|
17
|
+
// Characters used in simple XPaths
|
|
18
|
+
const SUPPORTED_CHAR = '[/\\w.-]';
|
|
19
|
+
// Special characters used in complex XPaths
|
|
20
|
+
const UNSUPPORTED_CHAR = '[*@:[\\]]';
|
|
21
|
+
const XPATH_CHAR = `(${SUPPORTED_CHAR}|${UNSUPPORTED_CHAR})`;
|
|
22
|
+
// Some XPaths lookup nodes in separate instances (e.g. instance('contact-summary')/context/pregnancy_uuid)
|
|
23
|
+
const INSTANCE = `instance\\([\\w-'"]+\\)`;
|
|
24
|
+
// Look ahead and make sure there are an even number of quotes following the match (this means that the match itself is not in quotes).
|
|
25
|
+
const LOOK_AHEADS_FOR_EVEN_QUOTES = `(?=([^"]*"[^"]*")*[^"]*$)(?=([^']*'[^']*')*[^']*$)`;
|
|
26
|
+
// Matches on all possible XPaths (simple and complex) not in quotes. May start with an instance reference. Must include a slash.
|
|
27
|
+
const XPATH_PATTERN = new RegExp(`(${INSTANCE}|)${XPATH_CHAR}*\\/${XPATH_CHAR}+${LOOK_AHEADS_FOR_EVEN_QUOTES}`, 'g');
|
|
28
|
+
// Matches on XPaths containing complex calculations (note that '//' indicates a deep lookup, which we do not validate)
|
|
29
|
+
const UNSUPPORTED_XPATH_PATTERN = new RegExp(`\\/\\/|${UNSUPPORTED_CHAR}|${INSTANCE}`, 'g');
|
|
30
|
+
|
|
31
|
+
const extractSimpleXpaths = (expression) => {
|
|
32
|
+
/*
|
|
33
|
+
"Simple" XPaths look a lot like subsections of complex XPaths. To ensure that we never accidentally extract part of a
|
|
34
|
+
complex XPath, we first just match on all possible XPaths (simple and complex). Then we do a second pass to filter the
|
|
35
|
+
list down to just the XPaths that have a supported beginning section (since that is a cheep check). Finally, we filter
|
|
36
|
+
out any complex XPaths (with unsupported characters).
|
|
37
|
+
*/
|
|
38
|
+
return (expression.match(XPATH_PATTERN) || [])
|
|
39
|
+
.filter(xpath => xpath.startsWith('/') || xpath.startsWith('./') || xpath.startsWith('../'))
|
|
40
|
+
.filter(xpath => !xpath.match(UNSUPPORTED_XPATH_PATTERN));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const isValidXpath = (nodeset, xpathToValidate, instance) => {
|
|
44
|
+
if(xpathToValidate.startsWith('/')) {
|
|
45
|
+
// Absolute XPath. Evaluate it relative to the instance
|
|
46
|
+
return getNode(instance, xpathToValidate.substring(1));
|
|
47
|
+
}
|
|
48
|
+
// Relative XPath. Evaluate it relative to the current node
|
|
49
|
+
const currentNode = getNode(instance, nodeset.substring(1));
|
|
50
|
+
if(!currentNode) {
|
|
51
|
+
throw new Error(`Could not find model node referenced by bind nodeset: ${nodeset}`);
|
|
52
|
+
}
|
|
53
|
+
return getNode(currentNode, xpathToValidate);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const extractInvalidXPaths = (nodeset, expression, instance) => {
|
|
57
|
+
return extractSimpleXpaths(expression)
|
|
58
|
+
.filter(xpath => !isValidXpath(nodeset, xpath, instance));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const getField = (bind, instance) => {
|
|
62
|
+
const nodeset = bind.getAttribute('nodeset');
|
|
63
|
+
const getInvalidXPaths = (expression) => extractInvalidXPaths(nodeset, expression, instance);
|
|
64
|
+
return {
|
|
65
|
+
nodeset,
|
|
66
|
+
calculate: getInvalidXPaths(bind.getAttribute('calculate')),
|
|
67
|
+
constraint: getInvalidXPaths(bind.getAttribute('constraint')),
|
|
68
|
+
readonly: getInvalidXPaths(bind.getAttribute('readonly')),
|
|
69
|
+
relevant: getInvalidXPaths(bind.getAttribute('relevant')),
|
|
70
|
+
required: getInvalidXPaths(bind.getAttribute('required')),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const keepFieldsWithXPaths = (field) => {
|
|
75
|
+
return field.calculate.length ||
|
|
76
|
+
field.constraint.length ||
|
|
77
|
+
field.readonly.length ||
|
|
78
|
+
field.relevant.length ||
|
|
79
|
+
field.required.length;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const getFieldsWithInvalidXPaths = (xmlDoc) => {
|
|
83
|
+
const instance = getPrimaryInstanceNode(xmlDoc);
|
|
84
|
+
if(!instance) {
|
|
85
|
+
throw new Error('No instance found in form XML.');
|
|
86
|
+
}
|
|
87
|
+
return getBindNodes(xmlDoc)
|
|
88
|
+
.map(bind => getField(bind, instance))
|
|
89
|
+
.filter(field => keepFieldsWithXPaths(field));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
requiresInstance: false,
|
|
94
|
+
skipFurtherValidation: false,
|
|
95
|
+
execute: async({ xformPath, xmlDoc }) => {
|
|
96
|
+
const errors = [];
|
|
97
|
+
try {
|
|
98
|
+
const fields = getFieldsWithInvalidXPaths(xmlDoc);
|
|
99
|
+
if(fields.length) {
|
|
100
|
+
errors.push(`Form at ${xformPath} contains invalid XPath expressions (absolute or relative paths that refer to a non-existant node):`);
|
|
101
|
+
|
|
102
|
+
fields.forEach(field => {
|
|
103
|
+
const recordError = (expressionName) => {
|
|
104
|
+
const xpaths = field[expressionName];
|
|
105
|
+
if(xpaths.length) {
|
|
106
|
+
errors.push(` - ${expressionName} for ${field.nodeset} contains [${xpaths.join(', ')}]`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
recordError('calculate');
|
|
110
|
+
recordError('constraint');
|
|
111
|
+
recordError('readonly');
|
|
112
|
+
recordError('relevant');
|
|
113
|
+
recordError('required');
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
} catch(e) {
|
|
117
|
+
errors.push(`Error encountered while validating XPaths in form at ${xformPath}: ${e.message}`);
|
|
118
|
+
}
|
|
119
|
+
return { errors, warnings: [] };
|
|
120
|
+
}
|
|
121
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const { formHasInstanceId } = require('../../forms-utils');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
requiresInstance: false,
|
|
5
|
+
skipFurtherValidation: true,
|
|
6
|
+
execute: async({ xformPath, xmlDoc }) => {
|
|
7
|
+
const errors = [];
|
|
8
|
+
if(!formHasInstanceId(xmlDoc)) {
|
|
9
|
+
errors.push(`Form at ${xformPath} appears to be missing <meta><instanceID/></meta> node. This form will not work on CHT webapp.`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return { errors, warnings: [] };
|
|
13
|
+
}
|
|
14
|
+
};
|
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const log = require('./log');
|
|
9
9
|
const environment = require('./environment');
|
|
10
10
|
const { compare, GroupingReporter } = require('dom-compare');
|
|
11
|
-
const DOMParser = require('xmldom').DOMParser;
|
|
11
|
+
const DOMParser = require('@xmldom/xmldom').DOMParser;
|
|
12
12
|
|
|
13
13
|
const question = 'You are trying to modify a configuration that has been modified since your last upload. Do you want to?';
|
|
14
14
|
const responseChoicesWithoutDiff = [
|