sqlite-hub 1.1.1 → 1.2.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.
Files changed (60) hide show
  1. package/README.md +17 -196
  2. package/bin/sqlite-hub.js +7 -2
  3. package/frontend/js/api.js +60 -0
  4. package/frontend/js/app.js +201 -5
  5. package/frontend/js/components/dropdownButton.js +92 -0
  6. package/frontend/js/components/modal.js +991 -876
  7. package/frontend/js/components/queryEditor.js +24 -15
  8. package/frontend/js/components/queryHistoryDetail.js +20 -1
  9. package/frontend/js/components/queryHistoryHeader.js +3 -2
  10. package/frontend/js/components/queryResults.js +1 -1
  11. package/frontend/js/components/rowEditorPanel.js +53 -13
  12. package/frontend/js/components/sidebar.js +1 -0
  13. package/frontend/js/components/topNav.js +1 -4
  14. package/frontend/js/components/workspaceOpenDropdown.js +52 -0
  15. package/frontend/js/router.js +2 -0
  16. package/frontend/js/store.js +450 -38
  17. package/frontend/js/utils/emailPreview.js +28 -0
  18. package/frontend/js/utils/markdownDocuments.js +17 -1
  19. package/frontend/js/utils/riskySql.js +165 -0
  20. package/frontend/js/views/backups.js +204 -0
  21. package/frontend/js/views/charts.js +1 -1
  22. package/frontend/js/views/data.js +42 -16
  23. package/frontend/js/views/documents.js +184 -154
  24. package/frontend/js/views/settings.js +1 -0
  25. package/frontend/js/views/structure.js +47 -33
  26. package/frontend/js/views/tableDesigner.js +23 -0
  27. package/frontend/styles/components.css +133 -0
  28. package/frontend/styles/tailwind.generated.css +1 -1
  29. package/frontend/styles/tokens.css +1 -0
  30. package/frontend/styles/views.css +33 -21
  31. package/package.json +2 -1
  32. package/server/routes/backups.js +120 -0
  33. package/server/routes/connections.js +26 -3
  34. package/server/routes/externalApi.js +6 -2
  35. package/server/server.js +3 -1
  36. package/server/services/databaseCommandService.js +4 -3
  37. package/server/services/sqlite/backupService.js +497 -66
  38. package/server/services/sqlite/connectionManager.js +2 -2
  39. package/server/services/sqlite/importService.js +25 -0
  40. package/server/services/sqlite/sqlExecutor.js +2 -0
  41. package/server/services/storage/appStateStore.js +379 -88
  42. package/tests/api-token-auth.test.js +45 -2
  43. package/tests/backup-manager.test.js +140 -0
  44. package/tests/backups-view.test.js +70 -0
  45. package/tests/charts-height-preset-storage.test.js +60 -0
  46. package/tests/charts-route-state.test.js +144 -0
  47. package/tests/cli-service-delegation.test.js +97 -0
  48. package/tests/connection-removal.test.js +52 -0
  49. package/tests/database-command-service.test.js +37 -2
  50. package/tests/documents-view.test.js +132 -0
  51. package/tests/dropdown-button.test.js +101 -0
  52. package/tests/email-preview.test.js +89 -0
  53. package/tests/markdown-documents.test.js +24 -0
  54. package/tests/query-editor.test.js +28 -0
  55. package/tests/query-history-detail.test.js +37 -0
  56. package/tests/query-history-header.test.js +30 -0
  57. package/tests/risky-sql.test.js +30 -0
  58. package/tests/row-editor-null-values.test.js +53 -0
  59. package/tests/settings-view.test.js +1 -0
  60. package/tests/structure-view.test.js +60 -0
@@ -19,12 +19,10 @@ import {
19
19
  suggestQueryChartType,
20
20
  validateQueryChartConfig,
21
21
  } from './lib/queryCharts.js';
22
- import {
23
- MEDIA_TAGGING_DEFAULT_MAPPING_TABLE,
24
- MEDIA_TAGGING_DEFAULT_TAG_TABLE,
25
- } from './lib/mediaTaggingDefaults.js';
22
+ import { MEDIA_TAGGING_DEFAULT_MAPPING_TABLE, MEDIA_TAGGING_DEFAULT_TAG_TABLE } from './lib/mediaTaggingDefaults.js';
26
23
  import { buildTextExportFilename } from './utils/exportFilenames.js';
27
24
  import { toggleMarkdownTodoLine } from './utils/markdownDocuments.js';
25
+ import { buildRiskySqlBackupContext, detectRiskySqlOperations } from './utils/riskySql.js';
28
26
 
29
27
  const listeners = new Set();
30
28
  const DEFAULT_SETTINGS = {
@@ -51,8 +49,10 @@ const UI_PREFERENCE_STORAGE_KEYS = {
51
49
  chartsHistoryVisible: 'sqlite_hub_charts_history_visible',
52
50
  chartsQueryVisible: 'sqlite_hub_charts_query_visible',
53
51
  chartsResultsVisible: 'sqlite_hub_charts_results_visible',
52
+ chartsHeightPreset: 'sqlite_hub_charts_height_preset',
54
53
  tableDesignerTablesVisible: 'sqlite_hub_table_designer_tables_visible',
55
54
  tableDesignerSqlPreviewVisible: 'sqlite_hub_table_designer_sql_preview_visible',
55
+ documentsVisible: 'sqlite_hub_documents_visible',
56
56
  documentsEditorVisible: 'sqlite_hub_documents_editor_visible',
57
57
  documentsPreviewVisible: 'sqlite_hub_documents_preview_visible',
58
58
  mediaTaggingViewerVisible: 'sqlite_hub_media_tagging_viewer_visible',
@@ -82,6 +82,7 @@ let chartsLoadVersion = 0;
82
82
  let chartsDetailLoadVersion = 0;
83
83
  let mediaTaggingPreviewVersion = 0;
84
84
  let documentsLoadVersion = 0;
85
+ let lastOpenDocumentId = null;
85
86
 
86
87
  function readStoredDataPageSize(fallback = DEFAULT_DATA_PAGE_SIZE) {
87
88
  try {
@@ -228,6 +229,15 @@ function storeChartsHistoryTab(tab) {
228
229
  }
229
230
  }
230
231
 
232
+ function readStoredChartsHeightPreset(fallback = 'medium') {
233
+ return normalizeChartsHeightPreset(readStoredString(UI_PREFERENCE_STORAGE_KEYS.chartsHeightPreset, fallback));
234
+ }
235
+
236
+ function storeChartsHeightPreset(preset) {
237
+ const normalizedPreset = normalizeChartsHeightPreset(preset);
238
+ storeString(UI_PREFERENCE_STORAGE_KEYS.chartsHeightPreset, normalizedPreset);
239
+ }
240
+
231
241
  function readStoredQueryHistoryTab(fallback = 'recent') {
232
242
  try {
233
243
  return normalizeQueryHistoryTab(globalThis.localStorage?.getItem(QUERY_HISTORY_TAB_STORAGE_KEY) ?? fallback);
@@ -256,6 +266,12 @@ const state = {
256
266
  backupLoading: false,
257
267
  error: null,
258
268
  },
269
+ backups: {
270
+ items: [],
271
+ loading: false,
272
+ operationLoading: false,
273
+ error: null,
274
+ },
259
275
  settings: {
260
276
  data: { ...DEFAULT_SETTINGS },
261
277
  section: 'information',
@@ -344,7 +360,7 @@ const state = {
344
360
  historyPanelVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsHistoryVisible, true),
345
361
  detailPanelVisible: false,
346
362
  selectedHistoryId: null,
347
- chartHeightPreset: 'medium',
363
+ chartHeightPreset: readStoredChartsHeightPreset(),
348
364
  queryVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsQueryVisible, true),
349
365
  resultsVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsResultsVisible, true),
350
366
  detail: null,
@@ -362,6 +378,7 @@ const state = {
362
378
  draftFilename: '',
363
379
  draftContent: '',
364
380
  dirty: false,
381
+ documentsVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.documentsVisible, true),
365
382
  editorVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.documentsEditorVisible, true),
366
383
  previewVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.documentsPreviewVisible, true),
367
384
  loading: false,
@@ -573,6 +590,7 @@ function clearQueryHistoryDetailState() {
573
590
  function requiresActiveDatabase(routeName) {
574
591
  return [
575
592
  'overview',
593
+ 'backups',
576
594
  'data',
577
595
  'editor',
578
596
  'editorResults',
@@ -611,7 +629,7 @@ function normalizeMediaTaggingRotationDegrees(value) {
611
629
  return 0;
612
630
  }
613
631
 
614
- return ((Math.round(numericValue / 90) * 90) % 360 + 360) % 360;
632
+ return (((Math.round(numericValue / 90) * 90) % 360) + 360) % 360;
615
633
  }
616
634
 
617
635
  function normalizeDataPageSize(value, fallback = 50) {
@@ -818,8 +836,10 @@ function areRowIdentitiesEqual(left, right) {
818
836
  return false;
819
837
  }
820
838
 
821
- return JSON.stringify(left.columns ?? []) === JSON.stringify(right.columns ?? [])
822
- && JSON.stringify(left.values ?? null) === JSON.stringify(right.values ?? null);
839
+ return (
840
+ JSON.stringify(left.columns ?? []) === JSON.stringify(right.columns ?? []) &&
841
+ JSON.stringify(left.values ?? null) === JSON.stringify(right.values ?? null)
842
+ );
823
843
  }
824
844
 
825
845
  function getSelectedDataBrowserRow(snapshot = state) {
@@ -851,9 +871,7 @@ function clearDataBrowserRowSelectionState() {
851
871
 
852
872
  function resolveDataBrowserRowSelection(rowIndex, identity = null) {
853
873
  const hasRowIndex =
854
- rowIndex !== null &&
855
- rowIndex !== undefined &&
856
- (typeof rowIndex !== 'string' || rowIndex.trim() !== '');
874
+ rowIndex !== null && rowIndex !== undefined && (typeof rowIndex !== 'string' || rowIndex.trim() !== '');
857
875
  const numericIndex = hasRowIndex ? Number(rowIndex) : NaN;
858
876
 
859
877
  if (Number.isInteger(numericIndex) && numericIndex >= 0) {
@@ -871,7 +889,11 @@ function resolveDataBrowserRowSelection(rowIndex, identity = null) {
871
889
  const selectedRow = getSelectedDataBrowserRow();
872
890
  const selectedIdentity = identity ?? selectedRow?.__identity ?? null;
873
891
 
874
- if (!selectedRow?.__identity || !selectedIdentity || !areRowIdentitiesEqual(selectedRow.__identity, selectedIdentity)) {
892
+ if (
893
+ !selectedRow?.__identity ||
894
+ !selectedIdentity ||
895
+ !areRowIdentitiesEqual(selectedRow.__identity, selectedIdentity)
896
+ ) {
875
897
  return {
876
898
  row: null,
877
899
  rowIndex: null,
@@ -947,7 +969,7 @@ function resetChartsState() {
947
969
  state.charts.historyPanelVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsHistoryVisible, true);
948
970
  state.charts.detailPanelVisible = false;
949
971
  state.charts.selectedHistoryId = null;
950
- state.charts.chartHeightPreset = 'medium';
972
+ state.charts.chartHeightPreset = readStoredChartsHeightPreset(state.charts.chartHeightPreset);
951
973
  state.charts.queryVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsQueryVisible, true);
952
974
  state.charts.resultsVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsResultsVisible, true);
953
975
  state.charts.detail = null;
@@ -982,6 +1004,10 @@ function applyCurrentDocument(document) {
982
1004
  state.documents.draftContent = document?.content ?? '';
983
1005
  state.documents.dirty = false;
984
1006
  state.documents.saveError = null;
1007
+
1008
+ if (document?.id) {
1009
+ lastOpenDocumentId = document.id;
1010
+ }
985
1011
  }
986
1012
 
987
1013
  function upsertDocumentSummary(document) {
@@ -1066,7 +1092,9 @@ function syncQueryHistoryItem(updatedItem) {
1066
1092
 
1067
1093
  const updatedItemId = String(updatedItem.id);
1068
1094
 
1069
- state.editor.history = state.editor.history.map(entry => (String(entry.id) === updatedItemId ? updatedItem : entry));
1095
+ state.editor.history = state.editor.history.map(entry =>
1096
+ String(entry.id) === updatedItemId ? updatedItem : entry,
1097
+ );
1070
1098
 
1071
1099
  if (String(state.editor.historyDetail?.id ?? '') === updatedItemId) {
1072
1100
  state.editor.historyDetail = updatedItem;
@@ -1195,7 +1223,10 @@ function setMissingDatabaseState() {
1195
1223
  state.tableDesigner.selectedTableName = null;
1196
1224
  state.tableDesigner.draft = null;
1197
1225
  state.tableDesigner.tablesVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerTablesVisible, true);
1198
- state.tableDesigner.sqlPreviewVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible, true);
1226
+ state.tableDesigner.sqlPreviewVisible = readStoredBoolean(
1227
+ UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible,
1228
+ true,
1229
+ );
1199
1230
  state.tableDesigner.pendingImportedDraft = null;
1200
1231
  state.tableDesigner.saving = false;
1201
1232
  state.tableDesigner.searchQuery = '';
@@ -1344,6 +1375,30 @@ async function refreshSettingsState() {
1344
1375
  }
1345
1376
  }
1346
1377
 
1378
+ async function refreshBackupsState() {
1379
+ if (!state.connections.active) {
1380
+ state.backups.items = [];
1381
+ state.backups.error = null;
1382
+ emitChange();
1383
+ return;
1384
+ }
1385
+
1386
+ state.backups.loading = true;
1387
+ state.backups.error = null;
1388
+ emitChange();
1389
+
1390
+ try {
1391
+ const response = await api.getBackups();
1392
+ state.backups.items = response.data ?? [];
1393
+ state.backups.error = null;
1394
+ } catch (error) {
1395
+ state.backups.error = normalizeError(error);
1396
+ } finally {
1397
+ state.backups.loading = false;
1398
+ emitChange();
1399
+ }
1400
+ }
1401
+
1347
1402
  export async function createSettingsApiToken(name) {
1348
1403
  state.settings.tokenSaving = true;
1349
1404
  state.settings.error = null;
@@ -1387,9 +1442,7 @@ export async function checkSettingsAppVersion() {
1387
1442
 
1388
1443
  state.settings.versionCheck = result;
1389
1444
  pushToast(
1390
- result?.updateAvailable
1391
- ? `SQLite Hub v${result.latestVersion} is available.`
1392
- : 'SQLite Hub is up to date.',
1445
+ result?.updateAvailable ? `SQLite Hub v${result.latestVersion} is available.` : 'SQLite Hub is up to date.',
1393
1446
  'success',
1394
1447
  );
1395
1448
  return result;
@@ -1671,11 +1724,17 @@ function getRequestedChartsHistoryId(route) {
1671
1724
  function resolveLoadableChartsHistoryId(route) {
1672
1725
  const requestedHistoryId = getRequestedChartsHistoryId(route);
1673
1726
 
1674
- if (!requestedHistoryId) {
1675
- return null;
1727
+ if (requestedHistoryId) {
1728
+ return state.charts.queries.some(item => item.id === requestedHistoryId) ? requestedHistoryId : null;
1676
1729
  }
1677
1730
 
1678
- return state.charts.queries.some(item => item.id === requestedHistoryId) ? requestedHistoryId : null;
1731
+ const selectedHistoryId = Number(state.charts.selectedHistoryId);
1732
+
1733
+ return Number.isInteger(selectedHistoryId) &&
1734
+ selectedHistoryId > 0 &&
1735
+ state.charts.queries.some(item => item.id === selectedHistoryId)
1736
+ ? selectedHistoryId
1737
+ : null;
1679
1738
  }
1680
1739
 
1681
1740
  function hasSettledChartsDetail(historyId) {
@@ -2371,6 +2430,10 @@ async function previewMediaTaggingDraft(options = {}) {
2371
2430
  function invalidateDatabaseCaches(options = {}) {
2372
2431
  const preserveDataBrowserState = options.preserveDataBrowserState === true;
2373
2432
 
2433
+ if (!preserveDataBrowserState) {
2434
+ lastOpenDocumentId = null;
2435
+ }
2436
+
2374
2437
  state.overview.data = null;
2375
2438
  state.dataBrowser.tables = [];
2376
2439
  if (!preserveDataBrowserState) {
@@ -2391,7 +2454,10 @@ function invalidateDatabaseCaches(options = {}) {
2391
2454
  state.tableDesigner.selectedTableName = null;
2392
2455
  state.tableDesigner.draft = null;
2393
2456
  state.tableDesigner.tablesVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerTablesVisible, true);
2394
- state.tableDesigner.sqlPreviewVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible, true);
2457
+ state.tableDesigner.sqlPreviewVisible = readStoredBoolean(
2458
+ UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible,
2459
+ true,
2460
+ );
2395
2461
  state.tableDesigner.pendingImportedDraft = null;
2396
2462
  state.tableDesigner.saving = false;
2397
2463
  state.tableDesigner.searchQuery = '';
@@ -2468,6 +2534,9 @@ async function loadRouteData(route, options = {}) {
2468
2534
  case 'overview':
2469
2535
  await loadOverview(version);
2470
2536
  return;
2537
+ case 'backups':
2538
+ await refreshBackupsState();
2539
+ return;
2471
2540
  case 'data':
2472
2541
  await loadData(version, route);
2473
2542
  return;
@@ -2505,7 +2574,7 @@ function pushToast(message, tone = 'muted') {
2505
2574
 
2506
2575
  window.setTimeout(() => {
2507
2576
  dismissToast(id);
2508
- }, 3600);
2577
+ }, 4500);
2509
2578
  }
2510
2579
 
2511
2580
  function withModalError(error) {
@@ -2845,7 +2914,9 @@ function normalizeDocumentInsertionRange(range = null) {
2845
2914
  const start = Number(range?.start);
2846
2915
  const end = Number(range?.end);
2847
2916
  const normalizedStart = Number.isInteger(start) ? Math.max(0, Math.min(contentLength, start)) : contentLength;
2848
- const normalizedEnd = Number.isInteger(end) ? Math.max(normalizedStart, Math.min(contentLength, end)) : normalizedStart;
2917
+ const normalizedEnd = Number.isInteger(end)
2918
+ ? Math.max(normalizedStart, Math.min(contentLength, end))
2919
+ : normalizedStart;
2849
2920
 
2850
2921
  return {
2851
2922
  start: normalizedStart,
@@ -2870,7 +2941,7 @@ function buildDocumentMarkdownInsertion(currentContent, insertion, range = null)
2870
2941
  return `${before}${prefix}${text}${suffix}${after}`;
2871
2942
  }
2872
2943
 
2873
- function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
2944
+ export function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
2874
2945
  if (!state.documents.selectedId) {
2875
2946
  return false;
2876
2947
  }
@@ -2888,6 +2959,63 @@ function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
2888
2959
  return true;
2889
2960
  }
2890
2961
 
2962
+ export async function insertMarkdownIntoLastOpenDocument(markdown) {
2963
+ if (state.route.name === 'documents' && state.documents.selectedId) {
2964
+ const inserted = insertMarkdownIntoCurrentDocumentDraft(markdown);
2965
+
2966
+ return {
2967
+ documentId: state.documents.selectedId,
2968
+ inserted,
2969
+ saved: false,
2970
+ };
2971
+ }
2972
+
2973
+ const documentId = lastOpenDocumentId ?? state.documents.selectedId;
2974
+
2975
+ if (!documentId) {
2976
+ return {
2977
+ documentId: null,
2978
+ inserted: false,
2979
+ saved: false,
2980
+ };
2981
+ }
2982
+
2983
+ const response = await api.getDocument(documentId);
2984
+ const document = response.data ?? null;
2985
+
2986
+ if (!document?.id) {
2987
+ return {
2988
+ documentId: null,
2989
+ inserted: false,
2990
+ saved: false,
2991
+ };
2992
+ }
2993
+
2994
+ const nextContent = buildDocumentMarkdownInsertion(document.content, markdown);
2995
+
2996
+ if (nextContent === document.content) {
2997
+ return {
2998
+ documentId: document.id,
2999
+ inserted: false,
3000
+ saved: false,
3001
+ };
3002
+ }
3003
+
3004
+ const updateResponse = await api.updateDocument(document.id, {
3005
+ content: nextContent,
3006
+ });
3007
+ const updatedDocument = updateResponse.data ?? document;
3008
+
3009
+ lastOpenDocumentId = updatedDocument.id;
3010
+ upsertDocumentSummary(updatedDocument);
3011
+
3012
+ return {
3013
+ documentId: updatedDocument.id,
3014
+ inserted: true,
3015
+ saved: true,
3016
+ };
3017
+ }
3018
+
2891
3019
  function getDocumentInsertQueryTitle(query) {
2892
3020
  return query?.displayTitle || query?.title || query?.previewSql || query?.rawSql || 'Saved query';
2893
3021
  }
@@ -3154,7 +3282,8 @@ export async function deleteCurrentDocument(options = {}) {
3154
3282
  try {
3155
3283
  await api.deleteDocument(documentId);
3156
3284
  state.documents.items = state.documents.items.filter(item => item.id !== documentId);
3157
- const nextDocument = state.documents.items[Math.max(0, Math.min(deletedIndex, state.documents.items.length - 1))] ?? null;
3285
+ const nextDocument =
3286
+ state.documents.items[Math.max(0, Math.min(deletedIndex, state.documents.items.length - 1))] ?? null;
3158
3287
  if (isSelectedDocument) {
3159
3288
  applyCurrentDocument(null);
3160
3289
  }
@@ -3244,9 +3373,7 @@ export function openDeleteDataRowModal(rowIndex) {
3244
3373
  const tableName = state.dataBrowser.selectedTable;
3245
3374
  const numericIndex = Number(rowIndex);
3246
3375
  const hasNumericIndex = Number.isInteger(numericIndex) && numericIndex >= 0;
3247
- const row = hasNumericIndex
3248
- ? state.dataBrowser.table?.rows?.[numericIndex] ?? null
3249
- : getSelectedDataBrowserRow();
3376
+ const row = hasNumericIndex ? (state.dataBrowser.table?.rows?.[numericIndex] ?? null) : getSelectedDataBrowserRow();
3250
3377
  const rowPreview = buildDeleteRowPreview(
3251
3378
  (state.dataBrowser.table?.columnMeta ?? [])
3252
3379
  .filter(column => column.visible)
@@ -3541,6 +3668,7 @@ export function setChartsHeightPreset(preset) {
3541
3668
  }
3542
3669
 
3543
3670
  state.charts.chartHeightPreset = nextPreset;
3671
+ storeChartsHeightPreset(nextPreset);
3544
3672
  emitChange();
3545
3673
  }
3546
3674
 
@@ -3691,10 +3819,36 @@ export async function chooseCreateDatabasePath() {
3691
3819
  }
3692
3820
 
3693
3821
  export async function submitImportSql(payload) {
3822
+ if (!payload?.skipSafety && !payload?.createNew && !payload?.targetConnectionId && !payload?.targetPath) {
3823
+ try {
3824
+ const preview = await api.previewImportSql(payload);
3825
+
3826
+ if (preview.data?.requiresSafetyBackup) {
3827
+ state.modal = {
3828
+ kind: 'backup-safety',
3829
+ operation: 'import',
3830
+ importPayload: payload,
3831
+ backupType: 'pre_import',
3832
+ backupName: 'Before import',
3833
+ notes: `SQL import, ${preview.data.statementCount ?? 0} statements, ${preview.data.sizeBytes ?? 0} bytes`,
3834
+ description: 'This SQL dump is large enough to change substantial data. Creating a backup allows you to restore the current state if the import goes wrong.',
3835
+ error: null,
3836
+ submitting: false,
3837
+ };
3838
+ emitChange();
3839
+ return null;
3840
+ }
3841
+ } catch (error) {
3842
+ withModalError(error);
3843
+ return null;
3844
+ }
3845
+ }
3846
+
3694
3847
  startModalSubmission();
3695
3848
 
3696
3849
  try {
3697
- const response = await api.importSql(payload);
3850
+ const { skipSafety, ...requestPayload } = payload ?? {};
3851
+ const response = await api.importSql(requestPayload);
3698
3852
  closeModalInternal();
3699
3853
  pushToast(response.message || 'SQL dump imported.', 'success');
3700
3854
  await refreshConnectionsState();
@@ -3770,28 +3924,257 @@ export async function removeConnection(id) {
3770
3924
  }
3771
3925
 
3772
3926
  export async function createActiveConnectionBackup() {
3927
+ openCreateBackupModal();
3928
+ return null;
3929
+ }
3930
+
3931
+ export function openCreateBackupModal(defaults = {}) {
3773
3932
  if (!state.connections.active) {
3774
3933
  pushToast('No active SQLite database selected for backup.', 'alert');
3934
+ return;
3935
+ }
3936
+
3937
+ state.modal = {
3938
+ kind: 'create-backup',
3939
+ name: defaults.name ?? '',
3940
+ notes: defaults.notes ?? '',
3941
+ backupType: defaults.type ?? 'manual',
3942
+ error: null,
3943
+ submitting: false,
3944
+ };
3945
+ emitChange();
3946
+ }
3947
+
3948
+ export async function submitCreateBackupConfirmation({ name = '', notes = '', type = 'manual' } = {}) {
3949
+ if (state.modal?.kind !== 'create-backup') {
3775
3950
  return null;
3776
3951
  }
3777
3952
 
3953
+ startModalSubmission();
3778
3954
  state.connections.backupLoading = true;
3779
- emitChange();
3955
+ state.backups.operationLoading = true;
3780
3956
 
3781
3957
  try {
3782
- const response = await api.createActiveConnectionBackup();
3783
- await refreshConnectionsState();
3958
+ const response = await api.createBackup({ name, notes, type });
3959
+ state.backups.items = [
3960
+ response.data,
3961
+ ...state.backups.items.filter(item => item.id !== response.data.id),
3962
+ ];
3963
+ closeModalInternal();
3784
3964
  pushToast(response.message || 'Backup created.', 'success');
3965
+ if (state.route.name === 'backups') {
3966
+ await refreshBackupsState();
3967
+ }
3785
3968
  return response.data;
3786
3969
  } catch (error) {
3787
- pushToast(normalizeError(error)?.message || 'Backup could not be created.', 'alert');
3970
+ withModalError(error);
3788
3971
  return null;
3789
3972
  } finally {
3790
3973
  state.connections.backupLoading = false;
3974
+ state.backups.operationLoading = false;
3791
3975
  emitChange();
3792
3976
  }
3793
3977
  }
3794
3978
 
3979
+ export function openEditBackupNotesModal(backupId) {
3980
+ const backup = state.backups.items.find(item => String(item.id) === String(backupId));
3981
+
3982
+ if (!backup) {
3983
+ pushToast('The selected backup could not be loaded.', 'alert');
3984
+ return;
3985
+ }
3986
+
3987
+ state.modal = {
3988
+ kind: 'edit-backup-notes',
3989
+ backupId: backup.id,
3990
+ backupName: backup.name,
3991
+ notes: backup.notes ?? '',
3992
+ error: null,
3993
+ submitting: false,
3994
+ };
3995
+ emitChange();
3996
+ }
3997
+
3998
+ export async function submitEditBackupNotesConfirmation(notes = '') {
3999
+ if (state.modal?.kind !== 'edit-backup-notes') {
4000
+ return null;
4001
+ }
4002
+
4003
+ const backupId = state.modal.backupId;
4004
+ startModalSubmission();
4005
+ state.backups.operationLoading = true;
4006
+
4007
+ try {
4008
+ const response = await api.updateBackup(backupId, { notes });
4009
+ state.backups.items = state.backups.items.map(item =>
4010
+ String(item.id) === String(response.data.id) ? response.data : item
4011
+ );
4012
+ closeModalInternal();
4013
+ pushToast(response.message || 'Backup notes updated.', 'success');
4014
+ return response.data;
4015
+ } catch (error) {
4016
+ withModalError(error);
4017
+ return null;
4018
+ } finally {
4019
+ state.backups.operationLoading = false;
4020
+ emitChange();
4021
+ }
4022
+ }
4023
+
4024
+ export function openDeleteBackupModal(backupId) {
4025
+ const backup = state.backups.items.find(item => String(item.id) === String(backupId));
4026
+
4027
+ if (!backup) {
4028
+ pushToast('The selected backup could not be loaded.', 'alert');
4029
+ return;
4030
+ }
4031
+
4032
+ state.modal = {
4033
+ kind: 'delete-backup',
4034
+ backupId: backup.id,
4035
+ backupName: backup.name,
4036
+ fileName: backup.fileName,
4037
+ error: null,
4038
+ submitting: false,
4039
+ };
4040
+ emitChange();
4041
+ }
4042
+
4043
+ export async function submitDeleteBackupConfirmation() {
4044
+ if (state.modal?.kind !== 'delete-backup') {
4045
+ return false;
4046
+ }
4047
+
4048
+ const backupId = state.modal.backupId;
4049
+ startModalSubmission();
4050
+ state.backups.operationLoading = true;
4051
+
4052
+ try {
4053
+ const response = await api.deleteBackup(backupId);
4054
+ state.backups.items = state.backups.items.filter(item => String(item.id) !== String(backupId));
4055
+ closeModalInternal();
4056
+ pushToast(response.message || 'Backup deleted.', 'muted');
4057
+ return true;
4058
+ } catch (error) {
4059
+ withModalError(error);
4060
+ return false;
4061
+ } finally {
4062
+ state.backups.operationLoading = false;
4063
+ emitChange();
4064
+ }
4065
+ }
4066
+
4067
+ export async function downloadBackup(backupId) {
4068
+ state.backups.operationLoading = true;
4069
+ emitChange();
4070
+
4071
+ try {
4072
+ await api.downloadBackup(backupId);
4073
+ pushToast('Backup download started.', 'success');
4074
+ return true;
4075
+ } catch (error) {
4076
+ state.backups.error = normalizeError(error);
4077
+ pushToast(state.backups.error.message || 'Backup could not be downloaded.', 'alert');
4078
+ return false;
4079
+ } finally {
4080
+ state.backups.operationLoading = false;
4081
+ emitChange();
4082
+ }
4083
+ }
4084
+
4085
+ export function openRestoreBackupModal(backupId) {
4086
+ const backup = state.backups.items.find(item => String(item.id) === String(backupId));
4087
+
4088
+ if (!backup) {
4089
+ pushToast('The selected backup could not be loaded.', 'alert');
4090
+ return;
4091
+ }
4092
+
4093
+ state.modal = {
4094
+ kind: 'backup-safety',
4095
+ operation: 'restore',
4096
+ backupId: backup.id,
4097
+ backupName: backup.name,
4098
+ backupType: 'pre_restore',
4099
+ backupNameBeforeOperation: `Before restore of ${backup.name}`,
4100
+ description: 'Restoring replaces the current database file with this backup.',
4101
+ error: null,
4102
+ submitting: false,
4103
+ };
4104
+ emitChange();
4105
+ }
4106
+
4107
+ async function restoreBackupById(backupId) {
4108
+ state.backups.operationLoading = true;
4109
+
4110
+ try {
4111
+ const response = await api.restoreBackup(backupId);
4112
+ pushToast(response.message || 'Backup restored.', 'success');
4113
+ await Promise.all([refreshConnectionsState(), refreshBackupsState()]);
4114
+ invalidateDatabaseCaches();
4115
+ return response.data;
4116
+ } finally {
4117
+ state.backups.operationLoading = false;
4118
+ emitChange();
4119
+ }
4120
+ }
4121
+
4122
+ async function createSafetyBackupFromModal(modal) {
4123
+ const response = await api.createBackup({
4124
+ name: modal.backupNameBeforeOperation ?? modal.backupName,
4125
+ notes: modal.notes,
4126
+ type: modal.backupType,
4127
+ });
4128
+
4129
+ if (state.route.name === 'backups') {
4130
+ await refreshBackupsState();
4131
+ }
4132
+
4133
+ return response.data;
4134
+ }
4135
+
4136
+ export async function submitBackupSafetyChoice(choice) {
4137
+ const modal = state.modal;
4138
+
4139
+ if (modal?.kind !== 'backup-safety') {
4140
+ return false;
4141
+ }
4142
+
4143
+ if (choice === 'cancel') {
4144
+ closeModalInternal();
4145
+ return false;
4146
+ }
4147
+
4148
+ startModalSubmission();
4149
+
4150
+ try {
4151
+ if (choice === 'create') {
4152
+ await createSafetyBackupFromModal(modal);
4153
+ }
4154
+
4155
+ if (modal.operation === 'restore') {
4156
+ await restoreBackupById(modal.backupId);
4157
+ } else if (modal.operation === 'sql') {
4158
+ await executeCurrentQuery({ skipSafety: true });
4159
+ } else if (modal.operation === 'import') {
4160
+ await submitImportSql({
4161
+ ...(modal.importPayload ?? {}),
4162
+ skipSafety: true,
4163
+ });
4164
+ }
4165
+
4166
+ closeModalInternal();
4167
+ return true;
4168
+ } catch (error) {
4169
+ withModalError(error);
4170
+ return false;
4171
+ }
4172
+ }
4173
+
4174
+ export async function refreshBackups() {
4175
+ await refreshBackupsState();
4176
+ }
4177
+
3795
4178
  export async function openOverviewInFinder() {
3796
4179
  try {
3797
4180
  const response = await api.openOverviewInFinder();
@@ -3864,7 +4247,26 @@ export function setEditorTab(tab) {
3864
4247
  emitChange();
3865
4248
  }
3866
4249
 
3867
- export async function executeCurrentQuery() {
4250
+ export async function executeCurrentQuery(options = {}) {
4251
+ const riskyOperations = detectRiskySqlOperations(state.editor.sqlText);
4252
+
4253
+ if (!options.skipSafety && riskyOperations.length) {
4254
+ const context = buildRiskySqlBackupContext(riskyOperations);
4255
+ state.modal = {
4256
+ kind: 'backup-safety',
4257
+ operation: 'sql',
4258
+ backupType: context.type,
4259
+ backupName: context.name,
4260
+ notes: state.editor.sqlText.slice(0, 600),
4261
+ description: context.description,
4262
+ sqlText: state.editor.sqlText,
4263
+ error: null,
4264
+ submitting: false,
4265
+ };
4266
+ emitChange();
4267
+ return false;
4268
+ }
4269
+
3868
4270
  state.editor.executing = true;
3869
4271
  state.editor.lastExecutedSql = state.editor.sqlText;
3870
4272
  state.editor.error = null;
@@ -4140,6 +4542,12 @@ export function setDocumentsSearchQuery(query) {
4140
4542
  emitChange();
4141
4543
  }
4142
4544
 
4545
+ export function toggleDocumentsPanel() {
4546
+ state.documents.documentsVisible = state.documents.documentsVisible === false;
4547
+ storeBoolean(UI_PREFERENCE_STORAGE_KEYS.documentsVisible, state.documents.documentsVisible);
4548
+ emitChange();
4549
+ }
4550
+
4143
4551
  export function setTableDesignerSqlPreviewVisibility(visible) {
4144
4552
  const nextValue = typeof visible === 'boolean' ? visible : !Boolean(state.tableDesigner.sqlPreviewVisible);
4145
4553
 
@@ -4707,8 +5115,7 @@ export function openDataRowByIdentity(tableName, identity) {
4707
5115
  export function preserveCurrentDataRowSelectionForReload() {
4708
5116
  const tableName = state.dataBrowser.selectedTable ?? state.dataBrowser.table?.name ?? '';
4709
5117
  const row = getSelectedDataBrowserRow();
4710
- const rowIndex =
4711
- typeof state.dataBrowser.selectedRowIndex === 'number' ? state.dataBrowser.selectedRowIndex : null;
5118
+ const rowIndex = typeof state.dataBrowser.selectedRowIndex === 'number' ? state.dataBrowser.selectedRowIndex : null;
4712
5119
 
4713
5120
  if (!tableName || !row) {
4714
5121
  return false;
@@ -5517,7 +5924,12 @@ export function getQueryMessages(snapshot = state) {
5517
5924
 
5518
5925
  return [
5519
5926
  ...snapshot.editor.result.statements.map(statement => ({
5520
- tone: statement.kind === 'resultSet' && statement.truncated ? 'warning' : statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
5927
+ tone:
5928
+ statement.kind === 'resultSet' && statement.truncated
5929
+ ? 'warning'
5930
+ : statement.kind === 'resultSet'
5931
+ ? 'success'
5932
+ : inferStatusTone(statement.keyword),
5521
5933
  label: `${statement.keyword} #${statement.index + 1}`,
5522
5934
  value:
5523
5935
  statement.kind === 'resultSet'