commit 746cebf509c26bada372fe4eb146402b05ac44e1 Author: zervo Date: Fri Nov 7 13:40:22 2025 +0100 Initial commit diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8ddffd9 --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +BINARY_NAME=worktime +VERSION=$(shell git describe --tags $(shel git rev-list --tags --max-count=1) || echo "v0.0.0") +COMMIT=$(shell git rev-parse --short HEAD || echo "?") + +all: build test + +build: + go build -ldflags "-X 'git.zervo.org/zervo/worktime/internal/cli/commands/version.Version=$(VERSION)' -X 'git.zervo.org/zervo/worktime/internal/cli/commands/version.Commit=$(COMMIT)'" -o $(BINARY_NAME) cmd/worktime/main.go + +test: + go test -v cmd/worktime/main.go + +run: + go build -ldflags "-X 'git.zervo.org/zervo/worktime/internal/cli/commands/version.Version=$(VERSION)' -X 'git.zervo.org/zervo/worktime/internal/cli/commands/version.Commit=$(COMMIT)'" -o $(BINARY_NAME) cmd/worktime/main.go + ./$(BINARY_NAME) $(ARGS) + +clean: + go clean + rm $(BINARY_NAME) \ No newline at end of file diff --git a/cmd/worktime/main.go b/cmd/worktime/main.go new file mode 100644 index 0000000..bb07977 --- /dev/null +++ b/cmd/worktime/main.go @@ -0,0 +1,9 @@ +package main + +import ( + "git.zervo.org/zervo/worktime/internal/cli/commands" +) + +func main() { + commands.Execute() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6314c1a --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module git.zervo.org/zervo/worktime + +go 1.25.1 + +require ( + github.com/google/uuid v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.32 // indirect + github.com/spf13/cobra v1.10.1 + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/text v0.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3611052 --- /dev/null +++ b/go.sum @@ -0,0 +1,25 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/internal/cli/color/color.go b/internal/cli/color/color.go new file mode 100644 index 0000000..96688be --- /dev/null +++ b/internal/cli/color/color.go @@ -0,0 +1,60 @@ +package color + +const ( + // Reset all attributes + Reset = "\033[0m" + + // Regular Colors + Black = "\033[30m" + Red = "\033[31m" + Green = "\033[32m" + Yellow = "\033[33m" + Blue = "\033[34m" + Magenta = "\033[35m" + Cyan = "\033[36m" + White = "\033[37m" + + // Bright Colors + BrightBlack = "\033[90m" + BrightRed = "\033[91m" + BrightGreen = "\033[92m" + BrightYellow = "\033[93m" + BrightBlue = "\033[94m" + BrightMagenta = "\033[95m" + BrightCyan = "\033[96m" + BrightWhite = "\033[97m" + + // Background Colors + BgBlack = "\033[40m" + BgRed = "\033[41m" + BgGreen = "\033[42m" + BgYellow = "\033[43m" + BgBlue = "\033[44m" + BgMagenta = "\033[45m" + BgCyan = "\033[46m" + BgWhite = "\033[47m" + + // Bright Background Colors + BgBrightBlack = "\033[100m" + BgBrightRed = "\033[101m" + BgBrightGreen = "\033[102m" + BgBrightYellow = "\033[103m" + BgBrightBlue = "\033[104m" + BgBrightMagenta = "\033[105m" + BgBrightCyan = "\033[106m" + BgBrightWhite = "\033[107m" + + // Text styles + Bold = "\033[1m" + Dim = "\033[2m" + Italic = "\033[3m" + Underline = "\033[4m" + Blink = "\033[5m" + Reverse = "\033[7m" + Hidden = "\033[8m" + Strike = "\033[9m" +) + +func Colorize(s string, color string) string { + return color + s + Reset +} diff --git a/internal/cli/commands/group/add.go b/internal/cli/commands/group/add.go new file mode 100644 index 0000000..1e18015 --- /dev/null +++ b/internal/cli/commands/group/add.go @@ -0,0 +1,52 @@ +package group + +import ( + "context" + "fmt" + + "git.zervo.org/zervo/worktime/internal/database" + "git.zervo.org/zervo/worktime/internal/database/models" + "github.com/spf13/cobra" + "gorm.io/gorm" +) + +var addCmd = &cobra.Command{ + Use: "add [flags] (name) [description]", + Short: "Add a group", + Long: `Create a new locally stored project group`, + Args: cobra.MatchAll(cobra.MinimumNArgs(1), cobra.MaximumNArgs(2)), + RunE: addExecute, +} + +func addExecute(cmd *cobra.Command, args []string) error { + name := args[0] + desc := "" + + if len(args) > 1 { + desc = args[1] + } + + if len(name) > 255 { + return fmt.Errorf("given name is too long (%d): maximum allowed length is 255", len(name)) + } + + db := database.DB() + ctx := context.Background() + + _, err := gorm.G[models.ProjectGroup](db).Where("name = ?", name).First(ctx) + if err != gorm.ErrRecordNotFound { + return fmt.Errorf("group with this name already exists") + } + + group := models.ProjectGroup{ + Name: name, + Description: desc, + } + + err = gorm.G[models.ProjectGroup](db).Create(ctx, &group) + if err != nil { + return fmt.Errorf("could not add group: %v", err) + } + + return nil +} diff --git a/internal/cli/commands/group/edit.go b/internal/cli/commands/group/edit.go new file mode 100644 index 0000000..e724022 --- /dev/null +++ b/internal/cli/commands/group/edit.go @@ -0,0 +1,57 @@ +package group + +import ( + "context" + "fmt" + + "git.zervo.org/zervo/worktime/internal/database" + "git.zervo.org/zervo/worktime/internal/database/models" + "github.com/spf13/cobra" + "gorm.io/gorm" +) + +var editCmd = &cobra.Command{ + Use: "edit [flags] (group) (field) (value)", + Short: "Edit details for a group", + Long: `Edit details, such as name or description, for a locally stored project group. +Specify group by ID or name, which can be found with 'group list'. +Specify either 'name' or 'description' as field. +Specify new desired value as value.`, + Args: cobra.ExactArgs(3), + RunE: editExecute, +} + +func editExecute(cmd *cobra.Command, args []string) error { + groupId := args[0] + field := args[1] + value := args[2] + + if len(value) > 255 { + return fmt.Errorf("given value is too long (%d): maximum allowed length is 255", len(value)) + } + + db := database.DB() + ctx := context.Background() + + group, err := gorm.G[models.ProjectGroup](db).Where("id = ?", groupId).First(ctx) + if err == gorm.ErrRecordNotFound { + group, err = gorm.G[models.ProjectGroup](db).Where("name = ?", groupId).First(ctx) + if err == gorm.ErrRecordNotFound { + return fmt.Errorf("no group with given name or ID found") + } + } + + switch field { + case "name": + group.Name = value + case "description": + group.Description = value + default: + return fmt.Errorf("uknown field '%s': expected 'name' or 'description'", field) + } + + db.Save(&group) + + fmt.Println("Edited project!") + return nil +} diff --git a/internal/cli/commands/group/group.go b/internal/cli/commands/group/group.go new file mode 100644 index 0000000..54d3146 --- /dev/null +++ b/internal/cli/commands/group/group.go @@ -0,0 +1,18 @@ +package group + +import ( + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "group", + Short: "Manage project groups", + Long: `List, edit, add or remove logical groups used to organize projects`, +} + +func init() { + Cmd.AddCommand(listCmd) + Cmd.AddCommand(addCmd) + Cmd.AddCommand(removeCmd) + Cmd.AddCommand(editCmd) +} diff --git a/internal/cli/commands/group/list.go b/internal/cli/commands/group/list.go new file mode 100644 index 0000000..588913e --- /dev/null +++ b/internal/cli/commands/group/list.go @@ -0,0 +1,32 @@ +package group + +import ( + "context" + "fmt" + + "git.zervo.org/zervo/worktime/internal/database" + "git.zervo.org/zervo/worktime/internal/database/models" + "github.com/spf13/cobra" + "gorm.io/gorm" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List all groups", + Long: `List all locally stored project groups`, + RunE: listExecute, +} + +func listExecute(cmd *cobra.Command, args []string) error { + db := database.DB() + ctx := context.Background() + + groups, err := gorm.G[models.ProjectGroup](db).Find(ctx) + if err != nil { + return fmt.Errorf("aaa") + } + + fmt.Printf("Amount: %d\n", len(groups)) + + return nil +} diff --git a/internal/cli/commands/group/remove.go b/internal/cli/commands/group/remove.go new file mode 100644 index 0000000..d745c2e --- /dev/null +++ b/internal/cli/commands/group/remove.go @@ -0,0 +1,18 @@ +package group + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove", + Short: "Remove a group", + Long: `Remove a locally stored project group`, + Run: removeExecute, +} + +func removeExecute(cmd *cobra.Command, args []string) { + fmt.Println("Removing!") +} diff --git a/internal/cli/commands/root.go b/internal/cli/commands/root.go new file mode 100644 index 0000000..d0cb041 --- /dev/null +++ b/internal/cli/commands/root.go @@ -0,0 +1,41 @@ +package commands + +import ( + "fmt" + "os" + + cmd_group "git.zervo.org/zervo/worktime/internal/cli/commands/group" + cmd_version "git.zervo.org/zervo/worktime/internal/cli/commands/version" + "git.zervo.org/zervo/worktime/internal/util" + + "github.com/spf13/cobra" +) + +var ( + verbose bool + + rootCmd = &cobra.Command{ + Use: "worktime", + Short: "WorkTime is a CLI project time management utility", + Long: `WorkTime is a CLI time management utility for keeping track of time spent on different projects. +Can associate git directories and user-provided summaries with sessions. Exports to CSVs or directly to PDFs.`, + } +) + +// Execute will execute the root command of the application +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + rootCmd.SetErrPrefix("ERROR:") + rootCmd.SetErr(util.NewErrorWriter()) + + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "V", false, "increase output verbosity") + + rootCmd.AddCommand(cmd_version.Cmd) + rootCmd.AddCommand(cmd_group.Cmd) +} diff --git a/internal/cli/commands/version/version.go b/internal/cli/commands/version/version.go new file mode 100644 index 0000000..4500a5e --- /dev/null +++ b/internal/cli/commands/version/version.go @@ -0,0 +1,21 @@ +package version + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var ( + Version string = "dev" + Commit string = "none" +) + +var Cmd = &cobra.Command{ + Use: "version", + Short: "Print version information", + Long: `Print the WorkTime version information of this WorkTime binary`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("WorkTime. Version: %s, Commit: %s\n", Version, Commit) + }, +} diff --git a/internal/cli/pretty/builder.go b/internal/cli/pretty/builder.go new file mode 100644 index 0000000..62c2efe --- /dev/null +++ b/internal/cli/pretty/builder.go @@ -0,0 +1,34 @@ +package pretty + +import "strings" + +type PrettyBuilder struct { + lines []string + padding int + border bool +} + +// NewBuilder creates and returns a new empty builder +func NewBuilder() *PrettyBuilder { + return &PrettyBuilder{ + lines: []string{""}, + } +} + +// NewBuilderFromString creates and returns a new builder based on the string +func NewBuilderFromString(s string) *PrettyBuilder { + lines := strings.Split(strings.Trim(s, "\n"), "\n") + return &PrettyBuilder{ + lines: lines, + } +} + +// AppendString adds a string to the builder line buffer +func (pb *PrettyBuilder) AppendString(s string) *PrettyBuilder { + if strings.HasSuffix(s, "\n") { + pb.lines = append(pb.lines, strings.Trim(s, "\n")) + return pb + } + pb.lines[len(pb.lines)-1] = pb.lines[len(pb.lines)-1] + s + return pb +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..c41e2b1 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,56 @@ +package database + +import ( + "fmt" + "log" + "path/filepath" + + "git.zervo.org/zervo/worktime/internal/database/models" + "git.zervo.org/zervo/worktime/internal/util" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var db *gorm.DB + +var migrations []any = []any{ + &models.ProjectGroup{}, + &models.Project{}, + &models.Session{}, + &models.SessionPause{}, + &models.SyncTarget{}, +} + +// migrate migrates the given database +func migrate(db *gorm.DB) error { + for _, model := range migrations { + if err := db.AutoMigrate(model); err != nil { + return fmt.Errorf("migrate %T: %v", model, err) + } + } + return nil +} + +// connect will open and prepare the local sqlite database +func connect() { + dbPath := filepath.Join(util.GetWorkDir(), "data.db") + + var err error = nil + db, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + log.Fatalf("Failed to open database: %v", err) + } + + err = migrate(db) + if err != nil { + log.Fatalf("Failed to migrate database: %v", err) + } +} + +// DB returns the global database connection instance +func DB() *gorm.DB { + if db == nil { + connect() + } + return db +} diff --git a/internal/database/models/base.go b/internal/database/models/base.go new file mode 100644 index 0000000..2ccf5b6 --- /dev/null +++ b/internal/database/models/base.go @@ -0,0 +1,20 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Base struct { + ID string `gorm:"column:id;<-:create;type:uuid;primaryKey;unique" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at"` +} + +func (b *Base) BeforeCreate(tx *gorm.DB) (err error) { + b.ID = uuid.NewString() + return +} diff --git a/internal/database/models/project.go b/internal/database/models/project.go new file mode 100644 index 0000000..f1d63f5 --- /dev/null +++ b/internal/database/models/project.go @@ -0,0 +1,8 @@ +package models + +type Project struct { + Base + GroupID string `gorm:"column:group_id;<-;type:uuid" json:"group_id"` + Name string `gorm:"column:name;<-;size:255;not null;unique" json:"name"` + Description string `gorm:"column:description;<-" json:"description"` +} diff --git a/internal/database/models/project_group.go b/internal/database/models/project_group.go new file mode 100644 index 0000000..e209524 --- /dev/null +++ b/internal/database/models/project_group.go @@ -0,0 +1,7 @@ +package models + +type ProjectGroup struct { + Base + Name string `gorm:"column:name;<-;size:255;not null;unique" json:"name"` + Description string `gorm:"column:description;<-" json:"description"` +} diff --git a/internal/database/models/session.go b/internal/database/models/session.go new file mode 100644 index 0000000..823c422 --- /dev/null +++ b/internal/database/models/session.go @@ -0,0 +1,17 @@ +package models + +import ( + "time" + + "git.zervo.org/zervo/worktime/internal/types" +) + +type Session struct { + Base + ProjectID string `gorm:"column:project_id;<-:create;type:uuid" json:"project_id"` + State types.SessionState `gorm:"column:state;<-;type:int" json:"state"` + Summary string `gorm:"column:summary;<-" json:"summary"` + Commits types.StringSlice `gorm:"column:commits;<-;type:json" json:"commits"` + FinishedAt time.Time `gorm:"column:finished_at;<-" json:"finished_at"` + PauseIDs types.StringSlice `gorm:"column:pause_ids;<-;type:json" json:"pause_ids"` +} diff --git a/internal/database/models/session_pause.go b/internal/database/models/session_pause.go new file mode 100644 index 0000000..7808f93 --- /dev/null +++ b/internal/database/models/session_pause.go @@ -0,0 +1,13 @@ +package models + +import ( + "time" +) + +type SessionPause struct { + ID uint `gorm:"primaryKey" json:"id"` + ProjectID string `gorm:"column:session_id;<-:create;type:uuid" json:"session_id"` + Resumed bool `gorm:"<-" json:"resumed"` + PausedAt time.Time `gorm:"<-;autoCreateTime" json:"paused_at"` + ResumedAt time.Time `gorm:"<-" json:"resumed_at"` +} diff --git a/internal/database/models/sync_target.go b/internal/database/models/sync_target.go new file mode 100644 index 0000000..b4735bd --- /dev/null +++ b/internal/database/models/sync_target.go @@ -0,0 +1,8 @@ +package models + +type SyncTarget struct { + Base + Endpoint string `gorm:"<-;type:string" json:"endpoint"` + Token string `gorm:"<-;type:string" json:"token"` + Included []Project `gorm:"many2many:synced_projects;"` +} diff --git a/internal/types/object_reference.go b/internal/types/object_reference.go new file mode 100644 index 0000000..a3e2997 --- /dev/null +++ b/internal/types/object_reference.go @@ -0,0 +1,54 @@ +package types + +import ( + "bytes" + "database/sql/driver" + "encoding/binary" + "fmt" +) + +type ObjectReference struct { + ID string + Kind ObjectType +} + +func (or *ObjectReference) Scan(value any) error { + if value == nil { + *or = ObjectReference{ID: "", Kind: UndefinedObject} + return nil + } + + switch v := value.(type) { + case string: + parts := bytes.Split([]byte(v), []byte{'-'}) + if len(parts) != 2 { + return fmt.Errorf("invalid format for ObjectReference: %s", v) + } + id := string(parts[0]) + kind, err := binary.ReadUvarint(bytes.NewReader(parts[1])) + if err != nil { + return err + } + or.ID = id + or.Kind = ObjectType(kind) + return nil + + case []byte: + vStr := string(v) + return or.Scan(vStr) + + default: + return fmt.Errorf("unexpected type for ObjectReference: %T", v) + } +} + +func (or ObjectReference) Value() (driver.Value, error) { + if or.ID == "" { + return nil, nil + } + var kindBuf bytes.Buffer + if err := binary.Write(&kindBuf, binary.LittleEndian, int64(or.Kind)); err != nil { + return nil, err + } + return fmt.Sprintf("%s-%s", or.ID, kindBuf.Bytes()), nil +} diff --git a/internal/types/object_type.go b/internal/types/object_type.go new file mode 100644 index 0000000..d327267 --- /dev/null +++ b/internal/types/object_type.go @@ -0,0 +1,44 @@ +package types + +import ( + "database/sql/driver" + "fmt" +) + +type ObjectType int + +const ( + UndefinedObject ObjectType = iota + ProjectGroupObject + ProjectObject + SessionObject +) + +var objectTypeNames = map[ObjectType]string{ + ProjectGroupObject: "project group object", + ProjectObject: "project object", + SessionObject: "session object", +} + +func (ot ObjectType) String() string { + return objectTypeNames[ot] +} + +func (ot *ObjectType) Scan(value any) error { + if value == nil { + *ot = UndefinedObject + return nil + } + + switch v := value.(type) { + case int64: + *ot = ObjectType(v) + return nil + default: + return fmt.Errorf("unexpected type for ObjectType: %T", v) + } +} + +func (ot ObjectType) Value() (driver.Value, error) { + return int64(ot), nil +} diff --git a/internal/types/session_state.go b/internal/types/session_state.go new file mode 100644 index 0000000..4158051 --- /dev/null +++ b/internal/types/session_state.go @@ -0,0 +1,43 @@ +package types + +import ( + "database/sql/driver" + "fmt" +) + +type SessionState int + +const ( + StateActive SessionState = iota + StatePaused + StateFinished +) + +var sessionStateNames = map[SessionState]string{ + StateActive: "active", + StatePaused: "paused", + StateFinished: "finished", +} + +func (ss SessionState) String() string { + return sessionStateNames[ss] +} + +func (ss *SessionState) Scan(value any) error { + if value == nil { + *ss = StateActive + return nil + } + + switch v := value.(type) { + case int64: + *ss = SessionState(v) + return nil + default: + return fmt.Errorf("unexpected type for SessionState: %T", v) + } +} + +func (ss SessionState) Value() (driver.Value, error) { + return int64(ss), nil +} diff --git a/internal/types/string_slice.go b/internal/types/string_slice.go new file mode 100644 index 0000000..0fd16bb --- /dev/null +++ b/internal/types/string_slice.go @@ -0,0 +1,31 @@ +package types + +import ( + "database/sql/driver" + "encoding/json" + "errors" +) + +type StringSlice []string + +func (s *StringSlice) Scan(value any) error { + bytes, ok := value.([]byte) + if !ok { + return errors.New("failed to unmarshal JSONB value") + } + + result := StringSlice{} + if err := json.Unmarshal(bytes, &result); err != nil { + return err + } + + *s = result + return nil +} + +func (s StringSlice) Value() (driver.Value, error) { + if len(s) == 0 { + return nil, nil + } + return json.Marshal(s) +} diff --git a/internal/util/dir.go b/internal/util/dir.go new file mode 100644 index 0000000..be95cec --- /dev/null +++ b/internal/util/dir.go @@ -0,0 +1,23 @@ +package util + +import ( + "log" + "os" + "path/filepath" +) + +// GetWorkDir returns the data directory for worktime +func GetWorkDir() string { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Fatalf("Failed to get user home directory: %v", err) + } + + workDir := filepath.Join(homeDir, ".myworktime") + + if err := os.MkdirAll(workDir, 0755); err != nil { + log.Fatalf("Failed to create worktime directory: %v", err) + } + + return workDir +} diff --git a/internal/util/writer.go b/internal/util/writer.go new file mode 100644 index 0000000..4ad5352 --- /dev/null +++ b/internal/util/writer.go @@ -0,0 +1,28 @@ +package util + +import ( + "fmt" + "io" + "os" + + "git.zervo.org/zervo/worktime/internal/cli/color" +) + +type ErrorWriter struct { + w io.Writer + color string + reset string +} + +func NewErrorWriter() *ErrorWriter { + return &ErrorWriter{ + w: os.Stderr, + color: color.Red, + reset: color.Reset, + } +} + +func (ew *ErrorWriter) Write(p []byte) (n int, err error) { + colored := fmt.Appendf(nil, "%s%s%s", ew.color, p, ew.reset) + return ew.w.Write(colored) +}