# 05h 壞味道審查・第二次掃描(第 4 關 rescan) **一句話:** 事件流水帳改版把第一次掃描的大問題(history 被洗掉、合併當成刪除、狀態被蓋掉)整批消掉了;這次最該處理的是「匯入一份 schema 3 進度檔會讓所有裝置永久打不開」,其次是 plan.js/store.js 兩份「哪些事件屬於這一次」清單、分頁之間共用 unsent 清單的競態,以及 ui.js 點擊處理與面板的補丁堆積。 審查範圍:index.html、app.css、js/plan.js、js/store.js、js/ui.js、server.py、tools/extract_book.py、tests/plan.test.js、tests/server_test.py、tests/e2e.js。book.js(產生的資料)與 guide.html(靜態文件)未審。判斷基準是 03-tech-spec.md(vanilla JS+Python 標準函式庫、不建置、單一資料夾)與 04-implementation.md 已記錄的決策(例如面板門檻改 1240、ui.js 單檔),不以個人偏好扣分。 | 條號 | 檔案:行號 | 白話說明 | 建議修法 | 嚴重度 | |---|---|---|---|---| | 疑似錯誤 | store.js:250、plan.js:255-259、ui.js:142、plan.js:270 | 匯入「schema 3」進度檔時,檔案裡的 session 原封不動放進進度。規格第 2 節寫的 session 欄位沒有 `plays`、`fixed`(這兩個是後來補丁加的),所以照規格格式的檔案、或 10/4 早期版本匯出的檔案,匯入後 `session.plays` 是 undefined。畫面一畫題目卡就丟錯(ui.js:142),之後任何一個「播放」事件也會讓重放丟錯(已用 node 實跑確認:`Cannot read properties of undefined (reading '7-1')`)。更糟的是 reset 事件會送上 server、永遠留在只追加的流水帳裡,所以**每一台裝置之後都打不開**,連匯入按鈕都出不來,只能手動改資料庫。 | reset 套用時把 session 補齊預設值:抽一個 `Plan.normalize(d)`,對 session 用 `Object.assign({ fixed:false, plays:{}, reviewMarks:{}, reviewResult:{}, reviewSnap:{} }, d.session)`(session 為 null 就保持 null);`fromFile` 的 schema 3 分支也先走 normalize。加一條測試:規格第 2 節格式的物件匯入後再記一筆 play 不會丟錯。 | 高 | | 1 重複程式碼/7 霰彈式修改 | plan.js:239、store.js:33 | 「哪些事件屬於這一次學習」寫了兩份:plan.js 的 `NEEDS_SESSION` 和 store.js 的 `SESSION_TYPES`,只差一個 `plan`。以後新增一種事件,只改了 plan.js 的話,store 不會幫它加 `ses`、也不會先釘住這一次的清單,晚到的事件就會算到錯的那一次,而且不會報錯。 | Plan 匯出 `SESSION_TYPES`(不含 plan),`NEEDS_SESSION` 由它加上 `plan` 組成;store.js 直接用 `Plan.SESSION_TYPES`。 | 中 | | 疑似錯誤 | store.js:160-165 | 同一個瀏覽器開兩個分頁時,「還沒送出的 id」清單(unsent)是共用的,但事件內容各自放在記憶體。分頁 B 推送時,如果它還沒收到分頁 A 剛記的事件(storage 通知是非同步的),B 的待送批次是空的,第 163 行就會把整份 unsent 清空——包含 A 剛記的那筆。A 接著推送時發現沒東西要送,顯示「✓ 已同步」,那筆事件其實沒上 server,其他裝置永遠看不到。機率低(要剛好幾毫秒內撞到),但結果是無聲遺失。 | 第 163 行只丟掉「本頁記憶體和本機快取(`ls.get(K.events)`)都沒有」的 id;或推送前先把 `K.events` union 進記憶體再算批次。 | 中 | | 1 重複程式碼 | ui.js:258-262、ui.js:361-362、ui.js:375 | 「該顯示開始/接著做/這次完成/提早結束哪顆鈕」的規則寫了三份(本頁底部、左側面板、手機底部條),而且已經不一致:還沒開始時面板寫「↓ 開始這一次」,手機底部條寫「↓ 接著做」;面板有「提早結束」,底部條沒有。進度摘要(複習 n/總數、新學 n/總數、連續聽 ✓)也還是三份(第一次掃描已指出)。 | 抽一個 `sessionActions(st)` 回傳要顯示的按鈕清單、一個 `progressText(st)`,三處都用它們組 HTML。 | 中 | | 2 過長函式/6 依戀情結 | ui.js:443-492 | 全域點擊處理從 40 行長到 50 行,仍然混用兩種分派(先看 `data-q/data-k/data-learn/data-rv/data-s/data-say/data-p/data-w/data-gapq/data-rate/data-f` 的 if 串,再看 `data-act` 的 switch)。裡面還藏著排程規則(第 455 行「在新學清單裡作答就算學完」)和星號規則(第 461-462 行)。每次補丁加按鈕都得先決定放哪一種,是補丁最容易堆積的地方。 | 統一成 `data-act`+`const actions = { answer, learn, review, star, … }` 對照表,點擊處理只剩查表;「作答算不算學完」移到 `Plan`(例如 `Plan.answerLearns(s, id, ctx)`)。 | 中 | | 2 過長函式/8 發散式變化 | ui.js:116-164 | `updateCard` 約 50 行,一次管選項顏色、對錯文字、空格、可點的字(一般/複習兩套規則)、已學標籤、播放次數、下一題鈕、學完鈕、複習鈕、拆解、星號,還有「全部題目」的篩選。改複習畫面、改篩選、改星號都要動同一個函式。 | 拆成 `paintOptions`、`paintTokens`、`paintSessionButtons`、`applyFilter` 幾個小函式,`updateCard` 依序呼叫。 | 低 | | 疑似錯誤 | store.js:120 | 開頁時 server 連不上,會每 15 秒重試;這時如果沒有待送事件,就改成「抓完整清單」(`pull(true)`)。這個計時器只有在推送成功時才會關掉,所以 server 恢復後、只要使用者沒再做任何動作,頁面就會每 15 秒下載一次**全部**歷史事件,一直到關頁。手機開著放著會一直耗流量。 | `pull(true)` 成功後也 `clearInterval(retry)`;或把重試統一交給 `push` 的那個計時器。 | 低 | | 2 過長函式/1 重複程式碼 | store.js:79-129 | `init` 50 行、三個分支(server/離線/本機)各自重複 `firstRunLegacy(); rebuild(); saveLocal(); startTimers()`,而且已經不一致:離線分支漏了 `saveLocal()`,第一次就碰到離線時,舊頁紀錄的 legacy 事件只在記憶體,關頁就沒了(下次會重記一筆新 id,結果無害,但屬於補丁遺漏)。 | 先決定 mode,再共用一段「legacy → rebuild → saveLocal → 依 mode 啟動計時器/設定狀態」。 | 低 | | 疑似錯誤 | ui.js:357-360、ui.js:523 | 面板的同步文字對照表沒有 `full`(儲存空間滿),這時面板的同步列是空白;`syncState.detail` 存了卻從沒用,所以「密碼失效」時面板仍寫「連不上 server」。離線的預設文案還同時寫在 ui.js:359、ui.js:526、store.js:117-118 三處。 | 對照表補 `full`;有 detail 時面板顯示 detail;離線文案只留一份(放 ui.js 的 TEXT)。 | 低 | | 疑似錯誤 | ui.js:194-208 | 連續聽的兩個問題(第一次掃描已指出)還在:播放中按任一張卡的 🔊,連續聽停了但畫面一直寫「正在播第 k / n 句」;按「停止」後 0.7 秒內再按「開始」,舊的計時器會接著跑,兩條播放鏈互搶。 | 每次 `startCont` 建一個 run 物件,`next()` 檢查 `contRun === run`;`saySentence` 等其他播放先呼叫 `stopCont()`。 | 低 | | 疑似錯誤 | ui.js:507-515 | 已練秒數先累積在 `pendingMs`,每 5 分鐘才記一筆 tick。如果這段時間另一台裝置按了「這次完成」,這台拉到新進度後 `pendingMs` 不會清掉,等新的一次開始後才被記進去,舊一次的分鐘數算到新一次上。 | `onReplace` 偵測到換了一次(`Plan.current` 變了)就把 `pendingMs` 歸零(或先以舊的 ses 記一筆 tick)。 | 低 | | 1 重複程式碼/9 基本型別偏執 | ui.js:10、ui.js:190、ui.js:33、ui.js:211、plan.js:38、plan.js:123-124 | 切字已經統一到 `Plan.sentenceParts`,但 ui.js 還留著自己的 `BLANK`,只用在系統朗讀的備援文字,而且 `replace` 只換掉第一個空格(7-9 這種兩個空格的題目,第二個會念成底線)。「題號拆開找題目」(`item`)和 `groupOf` 仍然兩個檔各一份。 | Plan 匯出 `item(book, id)`、`groupOf`、`blankSentence(book, id)`(用 split/join 換掉全部空格),刪掉 ui.js 的 BLANK、item、groupOf。 | 低 | | 11 冗贅/死碼 | plan.js:20、plan.js:309-314、plan.js:174、store.js:35、tests/plan.test.js:206 | 整份合併拿掉後留下的殘骸:`emptyState` 還有 `version`、`updatedAt`(沒人讀寫);Plan 匯出了沒人用的 `snapOf`、`markWord`、`INTERVALS/REVIEW_CAP/NEW_CAP/FIRST_GROUP`;`toggleMark` 只剩測試和「舊事件沒有 on」的分支在用;`setStatus` 仍只是原封轉呼叫 `onStatus`;測試檔的 `clone` 沒用到。 | 刪掉 version/updatedAt 與沒用的匯出;`toggleMark` 加一行註解說明只為相容舊事件;`setStatus` 直接用 `onStatus`;刪 `clone`。 | 低 | | 12 注釋與程式不符 | plan.js:13、e2e.js:46、e2e.js:121-122、e2e.js:139、e2e.js:260 | 註解或測試還在講已經移除的設計:MAP_FIELDS 的註解說「加在這裡就夠 defaults and merging」(已經沒有 merging);e2e 的請求紀錄還在印 `baseVersion`;「device A is now stale; its next save must not overwrite」「false conflict」、截圖名 `desktop-04-conflict` 都是整份覆蓋時代的說法;第 260 行檢查早就不用的 `tq-app-v3`,條件永遠成立。 | 註解改成事件流水帳的說法;e2e 刪掉 baseVersion、conflict 字樣與 v3 判斷。 | 低 | | 1 重複程式碼/2 不一致的寫法 | ui.js:403-413、ui.js:440、ui.js:469、ui.js:483、ui.js:508 | 記錄動作有兩種寫法:`act()`(記錄+全部重畫)和直接 `S = Store.record(...)`(只記錄,各自決定要不要重畫)。另外 ui.js 的 `touch()` 是「記一筆事件」,跟 `Plan.touch()`(標記這次已開始)同名不同義。 | 統一走 `act(type, fields, { redraw })`;ui 的 touch 改名 `recordTouch`。 | 低 | | 9 基本型別偏執 | ui.js:271、ui.js:288、ui.js:228、index.html:20、ui.js:538 | 「10 組」「100 題」「第 1 冊」仍寫死(第一次掃描已指出);`B.title` 有資料卻沒用,第 538 行又把標題設成跟 index.html 一模一樣的字。 | 迴圈用 `Object.keys(B.Q)`;冊名、題數從 B 計算;刪第 538 行。 | 低 | | 9 基本型別偏執/10 訊息鏈 | ui.js:466、ui.js:58-63、ui.js:108-110 | 題目資料仍是位置陣列,最長一串 `B.Q[g][i - 1][5][k - 1][0]`(第一次掃描已指出)。 | extract_book.py 輸出具名欄位,或在 plan.js 提供 `item()` 回傳具名物件。 | 低 | | 疑似錯誤 | server.py:114、server.py:77-79 | 第一次掃描的兩個 server 小問題都還在:Content-Length 不是數字時 `int()` 在 try 外丟例外,連線直接斷;`log_message` 取 `args[1]`,log_error 只帶一個參數時會 IndexError,而 send_error 的錯誤反而印不出來。 | `int()` 移進 try 回 400;改寫 `log_request`(參數固定),`log_message` 保持預設。 | 低 | | 疑似錯誤 | store.js:178、store.js:161、server.py:30-31 | server 回 400(例如批次超過 2MB)時,前端當成「連不上」每 15 秒用同一批重送,永遠卡住且只顯示「連不上 server」。一般事件約 100~200 字元,4000 筆約 0.5~0.8MB,平常碰不到;但上限 4000 筆(前端)與 5000 筆/2MB(server)是兩邊各寫的數字,沒有互相對應。 | 400 時把批次砍半重送;兩邊上限寫成同一組常數並註明對應關係。 | 低 | | 疑似錯誤(部署相關) | server.py:90-96 | 密碼比對沒有次數限制,存的是不加鹽的 sha256。照 DEPLOY.md 走 Tailscale 時風險很低;若有人把 port 直接開到網際網路,可以無限次猜密碼。 | 連續錯 N 次就暫停該 IP 幾秒;DEPLOY.md 強調不要直接對外開 port。 | 低 | | 疑似錯誤 | ui.js:382-383、plan.js:255-259 | 耳朵缺口清單把字和題號直接放進 innerHTML(第一次掃描已指出)。正常重放算出來的字只會是英數與撇號,安全;但 reset 事件會原封搬入檔案裡的 `gaps`,自己的匯入檔若含 `<` 才會出事。依設計屬可接受,列出備查。 | 用一個小 `esc()` 包起來,或在 normalize 時重算 gaps。 | 低 | | 3 過大檔案/8 發散式變化 | ui.js 全檔(546 行) | 比第一次掃描再多 46 行;音訊、這一次畫面、全部題目、方法說明全文、面板、對話框、計時、開機都在同一檔。規格允許單檔,但補丁都集中在這裡。 | 先抽音訊(speak/tts/連續聽)成 js/audio.js;方法說明那段 HTML 搬進 index.html 的隱藏區塊。 | 低 | | 7 霰彈式修改(樣式) | app.css:167-189、app.css:12 對照 187、app.css:42 對照 173 | 兩次補丁把規則直接接在檔尾(「gate 3 round 1/round 2」區),同一個元件的樣式散在兩處:`.banner.bad` 拆在第 12 與 187 行、`.qh .learned` 與 `.soft` 分開、`.finish .early` 與 `.pbtns .early` 幾乎一樣。 | 把補丁區的規則搬回各元件的段落,`.early` 共用一條規則。 | 低 | | 補丁殘骸 | server.py:134 | 一行被補丁接起來,中間夾著一大段空白(`"application/octet-stream") + (...)`),看起來像斷行被吃掉。 | 拆回兩行。 | 低 | | 規格落差 | 03-tech-spec.md:34、:39、:69 對照 plan.js:16、:59-68、app.css:142 | 規格的事件清單沒有 `plan`;session 欄位沒有 `fixed`、`plays`;進度欄位沒有 `plans`、`reviewPrev`;面板門檻仍寫 1080(04-implementation 已記錄改 1240)。第一項正是上面「高」那條的成因:照規格產生的檔案會缺欄位。 | 03-tech-spec 第 2、4 節補上這些欄位與門檻。 | 低 | | 規格落差 | plan.js:145 | `gapKey` 仍沒有去掉字尾單獨的撇號,8-6 的「tenants'」和「tenants」算成兩個字(第一次掃描已指出)。 | `gapKey` 加 `.replace(/'$/, "")`(保留 's)。 | 低 | | 2 過長函式 | tests/e2e.js:63-273 | 整份 e2e 仍是一個匿名 async 函式,從 160 行長到約 210 行,第 130-138 行還有一大串除錯字串。前段失敗會讓後段一起失效。 | 依區段(首次造訪、雙裝置、離線、完成、全部題目、匯出匯入、開頁離線、手機、本機模式)拆成具名函式。 | 低 | ## 第一次掃描的項目現況 - **已解決(架構改掉)**:history 只留 200 版被洗掉(server.py 改成只追加的事件表,表本身就是完整歷史);三方合併把「沒有欄位」當成刪除(不再合併整份進度;server 倒退時改成比對完整清單補送);pending 舊 baseVersion 誤報衝突;pending 寫入重複三次;merge 的 `pick` 三元運算;history_count 放在正式程式。 - **已修**:開頁狀態被 synced 蓋掉;http 下 API 失敗悄悄變本機模式(現在走離線開始,e2e 有測);切字規則兩份(改用 `Plan.sentenceParts/tokenSpans`,只剩 ui.js 的 BLANK 殘留);進度欄位要改三處(`MAP_FIELDS` 單一來源,註解待更新);音訊不支援 Range(已加,server_test 有測);server 重複 close(改用 `closing`)。 - **部分修**:同步文案不一致(衝突狀態已拿掉,但離線文案仍散在三處);冗贅轉呼叫(history_count 已移除,`setStatus` 還在)。 - **還在**:連續聽播放鏈競態;log_message 的 `args[1]`;Content-Length 的 `int()`;缺口清單 innerHTML 未跳脫;連續聽按鈕文字自己重算 contList;「作答算學完」的規則寫在 ui.js;點擊處理過長(變更長);ui.js 過大(變更大);進度摘要三份(而且按鈕規則也變成三份);item/groupOf 兩份;寫死 10 組/100 題;位置陣列;文案沒全部收進 TEXT;gapKey 撇號;e2e 一個大函式(變更長)。 本報告的每一項判斷都來自實際讀程式碼;「高」那一項另外用 node 實際重放確認會丟錯。分頁競態與 15 秒全量下載兩項是依程式流程推導,沒有實機重現。