Files
Leo 2f33324d3f fix(collector): #60 監看的是筆記庫底下的子資料夾時,保護整個失效
真正的根因不是「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>
2026-08-12 18:23:49 +08:00

196 lines
10 KiB
Go
Raw Permalink 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.
// vault.go — 判斷「daemon 要寫的東西,會不會落進使用者的筆記庫(Logseq/Obsidian)」。
//
// 本檔有**兩層**判準,回答兩個不同的問題,混用就是 bug:
//
// · DetectVaultType(dir) =「**這一層**是不是筆記庫」。逐字對齊 install.sh,
// 給「使用者親手指定的那個資料夾」用。
// · DetectVaultContext(root)=「這個監看根**在不在**某個筆記庫的範圍內」(會往上找)。
// VaultDirUnder(root,dst) =「這個寫入目標**會不會踩進**某個子筆記庫」(往下擋)。
// 決定「寫哪裡/能不能寫」一律用這一組。
//
// 前兩輪只有第一層,於是「監看根是 vault 底下的子資料夾」整個繞過保護——
// 全文見下方「第三輪」那段。
//
// 🔴 為什麼有這支檔(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"
"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#602026-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.ednLogseq 開過這個 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()
}