1package dstask 2 3// main task data structures 4 5import ( 6 "errors" 7 "fmt" 8 "io/ioutil" 9 "os" 10 "reflect" 11 "sort" 12 "strings" 13 "time" 14 15 yaml "gopkg.in/yaml.v2" 16) 17 18type SubTask struct { 19 Summary string 20 Resolved bool 21} 22 23// Task is our representation of tasks added at the command line and serialized 24// to the task database on disk. It is rendered in multiple ways by the TaskSet 25// to which it belongs. 26type Task struct { 27 // not stored in file -- rather filename and directory 28 UUID string `json:"uuid" yaml:"-"` // TODO: use actual uuid.UUID type here 29 Status string `json:"status" yaml:",omitempty"` 30 // is new or has changed. Need to write to disk. 31 WritePending bool `json:"-" yaml:"-"` 32 33 // ephemeral, used to address tasks quickly. Non-resolved only. Populated 34 // from IDCache or on-the-fly. 35 ID int `json:"id" yaml:"-"` 36 37 // Deleted, if true, marks this task for deletion 38 Deleted bool `json:"-" yaml:"-"` 39 40 // concise representation of task 41 Summary string `json:"summary"` 42 // more detail, or information to remember to complete the task 43 Notes string `json:"notes"` 44 Tags []string `json:"tags"` 45 Project string `json:"project"` 46 // see const.go for PRIORITY_ strings 47 Priority string `json:"priority"` 48 DelegatedTo string `json:"-"` 49 Subtasks []SubTask `json:"-"` 50 // uuids of tasks that this task depends on 51 // blocked status can be derived. 52 // TODO possible filter: :blocked. Also, :overdue 53 Dependencies []string `json:"-"` 54 55 Created time.Time `json:"created"` 56 Resolved time.Time `json:"resolved"` 57 Due time.Time `json:"due"` 58 59 // TaskSet uses this to indicate if a given task is excluded by a filter 60 // (context etc) 61 filtered bool `json:"-"` 62} 63 64// Equals returns whether t2 equals task. 65// for equality, we only consider "core properties", we ignore WritePending, ID, Deleted and filtered 66func (t Task) Equals(t2 Task) bool { 67 if t2.UUID != t.UUID { 68 return false 69 } 70 if t2.Status != t.Status { 71 return false 72 } 73 if t2.Summary != t.Summary { 74 return false 75 } 76 if t2.Notes != t.Notes { 77 return false 78 } 79 if !reflect.DeepEqual(t.Tags, t2.Tags) { 80 return false 81 } 82 if t2.Project != t.Project { 83 return false 84 } 85 if t2.Priority != t.Priority { 86 return false 87 } 88 if t2.DelegatedTo != t.DelegatedTo { 89 return false 90 } 91 if !reflect.DeepEqual(t.Subtasks, t2.Subtasks) { 92 return false 93 } 94 if !reflect.DeepEqual(t.Dependencies, t2.Dependencies) { 95 return false 96 } 97 if !t2.Created.Equal(t.Created) || !t2.Resolved.Equal(t.Resolved) || !t2.Due.Equal(t.Due) { 98 return false 99 } 100 return true 101} 102 103// Unmarshal a Task from disk. We explicitly pass status, because the caller 104// already knows the status, and can override the status declared in yaml. 105func unmarshalTask(path string, finfo os.FileInfo, ids IdsMap, status string) (Task, error) { 106 if len(finfo.Name()) != TASK_FILENAME_LEN { 107 return Task{}, fmt.Errorf("filename does not encode UUID %s (wrong length)", finfo.Name()) 108 } 109 110 uuid := finfo.Name()[0:36] 111 if !IsValidUUID4String(uuid) { 112 return Task{}, fmt.Errorf("filename does not encode UUID %s", finfo.Name()) 113 } 114 115 t := Task{ 116 UUID: uuid, 117 Status: status, 118 ID: ids[uuid], 119 } 120 121 data, err := ioutil.ReadFile(path) 122 if err != nil { 123 return Task{}, fmt.Errorf("failed to read %s", finfo.Name()) 124 } 125 err = yaml.Unmarshal(data, &t) 126 if err != nil { 127 return Task{}, fmt.Errorf("failed to unmarshal %s", finfo.Name()) 128 } 129 130 t.Status = status 131 return t, nil 132} 133 134func (task Task) String() string { 135 if task.ID > 0 { 136 return fmt.Sprintf("%v: %s", task.ID, task.Summary) 137 } 138 return task.Summary 139} 140 141func (task *Task) MatchesFilter(query Query) bool { 142 // IDs were specified but none match (OR logic) 143 if len(query.IDs) > 0 && !IntSliceContains(query.IDs, task.ID) { 144 return false 145 } 146 147 for _, tag := range query.Tags { 148 if !StrSliceContains(task.Tags, tag) { 149 return false 150 } 151 } 152 153 for _, tag := range query.AntiTags { 154 if StrSliceContains(task.Tags, tag) { 155 return false 156 } 157 } 158 159 if StrSliceContains(query.AntiProjects, task.Project) { 160 return false 161 } 162 163 if query.Project != "" && task.Project != query.Project { 164 return false 165 } 166 167 if query.Priority != "" && task.Priority != query.Priority { 168 return false 169 } 170 171 if query.Text != "" && !strings.Contains(strings.ToLower(task.Summary+task.Notes), strings.ToLower(query.Text)) { 172 return false 173 } 174 175 return true 176} 177 178// Normalise mutates and sorts some of a task object's fields into a consistent 179// format. This should make git diffs more useful. 180func (task *Task) Normalise() { 181 task.Project = strings.ToLower(task.Project) 182 183 // tags must be lowercase 184 for i, tag := range task.Tags { 185 task.Tags[i] = strings.ToLower(tag) 186 } 187 188 // tags must be sorted 189 sort.Strings(task.Tags) 190 191 // tags must be unique 192 task.Tags = DeduplicateStrings(task.Tags) 193 194 if task.Status == STATUS_RESOLVED { 195 // resolved task should not have ID as it's meaningless 196 task.ID = 0 197 } 198 199 if task.Priority == "" { 200 task.Priority = PRIORITY_NORMAL 201 } 202} 203 204// normalise the task before validating! 205func (task *Task) Validate() error { 206 if !IsValidUUID4String(task.UUID) { 207 return errors.New("invalid task UUID4") 208 } 209 210 if !IsValidStatus(task.Status) { 211 return errors.New("invalid status specified on task") 212 } 213 214 if !IsValidPriority(task.Priority) { 215 return errors.New("invalid priority specified") 216 } 217 218 for _, uuid := range task.Dependencies { 219 if !IsValidUUID4String(uuid) { 220 return errors.New("invalid dependency UUID4") 221 } 222 } 223 224 return nil 225} 226 227// provides Summary + Last note if available 228func (task *Task) LongSummary() string { 229 noteLines := strings.Split(task.Notes, "\n") 230 lastNote := noteLines[len(noteLines)-1] 231 232 if len(lastNote) > 0 { 233 return task.Summary + " " + NOTE_MODE_KEYWORD + " " + lastNote 234 } else { 235 return task.Summary 236 } 237} 238 239func (task *Task) Modify(query Query) { 240 for _, tag := range query.Tags { 241 if !StrSliceContains(task.Tags, tag) { 242 task.Tags = append(task.Tags, tag) 243 } 244 } 245 246 for i, tag := range task.Tags { 247 if StrSliceContains(query.AntiTags, tag) { 248 // delete item 249 task.Tags = append(task.Tags[:i], task.Tags[i+1:]...) 250 } 251 } 252 253 if query.Project != "" { 254 task.Project = query.Project 255 } 256 257 if StrSliceContains(query.AntiProjects, task.Project) { 258 task.Project = "" 259 } 260 261 if query.Priority != "" { 262 task.Priority = query.Priority 263 } 264} 265 266func (t *Task) SaveToDisk(repoPath string) { 267 // save should be idempotent 268 t.WritePending = false 269 270 filepath := MustGetRepoPath(repoPath, t.Status, t.UUID+".yml") 271 272 if t.Deleted { 273 // Task is marked deleted. Delete from its current status directory. 274 if err := os.Remove(filepath); err != nil { 275 ExitFail("Could not remove task %s: %v", filepath, err) 276 } 277 278 } else { 279 // Task is not deleted, and will be written to disk to a directory 280 // that indicates its current status. We make a shallow copy first, 281 // and we set Status to empty string. This shallow copy is serialised 282 // to disk, with the Status field omitted. This avoids redundant data. 283 taskCp := *t 284 taskCp.Status = "" 285 d, err := yaml.Marshal(&taskCp) 286 if err != nil { 287 // TODO present error to user, specific error message is important 288 ExitFail("Failed to marshal task %s", t) 289 } 290 291 err = ioutil.WriteFile(filepath, d, 0600) 292 if err != nil { 293 ExitFail("Failed to write task %s", t) 294 } 295 } 296 297 // Delete task from other status directories. Only one copy should exist, at most. 298 for _, st := range ALL_STATUSES { 299 if st == t.Status { 300 continue 301 } 302 303 filepath := MustGetRepoPath(repoPath, st, t.UUID+".yml") 304 305 if _, err := os.Stat(filepath); !os.IsNotExist(err) { 306 err := os.Remove(filepath) 307 if err != nil { 308 ExitFail("Could not remove task %s: %v", filepath, err) 309 } 310 } 311 } 312} 313