Files
pikasTech-HWLAB/internal/tasktree/mdtodo-import.ts
T

192 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { TaskStatus } from "./contracts.ts";
export type MdtodoReportSource = { href: string; sourcePath: string; body: string };
export type MdtodoImportPayload = {
sourcePath: string;
markdown: string;
reports: MdtodoReportSource[];
groupName?: string;
dryRun?: boolean;
};
export type MdtodoImportTask = {
sourceId: string;
parentSourceId: string | null;
title: string;
description: string;
status: TaskStatus;
reports: Array<{ title: string; body: string; status: string }>;
};
export type MdtodoImportPlan = {
sourcePath: string;
groupName: string;
groupDescription: string;
parserMode: "rxx" | "legacy-checkbox";
tasks: MdtodoImportTask[];
summary: {
taskCount: number;
subtaskCount: number;
reportCount: number;
missingReportCount: number;
flattenedTaskCount: number;
warningCount: number;
};
warnings: Array<{ code: string; message: string; sourceId?: string; href?: string }>;
};
const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu;
const checkboxPattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u;
const markdownLinkPattern = /\[([^\]]*)\]\(([^)]+)\)/gu;
export async function loadMdtodoImportPayload(filePath: string, options: { groupName?: string; dryRun?: boolean } = {}): Promise<MdtodoImportPayload> {
const sourcePath = path.resolve(filePath);
const markdown = await readFile(sourcePath, "utf8");
const reports: MdtodoReportSource[] = [];
const seen = new Set<string>();
for (const link of reportLinks(markdown)) {
const reportPath = path.resolve(path.dirname(sourcePath), link.href);
if (seen.has(reportPath)) continue;
seen.add(reportPath);
try {
reports.push({ href: link.href, sourcePath: reportPath, body: await readFile(reportPath, "utf8") });
} catch {
// Missing report files are reported by the pure parser so dry-run and import agree.
}
}
return { sourcePath, markdown, reports, groupName: options.groupName, dryRun: options.dryRun };
}
export function parseMdtodoImport(payload: MdtodoImportPayload): MdtodoImportPlan {
const lines = String(payload.markdown ?? "").split(/\r?\n/u);
const rxxBlocks = collectRxxBlocks(lines);
const parsed = rxxBlocks.length ? parseRxx(lines, rxxBlocks, payload) : parseCheckboxes(lines, payload);
if (!parsed.tasks.length) throw codedError("mdtodo_tasks_not_found", "MDTODO file does not contain Rxx headings or checkbox tasks");
const reportCount = parsed.tasks.reduce((count, task) => count + task.reports.length, 0);
const missingReportCount = parsed.warnings.filter((warning) => warning.code === "report_file_missing").length;
const flattenedTaskCount = parsed.warnings.filter((warning) => warning.code === "nested_task_flattened").length;
return {
sourcePath: payload.sourcePath,
groupName: cleanTitle(payload.groupName || documentTitle(lines, payload.sourcePath)),
groupDescription: `Imported from MDTODO: ${payload.sourcePath}`,
parserMode: rxxBlocks.length ? "rxx" : "legacy-checkbox",
tasks: parsed.tasks,
summary: {
taskCount: parsed.tasks.filter((task) => !task.parentSourceId).length,
subtaskCount: parsed.tasks.filter((task) => task.parentSourceId).length,
reportCount,
missingReportCount,
flattenedTaskCount,
warningCount: parsed.warnings.length
},
warnings: parsed.warnings.slice(0, 100)
};
}
function parseRxx(lines: string[], blocks: RxxBlock[], payload: MdtodoImportPayload) {
const tasks: MdtodoImportTask[] = [];
const warnings: MdtodoImportPlan["warnings"] = [];
const roots = new Set(blocks.filter((block) => rxxDepth(block.sourceId) === 0).map((block) => block.sourceId));
for (const block of blocks) {
const depth = rxxDepth(block.sourceId);
const rootId = `R${block.sourceId.slice(1).split(".")[0]}`;
const parentSourceId = depth > 0 && roots.has(rootId) ? rootId : null;
if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId: block.sourceId, message: `${block.sourceId} was flattened to a subtask of ${rootId}` });
if (depth > 0 && !parentSourceId) warnings.push({ code: "parent_task_missing", sourceId: block.sourceId, message: `${block.sourceId} has no importable root task and was imported as a task` });
const description = lines.slice(block.lineIndex + 1, block.endLine).join("\n").trim();
tasks.push({
sourceId: block.sourceId,
parentSourceId,
title: cleanTitle(`${block.sourceId} ${block.title || conciseBodyTitle(description)}`),
description,
status: block.status,
reports: reportsForTask(description, payload, block.sourceId, warnings)
});
}
return { tasks, warnings };
}
function parseCheckboxes(lines: string[], payload: MdtodoImportPayload) {
const tasks: MdtodoImportTask[] = [];
const warnings: MdtodoImportPlan["warnings"] = [];
const roots: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const match = checkboxPattern.exec(lines[index]);
if (!match) continue;
const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2));
const sourceId = `L${index + 1}`;
if (depth === 0) roots.push(sourceId);
const parentSourceId = depth > 0 ? roots.at(-1) ?? null : null;
if (depth > 1) warnings.push({ code: "nested_task_flattened", sourceId, message: `${sourceId} was flattened to the nearest root checkbox task` });
tasks.push({
sourceId,
parentSourceId,
title: cleanTitle(match[3]),
description: "",
status: match[2].toLowerCase() === "x" ? "completed" : match[2] === "-" ? "blocked" : "pending",
reports: []
});
}
return { tasks, warnings };
}
function reportsForTask(body: string, payload: MdtodoImportPayload, sourceId: string, warnings: MdtodoImportPlan["warnings"]) {
const reports: MdtodoImportTask["reports"] = [];
for (const link of reportLinks(body)) {
const resolved = path.resolve(path.dirname(payload.sourcePath), link.href);
const report = payload.reports.find((candidate) => path.resolve(candidate.sourcePath) === resolved);
if (!report) {
warnings.push({ code: "report_file_missing", sourceId, href: link.href, message: `Report file could not be read: ${link.href}` });
continue;
}
reports.push({ title: documentTitle(report.body.split(/\r?\n/u), report.sourcePath), body: report.body, status: "imported" });
}
return reports;
}
function reportLinks(markdown: string) {
const links: Array<{ label: string; href: string }> = [];
for (const match of String(markdown ?? "").matchAll(markdownLinkPattern)) {
const label = String(match[1] ?? "").trim();
const href = String(match[2] ?? "").trim().replace(/^<|>$/gu, "").split(/\s+/u)[0] ?? "";
if (!href || !/\.md(?:#.*)?$/iu.test(href) || /^(?:[a-z][a-z0-9+.-]*:|\/|[A-Za-z]:\/)/iu.test(href)) continue;
if (!/(?:任务报告|task[_ -]?report|report)/iu.test(`${label} ${href}`)) continue;
links.push({ label, href: href.split("#")[0] });
}
return links;
}
type RxxBlock = { sourceId: string; title: string; status: TaskStatus; lineIndex: number; endLine: number };
function collectRxxBlocks(lines: string[]): RxxBlock[] {
const blocks: Omit<RxxBlock, "endLine">[] = [];
for (let index = 0; index < lines.length; index += 1) {
const match = rxxHeadingPattern.exec(lines[index]);
if (!match) continue;
const rest = String(match[3] ?? "").trim();
blocks.push({ sourceId: match[2].replace(/^r/iu, "R"), title: stripStatus(rest), status: statusFrom(rest), lineIndex: index });
}
return blocks.map((block, index) => ({ ...block, endLine: blocks[index + 1]?.lineIndex ?? lines.length }));
}
function statusFrom(value: string): TaskStatus {
const markers = [...value.matchAll(/\[([^\]]+)\]/gu)].map((match) => String(match[1]).trim().toLowerCase().replace(/[\s-]+/gu, "_"));
if (markers.some((marker) => marker === "blocked")) return "blocked";
if (markers.some((marker) => ["completed", "finished", "done"].includes(marker))) return "completed";
if (markers.some((marker) => ["in_progress", "processing"].includes(marker))) return "in_progress";
return "pending";
}
function stripStatus(value: string) { return value.replace(/\s*\[(?:open|todo|blocked|in[_ -]?progress|processing|completed|finished|done)\]\s*/giu, " ").trim(); }
function rxxDepth(sourceId: string) { return Math.max(0, sourceId.split(".").length - 1); }
function documentTitle(lines: string[], sourcePath: string) { return cleanTitle(lines.find((line) => /^#{1,6}\s+\S/u.test(line))?.replace(/^#{1,6}\s+/u, "") || path.basename(sourcePath, path.extname(sourcePath))); }
function firstBodyLine(body: string) { return body.split(/\r?\n/u).map((line) => line.trim()).find(Boolean) || "Untitled task"; }
function conciseBodyTitle(body: string) {
const line = firstBodyLine(body);
const separator = line.search(/[:]/u);
return separator >= 6 && separator <= 120 ? line.slice(0, separator) : line;
}
function cleanTitle(value: string) { return String(value ?? "").replace(/\s+<!--.*?-->\s*$/u, "").trim().slice(0, 500) || "Untitled task"; }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }