From 4ceed6f3011a45f3b8aa4485d70dfd00046b8d42 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Wed, 2 Apr 2025 13:44:30 +0200 Subject: [PATCH] refactor: rebuild work without timewarrior dependency --- README.md | 75 ++++---- app.go | 369 ++++++++++++++++++++++++++----------- cmd.go | 455 ++++++++++++++++++++++++++++++++++++++++++--- config.go | 23 ++- export.go | 490 +++++++++++++++++++++++++++++++++++++++++++++++++ forwarder.go | 98 +++++++--- go.mod | 51 ++--- go.sum | 147 ++++++++------- main.go | 10 +- ssh.go | 20 +- store.go | 446 ++++++++++++++++++++++++++++++++++++++++++++ timewarrior.go | 321 -------------------------------- 12 files changed, 1879 insertions(+), 626 deletions(-) create mode 100644 export.go create mode 100644 store.go delete mode 100644 timewarrior.go diff --git a/README.md b/README.md index 11bd7fc..34035aa 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,53 @@ -# Work Time Management and Remote Connection Tool +# Work Control CLI (workctl) ## Description -This Golang program is a versatile tool for work time management and remote connections. It offers various functions such as starting and stopping work time tracking, displaying work time summaries, waking remote computers, and establishing SSH and RDP connections. +This Golang program is a versatile command-line tool designed to streamline common work-related tasks. It offers functions such as work time tracking (using an internal SQLite database), remote computer wake-up (Wake-on-LAN), and establishing SSH tunnels and RDP connections. ## Main Features -- Work time tracking with `timew` (TimeWarrior) -- Remote computer wake-up function (Wake-on-LAN) -- SSH tunneling and port forwarding -- RDP connection establishment +- **Work Time Tracking:** Start, stop, and track work time and breaks using an internal SQLite database (`~/.config/work/worktime.sqlite`). Summaries (daily, weekly, monthly) and yearly Excel export are available. +- **Remote Computer Wake-up:** Wake remote computers using Wake-on-LAN, potentially via an SSH jump host. +- **SSH Tunneling:** Establish SSH connections and set up port forwarding for accessing remote services (like SSH or RDP on a workstation) securely. +- **RDP Connection:** Helper command to launch an RDP client (`xfreerdp`) through an established tunnel. ## Prerequisites -- Go (Golang) installed -- TimeWarrior (`timew`) installed -- SSH access to remote systems -- xfreerdp for RDP connections +- Go (Golang) installed (for building or development) +- SSH client (`ssh`) installed and configured (keys recommended) +- `lsof` command (for killing tunnels, usually pre-installed on Linux/macOS) +- `wakeonlan` command installed _on the jump host_ if waking via jump host. +- `xfreerdp` command (or another RDP client) installed for RDP connections. ## Configuration -The program expects a configuration file with the following settings: +The program expects a configuration file at `~/.config/work/config.toml`. Create this directory and file if they don't exist. -- SSHHost, SSHPort, SSHUser -- VardaHost, VardaUser -- LouIP, LouHost, LouMac -- RDPUser, RDPPassword +Example `config.toml`: -Make sure these configuration details are correctly set before running the program. +```toml +# ~/.config/work/config.toml -## Usage +[default] +# SSH connection details for the first hop (e.g., Jump Host or Gateway) +SSH_USER = "your_ssh_user" +SSH_HOST = "[jumphost.example.com](https://www.google.com/search?q=jumphost.example.com)" +SSH_PORT = 22 # Optional, defaults to 22 -The program provides various commands that can be invoked through a user interface or command line: +# Optional: Details for a second SSH hop (if waking requires jumping) +JUMP_USER = "user_on_jump_host" # User needed to run wakeonlan on jump host +JUMP_HOST = "internal_host_reachable_from_jump" # Host from which wakeonlan is run -- `Start`: Starts work time tracking and establishes connections -- `stop Work`: Stops work time tracking -- `start break` / `stop break`: Manages breaks -- `show week summary` / `show month summary`: Displays work time summaries -- `wake lou`: Wakes the remote computer "Lou" -- `connect to varda` / `connect to lou`: Establishes connections to remote systems -- `start rdp connection`: Initiates an RDP connection +# Workstation details +WORKSTATION_HOST = "workstation.internal.network" # Hostname/IP from Jump Host's perspective +WORKSTATION_IP = "192.168.1.100" # IP for direct tunneling target +WORKSTATION_MAC = "AA:BB:CC:DD:EE:FF" # MAC address for Wake-on-LAN +WORKSTATION_USER = "your_workstation_ssh_user" # SSH user on the workstation -## Security Notes +# RDP connection details (used by the 'connect rdp' command) +RDP_USER = "your_windows_domain\\your_rdp_user" +RDP_PASSWORD = "your_rdp_password" # SECURITY RISK: Avoid storing passwords here. Consider alternatives. -- Use secure passwords and SSH keys. -- Check network security settings for port forwarding. -- Be cautious when using `ssh.InsecureIgnoreHostKey()` in production environments. - -## Troubleshooting - -If you encounter issues, check: - -- Network connections and firewalls -- SSH keys and permissions -- Correct IP addresses and port numbers in the configuration - -## Contribution - -Contributions to improve this tool are welcome. Please create a pull request or report issues using the repository's Issues feature. +# Optional: Specify database path explicitly +# DATABASE_PATH = "/path/to/your/worktime.sqlite" +``` diff --git a/app.go b/app.go index 417772c..10bdfcb 100644 --- a/app.go +++ b/app.go @@ -13,8 +13,9 @@ import ( ) type App struct { - cfg Config - flags Flags + cfg Config + flags Flags + timeStore *TimeStore // Datenbank-Handler hinzugefügt } func NewApp() (*App, error) { @@ -23,208 +24,356 @@ func NewApp() (*App, error) { return nil, fmt.Errorf("error loading config: %w", err) } + ts, err := NewTimeStore(cfg) + if err != nil { + return nil, fmt.Errorf("error initializing time store: %w", err) + } + return &App{ - cfg: cfg, + cfg: cfg, + timeStore: ts, // TimeStore initialisiert }, nil } +func (a *App) Close() error { + if a.timeStore != nil { + return a.timeStore.Close() + } + return nil +} + func (a *App) connect() { - tw := NewTimeWarrior() - tw.StartWork() - a.wakeWorkstation() + if err := a.timeStore.StartTracking(TagWork); err != nil { + log.Printf("WARN: Failed to start time tracking for '%s': %v", TagWork, err) + } + + a.wakeWorkstation() // Versuche, Workstation zu wecken + sshCon, err := a.newSSHConnection() if err != nil { - log.Fatalf("failed to establish ssh-connection: %v", err) + log.Fatalf("FATAL: Failed to establish primary SSH connection: %v", err) } - defer sshCon.Close() - sshFowarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) - go sshFowarder.forward() + log.Println("INFO: SSH connection established. Setting up tunnels...") - rdpFowarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP) - go rdpFowarder.forward() + sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) + go func() { + log.Println("INFO: Starting SSH forwarder (local :2048 -> remote workstation:22)") + if err := sshForwarder.forward(); err != nil { + // Logge Fehler, wenn der Listener nicht gestartet werden kann oder abbricht + log.Printf("ERROR: SSH forwarder failed: %v", err) + } + log.Println("INFO: SSH forwarder stopped.") + }() - a.connectToWorkstation() + rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP) + go func() { + log.Println("INFO: Starting RDP forwarder (local :6000 -> remote workstation:3389)") + if err := rdpForwarder.forward(); err != nil { + log.Printf("ERROR: RDP forwarder failed: %v", err) + } + log.Println("INFO: RDP forwarder stopped.") + }() + + time.Sleep(1 * time.Second) + log.Println("INFO: Tunnels should be active. You can now connect to localhost:2048 (SSH) or localhost:6000 (RDP).") } -func (a *App) makeSSHClient() *ssh.ClientConfig { - keypath := os.ExpandEnv("$HOME/.ssh/hegenberg") - keyBytes, err := os.ReadFile(keypath) - if err != nil { - log.Fatalf("Failed to read private key: %s", err) - } - - key, err := ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword)) - if err != nil { - log.Fatalf("Failed to parse private key: %s", err) - } - - return &ssh.ClientConfig{ - User: a.cfg.SSHUser, - Auth: []ssh.AuthMethod{ - ssh.PublicKeys(key), - }, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 5 * time.Second, - } -} - -func (a *App) runCommand(name string, args ...string) { +func (a *App) runCommand(name string, args ...string) error { + log.Printf("INFO: Executing command: %s %s", name, strings.Join(args, " ")) cmd := exec.Command(name, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Stdin = os.Stdin + cmd.Stdin = os.Stdin // Wichtig für interaktive Befehle wie ssh err := cmd.Run() if err != nil { - fmt.Println("Error:", err) + log.Printf("ERROR: Command failed: %s %s -> %v", name, strings.Join(args, " "), err) + return fmt.Errorf("command execution failed: %w", err) } + log.Printf("INFO: Command finished successfully: %s", name) + return nil } func (a *App) wakeWorkstation() { - sshCommand := fmt.Sprintf("ssh -tt -p %s %s@%s ssh -tt %s@%s \"wakeonlan %s && exit\"", - fmt.Sprintf("%v", a.cfg.SSHPort), - a.cfg.SSHUser, - a.cfg.SSHHost, - a.cfg.JumpUser, - a.cfg.JumpHost, - a.cfg.WorkstationMac) - args := strings.Split(sshCommand, " ") - log.Println(args) - a.runCommand("ssh", args[1:]...) + log.Println("INFO: Attempting to wake workstation...") + innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Wake-on-LAN packet sent.' && exit\"", + a.cfg.JumpUser, // Benutzer auf dem Jump-Host + a.cfg.JumpHost, // Jump-Host + a.cfg.WorkstationMac) // MAC-Adresse der Workstation + + outerSSHCmd := []string{ + "-tt", // TTY für die äußere Verbindung + "-p", fmt.Sprintf("%d", a.cfg.SSHPort), // Port für den ersten Host + fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost), // user@host für den ersten Host + innerSSHCmd, // Der gesamte innere SSH-Befehl als einzelnes Argument + } + + if err := a.runCommand("ssh", outerSSHCmd...); err != nil { + log.Println("WARN: Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.") + } else { + log.Println("INFO: Wake-on-LAN command executed.") + } } func (a *App) connectToJump() { - sshCommand := fmt.Sprintf("ssh -tt -L 2048:%s:22 %s@%s", - a.cfg.WorkstationHost, - a.cfg.SSHUser, - a.cfg.SSHHost) - args := strings.Split(sshCommand, " ") - a.runCommand("ssh", args[1:]...) + log.Println("INFO: Connecting to Jump Host with Port Forwarding...") + sshArgs := []string{ + "-tt", // TTY Allokation + "-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost), // Forwarding + "-p", fmt.Sprintf("%d", a.cfg.SSHPort), // Port für den Jump-Host + fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost), // user@jumphost + } + if err := a.runCommand("ssh", sshArgs...); err != nil { + // Fehler wird bereits in runCommand geloggt + } } func (a *App) connectToWorkstation() { - sshCommand := fmt.Sprintf("ssh -tt -L 6000:%s:3389 -p 2048 %s@127.0.0.1", - a.cfg.WorkstationHost, - a.cfg.SSHUser) - args := strings.Split(sshCommand, " ") - a.runCommand("ssh", args[1:]...) + log.Println("INFO: Connecting to Workstation via local tunnel (localhost:2048)...") + sshArgs := []string{ + "-tt", // TTY + "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost), // RDP Tunnel via Workstation SSH + "-p", "2048", // Verbinde zum lokalen Port des ersten Tunnels + fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser), // Benutzer@localhost (geht durch den Tunnel) + } + if err := a.runCommand("ssh", sshArgs...); err != nil { + } } func (a *App) startRDPConnection() { - rdpCommand := fmt.Sprintf("xfreerdp /u:%s /p:%s /v:127.0.0.1:6000 /size:3000x1350", + log.Println("INFO: Starting RDP connection to localhost:6000...") + rdpCommand := fmt.Sprintf("xfreerdp /u:%s /p:%s /v:127.0.0.1:6000 /size:3000x1350 +clipboard /dynamic-resolution", a.cfg.RDPUser, - a.cfg.RDPPassword) - a.runCommand("bash", "-c", rdpCommand) + a.cfg.RDPPassword, // SICHERHEITSRISIKO! + ) + if err := a.runCommand("bash", "-c", rdpCommand); err != nil { + } } func (a *App) makeChoice() { var choice string - tw := NewTimeWarrior() + form := huh.NewForm( huh.NewGroup( huh.NewSelect[string](). Title("What would you like to do?"). Options( - huh.NewOption("Start Work", "start work"), + huh.NewOption("Start Work & Connect", "start work"), // Kombinierte Aktion huh.NewOption("Stop Work", "stop work"), - huh.NewOption("Show Week Summary", "show week summary"), - huh.NewOption("Show Month Summary", "show month summary"), huh.NewOption("Start Break", "start break"), huh.NewOption("Stop Break", "stop break"), - huh.NewOption("Export Timetable", "export"), - huh.NewOption("Connect to Jump", "connect to jump"), - huh.NewOption("Connect to Workstation", "connect to workstation"), - huh.NewOption("Start RDP Connection", "start rdp connection"), + // huh.NewOption("--- Summaries ---", "").Disabled(true), // Trenner + huh.NewOption("Show Today Summary", "show day summary"), + huh.NewOption("Show Week Summary", "show week summary"), + huh.NewOption("Show Month Summary", "show month summary"), + huh.NewOption("Export Yearly Timetable", "export"), + // huh.NewOption("--- Connections ---", "").Disabled(true), // Trenner + huh.NewOption("Connect to Jump Host (Tunnel to Workstation)", "connect to jump"), + huh.NewOption("Connect to Workstation (via Tunnel)", "connect to workstation"), + huh.NewOption("Start RDP Connection (via Tunnel)", "start rdp connection"), + huh.NewOption("Wake Workstation", "wake workstation"), + // huh.NewOption("--- Maintenance ---", "").Disabled(true), // Trenner + huh.NewOption("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"), + huh.NewOption("Exit", "exit"), ). - Value(&choice), + Value(&choice), // Speichere die Auswahl in 'choice' ), ) err := form.Run() if err != nil { - fmt.Println("Error:", err) + if err == huh.ErrUserAborted { + fmt.Println("Operation cancelled.") + return + } + log.Printf("ERROR: Form execution failed: %v", err) return } switch choice { case "start work": - a.connect() + a.connect() // Startet Zeit, weckt Rechner, baut Tunnel auf case "stop work": - tw.StopWork() + if err := a.timeStore.StopTracking(); err != nil { + log.Printf("ERROR: Failed to stop time tracking: %v", err) + } + if err := a.killForwardings(); err != nil { + log.Printf("WARN: Could not kill all forwardings: %v", err) + } case "start break": - tw.StartBreak() + if err := a.timeStore.StartTracking(TagBreak); err != nil { + log.Printf("ERROR: Failed to start break tracking: %v", err) + } case "stop break": - tw.StopBreak() + if err := a.timeStore.StartTracking(TagWork); err != nil { + log.Printf("ERROR: Failed to stop break (start work): %v", err) + } + case "show day summary": + if err := a.timeStore.ShowSummary("today"); err != nil { + log.Printf("ERROR: Failed to show day summary: %v", err) + } case "show week summary": - tw.ShowSummary(":week") + if err := a.timeStore.ShowSummary("week"); err != nil { + log.Printf("ERROR: Failed to show week summary: %v", err) + } case "show month summary": - tw.ShowSummary(":month") - case "connect to jump": - a.connectToJump() - case "connect to workstation": - a.connectToWorkstation() - case "start rdp connection": - a.startRDPConnection() + if err := a.timeStore.ShowSummary("month"); err != nil { + log.Printf("ERROR: Failed to show month summary: %v", err) + } case "export": - tw.ExportSummary(a.flags.ExportName) + filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" + if a.flags.ExportName != "" && a.flags.ExportName != "Arbeitszeiten.xlsx" { // Check ob Flag von CLI kommt + filename = a.flags.ExportName + } + if err := a.timeStore.ExportSummary(filename); err != nil { + log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) + } + case "connect to jump": + a.connectToJump() // Blockiert + case "connect to workstation": + a.connectToWorkstation() // Blockiert + case "start rdp connection": + a.startRDPConnection() // Blockiert + case "wake workstation": + a.wakeWorkstation() + case "kill tunnels": + if err := a.killForwardings(); err != nil { + log.Printf("ERROR: Failed to kill forwardings: %v", err) + } else { + log.Println("INFO: Attempted to kill processes on ports 2048 and 6000.") + } + case "exit": + fmt.Println("Exiting.") + return // Beendet die Funktion, was zum Programmende führt + default: + log.Printf("WARN: Unhandled choice '%s'", choice) + } + + if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" { + fmt.Println("\nPress Enter to continue...") + fmt.Scanln() // Warte auf Enter + a.makeChoice() // Rufe Menü erneut auf } } func (a *App) getSSHAuth() ssh.AuthMethod { - keypath := os.ExpandEnv("$HOME/.ssh/hegenberg") - keyBytes, err := os.ReadFile(keypath) + keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg") // Sicherer Standard + + keyBytes, err := os.ReadFile(keyPath) if err != nil { - fmt.Printf("unable to read private key: %v", err) + log.Printf("ERROR: Unable to read private key '%s': %v", keyPath, err) return nil } - key, err := ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword)) + var key ssh.Signer + key, err = ssh.ParsePrivateKey(keyBytes) if err != nil { - fmt.Printf("unable to parse privat key: %v", err) - return nil + if _, ok := err.(*ssh.PassphraseMissingError); ok { + log.Printf("INFO: Private key '%s' requires a passphrase. Trying with RDP password from config.", keyPath) + key, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword)) + if err != nil { + log.Printf("ERROR: Unable to parse private key '%s' with passphrase: %v", keyPath, err) + return nil + } + } else { + log.Printf("ERROR: Unable to parse private key '%s': %v", keyPath, err) + return nil + } } + log.Printf("INFO: Successfully loaded private key '%s'", keyPath) return ssh.PublicKeys(key) } func (a *App) newSSHConnection() (*SSHConnection, error) { - config := &ssh.ClientConfig{ - User: a.cfg.SSHUser, - Auth: []ssh.AuthMethod{a.getSSHAuth()}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 5 * time.Second, + authMethod := a.getSSHAuth() + if authMethod == nil { + return nil, fmt.Errorf("SSH authentication method could not be obtained") } - client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort), config) - if err != nil { - return nil, fmt.Errorf("ssh dial failed: %w", err) + sshConfig := &ssh.ClientConfig{ + User: a.cfg.SSHUser, + Auth: []ssh.AuthMethod{authMethod}, + // ACHTUNG: InsecureIgnoreHostKey ist unsicher für Produktionsumgebungen! + // Besser: Known Hosts verwenden. + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 10 * time.Second, // Etwas längerer Timeout } + target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) + log.Printf("INFO: Dialing SSH to %s...", target) + + client, err := ssh.Dial("tcp", target, sshConfig) + if err != nil { + return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err) + } + log.Printf("INFO: SSH connection to %s successful.", target) + session, err := client.NewSession() if err != nil { - client.Close() - return nil, fmt.Errorf("creating ssh session failed: %w", err) + client.Close() // Client schließen, wenn Session fehlschlägt + return nil, fmt.Errorf("failed to create SSH session: %w", err) } + session.Close() return &SSHConnection{ - client: client, - session: session, + client: client, + // session: session, // Session wird für Forwarding nicht direkt gebraucht }, nil } func (a *App) killForwardings() error { ports := []string{"2048", "6000"} + killedSomething := false + var lastErr error + + log.Println("INFO: Attempting to kill processes listening on ports:", strings.Join(ports, ", ")) + for _, port := range ports { - cmd := exec.Command("lsof", "-ti", "tcp:"+port) + // Finde Prozess-ID (PID), die auf dem TCP-Port lauscht + // lsof -i tcp: -t gibt nur die PID aus + cmd := exec.Command("lsof", "-i", "tcp:"+port, "-t") output, err := cmd.Output() if err != nil { - continue + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + log.Printf("INFO: No process found listening on port %s.", port) + } else { + log.Printf("WARN: 'lsof' command failed for port %s: %v", port, err) + lastErr = fmt.Errorf("lsof failed for port %s: %w", port, err) // Letzten Fehler merken + } + continue // Gehe zum nächsten Port } - pids := strings.Split(string(output), "\n") - pid := strings.TrimSpace(pids[0]) - killCmd := exec.Command("kill", pid) - killCmd.Run() + pids := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") + for pidStr := range pids { + pid := strings.TrimSpace(pidStr) + if pid == "" { + continue + } + log.Printf("INFO: Found process PID %s on port %s. Attempting to kill...", pid, port) + killCmd := exec.Command("kill", pid) // SIGTERM senden + if err := killCmd.Run(); err != nil { + log.Printf("WARN: Failed to kill PID %s (port %s): %v. Trying kill -9...", pid, port, err) + forceKillCmd := exec.Command("kill", "-9", pid) + if err := forceKillCmd.Run(); err != nil { + log.Printf("ERROR: Failed to force kill PID %s (port %s): %v", pid, port, err) + lastErr = fmt.Errorf("kill -9 failed for PID %s: %w", pid, err) + } else { + log.Printf("INFO: Force killed PID %s (port %s).", pid, port) + killedSomething = true + } + } else { + log.Printf("INFO: Killed PID %s (port %s).", pid, port) + killedSomething = true + } + } } - return nil + + if killedSomething { + log.Println("INFO: Finished attempting to kill forwarding processes.") + } else { + log.Println("INFO: No forwarding processes found or killed.") + } + + return lastErr // Gibt den letzten aufgetretenen Fehler zurück } diff --git a/cmd.go b/cmd.go index 3de9559..ed4475d 100644 --- a/cmd.go +++ b/cmd.go @@ -1,32 +1,48 @@ package main import ( + "fmt" "log" "os" + "strings" + "time" "github.com/spf13/cobra" ) func (a *App) setupCommands() *cobra.Command { rootCmd := &cobra.Command{ - Use: "work", - Short: "Fast work interactions", - Long: `A CLI tool to perform basic work cli tasks.`, + Use: "workctl", // Name des CLI-Tools + Short: "Manage work time, connections, and tasks", + Long: `workctl is a command-line interface to streamline common work-related tasks, +including time tracking (using an internal SQLite database), remote connections (SSH, RDP), +and other utilities.`, + Version: "1.0.0-sqlite", // Beispielversion } - rootCmd.AddCommand(a.startCommand()) rootCmd.AddCommand(a.stopCommand()) rootCmd.AddCommand(a.showCommand()) + rootCmd.AddCommand(a.trackCommand()) // Neuer Befehl für Tracking + rootCmd.AddCommand(a.connectCommands()) // Befehle für Verbindungen gruppieren + rootCmd.AddCommand(a.wakeCommand()) + rootCmd.AddCommand(a.importTimewarriorCommand()) + + // Verberge das Standard 'completion' Kommando, falls nicht gewünscht + rootCmd.CompletionOptions.DisableDefaultCmd = true + return rootCmd } func (a *App) startCommand() *cobra.Command { return &cobra.Command{ Use: "start", - Short: "start work", - Long: "command to start the work day", + Short: "Start work: Track time, wake PC, connect", + Long: "Starts time tracking for 'work', attempts to wake the workstation, and sets up SSH tunnels.", Run: func(cmd *cobra.Command, args []string) { - a.connect() + fmt.Println("Starting workday procedures...") + a.connect() // Führt Start Tracking, Wake, Tunnel Setup aus + fmt.Println("Workday start initiated. Tunnels are running in the background.") + fmt.Println("Use 'workctl connect rdp' or connect manually.") }, } } @@ -34,43 +50,422 @@ func (a *App) startCommand() *cobra.Command { func (a *App) stopCommand() *cobra.Command { return &cobra.Command{ Use: "stop", - Short: "stop work", - Long: "command to stop the work day", + Short: "Stop work: Stop time tracking, kill tunnels", + Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.", Run: func(cmd *cobra.Command, args []string) { - tw := NewTimeWarrior() - tw.StopWork() - if err := a.killForwardings(); err != nil { - log.Printf("error stoping port forwarding: %v", err) + fmt.Println("Stopping workday procedures...") + if err := a.timeStore.StopTracking(); err != nil { + log.Printf("ERROR: Failed to stop time tracking: %v", err) + } else { + fmt.Println("Time tracking stopped.") } - os.Exit(0) + + if err := a.killForwardings(); err != nil { + log.Printf("WARN: Could not kill all forwarding processes: %v", err) + } else { + fmt.Println("Attempted to stop SSH tunnels.") + } + + fmt.Println("Workday stop procedures finished.") }, } } +// func (a *App) trackCommand() *cobra.Command { +// cmd := &cobra.Command{ +// Use: "track [tag]", +// Short: "Start tracking a new tag (stops current)", +// Long: `Starts a new time tracking entry with the specified tag (e.g., 'break', 'meeting', 'projectX'). +// This automatically stops any currently running timer. +// If no tag is provided, it stops the current timer and starts 'work'.`, +// Args: cobra.MaximumNArgs(1), // 0 oder 1 Argument +// Run: func(cmd *cobra.Command, args []string) { +// tag := TagWork // Standard-Tag, wenn kein Argument gegeben wird +// if len(args) > 0 { +// tag = args[0] +// } +// +// if tag == "" { +// log.Println("ERROR: Tag cannot be empty.") +// fmt.Println("Usage: workctl track ") +// os.Exit(1) // Fehler signalisieren +// } +// +// fmt.Printf("Attempting to start tracking '%s'...\n", tag) +// if err := a.timeStore.StartTracking(tag); err != nil { +// log.Printf("ERROR: Failed to start tracking '%s': %v", tag, err) +// fmt.Printf("Error: Could not start tracking '%s'.\n", tag) +// os.Exit(1) +// } else { +// fmt.Printf("Successfully started tracking '%s'.\n", tag) +// } +// }, +// } +// cmd.AddCommand(&cobra.Command{ +// Use: "break", +// Short: "Start tracking 'break'", +// Run: func(cmd *cobra.Command, args []string) { +// fmt.Println("Starting break...") +// if err := a.timeStore.StartTracking(TagBreak); err != nil { +// log.Printf("ERROR: Failed to start break tracking: %v", err) +// fmt.Println("Error: Could not start break.") +// os.Exit(1) +// } else { +// fmt.Println("Break started.") +// } +// }, +// }) +// +// return cmd +// } + +func (a *App) trackCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "track [tag]", + Short: "Start tracking time with a tag, or log a full day tag", + Long: `Starts a new time tracking interval with the specified tag (e.g., 'work', 'break', 'meeting'). +This automatically stops any currently running timer. +If no tag is provided, it stops the current timer and starts 'work'. + +If the provided tag is a special full-day tag ('uni', 'urlaub', 'feiertag', 'krank', 'free'), +it will mark the *current day* with that tag instead of starting an interval timer. +This also stops any currently running timer.`, + Args: cobra.MaximumNArgs(1), // 0 oder 1 Argument + RunE: func(cmd *cobra.Command, args []string) error { // RunE für Fehlerbehandlung + tag := TagWork // Standard-Tag, wenn kein Argument gegeben wird + if len(args) > 0 { + tag = args[0] + } + + if tag == "" { + // Sollte durch Default nicht passieren, aber sicher ist sicher + return fmt.Errorf("tag cannot be empty") + } + + tagLower := strings.ToLower(tag) + + // Prüfe, ob es ein spezieller Ganztages-Tag ist + switch tagLower { + case "uni", "urlaub", "feiertag", "krank", "free": + today := time.Now() + fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02")) + if err := a.timeStore.LogFullDay(tagLower, today); err != nil { + // Loggen passiert in LogFullDay oder bei Fehlern davor + // Gib den Fehler zurück, damit Cobra ihn behandelt + return fmt.Errorf("could not log '%s' for today: %w", tagLower, err) + } + // Erfolgsmeldung kommt aus LogFullDay + return nil // Erfolg + + default: // Normale Intervalle wie 'work', 'break', oder unbekannt + fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) + if err := a.timeStore.StartTracking(tag); err != nil { + log.Printf("ERROR: Failed to start tracking '%s': %v", tag, err) + // Gib Fehler zurück + return fmt.Errorf("could not start tracking '%s': %w", tag, err) + } + // Erfolgsmeldung kommt aus StartTracking + return nil // Erfolg + } + }, + } + + // Alias hinzufügen (bleibt bestehen) + cmd.AddCommand(&cobra.Command{ + Use: "break", + Short: "Start tracking 'break'", + RunE: func(cmd *cobra.Command, args []string) error { // RunE verwenden + fmt.Println("Starting break...") + if err := a.timeStore.StartTracking(TagBreak); err != nil { + log.Printf("ERROR: Failed to start break tracking: %v", err) + return fmt.Errorf("could not start break: %w", err) + } + return nil + }, + }) + + return cmd +} + func (a *App) showCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "show", - Short: "show timetracking", - Long: "show different timetracking", + Use: "show [period|export]", + Short: "Show time summaries or export data", + Long: `Shows time tracking summaries for different periods (day, week, month, year) +or exports the yearly data to an Excel file. + +Periods: today, day, week, month, year (or YYYY-MM-DD) +Export: Use the --export flag or the 'export' subcommand.`, + ValidArgs: []string{"day", "week", "month", "year", "today"}, // Zur Autovervollständigung Run: func(cmd *cobra.Command, args []string) { - tw := NewTimeWarrior() + period := "today" // Standard ist heute + if len(args) > 0 { + period = args[0] + } + if a.flags.ShowExport { - tw.ExportSummary(a.flags.ExportName) + filename := a.flags.ExportName + if filename == "" || filename == "Arbeitszeiten.xlsx" { // Standardwert aus Flags anpassen + filename = "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" + log.Printf("INFO: No export name specified, using default: %s", filename) + } + fmt.Printf("Exporting yearly timetable to '%s'...\n", filename) + if err := a.timeStore.ExportSummary(filename); err != nil { + log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) + fmt.Printf("Error: Could not export to '%s'.\n", filename) + } + } else if a.flags.ShowWeek { + fmt.Println("Showing weekly summary...") + if err := a.timeStore.ShowSummary("week"); err != nil { + log.Printf("ERROR: Failed to show week summary: %v", err) + } + } else if a.flags.ShowMonth { + fmt.Println("Showing monthly summary...") + if err := a.timeStore.ShowSummary("month"); err != nil { + log.Printf("ERROR: Failed to show month summary: %v", err) + } + } else { + fmt.Printf("Showing summary for period: %s...\n", period) + if err := a.timeStore.ShowSummary(period); err != nil { + log.Printf("ERROR: Failed to show summary for '%s': %v", period, err) + } } - if a.flags.ShowWeek { - tw.ShowSummary(":week") - } - if a.flags.ShowMonth { - tw.ShowSummary(":month") - } - os.Exit(0) }, } - cmd.Flags().BoolVarP(&a.flags.ShowWeek, "week", "w", false, "show timewarrior week summary") - cmd.Flags().BoolVarP(&a.flags.ShowMonth, "month", "m", false, "show timewarrior month summary") - cmd.Flags().BoolVarP(&a.flags.ShowExport, "export", "e", false, "export timewarrior timetable") - cmd.Flags().StringVarP(&a.flags.ExportName, "name", "n", "Arbeitszeiten.xlsx", "name of exported excel table") + cmd.Flags().BoolVarP(&a.flags.ShowWeek, "week", "w", false, "Show summary for the current week") + cmd.Flags().BoolVarP(&a.flags.ShowMonth, "month", "m", false, "Show summary for the current month") + cmd.Flags().BoolVarP(&a.flags.ShowExport, "export", "e", false, "Export yearly timetable to Excel") + cmd.Flags().StringVarP(&a.flags.ExportName, "name", "n", "Arbeitszeiten_"+time.Now().Format("2006")+".xlsx", "Filename for the exported Excel table") + + cmd.MarkFlagsMutuallyExclusive("week", "month", "export") + + // Füge einen 'export' Unterbefehl hinzu für klarere Nutzung + cmd.AddCommand(&cobra.Command{ + Use: "export [filename]", + Short: "Export yearly timetable to Excel", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" + if len(args) > 0 { + filename = args[0] + } + fmt.Printf("Exporting yearly timetable to '%s'...\n", filename) + if err := a.timeStore.ExportSummary(filename); err != nil { + log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) + fmt.Printf("Error: Could not export to '%s'.\n", filename) + } + }, + }) return cmd } + +func (a *App) wakeCommand() *cobra.Command { + return &cobra.Command{ + Use: "wake", + Short: "Wake the configured workstation via Wake-on-LAN", + Long: "Sends a Wake-on-LAN magic packet to the workstation defined in the configuration, using an SSH jump host if configured.", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("Attempting to wake workstation...") + a.wakeWorkstation() + }, + } +} + +func (a *App) connectCommands() *cobra.Command { + cmd := &cobra.Command{ + Use: "connect", + Short: "Manage remote connections (SSH, RDP)", + Long: "Provides subcommands to establish SSH tunnels and RDP connections.", + } + + cmd.AddCommand(&cobra.Command{ + Use: "jump", + 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) { + a.connectToJump() // Blockiert + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "workstation", + 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) { + a.connectToWorkstation() // Blockiert + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "rdp", + 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) { + a.startRDPConnection() // Blockiert + }, + }) + + return cmd +} + +func (a *App) importTimewarriorCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "import-timew [filepath]", + 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), + RunE: func(cmd *cobra.Command, args []string) error { + filepath := args[0] + fmt.Printf("Attempting to import timewarrior data from: %s\n", filepath) + + count, err := a.runImport(filepath) + if err != nil { + log.Printf("ERROR: Import failed: %v", err) + return fmt.Errorf("import failed: %w", err) + } + + fmt.Printf("Successfully imported %d time entries.\n", count) + log.Printf("INFO: Successfully imported %d time entries from %s", count, filepath) + return nil + }, + } + return cmd +} + +func (a *App) runImport(filepath string) (int, error) { + contentBytes, err := os.ReadFile(filepath) + if err != nil { + return 0, fmt.Errorf("could not read file '%s': %w", filepath, err) + } + content := string(contentBytes) + lines := strings.Split(content, "\n") + + tx, err := a.timeStore.db.Begin() + if err != nil { + return 0, fmt.Errorf("could not begin database transaction: %w", err) + } + defer tx.Rollback() // Rollback wird ausgeführt, wenn Commit nicht erreicht wird + + stmt, err := tx.Prepare("INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?)") + if err != nil { + return 0, fmt.Errorf("could not prepare insert statement: %w", err) + } + defer stmt.Close() + + var current_date_str string + imported_count := 0 + location := time.Local + + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" || i < 1 || strings.HasPrefix(line, "Total") || strings.HasPrefix(line, "---") || strings.Contains(line, "Wk Date Day Tags") { + continue + } + + fields := strings.Fields(line) + var tag, start_str, end_str string + has_date := false + + // Versuche, das Format zu erkennen (mit oder ohne Datum am Anfang) + // Format mit Datum: Wk Date Day Tags Start End Time [Total] + // Format ohne Datum: Tags Start End Time [Total] + // Mindestens 4 Felder für Tag, Start, End, Time erwartet + + if len(fields) >= 7 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 { // Prüft auf Datum YYYY-MM-DD + current_date_str = fields[1] // Datum merken + tag = fields[3] + start_str = fields[4] + end_str = fields[5] + has_date = true + } else if len(fields) >= 4 && strings.Contains(fields[1], ":") && strings.Contains(fields[2], ":") { + if current_date_str == "" { + log.Printf("WARN: Skipping line without preceding date: %s", line) + continue // Überspringe Zeile, wenn kein Datum bekannt ist + } + tag = fields[0] + start_str = fields[1] + end_str = fields[2] + has_date = false + } else if len(fields) >= 6 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 { + current_date_str = fields[1] + tag = fields[3] + start_str = fields[4] + end_str = fields[5] + has_date = true + if start_str == "0:00:00" && end_str == "0:00:00" { + start_time, err := time.ParseInLocation("2006-01-02", current_date_str, location) + if err != nil { + log.Printf("WARN: Skipping line with invalid date '%s': %v", current_date_str, err) + continue + } + end_time := start_time.Add(24 * time.Hour) // Ende ist Anfang des nächsten Tages + + _, err = stmt.Exec(tag, start_time, end_time) + if err != nil { + log.Printf("ERROR: Failed to insert full-day entry for %s (%s): %v", current_date_str, tag, err) + } else { + imported_count++ + } + continue // Gehe zur nächsten Zeile nach Behandlung des ganztägigen Eintrags + } + + } else { + log.Printf("WARN: Skipping unrecognized line format: %s", line) + continue + } + + if end_str == "-" { + log.Printf("INFO: Skipping currently running entry: %s", line) + continue + } + + start_datetime_str := current_date_str + " " + start_str + end_datetime_str := current_date_str + " " + end_str + + start_time, err_start := time.ParseInLocation("2006-01-02 15:04:05", start_datetime_str, location) + end_time, err_end := time.ParseInLocation("2006-01-02 15:04:05", end_datetime_str, location) + + if err_start != nil || err_end != nil { + log.Printf("WARN: Skipping line with invalid date/time format ('%s' / '%s'): %v / %v", start_datetime_str, end_datetime_str, err_start, err_end) + continue + } + + if end_time.Before(start_time) { + if has_date { + log.Printf("WARN: End time is before start time on the same date line, skipping: %s", line) + continue + } + // Wenn kein Datum da war, gehen wir davon aus, dass es sich um Mitternacht handelt. + // Diese Logik ist knifflig und fehleranfällig, da `timew summary` normalerweise aufteilt. + // Einfacher Ansatz: Ignoriere diesen Fall vorerst, da er im Standard-Summary selten auftritt. + // log.Printf("INFO: Detected potential midnight crossing (end %v < start %v) - adjusting end date might be needed if timew split wasn't done.", endTime, startTime) + // endTime = endTime.Add(24 * time.Hour) // Vorsicht mit dieser Annahme! + } + + db_tag := strings.ToLower(tag) + switch db_tag { + case "work": + db_tag = TagWork + case "break": + db_tag = TagBreak + } + + _, err = stmt.Exec(db_tag, start_time, end_time) + if err != nil { + log.Printf("ERROR: Failed to insert entry for %s (%s, %s -> %s): %v", current_date_str, db_tag, start_time.Format(time.RFC3339), end_time.Format(time.RFC3339), err) + } else { + imported_count++ + } + } + + if err := tx.Commit(); err != nil { + return imported_count, fmt.Errorf("failed to commit transaction: %w", err) + } + + return imported_count, nil +} diff --git a/config.go b/config.go index ef79ab0..2a9c6ea 100644 --- a/config.go +++ b/config.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "log" "os" "path/filepath" @@ -20,6 +21,7 @@ type Config struct { RDPPassword string `mapstructure:"RDP_PASSWORD"` WorkstationIP string `mapstructure:"WORKSTATION_IP"` SSHPort int `mapstructure:"SSH_PORT"` + // DatabasePath string `mapstructure:"DATABASE_PATH"` } type Flags struct { @@ -33,22 +35,35 @@ func loadConfig() (Config, error) { var cfg Config configPath, err := os.UserConfigDir() if err != nil { - return cfg, err + return cfg, fmt.Errorf("could not get user config dir: %w", err) } workConfigPath := filepath.Join(configPath, "work") configFile := filepath.Join(workConfigPath, "config.toml") + if err := os.MkdirAll(workConfigPath, 0750); err != nil { + return cfg, fmt.Errorf("could not create config directory '%s': %w", workConfigPath, err) + } + viper.SetConfigFile(configFile) viper.SetConfigType("toml") - viper.AddConfigPath(".") viper.AutomaticEnv() + if err := viper.ReadInConfig(); err != nil { - return cfg, fmt.Errorf("error reading config file: %w", err) + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err) + } + log.Printf("INFO: Config file '%s' not found, using defaults/env vars.", configFile) } if err := viper.UnmarshalKey("default", &cfg); err != nil { - return cfg, fmt.Errorf("error decoding config: %w", err) + if err := viper.Unmarshal(&cfg); err != nil { + return cfg, fmt.Errorf("error decoding config from '%s': %w", configFile, err) + } + } + + if cfg.SSHPort == 0 { + cfg.SSHPort = 22 } return cfg, nil diff --git a/export.go b/export.go new file mode 100644 index 0000000..ffa2dd0 --- /dev/null +++ b/export.go @@ -0,0 +1,490 @@ +package main + +import ( + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/xuri/excelize/v2" +) + +type DailySummary struct { + Date string // YYYY-MM-DD Format + Day string // Mon, Tue, etc. + WorkStart string // HH:MM:SS - Frühester Arbeitsbeginn + WorkEnd string // HH:MM:SS - Spätestes Arbeitsende + BreakDuration time.Duration // Summe aller Pausenzeiten an diesem Tag + WorkDuration time.Duration // Summe aller Arbeitszeiten an diesem Tag + Tag string // Spezielle Tags für den Tag (Urlaub, Krank, Feiertag, Uni, Free...) +} + +type ExcelEntry struct { + Date string // YYYY-MM-DD + Day string // Mon, Tue, etc. + WorkStart string // HH:MM:SS + WorkEnd string // HH:MM:SS + BreakDuration string // HH:MM:SS (formatiert) + Tag string // Spezielle Tags +} + +func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) { + dailyMap := make(map[string]*DailySummary) + location := yearStart.Location() // Verwende die Zeitzone des Startdatums + now := time.Now().In(location) // Aktuelle Zeit für laufende Einträge + + currentDay := yearStart + log.Println(currentDay) + for currentDay.Before(yearEnd) { + dayStr := currentDay.Format("2006-01-02") + weekday := currentDay.Weekday() + tag := "" + if weekday == time.Saturday || weekday == time.Sunday { + tag = "free" + } + + dailyMap[dayStr] = &DailySummary{ + Date: dayStr, + Day: weekday.String()[:3], // Mon, Tue, etc. + Tag: tag, // Initialisiere mit "free" für Wochenende, sonst leer + } + currentDay = currentDay.Add(24 * time.Hour) // Gehe zum nächsten Tag + } + + fullDayTags := make(map[string]string) // Map, um ganztägige Ereignisse zu speichern (Datum -> Tag) + + for _, entry := range entries { + if entry.StartTime.IsZero() { + log.Printf("WARN: Skipping entry with zero start time (ID: %d)", entry.ID) + continue + } + + startTime := entry.StartTime.In(location) // Stelle sicher, dass Zeiten in der korrekten Zeitzone sind + endTime := entry.EndTime.Time.In(location) + validEndTime := entry.EndTime.Valid + if !validEndTime { + endTime = now // Nimm aktuelle Zeit für laufende Einträge + } + + if endTime.Before(yearStart) || startTime.After(yearEnd) { + continue + } + + lowerTag := strings.ToLower(entry.Tag) + isPotentiallyFullDaySpecialTag := false + switch lowerTag { + case "urlaub", "krank", "feiertag", "uni", "free": + isPotentiallyFullDaySpecialTag = true + } + + if isPotentiallyFullDaySpecialTag { + loopTimeForTag := startTime + for loopTimeForTag.Before(endTime) || loopTimeForTag.Equal(endTime) { + dayStr := loopTimeForTag.Format("2006-01-02") + // Nur Tage innerhalb des Jahres berücksichtigen + if _, exists := dailyMap[dayStr]; exists { + existingTag := fullDayTags[dayStr] + if shouldOverwriteTag(existingTag, lowerTag) { + fullDayTags[dayStr] = lowerTag + } + } + loopTimeForTag = time.Date(loopTimeForTag.Year(), loopTimeForTag.Month(), loopTimeForTag.Day(), 0, 0, 0, 0, location).Add(24 * time.Hour) + } + } + + loopTime := startTime + for loopTime.Before(endTime) { + dayStr := loopTime.Format("2006-01-02") + dayStart := time.Date(loopTime.Year(), loopTime.Month(), loopTime.Day(), 0, 0, 0, 0, location) + dayEnd := dayStart.Add(24 * time.Hour) + + summary, exists := dailyMap[dayStr] + if !exists { + log.Printf("WARN: Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID) + loopTime = dayEnd // Gehe zum nächsten Tag + continue + } + + segmentStart := loopTime + segmentEnd := endTime + if segmentEnd.After(dayEnd) { + segmentEnd = dayEnd + } + + segmentDuration := segmentEnd.Sub(segmentStart) + if segmentDuration <= 0 { // Überspringe leere Segmente + loopTime = dayEnd + continue + } + + timeStr := segmentStart.Format("15:04:05") + // endTimeStr := segmentEnd.Format("15:04:05") // Ende des Segments + + switch lowerTag { + case TagWork: // Konstante verwenden + summary.WorkDuration += segmentDuration + if summary.WorkStart == "" || timeStr < summary.WorkStart { + summary.WorkStart = timeStr + } + entryEndTimeOnThisDay := endTime + if !endTime.Truncate(24 * time.Hour).Equal(dayStart) { + entryEndTimeOnThisDay = segmentEnd + } + entryEndTimeOnThisDayStr := entryEndTimeOnThisDay.Format("15:04:05") + if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd { + summary.WorkEnd = entryEndTimeOnThisDayStr + } + + if summary.Tag == "" || summary.Tag == "free" { + summary.Tag = TagWork + } + + case TagBreak: // Konstante verwenden + summary.BreakDuration += segmentDuration + default: + log.Printf("INFO: 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 + if summary.WorkStart == "" || timeStr < summary.WorkStart { + summary.WorkStart = timeStr + } + entryEndTimeOnThisDay := endTime + if !endTime.Truncate(24 * time.Hour).Equal(dayStart) { + entryEndTimeOnThisDay = segmentEnd + } + entryEndTimeOnThisDayStr := entryEndTimeOnThisDay.Format("15:04:05") + if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd { + summary.WorkEnd = entryEndTimeOnThisDayStr + } + if summary.Tag == "" || summary.Tag == "free" { + summary.Tag = TagWork // Behandle unbekannt wie Arbeit für den Tag-Typ + } + } + + loopTime = dayEnd // Gehe zum nächsten Tag + } + } + + for dayStr, specialTag := range fullDayTags { + if summary, exists := dailyMap[dayStr]; exists { + // Nur überschreiben, wenn der neue Tag höhere oder gleiche Prio hat + if shouldOverwriteTag(summary.Tag, specialTag) { + summary.Tag = specialTag + summary.WorkStart = "" + summary.WorkEnd = "" + summary.WorkDuration = 0 + summary.BreakDuration = 0 + } + } + } + + return dailyMap, nil +} + +func shouldOverwriteTag(existingTag, newTag string) bool { + if newTag != "" && (existingTag == "" || strings.ToLower(existingTag) == "free") { + return true + } + if newTag == "" { + return false + } + + priority := map[string]int{ + "krank": 1, + "feiertag": 1, + "urlaub": 1, + "uni": 2, + "work": 3, // Arbeit hat niedrigere Priorität als spezielle Tage + "break": 99, // Pause sollte nie der Haupt-Tag sein + "free": 100, // Frei hat niedrigste Priorität + } + prioExisting, okExisting := priority[strings.ToLower(existingTag)] + if !okExisting { + prioExisting = 999 + } + prioNew, okNew := priority[strings.ToLower(newTag)] + if !okNew { + prioNew = 999 + } + + return prioNew < prioExisting || (prioNew == prioExisting && strings.ToLower(newTag) != "work") +} + +func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []ExcelEntry { + excelEntries := make([]ExcelEntry, 0, len(dailySummaries)) + + dates := make([]string, 0, len(dailySummaries)) + for d := range dailySummaries { + dates = append(dates, d) + } + sort.Strings(dates) // Sortiere die Datums-Strings (YYYY-MM-DD) + + for _, dateStr := range dates { + summary := dailySummaries[dateStr] + entry := ExcelEntry{ + Date: summary.Date, + Day: summary.Day, + WorkStart: summary.WorkStart, + WorkEnd: summary.WorkEnd, + BreakDuration: formatDuration(summary.BreakDuration), // Formatierte Dauer + Tag: summary.Tag, // Übernehme den finalen Tag + } + excelEntries = append(excelEntries, entry) + } + return excelEntries +} + +func formatDuration(d time.Duration) string { + if d < 0 { + d = -d // Arbeite mit positivem Wert für die Berechnung + 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 { + var sollString string + switch dayOfWeek { + case "Mon", "Tue", "Thu", "Fri": // Standard-Arbeitstage + sollString = "08:00" + case "Wed": // Kurzer Tag + sollString = "04:00" + default: // Sa, So + return nil // Kein Soll an diesen Tagen + } + + sollDur, err := time.Parse("15:04", sollString) + if err != nil { + log.Printf("ERROR: Could not parse hardcoded soll string '%s': %v", sollString, err) + return nil // Fehler beim Parsen + } + return float64(sollDur.Hour())/24.0 + float64(sollDur.Minute())/(24.0*60.0) +} + +func writeExcelSheet(entries []ExcelEntry, name string) error { + // Sortierung erfolgt jetzt in convertDailyToExcelEntries + + f := excelize.NewFile() + defer func() { + if err := f.Close(); err != nil { + log.Printf("ERROR: Failed to close excel file handle: %v", err) + } + }() + + sheetName := "Zeiten" + if len(entries) > 0 { + if t, err := time.Parse("2006-01-02", entries[0].Date); err == nil { + sheetName = fmt.Sprintf("%d", t.Year()) + } + } + + index, err := f.NewSheet(sheetName) + if err != nil { + existingIndex, _ := f.GetSheetIndex(sheetName) + if existingIndex == -1 { + sheetName = "Sheet1" // Fallback auf Default + index, _ = f.GetSheetIndex(sheetName) + if index == -1 { + return fmt.Errorf("could not create or find sheet '%s' or 'Sheet1': %w", sheetName, err) + } + } else { + index = existingIndex + } + } + + defaultSheetName := "Sheet1" + defaultSheetIndex, _ := f.GetSheetIndex(defaultSheetName) + if sheetName != defaultSheetName && defaultSheetIndex != -1 { + f.DeleteSheet(defaultSheetName) + } + + f.SetCellValue(sheetName, "B1", "Arbeitszeiten "+sheetName) + f.MergeCell(sheetName, "B1", "O1") + + f.SetCellValue(sheetName, "B3", "Datum") + f.SetCellValue(sheetName, "C3", "Tag") + f.SetCellValue(sheetName, "D3", "Status / Zeit") // Titel angepasst + f.MergeCell(sheetName, "D3", "E3") + f.SetCellValue(sheetName, "G3", "Dauer") // Titel angepasst + f.MergeCell(sheetName, "G3", "H3") + f.SetCellValue(sheetName, "I3", "Pause") + f.SetCellValue(sheetName, "J3", "Netto") + f.SetCellValue(sheetName, "K3", "Soll") + f.SetCellValue(sheetName, "L3", "Saldo") + f.SetCellValue(sheetName, "N3", "Saldo Kumuliert") + f.MergeCell(sheetName, "N3", "O3") + + f.SetCellValue(sheetName, "D4", "von / Status") + f.SetCellValue(sheetName, "E4", "bis") + f.SetCellValue(sheetName, "G4", "brutto") + f.SetCellValue(sheetName, "H4", "") + f.SetCellValue(sheetName, "J4", "Ist (Netto)") // Titel angepasst + f.SetCellValue(sheetName, "K4", "") + f.SetCellValue(sheetName, "L4", "Tag") + f.SetCellValue(sheetName, "N4", "Total") + f.SetCellValue(sheetName, "O4", "") + + timeStyleCode := "hh:mm" // Format für Zeitpunkte und Dauer < 24h + timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode}) + dateStyleCode := "dd.mm.yyyy" // Format für Datum + dateStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &dateStyleCode}) + saldoStyleCode := "[h]:mm;[RED]-[h]:mm" // Verwende [h] um Stunden > 24 zu erlauben + saldoStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &saldoStyleCode}) + headerStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true}, + Alignment: &excelize.Alignment{Horizontal: "center"}, + }) + centerStyle, _ := f.NewStyle(&excelize.Style{Alignment: &excelize.Alignment{Horizontal: "center"}}) + // Stil für Zellen, die leer bleiben sollen (optional, um z.B. 0 auszublenden) + // emptyStyle, _ := f.NewStyle(&excelize.Style{Fill: excelize.Fill{Type: "pattern", Color: []string{"#FFFFFF"}, Pattern: 1}}) // Weißer Hintergrund + + f.SetCellStyle(sheetName, "B3", "O4", headerStyle) + f.SetCellStyle(sheetName, "B1", "O1", headerStyle) // Titel auch + + startRow := 6 + for i, entry := range entries { + row := startRow + i + rowStr := fmt.Sprintf("%d", row) + tagLower := strings.ToLower(entry.Tag) + + dateValue, err := time.Parse("2006-01-02", entry.Date) + if err == nil { + f.SetCellValue(sheetName, "B"+rowStr, dateValue) + f.SetCellStyle(sheetName, "B"+rowStr, "B"+rowStr, dateStyle) + } else { + f.SetCellValue(sheetName, "B"+rowStr, entry.Date) // Fallback + } + f.SetCellValue(sheetName, "C"+rowStr, entry.Day) + + sollExcelTime := getSollExcelTime(entry.Day) + if sollExcelTime != nil { + f.SetCellValue(sheetName, "K"+rowStr, sollExcelTime) + f.SetCellStyle(sheetName, "K"+rowStr, "K"+rowStr, timeStyle) + } else { + f.SetCellValue(sheetName, "K"+rowStr, "") // Leer für Wochenende etc. + } + + switch tagLower { + case TagWork, "": // Normaler Arbeitstag oder nicht speziell getaggter Tag + if entry.WorkStart != "" && entry.WorkEnd != "" { + startTime, _ := time.Parse("15:04:05", entry.WorkStart) + 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) + endExcelTime := float64(endTime.Hour())/24.0 + float64(endTime.Minute())/(24.0*60.0) + float64(endTime.Second())/(24.0*60.0*60.0) + if endExcelTime < startExcelTime { + endExcelTime += 1.0 + } + + f.SetCellValue(sheetName, "D"+rowStr, startExcelTime) + f.SetCellStyle(sheetName, "D"+rowStr, "D"+rowStr, timeStyle) + f.SetCellValue(sheetName, "E"+rowStr, endExcelTime) + f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle) + + 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 + + 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) + thirtyMinBreak := float64(30) / (24 * 60) + if breakExcelTime < thirtyMinBreak { + breakExcelTime = thirtyMinBreak + } + f.SetCellValue(sheetName, "I"+rowStr, breakExcelTime) + f.SetCellStyle(sheetName, "I"+rowStr, "I"+rowStr, timeStyle) + + f.SetCellFormula(sheetName, "J"+rowStr, fmt.Sprintf("MAX(0, G%d-I%d)", row, row)) + f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) // Saldo-Style für Dauer + + } else { + f.SetCellValue(sheetName, "J"+rowStr, 0.0) + f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) + } + + case "urlaub", "uni": + text := "" + if tagLower == "urlaub" { + text = "Urlaub" + } else { + text = "Hochschule" + } + f.SetCellValue(sheetName, "D"+rowStr, text) + f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) // Verbinde D bis I + f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle) + + if sollExcelTime != nil { + f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime) + } else { + f.SetCellValue(sheetName, "J"+rowStr, 0.0) // Kein Soll (WE), Netto 0 + } + f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) + + case "feiertag", "krank": + text := "" + if tagLower == "feiertag" { + text = "Feiertag" + } else { + text = "Krank" + } + f.SetCellValue(sheetName, "D"+rowStr, text) + f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) // Verbinde D bis I + f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle) + + if sollExcelTime != nil { + f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime) + } else { + f.SetCellValue(sheetName, "J"+rowStr, 0.0) // Kein Soll (WE), Netto 0 + } + f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) + + case "free": // Wochenende oder explizit "free" + f.SetCellValue(sheetName, "D"+rowStr, "") + f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) // Verbinde D bis I + f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle) + // J: Netto ist 0 + f.SetCellValue(sheetName, "J"+rowStr, 0.0) + f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) + + default: // Unbekannte Tags oder Tage ohne Eintrag + f.SetCellValue(sheetName, "J"+rowStr, 0.0) + f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) + } + + f.SetCellFormula(sheetName, "L"+rowStr, fmt.Sprintf("J%d-K%d", row, row)) + f.SetCellStyle(sheetName, "L"+rowStr, "M"+rowStr, saldoStyle) // Style auf L und M + + if i == 0 { // Erste Datenzeile + f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("L%d", row)) + } else { + prevSaldoTotalCell := fmt.Sprintf("N%d", row-1) + f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("%s+L%d", prevSaldoTotalCell, row)) + } + f.SetCellStyle(sheetName, "N"+rowStr, "O"+rowStr, saldoStyle) // Style auf N und O + } + + f.SetColWidth(sheetName, "B", "B", 12) // Datum + f.SetColWidth(sheetName, "C", "C", 5) // Tag + f.SetColWidth(sheetName, "D", "E", 10) // Status/Zeit von/bis + f.SetColWidth(sheetName, "F", "F", 2) // Leer + f.SetColWidth(sheetName, "G", "H", 9) // Dauer brutto + f.SetColWidth(sheetName, "I", "I", 9) // Pause + f.SetColWidth(sheetName, "J", "J", 9) // Netto + f.SetColWidth(sheetName, "K", "K", 9) // Soll + f.SetColWidth(sheetName, "L", "M", 9) // Saldo Tag + f.SetColWidth(sheetName, "N", "O", 10) // Saldo Total + + f.SetActiveSheet(index) + if err := f.SaveAs(name); err != nil { + return fmt.Errorf("failed to save excel file as '%s': %w", name, err) + } + + return nil +} diff --git a/forwarder.go b/forwarder.go index 6d56d2d..8115f71 100644 --- a/forwarder.go +++ b/forwarder.go @@ -1,22 +1,25 @@ package main import ( + "fmt" "io" "log" "net" + "sync" // Mutex hinzufügen für sichereres Logging "golang.org/x/crypto/ssh" ) -type PortFowarder struct { +type PortForwarder struct { sshCon *ssh.Client localPort string remotePort string remoteHost string + logMutex sync.Mutex // Mutex zum Schutz von Log-Ausgaben aus Goroutinen } -func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost string) *PortFowarder { - return &PortFowarder{ +func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost string) *PortForwarder { + return &PortForwarder{ sshCon: sshCon, localPort: localPort, remotePort: remotePort, @@ -24,35 +27,88 @@ func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost stri } } -func (pw *PortFowarder) forward() error { - listener, err := net.Listen("tcp", "127.0.0.1:"+pw.localPort) +func (pf *PortForwarder) forward() error { + localAddr := "127.0.0.1:" + pf.localPort + remoteAddr := net.JoinHostPort(pf.remoteHost, pf.remotePort) // Sicherer Host:Port kombinieren + + pf.logf("INFO: Starting port forwarder: local %s -> remote %s (via SSH)", localAddr, remoteAddr) + + listener, err := net.Listen("tcp", localAddr) if err != nil { - log.Printf("Fehler beim Öffnen des lokalen Ports %s: %v", pw.localPort, err) - return err + 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 on %s", localAddr) for { localConn, err := listener.Accept() if err != nil { - log.Printf("Fehler beim Akzeptieren der Verbindung: %v", err) + // Fehler tritt auf, wenn der Listener geschlossen wird oder ein Netzwerkproblem vorliegt. + // Prüfe, ob der Fehler durch Schließen des Listeners verursacht wurde (erwartet). + // Fehler wie 'use of closed network connection' sind hier normal beim Beenden. + // if errors.Is(err, net.ErrClosed) { // Bessere Prüfung in neueren Go-Versionen + if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" { + pf.logf("INFO: Listener on %s closed, stopping forwarder.", localAddr) + return nil // Kein Fehler, normales Beenden + } + pf.logf("ERROR: Failed to accept incoming connection on %s: %v", localAddr, err) + // Optional: Kurze Pause vor erneutem Versuch oder Abbruch? + // Bei dauerhaften Fehlern wird die Schleife hier schnell laufen. + // Für dieses Tool ist ein Weiterlaufen bei Accept-Fehlern wahrscheinlich ok. continue } - remoteConn, err := pw.sshCon.Dial("tcp", pw.remoteHost+":"+pw.remotePort) - if err != nil { - log.Printf("Fehler beim Verbinden zum Remote-Host %s:%s: %v", pw.remoteHost, pw.remotePort, err) - localConn.Close() - continue - } - - go pw.copyConn(localConn, remoteConn) - go pw.copyConn(remoteConn, localConn) + pf.logf("INFO: Accepted connection from %s on %s", localConn.RemoteAddr(), localAddr) + go pf.handleConnection(localConn, remoteAddr) } } -func (pw *PortFowarder) copyConn(dst, src net.Conn) { - defer dst.Close() - defer src.Close() - io.Copy(dst, src) +func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) { + defer localConn.Close() + + pf.logf("INFO: Dialing remote host %s via SSH tunnel for %s", remoteAddr, localConn.RemoteAddr()) + remoteConn, err := pf.sshCon.Dial("tcp", remoteAddr) + if err != nil { + pf.logf("ERROR: Failed to dial remote host %s via SSH: %v", remoteAddr, err) + // Schließe lokale Verbindung, wenn Remote nicht erreicht werden kann + // (defer macht das schon, aber hier explizit zur Klarheit) + // localConn.Close() // Ist durch defer abgedeckt + return + } + defer remoteConn.Close() + pf.logf("INFO: Connection to %s established. Starting data copy.", remoteAddr) + + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + defer localConn.Close() // Schließe die eine Seite, wenn die andere endet + bytesCopied, err := io.Copy(localConn, remoteConn) + if err != nil { + // Fehler beim Kopieren sind normal, wenn eine Seite die Verbindung schließt + // log.Printf("DEBUG: Error copying remote->local: %v", err) + } + pf.logf("INFO: Finished copying remote->local (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) + }() + + go func() { + defer wg.Done() + defer remoteConn.Close() // Schließe die andere Seite, wenn diese endet + bytesCopied, err := io.Copy(remoteConn, localConn) + if err != nil { + // log.Printf("DEBUG: Error copying local->remote: %v", err) + } + pf.logf("INFO: Finished copying local->remote (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) + }() + + wg.Wait() + pf.logf("INFO: Closing forwarded connection for %s", localConn.RemoteAddr()) +} + +func (pf *PortForwarder) logf(format string, v ...interface{}) { + pf.logMutex.Lock() + defer pf.logMutex.Unlock() + log.Printf(format, v...) // Verwende den Standard-Logger } diff --git a/go.mod b/go.mod index 0d287fc..882a145 100644 --- a/go.mod +++ b/go.mod @@ -1,61 +1,64 @@ module work -go 1.23.0 +go 1.24.2 require ( - github.com/charmbracelet/huh v0.5.3 - github.com/spf13/cobra v1.8.1 - github.com/spf13/viper v1.19.0 + github.com/charmbracelet/huh v0.6.0 + github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 github.com/xuri/excelize/v2 v2.9.0 - golang.org/x/crypto v0.28.0 + golang.org/x/crypto v0.36.0 + modernc.org/sqlite v1.37.0 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.2.0 // indirect - github.com/charmbracelet/bubbles v0.19.0 // indirect - github.com/charmbracelet/bubbletea v0.27.0 // indirect + github.com/charmbracelet/bubbles v0.20.0 // indirect + github.com/charmbracelet/bubbletea v1.1.0 // indirect github.com/charmbracelet/lipgloss v0.13.0 // indirect - github.com/charmbracelet/x/ansi v0.2.2 // indirect + github.com/charmbracelet/x/ansi v0.2.3 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/net v0.30.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.62.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.9.1 // indirect ) diff --git a/go.sum b/go.sum index 31108ec..c2980c4 100644 --- a/go.sum +++ b/go.sum @@ -6,37 +6,40 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0= -github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA= -github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU= -github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y= -github.com/charmbracelet/huh v0.5.3 h1:3KLP4a/K1/S4dq4xFMTNMt3XWhgMl/yx8NYtygQ0bmg= -github.com/charmbracelet/huh v0.5.3/go.mod h1:OZC3lshuF+/y8laj//DoZdFSHxC51OrtXLJI8xWVouQ= +github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE= +github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU= +github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c= +github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4= +github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8= +github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU= github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw= github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY= -github.com/charmbracelet/x/ansi v0.2.2 h1:BC7xzaVpfWIYZRNE8NhO9zo8KA4eGUL6L/JWXDh3GF0= -github.com/charmbracelet/x/ansi v0.2.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY= +github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -45,8 +48,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -55,8 +56,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -65,11 +64,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM= github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk= github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= @@ -81,32 +83,24 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY= @@ -119,29 +113,54 @@ go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= -golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= +modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= +modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= +modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index 5a18829..0bc0bd3 100644 --- a/main.go +++ b/main.go @@ -8,11 +8,17 @@ import ( func main() { app, err := NewApp() if err != nil { - log.Fatalf("unable to setup application: %v", err) + log.Fatalf("ERROR: Unable to setup application: %v", err) } + defer func() { + if err := app.Close(); err != nil { + log.Printf("ERROR: Failed to close application resources: %v", err) + } + }() + if len(os.Args) > 1 { if err := app.setupCommands().Execute(); err != nil { - log.Fatalf("error executing command: %v", err) + os.Exit(1) } } else { app.makeChoice() diff --git a/ssh.go b/ssh.go index ee43275..40c4ffb 100644 --- a/ssh.go +++ b/ssh.go @@ -1,18 +1,20 @@ package main -import "golang.org/x/crypto/ssh" +import ( + "log" + + "golang.org/x/crypto/ssh" +) type SSHConnection struct { - client *ssh.Client - session *ssh.Session + client *ssh.Client + // session *ssh.Session // Session wird für Forwarding nicht direkt benötigt } -func (s *SSHConnection) Close() { - if s.session != nil { - s.session.Close() - } - +func (s *SSHConnection) Close() error { if s.client != nil { - s.client.Close() + log.Println("DEBUG: Closing SSH client connection.") // Optional Debug Log + return s.client.Close() } + return nil } diff --git a/store.go b/store.go new file mode 100644 index 0000000..b3ed751 --- /dev/null +++ b/store.go @@ -0,0 +1,446 @@ +package main + +import ( + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +const ( + TagWork = "work" + TagBreak = "break" +) + +type TimeEntry struct { + ID int64 + Tag string + StartTime time.Time + EndTime sql.NullTime +} + +type TimeStore struct { + db *sql.DB + dbPath string +} + +func NewTimeStore(cfg Config) (*TimeStore, error) { + dbPath, err := ensureDatabasePath(cfg) + if err != nil { + return nil, fmt.Errorf("could not determine database path: %w", err) + } + + log.Printf("INFO: Using database at: %s", dbPath) + + db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath)) + if err != nil { + return nil, fmt.Errorf("failed to open database '%s': %w", dbPath, err) + } + + if err = db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to connect to database '%s': %w", dbPath, err) + } + + createTableSQL := ` + CREATE TABLE IF NOT EXISTS time_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tag TEXT NOT NULL CHECK(tag <> ''), -- Stelle sicher, dass Tag nicht leer ist + start_time DATETIME NOT NULL, + end_time DATETIME NULL, + -- Optional: Stelle sicher, dass nur ein Eintrag NULL end_time haben kann (falls DB unterstützt) + -- 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 { + db.Close() + 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);` + if _, err = db.Exec(createIndexSQL); err != nil { + log.Printf("WARN: Failed to create index on start_time: %v", err) + } + + return &TimeStore{db: db, dbPath: dbPath}, nil +} + +func ensureDatabasePath(cfg Config) (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("could not get user config dir: %w", err) + } + workConfigDir := filepath.Join(configDir, "work") + dbPath := filepath.Join(workConfigDir, "worktime.sqlite") + + if err := os.MkdirAll(workConfigDir, 0750); err != nil { + return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err) + } + + return dbPath, nil +} + +func (ts *TimeStore) Close() error { + if ts.db != nil { + log.Printf("INFO: Closing database connection to %s", ts.dbPath) + return ts.db.Close() + } + return nil +} + +func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) { + query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;` + result, err := ts.db.Exec(query, now) + if err != nil { + return false, fmt.Errorf("failed to execute stop current entry query: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("failed to get affected rows after stopping entry: %w", err) + } + + if rowsAffected > 1 { + log.Printf("WARN: Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected) + } + return rowsAffected > 0, nil +} + +func (ts *TimeStore) StartTracking(tag string) error { + if tag == "" { + return fmt.Errorf("cannot start tracking with an empty tag") + } + + now := time.Now() + stopped, err := ts.stopCurrentEntry(now) + if err != nil { + return err + } + if stopped { + log.Println("INFO: Stopped previous time entry.") + } + + query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);` + _, err = ts.db.Exec(query, tag, now) + if err != nil { + return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err) + } + log.Printf("INFO: Started tracking: %s at %s", tag, now.Format(time.RFC3339)) + return nil +} + +func (ts *TimeStore) StopTracking() error { + now := time.Now() + stopped, err := ts.stopCurrentEntry(now) + if err != nil { + return err + } + if stopped { + log.Printf("INFO: Stopped tracking at %s", now.Format(time.RFC3339)) + } else { + log.Println("INFO: No active time entry found to stop.") + } + return nil +} + +func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error) { + if start.IsZero() || end.IsZero() || end.Before(start) { + return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end) + } + + query := ` + SELECT id, tag, start_time, end_time + FROM time_entries + WHERE start_time >= ? AND start_time < ? + ORDER BY start_time ASC;` + + rows, err := ts.db.Query(query, start, end) + if err != nil { + return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, 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 nil, fmt.Errorf("failed to scan entry row: %w", err) + } + entries = append(entries, entry) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("error during row iteration: %w", err) + } + + return entries, nil +} + +func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, error) { + start, end := getTimeRangeFromPeriod(period) + if start.IsZero() { + return nil, fmt.Errorf("invalid period string: '%s'", period) + } + + query := ` + SELECT id, tag, start_time, end_time + FROM time_entries + WHERE (end_time IS NULL OR end_time > ?) -- Endet nach dem Start des Zeitraums + AND start_time < ? -- Beginnt vor dem Ende des Zeitraums + ORDER BY start_time ASC;` + + rows, err := ts.db.Query(query, start, end) + if err != nil { + return nil, fmt.Errorf("failed to query overlapping entries for range [%v, %v): %w", start, end, err) + } + defer rows.Close() + + summary := make(map[string]time.Duration) + now := time.Now() // Aktuelle Zeit für laufende Einträge + + for rows.Next() { + var entry TimeEntry + 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) + } + + effectiveStart := entry.StartTime + if effectiveStart.Before(start) { + effectiveStart = start + } + + effectiveEnd := entry.EndTime.Time + if !entry.EndTime.Valid { // Eintrag läuft noch + effectiveEnd = now + } + + if effectiveEnd.After(end) { + effectiveEnd = end + } + + 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 + } + log.Printf("WARN: Unrecognized period string '%s'. Cannot calculate time range.", period) + return time.Time{}, time.Time{} + } +} + +// func formatDuration(d time.Duration) string { +// 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 (ts *TimeStore) ShowSummary(period string) error { + summary, err := ts.CalculateSummary(period) + if err != nil { + return fmt.Errorf("error calculating summary for '%s': %w", period, err) + } + + start, _ := getTimeRangeFromPeriod(period) + titlePeriod := period + if !start.IsZero() { + _, end := getTimeRangeFromPeriod(period) + if period == ":day" || period == "today" { + titlePeriod = fmt.Sprintf("Today (%s)", start.Format("2006-01-02")) + } else if period == ":week" { + titlePeriod = fmt.Sprintf("Week starting %s", start.Format("Mon, 2006-01-02")) + } else if period == ":month" { + titlePeriod = fmt.Sprintf("Month %s", start.Format("January 2006")) + } else if period == ":year" { + titlePeriod = fmt.Sprintf("Year %d", start.Year()) + } else if _, err := time.Parse("2006-01-02", period); err == nil { + titlePeriod = fmt.Sprintf("Day %s", start.Format("2006-01-02")) + } else { + titlePeriod = fmt.Sprintf("Period '%s' (%s to %s)", period, start.Format("2006-01-02"), end.Format("2006-01-02")) + } + } + + fmt.Printf("\nTime Summary for %s\n", titlePeriod) + if len(summary) == 0 { + fmt.Println(" No recorded time entries for this period.") + return nil + } + + tags := make([]string, 0, len(summary)) + for tag := range summary { + tags = append(tags, tag) + } + + totalDuration := time.Duration(0) + fmt.Println("------------------------------") + for _, tag := range tags { + duration := summary[tag] + fmt.Printf(" %-12s: %s\n", strings.Title(tag), formatDuration(duration)) + totalDuration += duration + } + fmt.Println("------------------------------") + fmt.Printf(" Total : %s\n\n", formatDuration(totalDuration)) + + return nil +} + +func (ts *TimeStore) ExportSummary(filename string) error { + log.Printf("INFO: 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) + log.Printf("INFO: 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 < ? -- 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() + + 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 (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) + } + log.Printf("INFO: 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) + + if len(excelEntries) == 0 { + log.Println("WARN: No daily summaries generated for the export period.") + fmt.Println("No data available to generate the export for the specified period.") + return nil + } + log.Printf("INFO: Generated %d daily entries for the Excel export.", len(excelEntries)) + + if err := writeExcelSheet(excelEntries, filename); err != nil { // Aufruf der geänderten Funktion + return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err) + } + + log.Printf("INFO: Successfully exported timetable to %s", filename) + fmt.Printf("Successfully exported timetable to %s\n", filename) + 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) // Stelle sicher, dass der Tag klein geschrieben ist + + location := date.Location() // Verwende die Zeitzone des übergebenen Datums + // Berechne Start (00:00:00 des Tages) und Ende (00:00:00 des nächsten Tages) + 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") + + log.Printf("INFO: Attempting to log '%s' for the full day %s", tag, dayStr) + + // 1. Stoppe den aktuell laufenden Timer (falls vorhanden) + // Wir verwenden dayStart als Zeitpunkt für das Stoppen, um Konsistenz zu wahren + stopped, err := ts.stopCurrentEntry(dayStart) + if err != nil { + // Nur loggen, weitermachen. Der Nutzer will diesen Tag ja explizit setzen. + log.Printf("WARN: Failed to stop current entry before logging full day '%s': %v", tag, err) + } else if stopped { + log.Printf("INFO: 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() // Stellt sicher, dass bei Fehlern nichts gespeichert wird + + 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 { + // Spezifischere Fehlermeldung, falls es UNIQUE Constraints gäbe + return fmt.Errorf("failed to insert full-day entry for tag '%s' on %s: %w", tag, dayStr, err) + } + + // Transaktion erfolgreich abschließen + if err = tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction for full-day entry: %w", err) + } + + log.Printf("INFO: 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", strings.Title(tag), dayStr) // Benutzerfeedback + return nil +} diff --git a/timewarrior.go b/timewarrior.go deleted file mode 100644 index 044d197..0000000 --- a/timewarrior.go +++ /dev/null @@ -1,321 +0,0 @@ -package main - -import ( - "fmt" - "os" - "os/exec" - "sort" - "strconv" - "strings" - "time" - - "github.com/xuri/excelize/v2" -) - -type TimeWarrior struct{} - -func NewTimeWarrior() *TimeWarrior { - return &TimeWarrior{} -} - -func (t *TimeWarrior) runCommand(args ...string) error { - cmd := exec.Command("timew", args...) - cmd.Stdout = os.Stdout - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr - return cmd.Run() -} - -func (t *TimeWarrior) StartWork() error { - return t.runCommand("start", "work") -} - -func (t *TimeWarrior) StopWork() error { - return t.runCommand("stop", "work") -} - -func (t *TimeWarrior) StartBreak() error { - return t.runCommand("track", "break") -} - -func (t *TimeWarrior) StopBreak() error { - return t.runCommand("track", "work") -} - -func (t *TimeWarrior) ShowSummary(period string) error { - return t.runCommand("summary", period, "work") -} - -type TimeEntry struct { - Week string - Date string - Day string - Start string - End string - Duration string - Tag string -} - -func (t *TimeWarrior) ExportSummary(name string) error { - fmt.Println("Export Timetable") - exportCommand := exec.Command("timew", "summary", ":year") - output, err := exportCommand.Output() - if err != nil { - return err - } - lines := strings.Split(string(output), "\n") - if len(lines) > 2 { - var entries []TimeEntry - for _, line := range lines[3 : len(lines)-4] { - words := strings.Fields(line) - newLine := strings.Join(words, " ") - parts := strings.Split(strings.TrimSpace(newLine), " ") - entry := TimeEntry{} - - switch len(parts) { - case 4, 5: - entry.Tag = parts[0] - entry.Start = parts[1] - entry.End = parts[2] - entry.Duration = parts[3] - case 7, 8: - entry.Week = parts[0] - entry.Date = parts[1] - entry.Day = parts[2] - entry.Tag = parts[3] - entry.Start = parts[4] - entry.End = parts[5] - entry.Duration = parts[6] - default: - fmt.Println("Unknown length") - } - entries = append(entries, entry) - } - - dailySummary := make(map[string]*DailySummary) - var currentDate string - - for _, entry := range entries { - if entry.Date != "" { - currentDate = entry.Date - } - - if currentDate == "" { - continue - } - - if _, exists := dailySummary[currentDate]; !exists { - dailySummary[currentDate] = &DailySummary{ - Date: currentDate, - Day: entry.Day, - } - } - - summary := dailySummary[currentDate] - - switch strings.ToLower(entry.Tag) { - case "work": - if summary.WorkStart == "" { - summary.WorkStart = entry.Start - } - summary.WorkEnd = entry.End - case "break": - duration, _ := parseDuration(entry.Duration) - summary.BreakDuration += duration - case "uni", "free", "krank", "urlaub", "feiertag": - summary.Tag = entry.Tag - } - } - - var excelEntries []ExcelEntry - for _, summary := range dailySummary { - entry := ExcelEntry{ - Date: summary.Date, - Day: summary.Day, - WorkStart: summary.WorkStart, - WorkEnd: summary.WorkEnd, - BreakDuration: formatDuration(summary.BreakDuration), - Tag: summary.Tag, - } - excelEntries = append(excelEntries, entry) - } - - for _, entry := range excelEntries { - fmt.Printf("%+v\n", entry) - } - - err = writeExcelSheet(excelEntries, name) - if err != nil { - return err - } - } else { - fmt.Println("No Data") - } - - return nil -} - -type DailySummary struct { - Date string - Day string - WorkStart string - WorkEnd string - BreakDuration time.Duration - Tag string -} - -type ExcelEntry struct { - Date string - Day string - WorkStart string - WorkEnd string - BreakDuration string - Tag string -} - -func parseDuration(s string) (time.Duration, error) { - parts := strings.Split(s, ":") - if len(parts) != 3 { - return 0, fmt.Errorf("invalid duration format: %s", s) - } - - hours, err := strconv.Atoi(parts[0]) - if err != nil { - return 0, err - } - - minutes, err := strconv.Atoi(parts[1]) - if err != nil { - return 0, err - } - - seconds, err := strconv.Atoi(parts[2]) - if err != nil { - return 0, err - } - duration, err := time.ParseDuration(fmt.Sprintf("%vh%vm%vs", hours, minutes, seconds)) - if err != nil { - return 0, err - } - - return duration, nil -} - -func formatDuration(d time.Duration) string { - h := d / time.Hour - d -= h * time.Hour - m := d / time.Minute - d -= m * time.Minute - s := d / time.Second - return fmt.Sprintf("%d:%02d:%02d", h, m, s) -} - -func writeExcelSheet(entries []ExcelEntry, name string) error { - sort.Slice(entries, func(i, j int) bool { - dateI, _ := time.Parse("2006-01-02", entries[i].Date) - dateJ, _ := time.Parse("2006-01-02", entries[j].Date) - return dateI.Before(dateJ) - }) - - sheetName := fmt.Sprint(time.Now().Year()) - f := excelize.NewFile() - defer func() { - if err := f.Close(); err != nil { - fmt.Println(err) - } - }() - - index, err := f.NewSheet(sheetName) - if err != nil { - return err - } - - f.SetCellValue(sheetName, "B1", "Arbeitszeiten "+sheetName) - f.SetCellValue(sheetName, "B3", "Datum") - f.SetCellValue(sheetName, "D3", "Arbeitszeit") - f.SetCellValue(sheetName, "G3", "Summe") - f.SetCellValue(sheetName, "I3", "Pause") - f.SetCellValue(sheetName, "J3", "Summe") - f.SetCellValue(sheetName, "K3", "Soll") - f.SetCellValue(sheetName, "L3", "Saldo") - f.SetCellValue(sheetName, "N3", "Saldo") - f.SetCellValue(sheetName, "D4", "von") - f.SetCellValue(sheetName, "E4", "bis") - f.SetCellValue(sheetName, "G4", "brutto") - f.SetCellValue(sheetName, "J4", "netto") - f.SetCellValue(sheetName, "L4", "Tag") - f.SetCellValue(sheetName, "N4", "total") - - strStyle := "hh:mm" - timeStyle, err := f.NewStyle(&excelize.Style{ - CustomNumFmt: &strStyle, - }) - if err != nil { - return err - } - - for num, entry := range entries { - var soll string - switch entry.Day { - case "Mon", "Tue": - soll = "08:00" - case "Wed": - soll = "04:00" - default: - soll = "" - } - - row := fmt.Sprint(num + 6) - - dateValue, _ := time.Parse("2006-01-02", entry.Date) - f.SetCellValue(sheetName, "B"+row, dateValue) - - if entry.Tag == "" { - startTime, _ := time.Parse("15:04:05", entry.WorkStart) - endTime, _ := time.Parse("15:04:05", entry.WorkEnd) - - startExcel := float64(startTime.Hour())/24.0 + float64(startTime.Minute())/(24.0*60.0) - endExcel := float64(endTime.Hour())/24.0 + float64(endTime.Minute())/(24.0*60.0) - - f.SetCellValue(sheetName, "D"+row, startExcel) - f.SetCellStyle(sheetName, "D"+row, "D"+row, timeStyle) - f.SetCellValue(sheetName, "E"+row, endExcel) - f.SetCellStyle(sheetName, "E"+row, "E"+row, timeStyle) - - // Formeln setzen - f.SetCellFormula(sheetName, "G"+row, fmt.Sprintf("E%d-D%d", num+6, num+6)) - f.SetCellStyle(sheetName, "G"+row, "G"+row, timeStyle) - - f.SetCellValue(sheetName, "I"+row, entry.BreakDuration) - f.SetCellFormula(sheetName, "J"+row, fmt.Sprintf("G%d-I%d", num+6, num+6)) - f.SetCellStyle(sheetName, "J"+row, "J"+row, timeStyle) - - f.SetCellValue(sheetName, "K"+row, soll) - f.SetCellStyle(sheetName, "K"+row, "K"+row, timeStyle) - f.SetCellFormula(sheetName, "L"+row, fmt.Sprintf("J%d-K%d", num+6, num+6)) - f.SetCellStyle(sheetName, "L"+row, "L"+row, timeStyle) - - } else { - text := "" - switch entry.Tag { - case "uni": - text = "Hochschule" - case "urlaub": - text = "Urlaub" - case "feiertag": - text = "Feiertag" - default: - text = "" - } - f.SetCellValue(sheetName, "D"+row, text) - } - f.SetCellFormula(sheetName, "N"+row, fmt.Sprintf("N%d+L%d", num+5, num+6)) - f.SetCellStyle(sheetName, "N"+row, "N"+row, timeStyle) - } - - f.SetActiveSheet(index) - if err := f.SaveAs(name); err != nil { - fmt.Println(err) - } - return nil -}