168 lines
5.5 KiB
TypeScript
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;
|
|
}
|