cht-conf 6.4.1 → 6.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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/fn/delete-ui-extensions.js +12 -0
- package/src/fn/upload-ui-extensions.js +15 -0
- package/src/lib/main.js +2 -0
- package/src/lib/project-paths.js +1 -0
- package/src/lib/ui-extensions.js +166 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [6.5.0](https://github.com/medic/cht-conf/compare/v6.4.1...v6.5.0) (2026-06-23)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **#10224:** add actions for upload/delete UI extensions ([#823](https://github.com/medic/cht-conf/issues/823)) ([178bbfc](https://github.com/medic/cht-conf/commit/178bbfc9f412a2ea6c7ee2fe9d0610a18c2a50e3)), closes [#10224](https://github.com/medic/cht-conf/issues/10224)
|
|
7
|
+
|
|
1
8
|
## [6.4.1](https://github.com/medic/cht-conf/compare/v6.4.0...v6.4.1) (2026-05-12)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const uiExtensionsLib = require('../lib/ui-extensions');
|
|
2
|
+
const environment = require('../lib/environment');
|
|
3
|
+
|
|
4
|
+
const executeDeleteUiExtensions = async () => {
|
|
5
|
+
const specifiedExtensions = environment.extraArgs?.filter(arg => !arg.startsWith('--')) ?? [];
|
|
6
|
+
await uiExtensionsLib.deleteUiExtensions(specifiedExtensions);
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
requiresInstance: true,
|
|
11
|
+
execute: executeDeleteUiExtensions
|
|
12
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const projectPaths = require('../lib/project-paths');
|
|
2
|
+
const uploadUiExtensionsLib = require('../lib/ui-extensions');
|
|
3
|
+
const environment = require('../lib/environment');
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
const executeUploadUiExtensions = async () => {
|
|
7
|
+
const uiExtensionsDir = `${environment.pathToProject}/${projectPaths.UI_EXTENSIONS_PATH}`;
|
|
8
|
+
const specifiedExtensions = environment.extraArgs?.filter(arg => !arg.startsWith('--')) ?? [];
|
|
9
|
+
await uploadUiExtensionsLib.uploadUiExtensions(uiExtensionsDir, specifiedExtensions);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
requiresInstance: true,
|
|
14
|
+
execute: executeUploadUiExtensions
|
|
15
|
+
};
|
package/src/lib/main.js
CHANGED
|
@@ -38,6 +38,7 @@ const defaultActions = [
|
|
|
38
38
|
'upload-custom-translations',
|
|
39
39
|
'upload-privacy-policies',
|
|
40
40
|
'upload-extension-libs',
|
|
41
|
+
'upload-ui-extensions',
|
|
41
42
|
'upload-database-indexes',
|
|
42
43
|
];
|
|
43
44
|
const defaultArchiveActions = [
|
|
@@ -61,6 +62,7 @@ const defaultArchiveActions = [
|
|
|
61
62
|
'upload-custom-translations',
|
|
62
63
|
'upload-privacy-policies',
|
|
63
64
|
'upload-extension-libs',
|
|
65
|
+
'upload-ui-extensions',
|
|
64
66
|
'upload-database-indexes',
|
|
65
67
|
];
|
|
66
68
|
|
package/src/lib/project-paths.js
CHANGED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const Joi = require('joi');
|
|
4
|
+
const environment = require('./environment');
|
|
5
|
+
const log = require('./log');
|
|
6
|
+
const insertOrReplace = require('./insert-or-replace');
|
|
7
|
+
const warnUploadOverwrite = require('./warn-upload-overwrite');
|
|
8
|
+
const pouch = require('./db');
|
|
9
|
+
const attachmentFromFile = require('./attachment-from-file');
|
|
10
|
+
|
|
11
|
+
const TYPE = 'ui-extension';
|
|
12
|
+
const ID_PREFIX = `${TYPE}:`;
|
|
13
|
+
const ATTACHMENT_NAME = 'extension.js';
|
|
14
|
+
|
|
15
|
+
const schema = Joi.object({
|
|
16
|
+
extension_type: Joi.string().valid('header_tab', 'sidebar_tab').required(),
|
|
17
|
+
title: Joi.string().required(),
|
|
18
|
+
icon: Joi.string().pattern(/^fa-/).optional(),
|
|
19
|
+
resource_icon: Joi.string().optional(),
|
|
20
|
+
roles: Joi.array().items(Joi.string()),
|
|
21
|
+
accent_color: Joi.string().optional(),
|
|
22
|
+
weight: Joi.number().optional(),
|
|
23
|
+
config: Joi.object().unknown(true)
|
|
24
|
+
}).oxor('icon', 'resource_icon');
|
|
25
|
+
|
|
26
|
+
// Name will be used in Couch _id and in web component name.
|
|
27
|
+
const isValidExtensionName = (name) => /^[a-zA-Z0-9_.-]+$/.test(name);
|
|
28
|
+
|
|
29
|
+
const getNamesToUpload = (uiExtensionsDir) => {
|
|
30
|
+
const allFiles = fs.readdirSync(uiExtensionsDir);
|
|
31
|
+
const extensionNames = new Set(
|
|
32
|
+
allFiles
|
|
33
|
+
.filter(f => f.endsWith('.js') || f.endsWith('.properties.json'))
|
|
34
|
+
.map(f => f.replace(/(\.properties\.json|\.js)$/, ''))
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return Array.from(extensionNames);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const readPropertiesFile = (propsPath) => {
|
|
41
|
+
try {
|
|
42
|
+
const rawProps = fs.readFileSync(propsPath, 'utf-8');
|
|
43
|
+
return JSON.parse(rawProps);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Failed to parse ${propsPath.split('/').at(-1)} - Invalid JSON format: ${err.message}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getExtensionDoc = (uiExtensionsDir, name) => {
|
|
52
|
+
if (!isValidExtensionName(name)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`UI Extension name "${name}" is invalid. It must contain only letters, digits, hyphens, ` +
|
|
55
|
+
'periods, or underscores.'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const jsPath = path.join(uiExtensionsDir, `${name}.js`);
|
|
60
|
+
const propsPath = path.join(uiExtensionsDir, `${name}.properties.json`);
|
|
61
|
+
|
|
62
|
+
if (!fs.existsSync(jsPath) || !fs.existsSync(propsPath)) {
|
|
63
|
+
throw new Error(`UI Extension "${name}" is missing either its .js or .properties.json file.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const propsContent = readPropertiesFile(propsPath);
|
|
67
|
+
|
|
68
|
+
const validation = schema.validate(propsContent);
|
|
69
|
+
if (validation.error) {
|
|
70
|
+
throw new Error(`Validation error for UI extension "${name}": ${validation.error.message}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
_id: `${ID_PREFIX}${name}`,
|
|
75
|
+
...propsContent,
|
|
76
|
+
type: TYPE,
|
|
77
|
+
_attachments: {
|
|
78
|
+
[ATTACHMENT_NAME]: attachmentFromFile(jsPath)
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const uploadDocToDb = async (db, doc, name) => {
|
|
84
|
+
const changes = await warnUploadOverwrite.preUploadDoc(db, doc);
|
|
85
|
+
if (!changes) {
|
|
86
|
+
log.info(`UI Extension "${name}" not uploaded as already up to date`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await insertOrReplace(db, doc);
|
|
91
|
+
log.info(`UI Extension "${name}" upload complete`);
|
|
92
|
+
await warnUploadOverwrite.postUploadDoc(db, doc);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const uploadUiExtensions = async (uiExtensionsDir, specificExtensions = []) => {
|
|
96
|
+
if (!fs.existsSync(uiExtensionsDir)) {
|
|
97
|
+
log.info(`No directory found at "${uiExtensionsDir}" - not uploading ui-extensions`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// if specific extensions are provided, bypass directory reading
|
|
102
|
+
// missing files will be caught by getExtensionDoc
|
|
103
|
+
const namesToUpload = specificExtensions.length ? specificExtensions : getNamesToUpload(uiExtensionsDir);
|
|
104
|
+
|
|
105
|
+
if (!namesToUpload.length) {
|
|
106
|
+
log.info('No UI extensions to upload.');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log.info(`Found UI extensions: ${namesToUpload.join(', ')}`);
|
|
111
|
+
|
|
112
|
+
// process all docs before uploading any to ensure validation passes for everything
|
|
113
|
+
const namesWithDocs = namesToUpload.map(name => [name, getExtensionDoc(uiExtensionsDir, name)]);
|
|
114
|
+
|
|
115
|
+
const db = pouch(environment.apiUrl);
|
|
116
|
+
|
|
117
|
+
for (const [name, doc] of namesWithDocs) {
|
|
118
|
+
await uploadDocToDb(db, doc, name);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const getDocsToDelete = async (db, specifiedExtensions) => {
|
|
123
|
+
const keys = specifiedExtensions.map(name => `${ID_PREFIX}${name}`);
|
|
124
|
+
const opts = keys.length ? { keys }: {
|
|
125
|
+
startkey: ID_PREFIX,
|
|
126
|
+
endkey: `${ID_PREFIX}\ufff0`
|
|
127
|
+
};
|
|
128
|
+
const result = await db.allDocs({
|
|
129
|
+
...opts,
|
|
130
|
+
include_docs: true
|
|
131
|
+
});
|
|
132
|
+
const docs = result.rows
|
|
133
|
+
.map(row => row.doc)
|
|
134
|
+
.filter(Boolean);
|
|
135
|
+
keys
|
|
136
|
+
.filter(key => !docs.some(({ _id }) => _id === key))
|
|
137
|
+
.forEach(name => log.warn(`UI Extension "${name.slice(ID_PREFIX.length)}" not found in database. Skipping.`));
|
|
138
|
+
return docs;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const deleteUiExtensions = async (specifiedExtensions = []) => {
|
|
142
|
+
const db = pouch(environment.apiUrl);
|
|
143
|
+
|
|
144
|
+
const docs = await getDocsToDelete(db, specifiedExtensions);
|
|
145
|
+
|
|
146
|
+
if (!docs.length) {
|
|
147
|
+
log.info('No UI extensions found to delete.');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
log.info(`Deleting ${docs.length} UI extension(s)...`);
|
|
152
|
+
|
|
153
|
+
for (const doc of docs) {
|
|
154
|
+
try {
|
|
155
|
+
await db.remove(doc);
|
|
156
|
+
log.info(`Deleted UI Extension: ${doc._id.slice(ID_PREFIX.length)}`);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
throw new Error(`Failed to delete ${doc._id}: ${err.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
uploadUiExtensions,
|
|
165
|
+
deleteUiExtensions
|
|
166
|
+
};
|