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