@nzz/q-cli 1.6.0 → 1.8.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/README.md CHANGED
@@ -164,7 +164,29 @@ Q new-custom-code my-project-name
164
164
  Q new-custom-code my-project-name -d my-project-directory
165
165
  ```
166
166
 
167
- ### Updating existing Q items
167
+ ### Creating new ed-tech utility package project
168
+
169
+ Once `Q` cli is installed one can create the skeleton of a new ed-tech utility package project by executing
170
+
171
+ ```bash
172
+ Q new-et-utils-package package-name package-author package-description
173
+ ```
174
+
175
+ - The directory name where the new ed-tech utility package project is being created defaults to the project name and can be overwritten by using option `-d` or `--dir`
176
+
177
+ ```bash
178
+ Q new-et-utils-package package-name -d my-project-directory
179
+ ```
180
+
181
+ #### Notes
182
+
183
+ New utility package projects should only be created inside the [ed-tech-utilities](https://github.com/nzzdev/ed-tech-utilities) repository.
184
+
185
+ ### Q item actions
186
+
187
+ The `Q` cli can copy and/or update existing Q items.
188
+
189
+ #### Updating existing Q items
168
190
 
169
191
  Once `Q` cli installed one can update one or many Q items by executing:
170
192
 
@@ -193,63 +215,116 @@ Q update-item -r
193
215
  - Credentials can be provided as environment variables to avoid user prompts. The variable names are `Q_ENV_SERVER`, `Q_ENV_USERNAME`, `Q_ENV_PASSWORD`, `Q_ENV_ACCESSTOKEN`, where `ENV` is the uppercase version of the environment name.
194
216
 
195
217
  ```bash
196
- Q_TEST_SERVER=https://q-server.st-test.nzz.ch/ Q_TEST_USERNAME=[username] Q_TEST_PASSWORD=[password] Q update-item
218
+ Q_TEST_SERVER=[server_route] Q_TEST_USERNAME=[username] Q_TEST_PASSWORD=[password] Q update-item
197
219
  ```
198
220
 
199
221
  or
200
222
 
201
223
  ```bash
202
- Q_TEST_SERVER=https://q-server.st-test.nzz.ch/ Q_TEST_ACCESSTOKEN=[accessToken] Q update-item
224
+ Q_TEST_SERVER=[server_route] Q_TEST_ACCESSTOKEN=[accessToken] Q update-item
203
225
  ```
204
226
 
205
- Alternatively
206
-
207
- The config file has to follow [this json-schema](./bin/commands/updateItem/schema.json). Here an example:
227
+ The config file has to follow [this json-schema](./bin/commands/qItem/updateItem/updateSchema.json). This schema will be extended by the respective tool schema of your Q item.
228
+ Here's an example:
208
229
 
209
230
  ```json
210
231
  {
211
232
  "items": [
212
233
  {
213
234
  "environments": [
235
+ // "environments" references the desired q items to be updated, at least 1 environment is required
214
236
  {
215
237
  "name": "production",
216
- "id": "6dcf203a5c5f74b61aeea0cb0eef7e0b"
238
+ "id": "6dcf203a5c5f74b61aeea0cb0eef7e0b" // Id of your q item in the production environment
217
239
  },
218
240
  {
219
241
  "name": "staging",
220
- "id": "6dcf203a5c5f74b61aeea0cb0ef2ca9f"
242
+ "id": "6dcf203a5c5f74b61aeea0cb0ef2ca9f" // Id of your q item in the staging environment
221
243
  }
222
244
  ],
223
245
  "item": {
246
+ // The actual content you want to update for your referenced q items listed in "environments"
224
247
  "title": "Der Konsum in der Schweiz springt wieder an",
225
248
  "subtitle": "Wöchentliche Ausgaben mittels Bankkarten in Mio. Fr. im Jahr 2020, zum Vergleich 2019",
226
249
  "data": [
250
+ // "data" represents the data table of your q item inside the q-editor
227
251
  ["Datum", "2020", "2019"],
228
252
  ["2020-01-06", "690004302", "641528028"],
229
253
  ["2020-01-13", "662122373", "617653790"],
230
254
  ["2020-01-20", "688208667", "654303249"]
231
255
  ]
232
256
  }
233
- },
257
+ }
258
+ ]
259
+ }
260
+ ```
261
+
262
+ #### Copy existing Q items
263
+
264
+ Once `Q` cli installed one can copy one or many Q items by executing:
265
+
266
+ ```bash
267
+ Q copy-item
268
+ ```
269
+
270
+ - The path to the config file can be set by using option `-c` or `--config`. By default the `copy-item` command will look for a config file called `q.config.json` in the current directory
271
+
272
+ ```bash
273
+ Q copy-item -c [path]
274
+ ```
275
+
276
+ - Items of a specified environment can be updated by using the option `-e` or `--environment`. By default the `copy-item` command updates all item specified in the config file
277
+
278
+ ```bash
279
+ Q copy-item -e [env]
280
+ ```
281
+
282
+ - Stored configuration properties like Q-Server url or access tokens can be reset by using option `-r` or `--reset`
283
+
284
+ ```bash
285
+ Q copy-item -r
286
+ ```
287
+
288
+ - Credentials can be provided as environment variables to avoid user prompts. The variable names are `Q_ENV_SERVER`, `Q_ENV_USERNAME`, `Q_ENV_PASSWORD`, `Q_ENV_ACCESSTOKEN`, where `ENV` is the uppercase version of the environment name.
289
+
290
+ ```bash
291
+ Q_TEST_SERVER=[server_route] Q_TEST_USERNAME=[username] Q_TEST_PASSWORD=[password] Q update-item
292
+ ```
293
+
294
+ or
295
+
296
+ ```bash
297
+ Q_TEST_SERVER=[server_route] Q_TEST_ACCESSTOKEN=[accessToken] Q update-item
298
+ ```
299
+
300
+ The config file has to follow [this json-schema](./bin/commands/qItem/updateItem/updateSchema.json). This schema will be extended by the respective tool schema of your Q item.
301
+ Here's an example:
302
+
303
+ ```json
304
+ {
305
+ "items": [
234
306
  {
235
307
  "environments": [
236
308
  {
237
309
  "name": "production",
238
- "id": "6dcf203a5c5f74b61aeea0cb0ef2edea"
310
+ "id": "6dcf203a5c5f74b61aeea0cb0eef7e0b" // Id of your q item in the production environment
239
311
  },
240
312
  {
241
313
  "name": "staging",
242
- "id": "6dcf203a5c5f74b61aeea0cb0ef68480"
314
+ "id": "6dcf203a5c5f74b61aeea0cb0ef2ca9f" // Id of your q item in the staging environment
243
315
  }
244
316
  ],
245
317
  "item": {
246
- "title": "Der Lastwagenverkehr in Deutschland nimmt wieder zu",
247
- "subtitle": "Täglicher Lkw-Maut-Fahrleistungsindex (2015 = 100, saison- und kalenderbereinigt) im Jahr 2020, zum Vergleich 2019\t\t",
248
- "data": [
249
- ["Datum", "2020", "2019"],
250
- ["2020-01-07", "105.9", "112.1"],
251
- ["2020-01-08", "108.9", "111.4"],
252
- ["2020-01-09", "112.2", "113.5"]
318
+ "title": "Russische Angriffe auf die Ukraine",
319
+ "subtitle": "Verzeichnete Angriffe in der ganzen Ukraine",
320
+ "files": [
321
+ // Adds or overwrites the listed files in your q item
322
+ {
323
+ "loadSyncBeforeInit": false, // Has to be set for the file upload to work
324
+ "file": {
325
+ "path": "./angriffsFlaechen.json" // Your local path to your file. The path is relative to where you execute the command.
326
+ }
327
+ }
253
328
  ]
254
329
  }
255
330
  }
@@ -257,8 +332,6 @@ The config file has to follow [this json-schema](./bin/commands/updateItem/schem
257
332
  }
258
333
  ```
259
334
 
260
- The configuration object has a property `items` which contains an object for each Q item. A Q item has a property `environments` and `item`. The `environments` array contains an objects with properties `name` and `id` for each environment the item is deployed on. The `item` contains the data of the Q item. The structure of the item can vary between each graphic type (chart, map, table ect.).
261
-
262
335
  [to the top](#table-of-contents)
263
336
 
264
337
  ## License
@@ -2,11 +2,19 @@ const fs = require("fs-extra");
2
2
  const path = require("path");
3
3
  const replaceInFile = require("replace-in-file");
4
4
  const chalk = require("chalk");
5
+ const { replace } = require("nunjucks/src/filters");
5
6
  const errorColor = chalk.red;
6
7
  const successColor = chalk.green;
7
8
  const warningColor = chalk.yellow;
8
9
 
9
- module.exports = async function (type, name, basedir) {
10
+ /**
11
+ *
12
+ * @param {string} type - Skeleton type
13
+ * @param {string} name - Name of the project
14
+ * @param {string} basedir - Base directory name to be created
15
+ * @param {Array.<{regex: RegExp, replaceWith: string}>} textReplacements
16
+ */
17
+ module.exports = async function (type, basedir, textReplacements) {
10
18
  if (fs.existsSync(basedir)) {
11
19
  console.error(
12
20
  errorColor(`directory ${basedir} already exists or is not writable`)
@@ -16,34 +24,46 @@ module.exports = async function (type, name, basedir) {
16
24
  fs.mkdirSync(basedir);
17
25
  }
18
26
 
19
- const replaceOptions = {
20
- files: `${basedir}/**`,
21
- from: new RegExp(`${type}-skeleton`, "g"),
22
- to: name,
23
- glob: {
24
- dot: true, // Include file names starting with a dot
25
- },
26
- };
27
-
28
27
  try {
29
28
  await fs.copySync(
30
29
  path.join(__dirname, `../../skeletons/${type}-skeleton`),
31
30
  basedir
32
31
  );
33
- await replaceInFile(replaceOptions);
32
+
33
+ if (textReplacements) {
34
+ for (const txtRe of textReplacements) {
35
+ await replaceText(txtRe.regex, txtRe.replaceWith, basedir);
36
+ }
37
+ }
38
+
34
39
  console.log(successColor(`Q ${type} is now bootstrapped in ${basedir}`));
35
40
 
36
- if (type === "tool")
41
+ if (type === "tool" || type === "et-utils-package")
37
42
  console.log(
38
- warningColor("Search for 'TODO:' inside the new tool to get started!")
43
+ warningColor(
44
+ "Search for 'TODO' inside the new tool/package to get started!"
45
+ )
39
46
  );
40
47
  } catch (error) {
41
48
  console.error(
42
49
  errorColor(
43
- `An unexpected error occured. Please check the entered information and try again. ${JSON.stringify(
50
+ `An unexpected error occurred. Please check the entered information and try again. ${JSON.stringify(
44
51
  error
45
52
  )}`
46
53
  )
47
54
  );
48
55
  }
49
56
  };
57
+
58
+ async function replaceText(regex, replaceWith, basedir) {
59
+ const replaceOptions = {
60
+ files: `${basedir}/**`, // Replace in all files
61
+ from: regex,
62
+ to: replaceWith,
63
+ glob: {
64
+ dot: true, // Include file names starting with a dot
65
+ },
66
+ };
67
+
68
+ return await replaceInFile(replaceOptions);
69
+ }
@@ -0,0 +1,142 @@
1
+ const helpers = require("./helpers.js");
2
+ const promptly = require("promptly");
3
+ const Configstore = require("configstore");
4
+ const package = require("./../../../package.json");
5
+ const configStore = new Configstore(package.name, {});
6
+
7
+ async function setAuthenticationConfig(environment, qServer) {
8
+ const result = await authenticate(environment, qServer);
9
+ configStore.set(`${environment}.accessToken`, result.accessToken);
10
+ configStore.set(`${environment}.cookie`, result.cookie);
11
+ }
12
+
13
+ async function setupStore(qConfig, environmentFilter, reset) {
14
+ if (reset) {
15
+ configStore.clear();
16
+ }
17
+ for (const environment of helpers.getEnvironments(
18
+ qConfig,
19
+ environmentFilter
20
+ )) {
21
+ await setupConfigFromEnvVars(environment);
22
+
23
+ if (!configStore.get(`${environment}.qServer`)) {
24
+ const qServer = await promptly.prompt(
25
+ `Enter the Q-Server url for ${environment} environment: `,
26
+ {
27
+ validator: (qServer) => {
28
+ return new URL(qServer).toString();
29
+ },
30
+ retry: true,
31
+ }
32
+ );
33
+ configStore.set(`${environment}.qServer`, qServer);
34
+ }
35
+
36
+ const qServer = configStore.get(`${environment}.qServer`);
37
+ if (!configStore.get(`${environment}.accessToken`)) {
38
+ await setAuthenticationConfig(environment, qServer);
39
+ }
40
+
41
+ const accessToken = configStore.get(`${environment}.accessToken`);
42
+ const cookie = configStore.get(`${environment}.cookie`);
43
+ const isAccessTokenValid = await helpers.checkValidityOfAccessToken(
44
+ environment,
45
+ qServer,
46
+ accessToken,
47
+ cookie
48
+ );
49
+
50
+ // Get a new access token in case its not valid anymore
51
+ if (!isAccessTokenValid) {
52
+ await setAuthenticationConfig(environment, qServer);
53
+ }
54
+ }
55
+
56
+ return configStore;
57
+ }
58
+
59
+ async function setupConfigFromEnvVars(environment) {
60
+ const environmentPrefix = environment.toUpperCase();
61
+
62
+ const qServer = process.env[`Q_${environmentPrefix}_SERVER`];
63
+ if (qServer) {
64
+ configStore.set(`${environment}.qServer`, qServer);
65
+ }
66
+ const accessToken = process.env[`Q_${environmentPrefix}_ACCESSTOKEN`];
67
+ const username = process.env[`Q_${environmentPrefix}_USERNAME`];
68
+ const password = process.env[`Q_${environmentPrefix}_PASSWORD`];
69
+ if (qServer && accessToken) {
70
+ configStore.set(`${environment}.accessToken`, accessToken);
71
+ } else if (qServer && username && password) {
72
+ const cookie = configStore.get(`${environment}.cookie`);
73
+ const result = await helpers.getAccessToken(
74
+ environment,
75
+ qServer,
76
+ username,
77
+ password,
78
+ cookie
79
+ );
80
+
81
+ if (!result) {
82
+ console.error(
83
+ errorColor(
84
+ `A problem occured while authenticating to the ${environment} environment using environment variables. Please check your credentials and try again.`
85
+ )
86
+ );
87
+ process.exit(1);
88
+ }
89
+
90
+ configStore.set(`${environment}.accessToken`, result.accessToken);
91
+ configStore.set(`${environment}.cookie`, result.cookie);
92
+ }
93
+ }
94
+
95
+ async function authenticate(environment, qServer) {
96
+ let username = configStore.get(`${environment}.username`);
97
+ if (!username) {
98
+ username = await promptly.prompt(
99
+ `Enter your username on ${environment} environment: `,
100
+ { validator: (username) => username.trim() }
101
+ );
102
+ configStore.set(`${environment}.username`, username);
103
+ }
104
+
105
+ const password = await promptly.password(
106
+ `Enter your password on ${environment} environment: `,
107
+ {
108
+ validator: async (password) => password.trim(),
109
+ replace: "*",
110
+ }
111
+ );
112
+
113
+ const cookie = configStore.get(`${environment}.cookie`);
114
+ let result = await helpers.getAccessToken(
115
+ environment,
116
+ qServer,
117
+ username,
118
+ password,
119
+ cookie
120
+ );
121
+
122
+ while (!result) {
123
+ console.error(
124
+ errorColor(
125
+ "A problem occured while authenticating. Please check your credentials and try again."
126
+ )
127
+ );
128
+
129
+ result = await authenticate(environment, qServer);
130
+
131
+ if (result.accessToken) {
132
+ break;
133
+ }
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ module.exports = {
140
+ store: configStore,
141
+ setupStore: setupStore,
142
+ };
@@ -0,0 +1,103 @@
1
+ const schemaService = require("./../schemaService.js");
2
+ const configStore = require("./../configStore.js");
3
+ const itemService = require("./../itemService.js");
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const chalk = require("chalk");
7
+ const errorColor = chalk.red;
8
+ const successColor = chalk.green;
9
+
10
+ module.exports = async function (command) {
11
+ try {
12
+ const qConfigPath = path.resolve(command.config);
13
+
14
+ if (fs.existsSync(qConfigPath)) {
15
+ const qConfig = JSON.parse(fs.readFileSync(qConfigPath));
16
+ const validationResult = schemaService.validateConfig(
17
+ qConfig,
18
+ "copyItem"
19
+ );
20
+
21
+ if (validationResult.isValid) {
22
+ const config = await configStore.setupStore(
23
+ qConfig,
24
+ command.environment,
25
+ command.reset
26
+ );
27
+
28
+ for (const item of itemService.getItems(qConfig, command.environment)) {
29
+ for (const environment of item.environments) {
30
+ const qServer = config.get(`${environment.name}.qServer`);
31
+ const accessToken = config.get(`${environment.name}.accessToken`);
32
+ const cookie = config.get(`${environment.name}.cookie`);
33
+
34
+ const existingItem = await itemService.getItem(
35
+ qServer,
36
+ environment,
37
+ accessToken,
38
+ cookie
39
+ );
40
+
41
+ delete existingItem.updatedBy;
42
+ delete existingItem.createdBy;
43
+ delete existingItem.createdDate;
44
+ delete existingItem._id;
45
+ delete existingItem._rev;
46
+
47
+ let newItem = await itemService.createItem(
48
+ existingItem,
49
+ environment,
50
+ config
51
+ );
52
+ // Save for success message
53
+ const newItemId = newItem._id;
54
+ const existingItemId = environment.id;
55
+
56
+ const hasOverwrites =
57
+ item.item &&
58
+ Object.keys(item.item).length > 0 &&
59
+ Object.getPrototypeOf(item.item) === Object.prototype;
60
+
61
+ if (hasOverwrites) {
62
+ environment.id = newItemId;
63
+
64
+ newItem = await itemService.updateItem(
65
+ item.item,
66
+ environment,
67
+ config,
68
+ qConfigPath
69
+ );
70
+ }
71
+
72
+ if (newItem) {
73
+ console.log(
74
+ successColor(
75
+ `Successfully copied item with id ${existingItemId} on ${environment.name} environment. Copied item id ${newItemId}`
76
+ )
77
+ );
78
+ }
79
+ }
80
+ }
81
+ } else {
82
+ console.error(
83
+ errorColor(
84
+ `A problem occured while validating the config file: ${validationResult.errorsText}`
85
+ )
86
+ );
87
+ process.exit(1);
88
+ }
89
+ } else {
90
+ console.error(
91
+ errorColor(
92
+ "Couldn't find config file named q.config.json in the current directory. Create a config file in the current directory or pass the path to the config file with the option -c <path>"
93
+ )
94
+ );
95
+ }
96
+ } catch (error) {
97
+ console.error(
98
+ errorColor(
99
+ `A problem occured while parsing the config file at ${command.config}. Please make sure it is valid JSON.`
100
+ )
101
+ );
102
+ }
103
+ };
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Q Config",
4
+ "description": "Config used by the Q CLI to copy items",
5
+ "type": "object",
6
+ "properties": {
7
+ "items": {
8
+ "description": "Array of Q items",
9
+ "type": "array",
10
+ "minItems": 1,
11
+ "items": {
12
+ "type": "object",
13
+ "properties": {
14
+ "environments": {
15
+ "type": "array",
16
+ "minItems": 1,
17
+ "items": {
18
+ "type": "object",
19
+ "properties": {
20
+ "id": {
21
+ "type": "string",
22
+ "description": "Id of Q item"
23
+ }
24
+ },
25
+ "required": ["id"]
26
+ }
27
+ },
28
+ "item": {
29
+ "type": "object"
30
+ }
31
+ },
32
+ "required": ["environments", "item"]
33
+ }
34
+ }
35
+ },
36
+ "required": ["items"]
37
+ }
@@ -0,0 +1,102 @@
1
+ const fetch = require("node-fetch");
2
+ const chalk = require("chalk");
3
+ const errorColor = chalk.red;
4
+
5
+ function getEnvironments(qConfig, environmentFilter) {
6
+ try {
7
+ const environments = new Set();
8
+ for (const item of qConfig.items) {
9
+ for (const environment of item.environments) {
10
+ if (environmentFilter) {
11
+ if (environmentFilter === environment.name) {
12
+ environments.add(environment.name);
13
+ }
14
+ } else {
15
+ environments.add(environment.name);
16
+ }
17
+ }
18
+ }
19
+
20
+ if (environments.size > 0) {
21
+ return Array.from(environments);
22
+ } else {
23
+ throw new Error(
24
+ `No items with environment ${environmentFilter} found. Please check your configuration and try again.`
25
+ );
26
+ }
27
+ } catch (error) {
28
+ console.error(errorColor(error.message));
29
+ process.exit(1);
30
+ }
31
+ }
32
+
33
+ async function getAccessToken(
34
+ environment,
35
+ qServer,
36
+ username,
37
+ password,
38
+ cookie
39
+ ) {
40
+ try {
41
+ const response = await fetch(`${qServer}authenticate`, {
42
+ method: "POST",
43
+ headers: {
44
+ "user-agent": "Q Command-line Tool",
45
+ origin: qServer,
46
+ Cookie: cookie ? cookie : "",
47
+ },
48
+ body: JSON.stringify({
49
+ username: username,
50
+ password: password,
51
+ }),
52
+ });
53
+
54
+ if (response.ok) {
55
+ const body = await response.json();
56
+ return {
57
+ accessToken: body.access_token,
58
+ cookie: response.headers.get("set-cookie"),
59
+ };
60
+ }
61
+
62
+ return false;
63
+ } catch (error) {
64
+ console.error(
65
+ errorColor(
66
+ `A problem occured while authenticating on ${environment} environment. Please check your connection and try again.`
67
+ )
68
+ );
69
+ process.exit(1);
70
+ }
71
+ }
72
+
73
+ async function checkValidityOfAccessToken(
74
+ environment,
75
+ qServer,
76
+ accessToken,
77
+ cookie
78
+ ) {
79
+ try {
80
+ const response = await fetch(`${qServer}user`, {
81
+ headers: {
82
+ "user-agent": "Q Command-line Tool",
83
+ Authorization: `Bearer ${accessToken}`,
84
+ Cookie: cookie ? cookie : "",
85
+ },
86
+ });
87
+ return response.ok;
88
+ } catch (error) {
89
+ console.error(
90
+ errorColor(
91
+ `A problem occured while checking the validity of your access token on ${environment} environment. Please check your connection and try again.`
92
+ )
93
+ );
94
+ process.exit(1);
95
+ }
96
+ }
97
+
98
+ module.exports = {
99
+ getEnvironments: getEnvironments,
100
+ getAccessToken,
101
+ checkValidityOfAccessToken,
102
+ };