Initial commit
This commit is contained in:
commit
746cebf509
26 changed files with 757 additions and 0 deletions
19
Makefile
Normal file
19
Makefile
Normal 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
9
cmd/worktime/main.go
Normal 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
19
go.mod
Normal 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
25
go.sum
Normal 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=
|
||||||
60
internal/cli/color/color.go
Normal file
60
internal/cli/color/color.go
Normal 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
|
||||||
|
}
|
||||||
52
internal/cli/commands/group/add.go
Normal file
52
internal/cli/commands/group/add.go
Normal 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
|
||||||
|
}
|
||||||
57
internal/cli/commands/group/edit.go
Normal file
57
internal/cli/commands/group/edit.go
Normal 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
|
||||||
|
}
|
||||||
18
internal/cli/commands/group/group.go
Normal file
18
internal/cli/commands/group/group.go
Normal 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)
|
||||||
|
}
|
||||||
32
internal/cli/commands/group/list.go
Normal file
32
internal/cli/commands/group/list.go
Normal 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
|
||||||
|
}
|
||||||
18
internal/cli/commands/group/remove.go
Normal file
18
internal/cli/commands/group/remove.go
Normal 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!")
|
||||||
|
}
|
||||||
41
internal/cli/commands/root.go
Normal file
41
internal/cli/commands/root.go
Normal 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)
|
||||||
|
}
|
||||||
21
internal/cli/commands/version/version.go
Normal file
21
internal/cli/commands/version/version.go
Normal 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)
|
||||||
|
},
|
||||||
|
}
|
||||||
34
internal/cli/pretty/builder.go
Normal file
34
internal/cli/pretty/builder.go
Normal 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
56
internal/database/db.go
Normal 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
|
||||||
|
}
|
||||||
20
internal/database/models/base.go
Normal file
20
internal/database/models/base.go
Normal 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
|
||||||
|
}
|
||||||
8
internal/database/models/project.go
Normal file
8
internal/database/models/project.go
Normal 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"`
|
||||||
|
}
|
||||||
7
internal/database/models/project_group.go
Normal file
7
internal/database/models/project_group.go
Normal 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"`
|
||||||
|
}
|
||||||
17
internal/database/models/session.go
Normal file
17
internal/database/models/session.go
Normal 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"`
|
||||||
|
}
|
||||||
13
internal/database/models/session_pause.go
Normal file
13
internal/database/models/session_pause.go
Normal 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"`
|
||||||
|
}
|
||||||
8
internal/database/models/sync_target.go
Normal file
8
internal/database/models/sync_target.go
Normal 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;"`
|
||||||
|
}
|
||||||
54
internal/types/object_reference.go
Normal file
54
internal/types/object_reference.go
Normal 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
|
||||||
|
}
|
||||||
44
internal/types/object_type.go
Normal file
44
internal/types/object_type.go
Normal 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
|
||||||
|
}
|
||||||
43
internal/types/session_state.go
Normal file
43
internal/types/session_state.go
Normal 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
|
||||||
|
}
|
||||||
31
internal/types/string_slice.go
Normal file
31
internal/types/string_slice.go
Normal 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
23
internal/util/dir.go
Normal 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
28
internal/util/writer.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue