sqlite-hub 1.1.0 → 1.1.2

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 (58) 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 +140 -10
  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 +29 -18
  8. package/frontend/js/components/queryHistoryDetail.js +20 -1
  9. package/frontend/js/components/queryHistoryHeader.js +3 -2
  10. package/frontend/js/components/rowEditorPanel.js +1 -0
  11. package/frontend/js/components/sidebar.js +1 -0
  12. package/frontend/js/components/structureGraph.js +1 -0
  13. package/frontend/js/components/tableDesignerEditor.js +23 -42
  14. package/frontend/js/components/tableDesignerSidebar.js +7 -31
  15. package/frontend/js/components/tableDesignerSqlPreview.js +2 -1
  16. package/frontend/js/router.js +2 -0
  17. package/frontend/js/store.js +407 -38
  18. package/frontend/js/utils/riskySql.js +165 -0
  19. package/frontend/js/views/backups.js +176 -0
  20. package/frontend/js/views/charts.js +12 -11
  21. package/frontend/js/views/data.js +37 -35
  22. package/frontend/js/views/documents.js +231 -162
  23. package/frontend/js/views/mediaTagging.js +6 -5
  24. package/frontend/js/views/structure.js +47 -43
  25. package/frontend/js/views/tableDesigner.js +45 -1
  26. package/frontend/styles/components.css +237 -59
  27. package/frontend/styles/structure-graph.css +10 -2
  28. package/frontend/styles/tailwind.generated.css +1 -1
  29. package/frontend/styles/tokens.css +2 -0
  30. package/frontend/styles/views.css +84 -41
  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/dataBrowserService.js +11 -3
  40. package/server/services/sqlite/importService.js +25 -0
  41. package/server/services/sqlite/sqlExecutor.js +2 -0
  42. package/server/services/storage/appStateStore.js +379 -88
  43. package/tests/api-token-auth.test.js +45 -2
  44. package/tests/backup-manager.test.js +140 -0
  45. package/tests/backups-view.test.js +64 -0
  46. package/tests/charts-height-preset-storage.test.js +60 -0
  47. package/tests/charts-route-state.test.js +144 -0
  48. package/tests/cli-service-delegation.test.js +97 -0
  49. package/tests/connection-removal.test.js +52 -0
  50. package/tests/database-command-service.test.js +38 -3
  51. package/tests/documents-view.test.js +132 -0
  52. package/tests/dropdown-button.test.js +75 -0
  53. package/tests/query-editor.test.js +28 -0
  54. package/tests/query-history-detail.test.js +37 -0
  55. package/tests/query-history-header.test.js +30 -0
  56. package/tests/risky-sql.test.js +30 -0
  57. package/tests/row-editor-null-values.test.js +24 -0
  58. package/tests/structure-view.test.js +56 -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,9 +49,13 @@ 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',
53
+ tableDesignerTablesVisible: 'sqlite_hub_table_designer_tables_visible',
54
54
  tableDesignerSqlPreviewVisible: 'sqlite_hub_table_designer_sql_preview_visible',
55
+ documentsVisible: 'sqlite_hub_documents_visible',
55
56
  documentsEditorVisible: 'sqlite_hub_documents_editor_visible',
56
57
  documentsPreviewVisible: 'sqlite_hub_documents_preview_visible',
58
+ mediaTaggingViewerVisible: 'sqlite_hub_media_tagging_viewer_visible',
57
59
  };
58
60
  const QUERY_HISTORY_PAGE_SIZE = 30;
59
61
  const QUERY_HISTORY_RUN_LIMIT = 8;
@@ -226,6 +228,15 @@ function storeChartsHistoryTab(tab) {
226
228
  }
227
229
  }
228
230
 
231
+ function readStoredChartsHeightPreset(fallback = 'medium') {
232
+ return normalizeChartsHeightPreset(readStoredString(UI_PREFERENCE_STORAGE_KEYS.chartsHeightPreset, fallback));
233
+ }
234
+
235
+ function storeChartsHeightPreset(preset) {
236
+ const normalizedPreset = normalizeChartsHeightPreset(preset);
237
+ storeString(UI_PREFERENCE_STORAGE_KEYS.chartsHeightPreset, normalizedPreset);
238
+ }
239
+
229
240
  function readStoredQueryHistoryTab(fallback = 'recent') {
230
241
  try {
231
242
  return normalizeQueryHistoryTab(globalThis.localStorage?.getItem(QUERY_HISTORY_TAB_STORAGE_KEY) ?? fallback);
@@ -254,6 +265,12 @@ const state = {
254
265
  backupLoading: false,
255
266
  error: null,
256
267
  },
268
+ backups: {
269
+ items: [],
270
+ loading: false,
271
+ operationLoading: false,
272
+ error: null,
273
+ },
257
274
  settings: {
258
275
  data: { ...DEFAULT_SETTINGS },
259
276
  section: 'information',
@@ -342,7 +359,7 @@ const state = {
342
359
  historyPanelVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsHistoryVisible, true),
343
360
  detailPanelVisible: false,
344
361
  selectedHistoryId: null,
345
- chartHeightPreset: 'medium',
362
+ chartHeightPreset: readStoredChartsHeightPreset(),
346
363
  queryVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsQueryVisible, true),
347
364
  resultsVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsResultsVisible, true),
348
365
  detail: null,
@@ -356,9 +373,11 @@ const state = {
356
373
  items: [],
357
374
  selectedId: null,
358
375
  selected: null,
376
+ searchQuery: '',
359
377
  draftFilename: '',
360
378
  draftContent: '',
361
379
  dirty: false,
380
+ documentsVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.documentsVisible, true),
362
381
  editorVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.documentsEditorVisible, true),
363
382
  previewVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.documentsPreviewVisible, true),
364
383
  loading: false,
@@ -372,6 +391,7 @@ const state = {
372
391
  tables: [],
373
392
  selectedTableName: null,
374
393
  draft: null,
394
+ tablesVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerTablesVisible, true),
375
395
  sqlPreviewVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible, true),
376
396
  pendingImportedDraft: null,
377
397
  loading: false,
@@ -424,7 +444,7 @@ const state = {
424
444
  issues: [],
425
445
  dismissedIssueKeys: [],
426
446
  selectedTagKeys: [],
427
- workflowMediaDetailsVisible: true,
447
+ workflowMediaDetailsVisible: readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.mediaTaggingViewerVisible, true),
428
448
  workflowMediaRotationDegrees: 0,
429
449
  skippedMediaKeys: [],
430
450
  tagFormValues: {},
@@ -569,6 +589,7 @@ function clearQueryHistoryDetailState() {
569
589
  function requiresActiveDatabase(routeName) {
570
590
  return [
571
591
  'overview',
592
+ 'backups',
572
593
  'data',
573
594
  'editor',
574
595
  'editorResults',
@@ -607,7 +628,7 @@ function normalizeMediaTaggingRotationDegrees(value) {
607
628
  return 0;
608
629
  }
609
630
 
610
- return ((Math.round(numericValue / 90) * 90) % 360 + 360) % 360;
631
+ return (((Math.round(numericValue / 90) * 90) % 360) + 360) % 360;
611
632
  }
612
633
 
613
634
  function normalizeDataPageSize(value, fallback = 50) {
@@ -814,8 +835,10 @@ function areRowIdentitiesEqual(left, right) {
814
835
  return false;
815
836
  }
816
837
 
817
- return JSON.stringify(left.columns ?? []) === JSON.stringify(right.columns ?? [])
818
- && JSON.stringify(left.values ?? null) === JSON.stringify(right.values ?? null);
838
+ return (
839
+ JSON.stringify(left.columns ?? []) === JSON.stringify(right.columns ?? []) &&
840
+ JSON.stringify(left.values ?? null) === JSON.stringify(right.values ?? null)
841
+ );
819
842
  }
820
843
 
821
844
  function getSelectedDataBrowserRow(snapshot = state) {
@@ -847,9 +870,7 @@ function clearDataBrowserRowSelectionState() {
847
870
 
848
871
  function resolveDataBrowserRowSelection(rowIndex, identity = null) {
849
872
  const hasRowIndex =
850
- rowIndex !== null &&
851
- rowIndex !== undefined &&
852
- (typeof rowIndex !== 'string' || rowIndex.trim() !== '');
873
+ rowIndex !== null && rowIndex !== undefined && (typeof rowIndex !== 'string' || rowIndex.trim() !== '');
853
874
  const numericIndex = hasRowIndex ? Number(rowIndex) : NaN;
854
875
 
855
876
  if (Number.isInteger(numericIndex) && numericIndex >= 0) {
@@ -867,7 +888,11 @@ function resolveDataBrowserRowSelection(rowIndex, identity = null) {
867
888
  const selectedRow = getSelectedDataBrowserRow();
868
889
  const selectedIdentity = identity ?? selectedRow?.__identity ?? null;
869
890
 
870
- if (!selectedRow?.__identity || !selectedIdentity || !areRowIdentitiesEqual(selectedRow.__identity, selectedIdentity)) {
891
+ if (
892
+ !selectedRow?.__identity ||
893
+ !selectedIdentity ||
894
+ !areRowIdentitiesEqual(selectedRow.__identity, selectedIdentity)
895
+ ) {
871
896
  return {
872
897
  row: null,
873
898
  rowIndex: null,
@@ -943,7 +968,7 @@ function resetChartsState() {
943
968
  state.charts.historyPanelVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsHistoryVisible, true);
944
969
  state.charts.detailPanelVisible = false;
945
970
  state.charts.selectedHistoryId = null;
946
- state.charts.chartHeightPreset = 'medium';
971
+ state.charts.chartHeightPreset = readStoredChartsHeightPreset(state.charts.chartHeightPreset);
947
972
  state.charts.queryVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsQueryVisible, true);
948
973
  state.charts.resultsVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.chartsResultsVisible, true);
949
974
  state.charts.detail = null;
@@ -959,6 +984,7 @@ function resetDocumentsState() {
959
984
  state.documents.items = [];
960
985
  state.documents.selectedId = null;
961
986
  state.documents.selected = null;
987
+ state.documents.searchQuery = '';
962
988
  state.documents.draftFilename = '';
963
989
  state.documents.draftContent = '';
964
990
  state.documents.dirty = false;
@@ -1061,7 +1087,9 @@ function syncQueryHistoryItem(updatedItem) {
1061
1087
 
1062
1088
  const updatedItemId = String(updatedItem.id);
1063
1089
 
1064
- state.editor.history = state.editor.history.map(entry => (String(entry.id) === updatedItemId ? updatedItem : entry));
1090
+ state.editor.history = state.editor.history.map(entry =>
1091
+ String(entry.id) === updatedItemId ? updatedItem : entry,
1092
+ );
1065
1093
 
1066
1094
  if (String(state.editor.historyDetail?.id ?? '') === updatedItemId) {
1067
1095
  state.editor.historyDetail = updatedItem;
@@ -1189,7 +1217,11 @@ function setMissingDatabaseState() {
1189
1217
  state.tableDesigner.tables = [];
1190
1218
  state.tableDesigner.selectedTableName = null;
1191
1219
  state.tableDesigner.draft = null;
1192
- state.tableDesigner.sqlPreviewVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible, true);
1220
+ state.tableDesigner.tablesVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerTablesVisible, true);
1221
+ state.tableDesigner.sqlPreviewVisible = readStoredBoolean(
1222
+ UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible,
1223
+ true,
1224
+ );
1193
1225
  state.tableDesigner.pendingImportedDraft = null;
1194
1226
  state.tableDesigner.saving = false;
1195
1227
  state.tableDesigner.searchQuery = '';
@@ -1338,6 +1370,30 @@ async function refreshSettingsState() {
1338
1370
  }
1339
1371
  }
1340
1372
 
1373
+ async function refreshBackupsState() {
1374
+ if (!state.connections.active) {
1375
+ state.backups.items = [];
1376
+ state.backups.error = null;
1377
+ emitChange();
1378
+ return;
1379
+ }
1380
+
1381
+ state.backups.loading = true;
1382
+ state.backups.error = null;
1383
+ emitChange();
1384
+
1385
+ try {
1386
+ const response = await api.getBackups();
1387
+ state.backups.items = response.data ?? [];
1388
+ state.backups.error = null;
1389
+ } catch (error) {
1390
+ state.backups.error = normalizeError(error);
1391
+ } finally {
1392
+ state.backups.loading = false;
1393
+ emitChange();
1394
+ }
1395
+ }
1396
+
1341
1397
  export async function createSettingsApiToken(name) {
1342
1398
  state.settings.tokenSaving = true;
1343
1399
  state.settings.error = null;
@@ -1381,9 +1437,7 @@ export async function checkSettingsAppVersion() {
1381
1437
 
1382
1438
  state.settings.versionCheck = result;
1383
1439
  pushToast(
1384
- result?.updateAvailable
1385
- ? `SQLite Hub v${result.latestVersion} is available.`
1386
- : 'SQLite Hub is up to date.',
1440
+ result?.updateAvailable ? `SQLite Hub v${result.latestVersion} is available.` : 'SQLite Hub is up to date.',
1387
1441
  'success',
1388
1442
  );
1389
1443
  return result;
@@ -1665,11 +1719,17 @@ function getRequestedChartsHistoryId(route) {
1665
1719
  function resolveLoadableChartsHistoryId(route) {
1666
1720
  const requestedHistoryId = getRequestedChartsHistoryId(route);
1667
1721
 
1668
- if (!requestedHistoryId) {
1669
- return null;
1722
+ if (requestedHistoryId) {
1723
+ return state.charts.queries.some(item => item.id === requestedHistoryId) ? requestedHistoryId : null;
1670
1724
  }
1671
1725
 
1672
- return state.charts.queries.some(item => item.id === requestedHistoryId) ? requestedHistoryId : null;
1726
+ const selectedHistoryId = Number(state.charts.selectedHistoryId);
1727
+
1728
+ return Number.isInteger(selectedHistoryId) &&
1729
+ selectedHistoryId > 0 &&
1730
+ state.charts.queries.some(item => item.id === selectedHistoryId)
1731
+ ? selectedHistoryId
1732
+ : null;
1673
1733
  }
1674
1734
 
1675
1735
  function hasSettledChartsDetail(historyId) {
@@ -2384,7 +2444,11 @@ function invalidateDatabaseCaches(options = {}) {
2384
2444
  state.tableDesigner.tables = [];
2385
2445
  state.tableDesigner.selectedTableName = null;
2386
2446
  state.tableDesigner.draft = null;
2387
- state.tableDesigner.sqlPreviewVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible, true);
2447
+ state.tableDesigner.tablesVisible = readStoredBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerTablesVisible, true);
2448
+ state.tableDesigner.sqlPreviewVisible = readStoredBoolean(
2449
+ UI_PREFERENCE_STORAGE_KEYS.tableDesignerSqlPreviewVisible,
2450
+ true,
2451
+ );
2388
2452
  state.tableDesigner.pendingImportedDraft = null;
2389
2453
  state.tableDesigner.saving = false;
2390
2454
  state.tableDesigner.searchQuery = '';
@@ -2428,6 +2492,10 @@ function invalidateDatabaseCaches(options = {}) {
2428
2492
  state.mediaTagging.issues = [];
2429
2493
  state.mediaTagging.dismissedIssueKeys = [];
2430
2494
  state.mediaTagging.selectedTagKeys = [];
2495
+ state.mediaTagging.workflowMediaDetailsVisible = readStoredBoolean(
2496
+ UI_PREFERENCE_STORAGE_KEYS.mediaTaggingViewerVisible,
2497
+ true,
2498
+ );
2431
2499
  state.mediaTagging.workflowMediaRotationDegrees = 0;
2432
2500
  state.mediaTagging.skippedMediaKeys = [];
2433
2501
  state.mediaTagging.tagFormValues = {};
@@ -2457,6 +2525,9 @@ async function loadRouteData(route, options = {}) {
2457
2525
  case 'overview':
2458
2526
  await loadOverview(version);
2459
2527
  return;
2528
+ case 'backups':
2529
+ await refreshBackupsState();
2530
+ return;
2460
2531
  case 'data':
2461
2532
  await loadData(version, route);
2462
2533
  return;
@@ -2494,7 +2565,7 @@ function pushToast(message, tone = 'muted') {
2494
2565
 
2495
2566
  window.setTimeout(() => {
2496
2567
  dismissToast(id);
2497
- }, 3600);
2568
+ }, 4500);
2498
2569
  }
2499
2570
 
2500
2571
  function withModalError(error) {
@@ -2834,7 +2905,9 @@ function normalizeDocumentInsertionRange(range = null) {
2834
2905
  const start = Number(range?.start);
2835
2906
  const end = Number(range?.end);
2836
2907
  const normalizedStart = Number.isInteger(start) ? Math.max(0, Math.min(contentLength, start)) : contentLength;
2837
- const normalizedEnd = Number.isInteger(end) ? Math.max(normalizedStart, Math.min(contentLength, end)) : normalizedStart;
2908
+ const normalizedEnd = Number.isInteger(end)
2909
+ ? Math.max(normalizedStart, Math.min(contentLength, end))
2910
+ : normalizedStart;
2838
2911
 
2839
2912
  return {
2840
2913
  start: normalizedStart,
@@ -3143,7 +3216,8 @@ export async function deleteCurrentDocument(options = {}) {
3143
3216
  try {
3144
3217
  await api.deleteDocument(documentId);
3145
3218
  state.documents.items = state.documents.items.filter(item => item.id !== documentId);
3146
- const nextDocument = state.documents.items[Math.max(0, Math.min(deletedIndex, state.documents.items.length - 1))] ?? null;
3219
+ const nextDocument =
3220
+ state.documents.items[Math.max(0, Math.min(deletedIndex, state.documents.items.length - 1))] ?? null;
3147
3221
  if (isSelectedDocument) {
3148
3222
  applyCurrentDocument(null);
3149
3223
  }
@@ -3233,9 +3307,7 @@ export function openDeleteDataRowModal(rowIndex) {
3233
3307
  const tableName = state.dataBrowser.selectedTable;
3234
3308
  const numericIndex = Number(rowIndex);
3235
3309
  const hasNumericIndex = Number.isInteger(numericIndex) && numericIndex >= 0;
3236
- const row = hasNumericIndex
3237
- ? state.dataBrowser.table?.rows?.[numericIndex] ?? null
3238
- : getSelectedDataBrowserRow();
3310
+ const row = hasNumericIndex ? (state.dataBrowser.table?.rows?.[numericIndex] ?? null) : getSelectedDataBrowserRow();
3239
3311
  const rowPreview = buildDeleteRowPreview(
3240
3312
  (state.dataBrowser.table?.columnMeta ?? [])
3241
3313
  .filter(column => column.visible)
@@ -3530,6 +3602,7 @@ export function setChartsHeightPreset(preset) {
3530
3602
  }
3531
3603
 
3532
3604
  state.charts.chartHeightPreset = nextPreset;
3605
+ storeChartsHeightPreset(nextPreset);
3533
3606
  emitChange();
3534
3607
  }
3535
3608
 
@@ -3680,10 +3753,36 @@ export async function chooseCreateDatabasePath() {
3680
3753
  }
3681
3754
 
3682
3755
  export async function submitImportSql(payload) {
3756
+ if (!payload?.skipSafety && !payload?.createNew && !payload?.targetConnectionId && !payload?.targetPath) {
3757
+ try {
3758
+ const preview = await api.previewImportSql(payload);
3759
+
3760
+ if (preview.data?.requiresSafetyBackup) {
3761
+ state.modal = {
3762
+ kind: 'backup-safety',
3763
+ operation: 'import',
3764
+ importPayload: payload,
3765
+ backupType: 'pre_import',
3766
+ backupName: 'Before import',
3767
+ notes: `SQL import, ${preview.data.statementCount ?? 0} statements, ${preview.data.sizeBytes ?? 0} bytes`,
3768
+ 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.',
3769
+ error: null,
3770
+ submitting: false,
3771
+ };
3772
+ emitChange();
3773
+ return null;
3774
+ }
3775
+ } catch (error) {
3776
+ withModalError(error);
3777
+ return null;
3778
+ }
3779
+ }
3780
+
3683
3781
  startModalSubmission();
3684
3782
 
3685
3783
  try {
3686
- const response = await api.importSql(payload);
3784
+ const { skipSafety, ...requestPayload } = payload ?? {};
3785
+ const response = await api.importSql(requestPayload);
3687
3786
  closeModalInternal();
3688
3787
  pushToast(response.message || 'SQL dump imported.', 'success');
3689
3788
  await refreshConnectionsState();
@@ -3759,28 +3858,257 @@ export async function removeConnection(id) {
3759
3858
  }
3760
3859
 
3761
3860
  export async function createActiveConnectionBackup() {
3861
+ openCreateBackupModal();
3862
+ return null;
3863
+ }
3864
+
3865
+ export function openCreateBackupModal(defaults = {}) {
3762
3866
  if (!state.connections.active) {
3763
3867
  pushToast('No active SQLite database selected for backup.', 'alert');
3868
+ return;
3869
+ }
3870
+
3871
+ state.modal = {
3872
+ kind: 'create-backup',
3873
+ name: defaults.name ?? '',
3874
+ notes: defaults.notes ?? '',
3875
+ backupType: defaults.type ?? 'manual',
3876
+ error: null,
3877
+ submitting: false,
3878
+ };
3879
+ emitChange();
3880
+ }
3881
+
3882
+ export async function submitCreateBackupConfirmation({ name = '', notes = '', type = 'manual' } = {}) {
3883
+ if (state.modal?.kind !== 'create-backup') {
3764
3884
  return null;
3765
3885
  }
3766
3886
 
3887
+ startModalSubmission();
3767
3888
  state.connections.backupLoading = true;
3768
- emitChange();
3889
+ state.backups.operationLoading = true;
3769
3890
 
3770
3891
  try {
3771
- const response = await api.createActiveConnectionBackup();
3772
- await refreshConnectionsState();
3892
+ const response = await api.createBackup({ name, notes, type });
3893
+ state.backups.items = [
3894
+ response.data,
3895
+ ...state.backups.items.filter(item => item.id !== response.data.id),
3896
+ ];
3897
+ closeModalInternal();
3773
3898
  pushToast(response.message || 'Backup created.', 'success');
3899
+ if (state.route.name === 'backups') {
3900
+ await refreshBackupsState();
3901
+ }
3774
3902
  return response.data;
3775
3903
  } catch (error) {
3776
- pushToast(normalizeError(error)?.message || 'Backup could not be created.', 'alert');
3904
+ withModalError(error);
3777
3905
  return null;
3778
3906
  } finally {
3779
3907
  state.connections.backupLoading = false;
3908
+ state.backups.operationLoading = false;
3780
3909
  emitChange();
3781
3910
  }
3782
3911
  }
3783
3912
 
3913
+ export function openEditBackupNotesModal(backupId) {
3914
+ const backup = state.backups.items.find(item => String(item.id) === String(backupId));
3915
+
3916
+ if (!backup) {
3917
+ pushToast('The selected backup could not be loaded.', 'alert');
3918
+ return;
3919
+ }
3920
+
3921
+ state.modal = {
3922
+ kind: 'edit-backup-notes',
3923
+ backupId: backup.id,
3924
+ backupName: backup.name,
3925
+ notes: backup.notes ?? '',
3926
+ error: null,
3927
+ submitting: false,
3928
+ };
3929
+ emitChange();
3930
+ }
3931
+
3932
+ export async function submitEditBackupNotesConfirmation(notes = '') {
3933
+ if (state.modal?.kind !== 'edit-backup-notes') {
3934
+ return null;
3935
+ }
3936
+
3937
+ const backupId = state.modal.backupId;
3938
+ startModalSubmission();
3939
+ state.backups.operationLoading = true;
3940
+
3941
+ try {
3942
+ const response = await api.updateBackup(backupId, { notes });
3943
+ state.backups.items = state.backups.items.map(item =>
3944
+ String(item.id) === String(response.data.id) ? response.data : item
3945
+ );
3946
+ closeModalInternal();
3947
+ pushToast(response.message || 'Backup notes updated.', 'success');
3948
+ return response.data;
3949
+ } catch (error) {
3950
+ withModalError(error);
3951
+ return null;
3952
+ } finally {
3953
+ state.backups.operationLoading = false;
3954
+ emitChange();
3955
+ }
3956
+ }
3957
+
3958
+ export function openDeleteBackupModal(backupId) {
3959
+ const backup = state.backups.items.find(item => String(item.id) === String(backupId));
3960
+
3961
+ if (!backup) {
3962
+ pushToast('The selected backup could not be loaded.', 'alert');
3963
+ return;
3964
+ }
3965
+
3966
+ state.modal = {
3967
+ kind: 'delete-backup',
3968
+ backupId: backup.id,
3969
+ backupName: backup.name,
3970
+ fileName: backup.fileName,
3971
+ error: null,
3972
+ submitting: false,
3973
+ };
3974
+ emitChange();
3975
+ }
3976
+
3977
+ export async function submitDeleteBackupConfirmation() {
3978
+ if (state.modal?.kind !== 'delete-backup') {
3979
+ return false;
3980
+ }
3981
+
3982
+ const backupId = state.modal.backupId;
3983
+ startModalSubmission();
3984
+ state.backups.operationLoading = true;
3985
+
3986
+ try {
3987
+ const response = await api.deleteBackup(backupId);
3988
+ state.backups.items = state.backups.items.filter(item => String(item.id) !== String(backupId));
3989
+ closeModalInternal();
3990
+ pushToast(response.message || 'Backup deleted.', 'muted');
3991
+ return true;
3992
+ } catch (error) {
3993
+ withModalError(error);
3994
+ return false;
3995
+ } finally {
3996
+ state.backups.operationLoading = false;
3997
+ emitChange();
3998
+ }
3999
+ }
4000
+
4001
+ export async function downloadBackup(backupId) {
4002
+ state.backups.operationLoading = true;
4003
+ emitChange();
4004
+
4005
+ try {
4006
+ await api.downloadBackup(backupId);
4007
+ pushToast('Backup download started.', 'success');
4008
+ return true;
4009
+ } catch (error) {
4010
+ state.backups.error = normalizeError(error);
4011
+ pushToast(state.backups.error.message || 'Backup could not be downloaded.', 'alert');
4012
+ return false;
4013
+ } finally {
4014
+ state.backups.operationLoading = false;
4015
+ emitChange();
4016
+ }
4017
+ }
4018
+
4019
+ export function openRestoreBackupModal(backupId) {
4020
+ const backup = state.backups.items.find(item => String(item.id) === String(backupId));
4021
+
4022
+ if (!backup) {
4023
+ pushToast('The selected backup could not be loaded.', 'alert');
4024
+ return;
4025
+ }
4026
+
4027
+ state.modal = {
4028
+ kind: 'backup-safety',
4029
+ operation: 'restore',
4030
+ backupId: backup.id,
4031
+ backupName: backup.name,
4032
+ backupType: 'pre_restore',
4033
+ backupNameBeforeOperation: `Before restore of ${backup.name}`,
4034
+ description: 'Restoring replaces the current database file with this backup.',
4035
+ error: null,
4036
+ submitting: false,
4037
+ };
4038
+ emitChange();
4039
+ }
4040
+
4041
+ async function restoreBackupById(backupId) {
4042
+ state.backups.operationLoading = true;
4043
+
4044
+ try {
4045
+ const response = await api.restoreBackup(backupId);
4046
+ pushToast(response.message || 'Backup restored.', 'success');
4047
+ await Promise.all([refreshConnectionsState(), refreshBackupsState()]);
4048
+ invalidateDatabaseCaches();
4049
+ return response.data;
4050
+ } finally {
4051
+ state.backups.operationLoading = false;
4052
+ emitChange();
4053
+ }
4054
+ }
4055
+
4056
+ async function createSafetyBackupFromModal(modal) {
4057
+ const response = await api.createBackup({
4058
+ name: modal.backupNameBeforeOperation ?? modal.backupName,
4059
+ notes: modal.notes,
4060
+ type: modal.backupType,
4061
+ });
4062
+
4063
+ if (state.route.name === 'backups') {
4064
+ await refreshBackupsState();
4065
+ }
4066
+
4067
+ return response.data;
4068
+ }
4069
+
4070
+ export async function submitBackupSafetyChoice(choice) {
4071
+ const modal = state.modal;
4072
+
4073
+ if (modal?.kind !== 'backup-safety') {
4074
+ return false;
4075
+ }
4076
+
4077
+ if (choice === 'cancel') {
4078
+ closeModalInternal();
4079
+ return false;
4080
+ }
4081
+
4082
+ startModalSubmission();
4083
+
4084
+ try {
4085
+ if (choice === 'create') {
4086
+ await createSafetyBackupFromModal(modal);
4087
+ }
4088
+
4089
+ if (modal.operation === 'restore') {
4090
+ await restoreBackupById(modal.backupId);
4091
+ } else if (modal.operation === 'sql') {
4092
+ await executeCurrentQuery({ skipSafety: true });
4093
+ } else if (modal.operation === 'import') {
4094
+ await submitImportSql({
4095
+ ...(modal.importPayload ?? {}),
4096
+ skipSafety: true,
4097
+ });
4098
+ }
4099
+
4100
+ closeModalInternal();
4101
+ return true;
4102
+ } catch (error) {
4103
+ withModalError(error);
4104
+ return false;
4105
+ }
4106
+ }
4107
+
4108
+ export async function refreshBackups() {
4109
+ await refreshBackupsState();
4110
+ }
4111
+
3784
4112
  export async function openOverviewInFinder() {
3785
4113
  try {
3786
4114
  const response = await api.openOverviewInFinder();
@@ -3853,7 +4181,26 @@ export function setEditorTab(tab) {
3853
4181
  emitChange();
3854
4182
  }
3855
4183
 
3856
- export async function executeCurrentQuery() {
4184
+ export async function executeCurrentQuery(options = {}) {
4185
+ const riskyOperations = detectRiskySqlOperations(state.editor.sqlText);
4186
+
4187
+ if (!options.skipSafety && riskyOperations.length) {
4188
+ const context = buildRiskySqlBackupContext(riskyOperations);
4189
+ state.modal = {
4190
+ kind: 'backup-safety',
4191
+ operation: 'sql',
4192
+ backupType: context.type,
4193
+ backupName: context.name,
4194
+ notes: state.editor.sqlText.slice(0, 600),
4195
+ description: context.description,
4196
+ sqlText: state.editor.sqlText,
4197
+ error: null,
4198
+ submitting: false,
4199
+ };
4200
+ emitChange();
4201
+ return false;
4202
+ }
4203
+
3857
4204
  state.editor.executing = true;
3858
4205
  state.editor.lastExecutedSql = state.editor.sqlText;
3859
4206
  state.editor.error = null;
@@ -4108,6 +4455,12 @@ export function setTableDesignerSearchQuery(query) {
4108
4455
  emitChange();
4109
4456
  }
4110
4457
 
4458
+ export function toggleTableDesignerTablesPanel() {
4459
+ state.tableDesigner.tablesVisible = state.tableDesigner.tablesVisible === false;
4460
+ storeBoolean(UI_PREFERENCE_STORAGE_KEYS.tableDesignerTablesVisible, state.tableDesigner.tablesVisible);
4461
+ emitChange();
4462
+ }
4463
+
4111
4464
  export function setDataTableSearchQuery(query) {
4112
4465
  state.dataBrowser.tableSearchQuery = String(query ?? '');
4113
4466
  emitChange();
@@ -4118,6 +4471,17 @@ export function setStructureTableSearchQuery(query) {
4118
4471
  emitChange();
4119
4472
  }
4120
4473
 
4474
+ export function setDocumentsSearchQuery(query) {
4475
+ state.documents.searchQuery = String(query ?? '');
4476
+ emitChange();
4477
+ }
4478
+
4479
+ export function toggleDocumentsPanel() {
4480
+ state.documents.documentsVisible = state.documents.documentsVisible === false;
4481
+ storeBoolean(UI_PREFERENCE_STORAGE_KEYS.documentsVisible, state.documents.documentsVisible);
4482
+ emitChange();
4483
+ }
4484
+
4121
4485
  export function setTableDesignerSqlPreviewVisibility(visible) {
4122
4486
  const nextValue = typeof visible === 'boolean' ? visible : !Boolean(state.tableDesigner.sqlPreviewVisible);
4123
4487
 
@@ -4359,6 +4723,7 @@ export function setMediaTaggingWorkflowMediaDetailsVisible(value, options = {})
4359
4723
  }
4360
4724
 
4361
4725
  state.mediaTagging.workflowMediaDetailsVisible = nextValue;
4726
+ storeBoolean(UI_PREFERENCE_STORAGE_KEYS.mediaTaggingViewerVisible, nextValue);
4362
4727
 
4363
4728
  if (options.notify !== false) {
4364
4729
  emitChange();
@@ -4684,8 +5049,7 @@ export function openDataRowByIdentity(tableName, identity) {
4684
5049
  export function preserveCurrentDataRowSelectionForReload() {
4685
5050
  const tableName = state.dataBrowser.selectedTable ?? state.dataBrowser.table?.name ?? '';
4686
5051
  const row = getSelectedDataBrowserRow();
4687
- const rowIndex =
4688
- typeof state.dataBrowser.selectedRowIndex === 'number' ? state.dataBrowser.selectedRowIndex : null;
5052
+ const rowIndex = typeof state.dataBrowser.selectedRowIndex === 'number' ? state.dataBrowser.selectedRowIndex : null;
4689
5053
 
4690
5054
  if (!tableName || !row) {
4691
5055
  return false;
@@ -5494,7 +5858,12 @@ export function getQueryMessages(snapshot = state) {
5494
5858
 
5495
5859
  return [
5496
5860
  ...snapshot.editor.result.statements.map(statement => ({
5497
- tone: statement.kind === 'resultSet' && statement.truncated ? 'warning' : statement.kind === 'resultSet' ? 'success' : inferStatusTone(statement.keyword),
5861
+ tone:
5862
+ statement.kind === 'resultSet' && statement.truncated
5863
+ ? 'warning'
5864
+ : statement.kind === 'resultSet'
5865
+ ? 'success'
5866
+ : inferStatusTone(statement.keyword),
5498
5867
  label: `${statement.keyword} #${statement.index + 1}`,
5499
5868
  value:
5500
5869
  statement.kind === 'resultSet'