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.
- package/.editorconfig +15 -0
- package/CHANGELOG.md +14 -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 +46 -15
- 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 +5 -3
- 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 +8 -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
|
@@ -3,7 +3,9 @@ const joi = require('joi');
|
|
|
3
3
|
const { error, warn } = require('../log');
|
|
4
4
|
|
|
5
5
|
const err = (filename, message) => details => {
|
|
6
|
-
const acceptedValues = details[0].local.valids
|
|
6
|
+
const acceptedValues = details[0].local.valids
|
|
7
|
+
? ` but only ${JSON.stringify(details[0].local.valids)} are allowed`
|
|
8
|
+
: '';
|
|
7
9
|
return new Error(`Invalid schema at ${filename}${details[0].local.label}
|
|
8
10
|
${message}
|
|
9
11
|
Current value of ${filename}${details[0].local.label} is ${JSON.stringify(details[0].value)}${acceptedValues}
|
|
@@ -45,8 +47,9 @@ const TargetSchema = joi.array().items(
|
|
|
45
47
|
then: joi.function().required(),
|
|
46
48
|
otherwise: joi.function().forbidden(),
|
|
47
49
|
})
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
+
}).error(targetError(
|
|
51
|
+
'"passesIf" is required only "type=percent" and "groupBy" is not defined. Otherwise, it is forbidden.'
|
|
52
|
+
)),
|
|
50
53
|
groupBy: joi.function().optional()
|
|
51
54
|
.error(targetError('"groupBy" should be of type function(contact, report)')),
|
|
52
55
|
passesIfGroupCount: joi.alternatives().conditional('groupBy', {
|
|
@@ -57,23 +60,25 @@ const TargetSchema = joi.array().items(
|
|
|
57
60
|
otherwise: joi.forbidden(),
|
|
58
61
|
}),
|
|
59
62
|
date: joi.alternatives().try(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
joi.string().valid('reported', 'now'),
|
|
64
|
+
joi.function(),
|
|
65
|
+
)
|
|
63
66
|
.optional()
|
|
64
|
-
.error(targetError(
|
|
67
|
+
.error(targetError(
|
|
68
|
+
'"date" should be either ["reported", "now"] or "function(contact, report)" returning timestamp'
|
|
69
|
+
)),
|
|
65
70
|
emitCustom: joi.function().optional()
|
|
66
71
|
.error(targetError('"emitCustom" should be a function')),
|
|
67
72
|
dhis: joi.alternatives().try(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
DhisSchema,
|
|
74
|
+
joi.array().items(DhisSchema),
|
|
75
|
+
)
|
|
76
|
+
.optional(),
|
|
72
77
|
visible: joi.boolean().optional(),
|
|
73
78
|
idType: joi.alternatives().try(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
joi.string().valid('report', 'contact'),
|
|
80
|
+
joi.function(),
|
|
81
|
+
)
|
|
77
82
|
.optional()
|
|
78
83
|
.error(targetError('idType should be either "report" or "contact" or "function(contact, report)"')),
|
|
79
84
|
aggregate: joi.boolean().optional(),
|
|
@@ -83,14 +88,20 @@ const TargetSchema = joi.array().items(
|
|
|
83
88
|
.required();
|
|
84
89
|
|
|
85
90
|
const EventSchema = idPresence => joi.object({
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
id: joi.string().presence(idPresence),
|
|
92
|
+
days: joi.alternatives().conditional(
|
|
93
|
+
'dueDate',
|
|
94
|
+
{ is: joi.exist(), then: joi.forbidden(), otherwise: joi.number().required() }
|
|
95
|
+
).error(taskError('"event.days" is a required integer field only when "event.dueDate" is absent')),
|
|
96
|
+
dueDate: joi.alternatives().conditional(
|
|
97
|
+
'days',
|
|
98
|
+
{ is: joi.exist(), then: joi.forbidden(), otherwise: joi.function().required() }
|
|
99
|
+
).error(taskError(
|
|
100
|
+
'"event.dueDate" is required to be "function(event, contact, report)" only when "event.days" is absent'
|
|
101
|
+
)),
|
|
102
|
+
start: joi.number().min(0).required(),
|
|
103
|
+
end: joi.number().min(0).required(),
|
|
104
|
+
});
|
|
94
105
|
|
|
95
106
|
const TaskSchema = joi.array().items(
|
|
96
107
|
joi.object({
|
|
@@ -103,7 +114,7 @@ const TaskSchema = joi.array().items(
|
|
|
103
114
|
appliesToType: joi.array().items(joi.string()).optional().min(1),
|
|
104
115
|
contactLabel:
|
|
105
116
|
joi.alternatives().try( joi.string().min(1), joi.function() ).optional()
|
|
106
|
-
|
|
117
|
+
.error(taskError('"contactLabel" should either be a non-empty string or function(contact, report)')),
|
|
107
118
|
resolvedIf: joi
|
|
108
119
|
.alternatives()
|
|
109
120
|
.conditional('actions', {
|
|
@@ -111,12 +122,10 @@ const TaskSchema = joi.array().items(
|
|
|
111
122
|
then: joi.function().optional(),
|
|
112
123
|
otherwise: joi.function().required()
|
|
113
124
|
})
|
|
114
|
-
.error(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
)
|
|
119
|
-
),
|
|
125
|
+
.error(taskError(
|
|
126
|
+
'ERROR: Schema error in actions array: Actions with property "type" which value is different than "report", ' +
|
|
127
|
+
'should define property "resolvedIf" as: function(contact, report) { ... }.'
|
|
128
|
+
)),
|
|
120
129
|
events: joi.alternatives().conditional('events', {
|
|
121
130
|
is: joi.array().length(1),
|
|
122
131
|
then: joi.array().items(EventSchema('optional')).min(1).required(),
|
|
@@ -130,11 +139,18 @@ const TaskSchema = joi.array().items(
|
|
|
130
139
|
joi.function(),
|
|
131
140
|
)
|
|
132
141
|
.optional()
|
|
133
|
-
.error(taskError(
|
|
142
|
+
.error(taskError(
|
|
143
|
+
'"priority" should be an object with optional fields "level" and "label" or a function which returns the same'
|
|
144
|
+
)),
|
|
134
145
|
actions: joi.array().items(
|
|
135
146
|
joi.object({
|
|
136
147
|
type: joi.string().valid('report', 'contact').optional(),
|
|
137
|
-
form: joi
|
|
148
|
+
form: joi
|
|
149
|
+
.alternatives()
|
|
150
|
+
.conditional(
|
|
151
|
+
'type',
|
|
152
|
+
{ is: 'contact', then: joi.forbidden(), otherwise: joi.string().min(1).required() }
|
|
153
|
+
),
|
|
138
154
|
label: joi.string().min(1).optional(),
|
|
139
155
|
modifyContent: joi.function().optional()
|
|
140
156
|
.error(taskError('"actions.modifyContent" should be "function (content, contact, report)')),
|
|
@@ -181,7 +197,8 @@ const validate = (filename, fileContent, schema) => {
|
|
|
181
197
|
const formatJoiError = (desc, detail) => {
|
|
182
198
|
const { context } = detail;
|
|
183
199
|
if (detail.type === 'array.unique') {
|
|
184
|
-
|
|
200
|
+
const fieldValue = context.value[context.path];
|
|
201
|
+
return `${desc}${context.label} contains duplicate value for the "${context.path}" field: "${fieldValue}"`;
|
|
185
202
|
}
|
|
186
203
|
|
|
187
204
|
let result = detail.message;
|
package/src/lib/convert-forms.js
CHANGED
|
@@ -31,7 +31,9 @@ const formFileMatcher = (fileName) => {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
const execute = async (projectDir, subDirectory, options) => {
|
|
34
|
-
if (!options)
|
|
34
|
+
if (!options) {
|
|
35
|
+
options = {};
|
|
36
|
+
}
|
|
35
37
|
|
|
36
38
|
const formsDir = getFormDir(projectDir, subDirectory);
|
|
37
39
|
|
|
@@ -43,7 +45,7 @@ const execute = async (projectDir, subDirectory, options) => {
|
|
|
43
45
|
const filesToConvert = argsFormFilter(formsDir, FORM_EXTENSION, options)
|
|
44
46
|
.filter(name => formFileMatcher(name));
|
|
45
47
|
|
|
46
|
-
for (
|
|
48
|
+
for (const xls of filesToConvert) {
|
|
47
49
|
const originalSourcePath = `${formsDir}/${xls}`;
|
|
48
50
|
let sourcePath;
|
|
49
51
|
|
|
@@ -51,7 +53,9 @@ const execute = async (projectDir, subDirectory, options) => {
|
|
|
51
53
|
const temporaryPath = `${fs.mkdtemp()}/${options.force_data_node}.xlsx`;
|
|
52
54
|
fs.copy(originalSourcePath, temporaryPath);
|
|
53
55
|
sourcePath = temporaryPath;
|
|
54
|
-
} else
|
|
56
|
+
} else {
|
|
57
|
+
sourcePath = originalSourcePath;
|
|
58
|
+
}
|
|
55
59
|
|
|
56
60
|
const targetPath = `${fs.withoutExtension(originalSourcePath)}.xml`;
|
|
57
61
|
|
|
@@ -76,8 +80,14 @@ const xls2xform = (sourcePath, targetPath) =>
|
|
|
76
80
|
if (executableAvailable()) {
|
|
77
81
|
if (e.includes('unrecognized arguments: --skip_validate')) {
|
|
78
82
|
throw new Error('Your xls2xform installation appears to be out of date.' + UPDATE_INSTRUCTIONS);
|
|
79
|
-
} else
|
|
80
|
-
|
|
83
|
+
} else {
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'There was a problem executing xls2xform. It may not be installed.' + INSTALLATION_INSTRUCTIONS
|
|
89
|
+
);
|
|
90
|
+
}
|
|
81
91
|
});
|
|
82
92
|
|
|
83
93
|
// here we fix the form content in arcane ways. Seeing as we have out own fork
|
|
@@ -103,14 +113,18 @@ const fixXml = (path, hiddenFields, transformer, enketo) => {
|
|
|
103
113
|
|
|
104
114
|
// Enketo _may_ not work with forms which define a default language - see
|
|
105
115
|
// https://github.com/medic/cht-core/issues/3174
|
|
106
|
-
if (enketo)
|
|
116
|
+
if (enketo) {
|
|
117
|
+
xml = xml.replace(/ default="true\(\)"/g, '');
|
|
118
|
+
}
|
|
107
119
|
|
|
108
120
|
if (hiddenFields) {
|
|
109
121
|
const r = new RegExp(`<(${hiddenFields.join('|')})(/?)>`, 'g');
|
|
110
122
|
xml = xml.replace(r, '<$1 tag="hidden"$2>');
|
|
111
123
|
}
|
|
112
124
|
|
|
113
|
-
if (transformer)
|
|
125
|
+
if (transformer) {
|
|
126
|
+
xml = transformer(xml, path);
|
|
127
|
+
}
|
|
114
128
|
|
|
115
129
|
|
|
116
130
|
// Check for deprecations
|
|
@@ -122,8 +136,12 @@ const fixXml = (path, hiddenFields, transformer, enketo) => {
|
|
|
122
136
|
};
|
|
123
137
|
|
|
124
138
|
function getHiddenFields(propsJson) {
|
|
125
|
-
if (!fs.exists(propsJson))
|
|
126
|
-
|
|
139
|
+
if (!fs.exists(propsJson)) {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
return fs.readJson(propsJson).hidden_fields;
|
|
144
|
+
}
|
|
127
145
|
}
|
|
128
146
|
|
|
129
147
|
function executableAvailable() {
|
package/src/lib/exec-promise.js
CHANGED
|
@@ -6,26 +6,34 @@ const log = require('../lib/log');
|
|
|
6
6
|
|
|
7
7
|
module.exports = (args, logLevel=log.level) => new Promise((resolve, reject) => {
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// Include stdout at log.LEVEL_WARN because xls2xform outputs warnings on stdout
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
let stdio;
|
|
12
|
+
if (logLevel >= log.LEVEL_WARN) {
|
|
13
|
+
stdio = [ 'ignore', 'pipe', 'pipe' ];
|
|
14
|
+
} else if (logLevel >= log.LEVEL_ERROR) {
|
|
15
|
+
stdio = [ 'ignore', 'ignore', 'pipe' ];
|
|
16
|
+
} else {
|
|
17
|
+
stdio = [ 'ignore', 'ignore', 'ignore' ];
|
|
18
|
+
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
20
|
+
const sub = exec(
|
|
21
|
+
args.join(' '),
|
|
22
|
+
{ stdio:stdio },
|
|
23
|
+
(err, stdout, stderr) => {
|
|
24
|
+
if(err) {
|
|
25
|
+
reject(stderr);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
resolve(stdout);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
if(logLevel >= log.LEVEL_WARN) {
|
|
33
|
+
sub.stdout.pipe(process.stdout);
|
|
34
|
+
}
|
|
35
|
+
if(logLevel >= log.LEVEL_ERROR) {
|
|
36
|
+
sub.stderr.pipe(process.stderr);
|
|
37
|
+
}
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
});
|
|
@@ -9,8 +9,8 @@ const findErrors = (rows) => {
|
|
|
9
9
|
|
|
10
10
|
rows.forEach(row => {
|
|
11
11
|
if (!row.doc) {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
errors.docNotFound.push(`Document with id '${row.key}' could not be found.`);
|
|
13
|
+
return;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
if (!VALID_DOC_TYPES.includes(row.doc.type)) {
|
|
@@ -3,7 +3,7 @@ const fs = require('./sync-fs');
|
|
|
3
3
|
const info = require('./log').info;
|
|
4
4
|
|
|
5
5
|
module.exports = (data, csvPath) => {
|
|
6
|
-
const fields = ['username', 'password', 'roles', 'contact', 'phone', 'place'];
|
|
6
|
+
const fields = ['username', 'password', 'roles', 'contact', 'phone', 'place', 'fullname', 'email'];
|
|
7
7
|
const json2csvParser = new Json2csvParser({
|
|
8
8
|
fields,
|
|
9
9
|
doubleQuote: '\'',
|
package/src/lib/get-api-url.js
CHANGED
|
@@ -25,7 +25,10 @@ const getApiUrl = (cmdArgs, env = {}) => {
|
|
|
25
25
|
// See ./archiving-db.js
|
|
26
26
|
instanceUrl = parseLocalUrl(env.COUCH_URL);
|
|
27
27
|
if (instanceUrl.hostname !== 'localhost') {
|
|
28
|
-
throw Error(
|
|
28
|
+
throw Error(
|
|
29
|
+
`--local was specified but COUCH_URL env var is set to '${instanceUrl.hostname}'. `
|
|
30
|
+
+ `Please use --url for remote servers.`
|
|
31
|
+
);
|
|
29
32
|
}
|
|
30
33
|
} else if (cmdArgs.instance) {
|
|
31
34
|
const password = userPrompt.question(`${emoji.key} Password: `, { hideEchoBack: true });
|
package/src/lib/google-auth.js
CHANGED
|
@@ -11,11 +11,15 @@ const TOKENS_FILE = './.gdrive.session.json';
|
|
|
11
11
|
module.exports = () => {
|
|
12
12
|
const client = oauthClient();
|
|
13
13
|
|
|
14
|
-
if(!fs.exists(TOKENS_FILE))
|
|
14
|
+
if(!fs.exists(TOKENS_FILE)) {
|
|
15
|
+
return newSessionFor(client);
|
|
16
|
+
}
|
|
15
17
|
|
|
16
18
|
client.setCredentials(fs.readJson(TOKENS_FILE));
|
|
17
19
|
|
|
18
|
-
if(client.credentials.expiry_date < Date.now())
|
|
20
|
+
if(client.credentials.expiry_date < Date.now()) {
|
|
21
|
+
return newSessionFor(client);
|
|
22
|
+
}
|
|
19
23
|
|
|
20
24
|
return Promise.resolve(client);
|
|
21
25
|
};
|
|
@@ -30,7 +34,9 @@ function newSessionFor(client) {
|
|
|
30
34
|
|
|
31
35
|
return new Promise((resolve, reject) => {
|
|
32
36
|
client.getToken(accessCode, function (err, tokens) {
|
|
33
|
-
if(err)
|
|
37
|
+
if(err) {
|
|
38
|
+
return reject(err);
|
|
39
|
+
}
|
|
34
40
|
|
|
35
41
|
fs.writeJson(TOKENS_FILE, tokens);
|
|
36
42
|
|
|
@@ -61,7 +67,9 @@ function oauthClient() {
|
|
|
61
67
|
checkRequired(clientSecret, 'client_secret', 'GOOGLE_CLIENT_SECRET') |
|
|
62
68
|
checkRequired(redirectUri, 'redirect_uris', 'GOOGLE_REDIRECT_URI');
|
|
63
69
|
|
|
64
|
-
if(missingConfig)
|
|
70
|
+
if(missingConfig) {
|
|
71
|
+
throw new Error('Missing required config for google drive access. Please check warnings.');
|
|
72
|
+
}
|
|
65
73
|
|
|
66
74
|
return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
|
|
67
75
|
}
|
|
@@ -72,7 +80,9 @@ function openBrowserAt(url) {
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
function checkRequired(value, jsonKey, envVar) {
|
|
75
|
-
if(value)
|
|
83
|
+
if(value) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
76
86
|
|
|
77
87
|
warn(`Missing .${jsonKey} in ${SECRETS_FILE} or env var ${envVar}!`);
|
|
78
88
|
return true;
|
|
@@ -23,7 +23,10 @@ async function deleteHierarchy(db, options, sourceIds) {
|
|
|
23
23
|
|
|
24
24
|
const affectedContactCount = descendantsAndSelf.length;
|
|
25
25
|
|
|
26
|
-
info(
|
|
26
|
+
info(
|
|
27
|
+
`Staged updates to delete ${prettyPrintDocument(sourceDoc)}. ${affectedContactCount.length} contact(s) `
|
|
28
|
+
+ `and ${affectedReportCount} report(s).`
|
|
29
|
+
);
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -12,7 +12,9 @@ async function getContactsByIds(db, ids) {
|
|
|
12
12
|
include_docs: true,
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
const missingContactErrors = contactDocs.rows
|
|
15
|
+
const missingContactErrors = contactDocs.rows
|
|
16
|
+
.filter(row => !row.doc)
|
|
17
|
+
.map(row => `Contact with id '${row.key}' could not be found.`);
|
|
16
18
|
if (missingContactErrors.length > 0) {
|
|
17
19
|
throw Error(missingContactErrors);
|
|
18
20
|
}
|
|
@@ -86,7 +88,10 @@ async function getAncestorsOf(db, contactDoc) {
|
|
|
86
88
|
|
|
87
89
|
const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key);
|
|
88
90
|
if (ancestorIdsNotFound.length > 0) {
|
|
89
|
-
throw Error(
|
|
91
|
+
throw Error(
|
|
92
|
+
`Contact '${contactDoc?.name}' (${contactDoc?._id}) has parent id(s) `
|
|
93
|
+
+ `'${ancestorIdsNotFound.join(',')}' which could not be found.`
|
|
94
|
+
);
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
return ancestors.rows.map(ancestor => ancestor.doc);
|
|
@@ -35,11 +35,17 @@ async function moveHierarchy(db, options, sourceIds, destinationId) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`;
|
|
38
|
-
trace(
|
|
38
|
+
trace(
|
|
39
|
+
`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact `
|
|
40
|
+
+ `${prettyPrintDocument(sourceDoc)}.`
|
|
41
|
+
);
|
|
39
42
|
const updatedDescendants = replaceLineageInContacts(moveContext);
|
|
40
43
|
|
|
41
44
|
const ancestors = await DataSource.getAncestorsOf(db, sourceDoc);
|
|
42
|
-
trace(
|
|
45
|
+
trace(
|
|
46
|
+
`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact `
|
|
47
|
+
+ `${prettyPrintDocument(sourceDoc)}.`
|
|
48
|
+
);
|
|
43
49
|
const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors);
|
|
44
50
|
|
|
45
51
|
minifyLineageAndWriteToDisk(options, [...updatedDescendants, ...updatedAncestors]);
|
|
@@ -50,10 +56,16 @@ async function moveHierarchy(db, options, sourceIds, destinationId) {
|
|
|
50
56
|
affectedContactCount += updatedDescendants.length + updatedAncestors.length;
|
|
51
57
|
affectedReportCount += movedReportsCount;
|
|
52
58
|
|
|
53
|
-
info(
|
|
59
|
+
info(
|
|
60
|
+
`Staged updates to ${prettyPrintDocument(sourceDoc)}. ${updatedDescendants.length} contact(s) and `
|
|
61
|
+
+ `${movedReportsCount} report(s).`
|
|
62
|
+
);
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
info(
|
|
65
|
+
info(
|
|
66
|
+
`Staged changes to lineage information for ${affectedContactCount} contact(s) and `
|
|
67
|
+
+ `${affectedReportCount} report(s).`
|
|
68
|
+
);
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
async function updateReports(db, options, moveContext) {
|
|
@@ -22,7 +22,10 @@ function writeDoc({ docDirectoryPath }, doc) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function deleteAfterConfirmation(docDirectoryPath) {
|
|
25
|
-
warn(
|
|
25
|
+
warn(
|
|
26
|
+
`The document folder '${docDirectoryPath}' already contains files. `
|
|
27
|
+
+ 'It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?'
|
|
28
|
+
);
|
|
26
29
|
if (!userPrompt.keyInYN()) {
|
|
27
30
|
throw new Error('User aborted execution.');
|
|
28
31
|
}
|
|
@@ -11,7 +11,10 @@ module.exports = async (db, options) => {
|
|
|
11
11
|
assertNoPrimaryContactViolations: async (sourceDoc, destinationDoc, descendantDocs) => {
|
|
12
12
|
const invalidPrimaryContactDoc = await getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs);
|
|
13
13
|
if (invalidPrimaryContactDoc) {
|
|
14
|
-
throw Error(
|
|
14
|
+
throw Error(
|
|
15
|
+
`Cannot remove contact '${invalidPrimaryContactDoc?.name}' (${invalidPrimaryContactDoc?._id}) from the `
|
|
16
|
+
+ 'hierarchy for which they are a primary contact.'
|
|
17
|
+
);
|
|
15
18
|
}
|
|
16
19
|
},
|
|
17
20
|
assertNoHierarchyErrors: (sourceDocs, destinationDoc) => {
|
|
@@ -29,9 +32,10 @@ module.exports = async (db, options) => {
|
|
|
29
32
|
});
|
|
30
33
|
|
|
31
34
|
/*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
* It is nice that the tool can move lists of contacts as one operation, but strange things happen when two
|
|
36
|
+
* contactIds are in the same lineage. For example, moving a district_hospital and moving a contact under that
|
|
37
|
+
* district_hospital to a new clinic causes multiple colliding writes to the same json file.
|
|
38
|
+
*/
|
|
35
39
|
const contactIds = sourceDocs.map(doc => doc._id);
|
|
36
40
|
sourceDocs
|
|
37
41
|
.forEach(doc => {
|
|
@@ -81,7 +85,8 @@ function findCircularHierarchyErrors(sourceDoc, destinationDoc) {
|
|
|
81
85
|
|
|
82
86
|
const parentAncestry = lineageManipulation.pluckIdsFromLineage(destinationDoc);
|
|
83
87
|
if (parentAncestry.includes(sourceDoc._id)) {
|
|
84
|
-
return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}'
|
|
88
|
+
return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' `
|
|
89
|
+
+ `as it would create a circular hierarchy.`;
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -117,7 +122,8 @@ const getMergeViolations = (sourceDoc, destinationDoc) => {
|
|
|
117
122
|
const sourceContactType = getContactType(sourceDoc);
|
|
118
123
|
const destinationContactType = getContactType(destinationDoc);
|
|
119
124
|
if (sourceContactType !== destinationContactType) {
|
|
120
|
-
return `source and destinations must have the same type.
|
|
125
|
+
return `source and destinations must have the same type. `
|
|
126
|
+
+ `Source is "${sourceContactType}" while destination is "${destinationContactType}".`;
|
|
121
127
|
}
|
|
122
128
|
};
|
|
123
129
|
|
|
@@ -48,28 +48,30 @@ function replaceEntireLineage(replaceInDoc, lineageAttributeName, replaceWith) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
module.exports = {
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Given a doc, replace the parent's lineage
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} doc A CouchDB document containing a parent lineage (eg. parent.parent._id)
|
|
55
|
+
* @param {Object} params
|
|
56
|
+
* @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } }
|
|
57
|
+
* @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced
|
|
58
|
+
* @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is
|
|
59
|
+
* replaced
|
|
60
|
+
*/
|
|
60
61
|
replaceParentLineage: (doc, params) => {
|
|
61
62
|
return replaceLineage(doc, 'parent', params);
|
|
62
63
|
},
|
|
63
64
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Given a doc, replace the contact's lineage
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} doc A CouchDB document containing a contact lineage (eg. contact.parent._id)
|
|
69
|
+
* @param {Object} params
|
|
70
|
+
* @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } }
|
|
71
|
+
* @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced
|
|
72
|
+
* @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is
|
|
73
|
+
* replaced
|
|
74
|
+
*/
|
|
73
75
|
replaceContactLineage: (doc, params) => {
|
|
74
76
|
return replaceLineage(doc, 'contact', params);
|
|
75
77
|
},
|
|
@@ -2,7 +2,11 @@ module.exports = (db, doc) =>
|
|
|
2
2
|
db.get(doc._id)
|
|
3
3
|
.then(existingDoc => doc._rev = existingDoc._rev)
|
|
4
4
|
.catch(e => {
|
|
5
|
-
if(e.status === 404)
|
|
6
|
-
|
|
5
|
+
if(e.status === 404) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
else {
|
|
9
|
+
throw e;
|
|
10
|
+
}
|
|
7
11
|
})
|
|
8
12
|
.then(() => db.put(doc));
|
package/src/lib/log.js
CHANGED
|
@@ -30,7 +30,11 @@ function logAtLevel(color, level, ...args) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const redactUrls = s => {
|
|
33
|
-
if(s instanceof Error)
|
|
34
|
-
|
|
33
|
+
if(s instanceof Error) {
|
|
34
|
+
s = s.stack || s.message || s;
|
|
35
|
+
}
|
|
36
|
+
if(s && typeof s !== 'string') {
|
|
37
|
+
s = JSON.stringify(s, null, 2);
|
|
38
|
+
}
|
|
35
39
|
return s && redactBasicAuth(s);
|
|
36
40
|
};
|
package/src/lib/main.js
CHANGED
|
@@ -168,11 +168,12 @@ module.exports = async (argv, env) => {
|
|
|
168
168
|
await api().available();
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
const productionUrlMatch = environment.instanceUrl
|
|
171
|
+
const productionUrlMatch = environment.instanceUrl
|
|
172
|
+
&& environment.instanceUrl.match(/^https:\/\/(?:[^@]*@)?(.*)\.(app|dev)\.medicmobile\.org(?:$|\/)/);
|
|
172
173
|
const expectedOptions = ['alpha', projectName];
|
|
173
174
|
if (productionUrlMatch && !expectedOptions.includes(productionUrlMatch[1])) {
|
|
174
175
|
warn(`Attempting to use project for \x1b[31m${projectName}\x1b[33m`,
|
|
175
|
-
|
|
176
|
+
`against non-matching instance: \x1b[31m${redactBasicAuth(environment.instanceUrl)}\x1b[33m`);
|
|
176
177
|
if (!userPrompt.keyInYN()) {
|
|
177
178
|
throw new Error('User aborted execution.');
|
|
178
179
|
}
|
|
@@ -189,7 +190,7 @@ module.exports = async (argv, env) => {
|
|
|
189
190
|
await checkForUpdates({ nonFatal: true });
|
|
190
191
|
}
|
|
191
192
|
|
|
192
|
-
for (
|
|
193
|
+
for (const action of actions) {
|
|
193
194
|
info(`Starting action: ${action.name}…`);
|
|
194
195
|
await executeAction(action);
|
|
195
196
|
info(`${action.name} complete.`);
|
package/src/lib/nools-utils.js
CHANGED
|
@@ -2,18 +2,20 @@ const minify = js =>
|
|
|
2
2
|
js.split('\n')
|
|
3
3
|
.map(s =>
|
|
4
4
|
s.trim()
|
|
5
|
-
|
|
5
|
+
.replace(/\s*\/\/.*/, '') // single-line comments (like this one)
|
|
6
6
|
).join('')
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
.replace(/\s*\/\*(?:(?!\*\/).)*\*\/\s*/g, '') /* this kind of comment */
|
|
8
|
+
.replace(/function \(/g, 'function(') // different node versions do function.toString() differently :\
|
|
9
9
|
;
|
|
10
10
|
|
|
11
|
+
/* eslint-disable max-len */
|
|
11
12
|
const addBoilerplateToCode = code => `define Target { _id: null, contact: null, deleted: null, type: null, pass: null, date: null, groupBy: null }
|
|
12
13
|
define Contact { contact: null, reports: null, tasks: null }
|
|
13
14
|
define Task { _id: null, deleted: null, doc: null, contact: null, icon: null, date: null, readyStart: null, readyEnd: null, title: null, fields: null, resolved: null, priority: null, priorityLabel: null, reports: null, actions: null }
|
|
14
15
|
rule GenerateEvents {
|
|
15
16
|
when { c: Contact } then { ${code} }
|
|
16
17
|
}`;
|
|
18
|
+
/* eslint-enable max-len */
|
|
17
19
|
|
|
18
20
|
module.exports = {
|
|
19
21
|
addBoilerplateToCode,
|