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
@@ -1,3 +1,4 @@
1
+ import { renderDropdownButton } from "./dropdownButton.js";
1
2
  import { escapeHtml, highlightSql } from "../utils/format.js";
2
3
 
3
4
  function renderLineNumbers(query) {
@@ -57,21 +58,29 @@ export function renderQueryEditor({
57
58
  `;
58
59
 
59
60
  const center = `
60
- <button
61
- class="${secondaryButtonClass}"
62
- data-action="format-current-query"
63
- type="button"
64
- >
65
- <span class="material-symbols-outlined text-sm">format_align_left</span>
66
- Format
67
- </button>
68
- <button
69
- class="${secondaryButtonClass}"
70
- data-action="clear-query"
71
- type="button"
72
- >
73
- Clear
74
- </button>
61
+ ${renderDropdownButton({
62
+ icon: "edit_note",
63
+ label: "Editor",
64
+ title: "Editor actions",
65
+ items: [
66
+ {
67
+ action: "format-current-query",
68
+ icon: "format_align_left",
69
+ label: "Format",
70
+ },
71
+ {
72
+ action: "clear-query",
73
+ icon: "delete_sweep",
74
+ label: "Clear",
75
+ danger: true,
76
+ },
77
+ {
78
+ action: "copy-current-query",
79
+ icon: "content_copy",
80
+ label: "Copy to Clipboard",
81
+ },
82
+ ],
83
+ })}
75
84
  <button
76
85
  class="${secondaryButtonClass}"
77
86
  data-action="open-query-export-modal"
@@ -35,11 +35,30 @@ function getQueryTypeTone(queryType) {
35
35
  return "muted";
36
36
  }
37
37
 
38
+ function getExecutionSourceTone(source) {
39
+ if (source === "api") {
40
+ return "primary";
41
+ }
42
+
43
+ if (source === "cli") {
44
+ return "warning";
45
+ }
46
+
47
+ if (source === "mcp") {
48
+ return "muted";
49
+ }
50
+
51
+ return "success";
52
+ }
53
+
38
54
  function renderRunItem(run) {
55
+ const executedBy = String(run.executedBy ?? "user").trim().toLowerCase() || "user";
56
+
39
57
  return `
40
58
  <div class="border border-outline-variant/10 bg-surface-container px-3 py-3">
41
59
  <div class="flex flex-wrap items-center gap-2">
42
60
  ${renderStatusBadge(run.status, run.status === "error" ? "alert" : "success")}
61
+ ${renderStatusBadge(executedBy, getExecutionSourceTone(executedBy))}
43
62
  <span class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
44
63
  ${escapeHtml(formatCompactDateTime(run.executedAt))}
45
64
  </span>
@@ -199,7 +218,7 @@ export function renderQueryHistoryDetail({
199
218
  "</button>",
200
219
  '<button class="delete-button" data-action="open-delete-query-history-modal" data-history-id="',
201
220
  escapeHtml(item.id),
202
- '" type="button">Delete</button></div>',
221
+ '" type="button"><span class="material-symbols-outlined text-sm">delete</span>Delete</button></div>',
203
222
  '<div class="mt-6"><div class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">SQL</div>',
204
223
  '<pre class="query-history-detail-sql custom-scrollbar mt-2 overflow-auto border border-outline-variant/10 bg-surface-container-lowest p-4 font-mono text-sm leading-6 text-on-surface"><code>',
205
224
  highlightSql(item.rawSql),
@@ -34,10 +34,11 @@ export function renderQueryHistorySearch({
34
34
  placeholder = 'Search SQL, titles, notes...',
35
35
  } = {}) {
36
36
  return `
37
- <label class="mt-4 block">
37
+ <label class="query-history-search">
38
38
  <span class="sr-only">Search query history</span>
39
+ <span class="material-symbols-outlined query-history-search__icon" aria-hidden="true">search</span>
39
40
  <input
40
- class="control-input w-full border border-outline-variant/20 bg-surface-container text-sm text-on-surface outline-none placeholder:text-on-surface-variant/35 focus:border-primary-container"
41
+ class="control-input query-history-search__input w-full border border-outline-variant/20 bg-surface-container text-sm text-on-surface outline-none placeholder:text-on-surface-variant/35 focus:border-primary-container"
41
42
  data-bind="${escapeHtml(bind)}"
42
43
  placeholder="${escapeHtml(placeholder)}"
43
44
  type="search"
@@ -147,7 +147,7 @@ export function renderQueryResultsPane(
147
147
  ? renderDataGrid({
148
148
  columns,
149
149
  rows: result.rows ?? [],
150
- tableClass: "min-w-full border-collapse text-left font-mono text-xs",
150
+ tableClass: "data-table min-w-full border-collapse text-left font-mono text-xs",
151
151
  theadClass: "sticky top-0 z-10 bg-surface-container-highest text-on-surface",
152
152
  tbodyClass: "divide-y divide-outline-variant/5",
153
153
  getRowClass: (_, index) =>
@@ -1,4 +1,6 @@
1
1
  import { escapeHtml } from "../utils/format.js";
2
+ import { renderDropdownButton } from "./dropdownButton.js";
3
+ import { detectEmailValue } from "../utils/emailPreview.js";
2
4
  import {
3
5
  compactPathForDisplay,
4
6
  detectFilePathValue,
@@ -49,6 +51,19 @@ function withUrlBadge(badges = [], url) {
49
51
  return hasUrlBadge ? badges : [...badges, { label: "URL", tone: "url" }];
50
52
  }
51
53
 
54
+ function withEmailBadge(badges = [], email) {
55
+ if (!email) {
56
+ return badges;
57
+ }
58
+
59
+ const hasEmailBadge = badges.some((badge) => {
60
+ const label = typeof badge === "object" ? badge.label : badge;
61
+ return String(label ?? "").toUpperCase() === "EMAIL";
62
+ });
63
+
64
+ return hasEmailBadge ? badges : [...badges, { label: "EMAIL", tone: "email" }];
65
+ }
66
+
52
67
  function getAllowedValues(field) {
53
68
  const seen = new Set();
54
69
 
@@ -277,7 +292,11 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
277
292
  const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
278
293
  const filePathPreview = getFieldFilePathPreview({ ...field, rawValue }, tableMeta);
279
294
  const url = getUrlValue(value);
280
- const badges = withFilePathBadge(withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url), filePathPreview);
295
+ const email = detectEmailValue(rawValue);
296
+ const badges = withEmailBadge(
297
+ withFilePathBadge(withUrlBadge(Array.isArray(label?.badges) ? label.badges : [], url), filePathPreview),
298
+ email
299
+ );
281
300
  const displayLabel = typeof label === "object" ? label.label : label;
282
301
  const jsonPreview = formatJsonPreview(value);
283
302
  const valueState = getRowEditorValueState(rawValue);
@@ -290,7 +309,9 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
290
309
  data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
291
310
  ${
292
311
  url ? "data-row-editor-url-field" : ""
293
- }>
312
+ }
313
+ ${email ? "data-row-editor-email-field" : ""}
314
+ >
294
315
  <div class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
295
316
  <span>${escapeHtml(displayLabel)}</span>
296
317
  ${renderFieldBadgesWithCharacterCount(badges, rawValue)}
@@ -334,10 +355,11 @@ function renderEditableField(field, tableMeta = {}) {
334
355
  const columnName = getFieldColumnName(field);
335
356
  const protectedKeyColumn = isProtectedKeyColumn(columnName, tableMeta);
336
357
  const url = getUrlValue(field.value);
358
+ const email = detectEmailValue(getFieldRawValue(field));
337
359
  const allowedValues = getAllowedValues(field);
338
360
  const filePathPreview = getFieldFilePathPreview(field, tableMeta);
339
361
  const baseBadges = withCheckBadge(Array.isArray(field.badges) ? field.badges : [], allowedValues);
340
- const badges = withFilePathBadge(withUrlBadge(baseBadges, url), filePathPreview);
362
+ const badges = withEmailBadge(withFilePathBadge(withUrlBadge(baseBadges, url), filePathPreview), email);
341
363
  const jsonPreview = formatJsonPreview(field.value);
342
364
  const inputType = field.inputType === "number" ? "number" : "text";
343
365
  const numberStep = field.numberStep === "1" ? "1" : "any";
@@ -351,6 +373,7 @@ function renderEditableField(field, tableMeta = {}) {
351
373
  data-row-editor-initial-state="${escapeHtml(valueState)}"
352
374
  data-row-editor-protected-key="${protectedKeyColumn ? "true" : "false"}"
353
375
  ${url ? "data-row-editor-url-field" : ""}
376
+ ${email ? "data-row-editor-email-field" : ""}
354
377
  >
355
378
  <span class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
356
379
  <span>${escapeHtml(field.label ?? field.name)}</span>
@@ -427,6 +450,10 @@ function getFieldBadgeClassName(tone) {
427
450
  return "border-primary-container/35 bg-primary-container/15 text-primary-container";
428
451
  }
429
452
 
453
+ if (tone === "email") {
454
+ return "border-tertiary-fixed-dim/35 bg-tertiary-fixed-dim/15 text-tertiary-fixed-dim";
455
+ }
456
+
430
457
  if (tone === "check") {
431
458
  return "border-tertiary-fixed-dim/35 bg-tertiary-fixed-dim/15 text-tertiary-fixed-dim";
432
459
  }
@@ -488,16 +515,28 @@ export function renderRowEditorPanel({
488
515
  ].join("")
489
516
  : "",
490
517
  jsonActionsEnabled
491
- ? [
492
- '<button class="standard-button" data-action="copy-row-editor-json" type="button">',
493
- '<span class="material-symbols-outlined text-sm">content_copy</span>',
494
- "Copy as JSON",
495
- "</button>",
496
- '<button class="standard-button" data-action="export-row-editor-json" type="button">',
497
- '<span class="material-symbols-outlined text-sm">download</span>',
498
- "Export as JSON",
499
- "</button>",
500
- ].join("")
518
+ ? renderDropdownButton({
519
+ icon: "download",
520
+ label: "Export",
521
+ title: "Export row",
522
+ items: [
523
+ {
524
+ action: "copy-row-editor-json",
525
+ icon: "content_copy",
526
+ label: "Copy to clipboard",
527
+ },
528
+ {
529
+ action: "export-row-editor-json",
530
+ icon: "download",
531
+ label: "JSON file",
532
+ },
533
+ {
534
+ action: "insert-row-editor-json-into-document",
535
+ icon: "description",
536
+ label: "Markdown document",
537
+ },
538
+ ],
539
+ })
501
540
  : "",
502
541
  canSubmit
503
542
  ? [
@@ -521,6 +560,7 @@ export function renderRowEditorPanel({
521
560
  ' type="button" ',
522
561
  saving || deleting ? "disabled" : "",
523
562
  ">",
563
+ '<span class="material-symbols-outlined text-sm">delete</span>',
524
564
  escapeHtml(deleting ? "Deleting..." : deleteLabel),
525
565
  "</button>",
526
566
  ].join("")
@@ -19,6 +19,7 @@ const sidebarItems = [
19
19
  { label: 'TAGGING_QUEUE', href: '#/media-tagging/queue', key: 'mediaTaggingQueue' },
20
20
  ],
21
21
  },
22
+ { label: 'Backups', href: '#/backups', key: 'backups', icon: 'inventory_2' },
22
23
  { label: 'Settings', href: '#/settings', key: 'settings', icon: 'settings' },
23
24
  ];
24
25
 
@@ -1,11 +1,8 @@
1
1
  export function renderTopNav() {
2
- return `
2
+ return `
3
3
  <div class="top-nav-shell">
4
4
  <a class="top-nav-brand" href="#/">SQLite Hub</a>
5
5
  <div class="top-nav-actions">
6
- <button class="top-nav-icon" data-action="refresh-view" type="button" aria-label="Refresh">
7
- <span class="material-symbols-outlined">refresh</span>
8
- </button>
9
6
  <button
10
7
  class="top-nav-icon"
11
8
  data-action="open-modal"
@@ -0,0 +1,52 @@
1
+ import { renderDropdownButton } from "./dropdownButton.js";
2
+
3
+ function buildOpenItem({ disabled, icon, label, target, tableName }) {
4
+ return {
5
+ action: "navigate",
6
+ dataAttributes: {
7
+ to: target,
8
+ },
9
+ disabled,
10
+ icon,
11
+ label,
12
+ };
13
+ }
14
+
15
+ export function renderWorkspaceOpenDropdown({
16
+ align = "right",
17
+ destinations = [],
18
+ disabled = false,
19
+ tableName = "",
20
+ } = {}) {
21
+ const safeTableName = String(tableName ?? "").trim();
22
+ const items = destinations.map((destination) => {
23
+ if (destination.key === "sql-editor") {
24
+ return {
25
+ action: "open-table-in-sql-editor",
26
+ dataAttributes: {
27
+ tableName: safeTableName,
28
+ },
29
+ disabled: disabled || !safeTableName,
30
+ icon: "terminal",
31
+ label: "SQL Editor",
32
+ };
33
+ }
34
+
35
+ return buildOpenItem({
36
+ disabled: disabled || !safeTableName,
37
+ icon: destination.icon,
38
+ label: destination.label,
39
+ target: destination.target(safeTableName),
40
+ tableName: safeTableName,
41
+ });
42
+ });
43
+
44
+ return renderDropdownButton({
45
+ align,
46
+ disabled: disabled || !safeTableName,
47
+ icon: "open_in_new",
48
+ items,
49
+ label: "Open",
50
+ title: safeTableName ? `Open ${safeTableName}` : "Open table",
51
+ });
52
+ }
@@ -11,6 +11,8 @@ export function parseHash(hash = window.location.hash) {
11
11
  switch (segments[0]) {
12
12
  case 'connections':
13
13
  return { name: 'connections', path: '/connections', params: {} };
14
+ case 'backups':
15
+ return { name: 'backups', path: '/backups', params: {} };
14
16
  case 'overview':
15
17
  return { name: 'overview', path: '/overview', params: {} };
16
18
  case 'charts':