internal/index/sqlite.go
@@ -15,7 +15,8 @@
// Store SQLite 存储层
type Store struct {
   db *sql.DB
   db     *sql.DB
   dbPath string
}
// Open 打开或创建数据库
@@ -31,11 +32,15 @@
      return nil, fmt.Errorf("打开数据库失败: %w", err)
   }
   s := &Store{db: db}
   s := &Store{db: db, dbPath: dbPath}
   if err := s.initTables(); err != nil {
      db.Close()
      return nil, err
   }
   if err := s.migrate(); err != nil {
      db.Close()
      return nil, err
   }
   return s, nil
}
@@ -55,7 +60,6 @@
   CREATE TABLE IF NOT EXISTS nodes (
      id          INTEGER PRIMARY KEY AUTOINCREMENT,
      path        TEXT NOT NULL UNIQUE,
      node_type   TEXT NOT NULL DEFAULT 'file',
      title       TEXT,
      section     TEXT,
      tags        TEXT,
@@ -84,55 +88,140 @@
   if err != nil {
      return fmt.Errorf("创建表失败: %w", err)
   }
   // 旧库迁移:补 node_type 列(幂等)
   var colCount int
   if err := s.db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('nodes') WHERE name = 'node_type'").Scan(&colCount); err == nil && colCount == 0 {
      if _, err := s.db.Exec("ALTER TABLE nodes ADD COLUMN node_type TEXT NOT NULL DEFAULT 'file'"); err != nil {
         return fmt.Errorf("迁移 node_type 列失败: %w", err)
      }
   }
   return nil
}
// 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, node_type, title, section, tags, entities, wikilinks, content_fts)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
   `, n.Path, nodeTypeOf(n), n.Title, n.Section, string(tagsJSON), string(entitiesJSON),
      string(wikilinksJSON), n.Content)
      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), string(aliasesJSON), n.Status, n.Content,
      n.Size, n.Mtime, n.ContentHash)
   if err != nil {
      return 0, err
   }
   return result.LastInsertId()
}
// nodeTypeOf 节点类型(空值归一为 file)
func nodeTypeOf(n *graph.Node) string {
   if n.NodeType == "" {
      return "file"
   }
   return n.NodeType
}
// 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
}
@@ -198,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
@@ -206,7 +313,7 @@
// GetAllNodes 获取所有节点(用于 GC 检查)
func (s *Store) GetAllNodes() ([]NodeInfo, error) {
   rows, err := s.db.Query("SELECT id, path FROM nodes WHERE node_type = 'file'")
   rows, err := s.db.Query("SELECT id, path FROM nodes")
   if err != nil {
      return nil, fmt.Errorf("查询节点失败: %w", err)
   }
@@ -314,8 +421,7 @@
   query := `
      SELECT id, path, title, section, tags, entities, wikilinks, content_fts
      FROM nodes
      WHERE (title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?)
      AND node_type = 'file'
      WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
      LIMIT 20
   `
   rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
@@ -376,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 = ?
         `
@@ -395,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)
            }
@@ -418,8 +524,7 @@
   query := `
      SELECT id, path, title, section, tags, entities
      FROM nodes
      WHERE (title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?)
      AND node_type = 'file'
      WHERE title LIKE ? OR path LIKE ? OR tags LIKE ? OR entities LIKE ?
      LIMIT 5
   `
   rows, err := s.db.Query(query, keyword, keyword, keyword, keyword)
@@ -470,7 +575,7 @@
      rows, err := s.db.Query(`
         SELECT id, path, title, section, tags
         FROM nodes
         WHERE tags LIKE ? AND node_type = 'file'
         WHERE tags LIKE ?
      `, tagPattern)
      if err != nil {
         continue
@@ -500,7 +605,7 @@
      rows, err := s.db.Query(`
         SELECT id, path, title, section, tags
         FROM nodes
         WHERE entities LIKE ? AND node_type = 'file'
         WHERE entities LIKE ?
      `, entityPattern)
      if err != nil {
         continue