From 3e73d7aa07b288e06bd475d96d1a72dfc304adc4 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sat, 25 Jul 2026 23:39:38 +0800
Subject: [PATCH] feat: 实现 vault 解析器 - frontmatter 解析与 wikilink 提取
---
internal/vault/parser.go | 96 +++++++++++++++++++
internal/vault/scanner.go | 38 +++++++
internal/vault/parser_test.go | 71 ++++++++++++++
internal/vault/scanner_test.go | 40 ++++++++
internal/vault/sections.go | 53 ++++++++++
5 files changed, 298 insertions(+), 0 deletions(-)
diff --git a/internal/vault/parser.go b/internal/vault/parser.go
new file mode 100644
index 0000000..6764af4
--- /dev/null
+++ b/internal/vault/parser.go
@@ -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
+}
diff --git a/internal/vault/parser_test.go b/internal/vault/parser_test.go
new file mode 100644
index 0000000..d0b65ec
--- /dev/null
+++ b/internal/vault/parser_test.go
@@ -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)
+ }
+}
diff --git a/internal/vault/scanner.go b/internal/vault/scanner.go
new file mode 100644
index 0000000..f19def5
--- /dev/null
+++ b/internal/vault/scanner.go
@@ -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
+}
diff --git a/internal/vault/scanner_test.go b/internal/vault/scanner_test.go
new file mode 100644
index 0000000..d130bac
--- /dev/null
+++ b/internal/vault/scanner_test.go
@@ -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))
+ }
+}
diff --git a/internal/vault/sections.go b/internal/vault/sections.go
new file mode 100644
index 0000000..e2a8a35
--- /dev/null
+++ b/internal/vault/sections.go
@@ -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 "其他"
+}
--
Gitblit v1.9.1