cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 841 lines 25 kB view raw
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}