sqlite-hub 2.1.0 → 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 +7 -0
- package/docs/MCP.md +2 -2
- package/docs/changelog.md +6 -0
- package/docs/todo.md +2 -0
- package/frontend/js/api.js +7 -0
- package/frontend/js/app.js +329 -7
- package/frontend/js/components/modal.js +152 -2
- package/frontend/js/store.js +1332 -219
- package/frontend/js/utils/markdownDocuments.js +21 -4
- package/frontend/js/views/connections.js +3 -8
- package/frontend/js/views/data.js +34 -5
- package/frontend/js/views/documents.js +153 -59
- package/frontend/styles/base.css +9 -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/data.js +29 -1
- package/server/routes/documents.js +61 -5
- package/server/routes/settings.js +32 -0
- package/server/routes/tableDesigner.js +20 -1
- package/server/server.js +3 -3
- package/server/services/mcpToolService.js +4 -2
- package/server/services/sqlite/connectionManager.js +2 -0
- package/server/services/sqlite/overviewService.js +1 -0
- package/server/services/storage/appStateStore.js +287 -6
- package/server/utils/fileValidation.js +5 -0
- package/server/utils/userActionLog.js +49 -0
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
} = require("../services/appInfoService");
|
|
11
11
|
const { McpStatusService } = require("../services/mcpStatusService");
|
|
12
12
|
const { MCP_TOOL_DEFINITIONS } = require("../services/mcpToolService");
|
|
13
|
+
const { recordUserAction } = require("../utils/userActionLog");
|
|
13
14
|
|
|
14
15
|
function getActiveTokenContext({ connectionManager, tokenService }) {
|
|
15
16
|
const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
|
|
@@ -161,6 +162,20 @@ function createSettingsRouter({ appStateStore, connectionManager, tokenService,
|
|
|
161
162
|
const activeDatabase = requireActiveDatabase(connectionManager);
|
|
162
163
|
const token = tokenService.createToken(activeDatabase.id, req.body?.name);
|
|
163
164
|
|
|
165
|
+
recordUserAction({
|
|
166
|
+
appStateStore,
|
|
167
|
+
connectionManager,
|
|
168
|
+
action: "settings.api-token.create",
|
|
169
|
+
targetType: "api-token",
|
|
170
|
+
targetName: token.name ?? token.id,
|
|
171
|
+
databaseKey: activeDatabase.id,
|
|
172
|
+
metadata: {
|
|
173
|
+
apiTokenId: token.id,
|
|
174
|
+
apiTokenName: token.name ?? null,
|
|
175
|
+
tokenPrefix: token.tokenPrefix ?? null,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
164
179
|
res.status(201).json(
|
|
165
180
|
successResponse({
|
|
166
181
|
message: "API token created. It will only be shown once.",
|
|
@@ -175,8 +190,25 @@ function createSettingsRouter({ appStateStore, connectionManager, tokenService,
|
|
|
175
190
|
"/api-tokens/:tokenId",
|
|
176
191
|
route((req, res) => {
|
|
177
192
|
const activeDatabase = requireActiveDatabase(connectionManager);
|
|
193
|
+
const token = tokenService
|
|
194
|
+
.listTokens(activeDatabase.id)
|
|
195
|
+
.find((candidate) => candidate.id === req.params.tokenId);
|
|
178
196
|
const result = tokenService.deleteToken(activeDatabase.id, req.params.tokenId);
|
|
179
197
|
|
|
198
|
+
recordUserAction({
|
|
199
|
+
appStateStore,
|
|
200
|
+
connectionManager,
|
|
201
|
+
action: "settings.api-token.delete",
|
|
202
|
+
targetType: "api-token",
|
|
203
|
+
targetName: token?.name ?? req.params.tokenId,
|
|
204
|
+
databaseKey: activeDatabase.id,
|
|
205
|
+
metadata: {
|
|
206
|
+
apiTokenId: req.params.tokenId,
|
|
207
|
+
apiTokenName: token?.name ?? null,
|
|
208
|
+
tokenPrefix: token?.tokenPrefix ?? null,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
180
212
|
res.json(
|
|
181
213
|
successResponse({
|
|
182
214
|
message: "API token deleted.",
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const express = require("express");
|
|
2
2
|
const { route, successResponse } = require("../utils/errors");
|
|
3
|
+
const { recordUserAction } = require("../utils/userActionLog");
|
|
3
4
|
|
|
4
|
-
function createTableDesignerRouter({ tableDesignerService }) {
|
|
5
|
+
function createTableDesignerRouter({ tableDesignerService, appStateStore = null, connectionManager = null }) {
|
|
5
6
|
const router = express.Router();
|
|
6
7
|
|
|
7
8
|
router.get(
|
|
@@ -52,6 +53,24 @@ function createTableDesignerRouter({ tableDesignerService }) {
|
|
|
52
53
|
const data = tableDesignerService.saveDraft(req.body ?? {});
|
|
53
54
|
const isCreate = String(req.body?.draft?.mode ?? req.body?.mode ?? "").trim() !== "edit";
|
|
54
55
|
const fillsImportedRows = Boolean(req.body?.draft?.fillImportedRows ?? req.body?.fillImportedRows);
|
|
56
|
+
const importedRows = req.body?.draft?.importRows ?? req.body?.draft?.importedCsvRows ?? [];
|
|
57
|
+
const executedSqlCount = Array.isArray(data.executedSql) ? data.executedSql.length : 0;
|
|
58
|
+
|
|
59
|
+
if (isCreate || executedSqlCount > 0) {
|
|
60
|
+
recordUserAction({
|
|
61
|
+
appStateStore,
|
|
62
|
+
connectionManager,
|
|
63
|
+
action: isCreate ? "table-designer.table.create" : "table-designer.table.update",
|
|
64
|
+
targetType: "table",
|
|
65
|
+
targetName: data.savedTableName ?? req.body?.draft?.tableName ?? req.body?.tableName,
|
|
66
|
+
metadata: {
|
|
67
|
+
mode: isCreate ? "create" : "edit",
|
|
68
|
+
executedSqlCount,
|
|
69
|
+
fillsImportedRows,
|
|
70
|
+
importedRowCount: fillsImportedRows && Array.isArray(importedRows) ? importedRows.length : 0,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
55
74
|
|
|
56
75
|
res.json(
|
|
57
76
|
successResponse({
|
package/server/server.js
CHANGED
|
@@ -129,13 +129,13 @@ app.use(
|
|
|
129
129
|
nativeFileDialogService,
|
|
130
130
|
})
|
|
131
131
|
);
|
|
132
|
-
app.use("/api/backups", createBackupsRouter({ backupService }));
|
|
132
|
+
app.use("/api/backups", createBackupsRouter({ backupService, appStateStore, connectionManager }));
|
|
133
133
|
app.use("/api/db", createOverviewRouter({ overviewService }));
|
|
134
134
|
app.use("/api/sql", createSqlRouter({ appStateStore, connectionManager, sqlExecutor }));
|
|
135
135
|
app.use("/api/charts", createChartsRouter({ appStateStore, connectionManager, sqlExecutor }));
|
|
136
136
|
app.use("/api/structure", createStructureRouter({ structureService }));
|
|
137
|
-
app.use("/api/data", createDataRouter({ dataBrowserService }));
|
|
138
|
-
app.use("/api/table-designer", createTableDesignerRouter({ tableDesignerService }));
|
|
137
|
+
app.use("/api/data", createDataRouter({ dataBrowserService, appStateStore, connectionManager }));
|
|
138
|
+
app.use("/api/table-designer", createTableDesignerRouter({ tableDesignerService, appStateStore, connectionManager }));
|
|
139
139
|
app.use("/api/media-tagging", createMediaTaggingRouter({ mediaTaggingService }));
|
|
140
140
|
app.use(
|
|
141
141
|
"/api/settings",
|
|
@@ -77,7 +77,7 @@ const MCP_TOOL_DEFINITIONS = [
|
|
|
77
77
|
),
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
|
-
name: "
|
|
80
|
+
name: "get_saved_queries",
|
|
81
81
|
description: "List saved SQL Editor queries for a database. This is the MCP equivalent of `sqlite-hub --database:name --queries`.",
|
|
82
82
|
inputSchema: objectSchema({
|
|
83
83
|
databaseId: databaseIdProperty(),
|
|
@@ -164,6 +164,7 @@ function redactConnection(connection = {}) {
|
|
|
164
164
|
label: connection.label,
|
|
165
165
|
readOnly: Boolean(connection.readOnly),
|
|
166
166
|
sizeBytes: connection.sizeBytes ?? null,
|
|
167
|
+
createdAt: connection.createdAt ?? null,
|
|
167
168
|
lastOpenedAt: connection.lastOpenedAt ?? null,
|
|
168
169
|
lastModifiedAt: connection.lastModifiedAt ?? null,
|
|
169
170
|
};
|
|
@@ -176,6 +177,7 @@ function redactOverview(overview = {}) {
|
|
|
176
177
|
file: {
|
|
177
178
|
filename: overview.file?.filename ?? overview.connection?.label ?? null,
|
|
178
179
|
sizeBytes: overview.file?.sizeBytes ?? null,
|
|
180
|
+
createdAt: overview.file?.createdAt ?? null,
|
|
179
181
|
lastModifiedAt: overview.file?.lastModifiedAt ?? null,
|
|
180
182
|
},
|
|
181
183
|
};
|
|
@@ -249,7 +251,7 @@ class McpToolService {
|
|
|
249
251
|
executedBy: "mcp",
|
|
250
252
|
maxRows: args.maxRows,
|
|
251
253
|
});
|
|
252
|
-
case "
|
|
254
|
+
case "get_saved_queries":
|
|
253
255
|
return this.databaseService.listSavedQueries(args.databaseId, args.limit);
|
|
254
256
|
case "execute_stored_query":
|
|
255
257
|
return this.databaseService.executeSavedQuery(args.databaseId, args.queryName, {
|
|
@@ -70,6 +70,7 @@ class ConnectionManager {
|
|
|
70
70
|
label: options.label?.trim() || path.basename(filePath),
|
|
71
71
|
path: filePath,
|
|
72
72
|
lastOpenedAt: new Date().toISOString(),
|
|
73
|
+
createdAt: metadata.createdAt,
|
|
73
74
|
lastModifiedAt: metadata.lastModifiedAt,
|
|
74
75
|
sizeBytes: metadata.sizeBytes,
|
|
75
76
|
readOnly: options.readOnly ?? !isWritable(filePath),
|
|
@@ -276,6 +277,7 @@ class ConnectionManager {
|
|
|
276
277
|
...existing,
|
|
277
278
|
label: normalizedLabel,
|
|
278
279
|
path: resolvedPath,
|
|
280
|
+
createdAt: metadata.createdAt,
|
|
279
281
|
lastModifiedAt: metadata.lastModifiedAt,
|
|
280
282
|
sizeBytes: metadata.sizeBytes,
|
|
281
283
|
readOnly: normalizedReadOnly,
|
|
@@ -44,7 +44,7 @@ const MAX_CONNECTION_TAG_NAME_LENGTH = 40;
|
|
|
44
44
|
const MAX_DOCUMENT_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
45
45
|
const MAX_DOCUMENT_FILENAME_LENGTH = 160;
|
|
46
46
|
const QUERY_EXECUTION_SOURCES = new Set(["api", "cli", "user", "mcp"]);
|
|
47
|
-
const ACCESS_LOG_SOURCES = new Set(["api", "cli"]);
|
|
47
|
+
const ACCESS_LOG_SOURCES = new Set(["api", "cli", "user"]);
|
|
48
48
|
const ACCESS_LOG_STATUSES = new Set(["success", "error"]);
|
|
49
49
|
const MAX_ACCESS_LOG_TEXT_LENGTH = 500;
|
|
50
50
|
const MAX_ACCESS_LOG_METADATA_BYTES = 16 * 1024;
|
|
@@ -78,6 +78,32 @@ function normalizeDocumentId(documentId) {
|
|
|
78
78
|
return normalizedDocumentId;
|
|
79
79
|
}
|
|
80
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
|
+
|
|
81
107
|
function splitMarkdownFilename(filename) {
|
|
82
108
|
const normalizedFilename = String(filename ?? "");
|
|
83
109
|
const extensionMatch = normalizedFilename.match(/\.md$/i);
|
|
@@ -621,6 +647,7 @@ class AppStateStore {
|
|
|
621
647
|
CREATE TABLE IF NOT EXISTS database_documents (
|
|
622
648
|
id TEXT PRIMARY KEY,
|
|
623
649
|
database_key TEXT NOT NULL,
|
|
650
|
+
folder_id TEXT,
|
|
624
651
|
title TEXT NOT NULL,
|
|
625
652
|
filename TEXT NOT NULL,
|
|
626
653
|
content TEXT NOT NULL DEFAULT '',
|
|
@@ -632,6 +659,18 @@ class AppStateStore {
|
|
|
632
659
|
CREATE INDEX IF NOT EXISTS idx_database_documents_database_updated
|
|
633
660
|
ON database_documents(database_key, updated_at DESC, id ASC);
|
|
634
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
|
+
|
|
635
674
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
636
675
|
id TEXT PRIMARY KEY,
|
|
637
676
|
database_key TEXT NOT NULL,
|
|
@@ -647,7 +686,7 @@ class AppStateStore {
|
|
|
647
686
|
|
|
648
687
|
CREATE TABLE IF NOT EXISTS access_log (
|
|
649
688
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
650
|
-
source TEXT NOT NULL CHECK(source IN ('api', 'cli')),
|
|
689
|
+
source TEXT NOT NULL CHECK(source IN ('api', 'cli', 'user')),
|
|
651
690
|
action TEXT NOT NULL,
|
|
652
691
|
database_key TEXT,
|
|
653
692
|
target_type TEXT,
|
|
@@ -727,9 +766,113 @@ class AppStateStore {
|
|
|
727
766
|
}
|
|
728
767
|
|
|
729
768
|
this.ensureQueryRunsSchema();
|
|
769
|
+
this.ensureAccessLogSchema();
|
|
770
|
+
this.ensureDatabaseDocumentsSchema();
|
|
730
771
|
this.ensureMediaTaggingConfigSchema();
|
|
731
772
|
}
|
|
732
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
|
+
|
|
733
876
|
ensureQueryRunsSchema() {
|
|
734
877
|
const queryRunColumns = new Set(
|
|
735
878
|
this.db
|
|
@@ -2244,7 +2387,7 @@ class AppStateStore {
|
|
|
2244
2387
|
}
|
|
2245
2388
|
|
|
2246
2389
|
if (actor) {
|
|
2247
|
-
if (actor !== "api" && actor !== "cli") {
|
|
2390
|
+
if (actor !== "api" && actor !== "cli" && actor !== "user") {
|
|
2248
2391
|
return {
|
|
2249
2392
|
whereSql: "WHERE 1 = 0",
|
|
2250
2393
|
params: [],
|
|
@@ -2409,6 +2552,16 @@ class AppStateStore {
|
|
|
2409
2552
|
};
|
|
2410
2553
|
}
|
|
2411
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
|
+
|
|
2412
2565
|
updateQueryHistoryField(historyId, fieldName, value, databaseKey) {
|
|
2413
2566
|
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
2414
2567
|
|
|
@@ -3692,6 +3845,7 @@ class AppStateStore {
|
|
|
3692
3845
|
return {
|
|
3693
3846
|
id: String(row.id ?? ""),
|
|
3694
3847
|
databaseKey: row.database_key ?? row.databaseKey ?? "",
|
|
3848
|
+
folderId: row.folder_id ?? row.folderId ?? null,
|
|
3695
3849
|
title: String(row.title ?? ""),
|
|
3696
3850
|
filename: String(row.filename ?? ""),
|
|
3697
3851
|
content: row.content === undefined ? undefined : String(row.content ?? ""),
|
|
@@ -3701,6 +3855,115 @@ class AppStateStore {
|
|
|
3701
3855
|
};
|
|
3702
3856
|
}
|
|
3703
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
|
+
|
|
3704
3967
|
documentFilenameExists(databaseKey, filename, ignoredDocumentId = null) {
|
|
3705
3968
|
const row = this.db
|
|
3706
3969
|
.prepare(
|
|
@@ -3749,6 +4012,7 @@ class AppStateStore {
|
|
|
3749
4012
|
SELECT
|
|
3750
4013
|
id,
|
|
3751
4014
|
database_key,
|
|
4015
|
+
folder_id,
|
|
3752
4016
|
title,
|
|
3753
4017
|
filename,
|
|
3754
4018
|
LENGTH(content) AS content_length,
|
|
@@ -3772,6 +4036,7 @@ class AppStateStore {
|
|
|
3772
4036
|
SELECT
|
|
3773
4037
|
id,
|
|
3774
4038
|
database_key,
|
|
4039
|
+
folder_id,
|
|
3775
4040
|
title,
|
|
3776
4041
|
filename,
|
|
3777
4042
|
content,
|
|
@@ -3798,6 +4063,11 @@ class AppStateStore {
|
|
|
3798
4063
|
normalizedDatabaseKey,
|
|
3799
4064
|
normalizeDocumentFilename(document.filename)
|
|
3800
4065
|
);
|
|
4066
|
+
const folderId = normalizeDocumentFolderId(document.folderId);
|
|
4067
|
+
if (folderId) {
|
|
4068
|
+
this.getDatabaseDocumentFolder(normalizedDatabaseKey, folderId);
|
|
4069
|
+
}
|
|
4070
|
+
|
|
3801
4071
|
const title = normalizeDocumentTitle(document.title, filename);
|
|
3802
4072
|
const content = normalizeDocumentContent(document.content);
|
|
3803
4073
|
const now = new Date().toISOString();
|
|
@@ -3809,16 +4079,17 @@ class AppStateStore {
|
|
|
3809
4079
|
INSERT INTO database_documents (
|
|
3810
4080
|
id,
|
|
3811
4081
|
database_key,
|
|
4082
|
+
folder_id,
|
|
3812
4083
|
title,
|
|
3813
4084
|
filename,
|
|
3814
4085
|
content,
|
|
3815
4086
|
created_at,
|
|
3816
4087
|
updated_at
|
|
3817
4088
|
)
|
|
3818
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
4089
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
3819
4090
|
`
|
|
3820
4091
|
)
|
|
3821
|
-
.run(id, normalizedDatabaseKey, title, filename, content, now, now);
|
|
4092
|
+
.run(id, normalizedDatabaseKey, folderId, title, filename, content, now, now);
|
|
3822
4093
|
|
|
3823
4094
|
return this.getDatabaseDocument(normalizedDatabaseKey, id);
|
|
3824
4095
|
}
|
|
@@ -3829,6 +4100,15 @@ class AppStateStore {
|
|
|
3829
4100
|
const hasFilename = Object.prototype.hasOwnProperty.call(patch, "filename");
|
|
3830
4101
|
const hasTitle = Object.prototype.hasOwnProperty.call(patch, "title");
|
|
3831
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
|
+
|
|
3832
4112
|
const filename = hasFilename
|
|
3833
4113
|
? this.resolveUniqueDocumentFilename(
|
|
3834
4114
|
normalizedDatabaseKey,
|
|
@@ -3851,6 +4131,7 @@ class AppStateStore {
|
|
|
3851
4131
|
`
|
|
3852
4132
|
UPDATE database_documents
|
|
3853
4133
|
SET
|
|
4134
|
+
folder_id = ?,
|
|
3854
4135
|
title = ?,
|
|
3855
4136
|
filename = ?,
|
|
3856
4137
|
content = ?,
|
|
@@ -3859,7 +4140,7 @@ class AppStateStore {
|
|
|
3859
4140
|
AND id = ?
|
|
3860
4141
|
`
|
|
3861
4142
|
)
|
|
3862
|
-
.run(title, filename, content, updatedAt, normalizedDatabaseKey, existingDocument.id);
|
|
4143
|
+
.run(folderId, title, filename, content, updatedAt, normalizedDatabaseKey, existingDocument.id);
|
|
3863
4144
|
|
|
3864
4145
|
return this.getDatabaseDocument(normalizedDatabaseKey, existingDocument.id);
|
|
3865
4146
|
}
|
|
@@ -197,10 +197,15 @@ function isWritable(filePath) {
|
|
|
197
197
|
|
|
198
198
|
function getFileMetadata(filePath) {
|
|
199
199
|
const stat = fs.statSync(filePath);
|
|
200
|
+
const createdAt =
|
|
201
|
+
Number.isFinite(stat.birthtimeMs) && stat.birthtimeMs > 0
|
|
202
|
+
? stat.birthtime
|
|
203
|
+
: stat.ctime;
|
|
200
204
|
|
|
201
205
|
return {
|
|
202
206
|
path: filePath,
|
|
203
207
|
sizeBytes: stat.size,
|
|
208
|
+
createdAt: createdAt.toISOString(),
|
|
204
209
|
lastModifiedAt: stat.mtime.toISOString(),
|
|
205
210
|
};
|
|
206
211
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function getActiveConnection(connectionManager) {
|
|
2
|
+
try {
|
|
3
|
+
return connectionManager?.getActiveConnection?.() ?? null;
|
|
4
|
+
} catch {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function recordUserAction({
|
|
10
|
+
appStateStore,
|
|
11
|
+
connectionManager,
|
|
12
|
+
action,
|
|
13
|
+
targetType = "database",
|
|
14
|
+
targetName = null,
|
|
15
|
+
metadata = {},
|
|
16
|
+
databaseKey = null,
|
|
17
|
+
durationMs = null,
|
|
18
|
+
} = {}) {
|
|
19
|
+
if (!appStateStore?.recordAccessLog || !action) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const activeConnection = getActiveConnection(connectionManager);
|
|
24
|
+
const resolvedDatabaseKey = databaseKey ?? activeConnection?.id ?? null;
|
|
25
|
+
const resolvedTargetName =
|
|
26
|
+
targetName ?? activeConnection?.label ?? activeConnection?.path ?? resolvedDatabaseKey ?? targetType;
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
return appStateStore.recordAccessLog({
|
|
30
|
+
source: "user",
|
|
31
|
+
action,
|
|
32
|
+
databaseKey: resolvedDatabaseKey,
|
|
33
|
+
targetType,
|
|
34
|
+
targetName: resolvedTargetName,
|
|
35
|
+
status: "success",
|
|
36
|
+
durationMs,
|
|
37
|
+
metadata: {
|
|
38
|
+
databaseLabel: activeConnection?.label ?? null,
|
|
39
|
+
...metadata,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
recordUserAction,
|
|
49
|
+
};
|