carto-cli 0.1.0-rc.1
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/.nvmrc +1 -0
- package/ARCHITECTURE.md +497 -0
- package/CHANGELOG.md +28 -0
- package/LICENSE +15 -0
- package/MAP_JSON.md +516 -0
- package/README.md +1595 -0
- package/WORKFLOW_JSON.md +623 -0
- package/dist/api.js +489 -0
- package/dist/auth-oauth.js +485 -0
- package/dist/auth-server.js +432 -0
- package/dist/browser.js +30 -0
- package/dist/colors.js +45 -0
- package/dist/commands/activity.js +427 -0
- package/dist/commands/admin.js +177 -0
- package/dist/commands/ai.js +489 -0
- package/dist/commands/auth.js +652 -0
- package/dist/commands/connections.js +412 -0
- package/dist/commands/credentials.js +606 -0
- package/dist/commands/imports.js +234 -0
- package/dist/commands/maps.js +1022 -0
- package/dist/commands/org.js +195 -0
- package/dist/commands/sql.js +326 -0
- package/dist/commands/users.js +459 -0
- package/dist/commands/workflows.js +1025 -0
- package/dist/config.js +320 -0
- package/dist/download.js +108 -0
- package/dist/help.js +285 -0
- package/dist/http.js +139 -0
- package/dist/index.js +1133 -0
- package/dist/logo.js +11 -0
- package/dist/prompt.js +67 -0
- package/dist/schedule-parser.js +287 -0
- package/jest.config.ts +43 -0
- package/package.json +53 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getCurrentProfile = getCurrentProfile;
|
|
7
|
+
exports.setCurrentProfile = setCurrentProfile;
|
|
8
|
+
exports.suggestProfileName = suggestProfileName;
|
|
9
|
+
exports.loadCredentials = loadCredentials;
|
|
10
|
+
exports.loadAllCredentials = loadAllCredentials;
|
|
11
|
+
exports.listProfileNames = listProfileNames;
|
|
12
|
+
exports.saveCredentials = saveCredentials;
|
|
13
|
+
exports.deleteCredentials = deleteCredentials;
|
|
14
|
+
exports.loadConfig = loadConfig;
|
|
15
|
+
exports.saveConfig = saveConfig;
|
|
16
|
+
exports.deleteConfig = deleteConfig;
|
|
17
|
+
exports.getApiToken = getApiToken;
|
|
18
|
+
exports.fetchTenantConfig = fetchTenantConfig;
|
|
19
|
+
exports.getTenantId = getTenantId;
|
|
20
|
+
exports.getLiteLLMUrl = getLiteLLMUrl;
|
|
21
|
+
exports.getAllUrls = getAllUrls;
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const path_1 = __importDefault(require("path"));
|
|
24
|
+
const os_1 = __importDefault(require("os"));
|
|
25
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
26
|
+
const http_1 = require("./http");
|
|
27
|
+
// In-memory cache for tenant config (per process)
|
|
28
|
+
const tenantConfigCache = new Map();
|
|
29
|
+
const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.carto');
|
|
30
|
+
const CONFIG_FILE = path_1.default.join(CONFIG_DIR, 'config.json');
|
|
31
|
+
const CREDENTIALS_FILE = path_1.default.join(os_1.default.homedir(), '.carto_credentials.json');
|
|
32
|
+
// Migrate old credentials file format to new format
|
|
33
|
+
function migrateCredentialsFile(data) {
|
|
34
|
+
// Check if already in new format
|
|
35
|
+
if (data.profiles !== undefined) {
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
// Old format: profiles are at root level
|
|
39
|
+
// Migrate to new format with current_profile and profiles structure
|
|
40
|
+
const migratedProfiles = {};
|
|
41
|
+
for (const [profileName, creds] of Object.entries(data)) {
|
|
42
|
+
// Only migrate if it looks like a profile (has token field)
|
|
43
|
+
if (typeof creds === 'object' && creds !== null && 'token' in creds) {
|
|
44
|
+
const oldCreds = creds;
|
|
45
|
+
migratedProfiles[profileName] = {
|
|
46
|
+
token: oldCreds.token,
|
|
47
|
+
tenant_id: oldCreds.tenant_id,
|
|
48
|
+
tenant_domain: oldCreds.tenant_domain,
|
|
49
|
+
// For old profiles, we don't have this info
|
|
50
|
+
organization_id: oldCreds.organization_id || '',
|
|
51
|
+
organization_name: oldCreds.organization_name || '',
|
|
52
|
+
user_email: oldCreds.user_email || '',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
current_profile: 'default', // Default to 'default' profile for backwards compatibility
|
|
58
|
+
profiles: migratedProfiles,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
// Load all credentials with migration
|
|
62
|
+
function loadCredentialsFile() {
|
|
63
|
+
try {
|
|
64
|
+
if (fs_1.default.existsSync(CREDENTIALS_FILE)) {
|
|
65
|
+
const data = fs_1.default.readFileSync(CREDENTIALS_FILE, 'utf-8');
|
|
66
|
+
const parsed = JSON.parse(data);
|
|
67
|
+
const migrated = migrateCredentialsFile(parsed);
|
|
68
|
+
// Save migrated format if it was changed
|
|
69
|
+
if (parsed.profiles === undefined && Object.keys(migrated.profiles).length > 0) {
|
|
70
|
+
fs_1.default.writeFileSync(CREDENTIALS_FILE, JSON.stringify(migrated, null, 2), 'utf-8');
|
|
71
|
+
}
|
|
72
|
+
return migrated;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// Ignore errors, return empty structure
|
|
77
|
+
}
|
|
78
|
+
return { profiles: {} };
|
|
79
|
+
}
|
|
80
|
+
// Get current profile name with fallbacks
|
|
81
|
+
function getCurrentProfile() {
|
|
82
|
+
// Priority: CARTO_PROFILE env var > current_profile field > 'default'
|
|
83
|
+
if (process.env.CARTO_PROFILE) {
|
|
84
|
+
return process.env.CARTO_PROFILE;
|
|
85
|
+
}
|
|
86
|
+
const file = loadCredentialsFile();
|
|
87
|
+
return file.current_profile || 'default';
|
|
88
|
+
}
|
|
89
|
+
// Set current profile
|
|
90
|
+
function setCurrentProfile(profileName) {
|
|
91
|
+
const file = loadCredentialsFile();
|
|
92
|
+
// Verify profile exists
|
|
93
|
+
if (!file.profiles[profileName]) {
|
|
94
|
+
throw new Error(`Profile '${profileName}' not found`);
|
|
95
|
+
}
|
|
96
|
+
file.current_profile = profileName;
|
|
97
|
+
fs_1.default.writeFileSync(CREDENTIALS_FILE, JSON.stringify(file, null, 2), 'utf-8');
|
|
98
|
+
}
|
|
99
|
+
// Generate suggested profile name
|
|
100
|
+
function suggestProfileName(credentials) {
|
|
101
|
+
const parts = [];
|
|
102
|
+
// Add auth environment prefix only for non-production
|
|
103
|
+
if (credentials.auth_environment && credentials.auth_environment !== 'production') {
|
|
104
|
+
parts.push(credentials.auth_environment);
|
|
105
|
+
}
|
|
106
|
+
if (credentials.tenant_id) {
|
|
107
|
+
parts.push(credentials.tenant_id);
|
|
108
|
+
}
|
|
109
|
+
if (credentials.organization_name) {
|
|
110
|
+
parts.push(credentials.organization_name);
|
|
111
|
+
}
|
|
112
|
+
if (credentials.user_email) {
|
|
113
|
+
parts.push(credentials.user_email);
|
|
114
|
+
}
|
|
115
|
+
return parts.join('/');
|
|
116
|
+
}
|
|
117
|
+
// Load specific profile credentials
|
|
118
|
+
function loadCredentials(profileName) {
|
|
119
|
+
try {
|
|
120
|
+
const file = loadCredentialsFile();
|
|
121
|
+
const targetProfile = profileName || getCurrentProfile();
|
|
122
|
+
return file.profiles[targetProfile] || null;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Load all credentials
|
|
129
|
+
function loadAllCredentials() {
|
|
130
|
+
return loadCredentialsFile();
|
|
131
|
+
}
|
|
132
|
+
// Get list of profile names
|
|
133
|
+
function listProfileNames() {
|
|
134
|
+
const file = loadCredentialsFile();
|
|
135
|
+
return Object.keys(file.profiles);
|
|
136
|
+
}
|
|
137
|
+
// Save credentials for a profile
|
|
138
|
+
function saveCredentials(profileName, credentials, setAsCurrent = true) {
|
|
139
|
+
try {
|
|
140
|
+
const file = loadCredentialsFile();
|
|
141
|
+
// Add or update the profile
|
|
142
|
+
file.profiles[profileName] = credentials;
|
|
143
|
+
// Set as current if requested or if it's the first profile
|
|
144
|
+
if (setAsCurrent || Object.keys(file.profiles).length === 1) {
|
|
145
|
+
file.current_profile = profileName;
|
|
146
|
+
}
|
|
147
|
+
fs_1.default.writeFileSync(CREDENTIALS_FILE, JSON.stringify(file, null, 2), 'utf-8');
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw new Error(`Failed to save credentials: ${error}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Delete credentials for a profile
|
|
154
|
+
function deleteCredentials(profileName) {
|
|
155
|
+
try {
|
|
156
|
+
const file = loadCredentialsFile();
|
|
157
|
+
if (!file.profiles[profileName]) {
|
|
158
|
+
throw new Error(`Profile '${profileName}' not found`);
|
|
159
|
+
}
|
|
160
|
+
delete file.profiles[profileName];
|
|
161
|
+
// If we deleted the current profile, switch to another one or clear
|
|
162
|
+
if (file.current_profile === profileName) {
|
|
163
|
+
const remainingProfiles = Object.keys(file.profiles);
|
|
164
|
+
file.current_profile = remainingProfiles.length > 0 ? remainingProfiles[0] : undefined;
|
|
165
|
+
}
|
|
166
|
+
// If no profiles left, delete the file
|
|
167
|
+
if (Object.keys(file.profiles).length === 0) {
|
|
168
|
+
fs_1.default.unlinkSync(CREDENTIALS_FILE);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
fs_1.default.writeFileSync(CREDENTIALS_FILE, JSON.stringify(file, null, 2), 'utf-8');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
throw new Error(`Failed to delete credentials: ${error}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Legacy config file functions (kept for backwards compatibility)
|
|
179
|
+
function loadConfig() {
|
|
180
|
+
try {
|
|
181
|
+
if (fs_1.default.existsSync(CONFIG_FILE)) {
|
|
182
|
+
const data = fs_1.default.readFileSync(CONFIG_FILE, 'utf-8');
|
|
183
|
+
return JSON.parse(data);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
// Ignore errors, return empty config
|
|
188
|
+
}
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
function saveConfig(config) {
|
|
192
|
+
try {
|
|
193
|
+
if (!fs_1.default.existsSync(CONFIG_DIR)) {
|
|
194
|
+
fs_1.default.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
fs_1.default.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
throw new Error(`Failed to save config: ${error}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function deleteConfig() {
|
|
203
|
+
try {
|
|
204
|
+
if (fs_1.default.existsSync(CONFIG_FILE)) {
|
|
205
|
+
fs_1.default.unlinkSync(CONFIG_FILE);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
throw new Error(`Failed to delete config: ${error}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function getApiToken(profileName) {
|
|
213
|
+
// Priority: environment variable > credentials file > legacy config file
|
|
214
|
+
if (process.env.CARTO_API_TOKEN) {
|
|
215
|
+
return process.env.CARTO_API_TOKEN;
|
|
216
|
+
}
|
|
217
|
+
const credentials = loadCredentials(profileName);
|
|
218
|
+
if (credentials) {
|
|
219
|
+
return credentials.token;
|
|
220
|
+
}
|
|
221
|
+
return loadConfig().apiToken;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Fetch tenant configuration from config.yaml
|
|
225
|
+
* @param tenant_domain The tenant domain (e.g., "clausa-26.dev.app.carto.com")
|
|
226
|
+
* @returns Tenant configuration with API URLs
|
|
227
|
+
*/
|
|
228
|
+
async function fetchTenantConfig(tenant_domain) {
|
|
229
|
+
// Check cache first
|
|
230
|
+
if (tenantConfigCache.has(tenant_domain)) {
|
|
231
|
+
return tenantConfigCache.get(tenant_domain);
|
|
232
|
+
}
|
|
233
|
+
const configUrl = `https://${tenant_domain}/config.yaml`;
|
|
234
|
+
try {
|
|
235
|
+
const response = await (0, http_1.request)(configUrl, { method: 'GET' });
|
|
236
|
+
if (response.statusCode !== 200) {
|
|
237
|
+
throw new Error(`HTTP ${response.statusCode}: ${response.body}`);
|
|
238
|
+
}
|
|
239
|
+
// Parse YAML
|
|
240
|
+
const config = js_yaml_1.default.load(response.body);
|
|
241
|
+
if (!config || !config.apis) {
|
|
242
|
+
throw new Error('Invalid config.yaml format: missing apis section');
|
|
243
|
+
}
|
|
244
|
+
const tenantConfig = {
|
|
245
|
+
accountsUrl: config.apis.accountsUrl,
|
|
246
|
+
workspaceUrl: config.apis.workspaceUrl,
|
|
247
|
+
baseUrl: config.apis.baseUrl,
|
|
248
|
+
aiApi: config.apis.aiApi,
|
|
249
|
+
};
|
|
250
|
+
// Validate required fields
|
|
251
|
+
if (!tenantConfig.accountsUrl || !tenantConfig.workspaceUrl || !tenantConfig.baseUrl || !tenantConfig.aiApi) {
|
|
252
|
+
throw new Error('Invalid config.yaml: missing required API URLs');
|
|
253
|
+
}
|
|
254
|
+
// Cache the config
|
|
255
|
+
tenantConfigCache.set(tenant_domain, tenantConfig);
|
|
256
|
+
return tenantConfig;
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
throw new Error(`Failed to fetch tenant configuration from ${configUrl}: ${error.message}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Get validated tenant configuration
|
|
264
|
+
* Loads credentials, validates tenant_domain, and fetches config once
|
|
265
|
+
*/
|
|
266
|
+
async function getValidatedTenantConfig(profileName) {
|
|
267
|
+
const credentials = loadCredentials(profileName);
|
|
268
|
+
if (!credentials) {
|
|
269
|
+
throw new Error('Not authenticated. Please run: carto auth login');
|
|
270
|
+
}
|
|
271
|
+
if (!credentials.tenant_domain) {
|
|
272
|
+
throw new Error('Profile configuration incomplete: tenant_domain not found. Please re-authenticate with: carto auth login');
|
|
273
|
+
}
|
|
274
|
+
return await fetchTenantConfig(credentials.tenant_domain);
|
|
275
|
+
}
|
|
276
|
+
async function getTenantId(profileName) {
|
|
277
|
+
const credentials = loadCredentials(profileName);
|
|
278
|
+
if (!credentials) {
|
|
279
|
+
throw new Error('Not authenticated. Please run: carto auth login');
|
|
280
|
+
}
|
|
281
|
+
return credentials.tenant_id;
|
|
282
|
+
}
|
|
283
|
+
// TODO: LiteLLM URL is not available in config.yaml
|
|
284
|
+
// Keep using hardcoded construction for now
|
|
285
|
+
function getLiteLLMUrl(profileName) {
|
|
286
|
+
const credentials = loadCredentials(profileName);
|
|
287
|
+
if (credentials) {
|
|
288
|
+
return `https://litellm-${credentials.tenant_id}.api.carto.com`;
|
|
289
|
+
}
|
|
290
|
+
return 'https://litellm-gcp-us-east1.api.carto.com';
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Helper function to get all URLs at once for ApiClient
|
|
294
|
+
* Returns URLs from config.yaml if tenant_domain is available
|
|
295
|
+
*/
|
|
296
|
+
async function getAllUrls(profileName) {
|
|
297
|
+
const credentials = loadCredentials(profileName);
|
|
298
|
+
// For M2M profiles with base_url set directly, skip tenant config lookup
|
|
299
|
+
if (credentials?.base_url) {
|
|
300
|
+
// Extract region from base_url (e.g., "gcp-us-east1" from "https://gcp-us-east1.api.carto.com")
|
|
301
|
+
const regionMatch = credentials.base_url.match(/https:\/\/([^.]+)\.api\.carto\.com/);
|
|
302
|
+
const region = regionMatch ? regionMatch[1] : 'gcp-us-east1';
|
|
303
|
+
return {
|
|
304
|
+
baseUrl: credentials.base_url,
|
|
305
|
+
workspaceUrl: `https://workspace-${region}.app.carto.com`,
|
|
306
|
+
accountsUrl: 'https://accounts.app.carto.com',
|
|
307
|
+
aiApiUrl: `https://ai-${region}.api.carto.com`,
|
|
308
|
+
litellmUrl: `https://litellm-${region}.api.carto.com`
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const config = await getValidatedTenantConfig(profileName);
|
|
312
|
+
const litellmUrl = getLiteLLMUrl(profileName);
|
|
313
|
+
return {
|
|
314
|
+
baseUrl: config.baseUrl,
|
|
315
|
+
workspaceUrl: config.workspaceUrl,
|
|
316
|
+
accountsUrl: config.accountsUrl,
|
|
317
|
+
aiApiUrl: config.aiApi,
|
|
318
|
+
litellmUrl
|
|
319
|
+
};
|
|
320
|
+
}
|
package/dist/download.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.downloadFile = downloadFile;
|
|
7
|
+
exports.formatBytes = formatBytes;
|
|
8
|
+
exports.getFilenameFromUrl = getFilenameFromUrl;
|
|
9
|
+
exports.sanitizeFilename = sanitizeFilename;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
/**
|
|
13
|
+
* Downloads a file from a URL to a local path
|
|
14
|
+
* @param url - The URL to download from
|
|
15
|
+
* @param outputPath - The local file path to save to
|
|
16
|
+
* @param onProgress - Optional callback for progress updates
|
|
17
|
+
*/
|
|
18
|
+
async function downloadFile(url, outputPath, onProgress) {
|
|
19
|
+
// Ensure output directory exists
|
|
20
|
+
const outputDir = path_1.default.dirname(outputPath);
|
|
21
|
+
if (!fs_1.default.existsSync(outputDir)) {
|
|
22
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const https = require('https');
|
|
26
|
+
const http = require('http');
|
|
27
|
+
const protocol = url.startsWith('https:') ? https : http;
|
|
28
|
+
protocol.get(url, (response) => {
|
|
29
|
+
if (response.statusCode === 301 || response.statusCode === 302) {
|
|
30
|
+
// Handle redirects
|
|
31
|
+
const redirectUrl = response.headers.location;
|
|
32
|
+
if (redirectUrl) {
|
|
33
|
+
downloadFile(redirectUrl, outputPath, onProgress).then(resolve).catch(reject);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (response.statusCode !== 200) {
|
|
38
|
+
reject(new Error(`Download failed with status ${response.statusCode}`));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const totalSize = parseInt(response.headers['content-length'] || '0', 10);
|
|
42
|
+
let downloadedSize = 0;
|
|
43
|
+
const fileStream = fs_1.default.createWriteStream(outputPath);
|
|
44
|
+
response.on('data', (chunk) => {
|
|
45
|
+
downloadedSize += chunk.length;
|
|
46
|
+
if (onProgress && totalSize > 0) {
|
|
47
|
+
const percentage = Math.round((downloadedSize / totalSize) * 100);
|
|
48
|
+
onProgress({
|
|
49
|
+
downloaded: downloadedSize,
|
|
50
|
+
total: totalSize,
|
|
51
|
+
percentage
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
response.pipe(fileStream);
|
|
56
|
+
fileStream.on('finish', () => {
|
|
57
|
+
fileStream.close();
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
fileStream.on('error', (err) => {
|
|
61
|
+
fs_1.default.unlinkSync(outputPath);
|
|
62
|
+
reject(err);
|
|
63
|
+
});
|
|
64
|
+
response.on('error', (err) => {
|
|
65
|
+
fs_1.default.unlinkSync(outputPath);
|
|
66
|
+
reject(err);
|
|
67
|
+
});
|
|
68
|
+
}).on('error', (err) => {
|
|
69
|
+
reject(err);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Format bytes to human-readable string
|
|
75
|
+
*/
|
|
76
|
+
function formatBytes(bytes) {
|
|
77
|
+
if (bytes === 0)
|
|
78
|
+
return '0 B';
|
|
79
|
+
const k = 1024;
|
|
80
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
81
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
82
|
+
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Extract filename from URL
|
|
86
|
+
*/
|
|
87
|
+
function getFilenameFromUrl(url) {
|
|
88
|
+
try {
|
|
89
|
+
const urlObj = new URL(url);
|
|
90
|
+
const pathname = urlObj.pathname;
|
|
91
|
+
const filename = pathname.substring(pathname.lastIndexOf('/') + 1);
|
|
92
|
+
// Decode URL-encoded characters
|
|
93
|
+
return decodeURIComponent(filename);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
return 'downloaded_file';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Sanitize filename to make it safe for filesystem
|
|
101
|
+
*/
|
|
102
|
+
function sanitizeFilename(filename) {
|
|
103
|
+
// Remove or replace unsafe characters
|
|
104
|
+
return filename
|
|
105
|
+
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
|
|
106
|
+
.replace(/\s+/g, '_')
|
|
107
|
+
.substring(0, 255); // Limit length
|
|
108
|
+
}
|