Processing of todo.txt tasks
1 Tasks
1.1 Creating tasks
task
make-task
string->task
string->tasks
1.2 Converting tasks to strings
task->string
tasks->string
1.3 Sorting tasks
task-sort-spec
sort-tasks
2 Task groups
2.1 Creating task-groups
task-group
2.2 Grouping tasks
task-group-spec
group-tasks
2.3 Converting a group-tasks result to a string
group-tasks-result->string
3 Questions and answers
8.12

Processing of todo.txt tasks🔗ℹ

Stefan Schwarzer

This collection provides APIs to process tasks from todo.txt-formatted files:
  • Convert strings to task values

  • Convert task values to strings

  • Group and sort task values, also hierarchically

  • Convert group and sort results to strings

For the command-line interface, todoreport, see the README.md file.

 (require file/todo-txt) package: todo-txt

1 Tasks🔗ℹ

1.1 Creating tasks🔗ℹ

struct

(struct task (completed?
    priority
    completion-date
    creation-date
    description
    projects
    contexts
    tags)
    #:transparent)
  completed? : boolean?
  priority : string-field/c
  completion-date : string-field/c
  creation-date : string-field/c
  description : string?
  projects : (listof string?)
  contexts : (listof string?)
  tags : (listof (cons/c string? string?))
A todo.txt task, where string-field/c is (or/c string? #f).

However, usually it will be easier to use the following helper function make-task.

procedure

(make-task [#:completed? completed?    
  #:priority priority    
  #:completion-date completion-date    
  #:creation-date creation-date    
  #:description description    
  #:projects projects    
  #:contexts contexts    
  #:tags tags])  task?
  completed? : boolean? = #f
  priority : string-field/c = #f
  completion-date : string-field/c = #f
  creation-date : string-field/c = #f
  description : string? = ""
  projects : (listof string?) = '()
  contexts : (listof string?) = '()
  tags : (listof (cons/c string? string?)) = '()
Converts zero or more keyword arguments to a task.

Note that make-task prevents the creation of a task with a completion date, but no creation date. This is because it’s not possible to express such a task in the todo.txt string form, since a single date is supposed to be creation date, not a completion date. If it turns out that there are applications where you need to create the task with a completion date only and add the creation date later, before converting the task to a string, the restriction in make-task may be removed.

Tasks can also be parsed from strings:

procedure

(string->task line)  task?

  line : string?
Converts a single-line string to a task. Trailing whitespace is removed from the description.

Example:
> (string->task "x 2021-04-02 Task @home +todo.txt due:2021-04-03 ")

(task

 #t

 #f

 #f

 "2021-04-02"

 "Task @home +todo.txt due:2021-04-03"

 '("todo.txt")

 '("home")

 '(("due" . "2021-04-03")))

Note that a single date is parsed as a creation date.

procedure

(string->tasks str)  (listof task?)

  str : string?
Converts a multi-line string to a list of tasks. This function ignores lines that are empty or contain only whitespace.

Example:
> (string->tasks "x A completed task @home\n \nAn incomplete task @work")

(list

 (task #t #f #f #f "A completed task @home" '() '("home") '())

 (task #f #f #f #f "An incomplete task @work" '() '("work") '()))

1.2 Converting tasks to strings🔗ℹ

procedure

(task->string task    
  [#:omit-fields omit-fields])  string?
  task : task?
  omit-fields : (listof symbol?) = '()
Converts a single task to a single-line string. The allowed symbols for omit-fields are 'completed?, 'priority, 'completion-date and 'creation-date.

There’s a similar restriction as in make-task: If you include 'creation-date in the omit-fields list, you must also include 'completion-date, because you can’t get a valid string representation for the task if you have a single date which is a completion date. Note that this restriction is also applied if the concrete task doesn’t have a completion date. The rationale is that if you call task->string on different tasks with the same omit-fields argument, all of the conversions should succeed or all the conversions should fail.

Examples:
> (define task (make-task #:completed? #t
                          #:creation-date "2021-04-02"
                          #:description "Uninteresting task"))
> (task->string task)

"x 2021-04-02 Uninteresting task"

> (task->string task
                #:omit-fields '(creation-date completion-date))

"x Uninteresting task"

procedure

(tasks->string tasks    
  [#:omit-fields omit-fields])  string?
  tasks : (listof task?)
  omit-fields : (listof symbol?) = '()
Converts a list of tasks to a (possibly multi-line) string.

1.3 Sorting tasks🔗ℹ

To sort a list of tasks, you must provide a list of task-sort-specs:

struct

(struct task-sort-spec (order field)
    #:transparent)
  order : (or/c 'asc 'desc)
  field : symbol?
The argument order is one of the symbols 'asc or 'desc to sort in ascending or descending order, respectively.

The field argument is one of the symbols 'completed?, 'priority etc., which correspond to the task fields. Since tasks can have more than one context, project or tag, respectively, it doesn’t make sense to sort by context, for example. Therefore 'contexts etc. aren’t valid field values.

For a 'completed? field, ascending order means sorting incomplete tasks before completed tasks.

procedure

(sort-tasks tasks sort-specs)  (listof task?)

  tasks : (listof task?)
  sort-specs : (listof task-sort-spec?)
Sort tasks according to sort-specs and return a new list containing the sorted tasks.

Tasks that don’t contain the field to sort by are moved to the end of the sorted list – regardless of ascending or descending order –, keeping their original relative order. Note that tasks always have a 'completed? field, which is #f for incomplete tasks.

If the sort-specs list contains more than one task-sort-spec, the tasks are sorted according to the first task-sort-spec first, then sets of tasks that sorted equally, are sorted by the second task-sort-spec, and so on.

The sort is stable, so if tasks are sorted by a given field and have the same field value, the tasks remain in their previous relative order.

If sort-specs is an empty list, the original list of tasks is returned unchanged.

Here are a few examples.

First let's define a few tasks to sort:
> (define my-tasks
    (string->tasks
      (string-join
        '("x 2021-04-02 Early complete task"
          "2021-04-03 Late incomplete task"
          "2021-04-02 Early incomplete task"
          "x 2021-04-03 Late complete task")
        "\n")))
> my-tasks

(list

 (task #t #f #f "2021-04-02" "Early complete task" '() '() '())

 (task #f #f #f "2021-04-03" "Late incomplete task" '() '() '())

 (task #f #f #f "2021-04-02" "Early incomplete task" '() '() '())

 (task #t #f #f "2021-04-03" "Late complete task" '() '() '()))

Sort the tasks by a single sort spec:
> (displayln
    (tasks->string
      (sort-tasks my-tasks (list (task-sort-spec 'asc 'completed?)))))

2021-04-03 Late incomplete task

2021-04-02 Early incomplete task

x 2021-04-02 Early complete task

x 2021-04-03 Late complete task

Use descending order to sort completed tasks first:
> (displayln
    (tasks->string
      (sort-tasks my-tasks (list (task-sort-spec 'desc 'completed?)))))

x 2021-04-02 Early complete task

x 2021-04-03 Late complete task

2021-04-03 Late incomplete task

2021-04-02 Early incomplete task

Sort by 'completed? , then 'creation-date :
> (displayln
    (tasks->string
      (sort-tasks my-tasks (list (task-sort-spec 'asc 'completed?)
                                 (task-sort-spec 'asc 'creation-date)))))

2021-04-02 Early incomplete task

2021-04-03 Late incomplete task

x 2021-04-02 Early complete task

x 2021-04-03 Late complete task

Sort by 'creation-date , then 'completed? :
> (displayln
    (tasks->string
      (sort-tasks my-tasks (list (task-sort-spec 'asc 'creation-date)
                                 (task-sort-spec 'asc 'completed?)))))

2021-04-02 Early incomplete task

x 2021-04-02 Early complete task

2021-04-03 Late incomplete task

x 2021-04-03 Late complete task

Tasks without the field are always sorted last, regardless of ascending or descending sort order:

> (define my-tasks
    (string->tasks
      (string-join
        '("2021-04-02 Early task"
          "Task without creation date"
          "2021-04-03 Late task")
        "\n")))
> my-tasks

(list

 (task #f #f #f "2021-04-02" "Early task" '() '() '())

 (task #f #f #f #f "Task without creation date" '() '() '())

 (task #f #f #f "2021-04-03" "Late task" '() '() '()))

> (displayln
    (tasks->string
      (sort-tasks my-tasks (list (task-sort-spec 'asc 'creation-date)))))

2021-04-02 Early task

2021-04-03 Late task

Task without creation date

> (displayln
    (tasks->string
      (sort-tasks my-tasks (list (task-sort-spec 'desc 'creation-date)))))

2021-04-03 Late task

2021-04-02 Early task

Task without creation date

2 Task groups🔗ℹ

Tasks can’t only be sorted, but also be grouped. Both operations are similar, but not the same:

2.1 Creating task-groups🔗ℹ

struct

(struct task-group (title contents)
    #:transparent)
  title : string?
  contents : (or/c (listof task-group?) (listof task?))
A group of tasks, possibly nested in other task-groups. You shouldn’t create task-group values yourself; they’re created by group-tasks.

2.2 Grouping tasks🔗ℹ

In order to group tasks, we need task-group-spec structs:

struct

(struct task-group-spec task-sort-spec (tag-key)
    #:transparent)
  tag-key : (or/c string? #f)
A struct to specify how a list of tasks should be grouped.

A task-group-spec is similar to a task-sort-spec, but with the following differences:
  • The task-sort-spec field field can also contain 'contexts, 'projects and 'tags, which don’t make sense for sorting, only for grouping.

  • To group by tag values, specify the tag-key field as a string for the tag. For example, to group tasks by due date, use (task-group-spec 'asc 'tags "due"). When not grouping by a tag, set tag-key to #f.

With task-group-spec explained, we can group tasks:

procedure

(group-tasks tasks [group-specs sort-specs])

  (or/c (listof task-group?) (listof task?))
  tasks : (listof task?)
  group-specs : (listof task-group-spec?) = '()
  sort-specs : (listof task-sort-spec?) = '()
Groups tasks according to task-group-specs.

If sort-specs isn’t an empty list, all task lists inside the groups are sorted according to sort-specs. This argument behaves the same as for sort-tasks.

If there are tasks that don’t have a "natural" group (example: you group by 'contexts and a task doesn’t have any contexts), group-tasks appends a task-group with an appropriate name as the last group, say, "No contexts". This special group always comes at the end, regardless of whether the group order is specified as 'asc or 'desc.

Here's an example with ascending order:
> (define my-tasks
    (string->tasks
      (string-join
        '("Task @home"
          "Task without context"
          "Task @work")
        "\n")))
> my-tasks

(list

 (task #f #f #f #f "Task @home" '() '("home") '())

 (task #f #f #f #f "Task without context" '() '() '())

 (task #f #f #f #f "Task @work" '() '("work") '()))

> (group-tasks my-tasks (list (task-group-spec 'asc 'contexts #f)))

(list

 (task-group

  "@home"

  (list (task #f #f #f #f "Task @home" '() '("home") '())))

 (task-group

  "@work"

  (list (task #f #f #f #f "Task @work" '() '("work") '())))

 (task-group

  "No context"

  (list (task #f #f #f #f "Task without context" '() '() '()))))

Note that the group with the title "@home" is sorted alphabetically before the group with the title "@work", whereas the title "No contexts" appears last.

Here's the same example, but with descending order:
> (group-tasks my-tasks (list (task-group-spec 'desc 'contexts #f)))

(list

 (task-group

  "@work"

  (list (task #f #f #f #f "Task @work" '() '("work") '())))

 (task-group

  "@home"

  (list (task #f #f #f #f "Task @home" '() '("home") '())))

 (task-group

  "No context"

  (list (task #f #f #f #f "Task without context" '() '() '()))))

Note that the order of the groups titled "@home" and "@work" is swapped now, but the group titled "No contexts" still appears last.

Note that 'completed? in a task-group-spec seems to behave differently. You can control whether non-completed tasks come before completed tasks or the other way around by specifying 'asc or 'desc group order, respectively. This is because every task has a completed? field, which has the value #f for non-completed tasks. Therefore, the group title "Not completed" is used for tasks with a completed? value of #f instead of indicating a “missing” completion status.

See the difference in this example:
> (define my-tasks
    (string->tasks
      (string-join
        '("x Complete task"
          "Incomplete task")
        "\n")))
> my-tasks

(list

 (task #t #f #f #f "Complete task" '() '() '())

 (task #f #f #f #f "Incomplete task" '() '() '()))

> (group-tasks my-tasks (list (task-group-spec 'asc 'completed? #f)))

(list

 (task-group

  "Not completed"

  (list (task #f #f #f #f "Incomplete task" '() '() '())))

 (task-group

  "Completed"

  (list (task #t #f #f #f "Complete task" '() '() '()))))

> (group-tasks my-tasks (list (task-group-spec 'desc 'completed? #f)))

(list

 (task-group

  "Completed"

  (list (task #t #f #f #f "Complete task" '() '() '())))

 (task-group

  "Not completed"

  (list (task #f #f #f #f "Incomplete task" '() '() '()))))

Note how the order of the groups changes.

Nested grouping is supported. Each additional task-group-spec in the group-specs argument causes an additional level of task-groups.

For example, we can group by completion status and for each completion status group by priority:
> (define my-tasks
    (string->tasks
      (string-join
        '("x (B) Complete task with priority B"
          "(A) Incomplete task with priority A"
          "(C) Incomplete task with priority C"
          "(C) Other incomplete task with priority C"
          "x (A) Complete task with priority A")
        "\n")))
> my-tasks

(list

 (task #t "B" #f #f "Complete task with priority B" '() '() '())

 (task #f "A" #f #f "Incomplete task with priority A" '() '() '())

 (task #f "C" #f #f "Incomplete task with priority C" '() '() '())

 (task #f "C" #f #f "Other incomplete task with priority C" '() '() '())

 (task #t "A" #f #f "Complete task with priority A" '() '() '()))

> (group-tasks my-tasks
               (list (task-group-spec 'asc 'completed? #f)
                     (task-group-spec 'asc 'priority #f)))

(list

 (task-group

  "Not completed"

  (list

   (task-group

    "A"

    (list (task #f "A" #f #f "Incomplete task with priority A" '() '() '())))

   (task-group

    "C"

    (list

     (task #f "C" #f #f "Incomplete task with priority C" '() '() '())

     (task #f "C" #f #f "Other incomplete task with priority C" '() '() '())))))

 (task-group

  "Completed"

  (list

   (task-group

    "A"

    (list (task #t "A" #f #f "Complete task with priority A" '() '() '())))

   (task-group

    "B"

    (list (task #t "B" #f #f "Complete task with priority B" '() '() '()))))))

Similarly, it’s possible to specify an empty list for the group-specs argument. In this case, the result doesn’t contain any task-groups, that is, the behavior is the same as for sort-tasks.

2.3 Converting a group-tasks result to a string🔗ℹ

procedure

(group-tasks-result->string group-tasks-result 
  [#:omit-fields omit-fields]) 
  string?
  group-tasks-result : (or/c (listof task-group?) (listof task?))
  omit-fields : (listof symbol?) = '()
Converts the result of a group-tasks result to a string. For the description of the omit-fields argument, refer to task->string.

This example shows string representations of the previous nested grouping result:
> (define my-tasks
    (string->tasks
      (string-join
        '("x (B) Complete task with priority B"
          "(A) Incomplete task with priority A"
          "(C) Incomplete task with priority C"
          "(C) Other incomplete task with priority C"
          "x (A) Complete task with priority A")
        "\n")))
> my-tasks

(list

 (task #t "B" #f #f "Complete task with priority B" '() '() '())

 (task #f "A" #f #f "Incomplete task with priority A" '() '() '())

 (task #f "C" #f #f "Incomplete task with priority C" '() '() '())

 (task #f "C" #f #f "Other incomplete task with priority C" '() '() '())

 (task #t "A" #f #f "Complete task with priority A" '() '() '()))

> (define my-grouping-result
    (group-tasks my-tasks
                 (list (task-group-spec 'asc 'completed? #f)
                       (task-group-spec 'asc 'priority #f))))
> (displayln (group-tasks-result->string my-grouping-result))

Not completed

  A

    (A) Incomplete task with priority A

  C

    (C) Incomplete task with priority C

    (C) Other incomplete task with priority C

Completed

  A

    x (A) Complete task with priority A

  B

    x (B) Complete task with priority B

> (displayln (group-tasks-result->string
               my-grouping-result
               #:omit-fields '(completed? priority)))

Not completed

  A

    Incomplete task with priority A

  C

    Incomplete task with priority C

    Other incomplete task with priority C

Completed

  A

    Complete task with priority A

  B

    Complete task with priority B

3 Questions and answers🔗ℹ

How can I change the order of completed and non-completed tasks?

To sort non-completed tasks before completed tasks, use a task-sort-spec with order 'asc. To sort completed tasks before non-completed tasks, use the order 'desc. The same applies to the group-specs argument when calling group-tasks.

How can I group by tag values?

In the group-specs, use a task-group-spec with the third field specifying the name of the tag. For example, use (task-group-spec 'asc 'tags "due") to group by ascending due date.

How can I sort tasks inside groups?

By default, the relative order of tasks in each group is the same as in the original task list passed to group-tasks. To influence the order of tasks inside groups, pass a sort-specs argument as the third argument to group-tasks.

For example, to group by priority and sort the tasks in each group by their descriptions, use:
(group-tasks my-tasks
             (list (task-group-spec 'asc 'priority #f))
             (list (task-sort-spec 'asc 'description)))

Is the result of group-tasks always a list of task-groups?

Usually yes, but not always. If the group-specs argument for group-tasks is an empty list, there’s nothing to group by and the result is a list of tasks, not a list of task-groups.

Is it an error to pass an empty task list to sort-tasks or group-tasks?

Although passing an empty task list may come from a bug in your code, from the point of view of sort-tasks and group-tasks, it’s not an error to pass an empty task list. For an empty list, the result of either function will be an empty list.

Background: Sorting a list of tasks gives a list with the same tasks, but sorted. So sorting an empty task list returns an empty task list. Similarly, when grouping a list of tasks, groups without tasks are omitted. Therefore, grouping an empty task list results in only empty groups, and since they’re omitted, the overall result is an empty list.

Is it an error to pass an empty task list to tasks->string or group-tasks-result->string?

Similarly to the previous question, it’s not an error to pass an empty tasks list. If you do, the result will be an empty string.

Background: Converting a list of tasks to a string gives a string with as many lines as tasks, with one line per task. So a list with zero tasks is converted to a string with zero lines, i.e. an empty string. The same logic applies to grouping, considering that empty groups are removed from the output (see the previous question).