cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 238 lines 6.7 kB view raw
1package repo 2 3import ( 4 "context" 5 "database/sql" 6 "fmt" 7 8 "github.com/stormlightlabs/noteleaf/internal/documents" 9) 10 11func DocumentNotFoundError(id int64) error { 12 return fmt.Errorf("document with id %d not found", id) 13} 14 15// DocumentRepository provides database operations for documents 16type DocumentRepository struct { 17 db *sql.DB 18} 19 20// NewDocumentRepository creates a new document repository 21func NewDocumentRepository(db *sql.DB) *DocumentRepository { 22 return &DocumentRepository{db: db} 23} 24 25// scanDocument scans a database row into a Document model 26func (r *DocumentRepository) scanDocument(s scanner) (*documents.Document, error) { 27 var doc documents.Document 28 err := s.Scan(&doc.ID, &doc.Title, &doc.Body, &doc.CreatedAt, &doc.DocKind) 29 if err != nil { 30 return nil, err 31 } 32 return &doc, nil 33} 34 35// queryOne executes a query that returns a single document 36func (r *DocumentRepository) queryOne(ctx context.Context, query string, args ...any) (*documents.Document, error) { 37 row := r.db.QueryRowContext(ctx, query, args...) 38 doc, err := r.scanDocument(row) 39 if err != nil { 40 if err == sql.ErrNoRows { 41 return nil, fmt.Errorf("document not found") 42 } 43 return nil, fmt.Errorf("failed to scan document: %w", err) 44 } 45 return doc, nil 46} 47 48// queryMany executes a query that returns multiple documents 49func (r *DocumentRepository) queryMany(ctx context.Context, query string, args ...any) ([]documents.Document, error) { 50 rows, err := r.db.QueryContext(ctx, query, args...) 51 if err != nil { 52 return nil, fmt.Errorf("failed to query documents: %w", err) 53 } 54 defer rows.Close() 55 56 var docs []documents.Document 57 for rows.Next() { 58 doc, err := r.scanDocument(rows) 59 if err != nil { 60 return nil, fmt.Errorf("failed to scan document: %w", err) 61 } 62 docs = append(docs, *doc) 63 } 64 65 if err := rows.Err(); err != nil { 66 return nil, fmt.Errorf("error iterating over documents: %w", err) 67 } 68 69 return docs, nil 70} 71 72// Create stores a new document and returns its assigned ID 73func (r *DocumentRepository) Create(ctx context.Context, doc *documents.Document) (int64, error) { 74 result, err := r.db.ExecContext(ctx, queryDocumentInsert, 75 doc.Title, doc.Body, doc.CreatedAt, doc.DocKind) 76 if err != nil { 77 return 0, fmt.Errorf("failed to insert document: %w", err) 78 } 79 80 id, err := result.LastInsertId() 81 if err != nil { 82 return 0, fmt.Errorf("failed to get last insert id: %w", err) 83 } 84 85 doc.ID = id 86 return id, nil 87} 88 89// Get retrieves a document by its ID 90func (r *DocumentRepository) Get(ctx context.Context, id int64) (*documents.Document, error) { 91 doc, err := r.queryOne(ctx, queryDocumentByID, id) 92 if err != nil { 93 return nil, DocumentNotFoundError(id) 94 } 95 return doc, nil 96} 97 98// Delete removes a document by its ID 99func (r *DocumentRepository) Delete(ctx context.Context, id int64) error { 100 result, err := r.db.ExecContext(ctx, queryDocumentDelete, id) 101 if err != nil { 102 return fmt.Errorf("failed to delete document: %w", err) 103 } 104 105 rowsAffected, err := result.RowsAffected() 106 if err != nil { 107 return fmt.Errorf("failed to get rows affected: %w", err) 108 } 109 110 if rowsAffected == 0 { 111 return DocumentNotFoundError(id) 112 } 113 114 return nil 115} 116 117// List retrieves all documents 118func (r *DocumentRepository) List(ctx context.Context) ([]documents.Document, error) { 119 return r.queryMany(ctx, queryDocumentsList) 120} 121 122// ListByKind retrieves documents of a specific kind 123func (r *DocumentRepository) ListByKind(ctx context.Context, kind documents.DocKind) ([]documents.Document, error) { 124 return r.queryMany(ctx, queryDocumentsByKind, int64(kind)) 125} 126 127// DeleteAll removes all documents from the database 128func (r *DocumentRepository) DeleteAll(ctx context.Context) error { 129 _, err := r.db.ExecContext(ctx, queryDocumentsDeleteAll) 130 if err != nil { 131 return fmt.Errorf("failed to delete all documents: %w", err) 132 } 133 return nil 134} 135 136// RebuildFromNotes rebuilds the documents table from notes 137func (r *DocumentRepository) RebuildFromNotes(ctx context.Context, noteRepo *NoteRepository) error { 138 notes, err := noteRepo.List(ctx, NoteListOptions{}) 139 if err != nil { 140 return fmt.Errorf("failed to list notes: %w", err) 141 } 142 143 for _, note := range notes { 144 doc := &documents.Document{ 145 Title: note.Title, 146 Body: note.Content, 147 CreatedAt: note.Created, 148 DocKind: int64(documents.NoteDoc), 149 } 150 151 if _, err := r.Create(ctx, doc); err != nil { 152 return fmt.Errorf("failed to create document from note %d: %w", note.ID, err) 153 } 154 } 155 156 return nil 157} 158 159// BuildIndex creates a TF-IDF index from all documents in the database 160func (r *DocumentRepository) BuildIndex(ctx context.Context) (*documents.Index, error) { 161 docs, err := r.List(ctx) 162 if err != nil { 163 return nil, fmt.Errorf("failed to list documents: %w", err) 164 } 165 166 return documents.BuildIndex(docs), nil 167} 168 169// SearchEngine wraps a DocumentRepository with search capabilities 170type SearchEngine struct { 171 repo *DocumentRepository 172 index *documents.Index 173} 174 175// NewSearchEngine creates a new search engine with the given repository 176func NewSearchEngine(repo *DocumentRepository) *SearchEngine { 177 return &SearchEngine{ 178 repo: repo, 179 index: nil, 180 } 181} 182 183// Rebuild rebuilds the search index from the database 184func (se *SearchEngine) Rebuild(ctx context.Context) error { 185 idx, err := se.repo.BuildIndex(ctx) 186 if err != nil { 187 return fmt.Errorf("failed to build index: %w", err) 188 } 189 190 se.index = idx 191 return nil 192} 193 194// Search performs a TF-IDF search and returns matching documents 195func (se *SearchEngine) Search(ctx context.Context, query string, limit int) ([]documents.Document, error) { 196 if se.index == nil { 197 return nil, fmt.Errorf("search index not initialized") 198 } 199 200 results, err := se.index.Search(query, limit) 201 if err != nil { 202 return nil, fmt.Errorf("failed to search: %w", err) 203 } 204 205 docs := make([]documents.Document, 0, len(results)) 206 for _, result := range results { 207 doc, err := se.repo.Get(ctx, result.DocID) 208 if err != nil { 209 return nil, fmt.Errorf("failed to get document %d: %w", result.DocID, err) 210 } 211 docs = append(docs, *doc) 212 } 213 214 return docs, nil 215} 216 217// SearchWithScores performs a TF-IDF search and returns results with scores 218func (se *SearchEngine) SearchWithScores(ctx context.Context, query string, limit int) ([]documents.Result, []documents.Document, error) { 219 if se.index == nil { 220 return nil, nil, fmt.Errorf("search index not initialized") 221 } 222 223 results, err := se.index.Search(query, limit) 224 if err != nil { 225 return nil, nil, fmt.Errorf("failed to search: %w", err) 226 } 227 228 docs := make([]documents.Document, 0, len(results)) 229 for _, result := range results { 230 doc, err := se.repo.Get(ctx, result.DocID) 231 if err != nil { 232 return nil, nil, fmt.Errorf("failed to get document %d: %w", result.DocID, err) 233 } 234 docs = append(docs, *doc) 235 } 236 237 return results, docs, nil 238}