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