cht-conf 4.4.1 → 4.5.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/.editorconfig +15 -0
- package/CHANGELOG.md +7 -0
- package/README.md +9 -1
- package/package.json +27 -26
- package/src/bin/shell-completion.js +5 -5
- package/src/cli/supported-actions.js +2 -2
- package/src/cli/usage.js +2 -0
- package/src/fn/backup-all-forms.js +1 -1
- package/src/fn/compile-app-settings.js +41 -14
- package/src/fn/compress-pngs.js +10 -8
- package/src/fn/compress-svgs.js +5 -2
- package/src/fn/create-users.js +6 -3
- package/src/fn/csv-to-docs.js +73 -52
- package/src/fn/delete-contacts.js +2 -0
- package/src/fn/edit-contacts.js +14 -11
- package/src/fn/fetch-csvs-from-google-drive.js +3 -3
- package/src/fn/fetch-forms-from-google-drive.js +3 -3
- package/src/fn/initialise-project-layout.js +14 -8
- package/src/fn/merge-contacts.js +10 -2
- package/src/fn/move-contacts.js +2 -0
- package/src/fn/upload-custom-translations.js +29 -22
- package/src/fn/upload-docs.js +30 -13
- package/src/fn/upload-privacy-policies.js +4 -1
- package/src/fn/upload-sms-from-csv.js +11 -11
- package/src/fn/watch-project.js +212 -204
- package/src/lib/api.js +22 -22
- package/src/lib/args-form-filter.js +1 -1
- package/src/lib/attachments-from-dir.js +3 -1
- package/src/lib/check-cht-conf-dependency-version.js +4 -2
- package/src/lib/check-for-updates.js +4 -3
- package/src/lib/compilation/compile-contact-summary.js +12 -2
- package/src/lib/compilation/compile-tasks-and-targets.js +5 -2
- package/src/lib/compilation/package-lib.js +5 -2
- package/src/lib/compilation/validate-declarative-schema.js +49 -32
- package/src/lib/convert-forms.js +27 -9
- package/src/lib/exec-promise.js +27 -19
- package/src/lib/fetch-document-list.js +2 -2
- package/src/lib/generate-users-csv.js +1 -1
- package/src/lib/get-api-url.js +4 -1
- package/src/lib/google-auth.js +15 -5
- package/src/lib/hierarchy-operations/delete-hierarchy.js +4 -1
- package/src/lib/hierarchy-operations/hierarchy-data-source.js +7 -2
- package/src/lib/hierarchy-operations/index.js +16 -4
- package/src/lib/hierarchy-operations/jsdocFolder.js +4 -1
- package/src/lib/hierarchy-operations/lineage-constraints.js +12 -6
- package/src/lib/hierarchy-operations/replace-lineage.js +20 -18
- package/src/lib/insert-or-replace.js +6 -2
- package/src/lib/log.js +6 -2
- package/src/lib/main.js +4 -3
- package/src/lib/nools-utils.js +5 -3
- package/src/lib/parse-purge.js +4 -1
- package/src/lib/parse-targets.js +3 -1
- package/src/lib/polyfill.js +3 -1
- package/src/lib/progress-bar.js +26 -13
- package/src/lib/skip-fn.js +5 -3
- package/src/lib/sync-fs.js +24 -12
- package/src/lib/upload-forms.js +17 -7
- package/src/lib/validation/form/can-generate-xform.js +3 -1
- package/src/lib/validation/form/check-xpaths-exist.js +8 -3
- package/src/lib/validation/form/deprecated-appearance.js +4 -2
- package/src/lib/validation/form/has-instance-id.js +4 -1
- package/src/lib/validation/form/no-required-notes.js +4 -1
- package/src/lib/warn-upload-overwrite.js +6 -3
package/src/lib/parse-targets.js
CHANGED
|
@@ -27,7 +27,9 @@ module.exports = projectDir => {
|
|
|
27
27
|
throwError(`Targets are defined at both ${jsonPath} and ${jsPath}. Only one of these files should exist.`);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
if (jsonExists)
|
|
30
|
+
if (jsonExists) {
|
|
31
|
+
return fs.readJson(jsonPath);
|
|
32
|
+
}
|
|
31
33
|
|
|
32
34
|
const pathToTargetJs = path.join(projectDir, 'targets.js');
|
|
33
35
|
const targets = require(pathToTargetJs);
|
package/src/lib/polyfill.js
CHANGED
package/src/lib/progress-bar.js
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
module.exports = {
|
|
3
3
|
init: function(target, prefix, suffix) {
|
|
4
4
|
let runningTotal = 0;
|
|
5
|
-
|
|
5
|
+
const startTime = Date.now();
|
|
6
6
|
|
|
7
7
|
function print() {
|
|
8
|
-
let bar = ''
|
|
8
|
+
let bar = '';
|
|
9
|
+
let i;
|
|
9
10
|
|
|
10
11
|
const timeTaken = Date.now() - startTime;
|
|
11
12
|
const timeLeft = Math.floor(timeTaken * (target - runningTotal) / (runningTotal * 1000));
|
|
12
13
|
|
|
13
14
|
const format = message => message
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
.replace('{{n}}', runningTotal)
|
|
16
|
+
.replace('{{N}}', target)
|
|
17
|
+
.replace('{{s}}', () => roundAndPad(timeLeft % 60))
|
|
18
|
+
.replace('{{m}}', () => roundAndPad(timeLeft / 60, 1))
|
|
19
|
+
.replace('{{%}}', () => roundAndPad(runningTotal * 100 / target, 3, ' ') + '%');
|
|
19
20
|
|
|
20
21
|
const fPrefix = prefix ? format(prefix) : '';
|
|
21
22
|
const fSuffix = suffix ? format(suffix) : '';
|
|
@@ -23,9 +24,13 @@ module.exports = {
|
|
|
23
24
|
const barTotal = process.stdout.columns - fPrefix.length - fSuffix.length - 3;
|
|
24
25
|
const pBarLen = Math.floor(runningTotal * barTotal / target);
|
|
25
26
|
|
|
26
|
-
for(i=pBarLen; i>0; --i)
|
|
27
|
+
for(i=pBarLen; i>0; --i) {
|
|
28
|
+
bar += '█';
|
|
29
|
+
}
|
|
27
30
|
|
|
28
|
-
for(i=barTotal-pBarLen; i>0; --i)
|
|
31
|
+
for(i=barTotal-pBarLen; i>0; --i) {
|
|
32
|
+
bar += ' ';
|
|
33
|
+
}
|
|
29
34
|
|
|
30
35
|
process.stdout.write(`\r${fPrefix}[${bar}]${fSuffix}`);
|
|
31
36
|
}
|
|
@@ -41,15 +46,23 @@ module.exports = {
|
|
|
41
46
|
};
|
|
42
47
|
|
|
43
48
|
function roundAndPad(n, digits, padWith) {
|
|
44
|
-
if(typeof digits === 'undefined')
|
|
45
|
-
|
|
49
|
+
if(typeof digits === 'undefined') {
|
|
50
|
+
digits = 2;
|
|
51
|
+
}
|
|
52
|
+
if(typeof padWith === 'undefined') {
|
|
53
|
+
padWith = '0';
|
|
54
|
+
}
|
|
46
55
|
n = Math.round(n);
|
|
47
56
|
let s;
|
|
48
|
-
if(Number.isFinite(n))
|
|
57
|
+
if(Number.isFinite(n)) {
|
|
58
|
+
s = n.toString();
|
|
59
|
+
}
|
|
49
60
|
else {
|
|
50
61
|
padWith = '?';
|
|
51
62
|
s = '';
|
|
52
63
|
}
|
|
53
|
-
while(s.length < digits)
|
|
64
|
+
while(s.length < digits) {
|
|
65
|
+
s = padWith + s;
|
|
66
|
+
}
|
|
54
67
|
return s;
|
|
55
68
|
}
|
package/src/lib/skip-fn.js
CHANGED
|
@@ -2,11 +2,13 @@ const warn = require('./log').warn;
|
|
|
2
2
|
|
|
3
3
|
module.exports = reason => {
|
|
4
4
|
const fnName = new Error().stack.split('\n')
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
.filter(s => s.includes('fn'))[1]
|
|
6
|
+
.match('/fn/([a-z-]+)\.js')[1]; // eslint-disable-line no-useless-escape
|
|
7
7
|
|
|
8
8
|
let warning = `Skipping action: ${fnName}`;
|
|
9
|
-
if(reason)
|
|
9
|
+
if(reason) {
|
|
10
|
+
warning += ` (${reason})`;
|
|
11
|
+
}
|
|
10
12
|
warn(warning);
|
|
11
13
|
|
|
12
14
|
return Promise.resolve();
|
package/src/lib/sync-fs.js
CHANGED
|
@@ -18,7 +18,9 @@ function read(path) {
|
|
|
18
18
|
|
|
19
19
|
function readCsv(path) {
|
|
20
20
|
const raw = csvParse(read(path));
|
|
21
|
-
if(!raw.length)
|
|
21
|
+
if(!raw.length) {
|
|
22
|
+
return { cols:[], rows:[] };
|
|
23
|
+
}
|
|
22
24
|
return {
|
|
23
25
|
cols: raw[0],
|
|
24
26
|
rows: raw.slice(1),
|
|
@@ -36,14 +38,16 @@ function readJson(path) {
|
|
|
36
38
|
|
|
37
39
|
function dirs(dir) {
|
|
38
40
|
return fs
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
.readdirSync(dir)
|
|
42
|
+
.filter(file => fs
|
|
43
|
+
.statSync(path.join(dir, file))
|
|
44
|
+
.isDirectory());
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
function recurseFiles(dir, files) {
|
|
46
|
-
if(!files)
|
|
48
|
+
if(!files) {
|
|
49
|
+
files = [];
|
|
50
|
+
}
|
|
47
51
|
|
|
48
52
|
fs.readdirSync(dir)
|
|
49
53
|
.filter(name => !name.startsWith('.'))
|
|
@@ -52,11 +56,19 @@ function recurseFiles(dir, files) {
|
|
|
52
56
|
try {
|
|
53
57
|
const stat = fs.statSync(f);
|
|
54
58
|
|
|
55
|
-
if(stat.isDirectory())
|
|
56
|
-
|
|
59
|
+
if(stat.isDirectory()) {
|
|
60
|
+
recurseFiles(f, files);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
files.push(f);
|
|
64
|
+
}
|
|
57
65
|
} catch(e) {
|
|
58
|
-
if(e.code === 'ENOENT')
|
|
59
|
-
|
|
66
|
+
if(e.code === 'ENOENT') {
|
|
67
|
+
trace('Ignoring file (err ENOENT - may be a symlink):', f);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
60
72
|
}
|
|
61
73
|
});
|
|
62
74
|
|
|
@@ -66,8 +78,8 @@ function recurseFiles(dir, files) {
|
|
|
66
78
|
function extension(fileName) {
|
|
67
79
|
const extensionStart = fileName.lastIndexOf('.');
|
|
68
80
|
return extensionStart === -1 ?
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
fileName :
|
|
82
|
+
fileName.substring(extensionStart+1);
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
function withoutExtension(fileName) {
|
package/src/lib/upload-forms.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
} = require('./forms-utils');
|
|
16
16
|
|
|
17
17
|
const SUPPORTED_PROPERTIES = ['context', 'icon', 'title', 'xml2sms', 'subject_key', 'hidden_fields'];
|
|
18
|
+
const CONTACT_DUPLICATE_CHECK_PROPERTY = 'duplicate_check';
|
|
18
19
|
const FORM_EXTENSTION = '.xml';
|
|
19
20
|
const FORM_PROPERTIES_EXTENSION = '.properties.json';
|
|
20
21
|
const FORM_MEDIA_MATCHER = /(.+)-media$/;
|
|
@@ -40,13 +41,20 @@ const formMediaMatcher = (formMediaDir) => {
|
|
|
40
41
|
|
|
41
42
|
const execute = async (projectDir, subDirectory, options) => {
|
|
42
43
|
const db = pouch();
|
|
43
|
-
if (!options)
|
|
44
|
+
if (!options) {
|
|
45
|
+
options = {};
|
|
46
|
+
}
|
|
44
47
|
const formsDir = getFormDir(projectDir, subDirectory);
|
|
45
48
|
if (!fs.exists(formsDir)) {
|
|
46
49
|
log.info(`Forms dir not found: ${formsDir}`);
|
|
47
50
|
return;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
const PROPERTIES = [...SUPPORTED_PROPERTIES];
|
|
54
|
+
if (subDirectory === 'contact') {
|
|
55
|
+
PROPERTIES.push(CONTACT_DUPLICATE_CHECK_PROPERTY);
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
const fileNames = argsFormFilter(formsDir, FORM_EXTENSTION, options);
|
|
51
59
|
for (const fileName of fileNames) {
|
|
52
60
|
log.info(`Preparing form for upload: ${fileName}…`);
|
|
@@ -68,7 +76,9 @@ const execute = async (projectDir, subDirectory, options) => {
|
|
|
68
76
|
};
|
|
69
77
|
|
|
70
78
|
const internalId = readIdFrom(xml);
|
|
71
|
-
if (internalId !== baseDocId)
|
|
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
|
+
}
|
|
72
82
|
|
|
73
83
|
const docId = `form:${baseDocId}`;
|
|
74
84
|
const doc = {
|
|
@@ -81,12 +91,12 @@ const execute = async (projectDir, subDirectory, options) => {
|
|
|
81
91
|
};
|
|
82
92
|
|
|
83
93
|
const propertiesPath = `${formsDir}/${baseFileName}${FORM_PROPERTIES_EXTENSION}`;
|
|
84
|
-
updateFromPropertiesFile(doc, propertiesPath);
|
|
94
|
+
updateFromPropertiesFile(doc, propertiesPath, PROPERTIES);
|
|
85
95
|
|
|
86
96
|
doc._attachments = mediaDirExists ? attachmentsFromDir(mediaDir) : {};
|
|
87
97
|
doc._attachments.xml = attachmentFromFile(xformPath);
|
|
88
98
|
|
|
89
|
-
const properties =
|
|
99
|
+
const properties = PROPERTIES.concat('internalId');
|
|
90
100
|
|
|
91
101
|
const changes = await warnUploadOverwrite.preUploadForm(db, doc, xml, properties);
|
|
92
102
|
if (changes) {
|
|
@@ -108,15 +118,15 @@ module.exports = {
|
|
|
108
118
|
execute
|
|
109
119
|
};
|
|
110
120
|
|
|
111
|
-
const updateFromPropertiesFile = (doc, path) => {
|
|
121
|
+
const updateFromPropertiesFile = (doc, path, supported_properties) => {
|
|
112
122
|
if (fs.exists(path)) {
|
|
113
123
|
|
|
114
|
-
|
|
124
|
+
const ignoredKeys = [];
|
|
115
125
|
const properties = fs.readJson(path);
|
|
116
126
|
|
|
117
127
|
Object.keys(properties).forEach(key => {
|
|
118
128
|
if (typeof properties[key] !== 'undefined') {
|
|
119
|
-
if (
|
|
129
|
+
if (supported_properties.includes(key)) {
|
|
120
130
|
doc[key] = properties[key];
|
|
121
131
|
} else if (key === 'internalId') {
|
|
122
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.`);
|
|
@@ -7,7 +7,9 @@ module.exports = {
|
|
|
7
7
|
try {
|
|
8
8
|
const resp = await api().formsValidate(xmlStr);
|
|
9
9
|
if (resp.formsValidateEndpointFound === false) {
|
|
10
|
-
warnings.push(
|
|
10
|
+
warnings.push(
|
|
11
|
+
'Form validation endpoint not found in your version of CHT Core, no form will be checked before push'
|
|
12
|
+
);
|
|
11
13
|
}
|
|
12
14
|
} catch (err) {
|
|
13
15
|
errors.push(`Error found while validating "${xformPath}". Validation response: ${err.message}`);
|
|
@@ -21,9 +21,11 @@ const UNSUPPORTED_CHAR = '[*@:[\\]]';
|
|
|
21
21
|
const XPATH_CHAR = `(${SUPPORTED_CHAR}|${UNSUPPORTED_CHAR})`;
|
|
22
22
|
// Some XPaths lookup nodes in separate instances (e.g. instance('contact-summary')/context/pregnancy_uuid)
|
|
23
23
|
const INSTANCE = `instance\\([\\w-'"]+\\)`;
|
|
24
|
-
// Look ahead and make sure there are an even number of quotes following the match
|
|
24
|
+
// Look ahead and make sure there are an even number of quotes following the match
|
|
25
|
+
// (this means that the match itself is not in quotes).
|
|
25
26
|
const LOOK_AHEADS_FOR_EVEN_QUOTES = `(?=([^"]*"[^"]*")*[^"]*$)(?=([^']*'[^']*')*[^']*$)`;
|
|
26
|
-
// Matches on all possible XPaths (simple and complex) not in quotes. May start with an instance reference.
|
|
27
|
+
// Matches on all possible XPaths (simple and complex) not in quotes. May start with an instance reference.
|
|
28
|
+
// Must include a slash.
|
|
27
29
|
const XPATH_PATTERN = new RegExp(`(${INSTANCE}|)${XPATH_CHAR}*\\/${XPATH_CHAR}+${LOOK_AHEADS_FOR_EVEN_QUOTES}`, 'g');
|
|
28
30
|
// Matches on XPaths containing complex calculations (note that '//' indicates a deep lookup, which we do not validate)
|
|
29
31
|
const UNSUPPORTED_XPATH_PATTERN = new RegExp(`\\/\\/|${UNSUPPORTED_CHAR}|${INSTANCE}`, 'g');
|
|
@@ -97,7 +99,10 @@ module.exports = {
|
|
|
97
99
|
try {
|
|
98
100
|
const fields = getFieldsWithInvalidXPaths(xmlDoc);
|
|
99
101
|
if(fields.length) {
|
|
100
|
-
errors.push(
|
|
102
|
+
errors.push(
|
|
103
|
+
`Form at ${xformPath} contains invalid XPath expressions `
|
|
104
|
+
+ '(absolute or relative paths that refer to a non-existant node):'
|
|
105
|
+
);
|
|
101
106
|
|
|
102
107
|
fields.forEach(field => {
|
|
103
108
|
const recordError = (expressionName) => {
|
|
@@ -53,8 +53,10 @@ const createWarning = ({ ref, appearance, deprecatedAppearance }) => {
|
|
|
53
53
|
|
|
54
54
|
const populateWarningHeader = (xformPath, warnings) => {
|
|
55
55
|
if (warnings.length) {
|
|
56
|
-
warnings.unshift(
|
|
57
|
-
|
|
56
|
+
warnings.unshift(
|
|
57
|
+
`Form at ${xformPath} contains fields with the deprecated \`horizontal\`/\`compact\` appearance. `
|
|
58
|
+
+ 'These have been deprecated in favor of the `columns` appearance. Please update the following fields:'
|
|
59
|
+
);
|
|
58
60
|
}
|
|
59
61
|
};
|
|
60
62
|
|
|
@@ -6,7 +6,10 @@ module.exports = {
|
|
|
6
6
|
execute: async({ xformPath, xmlDoc }) => {
|
|
7
7
|
const errors = [];
|
|
8
8
|
if(!formHasInstanceId(xmlDoc)) {
|
|
9
|
-
errors.push(
|
|
9
|
+
errors.push(
|
|
10
|
+
`Form at ${xformPath} appears to be missing <meta><instanceID/></meta> node. `
|
|
11
|
+
+ 'This form will not work on CHT webapp.'
|
|
12
|
+
);
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
return { errors, warnings: [] };
|
|
@@ -16,7 +16,10 @@ module.exports = {
|
|
|
16
16
|
.filter(bind => !bind.getAttribute('calculate'))
|
|
17
17
|
.map(bind => bind.getAttribute('nodeset'));
|
|
18
18
|
if(requiredNotes.length) {
|
|
19
|
-
errors.push(
|
|
19
|
+
errors.push(
|
|
20
|
+
`Form at ${xformPath} contains the following note fields with 'required' expressions: `
|
|
21
|
+
+ `[${requiredNotes.join(', ')}]`
|
|
22
|
+
);
|
|
20
23
|
}
|
|
21
24
|
return { errors, warnings: [] };
|
|
22
25
|
}
|
|
@@ -10,7 +10,8 @@ const environment = require('./environment');
|
|
|
10
10
|
const { compare, GroupingReporter } = require('dom-compare');
|
|
11
11
|
const DOMParser = require('@xmldom/xmldom').DOMParser;
|
|
12
12
|
|
|
13
|
-
const question = 'You are trying to modify a configuration that has been modified since your last upload.
|
|
13
|
+
const question = 'You are trying to modify a configuration that has been modified since your last upload. ' +
|
|
14
|
+
'Do you want to?';
|
|
14
15
|
const responseChoicesWithoutDiff = [
|
|
15
16
|
'Overwrite the changes',
|
|
16
17
|
'Abort so that you can update the configuration'
|
|
@@ -110,7 +111,7 @@ const getDocHash = async (db, originalDoc) => {
|
|
|
110
111
|
for (const attachmentName of Object.keys(originalDoc._attachments)) {
|
|
111
112
|
const attachment = originalDoc._attachments[attachmentName];
|
|
112
113
|
const attachmentCompressible = compressibleTypes.length ?
|
|
113
|
-
|
|
114
|
+
compressibleTypes.some(c => matchRegex(c, attachment.content_type)) : true;
|
|
114
115
|
if (attachmentCompressible) {
|
|
115
116
|
const data = attachment.data ? attachment.data : await db.getAttachment(originalDoc._id, attachmentName);
|
|
116
117
|
crypt.update(data);
|
|
@@ -196,7 +197,9 @@ const preUploadForm = async (db, localDoc, localXml, properties) => {
|
|
|
196
197
|
} else {
|
|
197
198
|
// Unexpected error, we report it then quit
|
|
198
199
|
log.trace('Trying to fetch remote xml', e);
|
|
199
|
-
throw new Error(
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Unable to fetch xml attachment of doc with id ${localDoc._id}, returned status code = ${e.status}`
|
|
202
|
+
);
|
|
200
203
|
}
|
|
201
204
|
}
|
|
202
205
|
|