diff --git a/bun.lock b/bun.lock index 72b3384900..12377f9149 100644 --- a/bun.lock +++ b/bun.lock @@ -76,7 +76,7 @@ }, }, "overrides": { - "minimatch": "10.2.1", + "minimatch": "10.2.3", "vite": "npm:rolldown-vite@latest", }, "packages": { @@ -1086,7 +1086,7 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="], + "minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], diff --git a/package.json b/package.json index 57e3e7a94a..0ff0eb93c9 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,6 @@ }, "overrides": { "vite": "npm:rolldown-vite@latest", - "minimatch": "10.2.1" + "minimatch": "10.2.3" } } 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..616429ce46 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,150 @@ 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 + }); - trackEvent(Submit.DatabaseExportCsv); + trackError(error, Submit.DatabaseExportCsv); + } + } else { + $isSubmitting = true; + abortController = new AbortController(); // Initialize abort controller + exportProgress = 0; // Reset progress - 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) { + // Check for abort signal + if (abortController.signal.aborted) { + addNotification({ + type: 'warning', + message: 'JSON export cancelled.' + }); + break; // Exit the loop if aborted + } + + const pageQueries = [Query.limit(pageSize), ...activeQueries]; + + 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 + }); + + 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 = filename; + 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) { + addNotification({ + type: 'error', + message: error.message + }); + 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 +255,14 @@ }); - +
+ {#if exportFormat === 'json' && $isSubmitting} +
+
+ +
+ {/if}
@@ -172,30 +299,45 @@
- - - - - 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 +375,12 @@ @@ -245,4 +392,18 @@ .disabled-checkbox :global(*) { cursor: unset; } + + .cancel-btn { + background: none; + border: 1px solid currentColor; + border-radius: 0.25rem; + padding: 0.125rem 0.5rem; + cursor: pointer; + font-size: 0.75rem; + opacity: 0.8; + } + + .cancel-btn:hover { + opacity: 1; + }