From 0707febbdca368606fdcd12293dc787044b1a7c3 Mon Sep 17 00:00:00 2001 From: Brian Buller Date: Thu, 2 Aug 2018 15:05:31 -0500 Subject: [PATCH] Initial Commit --- .gitignore | 1 + helpers.go | 184 +++++++++++++++++++++++++++ main.go | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++ model.go | 127 +++++++++++++++++++ model_meta.go | 109 ++++++++++++++++ model_tag.go | 29 +++++ ops_tag.go | 66 ++++++++++ ops_timer.go | 306 +++++++++++++++++++++++++++++++++++++++++++++ timer.go | 228 +++++++++++++++++++++++++++++++++ 9 files changed, 1389 insertions(+) create mode 100644 .gitignore create mode 100644 helpers.go create mode 100644 main.go create mode 100644 model.go create mode 100644 model_meta.go create mode 100644 model_tag.go create mode 100644 ops_tag.go create mode 100644 ops_timer.go create mode 100644 timer.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba1a73c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +gime-flat diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..90f9ba5 --- /dev/null +++ b/helpers.go @@ -0,0 +1,184 @@ +package main + +import ( + "errors" + "fmt" + "time" +) + +func GetRoundToDuration() time.Duration { + var dur time.Duration + var err error + if dur, err = time.ParseDuration(cfg.Get("roundto")); err != nil { + cfg.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) +} + +// pullRemoveTagsFromArgs takes a list of arguments, removes all 'remove tags' from them +// then returns the tags and the remaining args +func pullRemoveTagsFromArgs(args []string) ([]string, []string) { + var tags, rem []string + for _, opt := range args { + if opt[0] == '-' { + tags = append(tags, opt[1:]) + } else { + rem = append(rem, opt) + } + } + return tags, rem +} + +// pullTagsFromArgs takes a list of arguments, removes all tags from them +// then returns the tags and the remaining args +func pullTagsFromArgs(args []string) ([]string, []string) { + var tags, rem []string + for _, opt := range args { + if opt[0] == '+' { + tags = append(tags, opt[1:]) + } else { + rem = append(rem, opt) + } + } + return tags, rem +} + +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) +} + +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..b1e961a --- /dev/null +++ b/main.go @@ -0,0 +1,339 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + "time" + + userConfig "github.com/br0xen/user-config" +) + +const ( + AppName = "gime-flat" + AppVersion = 0.1 + DefDBName = "./gime" + DefRoundTo = "1m0s" +) + +var validOperations map[string][]string +var opFuncs map[string]func([]string) int +var cfg *userConfig.Config +var roundTo time.Duration + +var fuzzyFormats []string + +func main() { + var ret int + initialize() + var parms []string + + if len(os.Args) > 1 { + parms = os.Args[1:] + } else { + // If no parameters were passed, just print the status + parms = append(parms, "status") + } + + if fn, ok := opFuncs[parms[0]]; ok { + ret = fn(parms[1:]) + } else { + fmt.Println("Unknown command") + ret = 1 + } + os.Exit(ret) +} + +func cmdDoConfig(args []string) int { + if len(args) == 0 { + fmt.Println("Invalid configuration options passed") + return 1 + } + + for _, opt := range args { + if !strings.Contains(opt, "=") { + // Single word triggers + switch opt { + case "reset": + fmt.Println("Are you sure you want to reset your configuration? (Y/[N])") + reader := bufio.NewReader(os.Stdin) + conf, _ := reader.ReadString('\n') + conf = strings.TrimSpace(conf) + if conf == "Y" { + fmt.Println("Resetting Configuration...") + cfg.Set("dbdir", cfg.GetConfigPath()+string(os.PathSeparator)) + cfg.Set("dbname", DefDBName) + cfg.Set("roundto", DefRoundTo) + } else { + fmt.Println("Done.") + } + return 0 + + case "list": + fmt.Println("Current " + AppName + " config") + for _, v := range cfg.GetKeyList() { + fmt.Println(" " + v + ": " + cfg.Get(v)) + } + case "dbpath": + fmt.Println(cfg.Get("dbdir")) + } + } else { + // Key=Value options + pts := strings.Split(opt, "=") + if len(pts) == 2 { + switch pts[0] { + case "dbdir": + val := pts[1] + if val[len(val)-1] != os.PathSeparator { + val = val + string(os.PathSeparator) + } + cfg.Set("dbdir", val) + case "dbname": + cfg.Set("dbname", pts[1]) + case "dbarchname": + cfg.Set("dbarchname", pts[1]) + case "roundto": + // Make sure that we can parse it + durStr := pts[1] + _, err := time.ParseDuration(durStr) + if err != nil { + fmt.Println("Unable to parse duration:", durStr) + durStr = DefRoundTo + } + cfg.Set("roundto", durStr) + fmt.Println("Rounding set to", durStr) + } + } + } + } + return 0 +} + +func cmdPrintHelp(args []string) int { + if len(args) == 0 { + fmt.Println("gime - A simple timekeeping application\n") + fmt.Println("Usage: gime [@timerID] [operation] [tags...]") + for _, v := range validOperations { + for vi := range v { + fmt.Println(" ", v[vi]) + } + fmt.Println("") + } + } else { + switch args[0] { + case "formats": + fmt.Println("Supported date/time formats:") + for i := range fuzzyFormats { + fmt.Println(" ", fuzzyFormats[i]) + } + } + fmt.Println("") + } + return 0 +} + +func cmdDoFuzzyParse(args []string) int { + if len(args) == 0 { + return 1 + } + var t time.Time + var err error + if t, err = parseFuzzyTime(args[0]); err != nil { + fmt.Println(err.Error()) + return 1 + } + fmt.Println(t.Format(time.RFC3339)) + return 0 +} + +func initialize() { + var err error + validOperations = make(map[string][]string) + opFuncs = make(map[string]func([]string) int) + + opFuncs["add"] = cmdAddTimer + validOperations["add"] = []string{ + "add [duration] [+tags] - Add a timer for the given duration", + " with the given tags", + } + + opFuncs["cont"] = cmdContinueTimer + validOperations["cont"] = []string{ + "cont [time] - Continue the last stopped timer", + } + + opFuncs["config"] = cmdDoConfig + validOperations["config"] = []string{ + "config [command] - Perform configuration", + " list - List current configuration", + " reset - Reset current configuration", + " Configuration Options:", + " dbdir=[database directory]", + " dbname=[database filename]", + " dbarchname=[archive database filename]", + } + + opFuncs["detail"] = cmdPrintDetail + validOperations["detail"] = []string{ + "detail @id - Print details about a timer", + } + + opFuncs["delete"] = cmdDeleteTimer + validOperations["delete"] = []string{ + "delete uuid - Delete a timer", + } + + opFuncs["end"] = cmdStopTimer + validOperations["end"] = []string{ + "end - The same as stop", + } + + opFuncs["help"] = cmdPrintHelp + validOperations["help"] = []string{ + "help - Print this", + } + + opFuncs["list"] = cmdPrintList + validOperations["list"] = []string{ + "list [duration] [+tags] - List time entries", + " valid values of [duration] include:", + " :day - List all entries for the current day", + " :week - List all entries for the current week", + " :month - List all entries for the current month", + " :year - List all entries for the current year", + " Or other date values, we'll try to parse it.", + " To list entries by tag, preceed the tags with a +", + } + + opFuncs["ls"] = cmdPrintList + validOperations["ls"] = []string{ + "ls [duration] [+tags] - The same as list", + } + + opFuncs["modify"] = cmdModifyTimer + validOperations["modify"] = []string{ + "modify [+tags] - Modify a timer", + } + + opFuncs["mod"] = cmdModifyTimer + validOperations["mod"] = []string{ + "mod [+tags] - Modify a timer", + } + + opFuncs["remove"] = cmdDeleteTimer + validOperations["remove"] = []string{ + "remove uuid - See 'delete'", + } + opFuncs["rm"] = cmdDeleteTimer + validOperations["rm"] = []string{ + "rm uuid - See 'delete'", + } + + opFuncs["status"] = cmdPrintStatus + validOperations["status"] = []string{ + "status - Print the status of all active timers", + } + + opFuncs["start"] = cmdStartTimer + validOperations["start"] = []string{ + "start [time] [+tags] - Start a timer with the given tags (space separated)", + " If the first sub-argument given looks like a time,", + " the timer will be started then (past or future).", + " If a timer is already running it'll be stopped", + } + + opFuncs["stop"] = cmdStopTimer + validOperations["stop"] = []string{ + "stop [time] - Stops the current timer", + " If the first sub-argument given looks like a time,", + " the timer will be stopped then (past or future).", + } + + opFuncs["switch"] = cmdSwitchTimer + validOperations["switch"] = []string{ + "switch [+tags] - Stop all currently running timers and start a new", + " one with the given tags", + } + + opFuncs["fuzzyparse"] = cmdDoFuzzyParse + validOperations["fuzzyparse"] = []string{ + "fuzzyparse - Parse the next argument as a date/time and print", + " the RFC3339 result. (Basically for testing)", + } + + opFuncs["tags"] = cmdManageTag + validOperations["tags"] = []string{ + "tags - Same as 'tag'", + } + + opFuncs["tag"] = cmdManageTag + validOperations["tag"] = []string{ + "tag [+name [:[non]bill] [+newname]] - Manage tags", + " If no tag is requested, list all tags", + " If tag is requested and:", + " newname is given, rename the tag", + " :non[[bill]able] - set tag to non-billable", + " :bill[able] - set tag to billable", + } + + // Load the Config + cfg, err = userConfig.NewConfig(AppName) + if err != nil { + fmt.Println(err.Error()) + fmt.Println("Creating new config") + cfg.Save() + } + // If dbdir isn't set, set it to the config directory + if cfg.Get("dbdir") == "" { + cfg.Set("dbdir", cfg.GetConfigPath()+"/") + } + // If dbname isn't set, set it to the default database filename + if cfg.Get("dbname") == "" { + cfg.Set("dbname", DefDBName) + } + /* + if gdb, err = gime.LoadDatabase(cfg.Get("dbdir"), cfg.Get("dbname"), cfg.Get("dbarchname")); err != nil { + fmt.Println("Error loading the database") + os.Exit(1) + } + */ + if _, err := time.ParseDuration(cfg.Get("roundto")); err != nil { + cfg.Set("roundto", DefRoundTo) + } + + 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 assertError(err error) { + if err != nil { + fmt.Println(err.Error()) + os.Exit(1) + } +} diff --git a/model.go b/model.go new file mode 100644 index 0000000..aa7397c --- /dev/null +++ b/model.go @@ -0,0 +1,127 @@ +package main + +import ( + "encoding/json" + "fmt" + "strconv" + "time" + + scribble "github.com/nanobox-io/golang-scribble" +) + +func SaveTimer(entry *Timer) error { + db, err := scribble.New(cfg.Get("dbdir"), nil) + if err != nil { + fmt.Println(err.Error()) + } + + yr := strconv.Itoa(entry.GetStart().Year()) + key := entry.GetUUID() + + // Make sure that this entry isn't saved anywhere + db.Delete("active", key) + db.Delete(yr, key) + + // Now write it + if entry.IsActive() { + if err = db.Write("active", key, entry); err != nil { + return err + } + } else { + if err = db.Write(yr, key, entry); err != nil { + // Error writing the time entry, don't edit metadata + return err + } + } + + var m *Meta + m, err = LoadMetadata() + if err != nil { + m = new(Meta) + } + m.AddYear(entry.GetStart().Year()) + m.AddTags(entry.GetTags()) + return SaveMetadata(m) +} + +func LoadTimer(uuid string) *Timer { + db, err := scribble.New(cfg.Get("dbdir"), nil) + timer := Timer{} + // Find the timer, see if it's active first + if err := db.Read("active", uuid, &timer); err == nil { + return &timer + } + // Ok, we have to check the years + var m *Meta + if m, err = LoadMetadata(); err != nil { + // Couldn't load the metadata... + return nil + } + for _, v := range m.Years { + if err = db.Read(strconv.Itoa(v), uuid, &timer); err == nil { + return &timer + } + } + // Didn't find it + return nil +} + +func DeleteTimer(uuid string) error { + db, err := scribble.New(cfg.Get("dbdir"), nil) + if err != nil { + return err + } + tmr := LoadTimer(uuid) + if tmr.IsActive() { + return db.Delete("active", uuid) + } + yr := strconv.Itoa(tmr.GetStart().Year()) + return db.Delete(yr, uuid) +} + +func LoadActiveTimers() []Timer { + return LoadTimersFromDb("active") +} + +func LoadTimers(st, end time.Time) []Timer { + var ret []Timer + stYr := st.Year() + endYr := end.Year() + m, err := LoadMetadata() + if err != nil { + return ret + } + for i := stYr; i <= endYr; i++ { + if m.HasYear(i) { + tmrs := LoadTimersFromDb(strconv.Itoa(i)) + for _, v := range tmrs { + if v.GetStart().After(st) && v.GetStart().Before(end) { + ret = append(ret, v) + } + } + } + } + return ret +} + +func LoadTimersFromDb(coll string) []Timer { + db, _ := scribble.New(cfg.Get("dbdir"), nil) + raw, _ := db.ReadAll(coll) + var timers []Timer + for _, tmr := range raw { + t := Timer{} + json.Unmarshal([]byte(tmr), &t) + timers = append(timers, t) + } + return timers +} + +func FilterTimers(tmrs []Timer, filter func(t *Timer) bool) []Timer { + var ret []Timer + for _, v := range tmrs { + if filter(&v) { + ret = append(ret, v) + } + } + return ret +} diff --git a/model_meta.go b/model_meta.go new file mode 100644 index 0000000..bb9e509 --- /dev/null +++ b/model_meta.go @@ -0,0 +1,109 @@ +package main + +import ( + "sort" + + scribble "github.com/nanobox-io/golang-scribble" +) + +type Meta struct { + Years []int + Tags []Tag +} + +func LoadMetadata() (*Meta, error) { + db, err := scribble.New(cfg.Get("dbdir"), nil) + var m *Meta + err = db.Read("meta", "data", &m) + return m, err +} + +func SaveMetadata(m *Meta) error { + db, err := scribble.New(cfg.Get("dbdir"), nil) + if err != nil { + return err + } + return db.Write("meta", "data", m) +} + +func (m *Meta) AddYear(yr int) { + for _, v := range m.Years { + if v == yr { + return + } + } + m.Years = append(m.Years, yr) + // Sort the years + sort.Slice(m.Years, func(i, j int) bool { + return m.Years[i] > m.Years[j] + }) +} + +func (m *Meta) HasYear(yr int) bool { + for _, v := range m.Years { + if v == yr { + return true + } + } + return false +} + +func (m *Meta) AddTags(tgs []string) { + for _, tg := range tgs { + if tag := m.GetTag(tg); tag == nil { + m.SaveTag(NewTag(tg, false)) + } + } + // sort the tags + sort.Slice(m.Tags, func(i, j int) bool { + return m.Tags[i].Name > m.Tags[j].Name + }) +} + +func (m *Meta) SaveTag(t *Tag) { + for i, v := range m.Tags { + if v.Name == t.Name { + m.Tags[i].Billable = t.Billable + return + } + } + m.Tags = append(m.Tags, *t) +} + +func (m *Meta) GetTag(uuid string) *Tag { + for _, v := range m.Tags { + if v.UUID == uuid { + return &v + } + } + return nil +} + +func (m *Meta) GetTags(uuids []string) []Tag { + var ret []Tag + for _, v := range uuids { + if t := m.GetTag(v); t != nil { + ret = append(ret, *t) + } + } + return ret +} + +func (m *Meta) GetTagByName(nm string) *Tag { + for _, v := range m.Tags { + if v.Name == nm { + return &v + } + } + return nil +} + +func (m *Meta) GetTagsByName(nms []string) []Tag { + var ret []Tag + for _, v := range nms { + if t := m.GetTagByName(v); t != nil { + ret = append(ret, *t) + } + } + return ret +} diff --git a/model_tag.go b/model_tag.go new file mode 100644 index 0000000..b878ff6 --- /dev/null +++ b/model_tag.go @@ -0,0 +1,29 @@ +package main + +import "github.com/pborman/uuid" + +type Tag struct { + UUID string + Name string + Billable bool +} + +func NewTag(nm string, bill bool) *Tag { + t := Tag{UUID: uuid.New(), Name: nm, Billable: bill} + return &t +} + +func (t *Tag) String() string { + ret := "{ " + t.Name + ", " + if t.Billable { + ret += "Billable" + } else { + ret += "Nonbillable" + } + ret += " (" + t.UUID + ") }" + return ret +} + +func (t *Tag) SetBillable(v bool) { + t.Billable = v +} diff --git a/ops_tag.go b/ops_tag.go new file mode 100644 index 0000000..2dd03ad --- /dev/null +++ b/ops_tag.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "strings" +) + +func cmdManageTag(args []string) int { + if len(args) == 0 { + return cmdListTags(args) + } + return cmdEditTag(args) +} + +func cmdListTags(args []string) int { + m, err := LoadMetadata() + if err != nil { + fmt.Println("Error loading tags") + return 1 + } + + fmt.Println("[") + for _, v := range m.Tags { + fmt.Println(" " + v.String()) + } + fmt.Println("]") + return 0 +} + +func cmdEditTag(args []string) int { + m, err := LoadMetadata() + if err != nil { + fmt.Println("Error loading tags") + return 1 + } + // The first argument should be the tag name that we want to edit + tagName := args[0] + if tagName[0] == '+' { + tagName = tagName[1:] + } + tg := m.GetTag(tagName) + if tg == nil { + tg = NewTag(tagName, false) + } + for _, v := range args[1:] { + switch v[0] { + case '+': + // Altering tag name + tg.Name = v[1:] + case ':': + // Altering billable flag + if strings.HasPrefix(v, ":non") { + tg.SetBillable(false) + } else if strings.HasPrefix(v, ":bill") { + tg.SetBillable(true) + } + } + } + m.SaveTag(tg) + if err = SaveMetadata(m); err != nil { + fmt.Println("Error saving metadata: " + err.Error()) + return 1 + } + fmt.Println(tg.String()) + return 0 +} diff --git a/ops_timer.go b/ops_timer.go new file mode 100644 index 0000000..9f04582 --- /dev/null +++ b/ops_timer.go @@ -0,0 +1,306 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/pborman/uuid" +) + +func cmdStartTimer(args []string) int { + var err error + var entry *Timer + // By default we start the timer now + tm := time.Now() + tags, rem := pullTagsFromArgs(args) + + if len(rem) > 0 { + // Check if the first argument looks like a date/time + tm, err = parseFuzzyTime(rem[0]) + } + + entry = NewTimer(tm, time.Time{}) + entry.SetTags(tags) + + if err = SaveTimer(entry); err != nil { + fmt.Println(err) + return 1 + } + + fmt.Println("Started:", entry.FriendlyString()) + return 0 +} + +func cmdAddTimer(args []string) int { + var err error + var entry *Timer + // By default we're starting the time now + tags, rem := pullTagsFromArgs(args) + var beg, end time.Time + for _, opt := range rem { + var tmpBeg, tmpEnd time.Time + if strings.Contains(opt, "-") { + pts := strings.Split(opt, "-") + if len(pts[0]) > 0 { + // This should be the starting date + tmpBeg, err = parseFuzzyTime(pts[0]) + if err != nil { + continue + } + } + if len(pts[1]) > 0 { + // This should be the ending date + tmpEnd, err = parseFuzzyTime(pts[1]) + if err != nil { + continue + } + } + } + if !tmpBeg.IsZero() || !tmpEnd.IsZero() { + beg, end = tmpBeg, tmpEnd + } + } + if end.IsZero() { + end = time.Now() + } + entry = NewTimer(beg, end) + fmt.Println("Adding Timer:", entry.FriendlyString()) + entry.Tags = tags + if err = SaveTimer(entry); err != nil { + fmt.Println(err) + return 1 + } + fmt.Println("Added Time Entry:", entry.FriendlyString()) + return 0 +} + +func cmdContinueTimer(args []string) int { + // Get the last running timer and start a new one with the same tags + return 0 +} + +func cmdStopTimer(args []string) int { + var err error + tm := time.Now() + actTimers := LoadActiveTimers() + var tmr *Timer + stopId := "@0" // By default, stop the first timer + for i := range args { + if args[i][0] == '@' { + stopId = args[i] + continue + } + tmpTm, err := parseFuzzyTime(args[i]) + if err == nil { + // We found a time + tm = tmpTm + continue + } + } + if stopId != "@all" { + // Find the timer that we're stopping + timerId, err := strconv.Atoi(stopId[1:]) + if err != nil { + fmt.Println("Error parsing timer id:", err.Error()) + return 1 + } + tmr = &actTimers[timerId] + if timerId >= len(actTimers) || timerId < 0 || tmr == nil { + fmt.Println("Error finding timer with id:", timerId) + return 1 + } + } + + stopTimer := func(tmr *Timer, at time.Time) int { + tmr.SetEnd(at) + if err = SaveTimer(tmr); err != nil { + fmt.Println(err.Error()) + return 1 + } + fmt.Println("Stopped:", tmr.InferDetailString()) + return 0 + } + if stopId == "@all" { + var ret int + for _, v := range actTimers { + ret += stopTimer(&v, tm) + } + if ret > 0 { + return 1 + } + return 0 + } + + return stopTimer(tmr, tm) +} + +func cmdDeleteTimer(args []string) int { + if len(args) < 1 { + fmt.Println("Delete requires a timer UUID") + return 1 + } + // Try to parse args[0] as a UUID + if uuid.Parse(args[0]) == nil { + // Not a UUID, bail for now + fmt.Print("Invalid UUID given (", args[0], ")\n") + return 1 + } + tmr := LoadTimer(args[0]) + if tmr == nil { + fmt.Println("Couldn't find timer with UUID (", args[0], ")\n") + return 1 + } + if DeleteTimer(args[0]) != nil { + fmt.Println("Error deleting timer") + return 1 + } + return 0 +} + +func cmdModifyTimer(args []string) int { + return 0 +} + +func cmdSwitchTimer(args []string) int { + return 0 +} + +func cmdPrintStatus(args []string) int { + tmrs := LoadActiveTimers() + curr := time.Now() + fmt.Println("Current Time:", curr.Format(time.Stamp)) + if len(tmrs) == 0 { + fmt.Println("No timer running") + } else { + fmt.Print("Active Timers (", len(tmrs), ")\n") + // Find the longest start time & longest duration + short := true + for _, v := range tmrs { + if v.GetStart().Day() != curr.Day() { + short = false + break + } + } + + for i, v := range tmrs { + if short { + fmt.Printf(" @%d %s\n", i, v.DetailString()) + } else { + fmt.Printf(" @%d %s\n", i, v.LongDetailString()) + } + } + } + return 0 +} + +func cmdPrintList(args []string) int { + useDefaultFilter := true + var showIds bool + var beg, end time.Time + tags, rem := pullTagsFromArgs(args) + _ = tags + for _, opt := range rem { + var tmpBeg, tmpEnd time.Time + // Check for command modifiers + if strings.HasPrefix(opt, ":") { + switch opt { + case ":ids": + showIds = true + case ":day": + beg, _ = parseFuzzyTime("00:00") + end, _ = parseFuzzyTime("23:59") + case ":week": + currDoW := time.Now().Weekday() + beg = time.Now().AddDate(0, 0, int(currDoW)*-1) + beg = time.Date(beg.Year(), beg.Month(), beg.Day(), 0, 0, 0, 0, beg.Location()) + case ":month": + currDoM := time.Now().Day() + beg = time.Now().AddDate(0, 0, int(currDoM)*-1) + beg = time.Date(beg.Year(), beg.Month(), beg.Day(), 0, 0, 0, 0, beg.Location()) + case ":year": + yr := strconv.Itoa(time.Now().Year()) + beg, _ = parseFuzzyTime(yr + "0101T00:00") + end, _ = parseFuzzyTime(yr + "1231T23:59") + } + continue + } + + // Do our best to figure out what timers the user wants to list + var err error + if strings.Contains(opt, "-") { + useDefaultFilter = false + pts := strings.Split(opt, "-") + if len(pts[0]) > 0 { + // This should be the starting date + tmpBeg, err = parseFuzzyTime(pts[0]) + if err != nil { + continue + } + } + if len(pts[1]) > 0 { + // This should be the ending date + tmpEnd, err = parseFuzzyTime(pts[1]) + if err != nil { + continue + } + } + } + if !tmpBeg.IsZero() || !tmpEnd.IsZero() { + beg, end = tmpBeg, tmpEnd + } + } + if end.IsZero() { + end = time.Now() + } + + // By default, list all entries ending today or still running + dayStr := "" + tmrs := LoadActiveTimers() + tmrs = append(tmrs, LoadTimers(beg, end)...) + var str string + if len(tmrs) == 0 { + if useDefaultFilter { + fmt.Println("No timers found for today") + } else { + begFmt := friendlyFormatForTime(beg) + endFmt := friendlyFormatForTime(end) + useFmt := endFmt + if len(begFmt) > len(endFmt) { + useFmt = begFmt + } + fmt.Println("No timers found in period " + beg.Format(useFmt) + " - " + end.Format(useFmt)) + } + return 0 + } + + // Get day totals + dayTotals := make(map[string]time.Duration) + for _, v := range tmrs { + dur := v.GetEnd().Sub(v.GetStart()) + if v.GetEnd().IsZero() { + dur = time.Now().Sub(v.GetStart()) + } + dayTotals[v.GetStart().Format("2006/01/02")] += dur + } + for _, v := range tmrs { + oldDayStr := dayStr + dayStr = v.GetStart().Format("2006/01/02") + if dayStr != oldDayStr { + vDur := dayTotals[dayStr].Round(GetRoundToDuration()) + fmtStr := dayStr + " ( %.2f )\n" + str += fmt.Sprintf(fmtStr, DurationToDecimal(vDur)) + } + _ = showIds + id := "" + str += fmt.Sprintf(" %s %s\n", id, v.FriendlyString()) + } + fmt.Println(str) + return 0 +} + +func cmdPrintDetail(args []string) int { + fmt.Println("Not implemented yet.") + return 1 +} diff --git a/timer.go b/timer.go new file mode 100644 index 0000000..faf2d60 --- /dev/null +++ b/timer.go @@ -0,0 +1,228 @@ +package main + +import ( + "time" + + "github.com/pborman/uuid" +) + +type Timer struct { + Uuid string `json:"uuid"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Tags []string `json:"tags"` + Notes string `json:"notes"` + Billed bool `json:"billed"` +} + +// NewTimer returns a new timer with the given start and end +func NewTimer(st, end time.Time) *Timer { + ret := Timer{ + Uuid: uuid.New(), + Start: st, + End: end, + } + return &ret +} + +// GetUUID returns the uuid of the timer +func (t *Timer) GetUUID() string { + return t.Uuid +} + +// GetStart returns the start time of the timer +func (t *Timer) GetStart() time.Time { + return t.Start +} + +// SetStart sets the starting time of the timer +func (t *Timer) SetStart(st time.Time) { + t.Start = st +} + +// GetEnd returns the end time of the timer +func (t *Timer) GetEnd() time.Time { + return t.End +} + +// SetEnd sets the ending time of the timer +func (t *Timer) SetEnd(end time.Time) { + t.End = end +} + +// IsActive returns if the timer is still active +func (t *Timer) IsActive() bool { + return time.Now().After(t.Start) && t.End.IsZero() +} + +// SetTags sets the timer's tags +func (t *Timer) SetTags(tags []string) { + t.Tags = tags +} + +// GetTags returns all of the tags for the timer +func (t *Timer) GetTags() []string { + return t.Tags +} + +// AddTag adds the given tag to the timer +func (t *Timer) AddTag(tg string) { + for _, v := range t.Tags { + if v == tg { + return + } + } + t.Tags = append(t.Tags, tg) +} + +// HasTag returns if the timer has the given tag +func (t *Timer) HasTag(s string) bool { + for _, v := range t.Tags { + if v == s { + return true + } + } + return false +} + +// RemoveTag will remove the given tag from the timer +func (t *Timer) RemoveTag(tg string) { + var tgs []string + for _, v := range t.Tags { + if v != tg { + tgs = append(tgs, v) + } + } + t.Tags = tgs +} + +// SetNotes sets the timer's notes to the given string +func (t *Timer) SetNotes(nt string) { + t.Notes = nt +} + +// StartsToday returns if the timer's end time is today +func (t *Timer) StartsToday() bool { + currTime := time.Now() + dur := int64(currTime.Hour())*int64(time.Hour) + int64(currTime.Minute())*int64(time.Minute) + return int64(time.Since(t.GetStart())) < dur +} + +// EndsToday returns if the timer's end time is today +func (t *Timer) EndsToday() bool { + currTime := time.Now() + dur := int64(currTime.Hour())*int64(time.Hour) + int64(currTime.Minute())*int64(time.Minute) + return int64(time.Since(t.GetEnd())) < dur +} + +// IsRunning returns if the timer is still running +func (t *Timer) IsRunning() bool { + return t.GetEnd().IsZero() +} + +// IsBillable checks if any of the tags on the timer are flagged as billable +func (t *Timer) IsBillable() bool { + m, err := LoadMetadata() + if err != nil { + return false + } + for _, v := range m.GetTags(t.Tags) { + if v.Billable { + return true + } + } + return false +} + +// String formats a string of the time entry +func (t *Timer) String() string { + var ret string + ret = t.GetStart().Format(time.RFC3339) + if !t.GetEnd().IsZero() { + ret += " - " + t.GetEnd().Format(time.RFC3339) + } + if len(t.GetTags()) > 0 { + ret += " [ " + for _, v := range t.GetTags() { + ret += v + " " + } + ret += "]" + } + if t.GetEnd().IsZero() { + ret += " Running" + } + return ret +} + +func (t *Timer) FriendlyString() string { + var ret string + var end string + if t.StartsToday() { + ret = t.GetStart().Format("15:04 - ") + end = "**:**" + } else { + ret = t.GetStart().Format("2006/01/02 15:04:05 - ") + end = "**:**:**" + } + if !t.GetEnd().IsZero() { + if t.EndsToday() { + end = t.GetEnd().Format("15:04") + } else { + end = t.GetEnd().Format("2006/01/02 15:04:05") + } + } + ret += end + if len(t.GetTags()) > 0 { + ret += " [ " + for _, v := range t.GetTags() { + ret += v + " " + } + ret += "]" + } + return ret +} + +func (t *Timer) InferDetailString() string { + diffEnd := time.Now() + if !t.GetEnd().IsZero() { + diffEnd = t.GetEnd() + } + if int(diffEnd.Sub(t.GetStart())) >= (int(time.Hour) * diffEnd.Hour()) { + return t.LongDetailString() + } + return t.DetailString() +} + +func (t *Timer) DetailString() string { + ret := t.GetStart().Format("15:04") + " - " + if t.GetEnd().IsZero() { + ret += "**:** (" + padLeft(sinceToString(t.GetStart()), len("00h 00m 00s")) + ") " + } else { + ret += t.GetEnd().Format("15:04") + " (" + padLeft(diffToString(t.GetStart(), t.GetEnd()), len("00h 00m 00s")) + ") " + } + if len(t.GetTags()) > 0 { + ret += " [ " + for _, v := range t.GetTags() { + ret += v + " " + } + ret += "] " + } + return ret +} + +func (t *Timer) LongDetailString() string { + ret := t.GetStart().Format(time.Stamp) + if t.GetEnd().IsZero() { + ret += " (" + padLeft(sinceToString(t.GetStart()), len("0000y 00m 00d 00h 00m 00s")) + ") " + } else { + ret += " (" + padLeft(diffToString(t.GetStart(), t.GetEnd()), len("0000y 00m 00d 00h 00m 00s")) + ") " + } + if len(t.GetTags()) > 0 { + ret += " [ " + for _, v := range t.GetTags() { + ret += v + " " + } + ret += "] " + } + return ret +}