From 4eb5c1a93559a27b126034a2471ae19c82998520 Mon Sep 17 00:00:00 2001
From: ax_rd <ax_rd@aisim.cn>
Date: Thu, 03 Sep 2026 09:57:28 +0800
Subject: [PATCH] feat: 增量对账 reconcile——(size,mtime,sha256) 对账替代 commit 判据,index build 默认增量
---
internal/index/reconcile.go | 262 ++++++++++++++++++++
internal/vault/parser.go | 5
internal/vault/scanner.go | 37 ++
cmd/search.go | 12
internal/index/reconcile_test.go | 175 +++++++++++++
internal/graph/model.go | 30 +
cmd/rebuild.go | 23 +
internal/index/cache.go | 32 --
cmd/index.go | 41 ++-
internal/index/sqlite.go | 105 +++++++
10 files changed, 648 insertions(+), 74 deletions(-)
diff --git a/cmd/index.go b/cmd/index.go
index 63ecbe2..4678803 100644
--- a/cmd/index.go
+++ b/cmd/index.go
@@ -17,10 +17,13 @@
Short: "索引管理",
}
+// forceRebuild index build 的 --force flag:全量重建索引
+var forceRebuild bool
+
var indexBuildCmd = &cobra.Command{
Use: "build",
Short: "构建或重建知识库索引",
- Long: `kb-cli index build [--vault <路径>] [--db <路径>] # 构建或重建知识库索引`,
+ Long: `kb-cli index build [--vault <路径>] [--db <路径>] [--force] # 构建或重建知识库索引(默认增量对账,--force 全量重建)`,
RunE: runIndexBuild,
}
@@ -55,6 +58,7 @@
indexCmd.AddCommand(indexBuildCmd)
indexCmd.AddCommand(indexStatusCmd)
indexCmd.AddCommand(indexGcCmd)
+ indexBuildCmd.Flags().BoolVar(&forceRebuild, "force", false, "全量重建索引(默认走增量对账)")
rootCmd.AddCommand(gitCmd)
gitCmd.AddCommand(gitSyncCmd)
@@ -68,19 +72,26 @@
}
defer store.Close()
- // 获取当前 commit
- commit, err := index.GetGitCommit(vaultPath)
- if err != nil {
- fmt.Fprintln(os.Stderr, "警告: 无法获取 git commit:", err)
- commit = ""
+ if forceRebuild {
+ // 全量重建
+ commit, err := index.GetGitCommit(vaultPath)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "警告: 无法获取 git commit:", err)
+ commit = ""
+ }
+ fmt.Fprintln(os.Stderr, "正在全量重建索引...")
+ if err := rebuildFull(store, commit); err != nil {
+ return fmt.Errorf("重建索引失败: %w", err)
+ }
+ fmt.Fprintln(os.Stderr, "索引重建完成")
+ return nil
}
- fmt.Fprintln(os.Stderr, "正在重建索引...")
- if err := rebuildIndex(store, commit); err != nil {
- return fmt.Errorf("重建索引失败: %w", err)
+ // 默认走增量对账
+ if err := syncIndex(store); err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
}
-
- fmt.Fprintln(os.Stderr, "索引重建完成")
+ fmt.Fprintln(os.Stderr, "索引同步完成")
return nil
}
@@ -127,14 +138,14 @@
fmt.Printf(" 当前 commit: %s\n", currentCommit)
fmt.Printf(" 构建时间: %s\n", builtAt)
- // 检查是否需要更新
- needsRebuild, _, err := index.NeedsRebuild(store, vaultPath)
+ // 检查是否有变更(stat 快速比对)
+ dirty, err := index.QuickCheck(store, vaultPath)
if err != nil {
return fmt.Errorf("检查索引状态失败: %w", err)
}
- if needsRebuild {
- fmt.Printf(" 状态: 需要更新\n")
+ if dirty {
+ fmt.Printf(" 状态: 有变更\n")
} else {
fmt.Printf(" 状态: 最新\n")
}
diff --git a/cmd/rebuild.go b/cmd/rebuild.go
index dfff961..a93794f 100644
--- a/cmd/rebuild.go
+++ b/cmd/rebuild.go
@@ -10,8 +10,8 @@
"github.com/aisim/kb-cli/internal/vault"
)
-// rebuildIndex 重建索引:扫描 vault → 构建图 → 写入 SQLite
-func rebuildIndex(store *index.Store, commit string) error {
+// rebuildFull 全量重建索引:扫描 vault → 构建图 → 写入 SQLite
+func rebuildFull(store *index.Store, commit string) error {
// 清空旧数据
if err := store.ClearData(); err != nil {
return fmt.Errorf("清空数据失败: %w", err)
@@ -84,3 +84,22 @@
fmt.Fprintf(os.Stderr, "已索引 %d 个节点, %d 条边\n", len(g.Nodes), actualEdgeCount)
return nil
}
+
+// syncIndex 增量对账路径(index build 默认、search pre-flight 用)
+func syncIndex(store *index.Store) error {
+ res, err := index.Reconcile(store, vaultPath)
+ if err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
+ }
+ fmt.Fprintf(os.Stderr, "增量同步: 新增 %d / 修改 %d / 删除 %d / 未变 %d\n",
+ res.Added, res.Modified, res.Deleted, res.Unchanged)
+ if commit, err := index.GetGitCommit(vaultPath); err == nil {
+ if err := store.SetMeta("git_commit", commit); err != nil {
+ return fmt.Errorf("记录 git commit 失败: %w", err)
+ }
+ }
+ if err := store.SetMeta("built_at", time.Now().Format(time.RFC3339)); err != nil {
+ return fmt.Errorf("记录构建时间失败: %w", err)
+ }
+ return nil
+}
diff --git a/cmd/search.go b/cmd/search.go
index e7e31a1..7083750 100644
--- a/cmd/search.go
+++ b/cmd/search.go
@@ -50,16 +50,16 @@
}
defer store.Close()
- // 检查是否需要重建索引
- needsRebuild, commit, err := index.NeedsRebuild(store, vaultPath)
+ // pre-flight:快速检查索引是否有变更(只 stat 比对,不读内容)
+ dirty, err := index.QuickCheck(store, vaultPath)
if err != nil {
return fmt.Errorf("检查索引状态失败: %w", err)
}
- if needsRebuild {
- fmt.Fprintln(os.Stderr, "索引过期,正在重建...")
- if err := rebuildIndex(store, commit); err != nil {
- return fmt.Errorf("重建索引失败: %w", err)
+ if dirty {
+ fmt.Fprintln(os.Stderr, "索引有变更,正在增量同步...")
+ if err := syncIndex(store); err != nil {
+ return fmt.Errorf("增量同步失败: %w", err)
}
}
diff --git a/internal/graph/model.go b/internal/graph/model.go
index 0702912..485cc6e 100644
--- a/internal/graph/model.go
+++ b/internal/graph/model.go
@@ -2,22 +2,28 @@
// Node 图节点(对应一个 markdown 文件)
type Node struct {
- ID int64 `json:"id"`
- Path string `json:"path"`
- Title string `json:"title"`
- Section string `json:"section"`
- Tags []string `json:"tags"`
- Entities []string `json:"entities"`
- Wikilinks []string `json:"wikilinks"`
- Content string `json:"content"`
+ ID int64 `json:"id"`
+ Path string `json:"path"`
+ Title string `json:"title"`
+ Section string `json:"section"`
+ Tags []string `json:"tags"`
+ Entities []string `json:"entities"`
+ Aliases []string `json:"aliases"`
+ Status string `json:"status"`
+ Wikilinks []string `json:"wikilinks"`
+ Content string `json:"content"`
+ Size int64 `json:"size"`
+ Mtime int64 `json:"mtime"`
+ ContentHash string `json:"content_hash"`
}
// Edge 图边(实体关系)
type Edge struct {
- FromNode int64 `json:"from_node"`
- ToNode int64 `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID
- Relation string `json:"relation"` // "tag" | "entity" | "wikilink"
- Label string `json:"label"` // 具体值
+ FromNode int64 `json:"from_node"`
+ ToNode int64 `json:"to_node"` // 对于 tag/entity 边,ToNode 可以是虚拟节点 ID
+ Relation string `json:"relation"` // "tag" | "entity" | "wikilink"
+ Label string `json:"label"` // 具体值
+ Provenance string `json:"provenance"`
}
// Graph 知识图谱
diff --git a/internal/index/cache.go b/internal/index/cache.go
index b54860c..74b64c7 100644
--- a/internal/index/cache.go
+++ b/internal/index/cache.go
@@ -14,35 +14,3 @@
}
return strings.TrimSpace(string(out)), nil
}
-
-// NeedsRebuild 检查是否需要重建索引
-func NeedsRebuild(store *Store, vaultPath string) (bool, string, error) {
- currentCommit, err := GetGitCommit(vaultPath)
- if err != nil {
- // 不是 git 仓库,总是需要重建
- return true, "", nil
- }
-
- storedCommit, err := store.GetMeta("git_commit")
- if err != nil {
- return true, currentCommit, nil
- }
-
- if storedCommit == "" {
- // 没有记录,需要重建
- return true, currentCommit, nil
- }
-
- if storedCommit != currentCommit {
- // commit 变了,需要重建
- return true, currentCommit, nil
- }
-
- // 检查是否有节点
- count, _ := store.NodeCount()
- if count == 0 {
- return true, currentCommit, nil
- }
-
- return false, currentCommit, nil
-}
diff --git a/internal/index/reconcile.go b/internal/index/reconcile.go
new file mode 100644
index 0000000..8024eb3
--- /dev/null
+++ b/internal/index/reconcile.go
@@ -0,0 +1,262 @@
+package index
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/aisim/kb-cli/internal/graph"
+ "github.com/aisim/kb-cli/internal/vault"
+)
+
+// ReconcileResult 对账结果统计
+type ReconcileResult struct {
+ Added int
+ Modified int
+ Deleted int
+ Unchanged int
+}
+
+// Reconcile 增量对账:stat 比对 → 只对变更文件解析和写库
+func Reconcile(store *Store, vaultPath string) (*ReconcileResult, error) {
+ res := &ReconcileResult{}
+
+ // 1. vault 侧指纹
+ vaultStats, err := vault.ScanVaultStat(vaultPath)
+ if err != nil {
+ return nil, fmt.Errorf("扫描失败: %w", err)
+ }
+ dbStats, err := store.GetFileStats()
+ if err != nil {
+ return nil, fmt.Errorf("读取索引指纹失败: %w", err)
+ }
+
+ // 2. 分类
+ type change struct {
+ path string
+ stat vault.FileStat
+ known FileStat
+ }
+ var added, modified []change
+ for _, v := range vaultStats {
+ known, ok := dbStats[v.Path]
+ if !ok {
+ added = append(added, change{v.Path, v, FileStat{}})
+ continue
+ }
+ if v.Size == known.Size && v.Mtime == known.Mtime {
+ res.Unchanged++
+ continue
+ }
+ // size/mtime 变化 → sha256 二次确认
+ hash, err := fileHash(filepath.Join(vaultPath, v.Path))
+ if err != nil {
+ return nil, fmt.Errorf("哈希 %s 失败: %w", v.Path, err)
+ }
+ if known.ContentHash != "" && hash == known.ContentHash {
+ res.Unchanged++ // 内容没变(如 touch),只更新指纹
+ store.db.Exec(`UPDATE nodes SET size=?, mtime=? WHERE path=?`, v.Size, v.Mtime, v.Path)
+ continue
+ }
+ modified = append(modified, change{v.Path, v, known})
+ }
+ // 3. 删除
+ for path := range dbStats {
+ found := false
+ for _, v := range vaultStats {
+ if v.Path == path {
+ found = true
+ break
+ }
+ }
+ if !found {
+ if err := store.DeleteNode(path); err != nil {
+ return nil, err
+ }
+ res.Deleted++
+ }
+ }
+
+ // 4. 新增 + 修改:解析 → 写节点 → 重建该节点边
+ for _, c := range append(added, modified...) {
+ isNew := c.known.Path == ""
+ meta, err := vault.ParseFile(filepath.Join(vaultPath, c.path), c.path)
+ if err != nil {
+ return nil, fmt.Errorf("解析 %s 失败: %w", c.path, err)
+ }
+ hash, err := fileHash(filepath.Join(vaultPath, c.path))
+ if err != nil {
+ return nil, err
+ }
+ if isNew {
+ res.Added++
+ } else {
+ res.Modified++
+ }
+ if err := applyNode(store, vaultPath, meta, c.stat.Size, c.stat.Mtime, hash); err != nil {
+ return nil, err
+ }
+ }
+
+ // 5. 悬空链接重试(新增/修改节点可能让 failed 链接变可解析)
+ if res.Added+res.Modified > 0 {
+ store.RetryUnresolved() // 见 Task 4,此处先以空实现占位编译通过
+ }
+ return res, nil
+}
+
+func fileHash(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+// applyNode 写节点 + 重建该节点的出边(tag/entity/wikilink)+ 悬空入表
+func applyNode(store *Store, vaultPath string, meta *vault.FileMeta, size, mtime int64, hash string) error {
+ n := &graph.Node{
+ Path: meta.Path, Title: meta.Title, Section: meta.Section,
+ Tags: meta.Tags, Entities: meta.Entities, Wikilinks: meta.Wikilinks,
+ Aliases: meta.Aliases, Status: meta.Status, Content: meta.Content,
+ }
+ if err := store.UpsertNode(n, size, mtime, hash); err != nil {
+ return err
+ }
+ var nodeID int64
+ if err := store.db.QueryRow(`SELECT id FROM nodes WHERE path=?`, meta.Path).Scan(&nodeID); err != nil {
+ return err
+ }
+ // 删旧边后重建出边
+ if err := store.DeleteNodeEdges(nodeID); err != nil {
+ return err
+ }
+ return store.buildNodeEdges(nodeID, meta)
+}
+
+// buildNodeEdges 为单个节点建出边;wikilink 解析失败入 unresolved_links
+func (s *Store) buildNodeEdges(nodeID int64, meta *vault.FileMeta) error {
+ // tag / entity 边(虚拟节点 ID 沿用 1000000+ 规则,按 label 查现有行避免重复)
+ for _, tag := range meta.Tags {
+ if err := s.insertTagEntityEdge(nodeID, "tag:"+tag, "tag", tag); err != nil {
+ return err
+ }
+ }
+ for _, entity := range meta.Entities {
+ if err := s.insertTagEntityEdge(nodeID, "entity:"+entity, "entity", entity); err != nil {
+ return err
+ }
+ }
+ // wikilink 边 + 悬空
+ for _, link := range meta.Wikilinks {
+ targetID, prov, ok := s.resolveWikilink(link)
+ if !ok {
+ tail := nameTail(link)
+ s.db.Exec(`INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
+ VALUES (?, ?, ?, 'pending') ON CONFLICT DO NOTHING`, nodeID, link, tail)
+ continue
+ }
+ if err := s.InsertEdge(&graph.Edge{
+ FromNode: nodeID, ToNode: targetID,
+ Relation: "wikilink", Label: link, Provenance: prov,
+ }); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// insertTagEntityEdge tag/entity 边(虚拟节点按 label 复用 ID)
+func (s *Store) insertTagEntityEdge(fromNode int64, key, relation, label string) error {
+ var virtualID int64
+ err := s.db.QueryRow(`SELECT to_node FROM edges WHERE relation=? AND label=? LIMIT 1`, relation, label).Scan(&virtualID)
+ if err != nil {
+ // 新虚拟节点:分配 ID = 1000000 + 行号(稳定:按 label 排序后的行号)
+ var maxID int64
+ s.db.QueryRow(`SELECT COALESCE(MAX(to_node), 1000000) FROM edges WHERE to_node >= 1000000 AND relation=?`, relation).Scan(&maxID)
+ virtualID = maxID + 1
+ }
+ return s.InsertEdge(&graph.Edge{FromNode: fromNode, ToNode: virtualID, Relation: relation, Label: label})
+}
+
+// resolveWikilink 解析 wikilink 目标,返回 (nodeID, provenance, ok)
+// provenance: exact = 标题或文件名精确匹配;fuzzy = 标题包含匹配
+func (s *Store) resolveWikilink(link string) (int64, string, bool) {
+ // 去锚点:[[标题|别名]] 取标题部分
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ var id int64
+ var title, path string
+ // 1. 标题精确
+ err := s.db.QueryRow(`SELECT id, title, path FROM nodes WHERE title = ? LIMIT 1`, link).Scan(&id, &title, &path)
+ if err == nil {
+ return id, "exact", true
+ }
+ // 2. 文件名精确(去 .md 和编号前缀)
+ rows, err := s.db.Query(`SELECT id, title, path FROM nodes`)
+ if err != nil {
+ return 0, "", false
+ }
+ defer rows.Close()
+ var fuzzyID int64
+ for rows.Next() {
+ var nid int64
+ var nTitle, nPath string
+ if err := rows.Scan(&nid, &nTitle, &nPath); err != nil {
+ return 0, "", false
+ }
+ base := filepath.Base(nPath)
+ base = strings.TrimSuffix(base, ".md")
+ if dash := strings.Index(base, "-"); dash >= 0 {
+ base = base[dash+1:]
+ }
+ if base == link || nTitle == link {
+ return nid, "exact", true
+ }
+ if fuzzyID == 0 && strings.Contains(nTitle, link) {
+ fuzzyID = nid
+ }
+ }
+ if fuzzyID != 0 {
+ return fuzzyID, "fuzzy", true
+ }
+ return 0, "", false
+}
+
+// nameTail 取 link 尾部用于重试匹配(去锚点修饰)
+func nameTail(link string) string {
+ if idx := strings.Index(link, "|"); idx >= 0 {
+ link = link[:idx]
+ }
+ return link
+}
+
+// RetryUnresolved 重试解析悬空链接(Task 4 补全完整实现)
+func (s *Store) RetryUnresolved() {}
+
+// QuickCheck 只 stat 比对(不读内容不哈希),返回是否有差异
+func QuickCheck(store *Store, vaultPath string) (bool, error) {
+ vaultStats, err := vault.ScanVaultStat(vaultPath)
+ if err != nil {
+ return false, err
+ }
+ dbStats, err := store.GetFileStats()
+ if err != nil {
+ return false, err
+ }
+ if len(vaultStats) != len(dbStats) {
+ return true, nil
+ }
+ for _, v := range vaultStats {
+ known, ok := dbStats[v.Path]
+ if !ok || v.Size != known.Size || v.Mtime != known.Mtime {
+ return true, nil
+ }
+ }
+ return false, nil
+}
diff --git a/internal/index/reconcile_test.go b/internal/index/reconcile_test.go
new file mode 100644
index 0000000..677015f
--- /dev/null
+++ b/internal/index/reconcile_test.go
@@ -0,0 +1,175 @@
+package index
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestReconcileAdd(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ content := "---\ntitle: 测试文档\ntags: [t1]\nstatus: 已解决\n---\n正文\n"
+ if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"), []byte(content), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Added != 1 || res.Unchanged != 0 {
+ t.Errorf("新增场景: %+v", res)
+ }
+ var title, status string
+ if err := store.db.QueryRow(`SELECT title, status FROM nodes WHERE path='FAQ/001-测试文档.md'`).Scan(&title, &status); err != nil {
+ t.Fatal(err)
+ }
+ if title != "测试文档" || status != "已解决" {
+ t.Errorf("节点字段错误: %s / %s", title, status)
+ }
+}
+
+func TestReconcileModify(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(dir, "FAQ", "001-测试文档.md")
+ if err := os.WriteFile(p, []byte("---\ntitle: 旧标题\n---\n旧内容\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := Reconcile(store, dir); err != nil {
+ t.Fatal(err)
+ }
+
+ // 修改内容(title 变新标题,内容加"补气")
+ time.Sleep(1100 * time.Millisecond) // mtime 秒级精度,确保 mtime 变化
+ if err := os.WriteFile(p, []byte("---\ntitle: 新标题\n---\n新内容补气\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Modified != 1 || res.Added != 0 {
+ t.Errorf("修改场景: %+v", res)
+ }
+ var title string
+ if err := store.db.QueryRow(`SELECT title FROM nodes WHERE path='FAQ/001-测试文档.md'`).Scan(&title); err != nil {
+ t.Fatal(err)
+ }
+ if title != "新标题" {
+ t.Errorf("标题未更新: %s", title)
+ }
+}
+
+func TestReconcileDelete(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(dir, "FAQ", "001-测试文档.md")
+ if err := os.WriteFile(p, []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := Reconcile(store, dir); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(p); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Deleted != 1 {
+ t.Errorf("删除场景: %+v", res)
+ }
+ var n int
+ if err := store.db.QueryRow(`SELECT COUNT(*) FROM nodes`).Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ if n != 0 {
+ t.Errorf("节点未删净: %d", n)
+ }
+}
+
+func TestReconcileUnchanged(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"),
+ []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := Reconcile(store, dir); err != nil {
+ t.Fatal(err)
+ }
+
+ res, err := Reconcile(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Unchanged != 1 || res.Added != 0 || res.Modified != 0 {
+ t.Errorf("未变场景: %+v", res)
+ }
+}
+
+func TestQuickCheck(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "FAQ"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "FAQ", "001-测试文档.md"),
+ []byte("---\ntitle: 测试文档\n---\n内容\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ store, err := Open(filepath.Join(t.TempDir(), "kb.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer store.Close()
+ if _, err := Reconcile(store, dir); err != nil {
+ t.Fatal(err)
+ }
+
+ dirty, err := QuickCheck(store, dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dirty {
+ t.Error("无差异时 QuickCheck 应 false")
+ }
+}
diff --git a/internal/index/sqlite.go b/internal/index/sqlite.go
index a70b7d1..06a02b3 100644
--- a/internal/index/sqlite.go
+++ b/internal/index/sqlite.go
@@ -97,17 +97,20 @@
return err
}
-// InsertNode 插入节点
+// InsertNode 插入节点(全量重建路径用)
func (s *Store) InsertNode(n *graph.Node) (int64, error) {
tagsJSON, _ := json.Marshal(n.Tags)
entitiesJSON, _ := json.Marshal(n.Entities)
wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+ aliasesJSON, _ := json.Marshal(n.Aliases)
result, err := s.db.Exec(`
- INSERT INTO nodes (path, title, section, tags, entities, wikilinks, content_fts)
- VALUES (?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+ content_fts, size, mtime, content_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
- string(wikilinksJSON), n.Content)
+ string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content,
+ n.Size, n.Mtime, n.ContentHash)
if err != nil {
return 0, err
}
@@ -117,9 +120,97 @@
// InsertEdge 插入边
func (s *Store) InsertEdge(e *graph.Edge) error {
_, err := s.db.Exec(`
- INSERT OR IGNORE INTO edges (from_node, to_node, relation, label)
- VALUES (?, ?, ?, ?)
- `, e.FromNode, e.ToNode, e.Relation, e.Label)
+ INSERT OR IGNORE INTO edges (from_node, to_node, relation, label, provenance)
+ VALUES (?, ?, ?, ?, ?)
+ `, e.FromNode, e.ToNode, e.Relation, e.Label, e.Provenance)
+ return err
+}
+
+// FileStat 索引中的文件指纹
+type FileStat struct {
+ Path string
+ Size int64
+ Mtime int64
+ ContentHash string
+}
+
+// GetFileStats 返回所有已索引文件的指纹
+func (s *Store) GetFileStats() (map[string]FileStat, error) {
+ rows, err := s.db.Query(`SELECT path, size, mtime, content_hash FROM nodes`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ m := make(map[string]FileStat)
+ for rows.Next() {
+ var st FileStat
+ var hash sql.NullString
+ if err := rows.Scan(&st.Path, &st.Size, &st.Mtime, &hash); err != nil {
+ return nil, err
+ }
+ st.ContentHash = hash.String
+ m[st.Path] = st
+ }
+ return m, rows.Err()
+}
+
+// UpsertNode 按 path 插入或更新节点(触发器自动维护 FTS)
+func (s *Store) UpsertNode(n *graph.Node, size, mtime int64, contentHash string) error {
+ tagsJSON, _ := json.Marshal(n.Tags)
+ entitiesJSON, _ := json.Marshal(n.Entities)
+ wikilinksJSON, _ := json.Marshal(n.Wikilinks)
+ aliasesJSON, _ := json.Marshal(n.Aliases)
+ _, err := s.db.Exec(`
+ INSERT INTO nodes (path, title, section, tags, entities, wikilinks, aliases, status,
+ content_fts, size, mtime, content_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(path) DO UPDATE SET
+ title = excluded.title,
+ section = excluded.section,
+ tags = excluded.tags,
+ entities = excluded.entities,
+ wikilinks = excluded.wikilinks,
+ aliases = excluded.aliases,
+ status = excluded.status,
+ content_fts = excluded.content_fts,
+ size = excluded.size,
+ mtime = excluded.mtime,
+ content_hash = excluded.content_hash,
+ updated_at = datetime('now')`,
+ n.Path, n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
+ string(wikilinksJSON), string(aliasesJSON), n.Status, n.Content, size, mtime, contentHash)
+ return err
+}
+
+// DeleteNode 按 path 删节点(级联删边),同时清该节点相关的 unresolved_links
+func (s *Store) DeleteNode(path string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ var id int64
+ if err := tx.QueryRow(`SELECT id FROM nodes WHERE path = ?`, path).Scan(&id); err != nil {
+ tx.Rollback()
+ return nil // 不存在,视为成功
+ }
+ if _, err := tx.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, id, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM unresolved_links WHERE from_node = ?`, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if _, err := tx.Exec(`DELETE FROM nodes WHERE id = ?`, id); err != nil {
+ tx.Rollback()
+ return err
+ }
+ return tx.Commit()
+}
+
+// DeleteNodeEdges 只删某节点的边(保留节点行,用于"修改"场景重建边)
+func (s *Store) DeleteNodeEdges(nodeID int64) error {
+ _, err := s.db.Exec(`DELETE FROM edges WHERE from_node = ? OR to_node = ?`, nodeID, nodeID)
return err
}
diff --git a/internal/vault/parser.go b/internal/vault/parser.go
index 6764af4..f4cbb89 100644
--- a/internal/vault/parser.go
+++ b/internal/vault/parser.go
@@ -16,6 +16,8 @@
Section string // 所属板块
Tags []string // frontmatter tags
Entities []string // frontmatter entities
+ Aliases []string // frontmatter aliases
+ Status string // frontmatter status
Wikilinks []string // 正文中的 [[xxx]] 链接
Content string // 纯文本内容(去 frontmatter)
}
@@ -26,6 +28,7 @@
Tags []string `yaml:"tags"`
Entities []string `yaml:"entities"`
Aliases []string `yaml:"aliases"`
+ Status string `yaml:"status"`
}
var wikilinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`)
@@ -75,6 +78,8 @@
meta.Title = fm.Title
meta.Tags = fm.Tags
meta.Entities = fm.Entities
+ meta.Aliases = fm.Aliases
+ meta.Status = fm.Status
}
}
diff --git a/internal/vault/scanner.go b/internal/vault/scanner.go
index 41b37b8..7981aaf 100644
--- a/internal/vault/scanner.go
+++ b/internal/vault/scanner.go
@@ -40,3 +40,40 @@
return files, err
}
+
+// FileStat 文件指纹(只 stat,不读内容)
+type FileStat struct {
+ Path string // 相对路径
+ Size int64
+ Mtime int64 // Unix 秒
+}
+
+// ScanVaultStat 扫描 .md 文件指纹清单(跳过隐藏目录和待审阅目录,与 ScanVault 一致)
+func ScanVaultStat(vaultPath string) ([]FileStat, error) {
+ var stats []FileStat
+ 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
+ }
+ if info.Name() == "待审阅" {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if !strings.HasSuffix(path, ".md") {
+ return nil
+ }
+ relPath, _ := filepath.Rel(vaultPath, path)
+ stats = append(stats, FileStat{
+ Path: relPath,
+ Size: info.Size(),
+ Mtime: info.ModTime().Unix(),
+ })
+ return nil
+ })
+ return stats, err
+}
--
Gitblit v1.10.0