This commit is contained in:
Alexander NeonXP Kiryukhin 2019-04-27 15:17:31 +03:00
commit 4a9e1c444e
5 changed files with 262 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.idea

5
Readme.md Normal file
View file

@ -0,0 +1,5 @@
# todo.txt Go parser
Simple parser for [todo.txt](http://todotxt.org/) file format.
[Documentation](https://godoc.org/github.com/neonxp/go-todo.txt)

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module todotxt
go 1.12

167
todotxt.go Normal file
View file

@ -0,0 +1,167 @@
package todotxt
import (
"errors"
"fmt"
"strings"
"time"
)
// Item represents todotxt task
type Item struct {
Complete bool
Priority *Priority
CompletionDate *time.Time
CreationDate *time.Time
Description string
Tags []Tag
}
// String returns text representation of Item
func (i *Item) String() string {
result := ""
if i.Complete {
result = "x "
}
if i.Priority != nil {
result += "(" + i.Priority.String() + ") "
}
if i.CompletionDate != nil {
result += i.CompletionDate.Format("2006-01-02") + " "
if i.CreationDate != nil {
result += i.CreationDate.Format("2006-01-02") + " "
} else {
result += time.Now().Format("2006-01-02") + " "
}
} else if i.CreationDate != nil {
result += i.CreationDate.Format("2006-01-02") + " "
}
result += i.Description + " "
for _, t := range i.Tags {
switch t.Key {
case TagContext:
result += "@" + t.Value + " "
case TagProject:
result += "+" + t.Value + " "
default:
result += t.Key + ":" + t.Value + " "
}
}
return strings.Trim(result, " \n")
}
// Parse multiline todotxt string
func Parse(todo string) ([]Item, error) {
lines := strings.Split(todo, "\n")
items := make([]Item, 0, len(lines))
for ln, line := range lines {
i, err := ParseLine(line)
if err != nil {
return nil, fmt.Errorf("error at line %d: %v", ln, err)
}
items = append(items, i)
}
return items, nil
}
// ParseLine parses single todotxt line
func ParseLine(line string) (Item, error) {
i := Item{}
tokens := strings.Split(line, " ")
state := 0
for _, t := range tokens {
if state == 0 && t == "x" {
state = 1
i.Complete = true
continue
}
if state <= 1 && len(t) == 3 && t[0] == '(' && t[2] == ')' {
p, err := PriorityFromLetter(string(t[1]))
if err != nil {
return i, err
}
i.Priority = &p
state = 2
continue
}
if state <= 2 {
ti, err := time.Parse("2006-01-02", t)
if err == nil {
i.CreationDate = &ti
state = 3
continue
}
state = 4
}
if state <= 3 {
ti, err := time.Parse("2006-01-02", t)
if err == nil {
i.CompletionDate = i.CreationDate
i.CreationDate = &ti
state = 4
continue
}
state = 4
}
if t[0] == '+' {
i.Tags = append(i.Tags, Tag{
Key: TagProject,
Value: string(t[1:]),
})
continue
}
if t[0] == '@' {
i.Tags = append(i.Tags, Tag{
Key: TagContext,
Value: string(t[1:]),
})
continue
}
kv := strings.Split(t, ":")
if len(kv) == 2 {
i.Tags = append(i.Tags, Tag{
Key: kv[0],
Value: kv[1],
})
continue
}
if i.Description == "" {
i.Description = t
} else {
i.Description += " " + t
}
}
return i, nil
}
type Priority int
// String returns letter by priority (0=A, 1=B, 2=C ...)
func (p Priority) String() string {
return string([]byte{byte(p + 65)})
}
// PriorityFromLetter returns numeric priority from letter priority (A=0, B=1, C=2 ...)
func PriorityFromLetter(letter string) (Priority, error) {
if len(letter) != 1 {
return 0, errors.New("incorrect priority length")
}
code := []byte(letter)[0]
if code < 65 || code > 90 {
return 0, errors.New("priority must be between A and Z")
}
return Priority(code - 65), nil
}
// TagContext constant is key for context tag
const TagContext = "@context"
// TagProject constant is key for project tag
const TagProject = "+project"
// Tag represents builtin and custom tags
type Tag struct {
Key string
Value string
}

86
todotxt_test.go Normal file
View file

@ -0,0 +1,86 @@
package todotxt
import (
"strings"
"testing"
"time"
)
func TestPriorityFromLetter(t *testing.T) {
tests := []struct {
letter string
expectedError bool
expectedVal int
}{
{"A", false, 0},
{"B", false, 1},
{"Z", false, 25},
{"a", true, 0},
{"AA", true, 0},
{"0", true, 0},
{"!", true, 0},
}
for i, test := range tests {
p, err := PriorityFromLetter(test.letter)
if test.expectedError && err == nil {
t.Errorf("expected error on test %d", i+1)
}
if !test.expectedError && err != nil {
t.Errorf("unexpected error on test %d: %v", i+1, err)
}
if p != Priority(test.expectedVal) {
t.Errorf("expected %d, got %d on test %d", test.expectedVal, p, i+1)
}
}
}
func TestPriorityToString(t *testing.T) {
a, _ := PriorityFromLetter("A")
z, _ := PriorityFromLetter("Z")
if a.String() != "A" {
t.Errorf("Expected A, got %s", a.String())
}
if z.String() != "Z" {
t.Errorf("Expected Z, got %s", z.String())
}
}
func TestItemToString(t *testing.T) {
cd, _ := time.Parse("2006-01-02", "2019-04-27")
tests := map[string]struct {
item Item
expected string
}{
"simple": {item: Item{Description: "simple"}, expected: "simple"},
"complete": {item: Item{Complete: true, Description: "complete"}, expected: "x complete"},
"completeWithDate": {item: Item{Complete: true, CreationDate: &cd, Description: "complete"}, expected: "x 2019-04-27 complete"},
"completeWithTags": {item: Item{Complete: true, Description: "complete", Tags: []Tag{{Key: TagProject, Value: "proj"}, {Key: TagContext, Value: "test"}, {Key: "custom", Value: "tag"}}}, expected: "x complete +proj @test custom:tag"},
}
for test, v := range tests {
t.Run(test, func(t *testing.T) {
if v.item.String() != v.expected {
t.Errorf("Expected %s got %s", v.expected, v.item.String())
}
})
}
}
func TestParse(t *testing.T) {
input := `(A) Call Mom @Phone +Family
(A) Schedule annual checkup +Health
(B) Outline chapter 5 +Novel @Computer
(C) Add cover sheets @Office +TPSReports
2019-04-27 Plan backyard herb garden @Home
2019-05-27 2019-04-27 Pick up milk @GroceryStore
Research self-publishing services +Novel @Computer
x Download Todo.txt mobile app @Phone custom:tag`
items, err := Parse(input)
if err != nil {
t.Error(err)
}
for ln, v := range items {
if strings.Split(input, "\n")[ln] != v.String() {
t.Logf("line %d expected %s got %s", ln, strings.Split(input, "\n")[ln], v.String())
}
}
}