sqlite-hub 0.9.3 → 0.9.4

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 (46) hide show
  1. package/.github/workflows/ci.yml +36 -0
  2. package/README.md +2 -2
  3. package/bin/sqlite-hub.js +1 -1
  4. package/frontend/index.html +2 -158
  5. package/frontend/js/app.js +5 -1
  6. package/frontend/js/components/connectionCard.js +62 -87
  7. package/frontend/js/components/emptyState.js +20 -23
  8. package/frontend/js/components/modal.js +145 -195
  9. package/frontend/js/components/pageHeader.js +1 -1
  10. package/frontend/js/components/queryEditor.js +16 -30
  11. package/frontend/js/components/queryHistoryDetail.js +93 -164
  12. package/frontend/js/components/queryHistoryPanel.js +81 -99
  13. package/frontend/js/components/queryResults.js +3 -1
  14. package/frontend/js/components/rowEditorPanel.js +28 -31
  15. package/frontend/js/components/tableDesignerEditor.js +91 -116
  16. package/frontend/js/store.js +39 -3
  17. package/frontend/js/utils/tableDesigner.js +8 -2
  18. package/frontend/js/views/charts.js +23 -43
  19. package/frontend/js/views/data.js +116 -132
  20. package/frontend/js/views/mediaTagging.js +131 -164
  21. package/frontend/js/views/structure.js +52 -48
  22. package/frontend/styles/tailwind.css +81 -0
  23. package/frontend/styles/tailwind.generated.css +1 -0
  24. package/frontend/styles/tokens.css +3 -3
  25. package/package.json +16 -3
  26. package/server/routes/mediaTagging.js +2 -10
  27. package/server/routes/sql.js +35 -10
  28. package/server/server.js +24 -0
  29. package/server/services/sqlite/dataBrowserService.js +25 -5
  30. package/server/services/sqlite/exportService.js +4 -2
  31. package/server/services/sqlite/introspection.js +2 -2
  32. package/server/services/sqlite/mediaTaggingService.js +166 -53
  33. package/server/services/sqlite/structureService.js +2 -2
  34. package/server/services/sqlite/tableDesigner/sql.js +19 -3
  35. package/server/services/storage/appStateStore.js +227 -87
  36. package/server/utils/appPaths.js +55 -19
  37. package/server/utils/fileValidation.js +94 -8
  38. package/tailwind.config.cjs +73 -0
  39. package/tests/security-paths.test.js +84 -0
  40. package/tests/sql-identifier-safety.test.js +66 -0
  41. package/.npmingnore +0 -4
  42. package/changelog.md +0 -84
  43. package/docs/DESIGN_GUIDELINES.md +0 -36
  44. package/scripts/publish_brew.sh +0 -466
  45. package/scripts/publish_npm.sh +0 -241
  46. package/shortkeys.md +0 -5
@@ -97,28 +97,37 @@ function renderFileField({
97
97
  }
98
98
 
99
99
  function renderSelectField({ label, name, value = "", options = [], bind = "" }) {
100
- return `
101
- <label class="block space-y-2">
102
- <span class="block text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
103
- ${escapeHtml(label)}
104
- </span>
105
- <select
106
- class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
107
- ${bind ? `data-bind="${escapeHtml(bind)}"` : ""}
108
- ${name ? `name="${escapeHtml(name)}"` : ""}
109
- >
110
- ${options
111
- .map(
112
- (option) => `
113
- <option value="${escapeHtml(option.value)}" ${String(option.value) === String(value) ? "selected" : ""}>
114
- ${escapeHtml(option.label)}
115
- </option>
116
- `
117
- )
118
- .join("")}
119
- </select>
120
- </label>
121
- `;
100
+ const attributes = [
101
+ bind ? ['data-bind="', escapeHtml(bind), '"'].join("") : "",
102
+ name ? ['name="', escapeHtml(name), '"'].join("") : "",
103
+ ]
104
+ .filter(Boolean)
105
+ .join(" ");
106
+ const optionMarkup = options
107
+ .map((option) =>
108
+ [
109
+ '<option value="',
110
+ escapeHtml(option.value),
111
+ '" ',
112
+ String(option.value) === String(value) ? "selected" : "",
113
+ ">",
114
+ escapeHtml(option.label),
115
+ "</option>",
116
+ ].join("")
117
+ )
118
+ .join("");
119
+
120
+ return [
121
+ '<label class="block space-y-2">',
122
+ '<span class="block text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">',
123
+ escapeHtml(label),
124
+ "</span>",
125
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" ',
126
+ attributes,
127
+ ">",
128
+ optionMarkup,
129
+ "</select></label>",
130
+ ].join("");
122
131
  }
123
132
 
124
133
  function renderError(error) {
@@ -283,62 +292,55 @@ function renderCreateDatabaseForm(modal) {
283
292
 
284
293
  function renderImportTargetOptions(state) {
285
294
  const recentOptions = state.connections.recent
286
- .map(
287
- (connection) => `
288
- <option value="${escapeHtml(connection.id)}">
289
- ${escapeHtml(connection.label)} • ${escapeHtml(truncateMiddle(connection.path, 42))}
290
- </option>
291
- `
295
+ .map((connection) =>
296
+ [
297
+ '<option value="',
298
+ escapeHtml(connection.id),
299
+ '">',
300
+ escapeHtml(connection.label),
301
+ " • ",
302
+ escapeHtml(truncateMiddle(connection.path, 42)),
303
+ "</option>",
304
+ ].join("")
292
305
  )
293
306
  .join("");
294
-
295
- return `
296
- <label class="block space-y-2">
297
- <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
298
- Import Target
299
- </span>
300
- <select
301
- class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
302
- name="targetMode"
303
- >
304
- ${
305
- state.connections.active
306
- ? '<option value="active">Use active database</option>'
307
- : ""
308
- }
309
- ${state.connections.recent.length ? '<option value="recent">Use recent connection</option>' : ""}
310
- <option value="create">Create new database from dump</option>
311
- <option value="path">Open explicit target path</option>
312
- </select>
313
- </label>
314
- ${
315
- state.connections.recent.length
316
- ? `
317
- <label class="block space-y-2">
318
- <span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">
319
- Recent Connection
320
- </span>
321
- <select
322
- class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container"
323
- name="targetConnectionId"
324
- >
325
- ${recentOptions}
326
- </select>
327
- </label>
328
- `
329
- : ""
330
- }
331
- ${renderField({
307
+ const activeOption = state.connections.active
308
+ ? '<option value="active">Use active database</option>'
309
+ : "";
310
+ const recentModeOption = state.connections.recent.length
311
+ ? '<option value="recent">Use recent connection</option>'
312
+ : "";
313
+ const recentConnectionSelect = state.connections.recent.length
314
+ ? [
315
+ '<label class="block space-y-2">',
316
+ '<span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">Recent Connection</span>',
317
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" name="targetConnectionId">',
318
+ recentOptions,
319
+ "</select></label>",
320
+ ].join("")
321
+ : "";
322
+
323
+ return [
324
+ '<label class="block space-y-2">',
325
+ '<span class="text-[10px] font-mono uppercase tracking-[0.22em] text-on-surface-variant/60">Import Target</span>',
326
+ '<select class="control-select w-full border border-outline-variant/20 bg-surface-container-lowest text-sm text-on-surface outline-none transition-colors focus:border-primary-container" name="targetMode">',
327
+ activeOption,
328
+ recentModeOption,
329
+ '<option value="create">Create new database from dump</option>',
330
+ '<option value="path">Open explicit target path</option>',
331
+ "</select></label>",
332
+ recentConnectionSelect,
333
+ renderField({
332
334
  label: "Target Path",
333
335
  name: "targetPath",
334
336
  placeholder: "/absolute/path/to/target.sqlite",
335
- })}
336
- ${renderField({
337
+ }),
338
+ renderField({
337
339
  label: "Target Label",
338
340
  name: "label",
339
341
  placeholder: "Optional display name",
340
- })}
341
- `;
342
+ }),
343
+ ].join("");
342
344
  }
343
345
 
344
346
  function renderImportSqlForm(modal, state) {
@@ -376,71 +378,51 @@ function renderImportSqlForm(modal, state) {
376
378
 
377
379
  function renderDeleteRowConfirmForm(modal) {
378
380
  const rowPreview = modal.rowPreview ?? [];
379
-
380
- return `
381
- <form class="space-y-5" data-form="delete-row-confirm">
382
- <div class="space-y-3">
383
- <p class="text-sm leading-7 text-on-surface">
384
- Delete this row from <span class="font-bold text-primary-container">${escapeHtml(
385
- modal.tableName ?? "the current table"
386
- )}</span>?
387
- </p>
388
- <p class="text-sm leading-7 text-on-surface-variant/65">
389
- This action cannot be undone.
390
- </p>
391
- ${
392
- modal.rowLabel
393
- ? `
394
- <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
395
- ${escapeHtml(modal.rowLabel)}
396
- </div>
397
- `
398
- : ""
399
- }
400
- ${
401
- rowPreview.length
402
- ? `
403
- <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
404
- ${rowPreview
405
- .map(
406
- (field) => `
407
- <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">
408
- <div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
409
- ${escapeHtml(field.label)}
410
- </div>
411
- <div
412
- class="mt-2 text-sm text-on-surface"
413
- title="${escapeHtml(field.fullValue ?? field.value ?? "")}"
414
- >
415
- ${escapeHtml(field.value ?? "")}
416
- </div>
417
- </div>
418
- `
419
- )
420
- .join("")}
421
- </div>
422
- `
423
- : ""
424
- }
425
- </div>
426
- ${renderError(modal.error)}
427
- <div class="flex items-center justify-end gap-3 pt-2">
428
- <button
429
- class="standard-button"
430
- data-action="close-modal"
431
- type="button"
432
- >
433
- Cancel
434
- </button>
435
- <button
436
- class="delete-button"
437
- type="submit"
438
- >
439
- ${modal.submitting ? "Deleting..." : "Delete Row"}
440
- </button>
441
- </div>
442
- </form>
443
- `;
381
+ const rowLabelMarkup = modal.rowLabel
382
+ ? [
383
+ '<div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">',
384
+ escapeHtml(modal.rowLabel),
385
+ "</div>",
386
+ ].join("")
387
+ : "";
388
+ const rowPreviewMarkup = rowPreview.length
389
+ ? [
390
+ '<div class="grid grid-cols-1 gap-3 md:grid-cols-2">',
391
+ rowPreview
392
+ .map((field) =>
393
+ [
394
+ '<div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">',
395
+ '<div class="text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">',
396
+ escapeHtml(field.label),
397
+ "</div>",
398
+ '<div class="mt-2 text-sm text-on-surface" title="',
399
+ escapeHtml(field.fullValue ?? field.value ?? ""),
400
+ '">',
401
+ escapeHtml(field.value ?? ""),
402
+ "</div></div>",
403
+ ].join("")
404
+ )
405
+ .join(""),
406
+ "</div>",
407
+ ].join("")
408
+ : "";
409
+
410
+ return [
411
+ '<form class="space-y-5" data-form="delete-row-confirm"><div class="space-y-3">',
412
+ '<p class="text-sm leading-7 text-on-surface">Delete this row from <span class="font-bold text-primary-container">',
413
+ escapeHtml(modal.tableName ?? "the current table"),
414
+ "</span>?</p>",
415
+ '<p class="text-sm leading-7 text-on-surface-variant/65">This action cannot be undone.</p>',
416
+ rowLabelMarkup,
417
+ rowPreviewMarkup,
418
+ "</div>",
419
+ renderError(modal.error),
420
+ '<div class="flex items-center justify-end gap-3 pt-2">',
421
+ '<button class="standard-button" data-action="close-modal" type="button">Cancel</button>',
422
+ '<button class="delete-button" type="submit">',
423
+ modal.submitting ? "Deleting..." : "Delete Row",
424
+ "</button></div></form>",
425
+ ].join("");
444
426
  }
445
427
 
446
428
  function renderChartColumnOptions(analysis, { allowEmpty = false, includeNumericHint = false } = {}) {
@@ -693,69 +675,37 @@ function renderChartEditorForm(modal, state) {
693
675
  }
694
676
 
695
677
  function renderDeleteChartForm(modal) {
696
- return `
697
- <form class="space-y-5" data-form="delete-query-chart">
698
- <div class="space-y-3">
699
- <p class="text-sm leading-7 text-on-surface">
700
- Delete chart <span class="font-bold text-primary-container">${escapeHtml(
701
- modal.chartName ?? "Chart"
702
- )}</span>?
703
- </p>
704
- <p class="text-sm leading-7 text-on-surface-variant/65">
705
- The linked query-history entry stays intact. Only this chart definition is removed.
706
- </p>
707
- </div>
708
- ${renderError(modal.error)}
709
- <div class="flex items-center justify-end gap-3 pt-2">
710
- <button
711
- class="standard-button"
712
- data-action="close-modal"
713
- type="button"
714
- >
715
- Cancel
716
- </button>
717
- <button
718
- class="delete-button"
719
- type="submit"
720
- >
721
- ${modal.submitting ? "Deleting..." : "Delete Chart"}
722
- </button>
723
- </div>
724
- </form>
725
- `;
678
+ return [
679
+ '<form class="space-y-5" data-form="delete-query-chart"><div class="space-y-3">',
680
+ '<p class="text-sm leading-7 text-on-surface">Delete chart <span class="font-bold text-primary-container">',
681
+ escapeHtml(modal.chartName ?? "Chart"),
682
+ "</span>?</p>",
683
+ '<p class="text-sm leading-7 text-on-surface-variant/65">The linked query-history entry stays intact. Only this chart definition is removed.</p>',
684
+ "</div>",
685
+ renderError(modal.error),
686
+ '<div class="flex items-center justify-end gap-3 pt-2">',
687
+ '<button class="standard-button" data-action="close-modal" type="button">Cancel</button>',
688
+ '<button class="delete-button" type="submit">',
689
+ modal.submitting ? "Deleting..." : "Delete Chart",
690
+ "</button></div></form>",
691
+ ].join("");
726
692
  }
727
693
 
728
694
  function renderDeleteQueryHistoryForm(modal) {
729
- return `
730
- <form class="space-y-5" data-form="delete-query-history-confirm">
731
- <div class="space-y-3">
732
- <p class="text-sm leading-7 text-on-surface">
733
- Delete query <span class="font-bold text-primary-container">${escapeHtml(
734
- modal.queryTitle ?? "SQL query"
735
- )}</span>?
736
- </p>
737
- <p class="text-sm leading-7 text-on-surface-variant/65">
738
- This removes the query-history entry and all recorded runs linked to it.
739
- </p>
740
- </div>
741
- ${renderError(modal.error)}
742
- <div class="flex items-center justify-end gap-3 pt-2">
743
- <button
744
- class="standard-button"
745
- data-action="close-modal"
746
- type="button"
747
- >
748
- Cancel
749
- </button>
750
- <button
751
- class="delete-button"
752
- type="submit"
753
- >
754
- ${modal.submitting ? "Deleting..." : "Delete Query"}
755
- </button>
756
- </div>
757
- </form>
758
- `;
695
+ return [
696
+ '<form class="space-y-5" data-form="delete-query-history-confirm"><div class="space-y-3">',
697
+ '<p class="text-sm leading-7 text-on-surface">Delete query <span class="font-bold text-primary-container">',
698
+ escapeHtml(modal.queryTitle ?? "SQL query"),
699
+ "</span>?</p>",
700
+ '<p class="text-sm leading-7 text-on-surface-variant/65">This removes the query-history entry and all recorded runs linked to it.</p>',
701
+ "</div>",
702
+ renderError(modal.error),
703
+ '<div class="flex items-center justify-end gap-3 pt-2">',
704
+ '<button class="standard-button" data-action="close-modal" type="button">Cancel</button>',
705
+ '<button class="delete-button" type="submit">',
706
+ modal.submitting ? "Deleting..." : "Delete Query",
707
+ "</button></div></form>",
708
+ ].join("");
759
709
  }
760
710
 
761
711
  function renderCreateMediaTaggingMappingTableForm(modal, state) {
@@ -16,7 +16,7 @@ export function renderPageHeader({ eyebrow = "", title, subtitle = "", actions =
16
16
  `
17
17
  : ""
18
18
  }
19
- <h1 class="text-5xl font-['Space_Grotesk'] font-bold text-[#FCE300] tracking-tighter uppercase">${escapeHtml(
19
+ <h1 class="text-5xl font-headline font-bold text-[#FCE300] tracking-tighter uppercase">${escapeHtml(
20
20
  title
21
21
  )}</h1>
22
22
  ${
@@ -18,36 +18,22 @@ function renderHighlightedQuery(query) {
18
18
  }
19
19
 
20
20
  function renderEditorSurface({ query }) {
21
- return `
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>
30
- </div>
31
- <div class="relative min-h-0 flex-1 overflow-hidden bg-surface-container p-6 font-mono text-sm leading-relaxed">
32
- <div class="pointer-events-none absolute right-0 top-0 p-4 opacity-5">
33
- <span class="material-symbols-outlined text-[120px] font-thin">terminal</span>
34
- </div>
35
- <div class="query-editor-layer relative z-10 h-full min-h-[140px]">
36
- <div
37
- aria-hidden="true"
38
- class="query-editor-highlight"
39
- data-query-editor-highlight
40
- >${renderHighlightedQuery(query)}</div>
41
- <textarea
42
- class="query-editor-input custom-scrollbar relative z-10 h-full min-h-[140px] w-full resize-none border-none focus:ring-0"
43
- data-bind="current-query"
44
- placeholder="SELECT name FROM sqlite_master WHERE type = 'table';"
45
- spellcheck="false"
46
- >${escapeHtml(query)}</textarea>
47
- </div>
48
- </div>
49
- </div>
50
- `;
21
+ return [
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 class="query-editor-gutter-track flex w-full flex-col items-center" data-query-editor-gutter>',
25
+ renderLineNumbers(query),
26
+ "</div></div>",
27
+ '<div class="relative min-h-0 flex-1 overflow-hidden bg-surface-container p-6 font-mono text-sm leading-relaxed">',
28
+ '<div class="pointer-events-none absolute right-0 top-0 p-4 opacity-5"><span class="material-symbols-outlined text-[120px] font-thin">terminal</span></div>',
29
+ '<div class="query-editor-layer relative z-10 h-full min-h-[140px]">',
30
+ '<div aria-hidden="true" class="query-editor-highlight" data-query-editor-highlight>',
31
+ renderHighlightedQuery(query),
32
+ "</div>",
33
+ '<textarea class="query-editor-input custom-scrollbar relative z-10 h-full min-h-[140px] w-full resize-none border-none focus:ring-0" data-bind="current-query" placeholder="SELECT name FROM sqlite_master WHERE type = \'table\';" spellcheck="false">',
34
+ escapeHtml(query),
35
+ "</textarea></div></div></div>",
36
+ ].join("");
51
37
  }
52
38
 
53
39
  export function renderQueryEditor({