diff --git a/sort.go b/sort.go new file mode 100644 index 0000000..61966c9 --- /dev/null +++ b/sort.go @@ -0,0 +1,83 @@ +package timertxt + +import ( + "errors" + "sort" + "time" +) + +// Flags for defining sort element and order. +const ( + SORT_START_DATE_ASC = iota + SORT_START_DATE_DESC + SORT_FINISH_DATE_ASC + SORT_FINISH_DATE_DESC +) + +// Sort allows a TimerList to be sorted by certain predefined fields. +// See constants SORT_* for fields and sort order. +func (timerlist *TimerList) Sort(sortFlag int) error { + switch sortFlag { + case SORT_START_DATE_ASC, SORT_START_DATE_DESC: + timerlist.sortByStartDate(sortFlag) + case SORT_FINISH_DATE_ASC, SORT_FINISH_DATE_DESC: + timerlist.sortByFinishDate(sortFlag) + default: + return errors.New("Unrecognized sort option") + } + return nil +} + +type timerlistSort struct { + timerlists TimerList + by func(t1, t2 *Timer) bool +} + +func (ts *timerlistSort) Len() int { + return len(ts.timerlists) +} + +func (ts *timerlistSort) Swap(l, r int) { + ts.timerlists[l], ts.timerlists[r] = ts.timerlists[r], ts.timerlists[l] +} + +func (ts *timerlistSort) Less(l, r int) bool { + return ts.by(&ts.timerlists[l], &ts.timerlists[r]) +} + +func (timerlist *TimerList) sortBy(by func(t1, t2 *Timer) bool) *TimerList { + ts := &timerlistSort{ + timerlists: *timerlist, + by: by, + } + sort.Sort(ts) + return timerlist +} + +func sortByDate(asc bool, date1, date2 time.Time) bool { + if asc { // ASC + if !date1.IsZero() && !date2.IsZero() { + return date1.Before(date2) + } + return !date2.IsZero() + } + // DESC + if !date1.IsZero() && !date2.IsZero() { + return date1.After(date2) + } + return date2.IsZero() +} + +func (timerlist *TimerList) sortByStartDate(order int) *TimerList { + timerlist.sortBy(func(t1, t2 *Timer) bool { + return sortByDate(order == SORT_START_DATE_ASC, t1.StartDate, t2.StartDate) + }) + return timerlist +} + +func (timerlist *TimerList) sortByFinishDate(order int) *TimerList { + timerlist.sortBy(func(t1, t2 *Timer) bool { + return sortByDate(order == SORT_FINISH_DATE_ASC, t1.FinishDate, t2.FinishDate) + }) + return timerlist +} diff --git a/timer.go b/timer.go new file mode 100644 index 0000000..74c2bbb --- /dev/null +++ b/timer.go @@ -0,0 +1,150 @@ +package timertxt + +import ( + "errors" + "fmt" + "regexp" + "sort" + "strings" + "time" +) + +var ( + // DateLayout is used for formatting time.Time into timer.txt date format and vice-versa. + DateLayout = time.RFC3339 + + 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 ...') +) + +type Timer struct { + Id int // Internal timer id + Original string // Original raw timer text + StartDate time.Time + FinishDate time.Time + Finished bool + Notes string // Notes part of timer text + Projects []string + Contexts []string + AdditionalTags map[string]string // Addon tags will be available here +} + +// String returns a complete timer string in timer.txt format. +// +// Contexts, Projects, and additional tags are alphabetically sorted, +// and appended at the end in the following order: +// Contexts, Projects, Tags +// +// For example: +// "2019-02-15T11:43:00-0600 Working on Go Library @home @personal +timertxt customTag1:Important! due:Today" +// "x 2019-02-15T10:00:00-0600 2019-02-15T06:00:00-0600 Creating Go Library Repo @home @personal +timertxt customTag1:Important! due:Today" +func (timer Timer) String() string { + var text string + if timer.Finished { + text += "x " + if timer.HasFinishDate() { + text += fmt.Sprintf("%s ", timer.FinishDate.Format(DateLayout)) + } + } + text += fmt.Sprintf("%s ", timer.StartDate.Format(DateLayout)) + text += timer.Notes + if len(timer.Contexts) > 0 { + sort.Strings(timer.Contexts) + for _, context := range timer.Contexts { + text += fmt.Sprintf(" @%s", context) + } + } + if len(timer.Projects) > 0 { + sort.Strings(timer.Projects) + for _, project := range timer.Projects { + text += fmt.Sprintf(" +%s", project) + } + } + if len(timer.AdditionalTags) > 0 { + // Sort map alphabetically by keys + keys := make([]string, 0, len(timer.AdditionalTags)) + for key := range timer.AdditionalTags { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + text += fmt.Sprintf(" %s:%s", key, timer.AdditionalTags[key]) + } + } + return text +} + +// NewTimer creates a new empty Timer with default values. (StartDate is set to Now()) +func NewTimer() Timer { + timer := Timer{} + timer.StartDate = time.Now() + return timer +} + +// ParseTimer parses the input text string into a Timer struct +func ParseTimer(text string) (*Timer, error) { + var err error + timer := Timer{} + timer.Original = strings.Trim(text, "\t\n\r ") + originalParts := strings.Fields(timer.Original) + + // Check for finished + if originalParts[0] == "x" { + timer.Finished = true + // If it's finished, there _must_ be a finished date + if timer.FinishDate, err = time.Parse(DateLayout, originalParts[1]); err != nil { + return nil, errors.New("Timer marked finished, but failed to parse FinishDate: " + err.Error()) + } + originalParts = originalParts[2:] + } + if timer.StartDate, err = time.Parse(DateLayout, originalParts[0]); err != nil { + return nil, errors.New("Unable to parse StartDate: " + err.Error()) + } + originalParts = originalParts[1:] + var notes []string + for _, v := range originalParts { + if strings.HasPrefix("@", v) { + // Contexts + timer.Contexts = append(timer.Contexts, v) + } else if strings.HasPrefix("+", v) { + // Projects + timer.Projects = append(timer.Projects, v) + } else if strings.Contains(":", v) { + // Additional tags + tagPts := strings.Split(v, ":") + if tagPts[0] != "" && tagPts[1] != "" { + timer.AdditionalTags[tagPts[0]] = tagPts[1] + } + } else { + notes = append(notes, v) + } + } + timer.Notes = strings.Join(notes, " ") + + return timer, nil +} + +// Timer returns a complete timer string in timer.txt format. +// See *Timer.String() for further information +func (timer *Timer) Timer() string { + return timer.String() +} + +// Finish sets Timer.Finished to true if the timer hasn't already been finished. +// Also sets Timer.FinishDate to time.Now() +func (timer *Timer) Finish() bool { + if !timer.Finished { + timer.Finished = true + timer.FinishDate = time.Now() + } +} + +// Reopen sets Timer.Finished to 'false' if the timer was finished +// Also resets Timer.FinishDate +func (timer *Timer) Reopen() { + if timer.Finished { + timer.Finished = false + timer.FinishDate = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC) // time.IsZero() value + } +} diff --git a/timerlist.go b/timerlist.go new file mode 100644 index 0000000..fd5117e --- /dev/null +++ b/timerlist.go @@ -0,0 +1,170 @@ +package timertxt + +import ( + "bufio" + "fmt" + "io/ioutil" + "os" + "strings" +) + +// TimerList represents a list of timer.txt timer entries. +// It is usually loasded from a whole timer.txt file. +type TimerList []Timer + +// NewTimerList creates a new empty TimerList. +func NewTimerList() TimerList { + timerlist := TimerList{} + return timerlist +} + +// String returns a complete list of timers in timer.txt format. +func (timerlist TimerList) String() string { + var ret string + for _, timer := range timerlist { + ret += fmt.Sprintf("%s\n", timer.String()) + } + return ret +} + +// AddTimer appends a Timer to the current TimerList and takes care to set the Timer.Id correctly +func (timerlist *TimerList) AddTimer(timer *Timer) { + timer.Id = 0 + for _, t := range *timerlist { + if t.Id > timer.Id { + timer.Id = t.Id + } + } + timer.Id += 1 + *timerlist = append(*timerlist, *timer) +} + +// GetTimer returns the Timer with the given timer 'id' from the TimerList. +// Returns an error if Timer could not be found. +func (timerlist *TimerList) GetTimer(id int) (*Timer, error) { + for i := range *timerlist { + if ([]Timer(*timerlist))[i].Id == id { + return &([]Timer(*timerlist))[i], nil + } + } + return nil, errors.new("timer not found") +} + +// RemoveTimerById removes any Timer with given Timer 'id' from the TimerList. +// Returns an error if no Timer was removed. +func (timerlist *TimerList) RemoveTimerById(id int) error { + var newList TimerList + found := false + for _, t := range *timerlist { + if t.Id != id { + newList = append(newList, t) + } else { + found = true + } + } + if !found { + return errors.new("timer not found") + } + *timerlist = newList + return nil +} + +// RemoveTimer removes any Timer from the TimerList with the same String representation as the given Timer. +// Returns an error if no Timer was removed. +func (timerlist *TimerList) RemoveTimer(timer Timer) error { + var newList TimerList + found := false + for _, t := range *timerlist { + if t.String() != timer.String() { + newList = append(newList, t) + } else { + found = true + } + } + if !found { + return errors.New("timer not found") + } + *timerlist = newList + return nil +} + +// Filter filters the current TimerList for the given predicate (a function that takes a timer as input and returns a +// bool), and returns a new TimerList. The original TimerList is not modified. +func (timerlist *TimerList) Filter(predicate func(Timer) bool) *TimerList { + var newList TimerList + for _, t := range *timerlist { + if predicate(t) { + newList = append(newList, t) + } + } + return &newList +} + +// LoadFromFile loads a TimerList from *os.File. +// Note: This will clear the current TimerList and overwrite it's contents with whatever is in *os.File. +func (timerlist *TimerList) LoadFromFile(file *os.File) error { + *timerlist = []Timer{} // Empty timerlist + timerId := 1 + scanner := bufio.NewScanner(file) + for scanner.Scan() { + text := strings.Trim(scanner.Text(), "\t\n\r") // Read Line + // Ignore blank lines + if text == "" { + continue + } + timer, err := ParseTimer(text) + if err != nil { + return err + } + timer.Id = timerId + *timerlist = append(*timerlist, *timer) + timerId++ + } + if err := scanner.Err(); err != nil { + return err + } + return nil +} + +// WriteToFile writes a TimerList to *os.File. +func (timerlist *TimerList) LoadFromFilename(filename string) error { + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + return timerlist.LoadFromFile(file) +} + +// WriteToFilename writes a TimerList to the specified file (most likely called "timer.txt"). +func (timerlist *TimerList) WriteToFilename(filename string) error { + return ioutil.WriteFile(filename, []byte(timerlist.String()), 0640) +} + +// LoadFromFile loads and returns a TimerList from *os.File. +func LoadFromFile(file *os.File) (TimerList, error) { + timerlist := TimerList{} + if err := timerlist.LoadFromFile(file); err != nil { + return nil, err + } + return timerlist, nil +} + +// WriteToFile writes a TimerList to *os.File. +func WriteToFile(timerlist *TimerList, file *os.File) error { + return timerlist.WriteToFile(file) +} + +// LoadFromFilename loads and returns a TimerList from a file (most likely called "timer.txt") +func LoadFromFilename(filename string) (TimerList, error) { + timerlist := TimerList{} + if err := timerlist.LoadFromFilename(filename); err != nil { + return nil, err + } + return timerlist, nil +} + +// WriteToFilename write a TimerList to the specified file (most likely called "timer.txt") +func WriteToFilename(timerlist *TimerList, filename string) error { + return timerlist.WriteToFilename(filename) +}