granite-mem 0.1.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/dist/index.js +1160 -0
- package/dist/public/app.js +588 -0
- package/dist/public/canvas-renderer.js +524 -0
- package/dist/public/graph.js +380 -0
- package/dist/public/index.html +157 -0
- package/dist/public/markdown-renderer.js +189 -0
- package/dist/public/style.css +923 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import fs2 from "fs";
|
|
8
|
+
import path2 from "path";
|
|
9
|
+
|
|
10
|
+
// src/core/config.ts
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
var CONFIG_FILENAME = "granite.yml";
|
|
15
|
+
var DEFAULT_CONFIG = {
|
|
16
|
+
vault_name: "My Vault",
|
|
17
|
+
version: 1,
|
|
18
|
+
note_types: {
|
|
19
|
+
fleeting: {
|
|
20
|
+
folder: "notes/fleeting",
|
|
21
|
+
description: "Quick captures, inbox items",
|
|
22
|
+
template: "",
|
|
23
|
+
line_limit: 50,
|
|
24
|
+
warn_only: true,
|
|
25
|
+
slug_format: "date",
|
|
26
|
+
instructions: "Write a short thought, observation, or idea. Keep it raw \u2014 refine later into a permanent note."
|
|
27
|
+
},
|
|
28
|
+
permanent: {
|
|
29
|
+
folder: "notes/permanent",
|
|
30
|
+
description: "Refined, linked notes \u2014 one idea per note",
|
|
31
|
+
template: "## Summary\n\n## Details\n\n## Links\n",
|
|
32
|
+
line_limit: 200,
|
|
33
|
+
warn_only: false,
|
|
34
|
+
instructions: "One atomic idea per note. Write a clear summary, then expand in details. Link to related notes with [[wikilinks]]. If the note grows too long, split it."
|
|
35
|
+
},
|
|
36
|
+
reference: {
|
|
37
|
+
folder: "notes/reference",
|
|
38
|
+
description: "Notes on external sources (articles, books, talks)",
|
|
39
|
+
template: "## Source\n\nURL or citation here.\n\n## Date\n\n## Key Points\n\n- \n\n## My Take\n\n",
|
|
40
|
+
line_limit: 300,
|
|
41
|
+
warn_only: true,
|
|
42
|
+
instructions: "Capture the source, the key points in your own words, and your reaction. Link to permanent notes that relate."
|
|
43
|
+
},
|
|
44
|
+
person: {
|
|
45
|
+
folder: "notes/people",
|
|
46
|
+
description: "Card for a person \u2014 contact, context, history",
|
|
47
|
+
template: "## Role\n\n## Context\n\nHow I know them, what we work on.\n\n## Contact\n\n## Notes\n\n- \n\n## Links\n\n",
|
|
48
|
+
line_limit: 150,
|
|
49
|
+
warn_only: true,
|
|
50
|
+
instructions: "Fill in their role and context. Add timestamped notes as you interact (meetings, emails, calls). Link to projects, companies, or topics they relate to."
|
|
51
|
+
},
|
|
52
|
+
meeting: {
|
|
53
|
+
folder: "notes/meetings",
|
|
54
|
+
description: "Meeting notes with attendees, decisions, and actions",
|
|
55
|
+
template: "## Attendees\n\n- \n\n## Agenda\n\n- \n\n## Notes\n\n\n\n## Decisions\n\n- \n\n## Actions\n\n- [ ] \n",
|
|
56
|
+
line_limit: 300,
|
|
57
|
+
warn_only: true,
|
|
58
|
+
instructions: "List attendees as [[person]] links. Capture decisions and action items clearly. Link to relevant projects or topics."
|
|
59
|
+
},
|
|
60
|
+
project: {
|
|
61
|
+
folder: "notes/projects",
|
|
62
|
+
description: "Project overview \u2014 goals, status, people, links",
|
|
63
|
+
template: "## Goal\n\n## Status\n\n## People\n\n- \n\n## Key Decisions\n\n- \n\n## Links\n\n",
|
|
64
|
+
line_limit: 300,
|
|
65
|
+
warn_only: true,
|
|
66
|
+
instructions: "Define the goal clearly. Keep status updated. Link to people involved, meeting notes, and related permanent notes."
|
|
67
|
+
},
|
|
68
|
+
decision: {
|
|
69
|
+
folder: "notes/decisions",
|
|
70
|
+
description: "Record of a decision \u2014 context, options, outcome",
|
|
71
|
+
template: "## Context\n\nWhat prompted this decision.\n\n## Options Considered\n\n1. \n2. \n\n## Decision\n\n\n\n## Rationale\n\n## Status\n\nActive\n",
|
|
72
|
+
line_limit: 200,
|
|
73
|
+
warn_only: false,
|
|
74
|
+
instructions: "Document the context, what options were considered, what was decided, and why. This is a permanent record \u2014 future-you will thank you. Link to the project or topic."
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
defaults: {
|
|
78
|
+
note_type: "fleeting",
|
|
79
|
+
editor: "$EDITOR"
|
|
80
|
+
},
|
|
81
|
+
index: {
|
|
82
|
+
auto_rebuild: true
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
function writeDefaultConfig(dir) {
|
|
86
|
+
const configPath = path.join(dir, CONFIG_FILENAME);
|
|
87
|
+
const content = yaml.dump(DEFAULT_CONFIG, { lineWidth: 120, noRefs: true });
|
|
88
|
+
fs.writeFileSync(configPath, content, "utf-8");
|
|
89
|
+
}
|
|
90
|
+
function loadConfig(vaultRoot) {
|
|
91
|
+
const configPath = path.join(vaultRoot, CONFIG_FILENAME);
|
|
92
|
+
if (!fs.existsSync(configPath)) {
|
|
93
|
+
throw new Error(`No ${CONFIG_FILENAME} found in ${vaultRoot}. Run "mem init" first.`);
|
|
94
|
+
}
|
|
95
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
96
|
+
const parsed = yaml.load(raw);
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/commands/init.ts
|
|
101
|
+
function initVault(dir) {
|
|
102
|
+
const targetDir = path2.resolve(dir);
|
|
103
|
+
if (fs2.existsSync(path2.join(targetDir, CONFIG_FILENAME))) {
|
|
104
|
+
console.error(`Vault already exists in ${targetDir}`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
writeDefaultConfig(targetDir);
|
|
108
|
+
fs2.mkdirSync(path2.join(targetDir, ".granite"), { recursive: true });
|
|
109
|
+
const config = loadConfig(targetDir);
|
|
110
|
+
for (const typeConfig of Object.values(config.note_types)) {
|
|
111
|
+
fs2.mkdirSync(path2.join(targetDir, typeConfig.folder), { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
console.log(`Vault initialized in ${targetDir}`);
|
|
114
|
+
console.log("");
|
|
115
|
+
console.log("Created:");
|
|
116
|
+
console.log(` ${CONFIG_FILENAME}`);
|
|
117
|
+
console.log(" .granite/");
|
|
118
|
+
for (const [name, typeConfig] of Object.entries(config.note_types)) {
|
|
119
|
+
console.log(` ${typeConfig.folder}/ (${name})`);
|
|
120
|
+
}
|
|
121
|
+
console.log("");
|
|
122
|
+
console.log('Next: mem new fleeting "My first note"');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/core/vault.ts
|
|
126
|
+
import fs3 from "fs";
|
|
127
|
+
import path3 from "path";
|
|
128
|
+
function findVaultRoot(from) {
|
|
129
|
+
let dir = from ? path3.resolve(from) : process.cwd();
|
|
130
|
+
while (true) {
|
|
131
|
+
if (fs3.existsSync(path3.join(dir, CONFIG_FILENAME))) {
|
|
132
|
+
return dir;
|
|
133
|
+
}
|
|
134
|
+
const parent = path3.dirname(dir);
|
|
135
|
+
if (parent === dir) return null;
|
|
136
|
+
dir = parent;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function requireVaultRoot(from) {
|
|
140
|
+
const root = findVaultRoot(from);
|
|
141
|
+
if (!root) {
|
|
142
|
+
throw new Error('Not inside a Granite vault. Run "mem init" to create one.');
|
|
143
|
+
}
|
|
144
|
+
return root;
|
|
145
|
+
}
|
|
146
|
+
function getIndexDbPath(vaultRoot) {
|
|
147
|
+
return path3.join(vaultRoot, ".granite", "index.db");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/core/note.ts
|
|
151
|
+
import fs4 from "fs";
|
|
152
|
+
import path4 from "path";
|
|
153
|
+
import { v4 as uuidv4 } from "uuid";
|
|
154
|
+
|
|
155
|
+
// src/core/slugify.ts
|
|
156
|
+
function slugify(title) {
|
|
157
|
+
return title.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/core/frontmatter.ts
|
|
161
|
+
import matter from "gray-matter";
|
|
162
|
+
function parseFrontmatter(content) {
|
|
163
|
+
const { data, content: body } = matter(content);
|
|
164
|
+
const fm = {
|
|
165
|
+
id: data.id ?? "",
|
|
166
|
+
title: data.title ?? "",
|
|
167
|
+
type: data.type ?? "",
|
|
168
|
+
created: data.created ?? "",
|
|
169
|
+
modified: data.modified ?? "",
|
|
170
|
+
tags: data.tags ?? [],
|
|
171
|
+
aliases: data.aliases ?? []
|
|
172
|
+
};
|
|
173
|
+
return { frontmatter: fm, body };
|
|
174
|
+
}
|
|
175
|
+
function serializeFrontmatter(fm, body) {
|
|
176
|
+
return matter.stringify(body, fm);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/core/note.ts
|
|
180
|
+
function createNote(vaultRoot, config, typeName, title, bodyOverride) {
|
|
181
|
+
const typeConfig = config.note_types[typeName];
|
|
182
|
+
if (!typeConfig) {
|
|
183
|
+
throw new Error(`Unknown note type: "${typeName}". Available: ${Object.keys(config.note_types).join(", ")}`);
|
|
184
|
+
}
|
|
185
|
+
const folder = path4.join(vaultRoot, typeConfig.folder);
|
|
186
|
+
let finalSlug;
|
|
187
|
+
if (typeConfig.slug_format === "date") {
|
|
188
|
+
const now2 = /* @__PURE__ */ new Date();
|
|
189
|
+
const date = now2.toISOString().slice(0, 10);
|
|
190
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
191
|
+
finalSlug = `${date}-${rand}`;
|
|
192
|
+
while (fs4.existsSync(path4.join(folder, `${finalSlug}.md`))) {
|
|
193
|
+
const r = Math.random().toString(36).slice(2, 6);
|
|
194
|
+
finalSlug = `${date}-${r}`;
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
let slug = slugify(title);
|
|
198
|
+
if (!slug) slug = "untitled";
|
|
199
|
+
finalSlug = slug;
|
|
200
|
+
let counter = 2;
|
|
201
|
+
while (fs4.existsSync(path4.join(folder, `${finalSlug}.md`))) {
|
|
202
|
+
finalSlug = `${slug}-${counter}`;
|
|
203
|
+
counter++;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
207
|
+
const frontmatter = {
|
|
208
|
+
id: uuidv4(),
|
|
209
|
+
title,
|
|
210
|
+
type: typeName,
|
|
211
|
+
created: now,
|
|
212
|
+
modified: now,
|
|
213
|
+
tags: [],
|
|
214
|
+
aliases: []
|
|
215
|
+
};
|
|
216
|
+
const body = bodyOverride ?? typeConfig.template;
|
|
217
|
+
const content = serializeFrontmatter(frontmatter, body);
|
|
218
|
+
const filepath = path4.join(folder, `${finalSlug}.md`);
|
|
219
|
+
fs4.mkdirSync(folder, { recursive: true });
|
|
220
|
+
fs4.writeFileSync(filepath, content, "utf-8");
|
|
221
|
+
return {
|
|
222
|
+
slug: finalSlug,
|
|
223
|
+
filepath,
|
|
224
|
+
frontmatter,
|
|
225
|
+
body,
|
|
226
|
+
outgoing_links: []
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function readNote(filepath) {
|
|
230
|
+
const content = fs4.readFileSync(filepath, "utf-8");
|
|
231
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
232
|
+
const slug = path4.basename(filepath, ".md");
|
|
233
|
+
return { slug, filepath, frontmatter, body, outgoing_links: [] };
|
|
234
|
+
}
|
|
235
|
+
function listNotes(vaultRoot, config) {
|
|
236
|
+
const notes = [];
|
|
237
|
+
for (const typeConfig of Object.values(config.note_types)) {
|
|
238
|
+
const folder = path4.join(vaultRoot, typeConfig.folder);
|
|
239
|
+
if (!fs4.existsSync(folder)) continue;
|
|
240
|
+
const files = fs4.readdirSync(folder).filter((f) => f.endsWith(".md"));
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
const filepath = path4.join(folder, file);
|
|
243
|
+
notes.push(readNote(filepath));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return notes;
|
|
247
|
+
}
|
|
248
|
+
function findNoteBySlug(vaultRoot, config, slug) {
|
|
249
|
+
for (const typeConfig of Object.values(config.note_types)) {
|
|
250
|
+
const filepath = path4.join(vaultRoot, typeConfig.folder, `${slug}.md`);
|
|
251
|
+
if (fs4.existsSync(filepath)) {
|
|
252
|
+
return readNote(filepath);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// src/core/json-output.ts
|
|
259
|
+
function jsonSuccess(data) {
|
|
260
|
+
return JSON.stringify({ success: true, data }, null, 2);
|
|
261
|
+
}
|
|
262
|
+
function jsonError(error) {
|
|
263
|
+
return JSON.stringify({ success: false, error }, null, 2);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/commands/new.ts
|
|
267
|
+
function newNote(title, options) {
|
|
268
|
+
const vaultRoot = requireVaultRoot();
|
|
269
|
+
const config = loadConfig(vaultRoot);
|
|
270
|
+
const resolvedType = options.type || config.defaults.note_type;
|
|
271
|
+
const typeConfig = config.note_types[resolvedType];
|
|
272
|
+
const bodyOverride = typeConfig?.slug_format === "date" ? title + "\n" : void 0;
|
|
273
|
+
const note = createNote(vaultRoot, config, resolvedType, title, bodyOverride);
|
|
274
|
+
const lines = note.body.split("\n").length;
|
|
275
|
+
const overLimit = typeConfig && typeConfig.line_limit && lines > typeConfig.line_limit;
|
|
276
|
+
const textToScan = (note.body + " " + title).toLowerCase();
|
|
277
|
+
const allNotes = listNotes(vaultRoot, config).filter((n) => n.slug !== note.slug);
|
|
278
|
+
const suggestions = [];
|
|
279
|
+
for (const other of allNotes) {
|
|
280
|
+
const otherTitle = other.frontmatter.title.toLowerCase();
|
|
281
|
+
if (otherTitle.length < 3) continue;
|
|
282
|
+
if (textToScan.includes(otherTitle)) {
|
|
283
|
+
suggestions.push({ slug: other.slug, title: other.frontmatter.title, type: other.frontmatter.type });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
for (const alias of other.frontmatter.aliases) {
|
|
287
|
+
if (alias.length < 3) continue;
|
|
288
|
+
if (textToScan.includes(alias.toLowerCase())) {
|
|
289
|
+
suggestions.push({ slug: other.slug, title: other.frontmatter.title, type: other.frontmatter.type });
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (options.json) {
|
|
295
|
+
console.log(jsonSuccess({
|
|
296
|
+
slug: note.slug,
|
|
297
|
+
title: note.frontmatter.title,
|
|
298
|
+
type: resolvedType,
|
|
299
|
+
filepath: note.filepath,
|
|
300
|
+
suggestions
|
|
301
|
+
}));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (overLimit) {
|
|
305
|
+
const action = typeConfig.warn_only ? "Warning" : "Error";
|
|
306
|
+
console.warn(`${action}: Note exceeds ${typeConfig.line_limit} line limit for type "${resolvedType}"`);
|
|
307
|
+
}
|
|
308
|
+
console.log(note.filepath);
|
|
309
|
+
if (typeConfig?.instructions) {
|
|
310
|
+
console.log("");
|
|
311
|
+
console.log(` \u{1F4A1} ${typeConfig.instructions}`);
|
|
312
|
+
}
|
|
313
|
+
if (suggestions.length > 0) {
|
|
314
|
+
console.log("");
|
|
315
|
+
console.log(" \u{1F517} Linked notes detected:");
|
|
316
|
+
for (const s of suggestions) {
|
|
317
|
+
console.log(` [[${s.slug}]] \u2014 ${s.title} (${s.type})`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/commands/add.ts
|
|
323
|
+
import fs5 from "fs";
|
|
324
|
+
function addNote(text, options = {}) {
|
|
325
|
+
const vaultRoot = requireVaultRoot();
|
|
326
|
+
const config = loadConfig(vaultRoot);
|
|
327
|
+
const typeName = config.defaults.note_type;
|
|
328
|
+
let content;
|
|
329
|
+
if (text) {
|
|
330
|
+
content = text;
|
|
331
|
+
} else if (!process.stdin.isTTY) {
|
|
332
|
+
content = fs5.readFileSync(0, "utf-8").trim();
|
|
333
|
+
} else {
|
|
334
|
+
console.error('Usage: mem add "some text" or echo "text" | mem add');
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
if (!content) {
|
|
338
|
+
console.error("No content provided.");
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
const firstLine = content.split("\n")[0];
|
|
342
|
+
const title = firstLine.length > 60 ? firstLine.slice(0, 60).trim() + "..." : firstLine;
|
|
343
|
+
const note = createNote(vaultRoot, config, typeName, title, content + "\n");
|
|
344
|
+
if (options.json) {
|
|
345
|
+
console.log(jsonSuccess({
|
|
346
|
+
slug: note.slug,
|
|
347
|
+
title: note.frontmatter.title,
|
|
348
|
+
type: typeName,
|
|
349
|
+
filepath: note.filepath
|
|
350
|
+
}));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
console.log(note.filepath);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/commands/open.ts
|
|
357
|
+
import { execSync } from "child_process";
|
|
358
|
+
function openNote(slug) {
|
|
359
|
+
const vaultRoot = requireVaultRoot();
|
|
360
|
+
const config = loadConfig(vaultRoot);
|
|
361
|
+
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
362
|
+
if (!note) {
|
|
363
|
+
console.error(`Note not found: "${slug}"`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
const editor = config.defaults.editor === "$EDITOR" ? process.env.EDITOR || "vi" : config.defaults.editor;
|
|
367
|
+
execSync(`${editor} "${note.filepath}"`, { stdio: "inherit" });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// src/commands/show.ts
|
|
371
|
+
import fs6 from "fs";
|
|
372
|
+
function showCommand(slug, options) {
|
|
373
|
+
const vaultRoot = requireVaultRoot();
|
|
374
|
+
const config = loadConfig(vaultRoot);
|
|
375
|
+
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
376
|
+
if (!note) {
|
|
377
|
+
if (options.json) {
|
|
378
|
+
console.log(jsonError(`Note not found: ${slug}`));
|
|
379
|
+
} else {
|
|
380
|
+
console.error(`Note not found: ${slug}`);
|
|
381
|
+
}
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
if (options.json) {
|
|
385
|
+
console.log(jsonSuccess({
|
|
386
|
+
slug: note.slug,
|
|
387
|
+
title: note.frontmatter.title,
|
|
388
|
+
type: note.frontmatter.type,
|
|
389
|
+
created: note.frontmatter.created,
|
|
390
|
+
modified: note.frontmatter.modified,
|
|
391
|
+
tags: note.frontmatter.tags,
|
|
392
|
+
aliases: note.frontmatter.aliases,
|
|
393
|
+
body: note.body,
|
|
394
|
+
filepath: note.filepath
|
|
395
|
+
}));
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (options.body) {
|
|
399
|
+
process.stdout.write(note.body);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
console.log(`# ${note.frontmatter.title} (${note.slug})`);
|
|
403
|
+
console.log(`# type: ${note.frontmatter.type} | modified: ${note.frontmatter.modified.slice(0, 10)}`);
|
|
404
|
+
console.log("");
|
|
405
|
+
const content = fs6.readFileSync(note.filepath, "utf-8");
|
|
406
|
+
console.log(content);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// src/core/index-db.ts
|
|
410
|
+
import Database from "better-sqlite3";
|
|
411
|
+
import fs7 from "fs";
|
|
412
|
+
import path5 from "path";
|
|
413
|
+
|
|
414
|
+
// src/core/wikilinks.ts
|
|
415
|
+
var WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
416
|
+
var FENCED_CODE_RE = /^```[\s\S]*?^```/gm;
|
|
417
|
+
var INLINE_CODE_RE = /`[^`]+`/g;
|
|
418
|
+
function parseWikilinks(body) {
|
|
419
|
+
const stripped = body.replace(FENCED_CODE_RE, "").replace(INLINE_CODE_RE, "");
|
|
420
|
+
const links = [];
|
|
421
|
+
let match;
|
|
422
|
+
while ((match = WIKILINK_RE.exec(stripped)) !== null) {
|
|
423
|
+
const inner = match[1];
|
|
424
|
+
const parts = inner.split("|");
|
|
425
|
+
const target = parts[0].trim();
|
|
426
|
+
const display = parts.length > 1 ? parts[1].trim() : target;
|
|
427
|
+
links.push({
|
|
428
|
+
raw: match[0],
|
|
429
|
+
target,
|
|
430
|
+
display,
|
|
431
|
+
resolved: false
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return links;
|
|
435
|
+
}
|
|
436
|
+
function resolveWikilinks(links, allNotes) {
|
|
437
|
+
return links.map((link) => {
|
|
438
|
+
const targetSlug = slugify(link.target);
|
|
439
|
+
let found = allNotes.find((n) => n.slug === targetSlug);
|
|
440
|
+
if (!found) {
|
|
441
|
+
found = allNotes.find((n) => n.frontmatter.title.toLowerCase() === link.target.toLowerCase());
|
|
442
|
+
}
|
|
443
|
+
if (!found) {
|
|
444
|
+
found = allNotes.find(
|
|
445
|
+
(n) => n.frontmatter.aliases.some((a) => a.toLowerCase() === link.target.toLowerCase())
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
...link,
|
|
450
|
+
resolved: !!found,
|
|
451
|
+
resolved_slug: found?.slug
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/core/index-db.ts
|
|
457
|
+
var SCHEMA_VERSION = 1;
|
|
458
|
+
var SCHEMA_SQL = `
|
|
459
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
460
|
+
slug TEXT PRIMARY KEY,
|
|
461
|
+
id TEXT UNIQUE NOT NULL,
|
|
462
|
+
title TEXT NOT NULL,
|
|
463
|
+
type TEXT NOT NULL,
|
|
464
|
+
created TEXT NOT NULL,
|
|
465
|
+
modified TEXT NOT NULL,
|
|
466
|
+
tags TEXT,
|
|
467
|
+
aliases TEXT,
|
|
468
|
+
body TEXT NOT NULL,
|
|
469
|
+
filepath TEXT NOT NULL
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
|
|
473
|
+
title,
|
|
474
|
+
body,
|
|
475
|
+
tags,
|
|
476
|
+
content='notes',
|
|
477
|
+
content_rowid='rowid'
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
|
|
481
|
+
INSERT INTO notes_fts(rowid, title, body, tags)
|
|
482
|
+
VALUES (new.rowid, new.title, new.body, new.tags);
|
|
483
|
+
END;
|
|
484
|
+
|
|
485
|
+
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
|
|
486
|
+
INSERT INTO notes_fts(notes_fts, rowid, title, body, tags)
|
|
487
|
+
VALUES ('delete', old.rowid, old.title, old.body, old.tags);
|
|
488
|
+
END;
|
|
489
|
+
|
|
490
|
+
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
|
|
491
|
+
INSERT INTO notes_fts(notes_fts, rowid, title, body, tags)
|
|
492
|
+
VALUES ('delete', old.rowid, old.title, old.body, old.tags);
|
|
493
|
+
INSERT INTO notes_fts(rowid, title, body, tags)
|
|
494
|
+
VALUES (new.rowid, new.title, new.body, new.tags);
|
|
495
|
+
END;
|
|
496
|
+
|
|
497
|
+
CREATE TABLE IF NOT EXISTS links (
|
|
498
|
+
source_slug TEXT NOT NULL,
|
|
499
|
+
target_slug TEXT,
|
|
500
|
+
target_raw TEXT NOT NULL,
|
|
501
|
+
context TEXT,
|
|
502
|
+
FOREIGN KEY (source_slug) REFERENCES notes(slug)
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_slug);
|
|
506
|
+
CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_slug);
|
|
507
|
+
|
|
508
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
509
|
+
key TEXT PRIMARY KEY,
|
|
510
|
+
value TEXT
|
|
511
|
+
);
|
|
512
|
+
`;
|
|
513
|
+
function createDatabase(dbPath) {
|
|
514
|
+
fs7.mkdirSync(path5.dirname(dbPath), { recursive: true });
|
|
515
|
+
const db = new Database(dbPath);
|
|
516
|
+
db.pragma("journal_mode = WAL");
|
|
517
|
+
db.exec(SCHEMA_SQL);
|
|
518
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("schema_version", String(SCHEMA_VERSION));
|
|
519
|
+
return db;
|
|
520
|
+
}
|
|
521
|
+
function openDatabase(vaultRoot) {
|
|
522
|
+
const dbPath = getIndexDbPath(vaultRoot);
|
|
523
|
+
if (!fs7.existsSync(dbPath)) {
|
|
524
|
+
return createDatabase(dbPath);
|
|
525
|
+
}
|
|
526
|
+
const db = new Database(dbPath);
|
|
527
|
+
db.pragma("journal_mode = WAL");
|
|
528
|
+
return db;
|
|
529
|
+
}
|
|
530
|
+
function rebuildIndex(vaultRoot, config, db) {
|
|
531
|
+
const notes = listNotes(vaultRoot, config);
|
|
532
|
+
db.exec("DELETE FROM links");
|
|
533
|
+
db.exec("DELETE FROM notes");
|
|
534
|
+
db.exec("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')");
|
|
535
|
+
const insertNote = db.prepare(`
|
|
536
|
+
INSERT INTO notes (slug, id, title, type, created, modified, tags, aliases, body, filepath)
|
|
537
|
+
VALUES (@slug, @id, @title, @type, @created, @modified, @tags, @aliases, @body, @filepath)
|
|
538
|
+
`);
|
|
539
|
+
const insertLink = db.prepare(`
|
|
540
|
+
INSERT INTO links (source_slug, target_slug, target_raw, context)
|
|
541
|
+
VALUES (@source_slug, @target_slug, @target_raw, @context)
|
|
542
|
+
`);
|
|
543
|
+
const transaction = db.transaction(() => {
|
|
544
|
+
for (const note of notes) {
|
|
545
|
+
insertNote.run({
|
|
546
|
+
slug: note.slug,
|
|
547
|
+
id: note.frontmatter.id,
|
|
548
|
+
title: note.frontmatter.title,
|
|
549
|
+
type: note.frontmatter.type,
|
|
550
|
+
created: note.frontmatter.created,
|
|
551
|
+
modified: note.frontmatter.modified,
|
|
552
|
+
tags: JSON.stringify(note.frontmatter.tags),
|
|
553
|
+
aliases: JSON.stringify(note.frontmatter.aliases),
|
|
554
|
+
body: note.body,
|
|
555
|
+
filepath: note.filepath
|
|
556
|
+
});
|
|
557
|
+
const links = parseWikilinks(note.body);
|
|
558
|
+
const resolved = resolveWikilinks(links, notes);
|
|
559
|
+
for (const link of resolved) {
|
|
560
|
+
const bodyLines = note.body.split("\n");
|
|
561
|
+
const contextLine = bodyLines.find((l) => l.includes(link.raw)) ?? "";
|
|
562
|
+
insertLink.run({
|
|
563
|
+
source_slug: note.slug,
|
|
564
|
+
target_slug: link.resolved_slug ?? null,
|
|
565
|
+
target_raw: link.target,
|
|
566
|
+
context: contextLine.trim()
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run("last_rebuild", (/* @__PURE__ */ new Date()).toISOString());
|
|
571
|
+
});
|
|
572
|
+
transaction();
|
|
573
|
+
}
|
|
574
|
+
function ensureIndex(vaultRoot, config) {
|
|
575
|
+
const db = openDatabase(vaultRoot);
|
|
576
|
+
if (config.index.auto_rebuild) {
|
|
577
|
+
rebuildIndex(vaultRoot, config, db);
|
|
578
|
+
}
|
|
579
|
+
return db;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/core/search.ts
|
|
583
|
+
function searchNotes(db, query) {
|
|
584
|
+
const stmt = db.prepare(`
|
|
585
|
+
SELECT
|
|
586
|
+
n.slug,
|
|
587
|
+
n.title,
|
|
588
|
+
snippet(notes_fts, 1, '>>>', '<<<', '...', 30) as snippet,
|
|
589
|
+
rank
|
|
590
|
+
FROM notes_fts
|
|
591
|
+
JOIN notes n ON n.rowid = notes_fts.rowid
|
|
592
|
+
WHERE notes_fts MATCH ?
|
|
593
|
+
ORDER BY rank
|
|
594
|
+
LIMIT 20
|
|
595
|
+
`);
|
|
596
|
+
const rows = stmt.all(query);
|
|
597
|
+
return rows.map((r) => ({
|
|
598
|
+
slug: r.slug,
|
|
599
|
+
title: r.title,
|
|
600
|
+
snippet: r.snippet,
|
|
601
|
+
score: r.rank
|
|
602
|
+
}));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/commands/search.ts
|
|
606
|
+
function searchCommand(query, options) {
|
|
607
|
+
const vaultRoot = requireVaultRoot();
|
|
608
|
+
const config = loadConfig(vaultRoot);
|
|
609
|
+
const db = ensureIndex(vaultRoot, config);
|
|
610
|
+
const results = searchNotes(db, query);
|
|
611
|
+
db.close();
|
|
612
|
+
if (options.json) {
|
|
613
|
+
console.log(jsonSuccess(results));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (results.length === 0) {
|
|
617
|
+
console.log("No results found.");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
for (const r of results) {
|
|
621
|
+
console.log(` ${r.title} (${r.slug})`);
|
|
622
|
+
console.log(` ${r.snippet}`);
|
|
623
|
+
console.log("");
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/core/backlinks.ts
|
|
628
|
+
function getBacklinks(db, slug) {
|
|
629
|
+
const stmt = db.prepare(`
|
|
630
|
+
SELECT
|
|
631
|
+
l.source_slug,
|
|
632
|
+
n.title as source_title,
|
|
633
|
+
l.context
|
|
634
|
+
FROM links l
|
|
635
|
+
JOIN notes n ON n.slug = l.source_slug
|
|
636
|
+
WHERE l.target_slug = ?
|
|
637
|
+
ORDER BY n.title
|
|
638
|
+
`);
|
|
639
|
+
return stmt.all(slug);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/commands/backlinks.ts
|
|
643
|
+
function backlinksCommand(slug, options) {
|
|
644
|
+
const vaultRoot = requireVaultRoot();
|
|
645
|
+
const config = loadConfig(vaultRoot);
|
|
646
|
+
const db = ensureIndex(vaultRoot, config);
|
|
647
|
+
const backlinks = getBacklinks(db, slug);
|
|
648
|
+
db.close();
|
|
649
|
+
if (options.json) {
|
|
650
|
+
console.log(jsonSuccess(backlinks));
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (backlinks.length === 0) {
|
|
654
|
+
console.log(`No backlinks found for "${slug}".`);
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
console.log(`Backlinks to "${slug}":`);
|
|
658
|
+
console.log("");
|
|
659
|
+
for (const bl of backlinks) {
|
|
660
|
+
console.log(` \u2190 ${bl.source_title} (${bl.source_slug})`);
|
|
661
|
+
if (bl.context) {
|
|
662
|
+
console.log(` ${bl.context}`);
|
|
663
|
+
}
|
|
664
|
+
console.log("");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// src/core/suggest.ts
|
|
669
|
+
function suggestLinks(db, note) {
|
|
670
|
+
const existingLinks = new Set(
|
|
671
|
+
parseWikilinks(note.body).map((l) => l.target.toLowerCase())
|
|
672
|
+
);
|
|
673
|
+
const allNotes = db.prepare("SELECT slug, title, aliases FROM notes WHERE slug != ?").all(note.slug);
|
|
674
|
+
const bodyLower = note.body.toLowerCase();
|
|
675
|
+
const suggestions = [];
|
|
676
|
+
for (const other of allNotes) {
|
|
677
|
+
const titleLower = other.title.toLowerCase();
|
|
678
|
+
if (existingLinks.has(titleLower) || existingLinks.has(other.slug)) continue;
|
|
679
|
+
let mentions = 0;
|
|
680
|
+
let idx = 0;
|
|
681
|
+
while ((idx = bodyLower.indexOf(titleLower, idx)) !== -1) {
|
|
682
|
+
mentions++;
|
|
683
|
+
idx += titleLower.length;
|
|
684
|
+
}
|
|
685
|
+
const aliases = JSON.parse(other.aliases || "[]");
|
|
686
|
+
for (const alias of aliases) {
|
|
687
|
+
const aliasLower = alias.toLowerCase();
|
|
688
|
+
if (existingLinks.has(aliasLower)) continue;
|
|
689
|
+
let aidx = 0;
|
|
690
|
+
while ((aidx = bodyLower.indexOf(aliasLower, aidx)) !== -1) {
|
|
691
|
+
mentions++;
|
|
692
|
+
aidx += aliasLower.length;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (mentions > 0) {
|
|
696
|
+
suggestions.push({
|
|
697
|
+
target_slug: other.slug,
|
|
698
|
+
target_title: other.title,
|
|
699
|
+
mentions
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return suggestions.sort((a, b) => b.mentions - a.mentions);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// src/commands/suggest-links.ts
|
|
707
|
+
function suggestLinksCommand(slug, options) {
|
|
708
|
+
const vaultRoot = requireVaultRoot();
|
|
709
|
+
const config = loadConfig(vaultRoot);
|
|
710
|
+
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
711
|
+
if (!note) {
|
|
712
|
+
if (options.json) {
|
|
713
|
+
console.log(jsonError(`Note not found: ${slug}`));
|
|
714
|
+
} else {
|
|
715
|
+
console.error(`Note not found: "${slug}"`);
|
|
716
|
+
}
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
const db = ensureIndex(vaultRoot, config);
|
|
720
|
+
const suggestions = suggestLinks(db, note);
|
|
721
|
+
db.close();
|
|
722
|
+
if (options.json) {
|
|
723
|
+
console.log(jsonSuccess(suggestions));
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (suggestions.length === 0) {
|
|
727
|
+
console.log("No link suggestions found.");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
console.log(`Suggested links for "${note.frontmatter.title}":`);
|
|
731
|
+
console.log("");
|
|
732
|
+
for (const s of suggestions) {
|
|
733
|
+
console.log(` \u2192 [[${s.target_title}]] \u2014 ${s.mentions} mention${s.mentions > 1 ? "s" : ""}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/core/doctor.ts
|
|
738
|
+
import fs8 from "fs";
|
|
739
|
+
import path6 from "path";
|
|
740
|
+
function runDoctor(vaultRoot, config, db) {
|
|
741
|
+
const issues = [];
|
|
742
|
+
const notes = listNotes(vaultRoot, config);
|
|
743
|
+
for (const [name, typeConfig] of Object.entries(config.note_types)) {
|
|
744
|
+
const folder = path6.join(vaultRoot, typeConfig.folder);
|
|
745
|
+
if (!fs8.existsSync(folder)) {
|
|
746
|
+
issues.push({
|
|
747
|
+
level: "error",
|
|
748
|
+
file: `granite.yml`,
|
|
749
|
+
message: `Note type "${name}" folder does not exist: ${typeConfig.folder}`
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (const note of notes) {
|
|
754
|
+
const fm = note.frontmatter;
|
|
755
|
+
if (!fm.id) {
|
|
756
|
+
issues.push({ level: "error", file: note.filepath, message: "Missing frontmatter field: id" });
|
|
757
|
+
}
|
|
758
|
+
if (!fm.title) {
|
|
759
|
+
issues.push({ level: "error", file: note.filepath, message: "Missing frontmatter field: title" });
|
|
760
|
+
}
|
|
761
|
+
if (!fm.type) {
|
|
762
|
+
issues.push({ level: "warning", file: note.filepath, message: "Missing frontmatter field: type" });
|
|
763
|
+
}
|
|
764
|
+
if (!fm.created) {
|
|
765
|
+
issues.push({ level: "warning", file: note.filepath, message: "Missing frontmatter field: created" });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
for (const note of notes) {
|
|
769
|
+
const links = parseWikilinks(note.body);
|
|
770
|
+
const resolved = resolveWikilinks(links, notes);
|
|
771
|
+
for (const link of resolved) {
|
|
772
|
+
if (!link.resolved) {
|
|
773
|
+
issues.push({
|
|
774
|
+
level: "warning",
|
|
775
|
+
file: note.filepath,
|
|
776
|
+
message: `Broken wikilink: [[${link.target}]]`
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const slugMap = /* @__PURE__ */ new Map();
|
|
782
|
+
for (const note of notes) {
|
|
783
|
+
const existing = slugMap.get(note.slug) ?? [];
|
|
784
|
+
existing.push(note.filepath);
|
|
785
|
+
slugMap.set(note.slug, existing);
|
|
786
|
+
}
|
|
787
|
+
for (const [slug, files] of slugMap) {
|
|
788
|
+
if (files.length > 1) {
|
|
789
|
+
issues.push({
|
|
790
|
+
level: "warning",
|
|
791
|
+
file: files.join(", "),
|
|
792
|
+
message: `Duplicate slug "${slug}" found in multiple locations`
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
797
|
+
for (const note of notes) {
|
|
798
|
+
if (!note.frontmatter.id) continue;
|
|
799
|
+
const existing = idMap.get(note.frontmatter.id) ?? [];
|
|
800
|
+
existing.push(note.filepath);
|
|
801
|
+
idMap.set(note.frontmatter.id, existing);
|
|
802
|
+
}
|
|
803
|
+
for (const [id, files] of idMap) {
|
|
804
|
+
if (files.length > 1) {
|
|
805
|
+
issues.push({
|
|
806
|
+
level: "error",
|
|
807
|
+
file: files.join(", "),
|
|
808
|
+
message: `Duplicate note ID "${id}"`
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
for (const note of notes) {
|
|
813
|
+
const typeConfig = config.note_types[note.frontmatter.type];
|
|
814
|
+
if (!typeConfig) continue;
|
|
815
|
+
const lineCount = note.body.split("\n").length;
|
|
816
|
+
if (typeConfig.line_limit && lineCount > typeConfig.line_limit) {
|
|
817
|
+
issues.push({
|
|
818
|
+
level: typeConfig.warn_only ? "warning" : "error",
|
|
819
|
+
file: note.filepath,
|
|
820
|
+
message: `Note has ${lineCount} lines, exceeds limit of ${typeConfig.line_limit} for type "${note.frontmatter.type}". Consider splitting into smaller notes with [[wikilinks]].`
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const hasIncoming = /* @__PURE__ */ new Set();
|
|
825
|
+
const hasOutgoing = /* @__PURE__ */ new Set();
|
|
826
|
+
for (const note of notes) {
|
|
827
|
+
const links = parseWikilinks(note.body);
|
|
828
|
+
if (links.length > 0) hasOutgoing.add(note.slug);
|
|
829
|
+
const resolved = resolveWikilinks(links, notes);
|
|
830
|
+
for (const link of resolved) {
|
|
831
|
+
if (link.resolved_slug) hasIncoming.add(link.resolved_slug);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (const note of notes) {
|
|
835
|
+
if (!hasIncoming.has(note.slug) && !hasOutgoing.has(note.slug)) {
|
|
836
|
+
issues.push({
|
|
837
|
+
level: "info",
|
|
838
|
+
file: note.filepath,
|
|
839
|
+
message: "Orphan note: no incoming or outgoing links"
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return issues;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// src/commands/doctor.ts
|
|
847
|
+
function doctorCommand() {
|
|
848
|
+
const vaultRoot = requireVaultRoot();
|
|
849
|
+
const config = loadConfig(vaultRoot);
|
|
850
|
+
const db = ensureIndex(vaultRoot, config);
|
|
851
|
+
const issues = runDoctor(vaultRoot, config, db);
|
|
852
|
+
db.close();
|
|
853
|
+
if (issues.length === 0) {
|
|
854
|
+
console.log("No issues found. Vault is healthy.");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const errors = issues.filter((i) => i.level === "error");
|
|
858
|
+
const warnings = issues.filter((i) => i.level === "warning");
|
|
859
|
+
const infos = issues.filter((i) => i.level === "info");
|
|
860
|
+
const print = (label, items) => {
|
|
861
|
+
if (items.length === 0) return;
|
|
862
|
+
console.log(`
|
|
863
|
+
${label}:`);
|
|
864
|
+
for (const i of items) {
|
|
865
|
+
const file = i.file.replace(vaultRoot + "/", "");
|
|
866
|
+
console.log(` ${file}: ${i.message}`);
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
print("Errors", errors);
|
|
870
|
+
print("Warnings", warnings);
|
|
871
|
+
print("Info", infos);
|
|
872
|
+
console.log(`
|
|
873
|
+
${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} info(s)`);
|
|
874
|
+
if (errors.length > 0) process.exit(1);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// src/web/server.ts
|
|
878
|
+
import { Hono } from "hono";
|
|
879
|
+
import { serve } from "@hono/node-server";
|
|
880
|
+
import path7 from "path";
|
|
881
|
+
import fs9 from "fs";
|
|
882
|
+
function createApp(vaultRoot, config) {
|
|
883
|
+
const app = new Hono();
|
|
884
|
+
app.get("/api/notes", (c) => {
|
|
885
|
+
const db = ensureIndex(vaultRoot, config);
|
|
886
|
+
const typeFilter = c.req.query("type");
|
|
887
|
+
let query = "SELECT slug, title, type, created, modified, tags FROM notes";
|
|
888
|
+
const params = [];
|
|
889
|
+
if (typeFilter) {
|
|
890
|
+
query += " WHERE type = ?";
|
|
891
|
+
params.push(typeFilter);
|
|
892
|
+
}
|
|
893
|
+
query += " ORDER BY modified DESC";
|
|
894
|
+
const notes = db.prepare(query).all(...params);
|
|
895
|
+
db.close();
|
|
896
|
+
return c.json({ notes });
|
|
897
|
+
});
|
|
898
|
+
app.get("/api/notes/:slug", (c) => {
|
|
899
|
+
const slug = c.req.param("slug");
|
|
900
|
+
const db = ensureIndex(vaultRoot, config);
|
|
901
|
+
const allNotes = listNotes(vaultRoot, config);
|
|
902
|
+
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
903
|
+
if (!note) {
|
|
904
|
+
db.close();
|
|
905
|
+
return c.json({ error: "Note not found" }, 404);
|
|
906
|
+
}
|
|
907
|
+
const links = parseWikilinks(note.body);
|
|
908
|
+
const resolvedLinks = resolveWikilinks(links, allNotes);
|
|
909
|
+
const backlinks = getBacklinks(db, slug);
|
|
910
|
+
db.close();
|
|
911
|
+
return c.json({
|
|
912
|
+
slug: note.slug,
|
|
913
|
+
title: note.frontmatter.title,
|
|
914
|
+
type: note.frontmatter.type,
|
|
915
|
+
created: note.frontmatter.created,
|
|
916
|
+
modified: note.frontmatter.modified,
|
|
917
|
+
tags: note.frontmatter.tags,
|
|
918
|
+
aliases: note.frontmatter.aliases,
|
|
919
|
+
body: note.body,
|
|
920
|
+
outgoing_links: resolvedLinks,
|
|
921
|
+
backlinks
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
app.get("/api/search", (c) => {
|
|
925
|
+
const q = c.req.query("q");
|
|
926
|
+
if (!q) return c.json({ query: "", results: [] });
|
|
927
|
+
const db = ensureIndex(vaultRoot, config);
|
|
928
|
+
const results = searchNotes(db, q);
|
|
929
|
+
db.close();
|
|
930
|
+
return c.json({ query: q, results });
|
|
931
|
+
});
|
|
932
|
+
app.get("/api/backlinks/:slug", (c) => {
|
|
933
|
+
const slug = c.req.param("slug");
|
|
934
|
+
const db = ensureIndex(vaultRoot, config);
|
|
935
|
+
const backlinks = getBacklinks(db, slug);
|
|
936
|
+
db.close();
|
|
937
|
+
return c.json({ slug, backlinks });
|
|
938
|
+
});
|
|
939
|
+
app.get("/api/types", (c) => {
|
|
940
|
+
return c.json({ types: config.note_types });
|
|
941
|
+
});
|
|
942
|
+
app.post("/api/notes", async (c) => {
|
|
943
|
+
const body = await c.req.json();
|
|
944
|
+
const { type, title, body: noteBody } = body;
|
|
945
|
+
if (!type || !title) {
|
|
946
|
+
return c.json({ error: "type and title are required" }, 400);
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
const note = createNote(vaultRoot, config, type, title, noteBody || void 0);
|
|
950
|
+
return c.json({
|
|
951
|
+
slug: note.slug,
|
|
952
|
+
title: note.frontmatter.title,
|
|
953
|
+
type: note.frontmatter.type,
|
|
954
|
+
created: note.frontmatter.created
|
|
955
|
+
});
|
|
956
|
+
} catch (err) {
|
|
957
|
+
return c.json({ error: err.message }, 400);
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
app.get("/api/graph", (c) => {
|
|
961
|
+
const db = ensureIndex(vaultRoot, config);
|
|
962
|
+
const nodes = db.prepare("SELECT slug, title, type FROM notes").all();
|
|
963
|
+
const edges = db.prepare("SELECT source_slug AS source, target_slug AS target FROM links WHERE target_slug IS NOT NULL").all();
|
|
964
|
+
db.close();
|
|
965
|
+
return c.json({ nodes, edges });
|
|
966
|
+
});
|
|
967
|
+
const publicDir = path7.join(import.meta.dirname, "public");
|
|
968
|
+
app.get("/*", (c) => {
|
|
969
|
+
const reqPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
970
|
+
const filePath = path7.join(publicDir, reqPath);
|
|
971
|
+
if (!fs9.existsSync(filePath)) {
|
|
972
|
+
const indexPath = path7.join(publicDir, "index.html");
|
|
973
|
+
const html = fs9.readFileSync(indexPath, "utf-8");
|
|
974
|
+
return c.html(html);
|
|
975
|
+
}
|
|
976
|
+
const content = fs9.readFileSync(filePath);
|
|
977
|
+
const ext = path7.extname(filePath);
|
|
978
|
+
const mimeTypes = {
|
|
979
|
+
".html": "text/html",
|
|
980
|
+
".css": "text/css",
|
|
981
|
+
".js": "application/javascript",
|
|
982
|
+
".json": "application/json",
|
|
983
|
+
".png": "image/png",
|
|
984
|
+
".svg": "image/svg+xml"
|
|
985
|
+
};
|
|
986
|
+
const contentType = mimeTypes[ext] || "application/octet-stream";
|
|
987
|
+
return new Response(content, { headers: { "Content-Type": contentType } });
|
|
988
|
+
});
|
|
989
|
+
return app;
|
|
990
|
+
}
|
|
991
|
+
function startServer(vaultRoot, config, port) {
|
|
992
|
+
const app = createApp(vaultRoot, config);
|
|
993
|
+
console.log(`Granite \u2014 serving vault at http://localhost:${port}`);
|
|
994
|
+
console.log(`Vault: ${vaultRoot}`);
|
|
995
|
+
console.log("");
|
|
996
|
+
serve({ fetch: app.fetch, port });
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/commands/serve.ts
|
|
1000
|
+
function serveCommand(options) {
|
|
1001
|
+
const vaultRoot = requireVaultRoot();
|
|
1002
|
+
const config = loadConfig(vaultRoot);
|
|
1003
|
+
const port = parseInt(process.env.PORT || options.port, 10) || 4321;
|
|
1004
|
+
startServer(vaultRoot, config, port);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/commands/types.ts
|
|
1008
|
+
function typesCommand() {
|
|
1009
|
+
const vaultRoot = requireVaultRoot();
|
|
1010
|
+
const config = loadConfig(vaultRoot);
|
|
1011
|
+
console.log("Available note types:\n");
|
|
1012
|
+
for (const [name, tc] of Object.entries(config.note_types)) {
|
|
1013
|
+
console.log(` ${name}`);
|
|
1014
|
+
console.log(` ${tc.description}`);
|
|
1015
|
+
console.log(` folder: ${tc.folder} | limit: ${tc.line_limit} lines`);
|
|
1016
|
+
if (tc.instructions) {
|
|
1017
|
+
console.log(` guide: ${tc.instructions}`);
|
|
1018
|
+
}
|
|
1019
|
+
console.log("");
|
|
1020
|
+
}
|
|
1021
|
+
console.log(`Default type: ${config.defaults.note_type}`);
|
|
1022
|
+
console.log(`
|
|
1023
|
+
Usage: mem new <type> <title>`);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/commands/list.ts
|
|
1027
|
+
function listCommand(options) {
|
|
1028
|
+
const vaultRoot = requireVaultRoot();
|
|
1029
|
+
const config = loadConfig(vaultRoot);
|
|
1030
|
+
let notes = listNotes(vaultRoot, config);
|
|
1031
|
+
if (options.type) {
|
|
1032
|
+
notes = notes.filter((n) => n.frontmatter.type === options.type);
|
|
1033
|
+
}
|
|
1034
|
+
notes.sort((a, b) => b.frontmatter.modified.localeCompare(a.frontmatter.modified));
|
|
1035
|
+
if (options.json) {
|
|
1036
|
+
const out = notes.map((n) => ({
|
|
1037
|
+
slug: n.slug,
|
|
1038
|
+
title: n.frontmatter.title,
|
|
1039
|
+
type: n.frontmatter.type,
|
|
1040
|
+
created: n.frontmatter.created,
|
|
1041
|
+
modified: n.frontmatter.modified,
|
|
1042
|
+
tags: n.frontmatter.tags,
|
|
1043
|
+
filepath: n.filepath
|
|
1044
|
+
}));
|
|
1045
|
+
console.log(jsonSuccess(out));
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (notes.length === 0) {
|
|
1049
|
+
console.log("No notes found.");
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
for (const note of notes) {
|
|
1053
|
+
const date = note.frontmatter.modified.slice(0, 10);
|
|
1054
|
+
const type = note.frontmatter.type.padEnd(10);
|
|
1055
|
+
console.log(` ${date} ${type} ${note.frontmatter.title} (${note.slug})`);
|
|
1056
|
+
}
|
|
1057
|
+
console.log(`
|
|
1058
|
+
${notes.length} note(s)`);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/commands/edit.ts
|
|
1062
|
+
import fs10 from "fs";
|
|
1063
|
+
import { execSync as execSync2 } from "child_process";
|
|
1064
|
+
function editCommand(slug, options) {
|
|
1065
|
+
const vaultRoot = requireVaultRoot();
|
|
1066
|
+
const config = loadConfig(vaultRoot);
|
|
1067
|
+
const note = findNoteBySlug(vaultRoot, config, slug);
|
|
1068
|
+
if (!note) {
|
|
1069
|
+
console.error(`Note not found: ${slug}`);
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
const hasFlags = options.body !== void 0 || options.append !== void 0 || options.title !== void 0 || options.tag !== void 0 || options.alias !== void 0;
|
|
1073
|
+
if (hasFlags) {
|
|
1074
|
+
let { frontmatter, body } = parseFrontmatter(fs10.readFileSync(note.filepath, "utf-8"));
|
|
1075
|
+
if (options.title) {
|
|
1076
|
+
frontmatter.title = options.title;
|
|
1077
|
+
}
|
|
1078
|
+
if (options.tag) {
|
|
1079
|
+
const newTags = options.tag.split(",").map((t) => t.trim()).filter(Boolean);
|
|
1080
|
+
const existing = new Set(frontmatter.tags);
|
|
1081
|
+
for (const t of newTags) existing.add(t);
|
|
1082
|
+
frontmatter.tags = [...existing];
|
|
1083
|
+
}
|
|
1084
|
+
if (options.alias) {
|
|
1085
|
+
const newAliases = options.alias.split(",").map((a) => a.trim()).filter(Boolean);
|
|
1086
|
+
const existing = new Set(frontmatter.aliases);
|
|
1087
|
+
for (const a of newAliases) existing.add(a);
|
|
1088
|
+
frontmatter.aliases = [...existing];
|
|
1089
|
+
}
|
|
1090
|
+
if (options.body !== void 0) {
|
|
1091
|
+
body = options.body + "\n";
|
|
1092
|
+
}
|
|
1093
|
+
if (options.append !== void 0) {
|
|
1094
|
+
body = body.trimEnd() + "\n" + options.append + "\n";
|
|
1095
|
+
}
|
|
1096
|
+
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1097
|
+
fs10.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
1098
|
+
console.log(note.filepath);
|
|
1099
|
+
} else {
|
|
1100
|
+
const editor = process.env.EDITOR || "vi";
|
|
1101
|
+
const statBefore = fs10.statSync(note.filepath).mtimeMs;
|
|
1102
|
+
try {
|
|
1103
|
+
execSync2(`${editor} "${note.filepath}"`, { stdio: "inherit" });
|
|
1104
|
+
} catch {
|
|
1105
|
+
console.error(`Failed to open editor: ${editor}`);
|
|
1106
|
+
process.exit(1);
|
|
1107
|
+
}
|
|
1108
|
+
const statAfter = fs10.statSync(note.filepath).mtimeMs;
|
|
1109
|
+
if (statAfter !== statBefore) {
|
|
1110
|
+
const { frontmatter, body } = parseFrontmatter(fs10.readFileSync(note.filepath, "utf-8"));
|
|
1111
|
+
frontmatter.modified = (/* @__PURE__ */ new Date()).toISOString();
|
|
1112
|
+
fs10.writeFileSync(note.filepath, serializeFrontmatter(frontmatter, body), "utf-8");
|
|
1113
|
+
console.log(`Updated: ${note.filepath}`);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/index.ts
|
|
1119
|
+
var program = new Command();
|
|
1120
|
+
program.name("mem").description("Granite \u2014 a local-first markdown memory system").version("0.1.0");
|
|
1121
|
+
program.command("init").description("Initialize a new vault in the current directory").action(() => {
|
|
1122
|
+
initVault(process.cwd());
|
|
1123
|
+
});
|
|
1124
|
+
program.command("new").description("Create a new note").argument("<title>", "Note title").option("-t, --type <type>", "Note type (e.g. fleeting, permanent, reference)").option("--json", "Output as JSON (agent-friendly)").action((title, options) => {
|
|
1125
|
+
newNote(title, options);
|
|
1126
|
+
});
|
|
1127
|
+
program.command("add").description("Quick-capture a fleeting note (reads stdin if no text given)").argument("[text]", "Note text (or pipe via stdin)").option("--json", "Output as JSON (agent-friendly)").action((text, options) => {
|
|
1128
|
+
addNote(text, options);
|
|
1129
|
+
});
|
|
1130
|
+
program.command("list").alias("ls").description("List notes").option("-t, --type <type>", "Filter by note type").option("--json", "Output as JSON (agent-friendly)").action((options) => {
|
|
1131
|
+
listCommand(options);
|
|
1132
|
+
});
|
|
1133
|
+
program.command("edit").description("Edit a note (opens $EDITOR, or use flags for programmatic edits)").argument("<slug>", "Note slug").option("--body <text>", "Replace the note body").option("--append <text>", "Append text to the note body").option("--title <title>", "Update the note title").option("--tag <tags>", "Add tags (comma-separated)").option("--alias <aliases>", "Add aliases (comma-separated)").action((slug, options) => {
|
|
1134
|
+
editCommand(slug, options);
|
|
1135
|
+
});
|
|
1136
|
+
program.command("open").description("Open a note in your editor (alias for edit)").argument("<slug>", "Note slug").action((slug) => {
|
|
1137
|
+
openNote(slug);
|
|
1138
|
+
});
|
|
1139
|
+
program.command("show").alias("cat").description("Display a note by slug").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").option("--body", "Output body only (no frontmatter)").action((slug, options) => {
|
|
1140
|
+
showCommand(slug, options);
|
|
1141
|
+
});
|
|
1142
|
+
program.command("search").description("Full-text search across notes").argument("<query>", "Search query").option("--json", "Output as JSON (agent-friendly)").action((query, options) => {
|
|
1143
|
+
searchCommand(query, options);
|
|
1144
|
+
});
|
|
1145
|
+
program.command("backlinks").description("Show notes that link to a given note").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").action((slug, options) => {
|
|
1146
|
+
backlinksCommand(slug, options);
|
|
1147
|
+
});
|
|
1148
|
+
program.command("suggest-links").description("Suggest wikilinks based on unlinked mentions").argument("<slug>", "Note slug").option("--json", "Output as JSON (agent-friendly)").action((slug, options) => {
|
|
1149
|
+
suggestLinksCommand(slug, options);
|
|
1150
|
+
});
|
|
1151
|
+
program.command("types").description("List available note types").action(() => {
|
|
1152
|
+
typesCommand();
|
|
1153
|
+
});
|
|
1154
|
+
program.command("doctor").description("Validate vault health").action(() => {
|
|
1155
|
+
doctorCommand();
|
|
1156
|
+
});
|
|
1157
|
+
program.command("serve").description("Start the local web UI").option("-p, --port <port>", "Port number", "4321").action((options) => {
|
|
1158
|
+
serveCommand(options);
|
|
1159
|
+
});
|
|
1160
|
+
program.parse();
|