@drewpayment/mink 0.1.0 → 0.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 +191 -8
- package/dist/cli.js +87465 -0
- package/package.json +7 -3
- package/skills/mink-note/SKILL.md +131 -0
- package/src/cli.ts +46 -0
- 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/note-index.ts +262 -0
- package/src/core/note-linker.ts +161 -0
- package/src/core/note-writer.ts +203 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { atomicWriteText } from "./fs-utils";
|
|
4
|
+
import { categoryToDir, vaultDailyDir, vaultTemplates } from "./vault";
|
|
5
|
+
import { loadTemplate } from "./vault-templates";
|
|
6
|
+
import type { NoteMetadata, NoteFrontmatter, NoteCategory } from "../types/note";
|
|
7
|
+
|
|
8
|
+
export function slugifyTitle(title: string): string {
|
|
9
|
+
return title
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
12
|
+
.replace(/\s+/g, "-")
|
|
13
|
+
.replace(/-+/g, "-")
|
|
14
|
+
.replace(/^-|-$/g, "")
|
|
15
|
+
.slice(0, 80);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateFrontmatter(meta: {
|
|
19
|
+
created: string;
|
|
20
|
+
updated: string;
|
|
21
|
+
tags: string[];
|
|
22
|
+
category: NoteCategory;
|
|
23
|
+
sourceProject?: string;
|
|
24
|
+
aliases?: string[];
|
|
25
|
+
extra?: Record<string, unknown>;
|
|
26
|
+
}): string {
|
|
27
|
+
const lines: string[] = ["---"];
|
|
28
|
+
lines.push(`created: "${meta.created}"`);
|
|
29
|
+
lines.push(`updated: "${meta.updated}"`);
|
|
30
|
+
|
|
31
|
+
if (meta.tags.length > 0) {
|
|
32
|
+
lines.push(`tags: [${meta.tags.join(", ")}]`);
|
|
33
|
+
} else {
|
|
34
|
+
lines.push("tags: []");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
lines.push(`category: ${meta.category}`);
|
|
38
|
+
|
|
39
|
+
if (meta.sourceProject) {
|
|
40
|
+
lines.push(`source_project: ${meta.sourceProject}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (meta.aliases && meta.aliases.length > 0) {
|
|
44
|
+
lines.push(`aliases: [${meta.aliases.join(", ")}]`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (meta.extra) {
|
|
48
|
+
for (const [key, value] of Object.entries(meta.extra)) {
|
|
49
|
+
lines.push(`${key}: ${JSON.stringify(value)}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
lines.push("---");
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createNote(meta: NoteMetadata): {
|
|
58
|
+
filePath: string;
|
|
59
|
+
content: string;
|
|
60
|
+
} {
|
|
61
|
+
const now = meta.created || new Date().toISOString();
|
|
62
|
+
const slug = slugifyTitle(meta.title);
|
|
63
|
+
const dir = categoryToDir(meta.category, meta.projectSlug);
|
|
64
|
+
const filePath = join(dir, `${slug}.md`);
|
|
65
|
+
|
|
66
|
+
let content: string;
|
|
67
|
+
|
|
68
|
+
if (meta.template) {
|
|
69
|
+
const rendered = loadTemplate(vaultTemplates(), meta.template, {
|
|
70
|
+
title: meta.title,
|
|
71
|
+
body: meta.body,
|
|
72
|
+
created: now,
|
|
73
|
+
updated: now,
|
|
74
|
+
date: now.split("T")[0],
|
|
75
|
+
});
|
|
76
|
+
content = rendered ?? buildNoteContent(meta, now);
|
|
77
|
+
} else {
|
|
78
|
+
content = buildNoteContent(meta, now);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
atomicWriteText(filePath, content);
|
|
82
|
+
return { filePath, content };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildNoteContent(meta: NoteMetadata, now: string): string {
|
|
86
|
+
const frontmatter = generateFrontmatter({
|
|
87
|
+
created: now,
|
|
88
|
+
updated: now,
|
|
89
|
+
tags: meta.tags,
|
|
90
|
+
category: meta.category,
|
|
91
|
+
sourceProject: meta.sourceProject,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return `${frontmatter}
|
|
95
|
+
|
|
96
|
+
# ${meta.title}
|
|
97
|
+
|
|
98
|
+
${meta.body}
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function appendToDaily(date: string, content: string): string {
|
|
103
|
+
const dir = vaultDailyDir();
|
|
104
|
+
const filePath = join(dir, `${date}.md`);
|
|
105
|
+
|
|
106
|
+
if (existsSync(filePath)) {
|
|
107
|
+
const existing = readFileSync(filePath, "utf-8");
|
|
108
|
+
const timestamp = new Date().toLocaleTimeString("en-US", {
|
|
109
|
+
hour: "2-digit",
|
|
110
|
+
minute: "2-digit",
|
|
111
|
+
hour12: false,
|
|
112
|
+
});
|
|
113
|
+
const updated = `${existing.trimEnd()}\n\n## ${timestamp}\n\n${content}\n`;
|
|
114
|
+
atomicWriteText(filePath, updated);
|
|
115
|
+
} else {
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
const rendered = loadTemplate(vaultTemplates(), "daily-note", {
|
|
118
|
+
title: date,
|
|
119
|
+
date,
|
|
120
|
+
body: content,
|
|
121
|
+
created: now,
|
|
122
|
+
updated: now,
|
|
123
|
+
});
|
|
124
|
+
const noteContent =
|
|
125
|
+
rendered ??
|
|
126
|
+
`---
|
|
127
|
+
created: "${now}"
|
|
128
|
+
updated: "${now}"
|
|
129
|
+
tags: [daily]
|
|
130
|
+
category: areas
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
# ${date}
|
|
134
|
+
|
|
135
|
+
${content}
|
|
136
|
+
`;
|
|
137
|
+
atomicWriteText(filePath, noteContent);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return filePath;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function ingestFile(
|
|
144
|
+
sourcePath: string,
|
|
145
|
+
meta: {
|
|
146
|
+
category: NoteCategory;
|
|
147
|
+
tags?: string[];
|
|
148
|
+
projectSlug?: string;
|
|
149
|
+
sourceProject?: string;
|
|
150
|
+
}
|
|
151
|
+
): { filePath: string; content: string } {
|
|
152
|
+
const raw = readFileSync(sourcePath, "utf-8");
|
|
153
|
+
const now = new Date().toISOString();
|
|
154
|
+
|
|
155
|
+
// Extract title from first heading or filename
|
|
156
|
+
const headingMatch = raw.match(/^#\s+(.+)$/m);
|
|
157
|
+
const title =
|
|
158
|
+
headingMatch?.[1] ??
|
|
159
|
+
sourcePath
|
|
160
|
+
.split("/")
|
|
161
|
+
.pop()!
|
|
162
|
+
.replace(/\.md$/, "");
|
|
163
|
+
|
|
164
|
+
// Check if file already has frontmatter
|
|
165
|
+
const hasFrontmatter = raw.startsWith("---");
|
|
166
|
+
let content: string;
|
|
167
|
+
|
|
168
|
+
if (hasFrontmatter) {
|
|
169
|
+
// Preserve existing frontmatter, add missing fields
|
|
170
|
+
const endIdx = raw.indexOf("---", 3);
|
|
171
|
+
if (endIdx !== -1) {
|
|
172
|
+
const existingFm = raw.slice(0, endIdx + 3);
|
|
173
|
+
const body = raw.slice(endIdx + 3).trim();
|
|
174
|
+
// Add category if missing
|
|
175
|
+
if (!existingFm.includes("category:")) {
|
|
176
|
+
const updatedFm = existingFm.replace(
|
|
177
|
+
/---$/,
|
|
178
|
+
`category: ${meta.category}\n---`
|
|
179
|
+
);
|
|
180
|
+
content = `${updatedFm}\n\n${body}\n`;
|
|
181
|
+
} else {
|
|
182
|
+
content = raw;
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
content = raw;
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const frontmatter = generateFrontmatter({
|
|
189
|
+
created: now,
|
|
190
|
+
updated: now,
|
|
191
|
+
tags: meta.tags ?? [],
|
|
192
|
+
category: meta.category,
|
|
193
|
+
sourceProject: meta.sourceProject,
|
|
194
|
+
});
|
|
195
|
+
content = `${frontmatter}\n\n${raw}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const slug = slugifyTitle(title);
|
|
199
|
+
const dir = categoryToDir(meta.category, meta.projectSlug);
|
|
200
|
+
const filePath = join(dir, `${slug}.md`);
|
|
201
|
+
atomicWriteText(filePath, content);
|
|
202
|
+
return { filePath, content };
|
|
203
|
+
}
|