From f10d10747cce16ac061bde6cff1c2ec71ba38d46 Mon Sep 17 00:00:00 2001 From: richblack Date: Tue, 11 Aug 2026 12:12:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(collector):=20=E8=90=83=E5=8F=96=E7=94=A2?= =?UTF-8?q?=E7=89=A9=E5=8A=A0=20vault=20=E8=BE=A8=E8=AD=98=EF=BC=8B?= =?UTF-8?q?=E8=90=BD=E5=8D=A1=E4=B8=8D=E5=86=8D=E7=84=A1=E6=A2=9D=E4=BB=B6?= =?UTF-8?q?=E8=A6=86=E8=93=8B=EF=BC=88arcrun-rag#60=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事故:daemon 完全沒有 vault 辨識,把萃取卡片寫進使用者的 Logseq vault (system-dev/wiki/cards/ 對一般資料夾沒事,對 vault=憑空多出頁面, 2026-08-10 leo 實撞:25 個簡體字卡片污染 ~/Documents/KB)。 修法(照票上指示搬現成的,不重新設計): - collector/vault.go:DetectVaultType/IsVault,判準逐條抄自 system-dev-template/scripts/install.sh:209-221(先查 logseq/、再查 .obsidian/)。 已用同一批 fixture 資料夾跑過 install.sh 與這支 Go 版,五種情境(logseq/ obsidian/一般資料夾/兩者皆有/空資料夾)IS_VAULT 判斷逐一比對一致。 - extract.go:cardsRelDirFor() 依 IsVault 決定卡片相對路徑——非 vault 不變 (system-dev/wiki/cards/),vault 改落 .arcrun-rag/wiki/cards/(點開頭隱藏 目錄,Logseq/Obsidian 預設不掃描,跟 daemon 自己 scan.go 的隱藏目錄跳過規則 一致)。extract_workersai.go/extract_gemma.go/direct.go 的落卡與下架清除 都改用這個函式,三處對同一個 absRoot 保證同一個答案。 - safewrite.go:safeWriteCard() 取代兩處無條件 os.WriteFile——目標已存在且 內容不同就先備份成 .bak- 才覆寫;內容相同則不動(不產生 垃圾備份);備份失敗就整個中止,不無聲蓋掉使用者機器上已有的東西。 驗證(見 PR/commit 說明附的實測輸出): - 用 fake Logseq vault fixture 重現舊行為(卡片確實落在 system-dev/wiki/cards/), 再用同一份 fixture 驗新行為(卡片改落 .arcrun-rag/wiki/cards/,vault 根目錄 非隱藏 .md 數量不變、journals/ 原稿位元不動)。 - 故意放同名既有卡片,跑完既有內容被備份、新內容確實寫入,未無聲遺失。 - 非 vault 既有測試(extract_gemma_test.go 原有三支)全數不動照過,確認 一般資料夾行為零改變。 範圍外(留給下一輪):ExtractWithClaude(extract.go 的 claude 路)與其 templatefs/.claude/commands/rag-extract-file.md 技能檔仍硬寫 system-dev/wiki/cards/, 但這條路目前在 direct.go 的 RunDirectOnce 是不支援狀態(cfg.Extractor 只認 workers-ai/gemma),非本次事故的作用路徑,故未動。 不影響:leo 機器上的 daemon(未重啟、未重新打包);此修復要生效還要 ①總管審過併 main ②重新打包桌面版裝上他機器 ③總管用新二進位實測, 三件缺一不可(見 issue #60 leo 的重啟條件)。 --- direct.go | 6 +- extract.go | 19 +++++- extract_gemma.go | 9 ++- extract_gemma_test.go | 130 ++++++++++++++++++++++++++++++++++++++ extract_workersai.go | 9 ++- extract_workersai_test.go | 99 +++++++++++++++++++++++++++++ safewrite.go | 40 ++++++++++++ safewrite_test.go | 86 +++++++++++++++++++++++++ vault.go | 54 ++++++++++++++++ vault_test.go | 99 +++++++++++++++++++++++++++++ 10 files changed, 538 insertions(+), 13 deletions(-) create mode 100644 extract_workersai_test.go create mode 100644 safewrite.go create mode 100644 safewrite_test.go create mode 100644 vault.go create mode 100644 vault_test.go diff --git a/direct.go b/direct.go index 1b9ca7d..39b4910 100644 --- a/direct.go +++ b/direct.go @@ -1194,10 +1194,12 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta delete(m.Entries, ev.Path) saveManifest() // t15:extractor 模式雲端下架成功後,同步清掉本地萃出的卡 - //(system-dev/wiki/cards/<頁名>.md),保持本地 wiki 與雲端一致。 + //(system-dev/wiki/cards/<頁名>.md 或 vault 時的 .arcrun-rag/wiki/cards/<頁名>.md, + // arcrun-rag#60:清除路徑要跟落卡路徑用同一個 cardsRelDirFor,否則 vault 目標 + // 清不到卡、留孤兒檔案),保持本地 wiki 與雲端一致。 // 存在才刪;刪失敗只記 warning 不擋(下架本體已成功)。 if cfg.Extractor != "" { - cardAbs := filepath.Join(absRoot, "system-dev", "wiki", "cards", pageNameOf(ev.Path)+".md") + cardAbs := filepath.Join(absRoot, filepath.FromSlash(cardsRelDirFor(absRoot)), pageNameOf(ev.Path)+".md") if _, serr := os.Stat(cardAbs); serr == nil { if rerr := os.Remove(cardAbs); rerr != nil { results = append(results, DirectResult{ diff --git a/extract.go b/extract.go index c455ff1..e89592e 100644 --- a/extract.go +++ b/extract.go @@ -61,9 +61,26 @@ func FindClaudeBin(hint string) (string, error) { strings.Join(tried, "、")) } -// cardsRelDir 是 template 規約的卡片產物區(相對監看根)。 +// cardsRelDir 是 template 規約的卡片產物區(相對監看根)——一般資料夾(非 vault)用這個, +// 行為與改 Go 版之前完全一致。 const cardsRelDir = "system-dev/wiki/cards" +// vaultCardsRelDir 是監看根被判定成 Logseq/Obsidian vault 時,卡片改落的位置(arcrun-rag#60)。 +// 刻意用點開頭的隱藏目錄:Logseq/Obsidian 預設都不掃描點開頭的資料夾(跟 .git/.obsidian +// 同待遇),daemon 自己的 Scan() 也一樣(見 scan.go「隱藏目錄整棵跳過」)——三邊一致, +// 卡片仍然落在監看根底下(呼叫端「路徑相對監看根」的假設不用改),但不會被筆記軟體 +// 收編成新頁面,vault 的頁面數不會因為 daemon 跑過而增加。 +const vaultCardsRelDir = ".arcrun-rag/wiki/cards" + +// cardsRelDirFor 決定某次萃取的卡片該落在哪個相對路徑:非 vault 用 cardsRelDir(不變), +// vault 用 vaultCardsRelDir(arcrun-rag#60)。vault 判準見 vault.go,與 install.sh 對齊。 +func cardsRelDirFor(absRoot string) string { + if IsVault(absRoot) { + return vaultCardsRelDir + } + return cardsRelDir +} + // snapshotCards 記 cards/ 目前每檔 mtime+size(偵測萃取後的新卡/變卡)。 func snapshotCards(absRoot string) map[string]fileState { out := map[string]fileState{} diff --git a/extract_gemma.go b/extract_gemma.go index c07202d..657a65e 100644 --- a/extract_gemma.go +++ b/extract_gemma.go @@ -124,12 +124,11 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error) if !strings.HasPrefix(card, "# ") { return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", card) } - cardRel := filepath.ToSlash(filepath.Join(cardsRelDir, pageName+".md")) + // arcrun-rag#60:非 vault 落 system-dev/wiki/cards/、vault 改落隱藏目錄,避免污染筆記軟體的頁面清單; + // 落地前先查目標存不存在、不無條件覆蓋(safeWriteCard),兩條都是同一票的紅線。 + cardRel := filepath.ToSlash(filepath.Join(cardsRelDirFor(absRoot), pageName+".md")) dest := filepath.Join(absRoot, filepath.FromSlash(cardRel)) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return nil, err - } - if err := os.WriteFile(dest, []byte(card), 0o644); err != nil { + if err := safeWriteCard(dest, []byte(card)); err != nil { return nil, err } return []string{cardRel}, nil diff --git a/extract_gemma_test.go b/extract_gemma_test.go index d0ddbfb..5c82627 100644 --- a/extract_gemma_test.go +++ b/extract_gemma_test.go @@ -84,3 +84,133 @@ func TestExtractWithGemmaNoKey(t *testing.T) { t.Fatal("缺 key 應報錯") } } + +// arcrun-rag#60 驗收核心:對著一個「長得像 Logseq vault」的資料夾跑萃取, +// 卡片不能落在 pages/、journals/ 或任何非隱藏目錄——vault 的頁面數不能因為 +// daemon 跑過而增加。 +func TestExtractWithGemma_VaultDoesNotGainPages(t *testing.T) { + root := t.TempDir() + // 造一個像真的 Logseq vault:有 logseq/、pages/、journals/,journals 裡放一篇 + // leo 自己的日記(模擬「原稿」,萃取對象另外放在 vault 根目錄下)。 + mustMkdir(t, filepath.Join(root, "logseq")) + mustMkdir(t, filepath.Join(root, "pages")) + mustMkdir(t, filepath.Join(root, "journals")) + journalPath := filepath.Join(root, "journals", "2026_08_08.md") + journalContent := "leo 原話原圖,不該被動" + if err := os.WriteFile(journalPath, []byte(journalContent), 0o644); err != nil { + t.Fatal(err) + } + // 監看到的來源檔(相對 vault 根)——模擬使用者丟進 vault 的一份原稿。 + srcRel := "會議記錄.md" + if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil { + t.Fatal(err) + } + + defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []map[string]any{{ + "content": map[string]any{"parts": []map[string]any{ + {"text": "# 會議記錄\n## 一句話定義\n測試卡\n"}, + }}, + }}, + }) + })() + + pagesBefore := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root) + + cards, err := ExtractWithGemma("k123", "gemma-test", root, srcRel) + if err != nil { + t.Fatal(err) + } + if len(cards) != 1 || cards[0] != ".arcrun-rag/wiki/cards/會議記錄.md" { + t.Fatalf("vault 目標的卡片路徑不對:%v,want [.arcrun-rag/wiki/cards/會議記錄.md]", cards) + } + + pagesAfter := countMD(t, filepath.Join(root, "pages")) + countMD(t, filepath.Join(root, "journals")) + countTopLevelMD(t, root) + if pagesAfter != pagesBefore { + t.Fatalf("vault 頁面數增加了:before=%d after=%d(daemon 跑完不該讓 Logseq 多任何頁)", pagesBefore, pagesAfter) + } + + // 原稿(journals 裡 leo 的日記)必須原封不動。 + got, err := os.ReadFile(journalPath) + if err != nil { + t.Fatal(err) + } + if string(got) != journalContent { + t.Fatalf("journals 原稿被動過:%q", got) + } + + // 卡片確實落在隱藏目錄,且是「監看根底下」(呼叫端 absRoot-relative 假設仍成立)。 + cardAbs := filepath.Join(root, ".arcrun-rag", "wiki", "cards", "會議記錄.md") + if _, err := os.Stat(cardAbs); err != nil { + t.Fatalf("卡片沒有落在預期的隱藏目錄:%v", err) + } +} + +// 故意在 vault 的隱藏卡片目錄放一個同名檔案,跑完必須被備份、不能無聲蓋掉。 +func TestExtractWithGemma_VaultExistingCardNotClobbered(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "logseq")) + srcRel := "x.md" + if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil { + t.Fatal(err) + } + cardDir := filepath.Join(root, ".arcrun-rag", "wiki", "cards") + mustMkdir(t, cardDir) + preexisting := "# x\n這份是先前就存在的內容" + cardPath := filepath.Join(cardDir, "x.md") + if err := os.WriteFile(cardPath, []byte(preexisting), 0o644); err != nil { + t.Fatal(err) + } + + defer gemmaStub(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "candidates": []map[string]any{{ + "content": map[string]any{"parts": []map[string]any{ + {"text": "# x\n## 一句話定義\n新卡\n"}, + }}, + }}, + }) + })() + + if _, err := ExtractWithGemma("k123", "gemma-test", root, srcRel); err != nil { + t.Fatal(err) + } + + entries, err := os.ReadDir(cardDir) + if err != nil { + t.Fatal(err) + } + var foundBackup bool + for _, e := range entries { + if strings.HasPrefix(e.Name(), "x.md.bak-") { + foundBackup = true + data, _ := os.ReadFile(filepath.Join(cardDir, e.Name())) + if string(data) != preexisting { + t.Fatalf("備份內容不對:%q", data) + } + } + } + if !foundBackup { + t.Fatalf("既有卡片沒有被備份,可能被無聲覆蓋。目錄內容:%v", entries) + } +} + +func countMD(t *testing.T, dir string) int { + t.Helper() + n := 0 + entries, err := os.ReadDir(dir) + if err != nil { + return 0 + } + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".md") { + n++ + } + } + return n +} + +func countTopLevelMD(t *testing.T, dir string) int { + return countMD(t, dir) +} diff --git a/extract_workersai.go b/extract_workersai.go index 2d92b02..6349d10 100644 --- a/extract_workersai.go +++ b/extract_workersai.go @@ -121,12 +121,11 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string, if !strings.HasPrefix(card, "# ") { return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", card) } - cardRel := filepath.ToSlash(filepath.Join(cardsRelDir, pageName+".md")) + // arcrun-rag#60:非 vault 落 system-dev/wiki/cards/、vault 改落隱藏目錄,避免污染筆記軟體的頁面清單; + // 落地前先查目標存不存在、不無條件覆蓋(safeWriteCard),兩條都是同一票的紅線。 + cardRel := filepath.ToSlash(filepath.Join(cardsRelDirFor(absRoot), pageName+".md")) dest := filepath.Join(absRoot, filepath.FromSlash(cardRel)) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return nil, err - } - if err := os.WriteFile(dest, []byte(card), 0o644); err != nil { + if err := safeWriteCard(dest, []byte(card)); err != nil { return nil, err } return []string{cardRel}, nil diff --git a/extract_workersai_test.go b/extract_workersai_test.go new file mode 100644 index 0000000..7075a3f --- /dev/null +++ b/extract_workersai_test.go @@ -0,0 +1,99 @@ +// extract_workersai_test.go — arcrun-rag#60:workers-ai 是預設/主線萃取路(t181), +// 同一套 vault 保護要對這條路也成立,不能只顧 gemma。 +package collector + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func workersAIStub(t *testing.T, handler http.HandlerFunc) (url string, closeFn func()) { + t.Helper() + srv := httptest.NewServer(handler) + return srv.URL, srv.Close +} + +func TestExtractWithWorkersAI_VaultRedirectsAndDoesNotClobber(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, ".obsidian")) // Obsidian vault + srcRel := "note.md" + if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil { + t.Fatal(err) + } + // 隱藏卡片目錄裡先放一份既有內容,驗證不會被無聲蓋掉。 + cardDir := filepath.Join(root, ".arcrun-rag", "wiki", "cards") + mustMkdir(t, cardDir) + preexisting := "# note\n既有內容" + if err := os.WriteFile(filepath.Join(cardDir, "note.md"), []byte(preexisting), 0o644); err != nil { + t.Fatal(err) + } + + url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Arcrun-API-Key") != "key123" { + t.Errorf("api key 未帶到 header") + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "card": "# note\n## 一句話定義\n新卡\n", + }) + }) + defer closeFn() + + cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel) + if err != nil { + t.Fatal(err) + } + if len(cards) != 1 || cards[0] != ".arcrun-rag/wiki/cards/note.md" { + t.Fatalf("vault 目標的卡片路徑不對:%v", cards) + } + + // pages/.obsidian 之外沒有新增任何非隱藏 .md(Obsidian 不掃描 .arcrun-rag/)。 + topLevel := countMD(t, root) + if topLevel != 1 { // 只有原本的 srcRel + t.Fatalf("vault 根目錄多出非預期的 .md:count=%d", topLevel) + } + + // 既有卡片必須被備份,不能無聲覆蓋。 + entries, err := os.ReadDir(cardDir) + if err != nil { + t.Fatal(err) + } + var foundBackup bool + for _, e := range entries { + if strings.HasPrefix(e.Name(), "note.md.bak-") { + foundBackup = true + } + } + if !foundBackup { + t.Fatalf("既有卡片沒有被備份,目錄內容:%v", entries) + } +} + +// 非 vault:既有行為不變(回歸)——卡片仍落 system-dev/wiki/cards/。 +func TestExtractWithWorkersAI_NonVaultUnchanged(t *testing.T) { + root := t.TempDir() + srcRel := "note.md" + if err := os.WriteFile(filepath.Join(root, srcRel), []byte("# 原稿"), 0o644); err != nil { + t.Fatal(err) + } + url, closeFn := workersAIStub(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "card": "# note\n## 一句話定義\n新卡\n", + }) + }) + defer closeFn() + + cards, err := ExtractWithWorkersAI(url, "key123", root, srcRel) + if err != nil { + t.Fatal(err) + } + if len(cards) != 1 || cards[0] != "system-dev/wiki/cards/note.md" { + t.Fatalf("非 vault 行為變了:%v,want [system-dev/wiki/cards/note.md]", cards) + } +} diff --git a/safewrite.go b/safewrite.go new file mode 100644 index 0000000..5f64cf2 --- /dev/null +++ b/safewrite.go @@ -0,0 +1,40 @@ +// safewrite.go — 落卡寫檔前的保護(arcrun-rag#60 第二條:不得無條件覆蓋既有檔案)。 +// +// 修前:extract_workersai.go / extract_gemma.go 都是無條件 os.WriteFile——不看目標 +// 存不存在、不備份、不詢問。這次沒出事只是檔名剛好沒撞上,不代表安全。 +package collector + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "time" +) + +// safeWriteCard 把卡片內容寫到 dest,寫之前先檢查目標是否已存在: +// - 不存在 → 直接寫(首次落卡,行為不變)。 +// - 存在且內容相同 → 不動它(冪等:同一份卡片重複萃取不該產生垃圾備份)。 +// - 存在但內容不同 → 先把既有內容備份成 `.bak-`,備份成功才覆寫; +// 備份失敗就整個中止,寧可這次萃取失敗,也不要無聲蓋掉使用者機器上已經有的東西。 +func safeWriteCard(dest string, content []byte) error { + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return fmt.Errorf("建立卡片目錄失敗:%w", err) + } + existing, err := os.ReadFile(dest) + switch { + case err == nil: + if bytes.Equal(existing, content) { + return nil // 內容沒變,不必重寫也不必備份 + } + backup := fmt.Sprintf("%s.bak-%d", dest, time.Now().UnixNano()) + if werr := os.WriteFile(backup, existing, 0o644); werr != nil { + return fmt.Errorf("寫卡前備份既有檔失敗,中止避免覆蓋(%s):%w", dest, werr) + } + case os.IsNotExist(err): + // 目標不存在,正常首次落卡,不必備份。 + default: + return fmt.Errorf("檢查既有卡片失敗(%s):%w", dest, err) + } + return os.WriteFile(dest, content, 0o644) +} diff --git a/safewrite_test.go b/safewrite_test.go new file mode 100644 index 0000000..4ab4e00 --- /dev/null +++ b/safewrite_test.go @@ -0,0 +1,86 @@ +// safewrite_test.go — arcrun-rag#60 第二條:落卡前不得無條件覆蓋既有檔案。 +package collector + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// 目標不存在=正常首次落卡,行為不變。 +func TestSafeWriteCard_NewFile(t *testing.T) { + dest := filepath.Join(t.TempDir(), "sub", "foo.md") + if err := safeWriteCard(dest, []byte("# foo\n內容")); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(got) != "# foo\n內容" { + t.Fatalf("內容不對:%q", got) + } +} + +// 核心紅線:目標已存在且內容不同 → 先備份既有內容,不能無聲蓋掉。 +func TestSafeWriteCard_ExistingFile_BacksUpBeforeOverwrite(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "foo.md") + original := "# foo\n這是使用者原本就有的東西,不是 daemon 寫的" + if err := os.WriteFile(dest, []byte(original), 0o644); err != nil { + t.Fatal(err) + } + + if err := safeWriteCard(dest, []byte("# foo\n機器新產出的卡")); err != nil { + t.Fatal(err) + } + + // 新內容確實寫進去了(這是萃取的目的,不是完全拒寫)。 + got, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(got) != "# foo\n機器新產出的卡" { + t.Fatalf("新內容未寫入:%q", got) + } + + // 但舊內容必須找得到備份,不能憑空消失。 + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var backup string + for _, e := range entries { + if strings.HasPrefix(e.Name(), "foo.md.bak-") { + backup = filepath.Join(dir, e.Name()) + } + } + if backup == "" { + t.Fatalf("沒有找到備份檔,既有內容可能已經被無聲覆蓋。目錄內容:%v", entries) + } + backupData, err := os.ReadFile(backup) + if err != nil { + t.Fatal(err) + } + if string(backupData) != original { + t.Fatalf("備份內容不對:%q,want %q", backupData, original) + } +} + +// 內容完全相同(重複萃取同一份卡)=不必動它,也不必產生垃圾備份。 +func TestSafeWriteCard_SameContent_NoOp(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "foo.md") + content := "# foo\n一樣的內容" + if err := os.WriteFile(dest, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if err := safeWriteCard(dest, []byte(content)); err != nil { + t.Fatal(err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("內容沒變不該產生額外檔案,目錄內容:%v", entries) + } +} diff --git a/vault.go b/vault.go new file mode 100644 index 0000000..8545d37 --- /dev/null +++ b/vault.go @@ -0,0 +1,54 @@ +// vault.go — 偵測監看根是不是筆記軟體的 vault(Logseq/Obsidian)。 +// +// 🔴 為什麼有這支檔(arcrun-rag#60,2026-08-10 leo 實撞): +// +// daemon 把萃取產物(知識卡)寫進了使用者的 Logseq vault,讓 vault 平白多出一堆 +// 機器寫的頁面——Logseq/Obsidian 這種筆記軟體,vault 底下每個 .md 都會變成一頁, +// 一般資料夾則不會。daemon 原本沒有這層辨識(改寫成 Go 版時沒把舊邏輯帶過來)。 +// +// 判準**逐條抄自** system-dev-template/scripts/install.sh:209-221(「偵測 vault 類型」段): +// +// if [ -d "logseq" ]; then VAULT_TYPE="logseq"; IS_VAULT="yes" +// elif [ -d ".obsidian" ]; then VAULT_TYPE="obsidian"; IS_VAULT="yes" +// else VAULT_TYPE="docs" (不是 vault) +// +// 🔴 同一個資料夾,安裝器(bash)與這支 daemon(Go)必須得出同一個答案——兩邊分歧本身 +// 就是 bug。之後任一邊改判準,要同步改另一邊(或把判準抽成單一真相源腳本)。 +package collector + +import ( + "os" + "path/filepath" +) + +// VaultType 是偵測結果的字串常數,值刻意跟 install.sh 的 VAULT_TYPE 用同一組字。 +type VaultType string + +const ( + VaultNone VaultType = "" // 一般資料夾(install.sh 裡叫 "docs") + VaultLogseq VaultType = "logseq" + VaultObsidian VaultType = "obsidian" +) + +// DetectVaultType 判斷 absRoot 是不是筆記軟體的 vault,回傳偵測到的類型。 +// 只看 absRoot 這一層(不遞迴往上找),跟 install.sh 在目標資料夾根目錄跑 `[ -d "logseq" ]` +// 語意一致。順序也刻意跟 install.sh 一致:先查 logseq/,再查 .obsidian/。 +func DetectVaultType(absRoot string) VaultType { + if isDir(filepath.Join(absRoot, "logseq")) { + return VaultLogseq + } + if isDir(filepath.Join(absRoot, ".obsidian")) { + return VaultObsidian + } + return VaultNone +} + +// IsVault 是 DetectVaultType 的布林簡寫,供只關心「是不是 vault」的呼叫端用。 +func IsVault(absRoot string) bool { + return DetectVaultType(absRoot) != VaultNone +} + +func isDir(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} diff --git a/vault_test.go b/vault_test.go new file mode 100644 index 0000000..3550792 --- /dev/null +++ b/vault_test.go @@ -0,0 +1,99 @@ +// vault_test.go — arcrun-rag#60:驗 vault 偵測跟 system-dev-template/scripts/install.sh:209-221 +// 的 bash 判準逐條對齊(同一個資料夾兩邊必須得出同一個答案)。 +package collector + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectVaultType_Logseq(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "logseq")) + mustMkdir(t, filepath.Join(root, "pages")) + mustMkdir(t, filepath.Join(root, "journals")) + if got := DetectVaultType(root); got != VaultLogseq { + t.Fatalf("DetectVaultType=%q,want %q", got, VaultLogseq) + } + if !IsVault(root) { + t.Fatal("IsVault 應為 true") + } +} + +func TestDetectVaultType_Obsidian(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, ".obsidian")) + if got := DetectVaultType(root); got != VaultObsidian { + t.Fatalf("DetectVaultType=%q,want %q", got, VaultObsidian) + } + if !IsVault(root) { + t.Fatal("IsVault 應為 true") + } +} + +func TestDetectVaultType_PlainFolder(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "docs")) + if got := DetectVaultType(root); got != VaultNone { + t.Fatalf("DetectVaultType=%q,want VaultNone(一般資料夾不該被認成 vault)", got) + } + if IsVault(root) { + t.Fatal("IsVault 應為 false") + } +} + +// 兩者都有時,install.sh 用 if/elif(logseq 優先)——Go 這邊也要一樣的優先序。 +func TestDetectVaultType_BothPresent_LogseqWins(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "logseq")) + mustMkdir(t, filepath.Join(root, ".obsidian")) + if got := DetectVaultType(root); got != VaultLogseq { + t.Fatalf("DetectVaultType=%q,want %q(install.sh 用 if/elif,logseq 優先)", got, VaultLogseq) + } +} + +// 空資料夾也不該被誤判成 vault。 +func TestDetectVaultType_EmptyFolder(t *testing.T) { + root := t.TempDir() + if got := DetectVaultType(root); got != VaultNone { + t.Fatalf("DetectVaultType=%q,want VaultNone", got) + } +} + +// install.sh 判準是「-d logseq」——檔案(非目錄)叫這名字不算數。 +func TestDetectVaultType_FileNotDir_DoesNotCount(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "logseq"), []byte("not a dir"), 0o644); err != nil { + t.Fatal(err) + } + if got := DetectVaultType(root); got != VaultNone { + t.Fatalf("DetectVaultType=%q,want VaultNone(logseq 是檔案不是目錄,不該算 vault)", got) + } +} + +// cardsRelDirFor:非 vault 行為不變(回歸),vault 改落隱藏目錄。 +func TestCardsRelDirFor(t *testing.T) { + plain := t.TempDir() + if got := cardsRelDirFor(plain); got != cardsRelDir { + t.Fatalf("非 vault:cardsRelDirFor=%q,want %q(既有行為不能變)", got, cardsRelDir) + } + + vault := t.TempDir() + mustMkdir(t, filepath.Join(vault, "logseq")) + if got := cardsRelDirFor(vault); got != vaultCardsRelDir { + t.Fatalf("vault:cardsRelDirFor=%q,want %q", got, vaultCardsRelDir) + } + // vault 目的地必須是隱藏(點開頭)路徑——這是「Logseq/Obsidian/daemon 自己的 scan.go + // 都不掃描」的唯一保證來源,不能改掉。 + if filepath.Base(filepath.Dir(vaultCardsRelDir))[0] != '.' && vaultCardsRelDir[0] != '.' { + t.Fatalf("vaultCardsRelDir=%q 不是隱藏路徑,vault 頁面數保護會失效", vaultCardsRelDir) + } +} + +func mustMkdir(t *testing.T, p string) { + t.Helper() + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } +}