···23## Core Task Management (TaskWarrior-inspired)
45-- `add` - Add new task with description and optional metadata
6- `list` - Display tasks with filtering and sorting options
0000007- `done` - Mark task as completed
8-- `delete` - Remove task permanently
9-- `modify` - Edit task properties (description, priority, project, tags)
10- `start/stop` - Track active time on tasks
11- `annotate` - Add notes/comments to existing tasks
12-- `projects` - List all project names
13-- `tags` - List all tag names
014- `calendar` - Display tasks in calendar view
15- `timesheet` - Show time tracking summaries
1617## Todo.txt Compatibility
1819- `archive` - Move completed tasks to done.txt
20-- `listcon` - List all contexts (@context)
21-- `listproj` - List all projects (+project)
22-- `pri` - Set task priority (A-Z)
23-- `depri` - Remove priority from task
24-- `replace` - Replace task text entirely
25- `prepend/append` - Add text to beginning/end of task
2627## Media Queue Management
2829- `movie add` - Add movie to watch queue
30- `movie list` - Show movie queue with ratings/metadata
31-- `movie watched` - Mark movie as watched
32-- `movie remove` - Remove from queue
033- `tv add` - Add TV show/season to queue
34- `tv list` - Show TV queue with episode tracking
35-- `tv watched` - Mark episodes/seasons as watched
36-- `tv remove` - Remove from TV queue
3738## Reading List Management
3940- `book add` - Add book to reading list
41- `book list` - Show reading queue with progress
42- `book reading` - Mark book as currently reading
43-- `book finished` - Mark book as completed
44-- `book remove` - Remove from reading list
45- `book progress` - Update reading progress percentage
4647## Data Management
4849- `sync` - Synchronize with remote storage
0050- `backup` - Create local backup
051- `import` - Import from various formats (CSV, JSON, todo.txt)
52- `export` - Export to various formats
053- `config` - Manage configuration settings
054- `undo` - Reverse last operation
···23## Core Task Management (TaskWarrior-inspired)
405- `list` - Display tasks with filtering and sorting options
6+- `projects` - List all project names
7+- `tags` - List all tag names
8+9+- `create` - Add new task with description and optional metadata
10+11+- `view` - View task by ID
12- `done` - Mark task as completed
13+- `update` - Edit task properties (description, priority, project, tags)
014- `start/stop` - Track active time on tasks
15- `annotate` - Add notes/comments to existing tasks
16+17+- `delete` - Remove task permanently
18+19- `calendar` - Display tasks in calendar view
20- `timesheet` - Show time tracking summaries
2122## Todo.txt Compatibility
2324- `archive` - Move completed tasks to done.txt
25+- `[con]texts` - List all contexts (@context)
26+- `[proj]ects` - List all projects (+project)
27+- `[pri]ority` - Set task priority (A-Z)
28+- `[depri]oritize` - Remove priority from task
29+- `[re]place` - Replace task text entirely
30- `prepend/append` - Add text to beginning/end of task
3132## Media Queue Management
3334- `movie add` - Add movie to watch queue
35- `movie list` - Show movie queue with ratings/metadata
36+- `movie watched|seen` - Mark movie as watched
37+- `movie remove|rm` - Remove from queue
38+39- `tv add` - Add TV show/season to queue
40- `tv list` - Show TV queue with episode tracking
41+- `tv watched|seen` - Mark episodes/seasons as watched
42+- `tv remove|rm` - Remove from TV queue
4344## Reading List Management
4546- `book add` - Add book to reading list
47- `book list` - Show reading queue with progress
48- `book reading` - Mark book as currently reading
49+- `book finished|read` - Mark book as completed
50+- `book remove|rm` - Remove from reading list
51- `book progress` - Update reading progress percentage
5253## Data Management
5455- `sync` - Synchronize with remote storage
56+- `sync setup` - Setup remote storage
57+58- `backup` - Create local backup
59+60- `import` - Import from various formats (CSV, JSON, todo.txt)
61- `export` - Export to various formats
62+63- `config` - Manage configuration settings
64+65- `undo` - Reverse last operation
···1+package repo
2+3+import (
4+ "context"
5+ "database/sql"
6+ "testing"
7+ "time"
8+9+ _ "github.com/mattn/go-sqlite3"
10+ "stormlightlabs.org/noteleaf/internal/models"
11+)
12+13+func createMovieTestDB(t *testing.T) *sql.DB {
14+ db, err := sql.Open("sqlite3", ":memory:")
15+ if err != nil {
16+ t.Fatalf("Failed to create in-memory database: %v", err)
17+ }
18+19+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
20+ t.Fatalf("Failed to enable foreign keys: %v", err)
21+ }
22+23+ schema := `
24+ CREATE TABLE IF NOT EXISTS movies (
25+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26+ title TEXT NOT NULL,
27+ year INTEGER,
28+ status TEXT DEFAULT 'queued',
29+ rating REAL,
30+ notes TEXT,
31+ added DATETIME DEFAULT CURRENT_TIMESTAMP,
32+ watched DATETIME
33+ );
34+ `
35+36+ if _, err := db.Exec(schema); err != nil {
37+ t.Fatalf("Failed to create schema: %v", err)
38+ }
39+40+ t.Cleanup(func() {
41+ db.Close()
42+ })
43+44+ return db
45+}
46+47+func createSampleMovie() *models.Movie {
48+ return &models.Movie{
49+ Title: "Test Movie",
50+ Year: 2023,
51+ Status: "queued",
52+ Rating: 8.5,
53+ Notes: "Great movie to watch",
54+ }
55+}
56+57+func TestMovieRepository(t *testing.T) {
58+ t.Run("CRUD Operations", func(t *testing.T) {
59+ db := createMovieTestDB(t)
60+ repo := NewMovieRepository(db)
61+ ctx := context.Background()
62+63+ t.Run("Create Movie", func(t *testing.T) {
64+ movie := createSampleMovie()
65+66+ id, err := repo.Create(ctx, movie)
67+ if err != nil {
68+ t.Errorf("Failed to create movie: %v", err)
69+ }
70+71+ if id == 0 {
72+ t.Error("Expected non-zero ID")
73+ }
74+75+ if movie.ID != id {
76+ t.Errorf("Expected movie ID to be set to %d, got %d", id, movie.ID)
77+ }
78+79+ if movie.Added.IsZero() {
80+ t.Error("Expected Added timestamp to be set")
81+ }
82+ })
83+84+ t.Run("Get Movie", func(t *testing.T) {
85+ original := createSampleMovie()
86+ id, err := repo.Create(ctx, original)
87+ if err != nil {
88+ t.Fatalf("Failed to create movie: %v", err)
89+ }
90+91+ retrieved, err := repo.Get(ctx, id)
92+ if err != nil {
93+ t.Errorf("Failed to get movie: %v", err)
94+ }
95+96+ if retrieved.Title != original.Title {
97+ t.Errorf("Expected title %s, got %s", original.Title, retrieved.Title)
98+ }
99+ if retrieved.Year != original.Year {
100+ t.Errorf("Expected year %d, got %d", original.Year, retrieved.Year)
101+ }
102+ if retrieved.Status != original.Status {
103+ t.Errorf("Expected status %s, got %s", original.Status, retrieved.Status)
104+ }
105+ if retrieved.Rating != original.Rating {
106+ t.Errorf("Expected rating %f, got %f", original.Rating, retrieved.Rating)
107+ }
108+ if retrieved.Notes != original.Notes {
109+ t.Errorf("Expected notes %s, got %s", original.Notes, retrieved.Notes)
110+ }
111+ })
112+113+ t.Run("Update Movie", func(t *testing.T) {
114+ movie := createSampleMovie()
115+ id, err := repo.Create(ctx, movie)
116+ if err != nil {
117+ t.Fatalf("Failed to create movie: %v", err)
118+ }
119+120+ movie.Title = "Updated Movie"
121+ movie.Status = "watched"
122+ movie.Rating = 9.0
123+ now := time.Now()
124+ movie.Watched = &now
125+126+ err = repo.Update(ctx, movie)
127+ if err != nil {
128+ t.Errorf("Failed to update movie: %v", err)
129+ }
130+131+ updated, err := repo.Get(ctx, id)
132+ if err != nil {
133+ t.Fatalf("Failed to get updated movie: %v", err)
134+ }
135+136+ if updated.Title != "Updated Movie" {
137+ t.Errorf("Expected updated title, got %s", updated.Title)
138+ }
139+ if updated.Status != "watched" {
140+ t.Errorf("Expected status watched, got %s", updated.Status)
141+ }
142+ if updated.Rating != 9.0 {
143+ t.Errorf("Expected rating 9.0, got %f", updated.Rating)
144+ }
145+ if updated.Watched == nil {
146+ t.Error("Expected watched time to be set")
147+ }
148+ })
149+150+ t.Run("Delete Movie", func(t *testing.T) {
151+ movie := createSampleMovie()
152+ id, err := repo.Create(ctx, movie)
153+ if err != nil {
154+ t.Fatalf("Failed to create movie: %v", err)
155+ }
156+157+ err = repo.Delete(ctx, id)
158+ if err != nil {
159+ t.Errorf("Failed to delete movie: %v", err)
160+ }
161+162+ _, err = repo.Get(ctx, id)
163+ if err == nil {
164+ t.Error("Expected error when getting deleted movie")
165+ }
166+ })
167+ })
168+169+ t.Run("List", func(t *testing.T) {
170+ db := createMovieTestDB(t)
171+ repo := NewMovieRepository(db)
172+ ctx := context.Background()
173+174+ movies := []*models.Movie{
175+ {Title: "Movie 1", Year: 2020, Status: "queued", Rating: 8.0},
176+ {Title: "Movie 2", Year: 2021, Status: "watched", Rating: 7.5},
177+ {Title: "Movie 3", Year: 2022, Status: "queued", Rating: 9.0},
178+ }
179+180+ for _, movie := range movies {
181+ _, err := repo.Create(ctx, movie)
182+ if err != nil {
183+ t.Fatalf("Failed to create movie: %v", err)
184+ }
185+ }
186+187+ t.Run("List All Movies", func(t *testing.T) {
188+ results, err := repo.List(ctx, MovieListOptions{})
189+ if err != nil {
190+ t.Errorf("Failed to list movies: %v", err)
191+ }
192+193+ if len(results) != 3 {
194+ t.Errorf("Expected 3 movies, got %d", len(results))
195+ }
196+ })
197+198+ t.Run("List Movies with Status Filter", func(t *testing.T) {
199+ results, err := repo.List(ctx, MovieListOptions{Status: "queued"})
200+ if err != nil {
201+ t.Errorf("Failed to list movies: %v", err)
202+ }
203+204+ if len(results) != 2 {
205+ t.Errorf("Expected 2 queued movies, got %d", len(results))
206+ }
207+208+ for _, movie := range results {
209+ if movie.Status != "queued" {
210+ t.Errorf("Expected queued status, got %s", movie.Status)
211+ }
212+ }
213+ })
214+215+ t.Run("List Movies with Year Filter", func(t *testing.T) {
216+ results, err := repo.List(ctx, MovieListOptions{Year: 2021})
217+ if err != nil {
218+ t.Errorf("Failed to list movies: %v", err)
219+ }
220+221+ if len(results) != 1 {
222+ t.Errorf("Expected 1 movie from 2021, got %d", len(results))
223+ }
224+225+ if len(results) > 0 && results[0].Year != 2021 {
226+ t.Errorf("Expected year 2021, got %d", results[0].Year)
227+ }
228+ })
229+230+ t.Run("List Movies with Rating Filter", func(t *testing.T) {
231+ results, err := repo.List(ctx, MovieListOptions{MinRating: 8.0})
232+ if err != nil {
233+ t.Errorf("Failed to list movies: %v", err)
234+ }
235+236+ if len(results) != 2 {
237+ t.Errorf("Expected 2 movies with rating >= 8.0, got %d", len(results))
238+ }
239+240+ for _, movie := range results {
241+ if movie.Rating < 8.0 {
242+ t.Errorf("Expected rating >= 8.0, got %f", movie.Rating)
243+ }
244+ }
245+ })
246+247+ t.Run("List Movies with Search", func(t *testing.T) {
248+ results, err := repo.List(ctx, MovieListOptions{Search: "Movie 1"})
249+ if err != nil {
250+ t.Errorf("Failed to list movies: %v", err)
251+ }
252+253+ if len(results) != 1 {
254+ t.Errorf("Expected 1 movie matching search, got %d", len(results))
255+ }
256+257+ if len(results) > 0 && results[0].Title != "Movie 1" {
258+ t.Errorf("Expected 'Movie 1', got %s", results[0].Title)
259+ }
260+ })
261+262+ t.Run("List Movies with Limit", func(t *testing.T) {
263+ results, err := repo.List(ctx, MovieListOptions{Limit: 2})
264+ if err != nil {
265+ t.Errorf("Failed to list movies: %v", err)
266+ }
267+268+ if len(results) != 2 {
269+ t.Errorf("Expected 2 movies due to limit, got %d", len(results))
270+ }
271+ })
272+ })
273+274+ t.Run("Special Methods", func(t *testing.T) {
275+ db := createMovieTestDB(t)
276+ repo := NewMovieRepository(db)
277+ ctx := context.Background()
278+279+ movie1 := &models.Movie{Title: "Queued Movie", Status: "queued", Rating: 8.0}
280+ movie2 := &models.Movie{Title: "Watched Movie", Status: "watched", Rating: 9.0}
281+ movie3 := &models.Movie{Title: "Another Queued", Status: "queued", Rating: 7.0}
282+283+ var movie1ID int64
284+ for _, movie := range []*models.Movie{movie1, movie2, movie3} {
285+ id, err := repo.Create(ctx, movie)
286+ if err != nil {
287+ t.Fatalf("Failed to create movie: %v", err)
288+ }
289+ if movie == movie1 {
290+ movie1ID = id
291+ }
292+ }
293+294+ t.Run("GetQueued", func(t *testing.T) {
295+ results, err := repo.GetQueued(ctx)
296+ if err != nil {
297+ t.Errorf("Failed to get queued movies: %v", err)
298+ }
299+300+ if len(results) != 2 {
301+ t.Errorf("Expected 2 queued movies, got %d", len(results))
302+ }
303+304+ for _, movie := range results {
305+ if movie.Status != "queued" {
306+ t.Errorf("Expected queued status, got %s", movie.Status)
307+ }
308+ }
309+ })
310+311+ t.Run("GetWatched", func(t *testing.T) {
312+ results, err := repo.GetWatched(ctx)
313+ if err != nil {
314+ t.Errorf("Failed to get watched movies: %v", err)
315+ }
316+317+ if len(results) != 1 {
318+ t.Errorf("Expected 1 watched movie, got %d", len(results))
319+ }
320+321+ if len(results) > 0 && results[0].Status != "watched" {
322+ t.Errorf("Expected watched status, got %s", results[0].Status)
323+ }
324+ })
325+326+ t.Run("MarkWatched", func(t *testing.T) {
327+ err := repo.MarkWatched(ctx, movie1ID)
328+ if err != nil {
329+ t.Errorf("Failed to mark movie as watched: %v", err)
330+ }
331+332+ updated, err := repo.Get(ctx, movie1ID)
333+ if err != nil {
334+ t.Fatalf("Failed to get updated movie: %v", err)
335+ }
336+337+ if updated.Status != "watched" {
338+ t.Errorf("Expected status to be watched, got %s", updated.Status)
339+ }
340+341+ if updated.Watched == nil {
342+ t.Error("Expected watched timestamp to be set")
343+ }
344+ })
345+ })
346+347+ t.Run("Count", func(t *testing.T) {
348+ db := createMovieTestDB(t)
349+ repo := NewMovieRepository(db)
350+ ctx := context.Background()
351+352+ movies := []*models.Movie{
353+ {Title: "Movie 1", Status: "queued", Rating: 8.0},
354+ {Title: "Movie 2", Status: "watched", Rating: 7.0},
355+ {Title: "Movie 3", Status: "queued", Rating: 9.0},
356+ }
357+358+ for _, movie := range movies {
359+ _, err := repo.Create(ctx, movie)
360+ if err != nil {
361+ t.Fatalf("Failed to create movie: %v", err)
362+ }
363+ }
364+365+ t.Run("Count all movies", func(t *testing.T) {
366+ count, err := repo.Count(ctx, MovieListOptions{})
367+ if err != nil {
368+ t.Errorf("Failed to count movies: %v", err)
369+ }
370+371+ if count != 3 {
372+ t.Errorf("Expected 3 movies, got %d", count)
373+ }
374+ })
375+376+ t.Run("Count queued movies", func(t *testing.T) {
377+ count, err := repo.Count(ctx, MovieListOptions{Status: "queued"})
378+ if err != nil {
379+ t.Errorf("Failed to count queued movies: %v", err)
380+ }
381+382+ if count != 2 {
383+ t.Errorf("Expected 2 queued movies, got %d", count)
384+ }
385+ })
386+387+ t.Run("Count movies by rating", func(t *testing.T) {
388+ count, err := repo.Count(ctx, MovieListOptions{MinRating: 8.0})
389+ if err != nil {
390+ t.Errorf("Failed to count high-rated movies: %v", err)
391+ }
392+393+ if count != 2 {
394+ t.Errorf("Expected 2 movies with rating >= 8.0, got %d", count)
395+ }
396+ })
397+ })
398+}
+15-45
internal/repo/repo.go
···1package repo
23import (
4- "context"
5-6- "stormlightlabs.org/noteleaf/internal/models"
7)
89-// Repository defines a general, behavior-focused interface for data access
10-type Repository interface {
11- // Create stores a new model and returns its assigned ID
12- Create(ctx context.Context, model models.Model) (int64, error)
13-14- // Get retrieves a model by ID
15- Get(ctx context.Context, table string, id int64, dest models.Model) error
16-17- // Update modifies an existing model
18- Update(ctx context.Context, model models.Model) error
19-20- // Delete removes a model by ID
21- Delete(ctx context.Context, table string, id int64) error
22-23- // List retrieves models with optional filtering and sorting
24- List(ctx context.Context, table string, opts ListOptions, dest any) error
25-26- // Find retrieves models matching specific conditions
27- Find(ctx context.Context, table string, conditions map[string]any, dest any) error
28-29- // Count returns the number of models matching conditions
30- Count(ctx context.Context, table string, conditions map[string]any) (int64, error)
31-32- // Execute runs a custom query with parameters
33- Execute(ctx context.Context, query string, args ...any) error
34-35- // Query runs a custom query and returns results
36- Query(ctx context.Context, query string, dest any, args ...any) error
37}
3839-// ListOptions defines generic options for listing items
40-type ListOptions struct {
41- // field: value pairs for WHERE conditions
42- Where map[string]any
43- Limit int
44- Offset int
45- // field name to sort by
46- SortBy string
47- // "asc" or "desc"
48- SortOrder string
49- // general search term
50- Search string
51- // fields to search in
52- SearchFields []string
53}
···1package repo
23import (
4+ "database/sql"
005)
67+// Repositories provides access to all resource repositories
8+type Repositories struct {
9+ Tasks *TaskRepository
10+ Movies *MovieRepository
11+ TV *TVRepository
12+ Books *BookRepository
000000000000000000000013}
1415+// NewRepositories creates a new set of repositories
16+func NewRepositories(db *sql.DB) *Repositories {
17+ return &Repositories{
18+ Tasks: NewTaskRepository(db),
19+ Movies: NewMovieRepository(db),
20+ TV: NewTVRepository(db),
21+ Books: NewBookRepository(db),
22+ }
00000023}
···1+# Noteleaf project commands
2+3+# Default recipe - show available commands
4+default:
5+ @just --list
6+7+# Run all tests
8+test:
9+ go test ./... -v
10+11+# Run tests with coverage
12+coverage:
13+ go test ./... -coverprofile=coverage.out
14+ go tool cover -html=coverage.out -o coverage.html
15+ @echo "Coverage report generated: coverage.html"
16+17+# Run tests and show coverage in terminal
18+test-coverage:
19+ go test ./... -coverprofile=coverage.out
20+ go tool cover -func=coverage.out
21+22+# Build the binary to /tmp/
23+build:
24+ mkdir -p /tmp/
25+ go build -o /tmp/noteleaf ./cmd/cli/
26+ @echo "Binary built: /tmp/noteleaf/noteleaf"
27+28+# Clean build artifacts
29+clean:
30+ rm -f coverage.out coverage.html
31+ rm -rf /tmp/noteleaf
32+33+# Run linting
34+lint:
35+ go vet ./...
36+ go fmt ./...
37+38+# Run all quality checks
39+check: lint test-coverage
40+41+# Install dependencies
42+deps:
43+ go mod download
44+ go mod tidy
45+46+# Run the application (after building)
47+run: build
48+ /tmp/noteleaf/noteleaf
49+50+# Show project status
51+status:
52+ @echo "Go version:"
53+ @go version
54+ @echo ""
55+ @echo "Module info:"
56+ @go list -m
57+ @echo ""
58+ @echo "Dependencies:"
59+ @go list -m all | head -10
60+61+# Quick development workflow
62+dev: clean lint test build
63+ @echo "Development workflow complete!"