/task.go

https://gitlab.com/JamesClonk/go-todotxt · Go · 272 lines · 189 code · 37 blank · 46 comment · 44 complexity · 396e33d5b669922ff29203df3d75df5b MD5 · raw file

  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. package todotxt
  5. import (
  6. "fmt"
  7. "regexp"
  8. "sort"
  9. "strings"
  10. "time"
  11. )
  12. var (
  13. // DateLayout is used for formatting time.Time into todo.txt date format and vice-versa.
  14. DateLayout = "2006-01-02"
  15. priorityRx = regexp.MustCompile(`^(x|x \d{4}-\d{2}-\d{2}|)\s*\(([A-Z])\)\s+`) // Match priority: '(A) ...' or 'x (A) ...' or 'x 2012-12-12 (A) ...'
  16. // Match created date: '(A) 2012-12-12 ...' or 'x 2012-12-12 (A) 2012-12-12 ...' or 'x (A) 2012-12-12 ...'or 'x 2012-12-12 2012-12-12 ...' or '2012-12-12 ...'
  17. createdDateRx = regexp.MustCompile(`^(\([A-Z]\)|x \d{4}-\d{2}-\d{2} \([A-Z]\)|x \([A-Z]\)|x \d{4}-\d{2}-\d{2}|)\s*(\d{4}-\d{2}-\d{2})\s+`)
  18. completedRx = regexp.MustCompile(`^x\s+`) // Match completed: 'x ...'
  19. completedDateRx = regexp.MustCompile(`^x\s*(\d{4}-\d{2}-\d{2})\s+`) // Match completed date: 'x 2012-12-12 ...'
  20. addonTagRx = regexp.MustCompile(`(^|\s+)([\w-]+):(\S+)`) // Match additional tags date: '... due:2012-12-12 ...'
  21. contextRx = regexp.MustCompile(`(^|\s+)@(\S+)`) // Match contexts: '@Context ...' or '... @Context ...'
  22. projectRx = regexp.MustCompile(`(^|\s+)\+(\S+)`) // Match projects: '+Project...' or '... +Project ...')
  23. )
  24. // Task represents a todo.txt task entry.
  25. type Task struct {
  26. Id int // Internal task id.
  27. Original string // Original raw task text.
  28. Todo string // Todo part of task text.
  29. Priority string
  30. Projects []string
  31. Contexts []string
  32. AdditionalTags map[string]string // Addon tags will be available here.
  33. CreatedDate time.Time
  34. DueDate time.Time
  35. CompletedDate time.Time
  36. Completed bool
  37. }
  38. // String returns a complete task string in todo.txt format.
  39. //
  40. // Contexts, Projects and additional tags are alphabetically sorted,
  41. // and appendend at the end in the following order:
  42. // Contexts, Projects, Tags
  43. //
  44. // For example:
  45. // "(A) 2013-07-23 Call Dad @Home @Phone +Family due:2013-07-31 customTag1:Important!"
  46. func (task Task) String() string {
  47. var text string
  48. if task.Completed {
  49. text += "x "
  50. if task.HasCompletedDate() {
  51. text += fmt.Sprintf("%s ", task.CompletedDate.Format(DateLayout))
  52. }
  53. }
  54. if task.HasPriority() {
  55. text += fmt.Sprintf("(%s) ", task.Priority)
  56. }
  57. if task.HasCreatedDate() {
  58. text += fmt.Sprintf("%s ", task.CreatedDate.Format(DateLayout))
  59. }
  60. text += task.Todo
  61. if len(task.Contexts) > 0 {
  62. sort.Strings(task.Contexts)
  63. for _, context := range task.Contexts {
  64. text += fmt.Sprintf(" @%s", context)
  65. }
  66. }
  67. if len(task.Projects) > 0 {
  68. sort.Strings(task.Projects)
  69. for _, project := range task.Projects {
  70. text += fmt.Sprintf(" +%s", project)
  71. }
  72. }
  73. if len(task.AdditionalTags) > 0 {
  74. // Sort map alphabetically by keys
  75. keys := make([]string, 0, len(task.AdditionalTags))
  76. for key := range task.AdditionalTags {
  77. keys = append(keys, key)
  78. }
  79. sort.Strings(keys)
  80. for _, key := range keys {
  81. text += fmt.Sprintf(" %s:%s", key, task.AdditionalTags[key])
  82. }
  83. }
  84. if task.HasDueDate() {
  85. text += fmt.Sprintf(" due:%s", task.DueDate.Format(DateLayout))
  86. }
  87. return text
  88. }
  89. // NewTask creates a new empty Task with default values. (CreatedDate is set to Now())
  90. func NewTask() Task {
  91. task := Task{}
  92. task.CreatedDate = time.Now()
  93. return task
  94. }
  95. // ParseTask parses the input text string into a Task struct.
  96. func ParseTask(text string) (*Task, error) {
  97. var err error
  98. task := Task{}
  99. task.Original = strings.Trim(text, "\t\n\r ")
  100. task.Todo = task.Original
  101. // Check for completed
  102. if completedRx.MatchString(task.Original) {
  103. task.Completed = true
  104. // Check for completed date
  105. if completedDateRx.MatchString(task.Original) {
  106. if date, err := time.Parse(DateLayout, completedDateRx.FindStringSubmatch(task.Original)[1]); err == nil {
  107. task.CompletedDate = date
  108. } else {
  109. return nil, err
  110. }
  111. }
  112. // Remove from Todo text
  113. task.Todo = completedDateRx.ReplaceAllString(task.Todo, "") // Strip CompletedDate first, otherwise it wouldn't match anymore (^x date...)
  114. task.Todo = completedRx.ReplaceAllString(task.Todo, "") // Strip 'x '
  115. }
  116. // Check for priority
  117. if priorityRx.MatchString(task.Original) {
  118. task.Priority = priorityRx.FindStringSubmatch(task.Original)[2]
  119. task.Todo = priorityRx.ReplaceAllString(task.Todo, "") // Remove from Todo text
  120. }
  121. // Check for created date
  122. if createdDateRx.MatchString(task.Original) {
  123. if date, err := time.Parse(DateLayout, createdDateRx.FindStringSubmatch(task.Original)[2]); err == nil {
  124. task.CreatedDate = date
  125. task.Todo = createdDateRx.ReplaceAllString(task.Todo, "") // Remove from Todo text
  126. } else {
  127. return nil, err
  128. }
  129. }
  130. // function for collecting projects/contexts as slices from text
  131. getSlice := func(rx *regexp.Regexp) []string {
  132. matches := rx.FindAllStringSubmatch(task.Original, -1)
  133. slice := make([]string, 0, len(matches))
  134. seen := make(map[string]bool, len(matches))
  135. for _, match := range matches {
  136. word := strings.Trim(match[2], "\t\n\r ")
  137. if _, found := seen[word]; !found {
  138. slice = append(slice, word)
  139. seen[word] = true
  140. }
  141. }
  142. sort.Strings(slice)
  143. return slice
  144. }
  145. // Check for contexts
  146. if contextRx.MatchString(task.Original) {
  147. task.Contexts = getSlice(contextRx)
  148. task.Todo = contextRx.ReplaceAllString(task.Todo, "") // Remove from Todo text
  149. }
  150. // Check for projects
  151. if projectRx.MatchString(task.Original) {
  152. task.Projects = getSlice(projectRx)
  153. task.Todo = projectRx.ReplaceAllString(task.Todo, "") // Remove from Todo text
  154. }
  155. // Check for additional tags
  156. if addonTagRx.MatchString(task.Original) {
  157. matches := addonTagRx.FindAllStringSubmatch(task.Original, -1)
  158. tags := make(map[string]string, len(matches))
  159. for _, match := range matches {
  160. key, value := match[2], match[3]
  161. if key == "due" { // due date is a known addon tag, it has its own struct field
  162. if date, err := time.Parse(DateLayout, value); err == nil {
  163. task.DueDate = date
  164. } else {
  165. return nil, err
  166. }
  167. } else if key != "" && value != "" {
  168. tags[key] = value
  169. }
  170. }
  171. task.AdditionalTags = tags
  172. task.Todo = addonTagRx.ReplaceAllString(task.Todo, "") // Remove from Todo text
  173. }
  174. // Trim any remaining whitespaces from Todo text
  175. task.Todo = strings.Trim(task.Todo, "\t\n\r\f ")
  176. return &task, err
  177. }
  178. // Task returns a complete task string in todo.txt format.
  179. // See *Task.String() for further information.
  180. func (task *Task) Task() string {
  181. return task.String()
  182. }
  183. // HasPriority returns true if the task has a priority.
  184. func (task *Task) HasPriority() bool {
  185. return task.Priority != ""
  186. }
  187. // HasCreatedDate returns true if the task has a created date.
  188. func (task *Task) HasCreatedDate() bool {
  189. return !task.CreatedDate.IsZero()
  190. }
  191. // HasDueDate returns true if the task has a due date.
  192. func (task *Task) HasDueDate() bool {
  193. return !task.DueDate.IsZero()
  194. }
  195. // HasCompletedDate returns true if the task has a completed date.
  196. func (task *Task) HasCompletedDate() bool {
  197. return !task.CompletedDate.IsZero() && task.Completed
  198. }
  199. // Complete sets Task.Completed to 'true' if the task was not already completed.
  200. // Also sets Task.CompletedDate to time.Now()
  201. func (task *Task) Complete() {
  202. if !task.Completed {
  203. task.Completed = true
  204. task.CompletedDate = time.Now()
  205. }
  206. }
  207. // Reopen sets Task.Completed to 'false' if the task was completed.
  208. // Also resets Task.CompletedDate.
  209. func (task *Task) Reopen() {
  210. if task.Completed {
  211. task.Completed = false
  212. task.CompletedDate = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC) // time.IsZero() value
  213. }
  214. }
  215. // IsOverdue returns true if due date is in the past.
  216. //
  217. // This function does not take the Completed flag into consideration.
  218. // You should check Task.Completed first if needed.
  219. func (task *Task) IsOverdue() bool {
  220. if task.HasDueDate() {
  221. return task.DueDate.Before(time.Now())
  222. }
  223. return false
  224. }
  225. // Due returns the duration passed since due date, or until due date from now.
  226. // Check with IsOverdue() if the task is overdue or not.
  227. //
  228. // Just as with IsOverdue(), this function does also not take the Completed flag into consideration.
  229. // You should check Task.Completed first if needed.
  230. func (task *Task) Due() time.Duration {
  231. if task.IsOverdue() {
  232. return time.Now().Sub(task.DueDate)
  233. }
  234. return task.DueDate.Sub(time.Now())
  235. }