iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0

ExtractWikilinks 只告訴你一篇筆記寫了哪些連結字串,它不知道『這個字串是誰』——要回答這個問題,你需要先看過全庫,而不是只看一篇筆記。」

Day 06 的 Scan 能掃出整個 vault 的 []*Note,Day 09 的 ExtractWikilinks 能從單篇筆記正文抓出 [[Target]] 字串清單,但這兩者目前互不相關:Scan 不知道筆記之間有沒有連結,ExtractWikilinks 也不知道自己抓到的字串到底對應到哪篇真實存在的筆記。今天在 internal/vault 新增 BuildIndex(notes []*Note) *Index,把這兩個既有能力的輸出整併成一份可查詢的全庫記憶體索引,這是 Day 11 孤立筆記/斷鏈健康檢查會直接依賴的資料。

Wikilink 的目標,可能是標題,也可能是別名

Obsidian 的 [[Target]] 語法有一個容易被忽略的細節:Target 不一定要精準匹配另一篇筆記的標題,它也可以匹配該筆記在 aliases 欄位宣告的任何一個別名。舉個實際情境:一篇筆記標題是「PARA 筆記法」,但因為打字習慣,正文裡有時會寫成 [[PARA]]——如果這篇筆記的 aliases 有宣告 ["PARA"],這條連結就該被視為指向同一篇筆記,而不是斷鏈。

這代表索引的「標題/別名 → 筆記」查找表,必須同時收錄 Frontmatter.TitleFrontmatter.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) 方法查詢——因為這份查找表混合了 TitleAliases 兩種來源,直接暴露原始 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 讓呼叫端自己決定要不要警告或阻擋。

值得注意的是,一篇筆記自己的 TitleAliases 之間如果重複(例如某人手滑把標題本身也寫進了別名清單),不會被視為衝突——indexTitleAndAliases 裡用了一個區域 seen map 過濾同一篇筆記內部的重複 key,只有跨筆記的重複才會進 Conflicts

outbound 原樣保留,unresolved 額外記錄

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]) 就夠了,不需要重新呼叫一次 ExtractWikilinksUnresolved 則是額外的診斷清單,只收錄解析失敗的部分,讓 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/*Notego test ./...gofmt -l .go vet ./... 全數通過;brain scan 重新跑過,輸出格式與行為都沒有變化——這次沒有動到 Note/Frontmatter 結構,也沒有動到 Scan/ExtractWikilinks 的函式簽章。

銜接 Day 11

BuildIndex 今天只交付「能力」本身,比照 Day 09 的模式,沒有接進任何 CLI 子指令。它產出的 idx.Unresolved(無法解析的連結)與 idx.Inbound(是否為空清單)這兩份資料,正是 Day 11 判斷「斷鏈」與「孤立筆記」時需要的原始事實,不需要重新計算。

👉 明天 Day 11,我們要用今天建好的索引,實作 brain health 指令,找出 vault 裡的孤立筆記與斷鏈。我們明天見!


上一篇
精準語法提取:用 AST Walking 抓取 [[Wikilink]] 雙向連結
下一篇
知識庫健康檢查:偵測孤立筆記 (Orphan Notes) 與斷鏈
系列文
打造 AI Agent 驅動的第二大腦:用 Go + Claude Code + Obsidian + Graphify 打造工程師知識作業系統15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言