diff --git a/ROADMAP.md b/ROADMAP.md index e1e7211..fc54419 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,294 +1,266 @@ # ROADMAP -## Task Management Commands (TaskWarrior-inspired) +Noteleaf is a command-line and TUI tool for managing tasks, notes, media, and articles. This roadmap outlines milestones: current capabilities, planned baseline features (v1), and future directions. -### Implemented Commands +## Core Usability -- [x] `todo add [description]` - Create new task with metadata (priority, project, context, due, tags) -- [x] `todo list` - Display tasks with filtering (status, priority, project, context) and interactive/static modes -- [x] `todo view [task-id]` - View task details with format options (detailed, brief, json) -- [x] `todo update [task-id]` - Edit task properties via flags -- [x] `todo edit [task-id]` - Interactive task editor with status picker and priority toggle -- [x] `todo done [task-id]` - Mark task as completed -- [x] `todo delete [task-id]` - Remove task permanently +The foundation across all domains is implemented. Tasks support CRUD operations, projects, tags, contexts, and time tracking. Notes have create, list, read, edit, and remove commands with interactive and static modes. Media queues exist for books, movies, and TV with progress and status management. SQLite persistence is in place with setup, seed, and reset commands. TUIs and colorized output are available. ---- +## RC -- [x] `todo projects` - List all project names (interactive/static modes) -- [x] `todo tags` - List all tag names (interactive/static modes) -- [x] `todo contexts` - List all contexts/locations (interactive/static modes) +### CORE ---- +- [ ] Ensure **all documented subcommands** exist and work: + - Tasks: add, list, view, update, edit, delete, projects, tags, contexts, done, start, stop, timesheet + - Notes: create, list, read, edit, remove + - Books: add, list, reading, finished, remove, progress, update + - Movies: add, list, watched, remove + - TV: add, list, watching, watched, remove + - Articles: add, list, view, read, remove +- [ ] Confirm all **aliases** work (`todo`, `ls`, `rm`, etc.). +- [ ] Verify **flags** and argument parsing match man page (priority, project, context, due, tags, etc.). +- [ ] Implement or finish stubs (e.g. `config management` noted in code). -- [x] `todo start [task-id]` - Start time tracking for a task -- [x] `todo stop [task-id]` - Stop time tracking for a task -- [x] `todo timesheet` - Show time tracking summaries (with date range and task filters) +### Task Management Domain -### Commands To Be Implemented +- [ ] Verify tasks can be created with all attributes (priority, project, context, due date, tags). +- [ ] Confirm task listing supports interactive and static modes. +- [ ] Implement status filtering (`pending`, `completed`, etc.). +- [ ] Validate time tracking (start/stop) writes entries and timesheet summarizes correctly. +- [ ] Ensure update supports add/remove tags and all fields. +- [ ] Test interactive editor (`task edit`). + +### Notes Domain + +- [ ] Implement note creation from: + - Inline text + - File (`--file`) + - Interactive input (`--interactive`) +- [ ] Verify note list interactive TUI works, static list fallback works. +- [ ] Confirm filtering by tags and `--archived`. +- [ ] Ensure notes can be opened, edited in `$EDITOR`, and deleted. + +### Media Domains + +#### Books + +- [ ] Implement search + add (possibly external API). +- [ ] Verify list supports statuses (`queued`, `reading`, `finished`). +- [ ] Progress updates (`book progress`) work with percentages. +- [ ] Status update (`book update`) accepts valid values. + +#### Movies + +- [ ] Implement search + add. +- [ ] Verify `list` with status filtering (`all`, `queued`, `watched`). +- [ ] Confirm `watched`/`remove` commands update correctly. + +#### TV + +- [ ] Implement search + add. +- [ ] Verify `list` with multiple statuses (`queued`, `watching`, `watched`). +- [ ] Ensure `watching`, `watched`, `remove` commands behave correctly. + +#### Articles + +- [ ] Implement article parser (XPath/domain-specific rules). +- [ ] Save articles in Markdown + HTML. +- [ ] Verify metadata is stored in DB. +- [ ] Confirm list supports query, author filter, limit. +- [ ] Test article view/read/remove. + +### Configuration & Data + +- [ ] Implement **config management** (flagged TODO in code). +- [ ] Define config file format (TOML, YAML, JSON). +- [ ] Set default config/data paths: + - Linux: `~/.config/noteleaf`, `~/.local/share/noteleaf` + - macOS: `~/Library/Application Support/noteleaf` + - Windows: `%APPDATA%\noteleaf` +- [ ] Implement overrides with environment variables (`NOTELEAF_CONFIG`, `NOTELEAF_DATA_DIR`). +- [ ] Ensure consistent DB schema migrations and versioning. + +### Documentation + +- [ ] Finalize **man page** (plaintext + roff). +- [ ] Write quickstart guide in `README.md`. +- [ ] Add examples for each command. +- [ ] Document config file with defaults and examples. +- [ ] Provide developer docs for contributing. + +### QA + +- [ ] Verify **unit tests** for all handlers (TaskHandler, NoteHandler, Media Handlers). +- [ ] Write **integration tests** covering CLI flows. +- [ ] Ensure error handling works for: + - Invalid IDs + - Invalid flags + - Schema corruption (already tested in repo) +- [ ] Test cross-platform behavior (Linux/macOS/Windows). + +### Packaging + +- [ ] Provide prebuilt binaries (via GoReleaser). +- [ ] Add installation instructions (Homebrew, AUR, Scoop, etc.). +- [ ] Version bump to `v1.0.0-rc1`. +- [ ] Publish man page with release. +- [ ] Verify `noteleaf --version` returns correct string. + +## v1 Features + +Planned functionality for a complete baseline release. + +### Tasks + +- [ ] Model + - [ ] Dependencies + - [ ] Recurrence (`recur`, `until`, templates) + - [ ] Wait/scheduled dates + - [ ] Urgency scoring +- [ ] Operations + - [ ] `annotate` + - [ ] Bulk edit and undo/history + - [ ] `$EDITOR` integration +- [ ] Reports and Views + - [ ] Next actions + - [ ] Completed/waiting/blocked reports + - [ ] Calendar view + - [ ] Sorting and urgency-based views +- [ ] Queries and Filters + - [ ] Rich query language + - [ ] Saved filters and aliases +- [ ] Interoperability + - [ ] JSON import/export + - [ ] todo.txt compatibility + +### Notes + +- [ ] Commands + - [ ] `note search` + - [ ] `note tag` + - [ ] `note recent` + - [ ] `note templates` + - [ ] `note archive` + - [ ] `note export` +- [ ] Features + - [ ] Full-text search + - [ ] Linking between notes, tasks, and media + +### Media + +- [ ] Articles/papers/blogs + - [ ] Parser with domain-specific rules + - [ ] Commands: `add`, `list`, `view`, `remove` + - [ ] Metadata validation and storage +- [ ] Books + - [ ] Source tracking and ratings + - [ ] Genre/topic tagging +- [ ] Movies/TV + - [ ] Ratings and notes + - [ ] Genre/topic tagging + - [ ] Episode/season progress for TV + - [ ] Platform/source tracking + +## Beyond v1 + +Features that demonstrate Go proficiency and broaden Noteleaf’s scope. + +### Tasks + +- [ ] Parallel report generation and background services +- [ ] Hook system for task lifecycle events +- [ ] Plugin mechanism +- [ ] Generics-based filter engine +- [ ] Functional options for configuration +- [ ] Error handling with wrapping and sentinel checks + +### Notes + +- [ ] Templates system for note types +- [ ] Versioning and history +- [ ] Export with formatting +- [ ] Import from other systems + +### Media + +- [ ] External imports (Goodreads, IMDB, Letterboxd) +- [ ] Cross-referencing across media types +- [ ] Analytics: velocity, completion rates + +### Articles + +- [ ] Enhanced parsing coverage +- [ ] Export to multiple formats +- [ ] Linking with tasks and notes + +### User Experience + +- [ ] Shell completions +- [ ] Manpages and docs generator +- [ ] Theming and customizable output +- [ ] Calendar integration + +### Tasks + +- [ ] Sub-tasks and hierarchical tasks +- [ ] Visual dependency mapping +- [ ] Forecasting and smart suggestions +- [ ] Habit and streak tracking +- [ ] Context-aware recommendations + +### Notes + +- [ ] Graph view of linked notes +- [ ] Content extraction and summarization +- [ ] Encryption and privacy controls + +### Media + +- [ ] Podcast and YouTube management +- [ ] Multi-format (audiobooks, comics) +- [ ] Media consumption goals and streaks +- [ ] Media budget tracking +- [ ] Seasonal and energy-based filtering + +### Articles + +- [ ] Content validation +- [ ] Encryption support +- [ ] Advanced classification + +## Technical Infrastructure + +### Completed + +SQLite persistence, CI with GitHub Actions and Codecov, TUIs with Charm stack, initial help system. + +### Planned + +- Prebuilt binaries for releases +- Installation and usage documentation +- Contribution guide and developer docs +- Consistent argument parsing +- Backup/restore +- Multiple profiles +- Optional synchronization + +## v1 Feature Matrix -- [ ] Due dates & scheduling - Including recurring tasks -- [ ] Task dependencies - Task A blocks task B relationships -- [ ] `annotate` - Add notes/comments to existing tasks -- [ ] Recurring tasks -- [ ] Smart due date suggestions -- [ ] Completion notifications -- [ ] `calendar` - Display tasks in calendar view - -## Media Queue Management Commands - -### Implemented Commands - -Book Management - -- [x] `media book add [search query...]` - Search and add book to reading list (with interactive mode) -- [x] `media book list` - Show reading queue with progress and status filtering -- [x] `media book reading ` - Mark book as currently reading -- [x] `media book finished ` - Mark book as completed -- [x] `media book remove ` - Remove from reading list -- [x] `media book progress ` - Update reading progress (0-100%) -- [x] `media book update ` - Update book status (queued|reading|finished|removed) - -Movie Management - -- [x] `media movie add [title]` - Add movie to watch queue (with interactive mode) -- [x] `media movie list` - Show movie queue with status filtering -- [x] `media movie watched ` - Mark movie as watched -- [x] `media movie remove ` - Remove from queue - -TV Show Management - -- [x] `media tv add [title]` - Add TV show/season to queue (with interactive mode) -- [x] `media tv list` - Show TV queue with status filtering -- [x] `media tv watching ` - Mark TV show as currently watching -- [x] `media tv watched ` - Mark episodes/seasons as watched -- [x] `media tv remove ` - Remove from TV queue - -### Commands To Be Implemented - ---- - -- [ ] Articles, papers, blogs support (implement article parser) -- [ ] Source tracking (recommendation sources) -- [ ] Ratings and personal notes -- [ ] Genre/topic tagging -- [ ] Episode/season progress tracking for TV -- [ ] Platform tracking (Netflix, Amazon, etc.) -- [ ] Watch status: queued, watching, completed, dropped - -## Management Commands - -### Implemented Commands - -Application Management - -- [x] `status` - Show application status and configuration -- [x] `setup` - Initialize and manage application setup -- [x] `setup seed` - Populate database with test data (with --force flag) -- [x] `reset` - Reset the application (removes all data) -- [x] `config [key] [value]` - Manage configuration settings (stubbed) - -### Commands To Be Implemented - -Organization Features - -- [ ] Custom queries and saved searches -- [ ] Context-aware suggestions -- [ ] Overdue/urgent highlighting -- [ ] Recently added/modified items -- [ ] Seasonal/mood-based filtering -- [ ] Full-text search across titles, notes, tags - -Analytics - -- [ ] Reading/watching velocity tracking -- [ ] Completion rates by content type -- [ ] Time investment analysis -- [ ] Personal productivity metrics -- [ ] Content source analysis - -Integrations - -- [ ] `import` - Import from various formats (CSV, JSON, todo.txt) -- [ ] `export` - Export to various formats -- [ ] Goodreads import for books -- [ ] IMDB/Letterboxd import for movies -- [ ] Todo.txt format compatibility -- [ ] TaskWarrior import/export -- [ ] URL parsing for automatic metadata - -`todo.txt` Compatibility - -- [ ] `archive` - Move completed tasks to done.txt -- [ ] `[con]texts` - List all contexts (@context) -- [ ] `[proj]ects` - List all projects (+project) -- [ ] `[pri]ority` - Set task priority (A-Z) -- [ ] `[depri]oritize` - Remove priority from task -- [ ] `[re]place` - Replace task text entirely -- [ ] `[pre]pend/[app]end` - Add text to beginning/end of task - -Automation - -- [ ] Auto-categorization of new items -- [ ] Smart due date suggestions -- [ ] Recurring content (weekly podcast check-ins) -- [ ] Completion notifications - -Storage - -- [ ] `sync` - Synchronize with remote storage -- [ ] `sync setup` - Setup remote storage -- [ ] Local SQLite database with optional cloud sync -- [ ] Multiple profile support -- [ ] `backup` - Create local backup -- [ ] Backup/restore functionality - -Configuration - -- [x] Enhanced `config` command implementation (basic stubbed version) -- [ ] `undo` - Reverse last operation -- [ ] Themes and personalization -- [ ] Customizable output formats - -## Notes Management Commands - -### Implemented Commands - -Core Notes Operations - -- [x] `note create [title] [content...]` - Create new markdown note with optional interactive editor -- [x] `note list` - Interactive TUI browser for navigating and viewing notes (with archive and tag filtering) -- [x] `note read ` - Display formatted note content with syntax highlighting -- [x] `note edit ` - Edit note in configured editor -- [x] `note remove ` - Permanently remove note file and metadata - -Additional Options - -- [x] `--interactive|-i` flag for create command (opens editor) -- [x] `--file|-f` flag for create command (create from markdown file) -- [x] `--archived|-a` flag for list command -- [x] `--tags` filtering for list command - -### Commands To Be Implemented - -- [ ] `note search [query]` - Search notes by content, title, or tags -- [ ] `note tag [tags...]` - Add/remove tags from notes -- [ ] `note recent` - Show recently created/modified notes -- [ ] `note templates` - Create notes from predefined templates -- [ ] `note archive ` - Archive old notes -- [ ] `note export` - Export notes to various formats -- [ ] Full-text search integration -- [ ] Linking between notes and tasks/content - -## User Experience - -- [x] Interactive TUI modes for task lists, projects, tags, contexts, and notes -- [x] Static output modes as alternatives to interactive TUI -- [x] Color-coded priority and status indicators -- [x] Comprehensive help system via cobra CLI framework - ---- - -- [ ] Quick-add commands for rapid entry -- [ ] Enhanced progress tracking UI -- [ ] Calendar view for tasks - -### Technical Infrastructure - -- [ ] CI/CD pipeline -> pre-build binaries -- [ ] Complete README/documentation -- [ ] Installation instructions -- [ ] Usage examples - -## Tech Debt - -### Signatures - -We've got inconsistent argument parsing and sanitization leading to calls to strconv.Atoi in tests & handler funcs. -This is only done correctly in the note command -> handler sequence - -- [ ] TaskCommand -- [ ] MovieCommand -- [ ] TVCommand -- [ ] BookCommand - -### Movie Commands - Missing Tests - -- [x] movie watched [id] - marks movie as watched - -### TV Commands - Missing Tests - -- [x] tv watching [id] - marks TV show as watching -- [x] tv watched [id] - marks TV show as watched - -### Book Commands - Missing Tests - -- [x] book add [search query...] - search and add book -- [x] book reading `` - marks book as reading -- [x] book finished `` - marks book as finished -- [x] book progress `` `` - updates reading progress - -## Ideas - -### Task Management Enhancements - -- Sub-tasks and hierarchical tasks - Break complex tasks into child tasks for better organization -- Linking - Establish relationships between related tasks without strict dependencies -- Batching - Group related tasks for bulk operations (completion, priority changes, etc.) -- Retrospectives - Analysis of completed tasks to improve future estimates and planning -- Automation rules - Create rules that automatically modify tasks based on conditions -- Habit formation - Track recurring micro-tasks that build into larger goals -- Context switching - Automatically adjust system settings, apps, or environment based on current task -- Forecasting - Predict future tasks based on patterns, calendar events, or seasonal trends -- "Energy" matching - Recommend tasks based on current energy levels or time of day -- Priority rebalancing - Automatically suggest priority adjustments based on deadlines and importance -- Dependency visualization: Visual flow charts showing how tasks connect and block each other - -### Media Management Enhancements - -- Podcast management: Add podcast tracking with episode progress and subscription management -- YouTube/video content management: Track video content queues and viewing progress -- Multi-format media support: Include audiobooks, comics, and other content formats -- Media consumption goals: Set reading/watching goals (e.g., "2 books per month") -- Media cross-referencing: Connect related content across different media types - - Series -- Media note integration: Link notes to specific books, movies, or shows for reviews - - Review system - Write and store personal reviews of consumed content -- Media budget tracking: Track spending on media content (subscriptions, purchases) -- Media consumption patterns: Analyze personal consumption patterns and preferences over time -- Media seasonal tracking: Track seasonal media preferences and suggest accordingly -- Media completion streaks: Gamification elements for consistent media consumption -- Media progress synchronization: Sync progress across different devices or platforms - -### Notes Management Enhancements - -- Linking and graph view: Create bidirectional links between related notes -- Templates system: Predefined templates for different note types (meeting notes, book summaries) -- Versioning and history: Track changes to notes over time with ability to revert -- Export with formatting: Rich export options with preserved formatting and links -- Import capabilities: Import notes from other systems (Notion, Evernote, etc.) -- Content extraction: Extract key points or action items from longer notes -- Encryption: End-to-end encryption for sensitive notes -- Content validation: Check for broken links, missing references, or inconsistent information - -### System Integration & Automation - -- Calendar integration: Sync tasks with calendar systems (Google Calendar, Outlook) -- Email integration: Create tasks or notes from emails automatically -- Browser extension: Quick capture of web content as tasks or notes -- IDE/plugin integration: Direct integration with code editors and development environments -- File system integration: Monitor files for content that should become tasks or notes - -### Advanced UI/UX Features - -- Customizable themes: Multiple visual themes and color schemes -- Terminal interface enhancements: Rich TUI with advanced navigation and visualization -- Web-based interface: Alternative web UI for browser-based access -- Advanced filtering and sorting: Complex query systems for data manipulation -- Visual task mapping: Gantt charts, Kanban boards, and other visual representations -- Quick entry mode: Rapid capture interface for minimal friction -- Keyboard customization: Fully customizable keyboard shortcuts - -### Security and Privacy - -- End-to-end encryption: Full encryption of sensitive data -- Local-first architecture: Guarantee that all data remains local +| Domain | Feature | Status | +|----------|-----------------------|-----------| +| Tasks | CRUD | Complete | +| Tasks | Projects/tags | Complete | +| Tasks | Time tracking | Complete | +| Tasks | Dependencies | Planned | +| Tasks | Recurrence | Planned | +| Tasks | Wait/scheduled | Planned | +| Tasks | Urgency scoring | Planned | +| Notes | CRUD | Complete | +| Notes | Search/tagging | Planned | +| Media | Books/movies/TV | Complete | +| Media | Articles | Planned | +| Media | Source/ratings | Planned | +| Articles | Parser + storage | Planned | +| System | SQLite persistence | Complete | +| System | Synchronization | Future | +| System | Import/export formats | Future | diff --git a/cmd/task_commands.go b/cmd/task_commands.go index b5e722f..6687fb8 100644 --- a/cmd/task_commands.go +++ b/cmd/task_commands.go @@ -1,6 +1,8 @@ package main import ( + "strings" + "github.com/spf13/cobra" "github.com/stormlightlabs/noteleaf/internal/handlers" ) @@ -37,20 +39,29 @@ func addTaskCmd(h *handlers.TaskHandler) *cobra.Command { Aliases: []string{"create", "new"}, Args: cobra.MinimumNArgs(1), RunE: func(c *cobra.Command, args []string) error { + description := strings.Join(args, " ") priority, _ := c.Flags().GetString("priority") project, _ := c.Flags().GetString("project") context, _ := c.Flags().GetString("context") due, _ := c.Flags().GetString("due") + recur, _ := c.Flags().GetString("recur") + until, _ := c.Flags().GetString("until") + parent, _ := c.Flags().GetString("parent") + dependsOn, _ := c.Flags().GetString("depends-on") tags, _ := c.Flags().GetStringSlice("tags") defer h.Close() - return h.Create(c.Context(), args, priority, project, context, due, tags) + return h.Create(c.Context(), description, priority, project, context, due, recur, until, parent, dependsOn, tags) }, } cmd.Flags().StringP("priority", "p", "", "Set task priority") cmd.Flags().String("project", "", "Set task project") cmd.Flags().StringP("context", "c", "", "Set task context") cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") + cmd.Flags().String("recur", "", "Set recurrence rule (e.g., FREQ=DAILY)") + cmd.Flags().String("until", "", "Set recurrence end date (YYYY-MM-DD)") + cmd.Flags().String("parent", "", "Set parent task UUID") + cmd.Flags().String("depends-on", "", "Set task dependencies (comma-separated UUIDs)") cmd.Flags().StringSliceP("tags", "t", []string{}, "Add tags to task") return cmd @@ -123,11 +134,16 @@ func updateTaskCmd(handler *handlers.TaskHandler) *cobra.Command { project, _ := cmd.Flags().GetString("project") context, _ := cmd.Flags().GetString("context") due, _ := cmd.Flags().GetString("due") + recur, _ := cmd.Flags().GetString("recur") + until, _ := cmd.Flags().GetString("until") + parent, _ := cmd.Flags().GetString("parent") addTags, _ := cmd.Flags().GetStringSlice("add-tag") removeTags, _ := cmd.Flags().GetStringSlice("remove-tag") + addDeps, _ := cmd.Flags().GetString("add-depends") + removeDeps, _ := cmd.Flags().GetString("remove-depends") defer handler.Close() - return handler.Update(cmd.Context(), taskID, description, status, priority, project, context, due, addTags, removeTags) + return handler.Update(cmd.Context(), taskID, description, status, priority, project, context, due, recur, until, parent, addTags, removeTags, addDeps, removeDeps) }, } updateCmd.Flags().String("description", "", "Update task description") @@ -136,8 +152,13 @@ func updateTaskCmd(handler *handlers.TaskHandler) *cobra.Command { updateCmd.Flags().String("project", "", "Update task project") updateCmd.Flags().StringP("context", "c", "", "Update task context") updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)") + updateCmd.Flags().String("recur", "", "Update recurrence rule") + updateCmd.Flags().String("until", "", "Update recurrence end date (YYYY-MM-DD)") + updateCmd.Flags().String("parent", "", "Update parent task UUID") updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task") updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task") + updateCmd.Flags().String("add-depends", "", "Add task dependencies (comma-separated UUIDs)") + updateCmd.Flags().String("remove-depends", "", "Remove task dependencies (comma-separated UUIDs)") return updateCmd } diff --git a/docs/manual/noteleaf.1.txt b/docs/manual/noteleaf.1.txt new file mode 100644 index 0000000..14c7640 --- /dev/null +++ b/docs/manual/noteleaf.1.txt @@ -0,0 +1,268 @@ +NOTELEAF(1) User Commands NOTELEAF(1) + +NAME + noteleaf - manage tasks, notes, books, movies, TV shows, and saved + articles from the command line + +SYNOPSIS + noteleaf [--help] [--version] [] + +DESCRIPTION + noteleaf is a terminal-first productivity tool written in Go. It combines + task management (with time tracking), note-taking, and media queues + (books, movies, TV shows, and saved articles) into a single command-line interface. + + The design borrows from Taskwarrior and todo.txt but extends them with + features such as: + - Interactive TUIs for browsing notes and tasks + - Article parsing and storage + - Unified commands across different domains + - Time tracking with start/stop and timesheet summaries + + Subcommands are grouped by domain: task, note, book, movie, tv, and article. + Each group has its own subcommands and options. + +OPTIONS + --help, -h + Show help for noteleaf or any subcommand. + + --version, -v + Print the current version and exit. + +COMMANDS + General + help [command] + Show help for a command or subcommand. + + version + Print the program version. + + TASK COMMANDS + noteleaf task add [description] + Add a new task. + Flags: + -p, --priority Set task priority + --project Set project + -c, --context Set context + -d, --due YYYY-MM-DD Set due date + -t, --tags tag1,tag2 Add tags + + noteleaf task list + List tasks. + Flags: + -i, --interactive Force interactive mode + --static Print static list + -a, --all Show all tasks + --status Filter by status + --priority Filter by priority + --project Filter by project + --context Filter by context + + noteleaf task view + View task details. + Flags: + --format detailed|brief Output format + --json Print JSON + --no-metadata Hide metadata + + noteleaf task update + Update task fields. + Flags: + --description + --status + -p, --priority + --project + -c, --context + -d, --due YYYY-MM-DD + --add-tag tag + --remove-tag tag + + noteleaf task edit + Interactive edit with status/priority toggles. + Alias: e + + noteleaf task delete + Delete task permanently. + + noteleaf task projects + List projects. + Flags: --static, --todo-txt + + noteleaf task tags + List tags. + Flags: --static + + noteleaf task contexts + List contexts. + Flags: --static, --todo-txt + + noteleaf task done + Mark task as completed. + Alias: complete + + noteleaf task start + Start time tracking for a task. + Flags: -n, --note Add note to entry + + noteleaf task stop + Stop time tracking for a task. + + noteleaf task timesheet + Show time tracking summary. + Flags: + -d, --days Number of days (default 7) + -t, --task Timesheet for specific task + + MOVIE COMMANDS + noteleaf movie add [query...] + Search and add a movie to the watch queue. + Flags: -i, --interactive + + noteleaf movie list [--all|--watched|--queued] + List movies by status. + + noteleaf movie watched + Mark movie as watched. + Alias: seen + + noteleaf movie remove + Remove from queue. + Alias: rm + + TV COMMANDS + noteleaf tv add [query...] + Search and add a TV show to the watch queue. + Flags: -i, --interactive + + noteleaf tv list [--all|--queued|--watching|--watched] + List TV shows by status. + + noteleaf tv watching + Mark as currently watching. + + noteleaf tv watched + Mark as watched. + Alias: seen + + noteleaf tv remove + Remove show from queue. + Alias: rm + + BOOK COMMANDS + noteleaf book add [query...] + Search and add a book to the reading list. + Flags: -i, --interactive + + noteleaf book list [--all|--reading|--finished|--queued] + Show reading list. + + noteleaf book reading + Mark book as currently reading. + + noteleaf book finished + Mark book as finished. + Alias: read + + noteleaf book remove + Remove from reading list. + Alias: rm + + noteleaf book progress + Update reading progress percentage (0-100). + + noteleaf book update + Update status (queued|reading|finished|removed). + + NOTE COMMANDS + noteleaf note create [title] [content...] + Create a note. + Aliases: new + Flags: + -i, --interactive Open interactive editor + -e, --editor Open note in editor + -f, --file Create from markdown file + + noteleaf note list + List notes (interactive TUI or static). + Aliases: ls + Flags: + -a, --archived Include archived + -s, --static Static list + --tags tag1,tag2 Filter by tags + + noteleaf note read + Display note content. + Alias: view + + noteleaf note edit + Edit note in editor. + + noteleaf note remove + Remove note permanently. + Aliases: rm, delete, del + + ARTICLE COMMANDS + noteleaf article add + Parse and save article from URL. + + noteleaf article list [query] + List saved articles. + Aliases: ls + Flags: + --author + -l, --limit + + noteleaf article view + Show article metadata and preview. + Alias: show + + noteleaf article read + Display full content as Markdown. + + noteleaf article remove + Remove article and associated files. + Aliases: rm, delete + +EXIT STATUS + noteleaf returns 0 on success. + Non-zero exit status indicates an error. + +EXAMPLES + Add and list tasks: + noteleaf task add "Write blog post" -p H --project blog --due 2025-10-15 + noteleaf task list --project blog + + Mark complete: + noteleaf task done 42 + + Track media: + noteleaf book add "The Name of the Wind" + noteleaf movie add "Blade Runner" -i + noteleaf tv list --watching + + Work with notes: + noteleaf note create "Ideas" "sketch out product roadmap" + noteleaf note list --tags=work + + Save an article: + noteleaf article add https://example.com/post + noteleaf article list --author "Ada Lovelace" + +FILES + (TODO: configuration and data file paths once implemented) + +SEE ALSO + Taskwarrior(1), todo.txt(5), git(1), neovim(1), rsync(1) + +AUTHOR + Owais @ Stormlight Labs + +BUGS + Please report issues at: https://github.com/stormlightlabs/noteleaf/issues + +COPYRIGHT + Copyright (c) 2025 Stormlight Labs, LLC. + Licensed under the MIT License. + +NOTELEAF(1) User Commands NOTELEAF(1) + diff --git a/internal/handlers/tasks.go b/internal/handlers/tasks.go index f98d686..fe234cb 100644 --- a/internal/handlers/tasks.go +++ b/internal/handlers/tasks.go @@ -53,31 +53,70 @@ func (h *TaskHandler) Close() error { } // Create creates a new task -func (h *TaskHandler) Create(ctx context.Context, desc []string, priority, project, context, due string, tags []string) error { - if len(desc) < 1 { +func (h *TaskHandler) Create(ctx context.Context, description, priority, project, context, due, recur, until, parentUUID, dependsOn string, tags []string) error { + if description == "" { return fmt.Errorf("task description required") } - description := strings.Join(desc, " ") + parsed := parseDescription(description) + + if project != "" { + parsed.Project = project + } + if context != "" { + parsed.Context = context + } + if due != "" { + parsed.Due = due + } + if recur != "" { + parsed.Recur = recur + } + if until != "" { + parsed.Until = until + } + if parentUUID != "" { + parsed.ParentUUID = parentUUID + } + if dependsOn != "" { + parsed.DependsOn = strings.Split(dependsOn, ",") + } + if len(tags) > 0 { + parsed.Tags = append(parsed.Tags, tags...) + } task := &models.Task{ UUID: uuid.New().String(), - Description: description, + Description: parsed.Description, Status: "pending", Priority: priority, - Project: project, - Context: context, - Tags: tags, + Project: parsed.Project, + Context: parsed.Context, + Tags: parsed.Tags, + Recur: models.RRule(parsed.Recur), + DependsOn: parsed.DependsOn, } - if due != "" { - if dueTime, err := time.Parse("2006-01-02", due); err == nil { + if parsed.Due != "" { + if dueTime, err := time.Parse("2006-01-02", parsed.Due); err == nil { task.Due = &dueTime } else { return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) } } + if parsed.Until != "" { + if untilTime, err := time.Parse("2006-01-02", parsed.Until); err == nil { + task.Until = &untilTime + } else { + return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) + } + } + + if parsed.ParentUUID != "" { + task.ParentUUID = &parsed.ParentUUID + } + id, err := h.repos.Tasks.Create(ctx, task) if err != nil { return fmt.Errorf("failed to create task: %w", err) @@ -88,18 +127,30 @@ func (h *TaskHandler) Create(ctx context.Context, desc []string, priority, proje if priority != "" { fmt.Printf("Priority: %s\n", priority) } - if project != "" { - fmt.Printf("Project: %s\n", project) + if task.Project != "" { + fmt.Printf("Project: %s\n", task.Project) } - if context != "" { - fmt.Printf("Context: %s\n", context) + if task.Context != "" { + fmt.Printf("Context: %s\n", task.Context) } - if len(tags) > 0 { - fmt.Printf("Tags: %s\n", strings.Join(tags, ", ")) + if len(task.Tags) > 0 { + fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", ")) } if task.Due != nil { fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02")) } + if task.Recur != "" { + fmt.Printf("Recur: %s\n", task.Recur) + } + if task.Until != nil { + fmt.Printf("Until: %s\n", task.Until.Format("2006-01-02")) + } + if task.ParentUUID != nil { + fmt.Printf("Parent: %s\n", *task.ParentUUID) + } + if len(task.DependsOn) > 0 { + fmt.Printf("Depends on: %s\n", strings.Join(task.DependsOn, ", ")) + } return nil } @@ -150,7 +201,7 @@ func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, st } // Update updates a task using parsed flag values -func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, context, due string, addTags, removeTags []string) error { +func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, context, due, recur, until, parentUUID string, addTags, removeTags []string, addDeps, removeDeps string) error { var task *models.Task var err error @@ -186,6 +237,19 @@ func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, p return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) } } + if recur != "" { + task.Recur = models.RRule(recur) + } + if until != "" { + if untilTime, err := time.Parse("2006-01-02", until); err == nil { + task.Until = &untilTime + } else { + return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) + } + } + if parentUUID != "" { + task.ParentUUID = &parentUUID + } for _, tag := range addTags { if !slices.Contains(task.Tags, tag) { @@ -197,6 +261,26 @@ func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, p task.Tags = removeString(task.Tags, tag) } + // Handle dependency additions + if addDeps != "" { + deps := strings.Split(addDeps, ",") + for _, dep := range deps { + dep = strings.TrimSpace(dep) + if dep != "" && !slices.Contains(task.DependsOn, dep) { + task.DependsOn = append(task.DependsOn, dep) + } + } + } + + // Handle dependency removals + if removeDeps != "" { + deps := strings.Split(removeDeps, ",") + for _, dep := range deps { + dep = strings.TrimSpace(dep) + task.DependsOn = removeString(task.DependsOn, dep) + } + } + err = h.repos.Tasks.Update(ctx, task) if err != nil { return fmt.Errorf("failed to update task: %w", err) @@ -681,6 +765,14 @@ func (h *TaskHandler) printTask(task *models.Task) { fmt.Printf(" (due: %s)", task.Due.Format("2006-01-02")) } + if task.Recur != "" { + fmt.Printf(" \u21bb") + } + + if len(task.DependsOn) > 0 { + fmt.Printf(" \u2937%d", len(task.DependsOn)) + } + fmt.Println() } @@ -710,6 +802,25 @@ func (h *TaskHandler) printTaskDetail(task *models.Task, noMetadata bool) { fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02 15:04")) } + if task.Recur != "" { + fmt.Printf("Recurrence: %s\n", task.Recur) + } + + if task.Until != nil { + fmt.Printf("Recur Until: %s\n", task.Until.Format("2006-01-02")) + } + + if task.ParentUUID != nil { + fmt.Printf("Parent Task: %s\n", *task.ParentUUID) + } + + if len(task.DependsOn) > 0 { + fmt.Printf("Depends On:\n") + for _, dep := range task.DependsOn { + fmt.Printf(" - %s\n", dep) + } + } + if !noMetadata { fmt.Printf("Created: %s\n", task.Entry.Format("2006-01-02 15:04")) fmt.Printf("Modified: %s\n", task.Modified.Format("2006-01-02 15:04")) @@ -740,6 +851,54 @@ func (h *TaskHandler) printTaskJSON(task *models.Task) error { return nil } +// ParsedTaskData holds extracted metadata from a task description +type ParsedTaskData struct { + Description string + Project string + Context string + Tags []string + Due string + Recur string + Until string + ParentUUID string + DependsOn []string +} + +// parseDescription extracts inline metadata from description text +// Supports: +project @context #tag due:YYYY-MM-DD recur:RULE until:DATE parent:UUID depends:UUID1,UUID2 +func parseDescription(text string) *ParsedTaskData { + parsed := &ParsedTaskData{Tags: []string{}, DependsOn: []string{}} + words := strings.Fields(text) + + var descWords []string + for _, word := range words { + switch { + case strings.HasPrefix(word, "+"): + parsed.Project = strings.TrimPrefix(word, "+") + case strings.HasPrefix(word, "@"): + parsed.Context = strings.TrimPrefix(word, "@") + case strings.HasPrefix(word, "#"): + parsed.Tags = append(parsed.Tags, strings.TrimPrefix(word, "#")) + case strings.HasPrefix(word, "due:"): + parsed.Due = strings.TrimPrefix(word, "due:") + case strings.HasPrefix(word, "recur:"): + parsed.Recur = strings.TrimPrefix(word, "recur:") + case strings.HasPrefix(word, "until:"): + parsed.Until = strings.TrimPrefix(word, "until:") + case strings.HasPrefix(word, "parent:"): + parsed.ParentUUID = strings.TrimPrefix(word, "parent:") + case strings.HasPrefix(word, "depends:"): + deps := strings.TrimPrefix(word, "depends:") + parsed.DependsOn = strings.Split(deps, ",") + default: + descWords = append(descWords, word) + } + } + + parsed.Description = strings.Join(descWords, " ") + return parsed +} + func removeString(slice []string, item string) []string { var result []string for _, s := range slice { @@ -772,9 +931,9 @@ func formatDuration(d time.Duration) string { return fmt.Sprintf("%.1fh", hours) } days := int(hours / 24) - remainingHours := hours - float64(days*24) - if remainingHours == 0 { + if remainingHours := hours - float64(days*24); remainingHours == 0 { return fmt.Sprintf("%dd", days) + } else { + return fmt.Sprintf("%dd %.1fh", days, remainingHours) } - return fmt.Sprintf("%dd %.1fh", days, remainingHours) } diff --git a/internal/handlers/tasks_test.go b/internal/handlers/tasks_test.go index 6969f97..95ad664 100644 --- a/internal/handlers/tasks_test.go +++ b/internal/handlers/tasks_test.go @@ -103,9 +103,8 @@ func TestTaskHandler(t *testing.T) { t.Run("creates task successfully", func(t *testing.T) { ctx := context.Background() - args := []string{"Buy groceries", "and", "cook dinner"} - - err := handler.Create(ctx, args, "", "", "", "", []string{}) + desc := "Buy groceries and cook dinner" + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) if err != nil { t.Errorf("CreateTask failed: %v", err) } @@ -136,9 +135,8 @@ func TestTaskHandler(t *testing.T) { t.Run("fails with empty description", func(t *testing.T) { ctx := context.Background() - args := []string{} - - err := handler.Create(ctx, args, "", "", "", "", []string{}) + desc := "" + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) if err == nil { t.Error("Expected error for empty description") } @@ -150,13 +148,13 @@ func TestTaskHandler(t *testing.T) { t.Run("creates task with flags", func(t *testing.T) { ctx := context.Background() - args := []string{"Task", "with", "flags"} + description := "Task with flags" priority := "A" project := "test-project" due := "2024-12-31" tags := []string{"urgent", "work"} - err := handler.Create(ctx, args, priority, project, "test-context", due, tags) + err := handler.Create(ctx, description, priority, project, "test-context", due, "", "", "", "", tags) if err != nil { t.Errorf("CreateTask with flags failed: %v", err) } @@ -209,10 +207,10 @@ func TestTaskHandler(t *testing.T) { t.Run("fails with invalid due date format", func(t *testing.T) { ctx := context.Background() - args := []string{"Task", "with", "invalid", "date"} + desc := "Task with invalid date" invalidDue := "invalid-date" - err := handler.Create(ctx, args, "", "", "", invalidDue, []string{}) + err := handler.Create(ctx, desc, "", "", "", invalidDue, "", "", "", "", []string{}) if err == nil { t.Error("Expected error for invalid due date format") } @@ -228,7 +226,6 @@ func TestTaskHandler(t *testing.T) { defer cleanup() ctx := context.Background() - handler, err := NewTaskHandler() if err != nil { t.Fatalf("Failed to create handler: %v", err) @@ -336,7 +333,7 @@ func TestTaskHandler(t *testing.T) { t.Run("updates task by ID", func(t *testing.T) { taskID := strconv.FormatInt(id, 10) - err := handler.Update(ctx, taskID, "Updated description", "", "", "", "", "", []string{}, []string{}) + err := handler.Update(ctx, taskID, "Updated description", "", "", "", "", "", "", "", "", []string{}, []string{}, "", "") if err != nil { t.Errorf("UpdateTask failed: %v", err) } @@ -353,8 +350,7 @@ func TestTaskHandler(t *testing.T) { t.Run("updates task by UUID", func(t *testing.T) { taskID := task.UUID - - err := handler.Update(ctx, taskID, "", "completed", "", "", "", "", []string{}, []string{}) + err := handler.Update(ctx, taskID, "", "completed", "", "", "", "", "", "", "", []string{}, []string{}, "", "") if err != nil { t.Errorf("UpdateTask by UUID failed: %v", err) } @@ -371,8 +367,7 @@ func TestTaskHandler(t *testing.T) { t.Run("updates multiple fields", func(t *testing.T) { taskID := strconv.FormatInt(id, 10) - - err := handler.Update(ctx, taskID, "Multiple updates", "", "B", "test", "office", "2024-12-31", []string{}, []string{}) + err := handler.Update(ctx, taskID, "Multiple updates", "", "B", "test", "office", "2024-12-31", "", "", "", []string{}, []string{}, "", "") if err != nil { t.Errorf("UpdateTask with multiple fields failed: %v", err) } @@ -398,8 +393,7 @@ func TestTaskHandler(t *testing.T) { t.Run("adds and removes tags", func(t *testing.T) { taskID := strconv.FormatInt(id, 10) - - err := handler.Update(ctx, taskID, "", "", "", "", "", "", []string{"work", "urgent"}, []string{}) + err := handler.Update(ctx, taskID, "", "", "", "", "", "", "", "", "", []string{"work", "urgent"}, []string{}, "", "") if err != nil { t.Errorf("UpdateTask with add tags failed: %v", err) } @@ -415,7 +409,7 @@ func TestTaskHandler(t *testing.T) { taskID = strconv.FormatInt(id, 10) - err = handler.Update(ctx, taskID, "", "", "", "", "", "", []string{}, []string{"urgent"}) + err = handler.Update(ctx, taskID, "", "", "", "", "", "", "", "", "", []string{}, []string{"urgent"}, "", "") if err != nil { t.Errorf("UpdateTask with remove tag failed: %v", err) } @@ -435,7 +429,7 @@ func TestTaskHandler(t *testing.T) { }) t.Run("fails with missing task ID", func(t *testing.T) { - err := handler.Update(ctx, "", "", "", "", "", "", "", []string{}, []string{}) + err := handler.Update(ctx, "", "", "", "", "", "", "", "", "", "", []string{}, []string{}, "", "") if err == nil { t.Error("Expected error for missing task ID") } @@ -448,7 +442,7 @@ func TestTaskHandler(t *testing.T) { t.Run("fails with invalid task ID", func(t *testing.T) { taskID := "99999" - err := handler.Update(ctx, taskID, "test", "", "", "", "", "", []string{}, []string{}) + err := handler.Update(ctx, taskID, "test", "", "", "", "", "", "", "", "", []string{}, []string{}, "", "") if err == nil { t.Error("Expected error for invalid task ID") } @@ -957,12 +951,12 @@ func TestTaskHandler(t *testing.T) { ctx := context.Background() - err = handler.Create(ctx, []string{"Test", "Task", "1"}, "high", "test-project", "test-context", "", []string{"tag1"}) + err = handler.Create(ctx, "Test Task 1", "high", "test-project", "test-context", "", "", "", "", "", []string{"tag1"}) if err != nil { t.Fatalf("Failed to create test task: %v", err) } - err = handler.Create(ctx, []string{"Test", "Task", "2"}, "medium", "test-project", "test-context", "", []string{"tag2"}) + err = handler.Create(ctx, "Test Task 2", "medium", "test-project", "test-context", "", "", "", "", "", []string{"tag2"}) if err != nil { t.Fatalf("Failed to create test task: %v", err) } diff --git a/internal/models/models.go b/internal/models/models.go index 424a903..2ab8cba 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -36,6 +36,10 @@ const ( PriorityNumericMax = 5 ) +// RRule represents a recurrence rule (RFC 5545). +// Example: "FREQ=DAILY;INTERVAL=1" or "FREQ=WEEKLY;BYDAY=MO,WE,FR". +type RRule string + // Model defines the common interface that all domain models must implement type Model interface { // GetID returns the primary key identifier @@ -56,33 +60,32 @@ type Model interface { // Task represents a task item with TaskWarrior-inspired fields type Task struct { - ID int64 `json:"id"` - UUID string `json:"uuid"` - Description string `json:"description"` - // pending, completed, deleted - Status string `json:"status"` - // A-Z or empty - Priority string `json:"priority,omitempty"` - Project string `json:"project,omitempty"` - Context string `json:"context,omitempty"` - Tags []string `json:"tags,omitempty"` - Due *time.Time `json:"due,omitempty"` - Entry time.Time `json:"entry"` - Modified time.Time `json:"modified"` - // completion time - End *time.Time `json:"end,omitempty"` - // when task was started - Start *time.Time `json:"start,omitempty"` + ID int64 `json:"id"` + UUID string `json:"uuid"` + Description string `json:"description"` + Status string `json:"status"` // pending, completed, deleted + Priority string `json:"priority,omitempty"` // A-Z or empty + Project string `json:"project,omitempty"` + Context string `json:"context,omitempty"` + Tags []string `json:"tags,omitempty"` + Due *time.Time `json:"due,omitempty"` + Entry time.Time `json:"entry"` + Modified time.Time `json:"modified"` + End *time.Time `json:"end,omitempty"` // Completion time + Start *time.Time `json:"start,omitempty"` // When the task was started Annotations []string `json:"annotations,omitempty"` + Recur RRule `json:"recur,omitempty"` + Until *time.Time `json:"until,omitempty"` // End date for recurrence + ParentUUID *string `json:"parent_uuid,omitempty"` // ID of parent/template task + DependsOn []string `json:"depends_on,omitempty"` // IDs of tasks this task depends on } // Movie represents a movie in the watch queue type Movie struct { - ID int64 `json:"id"` - Title string `json:"title"` - Year int `json:"year,omitempty"` - // queued, watched, removed - Status string `json:"status"` + ID int64 `json:"id"` + Title string `json:"title"` + Year int `json:"year,omitempty"` + Status string `json:"status"` // queued, watched, removed Rating float64 `json:"rating,omitempty"` Notes string `json:"notes,omitempty"` Added time.Time `json:"added"` @@ -91,12 +94,11 @@ type Movie struct { // TVShow represents a TV show in the watch queue type TVShow struct { - ID int64 `json:"id"` - Title string `json:"title"` - Season int `json:"season,omitempty"` - Episode int `json:"episode,omitempty"` - // queued, watching, watched, removed - Status string `json:"status"` + ID int64 `json:"id"` + Title string `json:"title"` + Season int `json:"season,omitempty"` + Episode int `json:"episode,omitempty"` + Status string `json:"status"` // queued, watching, watched, removed Rating float64 `json:"rating,omitempty"` Notes string `json:"notes,omitempty"` Added time.Time `json:"added"` @@ -105,13 +107,11 @@ type TVShow struct { // Book represents a book in the reading list type Book struct { - ID int64 `json:"id"` - Title string `json:"title"` - Author string `json:"author,omitempty"` - // queued, reading, finished, removed - Status string `json:"status"` - // percentage 0-100 - Progress int `json:"progress"` + ID int64 `json:"id"` + Title string `json:"title"` + Author string `json:"author,omitempty"` + Status string `json:"status"` // queued, reading, finished, removed + Progress int `json:"progress"` // percentage 0-100 Pages int `json:"pages,omitempty"` Rating float64 `json:"rating,omitempty"` Notes string `json:"notes,omitempty"` @@ -310,6 +310,57 @@ func (t *Task) GetPriorityWeight() int { } } +// IsStarted returns true if the task has a start time set. +func (t *Task) IsStarted() bool { + return t.Start != nil +} + +// IsOverdue returns true if the task is overdue. +func (t *Task) IsOverdue(now time.Time) bool { + return t.Due != nil && now.After(*t.Due) && !t.IsCompleted() +} + +// HasDueDate returns true if the task has a due date set. +func (t *Task) HasDueDate() bool { + return t.Due != nil +} + +// IsRecurring returns true if the task has recurrence defined. +func (t *Task) IsRecurring() bool { + return t.Recur != "" +} + +// IsRecurExpired checks if the recurrence has an end (until) date and is past it. +func (t *Task) IsRecurExpired(now time.Time) bool { + return t.Until != nil && now.After(*t.Until) +} + +// HasDependencies returns true if the task depends on other tasks. +func (t *Task) HasDependencies() bool { + return len(t.DependsOn) > 0 +} + +// Blocks checks if this task blocks another given task. +func (t *Task) Blocks(other *Task) bool { + return slices.Contains(other.DependsOn, t.UUID) +} + +// Urgency computes a score based on priority, due date, and tags. +// This can be expanded later with weights. +func (t *Task) Urgency(now time.Time) float64 { + score := 0.0 + if t.Priority != "" { + score += 1.0 + } + if t.IsOverdue(now) { + score += 2.0 + } + if len(t.Tags) > 0 { + score += 0.5 + } + return score +} + // IsWatched returns true if the movie has been watched func (m *Movie) IsWatched() bool { return m.Status == "watched" diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 1bfb0ba..8b0c98b 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -374,6 +374,109 @@ func TestModels(t *testing.T) { } }) + t.Run("IsStarted", func(t *testing.T) { + now := time.Now() + task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now} + + if task.IsStarted() { + t.Errorf("expected IsStarted to be false, got true") + } + task.Start = &now + if !task.IsStarted() { + t.Errorf("expected IsStarted to be true, got false") + } + }) + + t.Run("HasDueDate and IsOverdue", func(t *testing.T) { + now := time.Now() + past := now.Add(-24 * time.Hour) + future := now.Add(24 * time.Hour) + task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now} + + if task.HasDueDate() { + t.Errorf("expected HasDueDate to be false, got true") + } + task.Due = &future + if !task.HasDueDate() { + t.Errorf("expected HasDueDate to be true, got false") + } + task.Due = &past + task.Status = string(StatusPending) + if !task.IsOverdue(now) { + t.Errorf("expected overdue task, got false") + } + task.Status = string(StatusCompleted) + if task.IsOverdue(now) { + t.Errorf("expected completed task not to be overdue, got true") + } + }) + + t.Run("IsRecurring and IsRecurExpired", func(t *testing.T) { + now := time.Now() + past := now.Add(-24 * time.Hour) + future := now.Add(24 * time.Hour) + + task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now} + if task.IsRecurring() { + t.Errorf("expected IsRecurring to be false, got true") + } + task.Recur = "FREQ=DAILY" + if !task.IsRecurring() { + t.Errorf("expected IsRecurring to be true, got false") + } + if task.IsRecurExpired(now) { + t.Errorf("expected IsRecurExpired to be false without Until, got true") + } + task.Until = &past + if !task.IsRecurExpired(now) { + t.Errorf("expected IsRecurExpired to be true, got false") + } + task.Until = &future + if task.IsRecurExpired(now) { + t.Errorf("expected IsRecurExpired to be false, got true") + } + }) + + t.Run("HasDependencies and Blocks", func(t *testing.T) { + now := time.Now() + task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now} + if task.HasDependencies() { + t.Errorf("expected HasDependencies to be false, got true") + } + task.DependsOn = []string{"abc"} + if !task.HasDependencies() { + t.Errorf("expected HasDependencies to be true, got false") + } + other := Task{UUID: "abc", DependsOn: []string{"123"}} + if !task.Blocks(&other) { + t.Errorf("expected task to block other, got false") + } + other.DependsOn = []string{} + if task.Blocks(&other) { + t.Errorf("expected task not to block other, got true") + } + }) + + t.Run("Urgency", func(t *testing.T) { + now := time.Now() + past := now.Add(-24 * time.Hour) + + task := Task{ + UUID: "u1", + Description: "urgency test", + Priority: "H", + Tags: []string{"t1"}, + Due: &past, + Status: string(StatusPending), + Entry: now, + Modified: now, + } + score := task.Urgency(now) + if score <= 0 { + t.Errorf("expected positive urgency score, got %f", score) + } + }) + }) t.Run("Movie Model", func(t *testing.T) { diff --git a/internal/repo/repositories_test.go b/internal/repo/repositories_test.go index 361016a..41517ab 100644 --- a/internal/repo/repositories_test.go +++ b/internal/repo/repositories_test.go @@ -36,7 +36,20 @@ func createFullTestDB(t *testing.T) *sql.DB { modified DATETIME DEFAULT CURRENT_TIMESTAMP, end DATETIME, start DATETIME, - annotations TEXT + annotations TEXT, + recur TEXT, + until DATETIME, + parent_uuid TEXT + ); + + -- Task dependencies table + CREATE TABLE IF NOT EXISTS task_dependencies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_uuid TEXT NOT NULL, + depends_on_uuid TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE, + FOREIGN KEY(depends_on_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE ); -- Movies table @@ -110,12 +123,7 @@ func TestRepositories(t *testing.T) { ctx := context.Background() t.Run("Create all resource types", func(t *testing.T) { - task := &models.Task{ - UUID: uuid.New().String(), - Description: "Integration test task", - Status: "pending", - Project: "integration", - } + task := &models.Task{UUID: uuid.New().String(), Description: "Integration test task", Status: "pending", Project: "integration"} taskID, err := repos.Tasks.Create(ctx, task) if err != nil { t.Errorf("Failed to create task: %v", err) @@ -138,13 +146,7 @@ func TestRepositories(t *testing.T) { t.Error("Expected non-zero movie ID") } - tvShow := &models.TVShow{ - Title: "Integration Series", - Season: 1, - Episode: 1, - Status: "queued", - Rating: 9.0, - } + tvShow := &models.TVShow{Title: "Integration Series", Season: 1, Episode: 1, Status: "queued", Rating: 9.0} tvID, err := repos.TV.Create(ctx, tvShow) if err != nil { t.Errorf("Failed to create TV show: %v", err) @@ -168,11 +170,7 @@ func TestRepositories(t *testing.T) { t.Error("Expected non-zero book ID") } - note := &models.Note{ - Title: "Integration Note", - Content: "This is test content for integration", - Tags: []string{"integration", "test"}, - } + note := &models.Note{Title: "Integration Note", Content: "This is test content for integration", Tags: []string{"integration", "test"}} noteID, err := repos.Notes.Create(ctx, note) if err != nil { t.Errorf("Failed to create note: %v", err) diff --git a/internal/repo/task_repository.go b/internal/repo/task_repository.go index 4edc068..a2c6da5 100644 --- a/internal/repo/task_repository.go +++ b/internal/repo/task_repository.go @@ -70,12 +70,18 @@ func (r *TaskRepository) Create(ctx context.Context, task *models.Task) (int64, } query := ` - INSERT INTO tasks (uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + INSERT INTO tasks ( + uuid, description, status, priority, project, context, + tags, due, entry, modified, end, start, annotations, + recur, until, parent_uuid + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` result, err := r.db.ExecContext(ctx, query, task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, - tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations) + tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations, + task.Recur, task.Until, task.ParentUUID, + ) if err != nil { return 0, fmt.Errorf("failed to insert task: %w", err) } @@ -86,22 +92,35 @@ func (r *TaskRepository) Create(ctx context.Context, task *models.Task) (int64, } task.ID = id + + // Sync dependencies to task_dependencies table + for _, depUUID := range task.DependsOn { + if err := r.AddDependency(ctx, task.UUID, depUUID); err != nil { + return 0, fmt.Errorf("failed to add dependency: %w", err) + } + } + return id, nil } // Get retrieves a task by ID func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) { query := ` - SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations + SELECT id, uuid, description, status, priority, project, context, tags, + due, entry, modified, end, start, annotations, + recur, until, parent_uuid FROM tasks WHERE id = ?` task := &models.Task{} var tags, annotations sql.NullString - - err := r.db.QueryRowContext(ctx, query, id).Scan( - &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, &task.Context, - &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations) - if err != nil { + var parentUUID sql.NullString + + if err := r.db.QueryRowContext(ctx, query, id).Scan( + &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, + &task.Project, &task.Context, &tags, + &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, + &task.Recur, &task.Until, &parentUUID, + ); err != nil { return nil, fmt.Errorf("failed to get task: %w", err) } @@ -116,6 +135,14 @@ func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) } } + if parentUUID.Valid { + task.ParentUUID = &parentUUID.String + } + + // Populate dependencies from task_dependencies table + if err := r.PopulateDependencies(ctx, task); err != nil { + return nil, fmt.Errorf("failed to populate dependencies: %w", err) + } return task, nil } @@ -135,17 +162,32 @@ func (r *TaskRepository) Update(ctx context.Context, task *models.Task) error { } query := ` - UPDATE tasks SET uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, - tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ? + UPDATE tasks SET + uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, + tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?, + recur = ?, until = ?, parent_uuid = ? WHERE id = ?` - _, err = r.db.ExecContext(ctx, query, + if _, err = r.db.ExecContext(ctx, query, task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, - tags, task.Due, task.Modified, task.End, task.Start, annotations, task.ID) - if err != nil { + tags, task.Due, task.Modified, task.End, task.Start, annotations, + task.Recur, task.Until, task.ParentUUID, + task.ID, + ); err != nil { return fmt.Errorf("failed to update task: %w", err) } + // Sync dependencies: clear existing and add new ones + if err := r.ClearDependencies(ctx, task.UUID); err != nil { + return fmt.Errorf("failed to clear dependencies: %w", err) + } + + for _, depUUID := range task.DependsOn { + if err := r.AddDependency(ctx, task.UUID, depUUID); err != nil { + return fmt.Errorf("failed to add dependency: %w", err) + } + } + return nil } @@ -183,7 +225,11 @@ func (r *TaskRepository) List(ctx context.Context, opts TaskListOptions) ([]*mod } func (r *TaskRepository) buildListQuery(opts TaskListOptions) string { - query := "SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations FROM tasks" + query := ` + SELECT id, uuid, description, status, priority, project, context, tags, + due, entry, modified, end, start, annotations, + recur, until, parent_uuid + FROM tasks` var conditions []string @@ -272,12 +318,21 @@ func (r *TaskRepository) buildListArgs(opts TaskListOptions) []any { func (r *TaskRepository) scanTaskRow(rows *sql.Rows, task *models.Task) error { var tags, annotations sql.NullString - - if err := rows.Scan(&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, - &task.Project, &task.Context, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil { + var parentUUID sql.NullString + + if err := rows.Scan( + &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, + &task.Project, &task.Context, &tags, + &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, + &task.Recur, &task.Until, &parentUUID, + ); err != nil { return fmt.Errorf("failed to scan task row: %w", err) } + if parentUUID.Valid { + task.ParentUUID = &parentUUID.String + } + if tags.Valid { if err := task.UnmarshalTags(tags.String); err != nil { return fmt.Errorf("failed to unmarshal tags: %w", err) @@ -317,6 +372,10 @@ func (r *TaskRepository) Count(ctx context.Context, opts TaskListOptions) (int64 conditions = append(conditions, "project = ?") args = append(args, opts.Project) } + if opts.Context != "" { + conditions = append(conditions, "context = ?") + args = append(args, opts.Context) + } if !opts.DueAfter.IsZero() { conditions = append(conditions, "due >= ?") args = append(args, opts.DueAfter) @@ -330,11 +389,12 @@ func (r *TaskRepository) Count(ctx context.Context, opts TaskListOptions) (int64 searchConditions := []string{ "description LIKE ?", "project LIKE ?", + "context LIKE ?", "tags LIKE ?", } conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) searchPattern := "%" + opts.Search + "%" - args = append(args, searchPattern, searchPattern, searchPattern) + args = append(args, searchPattern, searchPattern, searchPattern, searchPattern) } if len(conditions) > 0 { @@ -353,15 +413,21 @@ func (r *TaskRepository) Count(ctx context.Context, opts TaskListOptions) (int64 // GetByUUID retrieves a task by UUID func (r *TaskRepository) GetByUUID(ctx context.Context, uuid string) (*models.Task, error) { query := ` - SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations + SELECT id, uuid, description, status, priority, project, context, tags, + due, entry, modified, end, start, annotations, + recur, until, parent_uuid FROM tasks WHERE uuid = ?` task := &models.Task{} var tags, annotations sql.NullString + var parentUUID sql.NullString if err := r.db.QueryRowContext(ctx, query, uuid).Scan( - &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, - &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil { + &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, + &task.Project, &task.Context, &tags, + &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, + &task.Recur, &task.Until, &parentUUID, + ); err != nil { return nil, fmt.Errorf("failed to get task by UUID: %w", err) } @@ -377,6 +443,15 @@ func (r *TaskRepository) GetByUUID(ctx context.Context, uuid string) (*models.Ta } } + if parentUUID.Valid { + task.ParentUUID = &parentUUID.String + } + + // Populate dependencies from task_dependencies table + if err := r.PopulateDependencies(ctx, task); err != nil { + return nil, fmt.Errorf("failed to populate dependencies: %w", err) + } + return task, nil } @@ -484,10 +559,12 @@ func (r *TaskRepository) GetContexts(ctx context.Context) ([]ContextSummary, err // GetTasksByTag retrieves all tasks with a specific tag func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) { query := ` - SELECT tasks.id, tasks.uuid, tasks.description, tasks.status, tasks.priority, tasks.project, tasks.context, tasks.tags, tasks.due, tasks.entry, tasks.modified, tasks.end, tasks.start, tasks.annotations - FROM tasks, json_each(tasks.tags) - WHERE tasks.tags != '' AND tasks.tags IS NOT NULL AND json_each.value = ? - ORDER BY tasks.modified DESC` + SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, + t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, + t.recur, t.until, t.parent_uuid + FROM tasks t, json_each(t.tags) + WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ? + ORDER BY t.modified DESC` rows, err := r.db.QueryContext(ctx, query, tag) if err != nil { @@ -532,13 +609,13 @@ func (r *TaskRepository) GetAbandoned(ctx context.Context) ([]*models.Task, erro return r.List(ctx, TaskListOptions{Status: models.StatusAbandoned}) } -// GetByPriority retrieves all tasks with a specific priority -// -// We need special handling for empty priority by using raw SQL +// GetByPriority retrieves all tasks with a specific priority with special handling for empty priority by using raw SQL func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([]*models.Task, error) { if priority == "" { query := ` - SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations + SELECT id, uuid, description, status, priority, project, context, + tags, due, entry, modified, end, start, annotations, + recur, until, parent_uuid FROM tasks WHERE priority = '' OR priority IS NULL ORDER BY modified DESC` @@ -557,7 +634,6 @@ func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([] } tasks = append(tasks, task) } - return tasks, rows.Err() } @@ -637,3 +713,118 @@ func (r *TaskRepository) GetPrioritySummary(ctx context.Context) (map[string]int return summary, rows.Err() } + +// AddDependency creates a dependency relationship where taskUUID depends on dependsOnUUID. +func (r *TaskRepository) AddDependency(ctx context.Context, taskUUID, dependsOnUUID string) error { + _, err := r.db.ExecContext(ctx, + `INSERT INTO task_dependencies (task_uuid, depends_on_uuid) VALUES (?, ?)`, + taskUUID, dependsOnUUID) + if err != nil { + err = fmt.Errorf("failed to add dependency: %w", err) + } + return err +} + +// RemoveDependency deletes a specific dependency relationship. +func (r *TaskRepository) RemoveDependency(ctx context.Context, taskUUID, dependsOnUUID string) error { + _, err := r.db.ExecContext(ctx, + `DELETE FROM task_dependencies WHERE task_uuid = ? AND depends_on_uuid = ?`, + taskUUID, dependsOnUUID) + if err != nil { + err = fmt.Errorf("failed to remove dependency: %w", err) + } + return err +} + +// ClearDependencies removes all dependencies for a given task. +func (r *TaskRepository) ClearDependencies(ctx context.Context, taskUUID string) error { + _, err := r.db.ExecContext(ctx, + `DELETE FROM task_dependencies WHERE task_uuid = ?`, + taskUUID) + if err != nil { + err = fmt.Errorf("failed to clear dependencies: %w", err) + } + return err +} + +// GetDependencies returns the UUIDs of tasks this task depends on. +func (r *TaskRepository) GetDependencies(ctx context.Context, taskUUID string) ([]string, error) { + rows, err := r.db.QueryContext(ctx, + `SELECT depends_on_uuid FROM task_dependencies WHERE task_uuid = ?`, taskUUID) + if err != nil { + return nil, fmt.Errorf("failed to get dependencies: %w", err) + } + defer rows.Close() + + var deps []string + for rows.Next() { + var dep string + if err := rows.Scan(&dep); err != nil { + return nil, fmt.Errorf("failed to scan dependency: %w", err) + } + deps = append(deps, dep) + } + return deps, rows.Err() +} + +// PopulateDependencies loads dependency UUIDs from task_dependencies table into task.DependsOn +func (r *TaskRepository) PopulateDependencies(ctx context.Context, task *models.Task) error { + deps, err := r.GetDependencies(ctx, task.UUID) + if err != nil { + return err + } + task.DependsOn = deps + return nil +} + +// GetDependents returns tasks that are blocked by a given UUID. +func (r *TaskRepository) GetDependents(ctx context.Context, blockingUUID string) ([]*models.Task, error) { + query := ` + SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, + t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, + t.recur, t.until, t.parent_uuid + FROM tasks t + JOIN task_dependencies d ON t.uuid = d.task_uuid + WHERE d.depends_on_uuid = ?` + + rows, err := r.db.QueryContext(ctx, query, blockingUUID) + if err != nil { + return nil, fmt.Errorf("failed to get dependents: %w", err) + } + defer rows.Close() + + var tasks []*models.Task + for rows.Next() { + task := &models.Task{} + if err := r.scanTaskRow(rows, task); err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +// GetBlockedTasks finds tasks that are blocked by a given UUID. +func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) { + query := ` + SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, + t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid + FROM tasks t + JOIN task_dependencies d ON t.uuid = d.task_uuid + WHERE d.depends_on_uuid = ?` + rows, err := r.db.QueryContext(ctx, query, blockingUUID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []*models.Task + for rows.Next() { + task := &models.Task{} + if err := r.scanTaskRow(rows, task); err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} diff --git a/internal/repo/task_repository_test.go b/internal/repo/task_repository_test.go index 66e3924..72237bf 100644 --- a/internal/repo/task_repository_test.go +++ b/internal/repo/task_repository_test.go @@ -727,7 +727,6 @@ func TestTaskRepository(t *testing.T) { } } - // Test legacy priority results, err = repo.GetByPriority(ctx, "A") if err != nil { t.Errorf("Failed to get tasks by priority A: %v", err) @@ -798,7 +797,6 @@ func TestTaskRepository(t *testing.T) { t.Error("Expected non-empty status summary") } - // Check that we have expected statuses with counts expectedStatuses := []string{ models.StatusTodo, models.StatusInProgress, models.StatusDone, models.StatusBlocked, models.StatusAbandoned, @@ -854,6 +852,88 @@ func TestTaskRepository(t *testing.T) { } }) }) + + t.Run("Recurrence Fields", func(t *testing.T) { + task := CreateSampleTask() + task.Recur = "FREQ=DAILY" + until := time.Now().Add(7 * 24 * time.Hour) + task.Until = &until + parent := newUUID() + task.ParentUUID = &parent + + id, err := repo.Create(ctx, task) + if err != nil { + t.Fatalf("failed to create task with recurrence: %v", err) + } + + retrieved, err := repo.Get(ctx, id) + if err != nil { + t.Fatalf("failed to get task with recurrence: %v", err) + } + + if retrieved.Recur != "FREQ=DAILY" { + t.Errorf("expected Recur=FREQ=DAILY, got %s", retrieved.Recur) + } + if retrieved.Until == nil || !retrieved.Until.Equal(until) { + t.Errorf("expected Until=%v, got %v", until, retrieved.Until) + } + if retrieved.ParentUUID == nil || *retrieved.ParentUUID != parent { + t.Errorf("expected ParentUUID=%s, got %v", parent, retrieved.ParentUUID) + } + }) + + t.Run("Dependencies", func(t *testing.T) { + parent := CreateSampleTask() + child := CreateSampleTask() + + _, err := repo.Create(ctx, parent) + if err != nil { + t.Fatalf("failed to create parent: %v", err) + } + _, err = repo.Create(ctx, child) + if err != nil { + t.Fatalf("failed to create child: %v", err) + } + + if err := repo.AddDependency(ctx, child.UUID, parent.UUID); err != nil { + t.Fatalf("failed to add dependency: %v", err) + } + + deps, err := repo.GetDependencies(ctx, child.UUID) + if err != nil { + t.Fatalf("failed to get dependencies: %v", err) + } + if len(deps) != 1 || deps[0] != parent.UUID { + t.Errorf("expected child to depend on parent=%s, got %v", parent.UUID, deps) + } + + dependents, err := repo.GetDependents(ctx, parent.UUID) + if err != nil { + t.Fatalf("failed to get dependents: %v", err) + } + if len(dependents) != 1 || dependents[0].UUID != child.UUID { + t.Errorf("expected dependent to be child=%s, got %v", child.UUID, dependents) + } + + if err := repo.RemoveDependency(ctx, child.UUID, parent.UUID); err != nil { + t.Fatalf("failed to remove dependency: %v", err) + } + deps, _ = repo.GetDependencies(ctx, child.UUID) + if len(deps) != 0 { + t.Errorf("expected dependencies to be cleared, got %v", deps) + } + + if err := repo.AddDependency(ctx, child.UUID, parent.UUID); err != nil { + t.Fatalf("failed to re-add dependency: %v", err) + } + if err := repo.ClearDependencies(ctx, child.UUID); err != nil { + t.Fatalf("failed to clear dependencies: %v", err) + } + deps, _ = repo.GetDependencies(ctx, child.UUID) + if len(deps) != 0 { + t.Errorf("expected no dependencies after clear, got %v", deps) + } + }) } func TestTaskRepository_GetContexts(t *testing.T) { @@ -882,7 +962,6 @@ func TestTaskRepository_GetContexts(t *testing.T) { t.Fatalf("Failed to create task3: %v", err) } - // Task with empty context should not be included task4 := CreateSampleTask() task4.Context = "" _, err = repo.Create(ctx, task4) @@ -920,7 +999,6 @@ func TestTaskRepository_GetByContext(t *testing.T) { repo := NewTaskRepository(db) ctx := context.Background() - // Create tasks with different contexts task1 := CreateSampleTask() task1.Context = "work" task1.Description = "Work task 1" @@ -945,7 +1023,6 @@ func TestTaskRepository_GetByContext(t *testing.T) { t.Fatalf("Failed to create task3: %v", err) } - // Get tasks by work context workTasks, err := repo.GetByContext(ctx, "work") if err != nil { t.Fatalf("Failed to get tasks by context: %v", err) @@ -961,7 +1038,6 @@ func TestTaskRepository_GetByContext(t *testing.T) { } } - // Get tasks by home context homeTasks, err := repo.GetByContext(ctx, "home") if err != nil { t.Fatalf("Failed to get tasks by context: %v", err) diff --git a/internal/repo/test_utilities.go b/internal/repo/test_utilities.go index 9aaa759..8e8ea55 100644 --- a/internal/repo/test_utilities.go +++ b/internal/repo/test_utilities.go @@ -16,6 +16,110 @@ import ( var fake = faker.New() +const testSchema string = ` + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT UNIQUE NOT NULL, + description TEXT NOT NULL, + status TEXT DEFAULT 'pending', + priority TEXT, + project TEXT, + context TEXT, + tags TEXT, + due DATETIME, + entry DATETIME DEFAULT CURRENT_TIMESTAMP, + modified DATETIME DEFAULT CURRENT_TIMESTAMP, + end DATETIME, + start DATETIME, + annotations TEXT, + recur TEXT, + until DATETIME, + parent_uuid TEXT + ); + + CREATE TABLE IF NOT EXISTS task_dependencies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_uuid TEXT NOT NULL, + depends_on_uuid TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE, + FOREIGN KEY(depends_on_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + author TEXT, + status TEXT DEFAULT 'queued', + progress INTEGER DEFAULT 0, + pages INTEGER, + rating REAL, + notes TEXT, + added DATETIME DEFAULT CURRENT_TIMESTAMP, + started DATETIME, + finished DATETIME + ); + + CREATE TABLE IF NOT EXISTS movies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + year INTEGER, + status TEXT DEFAULT 'queued', + rating REAL, + notes TEXT, + added DATETIME DEFAULT CURRENT_TIMESTAMP, + watched DATETIME + ); + + CREATE TABLE IF NOT EXISTS tv_shows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + season INTEGER, + episode INTEGER, + status TEXT DEFAULT 'queued', + rating REAL, + notes TEXT, + added DATETIME DEFAULT CURRENT_TIMESTAMP, + last_watched DATETIME + ); + + CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT, + tags TEXT, + archived BOOLEAN DEFAULT FALSE, + created DATETIME DEFAULT CURRENT_TIMESTAMP, + modified DATETIME DEFAULT CURRENT_TIMESTAMP, + file_path TEXT + ); + + CREATE TABLE IF NOT EXISTS time_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + start_time DATETIME NOT NULL, + end_time DATETIME, + duration_seconds INTEGER, + description TEXT, + created DATETIME DEFAULT CURRENT_TIMESTAMP, + modified DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT UNIQUE NOT NULL, + title TEXT NOT NULL, + author TEXT, + date TEXT, + markdown_path TEXT NOT NULL, + html_path TEXT NOT NULL, + created DATETIME DEFAULT CURRENT_TIMESTAMP, + modified DATETIME DEFAULT CURRENT_TIMESTAMP + ); +` + // CreateTestDB creates an in-memory SQLite database with the full schema for testing func CreateTestDB(t *testing.T) *sql.DB { db, err := sql.Open("sqlite3", ":memory:") @@ -27,98 +131,7 @@ func CreateTestDB(t *testing.T) *sql.DB { t.Fatalf("Failed to enable foreign keys: %v", err) } - // Full schema for all tables - schema := ` - CREATE TABLE IF NOT EXISTS tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - uuid TEXT UNIQUE NOT NULL, - description TEXT NOT NULL, - status TEXT DEFAULT 'pending', - priority TEXT, - project TEXT, - context TEXT, - tags TEXT, - due DATETIME, - entry DATETIME DEFAULT CURRENT_TIMESTAMP, - modified DATETIME DEFAULT CURRENT_TIMESTAMP, - end DATETIME, - start DATETIME, - annotations TEXT - ); - - CREATE TABLE IF NOT EXISTS books ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - author TEXT, - status TEXT DEFAULT 'queued', - progress INTEGER DEFAULT 0, - pages INTEGER, - rating REAL, - notes TEXT, - added DATETIME DEFAULT CURRENT_TIMESTAMP, - started DATETIME, - finished DATETIME - ); - - CREATE TABLE IF NOT EXISTS movies ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - year INTEGER, - status TEXT DEFAULT 'queued', - rating REAL, - notes TEXT, - added DATETIME DEFAULT CURRENT_TIMESTAMP, - watched DATETIME - ); - - CREATE TABLE IF NOT EXISTS tv_shows ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - season INTEGER, - episode INTEGER, - status TEXT DEFAULT 'queued', - rating REAL, - notes TEXT, - added DATETIME DEFAULT CURRENT_TIMESTAMP, - last_watched DATETIME - ); - - CREATE TABLE IF NOT EXISTS notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT NOT NULL, - content TEXT, - tags TEXT, - archived BOOLEAN DEFAULT FALSE, - created DATETIME DEFAULT CURRENT_TIMESTAMP, - modified DATETIME DEFAULT CURRENT_TIMESTAMP, - file_path TEXT - ); - - CREATE TABLE IF NOT EXISTS time_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id INTEGER NOT NULL, - start_time DATETIME NOT NULL, - end_time DATETIME, - duration_seconds INTEGER, - created DATETIME DEFAULT CURRENT_TIMESTAMP, - modified DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS articles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT UNIQUE NOT NULL, - title TEXT NOT NULL, - author TEXT, - date TEXT, - markdown_path TEXT NOT NULL, - html_path TEXT NOT NULL, - created DATETIME DEFAULT CURRENT_TIMESTAMP, - modified DATETIME DEFAULT CURRENT_TIMESTAMP - ); - ` - - if _, err := db.Exec(schema); err != nil { + if _, err := db.Exec(testSchema); err != nil { t.Fatalf("Failed to create schema: %v", err) } @@ -129,7 +142,6 @@ func CreateTestDB(t *testing.T) *sql.DB { return db } -// Sample data creators func CreateSampleTask() *models.Task { return &models.Task{ UUID: uuid.New().String(), @@ -258,7 +270,6 @@ func CreateFakeArticles(count int) []*models.Article { return articles } -// Test helpers for common operations func AssertNoError(t *testing.T, err error, msg string) { t.Helper() if err != nil { @@ -332,7 +343,6 @@ func SetupTestData(t *testing.T, db *sql.DB) *Repositories { AssertNoError(t, err, "Failed to create sample task 2") task2.ID = id2 - // Create sample books book1 := CreateSampleBook() book1.Title = "Sample Book 1" book1.Status = "reading" @@ -349,7 +359,6 @@ func SetupTestData(t *testing.T, db *sql.DB) *Repositories { AssertNoError(t, err, "Failed to create sample book 2") book2.ID = bookID2 - // Create sample movies movie1 := CreateSampleMovie() movie1.Title = "Sample Movie 1" movie1.Status = "queued" @@ -366,7 +375,6 @@ func SetupTestData(t *testing.T, db *sql.DB) *Repositories { AssertNoError(t, err, "Failed to create sample movie 2") movie2.ID = movieID2 - // Create sample TV shows tv1 := CreateSampleTVShow() tv1.Title = "Sample TV Show 1" tv1.Status = "queued" @@ -383,7 +391,6 @@ func SetupTestData(t *testing.T, db *sql.DB) *Repositories { AssertNoError(t, err, "Failed to create sample TV show 2") tv2.ID = tvID2 - // Create sample notes note1 := CreateSampleNote() note1.Title = "Sample Note 1" note1.Content = "Content for note 1" diff --git a/internal/store/sql/migrations/0007_add_recurrence_deps_to_tasks_down.sql b/internal/store/sql/migrations/0007_add_recurrence_deps_to_tasks_down.sql new file mode 100644 index 0000000..4b97ebf --- /dev/null +++ b/internal/store/sql/migrations/0007_add_recurrence_deps_to_tasks_down.sql @@ -0,0 +1,7 @@ +-- Remove recurrence fields +ALTER TABLE tasks DROP COLUMN recur; +ALTER TABLE tasks DROP COLUMN until; +ALTER TABLE tasks DROP COLUMN parent_uuid; + +-- Drop dependencies table +DROP TABLE IF EXISTS task_dependencies; diff --git a/internal/store/sql/migrations/0007_add_recurrence_deps_to_tasks_up.sql b/internal/store/sql/migrations/0007_add_recurrence_deps_to_tasks_up.sql new file mode 100644 index 0000000..a91c98d --- /dev/null +++ b/internal/store/sql/migrations/0007_add_recurrence_deps_to_tasks_up.sql @@ -0,0 +1,19 @@ +-- Add recurrence fields directly to tasks +ALTER TABLE tasks ADD COLUMN recur TEXT; -- e.g. "daily", "weekly", ISO8601 rule +ALTER TABLE tasks ADD COLUMN until DATETIME; -- optional end date for recurrence +ALTER TABLE tasks ADD COLUMN parent_uuid TEXT; -- parent/template task UUID + +-- Create dependencies table +CREATE TABLE IF NOT EXISTS task_dependencies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_uuid TEXT NOT NULL, -- the dependent task + depends_on_uuid TEXT NOT NULL, -- the blocking task + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY(task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE, + FOREIGN KEY(depends_on_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE +); + +-- Indexes for faster dependency lookups +CREATE INDEX IF NOT EXISTS idx_task_dependencies_task_uuid ON task_dependencies(task_uuid); +CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends_on_uuid ON task_dependencies(depends_on_uuid);