package sqlite import ( "context" "database/sql" "encoding/json" "fmt" "time" "strings" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" ) type NoteStore struct{} func NewNoteStore() *NoteStore { return &NoteStore{} } func (s *NoteStore) Create(ctx context.Context, n *models.Note) error { n.ID = store.NewID() now := time.Now().UTC() n.CreatedAt = now n.UpdatedAt = now _, err := DB.ExecContext(ctx, ` INSERT INTO notes (id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, n.ID, n.UserID, n.Title, n.Content, n.FolderPath, ArrayToJSON(n.Tags), ToJSON(n.Metadata), models.NullString(n.SourceChannelID), models.NullString(n.SourceMessageID), models.NullString(n.TeamID), now.Format(timeFmt), now.Format(timeFmt), ) return err } func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) { var n models.Note var sourceChannelID, sourceMessageID, teamID sql.NullString var tagsJSON, metadataJSON string err := DB.QueryRowContext(ctx, ` SELECT id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at FROM notes WHERE id = ?`, id).Scan( &n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, &tagsJSON, &metadataJSON, &sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt), ) if err != nil { return nil, err } n.Tags = ScanArray(tagsJSON) n.SourceChannelID = NullableStringPtr(sourceChannelID) n.SourceMessageID = NullableStringPtr(sourceMessageID) n.TeamID = NullableStringPtr(teamID) json.Unmarshal([]byte(metadataJSON), &n.Metadata) return &n, nil } func (s *NoteStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { b := NewUpdate("notes") for k, v := range fields { if k == "metadata" { b.SetJSON(k, v) } else if k == "tags" { if tags, ok := v.([]string); ok { b.Set(k, ArrayToJSON(tags)) } } else { b.Set(k, v) } } if !b.HasSets() { return nil } b.Where("id", id) _, err := b.Exec(DB) return err } func (s *NoteStore) Delete(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "DELETE FROM notes WHERE id = ?", id) return err } func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) { b := NewSelect( "id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at", "notes", ).Where("user_id = ?", userID) if opts.FolderPath != "" { b.Where("folder_path = ?", opts.FolderPath) } if opts.Tag != "" { // SQLite: check JSON array membership with json_each. b.Where("EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)", opts.Tag) } if opts.TeamID != "" { b.Where("team_id = ?", opts.TeamID) } // Count countQ, countArgs := b.CountBuild() var total int DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) if opts.Sort == "" { b.OrderBy("updated_at", "DESC") } b.Paginate(opts.ListOptions) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() return s.scanNotes(rows, total) } // Search uses LIKE against title and content (SQLite fallback for tsvector). func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) { // Split query into words, build LIKE clauses for each. words := strings.Fields(query) if len(words) == 0 { return nil, 0, nil } b := NewSelect( "id, user_id, title, content, folder_path, tags, metadata, source_channel_id, source_message_id, team_id, created_at, updated_at", "notes", ).Where("user_id = ?", userID) for _, w := range words { pattern := "%" + w + "%" b.Where("(title LIKE ? OR content LIKE ?)", pattern, pattern) } // Count countQ, countArgs := b.CountBuild() var total int DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total) b.OrderBy("updated_at", "DESC") b.Paginate(opts) q, args := b.Build() rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, 0, err } defer rows.Close() return s.scanNotes(rows, total) } func (s *NoteStore) SearchTitles(ctx context.Context, userID, query string, limit int) ([]models.Note, error) { if limit <= 0 || limit > 50 { limit = 10 } rows, err := DB.QueryContext(ctx, ` SELECT id, title, folder_path FROM notes WHERE user_id = ? AND title LIKE '%' || ? || '%' COLLATE NOCASE ORDER BY updated_at DESC LIMIT ?`, userID, query, limit) if err != nil { return nil, err } defer rows.Close() var results []models.Note for rows.Next() { var n models.Note if err := rows.Scan(&n.ID, &n.Title, &n.FolderPath); err != nil { return nil, err } results = append(results, n) } return results, rows.Err() } func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) { if len(ids) == 0 { return 0, nil } placeholders := make([]string, len(ids)) args := make([]interface{}, 0, len(ids)+1) args = append(args, userID) for i, id := range ids { placeholders[i] = "?" args = append(args, id) } result, err := DB.ExecContext(ctx, fmt.Sprintf("DELETE FROM notes WHERE user_id = ? AND id IN (%s)", strings.Join(placeholders, ",")), args...) if err != nil { return 0, err } n, _ := result.RowsAffected() return int(n), nil } // ── helpers ───────────────────────────────── func (s *NoteStore) scanNotes(rows *sql.Rows, total int) ([]models.Note, int, error) { var result []models.Note for rows.Next() { var n models.Note var sourceChannelID, sourceMessageID, teamID sql.NullString var tagsJSON, metadataJSON string err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath, &tagsJSON, &metadataJSON, &sourceChannelID, &sourceMessageID, &teamID, st(&n.CreatedAt), st(&n.UpdatedAt)) if err != nil { return nil, 0, err } n.Tags = ScanArray(tagsJSON) n.SourceChannelID = NullableStringPtr(sourceChannelID) n.SourceMessageID = NullableStringPtr(sourceMessageID) n.TeamID = NullableStringPtr(teamID) json.Unmarshal([]byte(metadataJSON), &n.Metadata) result = append(result, n) } return result, total, rows.Err() } // ── CS5c additions (v0.29.0) ────────────────────────────────────────── // SetEmbedding is a no-op on SQLite (pgvector is PG-only). func (s *NoteStore) SetEmbedding(ctx context.Context, noteID, vecStr string) error { return nil } // SearchKeyword performs LIKE-based keyword search on SQLite. func (s *NoteStore) SearchKeyword(ctx context.Context, userID, query string, limit int) ([]store.NoteSearchResult, error) { words := strings.Fields(query) if len(words) == 0 { return []store.NoteSearchResult{}, nil } q := `SELECT id, title, folder_path, tags, SUBSTR(content, 1, 500) FROM notes WHERE user_id = ?` args := []interface{}{userID} for _, w := range words { pattern := "%" + w + "%" q += " AND (title LIKE ? OR content LIKE ?)" args = append(args, pattern, pattern) } q += " ORDER BY updated_at DESC LIMIT ?" args = append(args, limit) rows, err := DB.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() results := make([]store.NoteSearchResult, 0) for rows.Next() { var r store.NoteSearchResult var tagsJSON string if err := rows.Scan(&r.ID, &r.Title, &r.FolderPath, &tagsJSON, &r.Excerpt); err != nil { continue } r.Tags = ScanArray(tagsJSON) if r.Tags == nil { r.Tags = []string{} } r.Rank = 1.0 // no ranking on SQLite results = append(results, r) } return results, rows.Err() } // SearchSemantic returns empty results on SQLite (vector search requires PG). func (s *NoteStore) SearchSemantic(ctx context.Context, userID, vecStr string, limit int) ([]store.NoteSearchResult, error) { return []store.NoteSearchResult{}, nil } func (s *NoteStore) ListFolders(ctx context.Context, userID string) ([]store.FolderInfo, error) { rows, err := DB.QueryContext(ctx, ` SELECT DISTINCT folder_path, COUNT(*) AS count FROM notes WHERE user_id = ? GROUP BY folder_path ORDER BY folder_path `, userID) if err != nil { return nil, err } defer rows.Close() results := make([]store.FolderInfo, 0) for rows.Next() { var f store.FolderInfo if err := rows.Scan(&f.Path, &f.Count); err != nil { continue } results = append(results, f) } return results, rows.Err() }