summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Readme.md5
-rw-r--r--go.mod3
-rw-r--r--todotxt.go167
-rw-r--r--todotxt_test.go86
5 files changed, 262 insertions, 0 deletions
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())
+ }
+ }
+}