Files
arcrun-collector/tidy.go
T

429 lines
19 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"`
// 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#1342026-08-28 實測):**隱藏目錄整棵跳過。**
//
// `<node>/.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
}
// CardMigrationMigrateCardNames 一輪的帳目。
//
// Blocked 不是「失敗」,是**刻意沒動**:那個資料夾在版控裡,我們沒有資格自己決定
// 改別人版控中的檔案。它要被講出來(direct.go 收進 results ⇒ status.json ⇒ App),
// 不是靜默跳過——#105 的紅線與 #104 同一條:「不要讓用戶猜」。
type CardMigration struct {
Moved int // 真的改名/搬移了幾個
Blocked int // 認出是舊卡、但因為在版控裡而沒動的
RepoRoot string // Blocked > 0 時:那個版控工作目錄的根
}
// MigrateCardNames 把卡片產物區裡「位置不對或沒帶標記」的舊卡歸位。daemon 每輪自動呼叫。
// **只碰卡片產物區**,其餘一概不動。
//
// 🔴 第四輪=arcrun-rag#1052026-08-14 實撞):**版控中的資料夾一個檔都不動。**
//
// 前三輪(#60f71e5a2578ac2b)修的都是同一個場景的深度——「卡片會不會汙染
// 使用者的**筆記庫**」,方向一路往「更自動」走,因為那條紅線是「不准要使用者
// 去設定什麼開關才能保護自己的筆記」。**本輪不推翻那個方向**:非版控資料夾
// (含 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
}