Initial
This commit is contained in:
commit
4a9e1c444e
5 changed files with 262 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.idea
|
5
Readme.md
Normal file
5
Readme.md
Normal 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
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module todotxt
|
||||||
|
|
||||||
|
go 1.12
|
167
todotxt.go
Normal file
167
todotxt.go
Normal 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
86
todotxt_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue