fathom-mcp 0.1.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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/package.json +43 -0
- package/scripts/fathom-context.sh +65 -0
- package/scripts/fathom-precompact.sh +68 -0
- package/src/cli.js +347 -0
- package/src/config.js +112 -0
- package/src/index.js +460 -0
- package/src/server-client.js +170 -0
- package/src/vault-ops.js +386 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for fathom-server REST API.
|
|
3
|
+
*
|
|
4
|
+
* Handles search, rooms, workspaces, and access notifications.
|
|
5
|
+
* Uses native fetch (Node 18+). Auth via Bearer token from .fathom.json.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} config - Resolved config from config.js
|
|
10
|
+
*/
|
|
11
|
+
export function createClient(config) {
|
|
12
|
+
const baseUrl = config.server;
|
|
13
|
+
const apiKey = config.apiKey;
|
|
14
|
+
const workspace = config.workspace;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Make an authenticated request to the server.
|
|
18
|
+
* Returns parsed JSON on success, { error } on failure.
|
|
19
|
+
*/
|
|
20
|
+
async function request(method, path, { params, body, timeout = 30000 } = {}) {
|
|
21
|
+
const url = new URL(path, baseUrl);
|
|
22
|
+
if (params) {
|
|
23
|
+
for (const [k, v] of Object.entries(params)) {
|
|
24
|
+
if (v != null) url.searchParams.set(k, String(v));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const headers = { "Content-Type": "application/json" };
|
|
29
|
+
if (apiKey) {
|
|
30
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const resp = await fetch(url.toString(), {
|
|
38
|
+
method,
|
|
39
|
+
headers,
|
|
40
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
41
|
+
signal: controller.signal,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const data = await resp.json();
|
|
45
|
+
|
|
46
|
+
if (!resp.ok) {
|
|
47
|
+
return { error: data.error || `Server returned ${resp.status}` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return data;
|
|
51
|
+
} catch (e) {
|
|
52
|
+
if (e.name === "AbortError") {
|
|
53
|
+
return { error: `Request timed out after ${timeout / 1000}s` };
|
|
54
|
+
}
|
|
55
|
+
return { error: `Server unavailable: ${e.message}` };
|
|
56
|
+
} finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Search ----------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
async function search(query, { mode = "bm25", limit, ws } = {}) {
|
|
64
|
+
return request("GET", "/api/search", {
|
|
65
|
+
params: { q: query, mode, n: limit, workspace: ws || workspace },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function vsearch(query, { limit, ws } = {}) {
|
|
70
|
+
return search(query, { mode: "vector", limit, ws });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function hybridSearch(query, { limit, ws } = {}) {
|
|
74
|
+
return search(query, { mode: "hybrid", limit, ws });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- Rooms -----------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
async function roomPost(room, message, sender) {
|
|
80
|
+
return request("POST", `/api/room/${encodeURIComponent(room)}`, {
|
|
81
|
+
body: { message, sender },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function roomRead(room, hours) {
|
|
86
|
+
return request("GET", `/api/room/${encodeURIComponent(room)}`, {
|
|
87
|
+
params: { hours },
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function roomList() {
|
|
92
|
+
return request("GET", "/api/room/list");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function roomDescribe(room, description) {
|
|
96
|
+
return request("PUT", `/api/room/${encodeURIComponent(room)}/description`, {
|
|
97
|
+
body: { description },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Workspaces ------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async function listWorkspaces() {
|
|
104
|
+
return request("GET", "/api/workspaces/profiles");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function registerWorkspace(name, projectPath) {
|
|
108
|
+
return request("POST", "/api/workspaces", {
|
|
109
|
+
body: { name, path: projectPath },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Access tracking -------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
async function notifyAccess(filePath, ws) {
|
|
116
|
+
return request("POST", "/api/vault/access", {
|
|
117
|
+
params: { workspace: ws || workspace },
|
|
118
|
+
body: { path: filePath },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- Activity-enriched listings (via server) --------------------------------
|
|
123
|
+
|
|
124
|
+
async function vaultList(ws) {
|
|
125
|
+
return request("GET", "/api/vault", {
|
|
126
|
+
params: { workspace: ws || workspace },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function vaultFolder(folder, ws) {
|
|
131
|
+
const folderPath = folder || "";
|
|
132
|
+
return request("GET", `/api/vault/folder/${folderPath}`, {
|
|
133
|
+
params: { workspace: ws || workspace },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Auth ------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
async function getApiKey() {
|
|
140
|
+
return request("GET", "/api/auth/key");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function healthCheck() {
|
|
144
|
+
try {
|
|
145
|
+
const resp = await fetch(`${baseUrl}/api/auth/status`, {
|
|
146
|
+
signal: AbortSignal.timeout(5000),
|
|
147
|
+
});
|
|
148
|
+
return resp.ok;
|
|
149
|
+
} catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
search,
|
|
156
|
+
vsearch,
|
|
157
|
+
hybridSearch,
|
|
158
|
+
roomPost,
|
|
159
|
+
roomRead,
|
|
160
|
+
roomList,
|
|
161
|
+
roomDescribe,
|
|
162
|
+
listWorkspaces,
|
|
163
|
+
registerWorkspace,
|
|
164
|
+
notifyAccess,
|
|
165
|
+
vaultList,
|
|
166
|
+
vaultFolder,
|
|
167
|
+
getApiKey,
|
|
168
|
+
healthCheck,
|
|
169
|
+
};
|
|
170
|
+
}
|
package/src/vault-ops.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct file I/O vault operations — read, write, append, frontmatter, images.
|
|
3
|
+
*
|
|
4
|
+
* These run locally (no network hop) for speed. The server is only notified
|
|
5
|
+
* after writes for access tracking / indexing.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
|
|
11
|
+
// --- Constants ---------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
const VALID_STATUSES = new Set(["draft", "published", "archived"]);
|
|
14
|
+
const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]);
|
|
15
|
+
const IMAGE_MIME_TYPES = {
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".png": "image/png",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
".webp": "image/webp",
|
|
21
|
+
".svg": "image/svg+xml",
|
|
22
|
+
};
|
|
23
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB
|
|
24
|
+
const VAULT_SCHEMA = {
|
|
25
|
+
title: { required: true, type: "string" },
|
|
26
|
+
date: { required: true, type: "string" },
|
|
27
|
+
tags: { required: false, type: "array" },
|
|
28
|
+
status: { required: false, type: "string" },
|
|
29
|
+
project: { required: false, type: "string" },
|
|
30
|
+
aliases: { required: false, type: "array" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// --- Path safety -------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve and validate a relative path within a vault directory.
|
|
37
|
+
* Returns { abs, vaultPath } on success, { error } on failure.
|
|
38
|
+
*/
|
|
39
|
+
export function safePath(relPath, vaultPath) {
|
|
40
|
+
if (!vaultPath || typeof vaultPath !== "string") {
|
|
41
|
+
return { error: "Vault path not configured" };
|
|
42
|
+
}
|
|
43
|
+
const abs = path.resolve(vaultPath, relPath);
|
|
44
|
+
if (abs !== vaultPath && !abs.startsWith(vaultPath + path.sep)) {
|
|
45
|
+
return { error: "Path traversal detected" };
|
|
46
|
+
}
|
|
47
|
+
return { abs, vaultPath };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Frontmatter -------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
export function parseFrontmatter(content) {
|
|
53
|
+
if (!content.startsWith("---")) return { fm: {}, body: content };
|
|
54
|
+
const lines = content.split("\n");
|
|
55
|
+
let endIdx = null;
|
|
56
|
+
for (let i = 1; i < lines.length; i++) {
|
|
57
|
+
if (lines[i].trim() === "---") { endIdx = i; break; }
|
|
58
|
+
}
|
|
59
|
+
if (endIdx === null) return { fm: {}, body: content };
|
|
60
|
+
try {
|
|
61
|
+
const fmLines = lines.slice(1, endIdx);
|
|
62
|
+
const fm = {};
|
|
63
|
+
let currentKey = null;
|
|
64
|
+
for (const line of fmLines) {
|
|
65
|
+
const listMatch = line.match(/^[ ]{2}- (.+)$/);
|
|
66
|
+
const kvMatch = line.match(/^(\w+):\s*(.*)$/);
|
|
67
|
+
if (listMatch && currentKey) {
|
|
68
|
+
fm[currentKey].push(listMatch[1].trim());
|
|
69
|
+
} else if (kvMatch) {
|
|
70
|
+
currentKey = kvMatch[1];
|
|
71
|
+
const val = kvMatch[2].trim();
|
|
72
|
+
if (val === "") {
|
|
73
|
+
fm[currentKey] = [];
|
|
74
|
+
} else {
|
|
75
|
+
fm[currentKey] = val;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const body = lines.slice(endIdx + 1).join("\n");
|
|
80
|
+
return { fm, body };
|
|
81
|
+
} catch {
|
|
82
|
+
return { fm: {}, body: content };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateFrontmatter(fm) {
|
|
87
|
+
const errors = [];
|
|
88
|
+
for (const [field, spec] of Object.entries(VAULT_SCHEMA)) {
|
|
89
|
+
const val = fm[field];
|
|
90
|
+
if (spec.required && val == null) {
|
|
91
|
+
errors.push(`Missing required field: '${field}'`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (val != null) {
|
|
95
|
+
const actualType = Array.isArray(val) ? "array" : typeof val;
|
|
96
|
+
if (actualType !== spec.type) {
|
|
97
|
+
errors.push(`Field '${field}' must be ${spec.type}, got ${actualType}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const status = fm["status"];
|
|
102
|
+
if (status != null && !VALID_STATUSES.has(status)) {
|
|
103
|
+
errors.push(`Field 'status' must be one of [${[...VALID_STATUSES].join(", ")}], got '${status}'`);
|
|
104
|
+
}
|
|
105
|
+
return errors;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- File handlers -----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export function handleVaultWrite({ path: relPath, content }, vaultPath) {
|
|
111
|
+
if (!relPath) return { error: "path is required" };
|
|
112
|
+
if (typeof content !== "string") return { error: "content must be a string" };
|
|
113
|
+
|
|
114
|
+
const { abs, error } = safePath(relPath, vaultPath);
|
|
115
|
+
if (error) return { error };
|
|
116
|
+
|
|
117
|
+
if (content.startsWith("---")) {
|
|
118
|
+
const { fm } = parseFrontmatter(content);
|
|
119
|
+
if (Object.keys(fm).length > 0) {
|
|
120
|
+
const errors = validateFrontmatter(fm);
|
|
121
|
+
if (errors.length > 0) {
|
|
122
|
+
return { error: "Frontmatter validation failed", validation_errors: errors };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
129
|
+
fs.writeFileSync(abs, content, "utf-8");
|
|
130
|
+
return { ok: true, path: relPath };
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { error: e.message };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function handleVaultAppend({ path: relPath, content }, vaultPath) {
|
|
137
|
+
if (!relPath) return { error: "path is required" };
|
|
138
|
+
if (typeof content !== "string") return { error: "content must be a string" };
|
|
139
|
+
|
|
140
|
+
const { abs, error } = safePath(relPath, vaultPath);
|
|
141
|
+
if (error) return { error };
|
|
142
|
+
|
|
143
|
+
const created = !fs.existsSync(abs);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
147
|
+
if (created) {
|
|
148
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
149
|
+
const title = path.basename(relPath, path.extname(relPath))
|
|
150
|
+
.replace(/-/g, " ")
|
|
151
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
152
|
+
const initial = `---\ntitle: ${title}\ndate: ${today}\n---\n\n${content}\n`;
|
|
153
|
+
fs.writeFileSync(abs, initial, "utf-8");
|
|
154
|
+
} else {
|
|
155
|
+
fs.appendFileSync(abs, "\n" + content + "\n", "utf-8");
|
|
156
|
+
}
|
|
157
|
+
return { ok: true, path: relPath, created };
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return { error: e.message };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function handleVaultRead({ path: relPath }, vaultPath) {
|
|
164
|
+
if (!relPath) return { error: "path is required" };
|
|
165
|
+
|
|
166
|
+
const { abs, error } = safePath(relPath, vaultPath);
|
|
167
|
+
if (error) return { error };
|
|
168
|
+
|
|
169
|
+
if (!fs.existsSync(abs)) return { error: "File not found" };
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const content = fs.readFileSync(abs, "utf-8");
|
|
173
|
+
const stat = fs.statSync(abs);
|
|
174
|
+
const { fm, body } = parseFrontmatter(content);
|
|
175
|
+
return {
|
|
176
|
+
path: relPath,
|
|
177
|
+
content,
|
|
178
|
+
frontmatter: fm,
|
|
179
|
+
body,
|
|
180
|
+
modified: stat.mtime.toISOString(),
|
|
181
|
+
size: stat.size,
|
|
182
|
+
};
|
|
183
|
+
} catch (e) {
|
|
184
|
+
return { error: e.message };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- List/folder handlers ----------------------------------------------------
|
|
189
|
+
|
|
190
|
+
export function handleVaultList(vaultPath) {
|
|
191
|
+
if (!vaultPath) return { error: "Vault path not configured" };
|
|
192
|
+
|
|
193
|
+
const allFolders = [];
|
|
194
|
+
|
|
195
|
+
function scanDir(dirAbs, relPath) {
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = fs.readdirSync(dirAbs, { withFileTypes: true });
|
|
199
|
+
} catch {
|
|
200
|
+
return { totalMdCount: 0, maxMtime: null, maxMtimeFile: null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const childDirNames = entries
|
|
204
|
+
.filter(e => e.isDirectory() && !e.name.startsWith("."))
|
|
205
|
+
.map(e => e.name);
|
|
206
|
+
const mdFiles = entries
|
|
207
|
+
.filter(e => e.isFile() && e.name.endsWith(".md"))
|
|
208
|
+
.map(e => e.name);
|
|
209
|
+
|
|
210
|
+
let maxMtime = null;
|
|
211
|
+
let maxMtimeFile = null;
|
|
212
|
+
let totalMdCount = mdFiles.length;
|
|
213
|
+
|
|
214
|
+
for (const fname of mdFiles) {
|
|
215
|
+
try {
|
|
216
|
+
const mtime = fs.statSync(path.join(dirAbs, fname)).mtime;
|
|
217
|
+
if (!maxMtime || mtime > maxMtime) {
|
|
218
|
+
maxMtime = mtime;
|
|
219
|
+
maxMtimeFile = fname;
|
|
220
|
+
}
|
|
221
|
+
} catch { /* skip */ }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const childName of childDirNames) {
|
|
225
|
+
const childAbs = path.join(dirAbs, childName);
|
|
226
|
+
const childRel = relPath ? `${relPath}/${childName}` : childName;
|
|
227
|
+
const child = scanDir(childAbs, childRel);
|
|
228
|
+
totalMdCount += child.totalMdCount;
|
|
229
|
+
if (child.maxMtime && (!maxMtime || child.maxMtime > maxMtime)) {
|
|
230
|
+
maxMtime = child.maxMtime;
|
|
231
|
+
maxMtimeFile = child.maxMtimeFile;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (relPath) {
|
|
236
|
+
allFolders.push({
|
|
237
|
+
name: path.basename(dirAbs),
|
|
238
|
+
path: relPath,
|
|
239
|
+
file_count: totalMdCount,
|
|
240
|
+
last_modified: maxMtime ? maxMtime.toISOString() : null,
|
|
241
|
+
last_modified_file: maxMtimeFile,
|
|
242
|
+
children: childDirNames,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { totalMdCount, maxMtime, maxMtimeFile };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
scanDir(vaultPath, "");
|
|
251
|
+
allFolders.sort((a, b) => {
|
|
252
|
+
if (!a.last_modified && !b.last_modified) return 0;
|
|
253
|
+
if (!a.last_modified) return 1;
|
|
254
|
+
if (!b.last_modified) return -1;
|
|
255
|
+
return b.last_modified.localeCompare(a.last_modified);
|
|
256
|
+
});
|
|
257
|
+
return allFolders;
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return { error: e.message };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function handleVaultFolder({ folder = "", limit = 50, sort = "modified", recursive = false, tag } = {}, vaultPath) {
|
|
264
|
+
if (!vaultPath) return { error: "Vault path not configured" };
|
|
265
|
+
|
|
266
|
+
const targetAbs = folder ? path.resolve(vaultPath, folder) : vaultPath;
|
|
267
|
+
if (targetAbs !== vaultPath && !targetAbs.startsWith(vaultPath + path.sep)) {
|
|
268
|
+
return { error: "Path traversal detected" };
|
|
269
|
+
}
|
|
270
|
+
if (!fs.existsSync(targetAbs)) return { error: `Folder not found: ${folder || "(root)"}` };
|
|
271
|
+
if (!fs.statSync(targetAbs).isDirectory()) return { error: `Not a directory: ${folder}` };
|
|
272
|
+
|
|
273
|
+
const items = [];
|
|
274
|
+
const tagLower = tag ? tag.toLowerCase() : null;
|
|
275
|
+
|
|
276
|
+
function collect(dirAbs) {
|
|
277
|
+
let entries;
|
|
278
|
+
try {
|
|
279
|
+
entries = fs.readdirSync(dirAbs, { withFileTypes: true });
|
|
280
|
+
} catch {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
for (const e of entries) {
|
|
284
|
+
const absPath = path.join(dirAbs, e.name);
|
|
285
|
+
if (e.isDirectory() && recursive && !e.name.startsWith(".")) {
|
|
286
|
+
collect(absPath);
|
|
287
|
+
} else if (e.isFile() && e.name.endsWith(".md")) {
|
|
288
|
+
try {
|
|
289
|
+
const stat = fs.statSync(absPath);
|
|
290
|
+
const content = fs.readFileSync(absPath, "utf-8");
|
|
291
|
+
const { fm, body } = parseFrontmatter(content);
|
|
292
|
+
|
|
293
|
+
if (tagLower) {
|
|
294
|
+
const fmTags = Array.isArray(fm.tags) ? fm.tags : [];
|
|
295
|
+
if (!fmTags.some(t => String(t).toLowerCase() === tagLower)) continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
items.push({
|
|
299
|
+
path: path.relative(vaultPath, absPath),
|
|
300
|
+
title: fm.title || null,
|
|
301
|
+
date: fm.date || null,
|
|
302
|
+
tags: Array.isArray(fm.tags) ? fm.tags : [],
|
|
303
|
+
status: fm.status || null,
|
|
304
|
+
project: fm.project || null,
|
|
305
|
+
preview: body.trim().slice(0, 200),
|
|
306
|
+
modified: stat.mtime.toISOString(),
|
|
307
|
+
size_bytes: stat.size,
|
|
308
|
+
});
|
|
309
|
+
} catch { /* skip */ }
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
collect(targetAbs);
|
|
316
|
+
if (sort === "name") {
|
|
317
|
+
items.sort((a, b) => a.path.localeCompare(b.path));
|
|
318
|
+
} else {
|
|
319
|
+
items.sort((a, b) => b.modified.localeCompare(a.modified));
|
|
320
|
+
}
|
|
321
|
+
return { folder: folder || "", total: items.length, files: items.slice(0, limit) };
|
|
322
|
+
} catch (e) {
|
|
323
|
+
return { error: e.message };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- Image handlers ----------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
export function handleVaultImage({ path: relPath }, vaultPath) {
|
|
330
|
+
if (!relPath) return { error: "path is required" };
|
|
331
|
+
|
|
332
|
+
const { abs, error } = safePath(relPath, vaultPath);
|
|
333
|
+
if (error) return { error };
|
|
334
|
+
|
|
335
|
+
const ext = path.extname(abs).toLowerCase();
|
|
336
|
+
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
337
|
+
return {
|
|
338
|
+
error: `Not an allowed image extension: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (!fs.existsSync(abs)) return { error: `File not found: ${relPath}` };
|
|
343
|
+
|
|
344
|
+
const stat = fs.statSync(abs);
|
|
345
|
+
if (stat.size > MAX_IMAGE_BYTES) {
|
|
346
|
+
return { error: `Image too large (${stat.size} bytes, max 5MB)` };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const mimeType = IMAGE_MIME_TYPES[ext];
|
|
350
|
+
const data = fs.readFileSync(abs).toString("base64");
|
|
351
|
+
return { _image: true, data, mimeType };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function handleVaultWriteAsset({ folder, filename, data }, vaultPath) {
|
|
355
|
+
if (typeof folder !== "string") return { error: "folder is required (use empty string for root)" };
|
|
356
|
+
if (!filename) return { error: "filename is required" };
|
|
357
|
+
if (!data) return { error: "data is required" };
|
|
358
|
+
|
|
359
|
+
const ext = path.extname(filename).toLowerCase();
|
|
360
|
+
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
361
|
+
return {
|
|
362
|
+
error: `Not an allowed image extension: ${ext}. Allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const relPath = folder
|
|
367
|
+
? path.join(folder, "assets", filename)
|
|
368
|
+
: path.join("assets", filename);
|
|
369
|
+
|
|
370
|
+
const { abs, error } = safePath(relPath, vaultPath);
|
|
371
|
+
if (error) return { error };
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
375
|
+
const buffer = Buffer.from(data, "base64");
|
|
376
|
+
fs.writeFileSync(abs, buffer);
|
|
377
|
+
return {
|
|
378
|
+
saved: true,
|
|
379
|
+
path: relPath,
|
|
380
|
+
markdown: ``,
|
|
381
|
+
fullPath: abs,
|
|
382
|
+
};
|
|
383
|
+
} catch (e) {
|
|
384
|
+
return { error: e.message };
|
|
385
|
+
}
|
|
386
|
+
}
|