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 }