sqlite-hub 0.16.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.
@@ -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
 
@@ -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,79 @@
1
+ const assert = require("node:assert/strict");
2
+ const path = require("node:path");
3
+ const { pathToFileURL } = require("node:url");
4
+ const test = require("node:test");
5
+
6
+ let markdownModulePromise = null;
7
+
8
+ function loadMarkdownModule() {
9
+ if (!markdownModulePromise) {
10
+ markdownModulePromise = import(
11
+ pathToFileURL(path.resolve(__dirname, "../frontend/js/utils/markdownDocuments.js")).href
12
+ );
13
+ }
14
+
15
+ return markdownModulePromise;
16
+ }
17
+
18
+ test("markdown preview renders clickable todo checkboxes with source line indexes", async () => {
19
+ const { renderMarkdownPreview } = await loadMarkdownModule();
20
+ const html = renderMarkdownPreview(["# Tasks", "- [ ] item1", "- [x] item2", "```", "- [ ] code", "```"].join("\n"));
21
+
22
+ assert.match(html, /document-markdown-task-list/);
23
+ assert.match(html, /data-action="toggle-document-todo"/);
24
+ assert.match(html, /data-line-index="1"/);
25
+ assert.match(html, /data-line-index="2"/);
26
+ assert.doesNotMatch(html, /data-line-index="4"/);
27
+ });
28
+
29
+ test("markdown todo toggle updates only real task lines", async () => {
30
+ const { toggleMarkdownTodoLine } = await loadMarkdownModule();
31
+ const markdown = ["- [ ] item1", "```", "- [ ] code", "```", "- [x] item2"].join("\n");
32
+
33
+ assert.equal(toggleMarkdownTodoLine(markdown, 0).split("\n")[0], "- [x] item1");
34
+ assert.equal(toggleMarkdownTodoLine(markdown, 2), markdown);
35
+ assert.equal(toggleMarkdownTodoLine(markdown, 4).split("\n")[4], "- [ ] item2");
36
+ });
37
+
38
+ test("markdown preview fallback renders unordered and ordered lists", async () => {
39
+ const { renderMarkdownPreview } = await loadMarkdownModule();
40
+ const previousMarked = globalThis.marked;
41
+
42
+ globalThis.marked = undefined;
43
+
44
+ try {
45
+ const html = renderMarkdownPreview(["- alpha", "- beta", "", "1. first", "2. second"].join("\n"));
46
+
47
+ assert.match(html, /<ul><li>alpha<\/li><li>beta<\/li><\/ul>/);
48
+ assert.match(html, /<ol><li>first<\/li><li>second<\/li><\/ol>/);
49
+ } finally {
50
+ globalThis.marked = previousMarked;
51
+ }
52
+ });
53
+
54
+ test("markdown preview escapes raw html content", async () => {
55
+ const { renderMarkdownPreview } = await loadMarkdownModule();
56
+ const html = renderMarkdownPreview('<img src=x onerror="alert(1)">\n- [ ] <script>alert(1)</script>');
57
+
58
+ assert.doesNotMatch(html, /<script>/i);
59
+ assert.doesNotMatch(html, /<img/i);
60
+ assert.match(html, /&lt;script&gt;/);
61
+ });
62
+
63
+ test("markdown preview strips executable link targets from rendered markdown", async () => {
64
+ const { renderMarkdownPreview } = await loadMarkdownModule();
65
+ const previousMarked = globalThis.marked;
66
+
67
+ globalThis.marked = {
68
+ parse: () => '<p><a href="javascript:alert(1)">unsafe</a></p>',
69
+ };
70
+
71
+ try {
72
+ const html = renderMarkdownPreview("[unsafe](javascript:alert(1))");
73
+
74
+ assert.doesNotMatch(html, /href="javascript:/i);
75
+ assert.match(html, /href="#"/);
76
+ } finally {
77
+ globalThis.marked = previousMarked;
78
+ }
79
+ });