From 9582553c41e008b4c3d3906443f25daf6afbb096 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 07:39:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(=E8=AD=B0=E9=A1=8C=E8=A7=A3=E6=9E=90):=20?= =?UTF-8?q?=E5=8B=BE=E9=81=B8=20checkbox=20=E7=9A=84=E7=B2=BE=E7=A2=BA?= =?UTF-8?q?=E6=9B=BF=E6=8F=9B=EF=BC=8C=E4=B8=A6=E7=B5=B1=E4=B8=80=E6=B8=85?= =?UTF-8?q?=E5=96=AE=E9=A0=85=E7=9A=84=E6=96=87=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/issue-body.js | 91 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 6 deletions(-) diff --git a/scripts/issue-body.js b/scripts/issue-body.js index aaf89de..0777eb7 100644 --- a/scripts/issue-body.js +++ b/scripts/issue-body.js @@ -148,18 +148,17 @@ export function checklistInSection(body, section) { * @returns {{indent: number, value: {text: string, done: boolean, raw: string}}|null} */ function parseChecklistItem(line) { - // [\s\S] 而非 . 的理由同 listSection:CRLF 的 body 行尾有 \r,. 不吃它。 - // text 靠 trim 修掉 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。 - const item = line.match(/^(\s*)(?:[-*+]|\d+\.)\s+([\s\S]*)$/); + // 文法與 tickLine 共用 LIST_ITEM:抽得出來的行,勾選端就要收得下。 + // text 靠 trim 修掉 CRLF 的 \r,raw 則原樣留著——它要逐字等於 body 裡的那一行。 + const item = LIST_ITEM.exec(line); if (!item) return null; - const box = item[2].match(/^\[([ xX])\]\s*([\s\S]*)$/); - const text = (box ? box[2] : item[2]).trim(); + const text = item[3].trim(); if (text === '') return null; return { 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)); } +/** + * 一行清單項的文法:符號或編號清單,後面可以有一個 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 }; +} + /** * 在指定段落裡就地更新(或補上)一行「前綴+值」。 *