fix(collector): 萃取產物加 vault 辨識+落卡不再無條件覆蓋(arcrun-rag#60)

事故: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——目標已存在且
  內容不同就先備份成 <dest>.bak-<unixnano> 才覆寫;內容相同則不動(不產生
  垃圾備份);備份失敗就整個中止,不無聲蓋掉使用者機器上已有的東西。

驗證(見 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 的重啟條件)。
This commit is contained in:
2026-08-11 12:12:51 +08:00
parent 3862a1a877
commit f10d10747c
10 changed files with 538 additions and 13 deletions
+4 -2
View File
@@ -1194,10 +1194,12 @@ func runDirectOnceRoot(cfg *DirectConfig, root string, dryRun bool, qs *quotaSta
delete(m.Entries, ev.Path) delete(m.Entries, ev.Path)
saveManifest() saveManifest()
// t15extractor 模式雲端下架成功後,同步清掉本地萃出的卡 // t15extractor 模式雲端下架成功後,同步清掉本地萃出的卡
//system-dev/wiki/cards/<頁名>.md),保持本地 wiki 與雲端一致。 //system-dev/wiki/cards/<頁名>.md 或 vault 時的 .arcrun-rag/wiki/cards/<頁名>.md
// arcrun-rag#60:清除路徑要跟落卡路徑用同一個 cardsRelDirFor,否則 vault 目標
// 清不到卡、留孤兒檔案),保持本地 wiki 與雲端一致。
// 存在才刪;刪失敗只記 warning 不擋(下架本體已成功)。 // 存在才刪;刪失敗只記 warning 不擋(下架本體已成功)。
if cfg.Extractor != "" { 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 _, serr := os.Stat(cardAbs); serr == nil {
if rerr := os.Remove(cardAbs); rerr != nil { if rerr := os.Remove(cardAbs); rerr != nil {
results = append(results, DirectResult{ results = append(results, DirectResult{
+18 -1
View File
@@ -61,9 +61,26 @@ func FindClaudeBin(hint string) (string, error) {
strings.Join(tried, "、")) strings.Join(tried, "、"))
} }
// cardsRelDir 是 template 規約的卡片產物區(相對監看根) // cardsRelDir 是 template 規約的卡片產物區(相對監看根)——一般資料夾(非 vault)用這個,
// 行為與改 Go 版之前完全一致。
const cardsRelDir = "system-dev/wiki/cards" 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 用 vaultCardsRelDirarcrun-rag#60)。vault 判準見 vault.go,與 install.sh 對齊。
func cardsRelDirFor(absRoot string) string {
if IsVault(absRoot) {
return vaultCardsRelDir
}
return cardsRelDir
}
// snapshotCards 記 cards/ 目前每檔 mtime+size(偵測萃取後的新卡/變卡)。 // snapshotCards 記 cards/ 目前每檔 mtime+size(偵測萃取後的新卡/變卡)。
func snapshotCards(absRoot string) map[string]fileState { func snapshotCards(absRoot string) map[string]fileState {
out := map[string]fileState{} out := map[string]fileState{}
+4 -5
View File
@@ -124,12 +124,11 @@ func ExtractWithGemma(apiKey, model, absRoot, relPath string) ([]string, error)
if !strings.HasPrefix(card, "# ") { if !strings.HasPrefix(card, "# ") {
return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", 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)) dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { if err := safeWriteCard(dest, []byte(card)); err != nil {
return nil, err
}
if err := os.WriteFile(dest, []byte(card), 0o644); err != nil {
return nil, err return nil, err
} }
return []string{cardRel}, nil return []string{cardRel}, nil
+130
View File
@@ -84,3 +84,133 @@ func TestExtractWithGemmaNoKey(t *testing.T) {
t.Fatal("缺 key 應報錯") 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 目標的卡片路徑不對:%vwant [.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=%ddaemon 跑完不該讓 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)
}
+4 -5
View File
@@ -121,12 +121,11 @@ func ExtractWithWorkersAI(cypherURL, apiKey, absRoot, relPath string) ([]string,
if !strings.HasPrefix(card, "# ") { if !strings.HasPrefix(card, "# ") {
return nil, fmt.Errorf("萃出內容不像卡片(未以 # 開頭):%.120s", 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)) dest := filepath.Join(absRoot, filepath.FromSlash(cardRel))
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { if err := safeWriteCard(dest, []byte(card)); err != nil {
return nil, err
}
if err := os.WriteFile(dest, []byte(card), 0o644); err != nil {
return nil, err return nil, err
} }
return []string{cardRel}, nil return []string{cardRel}, nil
+99
View File
@@ -0,0 +1,99 @@
// extract_workersai_test.go — arcrun-rag#60workers-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 之外沒有新增任何非隱藏 .mdObsidian 不掃描 .arcrun-rag/)。
topLevel := countMD(t, root)
if topLevel != 1 { // 只有原本的 srcRel
t.Fatalf("vault 根目錄多出非預期的 .mdcount=%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 行為變了:%vwant [system-dev/wiki/cards/note.md]", cards)
}
}
+40
View File
@@ -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,寫之前先檢查目標是否已存在:
// - 不存在 → 直接寫(首次落卡,行為不變)。
// - 存在且內容相同 → 不動它(冪等:同一份卡片重複萃取不該產生垃圾備份)。
// - 存在但內容不同 → 先把既有內容備份成 `<dest>.bak-<unixnano>`,備份成功才覆寫;
// 備份失敗就整個中止,寧可這次萃取失敗,也不要無聲蓋掉使用者機器上已經有的東西。
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)
}
+86
View File
@@ -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("備份內容不對:%qwant %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)
}
}
+54
View File
@@ -0,0 +1,54 @@
// vault.go — 偵測監看根是不是筆記軟體的 vaultLogseq/Obsidian)。
//
// 🔴 為什麼有這支檔(arcrun-rag#602026-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()
}
+99
View File
@@ -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=%qwant %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=%qwant %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=%qwant VaultNone(一般資料夾不該被認成 vault", got)
}
if IsVault(root) {
t.Fatal("IsVault 應為 false")
}
}
// 兩者都有時,install.sh 用 if/eliflogseq 優先)——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=%qwant %qinstall.sh 用 if/eliflogseq 優先)", got, VaultLogseq)
}
}
// 空資料夾也不該被誤判成 vault。
func TestDetectVaultType_EmptyFolder(t *testing.T) {
root := t.TempDir()
if got := DetectVaultType(root); got != VaultNone {
t.Fatalf("DetectVaultType=%qwant 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=%qwant VaultNonelogseq 是檔案不是目錄,不該算 vault)", got)
}
}
// cardsRelDirFor:非 vault 行為不變(回歸),vault 改落隱藏目錄。
func TestCardsRelDirFor(t *testing.T) {
plain := t.TempDir()
if got := cardsRelDirFor(plain); got != cardsRelDir {
t.Fatalf("非 vaultcardsRelDirFor=%qwant %q(既有行為不能變)", got, cardsRelDir)
}
vault := t.TempDir()
mustMkdir(t, filepath.Join(vault, "logseq"))
if got := cardsRelDirFor(vault); got != vaultCardsRelDir {
t.Fatalf("vaultcardsRelDirFor=%qwant %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)
}
}