sqlite-hub 0.3.2 → 0.5.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.
- package/README.md +5 -5
- package/changelog.md +14 -0
- package/{js → frontend/js}/api.js +99 -5
- package/{js → frontend/js}/app.js +162 -11
- package/{js → frontend/js}/components/connectionCard.js +8 -9
- package/frontend/js/components/connectionLogo.js +33 -0
- package/{js → frontend/js}/components/dataGrid.js +3 -3
- package/{js → frontend/js}/components/emptyState.js +25 -11
- package/{js → frontend/js}/components/modal.js +57 -0
- package/{js → frontend/js}/components/queryEditor.js +33 -55
- package/frontend/js/components/queryHistoryDetail.js +263 -0
- package/frontend/js/components/queryHistoryPanel.js +228 -0
- package/{js → frontend/js}/components/queryResults.js +32 -46
- package/{js → frontend/js}/components/rowEditorPanel.js +73 -14
- package/{js → frontend/js}/components/sidebar.js +8 -3
- package/{js → frontend/js}/store.js +577 -21
- package/{js → frontend/js}/utils/format.js +23 -0
- package/{js → frontend/js}/views/data.js +136 -10
- package/{js → frontend/js}/views/editor.js +34 -10
- package/{js → frontend/js}/views/overview.js +15 -0
- package/{js → frontend/js}/views/structure.js +10 -12
- package/{styles → frontend/styles}/components.css +106 -0
- package/{styles → frontend/styles}/structure-graph.css +5 -10
- package/package.json +2 -2
- package/{publish_brew.sh → scripts/publish_brew.sh} +2 -2
- package/{publish_npm.sh → scripts/publish_npm.sh} +2 -2
- package/server/data/db_logos/.gitkeep +0 -0
- package/server/routes/connections.js +2 -0
- package/server/routes/data.js +2 -0
- package/server/routes/export.js +4 -1
- package/server/routes/overview.js +12 -0
- package/server/routes/sql.js +163 -5
- package/server/server.js +8 -6
- package/server/services/sqlite/connectionManager.js +68 -33
- package/server/services/sqlite/dataBrowserService.js +4 -16
- package/server/services/sqlite/exportService.js +4 -16
- package/server/services/sqlite/overviewService.js +34 -0
- package/server/services/sqlite/sqlExecutor.js +83 -63
- package/server/services/sqlite/tableSort.js +63 -0
- package/server/services/storage/appStateStore.js +832 -20
- package/server/services/storage/queryHistoryUtils.js +169 -0
- package/server/utils/appPaths.js +42 -18
- package/{assets → frontend/assets}/images/logo.webp +0 -0
- package/{assets → frontend/assets}/images/logo_extrasmall.webp +0 -0
- package/{assets → frontend/assets}/images/logo_raw.png +0 -0
- package/{assets → frontend/assets}/images/logo_small.webp +0 -0
- package/{assets → frontend/assets}/mockups/connections.png +0 -0
- package/{assets → frontend/assets}/mockups/data.png +0 -0
- package/{assets → frontend/assets}/mockups/data_edit.png +0 -0
- package/{assets → frontend/assets}/mockups/graph_visualize.png +0 -0
- package/{assets → frontend/assets}/mockups/home.png +0 -0
- package/{assets → frontend/assets}/mockups/overview.png +0 -0
- package/{assets → frontend/assets}/mockups/sql_editor.png +0 -0
- package/{assets → frontend/assets}/mockups/structure.png +0 -0
- package/{index.html → frontend/index.html} +0 -0
- package/{js → frontend/js}/components/actionBar.js +0 -0
- package/{js → frontend/js}/components/appShell.js +0 -0
- package/{js → frontend/js}/components/badges.js +0 -0
- package/{js → frontend/js}/components/bottomTabs.js +1 -1
- /package/{js → frontend/js}/components/metricCard.js +0 -0
- /package/{js → frontend/js}/components/pageHeader.js +0 -0
- /package/{js → frontend/js}/components/statusBar.js +0 -0
- /package/{js → frontend/js}/components/structureGraph.js +0 -0
- /package/{js → frontend/js}/components/toast.js +0 -0
- /package/{js → frontend/js}/components/topNav.js +0 -0
- /package/{js → frontend/js}/lib/cytoscapeRuntime.js +0 -0
- /package/{js → frontend/js}/router.js +0 -0
- /package/{js → frontend/js}/views/connections.js +0 -0
- /package/{js → frontend/js}/views/landing.js +0 -0
- /package/{js → frontend/js}/views/settings.js +0 -0
- /package/{styles → frontend/styles}/base.css +0 -0
- /package/{styles → frontend/styles}/layout.css +0 -0
- /package/{styles → frontend/styles}/tokens.css +0 -0
- /package/{styles → frontend/styles}/views.css +0 -0
- /package/{data → server/data}/.gitkeep +0 -0
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
3
|
const Database = require("better-sqlite3");
|
|
4
|
+
const { NotFoundError, ValidationError } = require("../../utils/errors");
|
|
5
|
+
const {
|
|
6
|
+
buildAutoTitle,
|
|
7
|
+
buildSqlPreview,
|
|
8
|
+
detectQueryType,
|
|
9
|
+
detectTables,
|
|
10
|
+
isDestructiveQuery,
|
|
11
|
+
normalizeSql,
|
|
12
|
+
} = require("./queryHistoryUtils");
|
|
4
13
|
|
|
5
14
|
const DEFAULT_STATE = {
|
|
6
15
|
recentConnections: [],
|
|
@@ -16,9 +25,25 @@ const DEFAULT_STATE = {
|
|
|
16
25
|
},
|
|
17
26
|
};
|
|
18
27
|
|
|
28
|
+
const CONNECTION_LOGO_DIRECTORY = "db_logos";
|
|
29
|
+
const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
|
|
30
|
+
const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
|
|
31
|
+
"image/jpeg": "jpg",
|
|
32
|
+
"image/png": "png",
|
|
33
|
+
"image/webp": "webp",
|
|
34
|
+
};
|
|
35
|
+
const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
|
|
36
|
+
".jpeg": "jpg",
|
|
37
|
+
".jpg": "jpg",
|
|
38
|
+
".png": "png",
|
|
39
|
+
".webp": "webp",
|
|
40
|
+
};
|
|
41
|
+
const QUERY_HISTORY_MIGRATION_KEY = "queryHistoryV1Migrated";
|
|
42
|
+
|
|
19
43
|
class AppStateStore {
|
|
20
44
|
constructor(filePath, options = {}) {
|
|
21
45
|
this.filePath = filePath;
|
|
46
|
+
this.logoDirectory = path.join(path.dirname(this.filePath), CONNECTION_LOGO_DIRECTORY);
|
|
22
47
|
this.legacyFilePath = options.legacyFilePath ?? null;
|
|
23
48
|
this.legacyDatabasePaths = Array.isArray(options.legacyDatabasePaths)
|
|
24
49
|
? options.legacyDatabasePaths
|
|
@@ -26,6 +51,7 @@ class AppStateStore {
|
|
|
26
51
|
this.isFreshDatabase = !fs.existsSync(filePath);
|
|
27
52
|
|
|
28
53
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
54
|
+
fs.mkdirSync(this.logoDirectory, { recursive: true });
|
|
29
55
|
|
|
30
56
|
this.db = new Database(this.filePath);
|
|
31
57
|
this.configureDatabase();
|
|
@@ -37,6 +63,8 @@ class AppStateStore {
|
|
|
37
63
|
if (!importedLegacyDatabase && this.shouldImportLegacyState()) {
|
|
38
64
|
this.tryImportLegacyState();
|
|
39
65
|
}
|
|
66
|
+
|
|
67
|
+
this.migrateLegacySqlHistory();
|
|
40
68
|
}
|
|
41
69
|
|
|
42
70
|
configureDatabase() {
|
|
@@ -64,7 +92,8 @@ class AppStateStore {
|
|
|
64
92
|
lastOpenedAt TEXT NOT NULL,
|
|
65
93
|
lastModifiedAt TEXT,
|
|
66
94
|
sizeBytes INTEGER,
|
|
67
|
-
readOnly INTEGER NOT NULL DEFAULT 0
|
|
95
|
+
readOnly INTEGER NOT NULL DEFAULT 0,
|
|
96
|
+
logoPath TEXT
|
|
68
97
|
);
|
|
69
98
|
|
|
70
99
|
CREATE TABLE IF NOT EXISTS sql_history (
|
|
@@ -85,7 +114,74 @@ class AppStateStore {
|
|
|
85
114
|
|
|
86
115
|
CREATE INDEX IF NOT EXISTS idx_sql_history_executed_at
|
|
87
116
|
ON sql_history(executedAt DESC, id ASC);
|
|
117
|
+
|
|
118
|
+
CREATE TABLE IF NOT EXISTS query_history (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
database_key TEXT NOT NULL,
|
|
121
|
+
normalized_sql TEXT NOT NULL,
|
|
122
|
+
raw_sql TEXT NOT NULL,
|
|
123
|
+
title TEXT,
|
|
124
|
+
notes TEXT,
|
|
125
|
+
query_type TEXT NOT NULL DEFAULT 'other',
|
|
126
|
+
tables_detected TEXT NOT NULL DEFAULT '[]',
|
|
127
|
+
is_favorite INTEGER NOT NULL DEFAULT 0,
|
|
128
|
+
is_saved INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
is_destructive INTEGER NOT NULL DEFAULT 0,
|
|
130
|
+
use_count INTEGER NOT NULL DEFAULT 1,
|
|
131
|
+
first_executed_at TEXT NOT NULL,
|
|
132
|
+
last_used_at TEXT NOT NULL
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
CREATE TABLE IF NOT EXISTS query_runs (
|
|
136
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
137
|
+
history_id INTEGER NOT NULL,
|
|
138
|
+
executed_at TEXT NOT NULL,
|
|
139
|
+
duration_ms INTEGER,
|
|
140
|
+
row_count INTEGER,
|
|
141
|
+
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
|
|
142
|
+
error_message TEXT,
|
|
143
|
+
affected_rows INTEGER,
|
|
144
|
+
FOREIGN KEY (history_id) REFERENCES query_history(id) ON DELETE CASCADE
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_database_key
|
|
148
|
+
ON query_history(database_key);
|
|
149
|
+
|
|
150
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_last_used_at
|
|
151
|
+
ON query_history(last_used_at DESC);
|
|
152
|
+
|
|
153
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_is_saved
|
|
154
|
+
ON query_history(is_saved);
|
|
155
|
+
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_is_favorite
|
|
157
|
+
ON query_history(is_favorite);
|
|
158
|
+
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_query_history_query_type
|
|
160
|
+
ON query_history(query_type);
|
|
161
|
+
|
|
162
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_query_history_database_normalized_sql
|
|
163
|
+
ON query_history(database_key, normalized_sql);
|
|
164
|
+
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_query_runs_history_id
|
|
166
|
+
ON query_runs(history_id);
|
|
167
|
+
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_query_runs_executed_at
|
|
169
|
+
ON query_runs(executed_at DESC);
|
|
170
|
+
|
|
171
|
+
CREATE INDEX IF NOT EXISTS idx_query_runs_status
|
|
172
|
+
ON query_runs(status);
|
|
88
173
|
`);
|
|
174
|
+
|
|
175
|
+
const recentConnectionColumns = new Set(
|
|
176
|
+
this.db
|
|
177
|
+
.prepare("PRAGMA table_info(recent_connections)")
|
|
178
|
+
.all()
|
|
179
|
+
.map((column) => column.name)
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (!recentConnectionColumns.has("logoPath")) {
|
|
183
|
+
this.db.exec("ALTER TABLE recent_connections ADD COLUMN logoPath TEXT");
|
|
184
|
+
}
|
|
89
185
|
}
|
|
90
186
|
|
|
91
187
|
seedDefaultSettings() {
|
|
@@ -153,6 +249,14 @@ class AppStateStore {
|
|
|
153
249
|
.all()
|
|
154
250
|
.map((row) => row.name)
|
|
155
251
|
);
|
|
252
|
+
const recentConnectionColumns = tables.has("recent_connections")
|
|
253
|
+
? new Set(
|
|
254
|
+
legacyDb
|
|
255
|
+
.prepare("PRAGMA table_info(recent_connections)")
|
|
256
|
+
.all()
|
|
257
|
+
.map((column) => column.name)
|
|
258
|
+
)
|
|
259
|
+
: new Set();
|
|
156
260
|
|
|
157
261
|
return {
|
|
158
262
|
settings: tables.has("settings")
|
|
@@ -173,7 +277,8 @@ class AppStateStore {
|
|
|
173
277
|
lastOpenedAt,
|
|
174
278
|
lastModifiedAt,
|
|
175
279
|
sizeBytes,
|
|
176
|
-
readOnly
|
|
280
|
+
readOnly,
|
|
281
|
+
${recentConnectionColumns.has("logoPath") ? "logoPath" : "NULL AS logoPath"}
|
|
177
282
|
FROM recent_connections
|
|
178
283
|
ORDER BY lastOpenedAt DESC, id ASC
|
|
179
284
|
`)
|
|
@@ -223,16 +328,18 @@ class AppStateStore {
|
|
|
223
328
|
lastOpenedAt,
|
|
224
329
|
lastModifiedAt,
|
|
225
330
|
sizeBytes,
|
|
226
|
-
readOnly
|
|
331
|
+
readOnly,
|
|
332
|
+
logoPath
|
|
227
333
|
)
|
|
228
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
334
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
229
335
|
ON CONFLICT(id) DO UPDATE SET
|
|
230
336
|
label = excluded.label,
|
|
231
337
|
path = excluded.path,
|
|
232
338
|
lastOpenedAt = excluded.lastOpenedAt,
|
|
233
339
|
lastModifiedAt = excluded.lastModifiedAt,
|
|
234
340
|
sizeBytes = excluded.sizeBytes,
|
|
235
|
-
readOnly = excluded.readOnly
|
|
341
|
+
readOnly = excluded.readOnly,
|
|
342
|
+
logoPath = excluded.logoPath
|
|
236
343
|
`);
|
|
237
344
|
const insertHistory = this.db.prepare(`
|
|
238
345
|
INSERT INTO sql_history (
|
|
@@ -276,7 +383,8 @@ class AppStateStore {
|
|
|
276
383
|
connection.lastOpenedAt ?? new Date().toISOString(),
|
|
277
384
|
connection.lastModifiedAt ?? null,
|
|
278
385
|
connection.sizeBytes ?? null,
|
|
279
|
-
connection.readOnly ? 1 : 0
|
|
386
|
+
connection.readOnly ? 1 : 0,
|
|
387
|
+
this.normalizeLogoPath(connection.logoPath)
|
|
280
388
|
);
|
|
281
389
|
}
|
|
282
390
|
|
|
@@ -366,6 +474,612 @@ class AppStateStore {
|
|
|
366
474
|
.run(key, String(value));
|
|
367
475
|
}
|
|
368
476
|
|
|
477
|
+
normalizeQueryHistoryText(value) {
|
|
478
|
+
const text = String(value ?? "").trim();
|
|
479
|
+
return text ? text : null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
normalizeQueryHistoryInteger(value) {
|
|
483
|
+
if (value === null || value === undefined || value === "") {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const numericValue = Number(value);
|
|
488
|
+
return Number.isFinite(numericValue) ? Math.round(numericValue) : null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
parseTablesDetected(value) {
|
|
492
|
+
if (Array.isArray(value)) {
|
|
493
|
+
return Array.from(
|
|
494
|
+
new Set(
|
|
495
|
+
value
|
|
496
|
+
.map((entry) => String(entry ?? "").trim())
|
|
497
|
+
.filter(Boolean)
|
|
498
|
+
)
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!value) {
|
|
503
|
+
return [];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
return this.parseTablesDetected(JSON.parse(value));
|
|
508
|
+
} catch {
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
decorateQueryRun(row = {}) {
|
|
514
|
+
return {
|
|
515
|
+
id: Number(row.id),
|
|
516
|
+
historyId: Number(row.history_id ?? row.historyId),
|
|
517
|
+
executedAt: row.executed_at ?? row.executedAt ?? null,
|
|
518
|
+
durationMs: this.normalizeQueryHistoryInteger(row.duration_ms ?? row.durationMs),
|
|
519
|
+
rowCount: this.normalizeQueryHistoryInteger(row.row_count ?? row.rowCount),
|
|
520
|
+
status: row.status ?? "success",
|
|
521
|
+
errorMessage: this.normalizeQueryHistoryText(row.error_message ?? row.errorMessage),
|
|
522
|
+
affectedRows: this.normalizeQueryHistoryInteger(row.affected_rows ?? row.affectedRows),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
decorateQueryHistoryRow(row = {}) {
|
|
527
|
+
const tablesDetected = this.parseTablesDetected(row.tables_detected ?? row.tablesDetected);
|
|
528
|
+
const queryType = row.query_type ?? row.queryType ?? "other";
|
|
529
|
+
const title = this.normalizeQueryHistoryText(row.title);
|
|
530
|
+
const notes = this.normalizeQueryHistoryText(row.notes);
|
|
531
|
+
const lastRun =
|
|
532
|
+
row.last_run_id ?? row.lastRunId
|
|
533
|
+
? this.decorateQueryRun({
|
|
534
|
+
id: row.last_run_id ?? row.lastRunId,
|
|
535
|
+
history_id: row.id,
|
|
536
|
+
executed_at: row.last_run_executed_at ?? row.lastRunExecutedAt,
|
|
537
|
+
duration_ms: row.last_run_duration_ms ?? row.lastRunDurationMs,
|
|
538
|
+
row_count: row.last_run_row_count ?? row.lastRunRowCount,
|
|
539
|
+
status: row.last_run_status ?? row.lastRunStatus,
|
|
540
|
+
error_message: row.last_run_error_message ?? row.lastRunErrorMessage,
|
|
541
|
+
affected_rows: row.last_run_affected_rows ?? row.lastRunAffectedRows,
|
|
542
|
+
})
|
|
543
|
+
: null;
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
id: Number(row.id),
|
|
547
|
+
databaseKey: row.database_key ?? row.databaseKey,
|
|
548
|
+
normalizedSql: row.normalized_sql ?? row.normalizedSql,
|
|
549
|
+
rawSql: row.raw_sql ?? row.rawSql,
|
|
550
|
+
title,
|
|
551
|
+
notes,
|
|
552
|
+
queryType,
|
|
553
|
+
tablesDetected,
|
|
554
|
+
isFavorite: Boolean(row.is_favorite ?? row.isFavorite),
|
|
555
|
+
isSaved: Boolean(row.is_saved ?? row.isSaved),
|
|
556
|
+
isDestructive: Boolean(row.is_destructive ?? row.isDestructive),
|
|
557
|
+
useCount: Number(row.use_count ?? row.useCount ?? 0),
|
|
558
|
+
firstExecutedAt: row.first_executed_at ?? row.firstExecutedAt ?? null,
|
|
559
|
+
lastUsedAt: row.last_used_at ?? row.lastUsedAt ?? null,
|
|
560
|
+
displayTitle:
|
|
561
|
+
title ||
|
|
562
|
+
buildAutoTitle(row.raw_sql ?? row.rawSql, {
|
|
563
|
+
queryType,
|
|
564
|
+
tablesDetected,
|
|
565
|
+
}),
|
|
566
|
+
previewSql: buildSqlPreview(row.raw_sql ?? row.rawSql),
|
|
567
|
+
lastRun,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
buildQueryHistoryFilters({
|
|
572
|
+
databaseKey,
|
|
573
|
+
search,
|
|
574
|
+
queryType,
|
|
575
|
+
onlySaved = false,
|
|
576
|
+
onlyFavorites = false,
|
|
577
|
+
latestStatus = null,
|
|
578
|
+
} = {}) {
|
|
579
|
+
const clauses = [];
|
|
580
|
+
const params = [];
|
|
581
|
+
const normalizedSearch = String(search ?? "").trim().toLowerCase();
|
|
582
|
+
|
|
583
|
+
if (databaseKey) {
|
|
584
|
+
clauses.push("q.database_key = ?");
|
|
585
|
+
params.push(databaseKey);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (normalizedSearch) {
|
|
589
|
+
const searchPattern = `%${normalizedSearch}%`;
|
|
590
|
+
clauses.push(`
|
|
591
|
+
(
|
|
592
|
+
LOWER(COALESCE(q.title, '')) LIKE ?
|
|
593
|
+
OR LOWER(q.raw_sql) LIKE ?
|
|
594
|
+
OR LOWER(COALESCE(q.notes, '')) LIKE ?
|
|
595
|
+
OR LOWER(q.normalized_sql) LIKE ?
|
|
596
|
+
OR LOWER(q.tables_detected) LIKE ?
|
|
597
|
+
)
|
|
598
|
+
`);
|
|
599
|
+
params.push(
|
|
600
|
+
searchPattern,
|
|
601
|
+
searchPattern,
|
|
602
|
+
searchPattern,
|
|
603
|
+
searchPattern,
|
|
604
|
+
searchPattern
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (queryType) {
|
|
609
|
+
clauses.push("q.query_type = ?");
|
|
610
|
+
params.push(queryType);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (onlySaved) {
|
|
614
|
+
clauses.push("q.is_saved = 1");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (onlyFavorites) {
|
|
618
|
+
clauses.push("q.is_favorite = 1");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (latestStatus) {
|
|
622
|
+
clauses.push("latest.status = ?");
|
|
623
|
+
params.push(latestStatus);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return {
|
|
627
|
+
whereSql: clauses.length ? `WHERE ${clauses.join(" AND ")}` : "",
|
|
628
|
+
params,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
getQueryHistoryOrderBy({ onlySaved = false, onlyFavorites = false, latestStatus = null } = {}) {
|
|
633
|
+
if (onlySaved || onlyFavorites) {
|
|
634
|
+
return "ORDER BY q.is_favorite DESC, q.last_used_at DESC, q.id DESC";
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (latestStatus === "error") {
|
|
638
|
+
return "ORDER BY latest.executed_at DESC, q.id DESC";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return "ORDER BY q.last_used_at DESC, q.id DESC";
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
buildQueryHistoryCollection({
|
|
645
|
+
databaseKey,
|
|
646
|
+
limit = 30,
|
|
647
|
+
offset = 0,
|
|
648
|
+
search = "",
|
|
649
|
+
queryType = null,
|
|
650
|
+
onlySaved = false,
|
|
651
|
+
onlyFavorites = false,
|
|
652
|
+
latestStatus = null,
|
|
653
|
+
} = {}) {
|
|
654
|
+
const normalizedLimit = Math.max(1, Math.min(100, Number(limit) || 30));
|
|
655
|
+
const normalizedOffset = Math.max(0, Number(offset) || 0);
|
|
656
|
+
|
|
657
|
+
if (!databaseKey) {
|
|
658
|
+
return {
|
|
659
|
+
items: [],
|
|
660
|
+
total: 0,
|
|
661
|
+
limit: normalizedLimit,
|
|
662
|
+
offset: normalizedOffset,
|
|
663
|
+
hasMore: false,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const baseFromSql = `
|
|
668
|
+
FROM query_history q
|
|
669
|
+
LEFT JOIN query_runs latest
|
|
670
|
+
ON latest.id = (
|
|
671
|
+
SELECT runs.id
|
|
672
|
+
FROM query_runs runs
|
|
673
|
+
WHERE runs.history_id = q.id
|
|
674
|
+
ORDER BY runs.executed_at DESC, runs.id DESC
|
|
675
|
+
LIMIT 1
|
|
676
|
+
)
|
|
677
|
+
`;
|
|
678
|
+
const { whereSql, params } = this.buildQueryHistoryFilters({
|
|
679
|
+
databaseKey,
|
|
680
|
+
search,
|
|
681
|
+
queryType,
|
|
682
|
+
onlySaved,
|
|
683
|
+
onlyFavorites,
|
|
684
|
+
latestStatus,
|
|
685
|
+
});
|
|
686
|
+
const orderBySql = this.getQueryHistoryOrderBy({
|
|
687
|
+
onlySaved,
|
|
688
|
+
onlyFavorites,
|
|
689
|
+
latestStatus,
|
|
690
|
+
});
|
|
691
|
+
const rows = this.db
|
|
692
|
+
.prepare(`
|
|
693
|
+
SELECT
|
|
694
|
+
q.id,
|
|
695
|
+
q.database_key,
|
|
696
|
+
q.normalized_sql,
|
|
697
|
+
q.raw_sql,
|
|
698
|
+
q.title,
|
|
699
|
+
q.notes,
|
|
700
|
+
q.query_type,
|
|
701
|
+
q.tables_detected,
|
|
702
|
+
q.is_favorite,
|
|
703
|
+
q.is_saved,
|
|
704
|
+
q.is_destructive,
|
|
705
|
+
q.use_count,
|
|
706
|
+
q.first_executed_at,
|
|
707
|
+
q.last_used_at,
|
|
708
|
+
latest.id AS last_run_id,
|
|
709
|
+
latest.executed_at AS last_run_executed_at,
|
|
710
|
+
latest.duration_ms AS last_run_duration_ms,
|
|
711
|
+
latest.row_count AS last_run_row_count,
|
|
712
|
+
latest.status AS last_run_status,
|
|
713
|
+
latest.error_message AS last_run_error_message,
|
|
714
|
+
latest.affected_rows AS last_run_affected_rows
|
|
715
|
+
${baseFromSql}
|
|
716
|
+
${whereSql}
|
|
717
|
+
${orderBySql}
|
|
718
|
+
LIMIT ?
|
|
719
|
+
OFFSET ?
|
|
720
|
+
`)
|
|
721
|
+
.all(...params, normalizedLimit, normalizedOffset)
|
|
722
|
+
.map((row) => this.decorateQueryHistoryRow(row));
|
|
723
|
+
const countRow = this.db
|
|
724
|
+
.prepare(`
|
|
725
|
+
SELECT COUNT(*) AS count
|
|
726
|
+
${baseFromSql}
|
|
727
|
+
${whereSql}
|
|
728
|
+
`)
|
|
729
|
+
.get(...params);
|
|
730
|
+
const total = Number(countRow?.count ?? 0);
|
|
731
|
+
|
|
732
|
+
return {
|
|
733
|
+
items: rows,
|
|
734
|
+
total,
|
|
735
|
+
limit: normalizedLimit,
|
|
736
|
+
offset: normalizedOffset,
|
|
737
|
+
hasMore: normalizedOffset + rows.length < total,
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
migrateLegacySqlHistory() {
|
|
742
|
+
if (this.getMetaValue(QUERY_HISTORY_MIGRATION_KEY) === "1") {
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const hasLegacyTable = Boolean(
|
|
747
|
+
this.db
|
|
748
|
+
.prepare(
|
|
749
|
+
"SELECT 1 AS exists_flag FROM sqlite_master WHERE type = 'table' AND name = 'sql_history'"
|
|
750
|
+
)
|
|
751
|
+
.get()
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
if (!hasLegacyTable) {
|
|
755
|
+
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const legacyCount = Number(
|
|
760
|
+
this.db.prepare("SELECT COUNT(*) AS count FROM sql_history").get()?.count ?? 0
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
if (!legacyCount) {
|
|
764
|
+
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const currentHistoryCount = Number(
|
|
769
|
+
this.db.prepare("SELECT COUNT(*) AS count FROM query_history").get()?.count ?? 0
|
|
770
|
+
);
|
|
771
|
+
const currentRunCount = Number(
|
|
772
|
+
this.db.prepare("SELECT COUNT(*) AS count FROM query_runs").get()?.count ?? 0
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
if (currentHistoryCount > 0 || currentRunCount > 0) {
|
|
776
|
+
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const legacyRows = this.db
|
|
781
|
+
.prepare(`
|
|
782
|
+
SELECT
|
|
783
|
+
connectionId,
|
|
784
|
+
connectionLabel,
|
|
785
|
+
sql,
|
|
786
|
+
timingMs,
|
|
787
|
+
rowCount,
|
|
788
|
+
affectedRowCount,
|
|
789
|
+
executedAt
|
|
790
|
+
FROM sql_history
|
|
791
|
+
ORDER BY executedAt ASC, id ASC
|
|
792
|
+
`)
|
|
793
|
+
.all();
|
|
794
|
+
|
|
795
|
+
this.db.transaction(() => {
|
|
796
|
+
legacyRows.forEach((entry) => {
|
|
797
|
+
const databaseKey =
|
|
798
|
+
this.normalizeQueryHistoryText(entry.connectionId) ??
|
|
799
|
+
this.normalizeQueryHistoryText(entry.connectionLabel) ??
|
|
800
|
+
"legacy:default";
|
|
801
|
+
const rawSql = String(entry.sql ?? "");
|
|
802
|
+
|
|
803
|
+
if (!normalizeSql(rawSql)) {
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
this.recordQueryExecutionInTransaction({
|
|
808
|
+
databaseKey,
|
|
809
|
+
rawSql,
|
|
810
|
+
status: "success",
|
|
811
|
+
durationMs: entry.timingMs,
|
|
812
|
+
rowCount: entry.rowCount,
|
|
813
|
+
affectedRows: entry.affectedRowCount,
|
|
814
|
+
executedAt: entry.executedAt ?? new Date().toISOString(),
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
})();
|
|
818
|
+
|
|
819
|
+
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
recordQueryExecution(entry = {}) {
|
|
823
|
+
return this.db.transaction(() => this.recordQueryExecutionInTransaction(entry))();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
recordQueryExecutionInTransaction({
|
|
827
|
+
databaseKey,
|
|
828
|
+
rawSql,
|
|
829
|
+
status,
|
|
830
|
+
durationMs = null,
|
|
831
|
+
rowCount = null,
|
|
832
|
+
affectedRows = null,
|
|
833
|
+
errorMessage = null,
|
|
834
|
+
executedAt = null,
|
|
835
|
+
} = {}) {
|
|
836
|
+
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
837
|
+
const normalizedRawSql = String(rawSql ?? "");
|
|
838
|
+
const normalizedSql = normalizeSql(normalizedRawSql);
|
|
839
|
+
|
|
840
|
+
if (!normalizedDatabaseKey) {
|
|
841
|
+
throw new ValidationError("Query history requires a database key.");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (!normalizedSql) {
|
|
845
|
+
throw new ValidationError("Query history requires executable SQL.");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (!["success", "error"].includes(status)) {
|
|
849
|
+
throw new ValidationError(`Unsupported query run status: ${status}`);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const queryType = detectQueryType(normalizedRawSql);
|
|
853
|
+
const tablesDetected = detectTables(normalizedRawSql);
|
|
854
|
+
const timestamp = this.normalizeQueryHistoryText(executedAt) ?? new Date().toISOString();
|
|
855
|
+
const destructive = isDestructiveQuery(normalizedRawSql) ? 1 : 0;
|
|
856
|
+
const serializedTables = JSON.stringify(tablesDetected);
|
|
857
|
+
const existing = this.db
|
|
858
|
+
.prepare(
|
|
859
|
+
`
|
|
860
|
+
SELECT id
|
|
861
|
+
FROM query_history
|
|
862
|
+
WHERE database_key = ? AND normalized_sql = ?
|
|
863
|
+
`
|
|
864
|
+
)
|
|
865
|
+
.get(normalizedDatabaseKey, normalizedSql);
|
|
866
|
+
let historyId = Number(existing?.id ?? 0);
|
|
867
|
+
|
|
868
|
+
if (historyId) {
|
|
869
|
+
this.db
|
|
870
|
+
.prepare(`
|
|
871
|
+
UPDATE query_history
|
|
872
|
+
SET
|
|
873
|
+
raw_sql = ?,
|
|
874
|
+
query_type = ?,
|
|
875
|
+
tables_detected = ?,
|
|
876
|
+
is_destructive = ?,
|
|
877
|
+
use_count = use_count + 1,
|
|
878
|
+
last_used_at = ?
|
|
879
|
+
WHERE id = ?
|
|
880
|
+
`)
|
|
881
|
+
.run(
|
|
882
|
+
normalizedRawSql,
|
|
883
|
+
queryType,
|
|
884
|
+
serializedTables,
|
|
885
|
+
destructive,
|
|
886
|
+
timestamp,
|
|
887
|
+
historyId
|
|
888
|
+
);
|
|
889
|
+
} else {
|
|
890
|
+
const insertResult = this.db
|
|
891
|
+
.prepare(`
|
|
892
|
+
INSERT INTO query_history (
|
|
893
|
+
database_key,
|
|
894
|
+
normalized_sql,
|
|
895
|
+
raw_sql,
|
|
896
|
+
query_type,
|
|
897
|
+
tables_detected,
|
|
898
|
+
is_destructive,
|
|
899
|
+
use_count,
|
|
900
|
+
first_executed_at,
|
|
901
|
+
last_used_at
|
|
902
|
+
)
|
|
903
|
+
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
904
|
+
`)
|
|
905
|
+
.run(
|
|
906
|
+
normalizedDatabaseKey,
|
|
907
|
+
normalizedSql,
|
|
908
|
+
normalizedRawSql,
|
|
909
|
+
queryType,
|
|
910
|
+
serializedTables,
|
|
911
|
+
destructive,
|
|
912
|
+
timestamp,
|
|
913
|
+
timestamp
|
|
914
|
+
);
|
|
915
|
+
historyId = Number(insertResult.lastInsertRowid);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
this.db
|
|
919
|
+
.prepare(`
|
|
920
|
+
INSERT INTO query_runs (
|
|
921
|
+
history_id,
|
|
922
|
+
executed_at,
|
|
923
|
+
duration_ms,
|
|
924
|
+
row_count,
|
|
925
|
+
status,
|
|
926
|
+
error_message,
|
|
927
|
+
affected_rows
|
|
928
|
+
)
|
|
929
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
930
|
+
`)
|
|
931
|
+
.run(
|
|
932
|
+
historyId,
|
|
933
|
+
timestamp,
|
|
934
|
+
this.normalizeQueryHistoryInteger(durationMs),
|
|
935
|
+
this.normalizeQueryHistoryInteger(rowCount),
|
|
936
|
+
status,
|
|
937
|
+
this.normalizeQueryHistoryText(errorMessage),
|
|
938
|
+
this.normalizeQueryHistoryInteger(affectedRows)
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
return historyId;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
getRecentQueries(options = {}) {
|
|
945
|
+
return this.buildQueryHistoryCollection(options);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
getFailedQueries(options = {}) {
|
|
949
|
+
return this.buildQueryHistoryCollection({
|
|
950
|
+
...options,
|
|
951
|
+
latestStatus: "error",
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
getQueryHistoryItemById(historyId) {
|
|
956
|
+
const row = this.db
|
|
957
|
+
.prepare(`
|
|
958
|
+
SELECT
|
|
959
|
+
q.id,
|
|
960
|
+
q.database_key,
|
|
961
|
+
q.normalized_sql,
|
|
962
|
+
q.raw_sql,
|
|
963
|
+
q.title,
|
|
964
|
+
q.notes,
|
|
965
|
+
q.query_type,
|
|
966
|
+
q.tables_detected,
|
|
967
|
+
q.is_favorite,
|
|
968
|
+
q.is_saved,
|
|
969
|
+
q.is_destructive,
|
|
970
|
+
q.use_count,
|
|
971
|
+
q.first_executed_at,
|
|
972
|
+
q.last_used_at,
|
|
973
|
+
latest.id AS last_run_id,
|
|
974
|
+
latest.executed_at AS last_run_executed_at,
|
|
975
|
+
latest.duration_ms AS last_run_duration_ms,
|
|
976
|
+
latest.row_count AS last_run_row_count,
|
|
977
|
+
latest.status AS last_run_status,
|
|
978
|
+
latest.error_message AS last_run_error_message,
|
|
979
|
+
latest.affected_rows AS last_run_affected_rows
|
|
980
|
+
FROM query_history q
|
|
981
|
+
LEFT JOIN query_runs latest
|
|
982
|
+
ON latest.id = (
|
|
983
|
+
SELECT runs.id
|
|
984
|
+
FROM query_runs runs
|
|
985
|
+
WHERE runs.history_id = q.id
|
|
986
|
+
ORDER BY runs.executed_at DESC, runs.id DESC
|
|
987
|
+
LIMIT 1
|
|
988
|
+
)
|
|
989
|
+
WHERE q.id = ?
|
|
990
|
+
`)
|
|
991
|
+
.get(Number(historyId));
|
|
992
|
+
|
|
993
|
+
if (!row) {
|
|
994
|
+
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return this.decorateQueryHistoryRow(row);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
getQueryRunsByHistoryId(historyId, limit = 8) {
|
|
1001
|
+
const normalizedLimit = Math.max(1, Math.min(50, Number(limit) || 8));
|
|
1002
|
+
|
|
1003
|
+
return this.db
|
|
1004
|
+
.prepare(`
|
|
1005
|
+
SELECT
|
|
1006
|
+
id,
|
|
1007
|
+
history_id,
|
|
1008
|
+
executed_at,
|
|
1009
|
+
duration_ms,
|
|
1010
|
+
row_count,
|
|
1011
|
+
status,
|
|
1012
|
+
error_message,
|
|
1013
|
+
affected_rows
|
|
1014
|
+
FROM query_runs
|
|
1015
|
+
WHERE history_id = ?
|
|
1016
|
+
ORDER BY executed_at DESC, id DESC
|
|
1017
|
+
LIMIT ?
|
|
1018
|
+
`)
|
|
1019
|
+
.all(Number(historyId), normalizedLimit)
|
|
1020
|
+
.map((row) => this.decorateQueryRun(row));
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
updateQueryHistoryField(historyId, fieldName, value) {
|
|
1024
|
+
const result = this.db
|
|
1025
|
+
.prepare(`UPDATE query_history SET ${fieldName} = ? WHERE id = ?`)
|
|
1026
|
+
.run(value, Number(historyId));
|
|
1027
|
+
|
|
1028
|
+
if (!result.changes) {
|
|
1029
|
+
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return this.getQueryHistoryItemById(historyId);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
toggleFavorite(historyId, nextValue) {
|
|
1036
|
+
return this.updateQueryHistoryField(historyId, "is_favorite", nextValue ? 1 : 0);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
toggleSaved(historyId, nextValue) {
|
|
1040
|
+
return this.updateQueryHistoryField(historyId, "is_saved", nextValue ? 1 : 0);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
renameQuery(historyId, title) {
|
|
1044
|
+
return this.updateQueryHistoryField(
|
|
1045
|
+
historyId,
|
|
1046
|
+
"title",
|
|
1047
|
+
this.normalizeQueryHistoryText(title)
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
updateQueryNotes(historyId, notes) {
|
|
1052
|
+
return this.updateQueryHistoryField(
|
|
1053
|
+
historyId,
|
|
1054
|
+
"notes",
|
|
1055
|
+
this.normalizeQueryHistoryText(notes)
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
deleteQueryHistoryItem(historyId) {
|
|
1060
|
+
const result = this.db
|
|
1061
|
+
.prepare("DELETE FROM query_history WHERE id = ?")
|
|
1062
|
+
.run(Number(historyId));
|
|
1063
|
+
|
|
1064
|
+
if (!result.changes) {
|
|
1065
|
+
throw new NotFoundError(`Query history item not found: ${historyId}`);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
clearQueryHistoryForDatabase(databaseKey) {
|
|
1072
|
+
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
1073
|
+
|
|
1074
|
+
if (!normalizedDatabaseKey) {
|
|
1075
|
+
return 0;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
return this.db
|
|
1079
|
+
.prepare("DELETE FROM query_history WHERE database_key = ?")
|
|
1080
|
+
.run(normalizedDatabaseKey).changes;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
369
1083
|
trimRecentConnections() {
|
|
370
1084
|
const maxRecentConnections = Number(
|
|
371
1085
|
this.getSettings().maxRecentConnections ?? DEFAULT_STATE.settings.maxRecentConnections
|
|
@@ -373,7 +1087,7 @@ class AppStateStore {
|
|
|
373
1087
|
|
|
374
1088
|
const staleRows = this.db
|
|
375
1089
|
.prepare(`
|
|
376
|
-
SELECT id
|
|
1090
|
+
SELECT id, logoPath
|
|
377
1091
|
FROM recent_connections
|
|
378
1092
|
ORDER BY lastOpenedAt DESC, id ASC
|
|
379
1093
|
LIMIT -1 OFFSET ?
|
|
@@ -393,6 +1107,10 @@ class AppStateStore {
|
|
|
393
1107
|
if (activeConnectionId && staleRows.some((row) => row.id === activeConnectionId)) {
|
|
394
1108
|
this.setMetaValue("activeConnectionId", null);
|
|
395
1109
|
}
|
|
1110
|
+
|
|
1111
|
+
staleRows.forEach((row) => {
|
|
1112
|
+
this.deleteConnectionLogo(row.logoPath);
|
|
1113
|
+
});
|
|
396
1114
|
}
|
|
397
1115
|
|
|
398
1116
|
trimSqlHistory() {
|
|
@@ -433,19 +1151,19 @@ class AppStateStore {
|
|
|
433
1151
|
lastOpenedAt,
|
|
434
1152
|
lastModifiedAt,
|
|
435
1153
|
sizeBytes,
|
|
436
|
-
readOnly
|
|
1154
|
+
readOnly,
|
|
1155
|
+
logoPath
|
|
437
1156
|
FROM recent_connections
|
|
438
1157
|
ORDER BY lastOpenedAt DESC, id ASC
|
|
439
1158
|
`)
|
|
440
1159
|
.all()
|
|
441
|
-
.map((connection) => (
|
|
442
|
-
...connection,
|
|
443
|
-
readOnly: Boolean(connection.readOnly),
|
|
444
|
-
}));
|
|
1160
|
+
.map((connection) => this.decorateConnection(connection));
|
|
445
1161
|
}
|
|
446
1162
|
|
|
447
1163
|
upsertRecentConnection(connection, options = {}) {
|
|
448
1164
|
const makeActive = options.makeActive !== false;
|
|
1165
|
+
const existing = this.getRecentConnections().find((entry) => entry.id === connection.id);
|
|
1166
|
+
const nextLogoPath = this.normalizeLogoPath(connection.logoPath);
|
|
449
1167
|
|
|
450
1168
|
this.db.transaction(() => {
|
|
451
1169
|
this.db
|
|
@@ -457,16 +1175,18 @@ class AppStateStore {
|
|
|
457
1175
|
lastOpenedAt,
|
|
458
1176
|
lastModifiedAt,
|
|
459
1177
|
sizeBytes,
|
|
460
|
-
readOnly
|
|
1178
|
+
readOnly,
|
|
1179
|
+
logoPath
|
|
461
1180
|
)
|
|
462
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1181
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
463
1182
|
ON CONFLICT(id) DO UPDATE SET
|
|
464
1183
|
label = excluded.label,
|
|
465
1184
|
path = excluded.path,
|
|
466
1185
|
lastOpenedAt = excluded.lastOpenedAt,
|
|
467
1186
|
lastModifiedAt = excluded.lastModifiedAt,
|
|
468
1187
|
sizeBytes = excluded.sizeBytes,
|
|
469
|
-
readOnly = excluded.readOnly
|
|
1188
|
+
readOnly = excluded.readOnly,
|
|
1189
|
+
logoPath = excluded.logoPath
|
|
470
1190
|
`)
|
|
471
1191
|
.run(
|
|
472
1192
|
connection.id,
|
|
@@ -475,7 +1195,8 @@ class AppStateStore {
|
|
|
475
1195
|
connection.lastOpenedAt,
|
|
476
1196
|
connection.lastModifiedAt ?? null,
|
|
477
1197
|
connection.sizeBytes ?? null,
|
|
478
|
-
connection.readOnly ? 1 : 0
|
|
1198
|
+
connection.readOnly ? 1 : 0,
|
|
1199
|
+
nextLogoPath
|
|
479
1200
|
);
|
|
480
1201
|
|
|
481
1202
|
if (makeActive) {
|
|
@@ -485,10 +1206,16 @@ class AppStateStore {
|
|
|
485
1206
|
this.trimRecentConnections();
|
|
486
1207
|
})();
|
|
487
1208
|
|
|
1209
|
+
if (this.normalizeLogoPath(existing?.logoPath) !== nextLogoPath) {
|
|
1210
|
+
this.deleteConnectionLogo(existing?.logoPath);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
488
1213
|
return this.getRecentConnections();
|
|
489
1214
|
}
|
|
490
1215
|
|
|
491
1216
|
removeRecentConnection(id) {
|
|
1217
|
+
const existing = this.getRecentConnections().find((connection) => connection.id === id);
|
|
1218
|
+
|
|
492
1219
|
this.db.transaction(() => {
|
|
493
1220
|
this.db.prepare("DELETE FROM recent_connections WHERE id = ?").run(id);
|
|
494
1221
|
|
|
@@ -497,6 +1224,8 @@ class AppStateStore {
|
|
|
497
1224
|
}
|
|
498
1225
|
})();
|
|
499
1226
|
|
|
1227
|
+
this.deleteConnectionLogo(existing?.logoPath);
|
|
1228
|
+
|
|
500
1229
|
return this.getState();
|
|
501
1230
|
}
|
|
502
1231
|
|
|
@@ -518,16 +1247,18 @@ class AppStateStore {
|
|
|
518
1247
|
lastOpenedAt,
|
|
519
1248
|
lastModifiedAt,
|
|
520
1249
|
sizeBytes,
|
|
521
|
-
readOnly
|
|
1250
|
+
readOnly,
|
|
1251
|
+
logoPath
|
|
522
1252
|
)
|
|
523
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1253
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
524
1254
|
ON CONFLICT(id) DO UPDATE SET
|
|
525
1255
|
label = excluded.label,
|
|
526
1256
|
path = excluded.path,
|
|
527
1257
|
lastOpenedAt = excluded.lastOpenedAt,
|
|
528
1258
|
lastModifiedAt = excluded.lastModifiedAt,
|
|
529
1259
|
sizeBytes = excluded.sizeBytes,
|
|
530
|
-
readOnly = excluded.readOnly
|
|
1260
|
+
readOnly = excluded.readOnly,
|
|
1261
|
+
logoPath = excluded.logoPath
|
|
531
1262
|
`)
|
|
532
1263
|
.run(
|
|
533
1264
|
nextConnection.id,
|
|
@@ -536,12 +1267,93 @@ class AppStateStore {
|
|
|
536
1267
|
nextConnection.lastOpenedAt ?? existing.lastOpenedAt,
|
|
537
1268
|
nextConnection.lastModifiedAt ?? null,
|
|
538
1269
|
nextConnection.sizeBytes ?? null,
|
|
539
|
-
nextConnection.readOnly ? 1 : 0
|
|
1270
|
+
nextConnection.readOnly ? 1 : 0,
|
|
1271
|
+
this.normalizeLogoPath(nextConnection.logoPath)
|
|
540
1272
|
);
|
|
541
1273
|
|
|
1274
|
+
if (this.normalizeLogoPath(nextConnection.logoPath) !== this.normalizeLogoPath(existing.logoPath)) {
|
|
1275
|
+
this.deleteConnectionLogo(existing.logoPath);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
542
1278
|
return this.getRecentConnections();
|
|
543
1279
|
}
|
|
544
1280
|
|
|
1281
|
+
getConnectionLogoUrl(logoPath) {
|
|
1282
|
+
const normalizedLogoPath = this.normalizeLogoPath(logoPath);
|
|
1283
|
+
|
|
1284
|
+
if (!normalizedLogoPath) {
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
return `/${CONNECTION_LOGO_DIRECTORY}/${encodeURIComponent(normalizedLogoPath)}`;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
saveConnectionLogo(connectionId, logoUpload = {}) {
|
|
1292
|
+
const fileName = String(logoUpload.fileName ?? "").trim();
|
|
1293
|
+
const mimeType = String(logoUpload.mimeType ?? "").trim().toLowerCase();
|
|
1294
|
+
const base64 = String(logoUpload.base64 ?? "").trim();
|
|
1295
|
+
const extension =
|
|
1296
|
+
CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE[mimeType] ??
|
|
1297
|
+
CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION[path.extname(fileName).toLowerCase()] ??
|
|
1298
|
+
null;
|
|
1299
|
+
|
|
1300
|
+
if (!extension) {
|
|
1301
|
+
throw new ValidationError("Connection logos must be one of: .png, .jpg, .jpeg, .webp");
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
if (!base64) {
|
|
1305
|
+
throw new ValidationError("Connection logo upload is empty.");
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const buffer = Buffer.from(base64, "base64");
|
|
1309
|
+
|
|
1310
|
+
if (!buffer.length) {
|
|
1311
|
+
throw new ValidationError("Connection logo upload is empty.");
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (buffer.length > MAX_CONNECTION_LOGO_SIZE_BYTES) {
|
|
1315
|
+
throw new ValidationError("Connection logo must be 5 MB or smaller.");
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const safeConnectionId = String(connectionId ?? "connection").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
1319
|
+
const storedFileName = `${safeConnectionId}-${Date.now()}.${extension}`;
|
|
1320
|
+
|
|
1321
|
+
fs.writeFileSync(path.join(this.logoDirectory, storedFileName), buffer);
|
|
1322
|
+
|
|
1323
|
+
return storedFileName;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
deleteConnectionLogo(logoPath) {
|
|
1327
|
+
const normalizedLogoPath = this.normalizeLogoPath(logoPath);
|
|
1328
|
+
|
|
1329
|
+
if (!normalizedLogoPath) {
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
fs.rmSync(path.join(this.logoDirectory, normalizedLogoPath), { force: true });
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
decorateConnection(connection = {}) {
|
|
1337
|
+
const logoPath = this.normalizeLogoPath(connection.logoPath);
|
|
1338
|
+
|
|
1339
|
+
return {
|
|
1340
|
+
...connection,
|
|
1341
|
+
readOnly: Boolean(connection.readOnly),
|
|
1342
|
+
logoPath,
|
|
1343
|
+
logoUrl: this.getConnectionLogoUrl(logoPath),
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
normalizeLogoPath(logoPath) {
|
|
1348
|
+
const trimmedLogoPath = String(logoPath ?? "").trim();
|
|
1349
|
+
|
|
1350
|
+
if (!trimmedLogoPath) {
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
return path.basename(trimmedLogoPath);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
545
1357
|
setActiveConnectionId(id) {
|
|
546
1358
|
this.setMetaValue("activeConnectionId", id ?? null);
|
|
547
1359
|
return this.getActiveConnectionId();
|