|
|
|
@@ -0,0 +1,671 @@
|
|
|
|
|
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
|
|
|
|
|
import {
|
|
|
|
|
constants,
|
|
|
|
|
accessSync,
|
|
|
|
|
existsSync,
|
|
|
|
|
mkdirSync,
|
|
|
|
|
readdirSync,
|
|
|
|
|
readFileSync,
|
|
|
|
|
realpathSync,
|
|
|
|
|
renameSync,
|
|
|
|
|
statSync,
|
|
|
|
|
writeFileSync,
|
|
|
|
|
} from "node:fs";
|
|
|
|
|
import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
|
|
|
|
|
|
|
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
|
|
|
type JsonRecord = Record<string, JsonValue>;
|
|
|
|
|
type TodoStatus = "pending" | "in_progress" | "completed";
|
|
|
|
|
|
|
|
|
|
interface TodoLink {
|
|
|
|
|
label: string;
|
|
|
|
|
target: string;
|
|
|
|
|
exists: boolean | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TodoTask {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
description: string;
|
|
|
|
|
rawContent: string;
|
|
|
|
|
status: TodoStatus;
|
|
|
|
|
completed: boolean;
|
|
|
|
|
processing: boolean;
|
|
|
|
|
children: TodoTask[];
|
|
|
|
|
lineNumber: number;
|
|
|
|
|
level: number;
|
|
|
|
|
depth: number;
|
|
|
|
|
parentId: string | null;
|
|
|
|
|
filePath: string;
|
|
|
|
|
linkCount: number;
|
|
|
|
|
linkExists: number;
|
|
|
|
|
links: TodoLink[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TextBlock {
|
|
|
|
|
id: string;
|
|
|
|
|
content: string;
|
|
|
|
|
rawContent: string;
|
|
|
|
|
lineNumber: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TodoStats {
|
|
|
|
|
total: number;
|
|
|
|
|
completed: number;
|
|
|
|
|
inProgress: number;
|
|
|
|
|
pending: number;
|
|
|
|
|
maxDepth: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TodoFileSummary {
|
|
|
|
|
path: string;
|
|
|
|
|
name: string;
|
|
|
|
|
directory: string;
|
|
|
|
|
sizeBytes: number;
|
|
|
|
|
mtime: string;
|
|
|
|
|
stats: TodoStats | null;
|
|
|
|
|
parseError: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ParsedTodoFile {
|
|
|
|
|
file: TodoFileSummary;
|
|
|
|
|
tasks: TodoTask[];
|
|
|
|
|
textBlocks: TextBlock[];
|
|
|
|
|
stats: TodoStats;
|
|
|
|
|
content?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface TaskMutationResult {
|
|
|
|
|
success: boolean;
|
|
|
|
|
message: string;
|
|
|
|
|
taskId?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const startedAt = new Date().toISOString();
|
|
|
|
|
const host = envString("HOST", "0.0.0.0");
|
|
|
|
|
const port = envNumber("PORT", 4267);
|
|
|
|
|
const workspaceRoot = resolve(envString("MDTODO_ROOT_DIR", "/workspace"));
|
|
|
|
|
const rootRealPath = resolveRealWorkspaceRoot(workspaceRoot);
|
|
|
|
|
const maxFiles = envNumber("MDTODO_MAX_FILES", 250);
|
|
|
|
|
const recentLogs: JsonRecord[] = [];
|
|
|
|
|
const logFile = envString("LOG_FILE", envString("MDTODO_LOG_FILE", "/var/log/unidesk/mdtodo.jsonl"));
|
|
|
|
|
const logWriter = logFile
|
|
|
|
|
? createHourlyJsonlWriter({
|
|
|
|
|
baseLogFile: logFile,
|
|
|
|
|
service: "mdtodo",
|
|
|
|
|
maxBytes: logRetentionBytesForService("mdtodo"),
|
|
|
|
|
})
|
|
|
|
|
: null;
|
|
|
|
|
logWriter?.prune();
|
|
|
|
|
|
|
|
|
|
function envString(name: string, fallback: string): string {
|
|
|
|
|
const value = process.env[name];
|
|
|
|
|
return value === undefined || value.length === 0 ? fallback : value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function envNumber(name: string, fallback: number): number {
|
|
|
|
|
const raw = process.env[name];
|
|
|
|
|
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
|
|
|
const value = Number(raw);
|
|
|
|
|
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveRealWorkspaceRoot(root: string): string {
|
|
|
|
|
try {
|
|
|
|
|
return realpathSync(root);
|
|
|
|
|
} catch {
|
|
|
|
|
return root;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function log(level: "debug" | "info" | "warn" | "error", event: string, detail: JsonRecord = {}): void {
|
|
|
|
|
const record: JsonRecord = { at: new Date().toISOString(), service: "mdtodo", level, event, ...detail };
|
|
|
|
|
recentLogs.push(record);
|
|
|
|
|
while (recentLogs.length > 500) recentLogs.shift();
|
|
|
|
|
try {
|
|
|
|
|
logWriter?.appendJson(record, new Date(String(record.at)));
|
|
|
|
|
} catch {
|
|
|
|
|
// Logging must not break task edits.
|
|
|
|
|
}
|
|
|
|
|
const line = JSON.stringify(record);
|
|
|
|
|
const writer = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
|
|
|
writer(line);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
|
|
|
|
|
return new Response(JSON.stringify(body), {
|
|
|
|
|
status,
|
|
|
|
|
headers: { "content-type": "application/json; charset=utf-8", ...headers },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function textResponse(body: string, status = 200, headers: Record<string, string> = {}): Response {
|
|
|
|
|
return new Response(body, {
|
|
|
|
|
status,
|
|
|
|
|
headers: { "content-type": "text/plain; charset=utf-8", ...headers },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function errorToJson(error: unknown): JsonRecord {
|
|
|
|
|
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack ?? "" };
|
|
|
|
|
return { message: String(error) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function errorMessage(error: unknown): string {
|
|
|
|
|
return error instanceof Error ? error.message : String(error);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeRegex(value: string): string {
|
|
|
|
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toSlashPath(value: string): string {
|
|
|
|
|
return value.replace(/\\/gu, "/");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeRelativePath(value: unknown): string {
|
|
|
|
|
const raw = String(value ?? "").trim().replace(/\\/gu, "/").replace(/^\/+/u, "");
|
|
|
|
|
if (!raw) throw new Error("file path is required");
|
|
|
|
|
if (raw.split("/").some((segment) => segment === "..")) throw new Error("file path must stay inside MDTODO workspace");
|
|
|
|
|
if (extname(raw).toLowerCase() !== ".md") throw new Error("file path must point to a Markdown file");
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveWorkspaceFile(value: unknown): { relativePath: string; absolutePath: string } {
|
|
|
|
|
const relativePath = normalizeRelativePath(value);
|
|
|
|
|
const absolutePath = resolve(workspaceRoot, relativePath);
|
|
|
|
|
const parent = dirname(absolutePath);
|
|
|
|
|
const realParent = existsSync(parent) ? realpathSync(parent) : realpathSync(workspaceRoot);
|
|
|
|
|
const candidateReal = existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
|
|
|
|
|
const realRelativeParent = relative(rootRealPath, realParent);
|
|
|
|
|
const realRelativeCandidate = relative(rootRealPath, candidateReal);
|
|
|
|
|
if (realRelativeParent.startsWith("..") || realRelativeParent === "" && realParent !== rootRealPath && rootRealPath !== realParent) {
|
|
|
|
|
throw new Error("file path parent escapes MDTODO workspace");
|
|
|
|
|
}
|
|
|
|
|
if (realRelativeCandidate.startsWith("..")) throw new Error("file path escapes MDTODO workspace");
|
|
|
|
|
return { relativePath, absolutePath };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTaskHeading(line: string): boolean {
|
|
|
|
|
return /^(#{2,6})\s+R\d+(?:\.\d+)*(?=[\s\]]|$)/iu.test(line.trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function matchTaskHeading(line: string): { hashes: string; id: string; tail: string; level: number } | null {
|
|
|
|
|
const match = line.trim().match(/^(#{2,6})\s+(R\d+(?:\.\d+)*)(?=[\s\]]|$)(.*)$/iu);
|
|
|
|
|
if (match === null) return null;
|
|
|
|
|
const hashes = match[1] ?? "##";
|
|
|
|
|
const id = match[2] ?? "";
|
|
|
|
|
return { hashes, id, tail: match[3] ?? "", level: hashes.length };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function taskDepth(taskId: string): number {
|
|
|
|
|
const matches = taskId.match(/\./gu);
|
|
|
|
|
return matches === null ? 0 : matches.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parentTaskId(taskId: string): string | null {
|
|
|
|
|
const index = taskId.lastIndexOf(".");
|
|
|
|
|
return index < 0 ? null : taskId.slice(0, index);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeStatus(value: unknown): TodoStatus {
|
|
|
|
|
const raw = String(value ?? "").trim().toLowerCase();
|
|
|
|
|
if (raw === "completed" || raw === "done" || raw === "finished") return "completed";
|
|
|
|
|
if (raw === "in_progress" || raw === "processing" || raw === "running" || raw === "start") return "in_progress";
|
|
|
|
|
if (raw === "pending" || raw === "todo" || raw === "not_started" || raw === "") return "pending";
|
|
|
|
|
throw new Error(`unsupported task status: ${String(value)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusFromLine(line: string): TodoStatus {
|
|
|
|
|
if (/\[(completed|finished)\]/iu.test(line)) return "completed";
|
|
|
|
|
if (/\[(in_progress|processing)\]/iu.test(line)) return "in_progress";
|
|
|
|
|
return "pending";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusMarker(status: TodoStatus): string {
|
|
|
|
|
if (status === "completed") return "[completed]";
|
|
|
|
|
if (status === "in_progress") return "[in_progress]";
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stripStatusMarkers(value: string): string {
|
|
|
|
|
return value
|
|
|
|
|
.replace(/\s*\[(completed|in_progress|processing|finished)\]/giu, "")
|
|
|
|
|
.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function taskTitleFromLine(line: string, taskId: string): string {
|
|
|
|
|
const withoutLinks = line.trim().replace(/^#{2,6}\s+/u, "");
|
|
|
|
|
const withoutId = withoutLinks.replace(new RegExp(`^${escapeRegex(taskId)}(?=[\\s\\]]|$)`, "iu"), "");
|
|
|
|
|
const title = stripStatusMarkers(withoutId);
|
|
|
|
|
return title.length > 0 ? title : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractLinks(content: string, baseFilePath: string): TodoLink[] {
|
|
|
|
|
const links: TodoLink[] = [];
|
|
|
|
|
const regex = /\[([^\]]*)\]\(([^)]+)\)/gu;
|
|
|
|
|
let match: RegExpExecArray | null;
|
|
|
|
|
while ((match = regex.exec(content)) !== null) {
|
|
|
|
|
const label = match[1] ?? "";
|
|
|
|
|
const target = String(match[2] ?? "").trim();
|
|
|
|
|
let exists: boolean | null = null;
|
|
|
|
|
if (target && !target.startsWith("#") && !target.startsWith("mailto:") && !/^https?:\/\//iu.test(target)) {
|
|
|
|
|
try {
|
|
|
|
|
const decoded = decodeURIComponent(target.startsWith("file://") ? target.slice(7) : target);
|
|
|
|
|
const absolute = decoded.startsWith("/") ? decoded : resolve(dirname(baseFilePath), decoded);
|
|
|
|
|
exists = existsSync(absolute);
|
|
|
|
|
} catch {
|
|
|
|
|
exists = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
links.push({ label, target, exists });
|
|
|
|
|
}
|
|
|
|
|
return links;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseTodoContent(content: string, relativePath: string, absolutePath: string): ParsedTodoFile {
|
|
|
|
|
const normalizedContent = content.replace(/\[Processing\]/gu, "[in_progress]").replace(/\[Finished\]/gu, "[completed]");
|
|
|
|
|
const lines = normalizedContent.split(/\r?\n/u);
|
|
|
|
|
const taskLineIndexes: number[] = [];
|
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
|
|
|
if (isTaskHeading(lines[index] ?? "")) taskLineIndexes.push(index);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rootTasks: TodoTask[] = [];
|
|
|
|
|
const stack: { task: TodoTask; id: string; depth: number }[] = [];
|
|
|
|
|
for (let tokenIndex = 0; tokenIndex < taskLineIndexes.length; tokenIndex += 1) {
|
|
|
|
|
const lineNumber = taskLineIndexes[tokenIndex] ?? 0;
|
|
|
|
|
const heading = matchTaskHeading(lines[lineNumber] ?? "");
|
|
|
|
|
if (heading === null) continue;
|
|
|
|
|
const nextTaskLine = taskLineIndexes[tokenIndex + 1] ?? lines.length;
|
|
|
|
|
const rawContent = lines.slice(lineNumber + 1, nextTaskLine).join("\n").trim();
|
|
|
|
|
const headingAndContent = `${lines[lineNumber] ?? ""}\n${rawContent}`;
|
|
|
|
|
const links = extractLinks(headingAndContent, absolutePath);
|
|
|
|
|
const status = statusFromLine(lines[lineNumber] ?? "");
|
|
|
|
|
const task: TodoTask = {
|
|
|
|
|
id: heading.id,
|
|
|
|
|
title: taskTitleFromLine(lines[lineNumber] ?? "", heading.id),
|
|
|
|
|
description: rawContent || taskTitleFromLine(lines[lineNumber] ?? "", heading.id),
|
|
|
|
|
rawContent,
|
|
|
|
|
status,
|
|
|
|
|
completed: status === "completed",
|
|
|
|
|
processing: status === "in_progress",
|
|
|
|
|
children: [],
|
|
|
|
|
lineNumber,
|
|
|
|
|
level: heading.level,
|
|
|
|
|
depth: taskDepth(heading.id),
|
|
|
|
|
parentId: parentTaskId(heading.id),
|
|
|
|
|
filePath: relativePath,
|
|
|
|
|
linkCount: links.length,
|
|
|
|
|
linkExists: links.filter((link) => link.exists === true).length,
|
|
|
|
|
links,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
while (stack.length > 0 && stack[stack.length - 1]!.depth >= task.depth) stack.pop();
|
|
|
|
|
const parent = stack[stack.length - 1];
|
|
|
|
|
if (parent !== undefined && task.id.startsWith(`${parent.id}.`)) {
|
|
|
|
|
parent.task.children.push(task);
|
|
|
|
|
} else {
|
|
|
|
|
rootTasks.push(task);
|
|
|
|
|
}
|
|
|
|
|
stack.push({ task, id: task.id, depth: task.depth });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const textBlocks = parseTextBlocks(lines, taskLineIndexes);
|
|
|
|
|
const stats = countStats(rootTasks);
|
|
|
|
|
const file = fileSummary(relativePath, absolutePath, stats, null);
|
|
|
|
|
return { file, tasks: rootTasks, textBlocks, stats };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseTextBlocks(lines: string[], taskLineIndexes: number[]): TextBlock[] {
|
|
|
|
|
const firstTaskLine = taskLineIndexes[0] ?? lines.length;
|
|
|
|
|
const prefix = lines.slice(0, firstTaskLine).join("\n");
|
|
|
|
|
const trimmed = prefix.trim();
|
|
|
|
|
if (!trimmed) return [];
|
|
|
|
|
return [{
|
|
|
|
|
id: "text-0",
|
|
|
|
|
content: trimmed,
|
|
|
|
|
rawContent: prefix,
|
|
|
|
|
lineNumber: 0,
|
|
|
|
|
}];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function countStats(tasks: TodoTask[]): TodoStats {
|
|
|
|
|
const stats: TodoStats = { total: 0, completed: 0, inProgress: 0, pending: 0, maxDepth: 0 };
|
|
|
|
|
const visit = (items: TodoTask[]): void => {
|
|
|
|
|
for (const task of items) {
|
|
|
|
|
stats.total += 1;
|
|
|
|
|
stats.maxDepth = Math.max(stats.maxDepth, task.depth);
|
|
|
|
|
if (task.completed) stats.completed += 1;
|
|
|
|
|
else if (task.processing) stats.inProgress += 1;
|
|
|
|
|
else stats.pending += 1;
|
|
|
|
|
visit(task.children);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
visit(tasks);
|
|
|
|
|
return stats;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fileSummary(relativePath: string, absolutePath: string, stats: TodoStats | null, parseError: string | null): TodoFileSummary {
|
|
|
|
|
const info = statSync(absolutePath);
|
|
|
|
|
return {
|
|
|
|
|
path: relativePath,
|
|
|
|
|
name: basename(relativePath),
|
|
|
|
|
directory: toSlashPath(dirname(relativePath)) === "." ? "" : toSlashPath(dirname(relativePath)),
|
|
|
|
|
sizeBytes: info.size,
|
|
|
|
|
mtime: info.mtime.toISOString(),
|
|
|
|
|
stats,
|
|
|
|
|
parseError,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldSkipDirectory(name: string): boolean {
|
|
|
|
|
return [".git", "node_modules", "out", "dist", "resources", ".vscode", ".claude", ".playwright-mcp"].includes(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isTodoMarkdownFile(name: string): boolean {
|
|
|
|
|
return extname(name).toLowerCase() === ".md" && /(?:^|[-_])(?:mdtodo|todo)|(?:mdtodo|todo)(?:[-_]|\.|$)/iu.test(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function listTodoFiles(): TodoFileSummary[] {
|
|
|
|
|
if (!existsSync(workspaceRoot)) return [];
|
|
|
|
|
const results: TodoFileSummary[] = [];
|
|
|
|
|
const walk = (dir: string): void => {
|
|
|
|
|
if (results.length >= maxFiles) return;
|
|
|
|
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (results.length >= maxFiles) break;
|
|
|
|
|
const absolute = join(dir, entry.name);
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
if (!shouldSkipDirectory(entry.name)) walk(absolute);
|
|
|
|
|
} else if (entry.isFile() && isTodoMarkdownFile(entry.name)) {
|
|
|
|
|
const relativePath = toSlashPath(relative(workspaceRoot, absolute));
|
|
|
|
|
try {
|
|
|
|
|
const parsed = parseTodoContent(readFileSync(absolute, "utf8"), relativePath, absolute);
|
|
|
|
|
results.push(parsed.file);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
results.push(fileSummary(relativePath, absolute, null, errorMessage(error)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
walk(workspaceRoot);
|
|
|
|
|
return results.sort((left, right) => left.path.localeCompare(right.path, "en"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadTodoFile(filePath: unknown, includeContent = false): ParsedTodoFile {
|
|
|
|
|
const resolved = resolveWorkspaceFile(filePath);
|
|
|
|
|
if (!existsSync(resolved.absolutePath)) throw new Error(`file not found: ${resolved.relativePath}`);
|
|
|
|
|
const content = readFileSync(resolved.absolutePath, "utf8");
|
|
|
|
|
const parsed = parseTodoContent(content, resolved.relativePath, resolved.absolutePath);
|
|
|
|
|
return includeContent ? { ...parsed, content } : parsed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flattenTasks(tasks: TodoTask[]): TodoTask[] {
|
|
|
|
|
const result: TodoTask[] = [];
|
|
|
|
|
const visit = (items: TodoTask[]): void => {
|
|
|
|
|
for (const task of items) {
|
|
|
|
|
result.push(task);
|
|
|
|
|
visit(task.children);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
visit(tasks);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findTaskInParsed(parsed: ParsedTodoFile, taskId: string): TodoTask | null {
|
|
|
|
|
return flattenTasks(parsed.tasks).find((task) => task.id.toLowerCase() === taskId.toLowerCase()) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findTaskLine(lines: string[], taskId: string): number {
|
|
|
|
|
const regex = new RegExp(`^#{2,6}\\s+${escapeRegex(taskId)}(?=[\\s\\]]|$)`, "iu");
|
|
|
|
|
return lines.findIndex((line) => regex.test(line.trim()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nextTaskLine(lines: string[], startLine: number): number {
|
|
|
|
|
for (let index = startLine + 1; index < lines.length; index += 1) {
|
|
|
|
|
if (isTaskHeading(lines[index] ?? "")) return index;
|
|
|
|
|
}
|
|
|
|
|
return lines.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function subtreeEndLine(lines: string[], startLine: number, taskLevel: number): number {
|
|
|
|
|
for (let index = startLine + 1; index < lines.length; index += 1) {
|
|
|
|
|
const heading = matchTaskHeading(lines[index] ?? "");
|
|
|
|
|
if (heading !== null && heading.level <= taskLevel) return index;
|
|
|
|
|
}
|
|
|
|
|
return lines.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeFileAtomic(filePath: string, content: string): void {
|
|
|
|
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
|
|
|
const temp = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
|
|
|
|
writeFileSync(temp, content, "utf8");
|
|
|
|
|
renameSync(temp, filePath);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mutateFile(filePath: unknown, updater: (lines: string[], relativePath: string) => TaskMutationResult): { result: TaskMutationResult; parsed: ParsedTodoFile } {
|
|
|
|
|
const resolved = resolveWorkspaceFile(filePath);
|
|
|
|
|
if (!existsSync(resolved.absolutePath)) throw new Error(`file not found: ${resolved.relativePath}`);
|
|
|
|
|
const content = readFileSync(resolved.absolutePath, "utf8");
|
|
|
|
|
const lines = content.split(/\r?\n/u);
|
|
|
|
|
const result = updater(lines, resolved.relativePath);
|
|
|
|
|
if (!result.success) return { result, parsed: loadTodoFile(resolved.relativePath) };
|
|
|
|
|
writeFileAtomic(resolved.absolutePath, lines.join("\n"));
|
|
|
|
|
log("info", "file_mutated", { file: resolved.relativePath, taskId: result.taskId ?? "", message: result.message });
|
|
|
|
|
return { result, parsed: loadTodoFile(resolved.relativePath) };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function allTaskIds(tasks: TodoTask[]): string[] {
|
|
|
|
|
return flattenTasks(tasks).map((task) => task.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function generateTaskId(parsed: ParsedTodoFile, parentId: string | null): string {
|
|
|
|
|
const ids = allTaskIds(parsed.tasks);
|
|
|
|
|
if (parentId) {
|
|
|
|
|
const prefix = `${parentId}.`;
|
|
|
|
|
const maxChild = ids
|
|
|
|
|
.filter((id) => id.startsWith(prefix))
|
|
|
|
|
.map((id) => id.match(new RegExp(`^${escapeRegex(prefix)}(\\d+)$`, "iu"))?.[1] ?? "")
|
|
|
|
|
.map((value) => Number(value))
|
|
|
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
|
|
|
.reduce((max, value) => Math.max(max, value), 0);
|
|
|
|
|
return `${parentId}.${maxChild + 1}`;
|
|
|
|
|
}
|
|
|
|
|
const maxRoot = ids
|
|
|
|
|
.map((id) => id.match(/^R(\d+)$/iu)?.[1] ?? "")
|
|
|
|
|
.map((value) => Number(value))
|
|
|
|
|
.filter((value) => Number.isFinite(value) && value > 0)
|
|
|
|
|
.reduce((max, value) => Math.max(max, value), 0);
|
|
|
|
|
return `R${maxRoot + 1}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function headingLevelForTaskId(taskId: string): number {
|
|
|
|
|
return Math.min(6, 2 + taskDepth(taskId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function taskTemplate(taskId: string, title: string, rawContent: string): string {
|
|
|
|
|
const heading = "#".repeat(headingLevelForTaskId(taskId));
|
|
|
|
|
const safeTitle = stripStatusMarkers(title).replace(/\s+/gu, " ").trim() || "新任务";
|
|
|
|
|
const body = rawContent.trim();
|
|
|
|
|
return body ? `${heading} ${taskId} ${safeTitle}\n\n${body}` : `${heading} ${taskId} ${safeTitle}\n`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addTask(filePath: unknown, body: Record<string, unknown>): { result: TaskMutationResult; parsed: ParsedTodoFile } {
|
|
|
|
|
const parentId = typeof body.parentId === "string" && body.parentId.trim() ? body.parentId.trim() : null;
|
|
|
|
|
const title = typeof body.title === "string" ? body.title : "新任务";
|
|
|
|
|
const rawContent = typeof body.rawContent === "string" ? body.rawContent : "";
|
|
|
|
|
const current = loadTodoFile(filePath);
|
|
|
|
|
if (parentId !== null && findTaskInParsed(current, parentId) === null) {
|
|
|
|
|
return { result: { success: false, message: `parent task not found: ${parentId}` }, parsed: current };
|
|
|
|
|
}
|
|
|
|
|
const newId = generateTaskId(current, parentId);
|
|
|
|
|
return mutateFile(filePath, (lines) => {
|
|
|
|
|
const content = taskTemplate(newId, title, rawContent);
|
|
|
|
|
if (parentId === null) {
|
|
|
|
|
const insertAt = lines.length > 0 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
|
|
|
|
lines.splice(insertAt, 0, "", content);
|
|
|
|
|
} else {
|
|
|
|
|
const parentLine = findTaskLine(lines, parentId);
|
|
|
|
|
if (parentLine < 0) return { success: false, message: `parent task not found: ${parentId}` };
|
|
|
|
|
const heading = matchTaskHeading(lines[parentLine] ?? "");
|
|
|
|
|
const insertAt = subtreeEndLine(lines, parentLine, heading?.level ?? headingLevelForTaskId(parentId));
|
|
|
|
|
lines.splice(insertAt, 0, "", content);
|
|
|
|
|
}
|
|
|
|
|
return { success: true, message: `task ${newId} created`, taskId: newId };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function deleteTask(filePath: unknown, taskId: string): { result: TaskMutationResult; parsed: ParsedTodoFile } {
|
|
|
|
|
return mutateFile(filePath, (lines) => {
|
|
|
|
|
const start = findTaskLine(lines, taskId);
|
|
|
|
|
if (start < 0) return { success: false, message: `task not found: ${taskId}` };
|
|
|
|
|
const heading = matchTaskHeading(lines[start] ?? "");
|
|
|
|
|
const end = subtreeEndLine(lines, start, heading?.level ?? headingLevelForTaskId(taskId));
|
|
|
|
|
lines.splice(start, end - start);
|
|
|
|
|
return { success: true, message: `task ${taskId} deleted`, taskId };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function patchTask(filePath: unknown, taskId: string, body: Record<string, unknown>): { result: TaskMutationResult; parsed: ParsedTodoFile } {
|
|
|
|
|
return mutateFile(filePath, (lines) => {
|
|
|
|
|
const lineIndex = findTaskLine(lines, taskId);
|
|
|
|
|
if (lineIndex < 0) return { success: false, message: `task not found: ${taskId}` };
|
|
|
|
|
if (body.title !== undefined) {
|
|
|
|
|
const heading = matchTaskHeading(lines[lineIndex] ?? "");
|
|
|
|
|
const nextStatus = body.status === undefined ? statusFromLine(lines[lineIndex] ?? "") : normalizeStatus(body.status);
|
|
|
|
|
const marker = statusMarker(nextStatus);
|
|
|
|
|
const title = stripStatusMarkers(String(body.title ?? "")).replace(/\s+/gu, " ").trim();
|
|
|
|
|
lines[lineIndex] = `${heading?.hashes ?? "#".repeat(headingLevelForTaskId(taskId))} ${taskId}${title ? ` ${title}` : ""}${marker ? ` ${marker}` : ""}`;
|
|
|
|
|
} else if (body.status !== undefined) {
|
|
|
|
|
const nextStatus = normalizeStatus(body.status);
|
|
|
|
|
const marker = statusMarker(nextStatus);
|
|
|
|
|
const base = stripStatusMarkers(lines[lineIndex] ?? "");
|
|
|
|
|
lines[lineIndex] = marker ? `${base} ${marker}` : base;
|
|
|
|
|
}
|
|
|
|
|
if (body.rawContent !== undefined) {
|
|
|
|
|
const contentStart = lineIndex + 1;
|
|
|
|
|
const contentEnd = nextTaskLine(lines, lineIndex);
|
|
|
|
|
const rawContent = String(body.rawContent ?? "").replace(/\r\n/gu, "\n");
|
|
|
|
|
const replacement = rawContent.trim().length > 0 ? ["", ...rawContent.split("\n")] : [];
|
|
|
|
|
lines.splice(contentStart, contentEnd - contentStart, ...replacement);
|
|
|
|
|
}
|
|
|
|
|
return { success: true, message: `task ${taskId} updated`, taskId };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function updateContent(filePath: unknown, content: unknown): ParsedTodoFile {
|
|
|
|
|
const resolved = resolveWorkspaceFile(filePath);
|
|
|
|
|
if (typeof content !== "string") throw new Error("content must be a string");
|
|
|
|
|
writeFileAtomic(resolved.absolutePath, content);
|
|
|
|
|
log("info", "file_content_replaced", { file: resolved.relativePath, bytes: content.length });
|
|
|
|
|
return loadTodoFile(resolved.relativePath, true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildExecutionCommand(body: Record<string, unknown>): JsonRecord {
|
|
|
|
|
const file = normalizeRelativePath(body.file);
|
|
|
|
|
const taskId = String(body.taskId ?? "").trim();
|
|
|
|
|
if (!taskId) throw new Error("taskId is required");
|
|
|
|
|
const mode = String(body.mode ?? "codex").trim().toLowerCase();
|
|
|
|
|
const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : "";
|
|
|
|
|
const prompt = `execute "${file} 中的 ${taskId} 任务"`;
|
|
|
|
|
const command = mode === "opencode"
|
|
|
|
|
? `opencode${model ? ` --model ${model}` : ""} --prompt ${JSON.stringify(`${file} 中的 ${taskId} 任务`)}`
|
|
|
|
|
: `codex ${JSON.stringify(prompt)}`;
|
|
|
|
|
return { ok: true, file, taskId, mode, model, prompt, command };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function healthSnapshot(): { body: JsonRecord; status: number } {
|
|
|
|
|
const rootExists = existsSync(workspaceRoot);
|
|
|
|
|
let writable = false;
|
|
|
|
|
try {
|
|
|
|
|
if (rootExists) accessSync(workspaceRoot, constants.R_OK | constants.W_OK);
|
|
|
|
|
writable = rootExists;
|
|
|
|
|
} catch {
|
|
|
|
|
writable = false;
|
|
|
|
|
}
|
|
|
|
|
const files = rootExists ? listTodoFiles() : [];
|
|
|
|
|
const healthy = rootExists && files.length > 0;
|
|
|
|
|
return {
|
|
|
|
|
status: healthy ? 200 : 503,
|
|
|
|
|
body: {
|
|
|
|
|
ok: healthy,
|
|
|
|
|
service: "mdtodo",
|
|
|
|
|
startedAt,
|
|
|
|
|
rootDir: workspaceRoot,
|
|
|
|
|
rootExists,
|
|
|
|
|
writable,
|
|
|
|
|
fileCount: files.length,
|
|
|
|
|
parsedFileCount: files.filter((file) => file.parseError === null).length,
|
|
|
|
|
storage: {
|
|
|
|
|
primary: "hostPath-markdown",
|
|
|
|
|
rootDir: workspaceRoot,
|
|
|
|
|
},
|
|
|
|
|
files: files.slice(0, 20) as unknown as JsonValue,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
|
|
|
|
|
const text = await req.text();
|
|
|
|
|
if (!text.trim()) return {};
|
|
|
|
|
const parsed = JSON.parse(text) as unknown;
|
|
|
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("request body must be a JSON object");
|
|
|
|
|
return parsed as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function route(req: Request): Promise<Response> {
|
|
|
|
|
const url = new URL(req.url);
|
|
|
|
|
if (req.method === "OPTIONS") return jsonResponse({ ok: true });
|
|
|
|
|
try {
|
|
|
|
|
if (url.pathname === "/" || url.pathname === "/health") {
|
|
|
|
|
const health = healthSnapshot();
|
|
|
|
|
return jsonResponse(health.body, health.status);
|
|
|
|
|
}
|
|
|
|
|
if (url.pathname === "/live") return jsonResponse({ ok: true, service: "mdtodo", startedAt });
|
|
|
|
|
if (url.pathname === "/logs" && req.method === "GET") return jsonResponse({ ok: true, logs: recentLogs.slice(-100) });
|
|
|
|
|
if (url.pathname === "/api/files" && req.method === "GET") return jsonResponse({ ok: true, rootDir: workspaceRoot, files: listTodoFiles() });
|
|
|
|
|
if (url.pathname === "/api/content" && req.method === "GET") {
|
|
|
|
|
const parsed = loadTodoFile(url.searchParams.get("file"), true);
|
|
|
|
|
return textResponse(parsed.content ?? "");
|
|
|
|
|
}
|
|
|
|
|
if (url.pathname === "/api/content" && req.method === "PUT") {
|
|
|
|
|
const body = await readJsonBody(req);
|
|
|
|
|
return jsonResponse({ ok: true, file: updateContent(body.file, body.content) });
|
|
|
|
|
}
|
|
|
|
|
if (url.pathname === "/api/tasks" && req.method === "GET") {
|
|
|
|
|
return jsonResponse({ ok: true, ...loadTodoFile(url.searchParams.get("file"), url.searchParams.get("includeContent") === "1") });
|
|
|
|
|
}
|
|
|
|
|
if (url.pathname === "/api/tasks" && req.method === "POST") {
|
|
|
|
|
const body = await readJsonBody(req);
|
|
|
|
|
const result = addTask(body.file, body);
|
|
|
|
|
return jsonResponse({ ok: result.result.success, result: result.result, file: result.parsed }, result.result.success ? 200 : 404);
|
|
|
|
|
}
|
|
|
|
|
const taskMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)$/u);
|
|
|
|
|
if (taskMatch !== null && req.method === "GET") {
|
|
|
|
|
const parsed = loadTodoFile(url.searchParams.get("file"));
|
|
|
|
|
const task = findTaskInParsed(parsed, decodeURIComponent(taskMatch[1] ?? ""));
|
|
|
|
|
if (task === null) return jsonResponse({ ok: false, error: "task not found" }, 404);
|
|
|
|
|
return jsonResponse({ ok: true, task, file: parsed.file });
|
|
|
|
|
}
|
|
|
|
|
if (taskMatch !== null && req.method === "PATCH") {
|
|
|
|
|
const body = await readJsonBody(req);
|
|
|
|
|
const result = patchTask(body.file, decodeURIComponent(taskMatch[1] ?? ""), body);
|
|
|
|
|
return jsonResponse({ ok: result.result.success, result: result.result, file: result.parsed }, result.result.success ? 200 : 404);
|
|
|
|
|
}
|
|
|
|
|
if (taskMatch !== null && req.method === "DELETE") {
|
|
|
|
|
const result = deleteTask(url.searchParams.get("file"), decodeURIComponent(taskMatch[1] ?? ""));
|
|
|
|
|
return jsonResponse({ ok: result.result.success, result: result.result, file: result.parsed }, result.result.success ? 200 : 404);
|
|
|
|
|
}
|
|
|
|
|
if (url.pathname === "/api/execute-command" && req.method === "POST") {
|
|
|
|
|
return jsonResponse(buildExecutionCommand(await readJsonBody(req)));
|
|
|
|
|
}
|
|
|
|
|
return jsonResponse({ ok: false, error: "not found" }, 404);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
log("error", "request_failed", { path: url.pathname, method: req.method, error: errorToJson(error) });
|
|
|
|
|
return jsonResponse({ ok: false, error: errorMessage(error) }, 500);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Bun.serve({ hostname: host, port, idleTimeout: 120, fetch: route });
|
|
|
|
|
log("info", "service_started", { port, rootDir: workspaceRoot, rootExists: existsSync(workspaceRoot) });
|