5a140faf1f
leo 2026-08-12:「我的 Logseq 又被覆蓋⋯⋯**不只是加上 journal,可能所有的檔案都加一個前後綴,比如「wiki」**。」 不是資料被蓋掉,是機器產出用了跟他一樣的命名空間(status.md、日期檔那些) ⇒ 他打開資料夾分不出哪些是自己的。**心理上的覆蓋跟實際覆蓋一樣糟。**
324 lines
12 KiB
Go
324 lines
12 KiB
Go
// 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
|
||
}
|