feat: 实现 vault 解析器 - frontmatter 解析与 wikilink 提取
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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) |
| | | } |
| | | } |
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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)) |
| | | } |
| | | } |
| New file |
| | |
| | | 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 "其他" |
| | | } |