feat(議題解析): 勾選 checkbox 的精確替換,並統一清單項的文法
tickLine 把指定那一行的方框換成已勾,其餘一字不動。四件事決定它會不會靜靜改壞議題: - **跳過圍欄。** 這是 issue-body.js 全檔的前提,而勾選是本檔唯一會寫回議題的路徑。 工作包模板的架構圖就是一塊 fenced mermaid,裡面出現減號開頭的行是常態, 把它當成待辦勾下去,改壞的是一張圖。 - **限定段落。** 待辦與整體驗收常有一模一樣的一句話,不限定就會回報「分不出來」, 而使用者其實講得很清楚。理由與 upsertLineInSection 相同:弄錯的代價是靜靜改壞內容。 - **整行比對,認不出就交回 ambiguous。** 巢狀待辦底下常有一樣的驗收,賭第一個會讓 進度條指著錯的那一項,而沒有人會去比對編輯紀錄。 - **[ ]、[x]、[X] 指的是同一行。** [X] 是合法的 GFM,Gitea 也渲染成已勾;只認小寫的話, 中斷後重跑會硬失敗,錯誤訊息還會誣指「議題被改過」。 清單項的文法收斂成一份 LIST_ITEM,parseChecklistItem 與 tickLine 共用。先前兩端各寫一份, 鬆緊不一致:`- [ ]甲` 抽得出來卻勾不動,正本那句「一律用 wp-extract 給的 raw」就成了 做不到的指示。沒有方框的項目交回 no-checkbox,不再謊報「已經勾過」。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+85
-6
@@ -148,18 +148,17 @@ export function checklistInSection(body, section) {
|
|||||||
* @returns {{indent: number, value: {text: string, done: boolean, raw: string}}|null}
|
* @returns {{indent: number, value: {text: string, done: boolean, raw: string}}|null}
|
||||||
*/
|
*/
|
||||||
function parseChecklistItem(line) {
|
function parseChecklistItem(line) {
|
||||||
// [\s\S] 而非 . 的理由同 listSection:CRLF 的 body 行尾有 \r,. 不吃它。
|
// 文法與 tickLine 共用 LIST_ITEM:抽得出來的行,勾選端就要收得下。
|
||||||
// text 靠 trim 修掉 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。
|
// text 靠 trim 修掉 CRLF 的 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。
|
||||||
const item = line.match(/^(\s*)(?:[-*+]|\d+\.)\s+([\s\S]*)$/);
|
const item = LIST_ITEM.exec(line);
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
||||||
const box = item[2].match(/^\[([ xX])\]\s*([\s\S]*)$/);
|
const text = item[3].trim();
|
||||||
const text = (box ? box[2] : item[2]).trim();
|
|
||||||
if (text === '') return null;
|
if (text === '') return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
indent: item[1].length,
|
indent: item[1].length,
|
||||||
value: { text, done: box ? box[1].toLowerCase() === 'x' : false, raw: line },
|
value: { text, done: item[2]?.toLowerCase() === '[x]', raw: line },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +258,86 @@ function isSeparator(cells) {
|
|||||||
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
|
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一行清單項的文法:符號或編號清單,後面可以有一個 checkbox。
|
||||||
|
*
|
||||||
|
* 全檔只有這一份定義。抽取端(parseChecklistItem)與勾選端(tickLine)若各寫一份,
|
||||||
|
* 遲早會鬆緊不一——抽得出來卻勾不動的那一行,會讓「一律用 wp-extract 給的 raw」
|
||||||
|
* 變成做不到的指示。
|
||||||
|
*/
|
||||||
|
const LIST_ITEM = /^(\s*)(?:[-*+]|\d+\.)\s+(\[[ xX]\])?\s*([\s\S]*)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 這一行是不是清單項(有沒有 checkbox 都算)。
|
||||||
|
*
|
||||||
|
* `--tick` 用它驗輸入,而且刻意不要求 checkbox:抽取端會把「忘了寫 checkbox 的待辦」
|
||||||
|
* 也收成一項待辦,那種 raw 要走到 tickLine 才能得到「去議題上補成 checkbox」這句話,
|
||||||
|
* 在入口就擋掉只會回一個看不出該怎麼辦的格式錯誤。
|
||||||
|
* @param {string} line
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isListItem(line) {
|
||||||
|
return LIST_ITEM.test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 勾起一行 checkbox:把 `raw` 那一行的方框換成已勾,其餘一字不動。
|
||||||
|
*
|
||||||
|
* 三件事都限定在目標段落之內、且跳過圍欄,理由與 upsertLineInSection 相同——
|
||||||
|
* 弄錯的代價是靜靜改壞別人的內容。勾選是這個檔案裡唯一會寫回議題的路徑,
|
||||||
|
* 而工作包模板的架構圖就是一塊 fenced mermaid:裡面出現減號開頭的行是常態,
|
||||||
|
* 把它當成待辦勾下去,改壞的是一張圖。
|
||||||
|
*
|
||||||
|
* 用整行精確比對而不是「找那段文字」,因為巢狀待辦底下常有一模一樣的驗收
|
||||||
|
* (兩項待辦各有一條「加上測試」)。認不出是哪一行時交回 ambiguous 讓呼叫端報錯,
|
||||||
|
* 不賭第一個——猜錯的話議題上的進度條會指著錯的那一項,而沒有人會去比對編輯紀錄。
|
||||||
|
*
|
||||||
|
* 勾選狀態與大小寫都不影響比對:`[ ]`、`[x]`、`[X]` 指的是同一行,
|
||||||
|
* 已經勾過就交回 already,讓中斷後重跑是安靜的 no-op 而不是失敗。
|
||||||
|
*
|
||||||
|
* 本函式不拋錯——它是純解析,錯誤碼由呼叫端決定。
|
||||||
|
*
|
||||||
|
* @param {string} body 議題 body
|
||||||
|
* @param {string} raw 抽取契約交出的原始 markdown 行,逐字包含縮排與行尾的 \r
|
||||||
|
* @param {string} [section] 限定在這個段落內找;省略時找全文(圍欄照樣不算)
|
||||||
|
* @returns {{status: 'ticked'|'already'|'not-found'|'ambiguous'|'no-checkbox'|'no-section', body?: string, line?: string, count: number}}
|
||||||
|
*/
|
||||||
|
export function tickLine(body, raw, section) {
|
||||||
|
const item = LIST_ITEM.exec(raw);
|
||||||
|
if (!item || item[2] === undefined) return { status: 'no-checkbox', count: 0 };
|
||||||
|
|
||||||
|
const rows = [...eachLine(body)];
|
||||||
|
const { start, end } = section === undefined
|
||||||
|
? { start: -1, end: rows.length }
|
||||||
|
: sectionBounds(rows, section);
|
||||||
|
if (section !== undefined && start === -1) return { status: 'no-section', count: 0 };
|
||||||
|
|
||||||
|
/** 同一行的三種寫法都指向它自己:比對時一律正規化成未勾的小寫版本 */
|
||||||
|
const normalize = (line) => line.replace(/\[[ xX]\]/, '[ ]');
|
||||||
|
const wanted = normalize(raw);
|
||||||
|
const ticked = raw.replace(/\[[ xX]\]/, '[x]');
|
||||||
|
|
||||||
|
const hits = [];
|
||||||
|
for (let i = start + 1; i < end; i += 1) {
|
||||||
|
if (rows[i].inFence) continue;
|
||||||
|
if (normalize(rows[i].line) === wanted) hits.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hits.length === 0) return { status: 'not-found', count: 0 };
|
||||||
|
if (hits.length > 1) return { status: 'ambiguous', count: hits.length };
|
||||||
|
|
||||||
|
const [at] = hits;
|
||||||
|
// 已勾與否看方框本身,不比整行字串:`[X]` 是合法的 GFM,Gitea 也渲染成已勾,
|
||||||
|
// 用字串相等判斷會把它當成還沒勾,於是重跑時硬把大寫改成小寫
|
||||||
|
if (LIST_ITEM.exec(rows[at].line)[2].toLowerCase() === '[x]') {
|
||||||
|
return { status: 'already', line: rows[at].line, count: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = rows.map((row) => row.line);
|
||||||
|
lines[at] = ticked;
|
||||||
|
return { status: 'ticked', body: lines.join('\n'), line: ticked, count: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在指定段落裡就地更新(或補上)一行「前綴+值」。
|
* 在指定段落裡就地更新(或補上)一行「前綴+值」。
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user