mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
refactor: extract shared route handlers and modularize plan server (#214)
* refactor: extract shared route handlers and modularize plan server Pull duplicated /api/image, /api/upload, /api/agents handlers and handleServerReady into shared-handlers.ts. Move /api/doc and Obsidian reference endpoints into reference-handlers.ts. Eliminates ~120 lines of verbatim duplication across index.ts, review.ts, and annotate.ts. Closes #210 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: deduplicate OpencodeClient type and fix stale module comment Export OpencodeClient interface from shared-handlers.ts and import it in index.ts and review.ts instead of redefining inline. Also corrects the module doc comment about /api/agents usage scope. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,15 +11,14 @@
|
||||
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "fs";
|
||||
import { isRemoteSession, getServerPort } from "./remote";
|
||||
import { openBrowser } from "./browser";
|
||||
import { getRepoInfo } from "./repo";
|
||||
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
|
||||
import { handleImage, handleUpload, handleServerReady } from "./shared-handlers";
|
||||
|
||||
// Re-export utilities
|
||||
export { isRemoteSession, getServerPort } from "./remote";
|
||||
export { openBrowser } from "./browser";
|
||||
export { handleServerReady as handleAnnotateServerReady } from "./shared-handlers";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -126,48 +125,12 @@ export async function startAnnotateServer(
|
||||
|
||||
// API: Serve images (local paths or temp uploads)
|
||||
if (url.pathname === "/api/image") {
|
||||
const imagePath = url.searchParams.get("path");
|
||||
if (!imagePath) {
|
||||
return new Response("Missing path parameter", { status: 400 });
|
||||
}
|
||||
const validation = validateImagePath(imagePath);
|
||||
if (!validation.valid) {
|
||||
return new Response(validation.error!, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const file = Bun.file(validation.resolved);
|
||||
if (!(await file.exists())) {
|
||||
return new Response("File not found", { status: 404 });
|
||||
}
|
||||
return new Response(file);
|
||||
} catch {
|
||||
return new Response("Failed to read file", { status: 500 });
|
||||
}
|
||||
return handleImage(req);
|
||||
}
|
||||
|
||||
// API: Upload image -> save to temp -> return path
|
||||
if (url.pathname === "/api/upload" && req.method === "POST") {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
if (!file) {
|
||||
return new Response("No file provided", { status: 400 });
|
||||
}
|
||||
|
||||
const extResult = validateUploadExtension(file.name);
|
||||
if (!extResult.valid) {
|
||||
return Response.json({ error: extResult.error }, { status: 400 });
|
||||
}
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
|
||||
|
||||
await Bun.write(tempPath, file);
|
||||
return Response.json({ path: tempPath, originalName: file.name });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Upload failed";
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
return handleUpload(req);
|
||||
}
|
||||
|
||||
// API: Submit annotation feedback
|
||||
@@ -242,16 +205,3 @@ export async function startAnnotateServer(
|
||||
stop: () => server.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default behavior: open browser for local sessions
|
||||
*/
|
||||
export async function handleAnnotateServerReady(
|
||||
url: string,
|
||||
isRemote: boolean,
|
||||
_port: number
|
||||
): Promise<void> {
|
||||
if (!isRemote) {
|
||||
await openBrowser(url);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-267
@@ -9,14 +9,10 @@
|
||||
* PLANNOTATOR_ORIGIN - Origin identifier ("claude-code" or "opencode")
|
||||
*/
|
||||
|
||||
import { mkdirSync, existsSync, statSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { isRemoteSession, getServerPort } from "./remote";
|
||||
import { openBrowser } from "./browser";
|
||||
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
|
||||
import { openEditorDiff } from "./ide";
|
||||
import {
|
||||
detectObsidianVaults,
|
||||
saveToObsidian,
|
||||
saveToBear,
|
||||
type ObsidianConfig,
|
||||
@@ -37,12 +33,16 @@ import {
|
||||
} from "./storage";
|
||||
import { getRepoInfo } from "./repo";
|
||||
import { detectProjectName } from "./project";
|
||||
import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers";
|
||||
import { handleDoc, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers";
|
||||
|
||||
// Re-export utilities
|
||||
export { isRemoteSession, getServerPort } from "./remote";
|
||||
export { openBrowser } from "./browser";
|
||||
export * from "./integrations";
|
||||
export * from "./storage";
|
||||
export { handleServerReady } from "./shared-handlers";
|
||||
export { type VaultNode, buildFileTree } from "./reference-handlers";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -64,11 +64,7 @@ export interface ServerOptions {
|
||||
/** Called when server starts with the URL, remote status, and port */
|
||||
onReady?: (url: string, isRemote: boolean, port: number) => void;
|
||||
/** OpenCode client for querying available agents (OpenCode only) */
|
||||
opencodeClient?: {
|
||||
app: {
|
||||
agents: (options?: object) => Promise<{ data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }> }>;
|
||||
};
|
||||
};
|
||||
opencodeClient?: OpencodeClient;
|
||||
}
|
||||
|
||||
export interface ServerResult {
|
||||
@@ -203,120 +199,17 @@ export async function startPlannotatorServer(
|
||||
|
||||
// API: Serve a linked markdown document
|
||||
if (url.pathname === "/api/doc" && req.method === "GET") {
|
||||
const requestedPath = url.searchParams.get("path");
|
||||
if (!requestedPath) {
|
||||
return Response.json({ error: "Missing path parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Restrict to markdown files only
|
||||
if (!/\.mdx?$/i.test(requestedPath)) {
|
||||
return Response.json({ error: "Only .md and .mdx files are supported" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Path resolution: 3 strategies in order
|
||||
let resolvedPath: string | null = null;
|
||||
|
||||
if (requestedPath.startsWith("/")) {
|
||||
// 1. Absolute path
|
||||
resolvedPath = requestedPath;
|
||||
} else {
|
||||
// 2. Relative to project root
|
||||
const fromRoot = resolve(projectRoot, requestedPath);
|
||||
if (await Bun.file(fromRoot).exists()) {
|
||||
resolvedPath = fromRoot;
|
||||
}
|
||||
|
||||
// 3. Bare filename — search entire project for unique match
|
||||
if (!resolvedPath && !requestedPath.includes("/")) {
|
||||
const glob = new Bun.Glob(`**/${requestedPath}`);
|
||||
const matches: string[] = [];
|
||||
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
|
||||
if (match.includes("node_modules/") || match.includes(".git/")) continue;
|
||||
if (match.split("/").pop() === requestedPath) {
|
||||
matches.push(resolve(projectRoot, match));
|
||||
}
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
resolvedPath = matches[0];
|
||||
} else if (matches.length > 1) {
|
||||
const relativePaths = matches.map((m) => m.replace(projectRoot + "/", ""));
|
||||
return Response.json(
|
||||
{ error: `Ambiguous filename '${requestedPath}': found ${matches.length} matches`, matches: relativePaths },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
|
||||
}
|
||||
|
||||
// Security: path must stay within projectRoot
|
||||
const normalised = resolve(resolvedPath);
|
||||
if (!normalised.startsWith(projectRoot + "/") && normalised !== projectRoot) {
|
||||
return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(normalised);
|
||||
if (!(await file.exists())) {
|
||||
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const markdown = await file.text();
|
||||
return Response.json({ markdown, filepath: normalised });
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to read file" }, { status: 500 });
|
||||
}
|
||||
return handleDoc(req);
|
||||
}
|
||||
|
||||
// API: Serve images (local paths or temp uploads)
|
||||
if (url.pathname === "/api/image") {
|
||||
const imagePath = url.searchParams.get("path");
|
||||
if (!imagePath) {
|
||||
return new Response("Missing path parameter", { status: 400 });
|
||||
}
|
||||
const validation = validateImagePath(imagePath);
|
||||
if (!validation.valid) {
|
||||
return new Response(validation.error!, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const file = Bun.file(validation.resolved);
|
||||
if (!(await file.exists())) {
|
||||
return new Response("File not found", { status: 404 });
|
||||
}
|
||||
return new Response(file);
|
||||
} catch {
|
||||
return new Response("Failed to read file", { status: 500 });
|
||||
}
|
||||
return handleImage(req);
|
||||
}
|
||||
|
||||
// API: Upload image -> save to temp -> return path
|
||||
if (url.pathname === "/api/upload" && req.method === "POST") {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
if (!file) {
|
||||
return new Response("No file provided", { status: 400 });
|
||||
}
|
||||
|
||||
const extResult = validateUploadExtension(file.name);
|
||||
if (!extResult.valid) {
|
||||
return Response.json({ error: extResult.error }, { status: 400 });
|
||||
}
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
|
||||
|
||||
await Bun.write(tempPath, file);
|
||||
return Response.json({ path: tempPath, originalName: file.name });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Upload failed";
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
return handleUpload(req);
|
||||
}
|
||||
|
||||
// API: Open plan diff in VS Code
|
||||
@@ -346,104 +239,22 @@ export async function startPlannotatorServer(
|
||||
|
||||
// API: Detect Obsidian vaults
|
||||
if (url.pathname === "/api/obsidian/vaults") {
|
||||
const vaults = detectObsidianVaults();
|
||||
return Response.json({ vaults });
|
||||
return handleObsidianVaults();
|
||||
}
|
||||
|
||||
// API: List Obsidian vault files as a tree
|
||||
if (url.pathname === "/api/reference/obsidian/files" && req.method === "GET") {
|
||||
const vaultPath = url.searchParams.get("vaultPath");
|
||||
if (!vaultPath) {
|
||||
return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedVault = resolve(vaultPath);
|
||||
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
|
||||
return Response.json({ error: "Invalid vault path" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const glob = new Bun.Glob("**/*.md");
|
||||
const files: string[] = [];
|
||||
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
|
||||
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
|
||||
files.push(match);
|
||||
}
|
||||
files.sort();
|
||||
|
||||
const tree = buildFileTree(files);
|
||||
return Response.json({ tree });
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to list vault files" }, { status: 500 });
|
||||
}
|
||||
return handleObsidianFiles(req);
|
||||
}
|
||||
|
||||
// API: Read an Obsidian vault document
|
||||
if (url.pathname === "/api/reference/obsidian/doc" && req.method === "GET") {
|
||||
const vaultPath = url.searchParams.get("vaultPath");
|
||||
const filePath = url.searchParams.get("path");
|
||||
if (!vaultPath || !filePath) {
|
||||
return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 });
|
||||
}
|
||||
if (!/\.mdx?$/i.test(filePath)) {
|
||||
return Response.json({ error: "Only markdown files are supported" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedVault = resolve(vaultPath);
|
||||
let resolvedFile = resolve(resolvedVault, filePath);
|
||||
|
||||
// If direct path doesn't exist and it's a bare filename, search the vault
|
||||
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
|
||||
const glob = new Bun.Glob(`**/${filePath}`);
|
||||
const matches: string[] = [];
|
||||
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
|
||||
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
|
||||
matches.push(resolve(resolvedVault, match));
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
resolvedFile = matches[0];
|
||||
} else if (matches.length > 1) {
|
||||
const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", ""));
|
||||
return Response.json(
|
||||
{ error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Security: must be within vault
|
||||
if (!resolvedFile.startsWith(resolvedVault + "/")) {
|
||||
return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const file = Bun.file(resolvedFile);
|
||||
if (!(await file.exists())) {
|
||||
return Response.json({ error: `File not found: ${filePath}` }, { status: 404 });
|
||||
}
|
||||
const markdown = await file.text();
|
||||
return Response.json({ markdown, filepath: resolvedFile });
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to read file" }, { status: 500 });
|
||||
}
|
||||
return handleObsidianDoc(req);
|
||||
}
|
||||
|
||||
// API: Get available agents (OpenCode only)
|
||||
if (url.pathname === "/api/agents") {
|
||||
if (!options.opencodeClient) {
|
||||
return Response.json({ agents: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await options.opencodeClient.app.agents({});
|
||||
const agents = (result.data ?? [])
|
||||
.filter((a) => a.mode === "primary" && !a.hidden)
|
||||
.map((a) => ({ id: a.name, name: a.name, description: a.description }));
|
||||
|
||||
return Response.json({ agents });
|
||||
} catch {
|
||||
return Response.json({ agents: [], error: "Failed to fetch agents" });
|
||||
}
|
||||
return handleAgents(options.opencodeClient);
|
||||
}
|
||||
|
||||
// API: Save to notes (decoupled from approve/deny)
|
||||
@@ -637,69 +448,3 @@ export async function startPlannotatorServer(
|
||||
stop: () => server.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default behavior: open browser for local sessions
|
||||
*/
|
||||
export async function handleServerReady(
|
||||
url: string,
|
||||
isRemote: boolean,
|
||||
_port: number
|
||||
): Promise<void> {
|
||||
if (!isRemote) {
|
||||
await openBrowser(url);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Vault file tree helpers ---
|
||||
|
||||
export interface VaultNode {
|
||||
name: string;
|
||||
path: string; // relative path within vault
|
||||
type: "file" | "folder";
|
||||
children?: VaultNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested file tree from a sorted list of relative paths.
|
||||
* Folders are sorted before files at each level.
|
||||
*/
|
||||
function buildFileTree(relativePaths: string[]): VaultNode[] {
|
||||
const root: VaultNode[] = [];
|
||||
|
||||
for (const filePath of relativePaths) {
|
||||
const parts = filePath.split("/");
|
||||
let current = root;
|
||||
let pathSoFar = "";
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part;
|
||||
const isFile = i === parts.length - 1;
|
||||
|
||||
let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder"));
|
||||
if (!node) {
|
||||
node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" };
|
||||
if (!isFile) node.children = [];
|
||||
current.push(node);
|
||||
}
|
||||
if (!isFile) {
|
||||
current = node.children!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: folders first (alphabetical), then files (alphabetical)
|
||||
const sortNodes = (nodes: VaultNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const node of nodes) {
|
||||
if (node.children) sortNodes(node.children);
|
||||
}
|
||||
};
|
||||
sortNodes(root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Reference/document route handlers for the plan server.
|
||||
*
|
||||
* Handles /api/doc, /api/obsidian/vaults, /api/reference/obsidian/files,
|
||||
* and /api/reference/obsidian/doc. Extracted from index.ts for modularity.
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { detectObsidianVaults } from "./integrations";
|
||||
|
||||
// --- Vault file tree helpers ---
|
||||
|
||||
export interface VaultNode {
|
||||
name: string;
|
||||
path: string; // relative path within vault
|
||||
type: "file" | "folder";
|
||||
children?: VaultNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a nested file tree from a sorted list of relative paths.
|
||||
* Folders are sorted before files at each level.
|
||||
*/
|
||||
export function buildFileTree(relativePaths: string[]): VaultNode[] {
|
||||
const root: VaultNode[] = [];
|
||||
|
||||
for (const filePath of relativePaths) {
|
||||
const parts = filePath.split("/");
|
||||
let current = root;
|
||||
let pathSoFar = "";
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
pathSoFar = pathSoFar ? `${pathSoFar}/${part}` : part;
|
||||
const isFile = i === parts.length - 1;
|
||||
|
||||
let node = current.find((n) => n.name === part && n.type === (isFile ? "file" : "folder"));
|
||||
if (!node) {
|
||||
node = { name: part, path: pathSoFar, type: isFile ? "file" : "folder" };
|
||||
if (!isFile) node.children = [];
|
||||
current.push(node);
|
||||
}
|
||||
if (!isFile) {
|
||||
current = node.children!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: folders first (alphabetical), then files (alphabetical)
|
||||
const sortNodes = (nodes: VaultNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
for (const node of nodes) {
|
||||
if (node.children) sortNodes(node.children);
|
||||
}
|
||||
};
|
||||
sortNodes(root);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
// --- Route handlers ---
|
||||
|
||||
/** Serve a linked markdown document. Resolves absolute, relative, or bare filename paths. */
|
||||
export async function handleDoc(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const requestedPath = url.searchParams.get("path");
|
||||
if (!requestedPath) {
|
||||
return Response.json({ error: "Missing path parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Restrict to markdown files only
|
||||
if (!/\.mdx?$/i.test(requestedPath)) {
|
||||
return Response.json({ error: "Only .md and .mdx files are supported" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Path resolution: 3 strategies in order
|
||||
let resolvedPath: string | null = null;
|
||||
|
||||
if (requestedPath.startsWith("/")) {
|
||||
// 1. Absolute path
|
||||
resolvedPath = requestedPath;
|
||||
} else {
|
||||
// 2. Relative to project root
|
||||
const fromRoot = resolve(projectRoot, requestedPath);
|
||||
if (await Bun.file(fromRoot).exists()) {
|
||||
resolvedPath = fromRoot;
|
||||
}
|
||||
|
||||
// 3. Bare filename — search entire project for unique match
|
||||
if (!resolvedPath && !requestedPath.includes("/")) {
|
||||
const glob = new Bun.Glob(`**/${requestedPath}`);
|
||||
const matches: string[] = [];
|
||||
for await (const match of glob.scan({ cwd: projectRoot, onlyFiles: true })) {
|
||||
if (match.includes("node_modules/") || match.includes(".git/")) continue;
|
||||
if (match.split("/").pop() === requestedPath) {
|
||||
matches.push(resolve(projectRoot, match));
|
||||
}
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
resolvedPath = matches[0];
|
||||
} else if (matches.length > 1) {
|
||||
const relativePaths = matches.map((m) => m.replace(projectRoot + "/", ""));
|
||||
return Response.json(
|
||||
{ error: `Ambiguous filename '${requestedPath}': found ${matches.length} matches`, matches: relativePaths },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedPath) {
|
||||
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
|
||||
}
|
||||
|
||||
// Security: path must stay within projectRoot
|
||||
const normalised = resolve(resolvedPath);
|
||||
if (!normalised.startsWith(projectRoot + "/") && normalised !== projectRoot) {
|
||||
return Response.json({ error: "Access denied: path is outside project root" }, { status: 403 });
|
||||
}
|
||||
|
||||
const file = Bun.file(normalised);
|
||||
if (!(await file.exists())) {
|
||||
return Response.json({ error: `File not found: ${requestedPath}` }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const markdown = await file.text();
|
||||
return Response.json({ markdown, filepath: normalised });
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to read file" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect available Obsidian vaults. */
|
||||
export function handleObsidianVaults(): Response {
|
||||
const vaults = detectObsidianVaults();
|
||||
return Response.json({ vaults });
|
||||
}
|
||||
|
||||
/** List Obsidian vault files as a nested tree. */
|
||||
export async function handleObsidianFiles(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const vaultPath = url.searchParams.get("vaultPath");
|
||||
if (!vaultPath) {
|
||||
return Response.json({ error: "Missing vaultPath parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedVault = resolve(vaultPath);
|
||||
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
|
||||
return Response.json({ error: "Invalid vault path" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const glob = new Bun.Glob("**/*.md");
|
||||
const files: string[] = [];
|
||||
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
|
||||
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
|
||||
files.push(match);
|
||||
}
|
||||
files.sort();
|
||||
|
||||
const tree = buildFileTree(files);
|
||||
return Response.json({ tree });
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to list vault files" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an Obsidian vault document. Supports direct path and bare filename search. */
|
||||
export async function handleObsidianDoc(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const vaultPath = url.searchParams.get("vaultPath");
|
||||
const filePath = url.searchParams.get("path");
|
||||
if (!vaultPath || !filePath) {
|
||||
return Response.json({ error: "Missing vaultPath or path parameter" }, { status: 400 });
|
||||
}
|
||||
if (!/\.mdx?$/i.test(filePath)) {
|
||||
return Response.json({ error: "Only markdown files are supported" }, { status: 400 });
|
||||
}
|
||||
|
||||
const resolvedVault = resolve(vaultPath);
|
||||
let resolvedFile = resolve(resolvedVault, filePath);
|
||||
|
||||
// If direct path doesn't exist and it's a bare filename, search the vault
|
||||
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
|
||||
const glob = new Bun.Glob(`**/${filePath}`);
|
||||
const matches: string[] = [];
|
||||
for await (const match of glob.scan({ cwd: resolvedVault, onlyFiles: true })) {
|
||||
if (match.includes(".obsidian/") || match.includes(".trash/")) continue;
|
||||
matches.push(resolve(resolvedVault, match));
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
resolvedFile = matches[0];
|
||||
} else if (matches.length > 1) {
|
||||
const relativePaths = matches.map((m) => m.replace(resolvedVault + "/", ""));
|
||||
return Response.json(
|
||||
{ error: `Ambiguous filename '${filePath}': found ${matches.length} matches`, matches: relativePaths },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Security: must be within vault
|
||||
if (!resolvedFile.startsWith(resolvedVault + "/")) {
|
||||
return Response.json({ error: "Access denied: path is outside vault" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const file = Bun.file(resolvedFile);
|
||||
if (!(await file.exists())) {
|
||||
return Response.json({ error: `File not found: ${filePath}` }, { status: 404 });
|
||||
}
|
||||
const markdown = await file.text();
|
||||
return Response.json({ markdown, filepath: resolvedFile });
|
||||
} catch {
|
||||
return Response.json({ error: "Failed to read file" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,16 @@
|
||||
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "fs";
|
||||
import { isRemoteSession, getServerPort } from "./remote";
|
||||
import { openBrowser } from "./browser";
|
||||
import { type DiffType, type GitContext, runGitDiff } from "./git";
|
||||
import { getRepoInfo } from "./repo";
|
||||
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
|
||||
import { handleImage, handleUpload, handleAgents, handleServerReady, type OpencodeClient } from "./shared-handlers";
|
||||
|
||||
// Re-export utilities
|
||||
export { isRemoteSession, getServerPort } from "./remote";
|
||||
export { openBrowser } from "./browser";
|
||||
export { type DiffType, type DiffOption, type GitContext } from "./git";
|
||||
export { handleServerReady as handleReviewServerReady } from "./shared-handlers";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -45,11 +44,7 @@ export interface ReviewServerOptions {
|
||||
/** Called when server starts with the URL, remote status, and port */
|
||||
onReady?: (url: string, isRemote: boolean, port: number) => void;
|
||||
/** OpenCode client for querying available agents (OpenCode only) */
|
||||
opencodeClient?: {
|
||||
app: {
|
||||
agents: (options?: object) => Promise<{ data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }> }>;
|
||||
};
|
||||
};
|
||||
opencodeClient?: OpencodeClient;
|
||||
}
|
||||
|
||||
export interface ReviewServerResult {
|
||||
@@ -177,66 +172,17 @@ export async function startReviewServer(
|
||||
|
||||
// API: Serve images (local paths or temp uploads)
|
||||
if (url.pathname === "/api/image") {
|
||||
const imagePath = url.searchParams.get("path");
|
||||
if (!imagePath) {
|
||||
return new Response("Missing path parameter", { status: 400 });
|
||||
}
|
||||
const validation = validateImagePath(imagePath);
|
||||
if (!validation.valid) {
|
||||
return new Response(validation.error!, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const file = Bun.file(validation.resolved);
|
||||
if (!(await file.exists())) {
|
||||
return new Response("File not found", { status: 404 });
|
||||
}
|
||||
return new Response(file);
|
||||
} catch {
|
||||
return new Response("Failed to read file", { status: 500 });
|
||||
}
|
||||
return handleImage(req);
|
||||
}
|
||||
|
||||
// API: Upload image -> save to temp -> return path
|
||||
if (url.pathname === "/api/upload" && req.method === "POST") {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
if (!file) {
|
||||
return new Response("No file provided", { status: 400 });
|
||||
}
|
||||
|
||||
const extResult = validateUploadExtension(file.name);
|
||||
if (!extResult.valid) {
|
||||
return Response.json({ error: extResult.error }, { status: 400 });
|
||||
}
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
|
||||
|
||||
await Bun.write(tempPath, file);
|
||||
return Response.json({ path: tempPath, originalName: file.name });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Upload failed";
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
return handleUpload(req);
|
||||
}
|
||||
|
||||
// API: Get available agents (OpenCode only)
|
||||
if (url.pathname === "/api/agents") {
|
||||
if (!options.opencodeClient) {
|
||||
return Response.json({ agents: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await options.opencodeClient.app.agents({});
|
||||
const agents = (result.data ?? [])
|
||||
.filter((a) => a.mode === "primary" && !a.hidden)
|
||||
.map((a) => ({ id: a.name, name: a.name, description: a.description }));
|
||||
|
||||
return Response.json({ agents });
|
||||
} catch {
|
||||
return Response.json({ agents: [], error: "Failed to fetch agents" });
|
||||
}
|
||||
return handleAgents(options.opencodeClient);
|
||||
}
|
||||
|
||||
// API: Submit review feedback
|
||||
@@ -307,16 +253,3 @@ export async function startReviewServer(
|
||||
stop: () => server.stop(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Default behavior: open browser for local sessions
|
||||
*/
|
||||
export async function handleReviewServerReady(
|
||||
url: string,
|
||||
isRemote: boolean,
|
||||
_port: number
|
||||
): Promise<void> {
|
||||
if (!isRemote) {
|
||||
await openBrowser(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Shared route handlers used by plan, review, and annotate servers.
|
||||
*
|
||||
* Eliminates duplication of /api/image, /api/upload, and the server-ready
|
||||
* handler across all three server files. Also shares /api/agents for plan + review.
|
||||
*/
|
||||
|
||||
import { mkdirSync } from "fs";
|
||||
import { openBrowser } from "./browser";
|
||||
import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image";
|
||||
|
||||
/** Serve images from local paths or temp uploads. Used by all 3 servers. */
|
||||
export async function handleImage(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
const imagePath = url.searchParams.get("path");
|
||||
if (!imagePath) {
|
||||
return new Response("Missing path parameter", { status: 400 });
|
||||
}
|
||||
const validation = validateImagePath(imagePath);
|
||||
if (!validation.valid) {
|
||||
return new Response(validation.error!, { status: 403 });
|
||||
}
|
||||
try {
|
||||
const file = Bun.file(validation.resolved);
|
||||
if (!(await file.exists())) {
|
||||
return new Response("File not found", { status: 404 });
|
||||
}
|
||||
return new Response(file);
|
||||
} catch {
|
||||
return new Response("Failed to read file", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload image to temp dir, return path. Used by all 3 servers. */
|
||||
export async function handleUpload(req: Request): Promise<Response> {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File;
|
||||
if (!file) {
|
||||
return new Response("No file provided", { status: 400 });
|
||||
}
|
||||
|
||||
const extResult = validateUploadExtension(file.name);
|
||||
if (!extResult.valid) {
|
||||
return Response.json({ error: extResult.error }, { status: 400 });
|
||||
}
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const tempPath = `${UPLOAD_DIR}/${crypto.randomUUID()}.${extResult.ext}`;
|
||||
|
||||
await Bun.write(tempPath, file);
|
||||
return Response.json({ path: tempPath, originalName: file.name });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Upload failed";
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenCode agent client interface (subset of OpenCode SDK) */
|
||||
export interface OpencodeClient {
|
||||
app: {
|
||||
agents: (options?: object) => Promise<{
|
||||
data?: Array<{ name: string; description?: string; mode: string; hidden?: boolean }>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/** List available agents. Used by plan + review servers (OpenCode only). */
|
||||
export async function handleAgents(opencodeClient?: OpencodeClient): Promise<Response> {
|
||||
if (!opencodeClient) {
|
||||
return Response.json({ agents: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await opencodeClient.app.agents({});
|
||||
const agents = (result.data ?? [])
|
||||
.filter((a) => a.mode === "primary" && !a.hidden)
|
||||
.map((a) => ({ id: a.name, name: a.name, description: a.description }));
|
||||
|
||||
return Response.json({ agents });
|
||||
} catch {
|
||||
return Response.json({ agents: [], error: "Failed to fetch agents" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Open browser for local sessions. Used by all 3 servers. */
|
||||
export async function handleServerReady(
|
||||
url: string,
|
||||
isRemote: boolean,
|
||||
_port: number,
|
||||
): Promise<void> {
|
||||
if (!isRemote) {
|
||||
await openBrowser(url);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user