internal/index/sqlite.go
@@ -93,21 +93,24 @@
// ClearData 清空数据(重建前调用)
func (s *Store) ClearData() error {
   _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes;")
   _, err := s.db.Exec("DELETE FROM edges; DELETE FROM nodes; DELETE FROM unresolved_links;")
   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
   }
@@ -117,9 +120,108 @@
// 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
}
// InsertUnresolved 悬空链接入表(幂等:同 from_node+link_text 不重复插)
func (s *Store) InsertUnresolved(fromNode int64, linkText, nameTail string) error {
   _, err := 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 = ?)`,
      fromNode, linkText, nameTail, fromNode, linkText)
   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
}
@@ -185,6 +287,24 @@
   return links, nil
}
// GetUnresolvedLinks 某文档的悬空链接文本列表
func (s *Store) GetUnresolvedLinks(path string) ([]string, error) {
   rows, err := s.db.Query(`
      SELECT u.link_text FROM unresolved_links u
      JOIN nodes n ON n.id = u.from_node WHERE n.path = ?`, path)
   if err != nil {
      return nil, err
   }
   defer rows.Close()
   var links []string
   for rows.Next() {
      var l string
      rows.Scan(&l)
      links = append(links, l)
   }
   return links, rows.Err()
}
// NodeInfo 节点基本信息(用于 GC)
type NodeInfo struct {
   ID   int64
@@ -362,7 +482,7 @@
         visited[id] = true
         query := `
            SELECT id, from_node, to_node, relation, label
            SELECT id, from_node, to_node, relation, label, provenance
            FROM edges
            WHERE from_node = ?
         `
@@ -381,7 +501,7 @@
         for rows.Next() {
            var e graph.Edge
            var edgeID int64
            if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label); err != nil {
            if err := rows.Scan(&edgeID, &e.FromNode, &e.ToNode, &e.Relation, &e.Label, &e.Provenance); err != nil {
               rows.Close()
               return nil, fmt.Errorf("扫描边失败: %w", err)
            }