cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: task expansion

+1328 -462
+203 -231
ROADMAP.md
··· 1 1 # ROADMAP 2 2 3 - ## Task Management Commands (TaskWarrior-inspired) 3 + 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. 4 4 5 - ### Implemented Commands 5 + ## Core Usability 6 6 7 - - [x] `todo add [description]` - Create new task with metadata (priority, project, context, due, tags) 8 - - [x] `todo list` - Display tasks with filtering (status, priority, project, context) and interactive/static modes 9 - - [x] `todo view [task-id]` - View task details with format options (detailed, brief, json) 10 - - [x] `todo update [task-id]` - Edit task properties via flags 11 - - [x] `todo edit [task-id]` - Interactive task editor with status picker and priority toggle 12 - - [x] `todo done [task-id]` - Mark task as completed 13 - - [x] `todo delete [task-id]` - Remove task permanently 7 + 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. 14 8 15 - --- 9 + ## RC 16 10 17 - - [x] `todo projects` - List all project names (interactive/static modes) 18 - - [x] `todo tags` - List all tag names (interactive/static modes) 19 - - [x] `todo contexts` - List all contexts/locations (interactive/static modes) 11 + ### CORE 20 12 21 - --- 13 + - [ ] Ensure **all documented subcommands** exist and work: 14 + - Tasks: add, list, view, update, edit, delete, projects, tags, contexts, done, start, stop, timesheet 15 + - Notes: create, list, read, edit, remove 16 + - Books: add, list, reading, finished, remove, progress, update 17 + - Movies: add, list, watched, remove 18 + - TV: add, list, watching, watched, remove 19 + - Articles: add, list, view, read, remove 20 + - [ ] Confirm all **aliases** work (`todo`, `ls`, `rm`, etc.). 21 + - [ ] Verify **flags** and argument parsing match man page (priority, project, context, due, tags, etc.). 22 + - [ ] Implement or finish stubs (e.g. `config management` noted in code). 22 23 23 - - [x] `todo start [task-id]` - Start time tracking for a task 24 - - [x] `todo stop [task-id]` - Stop time tracking for a task 25 - - [x] `todo timesheet` - Show time tracking summaries (with date range and task filters) 24 + ### Task Management Domain 26 25 27 - ### Commands To Be Implemented 26 + - [ ] Verify tasks can be created with all attributes (priority, project, context, due date, tags). 27 + - [ ] Confirm task listing supports interactive and static modes. 28 + - [ ] Implement status filtering (`pending`, `completed`, etc.). 29 + - [ ] Validate time tracking (start/stop) writes entries and timesheet summarizes correctly. 30 + - [ ] Ensure update supports add/remove tags and all fields. 31 + - [ ] Test interactive editor (`task edit`). 28 32 29 - - [ ] Due dates & scheduling - Including recurring tasks 30 - - [ ] Task dependencies - Task A blocks task B relationships 31 - - [ ] `annotate` - Add notes/comments to existing tasks 32 - - [ ] Recurring tasks 33 - - [ ] Smart due date suggestions 34 - - [ ] Completion notifications 35 - - [ ] `calendar` - Display tasks in calendar view 33 + ### Notes Domain 36 34 37 - ## Media Queue Management Commands 35 + - [ ] Implement note creation from: 36 + - Inline text 37 + - File (`--file`) 38 + - Interactive input (`--interactive`) 39 + - [ ] Verify note list interactive TUI works, static list fallback works. 40 + - [ ] Confirm filtering by tags and `--archived`. 41 + - [ ] Ensure notes can be opened, edited in `$EDITOR`, and deleted. 38 42 39 - ### Implemented Commands 43 + ### Media Domains 40 44 41 - Book Management 45 + #### Books 42 46 43 - - [x] `media book add [search query...]` - Search and add book to reading list (with interactive mode) 44 - - [x] `media book list` - Show reading queue with progress and status filtering 45 - - [x] `media book reading <id>` - Mark book as currently reading 46 - - [x] `media book finished <id>` - Mark book as completed 47 - - [x] `media book remove <id>` - Remove from reading list 48 - - [x] `media book progress <id> <percentage>` - Update reading progress (0-100%) 49 - - [x] `media book update <id> <status>` - Update book status (queued|reading|finished|removed) 47 + - [ ] Implement search + add (possibly external API). 48 + - [ ] Verify list supports statuses (`queued`, `reading`, `finished`). 49 + - [ ] Progress updates (`book progress`) work with percentages. 50 + - [ ] Status update (`book update`) accepts valid values. 50 51 51 - Movie Management 52 + #### Movies 52 53 53 - - [x] `media movie add [title]` - Add movie to watch queue (with interactive mode) 54 - - [x] `media movie list` - Show movie queue with status filtering 55 - - [x] `media movie watched <id>` - Mark movie as watched 56 - - [x] `media movie remove <id>` - Remove from queue 54 + - [ ] Implement search + add. 55 + - [ ] Verify `list` with status filtering (`all`, `queued`, `watched`). 56 + - [ ] Confirm `watched`/`remove` commands update correctly. 57 57 58 - TV Show Management 58 + #### TV 59 59 60 - - [x] `media tv add [title]` - Add TV show/season to queue (with interactive mode) 61 - - [x] `media tv list` - Show TV queue with status filtering 62 - - [x] `media tv watching <id>` - Mark TV show as currently watching 63 - - [x] `media tv watched <id>` - Mark episodes/seasons as watched 64 - - [x] `media tv remove <id>` - Remove from TV queue 60 + - [ ] Implement search + add. 61 + - [ ] Verify `list` with multiple statuses (`queued`, `watching`, `watched`). 62 + - [ ] Ensure `watching`, `watched`, `remove` commands behave correctly. 65 63 66 - ### Commands To Be Implemented 64 + #### Articles 67 65 68 - --- 66 + - [ ] Implement article parser (XPath/domain-specific rules). 67 + - [ ] Save articles in Markdown + HTML. 68 + - [ ] Verify metadata is stored in DB. 69 + - [ ] Confirm list supports query, author filter, limit. 70 + - [ ] Test article view/read/remove. 69 71 70 - - [ ] Articles, papers, blogs support (implement article parser) 71 - - [ ] Source tracking (recommendation sources) 72 - - [ ] Ratings and personal notes 73 - - [ ] Genre/topic tagging 74 - - [ ] Episode/season progress tracking for TV 75 - - [ ] Platform tracking (Netflix, Amazon, etc.) 76 - - [ ] Watch status: queued, watching, completed, dropped 72 + ### Configuration & Data 77 73 78 - ## Management Commands 79 - 80 - ### Implemented Commands 81 - 82 - Application Management 83 - 84 - - [x] `status` - Show application status and configuration 85 - - [x] `setup` - Initialize and manage application setup 86 - - [x] `setup seed` - Populate database with test data (with --force flag) 87 - - [x] `reset` - Reset the application (removes all data) 88 - - [x] `config [key] [value]` - Manage configuration settings (stubbed) 89 - 90 - ### Commands To Be Implemented 91 - 92 - Organization Features 93 - 94 - - [ ] Custom queries and saved searches 95 - - [ ] Context-aware suggestions 96 - - [ ] Overdue/urgent highlighting 97 - - [ ] Recently added/modified items 98 - - [ ] Seasonal/mood-based filtering 99 - - [ ] Full-text search across titles, notes, tags 100 - 101 - Analytics 102 - 103 - - [ ] Reading/watching velocity tracking 104 - - [ ] Completion rates by content type 105 - - [ ] Time investment analysis 106 - - [ ] Personal productivity metrics 107 - - [ ] Content source analysis 108 - 109 - Integrations 110 - 111 - - [ ] `import` - Import from various formats (CSV, JSON, todo.txt) 112 - - [ ] `export` - Export to various formats 113 - - [ ] Goodreads import for books 114 - - [ ] IMDB/Letterboxd import for movies 115 - - [ ] Todo.txt format compatibility 116 - - [ ] TaskWarrior import/export 117 - - [ ] URL parsing for automatic metadata 118 - 119 - `todo.txt` Compatibility 120 - 121 - - [ ] `archive` - Move completed tasks to done.txt 122 - - [ ] `[con]texts` - List all contexts (@context) 123 - - [ ] `[proj]ects` - List all projects (+project) 124 - - [ ] `[pri]ority` - Set task priority (A-Z) 125 - - [ ] `[depri]oritize` - Remove priority from task 126 - - [ ] `[re]place` - Replace task text entirely 127 - - [ ] `[pre]pend/[app]end` - Add text to beginning/end of task 128 - 129 - Automation 130 - 131 - - [ ] Auto-categorization of new items 132 - - [ ] Smart due date suggestions 133 - - [ ] Recurring content (weekly podcast check-ins) 134 - - [ ] Completion notifications 74 + - [ ] Implement **config management** (flagged TODO in code). 75 + - [ ] Define config file format (TOML, YAML, JSON). 76 + - [ ] Set default config/data paths: 77 + - Linux: `~/.config/noteleaf`, `~/.local/share/noteleaf` 78 + - macOS: `~/Library/Application Support/noteleaf` 79 + - Windows: `%APPDATA%\noteleaf` 80 + - [ ] Implement overrides with environment variables (`NOTELEAF_CONFIG`, `NOTELEAF_DATA_DIR`). 81 + - [ ] Ensure consistent DB schema migrations and versioning. 135 82 136 - Storage 83 + ### Documentation 137 84 138 - - [ ] `sync` - Synchronize with remote storage 139 - - [ ] `sync setup` - Setup remote storage 140 - - [ ] Local SQLite database with optional cloud sync 141 - - [ ] Multiple profile support 142 - - [ ] `backup` - Create local backup 143 - - [ ] Backup/restore functionality 85 + - [ ] Finalize **man page** (plaintext + roff). 86 + - [ ] Write quickstart guide in `README.md`. 87 + - [ ] Add examples for each command. 88 + - [ ] Document config file with defaults and examples. 89 + - [ ] Provide developer docs for contributing. 144 90 145 - Configuration 91 + ### QA 146 92 147 - - [x] Enhanced `config` command implementation (basic stubbed version) 148 - - [ ] `undo` - Reverse last operation 149 - - [ ] Themes and personalization 150 - - [ ] Customizable output formats 93 + - [ ] Verify **unit tests** for all handlers (TaskHandler, NoteHandler, Media Handlers). 94 + - [ ] Write **integration tests** covering CLI flows. 95 + - [ ] Ensure error handling works for: 96 + - Invalid IDs 97 + - Invalid flags 98 + - Schema corruption (already tested in repo) 99 + - [ ] Test cross-platform behavior (Linux/macOS/Windows). 151 100 152 - ## Notes Management Commands 101 + ### Packaging 153 102 154 - ### Implemented Commands 103 + - [ ] Provide prebuilt binaries (via GoReleaser). 104 + - [ ] Add installation instructions (Homebrew, AUR, Scoop, etc.). 105 + - [ ] Version bump to `v1.0.0-rc1`. 106 + - [ ] Publish man page with release. 107 + - [ ] Verify `noteleaf --version` returns correct string. 155 108 156 - Core Notes Operations 109 + ## v1 Features 157 110 158 - - [x] `note create [title] [content...]` - Create new markdown note with optional interactive editor 159 - - [x] `note list` - Interactive TUI browser for navigating and viewing notes (with archive and tag filtering) 160 - - [x] `note read <note-id>` - Display formatted note content with syntax highlighting 161 - - [x] `note edit <note-id>` - Edit note in configured editor 162 - - [x] `note remove <note-id>` - Permanently remove note file and metadata 111 + Planned functionality for a complete baseline release. 163 112 164 - Additional Options 113 + ### Tasks 165 114 166 - - [x] `--interactive|-i` flag for create command (opens editor) 167 - - [x] `--file|-f` flag for create command (create from markdown file) 168 - - [x] `--archived|-a` flag for list command 169 - - [x] `--tags` filtering for list command 115 + - [ ] Model 116 + - [ ] Dependencies 117 + - [ ] Recurrence (`recur`, `until`, templates) 118 + - [ ] Wait/scheduled dates 119 + - [ ] Urgency scoring 120 + - [ ] Operations 121 + - [ ] `annotate` 122 + - [ ] Bulk edit and undo/history 123 + - [ ] `$EDITOR` integration 124 + - [ ] Reports and Views 125 + - [ ] Next actions 126 + - [ ] Completed/waiting/blocked reports 127 + - [ ] Calendar view 128 + - [ ] Sorting and urgency-based views 129 + - [ ] Queries and Filters 130 + - [ ] Rich query language 131 + - [ ] Saved filters and aliases 132 + - [ ] Interoperability 133 + - [ ] JSON import/export 134 + - [ ] todo.txt compatibility 170 135 171 - ### Commands To Be Implemented 136 + ### Notes 172 137 173 - - [ ] `note search [query]` - Search notes by content, title, or tags 174 - - [ ] `note tag <note-id> [tags...]` - Add/remove tags from notes 175 - - [ ] `note recent` - Show recently created/modified notes 176 - - [ ] `note templates` - Create notes from predefined templates 177 - - [ ] `note archive <note-id>` - Archive old notes 178 - - [ ] `note export` - Export notes to various formats 179 - - [ ] Full-text search integration 180 - - [ ] Linking between notes and tasks/content 138 + - [ ] Commands 139 + - [ ] `note search` 140 + - [ ] `note tag` 141 + - [ ] `note recent` 142 + - [ ] `note templates` 143 + - [ ] `note archive` 144 + - [ ] `note export` 145 + - [ ] Features 146 + - [ ] Full-text search 147 + - [ ] Linking between notes, tasks, and media 181 148 182 - ## User Experience 149 + ### Media 183 150 184 - - [x] Interactive TUI modes for task lists, projects, tags, contexts, and notes 185 - - [x] Static output modes as alternatives to interactive TUI 186 - - [x] Color-coded priority and status indicators 187 - - [x] Comprehensive help system via cobra CLI framework 151 + - [ ] Articles/papers/blogs 152 + - [ ] Parser with domain-specific rules 153 + - [ ] Commands: `add`, `list`, `view`, `remove` 154 + - [ ] Metadata validation and storage 155 + - [ ] Books 156 + - [ ] Source tracking and ratings 157 + - [ ] Genre/topic tagging 158 + - [ ] Movies/TV 159 + - [ ] Ratings and notes 160 + - [ ] Genre/topic tagging 161 + - [ ] Episode/season progress for TV 162 + - [ ] Platform/source tracking 188 163 189 - --- 164 + ## Beyond v1 190 165 191 - - [ ] Quick-add commands for rapid entry 192 - - [ ] Enhanced progress tracking UI 193 - - [ ] Calendar view for tasks 166 + Features that demonstrate Go proficiency and broaden Noteleafโ€™s scope. 194 167 195 - ### Technical Infrastructure 168 + ### Tasks 196 169 197 - - [ ] CI/CD pipeline -> pre-build binaries 198 - - [ ] Complete README/documentation 199 - - [ ] Installation instructions 200 - - [ ] Usage examples 170 + - [ ] Parallel report generation and background services 171 + - [ ] Hook system for task lifecycle events 172 + - [ ] Plugin mechanism 173 + - [ ] Generics-based filter engine 174 + - [ ] Functional options for configuration 175 + - [ ] Error handling with wrapping and sentinel checks 201 176 202 - ## Tech Debt 177 + ### Notes 203 178 204 - ### Signatures 179 + - [ ] Templates system for note types 180 + - [ ] Versioning and history 181 + - [ ] Export with formatting 182 + - [ ] Import from other systems 205 183 206 - We've got inconsistent argument parsing and sanitization leading to calls to strconv.Atoi in tests & handler funcs. 207 - This is only done correctly in the note command -> handler sequence 184 + ### Media 208 185 209 - - [ ] TaskCommand 210 - - [ ] MovieCommand 211 - - [ ] TVCommand 212 - - [ ] BookCommand 186 + - [ ] External imports (Goodreads, IMDB, Letterboxd) 187 + - [ ] Cross-referencing across media types 188 + - [ ] Analytics: velocity, completion rates 213 189 214 - ### Movie Commands - Missing Tests 190 + ### Articles 215 191 216 - - [x] movie watched [id] - marks movie as watched 192 + - [ ] Enhanced parsing coverage 193 + - [ ] Export to multiple formats 194 + - [ ] Linking with tasks and notes 217 195 218 - ### TV Commands - Missing Tests 196 + ### User Experience 219 197 220 - - [x] tv watching [id] - marks TV show as watching 221 - - [x] tv watched [id] - marks TV show as watched 198 + - [ ] Shell completions 199 + - [ ] Manpages and docs generator 200 + - [ ] Theming and customizable output 201 + - [ ] Calendar integration 222 202 223 - ### Book Commands - Missing Tests 203 + ### Tasks 224 204 225 - - [x] book add [search query...] - search and add book 226 - - [x] book reading `<id>` - marks book as reading 227 - - [x] book finished `<id>` - marks book as finished 228 - - [x] book progress `<id>` `<percentage>` - updates reading progress 205 + - [ ] Sub-tasks and hierarchical tasks 206 + - [ ] Visual dependency mapping 207 + - [ ] Forecasting and smart suggestions 208 + - [ ] Habit and streak tracking 209 + - [ ] Context-aware recommendations 229 210 230 - ## Ideas 211 + ### Notes 231 212 232 - ### Task Management Enhancements 213 + - [ ] Graph view of linked notes 214 + - [ ] Content extraction and summarization 215 + - [ ] Encryption and privacy controls 233 216 234 - - Sub-tasks and hierarchical tasks - Break complex tasks into child tasks for better organization 235 - - Linking - Establish relationships between related tasks without strict dependencies 236 - - Batching - Group related tasks for bulk operations (completion, priority changes, etc.) 237 - - Retrospectives - Analysis of completed tasks to improve future estimates and planning 238 - - Automation rules - Create rules that automatically modify tasks based on conditions 239 - - Habit formation - Track recurring micro-tasks that build into larger goals 240 - - Context switching - Automatically adjust system settings, apps, or environment based on current task 241 - - Forecasting - Predict future tasks based on patterns, calendar events, or seasonal trends 242 - - "Energy" matching - Recommend tasks based on current energy levels or time of day 243 - - Priority rebalancing - Automatically suggest priority adjustments based on deadlines and importance 244 - - Dependency visualization: Visual flow charts showing how tasks connect and block each other 217 + ### Media 245 218 246 - ### Media Management Enhancements 219 + - [ ] Podcast and YouTube management 220 + - [ ] Multi-format (audiobooks, comics) 221 + - [ ] Media consumption goals and streaks 222 + - [ ] Media budget tracking 223 + - [ ] Seasonal and energy-based filtering 247 224 248 - - Podcast management: Add podcast tracking with episode progress and subscription management 249 - - YouTube/video content management: Track video content queues and viewing progress 250 - - Multi-format media support: Include audiobooks, comics, and other content formats 251 - - Media consumption goals: Set reading/watching goals (e.g., "2 books per month") 252 - - Media cross-referencing: Connect related content across different media types 253 - - Series 254 - - Media note integration: Link notes to specific books, movies, or shows for reviews 255 - - Review system - Write and store personal reviews of consumed content 256 - - Media budget tracking: Track spending on media content (subscriptions, purchases) 257 - - Media consumption patterns: Analyze personal consumption patterns and preferences over time 258 - - Media seasonal tracking: Track seasonal media preferences and suggest accordingly 259 - - Media completion streaks: Gamification elements for consistent media consumption 260 - - Media progress synchronization: Sync progress across different devices or platforms 225 + ### Articles 261 226 262 - ### Notes Management Enhancements 227 + - [ ] Content validation 228 + - [ ] Encryption support 229 + - [ ] Advanced classification 263 230 264 - - Linking and graph view: Create bidirectional links between related notes 265 - - Templates system: Predefined templates for different note types (meeting notes, book summaries) 266 - - Versioning and history: Track changes to notes over time with ability to revert 267 - - Export with formatting: Rich export options with preserved formatting and links 268 - - Import capabilities: Import notes from other systems (Notion, Evernote, etc.) 269 - - Content extraction: Extract key points or action items from longer notes 270 - - Encryption: End-to-end encryption for sensitive notes 271 - - Content validation: Check for broken links, missing references, or inconsistent information 231 + ## Technical Infrastructure 272 232 273 - ### System Integration & Automation 233 + ### Completed 274 234 275 - - Calendar integration: Sync tasks with calendar systems (Google Calendar, Outlook) 276 - - Email integration: Create tasks or notes from emails automatically 277 - - Browser extension: Quick capture of web content as tasks or notes 278 - - IDE/plugin integration: Direct integration with code editors and development environments 279 - - File system integration: Monitor files for content that should become tasks or notes 235 + SQLite persistence, CI with GitHub Actions and Codecov, TUIs with Charm stack, initial help system. 280 236 281 - ### Advanced UI/UX Features 237 + ### Planned 282 238 283 - - Customizable themes: Multiple visual themes and color schemes 284 - - Terminal interface enhancements: Rich TUI with advanced navigation and visualization 285 - - Web-based interface: Alternative web UI for browser-based access 286 - - Advanced filtering and sorting: Complex query systems for data manipulation 287 - - Visual task mapping: Gantt charts, Kanban boards, and other visual representations 288 - - Quick entry mode: Rapid capture interface for minimal friction 289 - - Keyboard customization: Fully customizable keyboard shortcuts 239 + - Prebuilt binaries for releases 240 + - Installation and usage documentation 241 + - Contribution guide and developer docs 242 + - Consistent argument parsing 243 + - Backup/restore 244 + - Multiple profiles 245 + - Optional synchronization 290 246 291 - ### Security and Privacy 247 + ## v1 Feature Matrix 292 248 293 - - End-to-end encryption: Full encryption of sensitive data 294 - - Local-first architecture: Guarantee that all data remains local 249 + | Domain | Feature | Status | 250 + |----------|-----------------------|-----------| 251 + | Tasks | CRUD | Complete | 252 + | Tasks | Projects/tags | Complete | 253 + | Tasks | Time tracking | Complete | 254 + | Tasks | Dependencies | Planned | 255 + | Tasks | Recurrence | Planned | 256 + | Tasks | Wait/scheduled | Planned | 257 + | Tasks | Urgency scoring | Planned | 258 + | Notes | CRUD | Complete | 259 + | Notes | Search/tagging | Planned | 260 + | Media | Books/movies/TV | Complete | 261 + | Media | Articles | Planned | 262 + | Media | Source/ratings | Planned | 263 + | Articles | Parser + storage | Planned | 264 + | System | SQLite persistence | Complete | 265 + | System | Synchronization | Future | 266 + | System | Import/export formats | Future |
+23 -2
cmd/task_commands.go
··· 1 1 package main 2 2 3 3 import ( 4 + "strings" 5 + 4 6 "github.com/spf13/cobra" 5 7 "github.com/stormlightlabs/noteleaf/internal/handlers" 6 8 ) ··· 37 39 Aliases: []string{"create", "new"}, 38 40 Args: cobra.MinimumNArgs(1), 39 41 RunE: func(c *cobra.Command, args []string) error { 42 + description := strings.Join(args, " ") 40 43 priority, _ := c.Flags().GetString("priority") 41 44 project, _ := c.Flags().GetString("project") 42 45 context, _ := c.Flags().GetString("context") 43 46 due, _ := c.Flags().GetString("due") 47 + recur, _ := c.Flags().GetString("recur") 48 + until, _ := c.Flags().GetString("until") 49 + parent, _ := c.Flags().GetString("parent") 50 + dependsOn, _ := c.Flags().GetString("depends-on") 44 51 tags, _ := c.Flags().GetStringSlice("tags") 45 52 46 53 defer h.Close() 47 - return h.Create(c.Context(), args, priority, project, context, due, tags) 54 + return h.Create(c.Context(), description, priority, project, context, due, recur, until, parent, dependsOn, tags) 48 55 }, 49 56 } 50 57 cmd.Flags().StringP("priority", "p", "", "Set task priority") 51 58 cmd.Flags().String("project", "", "Set task project") 52 59 cmd.Flags().StringP("context", "c", "", "Set task context") 53 60 cmd.Flags().StringP("due", "d", "", "Set due date (YYYY-MM-DD)") 61 + cmd.Flags().String("recur", "", "Set recurrence rule (e.g., FREQ=DAILY)") 62 + cmd.Flags().String("until", "", "Set recurrence end date (YYYY-MM-DD)") 63 + cmd.Flags().String("parent", "", "Set parent task UUID") 64 + cmd.Flags().String("depends-on", "", "Set task dependencies (comma-separated UUIDs)") 54 65 cmd.Flags().StringSliceP("tags", "t", []string{}, "Add tags to task") 55 66 56 67 return cmd ··· 123 134 project, _ := cmd.Flags().GetString("project") 124 135 context, _ := cmd.Flags().GetString("context") 125 136 due, _ := cmd.Flags().GetString("due") 137 + recur, _ := cmd.Flags().GetString("recur") 138 + until, _ := cmd.Flags().GetString("until") 139 + parent, _ := cmd.Flags().GetString("parent") 126 140 addTags, _ := cmd.Flags().GetStringSlice("add-tag") 127 141 removeTags, _ := cmd.Flags().GetStringSlice("remove-tag") 142 + addDeps, _ := cmd.Flags().GetString("add-depends") 143 + removeDeps, _ := cmd.Flags().GetString("remove-depends") 128 144 129 145 defer handler.Close() 130 - return handler.Update(cmd.Context(), taskID, description, status, priority, project, context, due, addTags, removeTags) 146 + return handler.Update(cmd.Context(), taskID, description, status, priority, project, context, due, recur, until, parent, addTags, removeTags, addDeps, removeDeps) 131 147 }, 132 148 } 133 149 updateCmd.Flags().String("description", "", "Update task description") ··· 136 152 updateCmd.Flags().String("project", "", "Update task project") 137 153 updateCmd.Flags().StringP("context", "c", "", "Update task context") 138 154 updateCmd.Flags().StringP("due", "d", "", "Update due date (YYYY-MM-DD)") 155 + updateCmd.Flags().String("recur", "", "Update recurrence rule") 156 + updateCmd.Flags().String("until", "", "Update recurrence end date (YYYY-MM-DD)") 157 + updateCmd.Flags().String("parent", "", "Update parent task UUID") 139 158 updateCmd.Flags().StringSlice("add-tag", []string{}, "Add tags to task") 140 159 updateCmd.Flags().StringSlice("remove-tag", []string{}, "Remove tags from task") 160 + updateCmd.Flags().String("add-depends", "", "Add task dependencies (comma-separated UUIDs)") 161 + updateCmd.Flags().String("remove-depends", "", "Remove task dependencies (comma-separated UUIDs)") 141 162 142 163 return updateCmd 143 164 }
+268
docs/manual/noteleaf.1.txt
··· 1 + NOTELEAF(1) User Commands NOTELEAF(1) 2 + 3 + NAME 4 + noteleaf - manage tasks, notes, books, movies, TV shows, and saved 5 + articles from the command line 6 + 7 + SYNOPSIS 8 + noteleaf [--help] [--version] <command> [<args>] 9 + 10 + DESCRIPTION 11 + noteleaf is a terminal-first productivity tool written in Go. It combines 12 + task management (with time tracking), note-taking, and media queues 13 + (books, movies, TV shows, and saved articles) into a single command-line interface. 14 + 15 + The design borrows from Taskwarrior and todo.txt but extends them with 16 + features such as: 17 + - Interactive TUIs for browsing notes and tasks 18 + - Article parsing and storage 19 + - Unified commands across different domains 20 + - Time tracking with start/stop and timesheet summaries 21 + 22 + Subcommands are grouped by domain: task, note, book, movie, tv, and article. 23 + Each group has its own subcommands and options. 24 + 25 + OPTIONS 26 + --help, -h 27 + Show help for noteleaf or any subcommand. 28 + 29 + --version, -v 30 + Print the current version and exit. 31 + 32 + COMMANDS 33 + General 34 + help [command] 35 + Show help for a command or subcommand. 36 + 37 + version 38 + Print the program version. 39 + 40 + TASK COMMANDS 41 + noteleaf task add [description] 42 + Add a new task. 43 + Flags: 44 + -p, --priority <value> Set task priority 45 + --project <name> Set project 46 + -c, --context <name> Set context 47 + -d, --due YYYY-MM-DD Set due date 48 + -t, --tags tag1,tag2 Add tags 49 + 50 + noteleaf task list 51 + List tasks. 52 + Flags: 53 + -i, --interactive Force interactive mode 54 + --static Print static list 55 + -a, --all Show all tasks 56 + --status <status> Filter by status 57 + --priority <value> Filter by priority 58 + --project <name> Filter by project 59 + --context <name> Filter by context 60 + 61 + noteleaf task view <id> 62 + View task details. 63 + Flags: 64 + --format detailed|brief Output format 65 + --json Print JSON 66 + --no-metadata Hide metadata 67 + 68 + noteleaf task update <id> 69 + Update task fields. 70 + Flags: 71 + --description <text> 72 + --status <status> 73 + -p, --priority <value> 74 + --project <name> 75 + -c, --context <name> 76 + -d, --due YYYY-MM-DD 77 + --add-tag tag 78 + --remove-tag tag 79 + 80 + noteleaf task edit <id> 81 + Interactive edit with status/priority toggles. 82 + Alias: e 83 + 84 + noteleaf task delete <id> 85 + Delete task permanently. 86 + 87 + noteleaf task projects 88 + List projects. 89 + Flags: --static, --todo-txt 90 + 91 + noteleaf task tags 92 + List tags. 93 + Flags: --static 94 + 95 + noteleaf task contexts 96 + List contexts. 97 + Flags: --static, --todo-txt 98 + 99 + noteleaf task done <id> 100 + Mark task as completed. 101 + Alias: complete 102 + 103 + noteleaf task start <id> 104 + Start time tracking for a task. 105 + Flags: -n, --note <text> Add note to entry 106 + 107 + noteleaf task stop <id> 108 + Stop time tracking for a task. 109 + 110 + noteleaf task timesheet 111 + Show time tracking summary. 112 + Flags: 113 + -d, --days <n> Number of days (default 7) 114 + -t, --task <id> Timesheet for specific task 115 + 116 + MOVIE COMMANDS 117 + noteleaf movie add [query...] 118 + Search and add a movie to the watch queue. 119 + Flags: -i, --interactive 120 + 121 + noteleaf movie list [--all|--watched|--queued] 122 + List movies by status. 123 + 124 + noteleaf movie watched <id> 125 + Mark movie as watched. 126 + Alias: seen 127 + 128 + noteleaf movie remove <id> 129 + Remove from queue. 130 + Alias: rm 131 + 132 + TV COMMANDS 133 + noteleaf tv add [query...] 134 + Search and add a TV show to the watch queue. 135 + Flags: -i, --interactive 136 + 137 + noteleaf tv list [--all|--queued|--watching|--watched] 138 + List TV shows by status. 139 + 140 + noteleaf tv watching <id> 141 + Mark as currently watching. 142 + 143 + noteleaf tv watched <id> 144 + Mark as watched. 145 + Alias: seen 146 + 147 + noteleaf tv remove <id> 148 + Remove show from queue. 149 + Alias: rm 150 + 151 + BOOK COMMANDS 152 + noteleaf book add [query...] 153 + Search and add a book to the reading list. 154 + Flags: -i, --interactive 155 + 156 + noteleaf book list [--all|--reading|--finished|--queued] 157 + Show reading list. 158 + 159 + noteleaf book reading <id> 160 + Mark book as currently reading. 161 + 162 + noteleaf book finished <id> 163 + Mark book as finished. 164 + Alias: read 165 + 166 + noteleaf book remove <id> 167 + Remove from reading list. 168 + Alias: rm 169 + 170 + noteleaf book progress <id> <percent> 171 + Update reading progress percentage (0-100). 172 + 173 + noteleaf book update <id> <status> 174 + Update status (queued|reading|finished|removed). 175 + 176 + NOTE COMMANDS 177 + noteleaf note create [title] [content...] 178 + Create a note. 179 + Aliases: new 180 + Flags: 181 + -i, --interactive Open interactive editor 182 + -e, --editor Open note in editor 183 + -f, --file <path> Create from markdown file 184 + 185 + noteleaf note list 186 + List notes (interactive TUI or static). 187 + Aliases: ls 188 + Flags: 189 + -a, --archived Include archived 190 + -s, --static Static list 191 + --tags tag1,tag2 Filter by tags 192 + 193 + noteleaf note read <id> 194 + Display note content. 195 + Alias: view 196 + 197 + noteleaf note edit <id> 198 + Edit note in editor. 199 + 200 + noteleaf note remove <id> 201 + Remove note permanently. 202 + Aliases: rm, delete, del 203 + 204 + ARTICLE COMMANDS 205 + noteleaf article add <url> 206 + Parse and save article from URL. 207 + 208 + noteleaf article list [query] 209 + List saved articles. 210 + Aliases: ls 211 + Flags: 212 + --author <name> 213 + -l, --limit <n> 214 + 215 + noteleaf article view <id> 216 + Show article metadata and preview. 217 + Alias: show 218 + 219 + noteleaf article read <id> 220 + Display full content as Markdown. 221 + 222 + noteleaf article remove <id> 223 + Remove article and associated files. 224 + Aliases: rm, delete 225 + 226 + EXIT STATUS 227 + noteleaf returns 0 on success. 228 + Non-zero exit status indicates an error. 229 + 230 + EXAMPLES 231 + Add and list tasks: 232 + noteleaf task add "Write blog post" -p H --project blog --due 2025-10-15 233 + noteleaf task list --project blog 234 + 235 + Mark complete: 236 + noteleaf task done 42 237 + 238 + Track media: 239 + noteleaf book add "The Name of the Wind" 240 + noteleaf movie add "Blade Runner" -i 241 + noteleaf tv list --watching 242 + 243 + Work with notes: 244 + noteleaf note create "Ideas" "sketch out product roadmap" 245 + noteleaf note list --tags=work 246 + 247 + Save an article: 248 + noteleaf article add https://example.com/post 249 + noteleaf article list --author "Ada Lovelace" 250 + 251 + FILES 252 + (TODO: configuration and data file paths once implemented) 253 + 254 + SEE ALSO 255 + Taskwarrior(1), todo.txt(5), git(1), neovim(1), rsync(1) 256 + 257 + AUTHOR 258 + Owais @ Stormlight Labs <https://github.com/stormlightlabs/noteleaf> 259 + 260 + BUGS 261 + Please report issues at: https://github.com/stormlightlabs/noteleaf/issues 262 + 263 + COPYRIGHT 264 + Copyright (c) 2025 Stormlight Labs, LLC. 265 + Licensed under the MIT License. 266 + 267 + NOTELEAF(1) User Commands NOTELEAF(1) 268 +
+178 -19
internal/handlers/tasks.go
··· 53 53 } 54 54 55 55 // Create creates a new task 56 - func (h *TaskHandler) Create(ctx context.Context, desc []string, priority, project, context, due string, tags []string) error { 57 - if len(desc) < 1 { 56 + func (h *TaskHandler) Create(ctx context.Context, description, priority, project, context, due, recur, until, parentUUID, dependsOn string, tags []string) error { 57 + if description == "" { 58 58 return fmt.Errorf("task description required") 59 59 } 60 60 61 - description := strings.Join(desc, " ") 61 + parsed := parseDescription(description) 62 + 63 + if project != "" { 64 + parsed.Project = project 65 + } 66 + if context != "" { 67 + parsed.Context = context 68 + } 69 + if due != "" { 70 + parsed.Due = due 71 + } 72 + if recur != "" { 73 + parsed.Recur = recur 74 + } 75 + if until != "" { 76 + parsed.Until = until 77 + } 78 + if parentUUID != "" { 79 + parsed.ParentUUID = parentUUID 80 + } 81 + if dependsOn != "" { 82 + parsed.DependsOn = strings.Split(dependsOn, ",") 83 + } 84 + if len(tags) > 0 { 85 + parsed.Tags = append(parsed.Tags, tags...) 86 + } 62 87 63 88 task := &models.Task{ 64 89 UUID: uuid.New().String(), 65 - Description: description, 90 + Description: parsed.Description, 66 91 Status: "pending", 67 92 Priority: priority, 68 - Project: project, 69 - Context: context, 70 - Tags: tags, 93 + Project: parsed.Project, 94 + Context: parsed.Context, 95 + Tags: parsed.Tags, 96 + Recur: models.RRule(parsed.Recur), 97 + DependsOn: parsed.DependsOn, 71 98 } 72 99 73 - if due != "" { 74 - if dueTime, err := time.Parse("2006-01-02", due); err == nil { 100 + if parsed.Due != "" { 101 + if dueTime, err := time.Parse("2006-01-02", parsed.Due); err == nil { 75 102 task.Due = &dueTime 76 103 } else { 77 104 return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) 78 105 } 79 106 } 80 107 108 + if parsed.Until != "" { 109 + if untilTime, err := time.Parse("2006-01-02", parsed.Until); err == nil { 110 + task.Until = &untilTime 111 + } else { 112 + return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) 113 + } 114 + } 115 + 116 + if parsed.ParentUUID != "" { 117 + task.ParentUUID = &parsed.ParentUUID 118 + } 119 + 81 120 id, err := h.repos.Tasks.Create(ctx, task) 82 121 if err != nil { 83 122 return fmt.Errorf("failed to create task: %w", err) ··· 88 127 if priority != "" { 89 128 fmt.Printf("Priority: %s\n", priority) 90 129 } 91 - if project != "" { 92 - fmt.Printf("Project: %s\n", project) 130 + if task.Project != "" { 131 + fmt.Printf("Project: %s\n", task.Project) 93 132 } 94 - if context != "" { 95 - fmt.Printf("Context: %s\n", context) 133 + if task.Context != "" { 134 + fmt.Printf("Context: %s\n", task.Context) 96 135 } 97 - if len(tags) > 0 { 98 - fmt.Printf("Tags: %s\n", strings.Join(tags, ", ")) 136 + if len(task.Tags) > 0 { 137 + fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", ")) 99 138 } 100 139 if task.Due != nil { 101 140 fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02")) 102 141 } 142 + if task.Recur != "" { 143 + fmt.Printf("Recur: %s\n", task.Recur) 144 + } 145 + if task.Until != nil { 146 + fmt.Printf("Until: %s\n", task.Until.Format("2006-01-02")) 147 + } 148 + if task.ParentUUID != nil { 149 + fmt.Printf("Parent: %s\n", *task.ParentUUID) 150 + } 151 + if len(task.DependsOn) > 0 { 152 + fmt.Printf("Depends on: %s\n", strings.Join(task.DependsOn, ", ")) 153 + } 103 154 104 155 return nil 105 156 } ··· 150 201 } 151 202 152 203 // Update updates a task using parsed flag values 153 - func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, context, due string, addTags, removeTags []string) error { 204 + 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 { 154 205 var task *models.Task 155 206 var err error 156 207 ··· 186 237 return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) 187 238 } 188 239 } 240 + if recur != "" { 241 + task.Recur = models.RRule(recur) 242 + } 243 + if until != "" { 244 + if untilTime, err := time.Parse("2006-01-02", until); err == nil { 245 + task.Until = &untilTime 246 + } else { 247 + return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) 248 + } 249 + } 250 + if parentUUID != "" { 251 + task.ParentUUID = &parentUUID 252 + } 189 253 190 254 for _, tag := range addTags { 191 255 if !slices.Contains(task.Tags, tag) { ··· 195 259 196 260 for _, tag := range removeTags { 197 261 task.Tags = removeString(task.Tags, tag) 262 + } 263 + 264 + // Handle dependency additions 265 + if addDeps != "" { 266 + deps := strings.Split(addDeps, ",") 267 + for _, dep := range deps { 268 + dep = strings.TrimSpace(dep) 269 + if dep != "" && !slices.Contains(task.DependsOn, dep) { 270 + task.DependsOn = append(task.DependsOn, dep) 271 + } 272 + } 273 + } 274 + 275 + // Handle dependency removals 276 + if removeDeps != "" { 277 + deps := strings.Split(removeDeps, ",") 278 + for _, dep := range deps { 279 + dep = strings.TrimSpace(dep) 280 + task.DependsOn = removeString(task.DependsOn, dep) 281 + } 198 282 } 199 283 200 284 err = h.repos.Tasks.Update(ctx, task) ··· 681 765 fmt.Printf(" (due: %s)", task.Due.Format("2006-01-02")) 682 766 } 683 767 768 + if task.Recur != "" { 769 + fmt.Printf(" \u21bb") 770 + } 771 + 772 + if len(task.DependsOn) > 0 { 773 + fmt.Printf(" \u2937%d", len(task.DependsOn)) 774 + } 775 + 684 776 fmt.Println() 685 777 } 686 778 ··· 710 802 fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02 15:04")) 711 803 } 712 804 805 + if task.Recur != "" { 806 + fmt.Printf("Recurrence: %s\n", task.Recur) 807 + } 808 + 809 + if task.Until != nil { 810 + fmt.Printf("Recur Until: %s\n", task.Until.Format("2006-01-02")) 811 + } 812 + 813 + if task.ParentUUID != nil { 814 + fmt.Printf("Parent Task: %s\n", *task.ParentUUID) 815 + } 816 + 817 + if len(task.DependsOn) > 0 { 818 + fmt.Printf("Depends On:\n") 819 + for _, dep := range task.DependsOn { 820 + fmt.Printf(" - %s\n", dep) 821 + } 822 + } 823 + 713 824 if !noMetadata { 714 825 fmt.Printf("Created: %s\n", task.Entry.Format("2006-01-02 15:04")) 715 826 fmt.Printf("Modified: %s\n", task.Modified.Format("2006-01-02 15:04")) ··· 740 851 return nil 741 852 } 742 853 854 + // ParsedTaskData holds extracted metadata from a task description 855 + type ParsedTaskData struct { 856 + Description string 857 + Project string 858 + Context string 859 + Tags []string 860 + Due string 861 + Recur string 862 + Until string 863 + ParentUUID string 864 + DependsOn []string 865 + } 866 + 867 + // parseDescription extracts inline metadata from description text 868 + // Supports: +project @context #tag due:YYYY-MM-DD recur:RULE until:DATE parent:UUID depends:UUID1,UUID2 869 + func parseDescription(text string) *ParsedTaskData { 870 + parsed := &ParsedTaskData{Tags: []string{}, DependsOn: []string{}} 871 + words := strings.Fields(text) 872 + 873 + var descWords []string 874 + for _, word := range words { 875 + switch { 876 + case strings.HasPrefix(word, "+"): 877 + parsed.Project = strings.TrimPrefix(word, "+") 878 + case strings.HasPrefix(word, "@"): 879 + parsed.Context = strings.TrimPrefix(word, "@") 880 + case strings.HasPrefix(word, "#"): 881 + parsed.Tags = append(parsed.Tags, strings.TrimPrefix(word, "#")) 882 + case strings.HasPrefix(word, "due:"): 883 + parsed.Due = strings.TrimPrefix(word, "due:") 884 + case strings.HasPrefix(word, "recur:"): 885 + parsed.Recur = strings.TrimPrefix(word, "recur:") 886 + case strings.HasPrefix(word, "until:"): 887 + parsed.Until = strings.TrimPrefix(word, "until:") 888 + case strings.HasPrefix(word, "parent:"): 889 + parsed.ParentUUID = strings.TrimPrefix(word, "parent:") 890 + case strings.HasPrefix(word, "depends:"): 891 + deps := strings.TrimPrefix(word, "depends:") 892 + parsed.DependsOn = strings.Split(deps, ",") 893 + default: 894 + descWords = append(descWords, word) 895 + } 896 + } 897 + 898 + parsed.Description = strings.Join(descWords, " ") 899 + return parsed 900 + } 901 + 743 902 func removeString(slice []string, item string) []string { 744 903 var result []string 745 904 for _, s := range slice { ··· 772 931 return fmt.Sprintf("%.1fh", hours) 773 932 } 774 933 days := int(hours / 24) 775 - remainingHours := hours - float64(days*24) 776 - if remainingHours == 0 { 934 + if remainingHours := hours - float64(days*24); remainingHours == 0 { 777 935 return fmt.Sprintf("%dd", days) 936 + } else { 937 + return fmt.Sprintf("%dd %.1fh", days, remainingHours) 778 938 } 779 - return fmt.Sprintf("%dd %.1fh", days, remainingHours) 780 939 }
+17 -23
internal/handlers/tasks_test.go
··· 103 103 104 104 t.Run("creates task successfully", func(t *testing.T) { 105 105 ctx := context.Background() 106 - args := []string{"Buy groceries", "and", "cook dinner"} 107 - 108 - err := handler.Create(ctx, args, "", "", "", "", []string{}) 106 + desc := "Buy groceries and cook dinner" 107 + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) 109 108 if err != nil { 110 109 t.Errorf("CreateTask failed: %v", err) 111 110 } ··· 136 135 137 136 t.Run("fails with empty description", func(t *testing.T) { 138 137 ctx := context.Background() 139 - args := []string{} 140 - 141 - err := handler.Create(ctx, args, "", "", "", "", []string{}) 138 + desc := "" 139 + err := handler.Create(ctx, desc, "", "", "", "", "", "", "", "", []string{}) 142 140 if err == nil { 143 141 t.Error("Expected error for empty description") 144 142 } ··· 150 148 151 149 t.Run("creates task with flags", func(t *testing.T) { 152 150 ctx := context.Background() 153 - args := []string{"Task", "with", "flags"} 151 + description := "Task with flags" 154 152 priority := "A" 155 153 project := "test-project" 156 154 due := "2024-12-31" 157 155 tags := []string{"urgent", "work"} 158 156 159 - err := handler.Create(ctx, args, priority, project, "test-context", due, tags) 157 + err := handler.Create(ctx, description, priority, project, "test-context", due, "", "", "", "", tags) 160 158 if err != nil { 161 159 t.Errorf("CreateTask with flags failed: %v", err) 162 160 } ··· 209 207 210 208 t.Run("fails with invalid due date format", func(t *testing.T) { 211 209 ctx := context.Background() 212 - args := []string{"Task", "with", "invalid", "date"} 210 + desc := "Task with invalid date" 213 211 invalidDue := "invalid-date" 214 212 215 - err := handler.Create(ctx, args, "", "", "", invalidDue, []string{}) 213 + err := handler.Create(ctx, desc, "", "", "", invalidDue, "", "", "", "", []string{}) 216 214 if err == nil { 217 215 t.Error("Expected error for invalid due date format") 218 216 } ··· 228 226 defer cleanup() 229 227 230 228 ctx := context.Background() 231 - 232 229 handler, err := NewTaskHandler() 233 230 if err != nil { 234 231 t.Fatalf("Failed to create handler: %v", err) ··· 336 333 t.Run("updates task by ID", func(t *testing.T) { 337 334 taskID := strconv.FormatInt(id, 10) 338 335 339 - err := handler.Update(ctx, taskID, "Updated description", "", "", "", "", "", []string{}, []string{}) 336 + err := handler.Update(ctx, taskID, "Updated description", "", "", "", "", "", "", "", "", []string{}, []string{}, "", "") 340 337 if err != nil { 341 338 t.Errorf("UpdateTask failed: %v", err) 342 339 } ··· 353 350 354 351 t.Run("updates task by UUID", func(t *testing.T) { 355 352 taskID := task.UUID 356 - 357 - err := handler.Update(ctx, taskID, "", "completed", "", "", "", "", []string{}, []string{}) 353 + err := handler.Update(ctx, taskID, "", "completed", "", "", "", "", "", "", "", []string{}, []string{}, "", "") 358 354 if err != nil { 359 355 t.Errorf("UpdateTask by UUID failed: %v", err) 360 356 } ··· 371 367 372 368 t.Run("updates multiple fields", func(t *testing.T) { 373 369 taskID := strconv.FormatInt(id, 10) 374 - 375 - err := handler.Update(ctx, taskID, "Multiple updates", "", "B", "test", "office", "2024-12-31", []string{}, []string{}) 370 + err := handler.Update(ctx, taskID, "Multiple updates", "", "B", "test", "office", "2024-12-31", "", "", "", []string{}, []string{}, "", "") 376 371 if err != nil { 377 372 t.Errorf("UpdateTask with multiple fields failed: %v", err) 378 373 } ··· 398 393 399 394 t.Run("adds and removes tags", func(t *testing.T) { 400 395 taskID := strconv.FormatInt(id, 10) 401 - 402 - err := handler.Update(ctx, taskID, "", "", "", "", "", "", []string{"work", "urgent"}, []string{}) 396 + err := handler.Update(ctx, taskID, "", "", "", "", "", "", "", "", "", []string{"work", "urgent"}, []string{}, "", "") 403 397 if err != nil { 404 398 t.Errorf("UpdateTask with add tags failed: %v", err) 405 399 } ··· 415 409 416 410 taskID = strconv.FormatInt(id, 10) 417 411 418 - err = handler.Update(ctx, taskID, "", "", "", "", "", "", []string{}, []string{"urgent"}) 412 + err = handler.Update(ctx, taskID, "", "", "", "", "", "", "", "", "", []string{}, []string{"urgent"}, "", "") 419 413 if err != nil { 420 414 t.Errorf("UpdateTask with remove tag failed: %v", err) 421 415 } ··· 435 429 }) 436 430 437 431 t.Run("fails with missing task ID", func(t *testing.T) { 438 - err := handler.Update(ctx, "", "", "", "", "", "", "", []string{}, []string{}) 432 + err := handler.Update(ctx, "", "", "", "", "", "", "", "", "", "", []string{}, []string{}, "", "") 439 433 if err == nil { 440 434 t.Error("Expected error for missing task ID") 441 435 } ··· 448 442 t.Run("fails with invalid task ID", func(t *testing.T) { 449 443 taskID := "99999" 450 444 451 - err := handler.Update(ctx, taskID, "test", "", "", "", "", "", []string{}, []string{}) 445 + err := handler.Update(ctx, taskID, "test", "", "", "", "", "", "", "", "", []string{}, []string{}, "", "") 452 446 if err == nil { 453 447 t.Error("Expected error for invalid task ID") 454 448 } ··· 957 951 958 952 ctx := context.Background() 959 953 960 - err = handler.Create(ctx, []string{"Test", "Task", "1"}, "high", "test-project", "test-context", "", []string{"tag1"}) 954 + err = handler.Create(ctx, "Test Task 1", "high", "test-project", "test-context", "", "", "", "", "", []string{"tag1"}) 961 955 if err != nil { 962 956 t.Fatalf("Failed to create test task: %v", err) 963 957 } 964 958 965 - err = handler.Create(ctx, []string{"Test", "Task", "2"}, "medium", "test-project", "test-context", "", []string{"tag2"}) 959 + err = handler.Create(ctx, "Test Task 2", "medium", "test-project", "test-context", "", "", "", "", "", []string{"tag2"}) 966 960 if err != nil { 967 961 t.Fatalf("Failed to create test task: %v", err) 968 962 }
+86 -35
internal/models/models.go
··· 36 36 PriorityNumericMax = 5 37 37 ) 38 38 39 + // RRule represents a recurrence rule (RFC 5545). 40 + // Example: "FREQ=DAILY;INTERVAL=1" or "FREQ=WEEKLY;BYDAY=MO,WE,FR". 41 + type RRule string 42 + 39 43 // Model defines the common interface that all domain models must implement 40 44 type Model interface { 41 45 // GetID returns the primary key identifier ··· 56 60 57 61 // Task represents a task item with TaskWarrior-inspired fields 58 62 type Task struct { 59 - ID int64 `json:"id"` 60 - UUID string `json:"uuid"` 61 - Description string `json:"description"` 62 - // pending, completed, deleted 63 - Status string `json:"status"` 64 - // A-Z or empty 65 - Priority string `json:"priority,omitempty"` 66 - Project string `json:"project,omitempty"` 67 - Context string `json:"context,omitempty"` 68 - Tags []string `json:"tags,omitempty"` 69 - Due *time.Time `json:"due,omitempty"` 70 - Entry time.Time `json:"entry"` 71 - Modified time.Time `json:"modified"` 72 - // completion time 73 - End *time.Time `json:"end,omitempty"` 74 - // when task was started 75 - Start *time.Time `json:"start,omitempty"` 63 + ID int64 `json:"id"` 64 + UUID string `json:"uuid"` 65 + Description string `json:"description"` 66 + Status string `json:"status"` // pending, completed, deleted 67 + Priority string `json:"priority,omitempty"` // A-Z or empty 68 + Project string `json:"project,omitempty"` 69 + Context string `json:"context,omitempty"` 70 + Tags []string `json:"tags,omitempty"` 71 + Due *time.Time `json:"due,omitempty"` 72 + Entry time.Time `json:"entry"` 73 + Modified time.Time `json:"modified"` 74 + End *time.Time `json:"end,omitempty"` // Completion time 75 + Start *time.Time `json:"start,omitempty"` // When the task was started 76 76 Annotations []string `json:"annotations,omitempty"` 77 + Recur RRule `json:"recur,omitempty"` 78 + Until *time.Time `json:"until,omitempty"` // End date for recurrence 79 + ParentUUID *string `json:"parent_uuid,omitempty"` // ID of parent/template task 80 + DependsOn []string `json:"depends_on,omitempty"` // IDs of tasks this task depends on 77 81 } 78 82 79 83 // Movie represents a movie in the watch queue 80 84 type Movie struct { 81 - ID int64 `json:"id"` 82 - Title string `json:"title"` 83 - Year int `json:"year,omitempty"` 84 - // queued, watched, removed 85 - Status string `json:"status"` 85 + ID int64 `json:"id"` 86 + Title string `json:"title"` 87 + Year int `json:"year,omitempty"` 88 + Status string `json:"status"` // queued, watched, removed 86 89 Rating float64 `json:"rating,omitempty"` 87 90 Notes string `json:"notes,omitempty"` 88 91 Added time.Time `json:"added"` ··· 91 94 92 95 // TVShow represents a TV show in the watch queue 93 96 type TVShow struct { 94 - ID int64 `json:"id"` 95 - Title string `json:"title"` 96 - Season int `json:"season,omitempty"` 97 - Episode int `json:"episode,omitempty"` 98 - // queued, watching, watched, removed 99 - Status string `json:"status"` 97 + ID int64 `json:"id"` 98 + Title string `json:"title"` 99 + Season int `json:"season,omitempty"` 100 + Episode int `json:"episode,omitempty"` 101 + Status string `json:"status"` // queued, watching, watched, removed 100 102 Rating float64 `json:"rating,omitempty"` 101 103 Notes string `json:"notes,omitempty"` 102 104 Added time.Time `json:"added"` ··· 105 107 106 108 // Book represents a book in the reading list 107 109 type Book struct { 108 - ID int64 `json:"id"` 109 - Title string `json:"title"` 110 - Author string `json:"author,omitempty"` 111 - // queued, reading, finished, removed 112 - Status string `json:"status"` 113 - // percentage 0-100 114 - Progress int `json:"progress"` 110 + ID int64 `json:"id"` 111 + Title string `json:"title"` 112 + Author string `json:"author,omitempty"` 113 + Status string `json:"status"` // queued, reading, finished, removed 114 + Progress int `json:"progress"` // percentage 0-100 115 115 Pages int `json:"pages,omitempty"` 116 116 Rating float64 `json:"rating,omitempty"` 117 117 Notes string `json:"notes,omitempty"` ··· 308 308 } 309 309 return 0 310 310 } 311 + } 312 + 313 + // IsStarted returns true if the task has a start time set. 314 + func (t *Task) IsStarted() bool { 315 + return t.Start != nil 316 + } 317 + 318 + // IsOverdue returns true if the task is overdue. 319 + func (t *Task) IsOverdue(now time.Time) bool { 320 + return t.Due != nil && now.After(*t.Due) && !t.IsCompleted() 321 + } 322 + 323 + // HasDueDate returns true if the task has a due date set. 324 + func (t *Task) HasDueDate() bool { 325 + return t.Due != nil 326 + } 327 + 328 + // IsRecurring returns true if the task has recurrence defined. 329 + func (t *Task) IsRecurring() bool { 330 + return t.Recur != "" 331 + } 332 + 333 + // IsRecurExpired checks if the recurrence has an end (until) date and is past it. 334 + func (t *Task) IsRecurExpired(now time.Time) bool { 335 + return t.Until != nil && now.After(*t.Until) 336 + } 337 + 338 + // HasDependencies returns true if the task depends on other tasks. 339 + func (t *Task) HasDependencies() bool { 340 + return len(t.DependsOn) > 0 341 + } 342 + 343 + // Blocks checks if this task blocks another given task. 344 + func (t *Task) Blocks(other *Task) bool { 345 + return slices.Contains(other.DependsOn, t.UUID) 346 + } 347 + 348 + // Urgency computes a score based on priority, due date, and tags. 349 + // This can be expanded later with weights. 350 + func (t *Task) Urgency(now time.Time) float64 { 351 + score := 0.0 352 + if t.Priority != "" { 353 + score += 1.0 354 + } 355 + if t.IsOverdue(now) { 356 + score += 2.0 357 + } 358 + if len(t.Tags) > 0 { 359 + score += 0.5 360 + } 361 + return score 311 362 } 312 363 313 364 // IsWatched returns true if the movie has been watched
+103
internal/models/models_test.go
··· 374 374 } 375 375 }) 376 376 377 + t.Run("IsStarted", func(t *testing.T) { 378 + now := time.Now() 379 + task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now} 380 + 381 + if task.IsStarted() { 382 + t.Errorf("expected IsStarted to be false, got true") 383 + } 384 + task.Start = &now 385 + if !task.IsStarted() { 386 + t.Errorf("expected IsStarted to be true, got false") 387 + } 388 + }) 389 + 390 + t.Run("HasDueDate and IsOverdue", func(t *testing.T) { 391 + now := time.Now() 392 + past := now.Add(-24 * time.Hour) 393 + future := now.Add(24 * time.Hour) 394 + task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now} 395 + 396 + if task.HasDueDate() { 397 + t.Errorf("expected HasDueDate to be false, got true") 398 + } 399 + task.Due = &future 400 + if !task.HasDueDate() { 401 + t.Errorf("expected HasDueDate to be true, got false") 402 + } 403 + task.Due = &past 404 + task.Status = string(StatusPending) 405 + if !task.IsOverdue(now) { 406 + t.Errorf("expected overdue task, got false") 407 + } 408 + task.Status = string(StatusCompleted) 409 + if task.IsOverdue(now) { 410 + t.Errorf("expected completed task not to be overdue, got true") 411 + } 412 + }) 413 + 414 + t.Run("IsRecurring and IsRecurExpired", func(t *testing.T) { 415 + now := time.Now() 416 + past := now.Add(-24 * time.Hour) 417 + future := now.Add(24 * time.Hour) 418 + 419 + task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now} 420 + if task.IsRecurring() { 421 + t.Errorf("expected IsRecurring to be false, got true") 422 + } 423 + task.Recur = "FREQ=DAILY" 424 + if !task.IsRecurring() { 425 + t.Errorf("expected IsRecurring to be true, got false") 426 + } 427 + if task.IsRecurExpired(now) { 428 + t.Errorf("expected IsRecurExpired to be false without Until, got true") 429 + } 430 + task.Until = &past 431 + if !task.IsRecurExpired(now) { 432 + t.Errorf("expected IsRecurExpired to be true, got false") 433 + } 434 + task.Until = &future 435 + if task.IsRecurExpired(now) { 436 + t.Errorf("expected IsRecurExpired to be false, got true") 437 + } 438 + }) 439 + 440 + t.Run("HasDependencies and Blocks", func(t *testing.T) { 441 + now := time.Now() 442 + task := Task{UUID: "123", Description: "demo", Entry: now, Modified: now} 443 + if task.HasDependencies() { 444 + t.Errorf("expected HasDependencies to be false, got true") 445 + } 446 + task.DependsOn = []string{"abc"} 447 + if !task.HasDependencies() { 448 + t.Errorf("expected HasDependencies to be true, got false") 449 + } 450 + other := Task{UUID: "abc", DependsOn: []string{"123"}} 451 + if !task.Blocks(&other) { 452 + t.Errorf("expected task to block other, got false") 453 + } 454 + other.DependsOn = []string{} 455 + if task.Blocks(&other) { 456 + t.Errorf("expected task not to block other, got true") 457 + } 458 + }) 459 + 460 + t.Run("Urgency", func(t *testing.T) { 461 + now := time.Now() 462 + past := now.Add(-24 * time.Hour) 463 + 464 + task := Task{ 465 + UUID: "u1", 466 + Description: "urgency test", 467 + Priority: "H", 468 + Tags: []string{"t1"}, 469 + Due: &past, 470 + Status: string(StatusPending), 471 + Entry: now, 472 + Modified: now, 473 + } 474 + score := task.Urgency(now) 475 + if score <= 0 { 476 + t.Errorf("expected positive urgency score, got %f", score) 477 + } 478 + }) 479 + 377 480 }) 378 481 379 482 t.Run("Movie Model", func(t *testing.T) {
+17 -19
internal/repo/repositories_test.go
··· 36 36 modified DATETIME DEFAULT CURRENT_TIMESTAMP, 37 37 end DATETIME, 38 38 start DATETIME, 39 - annotations TEXT 39 + annotations TEXT, 40 + recur TEXT, 41 + until DATETIME, 42 + parent_uuid TEXT 43 + ); 44 + 45 + -- Task dependencies table 46 + CREATE TABLE IF NOT EXISTS task_dependencies ( 47 + id INTEGER PRIMARY KEY AUTOINCREMENT, 48 + task_uuid TEXT NOT NULL, 49 + depends_on_uuid TEXT NOT NULL, 50 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 51 + FOREIGN KEY(task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE, 52 + FOREIGN KEY(depends_on_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE 40 53 ); 41 54 42 55 -- Movies table ··· 110 123 ctx := context.Background() 111 124 112 125 t.Run("Create all resource types", func(t *testing.T) { 113 - task := &models.Task{ 114 - UUID: uuid.New().String(), 115 - Description: "Integration test task", 116 - Status: "pending", 117 - Project: "integration", 118 - } 126 + task := &models.Task{UUID: uuid.New().String(), Description: "Integration test task", Status: "pending", Project: "integration"} 119 127 taskID, err := repos.Tasks.Create(ctx, task) 120 128 if err != nil { 121 129 t.Errorf("Failed to create task: %v", err) ··· 138 146 t.Error("Expected non-zero movie ID") 139 147 } 140 148 141 - tvShow := &models.TVShow{ 142 - Title: "Integration Series", 143 - Season: 1, 144 - Episode: 1, 145 - Status: "queued", 146 - Rating: 9.0, 147 - } 149 + tvShow := &models.TVShow{Title: "Integration Series", Season: 1, Episode: 1, Status: "queued", Rating: 9.0} 148 150 tvID, err := repos.TV.Create(ctx, tvShow) 149 151 if err != nil { 150 152 t.Errorf("Failed to create TV show: %v", err) ··· 168 170 t.Error("Expected non-zero book ID") 169 171 } 170 172 171 - note := &models.Note{ 172 - Title: "Integration Note", 173 - Content: "This is test content for integration", 174 - Tags: []string{"integration", "test"}, 175 - } 173 + note := &models.Note{Title: "Integration Note", Content: "This is test content for integration", Tags: []string{"integration", "test"}} 176 174 noteID, err := repos.Notes.Create(ctx, note) 177 175 if err != nil { 178 176 t.Errorf("Failed to create note: %v", err)
+220 -29
internal/repo/task_repository.go
··· 70 70 } 71 71 72 72 query := ` 73 - INSERT INTO tasks (uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations) 74 - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 73 + INSERT INTO tasks ( 74 + uuid, description, status, priority, project, context, 75 + tags, due, entry, modified, end, start, annotations, 76 + recur, until, parent_uuid 77 + ) 78 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 75 79 76 80 result, err := r.db.ExecContext(ctx, query, 77 81 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 78 - tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations) 82 + tags, task.Due, task.Entry, task.Modified, task.End, task.Start, annotations, 83 + task.Recur, task.Until, task.ParentUUID, 84 + ) 79 85 if err != nil { 80 86 return 0, fmt.Errorf("failed to insert task: %w", err) 81 87 } ··· 86 92 } 87 93 88 94 task.ID = id 95 + 96 + // Sync dependencies to task_dependencies table 97 + for _, depUUID := range task.DependsOn { 98 + if err := r.AddDependency(ctx, task.UUID, depUUID); err != nil { 99 + return 0, fmt.Errorf("failed to add dependency: %w", err) 100 + } 101 + } 102 + 89 103 return id, nil 90 104 } 91 105 92 106 // Get retrieves a task by ID 93 107 func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) { 94 108 query := ` 95 - SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations 109 + SELECT id, uuid, description, status, priority, project, context, tags, 110 + due, entry, modified, end, start, annotations, 111 + recur, until, parent_uuid 96 112 FROM tasks WHERE id = ?` 97 113 98 114 task := &models.Task{} 99 115 var tags, annotations sql.NullString 116 + var parentUUID sql.NullString 100 117 101 - err := r.db.QueryRowContext(ctx, query, id).Scan( 102 - &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, &task.Context, 103 - &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations) 104 - if err != nil { 118 + if err := r.db.QueryRowContext(ctx, query, id).Scan( 119 + &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, 120 + &task.Project, &task.Context, &tags, 121 + &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 122 + &task.Recur, &task.Until, &parentUUID, 123 + ); err != nil { 105 124 return nil, fmt.Errorf("failed to get task: %w", err) 106 125 } 107 126 ··· 116 135 return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) 117 136 } 118 137 } 138 + if parentUUID.Valid { 139 + task.ParentUUID = &parentUUID.String 140 + } 141 + 142 + // Populate dependencies from task_dependencies table 143 + if err := r.PopulateDependencies(ctx, task); err != nil { 144 + return nil, fmt.Errorf("failed to populate dependencies: %w", err) 145 + } 119 146 120 147 return task, nil 121 148 } ··· 135 162 } 136 163 137 164 query := ` 138 - UPDATE tasks SET uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, 139 - tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ? 165 + UPDATE tasks SET 166 + uuid = ?, description = ?, status = ?, priority = ?, project = ?, context = ?, 167 + tags = ?, due = ?, modified = ?, end = ?, start = ?, annotations = ?, 168 + recur = ?, until = ?, parent_uuid = ? 140 169 WHERE id = ?` 141 170 142 - _, err = r.db.ExecContext(ctx, query, 171 + if _, err = r.db.ExecContext(ctx, query, 143 172 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context, 144 - tags, task.Due, task.Modified, task.End, task.Start, annotations, task.ID) 145 - if err != nil { 173 + tags, task.Due, task.Modified, task.End, task.Start, annotations, 174 + task.Recur, task.Until, task.ParentUUID, 175 + task.ID, 176 + ); err != nil { 146 177 return fmt.Errorf("failed to update task: %w", err) 147 178 } 148 179 180 + // Sync dependencies: clear existing and add new ones 181 + if err := r.ClearDependencies(ctx, task.UUID); err != nil { 182 + return fmt.Errorf("failed to clear dependencies: %w", err) 183 + } 184 + 185 + for _, depUUID := range task.DependsOn { 186 + if err := r.AddDependency(ctx, task.UUID, depUUID); err != nil { 187 + return fmt.Errorf("failed to add dependency: %w", err) 188 + } 189 + } 190 + 149 191 return nil 150 192 } 151 193 ··· 183 225 } 184 226 185 227 func (r *TaskRepository) buildListQuery(opts TaskListOptions) string { 186 - query := "SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations FROM tasks" 228 + query := ` 229 + SELECT id, uuid, description, status, priority, project, context, tags, 230 + due, entry, modified, end, start, annotations, 231 + recur, until, parent_uuid 232 + FROM tasks` 187 233 188 234 var conditions []string 189 235 ··· 272 318 273 319 func (r *TaskRepository) scanTaskRow(rows *sql.Rows, task *models.Task) error { 274 320 var tags, annotations sql.NullString 321 + var parentUUID sql.NullString 275 322 276 - if err := rows.Scan(&task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, 277 - &task.Project, &task.Context, &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil { 323 + if err := rows.Scan( 324 + &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, 325 + &task.Project, &task.Context, &tags, 326 + &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 327 + &task.Recur, &task.Until, &parentUUID, 328 + ); err != nil { 278 329 return fmt.Errorf("failed to scan task row: %w", err) 330 + } 331 + 332 + if parentUUID.Valid { 333 + task.ParentUUID = &parentUUID.String 279 334 } 280 335 281 336 if tags.Valid { ··· 317 372 conditions = append(conditions, "project = ?") 318 373 args = append(args, opts.Project) 319 374 } 375 + if opts.Context != "" { 376 + conditions = append(conditions, "context = ?") 377 + args = append(args, opts.Context) 378 + } 320 379 if !opts.DueAfter.IsZero() { 321 380 conditions = append(conditions, "due >= ?") 322 381 args = append(args, opts.DueAfter) ··· 330 389 searchConditions := []string{ 331 390 "description LIKE ?", 332 391 "project LIKE ?", 392 + "context LIKE ?", 333 393 "tags LIKE ?", 334 394 } 335 395 conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR "))) 336 396 searchPattern := "%" + opts.Search + "%" 337 - args = append(args, searchPattern, searchPattern, searchPattern) 397 + args = append(args, searchPattern, searchPattern, searchPattern, searchPattern) 338 398 } 339 399 340 400 if len(conditions) > 0 { ··· 353 413 // GetByUUID retrieves a task by UUID 354 414 func (r *TaskRepository) GetByUUID(ctx context.Context, uuid string) (*models.Task, error) { 355 415 query := ` 356 - SELECT id, uuid, description, status, priority, project, tags, due, entry, modified, end, start, annotations 416 + SELECT id, uuid, description, status, priority, project, context, tags, 417 + due, entry, modified, end, start, annotations, 418 + recur, until, parent_uuid 357 419 FROM tasks WHERE uuid = ?` 358 420 359 421 task := &models.Task{} 360 422 var tags, annotations sql.NullString 423 + var parentUUID sql.NullString 361 424 362 425 if err := r.db.QueryRowContext(ctx, query, uuid).Scan( 363 - &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, &task.Project, 364 - &tags, &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations); err != nil { 426 + &task.ID, &task.UUID, &task.Description, &task.Status, &task.Priority, 427 + &task.Project, &task.Context, &tags, 428 + &task.Due, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations, 429 + &task.Recur, &task.Until, &parentUUID, 430 + ); err != nil { 365 431 return nil, fmt.Errorf("failed to get task by UUID: %w", err) 366 432 } 367 433 ··· 375 441 if err := task.UnmarshalAnnotations(annotations.String); err != nil { 376 442 return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) 377 443 } 444 + } 445 + 446 + if parentUUID.Valid { 447 + task.ParentUUID = &parentUUID.String 448 + } 449 + 450 + // Populate dependencies from task_dependencies table 451 + if err := r.PopulateDependencies(ctx, task); err != nil { 452 + return nil, fmt.Errorf("failed to populate dependencies: %w", err) 378 453 } 379 454 380 455 return task, nil ··· 484 559 // GetTasksByTag retrieves all tasks with a specific tag 485 560 func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) { 486 561 query := ` 487 - 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 488 - FROM tasks, json_each(tasks.tags) 489 - WHERE tasks.tags != '' AND tasks.tags IS NOT NULL AND json_each.value = ? 490 - ORDER BY tasks.modified DESC` 562 + SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 563 + t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, 564 + t.recur, t.until, t.parent_uuid 565 + FROM tasks t, json_each(t.tags) 566 + WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ? 567 + ORDER BY t.modified DESC` 491 568 492 569 rows, err := r.db.QueryContext(ctx, query, tag) 493 570 if err != nil { ··· 532 609 return r.List(ctx, TaskListOptions{Status: models.StatusAbandoned}) 533 610 } 534 611 535 - // GetByPriority retrieves all tasks with a specific priority 536 - // 537 - // We need special handling for empty priority by using raw SQL 612 + // GetByPriority retrieves all tasks with a specific priority with special handling for empty priority by using raw SQL 538 613 func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([]*models.Task, error) { 539 614 if priority == "" { 540 615 query := ` 541 - SELECT id, uuid, description, status, priority, project, context, tags, due, entry, modified, end, start, annotations 616 + SELECT id, uuid, description, status, priority, project, context, 617 + tags, due, entry, modified, end, start, annotations, 618 + recur, until, parent_uuid 542 619 FROM tasks 543 620 WHERE priority = '' OR priority IS NULL 544 621 ORDER BY modified DESC` ··· 557 634 } 558 635 tasks = append(tasks, task) 559 636 } 560 - 561 637 return tasks, rows.Err() 562 638 } 563 639 ··· 637 713 638 714 return summary, rows.Err() 639 715 } 716 + 717 + // AddDependency creates a dependency relationship where taskUUID depends on dependsOnUUID. 718 + func (r *TaskRepository) AddDependency(ctx context.Context, taskUUID, dependsOnUUID string) error { 719 + _, err := r.db.ExecContext(ctx, 720 + `INSERT INTO task_dependencies (task_uuid, depends_on_uuid) VALUES (?, ?)`, 721 + taskUUID, dependsOnUUID) 722 + if err != nil { 723 + err = fmt.Errorf("failed to add dependency: %w", err) 724 + } 725 + return err 726 + } 727 + 728 + // RemoveDependency deletes a specific dependency relationship. 729 + func (r *TaskRepository) RemoveDependency(ctx context.Context, taskUUID, dependsOnUUID string) error { 730 + _, err := r.db.ExecContext(ctx, 731 + `DELETE FROM task_dependencies WHERE task_uuid = ? AND depends_on_uuid = ?`, 732 + taskUUID, dependsOnUUID) 733 + if err != nil { 734 + err = fmt.Errorf("failed to remove dependency: %w", err) 735 + } 736 + return err 737 + } 738 + 739 + // ClearDependencies removes all dependencies for a given task. 740 + func (r *TaskRepository) ClearDependencies(ctx context.Context, taskUUID string) error { 741 + _, err := r.db.ExecContext(ctx, 742 + `DELETE FROM task_dependencies WHERE task_uuid = ?`, 743 + taskUUID) 744 + if err != nil { 745 + err = fmt.Errorf("failed to clear dependencies: %w", err) 746 + } 747 + return err 748 + } 749 + 750 + // GetDependencies returns the UUIDs of tasks this task depends on. 751 + func (r *TaskRepository) GetDependencies(ctx context.Context, taskUUID string) ([]string, error) { 752 + rows, err := r.db.QueryContext(ctx, 753 + `SELECT depends_on_uuid FROM task_dependencies WHERE task_uuid = ?`, taskUUID) 754 + if err != nil { 755 + return nil, fmt.Errorf("failed to get dependencies: %w", err) 756 + } 757 + defer rows.Close() 758 + 759 + var deps []string 760 + for rows.Next() { 761 + var dep string 762 + if err := rows.Scan(&dep); err != nil { 763 + return nil, fmt.Errorf("failed to scan dependency: %w", err) 764 + } 765 + deps = append(deps, dep) 766 + } 767 + return deps, rows.Err() 768 + } 769 + 770 + // PopulateDependencies loads dependency UUIDs from task_dependencies table into task.DependsOn 771 + func (r *TaskRepository) PopulateDependencies(ctx context.Context, task *models.Task) error { 772 + deps, err := r.GetDependencies(ctx, task.UUID) 773 + if err != nil { 774 + return err 775 + } 776 + task.DependsOn = deps 777 + return nil 778 + } 779 + 780 + // GetDependents returns tasks that are blocked by a given UUID. 781 + func (r *TaskRepository) GetDependents(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 782 + query := ` 783 + SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 784 + t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, 785 + t.recur, t.until, t.parent_uuid 786 + FROM tasks t 787 + JOIN task_dependencies d ON t.uuid = d.task_uuid 788 + WHERE d.depends_on_uuid = ?` 789 + 790 + rows, err := r.db.QueryContext(ctx, query, blockingUUID) 791 + if err != nil { 792 + return nil, fmt.Errorf("failed to get dependents: %w", err) 793 + } 794 + defer rows.Close() 795 + 796 + var tasks []*models.Task 797 + for rows.Next() { 798 + task := &models.Task{} 799 + if err := r.scanTaskRow(rows, task); err != nil { 800 + return nil, err 801 + } 802 + tasks = append(tasks, task) 803 + } 804 + return tasks, rows.Err() 805 + } 806 + 807 + // GetBlockedTasks finds tasks that are blocked by a given UUID. 808 + func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) { 809 + query := ` 810 + SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context, 811 + t.tags, t.due, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid 812 + FROM tasks t 813 + JOIN task_dependencies d ON t.uuid = d.task_uuid 814 + WHERE d.depends_on_uuid = ?` 815 + rows, err := r.db.QueryContext(ctx, query, blockingUUID) 816 + if err != nil { 817 + return nil, err 818 + } 819 + defer rows.Close() 820 + 821 + var tasks []*models.Task 822 + for rows.Next() { 823 + task := &models.Task{} 824 + if err := r.scanTaskRow(rows, task); err != nil { 825 + return nil, err 826 + } 827 + tasks = append(tasks, task) 828 + } 829 + return tasks, rows.Err() 830 + }
+82 -6
internal/repo/task_repository_test.go
··· 727 727 } 728 728 } 729 729 730 - // Test legacy priority 731 730 results, err = repo.GetByPriority(ctx, "A") 732 731 if err != nil { 733 732 t.Errorf("Failed to get tasks by priority A: %v", err) ··· 798 797 t.Error("Expected non-empty status summary") 799 798 } 800 799 801 - // Check that we have expected statuses with counts 802 800 expectedStatuses := []string{ 803 801 models.StatusTodo, models.StatusInProgress, models.StatusDone, 804 802 models.StatusBlocked, models.StatusAbandoned, ··· 854 852 } 855 853 }) 856 854 }) 855 + 856 + t.Run("Recurrence Fields", func(t *testing.T) { 857 + task := CreateSampleTask() 858 + task.Recur = "FREQ=DAILY" 859 + until := time.Now().Add(7 * 24 * time.Hour) 860 + task.Until = &until 861 + parent := newUUID() 862 + task.ParentUUID = &parent 863 + 864 + id, err := repo.Create(ctx, task) 865 + if err != nil { 866 + t.Fatalf("failed to create task with recurrence: %v", err) 867 + } 868 + 869 + retrieved, err := repo.Get(ctx, id) 870 + if err != nil { 871 + t.Fatalf("failed to get task with recurrence: %v", err) 872 + } 873 + 874 + if retrieved.Recur != "FREQ=DAILY" { 875 + t.Errorf("expected Recur=FREQ=DAILY, got %s", retrieved.Recur) 876 + } 877 + if retrieved.Until == nil || !retrieved.Until.Equal(until) { 878 + t.Errorf("expected Until=%v, got %v", until, retrieved.Until) 879 + } 880 + if retrieved.ParentUUID == nil || *retrieved.ParentUUID != parent { 881 + t.Errorf("expected ParentUUID=%s, got %v", parent, retrieved.ParentUUID) 882 + } 883 + }) 884 + 885 + t.Run("Dependencies", func(t *testing.T) { 886 + parent := CreateSampleTask() 887 + child := CreateSampleTask() 888 + 889 + _, err := repo.Create(ctx, parent) 890 + if err != nil { 891 + t.Fatalf("failed to create parent: %v", err) 892 + } 893 + _, err = repo.Create(ctx, child) 894 + if err != nil { 895 + t.Fatalf("failed to create child: %v", err) 896 + } 897 + 898 + if err := repo.AddDependency(ctx, child.UUID, parent.UUID); err != nil { 899 + t.Fatalf("failed to add dependency: %v", err) 900 + } 901 + 902 + deps, err := repo.GetDependencies(ctx, child.UUID) 903 + if err != nil { 904 + t.Fatalf("failed to get dependencies: %v", err) 905 + } 906 + if len(deps) != 1 || deps[0] != parent.UUID { 907 + t.Errorf("expected child to depend on parent=%s, got %v", parent.UUID, deps) 908 + } 909 + 910 + dependents, err := repo.GetDependents(ctx, parent.UUID) 911 + if err != nil { 912 + t.Fatalf("failed to get dependents: %v", err) 913 + } 914 + if len(dependents) != 1 || dependents[0].UUID != child.UUID { 915 + t.Errorf("expected dependent to be child=%s, got %v", child.UUID, dependents) 916 + } 917 + 918 + if err := repo.RemoveDependency(ctx, child.UUID, parent.UUID); err != nil { 919 + t.Fatalf("failed to remove dependency: %v", err) 920 + } 921 + deps, _ = repo.GetDependencies(ctx, child.UUID) 922 + if len(deps) != 0 { 923 + t.Errorf("expected dependencies to be cleared, got %v", deps) 924 + } 925 + 926 + if err := repo.AddDependency(ctx, child.UUID, parent.UUID); err != nil { 927 + t.Fatalf("failed to re-add dependency: %v", err) 928 + } 929 + if err := repo.ClearDependencies(ctx, child.UUID); err != nil { 930 + t.Fatalf("failed to clear dependencies: %v", err) 931 + } 932 + deps, _ = repo.GetDependencies(ctx, child.UUID) 933 + if len(deps) != 0 { 934 + t.Errorf("expected no dependencies after clear, got %v", deps) 935 + } 936 + }) 857 937 } 858 938 859 939 func TestTaskRepository_GetContexts(t *testing.T) { ··· 882 962 t.Fatalf("Failed to create task3: %v", err) 883 963 } 884 964 885 - // Task with empty context should not be included 886 965 task4 := CreateSampleTask() 887 966 task4.Context = "" 888 967 _, err = repo.Create(ctx, task4) ··· 920 999 repo := NewTaskRepository(db) 921 1000 ctx := context.Background() 922 1001 923 - // Create tasks with different contexts 924 1002 task1 := CreateSampleTask() 925 1003 task1.Context = "work" 926 1004 task1.Description = "Work task 1" ··· 945 1023 t.Fatalf("Failed to create task3: %v", err) 946 1024 } 947 1025 948 - // Get tasks by work context 949 1026 workTasks, err := repo.GetByContext(ctx, "work") 950 1027 if err != nil { 951 1028 t.Fatalf("Failed to get tasks by context: %v", err) ··· 961 1038 } 962 1039 } 963 1040 964 - // Get tasks by home context 965 1041 homeTasks, err := repo.GetByContext(ctx, "home") 966 1042 if err != nil { 967 1043 t.Fatalf("Failed to get tasks by context: %v", err)
+105 -98
internal/repo/test_utilities.go
··· 16 16 17 17 var fake = faker.New() 18 18 19 + const testSchema string = ` 20 + CREATE TABLE IF NOT EXISTS tasks ( 21 + id INTEGER PRIMARY KEY AUTOINCREMENT, 22 + uuid TEXT UNIQUE NOT NULL, 23 + description TEXT NOT NULL, 24 + status TEXT DEFAULT 'pending', 25 + priority TEXT, 26 + project TEXT, 27 + context TEXT, 28 + tags TEXT, 29 + due DATETIME, 30 + entry DATETIME DEFAULT CURRENT_TIMESTAMP, 31 + modified DATETIME DEFAULT CURRENT_TIMESTAMP, 32 + end DATETIME, 33 + start DATETIME, 34 + annotations TEXT, 35 + recur TEXT, 36 + until DATETIME, 37 + parent_uuid TEXT 38 + ); 39 + 40 + CREATE TABLE IF NOT EXISTS task_dependencies ( 41 + id INTEGER PRIMARY KEY AUTOINCREMENT, 42 + task_uuid TEXT NOT NULL, 43 + depends_on_uuid TEXT NOT NULL, 44 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 45 + 46 + FOREIGN KEY(task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE, 47 + FOREIGN KEY(depends_on_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE 48 + ); 49 + 50 + CREATE TABLE IF NOT EXISTS books ( 51 + id INTEGER PRIMARY KEY AUTOINCREMENT, 52 + title TEXT NOT NULL, 53 + author TEXT, 54 + status TEXT DEFAULT 'queued', 55 + progress INTEGER DEFAULT 0, 56 + pages INTEGER, 57 + rating REAL, 58 + notes TEXT, 59 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 60 + started DATETIME, 61 + finished DATETIME 62 + ); 63 + 64 + CREATE TABLE IF NOT EXISTS movies ( 65 + id INTEGER PRIMARY KEY AUTOINCREMENT, 66 + title TEXT NOT NULL, 67 + year INTEGER, 68 + status TEXT DEFAULT 'queued', 69 + rating REAL, 70 + notes TEXT, 71 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 72 + watched DATETIME 73 + ); 74 + 75 + CREATE TABLE IF NOT EXISTS tv_shows ( 76 + id INTEGER PRIMARY KEY AUTOINCREMENT, 77 + title TEXT NOT NULL, 78 + season INTEGER, 79 + episode INTEGER, 80 + status TEXT DEFAULT 'queued', 81 + rating REAL, 82 + notes TEXT, 83 + added DATETIME DEFAULT CURRENT_TIMESTAMP, 84 + last_watched DATETIME 85 + ); 86 + 87 + CREATE TABLE IF NOT EXISTS notes ( 88 + id INTEGER PRIMARY KEY AUTOINCREMENT, 89 + title TEXT NOT NULL, 90 + content TEXT, 91 + tags TEXT, 92 + archived BOOLEAN DEFAULT FALSE, 93 + created DATETIME DEFAULT CURRENT_TIMESTAMP, 94 + modified DATETIME DEFAULT CURRENT_TIMESTAMP, 95 + file_path TEXT 96 + ); 97 + 98 + CREATE TABLE IF NOT EXISTS time_entries ( 99 + id INTEGER PRIMARY KEY AUTOINCREMENT, 100 + task_id INTEGER NOT NULL, 101 + start_time DATETIME NOT NULL, 102 + end_time DATETIME, 103 + duration_seconds INTEGER, 104 + description TEXT, 105 + created DATETIME DEFAULT CURRENT_TIMESTAMP, 106 + modified DATETIME DEFAULT CURRENT_TIMESTAMP, 107 + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE 108 + ); 109 + 110 + CREATE TABLE IF NOT EXISTS articles ( 111 + id INTEGER PRIMARY KEY AUTOINCREMENT, 112 + url TEXT UNIQUE NOT NULL, 113 + title TEXT NOT NULL, 114 + author TEXT, 115 + date TEXT, 116 + markdown_path TEXT NOT NULL, 117 + html_path TEXT NOT NULL, 118 + created DATETIME DEFAULT CURRENT_TIMESTAMP, 119 + modified DATETIME DEFAULT CURRENT_TIMESTAMP 120 + ); 121 + ` 122 + 19 123 // CreateTestDB creates an in-memory SQLite database with the full schema for testing 20 124 func CreateTestDB(t *testing.T) *sql.DB { 21 125 db, err := sql.Open("sqlite3", ":memory:") ··· 27 131 t.Fatalf("Failed to enable foreign keys: %v", err) 28 132 } 29 133 30 - // Full schema for all tables 31 - schema := ` 32 - CREATE TABLE IF NOT EXISTS tasks ( 33 - id INTEGER PRIMARY KEY AUTOINCREMENT, 34 - uuid TEXT UNIQUE NOT NULL, 35 - description TEXT NOT NULL, 36 - status TEXT DEFAULT 'pending', 37 - priority TEXT, 38 - project TEXT, 39 - context TEXT, 40 - tags TEXT, 41 - due DATETIME, 42 - entry DATETIME DEFAULT CURRENT_TIMESTAMP, 43 - modified DATETIME DEFAULT CURRENT_TIMESTAMP, 44 - end DATETIME, 45 - start DATETIME, 46 - annotations TEXT 47 - ); 48 - 49 - CREATE TABLE IF NOT EXISTS books ( 50 - id INTEGER PRIMARY KEY AUTOINCREMENT, 51 - title TEXT NOT NULL, 52 - author TEXT, 53 - status TEXT DEFAULT 'queued', 54 - progress INTEGER DEFAULT 0, 55 - pages INTEGER, 56 - rating REAL, 57 - notes TEXT, 58 - added DATETIME DEFAULT CURRENT_TIMESTAMP, 59 - started DATETIME, 60 - finished DATETIME 61 - ); 62 - 63 - CREATE TABLE IF NOT EXISTS movies ( 64 - id INTEGER PRIMARY KEY AUTOINCREMENT, 65 - title TEXT NOT NULL, 66 - year INTEGER, 67 - status TEXT DEFAULT 'queued', 68 - rating REAL, 69 - notes TEXT, 70 - added DATETIME DEFAULT CURRENT_TIMESTAMP, 71 - watched DATETIME 72 - ); 73 - 74 - CREATE TABLE IF NOT EXISTS tv_shows ( 75 - id INTEGER PRIMARY KEY AUTOINCREMENT, 76 - title TEXT NOT NULL, 77 - season INTEGER, 78 - episode INTEGER, 79 - status TEXT DEFAULT 'queued', 80 - rating REAL, 81 - notes TEXT, 82 - added DATETIME DEFAULT CURRENT_TIMESTAMP, 83 - last_watched DATETIME 84 - ); 85 - 86 - CREATE TABLE IF NOT EXISTS notes ( 87 - id INTEGER PRIMARY KEY AUTOINCREMENT, 88 - title TEXT NOT NULL, 89 - content TEXT, 90 - tags TEXT, 91 - archived BOOLEAN DEFAULT FALSE, 92 - created DATETIME DEFAULT CURRENT_TIMESTAMP, 93 - modified DATETIME DEFAULT CURRENT_TIMESTAMP, 94 - file_path TEXT 95 - ); 96 - 97 - CREATE TABLE IF NOT EXISTS time_entries ( 98 - id INTEGER PRIMARY KEY AUTOINCREMENT, 99 - task_id INTEGER NOT NULL, 100 - start_time DATETIME NOT NULL, 101 - end_time DATETIME, 102 - duration_seconds INTEGER, 103 - created DATETIME DEFAULT CURRENT_TIMESTAMP, 104 - modified DATETIME DEFAULT CURRENT_TIMESTAMP, 105 - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE 106 - ); 107 - 108 - CREATE TABLE IF NOT EXISTS articles ( 109 - id INTEGER PRIMARY KEY AUTOINCREMENT, 110 - url TEXT UNIQUE NOT NULL, 111 - title TEXT NOT NULL, 112 - author TEXT, 113 - date TEXT, 114 - markdown_path TEXT NOT NULL, 115 - html_path TEXT NOT NULL, 116 - created DATETIME DEFAULT CURRENT_TIMESTAMP, 117 - modified DATETIME DEFAULT CURRENT_TIMESTAMP 118 - ); 119 - ` 120 - 121 - if _, err := db.Exec(schema); err != nil { 134 + if _, err := db.Exec(testSchema); err != nil { 122 135 t.Fatalf("Failed to create schema: %v", err) 123 136 } 124 137 ··· 129 142 return db 130 143 } 131 144 132 - // Sample data creators 133 145 func CreateSampleTask() *models.Task { 134 146 return &models.Task{ 135 147 UUID: uuid.New().String(), ··· 258 270 return articles 259 271 } 260 272 261 - // Test helpers for common operations 262 273 func AssertNoError(t *testing.T, err error, msg string) { 263 274 t.Helper() 264 275 if err != nil { ··· 332 343 AssertNoError(t, err, "Failed to create sample task 2") 333 344 task2.ID = id2 334 345 335 - // Create sample books 336 346 book1 := CreateSampleBook() 337 347 book1.Title = "Sample Book 1" 338 348 book1.Status = "reading" ··· 349 359 AssertNoError(t, err, "Failed to create sample book 2") 350 360 book2.ID = bookID2 351 361 352 - // Create sample movies 353 362 movie1 := CreateSampleMovie() 354 363 movie1.Title = "Sample Movie 1" 355 364 movie1.Status = "queued" ··· 366 375 AssertNoError(t, err, "Failed to create sample movie 2") 367 376 movie2.ID = movieID2 368 377 369 - // Create sample TV shows 370 378 tv1 := CreateSampleTVShow() 371 379 tv1.Title = "Sample TV Show 1" 372 380 tv1.Status = "queued" ··· 383 391 AssertNoError(t, err, "Failed to create sample TV show 2") 384 392 tv2.ID = tvID2 385 393 386 - // Create sample notes 387 394 note1 := CreateSampleNote() 388 395 note1.Title = "Sample Note 1" 389 396 note1.Content = "Content for note 1"
+7
internal/store/sql/migrations/0007_add_recurrence_deps_to_tasks_down.sql
··· 1 + -- Remove recurrence fields 2 + ALTER TABLE tasks DROP COLUMN recur; 3 + ALTER TABLE tasks DROP COLUMN until; 4 + ALTER TABLE tasks DROP COLUMN parent_uuid; 5 + 6 + -- Drop dependencies table 7 + DROP TABLE IF EXISTS task_dependencies;
+19
internal/store/sql/migrations/0007_add_recurrence_deps_to_tasks_up.sql
··· 1 + -- Add recurrence fields directly to tasks 2 + ALTER TABLE tasks ADD COLUMN recur TEXT; -- e.g. "daily", "weekly", ISO8601 rule 3 + ALTER TABLE tasks ADD COLUMN until DATETIME; -- optional end date for recurrence 4 + ALTER TABLE tasks ADD COLUMN parent_uuid TEXT; -- parent/template task UUID 5 + 6 + -- Create dependencies table 7 + CREATE TABLE IF NOT EXISTS task_dependencies ( 8 + id INTEGER PRIMARY KEY AUTOINCREMENT, 9 + task_uuid TEXT NOT NULL, -- the dependent task 10 + depends_on_uuid TEXT NOT NULL, -- the blocking task 11 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 12 + 13 + FOREIGN KEY(task_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE, 14 + FOREIGN KEY(depends_on_uuid) REFERENCES tasks(uuid) ON DELETE CASCADE 15 + ); 16 + 17 + -- Indexes for faster dependency lookups 18 + CREATE INDEX IF NOT EXISTS idx_task_dependencies_task_uuid ON task_dependencies(task_uuid); 19 + CREATE INDEX IF NOT EXISTS idx_task_dependencies_depends_on_uuid ON task_dependencies(depends_on_uuid);