diff --git a/bun.lock b/bun.lock index 46d2f28474..f955bd3265 100644 --- a/bun.lock +++ b/bun.lock @@ -61,7 +61,7 @@ "kleur": "^4.1.5", "prettier": "^3.5.3", "prettier-plugin-svelte": "^3.3.3", - "sass": "^1.86.0", + "sass": "^1.97.3", "svelte": "^5.25.3", "svelte-check": "^4.1.5", "svelte-preprocess": "^6.0.3", @@ -926,7 +926,7 @@ "ignore": ["ignore@6.0.2", "", {}, "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A=="], - "immutable": ["immutable@5.1.4", "", {}, "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA=="], + "immutable": ["immutable@5.1.5", "", {}, "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1252,7 +1252,7 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - "sass": ["sass@1.94.2", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A=="], + "sass": ["sass@1.97.3", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg=="], "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], diff --git a/package.json b/package.json index 02c690cd37..d5b3d90599 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "kleur": "^4.1.5", "prettier": "^3.5.3", "prettier-plugin-svelte": "^3.3.3", - "sass": "^1.86.0", + "sass": "^1.97.3", "svelte": "^5.25.3", "svelte-check": "^4.1.5", "svelte-preprocess": "^6.0.3", @@ -91,4 +91,4 @@ "vite": "npm:rolldown-vite@latest", "minimatch": "10.2.3" } -} +} \ No newline at end of file diff --git a/src/lib/actions/analytics.ts b/src/lib/actions/analytics.ts index 023e2fbeee..6cdc1760e4 100644 --- a/src/lib/actions/analytics.ts +++ b/src/lib/actions/analytics.ts @@ -156,6 +156,7 @@ export enum Click { DatabaseDatabaseDelete = 'click_database_delete', DatabaseImportCsv = 'click_database_import_csv', DatabaseExportCsv = 'click_database_export_csv', + DatabaseExportJson = 'click_database_export_json', DomainCreateClick = 'click_domain_create', DomainDeleteClick = 'click_domain_delete', DomainRetryDomainVerificationClick = 'click_domain_retry_domain_verification', @@ -283,6 +284,7 @@ export enum Submit { DatabaseUpdateName = 'submit_database_update_name', DatabaseImportCsv = 'submit_database_import_csv', DatabaseExportCsv = 'submit_database_export_csv', + DatabaseExportJson = 'submit_database_export_json', DatabaseBackupDelete = 'submit_database_backup_delete', DatabaseBackupPolicyCreate = 'submit_database_backup_policy_create', diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte index 6467241f2a..a03d5157af 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/+page.svelte @@ -239,7 +239,7 @@ - Export CSV + Export diff --git a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/export/+page.svelte b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/export/+page.svelte index 9ed600090f..786e709448 100644 --- a/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/export/+page.svelte +++ b/src/routes/(console)/project-[region]-[project]/databases/database-[database]/table-[table]/export/+page.svelte @@ -12,14 +12,17 @@ import { table } from '../store'; import { queries, type TagValue } from '$lib/components/filters/store'; import { TagList } from '$lib/components/filters'; - import { Submit, trackEvent, trackError } from '$lib/actions/analytics'; + import { Click, Submit, trackEvent, trackError } from '$lib/actions/analytics'; import { toLocalDateTimeISO } from '$lib/helpers/date'; import { writable } from 'svelte/store'; import { isSmallViewport } from '$lib/stores/viewport'; + import { Query } from '@appwrite.io/console'; let showExitModal = $state(false); let formComponent: Form; let isSubmitting = $state(writable(false)); + let abortController: AbortController | null = null; + let exportProgress = $state(0); let localQueries = $state>(new Map()); const localTags = $derived(Array.from(localQueries.keys())); @@ -29,7 +32,9 @@ .split('T') .join('_') .slice(0, -4); - const filename = `${$table.name}_${timestamp}.csv`; + + let exportFormat = $state<'csv' | 'json'>('csv'); + let filename = $derived(`${$table.name}_${timestamp}.${exportFormat}`); let selectedColumns = $state>({}); let showAllColumns = $state(false); @@ -97,34 +102,169 @@ return; } - try { - await sdk - .forProject(page.params.region, page.params.project) - .migrations.createCSVExport({ - resourceId: `${page.params.database}:${page.params.table}`, - filename: filename, - columns: selectedCols, - queries: exportWithFilters ? Array.from(localQueries.values()) : [], - delimiter: delimiterMap[delimiter], - header: includeHeader, - notify: true + if (exportFormat === 'csv') { + try { + await sdk + .forProject(page.params.region, page.params.project) + .migrations.createCSVExport({ + resourceId: `${page.params.database}:${page.params.table}`, + filename: filename, + columns: selectedCols, + queries: exportWithFilters ? Array.from(localQueries.values()) : [], + delimiter: delimiterMap[delimiter], + header: includeHeader, + notify: true + }); + + addNotification({ + type: 'success', + message: 'CSV export has started' }); - addNotification({ - type: 'success', - message: 'CSV export has started' - }); + trackEvent(Submit.DatabaseExportCsv); + await goto(tableUrl); + } catch (error) { + addNotification({ + type: 'error', + message: error?.message || String(error) + }); - trackEvent(Submit.DatabaseExportCsv); + trackError(error, Submit.DatabaseExportCsv); + } + } else { + // Capture filename at export start — prevents mid-export format changes from + // causing a .csv file name on a JSON download (format selector stays visible). + const capturedFilename = filename; + $isSubmitting = true; + abortController = new AbortController(); + exportProgress = 0; - await goto(tableUrl); - } catch (error) { - addNotification({ - type: 'error', - message: error.message - }); + try { + const activeQueries = exportWithFilters ? Array.from(localQueries.values()) : []; + const allRows: Record[] = []; + const pageSize = 100; + let lastId: string | undefined = undefined; + let fetched = 0; + let total = Infinity; + let totalKnown = false; + + while (fetched < total) { + // Explicit guard in case the SDK doesn't throw an AbortError + if (abortController?.signal.aborted) break; + + // Strip any pagination-control queries the user may have added + // (limit, offset, cursorAfter, cursorBefore) to avoid conflicts + // with the paginator's own directives. + const sanitizedQueries = activeQueries.filter((q) => { + try { + const parsed = JSON.parse(q); + return !['limit', 'offset', 'cursorAfter', 'cursorBefore'].includes( + parsed?.method + ); + } catch { + return true; // keep unparseable queries as-is + } + }); + + const pageQueries = [Query.limit(pageSize), ...sanitizedQueries]; + + if (lastId) { + pageQueries.push(Query.cursorAfter(lastId)); + } + + const response = await sdk + .forProject(page.params.region, page.params.project) + .tablesDB.listRows({ + databaseId: page.params.database, + tableId: page.params.table, + queries: pageQueries, + signal: abortController.signal + }); + + total = response.total; + + if (response.rows.length === 0) break; + + // After first page, we know the real total — notify the user + if (!totalKnown) { + totalKnown = true; + addNotification({ + type: 'info', + message: `Exporting ${total.toLocaleString()} row${total !== 1 ? 's' : ''}…`, + timeout: 5000 + }); + if (total > 10_000) { + addNotification({ + type: 'warning', + message: `Large export (${total.toLocaleString()} rows) — this may use significant browser memory.` + }); + } + } + + const filtered = response.rows.map((row) => { + const obj: Record = {}; + for (const col of selectedCols) { + obj[col] = row[col]; + } + return obj; + }); + + allRows.push(...filtered); + fetched += response.rows.length; + lastId = response.rows[response.rows.length - 1].$id as string; + exportProgress = Math.min(100, Math.floor((fetched / total) * 100)); // Update progress + } + + if (!abortController.signal.aborted) { + const json = JSON.stringify(allRows, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = capturedFilename; + document.body.appendChild(anchor); + anchor.click(); - trackError(error, Submit.DatabaseExportCsv); + // Revoke the object URL after a short delay to ensure the browser has started the download + setTimeout(() => { + URL.revokeObjectURL(url); + document.body.removeChild(anchor); + }, 100); + + addNotification({ + type: 'success', + message: `JSON export complete — ${allRows.length} row${allRows.length !== 1 ? 's' : ''} downloaded` + }); + + trackEvent(Submit.DatabaseExportJson); + + await goto(tableUrl); + } + } catch (error) { + if (error?.name === 'AbortError') { + addNotification({ + type: 'warning', + message: 'JSON export cancelled.' + }); + } else { + addNotification({ + type: 'error', + message: error?.message || String(error) + }); + trackError(error, Submit.DatabaseExportJson); + } + } finally { + $isSubmitting = false; + exportProgress = 0; // Reset progress + abortController = null; // Clean up controller + } + } + } + + // Cancel the JSON export operation + function cancelExport() { + if (abortController) { + abortController.abort(); } } @@ -134,8 +274,22 @@ }); - +
+ {#if exportFormat === 'json' && $isSubmitting} +
+
+
+ +
+ {/if}
@@ -172,30 +326,46 @@
- - - - - Define how to separate values in the exported file. - - - - - - + { value: 'csv', label: 'CSV' }, + { value: 'json', label: 'JSON' } + ]} /> + + {#if exportFormat === 'csv'} + + + + + + Define how to separate values in the exported file. + + + + + + + {/if}
@@ -233,7 +403,12 @@ @@ -245,4 +420,22 @@ .disabled-checkbox :global(*) { cursor: unset; } + + .progress-container { + margin-top: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .export-progress-bar { + flex: 1; + height: 0.5rem; + border-radius: 0.25rem; + background: linear-gradient( + to right, + var(--color-success, #4caf50) var(--progress), + var(--color-neutral-80, #e0e0e0) 0% + ); + }