| 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 |
| | | 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 |
| | | } |