feat: 增量对账 reconcile——(size,mtime,sha256) 对账替代 commit 判据,index build 默认增量
2 files added
8 files modified
| | |
| | | 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, |
| | | } |
| | | |
| | |
| | | indexCmd.AddCommand(indexBuildCmd) |
| | | indexCmd.AddCommand(indexStatusCmd) |
| | | indexCmd.AddCommand(indexGcCmd) |
| | | indexBuildCmd.Flags().BoolVar(&forceRebuild, "force", false, "全量重建索引(默认走增量对账)") |
| | | |
| | | rootCmd.AddCommand(gitCmd) |
| | | gitCmd.AddCommand(gitSyncCmd) |
| | |
| | | } |
| | | defer store.Close() |
| | | |
| | | // 获取当前 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 := rebuildIndex(store, commit); err != nil { |
| | | fmt.Fprintln(os.Stderr, "正在全量重建索引...") |
| | | if err := rebuildFull(store, commit); err != nil { |
| | | return fmt.Errorf("重建索引失败: %w", err) |
| | | } |
| | | |
| | | fmt.Fprintln(os.Stderr, "索引重建完成") |
| | | return nil |
| | | } |
| | | |
| | | // 默认走增量对账 |
| | | if err := syncIndex(store); err != nil { |
| | | return fmt.Errorf("增量同步失败: %w", err) |
| | | } |
| | | fmt.Fprintln(os.Stderr, "索引同步完成") |
| | | return nil |
| | | } |
| | | |
| | | func runIndexStatus(cmd *cobra.Command, args []string) error { |
| | | // 打开索引 |
| | | store, err := index.Open(dbPath) |
| | |
| | | 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") |
| | | } |
| | |
| | | "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) |
| | |
| | | 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 |
| | | } |
| | |
| | | } |
| | | 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) |
| | | } |
| | | } |
| | | |
| | |
| | | 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 图边(实体关系) |
| | |
| | | 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 知识图谱 |
| | |
| | | } |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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 |
| | | } |
| New file |
| | |
| | | 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") |
| | | } |
| | | } |
| | |
| | | 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 |
| | | } |
| | |
| | | // 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 |
| | | } |
| | | |
| | |
| | | Section string // 所属板块 |
| | | Tags []string // frontmatter tags |
| | | Entities []string // frontmatter entities |
| | | Aliases []string // frontmatter aliases |
| | | Status string // frontmatter status |
| | | Wikilinks []string // 正文中的 [[xxx]] 链接 |
| | | Content string // 纯文本内容(去 frontmatter) |
| | | } |
| | |
| | | Tags []string `yaml:"tags"` |
| | | Entities []string `yaml:"entities"` |
| | | Aliases []string `yaml:"aliases"` |
| | | Status string `yaml:"status"` |
| | | } |
| | | |
| | | var wikilinkRe = regexp.MustCompile(`\[\[([^\]]+)\]\]`) |
| | |
| | | meta.Title = fm.Title |
| | | meta.Tags = fm.Tags |
| | | meta.Entities = fm.Entities |
| | | meta.Aliases = fm.Aliases |
| | | meta.Status = fm.Status |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | 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 |
| | | } |