internal/index/reconcile.go
New file
@@ -0,0 +1,372 @@
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
   Resolved  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. 悬空链接重试:新节点入库后,历史悬空链接可能变可解析
   resolved, err := store.RetryUnresolved()
   if err != nil {
      return nil, fmt.Errorf("悬空链接重试失败: %w", err)
   }
   res.Resolved = resolved
   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)
         // 幂等守卫:同 from_node+link_text 不重复插(unresolved_links 无 UNIQUE 约束,用 WHERE NOT EXISTS)
         s.db.Exec(`INSERT INTO unresolved_links (from_node, link_text, name_tail, status)
            SELECT ?, ?, ?, 'pending'
            WHERE NOT EXISTS (
               SELECT 1 FROM unresolved_links WHERE from_node = ? AND link_text = ?)`,
            nodeID, link, tail, nodeID, link)
         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, Provenance: relation})
}
// 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
      }
      // 空 link 跳过 fuzzy(Contains 对空串恒真会误建边)
      if fuzzyID == 0 && link != "" && 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 重试解析悬空链接:用当前全部节点的标题/文件名去匹配 unresolved_links 的 name_tail。
// 命中则建边(provenance 按匹配严格度)、删行。返回成功解析条数。
func (s *Store) RetryUnresolved() (int, error) {
   rows, err := s.db.Query(`SELECT id, from_node, link_text, name_tail FROM unresolved_links`)
   if err != nil {
      return 0, err
   }
   type pending struct {
      id       int64
      fromNode int64
      linkText string
      tail     string
   }
   var pendings []pending
   for rows.Next() {
      var p pending
      if err := rows.Scan(&p.id, &p.fromNode, &p.linkText, &p.tail); err != nil {
         rows.Close()
         return 0, err
      }
      pendings = append(pendings, p)
   }
   rows.Close()
   if len(pendings) == 0 {
      return 0, nil
   }
   // 建匹配索引:标题/文件名(去编号) → nodeID,精确匹配优先
   type matchInfo struct {
      id   int64
      prov string
   }
   exactMap := make(map[string]matchInfo)
   var fuzzyRows []struct {
      id    int64
      title string
   }
   nrows, err := s.db.Query(`SELECT id, title, path FROM nodes`)
   if err != nil {
      return 0, err
   }
   for nrows.Next() {
      var id int64
      var title, path string
      if err := nrows.Scan(&id, &title, &path); err != nil {
         nrows.Close()
         return 0, err
      }
      if _, ok := exactMap[title]; !ok {
         exactMap[title] = matchInfo{id, "exact"}
      }
      base := filepath.Base(path)
      base = strings.TrimSuffix(base, ".md")
      if dash := strings.Index(base, "-"); dash >= 0 {
         base = base[dash+1:]
      }
      if _, ok := exactMap[base]; !ok {
         exactMap[base] = matchInfo{id, "exact"}
      }
      fuzzyRows = append(fuzzyRows, struct {
         id    int64
         title string
      }{id, title})
   }
   nrows.Close()
   resolved := 0
   for _, p := range pendings {
      if info, ok := exactMap[p.tail]; ok {
         // 精确命中:建边 + 删悬空行
         if err := s.InsertEdge(&graph.Edge{
            FromNode: p.fromNode, ToNode: info.id,
            Relation: "wikilink", Label: p.linkText, Provenance: info.prov,
         }); err != nil {
            return resolved, err
         }
         if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil {
            return resolved, err
         }
         resolved++
         continue
      }
      // fuzzy:标题包含(空 tail 跳过,避免 Contains 恒真)
      if p.tail == "" {
         continue
      }
      for _, fr := range fuzzyRows {
         if strings.Contains(fr.title, p.tail) {
            if err := s.InsertEdge(&graph.Edge{
               FromNode: p.fromNode, ToNode: fr.id,
               Relation: "wikilink", Label: p.linkText, Provenance: "fuzzy",
            }); err != nil {
               return resolved, err
            }
            if _, err := s.db.Exec(`DELETE FROM unresolved_links WHERE id=?`, p.id); err != nil {
               return resolved, err
            }
            resolved++
            break
         }
      }
   }
   return resolved, nil
}
// 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
}