From 53e076b5742a2412cffb9176431b2476af996b75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6ran=20Sander?= Date: Tue, 24 Oct 2023 06:43:53 +0000 Subject: [PATCH] fix(task-import): Correctly handle upstream ext pgm tasks in reload task composite events Fixes #331 --- .pre-commit-config.yaml | 4 + .release-please-manifest.json | 2 +- .vscode/launch.json | 176 +-- CHANGELOG.md | 42 +- src/lib/task/class_allcompositeevents.js | 2 +- src/lib/task/class_alltasks.js | 1237 +++++++++++++--------- src/lib/util/task.js | 6 + testdata/tasks.xlsx | Bin 13929 -> 27912 bytes 8 files changed, 874 insertions(+), 595 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53fc198..62353c1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,6 +29,10 @@ repos: hooks: - id: check-case-conflict - id: check-json + exclude: | + (?x)^( + .vscode/launch.json + )$ - id: check-xml - id: check-yaml - id: detect-private-key diff --git a/.release-please-manifest.json b/.release-please-manifest.json index e431e86..1862707 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1 +1 @@ -{".":"3.13.2","src":"3.12.0"} +{ ".": "3.13.2", "src": "3.12.0" } diff --git a/.vscode/launch.json b/.vscode/launch.json index ac5acdb..baafb65 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -58,44 +58,44 @@ // ------------------------------------ // Import tasks from Excel file // ------------------------------------ - // "args": [ - // "task-import", - // "--auth-type", - // "cert", - // "--host", - // "192.168.100.109", - // "--auth-cert-file", - // "./cert/client.pem", - // "--auth-cert-key-file", - // "./cert/client_key.pem", - // "--auth-user-dir", - // "LAB", - // "--auth-user-id", - // "goran", + "args": [ + "task-import", + "--auth-type", + "cert", + "--host", + "192.168.100.109", + "--auth-cert-file", + "./cert/client.pem", + "--auth-cert-key-file", + "./cert/client_key.pem", + "--auth-user-dir", + "LAB", + "--auth-user-id", + "goran", - // "--file-type", - // "excel", + "--file-type", + "excel", - // "--file-name", - // "testdata/tasks.xlsx", - // "--sheet-name", - // "Ctrl-Q task import 1", + "--file-name", + "testdata/tasks.xlsx", + "--sheet-name", + "4" - // // "--import-app", - // // "--import-app-sheet-name", - // // "App import", + // "--import-app", + // "--import-app-sheet-name", + // "App import", - // // "--qvf-overwrite", - // // "no", + // "--qvf-overwrite", + // "no", - // // "--limit-import-count", - // // "2", + // "--limit-import-count", + // "2", - // // "--sleep-app-upload", - // // "500", + // "--sleep-app-upload", + // "500", - // // "--dry-run" - // ] + // "--dry-run" + ] // ------------------------------------ // Import tasks from CSV file @@ -106,10 +106,10 @@ // "cert", // "--host", // "192.168.100.109", - // "--auth-cert-file", - // "./cert/client.pem", - // "--auth-cert-key-file", - // "./cert/client_key.pem", + // // "--auth-cert-file", + // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "./cert/client_key.pem", // "--auth-user-dir", // "LAB", // "--auth-user-id", @@ -120,7 +120,8 @@ // "--file-name", // // "tasks2source.csv", - // "task-chain.csv", + // // "task-chain.csv", + // "reload-tasks.csv", // // "--qvf-overwrite", // // "no", @@ -222,46 +223,46 @@ // ------------------------------------ // Get task tree // ------------------------------------ - "args": [ - "task-get", - // "--auth-type", - // "cert", - "--host", - "192.168.100.109", - // "--auth-cert-file", - // "./cert/client.pem", - // "--auth-cert-key-file", - // "./cert/client_key.pem", - "--auth-user-dir", - "LAB", - "--auth-user-id", - "goran", - // "--task-id", - // "82bc3e66-c899-4e44-b52f-552145da5ee0", - // "--task-tag", - // "Test data", - // "--output-format", - // "table", - // "tree", - // "--tree-icons", - // "--tree-details", - // "taskid", - // "appname", - // "--task-type", - // "reload", - // "ext-program", - - // "--output-dest", - // "screen", - // "file", - // "--output-file-name", - // "tasks.json", - // "--output-file-format", - // "json", + // "args": [ + // "task-get", + // // "--auth-type", + // // "cert", + // "--host", + // "192.168.100.109", + // // "--auth-cert-file", + // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "./cert/client_key.pem", + // "--auth-user-dir", + // "LAB", + // "--auth-user-id", + // "goran", + // // "--task-id", + // // "82bc3e66-c899-4e44-b52f-552145da5ee0", + // // "--task-tag", + // // "Test data", + // // "--output-format", + // // "table", + // // "tree", + // // "--tree-icons", + // // "--tree-details", + // // "taskid", + // // "appname", + // // "--task-type", + // // "reload", + // // "ext-program", + + // // "--output-dest", + // // "screen", + // // "file", + // // "--output-file-name", + // // "tasks.json", + // // "--output-file-format", + // // "json", - // "--text-color", - // "no", - ] + // // "--text-color", + // // "no", + // ] // ------------------------------------ // Get reload tasks as table @@ -326,10 +327,10 @@ // "cert", // "--host", // "192.168.100.109", - // "--auth-cert-file", - // "./cert/client.pem", - // "--auth-cert-key-file", - // "./cert/client_key.pem", + // // "--auth-cert-file", + // // "./cert/client.pem", + // // "--auth-cert-key-file", + // // "./cert/client_key.pem", // "--auth-user-dir", // "LAB", // "--auth-user-id", @@ -339,8 +340,8 @@ // // "82bc3e66-c899-4e44-b52f-552145da5ee1", // // "5748afa9-3abe-43ab-bb1f-127c48ced075", // // "5520e710-91ad-41d2-aeb6-434cafbf366b", - // "--task-tag", - // "Ctrl-Q demo", + // // "--task-tag", + // // "Ctrl-Q demo", // // "Butler 5.0 demo", // "--output-format", @@ -349,22 +350,23 @@ // "--output-dest", // "file", // "--output-file-name", - // "tasks.xlsx", - // // "tasks.csv", + // // "tasks.xlsx", + // "tasks.csv", // // "tasks2.json", // // "tasks2.xlsx", // "--output-file-format", - // "excel", + // // "excel", // // "json", - // // "csv", + // "csv", // "--output-file-name", - // "reload-tasks.xlsx", + // // "reload-tasks.xlsx", + // "reload-tasks.csv", // // "--text-color", // // "no", - // "--output-file-overwrite", + // // "--output-file-overwrite", // // "--table-details", // // "common", diff --git a/CHANGELOG.md b/CHANGELOG.md index ea987db..0f2cd41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,53 +2,47 @@ ## [3.13.2](https://github.com/ptarmiganlabs/ctrl-q/compare/v3.13.1...v3.13.2) (2023-10-06) - ### Bug Fixes -* Handle relative config file paths ([72285e4](https://github.com/ptarmiganlabs/ctrl-q/commit/72285e4d6130f9ea1bdc96b25ada7491b265788f)) -* **master-item-dim-get:** Get correct colors for drill-down dimensions ([f0fae78](https://github.com/ptarmiganlabs/ctrl-q/commit/f0fae780c4dff16ac993dfe1f41cb49edb4847a5)), closes [#314](https://github.com/ptarmiganlabs/ctrl-q/issues/314) -* **task-get:** Fix task tree bug when task is triggered by ext program task ([98584b7](https://github.com/ptarmiganlabs/ctrl-q/commit/98584b7e0bad97c73e5aaa1015625e2eae5f1aee)) -* **task-get:** Include all tasks in task trees ([3fbc4d3](https://github.com/ptarmiganlabs/ctrl-q/commit/3fbc4d307179476e89068e5db2030b990e6603da)), closes [#308](https://github.com/ptarmiganlabs/ctrl-q/issues/308) -* **task-get:** Make "reload" and "ext-program" default for --task-type option ([9b13cce](https://github.com/ptarmiganlabs/ctrl-q/commit/9b13cce142ce07e8e56479f3dfa1b693f55e20da)) -* **task-get:** Warn if --task-type is used in task tree view ([1fe4764](https://github.com/ptarmiganlabs/ctrl-q/commit/1fe47642bc8c1e7ac516110febb925e354f907b2)), closes [#319](https://github.com/ptarmiganlabs/ctrl-q/issues/319) - +- Handle relative config file paths ([72285e4](https://github.com/ptarmiganlabs/ctrl-q/commit/72285e4d6130f9ea1bdc96b25ada7491b265788f)) +- **master-item-dim-get:** Get correct colors for drill-down dimensions ([f0fae78](https://github.com/ptarmiganlabs/ctrl-q/commit/f0fae780c4dff16ac993dfe1f41cb49edb4847a5)), closes [#314](https://github.com/ptarmiganlabs/ctrl-q/issues/314) +- **task-get:** Fix task tree bug when task is triggered by ext program task ([98584b7](https://github.com/ptarmiganlabs/ctrl-q/commit/98584b7e0bad97c73e5aaa1015625e2eae5f1aee)) +- **task-get:** Include all tasks in task trees ([3fbc4d3](https://github.com/ptarmiganlabs/ctrl-q/commit/3fbc4d307179476e89068e5db2030b990e6603da)), closes [#308](https://github.com/ptarmiganlabs/ctrl-q/issues/308) +- **task-get:** Make "reload" and "ext-program" default for --task-type option ([9b13cce](https://github.com/ptarmiganlabs/ctrl-q/commit/9b13cce142ce07e8e56479f3dfa1b693f55e20da)) +- **task-get:** Warn if --task-type is used in task tree view ([1fe4764](https://github.com/ptarmiganlabs/ctrl-q/commit/1fe47642bc8c1e7ac516110febb925e354f907b2)), closes [#319](https://github.com/ptarmiganlabs/ctrl-q/issues/319) ### Miscellaneous -* **deps:** Update dependencies to stay safe and secure ([ce0f7b4](https://github.com/ptarmiganlabs/ctrl-q/commit/ce0f7b42fe53b9bbeb7389345d537ffb0e2ef3a2)) +- **deps:** Update dependencies to stay safe and secure ([ce0f7b4](https://github.com/ptarmiganlabs/ctrl-q/commit/ce0f7b42fe53b9bbeb7389345d537ffb0e2ef3a2)) ## [3.13.1](https://github.com/ptarmiganlabs/ctrl-q/compare/v3.13.1...v3.13.1) (2023-09-27) - ### Miscellaneous -* Fix broken build flow post refactoring ([1c91e56](https://github.com/ptarmiganlabs/ctrl-q/commit/1c91e5651c0a408cf2527abae65763a8aa6bf09f)) -* Fix broken Linux build ([7847a6d](https://github.com/ptarmiganlabs/ctrl-q/commit/7847a6def5dafa180361fce48d493d8be68058b7)) -* **main:** release 3.13.1 ([3a42857](https://github.com/ptarmiganlabs/ctrl-q/commit/3a428572fbb2f364ab45fdcb8b26ae33aec84c80)) -* Recover from build refactoring ([bd865b6](https://github.com/ptarmiganlabs/ctrl-q/commit/bd865b67c14787dbc8e1fed562f984d8a6a56e43)) +- Fix broken build flow post refactoring ([1c91e56](https://github.com/ptarmiganlabs/ctrl-q/commit/1c91e5651c0a408cf2527abae65763a8aa6bf09f)) +- Fix broken Linux build ([7847a6d](https://github.com/ptarmiganlabs/ctrl-q/commit/7847a6def5dafa180361fce48d493d8be68058b7)) +- **main:** release 3.13.1 ([3a42857](https://github.com/ptarmiganlabs/ctrl-q/commit/3a428572fbb2f364ab45fdcb8b26ae33aec84c80)) +- Recover from build refactoring ([bd865b6](https://github.com/ptarmiganlabs/ctrl-q/commit/bd865b67c14787dbc8e1fed562f984d8a6a56e43)) ## [3.13.1](https://github.com/ptarmiganlabs/ctrl-q/compare/v3.13.0...v3.13.1) (2023-09-27) - ### Miscellaneous -* Fix broken build flow post refactoring ([1c91e56](https://github.com/ptarmiganlabs/ctrl-q/commit/1c91e5651c0a408cf2527abae65763a8aa6bf09f)) +- Fix broken build flow post refactoring ([1c91e56](https://github.com/ptarmiganlabs/ctrl-q/commit/1c91e5651c0a408cf2527abae65763a8aa6bf09f)) ## [3.13.0](https://github.com/ptarmiganlabs/ctrl-q/compare/v3.12.0...v3.13.0) (2023-09-26) - ### Miscellaneous -* Add insiders build step in CI pipeline ([97e933f](https://github.com/ptarmiganlabs/ctrl-q/commit/97e933fb89aed09fbac285a2ba870b3010a02cf2)), closes [#303](https://github.com/ptarmiganlabs/ctrl-q/issues/303) -* **deps:** update actions/checkout action to v4 ([460fd6b](https://github.com/ptarmiganlabs/ctrl-q/commit/460fd6b00e205835ad26c584c6a786c68dde483f)) -* **deps:** update crazy-max/ghaction-virustotal action to v4 ([49d4c23](https://github.com/ptarmiganlabs/ctrl-q/commit/49d4c23e8e308baf51920dc674b50050c2f38251)) -* Fix version number after build refactoring ([5f4347c](https://github.com/ptarmiganlabs/ctrl-q/commit/5f4347c640c58f9a31d8399350af7cededf8b7da)) - +- Add insiders build step in CI pipeline ([97e933f](https://github.com/ptarmiganlabs/ctrl-q/commit/97e933fb89aed09fbac285a2ba870b3010a02cf2)), closes [#303](https://github.com/ptarmiganlabs/ctrl-q/issues/303) +- **deps:** update actions/checkout action to v4 ([460fd6b](https://github.com/ptarmiganlabs/ctrl-q/commit/460fd6b00e205835ad26c584c6a786c68dde483f)) +- **deps:** update crazy-max/ghaction-virustotal action to v4 ([49d4c23](https://github.com/ptarmiganlabs/ctrl-q/commit/49d4c23e8e308baf51920dc674b50050c2f38251)) +- Fix version number after build refactoring ([5f4347c](https://github.com/ptarmiganlabs/ctrl-q/commit/5f4347c640c58f9a31d8399350af7cededf8b7da)) ### Documentation -* **app-import:** Add description of publishing apps after import from QVF files ([072a14f](https://github.com/ptarmiganlabs/ctrl-q/commit/072a14fc36438cfc9d0a85e99bc251cf7fe92dd8)), closes [#302](https://github.com/ptarmiganlabs/ctrl-q/issues/302) -* **app-import:** Add link to app import Excel file columns ([28cd76a](https://github.com/ptarmiganlabs/ctrl-q/commit/28cd76ae51b5eb4992dfcff3f670655b456e15a5)), closes [#301](https://github.com/ptarmiganlabs/ctrl-q/issues/301) +- **app-import:** Add description of publishing apps after import from QVF files ([072a14f](https://github.com/ptarmiganlabs/ctrl-q/commit/072a14fc36438cfc9d0a85e99bc251cf7fe92dd8)), closes [#302](https://github.com/ptarmiganlabs/ctrl-q/issues/302) +- **app-import:** Add link to app import Excel file columns ([28cd76a](https://github.com/ptarmiganlabs/ctrl-q/commit/28cd76ae51b5eb4992dfcff3f670655b456e15a5)), closes [#301](https://github.com/ptarmiganlabs/ctrl-q/issues/301) ## [3.4.1](https://github.com/ptarmiganlabs/ctrl-q/compare/ctrl-q-v3.4.0...ctrl-q-v3.4.1) (2023-02-08) diff --git a/src/lib/task/class_allcompositeevents.js b/src/lib/task/class_allcompositeevents.js index 1ad40dd..1493a3e 100644 --- a/src/lib/task/class_allcompositeevents.js +++ b/src/lib/task/class_allcompositeevents.js @@ -28,7 +28,7 @@ class QlikSenseCompositeEvents { this.compositeEventList = []; } - // Add new schema event + // Add new composite event addCompositeEvent(compositeEvent) { const newCompositeEvent = new QlikSenseCompositeEvent(compositeEvent); this.compositeEventList.push(newCompositeEvent); diff --git a/src/lib/task/class_alltasks.js b/src/lib/task/class_alltasks.js index 2d1f563..1f1f2db 100644 --- a/src/lib/task/class_alltasks.js +++ b/src/lib/task/class_alltasks.js @@ -33,6 +33,7 @@ class QlikSenseTasks { this.importedApps = importedApps; this.taskList = []; + this.compositeEventUpstreamTask = []; // Map that will map fake task IDs (used in source file) with real task IDs after tasks have been created in Sense this.taskIdMap = new Map(); @@ -63,6 +64,7 @@ class QlikSenseTasks { clear() { this.taskList = []; + this.compositeEventUpstreamTask = []; } // Add new task @@ -72,11 +74,580 @@ class QlikSenseTasks { this.taskList.push(newTask); } + // Function to parse the rows associated with a specific reload task in the source file + // Properties in the param object: + // - taskRows: Array of rows associated with the task. All rows associated with the task are passed to this function + // - taskFileColumnHeaders: Object containing info about which column contains what data + // - taskCounter: Counter for the current task + // - tagsExisting: Array of existing tags in QSEoW + // - cpExisting: Array of existing custom properties in QSEoW + // - fakeTaskId: Fake task ID used to associate task with schema/composite events + // - nodesWithEvents: Set of nodes that have associated events + // + // Returns: + // Object with two properties: + // - currentTask: Object containing task data + // - taskCreationOption: Task creation option. Possible values: "if-exists-add-another", "if-exists-update-existing" + async parseReloadTask(param) { + let currentTask = null; + let taskCreationOption; + + // Create task object using same structure as results from QRS API + + // Determine if the task is associated with an app that existed before Ctrl-Q was started, or + // an app that's been imported as part of this Ctrl-Q execution. + // Possible values for the app ID column: + // - newapp- (app has been imported as part of this Ctrl-Q execution) + // - A real, existing app ID. I.e. the app existed before Ctrl-Q was started. + const appIdRaw = param.taskRows[0][param.taskFileColumnHeaders.appId.pos].trim(); + let appId; + + if (appIdRaw.substring(0, 7).toLowerCase() === 'newapp-') { + // App ID starts with "newapp-". This means the app been imported as part of this Ctrl-Q session + // No guarantee that it is the case though. Maybe no apps were imported, or maybe the app specified for this very task was not imported + + // Have ANY apps been imported? + if (!this.importedApps) { + logger.error( + `(${param.taskCounter}) PARSE TASKS FROM FILE: No apps have been imported, but app "${param.taskRows[0][ + param.taskFileColumnHeaders.appId.pos + ].trim()}" has been specified in the task definition file. Exiting.` + ); + process.exit(1); + } + + // Has this specific app been imported? + if (!this.importedApps.appIdMap.has(appIdRaw.toLowerCase())) { + logger.error( + `(${param.taskCounter}) PARSE TASKS FROM FILE: App "${param.taskRows[0][ + param.taskFileColumnHeaders.appId.pos + ].trim()}" has not been imported, but has been specified in the task definition file. Exiting.` + ); + process.exit(1); + } + + appId = this.importedApps.appIdMap.get(appIdRaw.toLowerCase()); + + // Ensure the app exists + // Reasons for the app not existing could be: + // - The app was imported but has since been deleted or replaced. This could happen if the app-import step has several + // apps that are published-replaced or deleted-published to the same stream. In that case only the last published app will be present + + if (appId === undefined) { + logger.error( + `(${param.taskCounter}) PARSE TASKS FROM FILE: Cannot figure out which Sense app "${param.taskRows[0][ + param.taskFileColumnHeaders.appId.pos + ].trim()}" belongs to. App with ID "${appIdRaw}" not found.` + ); + + logger.error( + `(${param.taskCounter}) PARSE TASKS FROM FILE: This could be because the app was imported but has since been deleted or replaced, for example during app publishing. Don't know how to proceed, exiting.` + ); + + process.exit(1); + } + + // eslint-disable-next-line no-await-in-loop + const app = await getAppById(appId); + + if (!app) { + logger.error( + `(${param.taskCounter}) PARSE TASKS FROM FILE: App with ID "${appId}" not found. This could be because the app was imported but has since been deleted or replaced, for example during app publishing. Don't know how to proceed, exiting.` + ); + process.exit(1); + } + } else if (validate(appIdRaw)) { + // App ID is a proper UUID. We don't know if the app actually exists though. + + // eslint-disable-next-line no-await-in-loop + const app = await getAppById(appIdRaw); + + if (!app) { + logger.error( + `(${param.taskCounter}) PARSE TASKS FROM FILE: App with ID "${appIdRaw}" not found. This could be because the app was imported but has since been deleted or replaced, for example during app publishing. Don't know how to proceed, exiting.` + ); + process.exit(1); + } + + appId = appIdRaw; + } else { + logger.error(`(${param.taskCounter}) PARSE TASKS FROM FILE: Incorrect app ID "${appIdRaw}". Exiting.`); + process.exit(1); + } + + if (param.taskFileColumnHeaders.importOptions.pos === 999) { + // No task creation options column in the file + // Use the default task creation option + taskCreationOption = 'if-exists-update-existing'; + } else { + // Task creation options column exists in the file + // Use the value from the file + taskCreationOption = param.taskRows[0][param.taskFileColumnHeaders.importOptions.pos]; + } + + // Ensure task creation option is valid. Allow empty option + if ( + taskCreationOption && + taskCreationOption.trim() !== '' && + !['if-exists-add-another', 'if-exists-update-existing'].includes(taskCreationOption) + ) { + logger.error(`(${param.taskCounter}) PARSE TASKS FROM FILE: Incorrect task creation option "${taskCreationOption}". Exiting.`); + process.exit(1); + } + + currentTask = { + id: param.taskRows[0][param.taskFileColumnHeaders.taskId.pos], + name: param.taskRows[0][param.taskFileColumnHeaders.taskName.pos], + taskType: mapTaskType.get(param.taskRows[0][param.taskFileColumnHeaders.taskType.pos]), + enabled: param.taskRows[0][param.taskFileColumnHeaders.taskEnabled.pos], + taskSessionTimeout: param.taskRows[0][param.taskFileColumnHeaders.taskSessionTimeout.pos], + maxRetries: param.taskRows[0][param.taskFileColumnHeaders.taskMaxRetries.pos], + isManuallyTriggered: param.taskRows[0][param.taskFileColumnHeaders.isManuallyTriggered.pos], + isPartialReload: param.taskRows[0][param.taskFileColumnHeaders.isPartialReload.pos], + app: { + id: appId, + // name: taskData[0][taskFileColumnHeaders.appName.pos], + }, + tags: [], + customProperties: [], + schemaPath: 'ReloadTask', + schemaEvents: [], + compositeEvents: [], + prelCompositeEvents: [], + }; + + // Add tags to task object + if (param.taskRows[0][param.taskFileColumnHeaders.taskTags.pos]) { + const tmpTags = param.taskRows[0][param.taskFileColumnHeaders.taskTags.pos] + .split('/') + .filter((item) => item.trim().length !== 0) + .map((item) => item.trim()); + + // eslint-disable-next-line no-restricted-syntax + for (const item of tmpTags) { + // eslint-disable-next-line no-await-in-loop + const tagId = await getTagIdByName(item, param.tagsExisting); + currentTask.tags.push({ + id: tagId, + name: item, + }); + } + } + + // Add custom properties to task object + if (param.taskRows[0][param.taskFileColumnHeaders.taskCustomProperties.pos]) { + const tmpCustomProperties = param.taskRows[0][param.taskFileColumnHeaders.taskCustomProperties.pos] + .split('/') + .filter((item) => item.trim().length !== 0) + .map((cp) => cp.trim()); + + // eslint-disable-next-line no-restricted-syntax + for (const item of tmpCustomProperties) { + const tmpCustomProperty = item + .split('=') + .filter((item2) => item2.trim().length !== 0) + .map((cp) => cp.trim()); + + if (tmpCustomProperty?.length === 2) { + // eslint-disable-next-line no-await-in-loop + const customPropertyId = await getCustomPropertyIdByName('ReloadTask', tmpCustomProperty[0], param.cpExisting); + + currentTask.customProperties.push({ + definition: { + id: customPropertyId, + name: tmpCustomProperty[0].trim(), + }, + value: tmpCustomProperty[1].trim(), + }); + } + } + } + + // Get schema events for this task, storing the info using the same structure as returned from QRS API + currentTask.schemaEvents = this.parseSchemaEvents({ + taskRows: param.taskRows, + taskFileColumnHeaders: param.taskFileColumnHeaders, + taskCounter: param.taskCounter, + currentTask, + fakeTaskId: param.fakeTaskId, + nodesWithEvents: param.nodesWithEvents, + }); + + // Get composite events for this task + currentTask.prelCompositeEvents = await this.parseCompositeEvents({ + taskType: 'reload', + taskRows: param.taskRows, + taskFileColumnHeaders: param.taskFileColumnHeaders, + taskCounter: param.taskCounter, + currentTask, + fakeTaskId: param.fakeTaskId, + nodesWithEvents: param.nodesWithEvents, + }); + + return { currentTask, taskCreationOption }; + } + + // Function to get schema events for a specific task + // Parameters: + // - taskRows: Array of rows associated with the task. All rows associated with the task are passed to this function + // - taskFileColumnHeaders: Object containing info about which column contains what data + // - taskCounter: Counter for the current task + // - currentTask: Object containing task data + // - fakeTaskId: Fake task ID used to associate task with schema/composite events + // - nodesWithEvents: Set of nodes that have associated events + parseSchemaEvents(param) { + // Get schema events for this task, storing the info using the same structure as returned from QRS API + const prelAchemaEvents = []; + + const schemaEventRows = param.taskRows.filter( + (item) => + item[param.taskFileColumnHeaders.eventType.pos] && + item[param.taskFileColumnHeaders.eventType.pos].trim().toLowerCase() === 'schema' + ); + if (!schemaEventRows || schemaEventRows?.length === 0) { + logger.verbose(`(${param.taskCounter}) PARSE TASKS FROM FILE: No schema events for task "${param.currentTask.name}"`); + } else { + logger.verbose( + `(${param.taskCounter}) PARSE TASKS FROM FILE: ${schemaEventRows.length} schema event(s) for task "${param.currentTask.name}"` + ); + + // Add schema edges and start/trigger nodes + // eslint-disable-next-line no-restricted-syntax + for (const schemaEventRow of schemaEventRows) { + // Create object using same format that Sense uses for schema events + const schemaEvent = { + enabled: schemaEventRow[param.taskFileColumnHeaders.eventEnabled.pos], + eventType: mapEventType.get(schemaEventRow[param.taskFileColumnHeaders.eventType.pos]), + name: schemaEventRow[param.taskFileColumnHeaders.eventName.pos], + daylightSavingTime: mapDaylightSavingTime.get(schemaEventRow[param.taskFileColumnHeaders.daylightSavingsTime.pos]), + timeZone: schemaEventRow[param.taskFileColumnHeaders.schemaTimeZone.pos], + startDate: schemaEventRow[param.taskFileColumnHeaders.schemaStart.pos], + expirationDate: schemaEventRow[param.taskFileColumnHeaders.scheamExpiration.pos], + schemaFilterDescription: [schemaEventRow[param.taskFileColumnHeaders.schemaFilterDescription.pos]], + incrementDescription: schemaEventRow[param.taskFileColumnHeaders.schemaIncrementDescription.pos], + incrementOption: mapIncrementOption.get(schemaEventRow[param.taskFileColumnHeaders.schemaIncrementOption.pos]), + reloadTask: { + id: param.fakeTaskId, + }, + schemaPath: 'SchemaEvent', + }; + + this.qlikSenseSchemaEvents.addSchemaEvent(schemaEvent); + + // Add schema event to network representation of tasks + // Create an id for this node + const nodeId = `schema-event-${uuidv4()}`; + + // Add schema trigger nodes. These represent the implicit starting nodes that a schema event really are + this.taskNetwork.nodes.push({ + id: nodeId, + metaNodeType: 'schedule', // Meta nodes are not Sense tasks, but rather nodes representing task-like properties (e.g. a starting point for a reload chain) + metaNode: true, + isTopLevelNode: true, + label: schemaEvent.name, + enabled: schemaEvent.enabled, + + completeSchemaEvent: schemaEvent, + }); + + this.taskNetwork.edges.push({ + from: nodeId, + to: schemaEvent.reloadTask.id, + }); + + // Keep a note that this node has associated events + param.nodesWithEvents.add(schemaEvent.reloadTask.id); + + // Add this schema event to the current task + // Remove reference to task ID first though + delete schemaEvent.reloadTask.id; + delete schemaEvent.reloadTask; + + prelAchemaEvents.push(schemaEvent); + } + } + + return prelAchemaEvents; + } + + // Function to get composite events for a specific task + // Function is async as it may need to check if the upstream task pointed to by the composite event exists in Sense + + // Parameters (all properties in the param object): + // - taskType: Type of task. Possible values: "reload", "external program" + // - taskRows: Array of rows associated with the task. All rows associated with the task are passed to this function + // - taskFileColumnHeaders: Object containing info about which column contains what data + // - taskCounter: Counter for the current task + // - currentTask: Object containing task data + // - fakeTaskId: Fake task ID used to associate task with schema/composite events + // - nodesWithEvents: Set of nodes that have associated events + async parseCompositeEvents(param) { + // Get all composite events for this task + // + // Composite events + // - Consists of one main row defining the event, followed by one or more rows defining the composite event rules. + // - The main row is followed by one or more rows defining the composite event rules + // - All rows associated with a composite event share the same value in the "Event counter" column + // - Each composite event rule row has a unique value in the "Rule counter" column + const prelCompositeEvents = []; + + // Get all "main rows" of all composite events in this task + const compositeEventRows = param.taskRows.filter( + (item) => + item[param.taskFileColumnHeaders.eventType.pos] && + item[param.taskFileColumnHeaders.eventType.pos].trim().toLowerCase() === 'composite' + ); + if (!compositeEventRows || compositeEventRows?.length === 0) { + logger.verbose(`(${param.taskCounter}) PARSE TASKS FROM FILE: No composite events for task "${param.currentTask.name}"`); + } else { + logger.verbose( + `(${param.taskCounter}) PARSE TASKS FROM FILE: ${compositeEventRows.length} composite event(s) for task "${param.currentTask.name}"` + ); + + // Loop over all composite events, adding them and their event rules + // eslint-disable-next-line no-restricted-syntax + for (const compositeEventRow of compositeEventRows) { + // Get value in "Event counter" column for this composite event, then get array of all associated event rules + const compositeEventCounter = compositeEventRow[param.taskFileColumnHeaders.eventCounter.pos]; + const compositeEventRules = param.taskRows.filter( + (item) => + item[param.taskFileColumnHeaders.eventCounter.pos] === compositeEventCounter && + item[param.taskFileColumnHeaders.ruleCounter.pos] > 0 + ); + + // Create an object using same format that the Sense API uses for composite events + // Add task type specific properties in later step + const compositeEvent = { + timeConstraint: { + days: compositeEventRow[param.taskFileColumnHeaders.timeConstraintDays.pos], + hours: compositeEventRow[param.taskFileColumnHeaders.timeConstraintHours.pos], + minutes: compositeEventRow[param.taskFileColumnHeaders.timeConstraintMinutes.pos], + seconds: compositeEventRow[param.taskFileColumnHeaders.timeConstraintSeconds.pos], + }, + compositeRules: [], + name: compositeEventRow[param.taskFileColumnHeaders.eventName.pos], + enabled: compositeEventRow[param.taskFileColumnHeaders.eventEnabled.pos], + eventType: mapEventType.get(compositeEventRow[param.taskFileColumnHeaders.eventType.pos]), + schemaPath: 'CompositeEvent', + }; + + if (param.taskType === 'reload') { + compositeEvent.reloadTask = { + id: param.fakeTaskId, + }; + } else if (param.taskType === 'external program') { + compositeEvent.externalProgramTask = { + id: param.fakeTaskId, + }; + } else { + logger.error(`(${param.taskCounter}) PARSE TASKS FROM FILE: Incorrect task type "${param.taskType}". Exiting.`); + process.exit(1); + } + + // Add rules + // eslint-disable-next-line no-restricted-syntax + for (const rule of compositeEventRules) { + // Does the upstream task pointed to by the composite rule exist? + // If it *does* exist it means it's a real, existing task in QSEoW that should be used. + // If it is not a valid guid or does not exist, it's (best case) a referefence to some other task in the task definitions file. + // If the task pointed to by the rule doesn't exist in Sense and doesn't point to some other task in the file, an error should be shown. + if (validate(rule[param.taskFileColumnHeaders.ruleTaskId.pos])) { + // The rule points to an valid UUID. It should exist, otherwise it's an error + + // eslint-disable-next-line no-await-in-loop + const taskExists = await taskExistById(rule[param.taskFileColumnHeaders.ruleTaskId.pos], this.options); + + if (taskExists) { + // Add task ID to mapping table that will be used later when building the composite event data structures + // In this case we're adding a task ID that maps to itself, indicating that it's a task that already exists in QSEoW. + this.taskIdMap.set( + rule[param.taskFileColumnHeaders.ruleTaskId.pos], + rule[param.taskFileColumnHeaders.ruleTaskId.pos] + ); + } else { + // The task pointed to by the composite event rule does not exist + logger.error( + `(${param.taskCounter}) PARSE TASKS FROM FILE: Task "${ + rule[param.taskFileColumnHeaders.ruleTaskId.pos] + }" does not exist. Exiting.` + ); + process.exit(1); + } + } else { + logger.verbose( + `(${param.taskCounter}) ANALYZE COMPOSITE EVENT: "${ + rule[param.taskFileColumnHeaders.ruleTaskId.pos] + }" is not a valid UUID` + ); + } + + // Save composite event rule. + // Also add the upstream task id to the correct property in the rule object, depending on task type + + let upstreamTask; + let upstreamTaskExistence; + // First get upstream task type + // Two options: + // 1. The rule's task ID is a valid GUID. Get the associated task's metadata from Sense, if the task exists + // 2. The rule's task ID is not a valid GUID. It's a reference to a task that is created during this execution of Ctrl-Q. + if (!validate(rule[param.taskFileColumnHeaders.ruleTaskId.pos])) { + // The rule's task ID is not a valid GUID. It's a reference to a task that is created during this execution of Ctrl-Q. + // Add the task ID to the mapping table, indicating that it's a task that is created during this execution of Ctrl-Q. + + // // Check if the task ID already exists in the mapping table + // if (this.taskIdMap.has(rule[param.taskFileColumnHeaders.ruleTaskId.pos])) { + // // The task ID already exists in the mapping table. This means that the task has already been created during this execution of Ctrl-Q. + // // This is not allowed. The task ID must be unique. + // logger.error( + // `(${param.taskCounter}) PARSE TASKS FROM FILE: Task ID "${ + // rule[param.taskFileColumnHeaders.ruleTaskId.pos] + // }" already exists in mapping table. This is not allowed. Exiting.` + // ); + // process.exit(1); + // } + + // // Add task ID to mapping table + // this.taskIdMap.set(rule[param.taskFileColumnHeaders.ruleTaskId.pos], `fake-task-${uuidv4()}`); + + upstreamTaskExistence = 'exists-in-source-file'; + } else { + // eslint-disable-next-line no-await-in-loop + upstreamTask = await getTaskById(rule[param.taskFileColumnHeaders.ruleTaskId.pos]); + + // Save upstream task in shared task list + this.compositeEventUpstreamTask.push(upstreamTask); + + upstreamTaskExistence = 'exists-in-sense'; + } + + if (upstreamTaskExistence === 'exists-in-source-file') { + // Upstream task is a task that is created during this execution of Ctrl-Q + // We don't yet know what task ID it will get in Sense, so we'll have to find this when creating composite events later + compositeEvent.compositeRules.push({ + upstreamTaskExistence, + ruleState: mapRuleState.get(rule[param.taskFileColumnHeaders.ruleState.pos]), + task: { + id: rule[param.taskFileColumnHeaders.ruleTaskId.pos], + }, + }); + } else if (mapTaskType.get(upstreamTask.taskType).toLowerCase() === 'reload') { + // Upstream task is a reload task + compositeEvent.compositeRules.push({ + upstreamTaskExistence, + ruleState: mapRuleState.get(rule[param.taskFileColumnHeaders.ruleState.pos]), + task: { + id: rule[param.taskFileColumnHeaders.ruleTaskId.pos], + }, + reloadTask: { + id: rule[param.taskFileColumnHeaders.ruleTaskId.pos], + }, + }); + } else if (mapTaskType.get(upstreamTask.taskType).toLowerCase() === 'externalprogram') { + // Upstream task is an external program task + compositeEvent.compositeRules.push({ + upstreamTaskExistence, + ruleState: mapRuleState.get(rule[param.taskFileColumnHeaders.ruleState.pos]), + task: { + id: rule[param.taskFileColumnHeaders.ruleTaskId.pos], + }, + externalProgramTask: { + id: rule[param.taskFileColumnHeaders.ruleTaskId.pos], + }, + }); + } + } + + this.qlikSenseCompositeEvents.addCompositeEvent(compositeEvent); + + // Add composite event to network representation of tasks + if (compositeEvent.compositeRules.length === 1) { + // This trigger has exactly ONE upstream task + // For triggers with >1 upstream task we want an extra meta node to represent the waiting of all upstream tasks to finish + + if (param.taskType === 'reload') { + // Add edge from upstream task to current task, taking into account task type + this.taskNetwork.edges.push({ + from: compositeEvent.compositeRules[0].task.id, + to: compositeEvent.reloadTask.id, + + completeCompositeEvent: compositeEvent, + rule: compositeEvent.compositeRules, + // color: compositeEvent.enabled ? '#9FC2F7' : '#949298', + // color: edgeColor, + // dashes: compositeEvent.enabled ? false : [15, 15], + // title: compositeEvent.name + '
' + 'asdasd', + // label: compositeEvent.name, + }); + + // Keep a note that this node has associated events + param.nodesWithEvents.add(compositeEvent.compositeRules[0].task.id); + param.nodesWithEvents.add(compositeEvent.reloadTask.id); + } else if (param.taskType === 'external program') { + // Add edge from upstream task to current task, taking into account task type + this.taskNetwork.edges.push({ + from: compositeEvent.compositeRules[0].task.id, + to: compositeEvent.externalProgramTask.id, + + completeCompositeEvent: compositeEvent, + rule: compositeEvent.compositeRules, + }); + // Keep a note that this node has associated events + param.nodesWithEvents.add(compositeEvent.compositeRules[0].task.id); + param.nodesWithEvents.add(compositeEvent.externalProgramTask.id); + } + } else { + // There are more than one task involved in triggering a downstream task. + // Insert a proxy node that represents a Qlik Sense composite event + + const nodeId = `composite-event-${uuidv4()}`; + this.taskNetwork.nodes.push({ + id: nodeId, + label: '', + enabled: true, + metaNodeType: 'composite', + metaNode: true, + }); + param.nodesWithEvents.add(nodeId); + + // Add edges from upstream tasks to the new meta node + // eslint-disable-next-line no-restricted-syntax + for (const rule of compositeEvent.compositeRules) { + this.taskNetwork.edges.push({ + from: rule.task.id, + to: nodeId, + + completeCompositeEvent: compositeEvent, + rule, + }); + } + + // Add edge from new meta node to current node, taking into account task type + if (param.taskType === 'reload') { + this.taskNetwork.edges.push({ + from: nodeId, + to: compositeEvent.reloadTask.id, + }); + } else if (param.taskType === 'external program') { + this.taskNetwork.edges.push({ + from: nodeId, + to: compositeEvent.externalProgramTask.id, + }); + } + } + + // Add this composite event to the current task + prelCompositeEvents.push(compositeEvent); + } + } + + return prelCompositeEvents; + } + // Function to read task definitions from disk file (CSV or Excel) // Parameters: // - tasksFromFile: Object containing data read from file // - tagsExisting: Array of existing tags in QSEoW - // - cpExisting: Array of existing custom properties in QSEoW + // - cpExisting: Array of existing custom properties in QSEoW async getTaskModelFromFile(tasksFromFile, tagsExisting, cpExisting) { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { @@ -144,519 +715,222 @@ class QlikSenseTasks { )}` ); - // Create a fake ID for this task. Used to associate task with schema/composite events - const fakeTaskId = `reload-task-${uuidv4()}`; - - let currentTask = null; - let taskCreationOption; - - // Get task specific data for the current task - // The row containing task data will have "Reload" in the task type column - const taskData = taskRows.filter( - (item) => - item[taskFileColumnHeaders.taskType.pos] && - item[taskFileColumnHeaders.taskType.pos].trim().toLowerCase() === 'reload' - ); - if (taskData?.length !== 1) { - logger.error(`(${taskCounter}) PARSE TASKS FROM FILE: Incorrect task input data:\n${JSON.stringify(taskRows)}`); + // Verify that first row contains task data. Following rows should contain event data associated with the task. + // Valid task types are: + // - Reload + // - External program + if ( + !taskRows[0][taskFileColumnHeaders.taskType.pos] || + !['reload', 'external program'].includes(taskRows[0][taskFileColumnHeaders.taskType.pos].trim().toLowerCase()) + ) { + logger.error( + `(${taskCounter}) PARSE TASKS FROM FILE: Incorrect task type "${ + taskRows[0][taskFileColumnHeaders.taskType.pos] + }". Exiting.` + ); process.exit(1); - } else { - // Create task object using same structure as results from QRS API - - // Determine if the task is associated with an app that existed before Ctrl-Q was started, or - // an app that's been imported as part of this Ctrl-Q execution. - // Possible values for the app ID column: - // - newapp- (app has been imported as part of this Ctrl-Q execution) - // - A real, existing app ID. I.e. the app existed before Ctrl-Q was started. - let appId; - if (taskData[0][taskFileColumnHeaders.appId.pos].trim().substring(0, 7).toLowerCase() === 'newapp-') { - appId = this.importedApps.appIdMap.get(taskData[0][taskFileColumnHeaders.appId.pos].trim().toLowerCase()); - - // Ensure the app exists - // Reasons for the app not existing could be: - // - The app was imported but has since been deleted or replaced. This could happen if the app-import step has several - // apps that are published-replaced or deleted-published to the same stream. In that case only the last published app will be present - - if (appId === undefined) { - logger.error( - `(${taskCounter}) PARSE TASKS FROM FILE: Cannot figure out which Sense app "${taskData[0][ - taskFileColumnHeaders.appId.pos - ].trim()}" belongs to. App with ID "${taskData[0][taskFileColumnHeaders.appId.pos]}" not found.` - ); - - logger.error( - `(${taskCounter}) PARSE TASKS FROM FILE: This could be because the app was imported but has since been deleted or replaced, for example during app publishing. Don't know how to proceed, exiting.` - ); - - process.exit(1); - } - - // eslint-disable-next-line no-await-in-loop - const app = await getAppById(appId); - - if (!app) { - logger.error( - `(${taskCounter}) PARSE TASKS FROM FILE: App with ID "${appId}" not found. This could be because the app was imported but has since been deleted or replaced, for example during app publishing. Don't know how to proceed, exiting.` - ); - process.exit(1); - } - } else if (validate(taskData[0][taskFileColumnHeaders.appId.pos])) { - // App ID is a proper UUID. We don't know if the app actually exists though. - - // eslint-disable-next-line no-await-in-loop - const app = await getAppById(taskData[0][taskFileColumnHeaders.appId.pos]); - - if (!app) { - logger.error( - `(${taskCounter}) PARSE TASKS FROM FILE: App with ID "${ - taskData[0][taskFileColumnHeaders.appId.pos] - }" not found. This could be because the app was imported but has since been deleted or replaced, for example during app publishing. Don't know how to proceed, exiting.` - ); - process.exit(1); - } - - appId = taskData[0][taskFileColumnHeaders.appId.pos]; - } else { - logger.error( - `(${taskCounter}) PARSE TASKS FROM FILE: Incorrect app ID "${ - taskData[0][taskFileColumnHeaders.appId.pos] - }". Exiting.` - ); - process.exit(1); - } - - if (taskFileColumnHeaders.importOptions.pos === 999) { - // No task creation options column in the file - // Use the default task creation option - taskCreationOption = 'if-exists-update-existing'; - } else { - // Task creation options column exists in the file - // Use the value from the file - taskCreationOption = taskData[0][taskFileColumnHeaders.importOptions.pos]; - } - - // Ensure task creation option is valid. Allow empty option - if ( - taskCreationOption && - taskCreationOption.trim() !== '' && - !['if-exists-add-another', 'if-exists-update-existing'].includes(taskCreationOption) - ) { - logger.error( - `(${taskCounter}) PARSE TASKS FROM FILE: Incorrect task creation option "${taskCreationOption}". Exiting.` - ); - process.exit(1); - } - - currentTask = { - id: taskData[0][taskFileColumnHeaders.taskId.pos], - name: taskData[0][taskFileColumnHeaders.taskName.pos], - taskType: mapTaskType.get(taskData[0][taskFileColumnHeaders.taskType.pos]), - enabled: taskData[0][taskFileColumnHeaders.taskEnabled.pos], - taskSessionTimeout: taskData[0][taskFileColumnHeaders.taskSessionTimeout.pos], - maxRetries: taskData[0][taskFileColumnHeaders.taskMaxRetries.pos], - isManuallyTriggered: taskData[0][taskFileColumnHeaders.isManuallyTriggered.pos], - isPartialReload: taskData[0][taskFileColumnHeaders.isPartialReload.pos], - app: { - id: appId, - // name: taskData[0][taskFileColumnHeaders.appName.pos], - }, - tags: [], - customProperties: [], - schemaPath: 'ReloadTask', - schemaEvents: [], - compositeEvents: [], - prelCompositeEvents: [], - }; - - // Add tags to task object - if (taskData[0][taskFileColumnHeaders.taskTags.pos]) { - const tmpTags = taskData[0][taskFileColumnHeaders.taskTags.pos] - .split('/') - .filter((item) => item.trim().length !== 0) - .map((item) => item.trim()); - - // eslint-disable-next-line no-restricted-syntax - for (const item of tmpTags) { - // eslint-disable-next-line no-await-in-loop - const tagId = await getTagIdByName(item, tagsExisting); - currentTask.tags.push({ - id: tagId, - name: item, - }); - } - } - - // Add custom properties to task object - if (taskData[0][taskFileColumnHeaders.taskCustomProperties.pos]) { - const tmpCustomProperties = taskData[0][taskFileColumnHeaders.taskCustomProperties.pos] - .split('/') - .filter((item) => item.trim().length !== 0) - .map((cp) => cp.trim()); - - // eslint-disable-next-line no-restricted-syntax - for (const item of tmpCustomProperties) { - const tmpCustomProperty = item - .split('=') - .filter((item2) => item2.trim().length !== 0) - .map((cp) => cp.trim()); - - if (tmpCustomProperty?.length === 2) { - // eslint-disable-next-line no-await-in-loop - const customPropertyId = await getCustomPropertyIdByName( - 'ReloadTask', - tmpCustomProperty[0], - cpExisting - ); - - currentTask.customProperties.push({ - definition: { - id: customPropertyId, - name: tmpCustomProperty[0].trim(), - }, - value: tmpCustomProperty[1].trim(), - }); - } - } - } } - // Get schema events for this task, storing the info using the same structure as returned from QRS API - const schemaEventRows = taskRows.filter( - (item) => - item[taskFileColumnHeaders.eventType.pos] && - item[taskFileColumnHeaders.eventType.pos].trim().toLowerCase() === 'schema' - ); - if (!schemaEventRows || schemaEventRows?.length === 0) { - logger.verbose(`(${taskCounter}) PARSE TASKS FROM FILE: No schema events for task "${currentTask.name}"`); - } else { - logger.verbose( - `(${taskCounter}) PARSE TASKS FROM FILE: ${schemaEventRows.length} schema event(s) for task "${currentTask.name}"` - ); - - // Add schema edges and start/trigger nodes - // eslint-disable-next-line no-restricted-syntax - for (const schemaEventRow of schemaEventRows) { - // Create object using same format that Sense uses for schema events - const schemaEvent = { - enabled: schemaEventRow[taskFileColumnHeaders.eventEnabled.pos], - eventType: mapEventType.get(schemaEventRow[taskFileColumnHeaders.eventType.pos]), - name: schemaEventRow[taskFileColumnHeaders.eventName.pos], - daylightSavingTime: mapDaylightSavingTime.get( - schemaEventRow[taskFileColumnHeaders.daylightSavingsTime.pos] - ), - timeZone: schemaEventRow[taskFileColumnHeaders.schemaTimeZone.pos], - startDate: schemaEventRow[taskFileColumnHeaders.schemaStart.pos], - expirationDate: schemaEventRow[taskFileColumnHeaders.scheamExpiration.pos], - schemaFilterDescription: [schemaEventRow[taskFileColumnHeaders.schemaFilterDescription.pos]], - incrementDescription: schemaEventRow[taskFileColumnHeaders.schemaIncrementDescription.pos], - incrementOption: mapIncrementOption.get(schemaEventRow[taskFileColumnHeaders.schemaIncrementOption.pos]), - reloadTask: { - id: fakeTaskId, - }, - schemaPath: 'SchemaEvent', - }; - - this.qlikSenseSchemaEvents.addSchemaEvent(schemaEvent); + // Handle each task type separately + // Get task type (lower case) from first row + const taskType = taskRows[0][taskFileColumnHeaders.taskType.pos].trim().toLowerCase(); - // Add schema event to network representation of tasks - // Create an id for this node - const nodeId = `schema-event-${uuidv4()}`; + // Reload task + if (taskType === 'reload') { + // Create a fake ID for this task. Used to associate task with schema/composite events + const fakeTaskId = `reload-task-${uuidv4()}`; - // Add schema trigger nodes. These represent the implicit starting nodes that a schema event really are - this.taskNetwork.nodes.push({ - id: nodeId, - metaNodeType: 'schedule', // Meta nodes are not Sense tasks, but rather nodes representing task-like properties (e.g. a starting point for a reload chain) - metaNode: true, - isTopLevelNode: true, - label: schemaEvent.name, - enabled: schemaEvent.enabled, - - completeSchemaEvent: schemaEvent, - }); - - this.taskNetwork.edges.push({ - from: nodeId, - to: schemaEvent.reloadTask.id, - }); - - // Keep a note that this node has associated events - nodesWithEvents.add(schemaEvent.reloadTask.id); - - // Add this schema event to the current task - // Remove reference to task ID first though - delete schemaEvent.reloadTask.id; - delete schemaEvent.reloadTask; - currentTask.schemaEvents.push(schemaEvent); - } - } + // eslint-disable-next-line no-await-in-loop + const res = await this.parseReloadTask({ + taskRows, + taskFileColumnHeaders, + taskCounter, + tagsExisting, + cpExisting, + fakeTaskId, + nodesWithEvents, + }); - // Get composite events for this task - // NB: This will only get the main row for each composite event. - // Each such main row is followed by one or more event rule rows, that each share the same value in the "Event counter" column - const compositeEventRows = taskRows.filter( - (item) => - item[taskFileColumnHeaders.eventType.pos] && - item[taskFileColumnHeaders.eventType.pos].trim().toLowerCase() === 'composite' - ); - if (!compositeEventRows || compositeEventRows?.length === 0) { - logger.verbose(`(${taskCounter}) PARSE TASKS FROM FILE: No composite events for task "${currentTask.name}"`); - } else { - logger.verbose( - `(${taskCounter}) PARSE TASKS FROM FILE: ${compositeEventRows.length} composite event(s) for task "${currentTask.name}"` - ); + // Add reload task as node in task network + // NB: A top level node is defined as: + // 1. A task whose taskID does not show up in the "to" field of any edge. - // Loop over all composite events, adding them and their event rules // eslint-disable-next-line no-restricted-syntax - for (const compositeEventRow of compositeEventRows) { - // Get value in "Event counter" column for this composite event, then get array of all associated event rules - const compositeEventCounter = compositeEventRow[taskFileColumnHeaders.eventCounter.pos]; - const compositeEventRules = taskRows.filter( - (item) => - item[taskFileColumnHeaders.eventCounter.pos] === compositeEventCounter && - item[taskFileColumnHeaders.ruleCounter.pos] > 0 - ); - - // Create an object using same format that the Sense API uses for composite events - const compositeEvent = { - timeConstraint: { - days: compositeEventRow[taskFileColumnHeaders.timeConstraintDays.pos], - hours: compositeEventRow[taskFileColumnHeaders.timeConstraintHours.pos], - minutes: compositeEventRow[taskFileColumnHeaders.timeConstraintMinutes.pos], - seconds: compositeEventRow[taskFileColumnHeaders.timeConstraintSeconds.pos], - }, - compositeRules: [], - name: compositeEventRow[taskFileColumnHeaders.eventName.pos], - enabled: compositeEventRow[taskFileColumnHeaders.eventEnabled.pos], - eventType: mapEventType.get(compositeEventRow[taskFileColumnHeaders.eventType.pos]), - reloadTask: { - id: fakeTaskId, - }, - schemaPath: 'CompositeEvent', - }; - - // Add rules - // eslint-disable-next-line no-restricted-syntax - for (const rule of compositeEventRules) { - // Does the upstream task pointed to by the composite rule exist? - // If it *does* exist it means it's a real, existing task in QSEoW that should be used. - // If it is not a valid guid or does not exist, it's (best case) a referefence to some other task in the task definitions file. - // If the task pointed to by the rule doesn't exist in Sense and doesn't point to some other task in the file, an error should be shown. - if (validate(rule[taskFileColumnHeaders.ruleTaskId.pos])) { - // eslint-disable-next-line no-await-in-loop - const taskExists = await taskExistById(rule[taskFileColumnHeaders.ruleTaskId.pos], this.options); - - if (taskExists) { - // Add task ID to mapping table that will be used later when building the composite event data structures - // In this case we're adding a task ID that maps to itself, indicating that it's a task that already exists in QSEoW. - this.taskIdMap.set( - rule[taskFileColumnHeaders.ruleTaskId.pos], - rule[taskFileColumnHeaders.ruleTaskId.pos] - ); - } - } else { - logger.verbose( - `(${taskCounter}) ANALYZE COMPOSITE EVENT: "${ - rule[taskFileColumnHeaders.ruleTaskId.pos] - }" is not a valid UUID` - ); - } - - compositeEvent.compositeRules.push({ - // id: , - ruleState: mapRuleState.get(rule[taskFileColumnHeaders.ruleState.pos]), - reloadTask: { - id: rule[taskFileColumnHeaders.ruleTaskId.pos], - }, - }); - } - - this.qlikSenseCompositeEvents.addCompositeEvent(compositeEvent); - - // Add schema event to network representation of tasks - if (compositeEvent.compositeRules.length === 1) { - // This trigger has exactly ONE upstream task - // For triggers with >1 upstream task we want an extra meta node to represent the waiting of all upstream tasks to finish - - this.taskNetwork.edges.push({ - from: compositeEvent.compositeRules[0].reloadTask.id, - to: compositeEvent.reloadTask.id, - - completeCompositeEvent: compositeEvent, - rule: compositeEvent.compositeRules, - // color: compositeEvent.enabled ? '#9FC2F7' : '#949298', - // color: edgeColor, - // dashes: compositeEvent.enabled ? false : [15, 15], - // title: compositeEvent.name + '
' + 'asdasd', - // label: compositeEvent.name, - }); - - // Keep a note that this node has associated events - nodesWithEvents.add(compositeEvent.compositeRules[0].reloadTask.id); - nodesWithEvents.add(compositeEvent.reloadTask.id); - } else { - // There are more than one task involved in triggering a downstream task. - // Insert a proxy node that represents a Qlik Sense composite event - - const nodeId = `composite-event-${uuidv4()}`; - this.taskNetwork.nodes.push({ - id: nodeId, - label: '', - enabled: true, - metaNodeType: 'composite', - metaNode: true, - }); - nodesWithEvents.add(nodeId); - - // Add edges from upstream tasks to the new meta node - // eslint-disable-next-line no-restricted-syntax - for (const rule of compositeEvent.compositeRules) { - this.taskNetwork.edges.push({ - from: rule.reloadTask.id, - to: nodeId, - - completeCompositeEvent: compositeEvent, - rule, - }); - } - - // Add edge from new meta node to current node - this.taskNetwork.edges.push({ - from: nodeId, - to: compositeEvent.reloadTask.id, - }); - } - - // Add this composite event to the current task - currentTask.prelCompositeEvents.push(compositeEvent); - } - } - - // Add task as node in task network - // NB: A top level node is defined as: - // 1. A task whose taskID does not show up in the "to" field of any edge. - - // eslint-disable-next-line no-restricted-syntax - this.taskNetwork.nodes.push({ - id: currentTask.id, - metaNode: false, - isTopLevelNode: !this.taskNetwork.edges.find((edge) => edge.to === currentTask.taskId), - label: currentTask.name, - enabled: currentTask.enabled, - - completeTaskObject: currentTask, - - // Tabulator columns - taskId: currentTask.id, - taskName: currentTask.name, - taskEnabled: currentTask.enabled, - appId: currentTask.app.id, - appName: 'N/A', - appPublished: 'N/A', - appStream: 'N/A', - taskMaxRetries: currentTask.maxRetries, - taskLastExecutionStartTimestamp: 'N/A', - taskLastExecutionStopTimestamp: 'N/A', - taskLastExecutionDuration: 'N/A', - taskLastExecutionExecutingNodeName: 'N/A', - taskNextExecutionTimestamp: 'N/A', - taskLastStatus: 'N/A', - taskTags: currentTask.tags.map((tag) => tag.name), - taskCustomProperties: currentTask.customProperties.map((cp) => `${cp.definition.name}=${cp.value}`), - }); + this.taskNetwork.nodes.push({ + id: res.currentTask.id, + metaNode: false, + isTopLevelNode: !this.taskNetwork.edges.find((edge) => edge.to === res.currentTask.taskId), + label: res.currentTask.name, + enabled: res.currentTask.enabled, + + completeTaskObject: res.currentTask, + + // Tabulator columns + taskId: res.currentTask.id, + taskName: res.currentTask.name, + taskEnabled: res.currentTask.enabled, + appId: res.currentTask.app.id, + appName: 'N/A', + appPublished: 'N/A', + appStream: 'N/A', + taskMaxRetries: res.currentTask.maxRetries, + taskLastExecutionStartTimestamp: 'N/A', + taskLastExecutionStopTimestamp: 'N/A', + taskLastExecutionDuration: 'N/A', + taskLastExecutionExecutingNodeName: 'N/A', + taskNextExecutionTimestamp: 'N/A', + taskLastStatus: 'N/A', + taskTags: res.currentTask.tags.map((tag) => tag.name), + taskCustomProperties: res.currentTask.customProperties.map((cp) => `${cp.definition.name}=${cp.value}`), + }); - // We now have a basic task object including tags and custom properties. - // Schema events are included but composite events are only partially there, as they may need - // IDs of tasks that have not yet been created. - // Still, store all info for composite evets and then do another loop where those events are created for - // tasks for which they are defined. - // - // The strategey is to first create all tasks, then add composite events. - // Only then can we be sure all composite events refer to existing tasks. + // We now have a basic task object including tags and custom properties. + // Schema events are included but composite events are only partially there, as they may need + // IDs of tasks that have not yet been created. + // Still, store all info for composite evets and then do another loop where those events are created for + // tasks for which they are defined. + // + // The strategey is to first create all tasks, then add composite events. + // Only then can we be sure all composite events refer to existing tasks. - // Create new or update existing reload task in QSEoW - if (this.options.dryRun === false || this.options.dryRun === undefined) { + // Create new or update existing reload task in QSEoW // Should we create a new task? - // We do this if either the following is true: - // 1. taskCreationOption equals "if-exists-add-another" - if (taskCreationOption === 'if-exists-add-another') { + // Controlled by option --update-mode + if (this.options.updateMode === 'create') { // Create new task - // eslint-disable-next-line no-await-in-loop - const newTaskId = await this.createReloadTaskInQseow(currentTask, taskCounter); - logger.info(`(${taskCounter}) Created new task "${currentTask.name}", new task id: ${newTaskId}.`); + if (this.options.dryRun === false || this.options.dryRun === undefined) { + // eslint-disable-next-line no-await-in-loop + const newTaskId = await this.createReloadTaskInQseow(res.currentTask, taskCounter); + logger.info(`(${taskCounter}) Created new task "${res.currentTask.name}", new task id: ${newTaskId}.`); - // Add mapping between fake task ID used when creating task network and actual, newly created task ID - this.taskIdMap.set(fakeTaskId, newTaskId); + // Add mapping between fake task ID used when creating task network and actual, newly created task ID + this.taskIdMap.set(fakeTaskId, newTaskId); - // Add mapping between fake task ID specified in source file and actual, newly created task ID - if (currentTask.id) { - this.taskIdMap.set(currentTask.id, newTaskId); - } - - currentTask.idRef = currentTask.id; - currentTask.id = newTaskId; + // Add mapping between fake task ID specified in source file and actual, newly created task ID + if (res.currentTask.id) { + this.taskIdMap.set(res.currentTask.id, newTaskId); + } - // eslint-disable-next-line no-await-in-loop - await this.addTask('from_file', currentTask, false); - } else if (taskCreationOption === 'if-exists-update-existing') { - // Update existing task + res.currentTask.idRef = res.currentTask.id; + res.currentTask.id = newTaskId; - // Verify task ID is a valid UUID - // If it's not a valid UUID, the ID specified in the source file will be treated as a task name - if (!validate(currentTask.id)) { // eslint-disable-next-line no-await-in-loop - const task = await getTaskByName(currentTask.id); - if (task) { - // eslint-disable-next-line no-await-in-loop - await this.updateReloadTaskInQseow(currentTask, taskCounter); - } else { - throw new Error( - `Task "${currentTask.id}" does not exist in QSEoW and cannot be updated. ` + - 'Please specify a valid task ID or task name in the source file.' - ); - } + await this.addTask('from_file', res.currentTask, false); } else { - // Verify task ID exists in QSEoW - // eslint-disable-next-line no-await-in-loop - const taskExists = await getTaskById(currentTask.id); - if (!taskExists) { - throw new Error( - `Task "${currentTask.id}" does not exist in QSEoW and cannot be updated. ` + - 'Please specify a valid task ID or task name in the source file.' - ); - } else { - // eslint-disable-next-line no-await-in-loop - await this.updateReloadTaskInQseow(currentTask, taskCounter); - logger.info( - `(${taskCounter}) Updated existing task "${currentTask.name}", task id: ${currentTask.id}.` - ); - } + logger.info(`(${taskCounter}) DRY RUN: Creating reload task in QSEoW "${res.currentTask.name}"`); } - - // eslint-disable-next-line no-await-in-loop - await this.updateReloadTaskInQseow(currentTask, taskCounter); - logger.info(`(${taskCounter}) Updated existing task "${currentTask.name}", task id: ${currentTask.id}.`); + } else if (this.options.updateMode === 'update-if-exists') { + // Update existing task + // TODO + // // Verify task ID is a valid UUID + // // If it's not a valid UUID, the ID specified in the source file will be treated as a task name + // if (!validate(res.currentTask.id)) { + // // eslint-disable-next-line no-await-in-loop + // const task = await getTaskByName(res.currentTask.id); + // if (task) { + // // eslint-disable-next-line no-await-in-loop + // await this.updateReloadTaskInQseow(res.currentTask, taskCounter); + // } else { + // throw new Error( + // `Task "${res.currentTask.id}" does not exist in QSEoW and cannot be updated. ` + + // 'Please specify a valid task ID or task name in the source file.' + // ); + // } + // } else { + // // Verify task ID exists in QSEoW + // // eslint-disable-next-line no-await-in-loop + // const taskExists = await getTaskById(res.currentTask.id); + // if (!taskExists) { + // throw new Error( + // `Task "${res.currentTask.id}" does not exist in QSEoW and cannot be updated. ` + + // 'Please specify a valid task ID or task name in the source file.' + // ); + // } else { + // // eslint-disable-next-line no-await-in-loop + // await this.updateReloadTaskInQseow(res.currentTask, taskCounter); + // logger.info( + // `(${taskCounter}) Updated existing task "${res.currentTask.name}", task id: ${res.currentTask.id}.` + // ); + // } + // } + // logger.info( + // `(${taskCounter}) Updated existing task "${res.currentTask.name}", task id: ${res.currentTask.id}.` + // ); } else { // Invalid combination of import options throw new Error( - `Invalid combination of task import options for task "${currentTask.name}". ` + - 'You must specify either "if-exists-add-another" or "if-exists-update-existing", but not both.' + `Invalid task update mode. Valid values are "create" and "update-if-exists". You specified "${this.options.updateMode}".` ); } - } else { - logger.info(`(${taskCounter}) DRY RUN: Creating reload task in QSEoW "${currentTask.name}"`); + } else if (taskType === 'external program') { + logger.info(`(${taskCounter}) PARSE TASKS FROM FILE: External program task. Not yet implemented.`); + // External program task + // eslint-disable-next-line no-await-in-loop + // await this.parseExternalProgramTask( + // taskRows, + // taskFileColumnHeaders, + // taskCounter, + // tagsExisting, + // cpExisting, + // nodesWithEvents + // ); } } - // Get task IDs for upstream tasks that composite task events are connected to + // At this point all tasks have been created or updated in Sense. + // The tasks' associated composite events have been parsed and are stored in the qlikSenseCompositeEvents object. + // Time now to create the composite events in Sense. + + // Make sure all composite tasks contain real, valid task UUIDs pointing to previously existing or newly created tasks. + // Get task IDs for upstream tasks that composite events are connected to via their respective rules. + // Some rules will point to upstream tasks that are created during this execution of Ctrl-Q, other upstream tasks existed before this execution of Ctrl-Q. + // Use the task ID map to get the correct task ID for each upstream task. this.qlikSenseCompositeEvents.compositeEventList.map((item) => { const a = item; - a.compositeEvent.reloadTask.id = this.taskIdMap.get(item.compositeEvent.reloadTask.id); + // Set task ID for the composite event itself, i.e. which task is the event associated with (i.e. the downstream task) + // Handle different task types differently + if (item.compositeEvent.reloadTask.id) { + // Reload task + a.compositeEvent.reloadTask.id = this.taskIdMap.get(item.compositeEvent.reloadTask.id); + } else if (item.compositeEvent.externalProgramTask.id) { + // External program task + a.compositeEvent.externalProgramTask.id = this.taskIdMap.get(item.compositeEvent.externalProgramTask.id); + } + + // For this composite event, set the correct task id for each each rule. + // Different properties are used for reload tasks, external program tasks etc. + // Some rules may be pointing to newly created tasks. These can be looked up in the taskIdMap. a.compositeEvent.compositeRules.map((item2) => { const b = item2; - const id = this.taskIdMap.get(item2.reloadTask.id); + + // Get triggering/upstream task id + const id = this.taskIdMap.get(b.task.id); if (id !== undefined && validate(id) === true) { - b.reloadTask.id = id; + // Determine what kind of task this is. Options are: + // - reload + // - external program + // + // Also need to know if the task is a new task created during this execution of Ctrl-Q or if it's an existing task in Sense. + let taskType; + if (b.upstreamTaskExistence === 'exists-in-source-file') { + const task = this.taskList.find((item3) => item3.taskId === id); + taskType = task.taskType; + // const { taskType } = this.taskNetwork.nodes.find((node) => node.id === id).completeTaskObject; + } else if (b.upstreamTaskExistence === 'exists-in-sense') { + // eslint-disable-next-line no-await-in-loop + const task = this.compositeEventUpstreamTask.find((item4) => item4.id === id); + taskType = task.taskType; + } + + // Use mapTaskType to get the string variant of the task type. Convert to lower case. + const taskTypeString = mapTaskType.get(taskType).trim().toLowerCase(); + + if (taskTypeString === 'reload') { + b.reloadTask = { id }; + } else if (taskTypeString === 'externalprogram') { + b.externalProgramTask = { id }; + } } else if (this.options.dryRun === false || this.options.dryRun === undefined) { logger.error( `PREPARING COMPOSITE EVENT: Invalid upstream task ID "${b.reloadTask.id}" in rule for composite event "${a.compositeEvent.name}" ` @@ -886,7 +1160,6 @@ class QlikSenseTasks { // Don't add task id and tag filtering if the output is a task tree if (this.options.outputFormat !== 'tree') { - // Add task id(s) to query string if (this.options.taskId && this.options?.taskId.length >= 1) { // At least one task ID specified diff --git a/src/lib/util/task.js b/src/lib/util/task.js index c431e4b..87c8d24 100644 --- a/src/lib/util/task.js +++ b/src/lib/util/task.js @@ -5,6 +5,8 @@ const { validate } = require('uuid'); const { logger, execPath, getCliOptions } = require('../../globals'); const { setupQRSConnection } = require('./qrs'); +// Check if a task with a given id exists +// Look for all kinds of tasks, not just reload tasks async function taskExistById(taskId, optionsParam) { try { logger.debug(`Checking if task with ID ${taskId} exists`); @@ -129,6 +131,8 @@ async function getTaskByName(taskName, optionsParam) { } } +// Function to get task metadata, given a task ID +// If the task ID is a valid GUID it is assumed to be a task ID that exists in Sense. Report an error if not. async function getTaskById(taskId, optionsParam) { try { logger.debug(`Get task with ID ${taskId}`); @@ -149,6 +153,8 @@ async function getTaskById(taskId, optionsParam) { return false; } + logger.verbose(`GET TASK BY ID: Task ID ${taskId} is a valid GUID. Get associated task from QSEoW.`); + // Make sure certificates exist const fileCert = path.resolve(execPath, options.authCertFile); const fileCertKey = path.resolve(execPath, options.authCertKeyFile); diff --git a/testdata/tasks.xlsx b/testdata/tasks.xlsx index 5b4179d2b94b66d7966bb0732eecfb99984edc97..ef0c8509b81dc627938235471d2450351caaa524 100644 GIT binary patch delta 19376 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 JtbTp^zW}jnvTXnW delta 5941 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