|
9 | 9 | * PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode") |
10 | 10 | */ |
11 | 11 |
|
12 | | -import { mkdirSync } from "fs"; |
| 12 | +import { mkdirSync, existsSync, statSync } from "fs"; |
13 | 13 | import { resolve } from "path"; |
14 | 14 | import { isRemoteSession, getServerPort } from "./remote"; |
15 | 15 | import { openBrowser } from "./browser"; |
@@ -350,6 +350,84 @@ export async function startPlannotatorServer( |
350 | 350 | return Response.json({ vaults }); |
351 | 351 | } |
352 | 352 |
|
| 353 | + // API: List Obsidian vault files as a tree |
| 354 | + if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") { |
| 355 | + const vaultPath = url.searchParams.get("vaultPath"); |
| 356 | + if (!vaultPath) { |
| 357 | + return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 }); |
| 358 | + } |
| 359 | + |
| 360 | + const resolvedVault = resolve(vaultPath); |
| 361 | + if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) { |
| 362 | + return Response.json({ error: "Invalid vault path" }, { status: 400 }); |
| 363 | + } |
| 364 | + |
| 365 | + try { |
| 366 | + const glob = new Bun.Glob("**/*.md"); |
| 367 | + const files: string[] = []; |
| 368 | + for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { |
| 369 | + if (match.includes(".obsidian/") || match.includes(".trash/")) continue; |
| 370 | + files.push(match); |
| 371 | + } |
| 372 | + files.sort(); |
| 373 | + |
| 374 | + const tree = buildFileTree(files); |
| 375 | + return Response.json({ tree }); |
| 376 | + } catch { |
| 377 | + return Response.json({ error: "Failed to list vault files" }, { status: 500 }); |
| 378 | + } |
| 379 | + } |
| 380 | + |
| 381 | + // API: Read an Obsidian vault document |
| 382 | + if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") { |
| 383 | + const vaultPath = url.searchParams.get("vaultPath"); |
| 384 | + const filePath = url.searchParams.get("path"); |
| 385 | + if (!vaultPath || !filePath) { |
| 386 | + return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 }); |
| 387 | + } |
| 388 | + if (!/\.mdx?$/i.test(filePath)) { |
| 389 | + return Response.json({ error: "Only markdown files are supported" }, { status: 400 }); |
| 390 | + } |
| 391 | + |
| 392 | + const resolvedVault = resolve(vaultPath); |
| 393 | + let resolvedFile = resolve(resolvedVault, filePath); |
| 394 | + |
| 395 | + // If direct path doesn't exist and it's a bare filename, search the vault |
| 396 | + if (!existsSync(resolvedFile) && !filePath.includes("/")) { |
| 397 | + const glob = new Bun.Glob(`**/${filePath}`); |
| 398 | + const matches: string[] = []; |
| 399 | + for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) { |
| 400 | + if (match.includes(".obsidian/") || match.includes(".trash/")) continue; |
| 401 | + matches.push(resolve(resolvedVault, match)); |
| 402 | + } |
| 403 | + if (matches.length === 1) { |
| 404 | + resolvedFile = matches[0]; |
| 405 | + } else if (matches.length > 1) { |
| 406 | + const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", "")); |
| 407 | + return Response.json( |
| 408 | + { error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths }, |
| 409 | + { status: 400 } |
| 410 | + ); |
| 411 | + } |
| 412 | + } |
| 413 | + |
| 414 | + // Security: must be within vault |
| 415 | + if (!resolvedFile.startsWith(resolvedVault + "/")) { |
| 416 | + return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 }); |
| 417 | + } |
| 418 | + |
| 419 | + try { |
| 420 | + const file = Bun.file(resolvedFile); |
| 421 | + if (!(await file.exists())) { |
| 422 | + return Response.json({ error: `File not found: ${filePath}` }, { status: 404 }); |
| 423 | + } |
| 424 | + const markdown = await file.text(); |
| 425 | + return Response.json({ markdown, filepath: resolvedFile }); |
| 426 | + } catch { |
| 427 | + return Response.json({ error: "Failed to read file" }, { status: 500 }); |
| 428 | + } |
| 429 | + } |
| 430 | + |
353 | 431 | // API: Get available agents (OpenCode only) |
354 | 432 | if (url.pathname === "/api/agents") { |
355 | 433 | if (!options.opencodeClient) { |
@@ -572,3 +650,56 @@ export async function handleServerReady( |
572 | 650 | await openBrowser(url); |
573 | 651 | } |
574 | 652 | } |
| 653 | + |
| 654 | +// --- Vault file tree helpers --- |
| 655 | + |
| 656 | +export interface VaultNode { |
| 657 | + name: string; |
| 658 | + path: string; // relative path within vault |
| 659 | + type: "file" | "folder"; |
| 660 | + children?: VaultNode[]; |
| 661 | +} |
| 662 | + |
| 663 | +/** |
| 664 | + * Build a nested file tree from a sorted list of relative paths. |
| 665 | + * Folders are sorted before files at each level. |
| 666 | + */ |
| 667 | +function buildFileTree(relativePaths: string[]): VaultNode[] { |
| 668 | + const root: VaultNode[] = []; |
| 669 | + |
| 670 | + for (const filePath of relativePaths) { |
| 671 | + const parts = filePath.split("/"); |
| 672 | + let current = root; |
| 673 | + let pathSoFar = ""; |
| 674 | + |
| 675 | + for (let i = 0; i < parts.length; i++) { |
| 676 | + const part = parts[i]; |
| 677 | + pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part; |
| 678 | + const isFile = i === parts.length - 1; |
| 679 | + |
| 680 | + let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder")); |
| 681 | + if (!node) { |
| 682 | + node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" }; |
| 683 | + if (!isFile) node.children = []; |
| 684 | + current.push(node); |
| 685 | + } |
| 686 | + if (!isFile) { |
| 687 | + current = node.children!; |
| 688 | + } |
| 689 | + } |
| 690 | + } |
| 691 | + |
| 692 | + // Sort: folders first (alphabetical), then files (alphabetical) |
| 693 | + const sortNodes = (nodes: VaultNode[]) => { |
| 694 | + nodes.sort((a, b) => { |
| 695 | + if (a.type !== b.type) return a.type === "folder" ? -1 : 1; |
| 696 | + return a.name.localeCompare(b.name); |
| 697 | + }); |
| 698 | + for (const node of nodes) { |
| 699 | + if (node.children) sortNodes(node.children); |
| 700 | + } |
| 701 | + }; |
| 702 | + sortNodes(root); |
| 703 | + |
| 704 | + return root; |
| 705 | +} |
0 commit comments