@drewpayment/mink 0.1.0 → 0.2.1
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 +191 -8
- package/dist/cli.js +87605 -0
- package/package.json +7 -3
- package/skills/mink-note/SKILL.md +131 -0
- package/src/cli.ts +46 -0
- package/src/commands/dashboard.ts +1 -1
- package/src/commands/init.ts +77 -4
- package/src/commands/note.ts +267 -0
- package/src/commands/session-start.ts +26 -0
- package/src/commands/session-stop.ts +148 -2
- package/src/commands/skill.ts +186 -0
- package/src/commands/wiki.ts +250 -0
- package/src/core/daemon.ts +2 -1
- package/src/core/dashboard-server.ts +47 -48
- package/src/core/note-index.ts +262 -0
- package/src/core/note-linker.ts +161 -0
- package/src/core/note-writer.ts +203 -0
- package/src/core/runtime.ts +214 -0
- package/src/core/vault-templates.ts +179 -0
- package/src/core/vault.ts +132 -0
- package/src/types/config.ts +7 -0
- package/src/types/note.ts +60 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { watch, type FSWatcher } from "fs";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
|
-
import { basename, join, extname } from "path";
|
|
3
|
+
import { basename, dirname, join, extname } from "path";
|
|
4
4
|
import { projectDir, designCapturesDir } from "./paths";
|
|
5
5
|
import {
|
|
6
6
|
loadOverview,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "./dashboard-api";
|
|
18
18
|
import { listRegisteredProjects, getProjectMeta } from "./project-registry";
|
|
19
19
|
import { generateProjectId } from "./project-id";
|
|
20
|
+
import { runtimeFile, runtimeServe, runtimeSpawn } from "./runtime";
|
|
20
21
|
import type { StateFileId, StateChangeEvent } from "../types/dashboard";
|
|
21
22
|
import type { RegisteredProject } from "./project-registry";
|
|
22
23
|
|
|
@@ -262,10 +263,10 @@ export interface DashboardServer {
|
|
|
262
263
|
close(): void;
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
export function startDashboardServer(
|
|
266
|
+
export async function startDashboardServer(
|
|
266
267
|
cwd: string,
|
|
267
268
|
options: { port?: number; hostname?: string; open?: boolean } = {}
|
|
268
|
-
): DashboardServer {
|
|
269
|
+
): Promise<DashboardServer> {
|
|
269
270
|
const port = options.port ?? 4040;
|
|
270
271
|
const hostname = options.hostname ?? "127.0.0.1";
|
|
271
272
|
|
|
@@ -295,13 +296,15 @@ export function startDashboardServer(
|
|
|
295
296
|
});
|
|
296
297
|
|
|
297
298
|
// Resolve the Next.js static build directory
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
"
|
|
304
|
-
|
|
299
|
+
// Walk up from import.meta.url to find the package root (where package.json lives).
|
|
300
|
+
// From source: src/core/ → ../../ From compiled bundle: dist/ → ../
|
|
301
|
+
const __dir = dirname(new URL(import.meta.url).pathname);
|
|
302
|
+
let pkgRoot = __dir;
|
|
303
|
+
while (pkgRoot !== dirname(pkgRoot)) {
|
|
304
|
+
if (existsSync(join(pkgRoot, "package.json"))) break;
|
|
305
|
+
pkgRoot = dirname(pkgRoot);
|
|
306
|
+
}
|
|
307
|
+
const dashboardOutDir = join(pkgRoot, "dashboard", "out");
|
|
305
308
|
const dashboardBuilt = existsSync(join(dashboardOutDir, "index.html"));
|
|
306
309
|
let clientIdCounter = 0;
|
|
307
310
|
|
|
@@ -311,7 +314,20 @@ export function startDashboardServer(
|
|
|
311
314
|
);
|
|
312
315
|
}
|
|
313
316
|
|
|
314
|
-
|
|
317
|
+
async function serveFile(
|
|
318
|
+
filePath: string,
|
|
319
|
+
contentType: string
|
|
320
|
+
): Promise<Response | null> {
|
|
321
|
+
const file = runtimeFile(filePath);
|
|
322
|
+
if (await file.exists()) {
|
|
323
|
+
return new Response(await file.bytes() as unknown as BodyInit, {
|
|
324
|
+
headers: { "Content-Type": contentType },
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const server = await runtimeServe({
|
|
315
331
|
port,
|
|
316
332
|
hostname,
|
|
317
333
|
idleTimeout: 0, // Disable idle timeout — SSE connections are long-lived
|
|
@@ -343,30 +359,24 @@ export function startDashboardServer(
|
|
|
343
359
|
return jsonResponse({ error: "Forbidden" }, 403);
|
|
344
360
|
}
|
|
345
361
|
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return new Response(file, {
|
|
351
|
-
headers: { "Content-Type": contentType },
|
|
352
|
-
});
|
|
353
|
-
}
|
|
362
|
+
const ext = extname(filePath);
|
|
363
|
+
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
364
|
+
const served = await serveFile(filePath, contentType);
|
|
365
|
+
if (served) return served;
|
|
354
366
|
|
|
355
367
|
// Client-side routing fallback: try {pathname}.html then index.html
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
368
|
+
const htmlServed = await serveFile(
|
|
369
|
+
filePath + ".html",
|
|
370
|
+
"text/html; charset=utf-8"
|
|
371
|
+
);
|
|
372
|
+
if (htmlServed) return htmlServed;
|
|
362
373
|
|
|
363
374
|
// SPA fallback — serve index.html for unmatched routes
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
}
|
|
375
|
+
const indexServed = await serveFile(
|
|
376
|
+
join(dashboardOutDir, "index.html"),
|
|
377
|
+
"text/html; charset=utf-8"
|
|
378
|
+
);
|
|
379
|
+
if (indexServed) return indexServed;
|
|
370
380
|
}
|
|
371
381
|
}
|
|
372
382
|
|
|
@@ -376,8 +386,6 @@ export function startDashboardServer(
|
|
|
376
386
|
const stream = new ReadableStream<Uint8Array>({
|
|
377
387
|
start(controller) {
|
|
378
388
|
sseManager.addClient(clientId, controller);
|
|
379
|
-
// Send initial comment immediately to establish the stream
|
|
380
|
-
// and prevent Bun from treating it as idle/complete
|
|
381
389
|
controller.enqueue(encoder.encode(": connected\nretry: 3000\n\n"));
|
|
382
390
|
},
|
|
383
391
|
cancel() {
|
|
@@ -435,15 +443,11 @@ export function startDashboardServer(
|
|
|
435
443
|
return jsonResponse({ error: "Invalid filename" }, 400);
|
|
436
444
|
}
|
|
437
445
|
const imgPath = join(designCapturesDir(resolvedCwd), filename);
|
|
438
|
-
const
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
"Cache-Control": "public, max-age=60",
|
|
444
|
-
"Access-Control-Allow-Origin": "*",
|
|
445
|
-
},
|
|
446
|
-
});
|
|
446
|
+
const served = await serveFile(imgPath, "image/jpeg");
|
|
447
|
+
if (served) {
|
|
448
|
+
served.headers.set("Cache-Control", "public, max-age=60");
|
|
449
|
+
served.headers.set("Access-Control-Allow-Origin", "*");
|
|
450
|
+
return served;
|
|
447
451
|
}
|
|
448
452
|
return jsonResponse({ error: "Image not found" }, 404);
|
|
449
453
|
}
|
|
@@ -558,12 +562,7 @@ export function startDashboardServer(
|
|
|
558
562
|
: platform === "win32"
|
|
559
563
|
? ["cmd", "/c", "start", serverUrl]
|
|
560
564
|
: ["xdg-open", serverUrl];
|
|
561
|
-
|
|
562
|
-
stdout: "ignore",
|
|
563
|
-
stderr: "ignore",
|
|
564
|
-
stdin: "ignore",
|
|
565
|
-
});
|
|
566
|
-
proc.unref();
|
|
565
|
+
runtimeSpawn(cmd).unref();
|
|
567
566
|
} catch {
|
|
568
567
|
// Browser open is best-effort
|
|
569
568
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
3
|
+
import { atomicWriteJson, safeReadJson } from "./fs-utils";
|
|
4
|
+
import { vaultIndexPath, resolveVaultPath } from "./vault";
|
|
5
|
+
import type { VaultIndex, VaultIndexEntry, NoteCategory } from "../types/note";
|
|
6
|
+
|
|
7
|
+
export function createEmptyVaultIndex(): VaultIndex {
|
|
8
|
+
return {
|
|
9
|
+
lastScanTimestamp: "",
|
|
10
|
+
totalNotes: 0,
|
|
11
|
+
entries: {},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadVaultIndex(): VaultIndex {
|
|
16
|
+
const raw = safeReadJson(vaultIndexPath());
|
|
17
|
+
if (raw === null || typeof raw !== "object") return createEmptyVaultIndex();
|
|
18
|
+
const obj = raw as Record<string, unknown>;
|
|
19
|
+
if (typeof obj.entries !== "object" || obj.entries === null) {
|
|
20
|
+
return createEmptyVaultIndex();
|
|
21
|
+
}
|
|
22
|
+
return raw as VaultIndex;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function saveVaultIndex(index: VaultIndex): void {
|
|
26
|
+
atomicWriteJson(vaultIndexPath(), index);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function updateVaultEntry(
|
|
30
|
+
index: VaultIndex,
|
|
31
|
+
entry: VaultIndexEntry
|
|
32
|
+
): void {
|
|
33
|
+
index.entries[entry.filePath] = entry;
|
|
34
|
+
index.totalNotes = Object.keys(index.entries).length;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function removeVaultEntry(
|
|
38
|
+
index: VaultIndex,
|
|
39
|
+
filePath: string
|
|
40
|
+
): void {
|
|
41
|
+
delete index.entries[filePath];
|
|
42
|
+
index.totalNotes = Object.keys(index.entries).length;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function extractNoteTitle(content: string): string {
|
|
46
|
+
// Try first heading
|
|
47
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
48
|
+
if (match) return match[1].trim();
|
|
49
|
+
// Try frontmatter title
|
|
50
|
+
const fmMatch = content.match(/^title:\s*["']?(.+?)["']?\s*$/m);
|
|
51
|
+
if (fmMatch) return fmMatch[1].trim();
|
|
52
|
+
// First non-empty line after frontmatter
|
|
53
|
+
const lines = content.split("\n");
|
|
54
|
+
let pastFrontmatter = !content.startsWith("---");
|
|
55
|
+
let fmDashCount = 0;
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
if (!pastFrontmatter) {
|
|
58
|
+
if (line.trim() === "---") fmDashCount++;
|
|
59
|
+
if (fmDashCount >= 2) pastFrontmatter = true;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (trimmed && !trimmed.startsWith("#")) return trimmed;
|
|
64
|
+
}
|
|
65
|
+
return "Untitled";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function extractNoteTags(content: string): string[] {
|
|
69
|
+
// Parse tags from frontmatter
|
|
70
|
+
const fmMatch = content.match(/^tags:\s*\[(.+)\]/m);
|
|
71
|
+
if (fmMatch) {
|
|
72
|
+
return fmMatch[1]
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((t) => t.trim().replace(/["']/g, ""))
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
// Try multiline tags
|
|
78
|
+
const lines = content.split("\n");
|
|
79
|
+
const tagsIdx = lines.findIndex((l) => l.startsWith("tags:"));
|
|
80
|
+
if (tagsIdx === -1) return [];
|
|
81
|
+
const tags: string[] = [];
|
|
82
|
+
for (let i = tagsIdx + 1; i < lines.length; i++) {
|
|
83
|
+
const line = lines[i];
|
|
84
|
+
if (line.match(/^\s+-\s+/)) {
|
|
85
|
+
tags.push(line.replace(/^\s+-\s+/, "").trim().replace(/["']/g, ""));
|
|
86
|
+
} else {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return tags;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function extractNoteCategory(content: string): NoteCategory {
|
|
94
|
+
const match = content.match(/^category:\s*(.+)$/m);
|
|
95
|
+
if (match) {
|
|
96
|
+
const cat = match[1].trim().replace(/["']/g, "") as NoteCategory;
|
|
97
|
+
if (
|
|
98
|
+
["inbox", "projects", "areas", "resources", "archives"].includes(cat)
|
|
99
|
+
) {
|
|
100
|
+
return cat;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return "inbox";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function estimateTokens(content: string): number {
|
|
107
|
+
return Math.ceil(content.length / 3.75);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function buildEntryFromContent(
|
|
111
|
+
filePath: string,
|
|
112
|
+
content: string,
|
|
113
|
+
lastModified: string
|
|
114
|
+
): VaultIndexEntry {
|
|
115
|
+
const title = extractNoteTitle(content);
|
|
116
|
+
const tags = extractNoteTags(content);
|
|
117
|
+
const category = extractNoteCategory(content);
|
|
118
|
+
// Description: first non-heading, non-frontmatter line
|
|
119
|
+
let description = "";
|
|
120
|
+
const lines = content.split("\n");
|
|
121
|
+
let pastFrontmatter = !content.startsWith("---");
|
|
122
|
+
let seenFmEnd = false;
|
|
123
|
+
for (const line of lines) {
|
|
124
|
+
if (!pastFrontmatter) {
|
|
125
|
+
if (line === "---" && seenFmEnd) pastFrontmatter = true;
|
|
126
|
+
if (line === "---") seenFmEnd = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const trimmed = line.trim();
|
|
130
|
+
if (trimmed && !trimmed.startsWith("#")) {
|
|
131
|
+
description = trimmed.slice(0, 120);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
filePath,
|
|
138
|
+
title,
|
|
139
|
+
description,
|
|
140
|
+
tags,
|
|
141
|
+
category,
|
|
142
|
+
estimatedTokens: estimateTokens(content),
|
|
143
|
+
lastModified,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function updateVaultIndexForFile(
|
|
148
|
+
filePath: string,
|
|
149
|
+
content: string
|
|
150
|
+
): void {
|
|
151
|
+
const index = loadVaultIndex();
|
|
152
|
+
const root = resolveVaultPath();
|
|
153
|
+
const relativePath = filePath.startsWith(root)
|
|
154
|
+
? filePath.slice(root.length + 1)
|
|
155
|
+
: filePath;
|
|
156
|
+
const entry = buildEntryFromContent(
|
|
157
|
+
relativePath,
|
|
158
|
+
content,
|
|
159
|
+
new Date().toISOString()
|
|
160
|
+
);
|
|
161
|
+
updateVaultEntry(index, entry);
|
|
162
|
+
index.lastScanTimestamp = new Date().toISOString();
|
|
163
|
+
saveVaultIndex(index);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function rebuildVaultIndex(): VaultIndex {
|
|
167
|
+
const root = resolveVaultPath();
|
|
168
|
+
const index = createEmptyVaultIndex();
|
|
169
|
+
const files = collectAllMarkdown(root);
|
|
170
|
+
|
|
171
|
+
for (const file of files) {
|
|
172
|
+
try {
|
|
173
|
+
const content = readFileSync(file.absolutePath, "utf-8");
|
|
174
|
+
const entry = buildEntryFromContent(
|
|
175
|
+
file.relativePath,
|
|
176
|
+
content,
|
|
177
|
+
new Date(file.mtimeMs).toISOString()
|
|
178
|
+
);
|
|
179
|
+
updateVaultEntry(index, entry);
|
|
180
|
+
} catch {
|
|
181
|
+
// Skip unreadable files
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
index.lastScanTimestamp = new Date().toISOString();
|
|
186
|
+
saveVaultIndex(index);
|
|
187
|
+
return index;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function searchVaultIndex(
|
|
191
|
+
term: string
|
|
192
|
+
): VaultIndexEntry[] {
|
|
193
|
+
const index = loadVaultIndex();
|
|
194
|
+
const lower = term.toLowerCase();
|
|
195
|
+
return Object.values(index.entries).filter(
|
|
196
|
+
(e) =>
|
|
197
|
+
e.title.toLowerCase().includes(lower) ||
|
|
198
|
+
e.description.toLowerCase().includes(lower) ||
|
|
199
|
+
e.tags.some((t) => t.toLowerCase().includes(lower)) ||
|
|
200
|
+
e.filePath.toLowerCase().includes(lower)
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function getVaultTags(): string[] {
|
|
205
|
+
const index = loadVaultIndex();
|
|
206
|
+
const tags = new Set<string>();
|
|
207
|
+
for (const entry of Object.values(index.entries)) {
|
|
208
|
+
for (const tag of entry.tags) {
|
|
209
|
+
tags.add(tag);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return [...tags].sort();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getRecentNotes(n: number): VaultIndexEntry[] {
|
|
216
|
+
const index = loadVaultIndex();
|
|
217
|
+
return Object.values(index.entries)
|
|
218
|
+
.sort((a, b) => b.lastModified.localeCompare(a.lastModified))
|
|
219
|
+
.slice(0, n);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface ScannedMarkdown {
|
|
223
|
+
absolutePath: string;
|
|
224
|
+
relativePath: string;
|
|
225
|
+
mtimeMs: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const VAULT_EXCLUDES = new Set([
|
|
229
|
+
".obsidian",
|
|
230
|
+
".git",
|
|
231
|
+
".mink-vault.json",
|
|
232
|
+
".mink-index.json",
|
|
233
|
+
"node_modules",
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
function collectAllMarkdown(rootPath: string): ScannedMarkdown[] {
|
|
237
|
+
const files: ScannedMarkdown[] = [];
|
|
238
|
+
function walk(dir: string) {
|
|
239
|
+
try {
|
|
240
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (VAULT_EXCLUDES.has(entry.name)) continue;
|
|
243
|
+
if (entry.name.startsWith(".")) continue;
|
|
244
|
+
const fullPath = join(dir, entry.name);
|
|
245
|
+
if (entry.isDirectory()) {
|
|
246
|
+
walk(fullPath);
|
|
247
|
+
} else if (entry.name.endsWith(".md")) {
|
|
248
|
+
const stat = statSync(fullPath);
|
|
249
|
+
files.push({
|
|
250
|
+
absolutePath: fullPath,
|
|
251
|
+
relativePath: fullPath.slice(rootPath.length + 1),
|
|
252
|
+
mtimeMs: stat.mtimeMs,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
// Skip unreadable dirs
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
walk(rootPath);
|
|
261
|
+
return files;
|
|
262
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
3
|
+
import { atomicWriteText, safeAppendText } from "./fs-utils";
|
|
4
|
+
import { vaultRoot, vaultMasterIndexPath } from "./vault";
|
|
5
|
+
import type { VaultIndex } from "../types/note";
|
|
6
|
+
|
|
7
|
+
const WIKILINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
8
|
+
|
|
9
|
+
export function extractWikilinks(content: string): string[] {
|
|
10
|
+
const links: string[] = [];
|
|
11
|
+
let match: RegExpExecArray | null;
|
|
12
|
+
const re = new RegExp(WIKILINK_RE.source, "g");
|
|
13
|
+
while ((match = re.exec(content)) !== null) {
|
|
14
|
+
links.push(match[1].trim());
|
|
15
|
+
}
|
|
16
|
+
return [...new Set(links)];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function insertWikilinks(
|
|
20
|
+
content: string,
|
|
21
|
+
targets: string[]
|
|
22
|
+
): string {
|
|
23
|
+
let result = content;
|
|
24
|
+
for (const target of targets) {
|
|
25
|
+
// Don't insert if already a wikilink
|
|
26
|
+
if (result.includes(`[[${target}]]`)) continue;
|
|
27
|
+
// Don't insert inside frontmatter
|
|
28
|
+
const fmEnd = findFrontmatterEnd(result);
|
|
29
|
+
const body = result.slice(fmEnd);
|
|
30
|
+
// Replace first occurrence of the target text (case-insensitive, word boundary)
|
|
31
|
+
const re = new RegExp(`\\b(${escapeRegex(target)})\\b`, "i");
|
|
32
|
+
const replaced = body.replace(re, `[[$1]]`);
|
|
33
|
+
if (replaced !== body) {
|
|
34
|
+
result = result.slice(0, fmEnd) + replaced;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function addBacklink(
|
|
41
|
+
targetNotePath: string,
|
|
42
|
+
sourceTitle: string
|
|
43
|
+
): void {
|
|
44
|
+
if (!existsSync(targetNotePath)) return;
|
|
45
|
+
const content = readFileSync(targetNotePath, "utf-8");
|
|
46
|
+
|
|
47
|
+
// Don't add duplicate backlinks
|
|
48
|
+
if (content.includes(`[[${sourceTitle}]]`)) return;
|
|
49
|
+
|
|
50
|
+
const backlinkSection = "\n\n## Backlinks\n";
|
|
51
|
+
const backlinkEntry = `- [[${sourceTitle}]]\n`;
|
|
52
|
+
|
|
53
|
+
if (content.includes("## Backlinks")) {
|
|
54
|
+
// Append to existing backlinks section
|
|
55
|
+
const idx = content.indexOf("## Backlinks");
|
|
56
|
+
const sectionEnd = content.indexOf("\n## ", idx + 1);
|
|
57
|
+
const insertAt = sectionEnd === -1 ? content.length : sectionEnd;
|
|
58
|
+
const updated =
|
|
59
|
+
content.slice(0, insertAt).trimEnd() +
|
|
60
|
+
"\n" +
|
|
61
|
+
backlinkEntry +
|
|
62
|
+
(sectionEnd === -1 ? "" : content.slice(sectionEnd));
|
|
63
|
+
atomicWriteText(targetNotePath, updated);
|
|
64
|
+
} else {
|
|
65
|
+
safeAppendText(targetNotePath, backlinkSection + backlinkEntry);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function updateMasterIndex(vaultRootPath: string): void {
|
|
70
|
+
const now = new Date().toISOString().split("T")[0];
|
|
71
|
+
const sections: string[] = [
|
|
72
|
+
`---`,
|
|
73
|
+
`updated: "${new Date().toISOString()}"`,
|
|
74
|
+
`---`,
|
|
75
|
+
``,
|
|
76
|
+
`# Knowledge Base`,
|
|
77
|
+
``,
|
|
78
|
+
`> Last updated: ${now}`,
|
|
79
|
+
``,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const categories = [
|
|
83
|
+
{ name: "Inbox", dir: "inbox", emoji: "" },
|
|
84
|
+
{ name: "Projects", dir: "projects", emoji: "" },
|
|
85
|
+
{ name: "Areas", dir: "areas", emoji: "" },
|
|
86
|
+
{ name: "Resources", dir: "resources", emoji: "" },
|
|
87
|
+
{ name: "Archives", dir: "archives", emoji: "" },
|
|
88
|
+
{ name: "Patterns", dir: "patterns", emoji: "" },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
for (const cat of categories) {
|
|
92
|
+
const dirPath = join(vaultRootPath, cat.dir);
|
|
93
|
+
if (!existsSync(dirPath)) continue;
|
|
94
|
+
|
|
95
|
+
const files = collectMarkdownFiles(dirPath, vaultRootPath);
|
|
96
|
+
if (files.length === 0 && cat.dir !== "inbox") continue;
|
|
97
|
+
|
|
98
|
+
sections.push(`## ${cat.name}`);
|
|
99
|
+
sections.push("");
|
|
100
|
+
|
|
101
|
+
if (files.length === 0) {
|
|
102
|
+
sections.push("*No notes yet.*");
|
|
103
|
+
} else {
|
|
104
|
+
// Show up to 20 most recent
|
|
105
|
+
const sorted = files
|
|
106
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
107
|
+
.slice(0, 20);
|
|
108
|
+
for (const file of sorted) {
|
|
109
|
+
sections.push(`- [[${file.title}]]`);
|
|
110
|
+
}
|
|
111
|
+
if (files.length > 20) {
|
|
112
|
+
sections.push(`- *...and ${files.length - 20} more*`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
sections.push("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const indexPath = vaultMasterIndexPath();
|
|
119
|
+
atomicWriteText(indexPath, sections.join("\n"));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface CollectedFile {
|
|
123
|
+
title: string;
|
|
124
|
+
relativePath: string;
|
|
125
|
+
mtime: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function collectMarkdownFiles(
|
|
129
|
+
dirPath: string,
|
|
130
|
+
rootPath: string
|
|
131
|
+
): CollectedFile[] {
|
|
132
|
+
const files: CollectedFile[] = [];
|
|
133
|
+
try {
|
|
134
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
const fullPath = join(dirPath, entry.name);
|
|
137
|
+
if (entry.isDirectory()) {
|
|
138
|
+
files.push(...collectMarkdownFiles(fullPath, rootPath));
|
|
139
|
+
} else if (entry.name.endsWith(".md") && !entry.name.startsWith("_")) {
|
|
140
|
+
const stat = statSync(fullPath);
|
|
141
|
+
const title = entry.name.replace(/\.md$/, "");
|
|
142
|
+
const relativePath = fullPath.slice(rootPath.length + 1);
|
|
143
|
+
files.push({ title, relativePath, mtime: stat.mtimeMs });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Directory might not exist or be readable
|
|
148
|
+
}
|
|
149
|
+
return files;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function findFrontmatterEnd(content: string): number {
|
|
153
|
+
if (!content.startsWith("---")) return 0;
|
|
154
|
+
const endIdx = content.indexOf("---", 3);
|
|
155
|
+
if (endIdx === -1) return 0;
|
|
156
|
+
return endIdx + 3;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function escapeRegex(str: string): string {
|
|
160
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
161
|
+
}
|