今天要解的問題

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,但主要是健診與趣味

成品分兩頁,先是健診,再是趣味:

Obsidian 版 navi 的健診頁:健康分數 66,四格數字與八個可收合的問題清單

趣味頁:今日三頁、寫作熱度格子、今日媒人與四格統計

想法與取捨

只做健診與趣味

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 搬家時沒整理的死連結。