「
ExtractWikilinks只告訴你一篇筆記寫了哪些連結字串,它不知道『這個字串是誰』——要回答這個問題,你需要先看過全庫,而不是只看一篇筆記。」
Day 06 的 Scan 能掃出整個 vault 的 []*Note,Day 09 的 ExtractWikilinks 能從單篇筆記正文抓出 [[Target]] 字串清單,但這兩者目前互不相關:Scan 不知道筆記之間有沒有連結,ExtractWikilinks 也不知道自己抓到的字串到底對應到哪篇真實存在的筆記。今天在 internal/vault 新增 BuildIndex(notes []*Note) *Index,把這兩個既有能力的輸出整併成一份可查詢的全庫記憶體索引,這是 Day 11 孤立筆記/斷鏈健康檢查會直接依賴的資料。
Obsidian 的 [[Target]] 語法有一個容易被忽略的細節:Target 不一定要精準匹配另一篇筆記的標題,它也可以匹配該筆記在 aliases 欄位宣告的任何一個別名。舉個實際情境:一篇筆記標題是「PARA 筆記法」,但因為打字習慣,正文裡有時會寫成 [[PARA]]——如果這篇筆記的 aliases 有宣告 ["PARA"],這條連結就該被視為指向同一篇筆記,而不是斷鏈。
這代表索引的「標題/別名 → 筆記」查找表,必須同時收錄 Frontmatter.Title 與 Frontmatter.Aliases 兩種來源的字串。而只要有兩種來源同時寫進同一份查找表,就一定要面對一個現實問題:如果兩篇不同筆記剛好用了相同的標題或別名字串(複製貼上草稿忘記改標題、別名打錯字撞名),該怎麼辦?
Index 的資料結構:四份表,一個私有查找表type Index struct {
byTitleOrAlias map[string]*Note
Outbound map[*Note][]string
Inbound map[*Note][]*Note
Tags map[string][]*Note
Unresolved []UnresolvedLink
Conflicts []AliasConflict
}
type UnresolvedLink struct {
Source *Note
Target string
}
type AliasConflict struct {
Key string
WinnerNote *Note
LoserNote *Note
}
byTitleOrAlias 刻意維持未輸出(小寫開頭),對外只透過 Resolve(target string) (*Note, bool) 方法查詢——因為這份查找表混合了 Title 與 Aliases 兩種來源,直接暴露原始 map 會讓呼叫端得自己記得「這個 key 到底是標題還是別名」這種實作細節,而 Resolve 這個名字已經把「查連結目標」的語意講清楚了。
Outbound/Inbound/Tags 則相反,直接輸出為 map 欄位。Day 11 需要遍歷整份索引找孤立筆記(len(idx.Inbound[note]) == 0),直接暴露 map 比額外包一層方法更直接,也更貼近 Go 慣例——net/http.Header 也是直接輸出 map 型別。
這四份資料原本也考慮過放進 Note 本身的新欄位(例如 Note.InboundLinks),但 Note 是 Day 06 定義的「單篇筆記」結構,欄位該描述的是這篇筆記檔案本身的內容;inbound 連結是「全庫視角」才能算出來的衍生關係,硬塞進 Note 會讓 ParseRawNote 這種單篇解析函式產生語意不完整的物件。獨立成 Index 結構,能維持 Note 的單一職責。
BuildIndex 走訪 notes 切片(Scan 回傳的順序,也就是 filepath.WalkDir 的目錄走訪順序)時,對每篇筆記依序把 Title 與每個 Alias 字串寫進查找表:
func (idx *Index) indexTitleAndAliases(note *Note) {
keys := append([]string{note.Frontmatter.Title}, note.Frontmatter.Aliases...)
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
existing, exists := idx.byTitleOrAlias[key]
if !exists {
idx.byTitleOrAlias[key] = note
continue
}
idx.Conflicts = append(idx.Conflicts, AliasConflict{
Key: key,
WinnerNote: existing,
LoserNote: note,
})
}
}
若某個字串已經存在於查找表,不覆寫既有值,而是 append 一筆 AliasConflict 記錄下 Winner(先出現、被收錄的筆記)與 Loser(後出現、未被收錄的筆記)。這裡刻意排除了兩種替代方案:
Scan 的走訪順序是檔案系統目錄順序,不是使用者刻意排序,讓「檔名字母序較大的筆記」贏過其他筆記是任意且不可預期的。BuildIndex 回傳 error 中斷:放棄,這呼應 Day 06「掃描時單篇錯誤不中斷整體」的既有原則——重複標題/別名是資料品質問題,不是程式錯誤,讓整個索引建置失敗會連帶讓其他正常筆記的索引也建不出來。Conflicts 讓呼叫端自己決定要不要警告或阻擋。值得注意的是,一篇筆記自己的 Title 與 Aliases 之間如果重複(例如某人手滑把標題本身也寫進了別名清單),不會被視為衝突——indexTitleAndAliases 裡用了一個區域 seen map 過濾同一篇筆記內部的重複 key,只有跨筆記的重複才會進 Conflicts。
for _, note := range notes {
targets := ExtractWikilinks(note.BodyAST, note.BodySource)
idx.Outbound[note] = targets
for _, target := range targets {
resolved, ok := idx.Resolve(target)
if !ok {
idx.Unresolved = append(idx.Unresolved, UnresolvedLink{Source: note, Target: target})
continue
}
idx.Inbound[resolved] = append(idx.Inbound[resolved], note)
}
}
Outbound[note] 記錄的是「這篇筆記引用的原始 Target 字串清單」,不管解析成不成功都保留——這樣未來如果想知道「這篇筆記寫了幾個連結」(不管斷不斷),直接用 len(Outbound[note]) 就夠了,不需要重新呼叫一次 ExtractWikilinks。Unresolved 則是額外的診斷清單,只收錄解析失敗的部分,讓 Day 11 不用自己重新比對 Outbound 跟查找表,就能拿到斷鏈候選名單。
Inbound 有一個容易漏掉的細節:即使某篇筆記完全沒有被任何其他筆記引用,它在 Inbound 裡也要有一個對應到空 slice 的 entry,而不是完全沒有這個 key。做法很簡單,在走訪第一階段就先把每篇筆記的 Inbound[note] 初始化成 []*Note{}:
for _, note := range notes {
idx.Inbound[note] = []*Note{}
idx.indexTitleAndAliases(note)
idx.indexTags(note)
}
這樣 Day 11 遍歷 idx.Inbound 判斷孤立筆記時,直接檢查 len(inbound) == 0 即可,不需要額外判斷這個 key 存不存在(Go 的 map 對不存在的 key 取值會回傳零值,nil 切片跟空切片在 len() 判斷上其實沒有差異,但明確初始化能避免日後有人改用 _, exists := idx.Inbound[note] 這種寫法時得到誤導性的結果)。
BuildIndex 內部分成兩個明確階段:第一階段走訪一次 notes 建好標題/別名查找表與標籤查找表;第二階段才走訪 notes 建連結圖。如果在單一迴圈裡邊建邊查,會有 false negative 的風險——假設 notes[0] 引用了 notes[5],但走訪到 notes[0] 時 notes[5] 的標題還沒寫進查找表,這條連結就會被誤判成 unresolved。兩階段的代價是多一次對 notes 切片的線性走訪,但換來的是「連結圖建構時查找表保證完整」這個簡單的正確性保證,在目前 vault 的量級下完全可以接受。
func TestBuildIndex_ResolvedOutboundCreatesInboundRelation(t *testing.T) {
target := newTestNote("target.md", "Target Note", nil, nil, "")
source := newTestNote("source.md", "Source Note", nil, nil, "參考 [[Target Note]]。\n")
notes := []*vault.Note{target, source}
idx := vault.BuildIndex(notes)
assert.Equal(t, []string{"Target Note"}, idx.Outbound[source])
assert.Equal(t, []*vault.Note{source}, idx.Inbound[target])
}
func TestBuildIndex_DuplicateTitleFirstOccurrenceWins(t *testing.T) {
winner := newTestNote("winner.md", "Duplicate", nil, nil, "")
loser := newTestNote("loser.md", "Duplicate", nil, nil, "")
notes := []*vault.Note{winner, loser}
idx := vault.BuildIndex(notes)
resolved, ok := idx.Resolve("Duplicate")
assert.True(t, ok)
assert.Same(t, winner, resolved)
assert.Equal(t, []vault.AliasConflict{
{Key: "Duplicate", WinnerNote: winner, LoserNote: loser},
}, idx.Conflicts)
}
其餘測試涵蓋依標題/別名解析成功、查無對應筆記、標題與別名跨筆記衝突、同一標籤被多篇筆記引用、outbound Target 無法解析時寫入 Unresolved、未被引用筆記的 Inbound 為空清單而非缺項、以及 BuildIndex 執行前後不修改傳入的 notes/*Note。go test ./...、gofmt -l .、go vet ./... 全數通過;brain scan 重新跑過,輸出格式與行為都沒有變化——這次沒有動到 Note/Frontmatter 結構,也沒有動到 Scan/ExtractWikilinks 的函式簽章。
BuildIndex 今天只交付「能力」本身,比照 Day 09 的模式,沒有接進任何 CLI 子指令。它產出的 idx.Unresolved(無法解析的連結)與 idx.Inbound(是否為空清單)這兩份資料,正是 Day 11 判斷「斷鏈」與「孤立筆記」時需要的原始事實,不需要重新計算。
👉 明天 Day 11,我們要用今天建好的索引,實作 brain health 指令,找出 vault 裡的孤立筆記與斷鏈。我們明天見!