commit 4a9e1c444e1ed9cea43686acde89da80aee603f6 Author: Alexander NeonXP Kiryukhin Date: Sat Apr 27 15:17:31 2019 +0300 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..2f5bcf0 --- /dev/null +++ b/Readme.md @@ -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) \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..33554ce --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module todotxt + +go 1.12 diff --git a/todotxt.go b/todotxt.go new file mode 100644 index 0000000..6250051 --- /dev/null +++ b/todotxt.go @@ -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 +} diff --git a/todotxt_test.go b/todotxt_test.go new file mode 100644 index 0000000..9090f7b --- /dev/null +++ b/todotxt_test.go @@ -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()) + } + } +}