tme

Toggl like Time Manager
git clone git://gtms.dev:tme
Log | Files | Refs

commit 2fbc5eb1861dba9b1915de64b62fade04cf7248f
Author: Tomas Nemec <nemi@skaut.cz>
Date:   Mon,  5 Dec 2022 06:12:41 +0100

init

Diffstat:
Ago.mod | 3+++
Amain.go | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atme/entry.go | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atme/entry_test.go | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atme/group.go | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atme/time.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Atme_test.go | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 728 insertions(+), 0 deletions(-)

diff --git a/go.mod b/go.mod @@ -0,0 +1,3 @@ +module gtms.dev/tme + +go 1.19 diff --git a/main.go b/main.go @@ -0,0 +1,207 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "gtms.dev/tme/tme" +) + +const ( + activeFile = "active" +) + +func main() { + basePath := os.Getenv("TME_DIR") + if basePath == "" { + fmt.Fprintln(os.Stderr, "TME_DIR environment variable is not set.") + os.Exit(1) + } + + if _, err := os.Stat(basePath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "%s does not exist\n", basePath) + os.Exit(1) + } + + args := os.Args[1:] + if len(args) == 0 { + os.Exit(0) + } + + cmd := args[0] + args = args[1:] // shift + + entryTime := tme.NewTime() + command := Command{basePath, args, entryTime} + if cmd == "add" { + command.add() + } else if cmd == "start" { + command.start() + } else if cmd == "stop" { + command.stop() + } else if cmd == "ls" { + command.ls() + } +} + +type Command struct { + basePath string + args []string + entryTime *tme.Time +} + +func (c *Command) nextArg() (string, error) { + if len(c.args) == 0 { + return "", errors.New("No more arguments") + } + + if len(c.args) == 1 { + first := (c.args)[0] + c.args = make([]string, 0) + return first, nil + } + + first := (c.args)[0] + c.args = (c.args)[1:] + return first, nil +} + +func (c Command) add() { + if len(c.args) != 3 { + fmt.Fprintln(os.Stderr, "add <path> <start> <stop>") + os.Exit(1) + } + + groupArg, _ := c.nextArg() + startArg, _ := c.nextArg() + stopArg, _ := c.nextArg() + + start, err := c.entryTime.ParseArg(startArg) + if err != nil { + fmt.Fprintf(os.Stderr, "[start] time (%s) could not be parsed\n", startArg) + os.Exit(1) + } + + stop, err := c.entryTime.ParseArg(stopArg) + if err != nil { + fmt.Fprintf(os.Stderr, "[stop] time (%s) could not be parsed\n", stopArg) + os.Exit(1) + } + + group := tme.NewGroup(c.basePath, groupArg) + // TODO(tms) 22.10.22: maybe ask if user want create new folder (fe: typo) + group.Create() + + entry, err := tme.NewEntry(start, stop) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if entry.Exists(group) { + fmt.Fprintln(os.Stderr, "entry already created") + os.Exit(1) + } + + group.Add(entry) +} + +func (c Command) start() { + if len(c.args) != 2 { + fmt.Fprintln(os.Stderr, "start <path> <start>") + os.Exit(1) + } + + groupArg, _ := c.nextArg() + startArg, _ := c.nextArg() + + start, err := c.entryTime.ParseArg(startArg) + if err != nil { + fmt.Fprintf(os.Stderr, "[start] time (%s) could not be parsed\n", startArg) + os.Exit(1) + } + + group := tme.NewGroup(c.basePath, groupArg) + // TODO(tms) 22.10.22: maybe ask if user want create new folder (fe: typo) + group.Create() + + entry := tme.NewStartEntry(start) + + if entry.Exists(group) { + fmt.Fprintln(os.Stderr, "entry in this group already started") + os.Exit(1) + } + + group.Add(entry) +} + +func (c Command) stop() { + if len(c.args) != 2 { + fmt.Fprintln(os.Stderr, "stop <path> <stop>") + os.Exit(1) + } + + groupArg, _ := c.nextArg() + stopArg, _ := c.nextArg() + + stop, err := c.entryTime.ParseArg(stopArg) + if err != nil { + fmt.Fprintf(os.Stderr, "[start] time (%s) could not be parsed\n", stopArg) + os.Exit(1) + } + + group := tme.NewGroup(c.basePath, groupArg) + + startEntry, err := tme.NewStartEntryFromGroup(group, c.entryTime) + if err != nil { + fmt.Fprintf(os.Stderr, "no entry running in %q\n", groupArg) + os.Exit(1) + } + + entry, err := startEntry.Stop(stop) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + if entry.Exists(group) { + fmt.Fprintln(os.Stderr, "entry already created") + os.Exit(1) + } + + err = group.Add(entry) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + err = group.Remove(startEntry) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func (c Command) ls() { + if len(c.args) > 1 { + fmt.Fprintln(os.Stderr, "ls [<path>]") + os.Exit(1) + } + + rootPath := "" + if len(c.args) == 1 { + rootPath, _ = c.nextArg() + } + + group := tme.NewGroup(c.basePath, rootPath) + files, err := group.List() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + for _, file := range files { + fmt.Println(file) + } +} diff --git a/tme/entry.go b/tme/entry.go @@ -0,0 +1,126 @@ +package tme + +import ( + "errors" + "fmt" + "os" + "path" + "strings" + "time" +) + +type Entry interface { + Data() string + FileName() string + FullPath(group Group) string +} + +type FinalEntry struct { + Start time.Time + Stop time.Time +} + +func NewEntry(start time.Time, stop time.Time) (FinalEntry, error) { + if start.After(stop) { + return FinalEntry{}, errors.New("duration must be positive") + } + + return FinalEntry{ + Start: start, + Stop: stop, + }, nil +} + +func NewEntryFromPath(path string, entryTime *Time) (FinalEntry, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return FinalEntry{}, err + } + + data, err := os.ReadFile(path) + if err != nil { + return FinalEntry{}, err + } + + // TODO(tms) 11.11.22: format check + + lines := strings.Split(string(data), "\n") + start, _ := entryTime.ParseEntry(lines[0]) + stop, _ := entryTime.ParseEntry(lines[1]) + + return FinalEntry{ + Start: start, + Stop: stop, + }, nil +} + +func (e FinalEntry) Data() string { + return fmt.Sprintf("%s\n%s\n", e.Start.Format(DateTimeLayout), e.Stop.Format(DateTimeLayout)) +} + +func (e FinalEntry) FileName() string { + return strings.Join([]string{e.Start.Format(FileNameLayout), e.Stop.Format(FileNameLayout)}, "_") +} + +func (e FinalEntry) FullPath(group Group) string { + return path.Join(group.FullPath(), e.FileName()) +} + +func (e FinalEntry) Exists(group Group) bool { + if _, err := os.Stat(e.FullPath(group)); !os.IsNotExist(err) { + return true + } + return false +} + +type StartEntry struct { + Start time.Time +} + +func NewStartEntry(start time.Time) StartEntry { + return StartEntry{ + Start: start, + } +} + +func NewStartEntryFromGroup(group Group, entryTime *Time) (StartEntry, error) { + startEntryPath := group.ActivePath() + if _, err := os.Stat(startEntryPath); os.IsNotExist(err) { + return StartEntry{}, err + } + + data, err := os.ReadFile(startEntryPath) + if err != nil { + return StartEntry{}, err + } + + firstLine := strings.Split(string(data), "\n")[0] + start, _ := entryTime.ParseEntry(firstLine) + + return StartEntry{ + Start: start, + }, nil + +} + +func (e StartEntry) Data() string { + return fmt.Sprintf("%s\n", e.Start.Format(DateTimeLayout)) +} + +func (e StartEntry) FileName() string { + return "active" +} + +func (e StartEntry) FullPath(group Group) string { + return path.Join(group.FullPath(), e.FileName()) +} + +func (e StartEntry) Exists(group Group) bool { + if _, err := os.Stat(e.FullPath(group)); !os.IsNotExist(err) { + return true + } + return false +} + +func (e StartEntry) Stop(stop time.Time) (FinalEntry, error) { + return NewEntry(e.Start, stop) +} diff --git a/tme/entry_test.go b/tme/entry_test.go @@ -0,0 +1,84 @@ +package tme + +import ( + "os" + "testing" +) + +func TestEntry(t *testing.T) { + entryTime := NewTime() + + t.Run("positive duration is ok", func(b *testing.T) { + start, _ := entryTime.ParseArg("5:00") + stop, _ := entryTime.ParseArg("6:00") + _, err := NewEntry(start, stop) + if err != nil { + b.Error(err) + } + }) + + t.Run("same duration is kind of ok?", func(b *testing.T) { + start, _ := entryTime.ParseArg("6:00") + stop, _ := entryTime.ParseArg("6:00") + _, err := NewEntry(start, stop) + if err != nil { + b.Error(err) + } + }) + + t.Run("negative duration should fail", func(b *testing.T) { + start, _ := entryTime.ParseArg("6:00") + stop, _ := entryTime.ParseArg("5:00") + _, err := NewEntry(start, stop) + if err == nil { + b.Error(err) + } + }) +} + +func TestStartStopEntry(t *testing.T) { + basePath := setUp() + entryTime := NewTime() + t.Cleanup(func() { tearDown(basePath) }) + + start, _ := entryTime.ParseArg("6:00") + startEntry := NewStartEntry(start) + + group := NewGroup(basePath, "project") + group.Create() + + group.Add(startEntry) + if _, err := os.Stat(startEntry.FullPath(group)); os.IsNotExist(err) { + t.Fatalf(`want %q to exist`, startEntry.FullPath(group)) + } + + stop, _ := entryTime.ParseArg("7:00") + finalEntry, err := startEntry.Stop(stop) + if err != nil { + t.Error(err) + } + + group.Remove(startEntry) + if _, err := os.Stat(startEntry.FullPath(group)); !os.IsNotExist(err) { + t.Fatalf(`want %q to be deleted`, startEntry.FullPath(group)) + } + + group.Add(finalEntry) + if _, err := os.Stat(finalEntry.FullPath(group)); os.IsNotExist(err) { + t.Fatalf(`want %q to exist`, finalEntry.FullPath(group)) + } +} + +func setUp() string { + tempDir, err := os.MkdirTemp("", "tme_*") + if err != nil { + panic(err) + } + + os.Setenv("TME_DIR", tempDir) + return tempDir +} + +func tearDown(tempDir string) { + os.RemoveAll(tempDir) +} diff --git a/tme/group.go b/tme/group.go @@ -0,0 +1,97 @@ +package tme + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" +) + +type Group struct { + basePath string + Path string +} + +func NewGroup(basePath, path string) Group { + return Group{ + basePath: basePath, + Path: path, + } +} + +func (g Group) Create() error { + if err := os.MkdirAll(g.FullPath(), os.ModePerm); err != nil { + return err + } + return nil +} + +func (g Group) FullPath() string { + return path.Join(g.basePath, g.Path) +} + +func (g Group) EntryPath(entry Entry) string { + return path.Join(g.Path, entry.FileName()) +} + +func (g Group) ActivePath() string { + return path.Join(g.FullPath(), "active") +} + +func (g Group) Exists() bool { + if _, err := os.Stat(g.FullPath()); !os.IsNotExist(err) { + return true + } + return false +} + +func (g Group) Add(entry Entry) error { + err := os.WriteFile(entry.FullPath(g), []byte(entry.Data()), os.ModePerm) + if err != nil { + return err + } + return nil +} + +func (g Group) Remove(entry Entry) error { + return os.Remove(entry.FullPath(g)) +} + +func (g Group) List() ([]string, error) { + if !g.Exists() { + return []string{}, errors.New("Group does not exist") + } + + var files []string + err := filepath.WalkDir(g.FullPath(), func(path string, d fs.DirEntry, err error) error { + if d.IsDir() { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + return []string{}, err + } + + return files, nil +} + +func (g Group) FormatList(entryTime *Time) ([]string, error) { + filePaths, err := g.List() + if err != nil { + return []string{}, err + } + + var lines []string + for _, path := range filePaths { + entry, err := NewEntryFromPath(path, entryTime) + if err != nil { + return []string{}, err + } + lines = append(lines, fmt.Sprintf("%s %s %s", g.Path, entry.Start.Format(TimeLayout), entry.Stop.Format(TimeLayout))) + } + return lines, nil +} diff --git a/tme/time.go b/tme/time.go @@ -0,0 +1,45 @@ +package tme + +import "time" + +const ( + TimeLayout = "15:04" + FileNameLayout = "0601021504" + DateTimeLayout = time.RFC3339 +) + +type Time struct { + context time.Time +} + +func NewTime() *Time { + return &Time{time.Now()} +} + +func (et *Time) ParseArg(raw string) (time.Time, error) { + t, err := time.Parse(TimeLayout, raw) + return time.Date( + et.context.Year(), + et.context.Month(), + et.context.Day(), + t.Hour(), + t.Minute(), + 0, + 0, + et.context.Location(), + ), err +} + +func (et *Time) ParseEntry(raw string) (time.Time, error) { + t, err := time.Parse(DateTimeLayout, raw) + return time.Date( + t.Year(), + t.Month(), + t.Day(), + t.Hour(), + t.Minute(), + 0, + 0, + et.context.Location(), + ), err +} diff --git a/tme_test.go b/tme_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "fmt" + "os" + "strings" + "testing" + "time" + + "gtms.dev/tme/tme" +) + +func TestAdd(t *testing.T) { + basePath := setUp() + t.Cleanup(func() { tearDown(basePath) }) + entryTime := tme.NewTime() + group := createAndCheckGroup(t, basePath, "project") + + start, _ := entryTime.ParseArg("10:00") + stop, _ := entryTime.ParseArg("11:00") + tme, err := tme.NewEntry(start, stop) + if err != nil { + t.Error(err) + } + + group.Add(tme) + if data, err := os.ReadFile(tme.FullPath(group)); err != nil { + fmt.Printf("%v", err) + } else { + checkEntryFile(start, stop, data, entryTime, t) + } + +} + +func TestStart(t *testing.T) { + basePath := setUp() + t.Cleanup(func() { tearDown(basePath) }) + entryDir := "project" + fullEntryDir := strings.Join([]string{basePath, entryDir}, "/") + entryTime := tme.NewTime() + + group := createAndCheckGroup(t, basePath, "project") + + start, _ := entryTime.ParseArg("10:00") + entry := tme.NewStartEntry(start) + + group.Add(entry) + if _, err := os.Stat(fullEntryDir); os.IsNotExist(err) { + t.Fatalf(`want "%v" to exist`, entryDir) + } + + if data, err := os.ReadFile(entry.FullPath(group)); err != nil { + fmt.Printf("%v", err) + } else { + checkStartEntryFile(start, data, entryTime, t) + } +} + +func TestStop(t *testing.T) { + basePath := setUp() + t.Cleanup(func() { tearDown(basePath) }) + entryTime := tme.NewTime() + + group := tme.NewGroup(basePath, "project") + group.Create() + + start, _ := entryTime.ParseArg("10:00") + startEntry := tme.NewStartEntry(start) + group.Add(startEntry) + + stop, _ := entryTime.ParseArg("11:00") + entry, err := tme.NewEntry(startEntry.Start, stop) + if err != nil { + t.Error(err) + } + + group.Add(entry) + if data, err := os.ReadFile(entry.FullPath(group)); err != nil { + t.Errorf("%v", err) + } else { + checkEntryFile(start, stop, data, entryTime, t) + } +} + +func ExampleList() { + basePath := setUp() + entryTime := tme.NewTime() + + group := tme.NewGroup(basePath, "tme") + group.Create() + + start, _ := entryTime.ParseArg("10:00") + stop, _ := entryTime.ParseArg("11:00") + entry, _ := tme.NewEntry(start, stop) + group.Add(entry) + + lines, err := group.FormatList(entryTime) + if err != nil { + fmt.Print(err) + } + + for _, line := range lines { + fmt.Println(line) + } + + // Output: + // tme 10:00 11:00 + + tearDown(basePath) +} + +func checkEntryFile(start time.Time, stop time.Time, data []byte, entryTime *tme.Time, t *testing.T) { + lines := strings.Split(string(data), "\n") + expectLines := 3 + if len(lines) != expectLines { + t.Fatalf("want %d lines, got %d lines", expectLines, len(lines)) + } + + startLine := lines[0] + lineStart, _ := entryTime.ParseEntry(startLine) + if start != lineStart { + t.Fatalf("time mismatch! %v != %v", start, lineStart) + } + + stopLine := lines[1] + lineStop, _ := entryTime.ParseEntry(stopLine) + if stop != lineStop { + t.Fatalf("time mismatch! %v != %v", stop, lineStop) + } +} + +func checkStartEntryFile(start time.Time, data []byte, entryTime *tme.Time, t *testing.T) { + lines := strings.Split(string(data), "\n") + expectLines := 2 + if len(lines) != expectLines { + t.Fatalf("want %d lines, got %d lines", expectLines, len(lines)) + } + + startLine := lines[0] + lineStart, _ := entryTime.ParseEntry(startLine) + if start != lineStart { + t.Fatalf("time mismatch! %v != %v", start, lineStart) + } +} +func createAndCheckGroup(t *testing.T, basePath, groupName string) tme.Group { + group := tme.NewGroup(basePath, groupName) + group.Create() + if _, err := os.Stat(group.FullPath()); os.IsNotExist(err) { + t.Fatalf(`want %q to exist`, group.Path) + } + return group +} + +func setUp() string { + tempDir, err := os.MkdirTemp("", "tme_*") + if err != nil { + panic(err) + } + + os.Setenv("TME_DIR", tempDir) + return tempDir +} + +func tearDown(tempDir string) { + os.RemoveAll(tempDir) +}