sqlite-hub 0.3.1 → 0.4.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 (65) hide show
  1. package/README.md +12 -6
  2. package/changelog.md +12 -0
  3. package/{js → frontend/js}/api.js +7 -0
  4. package/{js → frontend/js}/app.js +87 -3
  5. package/{js → frontend/js}/components/connectionCard.js +8 -9
  6. package/frontend/js/components/connectionLogo.js +33 -0
  7. package/{js → frontend/js}/components/emptyState.js +25 -11
  8. package/{js → frontend/js}/components/modal.js +131 -0
  9. package/{js → frontend/js}/components/rowEditorPanel.js +63 -33
  10. package/{js → frontend/js}/components/sidebar.js +8 -3
  11. package/{js → frontend/js}/store.js +236 -1
  12. package/{js → frontend/js}/views/data.js +106 -9
  13. package/{js → frontend/js}/views/editor.js +4 -0
  14. package/{js → frontend/js}/views/structure.js +10 -12
  15. package/{styles → frontend/styles}/structure-graph.css +5 -10
  16. package/package.json +3 -3
  17. package/{publish_brew.sh → scripts/publish_brew.sh} +2 -2
  18. package/{publish_npm.sh → scripts/publish_npm.sh} +2 -2
  19. package/server/data/db_logos/.gitkeep +0 -0
  20. package/server/routes/connections.js +2 -0
  21. package/server/routes/data.js +14 -0
  22. package/server/server.js +29 -6
  23. package/server/services/sqlite/connectionManager.js +68 -33
  24. package/server/services/sqlite/dataBrowserService.js +30 -0
  25. package/server/services/storage/appStateStore.js +159 -20
  26. package/server/utils/appPaths.js +42 -18
  27. /package/{assets → frontend/assets}/images/logo.webp +0 -0
  28. /package/{assets → frontend/assets}/images/logo_extrasmall.webp +0 -0
  29. /package/{assets → frontend/assets}/images/logo_raw.png +0 -0
  30. /package/{assets → frontend/assets}/images/logo_small.webp +0 -0
  31. /package/{assets → frontend/assets}/mockups/connections.png +0 -0
  32. /package/{assets → frontend/assets}/mockups/data.png +0 -0
  33. /package/{assets → frontend/assets}/mockups/data_edit.png +0 -0
  34. /package/{assets → frontend/assets}/mockups/graph_visualize.png +0 -0
  35. /package/{assets → frontend/assets}/mockups/home.png +0 -0
  36. /package/{assets → frontend/assets}/mockups/overview.png +0 -0
  37. /package/{assets → frontend/assets}/mockups/sql_editor.png +0 -0
  38. /package/{assets → frontend/assets}/mockups/structure.png +0 -0
  39. /package/{index.html → frontend/index.html} +0 -0
  40. /package/{js → frontend/js}/components/actionBar.js +0 -0
  41. /package/{js → frontend/js}/components/appShell.js +0 -0
  42. /package/{js → frontend/js}/components/badges.js +0 -0
  43. /package/{js → frontend/js}/components/bottomTabs.js +0 -0
  44. /package/{js → frontend/js}/components/dataGrid.js +0 -0
  45. /package/{js → frontend/js}/components/metricCard.js +0 -0
  46. /package/{js → frontend/js}/components/pageHeader.js +0 -0
  47. /package/{js → frontend/js}/components/queryEditor.js +0 -0
  48. /package/{js → frontend/js}/components/queryResults.js +0 -0
  49. /package/{js → frontend/js}/components/statusBar.js +0 -0
  50. /package/{js → frontend/js}/components/structureGraph.js +0 -0
  51. /package/{js → frontend/js}/components/toast.js +0 -0
  52. /package/{js → frontend/js}/components/topNav.js +0 -0
  53. /package/{js → frontend/js}/lib/cytoscapeRuntime.js +0 -0
  54. /package/{js → frontend/js}/router.js +0 -0
  55. /package/{js → frontend/js}/utils/format.js +0 -0
  56. /package/{js → frontend/js}/views/connections.js +0 -0
  57. /package/{js → frontend/js}/views/landing.js +0 -0
  58. /package/{js → frontend/js}/views/overview.js +0 -0
  59. /package/{js → frontend/js}/views/settings.js +0 -0
  60. /package/{styles → frontend/styles}/base.css +0 -0
  61. /package/{styles → frontend/styles}/components.css +0 -0
  62. /package/{styles → frontend/styles}/layout.css +0 -0
  63. /package/{styles → frontend/styles}/tokens.css +0 -0
  64. /package/{styles → frontend/styles}/views.css +0 -0
  65. /package/{data → server/data}/.gitkeep +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  set -euo pipefail
4
4
 
5
- ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6
6
  cd "$ROOT_DIR"
7
7
 
8
8
  DRY_RUN=0
@@ -15,7 +15,7 @@ GH_REMOTE="origin"
15
15
  usage() {
16
16
  cat <<'EOF'
17
17
  Usage:
18
- ./publish_npm.sh [options]
18
+ ./scripts/publish_npm.sh [options]
19
19
 
20
20
  Options:
21
21
  --tag NAME Publish to a custom npm dist-tag instead of `latest`.
File without changes
@@ -113,6 +113,8 @@ function createConnectionsRouter({ connectionManager, importService, backupServi
113
113
  filePath: req.body.path,
114
114
  label: req.body.label,
115
115
  readOnly: Boolean(req.body.readOnly),
116
+ logoUpload: req.body.logoUpload ?? null,
117
+ clearLogo: Boolean(req.body.clearLogo),
116
118
  });
117
119
 
118
120
  res.json(
@@ -51,6 +51,20 @@ function createDataRouter({ dataBrowserService }) {
51
51
  })
52
52
  );
53
53
 
54
+ router.delete(
55
+ "/:tableName/rows",
56
+ route((req, res) => {
57
+ const data = dataBrowserService.deleteTableRow(req.params.tableName, req.body ?? {});
58
+
59
+ res.json(
60
+ successResponse({
61
+ message: "Table row deleted.",
62
+ data,
63
+ })
64
+ );
65
+ })
66
+ );
67
+
54
68
  return router;
55
69
  }
56
70
 
package/server/server.js CHANGED
@@ -20,7 +20,10 @@ const { createSettingsRouter } = require("./routes/settings");
20
20
  const { createExportRouter } = require("./routes/export");
21
21
 
22
22
  const PACKAGE_ROOT = path.resolve(__dirname, "..");
23
+ const FRONTEND_ROOT = path.join(PACKAGE_ROOT, "frontend");
24
+ const FRONTEND_ENTRYPOINT = path.join(FRONTEND_ROOT, "index.html");
23
25
  const {
26
+ appStateDirectory: APP_STATE_DIRECTORY,
24
27
  appStateDbPath: APP_STATE_DB_PATH,
25
28
  legacyStatePath: LEGACY_STATE_PATH,
26
29
  legacyDatabasePaths: LEGACY_DATABASE_PATHS,
@@ -83,11 +86,11 @@ app.get("/favicon.ico", (req, res) => {
83
86
  });
84
87
 
85
88
  app.get("/", (req, res) => {
86
- res.sendFile(path.resolve(__dirname, "..", "index.html"));
89
+ res.sendFile(FRONTEND_ENTRYPOINT);
87
90
  });
88
91
 
89
92
  app.get("/index.html", (req, res) => {
90
- res.sendFile(path.resolve(__dirname, "..", "index.html"));
93
+ res.sendFile(FRONTEND_ENTRYPOINT);
91
94
  });
92
95
 
93
96
  app.use(
@@ -102,12 +105,31 @@ app.use(
102
105
  "/vendor/elkjs",
103
106
  express.static(path.resolve(__dirname, "..", "node_modules", "elkjs"))
104
107
  );
105
- app.use("/js", express.static(path.resolve(__dirname, "..", "js")));
106
- app.use("/styles", express.static(path.resolve(__dirname, "..", "styles")));
107
- app.use("/assets", express.static(path.resolve(__dirname, "..", "assets")));
108
+ app.use(express.static(FRONTEND_ROOT));
109
+ app.use("/db_logos", express.static(path.join(APP_STATE_DIRECTORY, "db_logos")));
108
110
  app.use(errorMiddleware);
109
111
 
110
- function resolvePort(value = process.env.PORT) {
112
+ function parsePortArgument(argv = process.argv.slice(2)) {
113
+ for (let index = 0; index < argv.length; index += 1) {
114
+ const argument = argv[index];
115
+
116
+ if (argument.startsWith("--port:")) {
117
+ return argument.slice("--port:".length);
118
+ }
119
+
120
+ if (argument.startsWith("--port=")) {
121
+ return argument.slice("--port=".length);
122
+ }
123
+
124
+ if (argument === "--port") {
125
+ return argv[index + 1];
126
+ }
127
+ }
128
+
129
+ return undefined;
130
+ }
131
+
132
+ function resolvePort(value = process.env.PORT ?? parsePortArgument()) {
111
133
  if (value === undefined || value === null || value === "") {
112
134
  return DEFAULT_PORT;
113
135
  }
@@ -153,6 +175,7 @@ module.exports = {
153
175
  appStateStore,
154
176
  connectionManager,
155
177
  DEFAULT_PORT,
178
+ parsePortArgument,
156
179
  resolvePort,
157
180
  startServer,
158
181
  };
@@ -44,6 +44,7 @@ class ConnectionManager {
44
44
  filePath: recent.path,
45
45
  label: recent.label,
46
46
  id: recent.id,
47
+ logoPath: recent.logoPath ?? null,
47
48
  makeActive: true,
48
49
  });
49
50
  } catch (error) {
@@ -54,17 +55,26 @@ class ConnectionManager {
54
55
 
55
56
  buildConnectionRecord(filePath, options = {}) {
56
57
  const metadata = getFileMetadata(filePath);
58
+ const id =
59
+ options.id ??
60
+ `conn_${crypto.createHash("sha1").update(filePath).digest("hex").slice(0, 16)}`;
61
+ const existingConnection = this.appStateStore
62
+ .getRecentConnections()
63
+ .find((connection) => connection.id === id);
64
+ const logoPath = Object.prototype.hasOwnProperty.call(options, "logoPath")
65
+ ? options.logoPath
66
+ : (existingConnection?.logoPath ?? null);
57
67
 
58
68
  return {
59
- id:
60
- options.id ??
61
- `conn_${crypto.createHash("sha1").update(filePath).digest("hex").slice(0, 16)}`,
69
+ id,
62
70
  label: options.label?.trim() || path.basename(filePath),
63
71
  path: filePath,
64
72
  lastOpenedAt: new Date().toISOString(),
65
73
  lastModifiedAt: metadata.lastModifiedAt,
66
74
  sizeBytes: metadata.sizeBytes,
67
75
  readOnly: options.readOnly ?? !isWritable(filePath),
76
+ logoPath,
77
+ logoUrl: this.appStateStore.getConnectionLogoUrl(logoPath),
68
78
  };
69
79
  }
70
80
 
@@ -94,7 +104,7 @@ class ConnectionManager {
94
104
  this.current = null;
95
105
  }
96
106
 
97
- openConnection({ filePath, label, id, makeActive = true, readOnly = false }) {
107
+ openConnection({ filePath, label, id, makeActive = true, readOnly = false, logoPath }) {
98
108
  const resolvedPath = validateSqlitePath(filePath, { mustExist: true });
99
109
  const db = this.openRawDatabase(resolvedPath, {
100
110
  fileMustExist: true,
@@ -103,11 +113,17 @@ class ConnectionManager {
103
113
 
104
114
  this.closeCurrent();
105
115
 
106
- const connection = this.buildConnectionRecord(resolvedPath, {
116
+ const connectionOptions = {
107
117
  id,
108
118
  label,
109
119
  readOnly,
110
- });
120
+ };
121
+
122
+ if (logoPath !== undefined) {
123
+ connectionOptions.logoPath = logoPath;
124
+ }
125
+
126
+ const connection = this.buildConnectionRecord(resolvedPath, connectionOptions);
111
127
 
112
128
  this.current = {
113
129
  ...connection,
@@ -186,6 +202,7 @@ class ConnectionManager {
186
202
  id: recent.id,
187
203
  makeActive: true,
188
204
  readOnly: recent.readOnly,
205
+ logoPath: recent.logoPath ?? null,
189
206
  });
190
207
  }
191
208
 
@@ -199,7 +216,7 @@ class ConnectionManager {
199
216
  return state.recentConnections;
200
217
  }
201
218
 
202
- updateRecentConnection(id, { filePath, label, readOnly = false }) {
219
+ updateRecentConnection(id, { filePath, label, readOnly = false, logoUpload = null, clearLogo = false }) {
203
220
  const recentConnections = this.appStateStore.getRecentConnections();
204
221
  const existing = recentConnections.find((connection) => connection.id === id);
205
222
 
@@ -218,40 +235,58 @@ class ConnectionManager {
218
235
 
219
236
  const normalizedLabel = label?.trim() || path.basename(resolvedPath);
220
237
  const normalizedReadOnly = Boolean(readOnly);
238
+ let nextLogoPath = clearLogo ? null : existing.logoPath ?? null;
239
+ let createdLogoPath = null;
221
240
 
222
- if (this.current?.id === id) {
223
- return this.openConnection({
224
- filePath: resolvedPath,
225
- label: normalizedLabel,
226
- id,
227
- makeActive: true,
241
+ if (logoUpload) {
242
+ createdLogoPath = this.appStateStore.saveConnectionLogo(id, logoUpload);
243
+ nextLogoPath = createdLogoPath;
244
+ }
245
+
246
+ try {
247
+ if (this.current?.id === id) {
248
+ return this.openConnection({
249
+ filePath: resolvedPath,
250
+ label: normalizedLabel,
251
+ id,
252
+ makeActive: true,
253
+ readOnly: normalizedReadOnly,
254
+ logoPath: nextLogoPath,
255
+ });
256
+ }
257
+
258
+ const db = this.openRawDatabase(resolvedPath, {
259
+ fileMustExist: true,
228
260
  readOnly: normalizedReadOnly,
229
261
  });
230
- }
231
262
 
232
- const db = this.openRawDatabase(resolvedPath, {
233
- fileMustExist: true,
234
- readOnly: normalizedReadOnly,
235
- });
263
+ db.close();
236
264
 
237
- db.close();
265
+ const metadata = getFileMetadata(resolvedPath);
266
+ const nextConnection = {
267
+ ...existing,
268
+ label: normalizedLabel,
269
+ path: resolvedPath,
270
+ lastModifiedAt: metadata.lastModifiedAt,
271
+ sizeBytes: metadata.sizeBytes,
272
+ readOnly: normalizedReadOnly,
273
+ logoPath: nextLogoPath,
274
+ };
238
275
 
239
- const metadata = getFileMetadata(resolvedPath);
240
- const nextConnection = {
241
- ...existing,
242
- label: normalizedLabel,
243
- path: resolvedPath,
244
- lastModifiedAt: metadata.lastModifiedAt,
245
- sizeBytes: metadata.sizeBytes,
246
- readOnly: normalizedReadOnly,
247
- };
276
+ this.appStateStore.updateRecentConnection(id, () => nextConnection);
248
277
 
249
- this.appStateStore.updateRecentConnection(id, () => nextConnection);
278
+ return {
279
+ ...nextConnection,
280
+ logoUrl: this.appStateStore.getConnectionLogoUrl(nextLogoPath),
281
+ isActive: false,
282
+ };
283
+ } catch (error) {
284
+ if (createdLogoPath) {
285
+ this.appStateStore.deleteConnectionLogo(createdLogoPath);
286
+ }
250
287
 
251
- return {
252
- ...nextConnection,
253
- isActive: false,
254
- };
288
+ throw error;
289
+ }
255
290
  }
256
291
 
257
292
  getActiveConnection() {
@@ -171,6 +171,36 @@ class DataBrowserService {
171
171
  };
172
172
  }
173
173
 
174
+ deleteTableRow(tableName, payload = {}) {
175
+ this.connectionManager.assertWritable();
176
+
177
+ const db = this.connectionManager.getActiveDatabase();
178
+ const tableDetail = getTableDetail(db, tableName, { includeRowCount: false });
179
+ const identity = payload.identity ?? null;
180
+
181
+ if (tableDetail.notSafelyUpdatable) {
182
+ throw new ValidationError(
183
+ `Table ${tableName} cannot be safely updated because it has no stable row identity.`
184
+ );
185
+ }
186
+
187
+ const where = this.buildWhereClause(tableDetail, identity);
188
+ const result = db
189
+ .prepare(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${where.clause}`)
190
+ .run(...where.params);
191
+
192
+ if (!result.changes) {
193
+ throw new NotFoundError(`Row not found in table: ${tableName}`);
194
+ }
195
+
196
+ return {
197
+ tableName,
198
+ deleted: true,
199
+ identity,
200
+ affectedRowCount: result.changes,
201
+ };
202
+ }
203
+
174
204
  buildWhereClause(tableDetail, identity) {
175
205
  if (tableDetail.identityStrategy?.type === "rowid") {
176
206
  const rowid = identity?.values?.rowid;
@@ -1,6 +1,7 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
3
  const Database = require("better-sqlite3");
4
+ const { ValidationError } = require("../../utils/errors");
4
5
 
5
6
  const DEFAULT_STATE = {
6
7
  recentConnections: [],
@@ -16,9 +17,24 @@ const DEFAULT_STATE = {
16
17
  },
17
18
  };
18
19
 
20
+ const CONNECTION_LOGO_DIRECTORY = "db_logos";
21
+ const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
22
+ const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
23
+ "image/jpeg": "jpg",
24
+ "image/png": "png",
25
+ "image/webp": "webp",
26
+ };
27
+ const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
28
+ ".jpeg": "jpg",
29
+ ".jpg": "jpg",
30
+ ".png": "png",
31
+ ".webp": "webp",
32
+ };
33
+
19
34
  class AppStateStore {
20
35
  constructor(filePath, options = {}) {
21
36
  this.filePath = filePath;
37
+ this.logoDirectory = path.join(path.dirname(this.filePath), CONNECTION_LOGO_DIRECTORY);
22
38
  this.legacyFilePath = options.legacyFilePath ?? null;
23
39
  this.legacyDatabasePaths = Array.isArray(options.legacyDatabasePaths)
24
40
  ? options.legacyDatabasePaths
@@ -26,6 +42,7 @@ class AppStateStore {
26
42
  this.isFreshDatabase = !fs.existsSync(filePath);
27
43
 
28
44
  fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
45
+ fs.mkdirSync(this.logoDirectory, { recursive: true });
29
46
 
30
47
  this.db = new Database(this.filePath);
31
48
  this.configureDatabase();
@@ -64,7 +81,8 @@ class AppStateStore {
64
81
  lastOpenedAt TEXT NOT NULL,
65
82
  lastModifiedAt TEXT,
66
83
  sizeBytes INTEGER,
67
- readOnly INTEGER NOT NULL DEFAULT 0
84
+ readOnly INTEGER NOT NULL DEFAULT 0,
85
+ logoPath TEXT
68
86
  );
69
87
 
70
88
  CREATE TABLE IF NOT EXISTS sql_history (
@@ -86,6 +104,17 @@ class AppStateStore {
86
104
  CREATE INDEX IF NOT EXISTS idx_sql_history_executed_at
87
105
  ON sql_history(executedAt DESC, id ASC);
88
106
  `);
107
+
108
+ const recentConnectionColumns = new Set(
109
+ this.db
110
+ .prepare("PRAGMA table_info(recent_connections)")
111
+ .all()
112
+ .map((column) => column.name)
113
+ );
114
+
115
+ if (!recentConnectionColumns.has("logoPath")) {
116
+ this.db.exec("ALTER TABLE recent_connections ADD COLUMN logoPath TEXT");
117
+ }
89
118
  }
90
119
 
91
120
  seedDefaultSettings() {
@@ -153,6 +182,14 @@ class AppStateStore {
153
182
  .all()
154
183
  .map((row) => row.name)
155
184
  );
185
+ const recentConnectionColumns = tables.has("recent_connections")
186
+ ? new Set(
187
+ legacyDb
188
+ .prepare("PRAGMA table_info(recent_connections)")
189
+ .all()
190
+ .map((column) => column.name)
191
+ )
192
+ : new Set();
156
193
 
157
194
  return {
158
195
  settings: tables.has("settings")
@@ -173,7 +210,8 @@ class AppStateStore {
173
210
  lastOpenedAt,
174
211
  lastModifiedAt,
175
212
  sizeBytes,
176
- readOnly
213
+ readOnly,
214
+ ${recentConnectionColumns.has("logoPath") ? "logoPath" : "NULL AS logoPath"}
177
215
  FROM recent_connections
178
216
  ORDER BY lastOpenedAt DESC, id ASC
179
217
  `)
@@ -223,16 +261,18 @@ class AppStateStore {
223
261
  lastOpenedAt,
224
262
  lastModifiedAt,
225
263
  sizeBytes,
226
- readOnly
264
+ readOnly,
265
+ logoPath
227
266
  )
228
- VALUES (?, ?, ?, ?, ?, ?, ?)
267
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
229
268
  ON CONFLICT(id) DO UPDATE SET
230
269
  label = excluded.label,
231
270
  path = excluded.path,
232
271
  lastOpenedAt = excluded.lastOpenedAt,
233
272
  lastModifiedAt = excluded.lastModifiedAt,
234
273
  sizeBytes = excluded.sizeBytes,
235
- readOnly = excluded.readOnly
274
+ readOnly = excluded.readOnly,
275
+ logoPath = excluded.logoPath
236
276
  `);
237
277
  const insertHistory = this.db.prepare(`
238
278
  INSERT INTO sql_history (
@@ -276,7 +316,8 @@ class AppStateStore {
276
316
  connection.lastOpenedAt ?? new Date().toISOString(),
277
317
  connection.lastModifiedAt ?? null,
278
318
  connection.sizeBytes ?? null,
279
- connection.readOnly ? 1 : 0
319
+ connection.readOnly ? 1 : 0,
320
+ this.normalizeLogoPath(connection.logoPath)
280
321
  );
281
322
  }
282
323
 
@@ -373,7 +414,7 @@ class AppStateStore {
373
414
 
374
415
  const staleRows = this.db
375
416
  .prepare(`
376
- SELECT id
417
+ SELECT id, logoPath
377
418
  FROM recent_connections
378
419
  ORDER BY lastOpenedAt DESC, id ASC
379
420
  LIMIT -1 OFFSET ?
@@ -393,6 +434,10 @@ class AppStateStore {
393
434
  if (activeConnectionId && staleRows.some((row) => row.id === activeConnectionId)) {
394
435
  this.setMetaValue("activeConnectionId", null);
395
436
  }
437
+
438
+ staleRows.forEach((row) => {
439
+ this.deleteConnectionLogo(row.logoPath);
440
+ });
396
441
  }
397
442
 
398
443
  trimSqlHistory() {
@@ -433,19 +478,19 @@ class AppStateStore {
433
478
  lastOpenedAt,
434
479
  lastModifiedAt,
435
480
  sizeBytes,
436
- readOnly
481
+ readOnly,
482
+ logoPath
437
483
  FROM recent_connections
438
484
  ORDER BY lastOpenedAt DESC, id ASC
439
485
  `)
440
486
  .all()
441
- .map((connection) => ({
442
- ...connection,
443
- readOnly: Boolean(connection.readOnly),
444
- }));
487
+ .map((connection) => this.decorateConnection(connection));
445
488
  }
446
489
 
447
490
  upsertRecentConnection(connection, options = {}) {
448
491
  const makeActive = options.makeActive !== false;
492
+ const existing = this.getRecentConnections().find((entry) => entry.id === connection.id);
493
+ const nextLogoPath = this.normalizeLogoPath(connection.logoPath);
449
494
 
450
495
  this.db.transaction(() => {
451
496
  this.db
@@ -457,16 +502,18 @@ class AppStateStore {
457
502
  lastOpenedAt,
458
503
  lastModifiedAt,
459
504
  sizeBytes,
460
- readOnly
505
+ readOnly,
506
+ logoPath
461
507
  )
462
- VALUES (?, ?, ?, ?, ?, ?, ?)
508
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
463
509
  ON CONFLICT(id) DO UPDATE SET
464
510
  label = excluded.label,
465
511
  path = excluded.path,
466
512
  lastOpenedAt = excluded.lastOpenedAt,
467
513
  lastModifiedAt = excluded.lastModifiedAt,
468
514
  sizeBytes = excluded.sizeBytes,
469
- readOnly = excluded.readOnly
515
+ readOnly = excluded.readOnly,
516
+ logoPath = excluded.logoPath
470
517
  `)
471
518
  .run(
472
519
  connection.id,
@@ -475,7 +522,8 @@ class AppStateStore {
475
522
  connection.lastOpenedAt,
476
523
  connection.lastModifiedAt ?? null,
477
524
  connection.sizeBytes ?? null,
478
- connection.readOnly ? 1 : 0
525
+ connection.readOnly ? 1 : 0,
526
+ nextLogoPath
479
527
  );
480
528
 
481
529
  if (makeActive) {
@@ -485,10 +533,16 @@ class AppStateStore {
485
533
  this.trimRecentConnections();
486
534
  })();
487
535
 
536
+ if (this.normalizeLogoPath(existing?.logoPath) !== nextLogoPath) {
537
+ this.deleteConnectionLogo(existing?.logoPath);
538
+ }
539
+
488
540
  return this.getRecentConnections();
489
541
  }
490
542
 
491
543
  removeRecentConnection(id) {
544
+ const existing = this.getRecentConnections().find((connection) => connection.id === id);
545
+
492
546
  this.db.transaction(() => {
493
547
  this.db.prepare("DELETE FROM recent_connections WHERE id = ?").run(id);
494
548
 
@@ -497,6 +551,8 @@ class AppStateStore {
497
551
  }
498
552
  })();
499
553
 
554
+ this.deleteConnectionLogo(existing?.logoPath);
555
+
500
556
  return this.getState();
501
557
  }
502
558
 
@@ -518,16 +574,18 @@ class AppStateStore {
518
574
  lastOpenedAt,
519
575
  lastModifiedAt,
520
576
  sizeBytes,
521
- readOnly
577
+ readOnly,
578
+ logoPath
522
579
  )
523
- VALUES (?, ?, ?, ?, ?, ?, ?)
580
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
524
581
  ON CONFLICT(id) DO UPDATE SET
525
582
  label = excluded.label,
526
583
  path = excluded.path,
527
584
  lastOpenedAt = excluded.lastOpenedAt,
528
585
  lastModifiedAt = excluded.lastModifiedAt,
529
586
  sizeBytes = excluded.sizeBytes,
530
- readOnly = excluded.readOnly
587
+ readOnly = excluded.readOnly,
588
+ logoPath = excluded.logoPath
531
589
  `)
532
590
  .run(
533
591
  nextConnection.id,
@@ -536,12 +594,93 @@ class AppStateStore {
536
594
  nextConnection.lastOpenedAt ?? existing.lastOpenedAt,
537
595
  nextConnection.lastModifiedAt ?? null,
538
596
  nextConnection.sizeBytes ?? null,
539
- nextConnection.readOnly ? 1 : 0
597
+ nextConnection.readOnly ? 1 : 0,
598
+ this.normalizeLogoPath(nextConnection.logoPath)
540
599
  );
541
600
 
601
+ if (this.normalizeLogoPath(nextConnection.logoPath) !== this.normalizeLogoPath(existing.logoPath)) {
602
+ this.deleteConnectionLogo(existing.logoPath);
603
+ }
604
+
542
605
  return this.getRecentConnections();
543
606
  }
544
607
 
608
+ getConnectionLogoUrl(logoPath) {
609
+ const normalizedLogoPath = this.normalizeLogoPath(logoPath);
610
+
611
+ if (!normalizedLogoPath) {
612
+ return null;
613
+ }
614
+
615
+ return `/${CONNECTION_LOGO_DIRECTORY}/${encodeURIComponent(normalizedLogoPath)}`;
616
+ }
617
+
618
+ saveConnectionLogo(connectionId, logoUpload = {}) {
619
+ const fileName = String(logoUpload.fileName ?? "").trim();
620
+ const mimeType = String(logoUpload.mimeType ?? "").trim().toLowerCase();
621
+ const base64 = String(logoUpload.base64 ?? "").trim();
622
+ const extension =
623
+ CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE[mimeType] ??
624
+ CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION[path.extname(fileName).toLowerCase()] ??
625
+ null;
626
+
627
+ if (!extension) {
628
+ throw new ValidationError("Connection logos must be one of: .png, .jpg, .jpeg, .webp");
629
+ }
630
+
631
+ if (!base64) {
632
+ throw new ValidationError("Connection logo upload is empty.");
633
+ }
634
+
635
+ const buffer = Buffer.from(base64, "base64");
636
+
637
+ if (!buffer.length) {
638
+ throw new ValidationError("Connection logo upload is empty.");
639
+ }
640
+
641
+ if (buffer.length > MAX_CONNECTION_LOGO_SIZE_BYTES) {
642
+ throw new ValidationError("Connection logo must be 5 MB or smaller.");
643
+ }
644
+
645
+ const safeConnectionId = String(connectionId ?? "connection").replace(/[^a-zA-Z0-9_-]/g, "_");
646
+ const storedFileName = `${safeConnectionId}-${Date.now()}.${extension}`;
647
+
648
+ fs.writeFileSync(path.join(this.logoDirectory, storedFileName), buffer);
649
+
650
+ return storedFileName;
651
+ }
652
+
653
+ deleteConnectionLogo(logoPath) {
654
+ const normalizedLogoPath = this.normalizeLogoPath(logoPath);
655
+
656
+ if (!normalizedLogoPath) {
657
+ return;
658
+ }
659
+
660
+ fs.rmSync(path.join(this.logoDirectory, normalizedLogoPath), { force: true });
661
+ }
662
+
663
+ decorateConnection(connection = {}) {
664
+ const logoPath = this.normalizeLogoPath(connection.logoPath);
665
+
666
+ return {
667
+ ...connection,
668
+ readOnly: Boolean(connection.readOnly),
669
+ logoPath,
670
+ logoUrl: this.getConnectionLogoUrl(logoPath),
671
+ };
672
+ }
673
+
674
+ normalizeLogoPath(logoPath) {
675
+ const trimmedLogoPath = String(logoPath ?? "").trim();
676
+
677
+ if (!trimmedLogoPath) {
678
+ return null;
679
+ }
680
+
681
+ return path.basename(trimmedLogoPath);
682
+ }
683
+
545
684
  setActiveConnectionId(id) {
546
685
  this.setMetaValue("activeConnectionId", id ?? null);
547
686
  return this.getActiveConnectionId();