cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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}