Add plannotator extension v0.19.10

This commit is contained in:
2026-05-07 11:38:14 +10:00
parent e914bc59c9
commit f37e4565ff
91 changed files with 35103 additions and 0 deletions

View File

@@ -0,0 +1,358 @@
/**
* Document and reference handlers (Node.js equivalents of packages/server/reference-handlers.ts).
* VaultNode, buildFileTree, walkMarkdownFiles, handleDocRequest,
* detectObsidianVaults, handleObsidian*, handleFileBrowserRequest
*/
import {
existsSync,
readdirSync,
readFileSync,
statSync,
type Dirent,
} from "node:fs";
import type { ServerResponse } from "node:http";
import { join, resolve as resolvePath } from "node:path";
import { json, parseBody } from "./helpers";
import type { IncomingMessage } from "node:http";
import {
type VaultNode,
buildFileTree,
FILE_BROWSER_EXCLUDED,
} from "../generated/reference-common.js";
import { detectObsidianVaults } from "../generated/integrations-common.js";
import {
isAbsoluteUserPath,
isCodeFilePath,
resolveCodeFile,
resolveMarkdownFile,
resolveUserPath,
isWithinProjectRoot,
warmFileListCache,
} from "../generated/resolve-file.js";
import { htmlToMarkdown } from "../generated/html-to-markdown.js";
import { preloadFile } from "@pierre/diffs/ssr";
type Res = ServerResponse;
/** Recursively walk a directory collecting files by extension, skipping ignored dirs. */
function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = /\.(mdx?|html?)$/i): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (FILE_BROWSER_EXCLUDED.includes(entry.name + "/")) continue;
walkMarkdownFiles(join(dir, entry.name), root, results, extensions);
} else if (entry.isFile() && extensions.test(entry.name)) {
const relative = join(dir, entry.name)
.slice(root.length + 1)
.replace(/\\/g, "/");
results.push(relative);
}
}
}
/** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */
export async function handleDocRequest(res: Res, url: URL): Promise<void> {
const requestedPath = url.searchParams.get("path");
if (!requestedPath) {
json(res, { error: "Missing path parameter" }, 400);
return;
}
// Side-channel: warm the code-file walk so /api/doc/exists POSTs land warm.
void warmFileListCache(process.cwd(), "code");
// Try resolving relative to base directory first (used by annotate mode).
// No isWithinProjectRoot check here — intentional, matches pre-existing
// markdown behavior. The base param is set server-side by the annotate
// server (see serverAnnotate.ts /api/doc route). The standalone HTML
// block below (no base) retains its cwd-based containment check.
const base = url.searchParams.get("base");
const resolvedBase = base ? resolveUserPath(base) : null;
if (
resolvedBase &&
!isAbsoluteUserPath(requestedPath) &&
/\.(mdx?|html?)$/i.test(requestedPath)
) {
const fromBase = resolveUserPath(requestedPath, resolvedBase);
try {
if (existsSync(fromBase)) {
const raw = readFileSync(fromBase, "utf-8");
const isHtml = /\.html?$/i.test(requestedPath);
const markdown = isHtml ? htmlToMarkdown(raw) : raw;
json(res, { markdown, filepath: fromBase, isConverted: isHtml });
return;
}
} catch {
/* fall through to standard resolution */
}
}
// HTML files: resolve directly (not via resolveMarkdownFile which only handles .md/.mdx)
const projectRoot = process.cwd();
if (/\.html?$/i.test(requestedPath)) {
const resolvedHtml = resolveUserPath(requestedPath, resolvedBase || projectRoot);
if (!isWithinProjectRoot(resolvedHtml, projectRoot)) {
json(res, { error: "Access denied: path is outside project root" }, 403);
return;
}
try {
if (existsSync(resolvedHtml)) {
const html = readFileSync(resolvedHtml, "utf-8");
json(res, { markdown: htmlToMarkdown(html), filepath: resolvedHtml, isConverted: true });
return;
}
} catch { /* fall through to 404 */ }
json(res, { error: `File not found: ${requestedPath}` }, 404);
return;
}
// Code files: try literal resolve first; on miss, fall back to smart resolver.
if (isCodeFilePath(requestedPath)) {
const literalPath = resolveUserPath(requestedPath, resolvedBase || projectRoot);
const literalAllowed = resolvedBase || isWithinProjectRoot(literalPath, projectRoot);
let resolvedCode: string | null = null;
if (literalAllowed && existsSync(literalPath)) {
resolvedCode = literalPath;
}
if (!resolvedCode) {
const result = await resolveCodeFile(requestedPath, projectRoot);
if (result.kind === "found") {
resolvedCode = result.path;
} else if (result.kind === "ambiguous") {
const prefix = `${projectRoot}/`;
const relative = result.matches.map((m: string) =>
m.startsWith(prefix) ? m.slice(prefix.length) : m,
);
json(res, { error: `Ambiguous path '${requestedPath}'`, matches: relative }, 400);
return;
} else {
json(res, { error: `File not found: ${requestedPath}` }, 404);
return;
}
if (!isWithinProjectRoot(resolvedCode, projectRoot)) {
json(res, { error: "Access denied: path is outside project root" }, 403);
return;
}
}
try {
const stat = statSync(resolvedCode);
if (stat.size > 2 * 1024 * 1024) {
json(res, { error: "File too large (max 2MB)" }, 413);
return;
}
const contents = readFileSync(resolvedCode, "utf-8");
const displayName = resolvedCode.split("/").pop() || resolvedCode;
let prerenderedHTML: string | undefined;
try {
const result = await preloadFile({
file: { name: displayName, contents },
options: { disableFileHeader: true },
});
prerenderedHTML = result.prerenderedHTML;
} catch {
// Fall back to client-side rendering
}
json(res, { codeFile: true, contents, filepath: resolvedCode, prerenderedHTML });
return;
} catch {
json(res, { error: `File not found: ${requestedPath}` }, 404);
return;
}
}
const result = resolveMarkdownFile(requestedPath, projectRoot);
if (result.kind === "ambiguous") {
json(
res,
{
error: `Ambiguous filename '${result.input}': found ${result.matches.length} matches`,
matches: result.matches,
},
400,
);
return;
}
if (result.kind === "not_found" || result.kind === "unavailable") {
json(res, { error: `File not found: ${result.input}` }, 404);
return;
}
try {
const markdown = readFileSync(result.path, "utf-8");
json(res, { markdown, filepath: result.path });
} catch {
json(res, { error: "Failed to read file" }, 500);
}
}
/**
* Batch existence check for code-file paths the renderer wants to linkify.
* POST /api/doc/exists with { paths: string[] }.
*
* TODO(security): see packages/server/reference-handlers.ts handleDocExists —
* both absolute paths in `paths[]` AND the `base` field are honored verbatim
* with no project-root containment check, leaking file existence back to the
* caller. Fix in lockstep with the Bun handler.
*/
export async function handleDocExistsRequest(res: Res, req: IncomingMessage): Promise<void> {
const body = await parseBody(req);
const paths = (body as { paths?: unknown }).paths;
if (!Array.isArray(paths) || !paths.every((p) => typeof p === "string")) {
json(res, { error: "Expected { paths: string[] }" }, 400);
return;
}
if (paths.length > 500) {
json(res, { error: "Too many paths (max 500)" }, 400);
return;
}
const baseRaw = (body as { base?: unknown }).base;
const baseDir = typeof baseRaw === "string" && baseRaw.length > 0
? resolveUserPath(baseRaw)
: undefined;
const projectRoot = process.cwd();
const results: Record<
string,
| { status: "found"; resolved: string }
| { status: "ambiguous"; matches: string[] }
| { status: "missing" }
| { status: "unavailable" }
> = {};
await Promise.all(
(paths as string[]).map(async (p) => {
const r = await resolveCodeFile(p, projectRoot, baseDir);
if (r.kind === "found") {
results[p] = { status: "found", resolved: r.path };
} else if (r.kind === "ambiguous") {
const prefix = `${projectRoot}/`;
results[p] = {
status: "ambiguous",
matches: r.matches.map((m: string) => (m.startsWith(prefix) ? m.slice(prefix.length) : m)),
};
} else if (r.kind === "unavailable") {
results[p] = { status: "unavailable" };
} else {
results[p] = { status: "missing" };
}
}),
);
json(res, { results });
}
export function handleObsidianVaultsRequest(res: Res): void {
json(res, { vaults: detectObsidianVaults() });
}
export function handleObsidianFilesRequest(res: Res, url: URL): void {
const vaultPath = url.searchParams.get("vaultPath");
if (!vaultPath) {
json(res, { error: "Missing vaultPath parameter" }, 400);
return;
}
const resolvedVault = resolveUserPath(vaultPath);
if (!existsSync(resolvedVault) || !statSync(resolvedVault).isDirectory()) {
json(res, { error: "Invalid vault path" }, 400);
return;
}
try {
const files: string[] = [];
walkMarkdownFiles(resolvedVault, resolvedVault, files, /\.mdx?$/i);
files.sort();
json(res, { tree: buildFileTree(files) });
} catch {
json(res, { error: "Failed to list vault files" }, 500);
}
}
export function handleObsidianDocRequest(res: Res, url: URL): void {
const vaultPath = url.searchParams.get("vaultPath");
const filePath = url.searchParams.get("path");
if (!vaultPath || !filePath) {
json(res, { error: "Missing vaultPath or path parameter" }, 400);
return;
}
if (!/\.mdx?$/i.test(filePath)) {
json(res, { error: "Only markdown files are supported" }, 400);
return;
}
const resolvedVault = resolveUserPath(vaultPath);
let resolvedFile = resolvePath(resolvedVault, filePath);
// Bare filename search within vault
if (!existsSync(resolvedFile) && !filePath.includes("/")) {
const files: string[] = [];
walkMarkdownFiles(resolvedVault, resolvedVault, files, /\.mdx?$/i);
const matches = files.filter(
(f) => f.split("/").pop()!.toLowerCase() === filePath.toLowerCase(),
);
if (matches.length === 1) {
resolvedFile = resolvePath(resolvedVault, matches[0]);
} else if (matches.length > 1) {
json(
res,
{
error: `Ambiguous filename '${filePath}': found ${matches.length} matches`,
matches,
},
400,
);
return;
}
}
// Security: must be within vault
if (
!resolvedFile.startsWith(resolvedVault + "/") &&
resolvedFile !== resolvedVault
) {
json(res, { error: "Access denied: path is outside vault" }, 403);
return;
}
if (!existsSync(resolvedFile)) {
json(res, { error: `File not found: ${filePath}` }, 404);
return;
}
try {
const markdown = readFileSync(resolvedFile, "utf-8");
json(res, { markdown, filepath: resolvedFile });
} catch {
json(res, { error: "Failed to read file" }, 500);
}
}
export function handleFileBrowserRequest(res: Res, url: URL): void {
const dirPath = url.searchParams.get("dirPath");
if (!dirPath) {
json(res, { error: "Missing dirPath parameter" }, 400);
return;
}
const resolvedDir = resolveUserPath(dirPath);
if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) {
json(res, { error: "Invalid directory path" }, 400);
return;
}
try {
const files: string[] = [];
walkMarkdownFiles(resolvedDir, resolvedDir, files);
files.sort();
json(res, { tree: buildFileTree(files) });
} catch {
json(res, { error: "Failed to list directory files" }, 500);
}
}