今天要解的問題
llm-wiki 是我用 Obsidian 加 Claude Code 維護的個人知識庫:{域}/raw/ 放擷取進來的素材,{域}/wiki/ 放整理好的概念頁,git 是兩台電腦之間唯一的同步方式。vault 的狀態由 navi 負責看,它是 Claude Code 的 mod(做法見 Day26):輸入框上方有今日三頁,狀態列列出待處理的數字,/navi 開出側邊面板,裡面有 TODO、健檢、漫遊三個分頁。
問題是 navi 只在 Claude Code 的終端機裡。平常讀筆記是在 Obsidian,那邊只有一份 Dashboard.md,裡面寫了幾段 Dataview 查詢(閱讀中的書、最近的 wiki、Inbox 未處理)。翻一下 .obsidian/community-plugins.json 才發現,啟用的外掛只有 obsidian-git,Dataview 根本沒裝,這幾段查詢一直是以程式碼區塊的樣子躺在那裡。
我要的東西很明確:
我想要在我的 obsidian 也有類似 navi 的 dashboard,但主要是健診與趣味
成品分兩頁,先是健診,再是趣味:


想法與取捨
只做健診與趣味
Claude Code 版的 navi 有三個分頁,這次只搬兩種內容:健診(vault 哪裡壞了、哪裡該整理)和趣味(讓人想點進去逛的東西)。TODO 不在這次的範圍內。
健診的項目跟 Claude Code 端用同一套判準:wiki 頁必填的 frontmatter(title/summary/tags/date)跟 vault 的 Stop hook 檢查一樣;raw 堆積沿用「同域未被引用 ≥ 5 篇就該跑 /wiki-build」的規則,books 的讀書筆記與標了 status: retired 的退場 raw 不算。
三種做法
要在 Obsidian 裡畫出這些東西,考慮過三條路:
| 做法 | 放棄或採用的理由 |
|---|---|
| Dataview 查詢 | 沒裝,現有的查詢也一直沒在運作 |
| 腳本產生靜態 Markdown | 檔案要寫進 vault,每次重產都是一筆 git 變更;而 Claude 的 session 被 hook 擋著不能寫 Obsidian 讀的那份主 checkout |
| 自己寫一個外掛 | 採用 |
外掛最後勝出,有三個條件剛好都成立:
- 不用建置。Obsidian 外掛本質上就是一個
main.js加manifest.json,用 CommonJS 直接require('obsidian')就能跑,不需要 TypeScript、esbuild 或node_modules。vault 根目錄本來就禁止放依賴檔,這點很重要。 - 跟著 git 走。外掛放在
.obsidian/plugins/navi/,.obsidian/本來就進 git,兩台電腦 pull 下來就有,不用各自安裝。 - 資料現成。Obsidian 的
metadataCache已經把每頁的 frontmatter、連結、tag 解析好了,resolvedLinks就是整張連結圖,不必自己讀檔解析。
已知代價是 metadataCache 不知道 git 歷史。Claude Code 端的「🍂 久未互動」是看 git 最後一次 commit 的,這裡做不到,所以那一塊不搬,README 裡寫明「留給 /navi 與 /vault-maintain」。
名字沿用 navi
外掛一開始叫 Vault Pulse,做完後改了:
取名可以沿用 navi 嗎
同名不會打架:Claude Code 的 mod 從 .claude/skills/navi/ 載入,Obsidian 外掛從 .obsidian/plugins/navi/ 載入,兩邊各自的載入機制互不相干。改名之後外掛 id、側欄 view、嵌入用的程式碼區塊都叫 navi,兩份 README 互相指向對方。
實作
改動落在 llm-wiki 的 .obsidian/plugins/navi/ 三個檔案(main.js、styles.css、README.md),加上啟用設定與 Dashboard.md 的嵌入。
啟用:一行設定
[
"obsidian-git",
"navi"
]
.obsidian/community-plugins.json 列的是要啟用的外掛 id。obsidian-git 本身就是社群外掛,代表兩台電腦的 Obsidian 早就關掉了限制模式,加一行 id 就會載入。
判斷每個檔案在哪一層
vault 的路徑本身就是分類,外掛先把路徑拆成「域」和「層」:
function partsOf(path) {
const p = path.replace(/\.md$/, '').split('/')
const domain = p.length > 1 ? p[0] : ''
const name = p[p.length - 1]
let layer = 'other'
if (domain === 'hubs') layer = name === 'index' ? 'index' : 'hub'
else if (p.length === 2 && name === 'index') layer = 'index'
else if (p.includes('logs')) layer = 'logs'
else if (p[1] === 'wiki' && name !== 'index') layer = 'wiki'
else if (p[1] === 'raw') layer = 'raw'
return { domain, layer, name }
}
logs 用 includes 而不是看第二段,因為 learning/ 的日誌在 learning/{主題}/logs/ 多一層。各域的 index.md 獨立成一層,後面「沒進域 index 的 wiki」要拿它來比對。
連結網路:只算 wiki 與 hub 發出的
// 連結網路:只算 wiki/hub 發出的(同 navi)
const incoming = new Map()
const outgoing = new Map()
for (const p of all) {
if (p.layer !== 'wiki' && p.layer !== 'hub') continue
const dests = Object.keys(resolved[p.path] || {}).filter(d => d !== p.path && info.has(d))
outgoing.set(p.path, dests)
for (const d of dests) {
if (!incoming.has(d)) incoming.set(d, new Set())
incoming.get(d).add(p.path)
}
}
resolved 就是 metadataCache.resolvedLinks,格式是「來源路徑 → { 目標路徑: 次數 }」,Obsidian 已經處理好同名檔、別名與路徑限定的解析。只收 wiki 和 hub 發出的連結,是跟 Claude Code 端的口徑對齊:index、log、TODO 連到某頁不算「有人引用它」,否則每頁都被自己域的 index 連著,就不會有孤兒了。孤兒 wiki、raw 堆積、人氣排行都從這兩張表算出來。
健康分數:每項都有上限
const penalties = [
{ label: 'frontmatter 缺欄', n: missingFm.length, cost: missingFm.length * 3, cap: 20 },
{ label: '斷鏈', n: broken.length, cost: broken.length, cap: 20 },
{ label: 'books 裸連結', n: bareBooks.length, cost: bareBooks.length * 2, cap: 10 },
{ label: '孤兒 wiki', n: orphans.length, cost: orphans.length * 0.5, cap: 15 },
{ label: '未進 index', n: indexMiss.length, cost: indexMiss.length, cap: 15 },
{ label: 'Inbox 待分類', n: inbox.length, cost: inbox.length, cap: 10 },
{ label: 'raw 堆積域', n: pileList.filter(x => x.list.length >= PILE).length, cost: pileList.filter(x => x.list.length >= PILE).length * 3, cap: 10 },
].map(x => ({ ...x, lost: Math.min(x.cost, x.cap) }))
const score = Math.max(0, Math.round(100 - penalties.reduce((s, x) => s + x.lost, 0)))
從 100 往下扣,每項單價不同:缺 frontmatter 一頁扣 3,孤兒 wiki 一頁扣 0.5。cap 是每項的扣分上限,程式註解寫的是「不讓單一問題吃掉全部」。實機上這個設計馬上派上用場:斷鏈有 182 條,沒有上限的話分數直接歸零,什麼都看不出來;有上限,它只扣 20,其他項目的扣分還看得見。
「books 裸連結」是這個 vault 特有的:書在 books/wiki/ 和 books/raw/ 各有一份同名檔,[[書名]] 有歧義,規定要寫成 [[books/wiki/書名]]。外掛找出 wiki 與 raw 同名的書,再掃每頁的 cache.links 有沒有不帶路徑的連結指向它們。
今日三頁:同一天同一組
for (const p of pick(data.wiki, 3, rng(P.reroll || data.today))) {
rng 是一個以字串當種子的小亂數產生器,種子是今天的日期,所以一整天打開幾次都是同一組三頁,隔天自動換。按「🎲 換一組」時把 P.reroll 設成一個隨機字串,種子換掉、重畫一次。
「今日媒人」用同一招:找出共享兩個以上主題 tag、卻互相都沒連結的兩頁,跨域的優先,每天配一對。上面截圖裡配到的是〈滷肉飯地區差異〉和〈行走的美味〉,兩頁都標了 #飲食 #文化,全 vault 這樣的候選有 83 對。這其實是 Claude Code 端健檢分頁的「共享 tag 卻互不相連的頁」,換到趣味頁,每天只配一對,可以按「換一對」重抽。
兩個入口:側欄與程式碼區塊
this.registerMarkdownCodeBlockProcessor('navi', async (src, el, ctx) => {
const mode = src.trim() || 'all'
const entry = { el, mode }
this.blocks.add(entry)
// 筆記關掉時移除,避免背景重畫
const child = new MarkdownRenderChild(el)
child.onunload = () => this.blocks.delete(entry)
ctx.addChild(child)
await render(this, el, mode)
})
// vault 有變動 → 標記過期,靜下來 3 秒後重畫
const stale = debounce(() => this.refreshAll(true), 3000, true)
this.registerEvent(this.app.metadataCache.on('resolved', stale))
除了 ribbon 圖示打開的右側欄,任何筆記裡寫一個 ```navi``` 程式碼區塊就會原地畫出面板,區塊內容填 health 或 fun 只顯示一頁。Dashboard.md 頂端就嵌了一個。MarkdownRenderChild 讓筆記關掉時把區塊從重畫名單移除。
metadataCache 每次索引完會發 resolved 事件,編輯時會連續觸發。debounce 第三個參數設 true,每次觸發都重新計時,等 vault 靜下來 3 秒才重掃一次。
趣味頁下半部是各域的 wiki 頁數和排行榜,人氣、連出、長短各列前幾名:

踩到的坑
外掛沒辦法在終端機裡跑,所以我先做了一個 mock:用 Python 把 vault 每個檔案的 frontmatter 和 [[連結]] 抽成 JSON,自己照 basename 規則解析連結,模擬出 resolvedLinks 和 unresolvedLinks,再用 Node 載入 main.js、塞一個假的 obsidian 模組,直接呼叫掃描函式。
mock 自己的解析器製造了 99 條斷鏈
第一次跑出來斷鏈 99 條、coffee 域 raw 堆積 30 篇,分數 64。逐條看,大部分不是 vault 的問題:
CLAUDE.md裡的[[books/wiki/{書名}]]、[[page-path]]是寫在行內程式碼裡的範例,Obsidian 不會把程式碼裡的東西當連結,我的 regex 會。- coffee 的 wiki 在表格裡寫
[[檔名\|顯示文字]]。表格裡的|要跳脫成\|,我的解析器在|切開之後,目標名稱尾巴多了一個反斜線,解析不到。
t=link.split('#')[0].split('|')[0].rstrip('\\').strip()
body=re.sub(r'```.*?```','',txt[end:],flags=re.S); body=re.sub(r'`[^`\n]*`','',body)
去掉程式碼裡的連結、切掉尾巴的反斜線之後,斷鏈變成 0,coffee 的堆積從 30 篇變成 1 篇,分數 89。表格裡被誤判的那批連結,正好就是 coffee 的 raw 被 wiki 引用的地方。
預覽頁被筆記內容截斷
為了看畫面,我把外掛程式碼、樣式和整份 vault 資料塞進一個 HTML,在瀏覽器裡用假的 DOM 輔助函式(Obsidian 的 createEl、createDiv)渲染。第一次打開,畫面是一大段筆記原文,面板完全沒出來。
原因是 vault 裡有一篇講網頁安全標頭的筆記,內文就有 </script> 字樣。資料以 JSON 寫在 <script> 標籤裡,瀏覽器看到那串字就把 script 結束了,後面的 JSON 全當成頁面文字。把資料裡的 </ 換成 <\/(JSON 字串裡兩者等價)就正常了。
真的打開 Obsidian,分數是 66
mock 裡斷鏈是 0、分數 89。外掛裝進 vault、在 Obsidian 實際打開後,健診頁顯示 66 分,斷鏈 182 條(就是封面那張)。孤兒 wiki 和未進 index 也跟 mock 不太一樣,不過那段時間 vault 本身也有新頁進來。
斷鏈這麼大的落差,問題出在 mock 的解析器只認 [[ ]] wikilink。Obsidian 的 unresolvedLinks 還會收 Markdown 式的相對連結 [文字](./檔名.md),指不到檔案一樣算斷鏈。
books/raw/ 裡就有大約 183 條這種連結。這批書筆記是從卡片式筆記軟體 Heptabase 搬過來的,匯出的 Markdown 裡,書筆記用相對路徑連到一張張卡片:
[演算法的推播加深印象.md](./演算法的推播加深印象.md)
搬進 vault 時這些連結沒有經過整理,而那些卡片檔並不在 vault 裡,連結就一直指向不存在的檔案。
清理的做法依連結指向分三種:
- 對不到任何檔的卡片連結改成純文字,原句保留,只去掉
.md。上面那條變成演算法的推播加深印象 - 指向 vault 裡既有書筆記的 19 條,改成路徑限定的 wikilink
[[books/raw/書名|《書名》]] - 另外順手撈到的幾條:coffee 的 raw 裡兩個沒寫
https://的裸網域補上協定,engineer 的一篇 raw 指向舊 repo 的相對路徑改成行內程式碼
改動共 38 個檔,其中 books/raw 佔 34 個。處理方式選了修內容,外掛的掃描邏輯不動,README 的斷鏈說明補上「含 Markdown 式連結」。清完之後用同時解析 wikilink 和 Markdown 相對連結的腳本重掃全 vault(Inbox、log、Templates、personal 除外),未解析的是 0 條。
mock 拿來驗證「程式跑得動、畫面排得出來」是夠的;但「數字對不對」只能以實機為準。這次 mock 跟實機差了 182 條,追下去正好挖出一整批從舊系統帶過來的死連結。
小結
Obsidian 現在也有 navi 了:右側欄或任何筆記裡的 ```navi``` 區塊,健診頁給一個有上限的健康分數和八個可收合的問題清單,趣味頁有每天固定的三頁、寫作熱度格子、配對沒互連頁面的今日媒人、排行榜和 tag 雲。整個外掛不用建置,靠 git 同步到兩台電腦,資料全部取自 Obsidian 已經算好的 metadataCache。
這次帶走兩個判斷。一是 Obsidian 外掛的門檻比想像中低:一個 CommonJS 檔就能跑,不需要整套 TypeScript 工具鏈,對一個不想在 vault 裡放 node_modules 的專案剛好。二是 mock 能證明程式會動,不能證明數字是對的。自己寫的解析器只認得自己想到的連結格式,mock 跟實機的落差反而是線索:這次就是靠那 182 條,找到了從 Heptabase 搬家時沒整理的死連結。
