cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 322 lines 8.9 kB view raw
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}