// tidy.go — 認出並收拾「daemon 以前寫進使用者資料夾、但還沒帶標記」的舊產物 // (arcrun-rag#60 第二輪驗收條件③)。 // // 🔴 為什麼光「以後不再撞名」不夠: // // leo 打開資料夾的當下,裡面已經有一批舊產物了(`status.md`/`mistakes.md`/ // 日期檔那些)。修好未來不會讓現在變乾淨——**他抱怨的是他現在看到的東西**。 // 所以這一輪必須同時交出「一次認出來/一次改名」的辦法。 // // 🔴 這是別人的資料,所以本檔只做兩件可回收的事:**改名**與**搬移**。 // // 一行 os.Remove 都沒有。誤判時他把檔案搬回去就好,不會有東西不見。 // 同理,目標檔名已經存在時一律**跳過並報告**,不覆蓋(safewrite.go 同一條紅線)。 // // 兩個入口,責任不同: // // ① MigrateCardNames():daemon 每輪自動跑,只碰**卡片產物區**(那整個目錄是我們寫的, // 百分之百不會誤傷)。leo 不必知道它存在——「不該為了保護自己的筆記去學新選項」。 // ② Tidy() / `collector tidy`:人工跑,多處理一類——**template 殘留**。 // 那一類不能自動搬,因為同樣那幾個路徑在「leo 自己的開發 repo」裡是**他的真檔案** // (TemplateOwns 是路徑身分判準,分不出「daemon 鋪的」還是「repo 本來就有的」)。 // ⇒ 只有在**確定是 vault**(沒有人會把 system-dev template 放進 Logseq 筆記庫) // 時才動手,其餘一律只列出來讓人自己決定。 package collector import ( "encoding/json" "fmt" "io/fs" "os" "path/filepath" "regexp" "sort" "strings" ) // 產物分類(報告用;也是「動不動手」的判準)。 const ( TidyKindCard = "card" // 卡片本體 TidyKindCardBak = "card-backup" // safeWriteCard 產生的 .bak- TidyKindTemplate = "template-leftover" // 舊版 daemon 代裝的 system-dev template 殘留 ) // 動作(報告用)。dry-run 時只會出現 would-* 與 report-only。 const ( TidyActionRenamed = "renamed" TidyActionMoved = "moved" TidyActionWillRename = "would-rename" TidyActionWillMove = "would-move" TidyActionReport = "report-only" // 我不動它,請人自己判斷 TidyActionSkipped = "skipped" // 想動但不能動(目標已存在/搬不動) ) // legacyTemplateRelDir 是 template 殘留的收容處:監看根底下的隱藏目錄, // 保留原本的相對路徑結構(`.arcrun-rag/legacy-template/system-dev/wiki/status.md`)。 // 刻意留在同一個資料夾裡而不是丟到 ~/ 或垃圾桶——東西還在他手上,要還原只是搬回去。 const legacyTemplateRelDir = ".arcrun-rag/legacy-template" // bakSuffix 認 safeWriteCard 的備份檔:`<原名>.bak-`。 var bakSuffix = regexp.MustCompile(`\.bak-\d+$`) // TidyItem=一筆產物與我們對它做了(或會做)什麼。 type TidyItem struct { Rel string `json:"rel"` // 相對監看根的路徑(斜線分隔) To string `json:"to,omitempty"` // 改名/搬移的目的地(同樣相對監看根) Kind string `json:"kind"` // TidyKind* Action string `json:"action"` // TidyAction* Note string `json:"note,omitempty"` // 沒動它的原因(白話) } // TidyReport=一次收拾的完整帳目。 type TidyReport struct { Root string `json:"root"` VaultType VaultType `json:"vault_type"` // VaultRoot=筆記庫的根。與 Root 不同時代表「監看的是筆記庫底下的一層子資料夾」 // ——第三輪修的正是這一種(arcrun-rag#60)。空字串=不在任何筆記庫裡。 VaultRoot string `json:"vault_root,omitempty"` Applied bool `json:"applied"` // false=只看不動(dry-run) Items []TidyItem `json:"items"` } // Counts 依 action 統計,給人看的一行摘要用。 func (r *TidyReport) Counts() map[string]int { out := map[string]int{} for _, it := range r.Items { out[it.Action]++ } return out } // cardDirsToScan 回傳「卡片可能住過的所有目錄」(相對監看根)。 // 刻意兩個都掃,不看目前是不是 vault——舊產物可能是在資料夾還沒變成 vault 之前落下的, // 也可能是第一輪修復前落在 system-dev/wiki/cards/ 的。認人要認全,不能只認現在這一種。 func cardDirsToScan() []string { return []string{cardsRelDir, vaultCardsRelDir} } // isUnderCardDir 回答「這個相對路徑是不是住在卡片產物區底下」。 func isUnderCardDir(relSlash string) bool { for _, d := range cardDirsToScan() { if strings.HasPrefix(relSlash, d+"/") { return true } } return false } // collectCardItems 掃出卡片產物區裡「不在它現在該在的位置/還沒帶標記」的檔案, // 算出它們該搬去哪、該叫什麼名字。已經到位又帶標記的不列入(冪等:跑第二次是空的)。 // // 🔴 arcrun-rag#60 第三輪:判準從「有沒有帶標記」擴充成「**位置對不對** + 有沒有帶標記」。 // 只看標記在第三輪不夠——監看根是 vault 子資料夾時,前兩輪的 daemon 已經把一批 // **帶標記但落在可見目錄**(`system-dev/wiki/cards/arcrun-*.md`)的卡寫進使用者的 // 筆記庫了。那些卡在 Logseq 眼裡照樣是一頁一頁的機器頁面,改名救不了,要搬進隱藏目錄。 // 少了這一段,票上「已經寫進去的那一批要能收拾」對這一類就等於沒做。 func collectCardItems(absRoot string) []TidyItem { want := cardsRelDirFor(absRoot) var items []TidyItem for _, relDir := range cardDirsToScan() { base := filepath.Join(absRoot, filepath.FromSlash(relDir)) _ = filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil } // 🔴 inkstone/Arcrun#134(2026-08-28 實測):**隱藏目錄整棵跳過。** // // `/.wiki/` 是規範定的卡片正式落點(machinemark.go 例外 ③), // 而卡片產物區 `system-dev/wiki/cards/` 底下也會長出一個。少了這一行, // 這支就把 `cards/.wiki/換柱.md`「歸位」成 `cards/arcrun-換柱.md` // ——把**產物搬回 Scan 看得見的地方**,於是下一輪它被當成新原稿再萃一次, // 產出「卡片的卡片」,再被搬出來⋯⋯迴圈不會停。 // 實測 `youlinhsieh-test1`:`cards/` 20 分鐘內從 9 個檔長到 55 個, // 出現 `arcrun-換柱(arcrun-換柱).md` 這種第三代檔名。 // // `collectTemplateItems` 同一輪(f71e5a2)就寫了同一行同一個理由 // (「收容處自己就在 `.arcrun-rag/` 底下,不跳過的話第二次跑會把 // 搬過去的東西再搬一次」)——這支漏掉了。當時 `.wiki/` 還不存在 // (2026-08-15 才隨 ef5e6c5 進來),所以不是被否決,是沒人回來補。 // // 📌 `inkstone/Arcrun#180` 從**另一面**撞到同一個洞,兩件事一起記著: // · #134 看到的是「產物被搬出去 ⇒ 下一輪再萃 ⇒ 卡萃卡」 // · #180 看到的是「萃取端**讀不到自己一秒前寫的卡**」—— // 8 份文件全部 `讀卡片失敗:open …/.wiki/結晶管線.md: no such file`, // 於是 `system-dev/wiki/cards/` 底下那 9 張現成的卡一張都上不了雲端。 // ⇒ 同一行修好兩個症狀。 // ⚠️ 只跳**子**目錄(`p != base`):`.arcrun-rag/wiki/cards` 是 base 自己, // #60 第三輪正是把卡搬進那裡——連 base 一起跳掉等於把上一輪的修復關掉。 // `TestMigrateCardNames_收容處自己仍然掃得到` 守這一格。 if d.IsDir() { if p != base && strings.HasPrefix(d.Name(), ".") { return filepath.SkipDir } return nil } name := d.Name() rel, rerr := filepath.Rel(absRoot, p) if rerr != nil { return nil } relSlash := filepath.ToSlash(rel) if IsMarked(name) && filepath.ToSlash(filepath.Dir(relSlash)) == want { return nil // 位置對、標記也有,已經是新的了 } kind := TidyKindCard if bakSuffix.MatchString(name) { kind = TidyKindCardBak } items = append(items, TidyItem{ Rel: relSlash, To: filepath.ToSlash(filepath.Join(want, MarkName(name))), Kind: kind, }) return nil }) } return items } // collectTemplateItems 掃出 template 殘留(TemplateOwns 命中的路徑)。 // // vault=可以搬(沒人會把開發用 template 放進筆記庫,判定為 daemon 舊版鋪的)。 // 非 vault=**只列不動**:那可能正是使用者自己的開發 repo,搬了會把他的 repo 弄壞。 func collectTemplateItems(absRoot string, isVault bool) []TidyItem { var items []TidyItem _ = filepath.WalkDir(absRoot, func(p string, d fs.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() { // 隱藏目錄整棵跳過——收容處自己就在 .arcrun-rag/ 底下, // 不跳過的話第二次跑會把搬過去的東西再搬一次。 if p != absRoot && strings.HasPrefix(d.Name(), ".") { return filepath.SkipDir } return nil } rel, rerr := filepath.Rel(absRoot, p) if rerr != nil { return nil } relSlash := filepath.ToSlash(rel) if !TemplateOwns(relSlash) { return nil } // 🔴 卡片產物區就住在 `system-dev/wiki/cards/` 底下,而 TemplateOwns 是**目錄前綴** // 判準(`system-dev/` 整棵都算 template)⇒ 不排除的話,同一張卡會同時被認成 // 「卡片(該改名)」與「template 殘留(該搬走)」,兩個動作搶同一個檔, // 先跑的改完名、後跑的就 Stat 不到 ⇒ 報告出現假的失敗。卡片一律由 collectCardItems 管。 if isUnderCardDir(relSlash) { return nil } it := TidyItem{Rel: relSlash, Kind: TidyKindTemplate} if isVault { it.To = filepath.ToSlash(filepath.Join(legacyTemplateRelDir, relSlash)) } else { it.Action = TidyActionReport it.Note = "這不是 vault,可能是你自己的開發 repo(template 本來就該在)——我不動它,要清請自己確認" } items = append(items, it) return nil }) return items } // CardMigration=MigrateCardNames 一輪的帳目。 // // Blocked 不是「失敗」,是**刻意沒動**:那個資料夾在版控裡,我們沒有資格自己決定 // 改別人版控中的檔案。它要被講出來(direct.go 收進 results ⇒ status.json ⇒ App), // 不是靜默跳過——#105 的紅線與 #104 同一條:「不要讓用戶猜」。 type CardMigration struct { Moved int // 真的改名/搬移了幾個 Blocked int // 認出是舊卡、但因為在版控裡而沒動的 RepoRoot string // Blocked > 0 時:那個版控工作目錄的根 } // MigrateCardNames 把卡片產物區裡「位置不對或沒帶標記」的舊卡歸位。daemon 每輪自動呼叫。 // **只碰卡片產物區**,其餘一概不動。 // // 🔴 第四輪=arcrun-rag#105(2026-08-14 實撞):**版控中的資料夾一個檔都不動。** // // 前三輪(#60,f71e5a2/578ac2b)修的都是同一個場景的深度——「卡片會不會汙染 // 使用者的**筆記庫**」,方向一路往「更自動」走,因為那條紅線是「不准要使用者 // 去設定什麼開關才能保護自己的筆記」。**本輪不推翻那個方向**:非版控資料夾 // (含 vault)的行為與 578ac2b 完全一致。 // // 本輪修的是另一個場景——「監看的是他的 **git repo**」。上一版這裡的理由是 // 「那兩個目錄從頭到尾只有 daemon 會寫,不存在誤把使用者的檔案改名的可能」。 // 那句話在 repo 上錯了,而且錯得很貴:`system-dev/wiki/cards/` 是 // system-dev-template 的規約路徑,而 template 就是要裝進**開發者自己的 repo**—— // leo 的 `InkStoneCo` 裡那個目錄底下是他自己寫的知識卡(還有 `autonomy/` 這種 // 他自己開的子目錄)。daemon 把它們壓平改名,**16 個版控中的檔案當場變成刪除**。 // // ⇒ 判準加一問「這裡有沒有 `.git`」(見 repoguard.go 對「這夠不夠」的討論)。 // 有=停手、記帳、講給使用者聽,要整理請他自己跑 `collector tidy --apply` // (那條路預設 dry-run,會先把要動什麼列給他看)。 // 兩條紅線不衝突:「不要他學新選項才能保護筆記」管的是筆記庫; // repo 那邊 leo 的原話是「**預設應該是不動,要動要有出口**」。 func MigrateCardNames(absRoot string) CardMigration { items := collectCardItems(absRoot) if repoRoot := DetectRepoRoot(absRoot); repoRoot != "" { // 只把**確定是我們寫的**(帶 arcrun- 標記、只是位置不對)算進 Blocked。 // 沒帶標記的在 repo 裡預設就是使用者自己的檔案——把它們算進來,訊息就會變成 // 「有 40 個舊卡片沒整理」,而那 40 張是 leo 自己寫的知識卡, // 報一個全錯的數字比不報更糟(#105 要的是誠實,不是熱心)。 blocked := 0 for _, it := range items { if IsMarked(filepath.Base(it.Rel)) { blocked++ } } return CardMigration{Blocked: blocked, RepoRoot: repoRoot} } out := CardMigration{} for _, it := range items { from := filepath.Join(absRoot, filepath.FromSlash(it.Rel)) to := filepath.Join(absRoot, filepath.FromSlash(it.To)) if _, err := os.Stat(to); err == nil { continue // 新名字已經有東西了,不覆蓋 } if err := os.MkdirAll(filepath.Dir(to), 0o755); err != nil { continue } if err := os.Rename(from, to); err == nil { out.Moved++ } } return out } // Tidy 掃出所有舊產物;apply=false 只報告,apply=true 才真的改名/搬移。 func Tidy(root string, apply bool) (*TidyReport, error) { absRoot, err := filepath.Abs(root) if err != nil { return nil, err } info, err := os.Stat(absRoot) if err != nil { return nil, fmt.Errorf("看不到這個資料夾(%s):%w", absRoot, err) } if !info.IsDir() { return nil, fmt.Errorf("%s 不是資料夾", absRoot) } // 🔴 兩個判準,回答兩個不同的問題,不要合併(arcrun-rag#60 第三輪): // // · ctx(含往上找)=「我寫東西會不會落進誰的筆記庫」——決定卡片該在哪,也是報告顯示的身分。 // · DetectVaultType(absRoot)(只看這一層)=「這個資料夾**本身**是不是筆記庫」—— // 只有它才夠格授權「把 template 殘留搬走」。理由:一個住在筆記庫底下的 // **使用者自己的開發 repo**(`KB/some-repo/system-dev/…`)在 ctx 眼中也「在 vault 裡」, // 但那些 template 檔是他的真檔案,不是 daemon 鋪的。搬了就是弄壞他的 repo。 // 分不出來的時候只列不動——這條在第二輪就寫了,第三輪擴大偵測範圍時更要守住。 ctx := DetectVaultContext(absRoot) rep := &TidyReport{Root: absRoot, VaultType: ctx.Type, VaultRoot: ctx.Root, Applied: apply} items := collectCardItems(absRoot) items = append(items, collectTemplateItems(absRoot, DetectVaultType(absRoot) != VaultNone)...) sort.Slice(items, func(i, j int) bool { return items[i].Rel < items[j].Rel }) for _, it := range items { if it.Action == TidyActionReport { // 已經判定只列不動 rep.Items = append(rep.Items, it) continue } // 換了目錄就是「搬走」,只換名字才是「改名」——報告要說實話, // 使用者才知道去哪裡找他的東西(第三輪起卡片也會換目錄)。 willMove := it.Kind == TidyKindTemplate || filepath.ToSlash(filepath.Dir(it.Rel)) != filepath.ToSlash(filepath.Dir(it.To)) from := filepath.Join(absRoot, filepath.FromSlash(it.Rel)) to := filepath.Join(absRoot, filepath.FromSlash(it.To)) if _, serr := os.Stat(to); serr == nil { it.Action = TidyActionSkipped it.Note = "目的地已經有同名檔案了,我不覆蓋——請自己看一眼哪一份要留" rep.Items = append(rep.Items, it) continue } if !apply { it.Action = TidyActionWillRename if willMove { it.Action = TidyActionWillMove } rep.Items = append(rep.Items, it) continue } if merr := os.MkdirAll(filepath.Dir(to), 0o755); merr != nil { it.Action, it.Note = TidyActionSkipped, "建目的地資料夾失敗:"+merr.Error() rep.Items = append(rep.Items, it) continue } if rerr := os.Rename(from, to); rerr != nil { it.Action, it.Note = TidyActionSkipped, "搬不動:"+rerr.Error() rep.Items = append(rep.Items, it) continue } it.Action = TidyActionRenamed if willMove { it.Action = TidyActionMoved } rep.Items = append(rep.Items, it) } return rep, nil } // runTidy 是 `collector tidy` 子命令主體。 // 預設**只看不動**(要真的動手得自己加 --apply)——別人的資料夾,預設值就該是安全的那一邊。 func runTidy(args []string) int { fs2 := newFlagSet() folder := fs2.String("folder", "", "要收拾的資料夾(必填,就是 daemon 在看守的那個)") apply := fs2.Bool("apply", false, "真的動手改名/搬移(不加=只列出會動到什麼)") asJSON := fs2.Bool("json", false, "輸出 JSON(給程式讀;不加=給人看的清單)") if err := fs2.Parse(args); err != nil { return 2 } if *folder == "" { fmt.Fprintln(os.Stderr, "錯誤:--folder 為必填") return 2 } rep, err := Tidy(*folder, *apply) if err != nil { fmt.Fprintln(os.Stderr, "collector tidy:", err) return 1 } if *asJSON { data, _ := json.MarshalIndent(rep, "", " ") fmt.Println(string(data)) return 0 } if len(rep.Items) == 0 { fmt.Printf("%s:沒有找到任何沒帶標記的舊產物,這個資料夾是乾淨的。\n", rep.Root) return 0 } kind := "一般資料夾" switch { case rep.VaultType != VaultNone && rep.VaultRoot != rep.Root: // 第三輪的那一種擺法——講清楚是誰的庫,不然使用者看不懂為什麼要搬。 kind = fmt.Sprintf("%s 筆記庫「%s」底下的子資料夾", rep.VaultType, rep.VaultRoot) case rep.VaultType != VaultNone: kind = string(rep.VaultType) + " 筆記庫" } fmt.Printf("%s(%s)\n", rep.Root, kind) if !rep.Applied { fmt.Println("※ 這只是預覽,什麼都還沒動。確認清單沒問題後,同一行指令加上 --apply 才會真的執行。") } for _, it := range rep.Items { switch it.Action { case TidyActionRenamed, TidyActionWillRename: fmt.Printf(" 改名 %s → %s\n", it.Rel, it.To) case TidyActionMoved, TidyActionWillMove: why := "舊版 daemon 鋪的開發用檔案,不是你的筆記" if it.Kind != TidyKindTemplate { why = "舊版寫在看得見的位置,你的筆記軟體會把它當成一頁——搬進隱藏目錄" } fmt.Printf(" 搬走 %s → %s(%s)\n", it.Rel, it.To, why) case TidyActionReport: fmt.Printf(" 略過 %s(%s)\n", it.Rel, it.Note) case TidyActionSkipped: fmt.Printf(" 沒動 %s(%s)\n", it.Rel, it.Note) } } c := rep.Counts() fmt.Printf("合計:改名 %d、搬走 %d、只列出 %d、沒動 %d\n", c[TidyActionRenamed]+c[TidyActionWillRename], c[TidyActionMoved]+c[TidyActionWillMove], c[TidyActionReport], c[TidyActionSkipped]) fmt.Println("(本指令從不刪除任何東西:只有改名與搬移,覺得不對就自己搬回去。)") return 0 }