「在數百上千張筆記交織成的知識庫中,毫秒級的迴圈處理速度是保持流暢開發體驗(DX)的基石。Go 語言原生強大的檔案 I/O 與併發能力,正是執行這項任務的不二人選。」
Vault 掃描是一個會被高頻執行的動作——之後每天示範都要重新掃一次。Go 編譯成單一二進位檔,啟動時間趨近 0ms,跟需要直譯器啟動成本的 Python/Node.js 相比有明顯優勢;標準庫的 filepath.WalkDir 本身也是效率不錯的原生實作。但要誠實說明:這個優勢目前只來自「啟動快、原生工具鏈」,不是平行演算法——demo vault 現階段只有 2 篇真實筆記,規模遠遠稱不上「大容量」,這個階段引入 goroutine/worker pool 只會增加維護負擔,不會帶來可量測的效益,因此本次的掃描器刻意維持單執行緒循序掃描。
延續 Day 05 已定案的 cmd/ + internal/ 佈局(internal/ 讓 Go 編譯器強制不可被外部模組 import,符合「不對外釋出成函式庫」的現況),資料結構放在 internal/vault/note.go。Frontmatter 的欄位直接對齊 Day 04 封存的 note-metadata-schema spec:五個必填字串欄位、三個選填字串陣列欄位。
type Frontmatter struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Date string `yaml:"date"`
Type string `yaml:"type"`
Status string `yaml:"status"`
Tags []string `yaml:"tags"`
Related []string `yaml:"related"`
Aliases []string `yaml:"aliases"`
}
type Note struct {
FilePath string
Frontmatter Frontmatter
Body string
RawContent string
}
ParseRawNote 負責切開頭以 --- 包夾的 YAML 區塊與其後的 Markdown 正文,用 gopkg.in/yaml.v3(原本已在 go.mod 但標記 // indirect,實際 import 後執行 go build -mod=mod ./... 會自動轉成 direct 依賴)把 YAML 解析成 Frontmatter struct:
func ParseRawNote(filePath string, rawContent []byte) (*Note, error) {
frontmatterYAML, body, err := splitFrontmatter(rawContent)
if err != nil {
return nil, err
}
var fm Frontmatter
if err := yaml.Unmarshal(frontmatterYAML, &fm); err != nil {
return nil, fmt.Errorf("解析 Frontmatter YAML 失敗: %w", err)
}
return &Note{FilePath: filePath, Frontmatter: fm, Body: body, RawContent: string(rawContent)}, nil
}
這裡刻意只做「結構化解析」:YAML 能不能轉成 struct,就是成功或失敗,不驗證 id 格式、type/status 是否落在受控詞彙、title 是否跟檔名一致。語意層級的合法性檢查留給 Day 11 的 health check,避免今天的範圍蔓延。
internal/vault/scanner.go 用標準庫 filepath.WalkDir 循序遍歷 vault 目錄,跳過名稱以 . 開頭的目錄(.git、.obsidian)與非 .md 檔案:
func Scan(root string) ([]*Note, []ScanError, error) {
var notes []*Note
var scanErrors []ScanError
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if path != root && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(d.Name()) != ".md" {
return nil
}
rawContent, readErr := os.ReadFile(path)
if readErr != nil {
scanErrors = append(scanErrors, ScanError{FilePath: path, Err: readErr})
return nil
}
note, parseErr := ParseRawNote(path, rawContent)
if parseErr != nil {
scanErrors = append(scanErrors, ScanError{FilePath: path, Err: parseErr})
return nil
}
notes = append(notes, note)
return nil
})
if err != nil {
return nil, nil, err
}
return notes, scanErrors, nil
}
單一檔案解析失敗(缺少 Frontmatter、YAML 語法錯誤)不會中斷整體掃描,而是收進 []ScanError,跟成功解析的 []*Note 清單一起回傳——一座真實的 vault,遲早會有某篇筆記忘記加 Frontmatter,掃描器不能因為一篇壞檔案就整個掛掉。
值得一提的邊界情況是 vault/README.md:它副檔名是 .md,會被掃描器讀到,但因為沒有 YAML Frontmatter,會被 ParseRawNote 判定為「缺少 Frontmatter」而落入錯誤清單,而不是被特別 hardcode 排除——讓它跟未來任何一篇忘記加 Frontmatter 的筆記走同一套判斷邏輯,不需要額外維護白名單。
cmd/brain/main.go 掛上 scan 子指令,串接 vault.Scan,逐筆輸出筆記資訊與統計:
func newScanCmd() *cobra.Command {
return &cobra.Command{
Use: "scan",
Short: "掃描 vault 目錄下所有筆記並輸出清單與統計",
RunE: func(cmd *cobra.Command, args []string) error {
notes, scanErrors, err := vault.Scan(vaultRoot)
if err != nil {
return err
}
for _, note := range notes {
fm := note.Frontmatter
fmt.Fprintf(cmd.OutOrStdout(), "- [%s] %s (%s/%s)\n", fm.ID, fm.Title, fm.Type, fm.Status)
}
fmt.Fprintf(cmd.OutOrStdout(), "共 %d 篇筆記,%d 筆解析失敗\n", len(notes), len(scanErrors))
for _, scanErr := range scanErrors {
fmt.Fprintf(cmd.OutOrStdout(), " 失敗:%s\n", scanErr.Error())
}
return nil
},
}
}
輸出刻意只用最小可行的純文字格式,不做 JSON、不做彩色終端輸出,除非未來有明確的消費端(例如 Day 21 的 Graphify 匯出)需要結構化格式。同步在 Makefile 新增 make scan 目標,取代每次手打 make run ARGS="scan"。
對現有 vault(00_Inbox/PARA 筆記法.md、20_Areas/Cobra CLI 框架.md、以及沒有 Frontmatter 的 vault/README.md)執行:
$ make scan
go run ./cmd/brain scan
- [20260815-090000] PARA 筆記法 (inbox-draft/seed)
- [20260815-093000] Cobra CLI 框架 (atomic-note/growing)
共 2 篇筆記,1 筆解析失敗
失敗:vault/README.md: 缺少 Frontmatter:檔案開頭必須是 "---"
在 vault/00_Inbox/ 新增一篇符合規範的測試筆記後重跑 make scan,新筆記正確出現在清單中;放入一篇刻意缺少 Frontmatter 的檔案,scan 依然正常執行完畢(結束碼 0),只是多列進失敗清單。internal/vault 底下的 table-driven 測試(testify)涵蓋 ParseRawNote 的成功解析、缺少 Frontmatter、非法 YAML、選填欄位缺席等情境,以及 Scan 的跳過隱藏目錄、跳過非 .md 檔案、單一檔案失敗不中斷等情境,go test ./...、gofmt -l .、go vet ./... 全數通過。
今天我們成功用 Go 打造了 brain-cli 的第一重基石!我們實現了:
這意味著我們的 CLI 工具已經具備了讀取 Obsidian 檔案結構的能力。