(root: string, taskId: string)
| 1016 | |
| 1017 | /** Toggle a specific task identified by "<path>#<taskIndex>". */ |
| 1018 | export async function toggleTask(root: string, taskId: string): Promise<VaultTask | null> { |
| 1019 | const hashIdx = taskId.lastIndexOf('#') |
| 1020 | if (hashIdx < 0) throw new Error(`Malformed task id: ${taskId}`) |
| 1021 | const rel = taskId.slice(0, hashIdx) |
| 1022 | const indexStr = taskId.slice(hashIdx + 1) |
| 1023 | const targetIndex = Number.parseInt(indexStr, 10) |
| 1024 | if (!Number.isInteger(targetIndex) || targetIndex < 0) { |
| 1025 | throw new Error(`Malformed task index in id: ${taskId}`) |
| 1026 | } |
| 1027 | const abs = resolveSafe(root, rel) |
| 1028 | const body = await fs.readFile(abs, 'utf8') |
| 1029 | const normalized = body.replace(/\r\n/g, '\n') |
| 1030 | const lines = normalized.split('\n') |
| 1031 | let taskIndex = 0 |
| 1032 | let inFence = false |
| 1033 | let fenceMarker: string | null = null |
| 1034 | let lineNumber = -1 |
| 1035 | for (let i = 0; i < lines.length; i++) { |
| 1036 | const line = lines[i] |
| 1037 | const fenceMatch = line.match(FENCE_LINE_RE) |
| 1038 | if (fenceMatch) { |
| 1039 | const marker = fenceMatch[2] |
| 1040 | if (!inFence) { |
| 1041 | inFence = true |
| 1042 | fenceMarker = marker |
| 1043 | } else if (marker === fenceMarker) { |
| 1044 | inFence = false |
| 1045 | fenceMarker = null |
| 1046 | } |
| 1047 | continue |
| 1048 | } |
| 1049 | if (inFence) continue |
| 1050 | if (!TASK_LINE_RE.test(line)) continue |
| 1051 | if (taskIndex === targetIndex) { |
| 1052 | lineNumber = i |
| 1053 | break |
| 1054 | } |
| 1055 | taskIndex += 1 |
| 1056 | } |
| 1057 | if (lineNumber < 0) return null |
| 1058 | const original = lines[lineNumber] |
| 1059 | const toggled = original.replace( |
| 1060 | TASK_LINE_RE, |
| 1061 | (_m, ch: string, tail: string) => { |
| 1062 | const fullMatch = original.match(TASK_LINE_RE)! |
| 1063 | const bracketIdx = original.indexOf('[' + ch + ']') |
| 1064 | const next = ch === ' ' ? 'x' : ' ' |
| 1065 | // Preserve the full prefix (list marker, whitespace) by splicing only |
| 1066 | // the single character inside the brackets. |
| 1067 | if (bracketIdx >= 0) { |
| 1068 | return ( |
| 1069 | original.slice(0, bracketIdx + 1) + next + original.slice(bracketIdx + 2) |
| 1070 | ) |
| 1071 | } |
| 1072 | return fullMatch[0] |
| 1073 | } |
| 1074 | ) |
| 1075 | lines[lineNumber] = toggled |
no test coverage detected