sqlite-hub 0.8.7 → 0.9.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 (32) hide show
  1. package/changelog.md +11 -0
  2. package/frontend/index.html +1 -1
  3. package/frontend/js/api.js +67 -0
  4. package/frontend/js/app.js +1398 -999
  5. package/frontend/js/components/modal.js +208 -3
  6. package/frontend/js/components/queryEditor.js +9 -4
  7. package/frontend/js/components/queryHistoryDetail.js +20 -10
  8. package/frontend/js/components/queryHistoryPanel.js +4 -3
  9. package/frontend/js/components/rowEditorPanel.js +26 -5
  10. package/frontend/js/components/sidebar.js +49 -5
  11. package/frontend/js/components/structureGraph.js +614 -632
  12. package/frontend/js/components/tableDesignerSqlPreview.js +24 -25
  13. package/frontend/js/lib/mediaTaggingDefaults.js +27 -0
  14. package/frontend/js/router.js +81 -71
  15. package/frontend/js/store.js +3091 -2184
  16. package/frontend/js/views/charts.js +2 -2
  17. package/frontend/js/views/data.js +28 -14
  18. package/frontend/js/views/editor.js +172 -177
  19. package/frontend/js/views/mediaTagging.js +861 -0
  20. package/frontend/styles/base.css +11 -1
  21. package/frontend/styles/components.css +48 -6
  22. package/frontend/styles/views.css +796 -0
  23. package/package.json +1 -1
  24. package/server/routes/charts.js +4 -1
  25. package/server/routes/data.js +14 -0
  26. package/server/routes/mediaTagging.js +166 -0
  27. package/server/routes/sql.js +1 -0
  28. package/server/server.js +4 -0
  29. package/server/services/sqlite/dataBrowserService.js +25 -0
  30. package/server/services/sqlite/exportService.js +31 -1
  31. package/server/services/sqlite/mediaTaggingService.js +1689 -0
  32. package/server/services/storage/appStateStore.js +321 -2
@@ -1,10 +1,18 @@
1
- import { escapeHtml, truncateMiddle } from "../utils/format.js";
1
+ import { escapeHtml, highlightSql, truncateMiddle } from "../utils/format.js";
2
2
  import { renderConnectionLogo } from "./connectionLogo.js";
3
3
  import {
4
4
  analyzeQueryChartResult,
5
5
  getQueryChartTypeLabel,
6
6
  QUERY_CHART_TYPES,
7
7
  } from "../lib/queryCharts.js";
8
+ import {
9
+ hasDefaultMediaTaggingTagTable,
10
+ hasDefaultMediaTaggingMappingTable,
11
+ MEDIA_TAGGING_DEFAULT_MAPPING_TABLE,
12
+ MEDIA_TAGGING_DEFAULT_MAPPING_TABLE_SQL,
13
+ MEDIA_TAGGING_DEFAULT_TAG_TABLE,
14
+ MEDIA_TAGGING_DEFAULT_TAG_TABLE_SQL,
15
+ } from "../lib/mediaTaggingDefaults.js";
8
16
 
9
17
  function renderField({ label, name, type = "text", placeholder = "", value = "" }) {
10
18
  return `
@@ -25,7 +33,7 @@ function renderField({ label, name, type = "text", placeholder = "", value = ""
25
33
 
26
34
  function renderCheckboxField({ label, name, checked = false, text }) {
27
35
  return `
28
- <label class="block space-y-2">
36
+ <label class="flex flex-col gap-2">
29
37
  <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
30
38
  ${escapeHtml(label)}
31
39
  </span>
@@ -41,6 +49,27 @@ function renderCheckboxField({ label, name, checked = false, text }) {
41
49
  `;
42
50
  }
43
51
 
52
+ function renderSqlPreviewField(value, minHeightClass = "sql-highlight-shell--tall") {
53
+ return `
54
+ <div class="sql-highlight-shell ${minHeightClass}">
55
+ <div class="query-editor-layer sql-highlight-layer">
56
+ <div
57
+ aria-hidden="true"
58
+ class="query-editor-highlight sql-highlight-content"
59
+ data-query-editor-highlight
60
+ >${value ? highlightSql(value) : ""}</div>
61
+ <textarea
62
+ class="query-editor-input sql-highlight-input custom-scrollbar"
63
+ data-sql-highlight="true"
64
+ readonly
65
+ spellcheck="false"
66
+ wrap="off"
67
+ >${escapeHtml(value)}</textarea>
68
+ </div>
69
+ </div>
70
+ `;
71
+ }
72
+
44
73
  function renderFileField({
45
74
  label,
46
75
  name,
@@ -70,7 +99,7 @@ function renderFileField({
70
99
  function renderSelectField({ label, name, value = "", options = [], bind = "" }) {
71
100
  return `
72
101
  <label class="block space-y-2">
73
- <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
102
+ <span class="block text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
74
103
  ${escapeHtml(label)}
75
104
  </span>
76
105
  <select
@@ -687,6 +716,167 @@ function renderDeleteChartForm(modal) {
687
716
  `;
688
717
  }
689
718
 
719
+ function renderDeleteQueryHistoryForm(modal) {
720
+ return `
721
+ <form class="space-y-5" data-form="delete-query-history-confirm">
722
+ <div class="space-y-3">
723
+ <p class="text-sm leading-7 text-on-surface">
724
+ Delete query <span class="font-bold text-primary-container">${escapeHtml(
725
+ modal.queryTitle ?? "SQL query"
726
+ )}</span>?
727
+ </p>
728
+ <p class="text-sm leading-7 text-on-surface-variant/65">
729
+ This removes the query-history entry and all recorded runs linked to it.
730
+ </p>
731
+ </div>
732
+ ${renderError(modal.error)}
733
+ <div class="flex items-center justify-end gap-3 pt-2">
734
+ <button
735
+ class="standard-button"
736
+ data-action="close-modal"
737
+ type="button"
738
+ >
739
+ Cancel
740
+ </button>
741
+ <button
742
+ class="delete-button"
743
+ type="submit"
744
+ >
745
+ ${modal.submitting ? "Deleting..." : "Delete Query"}
746
+ </button>
747
+ </div>
748
+ </form>
749
+ `;
750
+ }
751
+
752
+ function renderCreateMediaTaggingMappingTableForm(modal, state) {
753
+ const mappingExists = hasDefaultMediaTaggingMappingTable(state.mediaTagging.schemaTables ?? []);
754
+ const readOnly = Boolean(state.mediaTagging.connection?.readOnly);
755
+
756
+ return `
757
+ <form class="space-y-5" data-form="create-media-tagging-mapping-table">
758
+ <div class="space-y-3">
759
+ <div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
760
+ Mapping Table
761
+ </div>
762
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 font-mono text-sm text-on-surface">
763
+ ${escapeHtml(MEDIA_TAGGING_DEFAULT_MAPPING_TABLE)}
764
+ </div>
765
+ </div>
766
+ <div class="space-y-3">
767
+ <div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
768
+ Status
769
+ </div>
770
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface">
771
+ ${
772
+ mappingExists
773
+ ? `${escapeHtml(MEDIA_TAGGING_DEFAULT_MAPPING_TABLE)} already exists in the active database.`
774
+ : `${escapeHtml(MEDIA_TAGGING_DEFAULT_MAPPING_TABLE)} does not exist yet.`
775
+ }
776
+ ${
777
+ readOnly && !mappingExists
778
+ ? `<div class="mt-2 text-on-surface-variant/60">The active connection is read-only, so the table cannot be created here.</div>`
779
+ : ""
780
+ }
781
+ </div>
782
+ </div>
783
+ <div class="space-y-3">
784
+ <div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
785
+ SQL
786
+ </div>
787
+ ${renderSqlPreviewField(MEDIA_TAGGING_DEFAULT_MAPPING_TABLE_SQL)}
788
+ </div>
789
+ ${renderError(modal.error)}
790
+ <div class="flex items-center justify-end gap-3 pt-2">
791
+ <button
792
+ class="standard-button"
793
+ data-action="close-modal"
794
+ type="button"
795
+ >
796
+ Close
797
+ </button>
798
+ ${
799
+ !mappingExists
800
+ ? `
801
+ <button
802
+ class="standard-button"
803
+ type="submit"
804
+ ${readOnly ? "disabled" : ""}
805
+ >
806
+ ${modal.submitting ? "Creating..." : "Create Table"}
807
+ </button>
808
+ `
809
+ : ""
810
+ }
811
+ </div>
812
+ </form>
813
+ `;
814
+ }
815
+
816
+ function renderCreateMediaTaggingTagTableForm(modal, state) {
817
+ const tagTableExists = hasDefaultMediaTaggingTagTable(state.mediaTagging.schemaTables ?? []);
818
+ const readOnly = Boolean(state.mediaTagging.connection?.readOnly);
819
+
820
+ return `
821
+ <form class="space-y-5" data-form="create-media-tagging-tag-table">
822
+ <div class="space-y-3">
823
+ <div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
824
+ Tag Table
825
+ </div>
826
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 font-mono text-sm text-on-surface">
827
+ ${escapeHtml(MEDIA_TAGGING_DEFAULT_TAG_TABLE)}
828
+ </div>
829
+ </div>
830
+ <div class="space-y-3">
831
+ <div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
832
+ Status
833
+ </div>
834
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-sm text-on-surface">
835
+ ${
836
+ tagTableExists
837
+ ? `${escapeHtml(MEDIA_TAGGING_DEFAULT_TAG_TABLE)} already exists in the active database.`
838
+ : `${escapeHtml(MEDIA_TAGGING_DEFAULT_TAG_TABLE)} does not exist yet.`
839
+ }
840
+ ${
841
+ readOnly && !tagTableExists
842
+ ? `<div class="mt-2 text-on-surface-variant/60">The active connection is read-only, so the table cannot be created here.</div>`
843
+ : ""
844
+ }
845
+ </div>
846
+ </div>
847
+ <div class="space-y-3">
848
+ <div class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
849
+ SQL
850
+ </div>
851
+ ${renderSqlPreviewField(MEDIA_TAGGING_DEFAULT_TAG_TABLE_SQL)}
852
+ </div>
853
+ ${renderError(modal.error)}
854
+ <div class="flex items-center justify-end gap-3 pt-2">
855
+ <button
856
+ class="standard-button"
857
+ data-action="close-modal"
858
+ type="button"
859
+ >
860
+ Close
861
+ </button>
862
+ ${
863
+ !tagTableExists
864
+ ? `
865
+ <button
866
+ class="standard-button"
867
+ type="submit"
868
+ ${readOnly ? "disabled" : ""}
869
+ >
870
+ ${modal.submitting ? "Creating..." : "Create Table"}
871
+ </button>
872
+ `
873
+ : ""
874
+ }
875
+ </div>
876
+ </form>
877
+ `;
878
+ }
879
+
690
880
  export function renderModal(state) {
691
881
  const modal = state.modal;
692
882
 
@@ -730,6 +920,21 @@ export function renderModal(state) {
730
920
  title: "Delete Chart",
731
921
  body: renderDeleteChartForm(modal),
732
922
  },
923
+ "delete-query-history": {
924
+ eyebrow: "History // Confirm query deletion",
925
+ title: "Delete Query",
926
+ body: renderDeleteQueryHistoryForm(modal),
927
+ },
928
+ "create-media-tagging-tag-table": {
929
+ eyebrow: "Media Tagging // Create default tag table",
930
+ title: "Create Tag Table",
931
+ body: renderCreateMediaTaggingTagTableForm(modal, state),
932
+ },
933
+ "create-media-tagging-mapping-table": {
934
+ eyebrow: "Media Tagging // Create default join table",
935
+ title: "Create Mapping Table",
936
+ body: renderCreateMediaTaggingMappingTableForm(modal, state),
937
+ },
733
938
  };
734
939
 
735
940
  const config = contentByKind[modal.kind];
@@ -19,11 +19,16 @@ function renderHighlightedQuery(query) {
19
19
 
20
20
  function renderEditorSurface({ query }) {
21
21
  return `
22
- <div class="flex min-h-0 flex-1 overflow-hidden">
23
- <div class="flex w-12 flex-col items-center bg-surface-container-lowest py-4 font-mono text-xs select-none text-outline-variant/30">
24
- ${renderLineNumbers(query)}
22
+ <div class="query-editor-shell flex min-h-0 flex-1 overflow-hidden">
23
+ <div class="flex w-12 min-h-0 overflow-hidden bg-surface-container-lowest py-4 font-mono text-xs select-none text-outline-variant/30">
24
+ <div
25
+ class="query-editor-gutter-track flex w-full flex-col items-center"
26
+ data-query-editor-gutter
27
+ >
28
+ ${renderLineNumbers(query)}
29
+ </div>
25
30
  </div>
26
- <div class="relative flex-1 overflow-hidden bg-surface-container p-6 font-mono text-sm leading-relaxed">
31
+ <div class="relative min-h-0 flex-1 overflow-hidden bg-surface-container p-6 font-mono text-sm leading-relaxed">
27
32
  <div class="pointer-events-none absolute right-0 top-0 p-4 opacity-5">
28
33
  <span class="material-symbols-outlined text-[120px] font-thin">terminal</span>
29
34
  </div>
@@ -19,6 +19,10 @@ function renderDetailMetaItem(label, value) {
19
19
  `;
20
20
  }
21
21
 
22
+ function canOpenQueryHistoryInCharts(item) {
23
+ return Boolean(item?.chartsEligible);
24
+ }
25
+
22
26
  function renderRunItem(run) {
23
27
  return `
24
28
  <div class="border border-outline-variant/10 bg-surface-container px-3 py-3">
@@ -182,15 +186,21 @@ export function renderQueryHistoryDetail({
182
186
  >
183
187
  Open In Editor
184
188
  </button>
185
- <button
186
- class="standard-button"
187
- data-action="navigate"
188
- data-to="/charts/${encodeURIComponent(item.id)}"
189
- type="button"
190
- >
191
- <span class="material-symbols-outlined text-sm">bar_chart</span>
192
- Open In Charts
193
- </button>
189
+ ${
190
+ canOpenQueryHistoryInCharts(item)
191
+ ? `
192
+ <button
193
+ class="standard-button"
194
+ data-action="navigate"
195
+ data-to="/charts/${encodeURIComponent(item.id)}"
196
+ type="button"
197
+ >
198
+ <span class="material-symbols-outlined text-sm">bar_chart</span>
199
+ Open In Charts
200
+ </button>
201
+ `
202
+ : ""
203
+ }
194
204
  <button
195
205
  class="standard-button"
196
206
  data-action="run-query-history"
@@ -210,7 +220,7 @@ export function renderQueryHistoryDetail({
210
220
  </button>
211
221
  <button
212
222
  class="delete-button"
213
- data-action="delete-query-history"
223
+ data-action="open-delete-query-history-modal"
214
224
  data-history-id="${escapeHtml(item.id)}"
215
225
  type="button"
216
226
  >
@@ -20,7 +20,7 @@ function getQueryTypeTone(queryType, isDestructive) {
20
20
  return "muted";
21
21
  }
22
22
 
23
- function renderHistoryItem(item, activeHistoryId, selectedHistoryId) {
23
+ export function renderQueryHistoryListItem(item, activeHistoryId, selectedHistoryId) {
24
24
  const isActive = Number(activeHistoryId) === Number(item.id);
25
25
  const isSelected = Number(selectedHistoryId) === Number(item.id);
26
26
  const visibleTables = (item.tablesDetected ?? []).slice(0, 3);
@@ -109,6 +109,7 @@ function renderQueryHistoryTabs(activeTab, historyTotal) {
109
109
  const tabs = [
110
110
  { id: "recent", label: "Recent" },
111
111
  { id: "saved", label: "Saved" },
112
+ { id: "unsaved", label: "Unsaved" },
112
113
  { id: "failed", label: "Failed" },
113
114
  ];
114
115
 
@@ -204,9 +205,9 @@ export function renderQueryHistoryPanel({
204
205
  `
205
206
  : ""
206
207
  }
207
- <div class="space-y-3">
208
+ <div class="space-y-3">
208
209
  ${items
209
- .map((item) => renderHistoryItem(item, activeHistoryId, selectedHistoryId))
210
+ .map((item) => renderQueryHistoryListItem(item, activeHistoryId, selectedHistoryId))
210
211
  .join("")}
211
212
  </div>
212
213
  ${
@@ -42,12 +42,23 @@ function renderJsonViewer(prettyJson, title = "JSON Viewer") {
42
42
  }
43
43
 
44
44
  function renderReadonlyField(label, value) {
45
+ const badges = Array.isArray(label?.badges) ? label.badges : [];
46
+ const displayLabel = typeof label === "object" ? label.label : label;
45
47
  const jsonPreview = getJsonPreview(value);
46
48
 
47
49
  return `
48
50
  <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">
49
- <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
50
- ${escapeHtml(label)}
51
+ <div class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
52
+ <span>${escapeHtml(displayLabel)}</span>
53
+ ${badges
54
+ .map(
55
+ (badge) => `
56
+ <span class="border border-outline-variant/20 bg-surface-container px-2 py-1 text-[9px] text-primary-container">
57
+ ${escapeHtml(badge)}
58
+ </span>
59
+ `
60
+ )
61
+ .join("")}
51
62
  </div>
52
63
  ${
53
64
  jsonPreview
@@ -59,12 +70,22 @@ function renderReadonlyField(label, value) {
59
70
  }
60
71
 
61
72
  function renderEditableField(field) {
73
+ const badges = Array.isArray(field.badges) ? field.badges : [];
62
74
  const jsonPreview = getJsonPreview(field.value);
63
75
 
64
76
  return `
65
77
  <label class="block space-y-2">
66
- <span class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
67
- ${escapeHtml(field.label ?? field.name)}
78
+ <span class="flex flex-wrap items-center gap-2 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
79
+ <span>${escapeHtml(field.label ?? field.name)}</span>
80
+ ${badges
81
+ .map(
82
+ (badge) => `
83
+ <span class="border border-outline-variant/20 bg-surface-container px-2 py-1 text-[9px] text-primary-container">
84
+ ${escapeHtml(badge)}
85
+ </span>
86
+ `
87
+ )
88
+ .join("")}
68
89
  </span>
69
90
  ${
70
91
  jsonPreview
@@ -135,7 +156,7 @@ export function renderRowEditorPanel({
135
156
  <button
136
157
  class="delete-button"
137
158
  data-action="${escapeHtml(deleteAction)}"
138
- data-row-index="${escapeHtml(String(deleteRowIndex ?? ""))}"
159
+ ${deleteRowIndex === null ? "" : `data-row-index="${escapeHtml(String(deleteRowIndex))}"`}
139
160
  type="button"
140
161
  ${saving || deleting ? "disabled" : ""}
141
162
  >
@@ -5,10 +5,19 @@ const sidebarItems = [
5
5
  { label: 'Connections', href: '#/connections', key: 'connections', icon: 'database' },
6
6
  { label: 'Overview', href: '#/overview', key: 'overview', icon: 'dashboard' },
7
7
  { label: 'Data', href: '#/data', key: 'data', icon: 'table_rows' },
8
- { label: 'SQL Editor', href: '#/editor', key: 'editor', icon: 'terminal' },
9
8
  { label: 'Structure', href: '#/structure', key: 'structure', icon: 'account_tree' },
9
+ { label: 'SQL Editor', href: '#/editor', key: 'editor', icon: 'terminal' },
10
10
  { label: 'Charts', href: '#/charts', key: 'charts', icon: 'bar_chart' },
11
11
  { label: 'Table Designer', href: '#/table-designer', key: 'tableDesigner', icon: 'table_chart' },
12
+ {
13
+ label: 'Media Tagging',
14
+ key: 'mediaTagging',
15
+ icon: 'sell',
16
+ children: [
17
+ { label: 'Setup', href: '#/media-tagging', key: 'mediaTaggingSetup' },
18
+ { label: 'Tagging Queue', href: '#/media-tagging/queue', key: 'mediaTaggingQueue' },
19
+ ],
20
+ },
12
21
  { label: 'Settings', href: '#/settings', key: 'settings', icon: 'settings' },
13
22
  ];
14
23
 
@@ -21,24 +30,59 @@ function getActiveSidebarKey(routeName) {
21
30
  return 'editor';
22
31
  }
23
32
 
33
+ if (routeName === 'mediaTaggingSetup' || routeName === 'mediaTaggingQueue') {
34
+ return 'mediaTagging';
35
+ }
36
+
24
37
  return routeName;
25
38
  }
26
39
 
27
40
  export function renderSidebar(state) {
28
41
  const activeKey = getActiveSidebarKey(state.route.name);
29
42
  const activeConnection = state.connections.active;
43
+ const expandedKey = activeKey === 'mediaTagging' ? 'mediaTagging' : null;
30
44
 
31
45
  return `
32
46
  <nav class="sidebar-links">
33
47
  ${sidebarItems
34
- .map(
35
- item => `
48
+ .map(item => {
49
+ if (item.children) {
50
+ const isExpanded = expandedKey === item.key;
51
+ const isActive = activeKey === item.key;
52
+ return `
53
+ <div class="sidebar-group">
54
+ <a class="sidebar-link ${isActive ? 'is-active' : ''}" href="${item.children[0].href}" data-group="${item.key}">
55
+ <span class="material-symbols-outlined">${item.icon}</span>
56
+ <span>${item.label}</span>
57
+ <span class="material-symbols-outlined ml-auto text-[14px] ${isExpanded ? 'rotate-180' : ''}">expand_more</span>
58
+ </a>
59
+ ${
60
+ isExpanded
61
+ ? `
62
+ <div class="sidebar-sublinks">
63
+ ${item.children
64
+ .map(
65
+ child => `
66
+ <a class="sidebar-sublink ${state.route.name === child.key ? 'is-active' : ''}" href="${child.href}">
67
+ ${child.label}
68
+ </a>
69
+ `,
70
+ )
71
+ .join('')}
72
+ </div>
73
+ `
74
+ : ''
75
+ }
76
+ </div>
77
+ `;
78
+ }
79
+ return `
36
80
  <a class="sidebar-link ${item.key === activeKey ? 'is-active' : ''}" href="${item.href}">
37
81
  <span class="material-symbols-outlined">${item.icon}</span>
38
82
  <span>${item.label}</span>
39
83
  </a>
40
- `,
41
- )
84
+ `;
85
+ })
42
86
  .join('')}
43
87
  </nav>
44
88
  <div class="sidebar-footer">