2f33324d3f
真正的根因不是「vault 判斷漏了子庫」,是**判斷的方向搞反了**: 前兩輪問的都是「監看根**這一層**是不是 vault」,但 daemon 的產物一律落在 監看根底下——這兩件事只有在「監看根 == 庫根」時才等價,而那正好是前兩輪 唯一測過、也唯一不會出事的擺法。 使用者只要把庫底下的某一層加進監看(`KB/docs`、`KB/pages`、Obsidian 庫裡的 某個專案夾——很自然的用法),DetectVaultType 就回 VaultNone,整套保護退回 一般資料夾模式,卡片落在 `<監看根>/system-dev/wiki/cards/`:那個路徑就在 使用者的 graph 裡面,而且看得見,Logseq/Obsidian 每一張卡都收編成一頁。 前綴(第二輪)只擋得住撞名,擋不住「多出一堆機器頁」。 改法:把「這一層是不是庫」與「我寫的東西會不會落進誰的庫」拆成兩個判準。 - vault.go:新增 DetectVaultContext(往上找到最近的庫根)與 VaultDirUnder (往下擋:寫入目標會不會踩進子庫)。DetectVaultType 一字未改,繼續與 install.sh 對齊——往上找用較嚴的判準(logseq/ 要有 config.edn 或 journals//pages/ 佐證),因為那是替使用者猜、而且一次猜好幾層。 停在家目錄與檔案系統根,避免 `~/logseq` 這種常見資料夾把整個家目錄判成庫。 - extract.go:cardsRelDirFor 改用 DetectVaultContext。 - safewrite.go:落卡前過 ensureWritable 機械閘——目標踩進子庫就中止, 不靜靜寫進去。今天不會觸發,它防的是以後新增的寫檔點。 - tidy.go:收拾判準從「有沒有帶標記」擴充成「位置對不對 + 有沒有帶標記」, 舊版留在看得見位置的卡會被搬進隱藏目錄;MigrateCardNames 每輪自動做, 使用者不必下任何指令。報告多一個 VaultRoot,說清楚是誰的庫。 leo 派工單上的線索(庫在監看根**底下**)實測不成立:產物一律錨在監看根, 不會落進子庫。但那個「本來就沒破」原本沒有任何機制保證,所以照樣把兩種 格式的子庫情境永久寫進測試,加上 ensureWritable 當第二道保險。 驗證缺口(票上第 6 條):第二輪的足跡測試方向是對的,漏的是**觀測窗**—— snapshotTree 只拍監看根,而災情發生在監看根外面、庫裡面;且 fixture 只有 `root := vault` 一種擺法,測試與被測程式犯了同一個假設,所以永遠是綠的。 vault_subdir_test.go 把快照邊界改成筆記庫,並把「監看根與庫根的關係」升格 成測試維度(庫在上/庫在下/庫就是它/沒有庫 × Logseq/Obsidian)。 全程只用 t.TempDir() 與 mktemp -d;沒碰任何真實筆記庫、沒重啟任何 daemon。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
196 lines
10 KiB
Go
196 lines
10 KiB
Go
// vault.go — 判斷「daemon 要寫的東西,會不會落進使用者的筆記庫(Logseq/Obsidian)」。
|
||
//
|
||
// 本檔有**兩層**判準,回答兩個不同的問題,混用就是 bug:
|
||
//
|
||
// · DetectVaultType(dir) =「**這一層**是不是筆記庫」。逐字對齊 install.sh,
|
||
// 給「使用者親手指定的那個資料夾」用。
|
||
// · DetectVaultContext(root)=「這個監看根**在不在**某個筆記庫的範圍內」(會往上找)。
|
||
// VaultDirUnder(root,dst) =「這個寫入目標**會不會踩進**某個子筆記庫」(往下擋)。
|
||
// 決定「寫哪裡/能不能寫」一律用這一組。
|
||
//
|
||
// 前兩輪只有第一層,於是「監看根是 vault 底下的子資料夾」整個繞過保護——
|
||
// 全文見下方「第三輪」那段。
|
||
//
|
||
// 🔴 為什麼有這支檔(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"
|
||
"strings"
|
||
)
|
||
|
||
// 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」的呼叫端用。
|
||
//
|
||
// ⚠️ 要決定「daemon 可不可以在這裡寫東西」請**不要**用這支——用 DetectVaultContext。
|
||
// 理由見下方 DetectVaultContext 的開頭:監看根不是 vault,不代表它不在別人的 vault 裡面。
|
||
func IsVault(absRoot string) bool {
|
||
return DetectVaultType(absRoot) != VaultNone
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 第三輪(arcrun-rag#60,2026-08-12):vault 不是「一層」,是「一個範圍」
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
//
|
||
// 🔴 前兩輪的共同盲點:所有判斷都問「**監看根這一層**是不是 vault」,
|
||
// 而 daemon 的產物一律落在**監看根底下**。這兩件事只有在「監看根 == vault 根」時
|
||
// 才等價——而那正好是前兩輪唯一測過的擺法。
|
||
//
|
||
// 只要使用者把 vault 底下的**某一層子資料夾**加進監看(`KB/docs`、`KB/pages`、
|
||
// Obsidian 庫裡的某個專案夾——這是很自然的用法:「我只想讓它讀這一區」),
|
||
// DetectVaultType 就回 VaultNone ⇒ 整套保護退回一般資料夾模式 ⇒ 卡片落在
|
||
// `<監看根>/system-dev/wiki/cards/`,而那個路徑**就在使用者的 vault 裡面**,
|
||
// 且不是隱藏目錄 ⇒ Logseq/Obsidian 會把每一張卡收編成一頁。
|
||
// 前綴(machinemark.go)只擋得住「撞名」,擋不住「多出一堆機器頁」。
|
||
//
|
||
// ⇒ 正確的問題不是「這個資料夾是不是 vault」,是「**我要寫的東西會不會落進誰的 vault**」。
|
||
// 那是一個範圍問題,要往上找(我在不在某個 vault 裡)也要往下擋(我會不會踩進某個子庫)。
|
||
//
|
||
// 為什麼往上找要用比 DetectVaultType 更嚴的判準(ancestorVaultTypeAt):
|
||
// DetectVaultType 是**使用者親手指定的那一個資料夾**的判準,必須逐字對齊 install.sh;
|
||
// 往上找則是**替使用者猜**,而且一次要猜好幾層——同樣的誤判率乘上層數,代價完全不同。
|
||
// 一個叫 `logseq` 的普通資料夾(放筆記軟體匯出檔、放腳本的人都有)擺在家目錄或
|
||
// 專案目錄裡,就會讓它底下所有監看根被誤判成「在 vault 裡」。所以往上找時
|
||
// **要求佐證**:`logseq/config.edn`(Logseq 開過的 graph 一定有)或 `journals/`/`pages/`。
|
||
// `.obsidian/` 是點開頭的專用目錄,不存在這種誤判,不必佐證。
|
||
|
||
// VaultContext 描述「監看根與筆記庫的關係」——daemon 要決定寫哪裡時看的是這個,不是 IsVault。
|
||
type VaultContext struct {
|
||
Type VaultType // VaultNone=這個監看根不在任何筆記庫的範圍內
|
||
Root string // 筆記庫的根(絕對路徑);Type==VaultNone 時為空字串
|
||
Self bool // true=筆記庫的根就是監看根本身(前兩輪唯一處理到的那條路)
|
||
}
|
||
|
||
// InVault 回答「這個監看根落在某個筆記庫的範圍內嗎」(含監看根自己就是庫的情況)。
|
||
func (c VaultContext) InVault() bool { return c.Type != VaultNone }
|
||
|
||
// DetectVaultContext 從 absRoot 自己開始,一路往上找到最近的筆記庫根。
|
||
//
|
||
// 順序刻意是「先自己、再往上」:自己這一層用 DetectVaultType(與 install.sh 同一套字),
|
||
// 往上才用 ancestorVaultTypeAt(要佐證,理由見上)。
|
||
//
|
||
// 停止條件兩個,都是為了不要往上猜過頭:
|
||
// - 走到檔案系統根就停(parent == dir)。
|
||
// - 走到使用者家目錄就停,且**不檢查家目錄本身**——`~/logseq` 這種名字的資料夾
|
||
// 太常見(Logseq 的 graph 常直接放在那),拿它把整個家目錄判成 vault,
|
||
// 會讓家目錄底下每一個監看根都改變行為。代價不對稱:漏判一個「家目錄本身就是
|
||
// 筆記庫」的病態擺法,遠比誤判所有人便宜。
|
||
func DetectVaultContext(absRoot string) VaultContext {
|
||
absRoot = filepath.Clean(absRoot)
|
||
if vt := DetectVaultType(absRoot); vt != VaultNone {
|
||
return VaultContext{Type: vt, Root: absRoot, Self: true}
|
||
}
|
||
home := ""
|
||
if h, err := os.UserHomeDir(); err == nil && h != "" {
|
||
home = filepath.Clean(h)
|
||
}
|
||
for dir := absRoot; ; {
|
||
parent := filepath.Dir(dir)
|
||
if parent == dir {
|
||
return VaultContext{} // 檔案系統根
|
||
}
|
||
dir = parent
|
||
if home != "" && dir == home {
|
||
return VaultContext{} // 家目錄(含)以上不猜
|
||
}
|
||
if vt := ancestorVaultTypeAt(dir); vt != VaultNone {
|
||
return VaultContext{Type: vt, Root: dir}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ancestorVaultTypeAt 是「往上找/往下擋」共用的**嚴格**判準:
|
||
// 不是使用者親手指定的資料夾,就要有佐證才算數(理由見本段開頭的長註解)。
|
||
// 優先序與 install.sh 一致:logseq 先於 obsidian。
|
||
func ancestorVaultTypeAt(dir string) VaultType {
|
||
if isDir(filepath.Join(dir, "logseq")) && logseqCorroborated(dir) {
|
||
return VaultLogseq
|
||
}
|
||
if isDir(filepath.Join(dir, ".obsidian")) {
|
||
return VaultObsidian
|
||
}
|
||
return VaultNone
|
||
}
|
||
|
||
// logseqCorroborated:除了 `logseq/` 之外,還看得到 Logseq graph 的其他證據嗎。
|
||
// config.edn=Logseq 開過這個 graph 就會有;journals/ 與 pages/ 是 graph 的骨架目錄。
|
||
func logseqCorroborated(dir string) bool {
|
||
if isFile(filepath.Join(dir, "logseq", "config.edn")) {
|
||
return true
|
||
}
|
||
return isDir(filepath.Join(dir, "journals")) || isDir(filepath.Join(dir, "pages"))
|
||
}
|
||
|
||
// VaultDirUnder 往**下**擋:從 absRoot 走到 absTarget 的路上,有沒有踩進某個子筆記庫。
|
||
// 命中就回傳那個子庫的絕對路徑與類型。
|
||
//
|
||
// 🔴 這支刻意**只走 absTarget 這一條路徑上的目錄**(幾層而已),不 walk 整棵樹——
|
||
// 使用者的筆記庫可能有幾萬個檔,每次落卡掃一遍是不能接受的成本,而且也沒必要:
|
||
// 我們只需要知道「**我這次要寫的這個位置**安不安全」,不需要知道樹裡還有哪些庫。
|
||
//
|
||
// 目前所有寫檔點的目標都錨在監看根(system-dev/wiki/cards 或 .arcrun-rag/wiki/cards),
|
||
// 所以這支正常情況下永遠不命中。它存在的意義是**機械閘**:以後誰新增一個
|
||
// 「把產物寫在原稿旁邊」之類的寫檔點,會在這裡當場被擋下來,而不是在某個
|
||
// 陌生人的筆記庫裡被發現。
|
||
func VaultDirUnder(absRoot, absTarget string) (string, VaultType) {
|
||
rel, err := filepath.Rel(filepath.Clean(absRoot), filepath.Clean(absTarget))
|
||
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
||
return "", VaultNone // 目標不在監看根底下,本支不負責
|
||
}
|
||
dir := filepath.Clean(absRoot)
|
||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||
// 最後一段是檔名,不必檢查;中間每一層目錄都要。
|
||
for _, seg := range parts[:len(parts)-1] {
|
||
dir = filepath.Join(dir, seg)
|
||
if vt := ancestorVaultTypeAt(dir); vt != VaultNone {
|
||
return dir, vt
|
||
}
|
||
}
|
||
return "", VaultNone
|
||
}
|
||
|
||
func isDir(p string) bool {
|
||
info, err := os.Stat(p)
|
||
return err == nil && info.IsDir()
|
||
}
|
||
|
||
func isFile(p string) bool {
|
||
info, err := os.Stat(p)
|
||
return err == nil && !info.IsDir()
|
||
}
|