refactor: refactor project structure to use golang best practices

This commit is contained in:
Patryk Hegenberg 2026-01-11 11:33:26 +01:00
parent 5b16cef525
commit 4ed6a61b1d
10 changed files with 617 additions and 702 deletions

374
app.go
View file

@ -1,191 +1,158 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
sshPkg "golang.org/x/crypto/ssh"
"workctl/internal/config"
"workctl/internal/ssh"
"workctl/internal/store"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
) )
type Flags struct {
ShowWeek bool
ShowMonth bool
ShowExport bool
ExportName string
StartInBackground bool
WithoutTimew bool
}
type App struct { type App struct {
cfg Config cfg config.Config
store *store.Store
flags Flags flags Flags
timeStore *TimeStore
} }
func NewApp() (*App, error) { func NewApp() (*App, error) {
cfg, err := loadConfig() cfg, err := config.Load()
if err != nil { if err != nil {
return nil, fmt.Errorf("error loading config: %w", err) return nil, fmt.Errorf("error loading config: %w", err)
} }
ts, err := NewTimeStore(cfg) st, err := store.NewStore()
if err != nil { if err != nil {
return nil, fmt.Errorf("error initializing time store: %w", err) return nil, fmt.Errorf("error initializing time store: %w", err)
} }
return &App{ return &App{
cfg: cfg, cfg: cfg,
timeStore: ts, store: st,
flags: Flags{},
}, nil }, nil
} }
func (a *App) Close() error { func (a *App) Close() error {
if a.timeStore != nil { if a.store != nil {
return a.timeStore.Close() return a.store.Close()
} }
return nil return nil
} }
func (a *App) StartTracking(tag string, withoutTimew bool) error { func (a *App) Execute(ctx context.Context) error {
if err := a.timeStore.StartTracking(tag); err != nil { if len(os.Args) > 1 {
return a.setupCommands().ExecuteContext(ctx)
}
return a.makeChoice(ctx)
}
func (a *App) StartTracking(ctx context.Context, tag string) error {
if err := a.store.StartTracking(ctx, tag); err != nil {
return err return err
} }
if !a.flags.WithoutTimew {
if !withoutTimew { _ = a.runCommand("timew", "start", tag)
if err := a.runCommand("timew", "start", tag); err != nil {
slog.Warn("Failed to start timewarrior (ignoring)", "error", err)
}
} }
return nil return nil
} }
func (a *App) StopTracking(withoutTimew bool) error { func (a *App) StopTracking(ctx context.Context) error {
if err := a.timeStore.StopTracking(); err != nil { if err := a.store.StopTracking(ctx); err != nil {
return err return err
} }
if !a.flags.WithoutTimew {
if !withoutTimew { _ = a.runCommand("timew", "stop")
if err := a.runCommand("timew", "stop"); err != nil {
slog.Warn("Failed to stop timewarrior (ignoring)", "error", err)
}
} }
return nil return nil
} }
func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { func (a *App) connect(ctx context.Context) error {
if err := a.StartTracking(TagWork, withoutTimew); err != nil { if err := a.StartTracking(ctx, store.TagWork); err != nil {
slog.Warn(fmt.Sprintf("Failed to start time tracking for '%s': %v", TagWork, err)) slog.Warn("Failed to start time tracking", "error", err)
} }
a.wakeWorkstation() a.wakeWorkstation()
sshCon, err := a.newSSHConnection() sshCon, err := ssh.NewConnection(a.cfg.SSHUser, a.cfg.SSHHost, a.cfg.SSHPort, a.getSSHAuth())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err) return fmt.Errorf("failed to establish primary SSH connection: %w", err)
} }
defer sshCon.Close()
slog.Info("SSH connection established. Setting up tunnels...") slog.Info("SSH connection established. Setting up tunnels...")
sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) tunnelCtx, cancelTunnels := context.WithCancel(ctx)
defer cancelTunnels()
sshForwarder := ssh.NewForwarder(sshCon.Client, "2048", "22", a.cfg.WorkstationIP)
rdpForwarder := ssh.NewForwarder(sshCon.Client, "6000", "3389", a.cfg.WorkstationIP)
go func() { go func() {
slog.Info("Starting SSH forwarder (local :2048 -> remote workstation:22)") if err := sshForwarder.Start(tunnelCtx); err != nil {
if err := sshForwarder.forward(); err != nil { slog.Error("SSH forwarder stopped", "error", err)
slog.Error(fmt.Sprintf("SSH forwarder failed: %v", err)) }
}()
go func() {
if err := rdpForwarder.Start(tunnelCtx); err != nil {
slog.Error("RDP forwarder stopped", "error", err)
} }
slog.Info("SSH forwarder stopped.")
}() }()
rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP) time.Sleep(200 * time.Millisecond)
go func() {
slog.Info("Starting RDP forwarder (local :6000 -> remote workstation:3389)") if a.flags.StartInBackground {
if err := rdpForwarder.forward(); err != nil { fmt.Println("\nINFO: Tunnels are active in background.")
slog.Error(fmt.Sprintf("ERROR: RDP forwarder failed: %v", err)) fmt.Println(" Connect manually via SSH: ssh -p 2048 <user>@127.0.0.1")
fmt.Println(" Connect manually via RDP: xfreerdp /v:127.0.0.1:6000 ...")
fmt.Println("INFO: Press Ctrl+C to stop.")
<-ctx.Done()
slog.Info("Context cancelled, shutting down tunnels...")
} else {
fmt.Println("Automatically connecting to workstation via SSH tunnel...")
a.connectToWorkstation()
fmt.Println("Workstation SSH session finished.")
} }
slog.Info("RDP forwarder stopped.")
}()
time.Sleep(500 * time.Millisecond) if err := a.StopTracking(context.Background()); err != nil {
slog.Warn("Failed to stop time tracking", "error", err)
} else {
slog.Info("Time tracking stopped.")
}
return sshCon, nil return nil
} }
func (a *App) runCommand(name string, args ...string) error { func (a *App) runCommand(name string, args ...string) error {
slog.Info(fmt.Sprintf("Executing command: %s %s", name, strings.Join(args, " "))) slog.Info("Executing command", "cmd", name, "args", args)
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
err := cmd.Run() if err := cmd.Run(); err != nil {
if err != nil { slog.Error("Command failed", "cmd", name, "error", err)
slog.Error(fmt.Sprintf("Command failed: %s %s -> %v", name, strings.Join(args, " "), err)) return err
return fmt.Errorf("command execution failed: %w", err)
} }
slog.Info(fmt.Sprintf("Command finished successfully: %s", name))
return nil return nil
} }
func (a *App) wakeWorkstation() { func (a *App) makeChoice(ctx context.Context) error {
slog.Info("Attempting to wake workstation...")
innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Wake-on-LAN packet sent.' && exit\"",
a.cfg.JumpUser,
a.cfg.JumpHost,
a.cfg.WorkstationMac)
outerSSHCmd := []string{
"-tt",
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
innerSSHCmd,
}
if err := a.runCommand("ssh", outerSSHCmd...); err != nil {
slog.Warn("Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.")
} else {
slog.Info("Wake-on-LAN command executed.")
}
}
func (a *App) connectToJump() {
slog.Info("Connecting to Jump Host with Port Forwarding...")
sshArgs := []string{
"-tt",
"-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost),
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
}
if err := a.runCommand("ssh", sshArgs...); err != nil {
}
}
func (a *App) connectToWorkstation() {
slog.Info("Connecting to Workstation via local tunnel (localhost:2048)...")
sshArgs := []string{
"-tt",
"-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost),
"-p", "2048",
fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser),
}
if err := a.runCommand("ssh", sshArgs...); err != nil {
return
}
}
func (a *App) startRDPConnection() {
slog.Info("Starting RDP connection to localhost:6000...")
args := []string{
fmt.Sprintf("/u:%s", a.cfg.RDPUser),
fmt.Sprintf("/p:%s", a.cfg.SSHPassword),
"/v:127.0.0.1:6000",
"/size:3000x1350",
"+clipboard",
"/dynamic-resolution",
}
if err := a.runCommand("xfreerdp", args...); err != nil {
return
}
}
func (a *App) makeChoice() {
var choice string var choice string
form := huh.NewForm( form := huh.NewForm(
@ -201,65 +168,47 @@ func (a *App) makeChoice() {
huh.NewOption("Show Week Summary", "show week summary"), huh.NewOption("Show Week Summary", "show week summary"),
huh.NewOption("Show Month Summary", "show month summary"), huh.NewOption("Show Month Summary", "show month summary"),
huh.NewOption("Export Yearly Timetable", "export"), huh.NewOption("Export Yearly Timetable", "export"),
huh.NewOption("Connect to Jump Host (Tunnel to Workstation)", "connect to jump"), huh.NewOption("Connect to Jump Host (Tunnel)", "connect to jump"),
huh.NewOption("Connect to Workstation (via Tunnel)", "connect to workstation"), huh.NewOption("Connect to Workstation (Tunnel)", "connect to workstation"),
huh.NewOption("Start RDP Connection (via Tunnel)", "start rdp connection"), huh.NewOption("Start RDP Connection", "start rdp connection"),
huh.NewOption("Wake Workstation", "wake workstation"), huh.NewOption("Wake Workstation", "wake workstation"),
huh.NewOption("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"), huh.NewOption("Kill Active Tunnels", "kill tunnels"),
huh.NewOption("Config: Set Secrets", "set secrets"),
huh.NewOption("Exit", "exit"), huh.NewOption("Exit", "exit"),
). ).
Value(&choice), Value(&choice),
), ),
) )
err := form.Run() if err := form.Run(); err != nil {
if err != nil { return nil
if err == huh.ErrUserAborted {
fmt.Println("Operation cancelled.")
return
}
slog.Error(fmt.Sprintf("Form execution failed: %v", err))
return
} }
switch choice { switch choice {
case "start work": case "start work":
a.connect(withoutTimew) return a.connect(ctx)
case "stop work": case "stop work":
if err := a.StopTracking(withoutTimew); err != nil { if err := a.StopTracking(ctx); err != nil {
slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err)) slog.Error("Failed to stop time tracking", "error", err)
}
if err := a.killForwardings(); err != nil {
slog.Warn(fmt.Sprintf("Could not kill all forwardings: %v", err))
} }
_ = a.killForwardings()
case "start break": case "start break":
if err := a.StartTracking(TagBreak, withoutTimew); err != nil { if err := a.StartTracking(ctx, store.TagBreak); err != nil {
slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) slog.Error("Failed to start break", "error", err)
} }
case "stop break": case "stop break":
if err := a.StartTracking(TagWork, withoutTimew); err != nil { if err := a.StartTracking(ctx, store.TagWork); err != nil {
slog.Error(fmt.Sprintf("Failed to stop break (start work): %v", err)) slog.Error("Failed to stop break", "error", err)
} }
case "show day summary": case "show day summary":
if err := a.timeStore.ShowSummary("today"); err != nil { _ = a.store.ShowSummary(ctx, "today")
slog.Error(fmt.Sprintf("Failed to show day summary: %v", err))
}
case "show week summary": case "show week summary":
if err := a.timeStore.ShowSummary("week"); err != nil { _ = a.store.ShowSummary(ctx, "week")
slog.Error(fmt.Sprintf("ERROR: Failed to show week summary: %v", err))
}
case "show month summary": case "show month summary":
if err := a.timeStore.ShowSummary("month"); err != nil { _ = a.store.ShowSummary(ctx, "month")
slog.Error(fmt.Sprintf("Failed to show month summary: %v", err))
}
case "export": case "export":
filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
if a.flags.ExportName != "" && a.flags.ExportName != "Arbeitszeiten.xlsx" { _ = a.store.ExportSummary(ctx, filename)
filename = a.flags.ExportName
}
if err := a.timeStore.ExportSummary(filename); err != nil {
slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
}
case "connect to jump": case "connect to jump":
a.connectToJump() a.connectToJump()
case "connect to workstation": case "connect to workstation":
@ -269,98 +218,91 @@ func (a *App) makeChoice() {
case "wake workstation": case "wake workstation":
a.wakeWorkstation() a.wakeWorkstation()
case "kill tunnels": case "kill tunnels":
if err := a.killForwardings(); err != nil { _ = a.killForwardings()
slog.Error(fmt.Sprintf("Failed to kill forwardings: %v", err)) case "set secrets":
} else { _ = a.configCommand()
slog.Info("Attempted to kill processes on ports 2048 and 6000.") fmt.Println("Please run 'workctl config set-secrets' directly from CLI.")
}
case "exit": case "exit":
fmt.Println("Exiting.") return nil
return
default:
slog.Warn(fmt.Sprintf("Unhandled choice '%s'", choice))
} }
if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" { if choice != "exit" && choice != "start work" {
fmt.Println("\nPress Enter to continue...") fmt.Println("\nPress Enter to continue...")
fmt.Scanln() fmt.Scanln()
a.makeChoice() return a.makeChoice(ctx)
} }
}
func (a *App) getSSHAuth() ssh.AuthMethod {
keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg")
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
slog.Error(fmt.Sprintf("Unable to read private key '%s': %v", keyPath, err))
return nil return nil
} }
var key ssh.Signer func (a *App) getSSHAuth() sshPkg.AuthMethod {
key, err = ssh.ParsePrivateKey(keyBytes) keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg")
keyBytes, err := os.ReadFile(keyPath)
if err != nil { if err != nil {
if _, ok := err.(*ssh.PassphraseMissingError); ok { slog.Error("Unable to read private key", "path", keyPath, "error", err)
slog.Info(fmt.Sprintf("Private key '%s' requires a passphrase. Trying with RDP password from config.", keyPath)) return nil
key, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword)) }
key, err := sshPkg.ParsePrivateKey(keyBytes)
if err != nil { if err != nil {
slog.Error(fmt.Sprintf("Unable to parse private key '%s' with passphrase: %v", keyPath, err)) if _, ok := err.(*sshPkg.PassphraseMissingError); ok {
slog.Info("Key requires passphrase, trying RDP password from config/keyring")
key, err = sshPkg.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword))
if err != nil {
slog.Error("Failed to parse key with passphrase", "error", err)
return nil return nil
} }
} else { } else {
slog.Error(fmt.Sprintf("Unable to parse private key '%s': %v", keyPath, err)) slog.Error("Failed to parse private key", "error", err)
return nil return nil
} }
} }
return sshPkg.PublicKeys(key)
slog.Info(fmt.Sprintf("Successfully loaded private key '%s'", keyPath))
return ssh.PublicKeys(key)
} }
func (a *App) newSSHConnection() (*SSHConnection, error) { func (a *App) wakeWorkstation() {
authMethod := a.getSSHAuth() slog.Info("Attempting to wake workstation...")
if authMethod == nil { innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Packet sent' && exit\"",
return nil, fmt.Errorf("SSH authentication method could not be obtained") a.cfg.JumpUser, a.cfg.JumpHost, a.cfg.WorkstationMac)
args := []string{
"-tt",
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
innerSSHCmd,
}
_ = a.runCommand("ssh", args...)
} }
homeDir, err := os.UserHomeDir() func (a *App) connectToJump() {
if err != nil { args := []string{
return nil, fmt.Errorf("failed to get user home dir: %w", err) "-tt",
"-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost),
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
} }
knownHostsPath := filepath.Join(homeDir, ".ssh", "known_hosts") _ = a.runCommand("ssh", args...)
hostKeyCallback, err := knownhosts.New(knownHostsPath)
if err != nil {
slog.Warn("Could not load known_hosts file. Please ensure you have connected to the host manually once to populate it.", "path", knownHostsPath)
return nil, fmt.Errorf("failed to create host key callback (check your known_hosts file): %w", err)
} }
sshConfig := &ssh.ClientConfig{ func (a *App) connectToWorkstation() {
User: a.cfg.SSHUser, args := []string{
Auth: []ssh.AuthMethod{authMethod}, "-tt",
HostKeyCallback: hostKeyCallback, "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost),
Timeout: 10 * time.Second, "-p", "2048",
fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser),
}
_ = a.runCommand("ssh", args...)
} }
target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) func (a *App) startRDPConnection() {
slog.Info(fmt.Sprintf("Dialing SSH to %s...", target)) args := []string{
fmt.Sprintf("/u:%s", a.cfg.RDPUser),
client, err := ssh.Dial("tcp", target, sshConfig) fmt.Sprintf("/p:%s", a.cfg.RDPPassword),
if err != nil { "/v:127.0.0.1:6000",
return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err) "/size:3000x1350",
"+clipboard",
"/dynamic-resolution",
} }
slog.Info(fmt.Sprintf("SSH connection to %s successful.", target)) _ = a.runCommand("xfreerdp", args...)
session, err := client.NewSession()
if err != nil {
client.Close()
return nil, fmt.Errorf("failed to create SSH session check: %w", err)
}
session.Close()
return &SSHConnection{
client: client,
}, nil
} }
func (a *App) killForwardings() error { func (a *App) killForwardings() error {

103
cmd.go
View file

@ -4,17 +4,16 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"os/signal"
"strings" "strings"
"syscall"
"time" "time"
"workctl/internal/config"
"workctl/internal/store"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var withoutTimew bool
func (a *App) setupCommands() *cobra.Command { func (a *App) setupCommands() *cobra.Command {
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: "workctl", Use: "workctl",
@ -71,14 +70,14 @@ func (a *App) configCommand() *cobra.Command {
} }
if sshPw != "" { if sshPw != "" {
if err := setSecret(keySSHPassword, sshPw); err != nil { if err := config.SetSecret(config.KeySSHPassword(), sshPw); err != nil {
return fmt.Errorf("failed to save SSH password: %w", err) return fmt.Errorf("failed to save SSH password: %w", err)
} }
fmt.Println("✓ SSH password saved to keyring.") fmt.Println("✓ SSH password saved to keyring.")
} }
if rdpPw != "" { if rdpPw != "" {
if err := setSecret(keyRDPPassword, rdpPw); err != nil { if err := config.SetSecret(config.KeyRDPPassword(), rdpPw); err != nil {
return fmt.Errorf("failed to save RDP password: %w", err) return fmt.Errorf("failed to save RDP password: %w", err)
} }
fmt.Println("✓ RDP password saved to keyring.") fmt.Println("✓ RDP password saved to keyring.")
@ -100,64 +99,15 @@ func (a *App) startCommand() *cobra.Command {
Use: "start", Use: "start",
Short: "Start work: Track time, WOL, setup tunnels, optionally connect or run in background", Short: "Start work: Track time, WOL, setup tunnels, optionally connect or run in background",
Long: `Starts time tracking, attempts WOL, sets up SSH tunnels. Long: `Starts time tracking, attempts WOL, sets up SSH tunnels.
Default behavior: Immediately starts an interactive SSH session to the workstation via the tunnel. The command blocks until this session ends. Default behavior: Immediately starts an interactive SSH session to the workstation via the tunnel.
Use --background (-b) to keep tunnels running in the background without auto-connecting. Press Ctrl+C to stop background tunnels.`, Use --background (-b) to keep tunnels running in the background without auto-connecting.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Starting workday procedures...") return a.connect(cmd.Context())
sshCon, err := a.connect(withoutTimew)
if err != nil {
fmt.Printf("ERROR: Failed to start connections: %v\n", err)
return fmt.Errorf("connection setup failed: %w", err)
}
defer func() {
slog.Info("Closing SSH connection to jump host (defer)...")
if err := sshCon.Close(); err != nil {
slog.Warn(fmt.Sprintf("Error closing SSH connection in defer: %v", err))
} else {
slog.Info("SSH connection closed via defer.")
}
}()
if a.flags.StartInBackground {
fmt.Println("\nINFO: Tunnels are active in background.")
fmt.Println(" Connect manually via SSH: ssh -p 2048 <user>@127.0.0.1")
fmt.Println(" Connect manually via RDP: xfreerdp /v:127.0.0.1:6000 ...")
fmt.Println("INFO: Press Ctrl+C to stop tunnels.")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
fmt.Println("\nINFO: Received interrupt signal. Shutting down background process...")
slog.Info("Received signal, cleanup via defer sshCon.Close() will run.")
if err := a.StopTracking(withoutTimew); err != nil {
slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else {
slog.Info("Time tracking stopped.")
}
fmt.Println("INFO: Background shutdown complete.")
} else {
fmt.Println("Automatically connecting to workstation via SSH tunnel...")
a.connectToWorkstation()
fmt.Println("Workstation SSH session finished.")
slog.Info("Foreground session ended, cleanup via defer sshCon.Close() will run.")
if err := a.StopTracking(withoutTimew); err != nil {
slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else {
slog.Info("Time tracking stopped.")
}
}
return nil
}, },
} }
cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately") cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately")
cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well") cmd.Flags().BoolVarP(&a.flags.WithoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
return cmd return cmd
} }
@ -169,7 +119,7 @@ func (a *App) stopCommand() *cobra.Command {
Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.", Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping workday procedures...") fmt.Println("Stopping workday procedures...")
if err := a.StopTracking(withoutTimew); err != nil { if err := a.StopTracking(cmd.Context()); err != nil {
slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err)) slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else { } else {
fmt.Println("Time tracking stopped.") fmt.Println("Time tracking stopped.")
@ -184,7 +134,7 @@ func (a *App) stopCommand() *cobra.Command {
fmt.Println("Workday stop procedures finished.") fmt.Println("Workday stop procedures finished.")
}, },
} }
cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well") cmd.Flags().BoolVarP(&a.flags.WithoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
return cmd return cmd
} }
@ -201,7 +151,7 @@ it will mark the *current day* with that tag instead of starting an interval tim
This also stops any currently running timer.`, This also stops any currently running timer.`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
tag := TagWork tag := store.TagWork
if len(args) > 0 { if len(args) > 0 {
tag = args[0] tag = args[0]
} }
@ -216,14 +166,14 @@ This also stops any currently running timer.`,
case "uni", "urlaub", "feiertag", "krank", "free": case "uni", "urlaub", "feiertag", "krank", "free":
today := time.Now() today := time.Now()
fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02")) fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02"))
if err := a.timeStore.LogFullDay(tagLower, today); err != nil { if err := a.store.LogFullDay(cmd.Context(), tagLower, today); err != nil {
return fmt.Errorf("could not log '%s' for today: %w", tagLower, err) return fmt.Errorf("could not log '%s' for today: %w", tagLower, err)
} }
return nil return nil
default: default:
fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) fmt.Printf("Attempting to start tracking interval '%s'...\n", tag)
if err := a.StartTracking(tag, withoutTimew); err != nil { if err := a.StartTracking(cmd.Context(), tag); err != nil {
slog.Error(fmt.Sprintf("Failed to start tracking '%s': %v", tag, err)) slog.Error(fmt.Sprintf("Failed to start tracking '%s': %v", tag, err))
return fmt.Errorf("could not start tracking '%s': %w", tag, err) return fmt.Errorf("could not start tracking '%s': %w", tag, err)
} }
@ -237,7 +187,7 @@ This also stops any currently running timer.`,
Short: "Start tracking 'break'", Short: "Start tracking 'break'",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Starting break...") fmt.Println("Starting break...")
if err := a.StartTracking(TagBreak, withoutTimew); err != nil { if err := a.StartTracking(cmd.Context(), store.TagBreak); err != nil {
slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err)) slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err))
return fmt.Errorf("could not start break: %w", err) return fmt.Errorf("could not start break: %w", err)
} }
@ -271,23 +221,23 @@ Export: Use the --export flag or the 'export' subcommand.`,
slog.Info(fmt.Sprintf("No export name specified, using default: %s", filename)) slog.Info(fmt.Sprintf("No export name specified, using default: %s", filename))
} }
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename) fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
if err := a.timeStore.ExportSummary(filename); err != nil { if err := a.store.ExportSummary(cmd.Context(), filename); err != nil {
slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err)) slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
fmt.Printf("Error: Could not export to '%s'.\n", filename) fmt.Printf("Error: Could not export to '%s'.\n", filename)
} }
} else if a.flags.ShowWeek { } else if a.flags.ShowWeek {
fmt.Println("Showing weekly summary...") fmt.Println("Showing weekly summary...")
if err := a.timeStore.ShowSummary("week"); err != nil { if err := a.store.ShowSummary(cmd.Context(), "week"); err != nil {
slog.Error(fmt.Sprintf("Failed to show week summary: %v", err)) slog.Error(fmt.Sprintf("Failed to show week summary: %v", err))
} }
} else if a.flags.ShowMonth { } else if a.flags.ShowMonth {
fmt.Println("Showing monthly summary...") fmt.Println("Showing monthly summary...")
if err := a.timeStore.ShowSummary("month"); err != nil { if err := a.store.ShowSummary(cmd.Context(), "month"); err != nil {
slog.Error(fmt.Sprintf("Failed to show month summary: %v", err)) slog.Error(fmt.Sprintf("Failed to show month summary: %v", err))
} }
} else { } else {
fmt.Printf("Showing summary for period: %s...\n", period) fmt.Printf("Showing summary for period: %s...\n", period)
if err := a.timeStore.ShowSummary(period); err != nil { if err := a.store.ShowSummary(cmd.Context(), period); err != nil {
slog.Error(fmt.Sprintf("Failed to show summary for '%s': %v", period, err)) slog.Error(fmt.Sprintf("Failed to show summary for '%s': %v", period, err))
} }
} }
@ -311,7 +261,7 @@ Export: Use the --export flag or the 'export' subcommand.`,
filename = args[0] filename = args[0]
} }
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename) fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
if err := a.timeStore.ExportSummary(filename); err != nil { if err := a.store.ExportSummary(cmd.Context(), filename); err != nil {
slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err)) slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
fmt.Printf("Error: Could not export to '%s'.\n", filename) fmt.Printf("Error: Could not export to '%s'.\n", filename)
} }
@ -343,7 +293,6 @@ func (a *App) connectCommands() *cobra.Command {
cmd.AddCommand(&cobra.Command{ cmd.AddCommand(&cobra.Command{
Use: "jump", Use: "jump",
Short: "Connect to Jump Host (with tunnel to workstation)", Short: "Connect to Jump Host (with tunnel to workstation)",
Long: "Establishes an SSH connection to the Jump Host and forwards local port 2048 to the workstation's SSH port (22). This command blocks.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
a.connectToJump() a.connectToJump()
}, },
@ -352,7 +301,6 @@ func (a *App) connectCommands() *cobra.Command {
cmd.AddCommand(&cobra.Command{ cmd.AddCommand(&cobra.Command{
Use: "workstation", Use: "workstation",
Short: "Connect to Workstation via SSH tunnel", Short: "Connect to Workstation via SSH tunnel",
Long: "Establishes an SSH connection to the Workstation via the local tunnel on port 2048. Also sets up RDP tunnel on local port 6000. Requires the 'jump' tunnel to be active. This command blocks.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
a.connectToWorkstation() a.connectToWorkstation()
}, },
@ -361,7 +309,6 @@ func (a *App) connectCommands() *cobra.Command {
cmd.AddCommand(&cobra.Command{ cmd.AddCommand(&cobra.Command{
Use: "rdp", Use: "rdp",
Short: "Start RDP session via tunnel", Short: "Start RDP session via tunnel",
Long: "Starts an RDP client (xfreerdp) connecting to localhost:6000. Requires an active tunnel forwarding this port to the workstation's RDP port (3389). This command blocks.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
a.startRDPConnection() a.startRDPConnection()
}, },
@ -374,10 +321,6 @@ func (a *App) importTimewarriorCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "import-timew [filepath]", Use: "import-timew [filepath]",
Short: "Import time entries from 'timewarrior summary' output file", Short: "Import time entries from 'timewarrior summary' output file",
Long: `Parses the output of 'timewarrior summary :year' (or similar) stored in a text file
and inserts the individual time intervals into the workctl SQLite database.
It expects the standard timewarrior summary format.
Example: workctl import-timew /path/to/timew-summary.txt`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
filepath := args[0] filepath := args[0]
@ -405,7 +348,7 @@ func (a *App) runImport(filepath string) (int, error) {
content := string(contentBytes) content := string(contentBytes)
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
tx, err := a.timeStore.db.Begin() tx, err := a.store.DB().Begin()
if err != nil { if err != nil {
return 0, fmt.Errorf("could not begin database transaction: %w", err) return 0, fmt.Errorf("could not begin database transaction: %w", err)
} }
@ -500,9 +443,9 @@ func (a *App) runImport(filepath string) (int, error) {
dbTag := strings.ToLower(tag) dbTag := strings.ToLower(tag)
switch dbTag { switch dbTag {
case "work": case "work":
dbTag = TagWork dbTag = store.TagWork
case "break": case "break":
dbTag = TagBreak dbTag = store.TagBreak
} }
_, err = stmt.Exec(dbTag, startTime, endTime) _, err = stmt.Exec(dbTag, startTime, endTime)

View file

@ -1,108 +0,0 @@
package main
import (
"fmt"
"io"
"log/slog"
"net"
"sync"
"golang.org/x/crypto/ssh"
)
type PortForwarder struct {
sshCon *ssh.Client
localPort string
remotePort string
remoteHost string
logMutex sync.Mutex
}
func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost string) *PortForwarder {
return &PortForwarder{
sshCon: sshCon,
localPort: localPort,
remotePort: remotePort,
remoteHost: remoteHost,
}
}
func (pf *PortForwarder) forward() error {
localAddr := "127.0.0.1:" + pf.localPort
remoteAddr := net.JoinHostPort(pf.remoteHost, pf.remotePort)
pf.logf("INFO", "Starting port forwarder: local -> remote (via SSH)", "Local Address", localAddr, "Remote Address", remoteAddr)
listener, err := net.Listen("tcp", localAddr)
if err != nil {
pf.logf("ERROR", "Failed to open local listener on %s: %v", localAddr, err)
return fmt.Errorf("failed to listen on %s: %w", localAddr, err)
}
defer listener.Close()
pf.logf("INFO", "Listener active", "Local Address", localAddr)
for {
localConn, err := listener.Accept()
if err != nil {
if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" {
pf.logf("INFO", "Listener closed, stopping forwarder.", "Local Address", localAddr)
return nil
}
pf.logf("ERROR", "Failed to accept incoming connection:", "Local Address", localAddr, "Error", err)
continue
}
pf.logf("INFO", "Accepted connection:", "Remote Address", localConn.RemoteAddr(), "Local Address", localAddr)
go pf.handleConnection(localConn, remoteAddr)
}
}
func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) {
defer localConn.Close()
pf.logf("INFO", "Dialing remote host via SSH tunnel", "Local Address", remoteAddr, "Remote Address", localConn.RemoteAddr())
remoteConn, err := pf.sshCon.Dial("tcp", remoteAddr)
if err != nil {
pf.logf("ERROR", "Failed to dial remote host via SSH:", "Remote Address", remoteAddr, "Error:", err)
return
}
defer remoteConn.Close()
pf.logf("INFO", "Connection established. Starting data copy.", "Remote Address", remoteAddr)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
defer localConn.Close()
bytesCopied, err := io.Copy(localConn, remoteConn)
if err != nil {
}
pf.logf("INFO", "Finished copying remote->local", "Bytes copied", bytesCopied, "Remote Address", localConn.RemoteAddr())
}()
go func() {
defer wg.Done()
defer remoteConn.Close()
bytesCopied, err := io.Copy(remoteConn, localConn)
if err != nil {
}
pf.logf("INFO", "Finished copying local->remote", "Bytes copied", bytesCopied, "Remote Address", localConn.RemoteAddr())
}()
wg.Wait()
pf.logf("INFO", "Closing forwarded connection", "Remote Address", localConn.RemoteAddr())
}
func (pf *PortForwarder) logf(level, format string, v ...any) {
pf.logMutex.Lock()
defer pf.logMutex.Unlock()
switch level {
case "INFO":
slog.Info(format, v...)
case "WARN":
slog.Warn(format, v...)
case "ERROR":
slog.Error(format, v...)
}
}

View file

@ -1,4 +1,4 @@
package main package config
import ( import (
"fmt" "fmt"
@ -7,6 +7,13 @@ import (
"path/filepath" "path/filepath"
"github.com/spf13/viper" "github.com/spf13/viper"
"github.com/zalando/go-keyring"
)
const (
serviceName = "workctl"
keySSHPassword = "ssh-password"
keyRDPPassword = "rdp-password"
) )
type Config struct { type Config struct {
@ -22,18 +29,9 @@ type Config struct {
RDPPassword string `mapstructure:"RDP_PASSWORD"` RDPPassword string `mapstructure:"RDP_PASSWORD"`
WorkstationIP string `mapstructure:"WORKSTATION_IP"` WorkstationIP string `mapstructure:"WORKSTATION_IP"`
SSHPort int `mapstructure:"SSH_PORT"` SSHPort int `mapstructure:"SSH_PORT"`
// DatabasePath string `mapstructure:"DATABASE_PATH"`
} }
type Flags struct { func Load() (Config, error) {
ShowWeek bool
ShowMonth bool
ShowExport bool
ExportName string
StartInBackground bool
}
func loadConfig() (Config, error) {
var cfg Config var cfg Config
configPath, err := os.UserConfigDir() configPath, err := os.UserConfigDir()
if err != nil { if err != nil {
@ -55,7 +53,7 @@ func loadConfig() (Config, error) {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok { if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err) return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err)
} }
slog.Info(fmt.Sprintf("Config file '%s' not found, using defaults/env vars.", configFile)) slog.Debug(fmt.Sprintf("Config file '%s' not found, using defaults/env vars.", configFile))
} }
if err := viper.UnmarshalKey("default", &cfg); err != nil { if err := viper.UnmarshalKey("default", &cfg); err != nil {
@ -69,13 +67,13 @@ func loadConfig() (Config, error) {
} }
if cfg.SSHPassword == "" { if cfg.SSHPassword == "" {
if secret, err := getSecret(keySSHPassword); err == nil { if secret, err := GetSecret(keySSHPassword); err == nil {
cfg.SSHPassword = secret cfg.SSHPassword = secret
slog.Debug("Loaded SSH password from keyring.") slog.Debug("Loaded SSH password from keyring.")
} }
} }
if cfg.RDPPassword == "" { if cfg.RDPPassword == "" {
if secret, err := getSecret(keyRDPPassword); err == nil { if secret, err := GetSecret(keyRDPPassword); err == nil {
cfg.RDPPassword = secret cfg.RDPPassword = secret
slog.Debug("Loaded RDP password from keyring.") slog.Debug("Loaded RDP password from keyring.")
} }
@ -83,3 +81,17 @@ func loadConfig() (Config, error) {
return cfg, nil return cfg, nil
} }
func GetSecret(key string) (string, error) {
return keyring.Get(serviceName, key)
}
func SetSecret(key, value string) error {
if value == "" {
return fmt.Errorf("secret cannot be empty")
}
return keyring.Set(serviceName, key, value)
}
func KeySSHPassword() string { return keySSHPassword }
func KeyRDPPassword() string { return keyRDPPassword }

55
internal/ssh/client.go Normal file
View file

@ -0,0 +1,55 @@
package ssh
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
type Connection struct {
Client *ssh.Client
}
func NewConnection(user, host string, port int, auth ssh.AuthMethod) (*Connection, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home dir: %w", err)
}
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
hkCallback, err := knownhosts.New(knownHostsPath)
if err != nil {
slog.Warn("Could not load known_hosts, ensure you connected manually once.", "path", knownHostsPath)
return nil, fmt.Errorf("known_hosts error: %w", err)
}
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{auth},
HostKeyCallback: hkCallback,
Timeout: 10 * time.Second,
}
addr := fmt.Sprintf("%s:%d", host, port)
slog.Debug("Dialing SSH", "target", addr)
client, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return nil, fmt.Errorf("ssh dial failed: %w", err)
}
slog.Debug("SSH connection established", "target", addr)
return &Connection{Client: client}, nil
}
func (c *Connection) Close() error {
if c.Client != nil {
return c.Client.Close()
}
return nil
}

103
internal/ssh/forwarder.go Normal file
View file

@ -0,0 +1,103 @@
package ssh
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"sync"
"time"
"golang.org/x/crypto/ssh"
)
type Forwarder struct {
sshClient *ssh.Client
localPort string
remotePort string
remoteHost string
}
func NewForwarder(client *ssh.Client, localPort, remotePort, remoteHost string) *Forwarder {
return &Forwarder{
sshClient: client,
localPort: localPort,
remotePort: remotePort,
remoteHost: remoteHost,
}
}
func (f *Forwarder) Start(ctx context.Context) error {
localAddr := "127.0.0.1:" + f.localPort
remoteAddr := net.JoinHostPort(f.remoteHost, f.remotePort)
listener, err := net.Listen("tcp", localAddr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", localAddr, err)
}
go func() {
<-ctx.Done()
listener.Close()
}()
slog.Info("Port forwarder active", "local", localAddr, "remote", remoteAddr)
for {
localConn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
}
slog.Error("Accept failed", "error", err)
time.Sleep(100 * time.Millisecond)
continue
}
go f.handleConnection(ctx, localConn, remoteAddr)
}
}
func (f *Forwarder) handleConnection(ctx context.Context, localConn net.Conn, remoteAddr string) {
defer localConn.Close()
_, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
remoteConn, err := f.sshClient.Dial("tcp", remoteAddr)
if err != nil {
slog.Error("Failed to dial remote via SSH", "target", remoteAddr, "error", err)
return
}
defer remoteConn.Close()
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, _ = io.Copy(localConn, remoteConn)
// localConn.SetWriteDeadline(time.Now())
localConn.Close()
}()
go func() {
defer wg.Done()
_, _ = io.Copy(remoteConn, localConn)
remoteConn.Close()
}()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
}
}

View file

@ -1,6 +1,7 @@
package main package store
import ( import (
"context"
"fmt" "fmt"
"log/slog" "log/slog"
"sort" "sort"
@ -29,6 +30,63 @@ type ExcelEntry struct {
Tag string Tag string
} }
func (s *Store) ExportSummary(ctx context.Context, filename string) error {
slog.Info(fmt.Sprintf("Starting export to '%s'...", filename))
currentYear := time.Now().Year()
location := time.Local
yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location)
yearEnd := yearStart.AddDate(1, 0, 0)
slog.Info(fmt.Sprintf("Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02")))
query := `
SELECT id, tag, start_time, end_time
FROM time_entries
WHERE start_time < ?
AND (end_time IS NULL OR end_time > ?)
ORDER BY start_time ASC;`
rows, err := s.db.QueryContext(ctx, query, yearEnd, yearStart)
if err != nil {
return fmt.Errorf("failed to query entries for year export: %w", err)
}
defer rows.Close()
var entries []TimeEntry
for rows.Next() {
var entry TimeEntry
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return fmt.Errorf("failed to scan entry row: %w", err)
}
entries = append(entries, entry)
}
if err = rows.Err(); err != nil {
return fmt.Errorf("error during export row iteration: %w", err)
}
slog.Info(fmt.Sprintf("Found %d potentially relevant time entries.", len(entries)))
dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd)
if err != nil {
return fmt.Errorf("failed to aggregate entries for export: %w", err)
}
excelEntries := convertDailyToExcelEntries(dailySummaries)
if len(excelEntries) == 0 {
slog.Warn("No daily summaries generated for the export period.")
fmt.Println("No data available to generate the export for the specified period.")
return nil
}
if err := writeExcelSheet(excelEntries, filename); err != nil {
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
}
slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename))
fmt.Printf("Successfully exported timetable to %s\n", filename)
return nil
}
func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) { func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) {
dailyMap := make(map[string]*DailySummary) dailyMap := make(map[string]*DailySummary)
location := yearStart.Location() location := yearStart.Location()
@ -55,14 +113,13 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
for _, entry := range entries { for _, entry := range entries {
if entry.StartTime.IsZero() { if entry.StartTime.IsZero() {
slog.Warn(fmt.Sprintf("Skipping entry with zero start time (ID: %d)", entry.ID)) slog.Warn("Skipping entry with zero start time", "ID", entry.ID)
continue continue
} }
startTime := entry.StartTime.In(location) startTime := entry.StartTime.In(location)
endTime := entry.EndTime.Time.In(location) endTime := entry.EndTime.Time.In(location)
validEndTime := entry.EndTime.Valid if !entry.EndTime.Valid {
if !validEndTime {
endTime = now endTime = now
} }
@ -99,7 +156,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
summary, exists := dailyMap[dayStr] summary, exists := dailyMap[dayStr]
if !exists { if !exists {
slog.Warn(fmt.Sprintf("Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID))
loopTime = dayEnd loopTime = dayEnd
continue continue
} }
@ -132,7 +188,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd { if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
summary.WorkEnd = entryEndTimeOnThisDayStr summary.WorkEnd = entryEndTimeOnThisDayStr
} }
if summary.Tag == "" || summary.Tag == "free" { if summary.Tag == "" || summary.Tag == "free" {
summary.Tag = TagWork summary.Tag = TagWork
} }
@ -140,7 +195,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
case TagBreak: case TagBreak:
summary.BreakDuration += segmentDuration summary.BreakDuration += segmentDuration
default: default:
slog.Info(fmt.Sprintf("Encountered unknown tag '%s' during interval processing for entry ID %d on %s. Counting duration as 'work'.", entry.Tag, entry.ID, dayStr))
summary.WorkDuration += segmentDuration summary.WorkDuration += segmentDuration
if summary.WorkStart == "" || timeStr < summary.WorkStart { if summary.WorkStart == "" || timeStr < summary.WorkStart {
summary.WorkStart = timeStr summary.WorkStart = timeStr
@ -157,7 +211,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
summary.Tag = TagWork summary.Tag = TagWork
} }
} }
loopTime = dayEnd loopTime = dayEnd
} }
} }
@ -230,23 +283,6 @@ func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []Excel
return excelEntries return excelEntries
} }
func formatDuration(d time.Duration) string {
if d < 0 {
d = -d
sign := "-"
d = d.Round(time.Second)
h := int64(d.Hours())
m := int64(d.Minutes()) % 60
s := int64(d.Seconds()) % 60
return fmt.Sprintf("%s%02d:%02d:%02d", sign, h, m, s)
}
d = d.Round(time.Second)
h := int64(d.Hours())
m := int64(d.Minutes()) % 60
s := int64(d.Seconds()) % 60
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
}
func getSollExcelTime(dayOfWeek string) any { func getSollExcelTime(dayOfWeek string) any {
var sollString string var sollString string
switch dayOfWeek { switch dayOfWeek {
@ -270,7 +306,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f := excelize.NewFile() f := excelize.NewFile()
defer func() { defer func() {
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
slog.Error(fmt.Sprintf("Failed to close excel file handle: %v", err)) slog.Error("Failed to close excel file handle", "error", err)
} }
}() }()
@ -288,7 +324,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
sheetName = "Sheet1" sheetName = "Sheet1"
index, _ = f.GetSheetIndex(sheetName) index, _ = f.GetSheetIndex(sheetName)
if index == -1 { if index == -1 {
return fmt.Errorf("could not create or find sheet '%s' or 'Sheet1': %w", sheetName, err) return fmt.Errorf("could not create sheet '%s': %w", sheetName, err)
} }
} else { } else {
index = existingIndex index = existingIndex
@ -327,6 +363,10 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellValue(sheetName, "N4", "Total") f.SetCellValue(sheetName, "N4", "Total")
f.SetCellValue(sheetName, "O4", "") f.SetCellValue(sheetName, "O4", "")
toExcelTime := func(t time.Time) float64 {
return float64(t.Hour())/24.0 + float64(t.Minute())/(24.0*60.0) + float64(t.Second())/(24.0*60.0*60.0)
}
timeStyleCode := "hh:mm" timeStyleCode := "hh:mm"
timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode}) timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode})
dateStyleCode := "dd.mm.yyyy" dateStyleCode := "dd.mm.yyyy"
@ -370,8 +410,8 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
if entry.WorkStart != "" && entry.WorkEnd != "" { if entry.WorkStart != "" && entry.WorkEnd != "" {
startTime, _ := time.Parse("15:04:05", entry.WorkStart) startTime, _ := time.Parse("15:04:05", entry.WorkStart)
endTime, _ := time.Parse("15:04:05", entry.WorkEnd) endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
startExcelTime := float64(startTime.Hour())/24.0 + float64(startTime.Minute())/(24.0*60.0) + float64(startTime.Second())/(24.0*60.0*60.0) startExcelTime := toExcelTime(startTime)
endExcelTime := float64(endTime.Hour())/24.0 + float64(endTime.Minute())/(24.0*60.0) + float64(endTime.Second())/(24.0*60.0*60.0) endExcelTime := toExcelTime(endTime)
if endExcelTime < startExcelTime { if endExcelTime < startExcelTime {
endExcelTime += 1.0 endExcelTime += 1.0
} }
@ -382,10 +422,10 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle) f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle)
f.SetCellFormula(sheetName, "G"+rowStr, fmt.Sprintf("E%d-D%d", row, row)) f.SetCellFormula(sheetName, "G"+rowStr, fmt.Sprintf("E%d-D%d", row, row))
f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle) // Saldo-Style für Dauer f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle)
breakDur, _ := time.Parse("15:04:05", entry.BreakDuration) breakDur, _ := time.Parse("15:04:05", entry.BreakDuration)
breakExcelTime := float64(breakDur.Hour())/24.0 + float64(breakDur.Minute())/(24.0*60.0) + float64(breakDur.Second())/(24.0*60.0*60.0) breakExcelTime := toExcelTime(breakDur)
thirtyMinBreak := float64(30) / (24 * 60) thirtyMinBreak := float64(30) / (24 * 60)
if breakExcelTime < thirtyMinBreak { if breakExcelTime < thirtyMinBreak {
breakExcelTime = thirtyMinBreak breakExcelTime = thirtyMinBreak

View file

@ -1,6 +1,7 @@
package main package store
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
@ -26,18 +27,18 @@ type TimeEntry struct {
EndTime sql.NullTime EndTime sql.NullTime
} }
type TimeStore struct { type Store struct {
db *sql.DB db *sql.DB
dbPath string dbPath string
} }
func NewTimeStore(cfg Config) (*TimeStore, error) { func NewStore() (*Store, error) {
dbPath, err := ensureDatabasePath(cfg) dbPath, err := ensureDatabasePath()
if err != nil { if err != nil {
return nil, fmt.Errorf("could not determine database path: %w", err) return nil, fmt.Errorf("could not determine database path: %w", err)
} }
slog.Info("Using database at:", "Database Path", dbPath) slog.Debug("Using database at:", "path", dbPath)
db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath)) db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath))
if err != nil { if err != nil {
@ -49,77 +50,75 @@ func NewTimeStore(cfg Config) (*TimeStore, error) {
return nil, fmt.Errorf("failed to connect to database '%s': %w", dbPath, err) return nil, fmt.Errorf("failed to connect to database '%s': %w", dbPath, err)
} }
if err := migrate(db); err != nil {
db.Close()
return nil, fmt.Errorf("migration failed: %w", err)
}
return &Store{db: db, dbPath: dbPath}, nil
}
func migrate(db *sql.DB) error {
createTableSQL := ` createTableSQL := `
CREATE TABLE IF NOT EXISTS time_entries ( CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
tag TEXT NOT NULL CHECK(tag <> ''), -- Stelle sicher, dass Tag nicht leer ist tag TEXT NOT NULL CHECK(tag <> ''),
start_time DATETIME NOT NULL, start_time DATETIME NOT NULL,
end_time DATETIME NULL, end_time DATETIME NULL,
-- Optional: Stelle sicher, dass nur ein Eintrag NULL end_time haben kann (falls DB unterstützt) CHECK (end_time IS NULL OR end_time >= start_time)
-- UNIQUE (end_time) WHERE end_time IS NULL -- SQLite unterstützt dies nicht direkt
CHECK (end_time IS NULL OR end_time >= start_time) -- Endzeit muss nach Startzeit liegen
);` );`
if _, err = db.Exec(createTableSQL); err != nil { if _, err := db.Exec(createTableSQL); err != nil {
db.Close() return fmt.Errorf("failed to create table 'time_entries': %w", err)
return nil, fmt.Errorf("failed to create table 'time_entries': %w", err)
} }
createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);` createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);`
if _, err = db.Exec(createIndexSQL); err != nil { if _, err := db.Exec(createIndexSQL); err != nil {
slog.Warn("Failed to create index on start_time:", "Error:", err) slog.Warn("Failed to create index on start_time:", "error", err)
}
return nil
} }
return &TimeStore{db: db, dbPath: dbPath}, nil func ensureDatabasePath() (string, error) {
}
func ensureDatabasePath(_ Config) (string, error) {
configDir, err := os.UserConfigDir() configDir, err := os.UserConfigDir()
if err != nil { if err != nil {
return "", fmt.Errorf("could not get user config dir: %w", err) return "", fmt.Errorf("could not get user config dir: %w", err)
} }
workConfigDir := filepath.Join(configDir, "work") workConfigDir := filepath.Join(configDir, "work")
dbPath := filepath.Join(workConfigDir, "worktime.sqlite")
if err := os.MkdirAll(workConfigDir, 0o750); err != nil { if err := os.MkdirAll(workConfigDir, 0o750); err != nil {
return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err) return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err)
} }
return filepath.Join(workConfigDir, "worktime.sqlite"), nil
return dbPath, nil
} }
func (ts *TimeStore) Close() error { func (s *Store) Close() error {
if ts.db != nil { if s.db != nil {
slog.Info("Closing database connection", "Database Path", ts.dbPath) slog.Debug("Closing database connection", "path", s.dbPath)
return ts.db.Close() return s.db.Close()
} }
return nil return nil
} }
func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) { func (s *Store) stopCurrentEntry(ctx context.Context, now time.Time) (bool, error) {
query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;` query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;`
result, err := ts.db.Exec(query, now) result, err := s.db.ExecContext(ctx, query, now)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to execute stop current entry query: %w", err) return false, fmt.Errorf("failed to execute stop current entry query: %w", err)
} }
rowsAffected, err := result.RowsAffected() rowsAffected, err := result.RowsAffected()
if err != nil { if err != nil {
return false, fmt.Errorf("failed to get affected rows after stopping entry: %w", err) return false, fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected > 1 {
slog.Warn(fmt.Sprintf("Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected))
} }
return rowsAffected > 0, nil return rowsAffected > 0, nil
} }
func (ts *TimeStore) StartTracking(tag string) error { func (s *Store) StartTracking(ctx context.Context, tag string) error {
if tag == "" { if tag == "" {
return fmt.Errorf("cannot start tracking with an empty tag") return fmt.Errorf("cannot start tracking with an empty tag")
} }
now := time.Now() now := time.Now()
stopped, err := ts.stopCurrentEntry(now) stopped, err := s.stopCurrentEntry(ctx, now)
if err != nil { if err != nil {
return err return err
} }
@ -128,17 +127,17 @@ func (ts *TimeStore) StartTracking(tag string) error {
} }
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);` query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);`
_, err = ts.db.Exec(query, tag, now) _, err = s.db.ExecContext(ctx, query, tag, now)
if err != nil { if err != nil {
return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err) return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err)
} }
slog.Info(fmt.Sprintf("Started tracking: %s at %s", tag, now.Format(time.RFC3339))) slog.Info(fmt.Sprintf("Started tracking: %s", tag))
return nil return nil
} }
func (ts *TimeStore) StopTracking() error { func (s *Store) StopTracking(ctx context.Context) error {
now := time.Now() now := time.Now()
stopped, err := ts.stopCurrentEntry(now) stopped, err := s.stopCurrentEntry(ctx, now)
if err != nil { if err != nil {
return err return err
} }
@ -150,7 +149,42 @@ func (ts *TimeStore) StopTracking() error {
return nil return nil
} }
func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error) { func (s *Store) LogFullDay(ctx context.Context, tag string, date time.Time) error {
if tag == "" {
return fmt.Errorf("cannot log full day with an empty tag")
}
tag = strings.ToLower(tag)
location := date.Location()
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
dayEnd := dayStart.Add(24 * time.Hour)
_, err := s.stopCurrentEntry(ctx, dayStart)
if err != nil {
slog.Warn("Failed to stop current entry before logging full day", "error", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
if _, err := tx.ExecContext(ctx, query, tag, dayStart, dayEnd); err != nil {
return fmt.Errorf("failed to insert full-day entry: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
titleCaser := cases.Title(language.English)
slog.Info(fmt.Sprintf("Successfully logged full day entry: Tag='%s', Date='%s'", tag, dayStart.Format("2006-01-02")))
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStart.Format("2006-01-02"))
return nil
}
func (s *Store) GetEntriesInRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
if start.IsZero() || end.IsZero() || end.Before(start) { if start.IsZero() || end.IsZero() || end.Before(start) {
return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end) return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end)
} }
@ -161,7 +195,7 @@ func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error
WHERE start_time >= ? AND start_time < ? WHERE start_time >= ? AND start_time < ?
ORDER BY start_time ASC;` ORDER BY start_time ASC;`
rows, err := ts.db.Query(query, start, end) rows, err := s.db.QueryContext(ctx, query, start, end)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, err) return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, err)
} }
@ -183,8 +217,8 @@ func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error
return entries, nil return entries, nil
} }
func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, error) { func (s *Store) CalculateSummary(ctx context.Context, period string) (map[string]time.Duration, error) {
start, end := getTimeRangeFromPeriod(period) start, end := GetTimeRangeFromPeriod(period)
if start.IsZero() { if start.IsZero() {
return nil, fmt.Errorf("invalid period string: '%s'", period) return nil, fmt.Errorf("invalid period string: '%s'", period)
} }
@ -192,13 +226,13 @@ func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration,
query := ` query := `
SELECT id, tag, start_time, end_time SELECT id, tag, start_time, end_time
FROM time_entries FROM time_entries
WHERE (end_time IS NULL OR end_time > ?) -- Endet nach dem Start des Zeitraums WHERE (end_time IS NULL OR end_time > ?)
AND start_time < ? -- Beginnt vor dem Ende des Zeitraums AND start_time < ?
ORDER BY start_time ASC;` ORDER BY start_time ASC;`
rows, err := ts.db.Query(query, start, end) rows, err := s.db.QueryContext(ctx, query, start, end)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query overlapping entries for range [%v, %v): %w", start, end, err) return nil, fmt.Errorf("failed to query entries: %w", err)
} }
defer rows.Close() defer rows.Close()
@ -208,86 +242,38 @@ func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration,
for rows.Next() { for rows.Next() {
var entry TimeEntry var entry TimeEntry
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil { if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return nil, fmt.Errorf("failed to scan overlapping entry row: %w", err) return nil, fmt.Errorf("failed to scan entry: %w", err)
} }
effectiveStart := entry.StartTime effStart := entry.StartTime
if effectiveStart.Before(start) { if effStart.Before(start) {
effectiveStart = start effStart = start
}
effEnd := now
if entry.EndTime.Valid {
effEnd = entry.EndTime.Time
}
if effEnd.After(end) {
effEnd = end
} }
effectiveEnd := entry.EndTime.Time if effEnd.After(effStart) {
if !entry.EndTime.Valid { summary[entry.Tag] += effEnd.Sub(effStart)
effectiveEnd = now }
}
return summary, rows.Err()
} }
if effectiveEnd.After(end) { func (s *Store) ShowSummary(ctx context.Context, period string) error {
effectiveEnd = end summary, err := s.CalculateSummary(ctx, period)
}
if effectiveEnd.After(effectiveStart) {
duration := effectiveEnd.Sub(effectiveStart)
summary[entry.Tag] += duration
}
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error during overlapping row iteration: %w", err)
}
return summary, nil
}
func getTimeRangeFromPeriod(period string) (time.Time, time.Time) {
now := time.Now()
year, month, day := now.Date()
loc := now.Location()
normalizedPeriod := strings.ToLower(strings.TrimPrefix(period, ":"))
switch normalizedPeriod {
case "week":
weekday := now.Weekday()
daysToMonday := time.Duration(weekday - time.Monday)
if weekday == time.Sunday {
daysToMonday = 6
}
start := time.Date(year, month, day, 0, 0, 0, 0, loc).Add(-daysToMonday * 24 * time.Hour)
end := start.Add(7 * 24 * time.Hour)
return start, end
case "month":
start := time.Date(year, month, 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
return start, end
case "year":
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
end := start.AddDate(1, 0, 0)
return start, end
case "day", "today":
start := time.Date(year, month, day, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
default:
if t, err := time.ParseInLocation("2006-01-02", period, loc); err == nil {
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
}
slog.Warn(fmt.Sprintf("Unrecognized period string '%s'. Cannot calculate time range.", period))
return time.Time{}, time.Time{}
}
}
func (ts *TimeStore) ShowSummary(period string) error {
summary, err := ts.CalculateSummary(period)
if err != nil { if err != nil {
return fmt.Errorf("error calculating summary for '%s': %w", period, err) return err
} }
start, _ := getTimeRangeFromPeriod(period) start, _ := GetTimeRangeFromPeriod(period)
titlePeriod := period titlePeriod := period
if !start.IsZero() { if !start.IsZero() {
_, end := getTimeRangeFromPeriod(period) _, end := GetTimeRangeFromPeriod(period)
if period == ":day" || period == "today" { if period == ":day" || period == "today" {
titlePeriod = fmt.Sprintf("Today (%s)", start.Format("2006-01-02")) titlePeriod = fmt.Sprintf("Today (%s)", start.Format("2006-01-02"))
} else if period == ":week" { } else if period == ":week" {
@ -328,109 +314,63 @@ func (ts *TimeStore) ShowSummary(period string) error {
return nil return nil
} }
func (ts *TimeStore) ExportSummary(filename string) error { func formatDuration(d time.Duration) string {
slog.Info(fmt.Sprintf("Starting export to '%s'...", filename)) if d < 0 {
d = -d
currentYear := time.Now().Year() sign := "-"
d = d.Round(time.Second)
location := time.Local h := int64(d.Hours())
yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location) m := int64(d.Minutes()) % 60
yearEnd := yearStart.AddDate(1, 0, 0) s := int64(d.Seconds()) % 60
slog.Info(fmt.Sprintf("Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02"))) return fmt.Sprintf("%s%02d:%02d:%02d", sign, h, m, s)
query := `
SELECT id, tag, start_time, end_time
FROM time_entries
WHERE start_time < ? -- Beginnt vor Anfang des nächsten Jahres
AND (end_time IS NULL OR end_time > ?) -- Endet nach Anfang des Jahres
ORDER BY start_time ASC;`
rows, err := ts.db.Query(query, yearEnd, yearStart)
if err != nil {
return fmt.Errorf("failed to query entries for year export [%v, %v): %w", yearStart, yearEnd, err)
} }
defer rows.Close() d = d.Round(time.Second)
h := int64(d.Hours())
var entries []TimeEntry m := int64(d.Minutes()) % 60
for rows.Next() { s := int64(d.Seconds()) % 60
var entry TimeEntry return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
return fmt.Errorf("failed to scan entry row (ID: %d) for export: %w", entry.ID, err)
}
entries = append(entries, entry)
}
if err = rows.Err(); err != nil {
return fmt.Errorf("error during export row iteration: %w", err)
}
slog.Info(fmt.Sprintf("Found %d potentially relevant time entries for year %d.", len(entries), currentYear))
dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd)
if err != nil {
return fmt.Errorf("failed to aggregate entries for export: %w", err)
} }
excelEntries := convertDailyToExcelEntries(dailySummaries) func GetTimeRangeFromPeriod(period string) (time.Time, time.Time) {
now := time.Now()
year, month, day := now.Date()
loc := now.Location()
if len(excelEntries) == 0 { normalizedPeriod := strings.ToLower(strings.TrimPrefix(period, ":"))
slog.Warn("No daily summaries generated for the export period.")
fmt.Println("No data available to generate the export for the specified period.") switch normalizedPeriod {
return nil case "week":
weekday := now.Weekday()
daysToMonday := time.Duration(weekday - time.Monday)
if weekday == time.Sunday {
daysToMonday = 6
}
start := time.Date(year, month, day, 0, 0, 0, 0, loc).Add(-daysToMonday * 24 * time.Hour)
end := start.Add(7 * 24 * time.Hour)
return start, end
case "month":
start := time.Date(year, month, 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
return start, end
case "year":
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
end := start.AddDate(1, 0, 0)
return start, end
case "day", "today":
start := time.Date(year, month, day, 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
default:
if t, err := time.ParseInLocation("2006-01-02", period, loc); err == nil {
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end
}
slog.Warn(fmt.Sprintf("Unrecognized period string '%s'. Cannot calculate time range.", period))
return time.Time{}, time.Time{}
} }
slog.Info(fmt.Sprintf("Generated %d daily entries for the Excel export.", len(excelEntries)))
if err := writeExcelSheet(excelEntries, filename); err != nil {
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
} }
slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename)) func (s *Store) DB() *sql.DB {
fmt.Printf("Successfully exported timetable to %s\n", filename) return s.db
return nil
}
func (ts *TimeStore) LogFullDay(tag string, date time.Time) error {
if tag == "" {
return fmt.Errorf("cannot log full day with an empty tag")
}
tag = strings.ToLower(tag)
location := date.Location()
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
dayEnd := dayStart.Add(24 * time.Hour)
dayStr := dayStart.Format("2006-01-02")
slog.Info(fmt.Sprintf("Attempting to log '%s' for the full day %s", tag, dayStr))
stopped, err := ts.stopCurrentEntry(dayStart)
if err != nil {
slog.Warn(fmt.Sprintf("Failed to stop current entry before logging full day '%s': %v", tag, err))
} else if stopped {
slog.Info(fmt.Sprintf("Stopped active timer before logging '%s' for %s.", tag, dayStr))
}
tx, err := ts.db.Begin()
if err != nil {
return fmt.Errorf("could not begin transaction to log full day: %w", err)
}
defer tx.Rollback()
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
stmt, err := tx.Prepare(query)
if err != nil {
return fmt.Errorf("could not prepare statement to log full day: %w", err)
}
defer stmt.Close()
_, err = stmt.Exec(tag, dayStart, dayEnd)
if err != nil {
return fmt.Errorf("failed to insert full-day entry for tag '%s' on %s: %w", tag, dayStr, err)
}
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction for full-day entry: %w", err)
}
titleCaser := cases.Title(language.English)
slog.Info(fmt.Sprintf("Successfully logged full day entry: Tag='%s', Start='%s', End='%s'", tag, dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)))
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr)
return nil
} }

29
main.go
View file

@ -1,43 +1,50 @@
package main package main
import ( import (
"context"
"fmt"
"log/slog" "log/slog"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"syscall"
) )
func main() { func main() {
configDir, err := os.UserConfigDir() configDir, err := os.UserConfigDir()
if err != nil { if err != nil {
slog.Error("Cant get user config dir") fmt.Fprintf(os.Stderr, "Error getting config dir: %v\n", err)
panic(err) os.Exit(1)
} }
logFile := filepath.Join(configDir, "work", "workctl.log")
_ = os.MkdirAll(filepath.Dir(logFile), 0750)
file, err := os.OpenFile(filepath.Join(configDir, "work", "workctl.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666) file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
if err != nil { if err != nil {
panic(err) fmt.Fprintf(os.Stderr, "Failed to open log file: %v\n", err)
os.Exit(1)
} }
defer file.Close() defer file.Close()
logger := slog.New(slog.NewTextHandler(file, nil)) logger := slog.New(slog.NewTextHandler(file, nil))
slog.SetDefault(logger) slog.SetDefault(logger)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
app, err := NewApp() app, err := NewApp()
if err != nil { if err != nil {
slog.Error("Unable to setup application", "Error", err) slog.Error("Unable to setup application", "error", err)
fmt.Fprintf(os.Stderr, "Error setting up application: %v\n", err)
os.Exit(1) os.Exit(1)
} }
defer func() { defer func() {
if err := app.Close(); err != nil { if err := app.Close(); err != nil {
slog.Error("Failed to close application resources", "Error", err) slog.Error("Failed to close application resources", "error", err)
} }
}() }()
if len(os.Args) > 1 { if err := app.Execute(ctx); err != nil {
if err := app.setupCommands().Execute(); err != nil {
os.Exit(1) os.Exit(1)
} }
} else {
app.makeChoice()
}
} }

19
ssh.go
View file

@ -1,19 +0,0 @@
package main
import (
"log/slog"
"golang.org/x/crypto/ssh"
)
type SSHConnection struct {
client *ssh.Client
}
func (s *SSHConnection) Close() error {
if s.client != nil {
slog.Debug("Closing SSH client connection.")
return s.client.Close()
}
return nil
}