From 9c74326a2bed5963c32ea611a089745a4c788880 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 02:24:13 +0200 Subject: [PATCH] feat(tasktree): import MDTODO files and reports --- internal/tasktree/contracts.ts | 1 + internal/tasktree/dispatcher.ts | 5 + internal/tasktree/mdtodo-import.test.ts | 30 ++ internal/tasktree/mdtodo-import.ts | 191 +++++++++++++ internal/tasktree/store.ts | 40 +++ tools/src/tasktree-cli.ts | 8 +- .../src/components/common/BaseDialog.vue | 6 +- .../src/components/common/OverlaySurface.vue | 2 +- .../src/styles/console-foundation.css | 27 ++ .../src/views/projects/TaskTreeView.vue | 269 ++++++++++++++++-- 10 files changed, 552 insertions(+), 27 deletions(-) create mode 100644 internal/tasktree/mdtodo-import.test.ts create mode 100644 internal/tasktree/mdtodo-import.ts diff --git a/internal/tasktree/contracts.ts b/internal/tasktree/contracts.ts index b5be7d36..1e490d14 100644 --- a/internal/tasktree/contracts.ts +++ b/internal/tasktree/contracts.ts @@ -60,6 +60,7 @@ export type TaskTreeCommand = | { operation: "task.delete"; taskId: string } | { operation: "milestone.create"; groupId: string; title: string; occursAt: string; taskId?: string } | { operation: "report.create"; taskId: string; title: string; body: string; status?: string } + | { operation: "mdtodo.import"; sourcePath: string; markdown: string; reports: Array<{ href: string; sourcePath: string; body: string }>; groupName?: string; dryRun?: boolean } | { operation: "timeline.get"; groupId: string } | { operation: "workflow.start"; taskId: string }; diff --git a/internal/tasktree/dispatcher.ts b/internal/tasktree/dispatcher.ts index 95b1e05c..fb78d92c 100644 --- a/internal/tasktree/dispatcher.ts +++ b/internal/tasktree/dispatcher.ts @@ -1,6 +1,7 @@ import { Client, Connection } from "@temporalio/client"; import type { TaskTreeCommand, TaskTreeResult } from "./contracts.ts"; +import { parseMdtodoImport } from "./mdtodo-import.ts"; import { TaskTreeStore } from "./store.ts"; export type TaskTreeDispatcherOptions = { @@ -26,6 +27,10 @@ export function createTaskTreeDispatcher(options: TaskTreeDispatcherOptions) { else if (operation === "task.delete") data = { deleted: await store.deleteTask(command.taskId) }; else if (operation === "milestone.create") data = await store.createMilestone({ ...command, title: requiredText(command.title, "title"), occursAt: validDate(command.occursAt, "occursAt") }); else if (operation === "report.create") data = await store.createReport({ ...command, title: requiredText(command.title, "title"), body: requiredText(command.body, "body") }); + else if (operation === "mdtodo.import") { + const plan = parseMdtodoImport(command); + data = command.dryRun ? { dryRun: true, ...plan, tasks: undefined } : await store.importMdtodo(plan); + } else if (operation === "timeline.get") data = required(await store.timeline(command.groupId), "group_not_found", "taskgroup was not found"); else if (operation === "workflow.start") data = await startTaskWorkflow(options, command.taskId); else throw codedError("unsupported_operation", `unsupported operation: ${String(operation)}`); diff --git a/internal/tasktree/mdtodo-import.test.ts b/internal/tasktree/mdtodo-import.test.ts new file mode 100644 index 00000000..8768b33f --- /dev/null +++ b/internal/tasktree/mdtodo-import.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { parseMdtodoImport } from "./mdtodo-import.ts"; + +test("MDTODO import preserves status, flattens deep Rxx tasks, and attaches reports", () => { + const plan = parseMdtodoImport({ + sourcePath: "/tmp/demo/MDTODO.md", + markdown: `# Demo\n\n## R1 Root [in_progress]\n\nSee [任务报告](./details/R1_Task_Report.md).\n\n### R1.1 Child [completed]\n\n#### R1.1.1 Deep child [blocked]\n`, + reports: [{ href: "./details/R1_Task_Report.md", sourcePath: "/tmp/demo/details/R1_Task_Report.md", body: "# R1 report\n\nPassed." }] + }); + + assert.equal(plan.groupName, "Demo"); + assert.deepEqual(plan.summary, { taskCount: 1, subtaskCount: 2, reportCount: 1, missingReportCount: 0, flattenedTaskCount: 1, warningCount: 1 }); + assert.equal(plan.tasks[0]?.status, "in_progress"); + assert.equal(plan.tasks[1]?.status, "completed"); + assert.equal(plan.tasks[2]?.status, "blocked"); + assert.equal(plan.tasks[2]?.parentSourceId, "R1"); + assert.equal(plan.tasks[0]?.reports[0]?.body, "# R1 report\n\nPassed."); +}); + +test("MDTODO import reports missing linked report files", () => { + const plan = parseMdtodoImport({ + sourcePath: "/tmp/demo/MDTODO.md", + markdown: "# Demo\n\n## R1 Root\n\n[Task report](./R1_Task_Report.md)\n", + reports: [] + }); + assert.equal(plan.summary.missingReportCount, 1); + assert.equal(plan.warnings[0]?.code, "report_file_missing"); +}); diff --git a/internal/tasktree/mdtodo-import.ts b/internal/tasktree/mdtodo-import.ts new file mode 100644 index 00000000..c1b916a1 --- /dev/null +++ b/internal/tasktree/mdtodo-import.ts @@ -0,0 +1,191 @@ +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 { + const sourcePath = path.resolve(filePath); + const markdown = await readFile(sourcePath, "utf8"); + const reports: MdtodoReportSource[] = []; + const seen = new Set(); + 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[] = []; + 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 }); } diff --git a/internal/tasktree/store.ts b/internal/tasktree/store.ts index a7da270c..87271903 100644 --- a/internal/tasktree/store.ts +++ b/internal/tasktree/store.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import pg from "pg"; import type { ExecutionReport, Milestone, TaskGroup, TaskItem, TaskStatus, Timeline } from "./contracts.ts"; +import type { MdtodoImportPlan } from "./mdtodo-import.ts"; const { Pool } = pg; @@ -140,6 +141,45 @@ export class TaskTreeStore { return reportRow(result.rows[0]); } + async importMdtodo(plan: MdtodoImportPlan) { + await this.ensureSchema(); + const client = await this.pool.connect(); + const groupId = `tg_${randomUUID()}`; + const taskIds = new Map(); + try { + await client.query("BEGIN"); + const groupResult = await client.query( + "INSERT INTO tasktree_groups (id,name,description) VALUES ($1,$2,$3) RETURNING *", + [groupId, plan.groupName, plan.groupDescription] + ); + for (let index = 0; index < plan.tasks.length; index += 1) { + const task = plan.tasks[index]; + const taskId = `tt_${randomUUID()}`; + taskIds.set(task.sourceId, taskId); + const parentId = task.parentSourceId ? taskIds.get(task.parentSourceId) ?? null : null; + const kind = parentId ? "subtask" : "task"; + await client.query( + `INSERT INTO tasktree_tasks (id,group_id,parent_id,kind,title,description,status,sort_order) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + [taskId, groupId, parentId, kind, task.title, task.description, task.status, index] + ); + for (const report of task.reports) { + await client.query( + "INSERT INTO tasktree_execution_reports (id,task_id,title,body,status) VALUES ($1,$2,$3,$4,$5)", + [`tr_${randomUUID()}`, taskId, report.title, report.body, report.status] + ); + } + } + await client.query("COMMIT"); + return { dryRun: false, sourcePath: plan.sourcePath, group: groupRow(groupResult.rows[0]), parserMode: plan.parserMode, summary: plan.summary, warnings: plan.warnings }; + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; + } finally { + client.release(); + } + } + async timeline(groupId: string): Promise { await this.ensureSchema(); const group = await this.getGroup(groupId); diff --git a/tools/src/tasktree-cli.ts b/tools/src/tasktree-cli.ts index a11d804c..67da1196 100644 --- a/tools/src/tasktree-cli.ts +++ b/tools/src/tasktree-cli.ts @@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises"; import type { TaskStatus, TaskTreeCommand } from "../../internal/tasktree/contracts.ts"; import { createTaskTreeDispatcher } from "../../internal/tasktree/dispatcher.ts"; +import { loadMdtodoImportPayload } from "../../internal/tasktree/mdtodo-import.ts"; import { taskTreeRuntime } from "../../internal/tasktree/runtime.ts"; export async function runTaskTreeCli(argv: string[], env: Record = process.env) { @@ -28,6 +29,7 @@ function help() { "hwlab-cli tasktree task create|update|delete --group ID|--task ID [--parent ID] --title TEXT --start ISO --due ISO --status STATUS", "hwlab-cli tasktree milestone create --group ID [--task ID] --title TEXT --at ISO", "hwlab-cli tasktree report create --task ID --title TEXT (--body TEXT|--body-file PATH)", + "hwlab-cli tasktree import mdtodo --file PATH [--name TEXT] [--dry-run] [--overapi]", "hwlab-cli tasktree timeline --group ID [--overapi]", "hwlab-cli tasktree workflow start --task ID" ], @@ -48,6 +50,7 @@ async function commandFrom(parsed: Parsed): Promise { if (group === "task" && action === "delete") return { operation: "task.delete", taskId: required(parsed, "task") }; if (group === "milestone" && action === "create") return { operation: "milestone.create", groupId: required(parsed, "group"), taskId: parsed.values.task, title: required(parsed, "title"), occursAt: required(parsed, "at") }; if (group === "report" && action === "create") return { operation: "report.create", taskId: required(parsed, "task"), title: required(parsed, "title"), body: parsed.values.body ?? await readFile(required(parsed, "body-file"), "utf8"), status: parsed.values.status }; + if (group === "import" && action === "mdtodo") return { operation: "mdtodo.import", ...await loadMdtodoImportPayload(required(parsed, "file"), { groupName: parsed.values.name, dryRun: parsed.flags.has("dry-run") }) }; if (group === "workflow" && action === "start") return { operation: "workflow.start", taskId: required(parsed, "task") }; throw new Error(`unsupported TaskTree command: ${group} ${action}`.trim()); } @@ -61,12 +64,13 @@ async function overApi(command: TaskTreeCommand, env: Record; overapi: boolean; help: boolean }; +type Parsed = { positionals: string[]; values: Record; flags: Set; overapi: boolean; help: boolean }; function parse(argv: string[]): Parsed { - const parsed: Parsed = { positionals: [], values: {}, overapi: false, help: false }; + const parsed: Parsed = { positionals: [], values: {}, flags: new Set(), overapi: false, help: false }; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (token === "--overapi") parsed.overapi = true; + else if (token === "--dry-run") parsed.flags.add("dry-run"); else if (token === "--help" || token === "-h") parsed.help = true; else if (token.startsWith("--")) { const key = token.slice(2); diff --git a/web/hwlab-cloud-web/src/components/common/BaseDialog.vue b/web/hwlab-cloud-web/src/components/common/BaseDialog.vue index 2987f8c9..4ef6cab5 100644 --- a/web/hwlab-cloud-web/src/components/common/BaseDialog.vue +++ b/web/hwlab-cloud-web/src/components/common/BaseDialog.vue @@ -13,18 +13,20 @@ const props = withDefaults(defineProps<{ closeOnBackdrop?: boolean; busy?: boolean; initialFocus?: string; + surfaceClass?: string; }>(), { description: "", wide: false, fullscreen: false, closeOnBackdrop: true, busy: false, - initialFocus: "" + initialFocus: "", + surfaceClass: "" }); defineEmits<{ close: [] }>(); diff --git a/web/hwlab-cloud-web/src/components/common/OverlaySurface.vue b/web/hwlab-cloud-web/src/components/common/OverlaySurface.vue index 8d33b66a..c3341617 100644 --- a/web/hwlab-cloud-web/src/components/common/OverlaySurface.vue +++ b/web/hwlab-cloud-web/src/components/common/OverlaySurface.vue @@ -71,7 +71,7 @@ function close(): void { >
-

{{ title }}

+

{{ title }}

{{ description }}