sqlite-hub 2.0.1 → 2.2.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 +21 -9
- package/docs/MCP.md +8 -0
- package/docs/changelog.md +16 -0
- package/docs/guidelines/DESIGN.md +64 -0
- package/docs/todo.md +2 -0
- package/frontend/assets/mockups/backups_1_1920.webp +0 -0
- package/frontend/assets/mockups/backups_2_compare_drawer_1920.webp +0 -0
- package/frontend/assets/mockups/backups_3_create_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_4_edit_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_5_restore_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_6_delete_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_1_1920.webp +0 -0
- package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
- package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
- package/frontend/assets/mockups/connections_1_1920.webp +0 -0
- package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
- package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
- package/frontend/assets/mockups/data_1_1920.webp +0 -0
- package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
- package/frontend/assets/mockups/data_3_generate_data_modal_1920.webp +0 -0
- package/frontend/assets/mockups/data_4_data_export_modal_1920.webp +0 -0
- package/frontend/assets/mockups/documents_1_1920.webp +0 -0
- package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
- package/frontend/assets/mockups/logs_1_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/overview_1_1920.webp +0 -0
- package/frontend/assets/mockups/settings_1_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
- package/frontend/assets/mockups/structure_1_1920.webp +0 -0
- package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
- package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
- package/frontend/js/api.js +18 -0
- package/frontend/js/app.js +567 -15
- package/frontend/js/components/badges.js +16 -0
- package/frontend/js/components/connectionCard.js +35 -0
- package/frontend/js/components/modal.js +268 -247
- package/frontend/js/components/queryHistoryDetail.js +25 -45
- package/frontend/js/components/rowEditorPanel.js +7 -60
- package/frontend/js/components/structureGraph.js +292 -13
- package/frontend/js/components/tableDesignerConstraintsDrawer.js +400 -0
- package/frontend/js/components/tableDesignerEditor.js +51 -16
- package/frontend/js/components/tableDesignerSidebar.js +9 -5
- package/frontend/js/store.js +1847 -190
- package/frontend/js/utils/connectionRegistry.js +76 -0
- package/frontend/js/utils/markdownDocuments.js +21 -4
- package/frontend/js/utils/tableDesigner.js +431 -62
- package/frontend/js/views/backups.js +2 -5
- package/frontend/js/views/charts.js +20 -10
- package/frontend/js/views/connections.js +101 -6
- package/frontend/js/views/data.js +52 -9
- package/frontend/js/views/documents.js +153 -59
- package/frontend/js/views/structure.js +28 -5
- package/frontend/js/views/tableAdvisor.js +14 -6
- package/frontend/js/views/tableDesigner.js +44 -9
- package/frontend/styles/base.css +9 -0
- package/frontend/styles/components.css +676 -79
- package/frontend/styles/structure-graph.css +49 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +12 -0
- package/package.json +1 -1
- package/server/routes/backups.js +37 -1
- package/server/routes/charts.js +28 -0
- package/server/routes/connections.js +12 -0
- package/server/routes/data.js +30 -2
- package/server/routes/documents.js +61 -5
- package/server/routes/settings.js +32 -0
- package/server/routes/tableDesigner.js +35 -2
- package/server/server.js +3 -3
- package/server/services/mcpToolService.js +30 -0
- package/server/services/sqlite/connectionManager.js +25 -2
- package/server/services/sqlite/dataBrowserService.js +21 -0
- package/server/services/sqlite/introspection.js +39 -1
- package/server/services/sqlite/overviewService.js +1 -0
- package/server/services/sqlite/structureService.js +66 -11
- package/server/services/sqlite/tableAdvisor.js +6 -0
- package/server/services/sqlite/tableDesigner/schemaMapping.js +7 -1
- package/server/services/sqlite/tableDesigner/sql.js +31 -2
- package/server/services/sqlite/tableDesigner/validation.js +69 -9
- package/server/services/sqlite/tableDesignerService.js +110 -5
- package/server/services/storage/appStateStore.js +590 -10
- package/server/utils/fileValidation.js +5 -0
- package/server/utils/userActionLog.js +49 -0
- package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
|
@@ -40,10 +40,11 @@ const CONNECTION_LOGO_DIRECTORY = "db_logos";
|
|
|
40
40
|
const LEGACY_STATE_FILENAME = "app-state.json";
|
|
41
41
|
const STATE_DATABASE_FILENAME = "sqlite-hub-state.db";
|
|
42
42
|
const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
|
|
43
|
+
const MAX_CONNECTION_TAG_NAME_LENGTH = 40;
|
|
43
44
|
const MAX_DOCUMENT_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
44
45
|
const MAX_DOCUMENT_FILENAME_LENGTH = 160;
|
|
45
46
|
const QUERY_EXECUTION_SOURCES = new Set(["api", "cli", "user", "mcp"]);
|
|
46
|
-
const ACCESS_LOG_SOURCES = new Set(["api", "cli"]);
|
|
47
|
+
const ACCESS_LOG_SOURCES = new Set(["api", "cli", "user"]);
|
|
47
48
|
const ACCESS_LOG_STATUSES = new Set(["success", "error"]);
|
|
48
49
|
const MAX_ACCESS_LOG_TEXT_LENGTH = 500;
|
|
49
50
|
const MAX_ACCESS_LOG_METADATA_BYTES = 16 * 1024;
|
|
@@ -77,6 +78,32 @@ function normalizeDocumentId(documentId) {
|
|
|
77
78
|
return normalizedDocumentId;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
function normalizeDocumentFolderId(folderId) {
|
|
82
|
+
const normalizedFolderId = String(folderId ?? "").trim();
|
|
83
|
+
|
|
84
|
+
return normalizedFolderId || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeDocumentFolderName(name) {
|
|
88
|
+
const normalizedName = String(name ?? "")
|
|
89
|
+
.trim()
|
|
90
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
91
|
+
.replace(/[\\/]+/g, " ")
|
|
92
|
+
.replace(/\s+/g, " ")
|
|
93
|
+
.replace(/^\.+/, "")
|
|
94
|
+
.trim();
|
|
95
|
+
|
|
96
|
+
if (!normalizedName) {
|
|
97
|
+
throw new ValidationError("Folder name is required.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (normalizedName.length > 80) {
|
|
101
|
+
throw new ValidationError("Folder name must not exceed 80 characters.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return normalizedName;
|
|
105
|
+
}
|
|
106
|
+
|
|
80
107
|
function splitMarkdownFilename(filename) {
|
|
81
108
|
const normalizedFilename = String(filename ?? "");
|
|
82
109
|
const extensionMatch = normalizedFilename.match(/\.md$/i);
|
|
@@ -251,6 +278,44 @@ const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
|
|
|
251
278
|
".png": "png",
|
|
252
279
|
".webp": "webp",
|
|
253
280
|
};
|
|
281
|
+
|
|
282
|
+
function normalizeConnectionTagName(value) {
|
|
283
|
+
const normalizedName = String(value ?? "")
|
|
284
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
285
|
+
.trim();
|
|
286
|
+
|
|
287
|
+
if (!normalizedName) {
|
|
288
|
+
throw new ValidationError("Tag name is required.");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (normalizedName.length > MAX_CONNECTION_TAG_NAME_LENGTH) {
|
|
292
|
+
throw new ValidationError(
|
|
293
|
+
`Tag name must be ${MAX_CONNECTION_TAG_NAME_LENGTH} characters or fewer.`
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return normalizedName;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function normalizeConnectionTagNames(values = []) {
|
|
301
|
+
if (!Array.isArray(values)) {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const namesByKey = new Map();
|
|
306
|
+
|
|
307
|
+
for (const value of values) {
|
|
308
|
+
const name = normalizeConnectionTagName(value);
|
|
309
|
+
const key = name.toLowerCase();
|
|
310
|
+
|
|
311
|
+
if (!namesByKey.has(key)) {
|
|
312
|
+
namesByKey.set(key, name);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return [...namesByKey.values()];
|
|
317
|
+
}
|
|
318
|
+
|
|
254
319
|
const MEDIA_TAGGING_CONFIG_FIELDS = [
|
|
255
320
|
{
|
|
256
321
|
column: "tag_table",
|
|
@@ -464,6 +529,29 @@ class AppStateStore {
|
|
|
464
529
|
CREATE INDEX IF NOT EXISTS idx_recent_connections_last_opened
|
|
465
530
|
ON recent_connections(lastOpenedAt DESC, id ASC);
|
|
466
531
|
|
|
532
|
+
CREATE TABLE IF NOT EXISTS tags (
|
|
533
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
534
|
+
name TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
|
535
|
+
createdAt TEXT NOT NULL,
|
|
536
|
+
updatedAt TEXT NOT NULL
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
CREATE TABLE IF NOT EXISTS connection_tags (
|
|
540
|
+
connectionId TEXT NOT NULL,
|
|
541
|
+
tagId INTEGER NOT NULL,
|
|
542
|
+
PRIMARY KEY (connectionId, tagId),
|
|
543
|
+
FOREIGN KEY (connectionId)
|
|
544
|
+
REFERENCES recent_connections(id)
|
|
545
|
+
ON UPDATE CASCADE
|
|
546
|
+
ON DELETE CASCADE,
|
|
547
|
+
FOREIGN KEY (tagId)
|
|
548
|
+
REFERENCES tags(id)
|
|
549
|
+
ON DELETE CASCADE
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
CREATE INDEX IF NOT EXISTS idx_connection_tags_tag
|
|
553
|
+
ON connection_tags(tagId, connectionId);
|
|
554
|
+
|
|
467
555
|
CREATE TABLE IF NOT EXISTS query_history (
|
|
468
556
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
469
557
|
database_key TEXT NOT NULL,
|
|
@@ -559,6 +647,7 @@ class AppStateStore {
|
|
|
559
647
|
CREATE TABLE IF NOT EXISTS database_documents (
|
|
560
648
|
id TEXT PRIMARY KEY,
|
|
561
649
|
database_key TEXT NOT NULL,
|
|
650
|
+
folder_id TEXT,
|
|
562
651
|
title TEXT NOT NULL,
|
|
563
652
|
filename TEXT NOT NULL,
|
|
564
653
|
content TEXT NOT NULL DEFAULT '',
|
|
@@ -570,6 +659,18 @@ class AppStateStore {
|
|
|
570
659
|
CREATE INDEX IF NOT EXISTS idx_database_documents_database_updated
|
|
571
660
|
ON database_documents(database_key, updated_at DESC, id ASC);
|
|
572
661
|
|
|
662
|
+
CREATE TABLE IF NOT EXISTS database_document_folders (
|
|
663
|
+
id TEXT PRIMARY KEY,
|
|
664
|
+
database_key TEXT NOT NULL,
|
|
665
|
+
name TEXT NOT NULL,
|
|
666
|
+
created_at TEXT NOT NULL,
|
|
667
|
+
updated_at TEXT NOT NULL,
|
|
668
|
+
UNIQUE(database_key, name)
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
CREATE INDEX IF NOT EXISTS idx_database_document_folders_database_name
|
|
672
|
+
ON database_document_folders(database_key, name COLLATE NOCASE, id ASC);
|
|
673
|
+
|
|
573
674
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
574
675
|
id TEXT PRIMARY KEY,
|
|
575
676
|
database_key TEXT NOT NULL,
|
|
@@ -585,7 +686,7 @@ class AppStateStore {
|
|
|
585
686
|
|
|
586
687
|
CREATE TABLE IF NOT EXISTS access_log (
|
|
587
688
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
588
|
-
source TEXT NOT NULL CHECK(source IN ('api', 'cli')),
|
|
689
|
+
source TEXT NOT NULL CHECK(source IN ('api', 'cli', 'user')),
|
|
589
690
|
action TEXT NOT NULL,
|
|
590
691
|
database_key TEXT,
|
|
591
692
|
target_type TEXT,
|
|
@@ -665,9 +766,113 @@ class AppStateStore {
|
|
|
665
766
|
}
|
|
666
767
|
|
|
667
768
|
this.ensureQueryRunsSchema();
|
|
769
|
+
this.ensureAccessLogSchema();
|
|
770
|
+
this.ensureDatabaseDocumentsSchema();
|
|
668
771
|
this.ensureMediaTaggingConfigSchema();
|
|
669
772
|
}
|
|
670
773
|
|
|
774
|
+
ensureDatabaseDocumentsSchema() {
|
|
775
|
+
const columns = new Set(
|
|
776
|
+
this.db
|
|
777
|
+
.prepare("PRAGMA table_info(database_documents)")
|
|
778
|
+
.all()
|
|
779
|
+
.map((column) => column.name)
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
if (!columns.has("folder_id")) {
|
|
783
|
+
this.db.exec("ALTER TABLE database_documents ADD COLUMN folder_id TEXT");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
this.db.exec(`
|
|
787
|
+
CREATE INDEX IF NOT EXISTS idx_database_documents_folder_updated
|
|
788
|
+
ON database_documents(database_key, folder_id, updated_at DESC, id ASC);
|
|
789
|
+
|
|
790
|
+
CREATE TABLE IF NOT EXISTS database_document_folders (
|
|
791
|
+
id TEXT PRIMARY KEY,
|
|
792
|
+
database_key TEXT NOT NULL,
|
|
793
|
+
name TEXT NOT NULL,
|
|
794
|
+
created_at TEXT NOT NULL,
|
|
795
|
+
updated_at TEXT NOT NULL,
|
|
796
|
+
UNIQUE(database_key, name)
|
|
797
|
+
);
|
|
798
|
+
|
|
799
|
+
CREATE INDEX IF NOT EXISTS idx_database_document_folders_database_name
|
|
800
|
+
ON database_document_folders(database_key, name COLLATE NOCASE, id ASC);
|
|
801
|
+
`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
ensureAccessLogSchema() {
|
|
805
|
+
const row = this.db
|
|
806
|
+
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'access_log'")
|
|
807
|
+
.get();
|
|
808
|
+
const tableSql = String(row?.sql ?? "");
|
|
809
|
+
|
|
810
|
+
if (tableSql.includes("'user'")) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
this.db.exec(`
|
|
815
|
+
DROP TABLE IF EXISTS access_log_source_migration;
|
|
816
|
+
DROP INDEX IF EXISTS idx_access_log_started;
|
|
817
|
+
DROP INDEX IF EXISTS idx_access_log_source_started;
|
|
818
|
+
DROP INDEX IF EXISTS idx_access_log_database_started;
|
|
819
|
+
|
|
820
|
+
ALTER TABLE access_log RENAME TO access_log_source_migration;
|
|
821
|
+
|
|
822
|
+
CREATE TABLE access_log (
|
|
823
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
824
|
+
source TEXT NOT NULL CHECK(source IN ('api', 'cli', 'user')),
|
|
825
|
+
action TEXT NOT NULL,
|
|
826
|
+
database_key TEXT,
|
|
827
|
+
target_type TEXT,
|
|
828
|
+
target_name TEXT,
|
|
829
|
+
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
|
|
830
|
+
started_at TEXT NOT NULL,
|
|
831
|
+
duration_ms INTEGER,
|
|
832
|
+
error_message TEXT,
|
|
833
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
INSERT INTO access_log (
|
|
837
|
+
id,
|
|
838
|
+
source,
|
|
839
|
+
action,
|
|
840
|
+
database_key,
|
|
841
|
+
target_type,
|
|
842
|
+
target_name,
|
|
843
|
+
status,
|
|
844
|
+
started_at,
|
|
845
|
+
duration_ms,
|
|
846
|
+
error_message,
|
|
847
|
+
metadata_json
|
|
848
|
+
)
|
|
849
|
+
SELECT
|
|
850
|
+
id,
|
|
851
|
+
source,
|
|
852
|
+
action,
|
|
853
|
+
database_key,
|
|
854
|
+
target_type,
|
|
855
|
+
target_name,
|
|
856
|
+
status,
|
|
857
|
+
started_at,
|
|
858
|
+
duration_ms,
|
|
859
|
+
error_message,
|
|
860
|
+
metadata_json
|
|
861
|
+
FROM access_log_source_migration;
|
|
862
|
+
|
|
863
|
+
DROP TABLE access_log_source_migration;
|
|
864
|
+
|
|
865
|
+
CREATE INDEX IF NOT EXISTS idx_access_log_started
|
|
866
|
+
ON access_log(started_at DESC, id DESC);
|
|
867
|
+
|
|
868
|
+
CREATE INDEX IF NOT EXISTS idx_access_log_source_started
|
|
869
|
+
ON access_log(source, started_at DESC, id DESC);
|
|
870
|
+
|
|
871
|
+
CREATE INDEX IF NOT EXISTS idx_access_log_database_started
|
|
872
|
+
ON access_log(database_key, started_at DESC, id DESC);
|
|
873
|
+
`);
|
|
874
|
+
}
|
|
875
|
+
|
|
671
876
|
ensureQueryRunsSchema() {
|
|
672
877
|
const queryRunColumns = new Set(
|
|
673
878
|
this.db
|
|
@@ -2182,7 +2387,7 @@ class AppStateStore {
|
|
|
2182
2387
|
}
|
|
2183
2388
|
|
|
2184
2389
|
if (actor) {
|
|
2185
|
-
if (actor !== "api" && actor !== "cli") {
|
|
2390
|
+
if (actor !== "api" && actor !== "cli" && actor !== "user") {
|
|
2186
2391
|
return {
|
|
2187
2392
|
whereSql: "WHERE 1 = 0",
|
|
2188
2393
|
params: [],
|
|
@@ -2347,6 +2552,16 @@ class AppStateStore {
|
|
|
2347
2552
|
};
|
|
2348
2553
|
}
|
|
2349
2554
|
|
|
2555
|
+
getLatestActivityLogTimestamp(databaseKey) {
|
|
2556
|
+
const result = this.listActivityLogs({
|
|
2557
|
+
databaseKey,
|
|
2558
|
+
limit: 1,
|
|
2559
|
+
offset: 0,
|
|
2560
|
+
});
|
|
2561
|
+
|
|
2562
|
+
return result.items[0]?.occurredAt ?? null;
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2350
2565
|
updateQueryHistoryField(historyId, fieldName, value, databaseKey) {
|
|
2351
2566
|
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
2352
2567
|
|
|
@@ -2917,8 +3132,142 @@ class AppStateStore {
|
|
|
2917
3132
|
});
|
|
2918
3133
|
}
|
|
2919
3134
|
|
|
2920
|
-
|
|
3135
|
+
decorateConnectionTag(row = {}) {
|
|
3136
|
+
return {
|
|
3137
|
+
id: Number(row.id),
|
|
3138
|
+
name: String(row.name ?? ""),
|
|
3139
|
+
createdAt: row.createdAt ?? null,
|
|
3140
|
+
updatedAt: row.updatedAt ?? null,
|
|
3141
|
+
...(row.connectionCount === undefined
|
|
3142
|
+
? {}
|
|
3143
|
+
: { connectionCount: Number(row.connectionCount ?? 0) }),
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
getConnectionTagsByConnectionIds(connectionIds = []) {
|
|
3148
|
+
const ids = connectionIds
|
|
3149
|
+
.map((id) => String(id ?? "").trim())
|
|
3150
|
+
.filter(Boolean)
|
|
3151
|
+
.filter((id, index, values) => values.indexOf(id) === index);
|
|
3152
|
+
|
|
3153
|
+
if (!ids.length) {
|
|
3154
|
+
return new Map();
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
3158
|
+
const rows = this.db
|
|
3159
|
+
.prepare(
|
|
3160
|
+
`
|
|
3161
|
+
SELECT
|
|
3162
|
+
ct.connectionId,
|
|
3163
|
+
t.id,
|
|
3164
|
+
t.name,
|
|
3165
|
+
t.createdAt,
|
|
3166
|
+
t.updatedAt
|
|
3167
|
+
FROM connection_tags ct
|
|
3168
|
+
INNER JOIN tags t
|
|
3169
|
+
ON t.id = ct.tagId
|
|
3170
|
+
WHERE ct.connectionId IN (${placeholders})
|
|
3171
|
+
ORDER BY t.name COLLATE NOCASE ASC, t.id ASC
|
|
3172
|
+
`
|
|
3173
|
+
)
|
|
3174
|
+
.all(...ids);
|
|
3175
|
+
const tagsByConnectionId = new Map(ids.map((id) => [id, []]));
|
|
3176
|
+
|
|
3177
|
+
for (const row of rows) {
|
|
3178
|
+
tagsByConnectionId
|
|
3179
|
+
.get(row.connectionId)
|
|
3180
|
+
?.push(this.decorateConnectionTag(row));
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
return tagsByConnectionId;
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
getConnectionTags(connectionId) {
|
|
3187
|
+
const normalizedConnectionId = String(connectionId ?? "").trim();
|
|
3188
|
+
|
|
3189
|
+
if (!normalizedConnectionId) {
|
|
3190
|
+
return [];
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
return this.getConnectionTagsByConnectionIds([normalizedConnectionId]).get(
|
|
3194
|
+
normalizedConnectionId
|
|
3195
|
+
) ?? [];
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
listConnectionTags() {
|
|
2921
3199
|
return this.db
|
|
3200
|
+
.prepare(
|
|
3201
|
+
`
|
|
3202
|
+
SELECT
|
|
3203
|
+
t.id,
|
|
3204
|
+
t.name,
|
|
3205
|
+
t.createdAt,
|
|
3206
|
+
t.updatedAt,
|
|
3207
|
+
COUNT(ct.connectionId) AS connectionCount
|
|
3208
|
+
FROM tags t
|
|
3209
|
+
LEFT JOIN connection_tags ct
|
|
3210
|
+
ON ct.tagId = t.id
|
|
3211
|
+
GROUP BY t.id
|
|
3212
|
+
ORDER BY t.name COLLATE NOCASE ASC, t.id ASC
|
|
3213
|
+
`
|
|
3214
|
+
)
|
|
3215
|
+
.all()
|
|
3216
|
+
.map((row) => this.decorateConnectionTag(row));
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
getConnectionTagById(tagId) {
|
|
3220
|
+
const row = this.db
|
|
3221
|
+
.prepare(
|
|
3222
|
+
`
|
|
3223
|
+
SELECT id, name, createdAt, updatedAt
|
|
3224
|
+
FROM tags
|
|
3225
|
+
WHERE id = ?
|
|
3226
|
+
`
|
|
3227
|
+
)
|
|
3228
|
+
.get(Number(tagId));
|
|
3229
|
+
|
|
3230
|
+
return row ? this.decorateConnectionTag(row) : null;
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
getConnectionTagByName(name) {
|
|
3234
|
+
const normalizedName = normalizeConnectionTagName(name);
|
|
3235
|
+
const row = this.db
|
|
3236
|
+
.prepare(
|
|
3237
|
+
`
|
|
3238
|
+
SELECT id, name, createdAt, updatedAt
|
|
3239
|
+
FROM tags
|
|
3240
|
+
WHERE name = ? COLLATE NOCASE
|
|
3241
|
+
`
|
|
3242
|
+
)
|
|
3243
|
+
.get(normalizedName);
|
|
3244
|
+
|
|
3245
|
+
return row ? this.decorateConnectionTag(row) : null;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
getOrCreateConnectionTag(name) {
|
|
3249
|
+
const normalizedName = normalizeConnectionTagName(name);
|
|
3250
|
+
const existingTag = this.getConnectionTagByName(normalizedName);
|
|
3251
|
+
|
|
3252
|
+
if (existingTag) {
|
|
3253
|
+
return existingTag;
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
const timestamp = new Date().toISOString();
|
|
3257
|
+
const result = this.db
|
|
3258
|
+
.prepare(
|
|
3259
|
+
`
|
|
3260
|
+
INSERT INTO tags (name, createdAt, updatedAt)
|
|
3261
|
+
VALUES (?, ?, ?)
|
|
3262
|
+
`
|
|
3263
|
+
)
|
|
3264
|
+
.run(normalizedName, timestamp, timestamp);
|
|
3265
|
+
|
|
3266
|
+
return this.getConnectionTagById(result.lastInsertRowid);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
getRecentConnections() {
|
|
3270
|
+
const rows = this.db
|
|
2922
3271
|
.prepare(`
|
|
2923
3272
|
SELECT
|
|
2924
3273
|
id,
|
|
@@ -2932,8 +3281,108 @@ class AppStateStore {
|
|
|
2932
3281
|
FROM recent_connections
|
|
2933
3282
|
ORDER BY lastOpenedAt DESC, id ASC
|
|
2934
3283
|
`)
|
|
2935
|
-
.all()
|
|
2936
|
-
|
|
3284
|
+
.all();
|
|
3285
|
+
const tagsByConnectionId = this.getConnectionTagsByConnectionIds(
|
|
3286
|
+
rows.map((connection) => connection.id)
|
|
3287
|
+
);
|
|
3288
|
+
|
|
3289
|
+
return rows.map((connection) =>
|
|
3290
|
+
this.decorateConnection(
|
|
3291
|
+
connection,
|
|
3292
|
+
tagsByConnectionId.get(connection.id) ?? []
|
|
3293
|
+
)
|
|
3294
|
+
);
|
|
3295
|
+
}
|
|
3296
|
+
|
|
3297
|
+
getRecentConnection(id) {
|
|
3298
|
+
return (
|
|
3299
|
+
this.getRecentConnections().find(
|
|
3300
|
+
(connection) => connection.id === String(id ?? "").trim()
|
|
3301
|
+
) ?? null
|
|
3302
|
+
);
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
assertRecentConnectionExists(id) {
|
|
3306
|
+
const normalizedConnectionId = String(id ?? "").trim();
|
|
3307
|
+
|
|
3308
|
+
if (!normalizedConnectionId) {
|
|
3309
|
+
throw new ValidationError("Connection id is required.");
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
const row = this.db
|
|
3313
|
+
.prepare("SELECT id FROM recent_connections WHERE id = ?")
|
|
3314
|
+
.get(normalizedConnectionId);
|
|
3315
|
+
|
|
3316
|
+
if (!row) {
|
|
3317
|
+
throw new NotFoundError(`Recent connection not found: ${normalizedConnectionId}`);
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3320
|
+
return normalizedConnectionId;
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
setConnectionTags(connectionId, tagNames = []) {
|
|
3324
|
+
const normalizedConnectionId = this.assertRecentConnectionExists(connectionId);
|
|
3325
|
+
const normalizedTagNames = normalizeConnectionTagNames(tagNames);
|
|
3326
|
+
|
|
3327
|
+
this.db.transaction(() => {
|
|
3328
|
+
this.db
|
|
3329
|
+
.prepare("DELETE FROM connection_tags WHERE connectionId = ?")
|
|
3330
|
+
.run(normalizedConnectionId);
|
|
3331
|
+
|
|
3332
|
+
for (const tagName of normalizedTagNames) {
|
|
3333
|
+
const tag = this.getOrCreateConnectionTag(tagName);
|
|
3334
|
+
|
|
3335
|
+
this.db
|
|
3336
|
+
.prepare(
|
|
3337
|
+
`
|
|
3338
|
+
INSERT OR IGNORE INTO connection_tags (connectionId, tagId)
|
|
3339
|
+
VALUES (?, ?)
|
|
3340
|
+
`
|
|
3341
|
+
)
|
|
3342
|
+
.run(normalizedConnectionId, tag.id);
|
|
3343
|
+
}
|
|
3344
|
+
})();
|
|
3345
|
+
|
|
3346
|
+
return this.getConnectionTags(normalizedConnectionId);
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
addConnectionTag(connectionId, tagName) {
|
|
3350
|
+
const normalizedConnectionId = this.assertRecentConnectionExists(connectionId);
|
|
3351
|
+
const tag = this.getOrCreateConnectionTag(tagName);
|
|
3352
|
+
|
|
3353
|
+
this.db
|
|
3354
|
+
.prepare(
|
|
3355
|
+
`
|
|
3356
|
+
INSERT OR IGNORE INTO connection_tags (connectionId, tagId)
|
|
3357
|
+
VALUES (?, ?)
|
|
3358
|
+
`
|
|
3359
|
+
)
|
|
3360
|
+
.run(normalizedConnectionId, tag.id);
|
|
3361
|
+
|
|
3362
|
+
return this.getConnectionTags(normalizedConnectionId);
|
|
3363
|
+
}
|
|
3364
|
+
|
|
3365
|
+
removeConnectionTag(connectionId, tagId) {
|
|
3366
|
+
const normalizedConnectionId = this.assertRecentConnectionExists(connectionId);
|
|
3367
|
+
|
|
3368
|
+
this.db
|
|
3369
|
+
.prepare(
|
|
3370
|
+
`
|
|
3371
|
+
DELETE FROM connection_tags
|
|
3372
|
+
WHERE connectionId = ? AND tagId = ?
|
|
3373
|
+
`
|
|
3374
|
+
)
|
|
3375
|
+
.run(normalizedConnectionId, Number(tagId));
|
|
3376
|
+
|
|
3377
|
+
return this.getConnectionTags(normalizedConnectionId);
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
deleteConnectionTag(tagId) {
|
|
3381
|
+
const result = this.db
|
|
3382
|
+
.prepare("DELETE FROM tags WHERE id = ?")
|
|
3383
|
+
.run(Number(tagId));
|
|
3384
|
+
|
|
3385
|
+
return result.changes > 0;
|
|
2937
3386
|
}
|
|
2938
3387
|
|
|
2939
3388
|
upsertRecentConnection(connection, options = {}) {
|
|
@@ -3121,7 +3570,7 @@ class AppStateStore {
|
|
|
3121
3570
|
);
|
|
3122
3571
|
}
|
|
3123
3572
|
|
|
3124
|
-
decorateConnection(connection = {}) {
|
|
3573
|
+
decorateConnection(connection = {}, tags = []) {
|
|
3125
3574
|
const logoPath = this.normalizeLogoPath(connection.logoPath);
|
|
3126
3575
|
|
|
3127
3576
|
return {
|
|
@@ -3129,6 +3578,7 @@ class AppStateStore {
|
|
|
3129
3578
|
readOnly: Boolean(connection.readOnly),
|
|
3130
3579
|
logoPath,
|
|
3131
3580
|
logoUrl: this.getConnectionLogoUrl(logoPath),
|
|
3581
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
3132
3582
|
};
|
|
3133
3583
|
}
|
|
3134
3584
|
|
|
@@ -3395,6 +3845,7 @@ class AppStateStore {
|
|
|
3395
3845
|
return {
|
|
3396
3846
|
id: String(row.id ?? ""),
|
|
3397
3847
|
databaseKey: row.database_key ?? row.databaseKey ?? "",
|
|
3848
|
+
folderId: row.folder_id ?? row.folderId ?? null,
|
|
3398
3849
|
title: String(row.title ?? ""),
|
|
3399
3850
|
filename: String(row.filename ?? ""),
|
|
3400
3851
|
content: row.content === undefined ? undefined : String(row.content ?? ""),
|
|
@@ -3404,6 +3855,115 @@ class AppStateStore {
|
|
|
3404
3855
|
};
|
|
3405
3856
|
}
|
|
3406
3857
|
|
|
3858
|
+
decorateDatabaseDocumentFolderRow(row = {}) {
|
|
3859
|
+
return {
|
|
3860
|
+
id: String(row.id ?? ""),
|
|
3861
|
+
databaseKey: row.database_key ?? row.databaseKey ?? "",
|
|
3862
|
+
name: String(row.name ?? ""),
|
|
3863
|
+
createdAt: row.created_at ?? row.createdAt ?? null,
|
|
3864
|
+
updatedAt: row.updated_at ?? row.updatedAt ?? null,
|
|
3865
|
+
};
|
|
3866
|
+
}
|
|
3867
|
+
|
|
3868
|
+
listDatabaseDocumentFolders(databaseKey) {
|
|
3869
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
3870
|
+
|
|
3871
|
+
return this.db
|
|
3872
|
+
.prepare(
|
|
3873
|
+
`
|
|
3874
|
+
SELECT
|
|
3875
|
+
id,
|
|
3876
|
+
database_key,
|
|
3877
|
+
name,
|
|
3878
|
+
created_at,
|
|
3879
|
+
updated_at
|
|
3880
|
+
FROM database_document_folders
|
|
3881
|
+
WHERE database_key = ?
|
|
3882
|
+
ORDER BY name COLLATE NOCASE ASC, id ASC
|
|
3883
|
+
`
|
|
3884
|
+
)
|
|
3885
|
+
.all(normalizedDatabaseKey)
|
|
3886
|
+
.map((row) => this.decorateDatabaseDocumentFolderRow(row));
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
getDatabaseDocumentFolder(databaseKey, folderId) {
|
|
3890
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
3891
|
+
const normalizedFolderId = normalizeDocumentFolderId(folderId);
|
|
3892
|
+
|
|
3893
|
+
if (!normalizedFolderId) {
|
|
3894
|
+
return null;
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
const row = this.db
|
|
3898
|
+
.prepare(
|
|
3899
|
+
`
|
|
3900
|
+
SELECT
|
|
3901
|
+
id,
|
|
3902
|
+
database_key,
|
|
3903
|
+
name,
|
|
3904
|
+
created_at,
|
|
3905
|
+
updated_at
|
|
3906
|
+
FROM database_document_folders
|
|
3907
|
+
WHERE database_key = ?
|
|
3908
|
+
AND id = ?
|
|
3909
|
+
`
|
|
3910
|
+
)
|
|
3911
|
+
.get(normalizedDatabaseKey, normalizedFolderId);
|
|
3912
|
+
|
|
3913
|
+
if (!row) {
|
|
3914
|
+
throw new NotFoundError("Document folder was not found.");
|
|
3915
|
+
}
|
|
3916
|
+
|
|
3917
|
+
return this.decorateDatabaseDocumentFolderRow(row);
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
createDatabaseDocumentFolder(databaseKey, folder = {}) {
|
|
3921
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
3922
|
+
const name = normalizeDocumentFolderName(folder.name);
|
|
3923
|
+
const now = new Date().toISOString();
|
|
3924
|
+
const id = crypto.randomUUID();
|
|
3925
|
+
const existingFolder = this.db
|
|
3926
|
+
.prepare(
|
|
3927
|
+
`
|
|
3928
|
+
SELECT id
|
|
3929
|
+
FROM database_document_folders
|
|
3930
|
+
WHERE database_key = ?
|
|
3931
|
+
AND name = ? COLLATE NOCASE
|
|
3932
|
+
LIMIT 1
|
|
3933
|
+
`
|
|
3934
|
+
)
|
|
3935
|
+
.get(normalizedDatabaseKey, name);
|
|
3936
|
+
|
|
3937
|
+
if (existingFolder) {
|
|
3938
|
+
throw new ConflictError("A folder with this name already exists.");
|
|
3939
|
+
}
|
|
3940
|
+
|
|
3941
|
+
try {
|
|
3942
|
+
this.db
|
|
3943
|
+
.prepare(
|
|
3944
|
+
`
|
|
3945
|
+
INSERT INTO database_document_folders (
|
|
3946
|
+
id,
|
|
3947
|
+
database_key,
|
|
3948
|
+
name,
|
|
3949
|
+
created_at,
|
|
3950
|
+
updated_at
|
|
3951
|
+
)
|
|
3952
|
+
VALUES (?, ?, ?, ?, ?)
|
|
3953
|
+
`
|
|
3954
|
+
)
|
|
3955
|
+
.run(id, normalizedDatabaseKey, name, now, now);
|
|
3956
|
+
} catch (error) {
|
|
3957
|
+
if (error?.code === "SQLITE_CONSTRAINT_UNIQUE") {
|
|
3958
|
+
throw new ConflictError("A folder with this name already exists.");
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
throw error;
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
return this.getDatabaseDocumentFolder(normalizedDatabaseKey, id);
|
|
3965
|
+
}
|
|
3966
|
+
|
|
3407
3967
|
documentFilenameExists(databaseKey, filename, ignoredDocumentId = null) {
|
|
3408
3968
|
const row = this.db
|
|
3409
3969
|
.prepare(
|
|
@@ -3452,6 +4012,7 @@ class AppStateStore {
|
|
|
3452
4012
|
SELECT
|
|
3453
4013
|
id,
|
|
3454
4014
|
database_key,
|
|
4015
|
+
folder_id,
|
|
3455
4016
|
title,
|
|
3456
4017
|
filename,
|
|
3457
4018
|
LENGTH(content) AS content_length,
|
|
@@ -3475,6 +4036,7 @@ class AppStateStore {
|
|
|
3475
4036
|
SELECT
|
|
3476
4037
|
id,
|
|
3477
4038
|
database_key,
|
|
4039
|
+
folder_id,
|
|
3478
4040
|
title,
|
|
3479
4041
|
filename,
|
|
3480
4042
|
content,
|
|
@@ -3501,6 +4063,11 @@ class AppStateStore {
|
|
|
3501
4063
|
normalizedDatabaseKey,
|
|
3502
4064
|
normalizeDocumentFilename(document.filename)
|
|
3503
4065
|
);
|
|
4066
|
+
const folderId = normalizeDocumentFolderId(document.folderId);
|
|
4067
|
+
if (folderId) {
|
|
4068
|
+
this.getDatabaseDocumentFolder(normalizedDatabaseKey, folderId);
|
|
4069
|
+
}
|
|
4070
|
+
|
|
3504
4071
|
const title = normalizeDocumentTitle(document.title, filename);
|
|
3505
4072
|
const content = normalizeDocumentContent(document.content);
|
|
3506
4073
|
const now = new Date().toISOString();
|
|
@@ -3512,16 +4079,17 @@ class AppStateStore {
|
|
|
3512
4079
|
INSERT INTO database_documents (
|
|
3513
4080
|
id,
|
|
3514
4081
|
database_key,
|
|
4082
|
+
folder_id,
|
|
3515
4083
|
title,
|
|
3516
4084
|
filename,
|
|
3517
4085
|
content,
|
|
3518
4086
|
created_at,
|
|
3519
4087
|
updated_at
|
|
3520
4088
|
)
|
|
3521
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
4089
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3522
4090
|
`
|
|
3523
4091
|
)
|
|
3524
|
-
.run(id, normalizedDatabaseKey, title, filename, content, now, now);
|
|
4092
|
+
.run(id, normalizedDatabaseKey, folderId, title, filename, content, now, now);
|
|
3525
4093
|
|
|
3526
4094
|
return this.getDatabaseDocument(normalizedDatabaseKey, id);
|
|
3527
4095
|
}
|
|
@@ -3532,6 +4100,15 @@ class AppStateStore {
|
|
|
3532
4100
|
const hasFilename = Object.prototype.hasOwnProperty.call(patch, "filename");
|
|
3533
4101
|
const hasTitle = Object.prototype.hasOwnProperty.call(patch, "title");
|
|
3534
4102
|
const hasContent = Object.prototype.hasOwnProperty.call(patch, "content");
|
|
4103
|
+
const hasFolderId = Object.prototype.hasOwnProperty.call(patch, "folderId");
|
|
4104
|
+
const folderId = hasFolderId
|
|
4105
|
+
? normalizeDocumentFolderId(patch.folderId)
|
|
4106
|
+
: existingDocument.folderId;
|
|
4107
|
+
|
|
4108
|
+
if (folderId) {
|
|
4109
|
+
this.getDatabaseDocumentFolder(normalizedDatabaseKey, folderId);
|
|
4110
|
+
}
|
|
4111
|
+
|
|
3535
4112
|
const filename = hasFilename
|
|
3536
4113
|
? this.resolveUniqueDocumentFilename(
|
|
3537
4114
|
normalizedDatabaseKey,
|
|
@@ -3554,6 +4131,7 @@ class AppStateStore {
|
|
|
3554
4131
|
`
|
|
3555
4132
|
UPDATE database_documents
|
|
3556
4133
|
SET
|
|
4134
|
+
folder_id = ?,
|
|
3557
4135
|
title = ?,
|
|
3558
4136
|
filename = ?,
|
|
3559
4137
|
content = ?,
|
|
@@ -3562,7 +4140,7 @@ class AppStateStore {
|
|
|
3562
4140
|
AND id = ?
|
|
3563
4141
|
`
|
|
3564
4142
|
)
|
|
3565
|
-
.run(title, filename, content, updatedAt, normalizedDatabaseKey, existingDocument.id);
|
|
4143
|
+
.run(folderId, title, filename, content, updatedAt, normalizedDatabaseKey, existingDocument.id);
|
|
3566
4144
|
|
|
3567
4145
|
return this.getDatabaseDocument(normalizedDatabaseKey, existingDocument.id);
|
|
3568
4146
|
}
|
|
@@ -3696,4 +4274,6 @@ class AppStateStore {
|
|
|
3696
4274
|
module.exports = {
|
|
3697
4275
|
AppStateStore,
|
|
3698
4276
|
DEFAULT_STATE,
|
|
4277
|
+
MAX_CONNECTION_TAG_NAME_LENGTH,
|
|
4278
|
+
normalizeConnectionTagName,
|
|
3699
4279
|
};
|