cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1// TODO: extend queryMany composition for GetTasksBy... methods
2package repo
3
4import (
5 "context"
6 "database/sql"
7 "encoding/json"
8 "fmt"
9 "strings"
10 "time"
11
12 "github.com/stormlightlabs/noteleaf/internal/models"
13)
14
15var (
16 marshalTaskTags = (*models.Task).MarshalTags
17 marshalTaskAnnotations = (*models.Task).MarshalAnnotations
18 unmarshalTaskTags = (*models.Task).UnmarshalTags
19 unmarshalTaskAnnotations = (*models.Task).UnmarshalAnnotations
20)
21
22// TaskListOptions defines options for listing tasks
23type TaskListOptions struct {
24 Status string
25 Priority string
26 Project string
27 Context string
28 DueAfter time.Time
29 DueBefore time.Time
30 Search string
31 SortBy string
32 SortOrder string
33 Limit int
34 Offset int
35}
36
37// ProjectSummary represents a project with its task count
38type ProjectSummary struct {
39 Name string `json:"name"`
40 TaskCount int `json:"task_count"`
41}
42
43// TagSummary represents a tag with its task count
44type TagSummary struct {
45 Name string `json:"name"`
46 TaskCount int `json:"task_count"`
47}
48
49// ContextSummary represents a context with its task count
50type ContextSummary struct {
51 Name string `json:"name"`
52 TaskCount int `json:"task_count"`
53}
54
55// TaskRepository provides database operations for tasks
56type TaskRepository struct {
57 db *sql.DB
58}
59
60// NewTaskRepository creates a new task repository
61func NewTaskRepository(db *sql.DB) *TaskRepository {
62 return &TaskRepository{db: db}
63}
64
65// scanTask scans a database row into a Task model
66func (r *TaskRepository) scanTask(s scanner) (*models.Task, error) {
67 task := &models.Task{}
68 var tags, annotations sql.NullString
69 var parentUUID sql.NullString
70 var priority, project, context sql.NullString
71
72 if err := s.Scan(
73 &task.ID, &task.UUID, &task.Description, &task.Status, &priority,
74 &project, &context, &tags,
75 &task.Due, &task.Wait, &task.Scheduled, &task.Entry, &task.Modified, &task.End, &task.Start, &annotations,
76 &task.Recur, &task.Until, &parentUUID,
77 ); err != nil {
78 return nil, err
79 }
80
81 if priority.Valid {
82 task.Priority = priority.String
83 }
84 if project.Valid {
85 task.Project = project.String
86 }
87 if context.Valid {
88 task.Context = context.String
89 }
90 if parentUUID.Valid {
91 task.ParentUUID = &parentUUID.String
92 }
93
94 if tags.Valid {
95 if err := unmarshalTaskTags(task, tags.String); err != nil {
96 return nil, fmt.Errorf("failed to unmarshal tags: %w", err)
97 }
98 }
99
100 if annotations.Valid {
101 if err := unmarshalTaskAnnotations(task, annotations.String); err != nil {
102 return nil, fmt.Errorf("failed to unmarshal annotations: %w", err)
103 }
104 }
105
106 return task, nil
107}
108
109// queryOne executes a query that returns a single task
110func (r *TaskRepository) queryOne(ctx context.Context, query string, args ...any) (*models.Task, error) {
111 row := r.db.QueryRowContext(ctx, query, args...)
112 task, err := r.scanTask(row)
113 if err != nil {
114 if err == sql.ErrNoRows {
115 return nil, fmt.Errorf("task not found")
116 }
117 return nil, fmt.Errorf("failed to scan task: %w", err)
118 }
119 return task, nil
120}
121
122// queryMany executes a query that returns multiple tasks
123func (r *TaskRepository) queryMany(ctx context.Context, query string, args ...any) ([]*models.Task, error) {
124 rows, err := r.db.QueryContext(ctx, query, args...)
125 if err != nil {
126 return nil, fmt.Errorf("failed to query tasks: %w", err)
127 }
128 defer rows.Close()
129
130 var tasks []*models.Task
131 for rows.Next() {
132 task, err := r.scanTask(rows)
133 if err != nil {
134 return nil, fmt.Errorf("failed to scan task: %w", err)
135 }
136 tasks = append(tasks, task)
137 }
138
139 if err := rows.Err(); err != nil {
140 return nil, fmt.Errorf("error iterating over tasks: %w", err)
141 }
142
143 return tasks, nil
144}
145
146// Create stores a new task and returns its assigned ID
147func (r *TaskRepository) Create(ctx context.Context, task *models.Task) (int64, error) {
148 now := time.Now()
149 task.Entry = now
150 task.Modified = now
151
152 tags, err := marshalTaskTags(task)
153 if err != nil {
154 return 0, fmt.Errorf("failed to marshal tags: %w", err)
155 }
156
157 annotations, err := marshalTaskAnnotations(task)
158 if err != nil {
159 return 0, fmt.Errorf("failed to marshal annotations: %w", err)
160 }
161
162 result, err := r.db.ExecContext(ctx, queryTaskInsert,
163 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context,
164 tags, task.Due, task.Wait, task.Scheduled, task.Entry, task.Modified, task.End, task.Start, annotations,
165 task.Recur, task.Until, task.ParentUUID,
166 )
167 if err != nil {
168 return 0, fmt.Errorf("failed to insert task: %w", err)
169 }
170
171 id, err := result.LastInsertId()
172 if err != nil {
173 return 0, fmt.Errorf("failed to get last insert id: %w", err)
174 }
175
176 task.ID = id
177
178 for _, depUUID := range task.DependsOn {
179 if err := r.AddDependency(ctx, task.UUID, depUUID); err != nil {
180 return 0, fmt.Errorf("failed to add dependency: %w", err)
181 }
182 }
183
184 return id, nil
185}
186
187// Get retrieves a task by ID
188func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error) {
189 task, err := r.queryOne(ctx, queryTaskByID, id)
190 if err != nil {
191 return nil, fmt.Errorf("failed to get task: %w", err)
192 }
193
194 if err := r.PopulateDependencies(ctx, task); err != nil {
195 return nil, fmt.Errorf("failed to populate dependencies: %w", err)
196 }
197
198 return task, nil
199}
200
201// Update modifies an existing task
202func (r *TaskRepository) Update(ctx context.Context, task *models.Task) error {
203 oldTask, err := r.Get(ctx, task.ID)
204 if err != nil {
205 return fmt.Errorf("failed to get current task state: %w", err)
206 }
207
208 if err := r.SaveHistory(ctx, oldTask, "update"); err != nil {
209 return fmt.Errorf("failed to save history: %w", err)
210 }
211
212 task.Modified = time.Now()
213
214 tags, err := marshalTaskTags(task)
215 if err != nil {
216 return fmt.Errorf("failed to marshal tags: %w", err)
217 }
218
219 annotations, err := marshalTaskAnnotations(task)
220 if err != nil {
221 return fmt.Errorf("failed to marshal annotations: %w", err)
222 }
223
224 if _, err = r.db.ExecContext(ctx, queryTaskUpdate,
225 task.UUID, task.Description, task.Status, task.Priority, task.Project, task.Context,
226 tags, task.Due, task.Wait, task.Scheduled, task.Modified, task.End, task.Start, annotations,
227 task.Recur, task.Until, task.ParentUUID,
228 task.ID,
229 ); err != nil {
230 return fmt.Errorf("failed to update task: %w", err)
231 }
232
233 if err := r.ClearDependencies(ctx, task.UUID); err != nil {
234 return fmt.Errorf("failed to clear dependencies: %w", err)
235 }
236
237 for _, depUUID := range task.DependsOn {
238 if err := r.AddDependency(ctx, task.UUID, depUUID); err != nil {
239 return fmt.Errorf("failed to add dependency: %w", err)
240 }
241 }
242
243 return nil
244}
245
246// Delete removes a task by ID
247func (r *TaskRepository) Delete(ctx context.Context, id int64) error {
248 _, err := r.db.ExecContext(ctx, queryTaskDelete, id)
249 if err != nil {
250 return fmt.Errorf("failed to delete task: %w", err)
251 }
252 return nil
253}
254
255// List retrieves tasks with optional filtering and sorting
256func (r *TaskRepository) List(ctx context.Context, opts TaskListOptions) ([]*models.Task, error) {
257 query := r.buildListQuery(opts)
258 args := r.buildListArgs(opts)
259 return r.queryMany(ctx, query, args...)
260}
261
262func (r *TaskRepository) buildListQuery(opts TaskListOptions) string {
263 query := queryTasksList
264 var conditions []string
265
266 if opts.Status != "" {
267 conditions = append(conditions, "status = ?")
268 }
269 if opts.Priority != "" {
270 conditions = append(conditions, "priority = ?")
271 }
272 if opts.Project != "" {
273 conditions = append(conditions, "project = ?")
274 }
275 if opts.Context != "" {
276 conditions = append(conditions, "context = ?")
277 }
278 if !opts.DueAfter.IsZero() {
279 conditions = append(conditions, "due >= ?")
280 }
281 if !opts.DueBefore.IsZero() {
282 conditions = append(conditions, "due <= ?")
283 }
284
285 if opts.Search != "" {
286 searchConditions := []string{
287 "description LIKE ?",
288 "project LIKE ?",
289 "context LIKE ?",
290 "tags LIKE ?",
291 }
292 conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
293 }
294
295 if len(conditions) > 0 {
296 query += " WHERE " + strings.Join(conditions, " AND ")
297 }
298
299 if opts.SortBy != "" {
300 order := "ASC"
301 if strings.ToUpper(opts.SortOrder) == "DESC" {
302 order = "DESC"
303 }
304 query += fmt.Sprintf(" ORDER BY %s %s", opts.SortBy, order)
305 } else {
306 query += " ORDER BY modified DESC"
307 }
308
309 if opts.Limit > 0 {
310 query += fmt.Sprintf(" LIMIT %d", opts.Limit)
311 if opts.Offset > 0 {
312 query += fmt.Sprintf(" OFFSET %d", opts.Offset)
313 }
314 }
315
316 return query
317}
318
319func (r *TaskRepository) buildListArgs(opts TaskListOptions) []any {
320 var args []any
321
322 if opts.Status != "" {
323 args = append(args, opts.Status)
324 }
325 if opts.Priority != "" {
326 args = append(args, opts.Priority)
327 }
328 if opts.Project != "" {
329 args = append(args, opts.Project)
330 }
331 if opts.Context != "" {
332 args = append(args, opts.Context)
333 }
334 if !opts.DueAfter.IsZero() {
335 args = append(args, opts.DueAfter)
336 }
337 if !opts.DueBefore.IsZero() {
338 args = append(args, opts.DueBefore)
339 }
340
341 if opts.Search != "" {
342 searchPattern := "%" + opts.Search + "%"
343 args = append(args, searchPattern, searchPattern, searchPattern, searchPattern)
344 }
345
346 return args
347}
348
349// Find retrieves tasks matching specific conditions
350func (r *TaskRepository) Find(ctx context.Context, conditions TaskListOptions) ([]*models.Task, error) {
351 return r.List(ctx, conditions)
352}
353
354// Count returns the number of tasks matching conditions
355func (r *TaskRepository) Count(ctx context.Context, opts TaskListOptions) (int64, error) {
356 query := "SELECT COUNT(*) FROM tasks"
357 args := []any{}
358
359 var conditions []string
360
361 if opts.Status != "" {
362 conditions = append(conditions, "status = ?")
363 args = append(args, opts.Status)
364 }
365 if opts.Priority != "" {
366 conditions = append(conditions, "priority = ?")
367 args = append(args, opts.Priority)
368 }
369 if opts.Project != "" {
370 conditions = append(conditions, "project = ?")
371 args = append(args, opts.Project)
372 }
373 if opts.Context != "" {
374 conditions = append(conditions, "context = ?")
375 args = append(args, opts.Context)
376 }
377 if !opts.DueAfter.IsZero() {
378 conditions = append(conditions, "due >= ?")
379 args = append(args, opts.DueAfter)
380 }
381 if !opts.DueBefore.IsZero() {
382 conditions = append(conditions, "due <= ?")
383 args = append(args, opts.DueBefore)
384 }
385
386 if opts.Search != "" {
387 searchConditions := []string{
388 "description LIKE ?",
389 "project LIKE ?",
390 "context LIKE ?",
391 "tags LIKE ?",
392 }
393 conditions = append(conditions, fmt.Sprintf("(%s)", strings.Join(searchConditions, " OR ")))
394 searchPattern := "%" + opts.Search + "%"
395 args = append(args, searchPattern, searchPattern, searchPattern, searchPattern)
396 }
397
398 if len(conditions) > 0 {
399 query += " WHERE " + strings.Join(conditions, " AND ")
400 }
401
402 var count int64
403 err := r.db.QueryRowContext(ctx, query, args...).Scan(&count)
404 if err != nil {
405 return 0, fmt.Errorf("failed to count tasks: %w", err)
406 }
407
408 return count, nil
409}
410
411// GetByUUID retrieves a task by UUID
412func (r *TaskRepository) GetByUUID(ctx context.Context, uuid string) (*models.Task, error) {
413 task, err := r.queryOne(ctx, queryTaskByUUID, uuid)
414 if err != nil {
415 return nil, fmt.Errorf("failed to get task by UUID: %w", err)
416 }
417
418 // Populate dependencies from task_dependencies table
419 if err := r.PopulateDependencies(ctx, task); err != nil {
420 return nil, fmt.Errorf("failed to populate dependencies: %w", err)
421 }
422
423 return task, nil
424}
425
426// GetPending retrieves all pending tasks
427func (r *TaskRepository) GetPending(ctx context.Context) ([]*models.Task, error) {
428 return r.List(ctx, TaskListOptions{Status: "pending"})
429}
430
431// GetCompleted retrieves all completed tasks
432func (r *TaskRepository) GetCompleted(ctx context.Context) ([]*models.Task, error) {
433 return r.List(ctx, TaskListOptions{Status: "completed"})
434}
435
436// GetByProject retrieves all tasks for a specific project
437func (r *TaskRepository) GetByProject(ctx context.Context, project string) ([]*models.Task, error) {
438 return r.List(ctx, TaskListOptions{Project: project})
439}
440
441// GetByContext retrieves all tasks for a specific context
442func (r *TaskRepository) GetByContext(ctx context.Context, context string) ([]*models.Task, error) {
443 return r.List(ctx, TaskListOptions{Context: context})
444}
445
446// GetProjects retrieves all unique project names with their task counts
447func (r *TaskRepository) GetProjects(ctx context.Context) ([]ProjectSummary, error) {
448 query := `
449 SELECT project, COUNT(*) as task_count
450 FROM tasks
451 WHERE project != '' AND project IS NOT NULL
452 GROUP BY project
453 ORDER BY project`
454
455 rows, err := r.db.QueryContext(ctx, query)
456 if err != nil {
457 return nil, fmt.Errorf("failed to get projects: %w", err)
458 }
459 defer rows.Close()
460
461 var projects []ProjectSummary
462 for rows.Next() {
463 var project ProjectSummary
464 if err := rows.Scan(&project.Name, &project.TaskCount); err != nil {
465 return nil, fmt.Errorf("failed to scan project row: %w", err)
466 }
467 projects = append(projects, project)
468 }
469
470 return projects, rows.Err()
471}
472
473// GetTags retrieves all unique tags with their task counts
474func (r *TaskRepository) GetTags(ctx context.Context) ([]TagSummary, error) {
475 query := `
476 SELECT DISTINCT json_each.value as tag, COUNT(tasks.id) as task_count
477 FROM tasks, json_each(tasks.tags)
478 WHERE tasks.tags != '' AND tasks.tags IS NOT NULL
479 GROUP BY tag
480 ORDER BY tag`
481
482 rows, err := r.db.QueryContext(ctx, query)
483 if err != nil {
484 return nil, fmt.Errorf("failed to get tags: %w", err)
485 }
486 defer rows.Close()
487
488 var tags []TagSummary
489 for rows.Next() {
490 var tag TagSummary
491 if err := rows.Scan(&tag.Name, &tag.TaskCount); err != nil {
492 return nil, fmt.Errorf("failed to scan tag row: %w", err)
493 }
494 tags = append(tags, tag)
495 }
496
497 return tags, rows.Err()
498}
499
500// GetContexts retrieves all unique context names with their task counts
501func (r *TaskRepository) GetContexts(ctx context.Context) ([]ContextSummary, error) {
502 query := `
503 SELECT context, COUNT(*) as task_count
504 FROM tasks
505 WHERE context != '' AND context IS NOT NULL
506 GROUP BY context
507 ORDER BY context`
508
509 rows, err := r.db.QueryContext(ctx, query)
510 if err != nil {
511 return nil, fmt.Errorf("failed to get contexts: %w", err)
512 }
513 defer rows.Close()
514
515 var contexts []ContextSummary
516 for rows.Next() {
517 var context ContextSummary
518 if err := rows.Scan(&context.Name, &context.TaskCount); err != nil {
519 return nil, fmt.Errorf("failed to scan context row: %w", err)
520 }
521 contexts = append(contexts, context)
522 }
523
524 return contexts, rows.Err()
525}
526
527// GetTasksByTag retrieves all tasks with a specific tag
528func (r *TaskRepository) GetTasksByTag(ctx context.Context, tag string) ([]*models.Task, error) {
529 query := `
530 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context,
531 t.tags, t.due, t.wait, t.scheduled, t.entry, t.modified, t.end, t.start, t.annotations,
532 t.recur, t.until, t.parent_uuid
533 FROM tasks t, json_each(t.tags)
534 WHERE t.tags != '' AND t.tags IS NOT NULL AND json_each.value = ?
535 ORDER BY t.modified DESC`
536
537 return r.queryMany(ctx, query, tag)
538}
539
540// GetTodo retrieves all tasks with todo status
541func (r *TaskRepository) GetTodo(ctx context.Context) ([]*models.Task, error) {
542 return r.List(ctx, TaskListOptions{Status: models.StatusTodo})
543}
544
545// GetInProgress retrieves all tasks with in-progress status
546func (r *TaskRepository) GetInProgress(ctx context.Context) ([]*models.Task, error) {
547 return r.List(ctx, TaskListOptions{Status: models.StatusInProgress})
548}
549
550// GetBlocked retrieves all tasks with blocked status
551func (r *TaskRepository) GetBlocked(ctx context.Context) ([]*models.Task, error) {
552 return r.List(ctx, TaskListOptions{Status: models.StatusBlocked})
553}
554
555// GetDone retrieves all tasks with done status
556func (r *TaskRepository) GetDone(ctx context.Context) ([]*models.Task, error) {
557 return r.List(ctx, TaskListOptions{Status: models.StatusDone})
558}
559
560// GetAbandoned retrieves all tasks with abandoned status
561func (r *TaskRepository) GetAbandoned(ctx context.Context) ([]*models.Task, error) {
562 return r.List(ctx, TaskListOptions{Status: models.StatusAbandoned})
563}
564
565// GetByPriority retrieves all tasks with a specific priority with special handling for empty priority by using raw SQL
566func (r *TaskRepository) GetByPriority(ctx context.Context, priority string) ([]*models.Task, error) {
567 if priority == "" {
568 query := "SELECT " + taskColumns + " FROM tasks WHERE priority = '' OR priority IS NULL ORDER BY modified DESC"
569 return r.queryMany(ctx, query)
570 }
571
572 return r.List(ctx, TaskListOptions{Priority: priority})
573}
574
575// GetHighPriority retrieves all high priority tasks
576func (r *TaskRepository) GetHighPriority(ctx context.Context) ([]*models.Task, error) {
577 return r.List(ctx, TaskListOptions{Priority: models.PriorityHigh})
578}
579
580// GetMediumPriority retrieves all medium priority tasks
581func (r *TaskRepository) GetMediumPriority(ctx context.Context) ([]*models.Task, error) {
582 return r.List(ctx, TaskListOptions{Priority: models.PriorityMedium})
583}
584
585// GetLowPriority retrieves all low priority tasks
586func (r *TaskRepository) GetLowPriority(ctx context.Context) ([]*models.Task, error) {
587 return r.List(ctx, TaskListOptions{Priority: models.PriorityLow})
588}
589
590// GetStatusSummary returns a summary of tasks by status
591func (r *TaskRepository) GetStatusSummary(ctx context.Context) (map[string]int64, error) {
592 query := `SELECT status, COUNT(*) as count FROM tasks GROUP BY status ORDER BY status`
593
594 rows, err := r.db.QueryContext(ctx, query)
595 if err != nil {
596 return nil, fmt.Errorf("failed to get status summary: %w", err)
597 }
598 defer rows.Close()
599
600 summary := make(map[string]int64)
601 for rows.Next() {
602 var status string
603 var count int64
604 if err := rows.Scan(&status, &count); err != nil {
605 return nil, fmt.Errorf("failed to scan status summary row: %w", err)
606 }
607 summary[status] = count
608 }
609 return summary, rows.Err()
610}
611
612// GetPrioritySummary returns a summary of tasks by priority
613func (r *TaskRepository) GetPrioritySummary(ctx context.Context) (map[string]int64, error) {
614 query := `
615 SELECT
616 CASE
617 WHEN priority = '' OR priority IS NULL THEN 'No Priority'
618 ELSE priority
619 END as priority_group,
620 COUNT(*) as count FROM tasks GROUP BY priority_group ORDER BY priority_group`
621
622 rows, err := r.db.QueryContext(ctx, query)
623 if err != nil {
624 return nil, fmt.Errorf("failed to get priority summary: %w", err)
625 }
626 defer rows.Close()
627
628 summary := make(map[string]int64)
629 for rows.Next() {
630 var priority string
631 var count int64
632 if err := rows.Scan(&priority, &count); err != nil {
633 return nil, fmt.Errorf("failed to scan priority summary row: %w", err)
634 }
635 summary[priority] = count
636 }
637 return summary, rows.Err()
638}
639
640// AddDependency creates a dependency relationship where taskUUID depends on dependsOnUUID.
641func (r *TaskRepository) AddDependency(ctx context.Context, taskUUID, dependsOnUUID string) error {
642 if _, err := r.db.ExecContext(ctx, `INSERT INTO task_dependencies (task_uuid, depends_on_uuid) VALUES (?, ?)`, taskUUID, dependsOnUUID); err != nil {
643 return fmt.Errorf("failed to add dependency: %w", err)
644 }
645 return nil
646}
647
648// RemoveDependency deletes a specific dependency relationship.
649func (r *TaskRepository) RemoveDependency(ctx context.Context, taskUUID, dependsOnUUID string) error {
650 if _, err := r.db.ExecContext(ctx, `DELETE FROM task_dependencies WHERE task_uuid = ? AND depends_on_uuid = ?`, taskUUID, dependsOnUUID); err != nil {
651 return fmt.Errorf("failed to remove dependency: %w", err)
652 }
653 return nil
654}
655
656// ClearDependencies removes all dependencies for a given task.
657func (r *TaskRepository) ClearDependencies(ctx context.Context, taskUUID string) error {
658 if _, err := r.db.ExecContext(ctx, `DELETE FROM task_dependencies WHERE task_uuid = ?`, taskUUID); err != nil {
659 return fmt.Errorf("failed to clear dependencies: %w", err)
660 }
661 return nil
662}
663
664// GetDependencies returns the UUIDs of tasks this task depends on.
665func (r *TaskRepository) GetDependencies(ctx context.Context, taskUUID string) ([]string, error) {
666 rows, err := r.db.QueryContext(ctx, `SELECT depends_on_uuid FROM task_dependencies WHERE task_uuid = ?`, taskUUID)
667 if err != nil {
668 return nil, fmt.Errorf("failed to get dependencies: %w", err)
669 }
670 defer rows.Close()
671
672 var deps []string
673 for rows.Next() {
674 var dep string
675 if err := rows.Scan(&dep); err != nil {
676 return nil, fmt.Errorf("failed to scan dependency: %w", err)
677 }
678 deps = append(deps, dep)
679 }
680 return deps, rows.Err()
681}
682
683// PopulateDependencies loads dependency UUIDs from task_dependencies table into task.DependsOn
684func (r *TaskRepository) PopulateDependencies(ctx context.Context, task *models.Task) error {
685 if deps, err := r.GetDependencies(ctx, task.UUID); err != nil {
686 return err
687 } else {
688 task.DependsOn = deps
689 }
690 return nil
691}
692
693// GetDependents returns tasks that are blocked by a given UUID.
694func (r *TaskRepository) GetDependents(ctx context.Context, blockingUUID string) ([]*models.Task, error) {
695 query := `
696 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context,
697 t.tags, t.due, t.wait, t.scheduled, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid
698 FROM tasks t JOIN task_dependencies d ON t.uuid = d.task_uuid WHERE d.depends_on_uuid = ?`
699
700 tasks, err := r.queryMany(ctx, query, blockingUUID)
701 if err != nil {
702 return nil, fmt.Errorf("failed to get dependents: %w", err)
703 }
704
705 for _, task := range tasks {
706 if err := r.PopulateDependencies(ctx, task); err != nil {
707 return nil, fmt.Errorf("failed to populate dependencies: %w", err)
708 }
709 }
710 return tasks, nil
711}
712
713// GetBlockedTasks finds tasks that are blocked by a given UUID.
714func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID string) ([]*models.Task, error) {
715 query := `
716 SELECT t.id, t.uuid, t.description, t.status, t.priority, t.project, t.context,
717 t.tags, t.due, t.wait, t.scheduled, t.entry, t.modified, t.end, t.start, t.annotations, t.recur, t.until, t.parent_uuid
718 FROM tasks t
719 JOIN task_dependencies d ON t.uuid = d.task_uuid
720 WHERE d.depends_on_uuid = ?`
721
722 tasks, err := r.queryMany(ctx, query, blockingUUID)
723 if err != nil {
724 return nil, err
725 }
726
727 for _, task := range tasks {
728 if err := r.PopulateDependencies(ctx, task); err != nil {
729 return nil, fmt.Errorf("failed to populate dependencies: %w", err)
730 }
731 }
732 return tasks, nil
733}
734
735// BulkUpdate applies the same updates to multiple tasks
736func (r *TaskRepository) BulkUpdate(ctx context.Context, taskIDs []int64, updates *models.Task) error {
737 if len(taskIDs) == 0 {
738 return fmt.Errorf("no task IDs provided")
739 }
740
741 for _, id := range taskIDs {
742 task, err := r.Get(ctx, id)
743 if err != nil {
744 return fmt.Errorf("failed to get task %d: %w", id, err)
745 }
746
747 if updates.Status != "" {
748 task.Status = updates.Status
749 }
750 if updates.Priority != "" {
751 task.Priority = updates.Priority
752 }
753 if updates.Project != "" {
754 task.Project = updates.Project
755 }
756 if updates.Context != "" {
757 task.Context = updates.Context
758 }
759 if len(updates.Tags) > 0 {
760 task.Tags = updates.Tags
761 }
762 if updates.Due != nil {
763 task.Due = updates.Due
764 }
765
766 if err := r.Update(ctx, task); err != nil {
767 return fmt.Errorf("failed to update task %d: %w", id, err)
768 }
769 }
770
771 return nil
772}
773
774// SaveHistory saves a snapshot of a task before an operation
775func (r *TaskRepository) SaveHistory(ctx context.Context, task *models.Task, operation string) error {
776 snapshot, err := json.Marshal(task)
777 if err != nil {
778 return fmt.Errorf("failed to marshal task snapshot: %w", err)
779 }
780
781 query := `INSERT INTO task_history (task_id, operation, snapshot) VALUES (?, ?, ?)`
782 if _, err := r.db.ExecContext(ctx, query, task.ID, operation, string(snapshot)); err != nil {
783 return fmt.Errorf("failed to save task history: %w", err)
784 }
785
786 return nil
787}
788
789// GetHistory retrieves the history of changes for a task
790func (r *TaskRepository) GetHistory(ctx context.Context, taskID int64, limit int) ([]*models.TaskHistory, error) {
791 query := `SELECT id, task_id, operation, snapshot, created_at FROM task_history WHERE task_id = ? ORDER BY created_at DESC`
792 if limit > 0 {
793 query += fmt.Sprintf(" LIMIT %d", limit)
794 }
795
796 rows, err := r.db.QueryContext(ctx, query, taskID)
797 if err != nil {
798 return nil, fmt.Errorf("failed to query task history: %w", err)
799 }
800 defer rows.Close()
801
802 var history []*models.TaskHistory
803 for rows.Next() {
804 h := &models.TaskHistory{}
805 if err := rows.Scan(&h.ID, &h.TaskID, &h.Operation, &h.Snapshot, &h.CreatedAt); err != nil {
806 return nil, fmt.Errorf("failed to scan task history: %w", err)
807 }
808 history = append(history, h)
809 }
810
811 return history, rows.Err()
812}
813
814// UndoLastChange reverts a task to its previous state
815func (r *TaskRepository) UndoLastChange(ctx context.Context, taskID int64) error {
816 history, err := r.GetHistory(ctx, taskID, 1)
817 if err != nil {
818 return fmt.Errorf("failed to get task history: %w", err)
819 }
820
821 if len(history) == 0 {
822 return fmt.Errorf("no history found for task")
823 }
824
825 lastHistory := history[0]
826 var task models.Task
827 if err := json.Unmarshal([]byte(lastHistory.Snapshot), &task); err != nil {
828 return fmt.Errorf("failed to unmarshal task snapshot: %w", err)
829 }
830
831 if err := r.Update(ctx, &task); err != nil {
832 return fmt.Errorf("failed to restore task: %w", err)
833 }
834
835 deleteQuery := `DELETE FROM task_history WHERE id = ?`
836 if _, err := r.db.ExecContext(ctx, deleteQuery, lastHistory.ID); err != nil {
837 return fmt.Errorf("failed to delete history entry: %w", err)
838 }
839
840 return nil
841}