cht-conf 4.4.1 → 4.6.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.
Files changed (63) hide show
  1. package/.editorconfig +15 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +9 -1
  4. package/package.json +27 -26
  5. package/src/bin/shell-completion.js +5 -5
  6. package/src/cli/supported-actions.js +2 -2
  7. package/src/cli/usage.js +2 -0
  8. package/src/fn/backup-all-forms.js +1 -1
  9. package/src/fn/compile-app-settings.js +46 -15
  10. package/src/fn/compress-pngs.js +10 -8
  11. package/src/fn/compress-svgs.js +5 -2
  12. package/src/fn/create-users.js +6 -3
  13. package/src/fn/csv-to-docs.js +73 -52
  14. package/src/fn/delete-contacts.js +2 -0
  15. package/src/fn/edit-contacts.js +14 -11
  16. package/src/fn/fetch-csvs-from-google-drive.js +3 -3
  17. package/src/fn/fetch-forms-from-google-drive.js +3 -3
  18. package/src/fn/initialise-project-layout.js +14 -8
  19. package/src/fn/merge-contacts.js +10 -2
  20. package/src/fn/move-contacts.js +2 -0
  21. package/src/fn/upload-custom-translations.js +29 -22
  22. package/src/fn/upload-docs.js +30 -13
  23. package/src/fn/upload-privacy-policies.js +4 -1
  24. package/src/fn/upload-sms-from-csv.js +11 -11
  25. package/src/fn/watch-project.js +212 -204
  26. package/src/lib/api.js +22 -22
  27. package/src/lib/args-form-filter.js +1 -1
  28. package/src/lib/attachments-from-dir.js +3 -1
  29. package/src/lib/check-cht-conf-dependency-version.js +4 -2
  30. package/src/lib/check-for-updates.js +4 -3
  31. package/src/lib/compilation/compile-contact-summary.js +12 -2
  32. package/src/lib/compilation/compile-tasks-and-targets.js +5 -2
  33. package/src/lib/compilation/package-lib.js +5 -2
  34. package/src/lib/compilation/validate-declarative-schema.js +49 -32
  35. package/src/lib/convert-forms.js +27 -9
  36. package/src/lib/exec-promise.js +27 -19
  37. package/src/lib/fetch-document-list.js +2 -2
  38. package/src/lib/generate-users-csv.js +1 -1
  39. package/src/lib/get-api-url.js +4 -1
  40. package/src/lib/google-auth.js +15 -5
  41. package/src/lib/hierarchy-operations/delete-hierarchy.js +4 -1
  42. package/src/lib/hierarchy-operations/hierarchy-data-source.js +7 -2
  43. package/src/lib/hierarchy-operations/index.js +16 -4
  44. package/src/lib/hierarchy-operations/jsdocFolder.js +4 -1
  45. package/src/lib/hierarchy-operations/lineage-constraints.js +12 -6
  46. package/src/lib/hierarchy-operations/replace-lineage.js +20 -18
  47. package/src/lib/insert-or-replace.js +6 -2
  48. package/src/lib/log.js +6 -2
  49. package/src/lib/main.js +4 -3
  50. package/src/lib/nools-utils.js +5 -3
  51. package/src/lib/parse-purge.js +5 -3
  52. package/src/lib/parse-targets.js +3 -1
  53. package/src/lib/polyfill.js +3 -1
  54. package/src/lib/progress-bar.js +26 -13
  55. package/src/lib/skip-fn.js +5 -3
  56. package/src/lib/sync-fs.js +24 -12
  57. package/src/lib/upload-forms.js +17 -7
  58. package/src/lib/validation/form/can-generate-xform.js +3 -1
  59. package/src/lib/validation/form/check-xpaths-exist.js +8 -3
  60. package/src/lib/validation/form/deprecated-appearance.js +8 -2
  61. package/src/lib/validation/form/has-instance-id.js +4 -1
  62. package/src/lib/validation/form/no-required-notes.js +4 -1
  63. package/src/lib/warn-upload-overwrite.js +6 -3
@@ -14,7 +14,10 @@ module.exports = (projectDir) => {
14
14
  const purgingExists = fs.exists(purgingPath);
15
15
 
16
16
  if (purgeExists && purgingExists) {
17
- throwError(`Purge is defined at both ${purgePath} and ${purgingPath}. Using 'purging.js' is deprecated. Create a 'purge.js' file instead.`);
17
+ throwError(
18
+ `Purge is defined at both ${purgePath} and ${purgingPath}. `
19
+ + `Using 'purging.js' is deprecated. Create a 'purge.js' file instead.`
20
+ );
18
21
  }
19
22
 
20
23
  let purgeConfig;
@@ -50,9 +53,8 @@ module.exports = (projectDir) => {
50
53
  }
51
54
 
52
55
  if (typeof purgeFn !== 'function') {
53
- throwError('Configured purging.js does not contain a function');
56
+ throwError('Configured purge file does not contain a function');
54
57
  }
55
-
56
58
  const appSettings = fs.readJson(path.join(projectDir, 'app_settings.json'));
57
59
  purgeConfig = appSettings.purging || {};
58
60
  purgeConfig.fn = purgeFnString;
@@ -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) return fs.readJson(jsonPath);
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);
@@ -1,2 +1,4 @@
1
- if(!Object.values) Object.values = o =>
1
+ if(!Object.values) {
2
+ Object.values = o =>
2
3
  Object.keys(o).map(k => o[k]);
4
+ }
@@ -2,20 +2,21 @@
2
2
  module.exports = {
3
3
  init: function(target, prefix, suffix) {
4
4
  let runningTotal = 0;
5
- let startTime = Date.now();
5
+ const startTime = Date.now();
6
6
 
7
7
  function print() {
8
- let bar = '', i;
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
- .replace('{{n}}', runningTotal)
15
- .replace('{{N}}', target)
16
- .replace('{{s}}', () => roundAndPad(timeLeft % 60))
17
- .replace('{{m}}', () => roundAndPad(timeLeft / 60, 1))
18
- .replace('{{%}}', () => roundAndPad(runningTotal * 100 / target, 3, ' ') + '%');
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) bar += '█';
27
+ for(i=pBarLen; i>0; --i) {
28
+ bar += '█';
29
+ }
27
30
 
28
- for(i=barTotal-pBarLen; i>0; --i) bar += ' ';
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') digits = 2;
45
- if(typeof padWith === 'undefined') padWith = '0';
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)) s = n.toString();
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) s = padWith + s;
64
+ while(s.length < digits) {
65
+ s = padWith + s;
66
+ }
54
67
  return s;
55
68
  }
@@ -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
- .filter(s => s.includes('fn'))[1]
6
- .match('/fn/([a-z-]+)\.js')[1]; // eslint-disable-line no-useless-escape
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) warning += ` (${reason})`;
9
+ if(reason) {
10
+ warning += ` (${reason})`;
11
+ }
10
12
  warn(warning);
11
13
 
12
14
  return Promise.resolve();
@@ -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) return { cols:[], rows:[] };
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
- .readdirSync(dir)
40
- .filter(file => fs
41
- .statSync(path.join(dir, file))
42
- .isDirectory());
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) 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()) recurseFiles(f, files);
56
- else files.push(f);
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') trace('Ignoring file (err ENOENT - may be a symlink):', f);
59
- else throw e;
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
- fileName :
70
- fileName.substring(extensionStart+1);
81
+ fileName :
82
+ fileName.substring(extensionStart+1);
71
83
  }
72
84
 
73
85
  function withoutExtension(fileName) {
@@ -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) 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) 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.');
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 = SUPPORTED_PROPERTIES.concat('internalId');
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
- let ignoredKeys = [];
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 (SUPPORTED_PROPERTIES.includes(key)) {
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('Form validation endpoint not found in your version of CHT Core, no form will be checked before 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 (this means that the match itself is not in quotes).
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. Must include a slash.
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(`Form at ${xformPath} contains invalid XPath expressions (absolute or relative paths that refer to a non-existant node):`);
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) => {
@@ -24,6 +24,11 @@ const getDeprecatedAppearances = (apiVersion) => [
24
24
  getReplacement: () => 'columns-pack no-buttons',
25
25
  versionDeprecated: '4.0.0'
26
26
  },
27
+ {
28
+ match: appearance => appearance.match(/(?:^|\s)db-object(?:$|\s)/),
29
+ getReplacement: () => 'select-contact',
30
+ versionDeprecated: '3.9.0'
31
+ },
27
32
  ].filter(deprecatedAppearance => semver.gte(apiVersion, deprecatedAppearance.versionDeprecated));
28
33
 
29
34
  const getElementsWithAppearance = (xmlDoc) => getNodes(xmlDoc, '/h:html/h:body//*[@appearance]');
@@ -53,8 +58,9 @@ const createWarning = ({ ref, appearance, deprecatedAppearance }) => {
53
58
 
54
59
  const populateWarningHeader = (xformPath, warnings) => {
55
60
  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:');
61
+ warnings.unshift(
62
+ `Form at ${xformPath} contains fields with a deprecated appearance. Please update the following:`
63
+ );
58
64
  }
59
65
  };
60
66
 
@@ -6,7 +6,10 @@ module.exports = {
6
6
  execute: async({ xformPath, xmlDoc }) => {
7
7
  const errors = [];
8
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.`);
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(`Form at ${xformPath} contains the following note fields with 'required' expressions: [${requiredNotes.join(', ')}]`);
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. Do you want to?';
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
- compressibleTypes.some(c => matchRegex(c, attachment.content_type)) : true;
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(`Unable to fetch xml attachment of doc with id ${localDoc._id}, returned status code = ${e.status}`);
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