diff --git a/direct.go b/direct.go index 6ddd0a8..05cc95c 100644 --- a/direct.go +++ b/direct.go @@ -1067,6 +1067,21 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta } } + // InkStoneCo#44 ⑩:改名/搬移後,舊路徑在雲端知識庫裡要跟著下架,否則舊卡永久留著 + // (純改檔名)或同一份文件在庫裡變兩套(搬到別的資料夾,新舊頁名相同)。 + // renamed 事件只在 removed×added 配對到的那一輪出現一次(見 scan.go 步驟 3), + // 下一輪不會再有機會補發——所以「這個舊路徑要下架」必須在偵測到的當下就 + // 寫進 manifest(QueueTakedown,持久化),不能只靠事件迴圈處理到才記,否則單輪 + // 上限(perRunCap)把它排除在外、或這輪下架失敗時就會永久遺失這個待辦。 + for _, ev := range payload.Events { + if ev.Type == "renamed" && ev.OldPath != "" { + m.QueueTakedown(ev.OldPath, pageNameOf(ev.OldPath)) + } + } + if len(m.PendingTakedowns) > 0 && !dryRun { + saveManifest() + } + // 2026-08-07 pacing task 1:新改的檔優先+單輪上限。 // - 排序:把 payload.Events 依檔案 mtime 由新到舊重排(不動送雲端的 payload 本身, // 只重排這裡的本機處理佇列——見 sortEventsNewestFirst 註解)。 @@ -1085,7 +1100,9 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta switch ev.Type { case "added", "modified", "renamed": // renamed 在 direct 模式視同 added:內容未變但為求 kbdb 有這頁名的卡,重送一次萃取 - //(頁名可能改變=要新頁名的卡)。冪等由 kbdb 端承擔(同頁名覆蓋語意)。 + //(頁名可能改變=要新頁名的卡)。新路徑這邊的冪等由 kbdb 端承擔(同頁名覆蓋語意); + // 舊路徑那邊不會自動消失——上面已經把 ev.OldPath 排進 m.PendingTakedowns, + // 這裡送完新卡之後、本函式結尾會補打下架(InkStoneCo#44 ⑩)。 res := DirectResult{Type: ev.Type, Path: ev.Path} // 2026-08-07 pacing task 2:帳號還在額度冷卻中 → 這輪連試都不試。 // 這不是這個檔的問題(不記 FailCount/退避——那是「這個檔」的病歷, @@ -1320,6 +1337,49 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta } } + // InkStoneCo#44 ⑩:補打「改名/搬移後還沒下架成功」的舊路徑——包含本輪剛 + // 上面排進去的,以及之前輪次失敗留下的(同一個待辦清單,一次處理完)。 + // 與 orderedEvents 共用同一個節流器(pace),避免一輪多筆改名瞬間打爆雲端。 + if len(m.PendingTakedowns) == 0 { + // no-op:沒有待辦 + } else if dryRun { + for oldPath := range m.PendingTakedowns { + results = append(results, DirectResult{Type: "renamed_takedown", Path: oldPath, Status: "planned"}) + } + } else { + for oldPath, pageName := range m.PendingTakedowns { + pace() + res := DirectResult{Type: "renamed_takedown", Path: oldPath} + status, _, perr := cfg.postJSON(cfg.triggerURL(cfg.RemovedWF), map[string]any{ + "page_name": pageName, + "path": oldPath, + }) + res.HTTPStatus = status + if perr != nil { + res.Status, res.Error = "failed", "改名/搬移後舊頁下架失敗(下輪重試):"+perr.Error() + exit = 1 + } else { + res.Status = "removed" + m.ClearTakedown(oldPath) + // 同步清掉本地舊卡(若還存在)——與「removed」分支同一套清理,見下方。 + if cfg.Extractor != "" { + cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardRelFor(absRoot, pageName))) + if _, serr := os.Stat(cardAbs); serr == nil { + _ = os.Remove(cardAbs) + } + if werr := RemoveWikiDoc(absRoot, oldPath); werr != nil { + results = append(results, DirectResult{ + Type: "warning", Path: oldPath, Status: "skipped", + Error: "wiki 卡收走失敗(不擋下架):" + werr.Error(), + }) + } + } + } + saveManifest() + results = append(results, res) + } + } + // 防呆警告輪:Scan 已壓下 removed 事件,這裡只回報警告不下架。 for _, w := range payload.Warnings { results = append(results, DirectResult{Type: "warning", Status: "skipped", Error: w.Code + ": " + w.Message}) diff --git a/manifest.go b/manifest.go index f0d8b66..eb14e30 100644 --- a/manifest.go +++ b/manifest.go @@ -81,6 +81,35 @@ type Manifest struct { // 雲端一壞就是每 5 秒撞一次。內容變了(雜湊不同)視同新卡,立即可再試。 InventoryFailHash string `json:"inventory_fail_hash,omitempty"` InventoryNextRetry int64 `json:"inventory_next_retry,omitempty"` + + // PendingTakedowns=改名/搬移後「舊路徑」在雲端知識庫裡還沒下架成功的待辦清單 + // (key=舊相對路徑,value=該路徑當時導出的頁名)。InkStoneCo#44 ⑩: + // + // direct 模式把 renamed 事件當 added 處理(用新路徑重送一次萃取),但從未告訴 + // 雲端「舊頁名/舊路徑那份已經死了」——純改檔名時舊頁名的舊卡永久留著;搬到 + // 別的資料夾時(basename 不變 ⇒ 新舊頁名相同)舊的不刪、新的照寫,kbdb 裡同一份 + // 文件變兩套,其中一套指向已不存在的路徑,而且沒有任何機制會回頭發現它。 + // + // 為什麼要持久化而不是「失敗了下一輪自然重試」(removed 事件的作法):removed + // 事件靠「檔案仍然不在」讓 Scan() 每輪重新偵測、重新補發;但 renamed 的配對 + // (removed×added 以 content_hash 配對)只在偵測到的那一輪出現一次,舊路徑已經 + // 不在任何一邊的掃描結果裡,下一輪不會再有 renamed 事件把它帶出來。不記住它, + // 一次下架失敗(雲端剛好那幾秒掛掉)就永久遺失,舊卡從此不會再被清。 + PendingTakedowns map[string]string `json:"pending_takedowns,omitempty"` +} + +// QueueTakedown 記一筆「這個舊路徑(連同當時的頁名)還沒在雲端下架」的待辦。 +// 冪等:同一路徑重複呼叫只覆蓋頁名(理論上不會變,但不假設呼叫端不會重複觸發)。 +func (m *Manifest) QueueTakedown(oldPath, pageName string) { + if m.PendingTakedowns == nil { + m.PendingTakedowns = map[string]string{} + } + m.PendingTakedowns[oldPath] = pageName +} + +// ClearTakedown 下架成功後從待辦清單移除。 +func (m *Manifest) ClearTakedown(oldPath string) { + delete(m.PendingTakedowns, oldPath) } // newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。