package sqlite import ( "context" "chat-switchboard/models" ) type NoteLinkStore struct{} func NewNoteLinkStore() *NoteLinkStore { return &NoteLinkStore{} } func (s *NoteLinkStore) ReplaceLinks(ctx context.Context, sourceNoteID string, links []models.NoteLink) error { tx, err := DB.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // Delete existing links for this source note if _, err := tx.ExecContext(ctx, "DELETE FROM note_links WHERE source_note_id = ?", sourceNoteID); err != nil { return err } // Insert new links if len(links) > 0 { stmt, err := tx.PrepareContext(ctx, ` INSERT OR IGNORE INTO note_links (source_note_id, target_note_id, target_title, display_text, is_transclusion) VALUES (?, ?, ?, ?, ?)`) if err != nil { return err } defer stmt.Close() for _, l := range links { var targetID interface{} if l.TargetNoteID != nil { targetID = *l.TargetNoteID } isTransclusion := 0 if l.IsTransclusion { isTransclusion = 1 } if _, err := stmt.ExecContext(ctx, sourceNoteID, targetID, l.TargetTitle, l.DisplayText, isTransclusion, ); err != nil { return err } } } return tx.Commit() } func (s *NoteLinkStore) ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error { _, err := DB.ExecContext(ctx, ` UPDATE note_links SET target_note_id = ? WHERE target_note_id IS NULL AND LOWER(target_title) = LOWER(?) AND source_note_id IN (SELECT id FROM notes WHERE user_id = ?)`, targetNoteID, title, userID) return err } func (s *NoteLinkStore) Backlinks(ctx context.Context, noteID string) ([]models.NoteLinkResult, error) { rows, err := DB.QueryContext(ctx, ` SELECT n.id, n.title, n.folder_path, n.updated_at, COALESCE(nl.display_text, '') FROM note_links nl JOIN notes n ON n.id = nl.source_note_id WHERE nl.target_note_id = ? ORDER BY n.updated_at DESC`, noteID) if err != nil { return nil, err } defer rows.Close() var results []models.NoteLinkResult for rows.Next() { var r models.NoteLinkResult if err := rows.Scan(&r.SourceNoteID, &r.Title, &r.FolderPath, st(&r.UpdatedAt), &r.DisplayText); err != nil { return nil, err } results = append(results, r) } return results, rows.Err() } func (s *NoteLinkStore) Graph(ctx context.Context, userID string) (*models.NoteGraph, error) { graph := &models.NoteGraph{ Nodes: make([]models.NoteGraphNode, 0), Edges: make([]models.NoteGraphEdge, 0), Unresolved: make([]models.NoteGraphDangling, 0), } // Nodes: all user's notes with link counts nodeRows, err := DB.QueryContext(ctx, ` SELECT n.id, n.title, n.folder_path, n.tags, n.updated_at, COALESCE(outbound.cnt, 0) + COALESCE(inbound.cnt, 0) AS link_count FROM notes n LEFT JOIN ( SELECT source_note_id, COUNT(*) AS cnt FROM note_links WHERE target_note_id IS NOT NULL GROUP BY source_note_id ) outbound ON outbound.source_note_id = n.id LEFT JOIN ( SELECT target_note_id, COUNT(*) AS cnt FROM note_links WHERE target_note_id IS NOT NULL GROUP BY target_note_id ) inbound ON inbound.target_note_id = n.id WHERE n.user_id = ? ORDER BY n.updated_at DESC`, userID) if err != nil { return nil, err } defer nodeRows.Close() for nodeRows.Next() { var node models.NoteGraphNode var tagsJSON string if err := nodeRows.Scan(&node.ID, &node.Title, &node.FolderPath, &tagsJSON, &node.UpdatedAt, &node.LinkCount); err != nil { return nil, err } node.Tags = ScanArray(tagsJSON) if node.Tags == nil { node.Tags = []string{} } graph.Nodes = append(graph.Nodes, node) } if err := nodeRows.Err(); err != nil { return nil, err } // Resolved edges edgeRows, err := DB.QueryContext(ctx, ` SELECT nl.source_note_id, nl.target_note_id, nl.target_title, nl.is_transclusion FROM note_links nl JOIN notes n ON n.id = nl.source_note_id WHERE n.user_id = ? AND nl.target_note_id IS NOT NULL`, userID) if err != nil { return nil, err } defer edgeRows.Close() for edgeRows.Next() { var edge models.NoteGraphEdge var isTransclusion int if err := edgeRows.Scan(&edge.Source, &edge.Target, &edge.Title, &isTransclusion); err != nil { return nil, err } edge.IsTransclusion = isTransclusion != 0 graph.Edges = append(graph.Edges, edge) } if err := edgeRows.Err(); err != nil { return nil, err } // Unresolved links (dangling) danglingRows, err := DB.QueryContext(ctx, ` SELECT nl.source_note_id, nl.target_title FROM note_links nl JOIN notes n ON n.id = nl.source_note_id WHERE n.user_id = ? AND nl.target_note_id IS NULL`, userID) if err != nil { return nil, err } defer danglingRows.Close() for danglingRows.Next() { var d models.NoteGraphDangling if err := danglingRows.Scan(&d.Source, &d.Title); err != nil { return nil, err } graph.Unresolved = append(graph.Unresolved, d) } return graph, danglingRows.Err() }