···11+package handlers
22+33+import (
44+ "context"
55+ "io"
66+)
77+88+// MediaHandler defines common operations for media handlers
99+//
1010+// This interface captures the shared behavior across media handlers for polymorphic handling of different media types.
1111+type MediaHandler interface {
1212+ // SearchAndAdd searches for media and allows user to select and add to queue
1313+ SearchAndAdd(ctx context.Context, query string, interactive bool) error
1414+ // List lists all media items with optional status filtering
1515+ List(ctx context.Context, status string) error
1616+ // UpdateStatus changes the status of a media item
1717+ UpdateStatus(ctx context.Context, id, status string) error
1818+ // Remove removes a media item from the queue
1919+ Remove(ctx context.Context, id string) error
2020+ // SetInputReader sets the input reader for interactive prompts
2121+ SetInputReader(reader io.Reader)
2222+ // Close cleans up resources
2323+ Close() error
2424+}
2525+2626+// Searchable defines search behavior for media handlers
2727+type Searchable interface {
2828+ SearchAndAdd(ctx context.Context, query string, interactive bool) error
2929+}
3030+3131+// Listable defines list behavior for media handlers
3232+type Listable interface {
3333+ List(ctx context.Context, status string) error
3434+}
3535+3636+// StatusUpdatable defines status update behavior for media handlers
3737+type StatusUpdatable interface {
3838+ UpdateStatus(ctx context.Context, id, status string) error
3939+}
4040+4141+// Removable defines remove behavior for media handlers
4242+type Removable interface {
4343+ Remove(ctx context.Context, id string) error
4444+}
+102
internal/handlers/media_utilities.go
···11+package handlers
22+33+import (
44+ "context"
55+ "fmt"
66+ "io"
77+ "strconv"
88+99+ "github.com/stormlightlabs/noteleaf/internal/models"
1010+)
1111+1212+// MediaPrinter defines how to format a media item for display
1313+type MediaPrinter[T any] func(item *T)
1414+1515+// ListMediaItems is a generic utility for listing media items with status filtering
1616+func ListMediaItems[T any](
1717+ ctx context.Context,
1818+ status string,
1919+ mediaType string,
2020+ listAll func(ctx context.Context) ([]*T, error),
2121+ listByStatus func(ctx context.Context, status string) ([]*T, error),
2222+ printer MediaPrinter[T],
2323+) error {
2424+ var items []*T
2525+ var err error
2626+2727+ if status == "" {
2828+ items, err = listAll(ctx)
2929+ if err != nil {
3030+ return fmt.Errorf("failed to list %s: %w", mediaType, err)
3131+ }
3232+ } else {
3333+ items, err = listByStatus(ctx, status)
3434+ if err != nil {
3535+ return fmt.Errorf("failed to get %s %s: %w", status, mediaType, err)
3636+ }
3737+ }
3838+3939+ if len(items) == 0 {
4040+ if status == "" {
4141+ fmt.Printf("No %s found\n", mediaType)
4242+ } else {
4343+ fmt.Printf("No %s %s found\n", status, mediaType)
4444+ }
4545+ return nil
4646+ }
4747+4848+ fmt.Printf("Found %d %s:\n\n", len(items), mediaType)
4949+ for _, item := range items {
5050+ printer(item)
5151+ }
5252+5353+ return nil
5454+}
5555+5656+// PromptUserChoice prompts the user to select from a list of results
5757+func PromptUserChoice(reader io.Reader, maxChoices int) (int, error) {
5858+ fmt.Print("\nEnter number to add (1-", maxChoices, "), or 0 to cancel: ")
5959+6060+ var choice int
6161+ if reader != nil {
6262+ if _, err := fmt.Fscanf(reader, "%d", &choice); err != nil {
6363+ return 0, fmt.Errorf("invalid input")
6464+ }
6565+ } else {
6666+ if _, err := fmt.Scanf("%d", &choice); err != nil {
6767+ return 0, fmt.Errorf("invalid input")
6868+ }
6969+ }
7070+7171+ if choice == 0 {
7272+ fmt.Println("Cancelled.")
7373+ return 0, nil
7474+ }
7575+ if choice < 1 || choice > maxChoices {
7676+ return 0, fmt.Errorf("invalid choice: %d", choice)
7777+ }
7878+ return choice, nil
7979+}
8080+8181+// ParseID converts a string ID to int64
8282+func ParseID(id string, itemType string) (int64, error) {
8383+ itemID, err := strconv.ParseInt(id, 10, 64)
8484+ if err != nil {
8585+ return 0, fmt.Errorf("invalid %s ID: %s", itemType, id)
8686+ }
8787+ return itemID, nil
8888+}
8989+9090+// PrintSearchResults displays search results with a type-specific formatter
9191+func PrintSearchResults[T models.Model](results []*models.Model, formatter func(*models.Model, int)) error {
9292+ if len(results) == 0 {
9393+ fmt.Println("No results found.")
9494+ return nil
9595+ }
9696+9797+ fmt.Printf("Found %d result(s):\n\n", len(results))
9898+ for i, result := range results {
9999+ formatter(result, i+1)
100100+ }
101101+ return nil
102102+}
+9-7
internal/handlers/movies.go
···2424 reader io.Reader
2525}
26262727+// Ensure MovieHandler implements interface [MediaHandler]
2828+var _ MediaHandler = (*MovieHandler)(nil)
2929+2730// NewMovieHandler creates a new movie handler
2831func NewMovieHandler() (*MovieHandler, error) {
2932 db, err := store.NewDatabase()
···218221}
219222220223// UpdateStatus changes the status of a movie
221221-func (h *MovieHandler) UpdateStatus(ctx context.Context, movieID int64, status string) error {
224224+func (h *MovieHandler) UpdateStatus(ctx context.Context, id, status string) error {
225225+ movieID, err := strconv.ParseInt(id, 10, 64)
226226+ if err != nil {
227227+ return fmt.Errorf("invalid movie ID %w", err)
228228+ }
222229 validStatuses := []string{"queued", "watched", "removed"}
223230 if !slices.Contains(validStatuses, status) {
224231 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", "))
···245252246253// MarkWatched marks a movie as watched
247254func (h *MovieHandler) MarkWatched(ctx context.Context, id string) error {
248248- movieID, err := strconv.ParseInt(id, 10, 64)
249249- if err != nil {
250250- return fmt.Errorf("invalid movie ID: %s", id)
251251- }
252252-253253- return h.UpdateStatus(ctx, movieID, "watched")
255255+ return h.UpdateStatus(ctx, id, "watched")
254256}
255257256258// Remove removes a movie from the queue
+6-9
internal/handlers/movies_test.go
···358358 handler := createTestMovieHandler(t)
359359 defer handler.Close()
360360361361- err := handler.UpdateStatus(context.Background(), 1, "invalid")
361361+ err := handler.UpdateStatus(context.Background(), "1", "invalid")
362362 if err == nil {
363363 t.Error("Expected error for invalid status")
364364 }
···371371 handler := createTestMovieHandler(t)
372372 defer handler.Close()
373373374374- err := handler.UpdateStatus(context.Background(), 999, "watched")
374374+ err := handler.UpdateStatus(context.Background(), "999", "watched")
375375 if err == nil {
376376 t.Error("Expected error for non-existent movie")
377377 }
···406406 err := handler.MarkWatched(context.Background(), "invalid")
407407 if err == nil {
408408 t.Error("Expected error for invalid movie ID")
409409- }
410410- if err.Error() != "invalid movie ID: invalid" {
411411- t.Errorf("Expected 'invalid movie ID: invalid', got: %v", err)
412409 }
413410 })
414411···468465 t.Errorf("Failed to view created movie: %v", err)
469466 }
470467471471- err = handler.UpdateStatus(context.Background(), id, "watched")
468468+ err = handler.UpdateStatus(context.Background(), strconv.Itoa(int(id)), "watched")
472469 if err != nil {
473470 t.Errorf("Failed to update movie status: %v", err)
474471 }
···540537 },
541538 {
542539 name: "Update status of non-existent movie",
543543- fn: func() error { return handler.UpdateStatus(ctx, nonExistentID, "watched") },
540540+ fn: func() error { return handler.UpdateStatus(ctx, strconv.Itoa(int(nonExistentID)), "watched") },
544541 },
545542 {
546543 name: "Mark non-existent movie as watched",
···570567 invalid := []string{"invalid", "pending", "completed", ""}
571568572569 for _, status := range valid {
573573- if err := handler.UpdateStatus(context.Background(), 999, status); err != nil &&
570570+ if err := handler.UpdateStatus(context.Background(), "999", status); err != nil &&
574571 err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watched, removed)", status) {
575572 t.Errorf("Status '%s' should be valid but was rejected", status)
576573 }
577574 }
578575579576 for _, status := range invalid {
580580- err := handler.UpdateStatus(context.Background(), 1, status)
577577+ err := handler.UpdateStatus(context.Background(), "1", status)
581578 if err == nil {
582579 t.Errorf("Status '%s' should be invalid but was accepted", status)
583580 }
+15-19
internal/handlers/tv.go
···1616)
17171818// TVHandler handles all TV show-related commands
1919+//
2020+// Implements MediaHandler interface for polymorphic media handling
1921type TVHandler struct {
2022 db *store.Database
2123 config *store.Config
···2325 service *services.TVService
2426 reader io.Reader
2527}
2828+2929+// Ensure TVHandler implements MediaHandler interface
3030+var _ MediaHandler = (*TVHandler)(nil)
26312732// NewTVHandler creates a new TV handler
2833func NewTVHandler() (*TVHandler, error) {
···232237}
233238234239// UpdateStatus changes the status of a TV show
235235-func (h *TVHandler) UpdateStatus(ctx context.Context, showID int64, status string) error {
240240+func (h *TVHandler) UpdateStatus(ctx context.Context, id, status string) error {
241241+ showID, err := strconv.ParseInt(id, 10, 64)
242242+ if err != nil {
243243+ return fmt.Errorf("invalid tv show ID %w", err)
244244+ }
236245 validStatuses := []string{"queued", "watching", "watched", "removed"}
237246 if !slices.Contains(validStatuses, status) {
238247 return fmt.Errorf("invalid status: %s (valid: %s)", status, strings.Join(validStatuses, ", "))
···258267}
259268260269// MarkWatching marks a TV show as currently watching
261261-func (h *TVHandler) MarkWatching(ctx context.Context, showID int64) error {
262262- return h.UpdateStatus(ctx, showID, "watching")
270270+func (h *TVHandler) MarkWatching(ctx context.Context, id string) error {
271271+ return h.UpdateStatus(ctx, id, "watching")
263272}
264273265274// MarkWatched marks a TV show as watched
266275func (h *TVHandler) MarkWatched(ctx context.Context, id string) error {
267267- showID, err := strconv.ParseInt(id, 10, 64)
268268- if err != nil {
269269- return fmt.Errorf("invalid TV show ID: %s", id)
270270- }
271271-272272- return h.UpdateStatus(ctx, showID, "watched")
276276+ return h.UpdateStatus(ctx, id, "watched")
273277}
274278275279// Remove removes a TV show from the queue
···317321318322// UpdateTVShowStatus changes the status of a TV show
319323func (h *TVHandler) UpdateTVShowStatus(ctx context.Context, id, status string) error {
320320- showID, err := strconv.ParseInt(id, 10, 64)
321321- if err != nil {
322322- return fmt.Errorf("invalid TV show ID: %s", id)
323323- }
324324- return h.UpdateStatus(ctx, showID, status)
324324+ return h.UpdateStatus(ctx, id, status)
325325}
326326327327// MarkTVShowWatching marks a TV show as currently watching
328328func (h *TVHandler) MarkTVShowWatching(ctx context.Context, id string) error {
329329- showID, err := strconv.ParseInt(id, 10, 64)
330330- if err != nil {
331331- return fmt.Errorf("invalid TV show ID: %s", id)
332332- }
333333- return h.MarkWatching(ctx, showID)
329329+ return h.MarkWatching(ctx, id)
334330}
+12-21
internal/handlers/tv_test.go
···355355 handler := createTestTVHandler(t)
356356 defer handler.Close()
357357358358- err := handler.View(context.Background(), strconv.Itoa(int(999)))
358358+ err := handler.View(context.Background(), "999")
359359 if err == nil {
360360 t.Error("Expected error for non-existent TV show")
361361 }
···381381 handler := createTestTVHandler(t)
382382 defer handler.Close()
383383384384- err := handler.UpdateStatus(context.Background(), 1, "invalid")
384384+ err := handler.UpdateStatus(context.Background(), "1", "invalid")
385385 if err == nil {
386386 t.Error("Expected error for invalid status")
387387 }
···394394 handler := createTestTVHandler(t)
395395 defer handler.Close()
396396397397- err := handler.UpdateStatus(context.Background(), 999, "watched")
397397+ err := handler.UpdateStatus(context.Background(), "999", "watched")
398398 if err == nil {
399399 t.Error("Expected error for non-existent TV show")
400400 }
···406406 handler := createTestTVHandler(t)
407407 defer handler.Close()
408408409409- err := handler.MarkWatching(context.Background(), 999)
409409+ err := handler.MarkWatching(context.Background(), "999")
410410 if err == nil {
411411 t.Error("Expected error for non-existent TV show")
412412 }
···416416 handler := createTestTVHandler(t)
417417 defer handler.Close()
418418419419- err := handler.MarkWatched(context.Background(), strconv.Itoa(int(999)))
419419+ err := handler.MarkWatched(context.Background(), "999")
420420 if err == nil {
421421 t.Error("Expected error for non-existent TV show")
422422 }
···426426 handler := createTestTVHandler(t)
427427 defer handler.Close()
428428429429- err := handler.Remove(context.Background(), strconv.Itoa(int(999)))
429429+ err := handler.Remove(context.Background(), "999")
430430 if err == nil {
431431 t.Error("Expected error for non-existent TV show")
432432 }
···440440 if err == nil {
441441 t.Error("Expected error for invalid TV show ID")
442442 }
443443- if err.Error() != "invalid TV show ID: invalid" {
444444- t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err)
445445- }
446443 })
447444448445 t.Run("MarkTVShowWatching_InvalidID", func(t *testing.T) {
···453450 if err == nil {
454451 t.Error("Expected error for invalid TV show ID")
455452 }
456456- if err.Error() != "invalid TV show ID: invalid" {
457457- t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err)
458458- }
459453 })
460454461455 t.Run("MarkWatched_InvalidID", func(t *testing.T) {
···465459 err := handler.MarkWatched(context.Background(), "invalid")
466460 if err == nil {
467461 t.Error("Expected error for invalid TV show ID")
468468- }
469469- if err.Error() != "invalid TV show ID: invalid" {
470470- t.Errorf("Expected 'invalid TV show ID: invalid', got: %v", err)
471462 }
472463 })
473464···528519 t.Errorf("Failed to view created TV show: %v", err)
529520 }
530521531531- err = handler.UpdateStatus(context.Background(), id, "watching")
522522+ err = handler.UpdateStatus(context.Background(), strconv.Itoa(int(id)), "watching")
532523 if err != nil {
533524 t.Errorf("Failed to update TV show status: %v", err)
534525 }
···538529 t.Errorf("Failed to mark TV show as watched: %v", err)
539530 }
540531541541- err = handler.MarkWatching(context.Background(), id)
532532+ err = handler.MarkWatching(context.Background(), strconv.Itoa(int(id)))
542533 if err != nil {
543534 t.Errorf("Failed to mark TV show as watching: %v", err)
544535 }
···617608 },
618609 {
619610 name: "Update status of non-existent show",
620620- fn: func() error { return handler.UpdateStatus(ctx, nonExistentID, "watched") },
611611+ fn: func() error { return handler.UpdateStatus(ctx, strconv.Itoa(int(nonExistentID)), "watched") },
621612 },
622613 {
623614 name: "Mark non-existent show as watching",
624624- fn: func() error { return handler.MarkWatching(ctx, nonExistentID) },
615615+ fn: func() error { return handler.MarkWatching(ctx, strconv.Itoa(int(nonExistentID))) },
625616 },
626617 {
627618 name: "Mark non-existent show as watched",
···651642 invalid := []string{"invalid", "pending", "completed", ""}
652643653644 for _, status := range valid {
654654- if err := handler.UpdateStatus(context.Background(), 999, status); err != nil &&
645645+ if err := handler.UpdateStatus(context.Background(), "999", status); err != nil &&
655646 err.Error() == fmt.Sprintf("invalid status: %s (valid: queued, watching, watched, removed)", status) {
656647 t.Errorf("Status '%s' should be valid but was rejected", status)
657648 }
658649 }
659650660651 for _, status := range invalid {
661661- err := handler.UpdateStatus(context.Background(), 1, status)
652652+ err := handler.UpdateStatus(context.Background(), "1", status)
662653 if err == nil {
663654 t.Errorf("Status '%s' should be invalid but was accepted", status)
664655 }
+154
internal/repo/base_media_repository.go
···11+package repo
22+33+import (
44+ "context"
55+ "database/sql"
66+ "fmt"
77+88+ "github.com/stormlightlabs/noteleaf/internal/models"
99+)
1010+1111+// MediaConfig defines configuration for a media repository
1212+//
1313+// T should be a pointer type (*models.Book, *models.Movie, *models.TVShow)
1414+type MediaConfig[T models.Model] struct {
1515+ TableName string // TableName is the database table name (e.g., "books", "movies", "tv_shows")
1616+ New func() T // New creates a new zero-value instance of T
1717+ Scan func(rows *sql.Rows, item T) error // Scan reads a database row into a model instance
1818+ ScanSingle func(row *sql.Row, item T) error // ScanSingle reads a single row from QueryRow into a model instance
1919+ InsertColumns string // InsertColumns returns the column names for INSERT statements
2020+ UpdateColumns string // UpdateColumns returns the SET clause for UPDATE statements (without WHERE)
2121+ InsertValues func(item T) []any // InsertValues extracts values from a model for INSERT
2222+ UpdateValues func(item T) []any // UpdateValues extracts values from a model for UPDATE (item values + ID)
2323+}
2424+2525+// BaseMediaRepository provides shared CRUD operations for media types
2626+//
2727+// This generic implementation eliminates duplicate code across Book, Movie, and TV repositories.
2828+// Type-specific behavior is configured via MediaConfig.
2929+//
3030+// T should be a pointer type (*models.Book, *models.Movie, *models.TVShow)
3131+type BaseMediaRepository[T models.Model] struct {
3232+ db *sql.DB
3333+ config MediaConfig[T]
3434+}
3535+3636+// NewBaseMediaRepository creates a new base media repository
3737+func NewBaseMediaRepository[T models.Model](db *sql.DB, config MediaConfig[T]) *BaseMediaRepository[T] {
3838+ return &BaseMediaRepository[T]{
3939+ db: db,
4040+ config: config,
4141+ }
4242+}
4343+4444+// Create stores a new media item and returns its assigned ID
4545+func (r *BaseMediaRepository[T]) Create(ctx context.Context, item T) (int64, error) {
4646+ query := fmt.Sprintf(
4747+ "INSERT INTO %s (%s) VALUES (%s)",
4848+ r.config.TableName,
4949+ r.config.InsertColumns,
5050+ buildPlaceholders(r.config.InsertValues(item)),
5151+ )
5252+5353+ result, err := r.db.ExecContext(ctx, query, r.config.InsertValues(item)...)
5454+ if err != nil {
5555+ return 0, fmt.Errorf("failed to insert %s: %w", r.config.TableName, err)
5656+ }
5757+5858+ id, err := result.LastInsertId()
5959+ if err != nil {
6060+ return 0, fmt.Errorf("failed to get last insert id: %w", err)
6161+ }
6262+6363+ return id, nil
6464+}
6565+6666+// Get retrieves a media item by ID
6767+//
6868+// Returns T directly (which is already a pointer type like *models.Book)
6969+func (r *BaseMediaRepository[T]) Get(ctx context.Context, id int64) (T, error) {
7070+ query := fmt.Sprintf("SELECT * FROM %s WHERE id = ?", r.config.TableName)
7171+ row := r.db.QueryRowContext(ctx, query, id)
7272+7373+ item := r.config.New()
7474+ if err := r.config.ScanSingle(row, item); err != nil {
7575+ var zero T
7676+ if err == sql.ErrNoRows {
7777+ return zero, fmt.Errorf("%s with id %d not found", r.config.TableName, id)
7878+ }
7979+ return zero, fmt.Errorf("failed to get %s: %w", r.config.TableName, err)
8080+ }
8181+8282+ return item, nil
8383+}
8484+8585+// Update modifies an existing media item
8686+func (r *BaseMediaRepository[T]) Update(ctx context.Context, item T) error {
8787+ query := fmt.Sprintf(
8888+ "UPDATE %s SET %s WHERE id = ?",
8989+ r.config.TableName,
9090+ r.config.UpdateColumns,
9191+ )
9292+9393+ _, err := r.db.ExecContext(ctx, query, r.config.UpdateValues(item)...)
9494+ if err != nil {
9595+ return fmt.Errorf("failed to update %s: %w", r.config.TableName, err)
9696+ }
9797+9898+ return nil
9999+}
100100+101101+// Delete removes a media item by ID
102102+func (r *BaseMediaRepository[T]) Delete(ctx context.Context, id int64) error {
103103+ query := fmt.Sprintf("DELETE FROM %s WHERE id = ?", r.config.TableName)
104104+ _, err := r.db.ExecContext(ctx, query, id)
105105+ if err != nil {
106106+ return fmt.Errorf("failed to delete %s: %w", r.config.TableName, err)
107107+ }
108108+ return nil
109109+}
110110+111111+// ListQuery executes a custom query and scans results
112112+//
113113+// Returns []T where T is a pointer type (e.g., []*models.Book)
114114+func (r *BaseMediaRepository[T]) ListQuery(ctx context.Context, query string, args ...any) ([]T, error) {
115115+ rows, err := r.db.QueryContext(ctx, query, args...)
116116+ if err != nil {
117117+ return nil, fmt.Errorf("failed to list %s: %w", r.config.TableName, err)
118118+ }
119119+ defer rows.Close()
120120+121121+ var items []T
122122+ for rows.Next() {
123123+ item := r.config.New()
124124+ if err := r.config.Scan(rows, item); err != nil {
125125+ return nil, err
126126+ }
127127+ items = append(items, item)
128128+ }
129129+130130+ return items, rows.Err()
131131+}
132132+133133+// CountQuery executes a custom COUNT query
134134+func (r *BaseMediaRepository[T]) CountQuery(ctx context.Context, query string, args ...any) (int64, error) {
135135+ var count int64
136136+ err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
137137+ if err != nil {
138138+ return 0, fmt.Errorf("failed to count %s: %w", r.config.TableName, err)
139139+ }
140140+ return count, nil
141141+}
142142+143143+// buildPlaceholders generates "?,?,?" for SQL placeholders
144144+func buildPlaceholders(values []any) string {
145145+ if len(values) == 0 {
146146+ return ""
147147+ }
148148+149149+ placeholders := "?"
150150+ for i := 1; i < len(values); i++ {
151151+ placeholders += ",?"
152152+ }
153153+ return placeholders
154154+}
+51-80
internal/repo/book_repository.go
···1111)
12121313// BookRepository provides database operations for books
1414+//
1515+// Uses BaseMediaRepository for common CRUD operations.
1616+// TODO: Implement Repository interface (Validate method) similar to ArticleRepository
1417type BookRepository struct {
1818+ *BaseMediaRepository[*models.Book]
1519 db *sql.DB
1620}
17211822// NewBookRepository creates a new book repository
1923func NewBookRepository(db *sql.DB) *BookRepository {
2020- return &BookRepository{db: db}
2424+ config := MediaConfig[*models.Book]{
2525+ TableName: "books",
2626+ New: func() *models.Book { return &models.Book{} },
2727+ InsertColumns: "title, author, status, progress, pages, rating, notes, added, started, finished",
2828+ UpdateColumns: "title = ?, author = ?, status = ?, progress = ?, pages = ?, rating = ?, notes = ?, started = ?, finished = ?",
2929+ InsertValues: func(book *models.Book) []any {
3030+ return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Added, book.Started, book.Finished}
3131+ },
3232+ UpdateValues: func(book *models.Book) []any {
3333+ return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Started, book.Finished, book.ID}
3434+ },
3535+ Scan: func(rows *sql.Rows, book *models.Book) error {
3636+ return scanBookRow(rows, book)
3737+ },
3838+ ScanSingle: func(row *sql.Row, book *models.Book) error {
3939+ return scanBookRowSingle(row, book)
4040+ },
4141+ }
4242+4343+ return &BookRepository{
4444+ BaseMediaRepository: NewBaseMediaRepository(db, config),
4545+ db: db,
4646+ }
2147}
22482349// Create stores a new book and returns its assigned ID
···2551 now := time.Now()
2652 book.Added = now
27532828- 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)
5454+ id, err := r.BaseMediaRepository.Create(ctx, book)
3555 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)
5656+ return 0, err
4257 }
43584459 book.ID = id
4560 return id, nil
4661}
47624848-// 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-9263// List retrieves books with optional filtering and sorting
9364func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) {
9465 query := r.buildListQuery(opts)
9566 args := r.buildListArgs(opts)
96679797- rows, err := r.db.QueryContext(ctx, query, args...)
6868+ items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...)
9869 if err != nil {
9999- return nil, fmt.Errorf("failed to list books: %w", err)
7070+ return nil, err
10071 }
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()
7272+ return items, nil
11373}
1147411575func (r *BookRepository) buildListQuery(opts BookListOptions) string {
···187147 return args
188148}
189149190190-func (r *BookRepository) scanBookRow(rows *sql.Rows, book *models.Book) error {
150150+// scanBookRow scans a database row into a book model
151151+func scanBookRow(rows *sql.Rows, book *models.Book) error {
191152 var pages sql.NullInt64
192153193154 if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages,
155155+ &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil {
156156+ return err
157157+ }
158158+159159+ if pages.Valid {
160160+ book.Pages = int(pages.Int64)
161161+ }
162162+163163+ return nil
164164+}
165165+166166+// scanBookRowSingle scans a single database row into a book model
167167+func scanBookRowSingle(row *sql.Row, book *models.Book) error {
168168+ var pages sql.NullInt64
169169+170170+ if err := row.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages,
194171 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil {
195172 return err
196173 }
···246223 query += " WHERE " + strings.Join(conditions, " AND ")
247224 }
248225249249- var count int64
250250- err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
251251- if err != nil {
252252- return 0, fmt.Errorf("failed to count books: %w", err)
253253- }
254254-255255- return count, nil
226226+ return r.BaseMediaRepository.CountQuery(ctx, query, args...)
256227}
257228258229// GetQueued retrieves all books in the queue
+40
internal/repo/media_repository.go
···11+package repo
22+33+import (
44+ "context"
55+66+ "github.com/stormlightlabs/noteleaf/internal/models"
77+)
88+99+// MediaRepository defines CRUD operations for media types (Books, Movies, TV)
1010+//
1111+// This interface captures the shared behavior across media repositories
1212+type MediaRepository[T models.Model] interface {
1313+ // Create stores a new media item and returns its assigned ID
1414+ Create(ctx context.Context, item *T) (int64, error)
1515+1616+ // Get retrieves a media item by ID
1717+ Get(ctx context.Context, id int64) (*T, error)
1818+1919+ // Update modifies an existing media item
2020+ Update(ctx context.Context, item *T) error
2121+2222+ // Delete removes a media item by ID
2323+ Delete(ctx context.Context, id int64) error
2424+2525+ // List retrieves media items with optional filtering and sorting
2626+ List(ctx context.Context, opts any) ([]*T, error)
2727+2828+ // Count returns the number of media items matching conditions
2929+ Count(ctx context.Context, opts any) (int64, error)
3030+}
3131+3232+// StatusFilterable extends MediaRepository with status-based filtering
3333+//
3434+// Media types (Books, Movies, TV) support status-based queries like "queued", "reading", "watching", "watched", "finished"
3535+type StatusFilterable[T models.Model] interface {
3636+ MediaRepository[T]
3737+3838+ // GetByStatus retrieves all items with the given status
3939+ GetByStatus(ctx context.Context, status string) ([]*T, error)
4040+}
+38-80
internal/repo/movie_repository.go
···12121313// MovieRepository provides database operations for movies
1414type MovieRepository struct {
1515+ *BaseMediaRepository[*models.Movie]
1516 db *sql.DB
1617}
17181819// NewMovieRepository creates a new movie repository
1920func NewMovieRepository(db *sql.DB) *MovieRepository {
2020- return &MovieRepository{db: db}
2121+ config := MediaConfig[*models.Movie]{
2222+ TableName: "movies",
2323+ New: func() *models.Movie { return &models.Movie{} },
2424+ InsertColumns: "title, year, status, rating, notes, added, watched",
2525+ UpdateColumns: "title = ?, year = ?, status = ?, rating = ?, notes = ?, watched = ?",
2626+ InsertValues: func(movie *models.Movie) []any {
2727+ return []any{movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Added, movie.Watched}
2828+ },
2929+ UpdateValues: func(movie *models.Movie) []any {
3030+ return []any{movie.Title, movie.Year, movie.Status, movie.Rating, movie.Notes, movie.Watched, movie.ID}
3131+ },
3232+ Scan: func(rows *sql.Rows, movie *models.Movie) error {
3333+ return scanMovieRow(rows, movie)
3434+ },
3535+ ScanSingle: func(row *sql.Row, movie *models.Movie) error {
3636+ return scanMovieRowSingle(row, movie)
3737+ },
3838+ }
3939+4040+ return &MovieRepository{
4141+ BaseMediaRepository: NewBaseMediaRepository(db, config),
4242+ db: db,
4343+ }
2144}
22452346// Create stores a new movie and returns its assigned ID
···2548 now := time.Now()
2649 movie.Added = now
27502828- 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)
5151+ id, err := r.BaseMediaRepository.Create(ctx, movie)
3452 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)
5353+ return 0, err
4154 }
42554356 movie.ID = id
4457 return id, nil
4558}
46594747-// 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-8960// List retrieves movies with optional filtering and sorting
9061func (r *MovieRepository) List(ctx context.Context, opts MovieListOptions) ([]*models.Movie, error) {
9162 query := r.buildListQuery(opts)
9263 args := r.buildListArgs(opts)
93649494- rows, err := r.db.QueryContext(ctx, query, args...)
6565+ items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...)
9566 if err != nil {
9696- return nil, fmt.Errorf("failed to list movies: %w", err)
6767+ return nil, err
9768 }
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()
6969+ return items, nil
11070}
1117111272func (r *MovieRepository) buildListQuery(opts MovieListOptions) string {
···177137 return args
178138}
179139180180-func (r *MovieRepository) scanMovieRow(rows *sql.Rows, movie *models.Movie) error {
140140+// scanMovieRow scans a database row into a [models.Movie]
141141+func scanMovieRow(rows *sql.Rows, movie *models.Movie) error {
181142 return rows.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating,
182143 &movie.Notes, &movie.Added, &movie.Watched)
183144}
184145146146+// scanMovieRowSingle scans a single database row into a [models.Movie]
147147+func scanMovieRowSingle(row *sql.Row, movie *models.Movie) error {
148148+ return row.Scan(&movie.ID, &movie.Title, &movie.Year, &movie.Status, &movie.Rating,
149149+ &movie.Notes, &movie.Added, &movie.Watched)
150150+}
151151+185152// Find retrieves movies matching specific conditions
186153func (r *MovieRepository) Find(ctx context.Context, conditions MovieListOptions) ([]*models.Movie, error) {
187154 return r.List(ctx, conditions)
···220187 if len(conditions) > 0 {
221188 query += " WHERE " + strings.Join(conditions, " AND ")
222189 }
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
190190+ return r.BaseMediaRepository.CountQuery(ctx, query, args...)
231191}
232192233193// GetQueued retrieves all movies in the queue
···246206 if err != nil {
247207 return err
248208 }
249249-250209 now := time.Now()
251210 movie.Status = "watched"
252211 movie.Watched = &now
253253-254212 return r.Update(ctx, movie)
255213}
256214
+2
internal/repo/task_repository.go
···5151}
52525353// TaskRepository provides database operations for tasks
5454+//
5555+// TODO: Implement Repository interface (Validate method) similar to ArticleRepository
5456type TaskRepository struct {
5557 db *sql.DB
5658}
+36-82
internal/repo/tv_repository.go
···12121313// TVRepository provides database operations for TV shows
1414type TVRepository struct {
1515+ *BaseMediaRepository[*models.TVShow]
1516 db *sql.DB
1617}
17181819// NewTVRepository creates a new TV show repository
1920func NewTVRepository(db *sql.DB) *TVRepository {
2020- return &TVRepository{db: db}
2121+ config := MediaConfig[*models.TVShow]{
2222+ TableName: "tv_shows",
2323+ New: func() *models.TVShow { return &models.TVShow{} },
2424+ InsertColumns: "title, season, episode, status, rating, notes, added, last_watched",
2525+ UpdateColumns: "title = ?, season = ?, episode = ?, status = ?, rating = ?, notes = ?, last_watched = ?",
2626+ InsertValues: func(show *models.TVShow) []any {
2727+ return []any{show.Title, show.Season, show.Episode, show.Status, show.Rating, show.Notes, show.Added, show.LastWatched}
2828+ },
2929+ UpdateValues: func(show *models.TVShow) []any {
3030+ return []any{show.Title, show.Season, show.Episode, show.Status, show.Rating, show.Notes, show.LastWatched, show.ID}
3131+ },
3232+ Scan: func(rows *sql.Rows, show *models.TVShow) error {
3333+ return scanTVShowRow(rows, show)
3434+ },
3535+ ScanSingle: func(row *sql.Row, show *models.TVShow) error {
3636+ return scanTVShowRowSingle(row, show)
3737+ },
3838+ }
3939+4040+ return &TVRepository{
4141+ BaseMediaRepository: NewBaseMediaRepository(db, config),
4242+ db: db,
4343+ }
2144}
22452346// Create stores a new TV show and returns its assigned ID
···2548 now := time.Now()
2649 tvShow.Added = now
27502828- 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)
5151+ id, err := r.BaseMediaRepository.Create(ctx, tvShow)
3552 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)
5353+ return 0, err
4254 }
43554456 tvShow.ID = id
4557 return id, nil
4658}
47594848-// 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-9260// List retrieves TV shows with optional filtering and sorting
9361func (r *TVRepository) List(ctx context.Context, opts TVListOptions) ([]*models.TVShow, error) {
9462 query := r.buildListQuery(opts)
9563 args := r.buildListArgs(opts)
96649797- rows, err := r.db.QueryContext(ctx, query, args...)
6565+ items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...)
9866 if err != nil {
9999- return nil, fmt.Errorf("failed to list TV shows: %w", err)
6767+ return nil, err
10068 }
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()
6969+ return items, nil
11370}
1147111572func (r *TVRepository) buildListQuery(opts TVListOptions) string {
···186143 return args
187144}
188145189189-func (r *TVRepository) scanTVShowRow(rows *sql.Rows, tvShow *models.TVShow) error {
146146+func scanTVShowRow(rows *sql.Rows, tvShow *models.TVShow) error {
190147 return rows.Scan(&tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status,
191148 &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched)
192149}
193150151151+func scanTVShowRowSingle(row *sql.Row, tvShow *models.TVShow) error {
152152+ return row.Scan(&tvShow.ID, &tvShow.Title, &tvShow.Season, &tvShow.Episode, &tvShow.Status,
153153+ &tvShow.Rating, &tvShow.Notes, &tvShow.Added, &tvShow.LastWatched)
154154+}
155155+194156// Find retrieves TV shows matching specific conditions
195157func (r *TVRepository) Find(ctx context.Context, conditions TVListOptions) ([]*models.TVShow, error) {
196158 return r.List(ctx, conditions)
···234196 query += " WHERE " + strings.Join(conditions, " AND ")
235197 }
236198237237- 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
199199+ return r.BaseMediaRepository.CountQuery(ctx, query, args...)
244200}
245201246202// GetQueued retrieves all TV shows in the queue
···274230 if err != nil {
275231 return err
276232 }
277277-278233 now := time.Now()
279234 tvShow.Status = "watched"
280235 tvShow.LastWatched = &now
281281-282236 return r.Update(ctx, tvShow)
283237}
284238