| | |
| | | |
| | | // Store SQLite 存储层 |
| | | type Store struct { |
| | | db *sql.DB |
| | | db *sql.DB |
| | | dbPath string |
| | | } |
| | | |
| | | // Open 打开或创建数据库 |
| | |
| | | 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 |
| | | } |
| | | |
| | |
| | | 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, |
| | |
| | | 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 |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | |
| | | // 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) |
| | | } |
| | |
| | | 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) |
| | |
| | | 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 = ? |
| | | ` |
| | |
| | | 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) |
| | | } |
| | |
| | | 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) |
| | |
| | | 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 |
| | |
| | | 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 |