feat: 增量对账 reconcile——(size,mtime,sha256) 对账替代 commit 判据,index build 默认增量
2 files added
8 files modified
722 ■■■■ changed files
cmd/index.go 41 ●●●●● patch | view | raw | blame | history
cmd/rebuild.go 23 ●●●●● patch | view | raw | blame | history
cmd/search.go 12 ●●●● patch | view | raw | blame | history
internal/graph/model.go 30 ●●●●● patch | view | raw | blame | history
internal/index/cache.go 32 ●●●●● patch | view | raw | blame | history
internal/index/reconcile.go 262 ●●●●● patch | view | raw | blame | history
internal/index/reconcile_test.go 175 ●●●●● patch | view | raw | blame | history
internal/index/sqlite.go 105 ●●●●● patch | view | raw | blame | history
internal/vault/parser.go 5 ●●●●● patch | view | raw | blame | history
internal/vault/scanner.go 37 ●●●●● patch | view | raw | blame | history
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")
    }
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
}
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)
        }
    }
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 知识图谱
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
}
internal/index/reconcile.go
New file
@@ -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
}
internal/index/reconcile_test.go
New file
@@ -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")
    }
}
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
}
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
        }
    }
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
}