cht-conf 3.12.0 → 3.15.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/fn/initialise-project-layout.js +12 -1
- package/src/fn/upload-custom-translations.js +3 -3
- package/src/lib/forms-utils.js +47 -9
- package/src/lib/get-api-version.js +7 -1
- package/src/lib/validate-forms.js +10 -1
- package/src/lib/validation/form/README.md +6 -6
- package/src/lib/validation/form/check-xpaths-exist.js +121 -0
- package/src/lib/validation/form/deprecated-appearance.js +87 -0
- package/src/lib/validation/form/has-instance-id.js +2 -2
- package/src/lib/validation/form/no-required-notes.js +23 -0
- package/src/lib/warn-upload-overwrite.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
# [3.15.0](https://github.com/medic/cht-conf/compare/v3.14.0...v3.15.0) (2022-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **#500:** Validate deprecated appearances in forms ([#501](https://github.com/medic/cht-conf/issues/501)) ([1e52c0c](https://github.com/medic/cht-conf/commit/1e52c0c769fbd27ee6a764358ef2a0463ae3023f)), closes [#500](https://github.com/medic/cht-conf/issues/500)
|
|
7
|
+
|
|
8
|
+
# [3.14.0](https://github.com/medic/cht-conf/compare/v3.13.0...v3.14.0) (2022-08-18)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **#494:** validate notes are not required in forms ([#496](https://github.com/medic/cht-conf/issues/496)) ([a5a8e09](https://github.com/medic/cht-conf/commit/a5a8e096a478373a43e63f0f606a2919bade8982)), closes [#494](https://github.com/medic/cht-conf/issues/494)
|
|
14
|
+
|
|
15
|
+
# [3.13.0](https://github.com/medic/cht-conf/compare/v3.12.0...v3.13.0) (2022-07-12)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **#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)
|
|
21
|
+
|
|
1
22
|
# [3.12.0](https://github.com/medic/cht-conf/compare/v3.11.1...v3.12.0) (2022-06-23)
|
|
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.15.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",
|
|
@@ -5,10 +5,15 @@ const fs = require('../lib/sync-fs');
|
|
|
5
5
|
const { info } = require('../lib/log');
|
|
6
6
|
|
|
7
7
|
const LAYOUT = {
|
|
8
|
-
'contact-summary.js':
|
|
8
|
+
'contact-summary.templated.js': `module.exports = {
|
|
9
|
+
fields: [],
|
|
10
|
+
cards: [],
|
|
11
|
+
context: {}
|
|
12
|
+
};`,
|
|
9
13
|
'privacy-policies.json': {},
|
|
10
14
|
'privacy-policies': {},
|
|
11
15
|
'resources.json': {},
|
|
16
|
+
'harness.defaults.json': {},
|
|
12
17
|
resources: {},
|
|
13
18
|
'targets.js': 'module.exports = [];',
|
|
14
19
|
'tasks.js': 'module.exports = [];',
|
|
@@ -32,6 +37,12 @@ const LAYOUT = {
|
|
|
32
37
|
'forms.json': {},
|
|
33
38
|
'schedules.json': [],
|
|
34
39
|
},
|
|
40
|
+
test: {
|
|
41
|
+
forms: {},
|
|
42
|
+
'contact-summary': {},
|
|
43
|
+
tasks: {},
|
|
44
|
+
targets: {}
|
|
45
|
+
}
|
|
35
46
|
};
|
|
36
47
|
|
|
37
48
|
function createRecursively(dir, layout) {
|
|
@@ -2,7 +2,7 @@ const semver = require('semver');
|
|
|
2
2
|
|
|
3
3
|
const environment = require('../lib/environment');
|
|
4
4
|
const pouch = require('../lib/db');
|
|
5
|
-
const
|
|
5
|
+
const { getValidApiVersion } = require('../lib/get-api-version');
|
|
6
6
|
const iso639 = require('iso-639-1');
|
|
7
7
|
const log = require('../lib/log');
|
|
8
8
|
const properties = require('properties');
|
|
@@ -168,9 +168,9 @@ function idFor(fileName) {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
async function genericTranslationsStructure(db) {
|
|
171
|
-
const version = await
|
|
171
|
+
const version = await getValidApiVersion();
|
|
172
172
|
|
|
173
|
-
if (
|
|
173
|
+
if (version) {
|
|
174
174
|
return semver.gte(version, '3.4.0');
|
|
175
175
|
}
|
|
176
176
|
|
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
|
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const semver = require('semver');
|
|
2
|
+
|
|
1
3
|
const api = require('./api');
|
|
2
4
|
const db = require('./db');
|
|
3
5
|
|
|
@@ -15,4 +17,8 @@ const getApiVersion = async () => {
|
|
|
15
17
|
return version;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
const getValidApiVersion = async () => {
|
|
21
|
+
return semver.valid(await getApiVersion());
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
module.exports = { getValidApiVersion };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
const { DOMParser } = require('@xmldom/xmldom');
|
|
1
2
|
const argsFormFilter = require('./args-form-filter');
|
|
3
|
+
const { getValidApiVersion } = require('./get-api-version');
|
|
2
4
|
const environment = require('./environment');
|
|
3
5
|
const fs = require('./sync-fs');
|
|
4
6
|
const log = require('./log');
|
|
@@ -7,6 +9,7 @@ const {
|
|
|
7
9
|
getFormFilePaths
|
|
8
10
|
} = require('./forms-utils');
|
|
9
11
|
|
|
12
|
+
const domParser = new DOMParser();
|
|
10
13
|
const VALIDATIONS_PATH = fs.path.resolve(__dirname, './validation/form');
|
|
11
14
|
const validations = fs.readdir(VALIDATIONS_PATH)
|
|
12
15
|
.filter(name => name.endsWith('.js'))
|
|
@@ -41,6 +44,7 @@ module.exports = async (projectDir, subDirectory, options={}) => {
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
const instanceProvided = environment.apiUrl;
|
|
47
|
+
const apiVersion = instanceProvided ? await getValidApiVersion() : null;
|
|
44
48
|
let validationSkipped = false;
|
|
45
49
|
|
|
46
50
|
const fileNames = argsFormFilter(formsDir, '.xml', options);
|
|
@@ -52,7 +56,12 @@ module.exports = async (projectDir, subDirectory, options={}) => {
|
|
|
52
56
|
const { xformPath } = getFormFilePaths(formsDir, fileName);
|
|
53
57
|
const xml = fs.read(xformPath);
|
|
54
58
|
|
|
55
|
-
const valParams = {
|
|
59
|
+
const valParams = {
|
|
60
|
+
xformPath,
|
|
61
|
+
xmlStr: xml,
|
|
62
|
+
xmlDoc: domParser.parseFromString(xml),
|
|
63
|
+
apiVersion,
|
|
64
|
+
};
|
|
56
65
|
for(const validation of validations) {
|
|
57
66
|
if(validation.requiresInstance && !instanceProvided) {
|
|
58
67
|
validationSkipped = true;
|
|
@@ -8,7 +8,7 @@ Their module should look something like this:
|
|
|
8
8
|
module.exports = {
|
|
9
9
|
requiresInstance: false,
|
|
10
10
|
skipFurtherValidation: true,
|
|
11
|
-
execute: async ({ xformPath, xmlStr }) => {
|
|
11
|
+
execute: async ({ xformPath, xmlStr, xmlDoc, apiVersion }) => {
|
|
12
12
|
...
|
|
13
13
|
}
|
|
14
14
|
};
|
|
@@ -16,11 +16,11 @@ module.exports = {
|
|
|
16
16
|
|
|
17
17
|
## Module Exports
|
|
18
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`
|
|
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(opts)` | Required | The function that is run when the validation is executed. The available ops are:<br>* `xformPath` - the file path to the form<br>* `xmlStr` - string value of the form XML<br>* `xmlDoc` - Document value of the form XML<br>* `apiVersion` - the version of the CHT instance (if connection information was provided) or `null` |
|
|
24
24
|
|
|
25
25
|
## Result
|
|
26
26
|
|
|
@@ -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,87 @@
|
|
|
1
|
+
const semver = require('semver');
|
|
2
|
+
const { getNodes } = require('../../forms-utils');
|
|
3
|
+
|
|
4
|
+
// Based on https://forum.getodk.org/t/spec-proposal-harmonize-compact-compact-n-horizontal-horizontal-compact-appearances/15565/32
|
|
5
|
+
// Note the order of evaluation is important
|
|
6
|
+
const getDeprecatedAppearances = (apiVersion) => [
|
|
7
|
+
{
|
|
8
|
+
match: appearance => appearance.match(/(?:^|\s)horizontal-compact(?:$|\s)/),
|
|
9
|
+
getReplacement: () => 'columns-pack',
|
|
10
|
+
versionDeprecated: '4.0.0'
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
match: appearance => appearance.match(/(?:^|\s)horizontal(?:$|\s)/),
|
|
14
|
+
getReplacement: () => 'columns',
|
|
15
|
+
versionDeprecated: '4.0.0'
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
match: appearance => appearance.match(/(?:^|\s)compact-(\d{1,2})(?:$|\s)/),
|
|
19
|
+
getReplacement: (match) => `columns-${match[1].trim()} no-buttons`,
|
|
20
|
+
versionDeprecated: '4.0.0'
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
match: appearance => appearance.match(/(?:^|\s)compact(?:$|\s)/),
|
|
24
|
+
getReplacement: () => 'columns-pack no-buttons',
|
|
25
|
+
versionDeprecated: '4.0.0'
|
|
26
|
+
},
|
|
27
|
+
].filter(deprecatedAppearance => semver.gte(apiVersion, deprecatedAppearance.versionDeprecated));
|
|
28
|
+
|
|
29
|
+
const getElementsWithAppearance = (xmlDoc) => getNodes(xmlDoc, '/h:html/h:body//*[@appearance]');
|
|
30
|
+
|
|
31
|
+
const unpackDeprecationAttributes = element => ({
|
|
32
|
+
ref: element.getAttribute('ref'),
|
|
33
|
+
appearance: element.getAttribute('appearance'),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const hasAppearance = ({ appearance }) => appearance;
|
|
37
|
+
|
|
38
|
+
const populateDeprecatedAppearance = deprecatedAppearances => ({ ref, appearance }) => ({
|
|
39
|
+
ref,
|
|
40
|
+
appearance,
|
|
41
|
+
// Only warning for the first deprecated appearance found for an element. This seems acceptable.
|
|
42
|
+
deprecatedAppearance: deprecatedAppearances.find(depAppearance => depAppearance.match(appearance))
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const hasDeprecatedAppearance = ({ deprecatedAppearance }) => deprecatedAppearance;
|
|
46
|
+
|
|
47
|
+
const createWarning = ({ ref, appearance, deprecatedAppearance }) => {
|
|
48
|
+
const match = deprecatedAppearance.match(appearance);
|
|
49
|
+
const originalAppearance = match[0].trim();
|
|
50
|
+
const newAppearance = deprecatedAppearance.getReplacement(match);
|
|
51
|
+
return ` - ${ref}: replace [${originalAppearance}] with [${newAppearance}]`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const populateWarningHeader = (xformPath, warnings) => {
|
|
55
|
+
if (warnings.length) {
|
|
56
|
+
warnings.unshift(`Form at ${xformPath} contains fields with the deprecated \`horizontal\`/\`compact\` appearance. ` +
|
|
57
|
+
'These have been deprecated in favor of the `columns` appearance. Please update the following fields:');
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const validateDeprecatedAppearances = async({ xformPath, xmlDoc, apiVersion }) => {
|
|
62
|
+
if (!apiVersion) {
|
|
63
|
+
return { warnings: [], errors: [], };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const deprecatedAppearances = getDeprecatedAppearances(apiVersion);
|
|
67
|
+
if (!deprecatedAppearances.length) {
|
|
68
|
+
return { warnings: [], errors: [], };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const warnings = getElementsWithAppearance(xmlDoc)
|
|
72
|
+
.map(unpackDeprecationAttributes)
|
|
73
|
+
.filter(hasAppearance)
|
|
74
|
+
.map(populateDeprecatedAppearance(deprecatedAppearances))
|
|
75
|
+
.filter(hasDeprecatedAppearance)
|
|
76
|
+
.map(createWarning);
|
|
77
|
+
|
|
78
|
+
populateWarningHeader(xformPath, warnings);
|
|
79
|
+
|
|
80
|
+
return { errors: [], warnings };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
module.exports = {
|
|
84
|
+
requiresInstance: true,
|
|
85
|
+
skipFurtherValidation: false,
|
|
86
|
+
execute: validateDeprecatedAppearances
|
|
87
|
+
};
|
|
@@ -3,9 +3,9 @@ const { formHasInstanceId } = require('../../forms-utils');
|
|
|
3
3
|
module.exports = {
|
|
4
4
|
requiresInstance: false,
|
|
5
5
|
skipFurtherValidation: true,
|
|
6
|
-
execute: async({ xformPath,
|
|
6
|
+
execute: async({ xformPath, xmlDoc }) => {
|
|
7
7
|
const errors = [];
|
|
8
|
-
if(!formHasInstanceId(
|
|
8
|
+
if(!formHasInstanceId(xmlDoc)) {
|
|
9
9
|
errors.push(`Form at ${xformPath} appears to be missing <meta><instanceID/></meta> node. This form will not work on CHT webapp.`);
|
|
10
10
|
}
|
|
11
11
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const { getBindNodes } = require('../../forms-utils');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
requiresInstance: false,
|
|
5
|
+
skipFurtherValidation: false,
|
|
6
|
+
execute: async({ xformPath, xmlDoc }) => {
|
|
7
|
+
const errors = [];
|
|
8
|
+
|
|
9
|
+
const requiredNotes = getBindNodes(xmlDoc)
|
|
10
|
+
.filter(bind => bind.getAttribute('readonly') === 'true()') // Notes will not be conditionally readonly
|
|
11
|
+
.filter(bind => {
|
|
12
|
+
const required = bind.getAttribute('required');
|
|
13
|
+
return required && required !== 'false()';
|
|
14
|
+
})
|
|
15
|
+
.filter(bind => bind.getAttribute('type') === 'string')
|
|
16
|
+
.filter(bind => !bind.getAttribute('calculate'))
|
|
17
|
+
.map(bind => bind.getAttribute('nodeset'));
|
|
18
|
+
if(requiredNotes.length) {
|
|
19
|
+
errors.push(`Form at ${xformPath} contains the following note fields with 'required' expressions: [${requiredNotes.join(', ')}]`);
|
|
20
|
+
}
|
|
21
|
+
return { errors, warnings: [] };
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -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 = [
|