Files
arcrun-collector/tidy.go
T
Leo 5a140faf1f fix(collector): 機器寫進筆記庫的檔案一律帶前綴,不再跟使用者的頁面撞名(arcrun-rag#60)
leo 2026-08-12:「我的 Logseq 又被覆蓋⋯⋯**不只是加上 journal,可能所有的檔案都加一個前後綴,比如「wiki」**。」

不是資料被蓋掉,是機器產出用了跟他一樣的命名空間(status.md、日期檔那些)
⇒ 他打開資料夾分不出哪些是自己的。**心理上的覆蓋跟實際覆蓋一樣糟。**
2026-08-12 14:57:32 +08:00

324 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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-<unixnano>
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-<unixnano>`。
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"`
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 掃出卡片產物區裡「還沒帶標記」的檔案,算出它們該改成什麼名字。
// 已經帶標記的不列入(冪等:跑第二次是空的)。
func collectCardItems(absRoot string) []TidyItem {
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 || d.IsDir() {
return nil
}
name := d.Name()
if IsMarked(name) {
return nil // 已經是新的了
}
rel, rerr := filepath.Rel(absRoot, p)
if rerr != nil {
return nil
}
kind := TidyKindCard
if bakSuffix.MatchString(name) {
kind = TidyKindCardBak
}
items = append(items, TidyItem{
Rel: filepath.ToSlash(rel),
To: filepath.ToSlash(filepath.Join(filepath.Dir(rel), 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
}
// MigrateCardNames 把卡片產物區裡沒帶標記的舊卡就地改名。daemon 每輪自動呼叫。
// 回傳實際改名的筆數。**只碰卡片產物區**,其餘一概不動。
//
// 為什麼可以自動:那兩個目錄從頭到尾只有 daemon 會寫(見 cardsRelDirFor 的註解),
// 不存在「誤把使用者的檔案改名」的可能。目標已存在就跳過,不覆蓋。
func MigrateCardNames(absRoot string) int {
n := 0
for _, it := range collectCardItems(absRoot) {
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.Rename(from, to); err == nil {
n++
}
}
return n
}
// 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)
}
vt := DetectVaultType(absRoot)
rep := &TidyReport{Root: absRoot, VaultType: vt, Applied: apply}
items := collectCardItems(absRoot)
items = append(items, collectTemplateItems(absRoot, vt != 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
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 := "一般資料夾"
if 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:
fmt.Printf(" 搬走 %s → %s(舊版 daemon 鋪的開發用檔案,不是你的筆記)\n", it.Rel, it.To)
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
}