ai_xiaopei
2026-07-25 3e73d7aa07b288e06bd475d96d1a72dfc304adc4
feat: 实现 vault 解析器 - frontmatter 解析与 wikilink 提取
5 files added
298 ■■■■■ changed files
internal/vault/parser.go 96 ●●●●● patch | view | raw | blame | history
internal/vault/parser_test.go 71 ●●●●● patch | view | raw | blame | history
internal/vault/scanner.go 38 ●●●●● patch | view | raw | blame | history
internal/vault/scanner_test.go 40 ●●●●● patch | view | raw | blame | history
internal/vault/sections.go 53 ●●●●● patch | view | raw | blame | history
internal/vault/parser.go
New file
@@ -0,0 +1,96 @@
package vault
import (
    "bufio"
    "os"
    "regexp"
    "strings"
    "gopkg.in/yaml.v3"
)
// FileMeta 文件元数据
type FileMeta struct {
    Path      string   // 相对路径
    Title     string   // frontmatter title
    Section   string   // 所属板块
    Tags      []string // frontmatter tags
    Entities  []string // frontmatter entities
    Wikilinks []string // 正文中的 [[xxx]] 链接
    Content   string   // 纯文本内容(去 frontmatter)
}
// Frontmatter YAML 结构
type Frontmatter struct {
    Title    string   `yaml:"title"`
    Tags     []string `yaml:"tags"`
    Entities []string `yaml:"entities"`
    Aliases  []string `yaml:"aliases"`
}
var wikilinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`)
// ParseFile 解析单个 markdown 文件
func ParseFile(absPath, relPath string) (*FileMeta, error) {
    f, err := os.Open(absPath)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    meta := &FileMeta{
        Path:    relPath,
        Section: GetSection(relPath),
    }
    scanner := bufio.NewScanner(f)
    inFrontmatter := false
    fmLines := []string{}
    contentLines := []string{}
    lineNum := 0
    for scanner.Scan() {
        line := scanner.Text()
        lineNum++
        if lineNum == 1 && line == "---" {
            inFrontmatter = true
            continue
        }
        if inFrontmatter && line == "---" {
            inFrontmatter = false
            continue
        }
        if inFrontmatter {
            fmLines = append(fmLines, line)
        } else {
            contentLines = append(contentLines, line)
        }
    }
    // 解析 frontmatter
    if len(fmLines) > 0 {
        var fm Frontmatter
        if err := yaml.Unmarshal([]byte(strings.Join(fmLines, "\n")), &fm); err == nil {
            meta.Title = fm.Title
            meta.Tags = fm.Tags
            meta.Entities = fm.Entities
        }
    }
    // 提取 wikilinks 和内容
    content := strings.Join(contentLines, "\n")
    meta.Content = content
    matches := wikilinkRe.FindAllStringSubmatch(content, -1)
    seen := make(map[string]bool)
    for _, m := range matches {
        link := m[1]
        if !seen[link] {
            meta.Wikilinks = append(meta.Wikilinks, link)
            seen[link] = true
        }
    }
    return meta, nil
}
internal/vault/parser_test.go
New file
@@ -0,0 +1,71 @@
package vault
import (
    "os"
    "path/filepath"
    "testing"
)
func TestParseFile(t *testing.T) {
    // 创建临时测试文件
    tmpDir := t.TempDir()
    testFile := filepath.Join(tmpDir, "test.md")
    content := `---
title: 测试文档
tags:
  - 充装
  - 智能枪
entities:
  - YSP-35.5
---
# 测试内容
这是一个关于 [[充装规格配置]] 的文档。
也引用了 [[智能枪使用指南]]。
`
    os.WriteFile(testFile, []byte(content), 0644)
    meta, err := ParseFile(testFile, "FAQ/充装类/test.md")
    if err != nil {
        t.Fatalf("ParseFile failed: %v", err)
    }
    if meta.Title != "测试文档" {
        t.Errorf("Title = %q, want %q", meta.Title, "测试文档")
    }
    if len(meta.Tags) != 2 || meta.Tags[0] != "充装" {
        t.Errorf("Tags = %v, want [充装 智能枪]", meta.Tags)
    }
    if len(meta.Entities) != 1 || meta.Entities[0] != "YSP-35.5" {
        t.Errorf("Entities = %v, want [YSP-35.5]", meta.Entities)
    }
    if len(meta.Wikilinks) != 2 {
        t.Errorf("Wikilinks count = %d, want 2", len(meta.Wikilinks))
    }
    if meta.Section != "FAQ" {
        t.Errorf("Section = %q, want %q", meta.Section, "FAQ")
    }
}
func TestParseFileNoFrontmatter(t *testing.T) {
    tmpDir := t.TempDir()
    testFile := filepath.Join(tmpDir, "nofm.md")
    content := `# 没有 frontmatter
只有内容。
`
    os.WriteFile(testFile, []byte(content), 0644)
    meta, err := ParseFile(testFile, "笔记/nofm.md")
    if err != nil {
        t.Fatalf("ParseFile failed: %v", err)
    }
    if meta.Title != "" {
        t.Errorf("Title = %q, want empty", meta.Title)
    }
    if len(meta.Tags) != 0 {
        t.Errorf("Tags = %v, want empty", meta.Tags)
    }
}
internal/vault/scanner.go
New file
@@ -0,0 +1,38 @@
package vault
import (
    "os"
    "path/filepath"
    "strings"
)
// ScanVault 扫描知识库目录,返回所有 markdown 文件的元数据
func ScanVault(vaultPath string) ([]*FileMeta, error) {
    var files []*FileMeta
    err := filepath.Walk(vaultPath, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return nil // 跳过错误文件
        }
        if info.IsDir() {
            // 跳过隐藏目录
            if strings.HasPrefix(info.Name(), ".") && info.Name() != "." {
                return filepath.SkipDir
            }
            return nil
        }
        if !strings.HasSuffix(path, ".md") {
            return nil
        }
        relPath, _ := filepath.Rel(vaultPath, path)
        meta, err := ParseFile(path, relPath)
        if err != nil {
            return nil // 跳过解析失败的文件
        }
        files = append(files, meta)
        return nil
    })
    return files, err
}
internal/vault/scanner_test.go
New file
@@ -0,0 +1,40 @@
package vault
import (
    "os"
    "path/filepath"
    "testing"
)
func TestScanVault(t *testing.T) {
    tmpDir := t.TempDir()
    // 创建测试目录结构
    os.MkdirAll(filepath.Join(tmpDir, "FAQ", "充装类"), 0755)
    os.MkdirAll(filepath.Join(tmpDir, "笔记"), 0755)
    os.MkdirAll(filepath.Join(tmpDir, ".obsidian"), 0755) // 应被跳过
    os.WriteFile(filepath.Join(tmpDir, "FAQ", "充装类", "001-test.md"), []byte(`---
title: 测试FAQ
tags: [充装]
---
内容
`), 0644)
    os.WriteFile(filepath.Join(tmpDir, "笔记", "note.md"), []byte(`---
title: 笔记
---
笔记内容
`), 0644)
    os.WriteFile(filepath.Join(tmpDir, ".obsidian", "config.json"), []byte(`{}`), 0644)
    files, err := ScanVault(tmpDir)
    if err != nil {
        t.Fatalf("ScanVault failed: %v", err)
    }
    if len(files) != 2 {
        t.Errorf("file count = %d, want 2", len(files))
    }
}
internal/vault/sections.go
New file
@@ -0,0 +1,53 @@
package vault
// SectionConfig 定义板块配置
type SectionConfig struct {
    Name     string
    SubPaths map[string]string // 子板块名 -> 路径
}
// RootSections 板块配置(对应 Python 版 ROOT_SECTIONS)
var RootSections = map[string]*SectionConfig{
    "实体":     {Name: "实体", SubPaths: map[string]string{"_root": "实体"}},
    "知识":     {Name: "知识", SubPaths: map[string]string{
        "平台知识": "知识/平台知识",
        "典型案例": "知识/典型案例",
        "行业知识": "知识/行业知识",
        "疑难问题": "知识/疑难问题",
        "FAQ":     "知识/FAQ",
        "技术运维": "知识/技术运维",
    }},
    "笔记":     {Name: "笔记", SubPaths: map[string]string{"_root": "笔记"}},
    "FAQ":      {Name: "FAQ", SubPaths: map[string]string{
        "充装类":     "FAQ/充装类",
        "配送类":     "FAQ/配送类",
        "平台操作类": "FAQ/平台操作类",
        "数据同步类": "FAQ/数据同步类",
        "硬件类":     "FAQ/硬件类",
        "网络类":     "FAQ/网络类",
        "综合类":     "FAQ/综合类",
    }},
    "案例":     {Name: "案例", SubPaths: map[string]string{"_root": "案例"}},
    "文档":     {Name: "文档", SubPaths: map[string]string{"_root": "文档"}},
    "行业":     {Name: "行业", SubPaths: map[string]string{"_root": "行业"}},
    "内部工具": {Name: "内部工具", SubPaths: map[string]string{"_root": "内部工具"}},
    "设备":     {Name: "设备", SubPaths: map[string]string{"_root": "设备"}},
}
// GetSection 根据文件路径判断所属板块
func GetSection(relPath string) string {
    for section, cfg := range RootSections {
        for _, subPath := range cfg.SubPaths {
            if subPath == "." || subPath == section {
                continue
            }
            if len(relPath) >= len(subPath) && relPath[:len(subPath)] == subPath {
                return section
            }
        }
        if len(relPath) >= len(section) && relPath[:len(section)] == section {
            return section
        }
    }
    return "其他"
}