···2233## Core Task Management (TaskWarrior-inspired)
4455-- `add` - Add new task with description and optional metadata
65- `list` - Display tasks with filtering and sorting options
66+- `projects` - List all project names
77+- `tags` - List all tag names
88+99+- `create` - Add new task with description and optional metadata
1010+1111+- `view` - View task by ID
712- `done` - Mark task as completed
88-- `delete` - Remove task permanently
99-- `modify` - Edit task properties (description, priority, project, tags)
1313+- `update` - Edit task properties (description, priority, project, tags)
1014- `start/stop` - Track active time on tasks
1115- `annotate` - Add notes/comments to existing tasks
1212-- `projects` - List all project names
1313-- `tags` - List all tag names
1616+1717+- `delete` - Remove task permanently
1818+1419- `calendar` - Display tasks in calendar view
1520- `timesheet` - Show time tracking summaries
16211722## Todo.txt Compatibility
18231924- `archive` - Move completed tasks to done.txt
2020-- `listcon` - List all contexts (@context)
2121-- `listproj` - List all projects (+project)
2222-- `pri` - Set task priority (A-Z)
2323-- `depri` - Remove priority from task
2424-- `replace` - Replace task text entirely
2525+- `[con]texts` - List all contexts (@context)
2626+- `[proj]ects` - List all projects (+project)
2727+- `[pri]ority` - Set task priority (A-Z)
2828+- `[depri]oritize` - Remove priority from task
2929+- `[re]place` - Replace task text entirely
2530- `prepend/append` - Add text to beginning/end of task
26312732## Media Queue Management
28332934- `movie add` - Add movie to watch queue
3035- `movie list` - Show movie queue with ratings/metadata
3131-- `movie watched` - Mark movie as watched
3232-- `movie remove` - Remove from queue
3636+- `movie watched|seen` - Mark movie as watched
3737+- `movie remove|rm` - Remove from queue
3838+3339- `tv add` - Add TV show/season to queue
3440- `tv list` - Show TV queue with episode tracking
3535-- `tv watched` - Mark episodes/seasons as watched
3636-- `tv remove` - Remove from TV queue
4141+- `tv watched|seen` - Mark episodes/seasons as watched
4242+- `tv remove|rm` - Remove from TV queue
37433844## Reading List Management
39454046- `book add` - Add book to reading list
4147- `book list` - Show reading queue with progress
4248- `book reading` - Mark book as currently reading
4343-- `book finished` - Mark book as completed
4444-- `book remove` - Remove from reading list
4949+- `book finished|read` - Mark book as completed
5050+- `book remove|rm` - Remove from reading list
4551- `book progress` - Update reading progress percentage
46524753## Data Management
48544955- `sync` - Synchronize with remote storage
5656+- `sync setup` - Setup remote storage
5757+5058- `backup` - Create local backup
5959+5160- `import` - Import from various formats (CSV, JSON, todo.txt)
5261- `export` - Export to various formats
6262+5363- `config` - Manage configuration settings
6464+5465- `undo` - Reverse last operation
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "fmt"
77+ "strings"
88+ "time"
99+1010+ "stormlightlabs.org/noteleaf/internal/models"
1111+)
1212+1313+// BookRepository provides database operations for books
1414+type BookRepository struct {
1515+ db *sql.DB
1616+}
1717+1818+// NewBookRepository creates a new book repository
1919+func NewBookRepository(db *sql.DB) *BookRepository {
2020+ return &BookRepository{db: db}
2121+}
2222+2323+// Create stores a new book and returns its assigned ID
2424+func (r *BookRepository) Create(ctx context.Context, book *models.Book) (int64, error) {
2525+ now := time.Now()
2626+ book.Added = now
2727+2828+ query := `
2929+ INSERT INTO books (title, author, status, progress, pages, rating, notes, added, started, finished)
3030+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
3131+3232+ result, err := r.db.ExecContext(ctx, query,
3333+ book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating,
3434+ book.Notes, book.Added, book.Started, book.Finished)
3535+ if err != nil {
3636+ return 0, fmt.Errorf("failed to insert book: %w", err)
3737+ }
3838+3939+ id, err := result.LastInsertId()
4040+ if err != nil {
4141+ return 0, fmt.Errorf("failed to get last insert id: %w", err)
4242+ }
4343+4444+ book.ID = id
4545+ return id, nil
4646+}
4747+4848+// Get retrieves a book by ID
4949+func (r *BookRepository) Get(ctx context.Context, id int64) (*models.Book, error) {
5050+ query := `
5151+ SELECT id, title, author, status, progress, pages, rating, notes, added, started, finished
5252+ FROM books WHERE id = ?`
5353+5454+ book := &models.Book{}
5555+ err := r.db.QueryRowContext(ctx, query, id).Scan(
5656+ &book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &book.Pages,
5757+ &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished)
5858+ if err != nil {
5959+ return nil, fmt.Errorf("failed to get book: %w", err)
6060+ }
6161+6262+ return book, nil
6363+}
6464+6565+// Update modifies an existing book
6666+func (r *BookRepository) Update(ctx context.Context, book *models.Book) error {
6767+ query := `
6868+ UPDATE books SET title = ?, author = ?, status = ?, progress = ?, pages = ?,
6969+ rating = ?, notes = ?, started = ?, finished = ?
7070+ WHERE id = ?`
7171+7272+ _, err := r.db.ExecContext(ctx, query,
7373+ book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating,
7474+ book.Notes, book.Started, book.Finished, book.ID)
7575+ if err != nil {
7676+ return fmt.Errorf("failed to update book: %w", err)
7777+ }
7878+7979+ return nil
8080+}
8181+8282+// Delete removes a book by ID
8383+func (r *BookRepository) Delete(ctx context.Context, id int64) error {
8484+ query := "DELETE FROM books WHERE id = ?"
8585+ _, err := r.db.ExecContext(ctx, query, id)
8686+ if err != nil {
8787+ return fmt.Errorf("failed to delete book: %w", err)
8888+ }
8989+ return nil
9090+}
9191+9292+// List retrieves books with optional filtering and sorting
9393+func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) {
9494+ query := r.buildListQuery(opts)
9595+ args := r.buildListArgs(opts)
9696+9797+ rows, err := r.db.QueryContext(ctx, query, args...)
9898+ if err != nil {
9999+ return nil, fmt.Errorf("failed to list books: %w", err)
100100+ }
101101+ defer rows.Close()
102102+103103+ var books []*models.Book
104104+ for rows.Next() {
105105+ book := &models.Book{}
106106+ if err := r.scanBookRow(rows, book); err != nil {
107107+ return nil, err
108108+ }
109109+ books = append(books, book)
110110+ }
111111+112112+ return books, rows.Err()
113113+}
114114+115115+func (r *BookRepository) buildListQuery(opts BookListOptions) string {
116116+ query := "SELECT id, title, author, status, progress, pages, rating, notes, added, started, finished FROM books"
117117+118118+ var conditions []string
119119+120120+ if opts.Status != "" {
121121+ conditions = append(conditions, "status = ?")
122122+ }
123123+ if opts.Author != "" {
124124+ conditions = append(conditions, "author = ?")
125125+ }
126126+ if opts.MinProgress > 0 {
127127+ conditions = append(conditions, "progress >= ?")
128128+ }
129129+ if opts.MinRating > 0 {
130130+ conditions = append(conditions, "rating >= ?")
131131+ }
132132+133133+ if opts.Search != "" {
134134+ searchConditions := []string{
135135+ "title LIKE ?",
136136+ "author LIKE ?",
137137+ "notes LIKE ?",
138138+ }
139139+ conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
140140+ }
141141+142142+ if len(conditions) > 0 {
143143+ query += " WHERE " + strings.Join(conditions, " AND ")
144144+ }
145145+146146+ if opts.SortBy != "" {
147147+ order := "ASC"
148148+ if strings.ToUpper(opts.SortOrder) == "DESC" {
149149+ order = "DESC"
150150+ }
151151+ query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order)
152152+ } else {
153153+ query += " ORDER BY added DESC"
154154+ }
155155+156156+ if opts.Limit > 0 {
157157+ query += fmt.Sprintf(" LIMIT %d", opts.Limit)
158158+ if opts.Offset > 0 {
159159+ query += fmt.Sprintf(" OFFSET %d", opts.Offset)
160160+ }
161161+ }
162162+163163+ return query
164164+}
165165+166166+func (r *BookRepository) buildListArgs(opts BookListOptions) []any {
167167+ var args []any
168168+169169+ if opts.Status != "" {
170170+ args = append(args, opts.Status)
171171+ }
172172+ if opts.Author != "" {
173173+ args = append(args, opts.Author)
174174+ }
175175+ if opts.MinProgress > 0 {
176176+ args = append(args, opts.MinProgress)
177177+ }
178178+ if opts.MinRating > 0 {
179179+ args = append(args, opts.MinRating)
180180+ }
181181+182182+ if opts.Search != "" {
183183+ searchPattern := "%" + opts.Search + "%"
184184+ args = append(args, searchPattern, searchPattern, searchPattern)
185185+ }
186186+187187+ return args
188188+}
189189+190190+func (r *BookRepository) scanBookRow(rows *sql.Rows, book *models.Book) error {
191191+ return rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &book.Pages,
192192+ &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished)
193193+}
194194+195195+// Find retrieves books matching specific conditions
196196+func (r *BookRepository) Find(ctx context.Context, conditions BookListOptions) ([]*models.Book, error) {
197197+ return r.List(ctx, conditions)
198198+}
199199+200200+// Count returns the number of books matching conditions
201201+func (r *BookRepository) Count(ctx context.Context, opts BookListOptions) (int64, error) {
202202+ query := "SELECT COUNT(*) FROM books"
203203+ args := []any{}
204204+205205+ var conditions []string
206206+207207+ if opts.Status != "" {
208208+ conditions = append(conditions, "status = ?")
209209+ args = append(args, opts.Status)
210210+ }
211211+ if opts.Author != "" {
212212+ conditions = append(conditions, "author = ?")
213213+ args = append(args, opts.Author)
214214+ }
215215+ if opts.MinProgress > 0 {
216216+ conditions = append(conditions, "progress >= ?")
217217+ args = append(args, opts.MinProgress)
218218+ }
219219+ if opts.MinRating > 0 {
220220+ conditions = append(conditions, "rating >= ?")
221221+ args = append(args, opts.MinRating)
222222+ }
223223+224224+ if opts.Search != "" {
225225+ searchConditions := []string{
226226+ "title LIKE ?",
227227+ "author LIKE ?",
228228+ "notes LIKE ?",
229229+ }
230230+ conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
231231+ searchPattern := "%" + opts.Search + "%"
232232+ args = append(args, searchPattern, searchPattern, searchPattern)
233233+ }
234234+235235+ if len(conditions) > 0 {
236236+ query += " WHERE " + strings.Join(conditions, " AND ")
237237+ }
238238+239239+ var count int64
240240+ err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
241241+ if err != nil {
242242+ return 0, fmt.Errorf("failed to count books: %w", err)
243243+ }
244244+245245+ return count, nil
246246+}
247247+248248+// GetQueued retrieves all books in the queue
249249+func (r *BookRepository) GetQueued(ctx context.Context) ([]*models.Book, error) {
250250+ return r.List(ctx, BookListOptions{Status: "queued"})
251251+}
252252+253253+// GetReading retrieves all books currently being read
254254+func (r *BookRepository) GetReading(ctx context.Context) ([]*models.Book, error) {
255255+ return r.List(ctx, BookListOptions{Status: "reading"})
256256+}
257257+258258+// GetFinished retrieves all finished books
259259+func (r *BookRepository) GetFinished(ctx context.Context) ([]*models.Book, error) {
260260+ return r.List(ctx, BookListOptions{Status: "finished"})
261261+}
262262+263263+// GetByAuthor retrieves all books by a specific author
264264+func (r *BookRepository) GetByAuthor(ctx context.Context, author string) ([]*models.Book, error) {
265265+ return r.List(ctx, BookListOptions{Author: author})
266266+}
267267+268268+// StartReading marks a book as started
269269+func (r *BookRepository) StartReading(ctx context.Context, id int64) error {
270270+ book, err := r.Get(ctx, id)
271271+ if err != nil {
272272+ return err
273273+ }
274274+275275+ now := time.Now()
276276+ book.Status = "reading"
277277+ book.Started = &now
278278+279279+ return r.Update(ctx, book)
280280+}
281281+282282+// FinishReading marks a book as finished
283283+func (r *BookRepository) FinishReading(ctx context.Context, id int64) error {
284284+ book, err := r.Get(ctx, id)
285285+ if err != nil {
286286+ return err
287287+ }
288288+289289+ now := time.Now()
290290+ book.Status = "finished"
291291+ book.Progress = 100
292292+ book.Finished = &now
293293+294294+ return r.Update(ctx, book)
295295+}
296296+297297+// UpdateProgress updates the reading progress of a book
298298+func (r *BookRepository) UpdateProgress(ctx context.Context, id int64, progress int) error {
299299+ book, err := r.Get(ctx, id)
300300+ if err != nil {
301301+ return err
302302+ }
303303+304304+ book.Progress = progress
305305+306306+ if progress >= 100 {
307307+ book.Status = "finished"
308308+ now := time.Now()
309309+ book.Finished = &now
310310+ } else if progress > 0 && book.Status == "queued" {
311311+ book.Status = "reading"
312312+ if book.Started == nil {
313313+ now := time.Now()
314314+ book.Started = &now
315315+ }
316316+ }
317317+318318+ return r.Update(ctx, book)
319319+}
320320+321321+// BookListOptions defines options for listing books
322322+type BookListOptions struct {
323323+ Status string
324324+ Author string
325325+ MinProgress int
326326+ MinRating float64
327327+ Search string
328328+ SortBy string
329329+ SortOrder string
330330+ Limit int
331331+ Offset int
332332+}
+560
internal/repo/book_repository_test.go
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "testing"
77+ "time"
88+99+ _ "github.com/mattn/go-sqlite3"
1010+ "stormlightlabs.org/noteleaf/internal/models"
1111+)
1212+1313+func createBookTestDB(t *testing.T) *sql.DB {
1414+ db, err := sql.Open("sqlite3", ":memory:")
1515+ if err != nil {
1616+ t.Fatalf("Failed to create in-memory database: %v", err)
1717+ }
1818+1919+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
2020+ t.Fatalf("Failed to enable foreign keys: %v", err)
2121+ }
2222+2323+ schema := `
2424+ CREATE TABLE IF NOT EXISTS books (
2525+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2626+ title TEXT NOT NULL,
2727+ author TEXT,
2828+ status TEXT DEFAULT 'queued',
2929+ progress INTEGER DEFAULT 0,
3030+ pages INTEGER,
3131+ rating REAL,
3232+ notes TEXT,
3333+ added DATETIME DEFAULT CURRENT_TIMESTAMP,
3434+ started DATETIME,
3535+ finished DATETIME
3636+ );
3737+ `
3838+3939+ if _, err := db.Exec(schema); err != nil {
4040+ t.Fatalf("Failed to create schema: %v", err)
4141+ }
4242+4343+ t.Cleanup(func() {
4444+ db.Close()
4545+ })
4646+4747+ return db
4848+}
4949+5050+func createSampleBook() *models.Book {
5151+ return &models.Book{
5252+ Title: "Test Book",
5353+ Author: "Test Author",
5454+ Status: "queued",
5555+ Progress: 25,
5656+ Pages: 300,
5757+ Rating: 4.5,
5858+ Notes: "Interesting read",
5959+ }
6060+}
6161+6262+func TestBookRepository(t *testing.T) {
6363+ t.Run("CRUD Operations", func(t *testing.T) {
6464+ db := createBookTestDB(t)
6565+ repo := NewBookRepository(db)
6666+ ctx := context.Background()
6767+6868+ t.Run("Create Book", func(t *testing.T) {
6969+ book := createSampleBook()
7070+7171+ id, err := repo.Create(ctx, book)
7272+ if err != nil {
7373+ t.Errorf("Failed to create book: %v", err)
7474+ }
7575+7676+ if id == 0 {
7777+ t.Error("Expected non-zero ID")
7878+ }
7979+8080+ if book.ID != id {
8181+ t.Errorf("Expected book ID to be set to %d, got %d", id, book.ID)
8282+ }
8383+8484+ if book.Added.IsZero() {
8585+ t.Error("Expected Added timestamp to be set")
8686+ }
8787+ })
8888+8989+ t.Run("Get Book", func(t *testing.T) {
9090+ original := createSampleBook()
9191+ id, err := repo.Create(ctx, original)
9292+ if err != nil {
9393+ t.Fatalf("Failed to create book: %v", err)
9494+ }
9595+9696+ retrieved, err := repo.Get(ctx, id)
9797+ if err != nil {
9898+ t.Errorf("Failed to get book: %v", err)
9999+ }
100100+101101+ if retrieved.Title != original.Title {
102102+ t.Errorf("Expected title %s, got %s", original.Title, retrieved.Title)
103103+ }
104104+ if retrieved.Author != original.Author {
105105+ t.Errorf("Expected author %s, got %s", original.Author, retrieved.Author)
106106+ }
107107+ if retrieved.Status != original.Status {
108108+ t.Errorf("Expected status %s, got %s", original.Status, retrieved.Status)
109109+ }
110110+ if retrieved.Progress != original.Progress {
111111+ t.Errorf("Expected progress %d, got %d", original.Progress, retrieved.Progress)
112112+ }
113113+ if retrieved.Pages != original.Pages {
114114+ t.Errorf("Expected pages %d, got %d", original.Pages, retrieved.Pages)
115115+ }
116116+ if retrieved.Rating != original.Rating {
117117+ t.Errorf("Expected rating %f, got %f", original.Rating, retrieved.Rating)
118118+ }
119119+ if retrieved.Notes != original.Notes {
120120+ t.Errorf("Expected notes %s, got %s", original.Notes, retrieved.Notes)
121121+ }
122122+ })
123123+124124+ t.Run("Update Book", func(t *testing.T) {
125125+ book := createSampleBook()
126126+ id, err := repo.Create(ctx, book)
127127+ if err != nil {
128128+ t.Fatalf("Failed to create book: %v", err)
129129+ }
130130+131131+ book.Title = "Updated Book"
132132+ book.Status = "reading"
133133+ book.Progress = 50
134134+ book.Rating = 5.0
135135+ now := time.Now()
136136+ book.Started = &now
137137+138138+ err = repo.Update(ctx, book)
139139+ if err != nil {
140140+ t.Errorf("Failed to update book: %v", err)
141141+ }
142142+143143+ updated, err := repo.Get(ctx, id)
144144+ if err != nil {
145145+ t.Fatalf("Failed to get updated book: %v", err)
146146+ }
147147+148148+ if updated.Title != "Updated Book" {
149149+ t.Errorf("Expected updated title, got %s", updated.Title)
150150+ }
151151+ if updated.Status != "reading" {
152152+ t.Errorf("Expected status reading, got %s", updated.Status)
153153+ }
154154+ if updated.Progress != 50 {
155155+ t.Errorf("Expected progress 50, got %d", updated.Progress)
156156+ }
157157+ if updated.Rating != 5.0 {
158158+ t.Errorf("Expected rating 5.0, got %f", updated.Rating)
159159+ }
160160+ if updated.Started == nil {
161161+ t.Error("Expected started time to be set")
162162+ }
163163+ })
164164+165165+ t.Run("Delete Book", func(t *testing.T) {
166166+ book := createSampleBook()
167167+ id, err := repo.Create(ctx, book)
168168+ if err != nil {
169169+ t.Fatalf("Failed to create book: %v", err)
170170+ }
171171+172172+ err = repo.Delete(ctx, id)
173173+ if err != nil {
174174+ t.Errorf("Failed to delete book: %v", err)
175175+ }
176176+177177+ _, err = repo.Get(ctx, id)
178178+ if err == nil {
179179+ t.Error("Expected error when getting deleted book")
180180+ }
181181+ })
182182+ })
183183+184184+ t.Run("List", func(t *testing.T) {
185185+ db := createBookTestDB(t)
186186+ repo := NewBookRepository(db)
187187+ ctx := context.Background()
188188+189189+ books := []*models.Book{
190190+ {Title: "Book 1", Author: "Author A", Status: "queued", Progress: 0, Rating: 4.0},
191191+ {Title: "Book 2", Author: "Author A", Status: "reading", Progress: 50, Rating: 4.5},
192192+ {Title: "Book 3", Author: "Author B", Status: "finished", Progress: 100, Rating: 5.0},
193193+ {Title: "Book 4", Author: "Author C", Status: "queued", Progress: 0, Rating: 3.5},
194194+ }
195195+196196+ for _, book := range books {
197197+ _, err := repo.Create(ctx, book)
198198+ if err != nil {
199199+ t.Fatalf("Failed to create book: %v", err)
200200+ }
201201+ }
202202+203203+ t.Run("List All Books", func(t *testing.T) {
204204+ results, err := repo.List(ctx, BookListOptions{})
205205+ if err != nil {
206206+ t.Errorf("Failed to list books: %v", err)
207207+ }
208208+209209+ if len(results) != 4 {
210210+ t.Errorf("Expected 4 books, got %d", len(results))
211211+ }
212212+ })
213213+214214+ t.Run("List Books with Status Filter", func(t *testing.T) {
215215+ results, err := repo.List(ctx, BookListOptions{Status: "queued"})
216216+ if err != nil {
217217+ t.Errorf("Failed to list books: %v", err)
218218+ }
219219+220220+ if len(results) != 2 {
221221+ t.Errorf("Expected 2 queued books, got %d", len(results))
222222+ }
223223+224224+ for _, book := range results {
225225+ if book.Status != "queued" {
226226+ t.Errorf("Expected queued status, got %s", book.Status)
227227+ }
228228+ }
229229+ })
230230+231231+ t.Run("List Books by Author", func(t *testing.T) {
232232+ results, err := repo.List(ctx, BookListOptions{Author: "Author A"})
233233+ if err != nil {
234234+ t.Errorf("Failed to list books: %v", err)
235235+ }
236236+237237+ if len(results) != 2 {
238238+ t.Errorf("Expected 2 books by Author A, got %d", len(results))
239239+ }
240240+241241+ for _, book := range results {
242242+ if book.Author != "Author A" {
243243+ t.Errorf("Expected author 'Author A', got %s", book.Author)
244244+ }
245245+ }
246246+ })
247247+248248+ t.Run("List Books with Progress Filter", func(t *testing.T) {
249249+ results, err := repo.List(ctx, BookListOptions{MinProgress: 50})
250250+ if err != nil {
251251+ t.Errorf("Failed to list books: %v", err)
252252+ }
253253+254254+ if len(results) != 2 {
255255+ t.Errorf("Expected 2 books with progress >= 50, got %d", len(results))
256256+ }
257257+258258+ for _, book := range results {
259259+ if book.Progress < 50 {
260260+ t.Errorf("Expected progress >= 50, got %d", book.Progress)
261261+ }
262262+ }
263263+ })
264264+265265+ t.Run("List Books with Rating Filter", func(t *testing.T) {
266266+ results, err := repo.List(ctx, BookListOptions{MinRating: 4.5})
267267+ if err != nil {
268268+ t.Errorf("Failed to list books: %v", err)
269269+ }
270270+271271+ if len(results) != 2 {
272272+ t.Errorf("Expected 2 books with rating >= 4.5, got %d", len(results))
273273+ }
274274+275275+ for _, book := range results {
276276+ if book.Rating < 4.5 {
277277+ t.Errorf("Expected rating >= 4.5, got %f", book.Rating)
278278+ }
279279+ }
280280+ })
281281+282282+ t.Run("List Books with Search", func(t *testing.T) {
283283+ results, err := repo.List(ctx, BookListOptions{Search: "Book 1"})
284284+ if err != nil {
285285+ t.Errorf("Failed to list books: %v", err)
286286+ }
287287+288288+ if len(results) != 1 {
289289+ t.Errorf("Expected 1 book matching search, got %d", len(results))
290290+ }
291291+292292+ if len(results) > 0 && results[0].Title != "Book 1" {
293293+ t.Errorf("Expected 'Book 1', got %s", results[0].Title)
294294+ }
295295+ })
296296+297297+ t.Run("List Books with Limit", func(t *testing.T) {
298298+ results, err := repo.List(ctx, BookListOptions{Limit: 2})
299299+ if err != nil {
300300+ t.Errorf("Failed to list books: %v", err)
301301+ }
302302+303303+ if len(results) != 2 {
304304+ t.Errorf("Expected 2 books due to limit, got %d", len(results))
305305+ }
306306+ })
307307+ })
308308+309309+ t.Run("Special Methods", func(t *testing.T) {
310310+ db := createBookTestDB(t)
311311+ repo := NewBookRepository(db)
312312+ ctx := context.Background()
313313+314314+ book1 := &models.Book{Title: "Queued Book", Author: "Author A", Status: "queued", Progress: 0}
315315+ book2 := &models.Book{Title: "Reading Book", Author: "Author B", Status: "reading", Progress: 45}
316316+ book3 := &models.Book{Title: "Finished Book", Author: "Author C", Status: "finished", Progress: 100}
317317+ book4 := &models.Book{Title: "Another Book", Author: "Author A", Status: "queued", Progress: 0}
318318+319319+ var book1ID int64
320320+ for _, book := range []*models.Book{book1, book2, book3, book4} {
321321+ id, err := repo.Create(ctx, book)
322322+ if err != nil {
323323+ t.Fatalf("Failed to create book: %v", err)
324324+ }
325325+ if book == book1 {
326326+ book1ID = id
327327+ }
328328+ }
329329+330330+ t.Run("GetQueued", func(t *testing.T) {
331331+ results, err := repo.GetQueued(ctx)
332332+ if err != nil {
333333+ t.Errorf("Failed to get queued books: %v", err)
334334+ }
335335+336336+ if len(results) != 2 {
337337+ t.Errorf("Expected 2 queued books, got %d", len(results))
338338+ }
339339+340340+ for _, book := range results {
341341+ if book.Status != "queued" {
342342+ t.Errorf("Expected queued status, got %s", book.Status)
343343+ }
344344+ }
345345+ })
346346+347347+ t.Run("GetReading", func(t *testing.T) {
348348+ results, err := repo.GetReading(ctx)
349349+ if err != nil {
350350+ t.Errorf("Failed to get reading books: %v", err)
351351+ }
352352+353353+ if len(results) != 1 {
354354+ t.Errorf("Expected 1 reading book, got %d", len(results))
355355+ }
356356+357357+ if len(results) > 0 && results[0].Status != "reading" {
358358+ t.Errorf("Expected reading status, got %s", results[0].Status)
359359+ }
360360+ })
361361+362362+ t.Run("GetFinished", func(t *testing.T) {
363363+ results, err := repo.GetFinished(ctx)
364364+ if err != nil {
365365+ t.Errorf("Failed to get finished books: %v", err)
366366+ }
367367+368368+ if len(results) != 1 {
369369+ t.Errorf("Expected 1 finished book, got %d", len(results))
370370+ }
371371+372372+ if len(results) > 0 && results[0].Status != "finished" {
373373+ t.Errorf("Expected finished status, got %s", results[0].Status)
374374+ }
375375+ })
376376+377377+ t.Run("GetByAuthor", func(t *testing.T) {
378378+ results, err := repo.GetByAuthor(ctx, "Author A")
379379+ if err != nil {
380380+ t.Errorf("Failed to get books by author: %v", err)
381381+ }
382382+383383+ if len(results) != 2 {
384384+ t.Errorf("Expected 2 books by Author A, got %d", len(results))
385385+ }
386386+387387+ for _, book := range results {
388388+ if book.Author != "Author A" {
389389+ t.Errorf("Expected author 'Author A', got %s", book.Author)
390390+ }
391391+ }
392392+ })
393393+394394+ t.Run("StartReading", func(t *testing.T) {
395395+ err := repo.StartReading(ctx, book1ID)
396396+ if err != nil {
397397+ t.Errorf("Failed to start reading book: %v", err)
398398+ }
399399+400400+ updated, err := repo.Get(ctx, book1ID)
401401+ if err != nil {
402402+ t.Fatalf("Failed to get updated book: %v", err)
403403+ }
404404+405405+ if updated.Status != "reading" {
406406+ t.Errorf("Expected status to be reading, got %s", updated.Status)
407407+ }
408408+409409+ if updated.Started == nil {
410410+ t.Error("Expected started timestamp to be set")
411411+ }
412412+ })
413413+414414+ t.Run("FinishReading", func(t *testing.T) {
415415+ newBook := &models.Book{Title: "New Book", Status: "reading", Progress: 80}
416416+ id, err := repo.Create(ctx, newBook)
417417+ if err != nil {
418418+ t.Fatalf("Failed to create new book: %v", err)
419419+ }
420420+421421+ err = repo.FinishReading(ctx, id)
422422+ if err != nil {
423423+ t.Errorf("Failed to finish reading book: %v", err)
424424+ }
425425+426426+ updated, err := repo.Get(ctx, id)
427427+ if err != nil {
428428+ t.Fatalf("Failed to get updated book: %v", err)
429429+ }
430430+431431+ if updated.Status != "finished" {
432432+ t.Errorf("Expected status to be finished, got %s", updated.Status)
433433+ }
434434+435435+ if updated.Progress != 100 {
436436+ t.Errorf("Expected progress to be 100, got %d", updated.Progress)
437437+ }
438438+439439+ if updated.Finished == nil {
440440+ t.Error("Expected finished timestamp to be set")
441441+ }
442442+ })
443443+444444+ t.Run("UpdateProgress", func(t *testing.T) {
445445+ newBook := &models.Book{Title: "Progress Book", Status: "queued", Progress: 0}
446446+ id, err := repo.Create(ctx, newBook)
447447+ if err != nil {
448448+ t.Fatalf("Failed to create new book: %v", err)
449449+ }
450450+451451+ err = repo.UpdateProgress(ctx, id, 25)
452452+ if err != nil {
453453+ t.Errorf("Failed to update progress: %v", err)
454454+ }
455455+456456+ updated, err := repo.Get(ctx, id)
457457+ if err != nil {
458458+ t.Fatalf("Failed to get updated book: %v", err)
459459+ }
460460+461461+ if updated.Status != "reading" {
462462+ t.Errorf("Expected status to be reading when progress > 0, got %s", updated.Status)
463463+ }
464464+465465+ if updated.Progress != 25 {
466466+ t.Errorf("Expected progress 25, got %d", updated.Progress)
467467+ }
468468+469469+ if updated.Started == nil {
470470+ t.Error("Expected started timestamp to be set when progress > 0")
471471+ }
472472+473473+ err = repo.UpdateProgress(ctx, id, 100)
474474+ if err != nil {
475475+ t.Errorf("Failed to update progress to 100: %v", err)
476476+ }
477477+478478+ updated, err = repo.Get(ctx, id)
479479+ if err != nil {
480480+ t.Fatalf("Failed to get updated book: %v", err)
481481+ }
482482+483483+ if updated.Status != "finished" {
484484+ t.Errorf("Expected status to be finished when progress = 100, got %s", updated.Status)
485485+ }
486486+487487+ if updated.Progress != 100 {
488488+ t.Errorf("Expected progress 100, got %d", updated.Progress)
489489+ }
490490+491491+ if updated.Finished == nil {
492492+ t.Error("Expected finished timestamp to be set when progress = 100")
493493+ }
494494+ })
495495+ })
496496+497497+ t.Run("Count", func(t *testing.T) {
498498+ db := createBookTestDB(t)
499499+ repo := NewBookRepository(db)
500500+ ctx := context.Background()
501501+502502+ books := []*models.Book{
503503+ {Title: "Book 1", Status: "queued", Progress: 0, Rating: 4.0},
504504+ {Title: "Book 2", Status: "reading", Progress: 50, Rating: 3.5},
505505+ {Title: "Book 3", Status: "finished", Progress: 100, Rating: 5.0},
506506+ {Title: "Book 4", Status: "queued", Progress: 0, Rating: 4.5},
507507+ }
508508+509509+ for _, book := range books {
510510+ _, err := repo.Create(ctx, book)
511511+ if err != nil {
512512+ t.Fatalf("Failed to create book: %v", err)
513513+ }
514514+ }
515515+516516+ t.Run("Count all books", func(t *testing.T) {
517517+ count, err := repo.Count(ctx, BookListOptions{})
518518+ if err != nil {
519519+ t.Errorf("Failed to count books: %v", err)
520520+ }
521521+522522+ if count != 4 {
523523+ t.Errorf("Expected 4 books, got %d", count)
524524+ }
525525+ })
526526+527527+ t.Run("Count queued books", func(t *testing.T) {
528528+ count, err := repo.Count(ctx, BookListOptions{Status: "queued"})
529529+ if err != nil {
530530+ t.Errorf("Failed to count queued books: %v", err)
531531+ }
532532+533533+ if count != 2 {
534534+ t.Errorf("Expected 2 queued books, got %d", count)
535535+ }
536536+ })
537537+538538+ t.Run("Count books by progress", func(t *testing.T) {
539539+ count, err := repo.Count(ctx, BookListOptions{MinProgress: 50})
540540+ if err != nil {
541541+ t.Errorf("Failed to count books with progress >= 50: %v", err)
542542+ }
543543+544544+ if count != 2 {
545545+ t.Errorf("Expected 2 books with progress >= 50, got %d", count)
546546+ }
547547+ })
548548+549549+ t.Run("Count books by rating", func(t *testing.T) {
550550+ count, err := repo.Count(ctx, BookListOptions{MinRating: 4.0})
551551+ if err != nil {
552552+ t.Errorf("Failed to count high-rated books: %v", err)
553553+ }
554554+555555+ if count != 3 {
556556+ t.Errorf("Expected 3 books with rating >= 4.0, got %d", count)
557557+ }
558558+ })
559559+ })
560560+}
+267
internal/repo/movie_repository.go
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "fmt"
77+ "strings"
88+ "time"
99+1010+ "stormlightlabs.org/noteleaf/internal/models"
1111+)
1212+1313+// MovieRepository provides database operations for movies
1414+type MovieRepository struct {
1515+ db *sql.DB
1616+}
1717+1818+// NewMovieRepository creates a new movie repository
1919+func NewMovieRepository(db *sql.DB) *MovieRepository {
2020+ return &MovieRepository{db: db}
2121+}
2222+2323+// Create stores a new movie and returns its assigned ID
2424+func (r *MovieRepository) Create(ctx context.Context, movie *models.Movie) (int64, error) {
2525+ now := time.Now()
2626+ movie.Added = now
2727+2828+ query := `
2929+ INSERT INTO movies (title, year, status, rating, notes, added, watched)
3030+ VALUES (?, ?, ?, ?, ?, ?, ?)`
3131+3232+ result, err := r.db.ExecContext(ctx, query,
3333+ movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Added, movie.Watched)
3434+ if err != nil {
3535+ return 0, fmt.Errorf("failed to insert movie: %w", err)
3636+ }
3737+3838+ id, err := result.LastInsertId()
3939+ if err != nil {
4040+ return 0, fmt.Errorf("failed to get last insert id: %w", err)
4141+ }
4242+4343+ movie.ID = id
4444+ return id, nil
4545+}
4646+4747+// Get retrieves a movie by ID
4848+func (r *MovieRepository) Get(ctx context.Context, id int64) (*models.Movie, error) {
4949+ query := `
5050+ SELECT id, title, year, status, rating, notes, added, watched
5151+ FROM movies WHERE id = ?`
5252+5353+ movie := &models.Movie{}
5454+ err := r.db.QueryRowContext(ctx, query, id).Scan(
5555+ &movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating,
5656+ &movie.Notes, &movie.Added, &movie.Watched)
5757+ if err != nil {
5858+ return nil, fmt.Errorf("failed to get movie: %w", err)
5959+ }
6060+6161+ return movie, nil
6262+}
6363+6464+// Update modifies an existing movie
6565+func (r *MovieRepository) Update(ctx context.Context, movie *models.Movie) error {
6666+ query := `
6767+ UPDATE movies SET title = ?, year = ?, status = ?, rating = ?, notes = ?, watched = ?
6868+ WHERE id = ?`
6969+7070+ _, err := r.db.ExecContext(ctx, query,
7171+ movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Watched, movie.ID)
7272+ if err != nil {
7373+ return fmt.Errorf("failed to update movie: %w", err)
7474+ }
7575+7676+ return nil
7777+}
7878+7979+// Delete removes a movie by ID
8080+func (r *MovieRepository) Delete(ctx context.Context, id int64) error {
8181+ query := "DELETE FROM movies WHERE id = ?"
8282+ _, err := r.db.ExecContext(ctx, query, id)
8383+ if err != nil {
8484+ return fmt.Errorf("failed to delete movie: %w", err)
8585+ }
8686+ return nil
8787+}
8888+8989+// List retrieves movies with optional filtering and sorting
9090+func (r *MovieRepository) List(ctx context.Context, opts MovieListOptions) ([]*models.Movie, error) {
9191+ query := r.buildListQuery(opts)
9292+ args := r.buildListArgs(opts)
9393+9494+ rows, err := r.db.QueryContext(ctx, query, args...)
9595+ if err != nil {
9696+ return nil, fmt.Errorf("failed to list movies: %w", err)
9797+ }
9898+ defer rows.Close()
9999+100100+ var movies []*models.Movie
101101+ for rows.Next() {
102102+ movie := &models.Movie{}
103103+ if err := r.scanMovieRow(rows, movie); err != nil {
104104+ return nil, err
105105+ }
106106+ movies = append(movies, movie)
107107+ }
108108+109109+ return movies, rows.Err()
110110+}
111111+112112+func (r *MovieRepository) buildListQuery(opts MovieListOptions) string {
113113+ query := "SELECT id, title, year, status, rating, notes, added, watched FROM movies"
114114+115115+ var conditions []string
116116+117117+ if opts.Status != "" {
118118+ conditions = append(conditions, "status = ?")
119119+ }
120120+ if opts.Year > 0 {
121121+ conditions = append(conditions, "year = ?")
122122+ }
123123+ if opts.MinRating > 0 {
124124+ conditions = append(conditions, "rating >= ?")
125125+ }
126126+127127+ if opts.Search != "" {
128128+ searchConditions := []string{
129129+ "title LIKE ?",
130130+ "notes LIKE ?",
131131+ }
132132+ conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
133133+ }
134134+135135+ if len(conditions) > 0 {
136136+ query += " WHERE " + strings.Join(conditions, " AND ")
137137+ }
138138+139139+ if opts.SortBy != "" {
140140+ order := "ASC"
141141+ if strings.ToUpper(opts.SortOrder) == "DESC" {
142142+ order = "DESC"
143143+ }
144144+ query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order)
145145+ } else {
146146+ query += " ORDER BY added DESC"
147147+ }
148148+149149+ if opts.Limit > 0 {
150150+ query += fmt.Sprintf(" LIMIT %d", opts.Limit)
151151+ if opts.Offset > 0 {
152152+ query += fmt.Sprintf(" OFFSET %d", opts.Offset)
153153+ }
154154+ }
155155+156156+ return query
157157+}
158158+159159+func (r *MovieRepository) buildListArgs(opts MovieListOptions) []any {
160160+ var args []any
161161+162162+ if opts.Status != "" {
163163+ args = append(args, opts.Status)
164164+ }
165165+ if opts.Year > 0 {
166166+ args = append(args, opts.Year)
167167+ }
168168+ if opts.MinRating > 0 {
169169+ args = append(args, opts.MinRating)
170170+ }
171171+172172+ if opts.Search != "" {
173173+ searchPattern := "%" + opts.Search + "%"
174174+ args = append(args, searchPattern, searchPattern)
175175+ }
176176+177177+ return args
178178+}
179179+180180+func (r *MovieRepository) scanMovieRow(rows *sql.Rows, movie *models.Movie) error {
181181+ return rows.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating,
182182+ &movie.Notes, &movie.Added, &movie.Watched)
183183+}
184184+185185+// Find retrieves movies matching specific conditions
186186+func (r *MovieRepository) Find(ctx context.Context, conditions MovieListOptions) ([]*models.Movie, error) {
187187+ return r.List(ctx, conditions)
188188+}
189189+190190+// Count returns the number of movies matching conditions
191191+func (r *MovieRepository) Count(ctx context.Context, opts MovieListOptions) (int64, error) {
192192+ query := "SELECT COUNT(*) FROM movies"
193193+ args := []any{}
194194+195195+ var conditions []string
196196+197197+ if opts.Status != "" {
198198+ conditions = append(conditions, "status = ?")
199199+ args = append(args, opts.Status)
200200+ }
201201+ if opts.Year > 0 {
202202+ conditions = append(conditions, "year = ?")
203203+ args = append(args, opts.Year)
204204+ }
205205+ if opts.MinRating > 0 {
206206+ conditions = append(conditions, "rating >= ?")
207207+ args = append(args, opts.MinRating)
208208+ }
209209+210210+ if opts.Search != "" {
211211+ searchConditions := []string{
212212+ "title LIKE ?",
213213+ "notes LIKE ?",
214214+ }
215215+ conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
216216+ searchPattern := "%" + opts.Search + "%"
217217+ args = append(args, searchPattern, searchPattern)
218218+ }
219219+220220+ if len(conditions) > 0 {
221221+ query += " WHERE " + strings.Join(conditions, " AND ")
222222+ }
223223+224224+ var count int64
225225+ err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
226226+ if err != nil {
227227+ return 0, fmt.Errorf("failed to count movies: %w", err)
228228+ }
229229+230230+ return count, nil
231231+}
232232+233233+// GetQueued retrieves all movies in the queue
234234+func (r *MovieRepository) GetQueued(ctx context.Context) ([]*models.Movie, error) {
235235+ return r.List(ctx, MovieListOptions{Status: "queued"})
236236+}
237237+238238+// GetWatched retrieves all watched movies
239239+func (r *MovieRepository) GetWatched(ctx context.Context) ([]*models.Movie, error) {
240240+ return r.List(ctx, MovieListOptions{Status: "watched"})
241241+}
242242+243243+// MarkWatched marks a movie as watched
244244+func (r *MovieRepository) MarkWatched(ctx context.Context, id int64) error {
245245+ movie, err := r.Get(ctx, id)
246246+ if err != nil {
247247+ return err
248248+ }
249249+250250+ now := time.Now()
251251+ movie.Status = "watched"
252252+ movie.Watched = &now
253253+254254+ return r.Update(ctx, movie)
255255+}
256256+257257+// MovieListOptions defines options for listing movies
258258+type MovieListOptions struct {
259259+ Status string
260260+ Year int
261261+ MinRating float64
262262+ Search string
263263+ SortBy string
264264+ SortOrder string
265265+ Limit int
266266+ Offset int
267267+}
+398
internal/repo/movie_repository_test.go
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "testing"
77+ "time"
88+99+ _ "github.com/mattn/go-sqlite3"
1010+ "stormlightlabs.org/noteleaf/internal/models"
1111+)
1212+1313+func createMovieTestDB(t *testing.T) *sql.DB {
1414+ db, err := sql.Open("sqlite3", ":memory:")
1515+ if err != nil {
1616+ t.Fatalf("Failed to create in-memory database: %v", err)
1717+ }
1818+1919+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
2020+ t.Fatalf("Failed to enable foreign keys: %v", err)
2121+ }
2222+2323+ schema := `
2424+ CREATE TABLE IF NOT EXISTS movies (
2525+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2626+ title TEXT NOT NULL,
2727+ year INTEGER,
2828+ status TEXT DEFAULT 'queued',
2929+ rating REAL,
3030+ notes TEXT,
3131+ added DATETIME DEFAULT CURRENT_TIMESTAMP,
3232+ watched DATETIME
3333+ );
3434+ `
3535+3636+ if _, err := db.Exec(schema); err != nil {
3737+ t.Fatalf("Failed to create schema: %v", err)
3838+ }
3939+4040+ t.Cleanup(func() {
4141+ db.Close()
4242+ })
4343+4444+ return db
4545+}
4646+4747+func createSampleMovie() *models.Movie {
4848+ return &models.Movie{
4949+ Title: "Test Movie",
5050+ Year: 2023,
5151+ Status: "queued",
5252+ Rating: 8.5,
5353+ Notes: "Great movie to watch",
5454+ }
5555+}
5656+5757+func TestMovieRepository(t *testing.T) {
5858+ t.Run("CRUD Operations", func(t *testing.T) {
5959+ db := createMovieTestDB(t)
6060+ repo := NewMovieRepository(db)
6161+ ctx := context.Background()
6262+6363+ t.Run("Create Movie", func(t *testing.T) {
6464+ movie := createSampleMovie()
6565+6666+ id, err := repo.Create(ctx, movie)
6767+ if err != nil {
6868+ t.Errorf("Failed to create movie: %v", err)
6969+ }
7070+7171+ if id == 0 {
7272+ t.Error("Expected non-zero ID")
7373+ }
7474+7575+ if movie.ID != id {
7676+ t.Errorf("Expected movie ID to be set to %d, got %d", id, movie.ID)
7777+ }
7878+7979+ if movie.Added.IsZero() {
8080+ t.Error("Expected Added timestamp to be set")
8181+ }
8282+ })
8383+8484+ t.Run("Get Movie", func(t *testing.T) {
8585+ original := createSampleMovie()
8686+ id, err := repo.Create(ctx, original)
8787+ if err != nil {
8888+ t.Fatalf("Failed to create movie: %v", err)
8989+ }
9090+9191+ retrieved, err := repo.Get(ctx, id)
9292+ if err != nil {
9393+ t.Errorf("Failed to get movie: %v", err)
9494+ }
9595+9696+ if retrieved.Title != original.Title {
9797+ t.Errorf("Expected title %s, got %s", original.Title, retrieved.Title)
9898+ }
9999+ if retrieved.Year != original.Year {
100100+ t.Errorf("Expected year %d, got %d", original.Year, retrieved.Year)
101101+ }
102102+ if retrieved.Status != original.Status {
103103+ t.Errorf("Expected status %s, got %s", original.Status, retrieved.Status)
104104+ }
105105+ if retrieved.Rating != original.Rating {
106106+ t.Errorf("Expected rating %f, got %f", original.Rating, retrieved.Rating)
107107+ }
108108+ if retrieved.Notes != original.Notes {
109109+ t.Errorf("Expected notes %s, got %s", original.Notes, retrieved.Notes)
110110+ }
111111+ })
112112+113113+ t.Run("Update Movie", func(t *testing.T) {
114114+ movie := createSampleMovie()
115115+ id, err := repo.Create(ctx, movie)
116116+ if err != nil {
117117+ t.Fatalf("Failed to create movie: %v", err)
118118+ }
119119+120120+ movie.Title = "Updated Movie"
121121+ movie.Status = "watched"
122122+ movie.Rating = 9.0
123123+ now := time.Now()
124124+ movie.Watched = &now
125125+126126+ err = repo.Update(ctx, movie)
127127+ if err != nil {
128128+ t.Errorf("Failed to update movie: %v", err)
129129+ }
130130+131131+ updated, err := repo.Get(ctx, id)
132132+ if err != nil {
133133+ t.Fatalf("Failed to get updated movie: %v", err)
134134+ }
135135+136136+ if updated.Title != "Updated Movie" {
137137+ t.Errorf("Expected updated title, got %s", updated.Title)
138138+ }
139139+ if updated.Status != "watched" {
140140+ t.Errorf("Expected status watched, got %s", updated.Status)
141141+ }
142142+ if updated.Rating != 9.0 {
143143+ t.Errorf("Expected rating 9.0, got %f", updated.Rating)
144144+ }
145145+ if updated.Watched == nil {
146146+ t.Error("Expected watched time to be set")
147147+ }
148148+ })
149149+150150+ t.Run("Delete Movie", func(t *testing.T) {
151151+ movie := createSampleMovie()
152152+ id, err := repo.Create(ctx, movie)
153153+ if err != nil {
154154+ t.Fatalf("Failed to create movie: %v", err)
155155+ }
156156+157157+ err = repo.Delete(ctx, id)
158158+ if err != nil {
159159+ t.Errorf("Failed to delete movie: %v", err)
160160+ }
161161+162162+ _, err = repo.Get(ctx, id)
163163+ if err == nil {
164164+ t.Error("Expected error when getting deleted movie")
165165+ }
166166+ })
167167+ })
168168+169169+ t.Run("List", func(t *testing.T) {
170170+ db := createMovieTestDB(t)
171171+ repo := NewMovieRepository(db)
172172+ ctx := context.Background()
173173+174174+ movies := []*models.Movie{
175175+ {Title: "Movie 1", Year: 2020, Status: "queued", Rating: 8.0},
176176+ {Title: "Movie 2", Year: 2021, Status: "watched", Rating: 7.5},
177177+ {Title: "Movie 3", Year: 2022, Status: "queued", Rating: 9.0},
178178+ }
179179+180180+ for _, movie := range movies {
181181+ _, err := repo.Create(ctx, movie)
182182+ if err != nil {
183183+ t.Fatalf("Failed to create movie: %v", err)
184184+ }
185185+ }
186186+187187+ t.Run("List All Movies", func(t *testing.T) {
188188+ results, err := repo.List(ctx, MovieListOptions{})
189189+ if err != nil {
190190+ t.Errorf("Failed to list movies: %v", err)
191191+ }
192192+193193+ if len(results) != 3 {
194194+ t.Errorf("Expected 3 movies, got %d", len(results))
195195+ }
196196+ })
197197+198198+ t.Run("List Movies with Status Filter", func(t *testing.T) {
199199+ results, err := repo.List(ctx, MovieListOptions{Status: "queued"})
200200+ if err != nil {
201201+ t.Errorf("Failed to list movies: %v", err)
202202+ }
203203+204204+ if len(results) != 2 {
205205+ t.Errorf("Expected 2 queued movies, got %d", len(results))
206206+ }
207207+208208+ for _, movie := range results {
209209+ if movie.Status != "queued" {
210210+ t.Errorf("Expected queued status, got %s", movie.Status)
211211+ }
212212+ }
213213+ })
214214+215215+ t.Run("List Movies with Year Filter", func(t *testing.T) {
216216+ results, err := repo.List(ctx, MovieListOptions{Year: 2021})
217217+ if err != nil {
218218+ t.Errorf("Failed to list movies: %v", err)
219219+ }
220220+221221+ if len(results) != 1 {
222222+ t.Errorf("Expected 1 movie from 2021, got %d", len(results))
223223+ }
224224+225225+ if len(results) > 0 && results[0].Year != 2021 {
226226+ t.Errorf("Expected year 2021, got %d", results[0].Year)
227227+ }
228228+ })
229229+230230+ t.Run("List Movies with Rating Filter", func(t *testing.T) {
231231+ results, err := repo.List(ctx, MovieListOptions{MinRating: 8.0})
232232+ if err != nil {
233233+ t.Errorf("Failed to list movies: %v", err)
234234+ }
235235+236236+ if len(results) != 2 {
237237+ t.Errorf("Expected 2 movies with rating >= 8.0, got %d", len(results))
238238+ }
239239+240240+ for _, movie := range results {
241241+ if movie.Rating < 8.0 {
242242+ t.Errorf("Expected rating >= 8.0, got %f", movie.Rating)
243243+ }
244244+ }
245245+ })
246246+247247+ t.Run("List Movies with Search", func(t *testing.T) {
248248+ results, err := repo.List(ctx, MovieListOptions{Search: "Movie 1"})
249249+ if err != nil {
250250+ t.Errorf("Failed to list movies: %v", err)
251251+ }
252252+253253+ if len(results) != 1 {
254254+ t.Errorf("Expected 1 movie matching search, got %d", len(results))
255255+ }
256256+257257+ if len(results) > 0 && results[0].Title != "Movie 1" {
258258+ t.Errorf("Expected 'Movie 1', got %s", results[0].Title)
259259+ }
260260+ })
261261+262262+ t.Run("List Movies with Limit", func(t *testing.T) {
263263+ results, err := repo.List(ctx, MovieListOptions{Limit: 2})
264264+ if err != nil {
265265+ t.Errorf("Failed to list movies: %v", err)
266266+ }
267267+268268+ if len(results) != 2 {
269269+ t.Errorf("Expected 2 movies due to limit, got %d", len(results))
270270+ }
271271+ })
272272+ })
273273+274274+ t.Run("Special Methods", func(t *testing.T) {
275275+ db := createMovieTestDB(t)
276276+ repo := NewMovieRepository(db)
277277+ ctx := context.Background()
278278+279279+ movie1 := &models.Movie{Title: "Queued Movie", Status: "queued", Rating: 8.0}
280280+ movie2 := &models.Movie{Title: "Watched Movie", Status: "watched", Rating: 9.0}
281281+ movie3 := &models.Movie{Title: "Another Queued", Status: "queued", Rating: 7.0}
282282+283283+ var movie1ID int64
284284+ for _, movie := range []*models.Movie{movie1, movie2, movie3} {
285285+ id, err := repo.Create(ctx, movie)
286286+ if err != nil {
287287+ t.Fatalf("Failed to create movie: %v", err)
288288+ }
289289+ if movie == movie1 {
290290+ movie1ID = id
291291+ }
292292+ }
293293+294294+ t.Run("GetQueued", func(t *testing.T) {
295295+ results, err := repo.GetQueued(ctx)
296296+ if err != nil {
297297+ t.Errorf("Failed to get queued movies: %v", err)
298298+ }
299299+300300+ if len(results) != 2 {
301301+ t.Errorf("Expected 2 queued movies, got %d", len(results))
302302+ }
303303+304304+ for _, movie := range results {
305305+ if movie.Status != "queued" {
306306+ t.Errorf("Expected queued status, got %s", movie.Status)
307307+ }
308308+ }
309309+ })
310310+311311+ t.Run("GetWatched", func(t *testing.T) {
312312+ results, err := repo.GetWatched(ctx)
313313+ if err != nil {
314314+ t.Errorf("Failed to get watched movies: %v", err)
315315+ }
316316+317317+ if len(results) != 1 {
318318+ t.Errorf("Expected 1 watched movie, got %d", len(results))
319319+ }
320320+321321+ if len(results) > 0 && results[0].Status != "watched" {
322322+ t.Errorf("Expected watched status, got %s", results[0].Status)
323323+ }
324324+ })
325325+326326+ t.Run("MarkWatched", func(t *testing.T) {
327327+ err := repo.MarkWatched(ctx, movie1ID)
328328+ if err != nil {
329329+ t.Errorf("Failed to mark movie as watched: %v", err)
330330+ }
331331+332332+ updated, err := repo.Get(ctx, movie1ID)
333333+ if err != nil {
334334+ t.Fatalf("Failed to get updated movie: %v", err)
335335+ }
336336+337337+ if updated.Status != "watched" {
338338+ t.Errorf("Expected status to be watched, got %s", updated.Status)
339339+ }
340340+341341+ if updated.Watched == nil {
342342+ t.Error("Expected watched timestamp to be set")
343343+ }
344344+ })
345345+ })
346346+347347+ t.Run("Count", func(t *testing.T) {
348348+ db := createMovieTestDB(t)
349349+ repo := NewMovieRepository(db)
350350+ ctx := context.Background()
351351+352352+ movies := []*models.Movie{
353353+ {Title: "Movie 1", Status: "queued", Rating: 8.0},
354354+ {Title: "Movie 2", Status: "watched", Rating: 7.0},
355355+ {Title: "Movie 3", Status: "queued", Rating: 9.0},
356356+ }
357357+358358+ for _, movie := range movies {
359359+ _, err := repo.Create(ctx, movie)
360360+ if err != nil {
361361+ t.Fatalf("Failed to create movie: %v", err)
362362+ }
363363+ }
364364+365365+ t.Run("Count all movies", func(t *testing.T) {
366366+ count, err := repo.Count(ctx, MovieListOptions{})
367367+ if err != nil {
368368+ t.Errorf("Failed to count movies: %v", err)
369369+ }
370370+371371+ if count != 3 {
372372+ t.Errorf("Expected 3 movies, got %d", count)
373373+ }
374374+ })
375375+376376+ t.Run("Count queued movies", func(t *testing.T) {
377377+ count, err := repo.Count(ctx, MovieListOptions{Status: "queued"})
378378+ if err != nil {
379379+ t.Errorf("Failed to count queued movies: %v", err)
380380+ }
381381+382382+ if count != 2 {
383383+ t.Errorf("Expected 2 queued movies, got %d", count)
384384+ }
385385+ })
386386+387387+ t.Run("Count movies by rating", func(t *testing.T) {
388388+ count, err := repo.Count(ctx, MovieListOptions{MinRating: 8.0})
389389+ if err != nil {
390390+ t.Errorf("Failed to count high-rated movies: %v", err)
391391+ }
392392+393393+ if count != 2 {
394394+ t.Errorf("Expected 2 movies with rating >= 8.0, got %d", count)
395395+ }
396396+ })
397397+ })
398398+}
+15-45
internal/repo/repo.go
···11package repo
2233import (
44- "context"
55-66- "stormlightlabs.org/noteleaf/internal/models"
44+ "database/sql"
75)
8699-// Repository defines a general, behavior-focused interface for data access
1010-type Repository interface {
1111- // Create stores a new model and returns its assigned ID
1212- Create(ctx context.Context, model models.Model) (int64, error)
1313-1414- // Get retrieves a model by ID
1515- Get(ctx context.Context, table string, id int64, dest models.Model) error
1616-1717- // Update modifies an existing model
1818- Update(ctx context.Context, model models.Model) error
1919-2020- // Delete removes a model by ID
2121- Delete(ctx context.Context, table string, id int64) error
2222-2323- // List retrieves models with optional filtering and sorting
2424- List(ctx context.Context, table string, opts ListOptions, dest any) error
2525-2626- // Find retrieves models matching specific conditions
2727- Find(ctx context.Context, table string, conditions map[string]any, dest any) error
2828-2929- // Count returns the number of models matching conditions
3030- Count(ctx context.Context, table string, conditions map[string]any) (int64, error)
3131-3232- // Execute runs a custom query with parameters
3333- Execute(ctx context.Context, query string, args ...any) error
3434-3535- // Query runs a custom query and returns results
3636- Query(ctx context.Context, query string, dest any, args ...any) error
77+// Repositories provides access to all resource repositories
88+type Repositories struct {
99+ Tasks *TaskRepository
1010+ Movies *MovieRepository
1111+ TV *TVRepository
1212+ Books *BookRepository
3713}
38143939-// ListOptions defines generic options for listing items
4040-type ListOptions struct {
4141- // field: value pairs for WHERE conditions
4242- Where map[string]any
4343- Limit int
4444- Offset int
4545- // field name to sort by
4646- SortBy string
4747- // "asc" or "desc"
4848- SortOrder string
4949- // general search term
5050- Search string
5151- // fields to search in
5252- SearchFields []string
1515+// NewRepositories creates a new set of repositories
1616+func NewRepositories(db *sql.DB) *Repositories {
1717+ return &Repositories{
1818+ Tasks: NewTaskRepository(db),
1919+ Movies: NewMovieRepository(db),
2020+ TV: NewTVRepository(db),
2121+ Books: NewBookRepository(db),
2222+ }
5323}
+283
internal/repo/repositories_test.go
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "testing"
77+88+ "github.com/google/uuid"
99+ _ "github.com/mattn/go-sqlite3"
1010+ "stormlightlabs.org/noteleaf/internal/models"
1111+)
1212+1313+func createFullTestDB(t *testing.T) *sql.DB {
1414+ db, err := sql.Open("sqlite3", ":memory:")
1515+ if err != nil {
1616+ t.Fatalf("Failed to create in-memory database: %v", err)
1717+ }
1818+1919+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
2020+ t.Fatalf("Failed to enable foreign keys: %v", err)
2121+ }
2222+2323+ // Create all tables
2424+ schema := `
2525+ -- Tasks table
2626+ CREATE TABLE IF NOT EXISTS tasks (
2727+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2828+ uuid TEXT UNIQUE NOT NULL,
2929+ description TEXT NOT NULL,
3030+ status TEXT DEFAULT 'pending',
3131+ priority TEXT,
3232+ project TEXT,
3333+ tags TEXT,
3434+ due DATETIME,
3535+ entry DATETIME DEFAULT CURRENT_TIMESTAMP,
3636+ modified DATETIME DEFAULT CURRENT_TIMESTAMP,
3737+ end DATETIME,
3838+ start DATETIME,
3939+ annotations TEXT
4040+ );
4141+4242+ -- Movies table
4343+ CREATE TABLE IF NOT EXISTS movies (
4444+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4545+ title TEXT NOT NULL,
4646+ year INTEGER,
4747+ status TEXT DEFAULT 'queued',
4848+ rating REAL,
4949+ notes TEXT,
5050+ added DATETIME DEFAULT CURRENT_TIMESTAMP,
5151+ watched DATETIME
5252+ );
5353+5454+ -- TV Shows table
5555+ CREATE TABLE IF NOT EXISTS tv_shows (
5656+ id INTEGER PRIMARY KEY AUTOINCREMENT,
5757+ title TEXT NOT NULL,
5858+ season INTEGER,
5959+ episode INTEGER,
6060+ status TEXT DEFAULT 'queued',
6161+ rating REAL,
6262+ notes TEXT,
6363+ added DATETIME DEFAULT CURRENT_TIMESTAMP,
6464+ last_watched DATETIME
6565+ );
6666+6767+ -- Books table
6868+ CREATE TABLE IF NOT EXISTS books (
6969+ id INTEGER PRIMARY KEY AUTOINCREMENT,
7070+ title TEXT NOT NULL,
7171+ author TEXT,
7272+ status TEXT DEFAULT 'queued',
7373+ progress INTEGER DEFAULT 0,
7474+ pages INTEGER,
7575+ rating REAL,
7676+ notes TEXT,
7777+ added DATETIME DEFAULT CURRENT_TIMESTAMP,
7878+ started DATETIME,
7979+ finished DATETIME
8080+ );
8181+ `
8282+8383+ if _, err := db.Exec(schema); err != nil {
8484+ t.Fatalf("Failed to create schema: %v", err)
8585+ }
8686+8787+ t.Cleanup(func() {
8888+ db.Close()
8989+ })
9090+9191+ return db
9292+}
9393+9494+func TestRepositories(t *testing.T) {
9595+ t.Run("Integration", func(t *testing.T) {
9696+ db := createFullTestDB(t)
9797+ repos := NewRepositories(db)
9898+ ctx := context.Background()
9999+100100+ t.Run("Create all resource types", func(t *testing.T) {
101101+ task := &models.Task{
102102+ UUID: uuid.New().String(),
103103+ Description: "Integration test task",
104104+ Status: "pending",
105105+ Project: "integration",
106106+ }
107107+ taskID, err := repos.Tasks.Create(ctx, task)
108108+ if err != nil {
109109+ t.Errorf("Failed to create task: %v", err)
110110+ }
111111+ if taskID == 0 {
112112+ t.Error("Expected non-zero task ID")
113113+ }
114114+115115+ movie := &models.Movie{
116116+ Title: "Integration Movie",
117117+ Year: 2023,
118118+ Status: "queued",
119119+ Rating: 8.5,
120120+ }
121121+ movieID, err := repos.Movies.Create(ctx, movie)
122122+ if err != nil {
123123+ t.Errorf("Failed to create movie: %v", err)
124124+ }
125125+ if movieID == 0 {
126126+ t.Error("Expected non-zero movie ID")
127127+ }
128128+129129+ tvShow := &models.TVShow{
130130+ Title: "Integration Series",
131131+ Season: 1,
132132+ Episode: 1,
133133+ Status: "queued",
134134+ Rating: 9.0,
135135+ }
136136+ tvID, err := repos.TV.Create(ctx, tvShow)
137137+ if err != nil {
138138+ t.Errorf("Failed to create TV show: %v", err)
139139+ }
140140+ if tvID == 0 {
141141+ t.Error("Expected non-zero TV show ID")
142142+ }
143143+144144+ book := &models.Book{
145145+ Title: "Integration Book",
146146+ Author: "Test Author",
147147+ Status: "queued",
148148+ Progress: 0,
149149+ Pages: 300,
150150+ }
151151+ bookID, err := repos.Books.Create(ctx, book)
152152+ if err != nil {
153153+ t.Errorf("Failed to create book: %v", err)
154154+ }
155155+ if bookID == 0 {
156156+ t.Error("Expected non-zero book ID")
157157+ }
158158+ })
159159+160160+ t.Run("Retrieve all resources", func(t *testing.T) {
161161+ tasks, err := repos.Tasks.List(ctx, TaskListOptions{})
162162+ if err != nil {
163163+ t.Errorf("Failed to list tasks: %v", err)
164164+ }
165165+ if len(tasks) != 1 {
166166+ t.Errorf("Expected 1 task, got %d", len(tasks))
167167+ }
168168+169169+ movies, err := repos.Movies.List(ctx, MovieListOptions{})
170170+ if err != nil {
171171+ t.Errorf("Failed to list movies: %v", err)
172172+ }
173173+ if len(movies) != 1 {
174174+ t.Errorf("Expected 1 movie, got %d", len(movies))
175175+ }
176176+177177+ tvShows, err := repos.TV.List(ctx, TVListOptions{})
178178+ if err != nil {
179179+ t.Errorf("Failed to list TV shows: %v", err)
180180+ }
181181+ if len(tvShows) != 1 {
182182+ t.Errorf("Expected 1 TV show, got %d", len(tvShows))
183183+ }
184184+185185+ books, err := repos.Books.List(ctx, BookListOptions{})
186186+ if err != nil {
187187+ t.Errorf("Failed to list books: %v", err)
188188+ }
189189+ if len(books) != 1 {
190190+ t.Errorf("Expected 1 book, got %d", len(books))
191191+ }
192192+ })
193193+194194+ t.Run("Count all resources", func(t *testing.T) {
195195+ taskCount, err := repos.Tasks.Count(ctx, TaskListOptions{})
196196+ if err != nil {
197197+ t.Errorf("Failed to count tasks: %v", err)
198198+ }
199199+ if taskCount != 1 {
200200+ t.Errorf("Expected 1 task, got %d", taskCount)
201201+ }
202202+203203+ movieCount, err := repos.Movies.Count(ctx, MovieListOptions{})
204204+ if err != nil {
205205+ t.Errorf("Failed to count movies: %v", err)
206206+ }
207207+ if movieCount != 1 {
208208+ t.Errorf("Expected 1 movie, got %d", movieCount)
209209+ }
210210+211211+ tvCount, err := repos.TV.Count(ctx, TVListOptions{})
212212+ if err != nil {
213213+ t.Errorf("Failed to count TV shows: %v", err)
214214+ }
215215+ if tvCount != 1 {
216216+ t.Errorf("Expected 1 TV show, got %d", tvCount)
217217+ }
218218+219219+ bookCount, err := repos.Books.Count(ctx, BookListOptions{})
220220+ if err != nil {
221221+ t.Errorf("Failed to count books: %v", err)
222222+ }
223223+ if bookCount != 1 {
224224+ t.Errorf("Expected 1 book, got %d", bookCount)
225225+ }
226226+ })
227227+228228+ t.Run("Use specialized methods", func(t *testing.T) {
229229+ pendingTasks, err := repos.Tasks.GetPending(ctx)
230230+ if err != nil {
231231+ t.Errorf("Failed to get pending tasks: %v", err)
232232+ }
233233+ if len(pendingTasks) != 1 {
234234+ t.Errorf("Expected 1 pending task, got %d", len(pendingTasks))
235235+ }
236236+237237+ queuedMovies, err := repos.Movies.GetQueued(ctx)
238238+ if err != nil {
239239+ t.Errorf("Failed to get queued movies: %v", err)
240240+ }
241241+ if len(queuedMovies) != 1 {
242242+ t.Errorf("Expected 1 queued movie, got %d", len(queuedMovies))
243243+ }
244244+245245+ queuedTV, err := repos.TV.GetQueued(ctx)
246246+ if err != nil {
247247+ t.Errorf("Failed to get queued TV shows: %v", err)
248248+ }
249249+ if len(queuedTV) != 1 {
250250+ t.Errorf("Expected 1 queued TV show, got %d", len(queuedTV))
251251+ }
252252+253253+ queuedBooks, err := repos.Books.GetQueued(ctx)
254254+ if err != nil {
255255+ t.Errorf("Failed to get queued books: %v", err)
256256+ }
257257+ if len(queuedBooks) != 1 {
258258+ t.Errorf("Expected 1 queued book, got %d", len(queuedBooks))
259259+ }
260260+ })
261261+ })
262262+263263+ t.Run("New", func(t *testing.T) {
264264+ db := createFullTestDB(t)
265265+ repos := NewRepositories(db)
266266+267267+ t.Run("All repositories are initialized", func(t *testing.T) {
268268+ if repos.Tasks == nil {
269269+ t.Error("Tasks repository should be initialized")
270270+ }
271271+ if repos.Movies == nil {
272272+ t.Error("Movies repository should be initialized")
273273+ }
274274+ if repos.TV == nil {
275275+ t.Error("TV repository should be initialized")
276276+ }
277277+ if repos.Books == nil {
278278+ t.Error("Books repository should be initialized")
279279+ }
280280+ })
281281+282282+ })
283283+}
+374
internal/repo/task_repository.go
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "fmt"
77+ "strings"
88+ "time"
99+1010+ "stormlightlabs.org/noteleaf/internal/models"
1111+)
1212+1313+// TaskRepository provides database operations for tasks
1414+type TaskRepository struct {
1515+ db *sql.DB
1616+}
1717+1818+// NewTaskRepository creates a new task repository
1919+func NewTaskRepository(db *sql.DB) *TaskRepository {
2020+ return &TaskRepository{db: db}
2121+}
2222+2323+// Create stores a new task and returns its assigned ID
2424+func (r *TaskRepository) Create(ctx context.Context, task *models.Task) (int64, error) {
2525+ now := time.Now()
2626+ task.Entry = now
2727+ task.Modified = now
2828+2929+ tags, err := task.MarshalTags()
3030+ if err != nil {
3131+ return 0, fmt.Errorf("failed to marshal tags: %w", err)
3232+ }
3333+3434+ annotations, err := task.MarshalAnnotations()
3535+ if err != nil {
3636+ return 0, fmt.Errorf("failed to marshal annotations: %w", err)
3737+ }
3838+3939+ query := `
4040+ INSERT INTO tasks (uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations)
4141+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
4242+4343+ result, err := r.db.ExecContext(ctx, query,
4444+ task.UUID, task.Description, task.Status, task.Priority, task.Project,
4545+ tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations)
4646+ if err != nil {
4747+ return 0, fmt.Errorf("failed to insert task: %w", err)
4848+ }
4949+5050+ id, err := result.LastInsertId()
5151+ if err != nil {
5252+ return 0, fmt.Errorf("failed to get last insert id: %w", err)
5353+ }
5454+5555+ task.ID = id
5656+ return id, nil
5757+}
5858+5959+// Get retrieves a task by ID
6060+func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) {
6161+ query := `
6262+ SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations
6363+ FROM tasks WHERE id = ?`
6464+6565+ task := &models.Task{}
6666+ var tags, annotations sql.NullString
6767+6868+ err := r.db.QueryRowContext(ctx, query, id).Scan(
6969+ &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project,
7070+ &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations)
7171+ if err != nil {
7272+ return nil, fmt.Errorf("failed to get task: %w", err)
7373+ }
7474+7575+ if tags.Valid {
7676+ if err := task.UnmarshalTags(tags.String); err != nil {
7777+ return nil, fmt.Errorf("failed to unmarshal tags: %w", err)
7878+ }
7979+ }
8080+8181+ if annotations.Valid {
8282+ if err := task.UnmarshalAnnotations(annotations.String); err != nil {
8383+ return nil, fmt.Errorf("failed to unmarshal annotations: %w", err)
8484+ }
8585+ }
8686+8787+ return task, nil
8888+}
8989+9090+// Update modifies an existing task
9191+func (r *TaskRepository) Update(ctx context.Context, task *models.Task) error {
9292+ task.Modified = time.Now()
9393+9494+ tags, err := task.MarshalTags()
9595+ if err != nil {
9696+ return fmt.Errorf("failed to marshal tags: %w", err)
9797+ }
9898+9999+ annotations, err := task.MarshalAnnotations()
100100+ if err != nil {
101101+ return fmt.Errorf("failed to marshal annotations: %w", err)
102102+ }
103103+104104+ query := `
105105+ UPDATE tasks SET uuid = ?, description = ?, status = ?, priority = ?, project = ?,
106106+ tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?
107107+ WHERE id = ?`
108108+109109+ _, err = r.db.ExecContext(ctx, query,
110110+ task.UUID, task.Description, task.Status, task.Priority, task.Project,
111111+ tags, task.Due, task.Modified, task.End, task.Start, annotations, task.ID)
112112+ if err != nil {
113113+ return fmt.Errorf("failed to update task: %w", err)
114114+ }
115115+116116+ return nil
117117+}
118118+119119+// Delete removes a task by ID
120120+func (r *TaskRepository) Delete(ctx context.Context, id int64) error {
121121+ query := "DELETE FROM tasks WHERE id = ?"
122122+ _, err := r.db.ExecContext(ctx, query, id)
123123+ if err != nil {
124124+ return fmt.Errorf("failed to delete task: %w", err)
125125+ }
126126+ return nil
127127+}
128128+129129+// List retrieves tasks with optional filtering and sorting
130130+func (r *TaskRepository) List(ctx context.Context, opts TaskListOptions) ([]*models.Task, error) {
131131+ query := r.buildListQuery(opts)
132132+ args := r.buildListArgs(opts)
133133+134134+ rows, err := r.db.QueryContext(ctx, query, args...)
135135+ if err != nil {
136136+ return nil, fmt.Errorf("failed to list tasks: %w", err)
137137+ }
138138+ defer rows.Close()
139139+140140+ var tasks []*models.Task
141141+ for rows.Next() {
142142+ task := &models.Task{}
143143+ if err := r.scanTaskRow(rows, task); err != nil {
144144+ return nil, err
145145+ }
146146+ tasks = append(tasks, task)
147147+ }
148148+149149+ return tasks, rows.Err()
150150+}
151151+152152+func (r *TaskRepository) buildListQuery(opts TaskListOptions) string {
153153+ query := "SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations FROM tasks"
154154+155155+ var conditions []string
156156+157157+ if opts.Status != "" {
158158+ conditions = append(conditions, "status = ?")
159159+ }
160160+ if opts.Priority != "" {
161161+ conditions = append(conditions, "priority = ?")
162162+ }
163163+ if opts.Project != "" {
164164+ conditions = append(conditions, "project = ?")
165165+ }
166166+ if !opts.DueAfter.IsZero() {
167167+ conditions = append(conditions, "due >= ?")
168168+ }
169169+ if !opts.DueBefore.IsZero() {
170170+ conditions = append(conditions, "due <= ?")
171171+ }
172172+173173+ if opts.Search != "" {
174174+ searchConditions := []string{
175175+ "description LIKE ?",
176176+ "project LIKE ?",
177177+ "tags LIKE ?",
178178+ }
179179+ conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
180180+ }
181181+182182+ if len(conditions) > 0 {
183183+ query += " WHERE " + strings.Join(conditions, " AND ")
184184+ }
185185+186186+ if opts.SortBy != "" {
187187+ order := "ASC"
188188+ if strings.ToUpper(opts.SortOrder) == "DESC" {
189189+ order = "DESC"
190190+ }
191191+ query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order)
192192+ } else {
193193+ query += " ORDER BY modified DESC"
194194+ }
195195+196196+ if opts.Limit > 0 {
197197+ query += fmt.Sprintf(" LIMIT %d", opts.Limit)
198198+ if opts.Offset > 0 {
199199+ query += fmt.Sprintf(" OFFSET %d", opts.Offset)
200200+ }
201201+ }
202202+203203+ return query
204204+}
205205+206206+func (r *TaskRepository) buildListArgs(opts TaskListOptions) []any {
207207+ var args []any
208208+209209+ if opts.Status != "" {
210210+ args = append(args, opts.Status)
211211+ }
212212+ if opts.Priority != "" {
213213+ args = append(args, opts.Priority)
214214+ }
215215+ if opts.Project != "" {
216216+ args = append(args, opts.Project)
217217+ }
218218+ if !opts.DueAfter.IsZero() {
219219+ args = append(args, opts.DueAfter)
220220+ }
221221+ if !opts.DueBefore.IsZero() {
222222+ args = append(args, opts.DueBefore)
223223+ }
224224+225225+ // Search args
226226+ if opts.Search != "" {
227227+ searchPattern := "%" + opts.Search + "%"
228228+ // Add search pattern for each search field
229229+ args = append(args, searchPattern, searchPattern, searchPattern)
230230+ }
231231+232232+ return args
233233+}
234234+235235+func (r *TaskRepository) scanTaskRow(rows *sql.Rows, task *models.Task) error {
236236+ var tags, annotations sql.NullString
237237+238238+ err := rows.Scan(&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority,
239239+ &task.Project, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations)
240240+ if err != nil {
241241+ return fmt.Errorf("failed to scan task row: %w", err)
242242+ }
243243+244244+ if tags.Valid {
245245+ if err := task.UnmarshalTags(tags.String); err != nil {
246246+ return fmt.Errorf("failed to unmarshal tags: %w", err)
247247+ }
248248+ }
249249+250250+ if annotations.Valid {
251251+ if err := task.UnmarshalAnnotations(annotations.String); err != nil {
252252+ return fmt.Errorf("failed to unmarshal annotations: %w", err)
253253+ }
254254+ }
255255+256256+ return nil
257257+}
258258+259259+// Find retrieves tasks matching specific conditions
260260+func (r *TaskRepository) Find(ctx context.Context, conditions TaskListOptions) ([]*models.Task, error) {
261261+ return r.List(ctx, conditions)
262262+}
263263+264264+// Count returns the number of tasks matching conditions
265265+func (r *TaskRepository) Count(ctx context.Context, opts TaskListOptions) (int64, error) {
266266+ query := "SELECT COUNT(*) FROM tasks"
267267+ args := []any{}
268268+269269+ var conditions []string
270270+271271+ if opts.Status != "" {
272272+ conditions = append(conditions, "status = ?")
273273+ args = append(args, opts.Status)
274274+ }
275275+ if opts.Priority != "" {
276276+ conditions = append(conditions, "priority = ?")
277277+ args = append(args, opts.Priority)
278278+ }
279279+ if opts.Project != "" {
280280+ conditions = append(conditions, "project = ?")
281281+ args = append(args, opts.Project)
282282+ }
283283+ if !opts.DueAfter.IsZero() {
284284+ conditions = append(conditions, "due >= ?")
285285+ args = append(args, opts.DueAfter)
286286+ }
287287+ if !opts.DueBefore.IsZero() {
288288+ conditions = append(conditions, "due <= ?")
289289+ args = append(args, opts.DueBefore)
290290+ }
291291+292292+ if opts.Search != "" {
293293+ searchConditions := []string{
294294+ "description LIKE ?",
295295+ "project LIKE ?",
296296+ "tags LIKE ?",
297297+ }
298298+ conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
299299+ searchPattern := "%" + opts.Search + "%"
300300+ args = append(args, searchPattern, searchPattern, searchPattern)
301301+ }
302302+303303+ if len(conditions) > 0 {
304304+ query += " WHERE " + strings.Join(conditions, " AND ")
305305+ }
306306+307307+ var count int64
308308+ err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
309309+ if err != nil {
310310+ return 0, fmt.Errorf("failed to count tasks: %w", err)
311311+ }
312312+313313+ return count, nil
314314+}
315315+316316+// GetByUUID retrieves a task by UUID
317317+func (r *TaskRepository) GetByUUID(ctx context.Context, uuid string) (*models.Task, error) {
318318+ query := `
319319+ SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations
320320+ FROM tasks WHERE uuid = ?`
321321+322322+ task := &models.Task{}
323323+ var tags, annotations sql.NullString
324324+325325+ err := r.db.QueryRowContext(ctx, query, uuid).Scan(
326326+ &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project,
327327+ &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations)
328328+ if err != nil {
329329+ return nil, fmt.Errorf("failed to get task by UUID: %w", err)
330330+ }
331331+332332+ if tags.Valid {
333333+ if err := task.UnmarshalTags(tags.String); err != nil {
334334+ return nil, fmt.Errorf("failed to unmarshal tags: %w", err)
335335+ }
336336+ }
337337+338338+ if annotations.Valid {
339339+ if err := task.UnmarshalAnnotations(annotations.String); err != nil {
340340+ return nil, fmt.Errorf("failed to unmarshal annotations: %w", err)
341341+ }
342342+ }
343343+344344+ return task, nil
345345+}
346346+347347+// GetPending retrieves all pending tasks
348348+func (r *TaskRepository) GetPending(ctx context.Context) ([]*models.Task, error) {
349349+ return r.List(ctx, TaskListOptions{Status: "pending"})
350350+}
351351+352352+// GetCompleted retrieves all completed tasks
353353+func (r *TaskRepository) GetCompleted(ctx context.Context) ([]*models.Task, error) {
354354+ return r.List(ctx, TaskListOptions{Status: "completed"})
355355+}
356356+357357+// GetByProject retrieves all tasks for a specific project
358358+func (r *TaskRepository) GetByProject(ctx context.Context, project string) ([]*models.Task, error) {
359359+ return r.List(ctx, TaskListOptions{Project: project})
360360+}
361361+362362+// TaskListOptions defines options for listing tasks
363363+type TaskListOptions struct {
364364+ Status string
365365+ Priority string
366366+ Project string
367367+ DueAfter time.Time
368368+ DueBefore time.Time
369369+ Search string
370370+ SortBy string
371371+ SortOrder string
372372+ Limit int
373373+ Offset int
374374+}
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "fmt"
77+ "strings"
88+ "time"
99+1010+ "stormlightlabs.org/noteleaf/internal/models"
1111+)
1212+1313+// TVRepository provides database operations for TV shows
1414+type TVRepository struct {
1515+ db *sql.DB
1616+}
1717+1818+// NewTVRepository creates a new TV show repository
1919+func NewTVRepository(db *sql.DB) *TVRepository {
2020+ return &TVRepository{db: db}
2121+}
2222+2323+// Create stores a new TV show and returns its assigned ID
2424+func (r *TVRepository) Create(ctx context.Context, tvShow *models.TVShow) (int64, error) {
2525+ now := time.Now()
2626+ tvShow.Added = now
2727+2828+ query := `
2929+ INSERT INTO tv_shows (title, season, episode, status, rating, notes, added, last_watched)
3030+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
3131+3232+ result, err := r.db.ExecContext(ctx, query,
3333+ tvShow.Title, tvShow.Season, tvShow.Episode, tvShow.Status, tvShow.Rating,
3434+ tvShow.Notes, tvShow.Added, tvShow.LastWatched)
3535+ if err != nil {
3636+ return 0, fmt.Errorf("failed to insert TV show: %w", err)
3737+ }
3838+3939+ id, err := result.LastInsertId()
4040+ if err != nil {
4141+ return 0, fmt.Errorf("failed to get last insert id: %w", err)
4242+ }
4343+4444+ tvShow.ID = id
4545+ return id, nil
4646+}
4747+4848+// Get retrieves a TV show by ID
4949+func (r *TVRepository) Get(ctx context.Context, id int64) (*models.TVShow, error) {
5050+ query := `
5151+ SELECT id, title, season, episode, status, rating, notes, added, last_watched
5252+ FROM tv_shows WHERE id = ?`
5353+5454+ tvShow := &models.TVShow{}
5555+ err := r.db.QueryRowContext(ctx, query, id).Scan(
5656+ &tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status,
5757+ &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched)
5858+ if err != nil {
5959+ return nil, fmt.Errorf("failed to get TV show: %w", err)
6060+ }
6161+6262+ return tvShow, nil
6363+}
6464+6565+// Update modifies an existing TV show
6666+func (r *TVRepository) Update(ctx context.Context, tvShow *models.TVShow) error {
6767+ query := `
6868+ UPDATE tv_shows SET title = ?, season = ?, episode = ?, status = ?, rating = ?,
6969+ notes = ?, last_watched = ?
7070+ WHERE id = ?`
7171+7272+ _, err := r.db.ExecContext(ctx, query,
7373+ tvShow.Title, tvShow.Season, tvShow.Episode, tvShow.Status, tvShow.Rating,
7474+ tvShow.Notes, tvShow.LastWatched, tvShow.ID)
7575+ if err != nil {
7676+ return fmt.Errorf("failed to update TV show: %w", err)
7777+ }
7878+7979+ return nil
8080+}
8181+8282+// Delete removes a TV show by ID
8383+func (r *TVRepository) Delete(ctx context.Context, id int64) error {
8484+ query := "DELETE FROM tv_shows WHERE id = ?"
8585+ _, err := r.db.ExecContext(ctx, query, id)
8686+ if err != nil {
8787+ return fmt.Errorf("failed to delete TV show: %w", err)
8888+ }
8989+ return nil
9090+}
9191+9292+// List retrieves TV shows with optional filtering and sorting
9393+func (r *TVRepository) List(ctx context.Context, opts TVListOptions) ([]*models.TVShow, error) {
9494+ query := r.buildListQuery(opts)
9595+ args := r.buildListArgs(opts)
9696+9797+ rows, err := r.db.QueryContext(ctx, query, args...)
9898+ if err != nil {
9999+ return nil, fmt.Errorf("failed to list TV shows: %w", err)
100100+ }
101101+ defer rows.Close()
102102+103103+ var tvShows []*models.TVShow
104104+ for rows.Next() {
105105+ tvShow := &models.TVShow{}
106106+ if err := r.scanTVShowRow(rows, tvShow); err != nil {
107107+ return nil, err
108108+ }
109109+ tvShows = append(tvShows, tvShow)
110110+ }
111111+112112+ return tvShows, rows.Err()
113113+}
114114+115115+func (r *TVRepository) buildListQuery(opts TVListOptions) string {
116116+ query := "SELECT id, title, season, episode, status, rating, notes, added, last_watched FROM tv_shows"
117117+118118+ var conditions []string
119119+120120+ if opts.Status != "" {
121121+ conditions = append(conditions, "status = ?")
122122+ }
123123+ if opts.Title != "" {
124124+ conditions = append(conditions, "title = ?")
125125+ }
126126+ if opts.Season > 0 {
127127+ conditions = append(conditions, "season = ?")
128128+ }
129129+ if opts.MinRating > 0 {
130130+ conditions = append(conditions, "rating >= ?")
131131+ }
132132+133133+ if opts.Search != "" {
134134+ searchConditions := []string{
135135+ "title LIKE ?",
136136+ "notes LIKE ?",
137137+ }
138138+ conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
139139+ }
140140+141141+ if len(conditions) > 0 {
142142+ query += " WHERE " + strings.Join(conditions, " AND ")
143143+ }
144144+145145+ if opts.SortBy != "" {
146146+ order := "ASC"
147147+ if strings.ToUpper(opts.SortOrder) == "DESC" {
148148+ order = "DESC"
149149+ }
150150+ query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order)
151151+ } else {
152152+ query += " ORDER BY title, season, episode"
153153+ }
154154+155155+ if opts.Limit > 0 {
156156+ query += fmt.Sprintf(" LIMIT %d", opts.Limit)
157157+ if opts.Offset > 0 {
158158+ query += fmt.Sprintf(" OFFSET %d", opts.Offset)
159159+ }
160160+ }
161161+162162+ return query
163163+}
164164+165165+func (r *TVRepository) buildListArgs(opts TVListOptions) []any {
166166+ var args []any
167167+168168+ if opts.Status != "" {
169169+ args = append(args, opts.Status)
170170+ }
171171+ if opts.Title != "" {
172172+ args = append(args, opts.Title)
173173+ }
174174+ if opts.Season > 0 {
175175+ args = append(args, opts.Season)
176176+ }
177177+ if opts.MinRating > 0 {
178178+ args = append(args, opts.MinRating)
179179+ }
180180+181181+ if opts.Search != "" {
182182+ searchPattern := "%" + opts.Search + "%"
183183+ args = append(args, searchPattern, searchPattern)
184184+ }
185185+186186+ return args
187187+}
188188+189189+func (r *TVRepository) scanTVShowRow(rows *sql.Rows, tvShow *models.TVShow) error {
190190+ return rows.Scan(&tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status,
191191+ &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched)
192192+}
193193+194194+// Find retrieves TV shows matching specific conditions
195195+func (r *TVRepository) Find(ctx context.Context, conditions TVListOptions) ([]*models.TVShow, error) {
196196+ return r.List(ctx, conditions)
197197+}
198198+199199+// Count returns the number of TV shows matching conditions
200200+func (r *TVRepository) Count(ctx context.Context, opts TVListOptions) (int64, error) {
201201+ query := "SELECT COUNT(*) FROM tv_shows"
202202+ args := []any{}
203203+204204+ var conditions []string
205205+206206+ if opts.Status != "" {
207207+ conditions = append(conditions, "status = ?")
208208+ args = append(args, opts.Status)
209209+ }
210210+ if opts.Title != "" {
211211+ conditions = append(conditions, "title = ?")
212212+ args = append(args, opts.Title)
213213+ }
214214+ if opts.Season > 0 {
215215+ conditions = append(conditions, "season = ?")
216216+ args = append(args, opts.Season)
217217+ }
218218+ if opts.MinRating > 0 {
219219+ conditions = append(conditions, "rating >= ?")
220220+ args = append(args, opts.MinRating)
221221+ }
222222+223223+ if opts.Search != "" {
224224+ searchConditions := []string{
225225+ "title LIKE ?",
226226+ "notes LIKE ?",
227227+ }
228228+ conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
229229+ searchPattern := "%" + opts.Search + "%"
230230+ args = append(args, searchPattern, searchPattern)
231231+ }
232232+233233+ if len(conditions) > 0 {
234234+ query += " WHERE " + strings.Join(conditions, " AND ")
235235+ }
236236+237237+ var count int64
238238+ err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
239239+ if err != nil {
240240+ return 0, fmt.Errorf("failed to count TV shows: %w", err)
241241+ }
242242+243243+ return count, nil
244244+}
245245+246246+// GetQueued retrieves all TV shows in the queue
247247+func (r *TVRepository) GetQueued(ctx context.Context) ([]*models.TVShow, error) {
248248+ return r.List(ctx, TVListOptions{Status: "queued"})
249249+}
250250+251251+// GetWatching retrieves all TV shows currently being watched
252252+func (r *TVRepository) GetWatching(ctx context.Context) ([]*models.TVShow, error) {
253253+ return r.List(ctx, TVListOptions{Status: "watching"})
254254+}
255255+256256+// GetWatched retrieves all watched TV shows
257257+func (r *TVRepository) GetWatched(ctx context.Context) ([]*models.TVShow, error) {
258258+ return r.List(ctx, TVListOptions{Status: "watched"})
259259+}
260260+261261+// GetByTitle retrieves all episodes for a specific TV show title
262262+func (r *TVRepository) GetByTitle(ctx context.Context, title string) ([]*models.TVShow, error) {
263263+ return r.List(ctx, TVListOptions{Title: title})
264264+}
265265+266266+// GetBySeason retrieves all episodes for a specific season of a show
267267+func (r *TVRepository) GetBySeason(ctx context.Context, title string, season int) ([]*models.TVShow, error) {
268268+ return r.List(ctx, TVListOptions{Title: title, Season: season})
269269+}
270270+271271+// MarkWatched marks a TV show episode as watched
272272+func (r *TVRepository) MarkWatched(ctx context.Context, id int64) error {
273273+ tvShow, err := r.Get(ctx, id)
274274+ if err != nil {
275275+ return err
276276+ }
277277+278278+ now := time.Now()
279279+ tvShow.Status = "watched"
280280+ tvShow.LastWatched = &now
281281+282282+ return r.Update(ctx, tvShow)
283283+}
284284+285285+// StartWatching marks a TV show as currently being watched
286286+func (r *TVRepository) StartWatching(ctx context.Context, id int64) error {
287287+ tvShow, err := r.Get(ctx, id)
288288+ if err != nil {
289289+ return err
290290+ }
291291+292292+ now := time.Now()
293293+ tvShow.Status = "watching"
294294+ tvShow.LastWatched = &now
295295+296296+ return r.Update(ctx, tvShow)
297297+}
298298+299299+// TVListOptions defines options for listing TV shows
300300+type TVListOptions struct {
301301+ Status string
302302+ Title string
303303+ Season int
304304+ MinRating float64
305305+ Search string
306306+ SortBy string
307307+ SortOrder string
308308+ Limit int
309309+ Offset int
310310+}
+512
internal/repo/tv_repository_test.go
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "testing"
77+ "time"
88+99+ _ "github.com/mattn/go-sqlite3"
1010+ "stormlightlabs.org/noteleaf/internal/models"
1111+)
1212+1313+func createTVTestDB(t *testing.T) *sql.DB {
1414+ db, err := sql.Open("sqlite3", ":memory:")
1515+ if err != nil {
1616+ t.Fatalf("Failed to create in-memory database: %v", err)
1717+ }
1818+1919+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
2020+ t.Fatalf("Failed to enable foreign keys: %v", err)
2121+ }
2222+2323+ schema := `
2424+ CREATE TABLE IF NOT EXISTS tv_shows (
2525+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2626+ title TEXT NOT NULL,
2727+ season INTEGER,
2828+ episode INTEGER,
2929+ status TEXT DEFAULT 'queued',
3030+ rating REAL,
3131+ notes TEXT,
3232+ added DATETIME DEFAULT CURRENT_TIMESTAMP,
3333+ last_watched DATETIME
3434+ );
3535+ `
3636+3737+ if _, err := db.Exec(schema); err != nil {
3838+ t.Fatalf("Failed to create schema: %v", err)
3939+ }
4040+4141+ t.Cleanup(func() {
4242+ db.Close()
4343+ })
4444+4545+ return db
4646+}
4747+4848+func createSampleTVShow() *models.TVShow {
4949+ return &models.TVShow{
5050+ Title: "Test Show",
5151+ Season: 1,
5252+ Episode: 1,
5353+ Status: "queued",
5454+ Rating: 9.0,
5555+ Notes: "Excellent series",
5656+ }
5757+}
5858+5959+func TestTVRepository(t *testing.T) {
6060+ t.Run("CRUD Operations", func(t *testing.T) {
6161+ db := createTVTestDB(t)
6262+ repo := NewTVRepository(db)
6363+ ctx := context.Background()
6464+6565+ t.Run("Create TV Show", func(t *testing.T) {
6666+ tvShow := createSampleTVShow()
6767+6868+ id, err := repo.Create(ctx, tvShow)
6969+ if err != nil {
7070+ t.Errorf("Failed to create TV show: %v", err)
7171+ }
7272+7373+ if id == 0 {
7474+ t.Error("Expected non-zero ID")
7575+ }
7676+7777+ if tvShow.ID != id {
7878+ t.Errorf("Expected TV show ID to be set to %d, got %d", id, tvShow.ID)
7979+ }
8080+8181+ if tvShow.Added.IsZero() {
8282+ t.Error("Expected Added timestamp to be set")
8383+ }
8484+ })
8585+8686+ t.Run("Get TV Show", func(t *testing.T) {
8787+ original := createSampleTVShow()
8888+ id, err := repo.Create(ctx, original)
8989+ if err != nil {
9090+ t.Fatalf("Failed to create TV show: %v", err)
9191+ }
9292+9393+ retrieved, err := repo.Get(ctx, id)
9494+ if err != nil {
9595+ t.Errorf("Failed to get TV show: %v", err)
9696+ }
9797+9898+ if retrieved.Title != original.Title {
9999+ t.Errorf("Expected title %s, got %s", original.Title, retrieved.Title)
100100+ }
101101+ if retrieved.Season != original.Season {
102102+ t.Errorf("Expected season %d, got %d", original.Season, retrieved.Season)
103103+ }
104104+ if retrieved.Episode != original.Episode {
105105+ t.Errorf("Expected episode %d, got %d", original.Episode, retrieved.Episode)
106106+ }
107107+ if retrieved.Status != original.Status {
108108+ t.Errorf("Expected status %s, got %s", original.Status, retrieved.Status)
109109+ }
110110+ if retrieved.Rating != original.Rating {
111111+ t.Errorf("Expected rating %f, got %f", original.Rating, retrieved.Rating)
112112+ }
113113+ if retrieved.Notes != original.Notes {
114114+ t.Errorf("Expected notes %s, got %s", original.Notes, retrieved.Notes)
115115+ }
116116+ })
117117+118118+ t.Run("Update TV Show", func(t *testing.T) {
119119+ tvShow := createSampleTVShow()
120120+ id, err := repo.Create(ctx, tvShow)
121121+ if err != nil {
122122+ t.Fatalf("Failed to create TV show: %v", err)
123123+ }
124124+125125+ tvShow.Title = "Updated Show"
126126+ tvShow.Season = 2
127127+ tvShow.Episode = 5
128128+ tvShow.Status = "watching"
129129+ tvShow.Rating = 9.5
130130+ now := time.Now()
131131+ tvShow.LastWatched = &now
132132+133133+ err = repo.Update(ctx, tvShow)
134134+ if err != nil {
135135+ t.Errorf("Failed to update TV show: %v", err)
136136+ }
137137+138138+ updated, err := repo.Get(ctx, id)
139139+ if err != nil {
140140+ t.Fatalf("Failed to get updated TV show: %v", err)
141141+ }
142142+143143+ if updated.Title != "Updated Show" {
144144+ t.Errorf("Expected updated title, got %s", updated.Title)
145145+ }
146146+ if updated.Season != 2 {
147147+ t.Errorf("Expected season 2, got %d", updated.Season)
148148+ }
149149+ if updated.Episode != 5 {
150150+ t.Errorf("Expected episode 5, got %d", updated.Episode)
151151+ }
152152+ if updated.Status != "watching" {
153153+ t.Errorf("Expected status watching, got %s", updated.Status)
154154+ }
155155+ if updated.Rating != 9.5 {
156156+ t.Errorf("Expected rating 9.5, got %f", updated.Rating)
157157+ }
158158+ if updated.LastWatched == nil {
159159+ t.Error("Expected last watched time to be set")
160160+ }
161161+ })
162162+163163+ t.Run("Delete TV Show", func(t *testing.T) {
164164+ tvShow := createSampleTVShow()
165165+ id, err := repo.Create(ctx, tvShow)
166166+ if err != nil {
167167+ t.Fatalf("Failed to create TV show: %v", err)
168168+ }
169169+170170+ err = repo.Delete(ctx, id)
171171+ if err != nil {
172172+ t.Errorf("Failed to delete TV show: %v", err)
173173+ }
174174+175175+ _, err = repo.Get(ctx, id)
176176+ if err == nil {
177177+ t.Error("Expected error when getting deleted TV show")
178178+ }
179179+ })
180180+ })
181181+182182+ t.Run("List", func(t *testing.T) {
183183+ db := createTVTestDB(t)
184184+ repo := NewTVRepository(db)
185185+ ctx := context.Background()
186186+187187+ tvShows := []*models.TVShow{
188188+ {Title: "Show A", Season: 1, Episode: 1, Status: "queued", Rating: 8.0},
189189+ {Title: "Show A", Season: 1, Episode: 2, Status: "watching", Rating: 8.5},
190190+ {Title: "Show B", Season: 1, Episode: 1, Status: "queued", Rating: 9.0},
191191+ {Title: "Show B", Season: 2, Episode: 1, Status: "watched", Rating: 9.2},
192192+ }
193193+194194+ for _, tvShow := range tvShows {
195195+ _, err := repo.Create(ctx, tvShow)
196196+ if err != nil {
197197+ t.Fatalf("Failed to create TV show: %v", err)
198198+ }
199199+ }
200200+201201+ t.Run("List All TV Shows", func(t *testing.T) {
202202+ results, err := repo.List(ctx, TVListOptions{})
203203+ if err != nil {
204204+ t.Errorf("Failed to list TV shows: %v", err)
205205+ }
206206+207207+ if len(results) != 4 {
208208+ t.Errorf("Expected 4 TV shows, got %d", len(results))
209209+ }
210210+ })
211211+212212+ t.Run("List TV Shows with Status Filter", func(t *testing.T) {
213213+ results, err := repo.List(ctx, TVListOptions{Status: "queued"})
214214+ if err != nil {
215215+ t.Errorf("Failed to list TV shows: %v", err)
216216+ }
217217+218218+ if len(results) != 2 {
219219+ t.Errorf("Expected 2 queued TV shows, got %d", len(results))
220220+ }
221221+222222+ for _, tvShow := range results {
223223+ if tvShow.Status != "queued" {
224224+ t.Errorf("Expected queued status, got %s", tvShow.Status)
225225+ }
226226+ }
227227+ })
228228+229229+ t.Run("List TV Shows by Title", func(t *testing.T) {
230230+ results, err := repo.List(ctx, TVListOptions{Title: "Show A"})
231231+ if err != nil {
232232+ t.Errorf("Failed to list TV shows: %v", err)
233233+ }
234234+235235+ if len(results) != 2 {
236236+ t.Errorf("Expected 2 episodes of Show A, got %d", len(results))
237237+ }
238238+239239+ for _, tvShow := range results {
240240+ if tvShow.Title != "Show A" {
241241+ t.Errorf("Expected title 'Show A', got %s", tvShow.Title)
242242+ }
243243+ }
244244+ })
245245+246246+ t.Run("List TV Shows by Season", func(t *testing.T) {
247247+ results, err := repo.List(ctx, TVListOptions{Title: "Show B", Season: 1})
248248+ if err != nil {
249249+ t.Errorf("Failed to list TV shows: %v", err)
250250+ }
251251+252252+ if len(results) != 1 {
253253+ t.Errorf("Expected 1 episode of Show B season 1, got %d", len(results))
254254+ }
255255+256256+ if len(results) > 0 {
257257+ if results[0].Title != "Show B" || results[0].Season != 1 {
258258+ t.Errorf("Expected Show B season 1, got %s season %d", results[0].Title, results[0].Season)
259259+ }
260260+ }
261261+ })
262262+263263+ t.Run("List TV Shows with Rating Filter", func(t *testing.T) {
264264+ results, err := repo.List(ctx, TVListOptions{MinRating: 9.0})
265265+ if err != nil {
266266+ t.Errorf("Failed to list TV shows: %v", err)
267267+ }
268268+269269+ if len(results) != 2 {
270270+ t.Errorf("Expected 2 TV shows with rating >= 9.0, got %d", len(results))
271271+ }
272272+273273+ for _, tvShow := range results {
274274+ if tvShow.Rating < 9.0 {
275275+ t.Errorf("Expected rating >= 9.0, got %f", tvShow.Rating)
276276+ }
277277+ }
278278+ })
279279+280280+ t.Run("List TV Shows with Search", func(t *testing.T) {
281281+ results, err := repo.List(ctx, TVListOptions{Search: "Show A"})
282282+ if err != nil {
283283+ t.Errorf("Failed to list TV shows: %v", err)
284284+ }
285285+286286+ if len(results) != 2 {
287287+ t.Errorf("Expected 2 TV shows matching search, got %d", len(results))
288288+ }
289289+290290+ for _, tvShow := range results {
291291+ if tvShow.Title != "Show A" {
292292+ t.Errorf("Expected 'Show A', got %s", tvShow.Title)
293293+ }
294294+ }
295295+ })
296296+297297+ t.Run("List TV Shows with Limit", func(t *testing.T) {
298298+ results, err := repo.List(ctx, TVListOptions{Limit: 2})
299299+ if err != nil {
300300+ t.Errorf("Failed to list TV shows: %v", err)
301301+ }
302302+303303+ if len(results) != 2 {
304304+ t.Errorf("Expected 2 TV shows due to limit, got %d", len(results))
305305+ }
306306+ })
307307+ })
308308+309309+ t.Run("Special Methods", func(t *testing.T) {
310310+ db := createTVTestDB(t)
311311+ repo := NewTVRepository(db)
312312+ ctx := context.Background()
313313+314314+ tvShow1 := &models.TVShow{Title: "Queued Show", Status: "queued", Rating: 8.0}
315315+ tvShow2 := &models.TVShow{Title: "Watching Show", Status: "watching", Rating: 9.0}
316316+ tvShow3 := &models.TVShow{Title: "Watched Show", Status: "watched", Rating: 8.5}
317317+ tvShow4 := &models.TVShow{Title: "Test Series", Season: 1, Episode: 1, Status: "queued"}
318318+ tvShow5 := &models.TVShow{Title: "Test Series", Season: 1, Episode: 2, Status: "queued"}
319319+ tvShow6 := &models.TVShow{Title: "Test Series", Season: 2, Episode: 1, Status: "queued"}
320320+321321+ var tvShow1ID int64
322322+ for _, tvShow := range []*models.TVShow{tvShow1, tvShow2, tvShow3, tvShow4, tvShow5, tvShow6} {
323323+ id, err := repo.Create(ctx, tvShow)
324324+ if err != nil {
325325+ t.Fatalf("Failed to create TV show: %v", err)
326326+ }
327327+ if tvShow == tvShow1 {
328328+ tvShow1ID = id
329329+ }
330330+ }
331331+332332+ t.Run("GetQueued", func(t *testing.T) {
333333+ results, err := repo.GetQueued(ctx)
334334+ if err != nil {
335335+ t.Errorf("Failed to get queued TV shows: %v", err)
336336+ }
337337+338338+ if len(results) != 4 {
339339+ t.Errorf("Expected 4 queued TV shows, got %d", len(results))
340340+ }
341341+342342+ for _, tvShow := range results {
343343+ if tvShow.Status != "queued" {
344344+ t.Errorf("Expected queued status, got %s", tvShow.Status)
345345+ }
346346+ }
347347+ })
348348+349349+ t.Run("GetWatching", func(t *testing.T) {
350350+ results, err := repo.GetWatching(ctx)
351351+ if err != nil {
352352+ t.Errorf("Failed to get watching TV shows: %v", err)
353353+ }
354354+355355+ if len(results) != 1 {
356356+ t.Errorf("Expected 1 watching TV show, got %d", len(results))
357357+ }
358358+359359+ if len(results) > 0 && results[0].Status != "watching" {
360360+ t.Errorf("Expected watching status, got %s", results[0].Status)
361361+ }
362362+ })
363363+364364+ t.Run("GetWatched", func(t *testing.T) {
365365+ results, err := repo.GetWatched(ctx)
366366+ if err != nil {
367367+ t.Errorf("Failed to get watched TV shows: %v", err)
368368+ }
369369+370370+ if len(results) != 1 {
371371+ t.Errorf("Expected 1 watched TV show, got %d", len(results))
372372+ }
373373+374374+ if len(results) > 0 && results[0].Status != "watched" {
375375+ t.Errorf("Expected watched status, got %s", results[0].Status)
376376+ }
377377+ })
378378+379379+ t.Run("GetByTitle", func(t *testing.T) {
380380+ results, err := repo.GetByTitle(ctx, "Test Series")
381381+ if err != nil {
382382+ t.Errorf("Failed to get TV shows by title: %v", err)
383383+ }
384384+385385+ if len(results) != 3 {
386386+ t.Errorf("Expected 3 episodes of Test Series, got %d", len(results))
387387+ }
388388+389389+ for _, tvShow := range results {
390390+ if tvShow.Title != "Test Series" {
391391+ t.Errorf("Expected title 'Test Series', got %s", tvShow.Title)
392392+ }
393393+ }
394394+ })
395395+396396+ t.Run("GetBySeason", func(t *testing.T) {
397397+ results, err := repo.GetBySeason(ctx, "Test Series", 1)
398398+ if err != nil {
399399+ t.Errorf("Failed to get TV shows by season: %v", err)
400400+ }
401401+402402+ if len(results) != 2 {
403403+ t.Errorf("Expected 2 episodes of Test Series season 1, got %d", len(results))
404404+ }
405405+406406+ for _, tvShow := range results {
407407+ if tvShow.Title != "Test Series" || tvShow.Season != 1 {
408408+ t.Errorf("Expected Test Series season 1, got %s season %d", tvShow.Title, tvShow.Season)
409409+ }
410410+ }
411411+ })
412412+413413+ t.Run("MarkWatched", func(t *testing.T) {
414414+ err := repo.MarkWatched(ctx, tvShow1ID)
415415+ if err != nil {
416416+ t.Errorf("Failed to mark TV show as watched: %v", err)
417417+ }
418418+419419+ updated, err := repo.Get(ctx, tvShow1ID)
420420+ if err != nil {
421421+ t.Fatalf("Failed to get updated TV show: %v", err)
422422+ }
423423+424424+ if updated.Status != "watched" {
425425+ t.Errorf("Expected status to be watched, got %s", updated.Status)
426426+ }
427427+428428+ if updated.LastWatched == nil {
429429+ t.Error("Expected last watched timestamp to be set")
430430+ }
431431+ })
432432+433433+ t.Run("StartWatching", func(t *testing.T) {
434434+ newShow := &models.TVShow{Title: "New Show", Status: "queued"}
435435+ id, err := repo.Create(ctx, newShow)
436436+ if err != nil {
437437+ t.Fatalf("Failed to create new TV show: %v", err)
438438+ }
439439+440440+ err = repo.StartWatching(ctx, id)
441441+ if err != nil {
442442+ t.Errorf("Failed to start watching TV show: %v", err)
443443+ }
444444+445445+ updated, err := repo.Get(ctx, id)
446446+ if err != nil {
447447+ t.Fatalf("Failed to get updated TV show: %v", err)
448448+ }
449449+450450+ if updated.Status != "watching" {
451451+ t.Errorf("Expected status to be watching, got %s", updated.Status)
452452+ }
453453+454454+ if updated.LastWatched == nil {
455455+ t.Error("Expected last watched timestamp to be set")
456456+ }
457457+ })
458458+ })
459459+460460+ t.Run("Count", func(t *testing.T) {
461461+ db := createTVTestDB(t)
462462+ repo := NewTVRepository(db)
463463+ ctx := context.Background()
464464+465465+ tvShows := []*models.TVShow{
466466+ {Title: "Show 1", Status: "queued", Rating: 8.0},
467467+ {Title: "Show 2", Status: "watching", Rating: 7.0},
468468+ {Title: "Show 3", Status: "watched", Rating: 9.0},
469469+ {Title: "Show 4", Status: "queued", Rating: 8.5},
470470+ }
471471+472472+ for _, tvShow := range tvShows {
473473+ _, err := repo.Create(ctx, tvShow)
474474+ if err != nil {
475475+ t.Fatalf("Failed to create TV show: %v", err)
476476+ }
477477+ }
478478+479479+ t.Run("Count all TV shows", func(t *testing.T) {
480480+ count, err := repo.Count(ctx, TVListOptions{})
481481+ if err != nil {
482482+ t.Errorf("Failed to count TV shows: %v", err)
483483+ }
484484+485485+ if count != 4 {
486486+ t.Errorf("Expected 4 TV shows, got %d", count)
487487+ }
488488+ })
489489+490490+ t.Run("Count queued TV shows", func(t *testing.T) {
491491+ count, err := repo.Count(ctx, TVListOptions{Status: "queued"})
492492+ if err != nil {
493493+ t.Errorf("Failed to count queued TV shows: %v", err)
494494+ }
495495+496496+ if count != 2 {
497497+ t.Errorf("Expected 2 queued TV shows, got %d", count)
498498+ }
499499+ })
500500+501501+ t.Run("Count TV shows by rating", func(t *testing.T) {
502502+ count, err := repo.Count(ctx, TVListOptions{MinRating: 8.0})
503503+ if err != nil {
504504+ t.Errorf("Failed to count high-rated TV shows: %v", err)
505505+ }
506506+507507+ if count != 3 {
508508+ t.Errorf("Expected 3 TV shows with rating >= 8.0, got %d", count)
509509+ }
510510+ })
511511+ })
512512+}
+63
justfile
···11+# Noteleaf project commands
22+33+# Default recipe - show available commands
44+default:
55+ @just --list
66+77+# Run all tests
88+test:
99+ go test ./... -v
1010+1111+# Run tests with coverage
1212+coverage:
1313+ go test ./... -coverprofile=coverage.out
1414+ go tool cover -html=coverage.out -o coverage.html
1515+ @echo "Coverage report generated: coverage.html"
1616+1717+# Run tests and show coverage in terminal
1818+test-coverage:
1919+ go test ./... -coverprofile=coverage.out
2020+ go tool cover -func=coverage.out
2121+2222+# Build the binary to /tmp/
2323+build:
2424+ mkdir -p /tmp/
2525+ go build -o /tmp/noteleaf ./cmd/cli/
2626+ @echo "Binary built: /tmp/noteleaf/noteleaf"
2727+2828+# Clean build artifacts
2929+clean:
3030+ rm -f coverage.out coverage.html
3131+ rm -rf /tmp/noteleaf
3232+3333+# Run linting
3434+lint:
3535+ go vet ./...
3636+ go fmt ./...
3737+3838+# Run all quality checks
3939+check: lint test-coverage
4040+4141+# Install dependencies
4242+deps:
4343+ go mod download
4444+ go mod tidy
4545+4646+# Run the application (after building)
4747+run: build
4848+ /tmp/noteleaf/noteleaf
4949+5050+# Show project status
5151+status:
5252+ @echo "Go version:"
5353+ @go version
5454+ @echo ""
5555+ @echo "Module info:"
5656+ @go list -m
5757+ @echo ""
5858+ @echo "Dependencies:"
5959+ @go list -m all | head -10
6060+6161+# Quick development workflow
6262+dev: clean lint test build
6363+ @echo "Development workflow complete!"