|
|
|
@@ -1,3 +1,7 @@
|
|
|
|
|
/**
|
|
|
|
|
* SPEC: PJ2026-010404 项目管理 draft-2026-06-25-p0-mdtodo-web-active-editing-hwpod-source.
|
|
|
|
|
* Projects MDTODO Markdown source-of-truth into stable Rxx task projections and mutation helpers.
|
|
|
|
|
*/
|
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
|
import { promises as fs } from "node:fs";
|
|
|
|
|
import path from "node:path";
|
|
|
|
@@ -15,6 +19,27 @@ const excludedDirectories = new Set([
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const taskLinePattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u;
|
|
|
|
|
const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu;
|
|
|
|
|
const rxxIdPattern = /^R\d+(?:\.\d+)*$/iu;
|
|
|
|
|
const markdownLinkPattern = /!?\[[^\]]*\]\([^)]+\)|https?:\/\/\S+/giu;
|
|
|
|
|
|
|
|
|
|
const statusAliases = new Map([
|
|
|
|
|
["open", "open"],
|
|
|
|
|
["todo", "open"],
|
|
|
|
|
["blocked", "blocked"],
|
|
|
|
|
["in_progress", "in_progress"],
|
|
|
|
|
["processing", "in_progress"],
|
|
|
|
|
["completed", "done"],
|
|
|
|
|
["finished", "done"],
|
|
|
|
|
["done", "done"]
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const statusMarkers = new Map([
|
|
|
|
|
["open", null],
|
|
|
|
|
["blocked", "blocked"],
|
|
|
|
|
["in_progress", "in_progress"],
|
|
|
|
|
["done", "completed"]
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
export async function discoverMdtodoDocuments(options = {}) {
|
|
|
|
|
const root = path.resolve(String(options.root || process.cwd()));
|
|
|
|
@@ -45,6 +70,162 @@ export function parseMdtodoDocument(markdown, options = {}) {
|
|
|
|
|
const fingerprint = sha256(markdown);
|
|
|
|
|
const projectId = safeToken(options.projectId, projectIdForRelativePath(relativePath));
|
|
|
|
|
const lines = String(markdown ?? "").split(/\r?\n/u);
|
|
|
|
|
const context = { sourceId, relativePath, fileRef, fingerprint, projectId };
|
|
|
|
|
const rxxProjection = parseRxxTasks(lines, context);
|
|
|
|
|
const projection = rxxProjection.tasks.length > 0 ? rxxProjection : parseLegacyCheckboxTasks(lines, context);
|
|
|
|
|
const parserMode = rxxProjection.tasks.length > 0 ? "rxx" : "legacy-checkbox";
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
sourceId,
|
|
|
|
|
fileRef,
|
|
|
|
|
relativePath,
|
|
|
|
|
fingerprint,
|
|
|
|
|
revision: fingerprint,
|
|
|
|
|
projectId,
|
|
|
|
|
title: documentTitle(markdown, relativePath),
|
|
|
|
|
parserMode,
|
|
|
|
|
taskCount: projection.tasks.length,
|
|
|
|
|
tasks: projection.tasks,
|
|
|
|
|
document: {
|
|
|
|
|
source: "markdown-file",
|
|
|
|
|
relativePath,
|
|
|
|
|
firstHeading: documentTitle(markdown, relativePath),
|
|
|
|
|
parserMode,
|
|
|
|
|
revision: fingerprint,
|
|
|
|
|
parseDiagnostics: projection.diagnostics,
|
|
|
|
|
valuesRedacted: true
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function mutateMdtodoDocument(markdown, operation = {}, options = {}) {
|
|
|
|
|
const originalMarkdown = String(markdown ?? "");
|
|
|
|
|
const previousFingerprint = sha256(originalMarkdown);
|
|
|
|
|
const expectedFingerprint = operation.expectedFingerprint ?? options.expectedFingerprint;
|
|
|
|
|
if (expectedFingerprint && expectedFingerprint !== previousFingerprint) {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
conflict: true,
|
|
|
|
|
code: "revision_conflict",
|
|
|
|
|
message: "MDTODO document fingerprint does not match expectedFingerprint",
|
|
|
|
|
expectedFingerprint,
|
|
|
|
|
actualFingerprint: previousFingerprint
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const type = String(operation.type ?? "").trim();
|
|
|
|
|
const lines = originalMarkdown.split(/\r?\n/u);
|
|
|
|
|
const blocks = collectRxxBlocks(lines);
|
|
|
|
|
let nextLines = lines.slice();
|
|
|
|
|
let changedTaskId = null;
|
|
|
|
|
let affectedTaskIds = [];
|
|
|
|
|
|
|
|
|
|
if (type === "updateTitle") {
|
|
|
|
|
const block = requireRxxBlock(blocks, operation.rxxId);
|
|
|
|
|
const title = cleanTaskTitle(operation.title);
|
|
|
|
|
nextLines[block.lineIndex] = formatRxxHeading(block.hashes, block.rxxId, block.status, title);
|
|
|
|
|
changedTaskId = block.rxxId;
|
|
|
|
|
affectedTaskIds = [block.rxxId];
|
|
|
|
|
} else if (type === "updateStatus") {
|
|
|
|
|
const block = requireRxxBlock(blocks, operation.rxxId);
|
|
|
|
|
const status = requireStatus(operation.status);
|
|
|
|
|
nextLines[block.lineIndex] = formatRxxHeading(block.hashes, block.rxxId, status, block.title);
|
|
|
|
|
changedTaskId = block.rxxId;
|
|
|
|
|
affectedTaskIds = [block.rxxId];
|
|
|
|
|
} else if (type === "updateBody") {
|
|
|
|
|
const block = requireRxxBlock(blocks, operation.rxxId);
|
|
|
|
|
const bodyLines = bodyReplacementLines(operation.body);
|
|
|
|
|
nextLines.splice(block.lineIndex + 1, Math.max(0, block.endLine - block.lineIndex - 1), ...bodyLines);
|
|
|
|
|
changedTaskId = block.rxxId;
|
|
|
|
|
affectedTaskIds = [block.rxxId];
|
|
|
|
|
} else if (type === "addTask") {
|
|
|
|
|
const add = buildAddTaskMutation(lines, blocks, operation);
|
|
|
|
|
nextLines = insertLines(nextLines, add.insertPos, add.lines);
|
|
|
|
|
changedTaskId = add.rxxId;
|
|
|
|
|
affectedTaskIds = [add.rxxId];
|
|
|
|
|
} else if (type === "deleteTask") {
|
|
|
|
|
const block = requireRxxBlock(blocks, operation.rxxId);
|
|
|
|
|
const endLine = subtreeEndLine(blocks, block.rxxId);
|
|
|
|
|
affectedTaskIds = blocks
|
|
|
|
|
.filter((candidate) => candidate.rxxId === block.rxxId || isDescendantRxxId(candidate.rxxId, block.rxxId))
|
|
|
|
|
.map((candidate) => candidate.rxxId);
|
|
|
|
|
nextLines.splice(block.lineIndex, Math.max(1, endLine - block.lineIndex));
|
|
|
|
|
changedTaskId = block.rxxId;
|
|
|
|
|
} else {
|
|
|
|
|
throw mutationError("unsupported_mutation", `Unsupported MDTODO mutation type: ${type || "<empty>"}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextMarkdown = normalizeMarkdownOutput(nextLines);
|
|
|
|
|
const fingerprint = sha256(nextMarkdown);
|
|
|
|
|
const document = parseMdtodoDocument(nextMarkdown, options);
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
conflict: false,
|
|
|
|
|
markdown: nextMarkdown,
|
|
|
|
|
previousFingerprint,
|
|
|
|
|
fingerprint,
|
|
|
|
|
revision: fingerprint,
|
|
|
|
|
changedTaskId,
|
|
|
|
|
affectedTaskIds,
|
|
|
|
|
taskCount: document.taskCount,
|
|
|
|
|
document
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function documentFileRef(relativePath) {
|
|
|
|
|
return `file_${shortHash(normalizeRelativePath(relativePath), 16)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseRxxTasks(lines, context) {
|
|
|
|
|
const diagnostics = [];
|
|
|
|
|
const blocks = collectRxxBlocks(lines);
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
const uniqueBlocks = [];
|
|
|
|
|
for (const block of blocks) {
|
|
|
|
|
if (seen.has(block.rxxId)) {
|
|
|
|
|
diagnostics.push({ code: "duplicate_rxx_id", rxxId: block.rxxId, lineNumber: block.lineNumber });
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
seen.add(block.rxxId);
|
|
|
|
|
uniqueBlocks.push(block);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const taskIds = new Set(uniqueBlocks.map((block) => block.rxxId));
|
|
|
|
|
const tasks = uniqueBlocks.map((block, index) => {
|
|
|
|
|
const parentRxxId = parentRxxIdFor(block.rxxId);
|
|
|
|
|
const parentTaskRef = parentRxxId && taskIds.has(parentRxxId) ? taskRefForRxx(context.sourceId, context.fileRef, parentRxxId) : null;
|
|
|
|
|
if (parentRxxId && !parentTaskRef) diagnostics.push({ code: "missing_parent_rxx_id", rxxId: block.rxxId, parentRxxId, lineNumber: block.lineNumber });
|
|
|
|
|
const bodyText = lines.slice(block.lineIndex + 1, block.endLine).join("\n");
|
|
|
|
|
const bodyPreview = bodyPreviewFromLines(lines, block.lineIndex + 1, block.endLine);
|
|
|
|
|
const title = cleanTaskTitle(block.title || bodyPreview || block.rxxId);
|
|
|
|
|
return {
|
|
|
|
|
taskRef: taskRefForRxx(context.sourceId, context.fileRef, block.rxxId),
|
|
|
|
|
taskId: block.rxxId,
|
|
|
|
|
rxxId: block.rxxId,
|
|
|
|
|
projectId: context.projectId,
|
|
|
|
|
sourceId: context.sourceId,
|
|
|
|
|
fileRef: context.fileRef,
|
|
|
|
|
relativePath: context.relativePath,
|
|
|
|
|
title,
|
|
|
|
|
status: block.status,
|
|
|
|
|
parentTaskRef,
|
|
|
|
|
parentRxxId: parentTaskRef ? parentRxxId : null,
|
|
|
|
|
depth: rxxDepth(block.rxxId),
|
|
|
|
|
lineNumber: block.lineNumber,
|
|
|
|
|
ordinal: index + 1,
|
|
|
|
|
linkCount: countMarkdownLinks(bodyText),
|
|
|
|
|
sourceFingerprint: context.fingerprint,
|
|
|
|
|
parserMode: "rxx",
|
|
|
|
|
headingLevel: block.hashes.length,
|
|
|
|
|
statusMarker: block.statusMarker,
|
|
|
|
|
bodyPreview
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { tasks, diagnostics };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseLegacyCheckboxTasks(lines, context) {
|
|
|
|
|
const tasks = [];
|
|
|
|
|
const parentStack = [];
|
|
|
|
|
let ordinal = 0;
|
|
|
|
@@ -56,16 +237,16 @@ export function parseMdtodoDocument(markdown, options = {}) {
|
|
|
|
|
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 taskId = `t${index + 1}_${shortHash(`${context.relativePath}:${index + 1}:${title}`)}`;
|
|
|
|
|
const parentTaskRef = parentStack[depth - 1] ?? null;
|
|
|
|
|
const taskRef = `mdtodo:${sourceId}:${fileRef}:${taskId}`;
|
|
|
|
|
const taskRef = `mdtodo:${context.sourceId}:${context.fileRef}:${taskId}`;
|
|
|
|
|
const task = {
|
|
|
|
|
taskRef,
|
|
|
|
|
taskId,
|
|
|
|
|
projectId,
|
|
|
|
|
sourceId,
|
|
|
|
|
fileRef,
|
|
|
|
|
relativePath,
|
|
|
|
|
projectId: context.projectId,
|
|
|
|
|
sourceId: context.sourceId,
|
|
|
|
|
fileRef: context.fileRef,
|
|
|
|
|
relativePath: context.relativePath,
|
|
|
|
|
title,
|
|
|
|
|
status: checkbox.toLowerCase() === "x" ? "done" : checkbox === "-" ? "blocked" : "open",
|
|
|
|
|
parentTaskRef,
|
|
|
|
@@ -73,38 +254,175 @@ export function parseMdtodoDocument(markdown, options = {}) {
|
|
|
|
|
lineNumber: index + 1,
|
|
|
|
|
ordinal,
|
|
|
|
|
linkCount: 0,
|
|
|
|
|
sourceFingerprint: fingerprint
|
|
|
|
|
sourceFingerprint: context.fingerprint,
|
|
|
|
|
parserMode: "legacy-checkbox"
|
|
|
|
|
};
|
|
|
|
|
tasks.push(task);
|
|
|
|
|
parentStack[depth] = taskRef;
|
|
|
|
|
parentStack.length = depth + 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { tasks, diagnostics: [] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function collectRxxBlocks(lines) {
|
|
|
|
|
const headings = [];
|
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
|
|
|
const heading = parseRxxHeading(lines[index]);
|
|
|
|
|
if (heading) headings.push({ ...heading, lineIndex: index, lineNumber: index + 1 });
|
|
|
|
|
}
|
|
|
|
|
return headings.map((heading, index) => ({
|
|
|
|
|
...heading,
|
|
|
|
|
endLine: headings[index + 1]?.lineIndex ?? lines.length
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseRxxHeading(line) {
|
|
|
|
|
const match = rxxHeadingPattern.exec(String(line ?? ""));
|
|
|
|
|
if (!match) return null;
|
|
|
|
|
const parsed = parseHeadingRest(match[3] ?? "");
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
hashes: match[1],
|
|
|
|
|
rxxId: normalizeRxxId(match[2]),
|
|
|
|
|
status: parsed.status,
|
|
|
|
|
statusMarker: parsed.statusMarker,
|
|
|
|
|
title: parsed.title
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function documentFileRef(relativePath) {
|
|
|
|
|
return `file_${shortHash(normalizeRelativePath(relativePath), 16)}`;
|
|
|
|
|
function parseHeadingRest(value) {
|
|
|
|
|
const rest = String(value ?? "").trim();
|
|
|
|
|
let status = "open";
|
|
|
|
|
let statusMarker = null;
|
|
|
|
|
for (const match of rest.matchAll(/\[([^\]]+)\]/gu)) {
|
|
|
|
|
const candidate = normalizeStatus(match[1]);
|
|
|
|
|
if (!candidate) continue;
|
|
|
|
|
if (statusRank(candidate) >= statusRank(status)) {
|
|
|
|
|
status = candidate;
|
|
|
|
|
statusMarker = match[1];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
status,
|
|
|
|
|
statusMarker,
|
|
|
|
|
title: stripStatusMarkers(rest)
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildAddTaskMutation(lines, blocks, operation) {
|
|
|
|
|
const title = cleanTaskTitle(operation.title);
|
|
|
|
|
const status = requireStatus(operation.status ?? "open");
|
|
|
|
|
const parentRxxId = normalizeOptionalRxxId(operation.parentRxxId);
|
|
|
|
|
const afterRxxId = normalizeOptionalRxxId(operation.afterRxxId);
|
|
|
|
|
let rxxId;
|
|
|
|
|
let headingLevel;
|
|
|
|
|
let insertPos;
|
|
|
|
|
|
|
|
|
|
if (parentRxxId) {
|
|
|
|
|
const parent = requireRxxBlock(blocks, parentRxxId);
|
|
|
|
|
rxxId = nextChildRxxId(blocks, parent.rxxId);
|
|
|
|
|
headingLevel = headingLevelForRxx(rxxId);
|
|
|
|
|
insertPos = subtreeEndLine(blocks, parent.rxxId);
|
|
|
|
|
} else if (afterRxxId) {
|
|
|
|
|
const after = requireRxxBlock(blocks, afterRxxId);
|
|
|
|
|
const siblingParent = parentRxxIdFor(after.rxxId);
|
|
|
|
|
rxxId = siblingParent ? nextChildRxxId(blocks, siblingParent) : nextRootRxxId(blocks);
|
|
|
|
|
headingLevel = headingLevelForRxx(rxxId);
|
|
|
|
|
insertPos = subtreeEndLine(blocks, after.rxxId);
|
|
|
|
|
} else {
|
|
|
|
|
rxxId = nextRootRxxId(blocks);
|
|
|
|
|
headingLevel = headingLevelForRxx(rxxId);
|
|
|
|
|
insertPos = logicalDocumentEnd(lines);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bodyLines = normalizeBodyLines(operation.body);
|
|
|
|
|
const taskLines = [formatRxxHeading("#".repeat(headingLevel), rxxId, status, title)];
|
|
|
|
|
if (bodyLines.length > 0) taskLines.push("", ...bodyLines);
|
|
|
|
|
return { rxxId, insertPos, lines: taskLines };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireRxxBlock(blocks, value) {
|
|
|
|
|
const rxxId = normalizeOptionalRxxId(value);
|
|
|
|
|
if (!rxxId) throw mutationError("rxx_id_required", "A valid Rxx id is required");
|
|
|
|
|
const block = blocks.find((candidate) => candidate.rxxId === rxxId);
|
|
|
|
|
if (!block) throw mutationError("rxx_id_not_found", `MDTODO task ${rxxId} was not found`, { rxxId });
|
|
|
|
|
return block;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function insertLines(lines, insertPos, newLines) {
|
|
|
|
|
const next = lines.slice();
|
|
|
|
|
const insertion = [];
|
|
|
|
|
if (insertPos > 0 && String(next[insertPos - 1] ?? "").trim() !== "") insertion.push("");
|
|
|
|
|
insertion.push(...newLines);
|
|
|
|
|
if (insertPos < next.length && String(next[insertPos] ?? "").trim() !== "") insertion.push("");
|
|
|
|
|
next.splice(insertPos, 0, ...insertion);
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bodyReplacementLines(value) {
|
|
|
|
|
const lines = normalizeBodyLines(value);
|
|
|
|
|
if (lines.length === 0) return [];
|
|
|
|
|
return ["", ...lines, ""];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeBodyLines(value) {
|
|
|
|
|
const text = String(value ?? "").replace(/\r\n/gu, "\n").trim();
|
|
|
|
|
if (!text) return [];
|
|
|
|
|
return text.split("\n");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatRxxHeading(hashes, rxxId, status, title) {
|
|
|
|
|
const marker = statusMarkers.get(normalizeStatus(status) ?? "open");
|
|
|
|
|
const parts = [hashes, rxxId];
|
|
|
|
|
if (title) parts.push(cleanTaskTitle(title));
|
|
|
|
|
if (marker) parts.push(`[${marker}]`);
|
|
|
|
|
return parts.join(" ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nextRootRxxId(blocks) {
|
|
|
|
|
const next = Math.max(0, ...blocks
|
|
|
|
|
.map((block) => rxxSegments(block.rxxId))
|
|
|
|
|
.filter((segments) => segments.length === 1)
|
|
|
|
|
.map((segments) => segments[0])) + 1;
|
|
|
|
|
return `R${next}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nextChildRxxId(blocks, parentRxxId) {
|
|
|
|
|
const parentSegments = rxxSegments(parentRxxId);
|
|
|
|
|
const prefix = `${parentRxxId}.`;
|
|
|
|
|
const next = Math.max(0, ...blocks
|
|
|
|
|
.filter((block) => block.rxxId.startsWith(prefix))
|
|
|
|
|
.map((block) => rxxSegments(block.rxxId))
|
|
|
|
|
.filter((segments) => segments.length === parentSegments.length + 1)
|
|
|
|
|
.map((segments) => segments.at(-1))) + 1;
|
|
|
|
|
return `${parentRxxId}.${next}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function subtreeEndLine(blocks, rxxId) {
|
|
|
|
|
const startIndex = blocks.findIndex((block) => block.rxxId === rxxId);
|
|
|
|
|
if (startIndex < 0) return 0;
|
|
|
|
|
let endLine = blocks[startIndex].endLine;
|
|
|
|
|
for (let index = startIndex + 1; index < blocks.length; index += 1) {
|
|
|
|
|
if (!isDescendantRxxId(blocks[index].rxxId, rxxId)) break;
|
|
|
|
|
endLine = blocks[index].endLine;
|
|
|
|
|
}
|
|
|
|
|
return endLine;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function logicalDocumentEnd(lines) {
|
|
|
|
|
let end = lines.length;
|
|
|
|
|
while (end > 0 && String(lines[end - 1] ?? "").trim() === "") end -= 1;
|
|
|
|
|
return end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeMarkdownOutput(lines) {
|
|
|
|
|
const text = lines.join("\n").replace(/[\t ]+$/gmu, "").replace(/\n{4,}/gu, "\n\n\n").trimEnd();
|
|
|
|
|
return text ? `${text}\n` : "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
return basename.includes("todo") || basename.includes("mdtodo") || String(markdown ?? "").split(/\r?\n/u).some((line) => taskLinePattern.test(line) || rxxHeadingPattern.test(line));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function walkMarkdown(root, current, files, maxFiles) {
|
|
|
|
@@ -140,6 +458,76 @@ function projectIdForRelativePath(relativePath) {
|
|
|
|
|
return `project_${safeToken(candidate.toLowerCase().replace(/[^a-z0-9_.:-]+/gu, "_"), "default")}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function taskRefForRxx(sourceId, fileRef, rxxId) {
|
|
|
|
|
return `mdtodo:${sourceId}:${fileRef}:${rxxId}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parentRxxIdFor(rxxId) {
|
|
|
|
|
const segments = String(rxxId ?? "").split(".");
|
|
|
|
|
if (segments.length <= 1) return null;
|
|
|
|
|
return segments.slice(0, -1).join(".");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isDescendantRxxId(candidate, parent) {
|
|
|
|
|
return candidate.startsWith(`${parent}.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rxxDepth(rxxId) {
|
|
|
|
|
return Math.max(0, rxxSegments(rxxId).length - 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function headingLevelForRxx(rxxId) {
|
|
|
|
|
return Math.min(6, rxxDepth(rxxId) + 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rxxSegments(rxxId) {
|
|
|
|
|
return String(rxxId ?? "").replace(/^R/iu, "").split(".").map((segment) => Number(segment)).filter((segment) => Number.isInteger(segment) && segment > 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeRxxId(value) {
|
|
|
|
|
return String(value ?? "").replace(/^r/iu, "R");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeOptionalRxxId(value) {
|
|
|
|
|
const text = normalizeRxxId(value).trim();
|
|
|
|
|
return rxxIdPattern.test(text) ? text : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeStatus(value) {
|
|
|
|
|
const key = String(value ?? "open").trim().toLowerCase().replace(/[\s-]+/gu, "_");
|
|
|
|
|
return statusAliases.get(key) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireStatus(value) {
|
|
|
|
|
const status = normalizeStatus(value);
|
|
|
|
|
if (!status) throw mutationError("invalid_status", `Unsupported MDTODO status: ${String(value ?? "")}`);
|
|
|
|
|
return status;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusRank(status) {
|
|
|
|
|
if (status === "done") return 3;
|
|
|
|
|
if (status === "in_progress") return 2;
|
|
|
|
|
if (status === "blocked") return 1;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stripStatusMarkers(value) {
|
|
|
|
|
return String(value ?? "").replace(/\s*\[([^\]]+)\]/gu, (match, label) => normalizeStatus(label) ? "" : match).trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function countMarkdownLinks(value) {
|
|
|
|
|
return [...String(value ?? "").matchAll(markdownLinkPattern)].length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bodyPreviewFromLines(lines, start, end) {
|
|
|
|
|
for (const line of lines.slice(start, end)) {
|
|
|
|
|
const text = String(line ?? "").trim();
|
|
|
|
|
if (!text || rxxHeadingPattern.test(text)) continue;
|
|
|
|
|
return text.replace(/^[-*+]\s+/u, "").slice(0, 240);
|
|
|
|
|
}
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cleanTaskTitle(value) {
|
|
|
|
|
return String(value ?? "").replace(/\s+<!--.*?-->\s*$/u, "").trim().slice(0, 500) || "Untitled task";
|
|
|
|
|
}
|
|
|
|
@@ -165,3 +553,10 @@ function positiveInteger(value, fallback) {
|
|
|
|
|
const number = Number(value);
|
|
|
|
|
return Number.isInteger(number) && number > 0 ? number : fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mutationError(code, message, details = {}) {
|
|
|
|
|
const error = new Error(message);
|
|
|
|
|
error.code = code;
|
|
|
|
|
Object.assign(error, details);
|
|
|
|
|
return error;
|
|
|
|
|
}
|
|
|
|
|