Initial commit

This commit is contained in:
zervo 2025-11-07 13:40:22 +01:00
commit 746cebf509
26 changed files with 757 additions and 0 deletions

19
Makefile Normal file
View file

@ -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)

9
cmd/worktime/main.go Normal file
View file

@ -0,0 +1,9 @@
package main
import (
"git.zervo.org/zervo/worktime/internal/cli/commands"
)
func main() {
commands.Execute()
}

19
go.mod Normal file
View file

@ -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
)

25
go.sum Normal file
View file

@ -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=

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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!")
}

View file

@ -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)
}

View file

@ -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)
},
}

View file

@ -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
}

56
internal/database/db.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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"`
}

View file

@ -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;"`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

23
internal/util/dir.go Normal file
View file

@ -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
}

28
internal/util/writer.go Normal file
View file

@ -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)
}