sqlite-hub 1.1.2 → 1.4.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 (157) hide show
  1. package/README.md +106 -73
  2. package/bin/sqlite-hub.js +176 -2
  3. package/docs/API.md +122 -0
  4. package/docs/CLI.md +227 -0
  5. package/docs/DESIGN_GUIDELINES.md +45 -0
  6. package/docs/changelog.md +141 -0
  7. package/docs/shortkeys.md +27 -0
  8. package/docs/todo.md +16 -0
  9. package/examples/api/generate-types.js +52 -0
  10. package/frontend/assets/mockups/backups_1_1920.webp +0 -0
  11. package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
  12. package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
  13. package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
  14. package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
  15. package/frontend/assets/mockups/charts_1_1920.webp +0 -0
  16. package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
  17. package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
  18. package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
  19. package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
  20. package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
  21. package/frontend/assets/mockups/connections_1_1920.webp +0 -0
  22. package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
  23. package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
  24. package/frontend/assets/mockups/data_1_1920.webp +0 -0
  25. package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
  26. package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
  27. package/frontend/assets/mockups/documents_1_1920.webp +0 -0
  28. package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
  29. package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
  30. package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
  31. package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
  32. package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
  33. package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
  34. package/frontend/assets/mockups/overview_1_1920.webp +0 -0
  35. package/frontend/assets/mockups/settings_1_1920.webp +0 -0
  36. package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
  37. package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
  38. package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
  39. package/frontend/assets/mockups/structure_1_1920.webp +0 -0
  40. package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
  41. package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
  42. package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
  43. package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
  44. package/frontend/js/api.js +18 -0
  45. package/frontend/js/app.js +209 -13
  46. package/frontend/js/components/connectionCard.js +1 -1
  47. package/frontend/js/components/emptyState.js +4 -4
  48. package/frontend/js/components/generateTypesDropdown.js +33 -0
  49. package/frontend/js/components/metricCard.js +1 -1
  50. package/frontend/js/components/modal.js +211 -18
  51. package/frontend/js/components/pageHeader.js +1 -1
  52. package/frontend/js/components/queryHistoryDetail.js +3 -3
  53. package/frontend/js/components/queryHistoryHeader.js +1 -1
  54. package/frontend/js/components/queryHistoryPanel.js +1 -1
  55. package/frontend/js/components/queryResults.js +1 -1
  56. package/frontend/js/components/rowEditorPanel.js +65 -16
  57. package/frontend/js/components/topNav.js +1 -4
  58. package/frontend/js/components/workspaceOpenDropdown.js +52 -0
  59. package/frontend/js/router.js +28 -6
  60. package/frontend/js/store.js +489 -9
  61. package/frontend/js/utils/emailPreview.js +28 -0
  62. package/frontend/js/utils/markdownDocuments.js +17 -1
  63. package/frontend/js/views/backups.js +556 -36
  64. package/frontend/js/views/charts.js +9 -9
  65. package/frontend/js/views/connections.js +2 -2
  66. package/frontend/js/views/data.js +43 -17
  67. package/frontend/js/views/documents.js +3 -3
  68. package/frontend/js/views/editor.js +6 -6
  69. package/frontend/js/views/mediaTagging.js +7 -5
  70. package/frontend/js/views/overview.js +3 -3
  71. package/frontend/js/views/settings.js +4 -3
  72. package/frontend/js/views/structure.js +27 -10
  73. package/frontend/js/views/tableDesigner.js +23 -0
  74. package/frontend/styles/base.css +1 -40
  75. package/frontend/styles/components.css +114 -232
  76. package/frontend/styles/structure-graph.css +32 -68
  77. package/frontend/styles/tailwind.css +8 -4
  78. package/frontend/styles/tailwind.generated.css +1 -1
  79. package/frontend/styles/tokens.css +29 -21
  80. package/frontend/styles/views.css +94 -261
  81. package/package.json +18 -2
  82. package/server/routes/backups.js +18 -2
  83. package/server/routes/externalApi.js +36 -0
  84. package/server/routes/structure.js +22 -0
  85. package/server/services/databaseCommandService.js +14 -0
  86. package/server/services/sqlite/backupDiff.js +914 -0
  87. package/server/services/sqlite/backupService.js +77 -1
  88. package/server/services/sqlite/structureService.js +7 -0
  89. package/server/services/typeGenerationService.js +663 -0
  90. package/tailwind.config.cjs +0 -1
  91. package/.github/funding.yml +0 -2
  92. package/.github/workflows/ci.yml +0 -36
  93. package/database.sqlite +0 -0
  94. package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
  95. package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
  96. package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
  97. package/frontend/assets/mockups/connections_1200.webp +0 -0
  98. package/frontend/assets/mockups/data_1_1200.webp +0 -0
  99. package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
  100. package/frontend/assets/mockups/documents_1200.webp +0 -0
  101. package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
  102. package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
  103. package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
  104. package/frontend/assets/mockups/overview_1200.webp +0 -0
  105. package/frontend/assets/mockups/settings_1200.webp +0 -0
  106. package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
  107. package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
  108. package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
  109. package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
  110. package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
  111. package/frontend/assets/mockups/structure_1_1200.webp +0 -0
  112. package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
  113. package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
  114. package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
  115. package/tests/api-token-auth.test.js +0 -279
  116. package/tests/backup-manager.test.js +0 -140
  117. package/tests/backups-view.test.js +0 -64
  118. package/tests/charts-height-preset-storage.test.js +0 -60
  119. package/tests/charts-route-state.test.js +0 -144
  120. package/tests/check-constraint-options.test.js +0 -90
  121. package/tests/cli-args.test.js +0 -113
  122. package/tests/cli-service-delegation.test.js +0 -140
  123. package/tests/connection-removal.test.js +0 -52
  124. package/tests/connections-file-dialog-route.test.js +0 -89
  125. package/tests/copy-column-modal.test.js +0 -131
  126. package/tests/database-command-service.test.js +0 -174
  127. package/tests/database-documents.test.js +0 -85
  128. package/tests/documents-view.test.js +0 -132
  129. package/tests/dropdown-button.test.js +0 -75
  130. package/tests/export-blob.test.js +0 -99
  131. package/tests/export-filenames.test.js +0 -38
  132. package/tests/file-path-preview.test.js +0 -165
  133. package/tests/form-controls.test.js +0 -34
  134. package/tests/json-preview.test.js +0 -49
  135. package/tests/local-request-security.test.js +0 -85
  136. package/tests/markdown-documents.test.js +0 -79
  137. package/tests/native-file-dialog.test.js +0 -105
  138. package/tests/query-editor.test.js +0 -28
  139. package/tests/query-history-detail.test.js +0 -37
  140. package/tests/query-history-header.test.js +0 -30
  141. package/tests/query-results-truncation.test.js +0 -20
  142. package/tests/risky-sql.test.js +0 -30
  143. package/tests/row-editor-json.test.js +0 -82
  144. package/tests/row-editor-null-values.test.js +0 -155
  145. package/tests/row-editor-timestamp-preview.test.js +0 -192
  146. package/tests/security-paths.test.js +0 -84
  147. package/tests/settings-api-tokens-route.test.js +0 -97
  148. package/tests/settings-metadata.test.js +0 -114
  149. package/tests/settings-view.test.js +0 -107
  150. package/tests/sql-formatter.test.js +0 -173
  151. package/tests/sql-highlight.test.js +0 -38
  152. package/tests/sql-identifier-safety.test.js +0 -171
  153. package/tests/sql-result-limit.test.js +0 -66
  154. package/tests/structure-view.test.js +0 -56
  155. package/tests/table-designer-v2-unique-constraints.test.js +0 -78
  156. package/tests/table-scroll-state.test.js +0 -70
  157. package/tests/text-cell-stats.test.js +0 -38
@@ -60,6 +60,10 @@ const UI_PREFERENCE_STORAGE_KEYS = {
60
60
  const QUERY_HISTORY_PAGE_SIZE = 30;
61
61
  const QUERY_HISTORY_RUN_LIMIT = 8;
62
62
  const CHART_HEIGHT_PRESETS = new Set(['small', 'medium', 'large']);
63
+ const TYPE_GENERATION_TARGETS = new Set(['typescript', 'rust', 'kotlin', 'swift']);
64
+ const TYPE_GENERATION_NAMING = new Set(['preserve', 'camel', 'pascal', 'snake']);
65
+ const TYPE_GENERATION_NULLABLE = new Set(['native', 'optional']);
66
+ const TYPE_GENERATION_JSON_TYPES = new Set(['unknown', 'record', 'json-value']);
63
67
  const EDITOR_RESULT_TABS = new Set(['results', 'performance', 'messages']);
64
68
  const COPY_COLUMN_MODES = new Set(['column', 'column-with-header', 'first-10', 'markdown-todo']);
65
69
  const TEXT_EXPORT_FORMAT_LABELS = {
@@ -82,6 +86,7 @@ let chartsLoadVersion = 0;
82
86
  let chartsDetailLoadVersion = 0;
83
87
  let mediaTaggingPreviewVersion = 0;
84
88
  let documentsLoadVersion = 0;
89
+ let lastOpenDocumentId = null;
85
90
 
86
91
  function readStoredDataPageSize(fallback = DEFAULT_DATA_PAGE_SIZE) {
87
92
  try {
@@ -270,6 +275,18 @@ const state = {
270
275
  loading: false,
271
276
  operationLoading: false,
272
277
  error: null,
278
+ diff: {
279
+ visible: false,
280
+ backupId: null,
281
+ backupName: '',
282
+ backupCreatedAt: null,
283
+ currentLabel: '',
284
+ activeTab: 'schema',
285
+ requestId: null,
286
+ loading: false,
287
+ error: null,
288
+ data: null,
289
+ },
273
290
  },
274
291
  settings: {
275
292
  data: { ...DEFAULT_SETTINGS },
@@ -550,6 +567,21 @@ function getMediaTaggingRouteErrorKey(error = {}) {
550
567
  return `route:${String(error.code ?? '').trim()}:${String(error.message ?? '').trim()}`;
551
568
  }
552
569
 
570
+ function createEmptyBackupDiffState() {
571
+ return {
572
+ visible: false,
573
+ backupId: null,
574
+ backupName: '',
575
+ backupCreatedAt: null,
576
+ currentLabel: '',
577
+ activeTab: 'schema',
578
+ requestId: null,
579
+ loading: false,
580
+ error: null,
581
+ data: null,
582
+ };
583
+ }
584
+
553
585
  function syncDismissedMediaTaggingIssues() {
554
586
  const availableKeys = new Set();
555
587
 
@@ -863,6 +895,91 @@ function findDataBrowserRowIndexByIdentity(rows = [], identity) {
863
895
  return rows.findIndex(row => areRowIdentitiesEqual(row?.__identity, identity));
864
896
  }
865
897
 
898
+ function coerceDataRouteIdentityValue(column, value) {
899
+ const text = String(value ?? '');
900
+ const affinity = String(column?.affinity ?? '').toUpperCase();
901
+
902
+ if (['INTEGER', 'REAL', 'NUMERIC'].includes(affinity) && text.trim() !== '') {
903
+ const numberValue = Number(text);
904
+
905
+ if (Number.isFinite(numberValue)) {
906
+ return numberValue;
907
+ }
908
+ }
909
+
910
+ return value;
911
+ }
912
+
913
+ function parseCompositeDataRoutePrimaryKey(rawValue) {
914
+ try {
915
+ const parsed = JSON.parse(rawValue);
916
+
917
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
918
+ return parsed;
919
+ }
920
+ } catch {
921
+ return null;
922
+ }
923
+
924
+ return null;
925
+ }
926
+
927
+ function buildDataRouteRowIdentity(table, rawPrimaryKey) {
928
+ const primaryKeyValue = String(rawPrimaryKey ?? '').trim();
929
+ const identityStrategy = table?.identityStrategy ?? null;
930
+
931
+ if (!primaryKeyValue || !identityStrategy) {
932
+ return null;
933
+ }
934
+
935
+ if (identityStrategy.type === 'rowid') {
936
+ const numberValue = Number(primaryKeyValue);
937
+
938
+ return {
939
+ kind: 'rowid',
940
+ values: {
941
+ rowid: Number.isInteger(numberValue) ? numberValue : primaryKeyValue,
942
+ },
943
+ };
944
+ }
945
+
946
+ if (identityStrategy.type !== 'primaryKey') {
947
+ return null;
948
+ }
949
+
950
+ const columns = identityStrategy.columns ?? [];
951
+
952
+ if (columns.length === 1) {
953
+ const columnName = columns[0];
954
+ const column = table?.columnMeta?.find(candidate => candidate.name === columnName);
955
+
956
+ return {
957
+ kind: 'primaryKey',
958
+ columns,
959
+ values: {
960
+ [columnName]: coerceDataRouteIdentityValue(column, primaryKeyValue),
961
+ },
962
+ };
963
+ }
964
+
965
+ const parsedValue = parseCompositeDataRoutePrimaryKey(primaryKeyValue);
966
+
967
+ if (!parsedValue || columns.some(columnName => !Object.prototype.hasOwnProperty.call(parsedValue, columnName))) {
968
+ return null;
969
+ }
970
+
971
+ return {
972
+ kind: 'primaryKey',
973
+ columns,
974
+ values: Object.fromEntries(
975
+ columns.map(columnName => {
976
+ const column = table?.columnMeta?.find(candidate => candidate.name === columnName);
977
+ return [columnName, coerceDataRouteIdentityValue(column, parsedValue[columnName])];
978
+ }),
979
+ ),
980
+ };
981
+ }
982
+
866
983
  function clearDataBrowserRowSelectionState() {
867
984
  state.dataBrowser.selectedRowIndex = null;
868
985
  state.dataBrowser.selectedRow = null;
@@ -1003,6 +1120,10 @@ function applyCurrentDocument(document) {
1003
1120
  state.documents.draftContent = document?.content ?? '';
1004
1121
  state.documents.dirty = false;
1005
1122
  state.documents.saveError = null;
1123
+
1124
+ if (document?.id) {
1125
+ lastOpenDocumentId = document.id;
1126
+ }
1006
1127
  }
1007
1128
 
1008
1129
  function upsertDocumentSummary(document) {
@@ -1319,6 +1440,10 @@ function syncRouteContext() {
1319
1440
  state.documents.saveError = null;
1320
1441
  }
1321
1442
 
1443
+ if (route.name !== 'backups') {
1444
+ state.backups.diff = createEmptyBackupDiffState();
1445
+ }
1446
+
1322
1447
  if (route.name !== 'mediaTaggingSetup' && route.name !== 'mediaTaggingQueue') {
1323
1448
  state.mediaTagging.selectedTagKeys = [];
1324
1449
  }
@@ -1374,6 +1499,7 @@ async function refreshBackupsState() {
1374
1499
  if (!state.connections.active) {
1375
1500
  state.backups.items = [];
1376
1501
  state.backups.error = null;
1502
+ state.backups.diff = createEmptyBackupDiffState();
1377
1503
  emitChange();
1378
1504
  return;
1379
1505
  }
@@ -1951,8 +2077,9 @@ async function resolvePendingDataBrowserRow(version) {
1951
2077
  }
1952
2078
  }
1953
2079
 
1954
- async function loadDataTable(version) {
2080
+ async function loadDataTable(version, options = {}) {
1955
2081
  const tableName = state.dataBrowser.selectedTable;
2082
+ const routeRowPrimaryKey = options.rowPrimaryKey ?? null;
1956
2083
  const pageSize = normalizeDataPageSize(state.dataBrowser.pageSize, DEFAULT_DATA_PAGE_SIZE);
1957
2084
  const page = Math.max(1, Number(state.dataBrowser.page) || 1);
1958
2085
  const sortColumn = state.dataBrowser.sortColumn;
@@ -2006,6 +2133,15 @@ async function loadDataTable(version) {
2006
2133
  state.dataBrowser.searchColumn,
2007
2134
  );
2008
2135
  clearDataBrowserRowSelectionState();
2136
+ const routeRowIdentity = buildDataRouteRowIdentity(state.dataBrowser.table, routeRowPrimaryKey);
2137
+ if (routeRowPrimaryKey !== null && routeRowPrimaryKey !== undefined) {
2138
+ state.dataBrowser.pendingOpenRow = routeRowIdentity
2139
+ ? {
2140
+ tableName,
2141
+ identity: routeRowIdentity,
2142
+ }
2143
+ : null;
2144
+ }
2009
2145
  await resolvePendingDataBrowserRow(version);
2010
2146
  } catch (error) {
2011
2147
  if (version !== routeLoadVersion) {
@@ -2066,7 +2202,9 @@ async function loadData(version, route) {
2066
2202
  return;
2067
2203
  }
2068
2204
 
2069
- await loadDataTable(version);
2205
+ await loadDataTable(version, {
2206
+ rowPrimaryKey: route.params?.rowPrimaryKey ?? null,
2207
+ });
2070
2208
  } catch (error) {
2071
2209
  if (version !== routeLoadVersion) {
2072
2210
  return;
@@ -2425,7 +2563,12 @@ async function previewMediaTaggingDraft(options = {}) {
2425
2563
  function invalidateDatabaseCaches(options = {}) {
2426
2564
  const preserveDataBrowserState = options.preserveDataBrowserState === true;
2427
2565
 
2566
+ if (!preserveDataBrowserState) {
2567
+ lastOpenDocumentId = null;
2568
+ }
2569
+
2428
2570
  state.overview.data = null;
2571
+ state.backups.diff = createEmptyBackupDiffState();
2429
2572
  state.dataBrowser.tables = [];
2430
2573
  if (!preserveDataBrowserState) {
2431
2574
  state.dataBrowser.selectedTable = null;
@@ -2932,7 +3075,7 @@ function buildDocumentMarkdownInsertion(currentContent, insertion, range = null)
2932
3075
  return `${before}${prefix}${text}${suffix}${after}`;
2933
3076
  }
2934
3077
 
2935
- function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
3078
+ export function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
2936
3079
  if (!state.documents.selectedId) {
2937
3080
  return false;
2938
3081
  }
@@ -2950,6 +3093,63 @@ function insertMarkdownIntoCurrentDocumentDraft(markdown, range = null) {
2950
3093
  return true;
2951
3094
  }
2952
3095
 
3096
+ export async function insertMarkdownIntoLastOpenDocument(markdown) {
3097
+ if (state.route.name === 'documents' && state.documents.selectedId) {
3098
+ const inserted = insertMarkdownIntoCurrentDocumentDraft(markdown);
3099
+
3100
+ return {
3101
+ documentId: state.documents.selectedId,
3102
+ inserted,
3103
+ saved: false,
3104
+ };
3105
+ }
3106
+
3107
+ const documentId = lastOpenDocumentId ?? state.documents.selectedId;
3108
+
3109
+ if (!documentId) {
3110
+ return {
3111
+ documentId: null,
3112
+ inserted: false,
3113
+ saved: false,
3114
+ };
3115
+ }
3116
+
3117
+ const response = await api.getDocument(documentId);
3118
+ const document = response.data ?? null;
3119
+
3120
+ if (!document?.id) {
3121
+ return {
3122
+ documentId: null,
3123
+ inserted: false,
3124
+ saved: false,
3125
+ };
3126
+ }
3127
+
3128
+ const nextContent = buildDocumentMarkdownInsertion(document.content, markdown);
3129
+
3130
+ if (nextContent === document.content) {
3131
+ return {
3132
+ documentId: document.id,
3133
+ inserted: false,
3134
+ saved: false,
3135
+ };
3136
+ }
3137
+
3138
+ const updateResponse = await api.updateDocument(document.id, {
3139
+ content: nextContent,
3140
+ });
3141
+ const updatedDocument = updateResponse.data ?? document;
3142
+
3143
+ lastOpenDocumentId = updatedDocument.id;
3144
+ upsertDocumentSummary(updatedDocument);
3145
+
3146
+ return {
3147
+ documentId: updatedDocument.id,
3148
+ inserted: true,
3149
+ saved: true,
3150
+ };
3151
+ }
3152
+
2953
3153
  function getDocumentInsertQueryTitle(query) {
2954
3154
  return query?.displayTitle || query?.title || query?.previewSql || query?.rawSql || 'Saved query';
2955
3155
  }
@@ -3910,7 +4110,7 @@ export async function submitCreateBackupConfirmation({ name = '', notes = '', ty
3910
4110
  }
3911
4111
  }
3912
4112
 
3913
- export function openEditBackupNotesModal(backupId) {
4113
+ export function openEditBackupModal(backupId) {
3914
4114
  const backup = state.backups.items.find(item => String(item.id) === String(backupId));
3915
4115
 
3916
4116
  if (!backup) {
@@ -3919,7 +4119,7 @@ export function openEditBackupNotesModal(backupId) {
3919
4119
  }
3920
4120
 
3921
4121
  state.modal = {
3922
- kind: 'edit-backup-notes',
4122
+ kind: 'edit-backup',
3923
4123
  backupId: backup.id,
3924
4124
  backupName: backup.name,
3925
4125
  notes: backup.notes ?? '',
@@ -3929,8 +4129,8 @@ export function openEditBackupNotesModal(backupId) {
3929
4129
  emitChange();
3930
4130
  }
3931
4131
 
3932
- export async function submitEditBackupNotesConfirmation(notes = '') {
3933
- if (state.modal?.kind !== 'edit-backup-notes') {
4132
+ export async function submitEditBackupConfirmation({ name = '', notes = '' } = {}) {
4133
+ if (state.modal?.kind !== 'edit-backup') {
3934
4134
  return null;
3935
4135
  }
3936
4136
 
@@ -3939,12 +4139,12 @@ export async function submitEditBackupNotesConfirmation(notes = '') {
3939
4139
  state.backups.operationLoading = true;
3940
4140
 
3941
4141
  try {
3942
- const response = await api.updateBackup(backupId, { notes });
4142
+ const response = await api.updateBackup(backupId, { name, notes });
3943
4143
  state.backups.items = state.backups.items.map(item =>
3944
4144
  String(item.id) === String(response.data.id) ? response.data : item
3945
4145
  );
3946
4146
  closeModalInternal();
3947
- pushToast(response.message || 'Backup notes updated.', 'success');
4147
+ pushToast(response.message || 'Backup updated.', 'success');
3948
4148
  return response.data;
3949
4149
  } catch (error) {
3950
4150
  withModalError(error);
@@ -3986,6 +4186,9 @@ export async function submitDeleteBackupConfirmation() {
3986
4186
  try {
3987
4187
  const response = await api.deleteBackup(backupId);
3988
4188
  state.backups.items = state.backups.items.filter(item => String(item.id) !== String(backupId));
4189
+ if (String(state.backups.diff.backupId ?? '') === String(backupId)) {
4190
+ state.backups.diff = createEmptyBackupDiffState();
4191
+ }
3989
4192
  closeModalInternal();
3990
4193
  pushToast(response.message || 'Backup deleted.', 'muted');
3991
4194
  return true;
@@ -4016,6 +4219,75 @@ export async function downloadBackup(backupId) {
4016
4219
  }
4017
4220
  }
4018
4221
 
4222
+ export async function openBackupDiffDrawer(backupId) {
4223
+ const backup = state.backups.items.find(item => String(item.id) === String(backupId));
4224
+
4225
+ if (!backup) {
4226
+ pushToast('The selected backup could not be loaded.', 'alert');
4227
+ return null;
4228
+ }
4229
+
4230
+ const requestId = crypto.randomUUID();
4231
+ state.backups.diff = {
4232
+ visible: true,
4233
+ backupId: backup.id,
4234
+ backupName: backup.name,
4235
+ backupCreatedAt: backup.createdAt,
4236
+ currentLabel: state.connections.active?.label ?? 'Current database',
4237
+ activeTab: 'schema',
4238
+ requestId,
4239
+ loading: true,
4240
+ error: null,
4241
+ data: null,
4242
+ };
4243
+ state.backups.operationLoading = true;
4244
+ emitChange();
4245
+
4246
+ try {
4247
+ const response = await api.getBackupDiff(backup.id);
4248
+
4249
+ if (state.backups.diff.requestId === requestId) {
4250
+ state.backups.diff.data = response.data ?? null;
4251
+ state.backups.diff.backupName = response.data?.backup?.name ?? state.backups.diff.backupName;
4252
+ state.backups.diff.backupCreatedAt = response.data?.backup?.createdAt ?? state.backups.diff.backupCreatedAt;
4253
+ state.backups.diff.currentLabel = response.data?.current?.label ?? state.backups.diff.currentLabel;
4254
+ state.backups.diff.error = null;
4255
+ }
4256
+
4257
+ return response.data ?? null;
4258
+ } catch (error) {
4259
+ if (state.backups.diff.requestId === requestId) {
4260
+ state.backups.diff.error = normalizeError(error);
4261
+ } else {
4262
+ pushToast(normalizeError(error).message || 'Backup comparison failed.', 'alert');
4263
+ }
4264
+
4265
+ return null;
4266
+ } finally {
4267
+ state.backups.operationLoading = false;
4268
+
4269
+ if (state.backups.diff.requestId === requestId) {
4270
+ state.backups.diff.loading = false;
4271
+ }
4272
+
4273
+ emitChange();
4274
+ }
4275
+ }
4276
+
4277
+ export function setBackupDiffTab(tab) {
4278
+ if (!state.backups.diff.visible) {
4279
+ return;
4280
+ }
4281
+
4282
+ state.backups.diff.activeTab = tab === 'data' ? 'data' : 'schema';
4283
+ emitChange();
4284
+ }
4285
+
4286
+ export function closeBackupDiffDrawer() {
4287
+ state.backups.diff = createEmptyBackupDiffState();
4288
+ emitChange();
4289
+ }
4290
+
4019
4291
  export function openRestoreBackupModal(backupId) {
4020
4292
  const backup = state.backups.items.find(item => String(item.id) === String(backupId));
4021
4293
 
@@ -4444,6 +4716,214 @@ export async function selectStructureEntry(name) {
4444
4716
  await loadStructureDetail(++routeLoadVersion);
4445
4717
  }
4446
4718
 
4719
+ function getDefaultTypeGenerationOptions(target = 'typescript') {
4720
+ const propertyNaming = target === 'rust' ? 'snake' : 'camel';
4721
+
4722
+ return {
4723
+ typeName: '',
4724
+ propertyNaming,
4725
+ nullableMode: 'native',
4726
+ includeComments: false,
4727
+ includeDefaultsAsComments: false,
4728
+ includeGeneratedColumns: true,
4729
+ includeHiddenColumns: false,
4730
+ exportDeclaration: true,
4731
+ jsonType: 'unknown',
4732
+ };
4733
+ }
4734
+
4735
+ function normalizeTypeGenerationOptions(options = {}, target = 'typescript') {
4736
+ const defaults = getDefaultTypeGenerationOptions(target);
4737
+ const propertyNaming = TYPE_GENERATION_NAMING.has(options.propertyNaming)
4738
+ ? options.propertyNaming
4739
+ : defaults.propertyNaming;
4740
+ const nullableMode =
4741
+ TYPE_GENERATION_NULLABLE.has(options.nullableMode) && (target === 'typescript' || options.nullableMode !== 'optional')
4742
+ ? options.nullableMode
4743
+ : 'native';
4744
+ const jsonType = TYPE_GENERATION_JSON_TYPES.has(options.jsonType) ? options.jsonType : 'unknown';
4745
+
4746
+ return {
4747
+ ...defaults,
4748
+ ...options,
4749
+ propertyNaming,
4750
+ nullableMode,
4751
+ jsonType,
4752
+ typeName: String(options.typeName ?? ''),
4753
+ };
4754
+ }
4755
+
4756
+ function getStructureTableNames() {
4757
+ return (state.structure.data?.grouped?.tables ?? [])
4758
+ .filter(table => table?.type === 'table')
4759
+ .map(table => String(table.name ?? '').trim())
4760
+ .filter(Boolean);
4761
+ }
4762
+
4763
+ function getTypeGenerationRequestOptions(modal) {
4764
+ const options = { ...modal.options };
4765
+
4766
+ if (!String(options.typeName ?? '').trim()) {
4767
+ delete options.typeName;
4768
+ }
4769
+
4770
+ if (modal.target !== 'typescript') {
4771
+ delete options.jsonType;
4772
+ }
4773
+
4774
+ return options;
4775
+ }
4776
+
4777
+ function combineGeneratedTypeFiles(files = []) {
4778
+ return files
4779
+ .map(file => [`// ${file.fileName}`, file.code].filter(Boolean).join('\n'))
4780
+ .join('\n\n');
4781
+ }
4782
+
4783
+ async function refreshTypeGenerationPreview() {
4784
+ const modal = state.modal;
4785
+
4786
+ if (modal?.kind !== 'generate-types') {
4787
+ return;
4788
+ }
4789
+
4790
+ const requestId = (modal.previewRequestId ?? 0) + 1;
4791
+ modal.previewRequestId = requestId;
4792
+ modal.loading = true;
4793
+ modal.error = null;
4794
+ emitChange();
4795
+
4796
+ try {
4797
+ const options = getTypeGenerationRequestOptions(modal);
4798
+ const isAllTables = modal.scope === 'all';
4799
+ const tableNames = isAllTables ? modal.tableNames : [modal.tableName];
4800
+
4801
+ if (!tableNames.length) {
4802
+ throw new Error('No tables available for type generation.');
4803
+ }
4804
+
4805
+ const responses = await Promise.all(
4806
+ tableNames.map(async tableName => {
4807
+ const response = await api.generateStructureTypes(tableName, {
4808
+ target: modal.target,
4809
+ options,
4810
+ });
4811
+
4812
+ return {
4813
+ ...response.data,
4814
+ tableName,
4815
+ warnings: response.warnings ?? [],
4816
+ metadata: response.metadata ?? {},
4817
+ };
4818
+ }),
4819
+ );
4820
+
4821
+ if (state.modal?.kind !== 'generate-types' || state.modal.previewRequestId !== requestId) {
4822
+ return;
4823
+ }
4824
+
4825
+ if (isAllTables) {
4826
+ state.modal.result = {
4827
+ scope: 'all',
4828
+ target: modal.target,
4829
+ fileName: `${responses.length} files`,
4830
+ files: responses,
4831
+ code: combineGeneratedTypeFiles(responses),
4832
+ };
4833
+ state.modal.warnings = responses.flatMap(file =>
4834
+ (file.warnings ?? []).map(warning => `${file.tableName}: ${warning}`),
4835
+ );
4836
+ state.modal.metadata = {
4837
+ fileCount: responses.length,
4838
+ columnCount: responses.reduce((count, file) => count + Number(file.metadata?.columnCount ?? 0), 0),
4839
+ };
4840
+ } else {
4841
+ const [response] = responses;
4842
+ state.modal.result = response;
4843
+ state.modal.warnings = response.warnings ?? [];
4844
+ state.modal.metadata = response.metadata ?? {};
4845
+ }
4846
+ } catch (error) {
4847
+ if (state.modal?.kind !== 'generate-types' || state.modal.previewRequestId !== requestId) {
4848
+ return;
4849
+ }
4850
+
4851
+ state.modal.error = normalizeError(error);
4852
+ state.modal.result = null;
4853
+ state.modal.warnings = error.warnings ?? [];
4854
+ } finally {
4855
+ if (state.modal?.kind === 'generate-types' && state.modal.previewRequestId === requestId) {
4856
+ state.modal.loading = false;
4857
+ emitChange();
4858
+ }
4859
+ }
4860
+ }
4861
+
4862
+ export async function openGenerateTypesModal(tableName, target = 'typescript', scope = 'selected') {
4863
+ const normalizedTarget = TYPE_GENERATION_TARGETS.has(target) ? target : 'typescript';
4864
+ const normalizedScope = scope === 'all' ? 'all' : 'selected';
4865
+ const tableNames = getStructureTableNames();
4866
+ const normalizedTableName = String(tableName ?? state.structure.selectedName ?? '').trim();
4867
+
4868
+ if (normalizedScope === 'all' && !tableNames.length) {
4869
+ pushToast('No tables available for type generation.', 'alert');
4870
+ return;
4871
+ }
4872
+
4873
+ if (normalizedScope === 'selected' && !normalizedTableName) {
4874
+ pushToast('Select a table before generating types.', 'alert');
4875
+ return;
4876
+ }
4877
+
4878
+ state.modal = {
4879
+ kind: 'generate-types',
4880
+ scope: normalizedScope,
4881
+ tableName: normalizedScope === 'all' ? '' : normalizedTableName,
4882
+ tableNames: normalizedScope === 'all' ? tableNames : [],
4883
+ target: normalizedTarget,
4884
+ options: getDefaultTypeGenerationOptions(normalizedTarget),
4885
+ result: null,
4886
+ warnings: [],
4887
+ metadata: {},
4888
+ loading: false,
4889
+ error: null,
4890
+ submitting: false,
4891
+ };
4892
+ emitChange();
4893
+ await refreshTypeGenerationPreview();
4894
+ }
4895
+
4896
+ export async function updateGenerateTypesModal(field, value) {
4897
+ if (state.modal?.kind !== 'generate-types') {
4898
+ return;
4899
+ }
4900
+
4901
+ if (field === 'target') {
4902
+ const target = TYPE_GENERATION_TARGETS.has(value) ? value : 'typescript';
4903
+ state.modal.target = target;
4904
+ state.modal.options = normalizeTypeGenerationOptions(
4905
+ {
4906
+ ...state.modal.options,
4907
+ propertyNaming: getDefaultTypeGenerationOptions(target).propertyNaming,
4908
+ },
4909
+ target,
4910
+ );
4911
+ } else if (field in state.modal.options) {
4912
+ const booleanFields = new Set([
4913
+ 'includeComments',
4914
+ 'includeDefaultsAsComments',
4915
+ 'includeGeneratedColumns',
4916
+ 'includeHiddenColumns',
4917
+ 'exportDeclaration',
4918
+ ]);
4919
+ state.modal.options[field] = booleanFields.has(field) ? Boolean(value) : String(value ?? '');
4920
+ state.modal.options = normalizeTypeGenerationOptions(state.modal.options, state.modal.target);
4921
+ }
4922
+
4923
+ emitChange();
4924
+ await refreshTypeGenerationPreview();
4925
+ }
4926
+
4447
4927
  export function toggleStructureTablesPanel() {
4448
4928
  state.structure.tablesVisible = state.structure.tablesVisible === false;
4449
4929
  storeBoolean(UI_PREFERENCE_STORAGE_KEYS.structureTablesVisible, state.structure.tablesVisible);
@@ -0,0 +1,28 @@
1
+ const EMAIL_PATTERN = /^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/;
2
+
3
+ export function detectEmailValue(value) {
4
+ if (typeof value !== "string") {
5
+ return null;
6
+ }
7
+
8
+ const text = value.trim();
9
+
10
+ if (!text || text.length > 320 || !EMAIL_PATTERN.test(text)) {
11
+ return null;
12
+ }
13
+
14
+ const atIndex = text.lastIndexOf("@");
15
+ const localPart = text.slice(0, atIndex);
16
+ const domain = text.slice(atIndex + 1);
17
+
18
+ if (!localPart || !domain || domain.startsWith(".") || domain.endsWith(".")) {
19
+ return null;
20
+ }
21
+
22
+ return {
23
+ type: "email",
24
+ value: text,
25
+ localPart,
26
+ domain,
27
+ };
28
+ }
@@ -83,8 +83,24 @@ function renderFallbackMarkdown(markdown = '') {
83
83
  return html.join('\n');
84
84
  }
85
85
 
86
+ function escapeMarkdownHtmlOutsideFences(markdown = '') {
87
+ const lines = String(markdown ?? '').split('\n');
88
+ let inFence = false;
89
+
90
+ return lines
91
+ .map(line => {
92
+ if (FENCED_CODE_PATTERN.test(line)) {
93
+ inFence = !inFence;
94
+ return line;
95
+ }
96
+
97
+ return inFence ? line : escapeHtml(line);
98
+ })
99
+ .join('\n');
100
+ }
101
+
86
102
  function renderMarkdownBlock(markdown = '') {
87
- const source = escapeHtml(markdown);
103
+ const source = escapeMarkdownHtmlOutsideFences(markdown);
88
104
  const markedRuntime = globalThis.marked;
89
105
  const parser = typeof markedRuntime?.parse === 'function' ? markedRuntime.parse.bind(markedRuntime) : null;
90
106