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
@@ -46,6 +46,63 @@ const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
46
46
  ".webp": "webp",
47
47
  };
48
48
  const QUERY_HISTORY_MIGRATION_KEY = "queryHistoryV1Migrated";
49
+ const MEDIA_TAGGING_CONFIG_FIELDS = [
50
+ {
51
+ column: "tag_table",
52
+ property: "tagTable",
53
+ definition: "tag_table TEXT NOT NULL DEFAULT ''",
54
+ },
55
+ {
56
+ column: "media_table",
57
+ property: "mediaTable",
58
+ definition: "media_table TEXT NOT NULL DEFAULT ''",
59
+ },
60
+ {
61
+ column: "path_column",
62
+ property: "pathColumn",
63
+ definition: "path_column TEXT NOT NULL DEFAULT ''",
64
+ },
65
+ {
66
+ column: "tagged_column",
67
+ property: "taggedColumn",
68
+ definition: "tagged_column TEXT NOT NULL DEFAULT ''",
69
+ },
70
+ {
71
+ column: "untagged_query",
72
+ property: "untaggedQuery",
73
+ definition: "untagged_query TEXT NOT NULL DEFAULT ''",
74
+ },
75
+ {
76
+ column: "tagged_query",
77
+ property: "taggedQuery",
78
+ definition: "tagged_query TEXT NOT NULL DEFAULT ''",
79
+ },
80
+ {
81
+ column: "mapping_table",
82
+ property: "mappingTable",
83
+ definition: "mapping_table TEXT NOT NULL DEFAULT ''",
84
+ },
85
+ ];
86
+
87
+ function normalizeMediaTaggingConfigValue(value) {
88
+ return String(value ?? "").trim();
89
+ }
90
+
91
+ function normalizeMediaTaggingConfigRecord(config = {}) {
92
+ return {
93
+ tagTable: normalizeMediaTaggingConfigValue(config.tagTable),
94
+ mediaTable: normalizeMediaTaggingConfigValue(config.mediaTable),
95
+ pathColumn: normalizeMediaTaggingConfigValue(config.pathColumn),
96
+ taggedColumn: normalizeMediaTaggingConfigValue(config.taggedColumn),
97
+ untaggedQuery: String(config.untaggedQuery ?? "").trim(),
98
+ taggedQuery: String(config.taggedQuery ?? "").trim(),
99
+ mappingTable: normalizeMediaTaggingConfigValue(config.mappingTable),
100
+ };
101
+ }
102
+
103
+ function isChartCompatibleQueryType(queryType) {
104
+ return String(queryType ?? "").trim().toLowerCase() === "select";
105
+ }
49
106
 
50
107
  class AppStateStore {
51
108
  constructor(filePath, options = {}) {
@@ -200,6 +257,18 @@ class AppStateStore {
200
257
 
201
258
  CREATE INDEX IF NOT EXISTS idx_query_history_chart_chart_type
202
259
  ON query_history_chart(chart_type);
260
+
261
+ CREATE TABLE IF NOT EXISTS media_tagging_config (
262
+ database_key TEXT PRIMARY KEY,
263
+ tag_table TEXT NOT NULL DEFAULT '',
264
+ media_table TEXT NOT NULL DEFAULT '',
265
+ path_column TEXT NOT NULL DEFAULT '',
266
+ tagged_column TEXT NOT NULL DEFAULT '',
267
+ untagged_query TEXT NOT NULL DEFAULT '',
268
+ tagged_query TEXT NOT NULL DEFAULT '',
269
+ mapping_table TEXT NOT NULL DEFAULT '',
270
+ updated_at TEXT NOT NULL
271
+ );
203
272
  `);
204
273
 
205
274
  const recentConnectionColumns = new Set(
@@ -212,6 +281,8 @@ class AppStateStore {
212
281
  if (!recentConnectionColumns.has("logoPath")) {
213
282
  this.db.exec("ALTER TABLE recent_connections ADD COLUMN logoPath TEXT");
214
283
  }
284
+
285
+ this.ensureMediaTaggingConfigSchema();
215
286
  }
216
287
 
217
288
  seedDefaultSettings() {
@@ -481,6 +552,85 @@ class AppStateStore {
481
552
  }
482
553
  }
483
554
 
555
+ ensureMediaTaggingConfigSchema() {
556
+ const columns = this.db
557
+ .prepare("PRAGMA table_info(media_tagging_config)")
558
+ .all()
559
+ .map((column) => column.name);
560
+ const columnSet = new Set(columns);
561
+
562
+ this.mediaTaggingConfigHasLegacyJsonColumn = columnSet.has("config_json");
563
+
564
+ for (const field of MEDIA_TAGGING_CONFIG_FIELDS) {
565
+ if (!columnSet.has(field.column)) {
566
+ this.db.exec(`ALTER TABLE media_tagging_config ADD COLUMN ${field.definition}`);
567
+ }
568
+ }
569
+
570
+ if (this.mediaTaggingConfigHasLegacyJsonColumn) {
571
+ this.backfillMediaTaggingConfigColumnsFromLegacyJson();
572
+ }
573
+ }
574
+
575
+ backfillMediaTaggingConfigColumnsFromLegacyJson() {
576
+ const rows = this.db
577
+ .prepare(
578
+ `
579
+ SELECT database_key, config_json
580
+ FROM media_tagging_config
581
+ WHERE config_json IS NOT NULL
582
+ `
583
+ )
584
+ .all();
585
+
586
+ if (!rows.length) {
587
+ return;
588
+ }
589
+
590
+ const updateStatement = this.db.prepare(`
591
+ UPDATE media_tagging_config
592
+ SET
593
+ tag_table = COALESCE(NULLIF(tag_table, ''), ?),
594
+ media_table = COALESCE(NULLIF(media_table, ''), ?),
595
+ path_column = COALESCE(NULLIF(path_column, ''), ?),
596
+ tagged_column = COALESCE(NULLIF(tagged_column, ''), ?),
597
+ untagged_query = COALESCE(NULLIF(untagged_query, ''), ?),
598
+ tagged_query = COALESCE(NULLIF(tagged_query, ''), ?),
599
+ mapping_table = COALESCE(NULLIF(mapping_table, ''), ?)
600
+ WHERE database_key = ?
601
+ `);
602
+
603
+ this.db.transaction(() => {
604
+ for (const row of rows) {
605
+ const parsedConfig = this.parseStoredValue(row.config_json);
606
+ const normalizedConfig = normalizeMediaTaggingConfigRecord(
607
+ parsedConfig && typeof parsedConfig === "object" && !Array.isArray(parsedConfig)
608
+ ? parsedConfig
609
+ : {}
610
+ );
611
+
612
+ updateStatement.run(
613
+ normalizedConfig.tagTable,
614
+ normalizedConfig.mediaTable,
615
+ normalizedConfig.pathColumn,
616
+ normalizedConfig.taggedColumn,
617
+ normalizedConfig.untaggedQuery,
618
+ normalizedConfig.taggedQuery,
619
+ normalizedConfig.mappingTable,
620
+ row.database_key
621
+ );
622
+ }
623
+ })();
624
+ }
625
+
626
+ buildMediaTaggingConfigRecord(row = {}) {
627
+ return normalizeMediaTaggingConfigRecord(
628
+ Object.fromEntries(
629
+ MEDIA_TAGGING_CONFIG_FIELDS.map((field) => [field.property, row[field.column] ?? null])
630
+ )
631
+ );
632
+ }
633
+
484
634
  getMetaValue(key) {
485
635
  const row = this.db
486
636
  .prepare("SELECT value FROM app_meta WHERE key = ?")
@@ -601,6 +751,9 @@ class AppStateStore {
601
751
  }),
602
752
  previewSql: buildSqlPreview(row.raw_sql ?? row.rawSql),
603
753
  lastRun,
754
+ chartsEligible:
755
+ isChartCompatibleQueryType(queryType) &&
756
+ (!lastRun || String(lastRun.status ?? "").trim().toLowerCase() !== "error"),
604
757
  };
605
758
  }
606
759
 
@@ -661,6 +814,7 @@ class AppStateStore {
661
814
  search,
662
815
  queryType,
663
816
  onlySaved = false,
817
+ onlyUnsaved = false,
664
818
  onlyFavorites = false,
665
819
  latestStatus = null,
666
820
  } = {}) {
@@ -702,6 +856,10 @@ class AppStateStore {
702
856
  clauses.push("q.is_saved = 1");
703
857
  }
704
858
 
859
+ if (onlyUnsaved) {
860
+ clauses.push("q.is_saved = 0");
861
+ }
862
+
705
863
  if (onlyFavorites) {
706
864
  clauses.push("q.is_favorite = 1");
707
865
  }
@@ -736,6 +894,7 @@ class AppStateStore {
736
894
  search = "",
737
895
  queryType = null,
738
896
  onlySaved = false,
897
+ onlyUnsaved = false,
739
898
  onlyFavorites = false,
740
899
  latestStatus = null,
741
900
  } = {}) {
@@ -768,6 +927,7 @@ class AppStateStore {
768
927
  search,
769
928
  queryType,
770
929
  onlySaved,
930
+ onlyUnsaved,
771
931
  onlyFavorites,
772
932
  latestStatus,
773
933
  });
@@ -1092,6 +1252,7 @@ class AppStateStore {
1092
1252
  LIMIT 1
1093
1253
  )
1094
1254
  WHERE q.database_key = ?
1255
+ AND q.query_type = 'select'
1095
1256
  AND COALESCE(latest.status, 'success') != 'error'
1096
1257
  ORDER BY q.last_used_at DESC, q.id DESC
1097
1258
  `)
@@ -1144,6 +1305,54 @@ class AppStateStore {
1144
1305
  return this.decorateQueryHistoryRow(row);
1145
1306
  }
1146
1307
 
1308
+ findQueryHistoryItemBySql(databaseKey, rawSql) {
1309
+ const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
1310
+ const normalizedSql = normalizeSql(rawSql);
1311
+
1312
+ if (!normalizedDatabaseKey || !normalizedSql) {
1313
+ return null;
1314
+ }
1315
+
1316
+ const row = this.db
1317
+ .prepare(`
1318
+ SELECT
1319
+ q.id,
1320
+ q.database_key,
1321
+ q.normalized_sql,
1322
+ q.raw_sql,
1323
+ q.title,
1324
+ q.notes,
1325
+ q.query_type,
1326
+ q.tables_detected,
1327
+ q.is_favorite,
1328
+ q.is_saved,
1329
+ q.is_destructive,
1330
+ q.use_count,
1331
+ q.first_executed_at,
1332
+ q.last_used_at,
1333
+ latest.id AS last_run_id,
1334
+ latest.executed_at AS last_run_executed_at,
1335
+ latest.duration_ms AS last_run_duration_ms,
1336
+ latest.row_count AS last_run_row_count,
1337
+ latest.status AS last_run_status,
1338
+ latest.error_message AS last_run_error_message,
1339
+ latest.affected_rows AS last_run_affected_rows
1340
+ FROM query_history q
1341
+ LEFT JOIN query_runs latest
1342
+ ON latest.id = (
1343
+ SELECT runs.id
1344
+ FROM query_runs runs
1345
+ WHERE runs.history_id = q.id
1346
+ ORDER BY runs.executed_at DESC, runs.id DESC
1347
+ LIMIT 1
1348
+ )
1349
+ WHERE q.database_key = ? AND q.normalized_sql = ?
1350
+ `)
1351
+ .get(normalizedDatabaseKey, normalizedSql);
1352
+
1353
+ return row ? this.decorateQueryHistoryRow(row) : null;
1354
+ }
1355
+
1147
1356
  getQueryHistoryItemForDatabase(historyId, databaseKey) {
1148
1357
  const item = this.getQueryHistoryItemById(historyId);
1149
1358
  const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
@@ -1155,6 +1364,16 @@ class AppStateStore {
1155
1364
  return item;
1156
1365
  }
1157
1366
 
1367
+ getChartQueryHistoryItemForDatabase(historyId, databaseKey) {
1368
+ const item = this.getQueryHistoryItemForDatabase(historyId, databaseKey);
1369
+
1370
+ if (!isChartCompatibleQueryType(item.queryType)) {
1371
+ throw new ValidationError("Only SELECT queries can be opened in Charts.");
1372
+ }
1373
+
1374
+ return item;
1375
+ }
1376
+
1158
1377
  getQueryRunsByHistoryId(historyId, limit = 8) {
1159
1378
  const normalizedLimit = Math.max(1, Math.min(50, Number(limit) || 8));
1160
1379
 
@@ -1259,7 +1478,7 @@ class AppStateStore {
1259
1478
  }
1260
1479
 
1261
1480
  getQueryHistoryChartsDetail(historyId, databaseKey) {
1262
- const item = this.getQueryHistoryItemForDatabase(historyId, databaseKey);
1481
+ const item = this.getChartQueryHistoryItemForDatabase(historyId, databaseKey);
1263
1482
 
1264
1483
  return {
1265
1484
  item,
@@ -1318,7 +1537,7 @@ class AppStateStore {
1318
1537
  tableVisible = true,
1319
1538
  databaseKey = null,
1320
1539
  } = {}) {
1321
- const item = this.getQueryHistoryItemForDatabase(queryHistoryId, databaseKey);
1540
+ const item = this.getChartQueryHistoryItemForDatabase(queryHistoryId, databaseKey);
1322
1541
  const normalizedChartType = normalizeChartType(chartType);
1323
1542
  const normalizedConfig = normalizeChartConfig(normalizedChartType, config);
1324
1543
  const normalizedResultColumns = normalizeResultColumns(resultColumns);
@@ -1860,6 +2079,106 @@ class AppStateStore {
1860
2079
 
1861
2080
  return this.getSettings();
1862
2081
  }
2082
+
2083
+ getMediaTaggingConfig(databaseKey) {
2084
+ const normalizedDatabaseKey = String(databaseKey ?? "").trim();
2085
+
2086
+ if (!normalizedDatabaseKey) {
2087
+ return null;
2088
+ }
2089
+
2090
+ const row = this.db
2091
+ .prepare(
2092
+ `
2093
+ SELECT
2094
+ tag_table,
2095
+ media_table,
2096
+ path_column,
2097
+ tagged_column,
2098
+ untagged_query,
2099
+ tagged_query,
2100
+ mapping_table,
2101
+ updated_at
2102
+ FROM media_tagging_config
2103
+ WHERE database_key = ?
2104
+ `
2105
+ )
2106
+ .get(normalizedDatabaseKey);
2107
+
2108
+ if (!row) {
2109
+ return null;
2110
+ }
2111
+
2112
+ return {
2113
+ config: structuredClone(this.buildMediaTaggingConfigRecord(row)),
2114
+ updatedAt: row.updated_at ?? null,
2115
+ };
2116
+ }
2117
+
2118
+ setMediaTaggingConfig(databaseKey, config) {
2119
+ const normalizedDatabaseKey = String(databaseKey ?? "").trim();
2120
+
2121
+ if (!normalizedDatabaseKey) {
2122
+ throw new ValidationError("Media tagging configuration requires a database key.");
2123
+ }
2124
+
2125
+ const updatedAt = new Date().toISOString();
2126
+ const normalizedConfig = normalizeMediaTaggingConfigRecord(config);
2127
+ const values = [
2128
+ normalizedDatabaseKey,
2129
+ normalizedConfig.tagTable,
2130
+ normalizedConfig.mediaTable,
2131
+ normalizedConfig.pathColumn,
2132
+ normalizedConfig.taggedColumn,
2133
+ normalizedConfig.untaggedQuery,
2134
+ normalizedConfig.taggedQuery,
2135
+ normalizedConfig.mappingTable,
2136
+ ];
2137
+ const explicitColumns = [
2138
+ "database_key",
2139
+ ...MEDIA_TAGGING_CONFIG_FIELDS.map((field) => field.column),
2140
+ ];
2141
+ const assignments = MEDIA_TAGGING_CONFIG_FIELDS.map(
2142
+ (field) => `${field.column} = excluded.${field.column}`
2143
+ );
2144
+
2145
+ if (this.mediaTaggingConfigHasLegacyJsonColumn) {
2146
+ explicitColumns.push("config_json");
2147
+ assignments.push("config_json = excluded.config_json");
2148
+ values.push(JSON.stringify(normalizedConfig));
2149
+ }
2150
+
2151
+ explicitColumns.push("updated_at");
2152
+ assignments.push("updated_at = excluded.updated_at");
2153
+ values.push(updatedAt);
2154
+
2155
+ this.db
2156
+ .prepare(
2157
+ `
2158
+ INSERT INTO media_tagging_config (${explicitColumns.join(", ")})
2159
+ VALUES (${explicitColumns.map(() => "?").join(", ")})
2160
+ ON CONFLICT(database_key) DO UPDATE SET
2161
+ ${assignments.join(",\n ")}
2162
+ `
2163
+ )
2164
+ .run(...values);
2165
+
2166
+ return this.getMediaTaggingConfig(normalizedDatabaseKey);
2167
+ }
2168
+
2169
+ clearMediaTaggingConfig(databaseKey) {
2170
+ const normalizedDatabaseKey = String(databaseKey ?? "").trim();
2171
+
2172
+ if (!normalizedDatabaseKey) {
2173
+ return false;
2174
+ }
2175
+
2176
+ const result = this.db
2177
+ .prepare("DELETE FROM media_tagging_config WHERE database_key = ?")
2178
+ .run(normalizedDatabaseKey);
2179
+
2180
+ return result.changes > 0;
2181
+ }
1863
2182
  }
1864
2183
 
1865
2184
  module.exports = {