sqlite-hub 0.12.0 → 0.17.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 +118 -23
- package/bin/sqlite-hub.js +1041 -224
- package/frontend/index.html +1 -0
- package/frontend/js/api.js +32 -2
- package/frontend/js/app.js +989 -13
- package/frontend/js/components/modal.js +644 -15
- package/frontend/js/components/pageHeader.js +14 -16
- package/frontend/js/components/queryEditor.js +9 -1
- package/frontend/js/components/queryHistoryPanel.js +126 -131
- package/frontend/js/components/queryResults.js +79 -17
- package/frontend/js/components/rowEditorPanel.js +204 -10
- package/frontend/js/components/sidebar.js +102 -18
- package/frontend/js/components/tableDesignerEditor.js +69 -11
- package/frontend/js/router.js +8 -0
- package/frontend/js/store.js +868 -9
- package/frontend/js/utils/copyColumnExport.js +117 -0
- package/frontend/js/utils/exportFilenames.js +32 -0
- package/frontend/js/utils/filePathPreview.js +315 -0
- package/frontend/js/utils/format.js +1 -1
- package/frontend/js/utils/markdownDocuments.js +248 -0
- package/frontend/js/utils/rowEditorJson.js +65 -0
- package/frontend/js/utils/sqlFormatter.js +691 -0
- package/frontend/js/utils/tableDesigner.js +178 -6
- package/frontend/js/utils/textCellStats.js +20 -0
- package/frontend/js/utils/timestampPreview.js +264 -0
- package/frontend/js/views/charts.js +3 -1
- package/frontend/js/views/data.js +34 -2
- package/frontend/js/views/documents.js +300 -0
- package/frontend/js/views/editor.js +48 -1
- package/frontend/js/views/settings.js +39 -6
- package/frontend/js/views/structure.js +154 -212
- package/frontend/styles/base.css +6 -0
- package/frontend/styles/components.css +476 -2
- package/frontend/styles/structure-graph.css +0 -3
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/tokens.css +1 -1
- package/frontend/styles/views.css +422 -0
- package/package.json +3 -3
- package/server/routes/documents.js +163 -0
- package/server/routes/settings.js +22 -6
- package/server/server.js +6 -0
- package/server/services/sqlite/introspection.js +10 -0
- package/server/services/sqlite/sqlExecutor.js +29 -0
- package/server/services/sqlite/tableDesigner/changeAnalysis.js +25 -1
- package/server/services/sqlite/tableDesigner/schemaMapping.js +105 -10
- package/server/services/sqlite/tableDesigner/validation.js +60 -2
- package/server/services/sqlite/tableDesignerService.js +1 -1
- package/server/services/storage/appStateStore.js +313 -0
- package/tests/check-constraint-options.test.js +14 -0
- package/tests/cli-args.test.js +100 -0
- package/tests/copy-column-modal.test.js +83 -0
- package/tests/database-documents.test.js +85 -0
- package/tests/export-filenames.test.js +34 -0
- package/tests/file-path-preview.test.js +165 -0
- package/tests/markdown-documents.test.js +79 -0
- package/tests/row-editor-json.test.js +82 -0
- package/tests/row-editor-timestamp-preview.test.js +192 -0
- package/tests/settings-metadata.test.js +16 -0
- package/tests/settings-view.test.js +32 -0
- package/tests/sql-formatter.test.js +173 -0
- package/tests/sql-highlight.test.js +38 -0
- package/tests/table-designer-v2-unique-constraints.test.js +78 -0
- package/tests/text-cell-stats.test.js +38 -0
- package/fill.js +0 -526
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
|
+
const crypto = require("node:crypto");
|
|
2
3
|
const path = require("node:path");
|
|
3
4
|
const { pathToFileURL } = require("node:url");
|
|
4
5
|
const Database = require("better-sqlite3");
|
|
@@ -41,11 +42,110 @@ const CONNECTION_LOGO_DIRECTORY = "db_logos";
|
|
|
41
42
|
const LEGACY_STATE_FILENAME = "app-state.json";
|
|
42
43
|
const STATE_DATABASE_FILENAME = "sqlite-hub-state.db";
|
|
43
44
|
const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
|
|
45
|
+
const MAX_DOCUMENT_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
46
|
+
const MAX_DOCUMENT_FILENAME_LENGTH = 160;
|
|
44
47
|
const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
|
|
45
48
|
"image/jpeg": "jpg",
|
|
46
49
|
"image/png": "png",
|
|
47
50
|
"image/webp": "webp",
|
|
48
51
|
};
|
|
52
|
+
|
|
53
|
+
function normalizeDocumentDatabaseKey(databaseKey) {
|
|
54
|
+
const normalizedDatabaseKey = String(databaseKey ?? "").trim();
|
|
55
|
+
|
|
56
|
+
if (!normalizedDatabaseKey) {
|
|
57
|
+
throw new ValidationError("Database key is required.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return normalizedDatabaseKey;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeDocumentId(documentId) {
|
|
64
|
+
const normalizedDocumentId = String(documentId ?? "").trim();
|
|
65
|
+
|
|
66
|
+
if (!normalizedDocumentId) {
|
|
67
|
+
throw new ValidationError("Document id is required.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return normalizedDocumentId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function splitMarkdownFilename(filename) {
|
|
74
|
+
const normalizedFilename = String(filename ?? "");
|
|
75
|
+
const extensionMatch = normalizedFilename.match(/\.md$/i);
|
|
76
|
+
|
|
77
|
+
if (!extensionMatch) {
|
|
78
|
+
return {
|
|
79
|
+
baseName: normalizedFilename,
|
|
80
|
+
extension: ".md",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
baseName: normalizedFilename.slice(0, -extensionMatch[0].length),
|
|
86
|
+
extension: normalizedFilename.slice(-extensionMatch[0].length),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeDocumentFilename(value, fallback = "Untitled.md") {
|
|
91
|
+
let filename = String(value ?? "").trim();
|
|
92
|
+
|
|
93
|
+
if (!filename) {
|
|
94
|
+
filename = fallback;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
filename = filename
|
|
98
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
99
|
+
.replace(/[\\/]+/g, " ")
|
|
100
|
+
.replace(/\s+/g, " ")
|
|
101
|
+
.trim()
|
|
102
|
+
.replace(/^\.+/, "")
|
|
103
|
+
.trim();
|
|
104
|
+
|
|
105
|
+
if (!filename) {
|
|
106
|
+
filename = fallback;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!/\.md$/i.test(filename)) {
|
|
110
|
+
filename = `${filename}.md`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (filename.length > MAX_DOCUMENT_FILENAME_LENGTH) {
|
|
114
|
+
const { baseName, extension } = splitMarkdownFilename(filename);
|
|
115
|
+
filename = `${baseName.slice(0, MAX_DOCUMENT_FILENAME_LENGTH - extension.length)}${extension}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return filename;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildDocumentTitleFromFilename(filename) {
|
|
122
|
+
const { baseName } = splitMarkdownFilename(filename);
|
|
123
|
+
const title = baseName.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
124
|
+
|
|
125
|
+
return title || "Untitled";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeDocumentTitle(value, filename) {
|
|
129
|
+
const title = String(value ?? "").trim();
|
|
130
|
+
|
|
131
|
+
return title || buildDocumentTitleFromFilename(filename);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeDocumentContent(value) {
|
|
135
|
+
const content = String(value ?? "");
|
|
136
|
+
const byteLength = Buffer.byteLength(content, "utf8");
|
|
137
|
+
|
|
138
|
+
if (byteLength > MAX_DOCUMENT_CONTENT_BYTES) {
|
|
139
|
+
throw new ValidationError("Document content is too large.", {
|
|
140
|
+
details: {
|
|
141
|
+
maxBytes: MAX_DOCUMENT_CONTENT_BYTES,
|
|
142
|
+
actualBytes: byteLength,
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return content;
|
|
148
|
+
}
|
|
49
149
|
const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
|
|
50
150
|
".jpeg": "jpg",
|
|
51
151
|
".jpg": "jpg",
|
|
@@ -343,6 +443,20 @@ class AppStateStore {
|
|
|
343
443
|
mapping_table TEXT NOT NULL DEFAULT '',
|
|
344
444
|
updated_at TEXT NOT NULL
|
|
345
445
|
);
|
|
446
|
+
|
|
447
|
+
CREATE TABLE IF NOT EXISTS database_documents (
|
|
448
|
+
id TEXT PRIMARY KEY,
|
|
449
|
+
database_key TEXT NOT NULL,
|
|
450
|
+
title TEXT NOT NULL,
|
|
451
|
+
filename TEXT NOT NULL,
|
|
452
|
+
content TEXT NOT NULL DEFAULT '',
|
|
453
|
+
created_at TEXT NOT NULL,
|
|
454
|
+
updated_at TEXT NOT NULL,
|
|
455
|
+
UNIQUE(database_key, filename)
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
CREATE INDEX IF NOT EXISTS idx_database_documents_database_updated
|
|
459
|
+
ON database_documents(database_key, updated_at DESC, id ASC);
|
|
346
460
|
`);
|
|
347
461
|
|
|
348
462
|
const recentConnectionColumns = new Set(
|
|
@@ -2221,6 +2335,205 @@ class AppStateStore {
|
|
|
2221
2335
|
return this.getSettings();
|
|
2222
2336
|
}
|
|
2223
2337
|
|
|
2338
|
+
decorateDatabaseDocumentRow(row = {}) {
|
|
2339
|
+
return {
|
|
2340
|
+
id: String(row.id ?? ""),
|
|
2341
|
+
databaseKey: row.database_key ?? row.databaseKey ?? "",
|
|
2342
|
+
title: String(row.title ?? ""),
|
|
2343
|
+
filename: String(row.filename ?? ""),
|
|
2344
|
+
content: row.content === undefined ? undefined : String(row.content ?? ""),
|
|
2345
|
+
contentLength: Number(row.content_length ?? row.contentLength ?? 0),
|
|
2346
|
+
createdAt: row.created_at ?? row.createdAt ?? null,
|
|
2347
|
+
updatedAt: row.updated_at ?? row.updatedAt ?? null,
|
|
2348
|
+
};
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
documentFilenameExists(databaseKey, filename, ignoredDocumentId = null) {
|
|
2352
|
+
const row = this.db
|
|
2353
|
+
.prepare(
|
|
2354
|
+
`
|
|
2355
|
+
SELECT id
|
|
2356
|
+
FROM database_documents
|
|
2357
|
+
WHERE database_key = ?
|
|
2358
|
+
AND filename = ?
|
|
2359
|
+
AND (? IS NULL OR id != ?)
|
|
2360
|
+
LIMIT 1
|
|
2361
|
+
`
|
|
2362
|
+
)
|
|
2363
|
+
.get(databaseKey, filename, ignoredDocumentId, ignoredDocumentId);
|
|
2364
|
+
|
|
2365
|
+
return Boolean(row);
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
resolveUniqueDocumentFilename(databaseKey, desiredFilename, ignoredDocumentId = null) {
|
|
2369
|
+
const normalizedFilename = normalizeDocumentFilename(desiredFilename);
|
|
2370
|
+
|
|
2371
|
+
if (!this.documentFilenameExists(databaseKey, normalizedFilename, ignoredDocumentId)) {
|
|
2372
|
+
return normalizedFilename;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const { baseName, extension } = splitMarkdownFilename(normalizedFilename);
|
|
2376
|
+
|
|
2377
|
+
for (let index = 2; index < 1000; index += 1) {
|
|
2378
|
+
const suffix = ` ${index}`;
|
|
2379
|
+
const maxBaseLength = MAX_DOCUMENT_FILENAME_LENGTH - extension.length - suffix.length;
|
|
2380
|
+
const candidate = `${baseName.slice(0, Math.max(1, maxBaseLength))}${suffix}${extension}`;
|
|
2381
|
+
|
|
2382
|
+
if (!this.documentFilenameExists(databaseKey, candidate, ignoredDocumentId)) {
|
|
2383
|
+
return candidate;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
throw new ConflictError("Could not create a unique document filename.");
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
listDatabaseDocuments(databaseKey) {
|
|
2391
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2392
|
+
|
|
2393
|
+
return this.db
|
|
2394
|
+
.prepare(
|
|
2395
|
+
`
|
|
2396
|
+
SELECT
|
|
2397
|
+
id,
|
|
2398
|
+
database_key,
|
|
2399
|
+
title,
|
|
2400
|
+
filename,
|
|
2401
|
+
LENGTH(content) AS content_length,
|
|
2402
|
+
created_at,
|
|
2403
|
+
updated_at
|
|
2404
|
+
FROM database_documents
|
|
2405
|
+
WHERE database_key = ?
|
|
2406
|
+
ORDER BY updated_at DESC, id ASC
|
|
2407
|
+
`
|
|
2408
|
+
)
|
|
2409
|
+
.all(normalizedDatabaseKey)
|
|
2410
|
+
.map((row) => this.decorateDatabaseDocumentRow(row));
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
getDatabaseDocument(databaseKey, documentId) {
|
|
2414
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2415
|
+
const normalizedDocumentId = normalizeDocumentId(documentId);
|
|
2416
|
+
const row = this.db
|
|
2417
|
+
.prepare(
|
|
2418
|
+
`
|
|
2419
|
+
SELECT
|
|
2420
|
+
id,
|
|
2421
|
+
database_key,
|
|
2422
|
+
title,
|
|
2423
|
+
filename,
|
|
2424
|
+
content,
|
|
2425
|
+
LENGTH(content) AS content_length,
|
|
2426
|
+
created_at,
|
|
2427
|
+
updated_at
|
|
2428
|
+
FROM database_documents
|
|
2429
|
+
WHERE database_key = ?
|
|
2430
|
+
AND id = ?
|
|
2431
|
+
`
|
|
2432
|
+
)
|
|
2433
|
+
.get(normalizedDatabaseKey, normalizedDocumentId);
|
|
2434
|
+
|
|
2435
|
+
if (!row) {
|
|
2436
|
+
throw new NotFoundError("Document was not found.");
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
return this.decorateDatabaseDocumentRow(row);
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
createDatabaseDocument(databaseKey, document = {}) {
|
|
2443
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2444
|
+
const filename = this.resolveUniqueDocumentFilename(
|
|
2445
|
+
normalizedDatabaseKey,
|
|
2446
|
+
normalizeDocumentFilename(document.filename)
|
|
2447
|
+
);
|
|
2448
|
+
const title = normalizeDocumentTitle(document.title, filename);
|
|
2449
|
+
const content = normalizeDocumentContent(document.content);
|
|
2450
|
+
const now = new Date().toISOString();
|
|
2451
|
+
const id = crypto.randomUUID();
|
|
2452
|
+
|
|
2453
|
+
this.db
|
|
2454
|
+
.prepare(
|
|
2455
|
+
`
|
|
2456
|
+
INSERT INTO database_documents (
|
|
2457
|
+
id,
|
|
2458
|
+
database_key,
|
|
2459
|
+
title,
|
|
2460
|
+
filename,
|
|
2461
|
+
content,
|
|
2462
|
+
created_at,
|
|
2463
|
+
updated_at
|
|
2464
|
+
)
|
|
2465
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2466
|
+
`
|
|
2467
|
+
)
|
|
2468
|
+
.run(id, normalizedDatabaseKey, title, filename, content, now, now);
|
|
2469
|
+
|
|
2470
|
+
return this.getDatabaseDocument(normalizedDatabaseKey, id);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
updateDatabaseDocument(databaseKey, documentId, patch = {}) {
|
|
2474
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2475
|
+
const existingDocument = this.getDatabaseDocument(normalizedDatabaseKey, documentId);
|
|
2476
|
+
const hasFilename = Object.prototype.hasOwnProperty.call(patch, "filename");
|
|
2477
|
+
const hasTitle = Object.prototype.hasOwnProperty.call(patch, "title");
|
|
2478
|
+
const hasContent = Object.prototype.hasOwnProperty.call(patch, "content");
|
|
2479
|
+
const filename = hasFilename
|
|
2480
|
+
? this.resolveUniqueDocumentFilename(
|
|
2481
|
+
normalizedDatabaseKey,
|
|
2482
|
+
normalizeDocumentFilename(patch.filename, existingDocument.filename),
|
|
2483
|
+
existingDocument.id
|
|
2484
|
+
)
|
|
2485
|
+
: existingDocument.filename;
|
|
2486
|
+
const title = hasTitle
|
|
2487
|
+
? normalizeDocumentTitle(patch.title, filename)
|
|
2488
|
+
: hasFilename
|
|
2489
|
+
? buildDocumentTitleFromFilename(filename)
|
|
2490
|
+
: existingDocument.title;
|
|
2491
|
+
const content = hasContent
|
|
2492
|
+
? normalizeDocumentContent(patch.content)
|
|
2493
|
+
: existingDocument.content;
|
|
2494
|
+
const updatedAt = new Date().toISOString();
|
|
2495
|
+
|
|
2496
|
+
this.db
|
|
2497
|
+
.prepare(
|
|
2498
|
+
`
|
|
2499
|
+
UPDATE database_documents
|
|
2500
|
+
SET
|
|
2501
|
+
title = ?,
|
|
2502
|
+
filename = ?,
|
|
2503
|
+
content = ?,
|
|
2504
|
+
updated_at = ?
|
|
2505
|
+
WHERE database_key = ?
|
|
2506
|
+
AND id = ?
|
|
2507
|
+
`
|
|
2508
|
+
)
|
|
2509
|
+
.run(title, filename, content, updatedAt, normalizedDatabaseKey, existingDocument.id);
|
|
2510
|
+
|
|
2511
|
+
return this.getDatabaseDocument(normalizedDatabaseKey, existingDocument.id);
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
deleteDatabaseDocument(databaseKey, documentId) {
|
|
2515
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2516
|
+
const normalizedDocumentId = normalizeDocumentId(documentId);
|
|
2517
|
+
const result = this.db
|
|
2518
|
+
.prepare(
|
|
2519
|
+
`
|
|
2520
|
+
DELETE FROM database_documents
|
|
2521
|
+
WHERE database_key = ?
|
|
2522
|
+
AND id = ?
|
|
2523
|
+
`
|
|
2524
|
+
)
|
|
2525
|
+
.run(normalizedDatabaseKey, normalizedDocumentId);
|
|
2526
|
+
|
|
2527
|
+
if (result.changes < 1) {
|
|
2528
|
+
throw new NotFoundError("Document was not found.");
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
return {
|
|
2532
|
+
id: normalizedDocumentId,
|
|
2533
|
+
deleted: true,
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2224
2537
|
getMediaTaggingConfig(databaseKey) {
|
|
2225
2538
|
const normalizedDatabaseKey = String(databaseKey ?? "").trim();
|
|
2226
2539
|
|
|
@@ -3,6 +3,7 @@ const assert = require("node:assert/strict");
|
|
|
3
3
|
const test = require("node:test");
|
|
4
4
|
const { getTableDetail } = require("../server/services/sqlite/introspection");
|
|
5
5
|
const { SqlExecutor } = require("../server/services/sqlite/sqlExecutor");
|
|
6
|
+
const { buildTableDesignerDraft } = require("../server/services/sqlite/tableDesigner/schemaMapping");
|
|
6
7
|
|
|
7
8
|
test("table detail exposes string options from simple CHECK IN constraints", () => {
|
|
8
9
|
const db = new Database(":memory:");
|
|
@@ -34,6 +35,7 @@ test("table detail exposes string options from simple CHECK IN constraints", ()
|
|
|
34
35
|
`);
|
|
35
36
|
|
|
36
37
|
const tableDetail = getTableDetail(db, "stream_company_mentions");
|
|
38
|
+
const designerDraft = buildTableDesignerDraft(tableDetail);
|
|
37
39
|
const mentionTypeColumn = tableDetail.columns.find(
|
|
38
40
|
(column) => column.name === "mention_type"
|
|
39
41
|
);
|
|
@@ -70,6 +72,18 @@ test("table detail exposes string options from simple CHECK IN constraints", ()
|
|
|
70
72
|
);
|
|
71
73
|
|
|
72
74
|
assert.deepEqual(editableColumn.allowedValues, mentionTypeColumn.allowedValues);
|
|
75
|
+
|
|
76
|
+
assert.equal(designerDraft.designerVersion, 2);
|
|
77
|
+
assert.equal(designerDraft.checkConstraints.length, 1);
|
|
78
|
+
assert.match(designerDraft.checkConstraints[0].expression, /CHECK/i);
|
|
79
|
+
assert.equal(designerDraft.checkConstraints[0].originalExpression, designerDraft.checkConstraints[0].expression);
|
|
80
|
+
assert.equal(designerDraft.checkConstraints[0].editable, true);
|
|
81
|
+
assert.deepEqual(designerDraft.checkConstraints[0].columns, [
|
|
82
|
+
{
|
|
83
|
+
name: "mention_type",
|
|
84
|
+
allowedValues: mentionTypeColumn.allowedValues,
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
73
87
|
} finally {
|
|
74
88
|
db.close();
|
|
75
89
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
normalizeExportFormat,
|
|
6
|
+
parseCliArguments,
|
|
7
|
+
} = require("../bin/sqlite-hub");
|
|
8
|
+
|
|
9
|
+
test("parses database execute command", () => {
|
|
10
|
+
const options = parseCliArguments([
|
|
11
|
+
"--database:trump live interviews",
|
|
12
|
+
"--execute:Stock Winners",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
assert.equal(options.databaseName, "trump live interviews");
|
|
16
|
+
assert.equal(options.executeQuery, "Stock Winners");
|
|
17
|
+
assert.equal(options.queries, false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("parses database table list command", () => {
|
|
21
|
+
const options = parseCliArguments(["--database:trump live interviews", "--tables"]);
|
|
22
|
+
|
|
23
|
+
assert.equal(options.databaseName, "trump live interviews");
|
|
24
|
+
assert.equal(options.tables, true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("parses database info commands with the new schema", () => {
|
|
28
|
+
assert.equal(parseCliArguments(["--database:db", "--path"]).pathInfo, true);
|
|
29
|
+
assert.equal(parseCliArguments(["--database:db", "---path"]).pathInfo, true);
|
|
30
|
+
assert.equal(parseCliArguments(["--database:db", "--size"]).sizeInfo, true);
|
|
31
|
+
assert.equal(parseCliArguments(["--database:db", "--lastopened"]).lastOpenedInfo, true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("keeps old sqleditor aliases working", () => {
|
|
35
|
+
const listOptions = parseCliArguments(["--database:Unit-00", "--sqleditor"]);
|
|
36
|
+
const executeOptions = parseCliArguments(["--database:Unit-00", "--sqleditor:Saved Query"]);
|
|
37
|
+
|
|
38
|
+
assert.equal(listOptions.queries, true);
|
|
39
|
+
assert.equal(executeOptions.executeQuery, "Saved Query");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("keeps old database detail aliases working", () => {
|
|
43
|
+
const pathOptions = parseCliArguments(["--database-path:Billly", "--queries"]);
|
|
44
|
+
const tableOptions = parseCliArguments(["--database-tables:Billly"]);
|
|
45
|
+
|
|
46
|
+
assert.equal(pathOptions.databaseName, "Billly");
|
|
47
|
+
assert.equal(pathOptions.pathInfo, true);
|
|
48
|
+
assert.equal(pathOptions.queries, true);
|
|
49
|
+
assert.equal(tableOptions.databaseName, "Billly");
|
|
50
|
+
assert.equal(tableOptions.tables, true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("parses query display and export commands", () => {
|
|
54
|
+
const showOptions = parseCliArguments(["--database:db", "--query:Stock Winners"]);
|
|
55
|
+
const notesOptions = parseCliArguments(["--database:db", "--notes:Stock Winners"]);
|
|
56
|
+
const exportOptions = parseCliArguments([
|
|
57
|
+
"--database:db",
|
|
58
|
+
"--export:Stock Winners",
|
|
59
|
+
"--format:md",
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
assert.equal(showOptions.showQuery, "Stock Winners");
|
|
63
|
+
assert.equal(notesOptions.showNotes, "Stock Winners");
|
|
64
|
+
assert.equal(exportOptions.exportTarget, "Stock Winners");
|
|
65
|
+
assert.equal(exportOptions.exportFormat, "md");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("parses row json export command", () => {
|
|
69
|
+
const options = parseCliArguments([
|
|
70
|
+
"--database:db",
|
|
71
|
+
"--table:companies",
|
|
72
|
+
"--export:0a754aba373d34972998792a0be4333c",
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
assert.equal(options.tableName, "companies");
|
|
76
|
+
assert.equal(options.exportTarget, "0a754aba373d34972998792a0be4333c");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("parses document commands", () => {
|
|
80
|
+
const listOptions = parseCliArguments(["--database:db", "--documents"]);
|
|
81
|
+
const showOptions = parseCliArguments(["--database:db", "--documents:Research Note"]);
|
|
82
|
+
const exportOptions = parseCliArguments(["--database:db", "--documents:Research Note", "--export"]);
|
|
83
|
+
const compactExportOptions = parseCliArguments(["--database:db", "--documents:Research Note--export"]);
|
|
84
|
+
|
|
85
|
+
assert.equal(listOptions.documents, true);
|
|
86
|
+
assert.equal(listOptions.documentName, null);
|
|
87
|
+
assert.equal(showOptions.documents, true);
|
|
88
|
+
assert.equal(showOptions.documentName, "Research Note");
|
|
89
|
+
assert.equal(showOptions.documentExport, false);
|
|
90
|
+
assert.equal(exportOptions.documentName, "Research Note");
|
|
91
|
+
assert.equal(exportOptions.documentExport, true);
|
|
92
|
+
assert.equal(compactExportOptions.documentName, "Research Note");
|
|
93
|
+
assert.equal(compactExportOptions.documentExport, true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("validates export formats", () => {
|
|
97
|
+
assert.equal(normalizeExportFormat("csv"), "csv");
|
|
98
|
+
assert.equal(normalizeExportFormat("TSV"), "tsv");
|
|
99
|
+
assert.throws(() => normalizeExportFormat("json"), /Unsupported export format/);
|
|
100
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const { readFileSync } = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { pathToFileURL } = require("node:url");
|
|
5
|
+
const test = require("node:test");
|
|
6
|
+
|
|
7
|
+
let modalModulePromise = null;
|
|
8
|
+
|
|
9
|
+
function loadModalModule() {
|
|
10
|
+
if (!modalModulePromise) {
|
|
11
|
+
modalModulePromise = import(
|
|
12
|
+
pathToFileURL(path.resolve(__dirname, "../frontend/js/components/modal.js")).href
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return modalModulePromise;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildState(copyMode) {
|
|
20
|
+
return {
|
|
21
|
+
modal: {
|
|
22
|
+
kind: "copy-column",
|
|
23
|
+
scope: "editor",
|
|
24
|
+
columnName: "task",
|
|
25
|
+
copyMode,
|
|
26
|
+
separator: ",",
|
|
27
|
+
wrapper: '"',
|
|
28
|
+
lineBreaks: false,
|
|
29
|
+
error: null,
|
|
30
|
+
submitting: false,
|
|
31
|
+
},
|
|
32
|
+
editor: {
|
|
33
|
+
result: {
|
|
34
|
+
columns: ["task"],
|
|
35
|
+
rows: [{ task: "item1" }, { task: "item2" }],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
charts: {
|
|
39
|
+
result: null,
|
|
40
|
+
},
|
|
41
|
+
connections: {
|
|
42
|
+
recent: [],
|
|
43
|
+
active: null,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("markdown todo column export renders an editable preview textarea", async () => {
|
|
49
|
+
const { renderCopyColumnModal } = await loadModalModule();
|
|
50
|
+
const state = buildState("markdown-todo");
|
|
51
|
+
const html = renderCopyColumnModal(state.modal, state);
|
|
52
|
+
|
|
53
|
+
assert.match(html, /Editable Preview/);
|
|
54
|
+
assert.match(html, /<textarea[^>]+name="editedText"/);
|
|
55
|
+
assert.match(html, /- \[ \] item1/);
|
|
56
|
+
assert.match(html, /- \[ \] item2/);
|
|
57
|
+
assert.match(html, /Export to document folder/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("regular copy column preview stays read-only", async () => {
|
|
61
|
+
const { renderCopyColumnModal } = await loadModalModule();
|
|
62
|
+
const state = buildState("column");
|
|
63
|
+
const html = renderCopyColumnModal(state.modal, state);
|
|
64
|
+
|
|
65
|
+
assert.match(html, /<pre class="copy-column-preview custom-scrollbar">/);
|
|
66
|
+
assert.doesNotMatch(html, /name="editedText"/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("modal footer close buttons are not right-aligned", () => {
|
|
70
|
+
const source = readFileSync(
|
|
71
|
+
path.resolve(__dirname, "../frontend/js/components/modal.js"),
|
|
72
|
+
"utf8"
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
assert.doesNotMatch(
|
|
76
|
+
source,
|
|
77
|
+
/<div class="[^"]*justify-end[^"]*pt-2[^"]*"[\s\S]{0,500}?data-action="close-modal"/
|
|
78
|
+
);
|
|
79
|
+
assert.doesNotMatch(
|
|
80
|
+
source,
|
|
81
|
+
/'<div class="[^']*justify-end[^']*pt-2[^']*>'[\s\S]{0,500}?data-action="close-modal"/
|
|
82
|
+
);
|
|
83
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const test = require("node:test");
|
|
6
|
+
|
|
7
|
+
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
8
|
+
const { ensureDatabaseDocuments } = require("../server/routes/documents");
|
|
9
|
+
|
|
10
|
+
function createStore(t) {
|
|
11
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-documents-"));
|
|
12
|
+
const store = new AppStateStore(path.join(directory, "state.db"));
|
|
13
|
+
|
|
14
|
+
t.after(() => {
|
|
15
|
+
store.db.close();
|
|
16
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return store;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("database documents are scoped to one database and normalize filenames", (t) => {
|
|
23
|
+
const store = createStore(t);
|
|
24
|
+
const first = store.createDatabaseDocument("db-one", {
|
|
25
|
+
filename: "../Daily Notes",
|
|
26
|
+
content: "# Tasks\n\n- [ ] item1",
|
|
27
|
+
});
|
|
28
|
+
const second = store.createDatabaseDocument("db-one", {
|
|
29
|
+
filename: "Daily Notes.md",
|
|
30
|
+
content: "second",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.equal(first.filename, "Daily Notes.md");
|
|
34
|
+
assert.equal(second.filename, "Daily Notes 2.md");
|
|
35
|
+
assert.equal(store.listDatabaseDocuments("db-one").length, 2);
|
|
36
|
+
assert.equal(store.listDatabaseDocuments("db-two").length, 0);
|
|
37
|
+
assert.throws(() => store.getDatabaseDocument("db-two", first.id), /Document was not found/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("database document updates preserve raw markdown content", (t) => {
|
|
41
|
+
const store = createStore(t);
|
|
42
|
+
const document = store.createDatabaseDocument("db-one", {
|
|
43
|
+
filename: "todo",
|
|
44
|
+
content: "- [ ] item1",
|
|
45
|
+
});
|
|
46
|
+
const updated = store.updateDatabaseDocument("db-one", document.id, {
|
|
47
|
+
filename: "todo-renamed",
|
|
48
|
+
content: "- [x] item1\n1717682400",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assert.equal(updated.filename, "todo-renamed.md");
|
|
52
|
+
assert.equal(updated.content, "- [x] item1\n1717682400");
|
|
53
|
+
|
|
54
|
+
const deleted = store.deleteDatabaseDocument("db-one", document.id);
|
|
55
|
+
assert.deepEqual(deleted, { id: document.id, deleted: true });
|
|
56
|
+
assert.equal(store.listDatabaseDocuments("db-one").length, 0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("document folder creates one initial document for an empty database namespace", (t) => {
|
|
60
|
+
const store = createStore(t);
|
|
61
|
+
const connectionManager = {
|
|
62
|
+
getActiveConnection: () => ({
|
|
63
|
+
id: "db-one",
|
|
64
|
+
label: "trump.sqlite",
|
|
65
|
+
path: "/tmp/trump.sqlite",
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const firstList = ensureDatabaseDocuments({
|
|
70
|
+
appStateStore: store,
|
|
71
|
+
connectionManager,
|
|
72
|
+
databaseKey: "db-one",
|
|
73
|
+
});
|
|
74
|
+
const secondList = ensureDatabaseDocuments({
|
|
75
|
+
appStateStore: store,
|
|
76
|
+
connectionManager,
|
|
77
|
+
databaseKey: "db-one",
|
|
78
|
+
});
|
|
79
|
+
const document = store.getDatabaseDocument("db-one", firstList[0].id);
|
|
80
|
+
|
|
81
|
+
assert.equal(firstList.length, 1);
|
|
82
|
+
assert.equal(secondList.length, 1);
|
|
83
|
+
assert.equal(document.filename, "trump.sqlite.md");
|
|
84
|
+
assert.equal(document.content, "# trump.sqlite\n");
|
|
85
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const { readFileSync } = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const test = require("node:test");
|
|
5
|
+
|
|
6
|
+
let exportFilenamesModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadExportFilenamesModule() {
|
|
9
|
+
if (!exportFilenamesModulePromise) {
|
|
10
|
+
const source = readFileSync(
|
|
11
|
+
path.resolve(__dirname, "../frontend/js/utils/exportFilenames.js"),
|
|
12
|
+
"utf8"
|
|
13
|
+
);
|
|
14
|
+
const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
|
|
15
|
+
|
|
16
|
+
exportFilenamesModulePromise = import(url);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return exportFilenamesModulePromise;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("text export filenames use the selected format extension", async () => {
|
|
23
|
+
const { buildTextExportFilename } = await loadExportFilenamesModule();
|
|
24
|
+
|
|
25
|
+
assert.equal(buildTextExportFilename("query-results.csv", { format: "tsv" }), "query-results.tsv");
|
|
26
|
+
assert.equal(buildTextExportFilename("white_house_live_streams", { format: "md" }), "white_house_live_streams.md");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("text export filenames sanitize unsafe names and fallback when empty", async () => {
|
|
30
|
+
const { buildTextExportFilename } = await loadExportFilenamesModule();
|
|
31
|
+
|
|
32
|
+
assert.equal(buildTextExportFilename("", { format: "csv", fallback: "table" }), "table.csv");
|
|
33
|
+
assert.equal(buildTextExportFilename("../bad:name?.csv", { format: "csv", fallback: "table" }), "bad name.csv");
|
|
34
|
+
});
|