@claryai/cli 0.1.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/LICENSE +25 -0
- package/README.md +197 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/ajv.d.ts +3 -0
- package/dist/ajv.d.ts.map +1 -0
- package/dist/ajv.js +13 -0
- package/dist/analytics/analytics.d.ts +370 -0
- package/dist/analytics/analytics.d.ts.map +1 -0
- package/dist/analytics/analytics.js +143 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +134 -0
- package/dist/dbt/context.d.ts +14 -0
- package/dist/dbt/context.d.ts.map +1 -0
- package/dist/dbt/context.js +76 -0
- package/dist/dbt/context.test.d.ts +2 -0
- package/dist/dbt/context.test.d.ts.map +1 -0
- package/dist/dbt/context.test.js +152 -0
- package/dist/dbt/manifest.d.ts +7 -0
- package/dist/dbt/manifest.d.ts.map +1 -0
- package/dist/dbt/manifest.js +23 -0
- package/dist/dbt/models.d.ts +43 -0
- package/dist/dbt/models.d.ts.map +1 -0
- package/dist/dbt/models.js +256 -0
- package/dist/dbt/models.test.d.ts +2 -0
- package/dist/dbt/models.test.d.ts.map +1 -0
- package/dist/dbt/models.test.js +19 -0
- package/dist/dbt/profile.d.ts +9 -0
- package/dist/dbt/profile.d.ts.map +1 -0
- package/dist/dbt/profile.js +86 -0
- package/dist/dbt/profiles.test.d.ts +2 -0
- package/dist/dbt/profiles.test.d.ts.map +1 -0
- package/dist/dbt/profiles.test.js +50 -0
- package/dist/dbt/schema.d.ts +31 -0
- package/dist/dbt/schema.d.ts.map +1 -0
- package/dist/dbt/schema.js +49 -0
- package/dist/dbt/targets/Bigquery/index.d.ts +18 -0
- package/dist/dbt/targets/Bigquery/index.d.ts.map +1 -0
- package/dist/dbt/targets/Bigquery/index.js +105 -0
- package/dist/dbt/targets/Bigquery/oauth.d.ts +2 -0
- package/dist/dbt/targets/Bigquery/oauth.d.ts.map +1 -0
- package/dist/dbt/targets/Bigquery/oauth.js +43 -0
- package/dist/dbt/targets/Bigquery/serviceAccount.d.ts +35 -0
- package/dist/dbt/targets/Bigquery/serviceAccount.d.ts.map +1 -0
- package/dist/dbt/targets/Bigquery/serviceAccount.js +149 -0
- package/dist/dbt/targets/Databricks/oauth.d.ts +21 -0
- package/dist/dbt/targets/Databricks/oauth.d.ts.map +1 -0
- package/dist/dbt/targets/Databricks/oauth.js +184 -0
- package/dist/dbt/targets/athena.d.ts +21 -0
- package/dist/dbt/targets/athena.d.ts.map +1 -0
- package/dist/dbt/targets/athena.js +91 -0
- package/dist/dbt/targets/athena.test.d.ts +2 -0
- package/dist/dbt/targets/athena.test.d.ts.map +1 -0
- package/dist/dbt/targets/athena.test.js +60 -0
- package/dist/dbt/targets/clickhouse.d.ts +24 -0
- package/dist/dbt/targets/clickhouse.d.ts.map +1 -0
- package/dist/dbt/targets/clickhouse.js +90 -0
- package/dist/dbt/targets/databricks.d.ts +27 -0
- package/dist/dbt/targets/databricks.d.ts.map +1 -0
- package/dist/dbt/targets/databricks.js +138 -0
- package/dist/dbt/targets/duckdb.d.ts +16 -0
- package/dist/dbt/targets/duckdb.d.ts.map +1 -0
- package/dist/dbt/targets/duckdb.js +63 -0
- package/dist/dbt/targets/duckdb.test.d.ts +2 -0
- package/dist/dbt/targets/duckdb.test.d.ts.map +1 -0
- package/dist/dbt/targets/duckdb.test.js +37 -0
- package/dist/dbt/targets/postgres.d.ts +26 -0
- package/dist/dbt/targets/postgres.d.ts.map +1 -0
- package/dist/dbt/targets/postgres.js +142 -0
- package/dist/dbt/targets/redshift.d.ts +23 -0
- package/dist/dbt/targets/redshift.d.ts.map +1 -0
- package/dist/dbt/targets/redshift.js +96 -0
- package/dist/dbt/targets/snowflake.d.ts +4 -0
- package/dist/dbt/targets/snowflake.d.ts.map +1 -0
- package/dist/dbt/targets/snowflake.js +134 -0
- package/dist/dbt/targets/trino.d.ts +16 -0
- package/dist/dbt/targets/trino.d.ts.map +1 -0
- package/dist/dbt/targets/trino.js +65 -0
- package/dist/dbt/templating.d.ts +15 -0
- package/dist/dbt/templating.d.ts.map +1 -0
- package/dist/dbt/templating.js +50 -0
- package/dist/dbt/templating.test.d.ts +2 -0
- package/dist/dbt/templating.test.d.ts.map +1 -0
- package/dist/dbt/templating.test.js +51 -0
- package/dist/dbt/types.d.ts +17 -0
- package/dist/dbt/types.d.ts.map +1 -0
- package/dist/dbt/types.js +2 -0
- package/dist/dbt/validation.d.ts +9 -0
- package/dist/dbt/validation.d.ts.map +1 -0
- package/dist/dbt/validation.js +54 -0
- package/dist/env.d.ts +12 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +40 -0
- package/dist/error.d.ts +2 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +12 -0
- package/dist/globalState.d.ts +29 -0
- package/dist/globalState.d.ts.map +1 -0
- package/dist/globalState.js +67 -0
- package/dist/handlers/asyncQuery.d.ts +7 -0
- package/dist/handlers/asyncQuery.d.ts.map +1 -0
- package/dist/handlers/asyncQuery.js +50 -0
- package/dist/handlers/compile.d.ts +16 -0
- package/dist/handlers/compile.d.ts.map +1 -0
- package/dist/handlers/compile.js +277 -0
- package/dist/handlers/compile.test.d.ts +2 -0
- package/dist/handlers/compile.test.d.ts.map +1 -0
- package/dist/handlers/compile.test.js +201 -0
- package/dist/handlers/createProject.d.ts +37 -0
- package/dist/handlers/createProject.d.ts.map +1 -0
- package/dist/handlers/createProject.js +272 -0
- package/dist/handlers/dbt/apiClient.d.ts +14 -0
- package/dist/handlers/dbt/apiClient.d.ts.map +1 -0
- package/dist/handlers/dbt/apiClient.js +167 -0
- package/dist/handlers/dbt/compile.d.ts +35 -0
- package/dist/handlers/dbt/compile.d.ts.map +1 -0
- package/dist/handlers/dbt/compile.js +220 -0
- package/dist/handlers/dbt/getDbtProfileTargetName.d.ts +9 -0
- package/dist/handlers/dbt/getDbtProfileTargetName.d.ts.map +1 -0
- package/dist/handlers/dbt/getDbtProfileTargetName.js +44 -0
- package/dist/handlers/dbt/getDbtVersion.d.ts +16 -0
- package/dist/handlers/dbt/getDbtVersion.d.ts.map +1 -0
- package/dist/handlers/dbt/getDbtVersion.js +141 -0
- package/dist/handlers/dbt/getDbtVersion.mocks.d.ts +11 -0
- package/dist/handlers/dbt/getDbtVersion.mocks.d.ts.map +1 -0
- package/dist/handlers/dbt/getDbtVersion.mocks.js +70 -0
- package/dist/handlers/dbt/getDbtVersion.test.d.ts +2 -0
- package/dist/handlers/dbt/getDbtVersion.test.d.ts.map +1 -0
- package/dist/handlers/dbt/getDbtVersion.test.js +97 -0
- package/dist/handlers/dbt/getWarehouseClient.d.ts +24 -0
- package/dist/handlers/dbt/getWarehouseClient.d.ts.map +1 -0
- package/dist/handlers/dbt/getWarehouseClient.js +312 -0
- package/dist/handlers/dbt/refresh.d.ts +11 -0
- package/dist/handlers/dbt/refresh.d.ts.map +1 -0
- package/dist/handlers/dbt/refresh.js +114 -0
- package/dist/handlers/dbt/run.d.ts +14 -0
- package/dist/handlers/dbt/run.d.ts.map +1 -0
- package/dist/handlers/dbt/run.js +67 -0
- package/dist/handlers/deploy.d.ts +26 -0
- package/dist/handlers/deploy.d.ts.map +1 -0
- package/dist/handlers/deploy.js +377 -0
- package/dist/handlers/diagnostics.d.ts +11 -0
- package/dist/handlers/diagnostics.d.ts.map +1 -0
- package/dist/handlers/diagnostics.js +194 -0
- package/dist/handlers/download.d.ts +29 -0
- package/dist/handlers/download.d.ts.map +1 -0
- package/dist/handlers/download.js +955 -0
- package/dist/handlers/exportChartImage.d.ts +7 -0
- package/dist/handlers/exportChartImage.d.ts.map +1 -0
- package/dist/handlers/exportChartImage.js +33 -0
- package/dist/handlers/generate.d.ts +13 -0
- package/dist/handlers/generate.d.ts.map +1 -0
- package/dist/handlers/generate.js +159 -0
- package/dist/handlers/generateExposures.d.ts +8 -0
- package/dist/handlers/generateExposures.d.ts.map +1 -0
- package/dist/handlers/generateExposures.js +100 -0
- package/dist/handlers/getProject.d.ts +6 -0
- package/dist/handlers/getProject.d.ts.map +1 -0
- package/dist/handlers/getProject.js +43 -0
- package/dist/handlers/installSkills.d.ts +12 -0
- package/dist/handlers/installSkills.d.ts.map +1 -0
- package/dist/handlers/installSkills.js +321 -0
- package/dist/handlers/lint/ajvToSarif.d.ts +66 -0
- package/dist/handlers/lint/ajvToSarif.d.ts.map +1 -0
- package/dist/handlers/lint/ajvToSarif.js +222 -0
- package/dist/handlers/lint/sarifFormatter.d.ts +14 -0
- package/dist/handlers/lint/sarifFormatter.d.ts.map +1 -0
- package/dist/handlers/lint/sarifFormatter.js +111 -0
- package/dist/handlers/lint.d.ts +8 -0
- package/dist/handlers/lint.d.ts.map +1 -0
- package/dist/handlers/lint.js +308 -0
- package/dist/handlers/listProjects.d.ts +6 -0
- package/dist/handlers/listProjects.d.ts.map +1 -0
- package/dist/handlers/listProjects.js +53 -0
- package/dist/handlers/login/oauth.d.ts +2 -0
- package/dist/handlers/login/oauth.d.ts.map +1 -0
- package/dist/handlers/login/oauth.js +27 -0
- package/dist/handlers/login/pat.d.ts +2 -0
- package/dist/handlers/login/pat.d.ts.map +1 -0
- package/dist/handlers/login/pat.js +31 -0
- package/dist/handlers/login.d.ts +15 -0
- package/dist/handlers/login.d.ts.map +1 -0
- package/dist/handlers/login.js +239 -0
- package/dist/handlers/metadataFile.d.ts +9 -0
- package/dist/handlers/metadataFile.d.ts.map +1 -0
- package/dist/handlers/metadataFile.js +34 -0
- package/dist/handlers/oauthLogin.d.ts +6 -0
- package/dist/handlers/oauthLogin.d.ts.map +1 -0
- package/dist/handlers/oauthLogin.js +191 -0
- package/dist/handlers/preview.d.ts +29 -0
- package/dist/handlers/preview.d.ts.map +1 -0
- package/dist/handlers/preview.js +415 -0
- package/dist/handlers/renameHandler.d.ts +16 -0
- package/dist/handlers/renameHandler.d.ts.map +1 -0
- package/dist/handlers/renameHandler.js +160 -0
- package/dist/handlers/runChart.d.ts +10 -0
- package/dist/handlers/runChart.d.ts.map +1 -0
- package/dist/handlers/runChart.js +105 -0
- package/dist/handlers/selectProject.d.ts +20 -0
- package/dist/handlers/selectProject.d.ts.map +1 -0
- package/dist/handlers/selectProject.js +91 -0
- package/dist/handlers/setProject.d.ts +14 -0
- package/dist/handlers/setProject.d.ts.map +1 -0
- package/dist/handlers/setProject.js +131 -0
- package/dist/handlers/setWarehouse.d.ts +14 -0
- package/dist/handlers/setWarehouse.d.ts.map +1 -0
- package/dist/handlers/setWarehouse.js +94 -0
- package/dist/handlers/sql.d.ts +9 -0
- package/dist/handlers/sql.d.ts.map +1 -0
- package/dist/handlers/sql.js +89 -0
- package/dist/handlers/utils.d.ts +11 -0
- package/dist/handlers/utils.d.ts.map +1 -0
- package/dist/handlers/utils.js +36 -0
- package/dist/handlers/validate.d.ts +22 -0
- package/dist/handlers/validate.d.ts.map +1 -0
- package/dist/handlers/validate.js +201 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +581 -0
- package/dist/lightdash/loader.d.ts +21 -0
- package/dist/lightdash/loader.d.ts.map +1 -0
- package/dist/lightdash/loader.js +122 -0
- package/dist/lightdash/projectType.d.ts +84 -0
- package/dist/lightdash/projectType.d.ts.map +1 -0
- package/dist/lightdash/projectType.js +75 -0
- package/dist/lightdash-config/index.d.ts +2 -0
- package/dist/lightdash-config/index.d.ts.map +1 -0
- package/dist/lightdash-config/index.js +41 -0
- package/dist/lightdash-config/lightdash-config.test.d.ts +2 -0
- package/dist/lightdash-config/lightdash-config.test.d.ts.map +1 -0
- package/dist/lightdash-config/lightdash-config.test.js +70 -0
- package/dist/styles.d.ts +10 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +14 -0
- package/entitlements.plist +33 -0
- package/package.json +71 -0
- package/track.sh +116 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.uploadHandler = exports.downloadHandler = exports.downloadContent = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/* eslint-disable no-await-in-loop */
|
|
6
|
+
/* eslint-disable no-param-reassign */
|
|
7
|
+
const common_1 = require("@lightdash/common");
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const yaml = tslib_1.__importStar(require("js-yaml"));
|
|
10
|
+
const groupBy_1 = tslib_1.__importDefault(require("lodash/groupBy"));
|
|
11
|
+
const p_limit_1 = tslib_1.__importDefault(require("p-limit"));
|
|
12
|
+
const path = tslib_1.__importStar(require("path"));
|
|
13
|
+
const analytics_1 = require("../analytics/analytics");
|
|
14
|
+
const config_1 = require("../config");
|
|
15
|
+
const globalState_1 = tslib_1.__importDefault(require("../globalState"));
|
|
16
|
+
const styles = tslib_1.__importStar(require("../styles"));
|
|
17
|
+
const apiClient_1 = require("./dbt/apiClient");
|
|
18
|
+
const metadataFile_1 = require("./metadataFile");
|
|
19
|
+
const selectProject_1 = require("./selectProject");
|
|
20
|
+
const getDownloadFolder = (customPath) => {
|
|
21
|
+
if (customPath) {
|
|
22
|
+
return path.isAbsolute(customPath)
|
|
23
|
+
? customPath
|
|
24
|
+
: path.join(process.cwd(), customPath);
|
|
25
|
+
}
|
|
26
|
+
return path.join(process.cwd(), 'clary');
|
|
27
|
+
};
|
|
28
|
+
/*
|
|
29
|
+
This function is used to parse the content filters.
|
|
30
|
+
It can be slugs, uuids or urls
|
|
31
|
+
We remove the URL part (if any) and return a list of `slugs or uuids` that can be used in the API call
|
|
32
|
+
*/
|
|
33
|
+
const parseContentFilters = (items) => {
|
|
34
|
+
if (items.length === 0)
|
|
35
|
+
return '';
|
|
36
|
+
const parsedItems = items.map((item) => {
|
|
37
|
+
const uuidMatch = item.match(/https?:\/\/.+\/(?:saved|dashboards)\/([a-f0-9-]+)/i);
|
|
38
|
+
return uuidMatch ? uuidMatch[1] : item;
|
|
39
|
+
});
|
|
40
|
+
return `?${new URLSearchParams(parsedItems.map((item) => ['ids', item])).toString()}`;
|
|
41
|
+
};
|
|
42
|
+
const createDirForContent = async (projectName, spaceSlug, folder, customPath, folderScheme) => {
|
|
43
|
+
const baseDir = getDownloadFolder(customPath);
|
|
44
|
+
let outputDir;
|
|
45
|
+
if (folderScheme === 'flat') {
|
|
46
|
+
// Flat scheme: baseDir/folder
|
|
47
|
+
outputDir = path.join(baseDir, folder);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// Nested scheme: baseDir/projectName/spaceSlug/folder
|
|
51
|
+
outputDir = path.join(baseDir, projectName, spaceSlug, folder);
|
|
52
|
+
}
|
|
53
|
+
globalState_1.default.debug(`Creating directory: ${outputDir}`);
|
|
54
|
+
await fs_1.promises.mkdir(outputDir, { recursive: true });
|
|
55
|
+
return outputDir;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Get file extension for content-as-code files.
|
|
59
|
+
* SQL charts use '.sql.yml' extension to avoid filename conflicts with regular charts
|
|
60
|
+
* that may have the same slug, since both chart types share the same output directory.
|
|
61
|
+
*/
|
|
62
|
+
const getFileExtension = (contentType) => {
|
|
63
|
+
switch (contentType) {
|
|
64
|
+
case 'sqlChart':
|
|
65
|
+
return '.sql.yml';
|
|
66
|
+
case 'chart':
|
|
67
|
+
case 'dashboard':
|
|
68
|
+
default:
|
|
69
|
+
return '.yml';
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const writeContent = async (contentAsCode, outputDir, languageMap) => {
|
|
73
|
+
const extension = getFileExtension(contentAsCode.type);
|
|
74
|
+
const itemPath = path.join(outputDir, `${contentAsCode.content.slug}${extension}`);
|
|
75
|
+
// Strip timestamps — they go to .clary-metadata.json instead
|
|
76
|
+
const { updatedAt, downloadedAt, ...cleanContent } = contentAsCode.content;
|
|
77
|
+
const chartYml = yaml.dump(cleanContent, {
|
|
78
|
+
quotingType: '"',
|
|
79
|
+
sortKeys: true,
|
|
80
|
+
});
|
|
81
|
+
await fs_1.promises.writeFile(itemPath, chartYml);
|
|
82
|
+
if (contentAsCode.translationMap && languageMap) {
|
|
83
|
+
const translationPath = path.join(outputDir, `${contentAsCode.content.slug}.language.map.yml`);
|
|
84
|
+
await fs_1.promises.writeFile(translationPath, yaml.dump(contentAsCode.translationMap, { sortKeys: true }));
|
|
85
|
+
}
|
|
86
|
+
const metadataType = contentAsCode.type === 'dashboard' ? 'dashboards' : 'charts';
|
|
87
|
+
let downloadedAtString;
|
|
88
|
+
if (downloadedAt instanceof Date) {
|
|
89
|
+
downloadedAtString = downloadedAt.toISOString();
|
|
90
|
+
}
|
|
91
|
+
else if (typeof downloadedAt === 'string') {
|
|
92
|
+
downloadedAtString = downloadedAt;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
downloadedAtString = new Date().toISOString();
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
slug: contentAsCode.content.slug,
|
|
99
|
+
type: metadataType,
|
|
100
|
+
downloadedAt: downloadedAtString,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Writes space YAML files for each space in the download results.
|
|
105
|
+
* In flat mode, files go in the base download directory.
|
|
106
|
+
* In nested mode, files go in the root of each space's directory.
|
|
107
|
+
*/
|
|
108
|
+
const writeSpaceFiles = async (spaces, projectName, customPath, folderScheme = 'flat') => {
|
|
109
|
+
if (spaces.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
const baseDir = getDownloadFolder(customPath);
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const space of spaces) {
|
|
114
|
+
// Deduplicate across paginated API responses
|
|
115
|
+
if (!seen.has(space.slug)) {
|
|
116
|
+
seen.add(space.slug);
|
|
117
|
+
let outputDir;
|
|
118
|
+
if (folderScheme === 'nested') {
|
|
119
|
+
outputDir = path.join(baseDir, projectName, space.slug);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
outputDir = baseDir;
|
|
123
|
+
}
|
|
124
|
+
await fs_1.promises.mkdir(outputDir, { recursive: true });
|
|
125
|
+
const fileName = `${(0, common_1.generateSlug)(space.spaceName)}.space.yml`;
|
|
126
|
+
const filePath = path.join(outputDir, fileName);
|
|
127
|
+
const content = yaml.dump(space, {
|
|
128
|
+
quotingType: '"',
|
|
129
|
+
sortKeys: true,
|
|
130
|
+
});
|
|
131
|
+
await fs_1.promises.writeFile(filePath, content);
|
|
132
|
+
globalState_1.default.debug(`Wrote space file: ${filePath}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Reads all .space.yml files from the download directory and returns
|
|
138
|
+
* a map of space slug → original space name. Used during upload to
|
|
139
|
+
* preserve human-readable space names instead of deriving them from slugs.
|
|
140
|
+
*/
|
|
141
|
+
const readSpaceNames = async (customPath) => {
|
|
142
|
+
const baseDir = getDownloadFolder(customPath);
|
|
143
|
+
const spaceNames = {};
|
|
144
|
+
try {
|
|
145
|
+
const allEntries = await fs_1.promises.readdir(baseDir, {
|
|
146
|
+
recursive: true,
|
|
147
|
+
withFileTypes: true,
|
|
148
|
+
});
|
|
149
|
+
await Promise.all(allEntries
|
|
150
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.space.yml'))
|
|
151
|
+
.map(async (file) => {
|
|
152
|
+
try {
|
|
153
|
+
const filePath = path.join(file.parentPath, file.name);
|
|
154
|
+
const fileContent = await fs_1.promises.readFile(filePath, 'utf-8');
|
|
155
|
+
const parsed = yaml.load(fileContent);
|
|
156
|
+
if (parsed?.contentType ===
|
|
157
|
+
common_1.ContentAsCodeType.SPACE &&
|
|
158
|
+
typeof parsed.slug === 'string' &&
|
|
159
|
+
typeof parsed.spaceName === 'string') {
|
|
160
|
+
spaceNames[parsed.slug] = parsed.spaceName;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
globalState_1.default.debug(`Skipping space file ${file.name}: ${(0, common_1.getErrorMessage)(e)}`);
|
|
165
|
+
}
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Directory doesn't exist or can't be read — return empty map
|
|
170
|
+
}
|
|
171
|
+
return spaceNames;
|
|
172
|
+
};
|
|
173
|
+
const hasUnsortedKeys = (obj) => {
|
|
174
|
+
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
175
|
+
if (Array.isArray(obj)) {
|
|
176
|
+
return obj.some(hasUnsortedKeys);
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
const keys = Object.keys(obj);
|
|
181
|
+
const sorted = [...keys].sort();
|
|
182
|
+
if (keys.some((key, i) => key !== sorted[i])) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
return Object.values(obj).some(hasUnsortedKeys);
|
|
186
|
+
};
|
|
187
|
+
const isLightdashContentFile = (folder, entry) => entry.isFile() &&
|
|
188
|
+
entry.parentPath &&
|
|
189
|
+
entry.parentPath.endsWith(path.sep + folder) &&
|
|
190
|
+
entry.name.endsWith('.yml') &&
|
|
191
|
+
!entry.name.endsWith('.language.map.yml');
|
|
192
|
+
const isLooseContentFile = (entry) => entry.isFile() &&
|
|
193
|
+
entry.parentPath &&
|
|
194
|
+
!entry.parentPath.endsWith(`${path.sep}charts`) &&
|
|
195
|
+
!entry.parentPath.endsWith(`${path.sep}dashboards`) &&
|
|
196
|
+
entry.name.endsWith('.yml') &&
|
|
197
|
+
!entry.name.endsWith('.language.map.yml');
|
|
198
|
+
const processYamlItem = (item, fileName, stats, folder, metadata) => {
|
|
199
|
+
if (hasUnsortedKeys(item)) {
|
|
200
|
+
globalState_1.default.log(styles.warning(`Warning: ${fileName} has unsorted YAML keys. Re-download to fix, or sort keys alphabetically.`));
|
|
201
|
+
}
|
|
202
|
+
const metadataSection = folder === 'dashboards' ? metadata.dashboards : metadata.charts;
|
|
203
|
+
const downloadedAtRaw = metadataSection[item.slug] ?? item.downloadedAt;
|
|
204
|
+
const downloadedAt = downloadedAtRaw
|
|
205
|
+
? new Date(downloadedAtRaw instanceof Date
|
|
206
|
+
? downloadedAtRaw.getTime()
|
|
207
|
+
: downloadedAtRaw)
|
|
208
|
+
: undefined;
|
|
209
|
+
const needsUpdating = downloadedAt &&
|
|
210
|
+
Math.abs(stats.mtime.getTime() - downloadedAt.getTime()) > 30000;
|
|
211
|
+
return {
|
|
212
|
+
...item,
|
|
213
|
+
updatedAt: needsUpdating ? stats.mtime : item.updatedAt,
|
|
214
|
+
needsUpdating: needsUpdating ?? true,
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
const loadYamlFile = async (file, folder, metadata) => {
|
|
218
|
+
const filePath = path.join(file.parentPath, file.name);
|
|
219
|
+
const [fileContent, stats] = await Promise.all([
|
|
220
|
+
fs_1.promises.readFile(filePath, 'utf-8'),
|
|
221
|
+
fs_1.promises.stat(filePath),
|
|
222
|
+
]);
|
|
223
|
+
const item = yaml.load(fileContent);
|
|
224
|
+
return processYamlItem(item, file.name, stats, folder, metadata);
|
|
225
|
+
};
|
|
226
|
+
const readCodeFiles = async (folder, customPath) => {
|
|
227
|
+
const baseDir = getDownloadFolder(customPath);
|
|
228
|
+
globalState_1.default.log(`Reading ${folder} from ${baseDir}`);
|
|
229
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
230
|
+
if (major < 20 || (major === 20 && minor < 12)) {
|
|
231
|
+
throw new Error(`Node.js v20.12.0 or later is required for this command (current: ${process.version}).`);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const metadata = await (0, metadataFile_1.readMetadataFile)(baseDir);
|
|
235
|
+
const allEntries = await fs_1.promises.readdir(baseDir, {
|
|
236
|
+
recursive: true,
|
|
237
|
+
withFileTypes: true,
|
|
238
|
+
});
|
|
239
|
+
const items = await Promise.all(allEntries
|
|
240
|
+
.filter((entry) => isLightdashContentFile(folder, entry))
|
|
241
|
+
.map((file) => loadYamlFile(file, folder, metadata)));
|
|
242
|
+
if (items.length === 0) {
|
|
243
|
+
console.error(styles.warning(`Unable to upload ${folder}, no files found in "${baseDir}". Run download command first.`));
|
|
244
|
+
}
|
|
245
|
+
return items;
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
// Handle case where base directory doesn't exist
|
|
249
|
+
if (error.code === 'ENOENT') {
|
|
250
|
+
console.error(styles.warning(`Unable to upload ${folder}, "${baseDir}" folder not found. Run download command first.`));
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
// Unknown error
|
|
254
|
+
console.error(styles.error(`Error reading ${baseDir}: ${error}`));
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
259
|
+
* Reads YAML files outside the standard charts/ and dashboards/ directories
|
|
260
|
+
* and classifies them by their contentType field.
|
|
261
|
+
*/
|
|
262
|
+
const readLooseCodeFiles = async (customPath) => {
|
|
263
|
+
const baseDir = getDownloadFolder(customPath);
|
|
264
|
+
const charts = [];
|
|
265
|
+
const dashboards = [];
|
|
266
|
+
try {
|
|
267
|
+
const metadata = await (0, metadataFile_1.readMetadataFile)(baseDir);
|
|
268
|
+
const allEntries = await fs_1.promises.readdir(baseDir, {
|
|
269
|
+
recursive: true,
|
|
270
|
+
withFileTypes: true,
|
|
271
|
+
});
|
|
272
|
+
const looseFiles = allEntries.filter(isLooseContentFile);
|
|
273
|
+
await Promise.all(looseFiles.map(async (file) => {
|
|
274
|
+
try {
|
|
275
|
+
const filePath = path.join(file.parentPath, file.name);
|
|
276
|
+
const [fileContent, stats] = await Promise.all([
|
|
277
|
+
fs_1.promises.readFile(filePath, 'utf-8'),
|
|
278
|
+
fs_1.promises.stat(filePath),
|
|
279
|
+
]);
|
|
280
|
+
const parsed = yaml.load(fileContent);
|
|
281
|
+
const contentType = parsed?.contentType;
|
|
282
|
+
if (contentType === common_1.ContentAsCodeType.CHART ||
|
|
283
|
+
contentType === common_1.ContentAsCodeType.SQL_CHART) {
|
|
284
|
+
charts.push(processYamlItem(parsed, file.name, stats, 'charts', metadata));
|
|
285
|
+
}
|
|
286
|
+
else if (contentType === common_1.ContentAsCodeType.DASHBOARD) {
|
|
287
|
+
dashboards.push(processYamlItem(parsed, file.name, stats, 'dashboards', metadata));
|
|
288
|
+
}
|
|
289
|
+
else if (contentType === common_1.ContentAsCodeType.SPACE) {
|
|
290
|
+
// Space YAML files are metadata-only until the next PR
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
globalState_1.default.debug(`Skipping ${file.name}: no recognized contentType`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
globalState_1.default.debug(`Skipping ${file.name}: failed to parse (${(0, common_1.getErrorMessage)(e)})`);
|
|
298
|
+
}
|
|
299
|
+
}));
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (error.code === 'ENOENT') {
|
|
303
|
+
// Base directory doesn't exist — nothing to discover
|
|
304
|
+
return { charts, dashboards };
|
|
305
|
+
}
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
return { charts, dashboards };
|
|
309
|
+
};
|
|
310
|
+
const groupBySpace = (items) => {
|
|
311
|
+
const itemsWithIndex = items.map((item, index) => ({ item, index }));
|
|
312
|
+
return (0, groupBy_1.default)(itemsWithIndex, (entry) => entry.item.spaceSlug);
|
|
313
|
+
};
|
|
314
|
+
const writeSpaceContent = async ({ projectName, spaceSlug, folder, contentType, contentInSpace, contentAsCode, customPath, languageMap, folderScheme, }) => {
|
|
315
|
+
const outputDir = await createDirForContent(projectName, spaceSlug, folder, customPath, folderScheme);
|
|
316
|
+
const entries = [];
|
|
317
|
+
for (const { item, index } of contentInSpace) {
|
|
318
|
+
const translationMap = 'languageMap' in contentAsCode
|
|
319
|
+
? contentAsCode.languageMap?.[index]
|
|
320
|
+
: undefined;
|
|
321
|
+
const entry = await writeContent({
|
|
322
|
+
type: contentType,
|
|
323
|
+
content: item,
|
|
324
|
+
translationMap,
|
|
325
|
+
}, outputDir, languageMap);
|
|
326
|
+
entries.push(entry);
|
|
327
|
+
}
|
|
328
|
+
return entries;
|
|
329
|
+
};
|
|
330
|
+
const getContentTypeConfig = (type, projectId) => {
|
|
331
|
+
switch (type) {
|
|
332
|
+
case 'charts':
|
|
333
|
+
return {
|
|
334
|
+
endpoint: `/api/v1/projects/${projectId}/charts/code`,
|
|
335
|
+
displayName: 'charts',
|
|
336
|
+
supportsLanguageMap: true,
|
|
337
|
+
};
|
|
338
|
+
case 'dashboards':
|
|
339
|
+
return {
|
|
340
|
+
endpoint: `/api/v1/projects/${projectId}/dashboards/code`,
|
|
341
|
+
displayName: 'dashboards',
|
|
342
|
+
supportsLanguageMap: true,
|
|
343
|
+
};
|
|
344
|
+
case 'sqlCharts':
|
|
345
|
+
return {
|
|
346
|
+
endpoint: `/api/v1/projects/${projectId}/sqlCharts/code`,
|
|
347
|
+
displayName: 'SQL charts',
|
|
348
|
+
supportsLanguageMap: false,
|
|
349
|
+
};
|
|
350
|
+
default:
|
|
351
|
+
return (0, common_1.assertUnreachable)(type, `Unknown content type: ${type}`);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
const extractChartSlugsFromDashboards = (dashboards) => dashboards.reduce((acc, dashboard) => {
|
|
355
|
+
const slugs = dashboard.tiles
|
|
356
|
+
.map((tile) => 'chartSlug' in tile.properties
|
|
357
|
+
? tile.properties.chartSlug
|
|
358
|
+
: undefined)
|
|
359
|
+
.filter((slug) => slug !== undefined);
|
|
360
|
+
return [...acc, ...slugs];
|
|
361
|
+
}, []);
|
|
362
|
+
const downloadContent = async (ids, type, projectId, projectName, customPath, languageMap = false, nested = false, skipSpaces = false) => {
|
|
363
|
+
const spinner = globalState_1.default.getActiveSpinner();
|
|
364
|
+
const contentFilters = parseContentFilters(ids);
|
|
365
|
+
const folderScheme = nested ? 'nested' : 'flat';
|
|
366
|
+
const config = getContentTypeConfig(type, projectId);
|
|
367
|
+
let offset = 0;
|
|
368
|
+
let total = 0;
|
|
369
|
+
let chartSlugs = [];
|
|
370
|
+
let allMetadataEntries = [];
|
|
371
|
+
let allSpaces = [];
|
|
372
|
+
do {
|
|
373
|
+
globalState_1.default.debug(`Downloading ${config.displayName} with offset "${offset}" and filters "${contentFilters}"`);
|
|
374
|
+
const commonParams = config.supportsLanguageMap
|
|
375
|
+
? `offset=${offset}&languageMap=${languageMap}`
|
|
376
|
+
: `offset=${offset}`;
|
|
377
|
+
const queryParams = contentFilters
|
|
378
|
+
? `${contentFilters}&${commonParams}`
|
|
379
|
+
: `?${commonParams}`;
|
|
380
|
+
const results = await (0, apiClient_1.lightdashApi)({
|
|
381
|
+
method: 'GET',
|
|
382
|
+
url: `${config.endpoint}${queryParams}`,
|
|
383
|
+
body: undefined,
|
|
384
|
+
});
|
|
385
|
+
spinner?.start(`Downloaded ${results.offset} of ${results.total} ${config.displayName}`);
|
|
386
|
+
// For the same chart slug, we run the code for saved charts and sql chart
|
|
387
|
+
// so we are going to get more false positives here, so we keep it on the debug log
|
|
388
|
+
results.missingIds.forEach((missingId) => {
|
|
389
|
+
globalState_1.default.debug(`\nNo ${config.displayName} with id "${missingId}"`);
|
|
390
|
+
});
|
|
391
|
+
// Write content based on type
|
|
392
|
+
if ('sqlCharts' in results) {
|
|
393
|
+
const sqlChartsBySpace = groupBySpace(results.sqlCharts);
|
|
394
|
+
for (const [spaceSlug, sqlChartsInSpace] of Object.entries(sqlChartsBySpace)) {
|
|
395
|
+
const entries = await writeSpaceContent({
|
|
396
|
+
projectName,
|
|
397
|
+
spaceSlug,
|
|
398
|
+
folder: 'charts',
|
|
399
|
+
contentType: 'sqlChart',
|
|
400
|
+
contentInSpace: sqlChartsInSpace,
|
|
401
|
+
contentAsCode: results,
|
|
402
|
+
customPath,
|
|
403
|
+
languageMap,
|
|
404
|
+
folderScheme,
|
|
405
|
+
});
|
|
406
|
+
allMetadataEntries = [...allMetadataEntries, ...entries];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
else if ('dashboards' in results) {
|
|
410
|
+
const dashboardsBySpace = groupBySpace(results.dashboards);
|
|
411
|
+
for (const [spaceSlug, dashboardsInSpace] of Object.entries(dashboardsBySpace)) {
|
|
412
|
+
const entries = await writeSpaceContent({
|
|
413
|
+
projectName,
|
|
414
|
+
spaceSlug,
|
|
415
|
+
folder: 'dashboards',
|
|
416
|
+
contentType: 'dashboard',
|
|
417
|
+
contentInSpace: dashboardsInSpace,
|
|
418
|
+
contentAsCode: results,
|
|
419
|
+
customPath,
|
|
420
|
+
languageMap,
|
|
421
|
+
folderScheme,
|
|
422
|
+
});
|
|
423
|
+
allMetadataEntries = [...allMetadataEntries, ...entries];
|
|
424
|
+
}
|
|
425
|
+
chartSlugs = [
|
|
426
|
+
...chartSlugs,
|
|
427
|
+
...extractChartSlugsFromDashboards(results.dashboards),
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
const chartsBySpace = groupBySpace(results.charts);
|
|
432
|
+
for (const [spaceSlug, chartsInSpace] of Object.entries(chartsBySpace)) {
|
|
433
|
+
const entries = await writeSpaceContent({
|
|
434
|
+
projectName,
|
|
435
|
+
spaceSlug,
|
|
436
|
+
folder: 'charts',
|
|
437
|
+
contentType: 'chart',
|
|
438
|
+
contentInSpace: chartsInSpace,
|
|
439
|
+
contentAsCode: results,
|
|
440
|
+
customPath,
|
|
441
|
+
languageMap,
|
|
442
|
+
folderScheme,
|
|
443
|
+
});
|
|
444
|
+
allMetadataEntries = [...allMetadataEntries, ...entries];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Accumulate space metadata from each page
|
|
448
|
+
if ('spaces' in results && results.spaces) {
|
|
449
|
+
allSpaces = [...allSpaces, ...results.spaces];
|
|
450
|
+
}
|
|
451
|
+
offset = results.offset;
|
|
452
|
+
total = results.total;
|
|
453
|
+
} while (offset < total);
|
|
454
|
+
// Write space YAML files
|
|
455
|
+
if (!skipSpaces) {
|
|
456
|
+
await writeSpaceFiles(allSpaces, projectName, customPath, folderScheme);
|
|
457
|
+
}
|
|
458
|
+
return [total, [...new Set(chartSlugs)], allMetadataEntries, allSpaces];
|
|
459
|
+
};
|
|
460
|
+
exports.downloadContent = downloadContent;
|
|
461
|
+
const downloadHandler = async (options) => {
|
|
462
|
+
globalState_1.default.setVerbose(options.verbose);
|
|
463
|
+
await (0, apiClient_1.checkLightdashVersion)();
|
|
464
|
+
const config = await (0, config_1.getConfig)();
|
|
465
|
+
if (!config.context?.apiKey || !config.context.serverUrl) {
|
|
466
|
+
throw new common_1.AuthorizationError(`Not logged in. Run 'clary login --help'`);
|
|
467
|
+
}
|
|
468
|
+
const projectSelection = await (0, selectProject_1.selectProject)(config, options.project);
|
|
469
|
+
if (!projectSelection) {
|
|
470
|
+
throw new common_1.LightdashError({
|
|
471
|
+
message: 'No project selected. Run clary config set-project',
|
|
472
|
+
name: 'Not Found',
|
|
473
|
+
statusCode: 404,
|
|
474
|
+
data: {},
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
const projectId = projectSelection.projectUuid;
|
|
478
|
+
// Log current project info
|
|
479
|
+
(0, selectProject_1.logSelectedProject)(projectSelection, config, 'Downloading from');
|
|
480
|
+
const spinner = globalState_1.default.startSpinner(`Downloading charts`);
|
|
481
|
+
spinner.start(`Downloading content from project`);
|
|
482
|
+
// Fetch project details to get project name for folder structure
|
|
483
|
+
const project = await (0, apiClient_1.lightdashApi)({
|
|
484
|
+
method: 'GET',
|
|
485
|
+
url: `/api/v1/projects/${projectId}`,
|
|
486
|
+
body: undefined,
|
|
487
|
+
});
|
|
488
|
+
const projectName = (0, common_1.generateSlug)(project.name);
|
|
489
|
+
// For analytics
|
|
490
|
+
let chartTotal;
|
|
491
|
+
let dashboardTotal;
|
|
492
|
+
const start = Date.now();
|
|
493
|
+
await analytics_1.LightdashAnalytics.track({
|
|
494
|
+
event: 'download.started',
|
|
495
|
+
properties: {
|
|
496
|
+
userId: config.user?.userUuid,
|
|
497
|
+
organizationId: config.user?.organizationUuid,
|
|
498
|
+
projectId,
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
try {
|
|
502
|
+
const hasFilters = options.charts.length > 0 || options.dashboards.length > 0;
|
|
503
|
+
// When downloading specific charts or dashboards, skip space metadata
|
|
504
|
+
const skipSpaces = options.skipSpaces || hasFilters;
|
|
505
|
+
let allMetadataEntries = [];
|
|
506
|
+
let allSpaces = [];
|
|
507
|
+
// Download regular charts
|
|
508
|
+
if (hasFilters && options.charts.length === 0) {
|
|
509
|
+
console.info(styles.warning(`No charts filters provided, skipping`));
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
const [regularChartTotal, , regularChartMeta, regularChartSpaces] = await (0, exports.downloadContent)(options.charts, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipSpaces);
|
|
513
|
+
spinner.succeed(`Downloaded ${regularChartTotal} charts`);
|
|
514
|
+
allMetadataEntries = [...allMetadataEntries, ...regularChartMeta];
|
|
515
|
+
allSpaces = [...allSpaces, ...regularChartSpaces];
|
|
516
|
+
// Download SQL charts
|
|
517
|
+
spinner.start(`Downloading SQL charts`);
|
|
518
|
+
const [sqlChartTotal, , sqlChartMeta, sqlChartSpaces] = await (0, exports.downloadContent)(options.charts, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipSpaces);
|
|
519
|
+
spinner.succeed(`Downloaded ${sqlChartTotal} SQL charts`);
|
|
520
|
+
allMetadataEntries = [...allMetadataEntries, ...sqlChartMeta];
|
|
521
|
+
allSpaces = [...allSpaces, ...sqlChartSpaces];
|
|
522
|
+
chartTotal = regularChartTotal + sqlChartTotal;
|
|
523
|
+
}
|
|
524
|
+
// Download dashboards
|
|
525
|
+
if (hasFilters && options.dashboards.length === 0) {
|
|
526
|
+
console.info(styles.warning(`No dashboards filters provided, skipping`));
|
|
527
|
+
}
|
|
528
|
+
else {
|
|
529
|
+
let chartSlugs = [];
|
|
530
|
+
let dashMeta;
|
|
531
|
+
let dashSpaces;
|
|
532
|
+
[dashboardTotal, chartSlugs, dashMeta, dashSpaces] =
|
|
533
|
+
await (0, exports.downloadContent)(options.dashboards, 'dashboards', projectId, projectName, options.path, options.languageMap, options.nested, skipSpaces);
|
|
534
|
+
allMetadataEntries = [...allMetadataEntries, ...dashMeta];
|
|
535
|
+
allSpaces = [...allSpaces, ...dashSpaces];
|
|
536
|
+
spinner.succeed(`Downloaded ${dashboardTotal} dashboards`);
|
|
537
|
+
if (hasFilters && chartSlugs.length > 0) {
|
|
538
|
+
spinner.start(`Downloading ${chartSlugs.length} charts linked to dashboards`);
|
|
539
|
+
const [regularCharts, , linkedChartMeta, linkedChartSpaces] = await (0, exports.downloadContent)(chartSlugs, 'charts', projectId, projectName, options.path, options.languageMap, options.nested, skipSpaces);
|
|
540
|
+
allMetadataEntries = [
|
|
541
|
+
...allMetadataEntries,
|
|
542
|
+
...linkedChartMeta,
|
|
543
|
+
];
|
|
544
|
+
allSpaces = [...allSpaces, ...linkedChartSpaces];
|
|
545
|
+
const [sqlCharts, , linkedSqlMeta, linkedSqlSpaces] = await (0, exports.downloadContent)(chartSlugs, 'sqlCharts', projectId, projectName, options.path, options.languageMap, options.nested, skipSpaces);
|
|
546
|
+
allMetadataEntries = [...allMetadataEntries, ...linkedSqlMeta];
|
|
547
|
+
allSpaces = [...allSpaces, ...linkedSqlSpaces];
|
|
548
|
+
spinner.succeed(`Downloaded ${regularCharts + sqlCharts} charts linked to dashboards`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// Report space definitions count
|
|
552
|
+
if (!skipSpaces) {
|
|
553
|
+
const uniqueSpaceCount = new Set(allSpaces.map((s) => s.slug)).size;
|
|
554
|
+
spinner.succeed(`Downloaded ${uniqueSpaceCount} space definitions`);
|
|
555
|
+
}
|
|
556
|
+
// Write metadata file with all downloadedAt timestamps
|
|
557
|
+
const metadataToWrite = {
|
|
558
|
+
version: 1,
|
|
559
|
+
charts: {},
|
|
560
|
+
dashboards: {},
|
|
561
|
+
};
|
|
562
|
+
for (const entry of allMetadataEntries) {
|
|
563
|
+
metadataToWrite[entry.type][entry.slug] = entry.downloadedAt;
|
|
564
|
+
}
|
|
565
|
+
const baseDir = getDownloadFolder(options.path);
|
|
566
|
+
const downloadRoot = options.nested
|
|
567
|
+
? path.join(baseDir, projectName)
|
|
568
|
+
: baseDir;
|
|
569
|
+
await (0, metadataFile_1.writeMetadataFile)(baseDir, metadataToWrite);
|
|
570
|
+
if (!config.answers?.metadataFileGitignoreNoticeShown) {
|
|
571
|
+
globalState_1.default.log(styles.warning(`\nNote: ${metadataFile_1.METADATA_FILENAME} was written to ${baseDir}. Consider adding it to your .gitignore.`));
|
|
572
|
+
await (0, config_1.setAnswer)({ metadataFileGitignoreNoticeShown: true });
|
|
573
|
+
}
|
|
574
|
+
globalState_1.default.log(styles.success(`Downloaded content saved to ${downloadRoot}`));
|
|
575
|
+
const end = Date.now();
|
|
576
|
+
await analytics_1.LightdashAnalytics.track({
|
|
577
|
+
event: 'download.completed',
|
|
578
|
+
properties: {
|
|
579
|
+
userId: config.user?.userUuid,
|
|
580
|
+
organizationId: config.user?.organizationUuid,
|
|
581
|
+
projectId,
|
|
582
|
+
chartsNum: chartTotal,
|
|
583
|
+
dashboardsNum: dashboardTotal,
|
|
584
|
+
timeToCompleted: (end - start) / 1000,
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
console.error(styles.error(`\nError downloading ${error}`));
|
|
590
|
+
await analytics_1.LightdashAnalytics.track({
|
|
591
|
+
event: 'download.error',
|
|
592
|
+
properties: {
|
|
593
|
+
userId: config.user?.userUuid,
|
|
594
|
+
organizationId: config.user?.organizationUuid,
|
|
595
|
+
projectId,
|
|
596
|
+
error: `${error}`,
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
exports.downloadHandler = downloadHandler;
|
|
602
|
+
const getPromoteAction = (action) => {
|
|
603
|
+
switch (action) {
|
|
604
|
+
case common_1.PromotionAction.CREATE:
|
|
605
|
+
return 'created';
|
|
606
|
+
case common_1.PromotionAction.UPDATE:
|
|
607
|
+
return 'updated';
|
|
608
|
+
case common_1.PromotionAction.DELETE:
|
|
609
|
+
return 'deleted';
|
|
610
|
+
case common_1.PromotionAction.NO_CHANGES:
|
|
611
|
+
return 'skipped';
|
|
612
|
+
default:
|
|
613
|
+
(0, common_1.assertUnreachable)(action, `Unknown promotion action: ${action}`);
|
|
614
|
+
}
|
|
615
|
+
return 'skipped';
|
|
616
|
+
};
|
|
617
|
+
const storeUploadChanges = (changes, promoteChanges) => {
|
|
618
|
+
const getPromoteChanges = (resource) => {
|
|
619
|
+
const promotions = promoteChanges[resource];
|
|
620
|
+
return promotions.reduce((acc, promoteChange) => {
|
|
621
|
+
const action = getPromoteAction(promoteChange.action);
|
|
622
|
+
const key = `${resource} ${action}`;
|
|
623
|
+
acc[key] = (acc[key] ?? 0) + 1;
|
|
624
|
+
return acc;
|
|
625
|
+
}, {});
|
|
626
|
+
};
|
|
627
|
+
const updatedChanges = {
|
|
628
|
+
...changes,
|
|
629
|
+
};
|
|
630
|
+
['spaces', 'charts', 'dashboards'].forEach((resource) => {
|
|
631
|
+
const resourceChanges = getPromoteChanges(resource);
|
|
632
|
+
Object.entries(resourceChanges).forEach(([key, value]) => {
|
|
633
|
+
updatedChanges[key] = (updatedChanges[key] ?? 0) + value;
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
return updatedChanges;
|
|
637
|
+
};
|
|
638
|
+
const logUploadChanges = (changes) => {
|
|
639
|
+
Object.entries(changes).forEach(([key, value]) => {
|
|
640
|
+
console.info(`Total ${key}: ${value} `);
|
|
641
|
+
});
|
|
642
|
+
const totalSkipped = Object.entries(changes)
|
|
643
|
+
.filter(([key]) => key.includes('skipped'))
|
|
644
|
+
.reduce((sum, [, value]) => sum + value, 0);
|
|
645
|
+
const totalUpserted = Object.entries(changes)
|
|
646
|
+
.filter(([key]) => !key.includes('skipped'))
|
|
647
|
+
.reduce((sum, [, value]) => sum + value, 0);
|
|
648
|
+
if (totalSkipped > 0 && totalUpserted === 0) {
|
|
649
|
+
console.warn(styles.warning(`\nAll content was skipped (no local changes detected). Use --force to upload all content, e.g. when uploading to a new project.`));
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
// SQL charts have 'sql' field instead of 'tableName'/'metricQuery'
|
|
653
|
+
const isSqlChart = (item) => 'sql' in item && !('tableName' in item);
|
|
654
|
+
const upsertSingleItem = async (item, type, projectId, changes, force, config, skipSpaceCreate, publicSpaceCreate, validate, spaceNames) => {
|
|
655
|
+
try {
|
|
656
|
+
if (!force && !item.needsUpdating) {
|
|
657
|
+
globalState_1.default.debug(`Skipping ${type} "${item.slug}" with no local changes`);
|
|
658
|
+
changes[`${type} skipped`] = (changes[`${type} skipped`] ?? 0) + 1;
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
globalState_1.default.debug(`Upserting ${type} ${item.slug}`);
|
|
662
|
+
// SQL charts use a different endpoint
|
|
663
|
+
const isSqlChartItem = type === 'charts' && isSqlChart(item);
|
|
664
|
+
const endpoint = isSqlChartItem
|
|
665
|
+
? `/api/v1/projects/${projectId}/sqlCharts/${item.slug}/code`
|
|
666
|
+
: `/api/v1/projects/${projectId}/${type}/${item.slug}/code`;
|
|
667
|
+
const upsertData = await (0, apiClient_1.lightdashApi)({
|
|
668
|
+
method: 'POST',
|
|
669
|
+
url: endpoint,
|
|
670
|
+
body: JSON.stringify({
|
|
671
|
+
...item,
|
|
672
|
+
skipSpaceCreate,
|
|
673
|
+
publicSpaceCreate,
|
|
674
|
+
force,
|
|
675
|
+
...(spaceNames &&
|
|
676
|
+
Object.keys(spaceNames).length > 0 && { spaceNames }),
|
|
677
|
+
}),
|
|
678
|
+
});
|
|
679
|
+
globalState_1.default.debug(`${type} "${item.name}": ${upsertData[type]?.[0].action}`);
|
|
680
|
+
// Merge storeUploadChanges result into changes in-place
|
|
681
|
+
const updatedChanges = storeUploadChanges(changes, upsertData);
|
|
682
|
+
Object.keys(updatedChanges).forEach((key) => {
|
|
683
|
+
changes[key] = updatedChanges[key];
|
|
684
|
+
});
|
|
685
|
+
// Warn if contentType contradicts the folder this item came from
|
|
686
|
+
const itemContentType = item.contentType;
|
|
687
|
+
if (itemContentType) {
|
|
688
|
+
const expectedType = itemContentType === common_1.ContentAsCodeType.DASHBOARD
|
|
689
|
+
? 'dashboards'
|
|
690
|
+
: 'charts';
|
|
691
|
+
if (expectedType !== type) {
|
|
692
|
+
globalState_1.default.log(styles.warning(`Warning: "${item.name}" has contentType "${itemContentType}" but is in the ${type}/ directory. It will be uploaded as a ${type.slice(0, -1)}.`));
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// Run validation if requested
|
|
696
|
+
if (validate && !isSqlChartItem) {
|
|
697
|
+
const contentUuid = type === 'charts'
|
|
698
|
+
? upsertData.charts?.[0]?.data?.uuid
|
|
699
|
+
: upsertData.dashboards?.[0]?.data?.uuid;
|
|
700
|
+
if (contentUuid) {
|
|
701
|
+
try {
|
|
702
|
+
const validationEndpoint = type === 'charts'
|
|
703
|
+
? `/api/v1/projects/${projectId}/validate/chart/${contentUuid}`
|
|
704
|
+
: `/api/v1/projects/${projectId}/validate/dashboard/${contentUuid}`;
|
|
705
|
+
const validationResult = await (0, apiClient_1.lightdashApi)({
|
|
706
|
+
method: 'POST',
|
|
707
|
+
url: validationEndpoint,
|
|
708
|
+
body: JSON.stringify({}),
|
|
709
|
+
});
|
|
710
|
+
if (validationResult.errors &&
|
|
711
|
+
validationResult.errors.length > 0) {
|
|
712
|
+
globalState_1.default.log(styles.warning(`Validation found ${validationResult.errors.length} issue(s) in ${type.slice(0, -1)} "${item.name}"`));
|
|
713
|
+
validationResult.errors.forEach((error) => {
|
|
714
|
+
globalState_1.default.log(styles.warning(` - ${error.error}`));
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
globalState_1.default.log(styles.success(`✓ No validation issues in ${type.slice(0, -1)} "${item.name}"`));
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch (validationError) {
|
|
722
|
+
globalState_1.default.debug(`Validation failed for ${type.slice(0, -1)} "${item.name}": ${(0, common_1.getErrorMessage)(validationError)}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
if (error instanceof common_1.LightdashError &&
|
|
729
|
+
error.name === 'NotFoundError' &&
|
|
730
|
+
skipSpaceCreate) {
|
|
731
|
+
globalState_1.default.log(styles.warning(`Skipping ${type} "${item.slug}" because space "${item.spaceSlug}" does not exist and --skip-space-create is true`));
|
|
732
|
+
changes[`${type} skipped`] = (changes[`${type} skipped`] ?? 0) + 1;
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
changes[`${type} with errors`] =
|
|
736
|
+
(changes[`${type} with errors`] ?? 0) + 1;
|
|
737
|
+
globalState_1.default.log(styles.error(`Error upserting ${type}:\n\t"${item.name}" (slug: "${item.slug}")\n\t${(0, common_1.getErrorMessage)(error)}`));
|
|
738
|
+
await analytics_1.LightdashAnalytics.track({
|
|
739
|
+
event: 'download.error',
|
|
740
|
+
properties: {
|
|
741
|
+
userId: config.user?.userUuid,
|
|
742
|
+
organizationId: config.user?.organizationUuid,
|
|
743
|
+
projectId,
|
|
744
|
+
type,
|
|
745
|
+
error: (0, common_1.getErrorMessage)(error),
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
/**
|
|
752
|
+
*
|
|
753
|
+
* @param slugs if slugs are provided, we only force upsert the charts/dashboards that match the slugs, if slugs are empty, we upload files that were locally updated
|
|
754
|
+
*/
|
|
755
|
+
const upsertResources = async (type, projectId, changes, force, slugs, customPath, skipSpaceCreate, publicSpaceCreate, validate, concurrency = 1, extraItems = [], spaceNames) => {
|
|
756
|
+
const config = await (0, config_1.getConfig)();
|
|
757
|
+
const folderItems = await readCodeFiles(type, customPath);
|
|
758
|
+
const items = [...folderItems, ...extraItems];
|
|
759
|
+
globalState_1.default.log(`Found ${items.length} ${type} files`);
|
|
760
|
+
const hasFilter = slugs.length > 0;
|
|
761
|
+
const filteredItems = hasFilter
|
|
762
|
+
? items.filter((item) => slugs.includes(item.slug))
|
|
763
|
+
: items;
|
|
764
|
+
if (hasFilter) {
|
|
765
|
+
globalState_1.default.log(`Filtered ${filteredItems.length} ${type} with slugs: ${slugs.join(', ')}`);
|
|
766
|
+
const missingItems = slugs.filter((slug) => !items.find((item) => item.slug === slug));
|
|
767
|
+
missingItems.forEach((slug) => {
|
|
768
|
+
globalState_1.default.log(styles.warning(`No ${type} with slug: "${slug}"`));
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
if (concurrency <= 1) {
|
|
772
|
+
// Sequential path — preserves original behavior exactly
|
|
773
|
+
for (const item of filteredItems) {
|
|
774
|
+
// eslint-disable-next-line no-await-in-loop
|
|
775
|
+
await upsertSingleItem(item, type, projectId, changes, force, config, skipSpaceCreate, publicSpaceCreate, validate, spaceNames);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
const grouped = (0, groupBy_1.default)(filteredItems, (item) => item.spaceSlug);
|
|
780
|
+
const seedItems = new Set();
|
|
781
|
+
const remainingItems = [];
|
|
782
|
+
Object.values(grouped).forEach((spaceItems) => {
|
|
783
|
+
// Pick the first item that will actually trigger an API call
|
|
784
|
+
// (and thus create the space). If force is true, any item works.
|
|
785
|
+
const seedIndex = force
|
|
786
|
+
? 0
|
|
787
|
+
: spaceItems.findIndex((i) => i.needsUpdating);
|
|
788
|
+
if (seedIndex >= 0) {
|
|
789
|
+
seedItems.add(spaceItems[seedIndex]);
|
|
790
|
+
remainingItems.push(...spaceItems.filter((_, idx) => idx !== seedIndex));
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
// No items need updating — all will be skipped, no space needed
|
|
794
|
+
remainingItems.push(...spaceItems);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
// For charts: also seed one item per unique dashboardSlug to avoid
|
|
798
|
+
// concurrent placeholder dashboard creation (duplicate slug bug)
|
|
799
|
+
if (type === 'charts') {
|
|
800
|
+
const chartsWithDashboard = remainingItems.filter((item) => 'dashboardSlug' in item &&
|
|
801
|
+
item.dashboardSlug);
|
|
802
|
+
const groupedByDashboard = (0, groupBy_1.default)(chartsWithDashboard, (item) => item.dashboardSlug);
|
|
803
|
+
Object.values(groupedByDashboard).forEach((dashboardItems) => {
|
|
804
|
+
// If no item for this dashboardSlug was already picked as a
|
|
805
|
+
// space seed, pick the first one as a dashboard seed
|
|
806
|
+
const alreadySeeded = dashboardItems.some((item) => seedItems.has(item));
|
|
807
|
+
if (!alreadySeeded) {
|
|
808
|
+
const seedIndex = force
|
|
809
|
+
? 0
|
|
810
|
+
: dashboardItems.findIndex((i) => i.needsUpdating);
|
|
811
|
+
if (seedIndex >= 0) {
|
|
812
|
+
seedItems.add(dashboardItems[seedIndex]);
|
|
813
|
+
// Remove from remainingItems since it's now a seed
|
|
814
|
+
const idx = remainingItems.indexOf(dashboardItems[seedIndex]);
|
|
815
|
+
if (idx >= 0) {
|
|
816
|
+
remainingItems.splice(idx, 1);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
// Phase 1: Sequential seeding (spaces + dashboard placeholders)
|
|
823
|
+
for (const item of seedItems) {
|
|
824
|
+
// eslint-disable-next-line no-await-in-loop
|
|
825
|
+
await upsertSingleItem(item, type, projectId, changes, force, config, skipSpaceCreate, publicSpaceCreate, validate, spaceNames);
|
|
826
|
+
}
|
|
827
|
+
// Phase 2: Parallel bulk upload of remaining items
|
|
828
|
+
const limit = (0, p_limit_1.default)(concurrency);
|
|
829
|
+
await Promise.all(remainingItems.map((item) => limit(async () => {
|
|
830
|
+
await upsertSingleItem(item, type, projectId, changes, force, config, skipSpaceCreate, publicSpaceCreate, validate, spaceNames);
|
|
831
|
+
})));
|
|
832
|
+
}
|
|
833
|
+
return { changes, total: filteredItems.length };
|
|
834
|
+
};
|
|
835
|
+
const getDashboardChartSlugs = async (dashboardSlugs, customPath) => {
|
|
836
|
+
const dashboardItems = await readCodeFiles('dashboards', customPath);
|
|
837
|
+
const filteredDashboardItems = dashboardSlugs.length > 0
|
|
838
|
+
? dashboardItems.filter((dashboard) => dashboardSlugs.includes(dashboard.slug))
|
|
839
|
+
: dashboardItems;
|
|
840
|
+
return filteredDashboardItems.reduce((acc, dashboard) => {
|
|
841
|
+
const dashboardChartSlugs = dashboard.tiles
|
|
842
|
+
.map((tile) => 'chartSlug' in tile.properties
|
|
843
|
+
? tile.properties.chartSlug
|
|
844
|
+
: undefined)
|
|
845
|
+
.filter((dashboardChartSlug) => !!dashboardChartSlug);
|
|
846
|
+
return [...acc, ...dashboardChartSlugs];
|
|
847
|
+
}, []);
|
|
848
|
+
};
|
|
849
|
+
const uploadHandler = async (options) => {
|
|
850
|
+
globalState_1.default.setVerbose(options.verbose);
|
|
851
|
+
if (options.gzip) {
|
|
852
|
+
(0, apiClient_1.setGzipEnabled)(true);
|
|
853
|
+
}
|
|
854
|
+
await (0, apiClient_1.checkLightdashVersion)();
|
|
855
|
+
const config = await (0, config_1.getConfig)();
|
|
856
|
+
if (!config.context?.apiKey || !config.context.serverUrl) {
|
|
857
|
+
throw new common_1.AuthorizationError(`Not logged in. Run 'clary login --help'`);
|
|
858
|
+
}
|
|
859
|
+
const projectSelection = await (0, selectProject_1.selectProject)(config, options.project);
|
|
860
|
+
if (!projectSelection) {
|
|
861
|
+
throw new common_1.LightdashError({
|
|
862
|
+
message: 'No project selected. Run clary config set-project',
|
|
863
|
+
name: 'Not Found',
|
|
864
|
+
statusCode: 404,
|
|
865
|
+
data: {},
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
const projectId = projectSelection.projectUuid;
|
|
869
|
+
// Log current project info
|
|
870
|
+
(0, selectProject_1.logSelectedProject)(projectSelection, config, 'Uploading to');
|
|
871
|
+
let changes = {};
|
|
872
|
+
// For analytics
|
|
873
|
+
let chartTotal;
|
|
874
|
+
let dashboardTotal;
|
|
875
|
+
const start = Date.now();
|
|
876
|
+
await analytics_1.LightdashAnalytics.track({
|
|
877
|
+
event: 'upload.started',
|
|
878
|
+
properties: {
|
|
879
|
+
userId: config.user?.userUuid,
|
|
880
|
+
organizationId: config.user?.organizationUuid,
|
|
881
|
+
projectId,
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
try {
|
|
885
|
+
// If any filter is provided, we skip those items without filters
|
|
886
|
+
// eg: if a --charts filter is provided, we skip dashboards if no --dashboards filter is provided
|
|
887
|
+
const hasFilters = options.charts.length > 0 || options.dashboards.length > 0;
|
|
888
|
+
// Always include the charts from dashboards if includeCharts is true regardless of the charts filters
|
|
889
|
+
const chartSlugs = options.includeCharts
|
|
890
|
+
? Array.from(new Set([
|
|
891
|
+
...options.charts,
|
|
892
|
+
...(await getDashboardChartSlugs(options.dashboards, options.path)),
|
|
893
|
+
]))
|
|
894
|
+
: options.charts;
|
|
895
|
+
const concurrency = Math.min(Math.max(1, parseInt(String(options.concurrency), 10) || 1), 1000);
|
|
896
|
+
if (parseInt(String(options.concurrency), 10) > 1000) {
|
|
897
|
+
globalState_1.default.log(styles.warning(`Concurrency limit exceeded. Using maximum of 1000 instead of ${options.concurrency}`));
|
|
898
|
+
}
|
|
899
|
+
// Read space definition files to preserve original space names during upload
|
|
900
|
+
const spaceNames = await readSpaceNames(options.path);
|
|
901
|
+
if (Object.keys(spaceNames).length > 0) {
|
|
902
|
+
globalState_1.default.log(`Found ${Object.keys(spaceNames).length} space definition(s)`);
|
|
903
|
+
}
|
|
904
|
+
// Discover loose YAML files (outside charts/ and dashboards/) classified by contentType
|
|
905
|
+
const looseFiles = await readLooseCodeFiles(options.path);
|
|
906
|
+
if (looseFiles.charts.length > 0) {
|
|
907
|
+
globalState_1.default.log(`Found ${looseFiles.charts.length} chart(s) outside charts/ directory (classified by contentType)`);
|
|
908
|
+
}
|
|
909
|
+
if (looseFiles.dashboards.length > 0) {
|
|
910
|
+
globalState_1.default.log(`Found ${looseFiles.dashboards.length} dashboard(s) outside dashboards/ directory (classified by contentType)`);
|
|
911
|
+
}
|
|
912
|
+
if (hasFilters && chartSlugs.length === 0) {
|
|
913
|
+
globalState_1.default.log(styles.warning(`No charts filters provided, skipping`));
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
const { changes: chartChanges, total } = await upsertResources('charts', projectId, changes, options.force, chartSlugs, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.charts, spaceNames);
|
|
917
|
+
changes = chartChanges;
|
|
918
|
+
chartTotal = total;
|
|
919
|
+
}
|
|
920
|
+
if (hasFilters && options.dashboards.length === 0) {
|
|
921
|
+
globalState_1.default.log(styles.warning(`No dashboard filters provided, skipping`));
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
const { changes: dashboardChanges, total } = await upsertResources('dashboards', projectId, changes, options.force, options.dashboards, options.path, options.skipSpaceCreate, options.public, options.validate, concurrency, looseFiles.dashboards, spaceNames);
|
|
925
|
+
changes = dashboardChanges;
|
|
926
|
+
dashboardTotal = total;
|
|
927
|
+
}
|
|
928
|
+
const end = Date.now();
|
|
929
|
+
await analytics_1.LightdashAnalytics.track({
|
|
930
|
+
event: 'upload.completed',
|
|
931
|
+
properties: {
|
|
932
|
+
userId: config.user?.userUuid,
|
|
933
|
+
organizationId: config.user?.organizationUuid,
|
|
934
|
+
projectId,
|
|
935
|
+
chartsNum: chartTotal,
|
|
936
|
+
dashboardsNum: dashboardTotal,
|
|
937
|
+
timeToCompleted: (end - start) / 1000, // in seconds
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
logUploadChanges(changes);
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
globalState_1.default.log(styles.error(`\nError downloading: ${(0, common_1.getErrorMessage)(error)}`));
|
|
944
|
+
await analytics_1.LightdashAnalytics.track({
|
|
945
|
+
event: 'download.error',
|
|
946
|
+
properties: {
|
|
947
|
+
userId: config.user?.userUuid,
|
|
948
|
+
organizationId: config.user?.organizationUuid,
|
|
949
|
+
projectId,
|
|
950
|
+
error: (0, common_1.getErrorMessage)(error),
|
|
951
|
+
},
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
exports.uploadHandler = uploadHandler;
|