From c1f16b56a510279ee68e3a10bce0b340c7bb7c2f Mon Sep 17 00:00:00 2001 From: JamesClonk Date: Mon, 13 Jan 2014 15:37:21 +0100 Subject: [PATCH] Work in progress Started working on new functionality, like internal task id and new useful methods on tasklists. --- task.go | 119 ++++++++++++++++++++++++++++++++ task_test.go | 178 ++++++++++++++++++++++++++++++++++++++++++++++++ todotxt.go | 125 +++++++--------------------------- todotxt_test.go | 129 +++++++++++++++++++++++++++++++---- 4 files changed, 435 insertions(+), 116 deletions(-) diff --git a/task.go b/task.go index 0a4d7e8..6d25723 100644 --- a/task.go +++ b/task.go @@ -6,12 +6,29 @@ package todotxt import ( "fmt" + "regexp" "sort" + "strings" "time" ) +var ( + // DateLayout is used for formatting time.Time into todo.txt date format and vice-versa. + DateLayout = "2006-01-02" + + priorityRx = regexp.MustCompile(`^(x|x \d{4}-\d{2}-\d{2}|)\s*\(([A-Z])\)\s+`) // Match priority: '(A) ...' or 'x (A) ...' or 'x 2012-12-12 (A) ...' + // Match created date: '(A) 2012-12-12 ...' or 'x 2012-12-12 (A) 2012-12-12 ...' or 'x (A) 2012-12-12 ...'or 'x 2012-12-12 2012-12-12 ...' or '2012-12-12 ...' + createdDateRx = regexp.MustCompile(`^(\([A-Z]\)|x \d{4}-\d{2}-\d{2} \([A-Z]\)|x \([A-Z]\)|x \d{4}-\d{2}-\d{2}|)\s*(\d{4}-\d{2}-\d{2})\s+`) + completedRx = regexp.MustCompile(`^x\s+`) // Match completed: 'x ...' + completedDateRx = regexp.MustCompile(`^x\s*(\d{4}-\d{2}-\d{2})\s+`) // Match completed date: 'x 2012-12-12 ...' + addonTagRx = regexp.MustCompile(`(^|\s+)([\w-]+):(\S+)`) // Match additional tags date: '... due:2012-12-12 ...' + contextRx = regexp.MustCompile(`(^|\s+)@(\S+)`) // Match contexts: '@Context ...' or '... @Context ...' + projectRx = regexp.MustCompile(`(^|\s+)\+(\S+)`) // Match projects: '+Project...' or '... +Project ...') +) + // Task represents a todo.txt task entry. type Task struct { + Id int // Internal task id. Original string // Original raw task text. Todo string // Todo part of task text. Priority string @@ -85,6 +102,108 @@ func (task Task) String() string { return text } +// NewTask creates a new empty Task with default values. (CreatedDate is set to Now()) +func NewTask() *Task { + task := Task{} + task.CreatedDate = time.Now() + return &task +} + +// ParseTask parses the input text string into a Task struct. +func ParseTask(text string) (*Task, error) { + var err error + + task := Task{} + task.Original = strings.Trim(text, "\t\n\r ") + task.Todo = task.Original + + // Check for completed + if completedRx.MatchString(task.Original) { + task.Completed = true + // Check for completed date + if completedDateRx.MatchString(task.Original) { + if date, err := time.Parse(DateLayout, completedDateRx.FindStringSubmatch(task.Original)[1]); err == nil { + task.CompletedDate = date + } else { + return nil, err + } + } + + // Remove from Todo text + task.Todo = completedDateRx.ReplaceAllString(task.Todo, "") // Strip CompletedDate first, otherwise it wouldn't match anymore (^x date...) + task.Todo = completedRx.ReplaceAllString(task.Todo, "") // Strip 'x ' + } + + // Check for priority + if priorityRx.MatchString(task.Original) { + task.Priority = priorityRx.FindStringSubmatch(task.Original)[2] + task.Todo = priorityRx.ReplaceAllString(task.Todo, "") // Remove from Todo text + } + + // Check for created date + if createdDateRx.MatchString(task.Original) { + if date, err := time.Parse(DateLayout, createdDateRx.FindStringSubmatch(task.Original)[2]); err == nil { + task.CreatedDate = date + task.Todo = createdDateRx.ReplaceAllString(task.Todo, "") // Remove from Todo text + } else { + return nil, err + } + } + + // function for collecting projects/contexts as slices from text + getSlice := func(rx *regexp.Regexp) []string { + matches := rx.FindAllStringSubmatch(task.Original, -1) + slice := make([]string, 0, len(matches)) + seen := make(map[string]bool, len(matches)) + for _, match := range matches { + word := strings.Trim(match[2], "\t\n\r ") + if _, found := seen[word]; !found { + slice = append(slice, word) + seen[word] = true + } + } + sort.Strings(slice) + return slice + } + + // Check for contexts + if contextRx.MatchString(task.Original) { + task.Contexts = getSlice(contextRx) + task.Todo = contextRx.ReplaceAllString(task.Todo, "") // Remove from Todo text + } + + // Check for projects + if projectRx.MatchString(task.Original) { + task.Projects = getSlice(projectRx) + task.Todo = projectRx.ReplaceAllString(task.Todo, "") // Remove from Todo text + } + + // Check for additional tags + if addonTagRx.MatchString(task.Original) { + matches := addonTagRx.FindAllStringSubmatch(task.Original, -1) + tags := make(map[string]string, len(matches)) + for _, match := range matches { + key, value := match[2], match[3] + if key == "due" { // due date is a known addon tag, it has its own struct field + if date, err := time.Parse(DateLayout, value); err == nil { + task.DueDate = date + } else { + return nil, err + } + } else if key != "" && value != "" { + tags[key] = value + } + } + task.AdditionalTags = tags + task.Todo = addonTagRx.ReplaceAllString(task.Todo, "") // Remove from Todo text + } + + // Trim any remaining whitespaces from Todo text + task.Todo = strings.Trim(task.Todo, "\t\n\r\f ") + + return &task, err +} + // Task returns a complete task string in todo.txt format. // See *Task.String() for further information. func (task *Task) Task() string { diff --git a/task_test.go b/task_test.go index e167cbd..3a225f9 100644 --- a/task_test.go +++ b/task_test.go @@ -13,6 +13,184 @@ var ( testInputTask = "testdata/task_todo.txt" ) +func TestNewTask(t *testing.T) { + task := NewTask() + + testExpected = 0 + testGot = task.Id + if testGot != testExpected { + t.Errorf("Expected new Task to have default Id [%d], but got [%d]", testExpected, testGot) + } + + testExpected = "" + testGot = task.Original + if testGot != testExpected { + t.Errorf("Expected new Task to be empty, but got [%s]", testGot) + } + + testExpected = "" + testGot = task.Todo + if testGot != testExpected { + t.Errorf("Expected new Task to be empty, but got [%s]", testGot) + } + + testExpected = false + testGot = task.HasPriority() + if testGot != testExpected { + t.Errorf("Expected new Task to have no priority, but got [%v]", testGot) + } + + testExpected = 0 + testGot = len(task.Projects) + if testGot != testExpected { + t.Errorf("Expected new Task to have %d projects, but got [%d]", testExpected, testGot) + } + + testExpected = 0 + testGot = len(task.Contexts) + if testGot != testExpected { + t.Errorf("Expected new Task to have %d contexts, but got [%d]", testExpected, testGot) + } + + testExpected = 0 + testGot = len(task.AdditionalTags) + if testGot != testExpected { + t.Errorf("Expected new Task to have %d additional tags, but got [%d]", testExpected, testGot) + } + + testExpected = true + testGot = task.HasCreatedDate() + if testGot != testExpected { + t.Errorf("Expected new Task to have a created date, but got [%v]", testGot) + } + + testExpected = false + testGot = task.HasCompletedDate() + if testGot != testExpected { + t.Errorf("Expected new Task to not have a completed date, but got [%v]", testGot) + } + + testExpected = false + testGot = task.HasDueDate() + if testGot != testExpected { + t.Errorf("Expected new Task to not have a due date, but got [%v]", testGot) + } + + testExpected = false + testGot = task.Completed + if testGot != testExpected { + t.Errorf("Expected new Task to not be completed, but got [%v]", testGot) + } +} + +func TestParseTask(t *testing.T) { + task, err := ParseTask("x (C) 2014-01-01 @Go due:2014-01-12 Create golang library documentation +go-todotxt ") + if err != nil { + t.Error(err) + } + + testExpected = "x (C) 2014-01-01 Create golang library documentation @Go +go-todotxt due:2014-01-12" + testGot = task.Task() + if testGot != testExpected { + t.Errorf("Expected Task to be [%s], but got [%s]", testExpected, testGot) + } + + testExpected = 0 + testGot = task.Id + if testGot != testExpected { + t.Errorf("Expected Task to have default Id [%d], but got [%d]", testExpected, testGot) + } + + testExpected = "x (C) 2014-01-01 @Go due:2014-01-12 Create golang library documentation +go-todotxt" + testGot = task.Original + if testGot != testExpected { + t.Errorf("Expected Task to be [%s], but got [%s]", testExpected, testGot) + } + + testExpected = "Create golang library documentation" + testGot = task.Todo + if testGot != testExpected { + t.Errorf("Expected Task to be [%s], but got [%s]", testExpected, testGot) + } + + testExpected = true + testGot = task.HasPriority() + if testGot != testExpected { + t.Errorf("Expected Task to have no priority, but got [%v]", testGot) + } + + testExpected = "C" + testGot = task.Priority + if testGot != testExpected { + t.Errorf("Expected Task to have priority [%v], but got [%v]", testExpected, testGot) + } + + testExpected = 1 + testGot = len(task.Projects) + if testGot != testExpected { + t.Errorf("Expected Task to have %d projects, but got [%d]", testExpected, testGot) + } + + testExpected = 1 + testGot = len(task.Contexts) + if testGot != testExpected { + t.Errorf("Expected Task to have %d contexts, but got [%d]", testExpected, testGot) + } + + testExpected = 0 + testGot = len(task.AdditionalTags) + if testGot != testExpected { + t.Errorf("Expected Task to have %d additional tags, but got [%d]", testExpected, testGot) + } + + testExpected = true + testGot = task.HasCreatedDate() + if testGot != testExpected { + t.Errorf("Expected Task to have a created date, but got [%v]", testGot) + } + + testExpected = false + testGot = task.HasCompletedDate() + if testGot != testExpected { + t.Errorf("Expected Task to not have a completed date, but got [%v]", testGot) + } + + testExpected = true + testGot = task.HasDueDate() + if testGot != testExpected { + t.Errorf("Expected Task to have a due date, but got [%v]", testGot) + } + + testExpected = true + testGot = task.Completed + if testGot != testExpected { + t.Errorf("Expected Task to be completed, but got [%v]", testGot) + } +} + +func TestTaskId(t *testing.T) { + testTasklist.LoadFromFilename(testInputTask) + + taskId := 1 + testGot = testTasklist[taskId-1].Id + if testGot != taskId { + t.Errorf("Expected Task[%d] to have Id [%d], but got [%d]", taskId, taskId, testGot) + } + + taskId = 5 + testGot = testTasklist[taskId-1].Id + if testGot != taskId { + t.Errorf("Expected Task[%d] to have Id [%d], but got [%d]", taskId, taskId, testGot) + } + + taskId = 27 + testGot = testTasklist[taskId-1].Id + if testGot != taskId { + t.Errorf("Expected Task[%d] to have Id [%d], but got [%d]", taskId, taskId, testGot) + } + taskId++ +} + func TestTaskString(t *testing.T) { testTasklist.LoadFromFilename(testInputTask) taskId := 1 diff --git a/todotxt.go b/todotxt.go index 67ff339..c774526 100644 --- a/todotxt.go +++ b/todotxt.go @@ -13,10 +13,7 @@ import ( "fmt" "io/ioutil" "os" - "regexp" - "sort" "strings" - "time" ) // TaskList represents a list of todo.txt task entries. @@ -26,21 +23,9 @@ type TaskList []Task // IgnoreComments can be set to 'false', in order to revert to a more standard todo.txt behaviour. // The todo.txt format does not define comments. var ( - // DateLayout is used for formatting time.Time into todo.txt date format and vice-versa. - DateLayout = "2006-01-02" // IgnoreComments is used to switch ignoring of comments (lines starting with "#"). // If this is set to 'false', then lines starting with "#" will be parsed as tasks. IgnoreComments = true - - // unexported vars - priorityRx = regexp.MustCompile(`^(x|x \d{4}-\d{2}-\d{2}|)\s*\(([A-Z])\)\s+`) // Match priority: '(A) ...' or 'x (A) ...' or 'x 2012-12-12 (A) ...' - // Match created date: '(A) 2012-12-12 ...' or 'x 2012-12-12 (A) 2012-12-12 ...' or 'x (A) 2012-12-12 ...'or 'x 2012-12-12 2012-12-12 ...' or '2012-12-12 ...' - createdDateRx = regexp.MustCompile(`^(\([A-Z]\)|x \d{4}-\d{2}-\d{2} \([A-Z]\)|x \([A-Z]\)|x \d{4}-\d{2}-\d{2}|)\s*(\d{4}-\d{2}-\d{2})\s+`) - completedRx = regexp.MustCompile(`^x\s+`) // Match completed: 'x ...' - completedDateRx = regexp.MustCompile(`^x\s*(\d{4}-\d{2}-\d{2})\s+`) // Match completed date: 'x 2012-12-12 ...' - addonTagRx = regexp.MustCompile(`(^|\s+)([\w-]+):(\S+)`) // Match additional tags date: '... due:2012-12-12 ...' - contextRx = regexp.MustCompile(`(^|\s+)@(\S+)`) // Match contexts: '@Context ...' or '... @Context ...' - projectRx = regexp.MustCompile(`(^|\s+)\+(\S+)`) // Match projects: '+Project...' or '... +Project ...' ) // String returns a complete tasklist string in todo.txt format. @@ -51,6 +36,20 @@ func (tasklist TaskList) String() (text string) { return text } +// AddTask appends a task to the current TaskList, and takes care to set the Task.Id correctly, modifying the Task by the given pointer! +func (tasklist *TaskList) AddTask(task *Task) (err error) { + task.Id = 0 + for _, t := range *tasklist { + if t.Id > task.Id { + task.Id = t.Id + } + } + task.Id += 1 + + *tasklist = append(*tasklist, *task) + return +} + // LoadFromFile loads a TaskList from *os.File. // // Using *os.File instead of a filename allows to also use os.Stdin. @@ -59,102 +58,24 @@ func (tasklist TaskList) String() (text string) { func (tasklist *TaskList) LoadFromFile(file *os.File) error { *tasklist = []Task{} // Empty tasklist + taskId := 1 scanner := bufio.NewScanner(file) for scanner.Scan() { - task := Task{} - task.Original = strings.Trim(scanner.Text(), "\t\n\r ") // Read line - task.Todo = task.Original + text := strings.Trim(scanner.Text(), "\t\n\r ") // Read line // Ignore blank or comment lines - if task.Todo == "" || (IgnoreComments && strings.HasPrefix(task.Todo, "#")) { + if text == "" || (IgnoreComments && strings.HasPrefix(text, "#")) { continue } - // Check for completed - if completedRx.MatchString(task.Original) { - task.Completed = true - // Check for completed date - if completedDateRx.MatchString(task.Original) { - if date, err := time.Parse(DateLayout, completedDateRx.FindStringSubmatch(task.Original)[1]); err == nil { - task.CompletedDate = date - } else { - return err - } - } - - // Remove from Todo text - task.Todo = completedDateRx.ReplaceAllString(task.Todo, "") // Strip CompletedDate first, otherwise it wouldn't match anymore (^x date...) - task.Todo = completedRx.ReplaceAllString(task.Todo, "") // Strip 'x ' + task, err := ParseTask(text) + if err != nil { + return err } + task.Id = taskId - // Check for priority - if priorityRx.MatchString(task.Original) { - task.Priority = priorityRx.FindStringSubmatch(task.Original)[2] - task.Todo = priorityRx.ReplaceAllString(task.Todo, "") // Remove from Todo text - } - - // Check for created date - if createdDateRx.MatchString(task.Original) { - if date, err := time.Parse(DateLayout, createdDateRx.FindStringSubmatch(task.Original)[2]); err == nil { - task.CreatedDate = date - task.Todo = createdDateRx.ReplaceAllString(task.Todo, "") // Remove from Todo text - } else { - return err - } - } - - // function for collecting projects/contexts as slices from text - getSlice := func(rx *regexp.Regexp) []string { - matches := rx.FindAllStringSubmatch(task.Original, -1) - slice := make([]string, 0, len(matches)) - seen := make(map[string]bool, len(matches)) - for _, match := range matches { - word := strings.Trim(match[2], "\t\n\r ") - if _, found := seen[word]; !found { - slice = append(slice, word) - seen[word] = true - } - } - sort.Strings(slice) - return slice - } - - // Check for contexts - if contextRx.MatchString(task.Original) { - task.Contexts = getSlice(contextRx) - task.Todo = contextRx.ReplaceAllString(task.Todo, "") // Remove from Todo text - } - - // Check for projects - if projectRx.MatchString(task.Original) { - task.Projects = getSlice(projectRx) - task.Todo = projectRx.ReplaceAllString(task.Todo, "") // Remove from Todo text - } - - // Check for additional tags - if addonTagRx.MatchString(task.Original) { - matches := addonTagRx.FindAllStringSubmatch(task.Original, -1) - tags := make(map[string]string, len(matches)) - for _, match := range matches { - key, value := match[2], match[3] - if key == "due" { // due date is a known addon tag, it has its own struct field - if date, err := time.Parse(DateLayout, value); err == nil { - task.DueDate = date - } else { - return err - } - } else if key != "" && value != "" { - tags[key] = value - } - } - task.AdditionalTags = tags - task.Todo = addonTagRx.ReplaceAllString(task.Todo, "") // Remove from Todo text - } - - // Trim any remaining whitespaces from Todo text - task.Todo = strings.Trim(task.Todo, "\t\n\r\f ") - - *tasklist = append(*tasklist, task) + *tasklist = append(*tasklist, *task) + taskId++ } if err := scanner.Err(); err != nil { return err diff --git a/todotxt_test.go b/todotxt_test.go index 4e275f9..ea545eb 100644 --- a/todotxt_test.go +++ b/todotxt_test.go @@ -8,6 +8,7 @@ import ( "io/ioutil" "os" "testing" + "time" ) var ( @@ -37,8 +38,8 @@ func TestLoadFromFile(t *testing.T) { if err != nil { t.Fatal(err) } - testExpected := string(data) - testGot := testTasklist.String() + testExpected = string(data) + testGot = testTasklist.String() if testGot != testExpected { t.Errorf("Expected TaskList to be [%s], but got [%s]", testExpected, testGot) } @@ -57,8 +58,8 @@ func TestLoadFromFilename(t *testing.T) { if err != nil { t.Fatal(err) } - testExpected := string(data) - testGot := testTasklist.String() + testExpected = string(data) + testGot = testTasklist.String() if testGot != testExpected { t.Errorf("Expected TaskList to be [%s], but got [%s]", testExpected, testGot) } @@ -104,8 +105,8 @@ func TestWriteFile(t *testing.T) { if err != nil { t.Fatal(err) } - testExpected := string(data) - testGot := testTasklist.String() + testExpected = string(data) + testGot = testTasklist.String() if testGot != testExpected { t.Errorf("Expected TaskList to be [%s], but got [%s]", testExpected, testGot) } @@ -146,8 +147,8 @@ func TestTaskListWriteFile(t *testing.T) { if err != nil { t.Fatal(err) } - testExpected := string(data) - testGot := testTasklist.String() + testExpected = string(data) + testGot = testTasklist.String() if testGot != testExpected { t.Errorf("Expected TaskList to be [%s], but got [%s]", testExpected, testGot) } @@ -171,8 +172,8 @@ func TestWriteFilename(t *testing.T) { if err != nil { t.Fatal(err) } - testExpected := string(data) - testGot := testTasklist.String() + testExpected = string(data) + testGot = testTasklist.String() if testGot != testExpected { t.Errorf("Expected TaskList to be [%s], but got [%s]", testExpected, testGot) } @@ -196,25 +197,125 @@ func TestTaskListWriteFilename(t *testing.T) { if err != nil { t.Fatal(err) } - testExpected := string(data) - testGot := testTasklist.String() + testExpected = string(data) + testGot = testTasklist.String() if testGot != testExpected { t.Errorf("Expected TaskList to be [%s], but got [%s]", testExpected, testGot) } } +func TestNewTaskList(t *testing.T) { + t.Fail() +} + func TestTaskListCount(t *testing.T) { if err := testTasklist.LoadFromFilename(testInputTasklist); err != nil { t.Fatal(err) } - testExpected := 63 - testGot := len(testTasklist) + testExpected = 63 + testGot = len(testTasklist) if testGot != testExpected { t.Errorf("Expected TaskList to contain %d tasks, but got %d", testExpected, testGot) } } +func TestTaskListAddTask(t *testing.T) { + if err := testTasklist.LoadFromFilename(testInputTasklist); err != nil { + t.Fatal(err) + } + + // add new empty task + testTasklist.AddTask(NewTask()) + + testExpected = 64 + testGot = len(testTasklist) + if testGot != testExpected { + t.Errorf("Expected TaskList to contain %d tasks, but got %d", testExpected, testGot) + } + + taskId := 64 + testExpected = time.Now().Format(DateLayout) + " " // tasks created by NewTask() have their created date set + testGot = testTasklist[taskId-1].String() + if testGot != testExpected { + t.Errorf("Expected Task[%d] to be [%s], but got [%s]", taskId, testExpected, testGot) + } + testExpected = 64 + testGot = testTasklist[taskId-1].Id + if testGot != testExpected { + t.Errorf("Expected Task[%d] to be [%d], but got [%d]", taskId, testExpected, testGot) + } + taskId++ + + // add parsed task + parsed, err := ParseTask("x (C) 2014-01-01 Create golang library documentation @Go +go-todotxt due:2014-01-12") + if err != nil { + t.Error(err) + } + testTasklist.AddTask(parsed) + + testExpected = "x (C) 2014-01-01 Create golang library documentation @Go +go-todotxt due:2014-01-12" + testGot = testTasklist[taskId-1].String() + if testGot != testExpected { + t.Errorf("Expected Task[%d] to be [%s], but got [%s]", taskId, testExpected, testGot) + } + testExpected = 65 + testGot = testTasklist[taskId-1].Id + if testGot != testExpected { + t.Errorf("Expected Task[%d] to be [%d], but got [%d]", taskId, testExpected, testGot) + } + taskId++ + + // add selfmade task + createdDate := time.Now() + testTasklist.AddTask(&Task{ + CreatedDate: createdDate, + Todo: "Go shopping..", + Contexts: []string{"GroceryStore"}, + }) + + testExpected = createdDate.Format(DateLayout) + " Go shopping.. @GroceryStore" + testGot = testTasklist[taskId-1].String() + if testGot != testExpected { + t.Errorf("Expected Task[%d] to be [%s], but got [%s]", taskId, testExpected, testGot) + } + testExpected = 66 + testGot = testTasklist[taskId-1].Id + if testGot != testExpected { + t.Errorf("Expected Task[%d] to be [%d], but got [%d]", taskId, testExpected, testGot) + } + taskId++ + + // add task with explicit Id, AddTask() should ignore this! + testTasklist.AddTask(&Task{ + Id: 101, + }) + + testExpected = 67 + testGot = testTasklist[taskId-1].Id + if testGot != testExpected { + t.Errorf("Expected Task[%d] to be [%d], but got [%d]", taskId, testExpected, testGot) + } + taskId++ +} + +func TestTaskListRemoveTaskById(t *testing.T) { + t.Fail() +} + +func TestTaskListRemoveTask(t *testing.T) { + // removes by comparing Task.String() with each other + t.Fail() +} + +func TestTaskListFilter(t *testing.T) { + t.Fail() +} + +func TestTaskListFilterNot(t *testing.T) { + t.Fail() +} + func TestTaskListReadErrors(t *testing.T) { if testTasklist, err := LoadFromFilename(testInputTasklistCreatedDateError); testTasklist != nil || err == nil { t.Errorf("Expected LoadFromFilename to fail because of invalid created date, but got TaskList back: [%s]", testTasklist)