Files
pikasTech-HWLAB/internal/project-management/mdtodo.ts
T
2026-06-25 16:34:59 +08:00

168 lines
5.5 KiB
TypeScript

import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
const excludedDirectories = new Set([
".git",
".worktree",
".state",
"node_modules",
"dist",
"build",
"coverage",
"tmp",
"vendor"
]);
const taskLinePattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u;
export async function discoverMdtodoDocuments(options = {}) {
const root = path.resolve(String(options.root || process.cwd()));
const sourceId = safeToken(options.sourceId, "default-mdtodo");
const projectId = safeToken(options.projectId, null);
const maxFiles = positiveInteger(options.maxFiles, 300);
const files = [];
await walkMarkdown(root, root, files, maxFiles);
const documents = [];
for (const filePath of files) {
const markdown = await fs.readFile(filePath, "utf8");
if (!isMdtodoCandidate(filePath, markdown)) continue;
const relativePath = normalizeRelativePath(path.relative(root, filePath));
const parsed = parseMdtodoDocument(markdown, { sourceId, relativePath, projectId });
if (parsed.tasks.length === 0 && !/mdtodo|todo/i.test(path.basename(filePath))) continue;
documents.push(parsed);
}
documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
return documents;
}
export function parseMdtodoDocument(markdown, options = {}) {
const sourceId = safeToken(options.sourceId, "default-mdtodo");
const relativePath = normalizeRelativePath(options.relativePath || "MDTODO.md");
const fileRef = documentFileRef(relativePath);
const fingerprint = sha256(markdown);
const projectId = safeToken(options.projectId, projectIdForRelativePath(relativePath));
const lines = String(markdown ?? "").split(/\r?\n/u);
const tasks = [];
const parentStack = [];
let ordinal = 0;
for (let index = 0; index < lines.length; index += 1) {
const match = taskLinePattern.exec(lines[index]);
if (!match) continue;
ordinal += 1;
const depth = Math.max(0, Math.floor(match[1].replace(/\t/gu, " ").length / 2));
const checkbox = match[2];
const title = cleanTaskTitle(match[3]);
const taskId = `t${index + 1}_${shortHash(`${relativePath}:${index + 1}:${title}`)}`;
const parentTaskRef = parentStack[depth - 1] ?? null;
const taskRef = `mdtodo:${sourceId}:${fileRef}:${taskId}`;
const task = {
taskRef,
taskId,
projectId,
sourceId,
fileRef,
relativePath,
title,
status: checkbox.toLowerCase() === "x" ? "done" : checkbox === "-" ? "blocked" : "open",
parentTaskRef,
depth,
lineNumber: index + 1,
ordinal,
linkCount: 0,
sourceFingerprint: fingerprint
};
tasks.push(task);
parentStack[depth] = taskRef;
parentStack.length = depth + 1;
}
return {
sourceId,
fileRef,
relativePath,
fingerprint,
projectId,
title: documentTitle(markdown, relativePath),
taskCount: tasks.length,
tasks,
document: {
source: "markdown-file",
relativePath,
firstHeading: documentTitle(markdown, relativePath),
valuesRedacted: true
}
};
}
export function documentFileRef(relativePath) {
return `file_${shortHash(normalizeRelativePath(relativePath), 16)}`;
}
function isMdtodoCandidate(filePath, markdown) {
const basename = path.basename(filePath).toLowerCase();
return basename.includes("todo") || basename.includes("mdtodo") || String(markdown ?? "").split(/\r?\n/u).some((line) => taskLinePattern.test(line));
}
async function walkMarkdown(root, current, files, maxFiles) {
if (files.length >= maxFiles) return;
let entries;
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch {
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (files.length >= maxFiles) return;
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
if (excludedDirectories.has(entry.name)) continue;
await walkMarkdown(root, absolute, files, maxFiles);
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) files.push(absolute);
}
}
function documentTitle(markdown, relativePath) {
const heading = String(markdown ?? "").split(/\r?\n/u).find((line) => /^#{1,6}\s+\S/u.test(line));
if (heading) return heading.replace(/^#{1,6}\s+/u, "").trim().slice(0, 180);
return path.basename(relativePath, path.extname(relativePath)) || "MDTODO";
}
function projectIdForRelativePath(relativePath) {
const segments = normalizeRelativePath(relativePath).split("/").filter(Boolean);
const candidate = segments.find((segment) => /^PJ\d{4}-\d+/iu.test(segment)) ?? segments[0] ?? "default";
return `project_${safeToken(candidate.toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "_"), "default")}`;
}
function cleanTaskTitle(value) {
return String(value ?? "").replace(/\s+<!--.*?-->\s*$/u, "").trim().slice(0, 500) || "Untitled task";
}
function normalizeRelativePath(value) {
return String(value ?? "").replace(/\\/gu, "/").replace(/^\/+|\/+$/gu, "") || "MDTODO.md";
}
function shortHash(value, length = 10) {
return sha256(value).slice(0, length);
}
function sha256(value) {
return createHash("sha256").update(String(value ?? "")).digest("hex");
}
function safeToken(value, fallback) {
const text = String(value ?? "").trim();
return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback;
}
function positiveInteger(value, fallback) {
const number = Number(value);
return Number.isInteger(number) && number > 0 ? number : fallback;
}