diff --git a/.gitignore b/.gitignore index 7560965..a879e5e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # ---> Go # Binaries for programs and plugins +gime *.exe *.dll *.so diff --git a/app_state.go b/app_state.go new file mode 100644 index 0000000..38bcc6d --- /dev/null +++ b/app_state.go @@ -0,0 +1,189 @@ +package main + +import ( + "fmt" + "os" + "strings" + + timertxt "git.bullercodeworks.com/brian/go-timertxt" + "github.com/br0xen/user-config" +) + +type AppState struct { + Name string + Version int + config *userConfig.Config + directory string + fileTimer string + fileDone string + fileReport string + ValidOperations map[string][]string + OpFuncs map[string]func([]string) int + + TimerList *timertxt.TimerList +} + +func NewApp() *AppState { + app := &AppState{Name: AppName, Version: AppVersion} + app.initialize() + app.doVersionCheck() + if err := app.LoadTimerList(); err != nil { + if len(os.Args) > 1 && os.Args[1] != "--reinit" { + panic(err) + } + } + return app +} + +func (a *AppState) run(parms []string) int { + if len(parms) == 0 || parms[0] == "ui" { + // UI Mode + //return uiLoop() + } + if fn, ok := a.OpFuncs[parms[0]]; ok { + return fn(parms[1:]) + } + fmt.Println("Unknown Command") + return 1 +} + +func (a *AppState) getTimerFile() string { + return a.directory + a.fileTimer +} + +func (a *AppState) getDoneFile() string { + return a.directory + a.fileDone +} + +func (a *AppState) getReportFile() string { + return a.directory + a.fileReport +} + +func (a *AppState) addOperation(name string, desc []string, fn func([]string) int) { + a.ValidOperations[name] = desc + a.OpFuncs[name] = fn +} + +func (a *AppState) doVersionCheck() { + confVer, _ := a.config.GetInt("version") + for confVer < a.Version { + confVer = a.migrate(confVer, a.Version) + } + a.config.SetInt("version", confVer) +} + +func (a *AppState) migrate(from, to int) int { + if from == to { + return to + } + switch from { + case 0: + a.initializeConfig() + return 1 + } + // If we get all the way down here, we _must_ be done. + return to +} + +func (a *AppState) initialize() { + var err error + a.config, err = userConfig.NewConfig(a.Name) + if err != nil { + panic(err) + } + a.ValidOperations = make(map[string][]string) + a.OpFuncs = make(map[string]func([]string) int) + a.addOperation("ls", + []string{ + "ls - List Timers", + }, + a.opListTimers, + ) + a.addOperation("lsa", + []string{ + "lsa - The same as 'ls -a'", + }, + func(args []string) int { + return a.opListTimers(append([]string{"-a"}, args...)) + }, + ) + a.addOperation("start", + []string{ + "start [time] [@contexts...] [+projects...] [tag:value...]", + " - Start a timer with the given details", + " If the first argument looks like a time,", + " the timer will be started then (past or future)", + }, + a.opStartTimer, + ) + a.addOperation("stop", + []string{ + "stop [time] - Stops the current timer", + " If the first argument looks like a time,", + " the timer will be stopped then (past or future)", + }, + a.opStopTimer, + ) + a.addOperation("status", + []string{ + "status - Prints the status of all active timers", + }, + a.opStatus, + ) + a.addOperation("fuzzyparse", + []string{ + "fuzzyparse [date string] - Parses the passed string and print the RFC3339 result (for testing)", + }, + a.opFuzzyParse, + ) + a.addOperation("--reinit", + []string{"--reinit - Reset all Configuration Settings"}, + func(args []string) int { + a.initializeConfig() + return 0 + }, + ) + a.addOperation("-h", + []string{"-h - Print this message"}, + a.opPrintUsage, + ) + a.addOperation("help", + []string{"help - Print this message"}, + a.opPrintUsage, + ) + a.addOperation("--h", + []string{"--h - Print this message"}, + a.opPrintUsage, + ) + a.directory = a.config.Get("directory") + a.fileTimer = a.config.Get("timerfile") + a.fileDone = a.config.Get("donefile") + a.fileReport = a.config.Get("reportfile") +} + +func (a *AppState) initializeConfig() { + fmt.Println("Initializing " + a.Name) + for { + var add string + if a.directory != "" { + add = " (" + a.directory + ")" + } + fmt.Println("Path to timer.txt" + add + ":") + var resp string + fmt.Scanln(&resp) + if resp == "" && a.directory != "" { + resp = a.directory + } + if resp != "" { + if !strings.HasSuffix(resp, "/") { + resp = resp + "/" + } + fmt.Println("Setting timer.txt directory to: " + resp) + a.config.Set("directory", resp) + break + } + } + a.config.Set("timerfile", "timer.txt") + a.config.Set("donefile", "done.txt") + a.config.Set("reportfile", "report.txt") +} diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..257ea8c --- /dev/null +++ b/helpers.go @@ -0,0 +1,234 @@ +package main + +import ( + "errors" + "fmt" + "strings" + "time" + + timertxt "git.bullercodeworks.com/brian/go-timertxt" +) + +func GetRoundToDuration() time.Duration { + var dur time.Duration + var err error + if dur, err = time.ParseDuration(app.config.Get("roundto")); err != nil { + app.config.Set("roundto", DefRoundTo) + dur, _ = time.ParseDuration(DefRoundTo) + } + return dur +} + +func DurationToDecimal(dur time.Duration) float64 { + mins := dur.Minutes() - (dur.Hours() * 60) + return dur.Hours() + (mins / 60) +} + +// getContextsFromSlice pulls all '@' (contexts) out of the +// string slice and return those contexts and the remaining +// strings from the slice +func getContextsFromSlice(args []string) ([]string, []string) { + return splitSlice(args, func(v string) bool { + return strings.HasPrefix(v, "@") + }) +} + +// getProjectsFromSlice pulls all '+' (projects) out of the +// string slice and return those projects and the remaining +// strings from the slice +func getProjectsFromSlice(args []string) ([]string, []string) { + return splitSlice(args, func(v string) bool { + return strings.HasPrefix(v, "+") + }) +} + +func splitSlice(args []string, predicate func(string) bool) ([]string, []string) { + var rem1, rem2 []string + for _, v := range args { + if predicate(v) { + rem1 = append(rem1, v) + } else { + rem2 = append(rem2, v) + } + } + return rem1, rem2 +} + +func parseFuzzyTime(t string) (time.Time, error) { + var ret time.Time + var err error + for i := range fuzzyFormats { + ret, err = time.Parse(fuzzyFormats[i], t) + if err == nil { + // Make sure it's in the local timezone + tz := time.Now().Format("Z07:00") + t = ret.Format("2006-01-02T15:04:05") + tz + if ret, err = time.Parse(time.RFC3339, t); err != nil { + return ret, err + } + // Check for zero on year/mo/day + if ret.Year() == 0 && ret.Month() == time.January && ret.Day() == 1 { + ret = ret.AddDate(time.Now().Year(), int(time.Now().Month())-1, time.Now().Day()-1) + } + return ret, nil + } + } + return time.Time{}, errors.New("Unable to parse time: " + t) +} + +var fuzzyFormats = []string{ + "1504", + "15:04", // Kitchen, 24hr + time.Kitchen, + time.RFC3339, + "2006-01-02T15:04:05", // RFC3339 without timezone + "2006-01-02T15:04", // RFC3339 without seconds or timezone + time.Stamp, + "02 Jan 06 15:04:05", // RFC822 with second + time.RFC822, + "01/02/2006 15:04", // U.S. Format + "01/02/2006 15:04:05", // U.S. Format with seconds + "01/02/06 15:04", // U.S. Format, short year + "01/02/06 15:04:05", // U.S. Format, short year, with seconds + "2006-01-02", + "2006-01-02 15:04", + "2006-01-02 15:04:05", + "20060102", + "20060102 15:04", + "20060102 15:04:05", + "20060102 1504", + "20060102 150405", + "20060102T15:04", + "20060102T15:04:05", + "20060102T1504", + "20060102T150405", +} + +func timerToFriendlyString(t *timertxt.Timer) string { + var start, end, contexts, projects string + start = t.StartDate.Format(friendlyFormatForTime(t.StartDate)) + if t.FinishDate.IsZero() { + end = "**:**" + } else { + end = t.FinishDate.Format(friendlyFormatForTime(t.FinishDate)) + } + for _, v := range t.Contexts { + contexts += "@" + v + " " + } + for _, v := range t.Projects { + projects += "+" + v + " " + } + return fmt.Sprintf("% 2d. %s - %s [ %s] [ %s] %s", t.Id, start, end, contexts, projects, t.Notes) +} + +func friendlyFormatForTime(t time.Time) string { + nowTime := time.Now() + if t.Year() != nowTime.Year() || t.Month() != nowTime.Month() { + return "2006-01-02 15:04" + } else if t.Day() != nowTime.Day() { + return "01/02 15:04" + } + return "15:04" +} + +// timeToFriendlyString returns an easier to read version of the time +// giving enough details that the user should be fine inferring the rest +func timeToFriendlyString(t time.Time) string { + return t.Format(friendlyFormatForTime(t)) +} + +func sinceToString(tm time.Time) string { + return diffToString(tm, time.Now()) +} + +func diffToString(tm1, tm2 time.Time) string { + ret := "" + yr, mo, dy, hr, mn, sc := diff(tm1, tm2) + higher := false + + if yr > 0 { + ret += fmt.Sprintf("%4dy ", yr) + higher = true + } + if mo > 0 || higher { + ret += fmt.Sprintf("%2dm ", mo) + higher = true + } + if dy > 0 || higher { + ret += fmt.Sprintf("%2dd ", dy) + higher = true + } + if hr > 0 || higher { + ret += fmt.Sprintf("%2dh ", hr) + higher = true + } + if mn > 0 || higher { + ret += fmt.Sprintf("%2dm ", mn) + higher = true + } + if sc > 0 || higher { + ret += fmt.Sprintf("%2ds", sc) + } + return ret +} + +func padRight(st string, l int) string { + for len(st) < l { + st = st + " " + } + return st +} + +func padLeft(st string, l int) string { + for len(st) < l { + st = " " + st + } + return st +} + +func diff(a, b time.Time) (year, month, day, hour, min, sec int) { + if a.Location() != b.Location() { + b = b.In(a.Location()) + } + if a.After(b) { + a, b = b, a + } + y1, M1, d1 := a.Date() + y2, M2, d2 := b.Date() + + h1, m1, s1 := a.Clock() + h2, m2, s2 := b.Clock() + + year = int(y2 - y1) + month = int(M2 - M1) + day = int(d2 - d1) + hour = int(h2 - h1) + min = int(m2 - m1) + sec = int(s2 - s1) + + // Normalize negative values + if sec < 0 { + sec += 60 + min-- + } + if min < 0 { + min += 60 + hour-- + } + if hour < 0 { + hour += 24 + day-- + } + if day < 0 { + // days in month: + t := time.Date(y1, M1, 32, 0, 0, 0, 0, time.UTC) + day += 32 - t.Day() + month-- + } + if month < 0 { + month += 12 + year-- + } + + return +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..92602aa --- /dev/null +++ b/main.go @@ -0,0 +1,25 @@ +package main + +import "os" + +const ( + AppName = "gime" + AppVersion = 1 + + DefRoundTo = "1m0s" +) + +var app *AppState + +func main() { + app = NewApp() + + var parms []string + if len(os.Args) > 1 { + parms = os.Args[1:] + } else { + // if no parameters were passed, just do a status + parms = append(parms, "status") + } + os.Exit(app.run(parms)) +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..4a92440 --- /dev/null +++ b/model.go @@ -0,0 +1,45 @@ +package main + +import ( + "time" + + timertxt "git.bullercodeworks.com/brian/go-timertxt" +) + +func (a *AppState) SetTimerFinished(id int, end time.Time) error { + var t *timertxt.Timer + var err error + if t, err = a.TimerList.GetTimer(id); err != nil { + return err + } + t.FinishDate = end + t.Finished = true + return nil +} + +func (a *AppState) LoadTimerList() error { + var err error + var tl timertxt.TimerList + tl, err = timertxt.LoadFromFilename(a.getTimerFile()) + tl.Sort(timertxt.SORT_UNFINISHED_START) + a.TimerList = &tl + return err +} + +func (a *AppState) WriteList() error { + return a.TimerList.WriteToFilename(a.getTimerFile()) +} + +/* +func (a *AppState) LoadDoneList() error { + var err error + var tl timertxt.TimerList + tl, err = timertxt.LoadFromFilename(a.getDoneFile()) + a.DoneList = &tl + return err +} + +func (a *AppState) WriteDoneList() error { + return a.DoneList.WriteToFilename(a.getDoneFile()) +} +*/ diff --git a/task_ops.go b/task_ops.go new file mode 100644 index 0000000..40fb645 --- /dev/null +++ b/task_ops.go @@ -0,0 +1,138 @@ +package main + +import ( + "fmt" + "strconv" + "time" + + timertxt "git.bullercodeworks.com/brian/go-timertxt" +) + +func (a *AppState) opStatus(args []string) int { + fmt.Println("Current Time: " + time.Now().Format(time.Stamp)) + if len(*a.TimerList.GetActiveTimers()) == 0 { + fmt.Println("No timers running") + return 0 + } + for _, v := range *a.TimerList.GetActiveTimers() { + fmt.Println(timerToFriendlyString(&v)) + } + return 0 +} + +func (a *AppState) opListTimers(args []string) int { + // By default only list today + // Format: + // 2019/02/20 (9.25) + // start - end [ contexts ] [ projects ] + // ... + dayTotals := make(map[string]time.Duration) + for _, v := range *a.TimerList { + dur := v.FinishDate.Sub(v.StartDate) + if v.FinishDate.IsZero() { + dur = time.Now().Sub(v.StartDate) + } + dayTotals[v.StartDate.Format("2006/01/02")] += dur + } + var oldDayStr, dayStr string + for _, v := range *a.TimerList { + oldDayStr = dayStr + dayStr = v.StartDate.Format("2006/01/02") + if dayStr != oldDayStr { + wrkDur := dayTotals[dayStr].Round(GetRoundToDuration()) + fmtStr := dayStr + " ( %.2f )\n" + fmt.Printf(fmtStr, DurationToDecimal(wrkDur)) + } + + fmt.Println(" " + timerToFriendlyString(&v)) + } + return 0 +} + +func (a *AppState) opStartTimer(args []string) int { + var contexts, projects []string + t := timertxt.NewTimer() + contexts, args = getContextsFromSlice(args) + projects, args = getProjectsFromSlice(args) + if len(args) > 0 { + if start, err := parseFuzzyTime(args[0]); err == nil { + t.StartDate = start + args = args[1:] + } + } + t.Contexts = contexts + t.Projects = projects + + a.TimerList.AddTimer(t) + if err := a.WriteList(); err != nil { + fmt.Println(err.Error()) + return 1 + } + return 0 +} + +func (a *AppState) opStopTimer(args []string) int { + var err error + var wrk time.Time + end := time.Now() + id := -1 + + if len(args) > 0 { + if wrk, err = parseFuzzyTime(args[0]); err != nil { + id, err = strconv.Atoi(args[0]) + } else { + end = wrk + args = args[1:] + } + } + fmt.Println("Stopping at : " + end.Format(time.RFC3339)) + var timerIds []int + if id == -1 { + for _, v := range *a.TimerList.GetActiveTimers() { + timerIds = append(timerIds, v.Id) + } + } else { + timerIds = append(timerIds, id) + } + for _, v := range timerIds { + var stopped *timertxt.Timer + if stopped, err = a.TimerList.GetTimer(v); err != nil { + fmt.Println(err.Error()) + } + if err = a.SetTimerFinished(v, end); err != nil { + fmt.Println(err.Error()) + continue + } + fmt.Println("Stopped Timer:", timerToFriendlyString(stopped)) + } + if err = a.WriteList(); err != nil { + fmt.Println(err.Error()) + return 1 + } + return 0 +} + +func (a *AppState) opState(args []string) int { + return 0 +} + +func (a *AppState) opFuzzyParse(args []string) int { + if len(args) > 0 { + if start, err := parseFuzzyTime(args[0]); err == nil { + fmt.Println(start.Format(time.RFC3339)) + } else { + fmt.Println(err.Error()) + } + } + return 0 +} + +func (a *AppState) opPrintUsage(args []string) int { + for _, v := range a.ValidOperations { + for _, vv := range v { + fmt.Println(" " + vv) + fmt.Println("") + } + } + return 0 +}