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 "strings"
8 "time"
9
10 "github.com/stormlightlabs/noteleaf/internal/models"
11 "github.com/stormlightlabs/noteleaf/internal/services"
12)
13
14func ArticleNotFoundError(id int64) error {
15 return fmt.Errorf("article with id %d not found", id)
16}
17
18// ArticleRepository provides database operations for articles
19type ArticleRepository struct {
20 db *sql.DB
21}
22
23// NewArticleRepository creates a new article repository
24func NewArticleRepository(db *sql.DB) *ArticleRepository {
25 return &ArticleRepository{db: db}
26}
27
28// ArticleListOptions defines filtering options for listing articles
29type ArticleListOptions struct {
30 URL string
31 Title string
32 Author string
33 DateFrom string
34 DateTo string
35 Limit int
36 Offset int
37}
38
39// scanArticle scans a database row into an Article model
40func (r *ArticleRepository) scanArticle(s scanner) (*models.Article, error) {
41 var article models.Article
42 err := s.Scan(&article.ID, &article.URL, &article.Title, &article.Author, &article.Date,
43 &article.MarkdownPath, &article.HTMLPath, &article.Created, &article.Modified)
44 if err != nil {
45 return nil, err
46 }
47 return &article, nil
48}
49
50// queryOne executes a query that returns a single article
51func (r *ArticleRepository) queryOne(ctx context.Context, query string, args ...any) (*models.Article, error) {
52 row := r.db.QueryRowContext(ctx, query, args...)
53 article, err := r.scanArticle(row)
54 if err != nil {
55 if err == sql.ErrNoRows {
56 return nil, fmt.Errorf("article not found")
57 }
58 return nil, fmt.Errorf("failed to scan article: %w", err)
59 }
60 return article, nil
61}
62
63// queryMany executes a query that returns multiple articles
64func (r *ArticleRepository) queryMany(ctx context.Context, query string, args ...any) ([]*models.Article, error) {
65 rows, err := r.db.QueryContext(ctx, query, args...)
66 if err != nil {
67 return nil, fmt.Errorf("failed to query articles: %w", err)
68 }
69 defer rows.Close()
70
71 var articles []*models.Article
72 for rows.Next() {
73 article, err := r.scanArticle(rows)
74 if err != nil {
75 return nil, fmt.Errorf("failed to scan article: %w", err)
76 }
77 articles = append(articles, article)
78 }
79
80 if err := rows.Err(); err != nil {
81 return nil, fmt.Errorf("error iterating over articles: %w", err)
82 }
83
84 return articles, nil
85}
86
87// buildListQuery constructs a query and arguments for the List method
88func (r *ArticleRepository) buildListQuery(opts *ArticleListOptions) (string, []any) {
89 query := queryArticlesList
90 var conditions []string
91 var args []any
92
93 if opts != nil {
94 if opts.URL != "" {
95 conditions = append(conditions, "url LIKE ?")
96 args = append(args, "%"+opts.URL+"%")
97 }
98 if opts.Title != "" {
99 conditions = append(conditions, "title LIKE ?")
100 args = append(args, "%"+opts.Title+"%")
101 }
102 if opts.Author != "" {
103 conditions = append(conditions, "author LIKE ?")
104 args = append(args, "%"+opts.Author+"%")
105 }
106 if opts.DateFrom != "" {
107 conditions = append(conditions, "date >= ?")
108 args = append(args, opts.DateFrom)
109 }
110 if opts.DateTo != "" {
111 conditions = append(conditions, "date <= ?")
112 args = append(args, opts.DateTo)
113 }
114 }
115
116 if len(conditions) > 0 {
117 query += " WHERE " + strings.Join(conditions, " AND ")
118 }
119
120 query += " ORDER BY created DESC"
121
122 if opts != nil && opts.Limit > 0 {
123 query += " LIMIT ?"
124 args = append(args, opts.Limit)
125 if opts.Offset > 0 {
126 query += " OFFSET ?"
127 args = append(args, opts.Offset)
128 }
129 }
130
131 return query, args
132}
133
134// buildCountQuery constructs a count query and arguments
135func (r *ArticleRepository) buildCountQuery(opts *ArticleListOptions) (string, []any) {
136 query := queryArticlesCount
137 var conditions []string
138 var args []any
139
140 if opts != nil {
141 if opts.URL != "" {
142 conditions = append(conditions, "url LIKE ?")
143 args = append(args, "%"+opts.URL+"%")
144 }
145 if opts.Title != "" {
146 conditions = append(conditions, "title LIKE ?")
147 args = append(args, "%"+opts.Title+"%")
148 }
149 if opts.Author != "" {
150 conditions = append(conditions, "author LIKE ?")
151 args = append(args, "%"+opts.Author+"%")
152 }
153 if opts.DateFrom != "" {
154 conditions = append(conditions, "date >= ?")
155 args = append(args, opts.DateFrom)
156 }
157 if opts.DateTo != "" {
158 conditions = append(conditions, "date <= ?")
159 args = append(args, opts.DateTo)
160 }
161 }
162
163 if len(conditions) > 0 {
164 query += " WHERE " + strings.Join(conditions, " AND ")
165 }
166
167 return query, args
168}
169
170// Create stores a new article and returns its assigned ID
171func (r *ArticleRepository) Create(ctx context.Context, article *models.Article) (int64, error) {
172 if err := r.Validate(article); err != nil {
173 return 0, err
174 }
175
176 now := time.Now()
177 article.Created = now
178 article.Modified = now
179
180 result, err := r.db.ExecContext(ctx, queryArticleInsert,
181 article.URL, article.Title, article.Author, article.Date,
182 article.MarkdownPath, article.HTMLPath, article.Created, article.Modified)
183 if err != nil {
184 return 0, fmt.Errorf("failed to insert article: %w", err)
185 }
186
187 id, err := result.LastInsertId()
188 if err != nil {
189 return 0, fmt.Errorf("failed to get last insert id: %w", err)
190 }
191
192 article.ID = id
193 return id, nil
194}
195
196// Get retrieves an article by its ID
197func (r *ArticleRepository) Get(ctx context.Context, id int64) (*models.Article, error) {
198 article, err := r.queryOne(ctx, queryArticleByID, id)
199 if err != nil {
200 return nil, ArticleNotFoundError(id)
201 }
202 return article, nil
203}
204
205// GetByURL retrieves an article by its URL
206func (r *ArticleRepository) GetByURL(ctx context.Context, url string) (*models.Article, error) {
207 article, err := r.queryOne(ctx, queryArticleByURL, url)
208 if err != nil {
209 return nil, fmt.Errorf("article with url %s not found", url)
210 }
211 return article, nil
212}
213
214// Update modifies an existing article
215func (r *ArticleRepository) Update(ctx context.Context, article *models.Article) error {
216 if err := r.Validate(article); err != nil {
217 return err
218 }
219
220 article.Modified = time.Now()
221
222 result, err := r.db.ExecContext(ctx, queryArticleUpdate,
223 article.Title, article.Author, article.Date, article.MarkdownPath,
224 article.HTMLPath, article.Modified, article.ID)
225 if err != nil {
226 return fmt.Errorf("failed to update article: %w", err)
227 }
228
229 rowsAffected, err := result.RowsAffected()
230 if err != nil {
231 return fmt.Errorf("failed to get rows affected: %w", err)
232 }
233
234 if rowsAffected == 0 {
235 return ArticleNotFoundError(article.ID)
236 }
237
238 return nil
239}
240
241// Delete removes an article from the database
242func (r *ArticleRepository) Delete(ctx context.Context, id int64) error {
243 result, err := r.db.ExecContext(ctx, queryArticleDelete, id)
244 if err != nil {
245 return fmt.Errorf("failed to delete article: %w", err)
246 }
247
248 rowsAffected, err := result.RowsAffected()
249 if err != nil {
250 return fmt.Errorf("failed to get rows affected: %w", err)
251 }
252
253 if rowsAffected == 0 {
254 return ArticleNotFoundError(id)
255 }
256
257 return nil
258}
259
260// List retrieves articles with optional filtering
261func (r *ArticleRepository) List(ctx context.Context, opts *ArticleListOptions) ([]*models.Article, error) {
262 query, args := r.buildListQuery(opts)
263 return r.queryMany(ctx, query, args...)
264}
265
266// Count returns the total number of articles matching the given options
267func (r *ArticleRepository) Count(ctx context.Context, opts *ArticleListOptions) (int64, error) {
268 query, args := r.buildCountQuery(opts)
269
270 var count int64
271 err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
272 if err != nil {
273 return 0, fmt.Errorf("failed to count articles: %w", err)
274 }
275
276 return count, nil
277}
278
279// Validate validates a model using the validation service
280func (r *ArticleRepository) Validate(model models.Model) error {
281 article, ok := (model).(*models.Article)
282 if !ok {
283 return services.ValidationError{
284 Field: "model",
285 Message: "expected Article model",
286 }
287 }
288
289 validator := services.NewValidator()
290
291 validator.Check(services.RequiredString("URL", article.URL))
292 validator.Check(services.RequiredString("Title", article.Title))
293 validator.Check(services.RequiredString("MarkdownPath", article.MarkdownPath))
294 validator.Check(services.RequiredString("HTMLPath", article.HTMLPath))
295
296 validator.Check(services.ValidURL("URL", article.URL))
297
298 validator.Check(services.ValidFilePath("MarkdownPath", article.MarkdownPath))
299 validator.Check(services.ValidFilePath("HTMLPath", article.HTMLPath))
300
301 if article.Date != "" {
302 validator.Check(services.ValidDate("Date", article.Date))
303 }
304
305 validator.Check(services.StringLength("Title", article.Title, 1, 500))
306 validator.Check(services.StringLength("Author", article.Author, 0, 200))
307
308 if article.ID > 0 {
309 validator.Check(services.PositiveID("ID", article.ID))
310 }
311
312 if !article.Created.IsZero() && !article.Modified.IsZero() {
313 if article.Created.After(article.Modified) {
314 validator.Check(services.ValidationError{
315 Field: "Created",
316 Message: "cannot be after Modified timestamp",
317 })
318 }
319 }
320
321 return validator.Errors()
322}