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