@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.
@@ -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
+ }