refactor: clean up code from comments

This commit is contained in:
Patryk Hegenberg 2025-04-02 14:21:33 +02:00
parent 4ceed6f301
commit 29bdd3a2a4
8 changed files with 150 additions and 252 deletions

84
app.go
View file

@ -15,7 +15,7 @@ import (
type App struct { type App struct {
cfg Config cfg Config
flags Flags flags Flags
timeStore *TimeStore // Datenbank-Handler hinzugefügt timeStore *TimeStore
} }
func NewApp() (*App, error) { func NewApp() (*App, error) {
@ -31,7 +31,7 @@ func NewApp() (*App, error) {
return &App{ return &App{
cfg: cfg, cfg: cfg,
timeStore: ts, // TimeStore initialisiert timeStore: ts,
}, nil }, nil
} }
@ -47,7 +47,7 @@ func (a *App) connect() {
log.Printf("WARN: Failed to start time tracking for '%s': %v", TagWork, err) log.Printf("WARN: Failed to start time tracking for '%s': %v", TagWork, err)
} }
a.wakeWorkstation() // Versuche, Workstation zu wecken a.wakeWorkstation()
sshCon, err := a.newSSHConnection() sshCon, err := a.newSSHConnection()
if err != nil { if err != nil {
@ -60,7 +60,6 @@ func (a *App) connect() {
go func() { go func() {
log.Println("INFO: Starting SSH forwarder (local :2048 -> remote workstation:22)") log.Println("INFO: Starting SSH forwarder (local :2048 -> remote workstation:22)")
if err := sshForwarder.forward(); err != nil { 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.Printf("ERROR: SSH forwarder failed: %v", err)
} }
log.Println("INFO: SSH forwarder stopped.") log.Println("INFO: SSH forwarder stopped.")
@ -84,7 +83,7 @@ func (a *App) runCommand(name string, args ...string) error {
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin // Wichtig für interaktive Befehle wie ssh cmd.Stdin = os.Stdin
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
log.Printf("ERROR: Command failed: %s %s -> %v", name, strings.Join(args, " "), err) log.Printf("ERROR: Command failed: %s %s -> %v", name, strings.Join(args, " "), err)
@ -97,15 +96,15 @@ func (a *App) runCommand(name string, args ...string) error {
func (a *App) wakeWorkstation() { func (a *App) wakeWorkstation() {
log.Println("INFO: Attempting to wake workstation...") log.Println("INFO: Attempting to wake workstation...")
innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Wake-on-LAN packet sent.' && exit\"", 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.JumpUser,
a.cfg.JumpHost, // Jump-Host a.cfg.JumpHost,
a.cfg.WorkstationMac) // MAC-Adresse der Workstation a.cfg.WorkstationMac)
outerSSHCmd := []string{ outerSSHCmd := []string{
"-tt", // TTY für die äußere Verbindung "-tt",
"-p", fmt.Sprintf("%d", a.cfg.SSHPort), // Port für den ersten Host "-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost), // user@host für den ersten Host fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
innerSSHCmd, // Der gesamte innere SSH-Befehl als einzelnes Argument innerSSHCmd,
} }
if err := a.runCommand("ssh", outerSSHCmd...); err != nil { if err := a.runCommand("ssh", outerSSHCmd...); err != nil {
@ -118,23 +117,22 @@ func (a *App) wakeWorkstation() {
func (a *App) connectToJump() { func (a *App) connectToJump() {
log.Println("INFO: Connecting to Jump Host with Port Forwarding...") log.Println("INFO: Connecting to Jump Host with Port Forwarding...")
sshArgs := []string{ sshArgs := []string{
"-tt", // TTY Allokation "-tt",
"-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost), // Forwarding "-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost),
"-p", fmt.Sprintf("%d", a.cfg.SSHPort), // Port für den Jump-Host "-p", fmt.Sprintf("%d", a.cfg.SSHPort),
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost), // user@jumphost fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
} }
if err := a.runCommand("ssh", sshArgs...); err != nil { if err := a.runCommand("ssh", sshArgs...); err != nil {
// Fehler wird bereits in runCommand geloggt
} }
} }
func (a *App) connectToWorkstation() { func (a *App) connectToWorkstation() {
log.Println("INFO: Connecting to Workstation via local tunnel (localhost:2048)...") log.Println("INFO: Connecting to Workstation via local tunnel (localhost:2048)...")
sshArgs := []string{ sshArgs := []string{
"-tt", // TTY "-tt",
"-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost), // RDP Tunnel via Workstation SSH "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost),
"-p", "2048", // Verbinde zum lokalen Port des ersten Tunnels "-p", "2048",
fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser), // Benutzer@localhost (geht durch den Tunnel) fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser),
} }
if err := a.runCommand("ssh", sshArgs...); err != nil { if err := a.runCommand("ssh", sshArgs...); err != nil {
} }
@ -144,7 +142,7 @@ func (a *App) startRDPConnection() {
log.Println("INFO: Starting RDP connection to localhost:6000...") 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", rdpCommand := fmt.Sprintf("xfreerdp /u:%s /p:%s /v:127.0.0.1:6000 /size:3000x1350 +clipboard /dynamic-resolution",
a.cfg.RDPUser, a.cfg.RDPUser,
a.cfg.RDPPassword, // SICHERHEITSRISIKO! a.cfg.SSHPassword,
) )
if err := a.runCommand("bash", "-c", rdpCommand); err != nil { if err := a.runCommand("bash", "-c", rdpCommand); err != nil {
} }
@ -158,25 +156,22 @@ func (a *App) makeChoice() {
huh.NewSelect[string](). huh.NewSelect[string]().
Title("What would you like to do?"). Title("What would you like to do?").
Options( Options(
huh.NewOption("Start Work & Connect", "start work"), // Kombinierte Aktion huh.NewOption("Start Work & Connect", "start work"),
huh.NewOption("Stop Work", "stop work"), huh.NewOption("Stop Work", "stop work"),
huh.NewOption("Start Break", "start break"), huh.NewOption("Start Break", "start break"),
huh.NewOption("Stop Break", "stop break"), huh.NewOption("Stop Break", "stop break"),
// huh.NewOption("--- Summaries ---", "").Disabled(true), // Trenner
huh.NewOption("Show Today Summary", "show day summary"), huh.NewOption("Show Today Summary", "show day summary"),
huh.NewOption("Show Week Summary", "show week summary"), huh.NewOption("Show Week Summary", "show week summary"),
huh.NewOption("Show Month Summary", "show month summary"), huh.NewOption("Show Month Summary", "show month summary"),
huh.NewOption("Export Yearly Timetable", "export"), huh.NewOption("Export Yearly Timetable", "export"),
// huh.NewOption("--- Connections ---", "").Disabled(true), // Trenner
huh.NewOption("Connect to Jump Host (Tunnel to Workstation)", "connect to jump"), huh.NewOption("Connect to Jump Host (Tunnel to Workstation)", "connect to jump"),
huh.NewOption("Connect to Workstation (via Tunnel)", "connect to workstation"), huh.NewOption("Connect to Workstation (via Tunnel)", "connect to workstation"),
huh.NewOption("Start RDP Connection (via Tunnel)", "start rdp connection"), huh.NewOption("Start RDP Connection (via Tunnel)", "start rdp connection"),
huh.NewOption("Wake Workstation", "wake workstation"), huh.NewOption("Wake Workstation", "wake workstation"),
// huh.NewOption("--- Maintenance ---", "").Disabled(true), // Trenner
huh.NewOption("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"), huh.NewOption("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"),
huh.NewOption("Exit", "exit"), huh.NewOption("Exit", "exit"),
). ).
Value(&choice), // Speichere die Auswahl in 'choice' Value(&choice),
), ),
) )
@ -192,7 +187,7 @@ func (a *App) makeChoice() {
switch choice { switch choice {
case "start work": case "start work":
a.connect() // Startet Zeit, weckt Rechner, baut Tunnel auf a.connect()
case "stop work": case "stop work":
if err := a.timeStore.StopTracking(); err != nil { if err := a.timeStore.StopTracking(); err != nil {
log.Printf("ERROR: Failed to stop time tracking: %v", err) log.Printf("ERROR: Failed to stop time tracking: %v", err)
@ -222,18 +217,18 @@ func (a *App) makeChoice() {
} }
case "export": case "export":
filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
if a.flags.ExportName != "" && a.flags.ExportName != "Arbeitszeiten.xlsx" { // Check ob Flag von CLI kommt if a.flags.ExportName != "" && a.flags.ExportName != "Arbeitszeiten.xlsx" {
filename = a.flags.ExportName filename = a.flags.ExportName
} }
if err := a.timeStore.ExportSummary(filename); err != nil { if err := a.timeStore.ExportSummary(filename); err != nil {
log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err)
} }
case "connect to jump": case "connect to jump":
a.connectToJump() // Blockiert a.connectToJump()
case "connect to workstation": case "connect to workstation":
a.connectToWorkstation() // Blockiert a.connectToWorkstation()
case "start rdp connection": case "start rdp connection":
a.startRDPConnection() // Blockiert a.startRDPConnection()
case "wake workstation": case "wake workstation":
a.wakeWorkstation() a.wakeWorkstation()
case "kill tunnels": case "kill tunnels":
@ -244,20 +239,20 @@ func (a *App) makeChoice() {
} }
case "exit": case "exit":
fmt.Println("Exiting.") fmt.Println("Exiting.")
return // Beendet die Funktion, was zum Programmende führt return
default: default:
log.Printf("WARN: Unhandled choice '%s'", choice) log.Printf("WARN: Unhandled choice '%s'", choice)
} }
if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" { if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" {
fmt.Println("\nPress Enter to continue...") fmt.Println("\nPress Enter to continue...")
fmt.Scanln() // Warte auf Enter fmt.Scanln()
a.makeChoice() // Rufe Menü erneut auf a.makeChoice()
} }
} }
func (a *App) getSSHAuth() ssh.AuthMethod { func (a *App) getSSHAuth() ssh.AuthMethod {
keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg") // Sicherer Standard keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg")
keyBytes, err := os.ReadFile(keyPath) keyBytes, err := os.ReadFile(keyPath)
if err != nil { if err != nil {
@ -292,10 +287,8 @@ func (a *App) newSSHConnection() (*SSHConnection, error) {
} }
sshConfig := &ssh.ClientConfig{ sshConfig := &ssh.ClientConfig{
User: a.cfg.SSHUser, User: a.cfg.SSHUser,
Auth: []ssh.AuthMethod{authMethod}, Auth: []ssh.AuthMethod{authMethod},
// ACHTUNG: InsecureIgnoreHostKey ist unsicher für Produktionsumgebungen!
// Besser: Known Hosts verwenden.
HostKeyCallback: ssh.InsecureIgnoreHostKey(), HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 10 * time.Second, // Etwas längerer Timeout Timeout: 10 * time.Second, // Etwas längerer Timeout
} }
@ -318,7 +311,6 @@ func (a *App) newSSHConnection() (*SSHConnection, error) {
return &SSHConnection{ return &SSHConnection{
client: client, client: client,
// session: session, // Session wird für Forwarding nicht direkt gebraucht
}, nil }, nil
} }
@ -330,8 +322,6 @@ func (a *App) killForwardings() error {
log.Println("INFO: Attempting to kill processes listening on ports:", strings.Join(ports, ", ")) log.Println("INFO: Attempting to kill processes listening on ports:", strings.Join(ports, ", "))
for _, port := range ports { for _, port := range ports {
// Finde Prozess-ID (PID), die auf dem TCP-Port lauscht
// lsof -i tcp:<port> -t gibt nur die PID aus
cmd := exec.Command("lsof", "-i", "tcp:"+port, "-t") cmd := exec.Command("lsof", "-i", "tcp:"+port, "-t")
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
@ -339,9 +329,9 @@ func (a *App) killForwardings() error {
log.Printf("INFO: No process found listening on port %s.", port) log.Printf("INFO: No process found listening on port %s.", port)
} else { } else {
log.Printf("WARN: 'lsof' command failed for port %s: %v", port, err) 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 lastErr = fmt.Errorf("lsof failed for port %s: %w", port, err)
} }
continue // Gehe zum nächsten Port continue
} }
pids := strings.SplitSeq(strings.TrimSpace(string(output)), "\n") pids := strings.SplitSeq(strings.TrimSpace(string(output)), "\n")
@ -351,7 +341,7 @@ func (a *App) killForwardings() error {
continue continue
} }
log.Printf("INFO: Found process PID %s on port %s. Attempting to kill...", pid, port) log.Printf("INFO: Found process PID %s on port %s. Attempting to kill...", pid, port)
killCmd := exec.Command("kill", pid) // SIGTERM senden killCmd := exec.Command("kill", pid)
if err := killCmd.Run(); err != nil { if err := killCmd.Run(); err != nil {
log.Printf("WARN: Failed to kill PID %s (port %s): %v. Trying kill -9...", pid, port, err) log.Printf("WARN: Failed to kill PID %s (port %s): %v. Trying kill -9...", pid, port, err)
forceKillCmd := exec.Command("kill", "-9", pid) forceKillCmd := exec.Command("kill", "-9", pid)
@ -375,5 +365,5 @@ func (a *App) killForwardings() error {
log.Println("INFO: No forwarding processes found or killed.") log.Println("INFO: No forwarding processes found or killed.")
} }
return lastErr // Gibt den letzten aufgetretenen Fehler zurück return lastErr
} }

112
cmd.go
View file

@ -12,22 +12,21 @@ import (
func (a *App) setupCommands() *cobra.Command { func (a *App) setupCommands() *cobra.Command {
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: "workctl", // Name des CLI-Tools Use: "workctl",
Short: "Manage work time, connections, and tasks", Short: "Manage work time, connections, and tasks",
Long: `workctl is a command-line interface to streamline common work-related 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), including time tracking (using an internal SQLite database), remote connections (SSH, RDP),
and other utilities.`, and other utilities.`,
Version: "1.0.0-sqlite", // Beispielversion Version: "1.0.0-sqlite",
} }
rootCmd.AddCommand(a.startCommand()) rootCmd.AddCommand(a.startCommand())
rootCmd.AddCommand(a.stopCommand()) rootCmd.AddCommand(a.stopCommand())
rootCmd.AddCommand(a.showCommand()) rootCmd.AddCommand(a.showCommand())
rootCmd.AddCommand(a.trackCommand()) // Neuer Befehl für Tracking rootCmd.AddCommand(a.trackCommand())
rootCmd.AddCommand(a.connectCommands()) // Befehle für Verbindungen gruppieren rootCmd.AddCommand(a.connectCommands())
rootCmd.AddCommand(a.wakeCommand()) rootCmd.AddCommand(a.wakeCommand())
rootCmd.AddCommand(a.importTimewarriorCommand()) rootCmd.AddCommand(a.importTimewarriorCommand())
// Verberge das Standard 'completion' Kommando, falls nicht gewünscht
rootCmd.CompletionOptions.DisableDefaultCmd = true rootCmd.CompletionOptions.DisableDefaultCmd = true
return rootCmd return rootCmd
@ -40,7 +39,7 @@ func (a *App) startCommand() *cobra.Command {
Long: "Starts time tracking for 'work', attempts to wake the workstation, and sets up SSH tunnels.", Long: "Starts time tracking for 'work', attempts to wake the workstation, and sets up SSH tunnels.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Starting workday procedures...") fmt.Println("Starting workday procedures...")
a.connect() // Führt Start Tracking, Wake, Tunnel Setup aus a.connect()
fmt.Println("Workday start initiated. Tunnels are running in the background.") fmt.Println("Workday start initiated. Tunnels are running in the background.")
fmt.Println("Use 'workctl connect rdp' or connect manually.") fmt.Println("Use 'workctl connect rdp' or connect manually.")
}, },
@ -71,54 +70,6 @@ func (a *App) stopCommand() *cobra.Command {
} }
} }
// 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 <tag_name>")
// 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 { func (a *App) trackCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "track [tag]", Use: "track [tag]",
@ -130,51 +81,43 @@ 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'), 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. it will mark the *current day* with that tag instead of starting an interval timer.
This also stops any currently running timer.`, This also stops any currently running timer.`,
Args: cobra.MaximumNArgs(1), // 0 oder 1 Argument Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { // RunE für Fehlerbehandlung RunE: func(cmd *cobra.Command, args []string) error {
tag := TagWork // Standard-Tag, wenn kein Argument gegeben wird tag := TagWork
if len(args) > 0 { if len(args) > 0 {
tag = args[0] tag = args[0]
} }
if tag == "" { if tag == "" {
// Sollte durch Default nicht passieren, aber sicher ist sicher
return fmt.Errorf("tag cannot be empty") return fmt.Errorf("tag cannot be empty")
} }
tagLower := strings.ToLower(tag) tagLower := strings.ToLower(tag)
// Prüfe, ob es ein spezieller Ganztages-Tag ist
switch tagLower { switch tagLower {
case "uni", "urlaub", "feiertag", "krank", "free": case "uni", "urlaub", "feiertag", "krank", "free":
today := time.Now() today := time.Now()
fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02")) fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02"))
if err := a.timeStore.LogFullDay(tagLower, today); err != nil { if err := a.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) return fmt.Errorf("could not log '%s' for today: %w", tagLower, err)
} }
// Erfolgsmeldung kommt aus LogFullDay
return nil // Erfolg return nil // Erfolg
default: // Normale Intervalle wie 'work', 'break', oder unbekannt default:
fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) fmt.Printf("Attempting to start tracking interval '%s'...\n", tag)
if err := a.timeStore.StartTracking(tag); err != nil { if err := a.timeStore.StartTracking(tag); err != nil {
log.Printf("ERROR: Failed to start tracking '%s': %v", tag, err) 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) return fmt.Errorf("could not start tracking '%s': %w", tag, err)
} }
// Erfolgsmeldung kommt aus StartTracking
return nil // Erfolg return nil // Erfolg
} }
}, },
} }
// Alias hinzufügen (bleibt bestehen)
cmd.AddCommand(&cobra.Command{ cmd.AddCommand(&cobra.Command{
Use: "break", Use: "break",
Short: "Start tracking 'break'", Short: "Start tracking 'break'",
RunE: func(cmd *cobra.Command, args []string) error { // RunE verwenden RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Starting break...") fmt.Println("Starting break...")
if err := a.timeStore.StartTracking(TagBreak); err != nil { if err := a.timeStore.StartTracking(TagBreak); err != nil {
log.Printf("ERROR: Failed to start break tracking: %v", err) log.Printf("ERROR: Failed to start break tracking: %v", err)
@ -196,16 +139,16 @@ or exports the yearly data to an Excel file.
Periods: today, day, week, month, year (or YYYY-MM-DD) Periods: today, day, week, month, year (or YYYY-MM-DD)
Export: Use the --export flag or the 'export' subcommand.`, Export: Use the --export flag or the 'export' subcommand.`,
ValidArgs: []string{"day", "week", "month", "year", "today"}, // Zur Autovervollständigung ValidArgs: []string{"day", "week", "month", "year", "today"},
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
period := "today" // Standard ist heute period := "today"
if len(args) > 0 { if len(args) > 0 {
period = args[0] period = args[0]
} }
if a.flags.ShowExport { if a.flags.ShowExport {
filename := a.flags.ExportName filename := a.flags.ExportName
if filename == "" || filename == "Arbeitszeiten.xlsx" { // Standardwert aus Flags anpassen if filename == "" || filename == "Arbeitszeiten.xlsx" {
filename = "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" filename = "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
log.Printf("INFO: No export name specified, using default: %s", filename) log.Printf("INFO: No export name specified, using default: %s", filename)
} }
@ -240,7 +183,6 @@ Export: Use the --export flag or the 'export' subcommand.`,
cmd.MarkFlagsMutuallyExclusive("week", "month", "export") cmd.MarkFlagsMutuallyExclusive("week", "month", "export")
// Füge einen 'export' Unterbefehl hinzu für klarere Nutzung
cmd.AddCommand(&cobra.Command{ cmd.AddCommand(&cobra.Command{
Use: "export [filename]", Use: "export [filename]",
Short: "Export yearly timetable to Excel", Short: "Export yearly timetable to Excel",
@ -285,7 +227,7 @@ func (a *App) connectCommands() *cobra.Command {
Short: "Connect to Jump Host (with tunnel to workstation)", Short: "Connect to Jump Host (with tunnel to workstation)",
Long: "Establishes an SSH connection to the Jump Host and forwards local port 2048 to the workstation's SSH port (22). This command blocks.", Long: "Establishes an SSH connection to the Jump Host and forwards local port 2048 to the workstation's SSH port (22). This command blocks.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
a.connectToJump() // Blockiert a.connectToJump()
}, },
}) })
@ -294,7 +236,7 @@ func (a *App) connectCommands() *cobra.Command {
Short: "Connect to Workstation via SSH tunnel", Short: "Connect to Workstation via SSH tunnel",
Long: "Establishes an SSH connection to the Workstation via the local tunnel on port 2048. Also sets up RDP tunnel on local port 6000. Requires the 'jump' tunnel to be active. This command blocks.", Long: "Establishes an SSH connection to the Workstation via the local tunnel on port 2048. Also sets up RDP tunnel on local port 6000. Requires the 'jump' tunnel to be active. This command blocks.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
a.connectToWorkstation() // Blockiert a.connectToWorkstation()
}, },
}) })
@ -303,7 +245,7 @@ func (a *App) connectCommands() *cobra.Command {
Short: "Start RDP session via tunnel", Short: "Start RDP session via tunnel",
Long: "Starts an RDP client (xfreerdp) connecting to localhost:6000. Requires an active tunnel forwarding this port to the workstation's RDP port (3389). This command blocks.", Long: "Starts an RDP client (xfreerdp) connecting to localhost:6000. Requires an active tunnel forwarding this port to the workstation's RDP port (3389). This command blocks.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
a.startRDPConnection() // Blockiert a.startRDPConnection()
}, },
}) })
@ -349,7 +291,7 @@ func (a *App) runImport(filepath string) (int, error) {
if err != nil { if err != nil {
return 0, fmt.Errorf("could not begin database transaction: %w", err) return 0, fmt.Errorf("could not begin database transaction: %w", err)
} }
defer tx.Rollback() // Rollback wird ausgeführt, wenn Commit nicht erreicht wird defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?)") stmt, err := tx.Prepare("INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?)")
if err != nil { if err != nil {
@ -371,13 +313,8 @@ func (a *App) runImport(filepath string) (int, error) {
var tag, start_str, end_str string var tag, start_str, end_str string
has_date := false has_date := false
// Versuche, das Format zu erkennen (mit oder ohne Datum am Anfang) if len(fields) >= 7 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 {
// Format mit Datum: Wk Date Day Tags Start End Time [Total] current_date_str = fields[1]
// 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] tag = fields[3]
start_str = fields[4] start_str = fields[4]
end_str = fields[5] end_str = fields[5]
@ -385,7 +322,7 @@ func (a *App) runImport(filepath string) (int, error) {
} else if len(fields) >= 4 && strings.Contains(fields[1], ":") && strings.Contains(fields[2], ":") { } else if len(fields) >= 4 && strings.Contains(fields[1], ":") && strings.Contains(fields[2], ":") {
if current_date_str == "" { if current_date_str == "" {
log.Printf("WARN: Skipping line without preceding date: %s", line) log.Printf("WARN: Skipping line without preceding date: %s", line)
continue // Überspringe Zeile, wenn kein Datum bekannt ist continue
} }
tag = fields[0] tag = fields[0]
start_str = fields[1] start_str = fields[1]
@ -403,7 +340,7 @@ func (a *App) runImport(filepath string) (int, error) {
log.Printf("WARN: Skipping line with invalid date '%s': %v", current_date_str, err) log.Printf("WARN: Skipping line with invalid date '%s': %v", current_date_str, err)
continue continue
} }
end_time := start_time.Add(24 * time.Hour) // Ende ist Anfang des nächsten Tages end_time := start_time.Add(24 * time.Hour)
_, err = stmt.Exec(tag, start_time, end_time) _, err = stmt.Exec(tag, start_time, end_time)
if err != nil { if err != nil {
@ -411,7 +348,7 @@ func (a *App) runImport(filepath string) (int, error) {
} else { } else {
imported_count++ imported_count++
} }
continue // Gehe zur nächsten Zeile nach Behandlung des ganztägigen Eintrags continue
} }
} else { } else {
@ -440,11 +377,6 @@ func (a *App) runImport(filepath string) (int, error) {
log.Printf("WARN: End time is before start time on the same date line, skipping: %s", line) log.Printf("WARN: End time is before start time on the same date line, skipping: %s", line)
continue 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) db_tag := strings.ToLower(tag)

View file

@ -11,6 +11,7 @@ import (
type Config struct { type Config struct {
SSHUser string `mapstructure:"SSH_USER"` SSHUser string `mapstructure:"SSH_USER"`
SSHPassword string `mapstructure:"SSH_PASSWORD"`
SSHHost string `mapstructure:"SSH_HOST"` SSHHost string `mapstructure:"SSH_HOST"`
JumpUser string `mapstructure:"JUMP_USER"` JumpUser string `mapstructure:"JUMP_USER"`
JumpHost string `mapstructure:"JUMP_HOST"` JumpHost string `mapstructure:"JUMP_HOST"`

147
export.go
View file

@ -11,28 +11,28 @@ import (
) )
type DailySummary struct { type DailySummary struct {
Date string // YYYY-MM-DD Format Date string
Day string // Mon, Tue, etc. Day string
WorkStart string // HH:MM:SS - Frühester Arbeitsbeginn WorkStart string
WorkEnd string // HH:MM:SS - Spätestes Arbeitsende WorkEnd string
BreakDuration time.Duration // Summe aller Pausenzeiten an diesem Tag BreakDuration time.Duration
WorkDuration time.Duration // Summe aller Arbeitszeiten an diesem Tag WorkDuration time.Duration
Tag string // Spezielle Tags für den Tag (Urlaub, Krank, Feiertag, Uni, Free...) Tag string
} }
type ExcelEntry struct { type ExcelEntry struct {
Date string // YYYY-MM-DD Date string
Day string // Mon, Tue, etc. Day string
WorkStart string // HH:MM:SS WorkStart string
WorkEnd string // HH:MM:SS WorkEnd string
BreakDuration string // HH:MM:SS (formatiert) BreakDuration string
Tag string // Spezielle Tags Tag string
} }
func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) { func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) {
dailyMap := make(map[string]*DailySummary) dailyMap := make(map[string]*DailySummary)
location := yearStart.Location() // Verwende die Zeitzone des Startdatums location := yearStart.Location()
now := time.Now().In(location) // Aktuelle Zeit für laufende Einträge now := time.Now().In(location)
currentDay := yearStart currentDay := yearStart
log.Println(currentDay) log.Println(currentDay)
@ -46,13 +46,13 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
dailyMap[dayStr] = &DailySummary{ dailyMap[dayStr] = &DailySummary{
Date: dayStr, Date: dayStr,
Day: weekday.String()[:3], // Mon, Tue, etc. Day: weekday.String()[:3],
Tag: tag, // Initialisiere mit "free" für Wochenende, sonst leer Tag: tag,
} }
currentDay = currentDay.Add(24 * time.Hour) // Gehe zum nächsten Tag currentDay = currentDay.Add(24 * time.Hour)
} }
fullDayTags := make(map[string]string) // Map, um ganztägige Ereignisse zu speichern (Datum -> Tag) fullDayTags := make(map[string]string)
for _, entry := range entries { for _, entry := range entries {
if entry.StartTime.IsZero() { if entry.StartTime.IsZero() {
@ -60,11 +60,11 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
continue continue
} }
startTime := entry.StartTime.In(location) // Stelle sicher, dass Zeiten in der korrekten Zeitzone sind startTime := entry.StartTime.In(location)
endTime := entry.EndTime.Time.In(location) endTime := entry.EndTime.Time.In(location)
validEndTime := entry.EndTime.Valid validEndTime := entry.EndTime.Valid
if !validEndTime { if !validEndTime {
endTime = now // Nimm aktuelle Zeit für laufende Einträge endTime = now
} }
if endTime.Before(yearStart) || startTime.After(yearEnd) { if endTime.Before(yearStart) || startTime.After(yearEnd) {
@ -82,7 +82,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
loopTimeForTag := startTime loopTimeForTag := startTime
for loopTimeForTag.Before(endTime) || loopTimeForTag.Equal(endTime) { for loopTimeForTag.Before(endTime) || loopTimeForTag.Equal(endTime) {
dayStr := loopTimeForTag.Format("2006-01-02") dayStr := loopTimeForTag.Format("2006-01-02")
// Nur Tage innerhalb des Jahres berücksichtigen
if _, exists := dailyMap[dayStr]; exists { if _, exists := dailyMap[dayStr]; exists {
existingTag := fullDayTags[dayStr] existingTag := fullDayTags[dayStr]
if shouldOverwriteTag(existingTag, lowerTag) { if shouldOverwriteTag(existingTag, lowerTag) {
@ -102,7 +101,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
summary, exists := dailyMap[dayStr] summary, exists := dailyMap[dayStr]
if !exists { if !exists {
log.Printf("WARN: Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID) 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 loopTime = dayEnd
continue continue
} }
@ -113,16 +112,15 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
} }
segmentDuration := segmentEnd.Sub(segmentStart) segmentDuration := segmentEnd.Sub(segmentStart)
if segmentDuration <= 0 { // Überspringe leere Segmente if segmentDuration <= 0 {
loopTime = dayEnd loopTime = dayEnd
continue continue
} }
timeStr := segmentStart.Format("15:04:05") timeStr := segmentStart.Format("15:04:05")
// endTimeStr := segmentEnd.Format("15:04:05") // Ende des Segments
switch lowerTag { switch lowerTag {
case TagWork: // Konstante verwenden case TagWork:
summary.WorkDuration += segmentDuration summary.WorkDuration += segmentDuration
if summary.WorkStart == "" || timeStr < summary.WorkStart { if summary.WorkStart == "" || timeStr < summary.WorkStart {
summary.WorkStart = timeStr summary.WorkStart = timeStr
@ -140,7 +138,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
summary.Tag = TagWork summary.Tag = TagWork
} }
case TagBreak: // Konstante verwenden case TagBreak:
summary.BreakDuration += segmentDuration summary.BreakDuration += segmentDuration
default: 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) 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)
@ -157,17 +155,16 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
summary.WorkEnd = entryEndTimeOnThisDayStr summary.WorkEnd = entryEndTimeOnThisDayStr
} }
if summary.Tag == "" || summary.Tag == "free" { if summary.Tag == "" || summary.Tag == "free" {
summary.Tag = TagWork // Behandle unbekannt wie Arbeit für den Tag-Typ summary.Tag = TagWork
} }
} }
loopTime = dayEnd // Gehe zum nächsten Tag loopTime = dayEnd
} }
} }
for dayStr, specialTag := range fullDayTags { for dayStr, specialTag := range fullDayTags {
if summary, exists := dailyMap[dayStr]; exists { if summary, exists := dailyMap[dayStr]; exists {
// Nur überschreiben, wenn der neue Tag höhere oder gleiche Prio hat
if shouldOverwriteTag(summary.Tag, specialTag) { if shouldOverwriteTag(summary.Tag, specialTag) {
summary.Tag = specialTag summary.Tag = specialTag
summary.WorkStart = "" summary.WorkStart = ""
@ -194,9 +191,9 @@ func shouldOverwriteTag(existingTag, newTag string) bool {
"feiertag": 1, "feiertag": 1,
"urlaub": 1, "urlaub": 1,
"uni": 2, "uni": 2,
"work": 3, // Arbeit hat niedrigere Priorität als spezielle Tage "work": 3,
"break": 99, // Pause sollte nie der Haupt-Tag sein "break": 99,
"free": 100, // Frei hat niedrigste Priorität "free": 100,
} }
prioExisting, okExisting := priority[strings.ToLower(existingTag)] prioExisting, okExisting := priority[strings.ToLower(existingTag)]
if !okExisting { if !okExisting {
@ -217,7 +214,7 @@ func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []Excel
for d := range dailySummaries { for d := range dailySummaries {
dates = append(dates, d) dates = append(dates, d)
} }
sort.Strings(dates) // Sortiere die Datums-Strings (YYYY-MM-DD) sort.Strings(dates)
for _, dateStr := range dates { for _, dateStr := range dates {
summary := dailySummaries[dateStr] summary := dailySummaries[dateStr]
@ -226,8 +223,8 @@ func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []Excel
Day: summary.Day, Day: summary.Day,
WorkStart: summary.WorkStart, WorkStart: summary.WorkStart,
WorkEnd: summary.WorkEnd, WorkEnd: summary.WorkEnd,
BreakDuration: formatDuration(summary.BreakDuration), // Formatierte Dauer BreakDuration: formatDuration(summary.BreakDuration),
Tag: summary.Tag, // Übernehme den finalen Tag Tag: summary.Tag,
} }
excelEntries = append(excelEntries, entry) excelEntries = append(excelEntries, entry)
} }
@ -236,7 +233,7 @@ func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []Excel
func formatDuration(d time.Duration) string { func formatDuration(d time.Duration) string {
if d < 0 { if d < 0 {
d = -d // Arbeite mit positivem Wert für die Berechnung d = -d
sign := "-" sign := "-"
d = d.Round(time.Second) d = d.Round(time.Second)
h := int64(d.Hours()) h := int64(d.Hours())
@ -254,25 +251,23 @@ func formatDuration(d time.Duration) string {
func getSollExcelTime(dayOfWeek string) any { func getSollExcelTime(dayOfWeek string) any {
var sollString string var sollString string
switch dayOfWeek { switch dayOfWeek {
case "Mon", "Tue", "Thu", "Fri": // Standard-Arbeitstage case "Mon", "Tue", "Thu", "Fri":
sollString = "08:00" sollString = "08:00"
case "Wed": // Kurzer Tag case "Wed":
sollString = "04:00" sollString = "04:00"
default: // Sa, So default:
return nil // Kein Soll an diesen Tagen return nil
} }
sollDur, err := time.Parse("15:04", sollString) sollDur, err := time.Parse("15:04", sollString)
if err != nil { if err != nil {
log.Printf("ERROR: Could not parse hardcoded soll string '%s': %v", sollString, err) log.Printf("ERROR: Could not parse hardcoded soll string '%s': %v", sollString, err)
return nil // Fehler beim Parsen return nil
} }
return float64(sollDur.Hour())/24.0 + float64(sollDur.Minute())/(24.0*60.0) return float64(sollDur.Hour())/24.0 + float64(sollDur.Minute())/(24.0*60.0)
} }
func writeExcelSheet(entries []ExcelEntry, name string) error { func writeExcelSheet(entries []ExcelEntry, name string) error {
// Sortierung erfolgt jetzt in convertDailyToExcelEntries
f := excelize.NewFile() f := excelize.NewFile()
defer func() { defer func() {
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
@ -291,7 +286,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
if err != nil { if err != nil {
existingIndex, _ := f.GetSheetIndex(sheetName) existingIndex, _ := f.GetSheetIndex(sheetName)
if existingIndex == -1 { if existingIndex == -1 {
sheetName = "Sheet1" // Fallback auf Default sheetName = "Sheet1"
index, _ = f.GetSheetIndex(sheetName) index, _ = f.GetSheetIndex(sheetName)
if index == -1 { if index == -1 {
return fmt.Errorf("could not create or find sheet '%s' or 'Sheet1': %w", sheetName, err) return fmt.Errorf("could not create or find sheet '%s' or 'Sheet1': %w", sheetName, err)
@ -312,9 +307,9 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellValue(sheetName, "B3", "Datum") f.SetCellValue(sheetName, "B3", "Datum")
f.SetCellValue(sheetName, "C3", "Tag") f.SetCellValue(sheetName, "C3", "Tag")
f.SetCellValue(sheetName, "D3", "Status / Zeit") // Titel angepasst f.SetCellValue(sheetName, "D3", "Status / Zeit")
f.MergeCell(sheetName, "D3", "E3") f.MergeCell(sheetName, "D3", "E3")
f.SetCellValue(sheetName, "G3", "Dauer") // Titel angepasst f.SetCellValue(sheetName, "G3", "Dauer")
f.MergeCell(sheetName, "G3", "H3") f.MergeCell(sheetName, "G3", "H3")
f.SetCellValue(sheetName, "I3", "Pause") f.SetCellValue(sheetName, "I3", "Pause")
f.SetCellValue(sheetName, "J3", "Netto") f.SetCellValue(sheetName, "J3", "Netto")
@ -327,28 +322,26 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellValue(sheetName, "E4", "bis") f.SetCellValue(sheetName, "E4", "bis")
f.SetCellValue(sheetName, "G4", "brutto") f.SetCellValue(sheetName, "G4", "brutto")
f.SetCellValue(sheetName, "H4", "") f.SetCellValue(sheetName, "H4", "")
f.SetCellValue(sheetName, "J4", "Ist (Netto)") // Titel angepasst f.SetCellValue(sheetName, "J4", "Ist (Netto)")
f.SetCellValue(sheetName, "K4", "") f.SetCellValue(sheetName, "K4", "")
f.SetCellValue(sheetName, "L4", "Tag") f.SetCellValue(sheetName, "L4", "Tag")
f.SetCellValue(sheetName, "N4", "Total") f.SetCellValue(sheetName, "N4", "Total")
f.SetCellValue(sheetName, "O4", "") f.SetCellValue(sheetName, "O4", "")
timeStyleCode := "hh:mm" // Format für Zeitpunkte und Dauer < 24h timeStyleCode := "hh:mm"
timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode}) timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode})
dateStyleCode := "dd.mm.yyyy" // Format für Datum dateStyleCode := "dd.mm.yyyy"
dateStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &dateStyleCode}) dateStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &dateStyleCode})
saldoStyleCode := "[h]:mm;[RED]-[h]:mm" // Verwende [h] um Stunden > 24 zu erlauben saldoStyleCode := "[h]:mm;[RED]-[h]:mm"
saldoStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &saldoStyleCode}) saldoStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &saldoStyleCode})
headerStyle, _ := f.NewStyle(&excelize.Style{ headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true}, Font: &excelize.Font{Bold: true},
Alignment: &excelize.Alignment{Horizontal: "center"}, Alignment: &excelize.Alignment{Horizontal: "center"},
}) })
centerStyle, _ := f.NewStyle(&excelize.Style{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, "B3", "O4", headerStyle)
f.SetCellStyle(sheetName, "B1", "O1", headerStyle) // Titel auch f.SetCellStyle(sheetName, "B1", "O1", headerStyle)
startRow := 6 startRow := 6
for i, entry := range entries { for i, entry := range entries {
@ -361,7 +354,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellValue(sheetName, "B"+rowStr, dateValue) f.SetCellValue(sheetName, "B"+rowStr, dateValue)
f.SetCellStyle(sheetName, "B"+rowStr, "B"+rowStr, dateStyle) f.SetCellStyle(sheetName, "B"+rowStr, "B"+rowStr, dateStyle)
} else { } else {
f.SetCellValue(sheetName, "B"+rowStr, entry.Date) // Fallback f.SetCellValue(sheetName, "B"+rowStr, entry.Date)
} }
f.SetCellValue(sheetName, "C"+rowStr, entry.Day) f.SetCellValue(sheetName, "C"+rowStr, entry.Day)
@ -370,11 +363,11 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellValue(sheetName, "K"+rowStr, sollExcelTime) f.SetCellValue(sheetName, "K"+rowStr, sollExcelTime)
f.SetCellStyle(sheetName, "K"+rowStr, "K"+rowStr, timeStyle) f.SetCellStyle(sheetName, "K"+rowStr, "K"+rowStr, timeStyle)
} else { } else {
f.SetCellValue(sheetName, "K"+rowStr, "") // Leer für Wochenende etc. f.SetCellValue(sheetName, "K"+rowStr, "")
} }
switch tagLower { switch tagLower {
case TagWork, "": // Normaler Arbeitstag oder nicht speziell getaggter Tag case TagWork, "":
if entry.WorkStart != "" && entry.WorkEnd != "" { if entry.WorkStart != "" && entry.WorkEnd != "" {
startTime, _ := time.Parse("15:04:05", entry.WorkStart) startTime, _ := time.Parse("15:04:05", entry.WorkStart)
endTime, _ := time.Parse("15:04:05", entry.WorkEnd) endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
@ -402,7 +395,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellStyle(sheetName, "I"+rowStr, "I"+rowStr, timeStyle) f.SetCellStyle(sheetName, "I"+rowStr, "I"+rowStr, timeStyle)
f.SetCellFormula(sheetName, "J"+rowStr, fmt.Sprintf("MAX(0, G%d-I%d)", row, row)) 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 f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
} else { } else {
f.SetCellValue(sheetName, "J"+rowStr, 0.0) f.SetCellValue(sheetName, "J"+rowStr, 0.0)
@ -417,13 +410,13 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
text = "Hochschule" text = "Hochschule"
} }
f.SetCellValue(sheetName, "D"+rowStr, text) f.SetCellValue(sheetName, "D"+rowStr, text)
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) // Verbinde D bis I f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle) f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
if sollExcelTime != nil { if sollExcelTime != nil {
f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime) f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime)
} else { } else {
f.SetCellValue(sheetName, "J"+rowStr, 0.0) // Kein Soll (WE), Netto 0 f.SetCellValue(sheetName, "J"+rowStr, 0.0)
} }
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
@ -435,19 +428,19 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
text = "Krank" text = "Krank"
} }
f.SetCellValue(sheetName, "D"+rowStr, text) f.SetCellValue(sheetName, "D"+rowStr, text)
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) // Verbinde D bis I f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle) f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
if sollExcelTime != nil { if sollExcelTime != nil {
f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime) f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime)
} else { } else {
f.SetCellValue(sheetName, "J"+rowStr, 0.0) // Kein Soll (WE), Netto 0 f.SetCellValue(sheetName, "J"+rowStr, 0.0)
} }
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
case "free": // Wochenende oder explizit "free" case "free":
f.SetCellValue(sheetName, "D"+rowStr, "") f.SetCellValue(sheetName, "D"+rowStr, "")
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) // Verbinde D bis I f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle) f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
// J: Netto ist 0 // J: Netto ist 0
f.SetCellValue(sheetName, "J"+rowStr, 0.0) f.SetCellValue(sheetName, "J"+rowStr, 0.0)
@ -459,27 +452,27 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
} }
f.SetCellFormula(sheetName, "L"+rowStr, fmt.Sprintf("J%d-K%d", row, row)) 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 f.SetCellStyle(sheetName, "L"+rowStr, "M"+rowStr, saldoStyle)
if i == 0 { // Erste Datenzeile if i == 0 {
f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("L%d", row)) f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("L%d", row))
} else { } else {
prevSaldoTotalCell := fmt.Sprintf("N%d", row-1) prevSaldoTotalCell := fmt.Sprintf("N%d", row-1)
f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("%s+L%d", prevSaldoTotalCell, row)) 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.SetCellStyle(sheetName, "N"+rowStr, "O"+rowStr, saldoStyle)
} }
f.SetColWidth(sheetName, "B", "B", 12) // Datum f.SetColWidth(sheetName, "B", "B", 12)
f.SetColWidth(sheetName, "C", "C", 5) // Tag f.SetColWidth(sheetName, "C", "C", 5)
f.SetColWidth(sheetName, "D", "E", 10) // Status/Zeit von/bis f.SetColWidth(sheetName, "D", "E", 10)
f.SetColWidth(sheetName, "F", "F", 2) // Leer f.SetColWidth(sheetName, "F", "F", 2)
f.SetColWidth(sheetName, "G", "H", 9) // Dauer brutto f.SetColWidth(sheetName, "G", "H", 9)
f.SetColWidth(sheetName, "I", "I", 9) // Pause f.SetColWidth(sheetName, "I", "I", 9)
f.SetColWidth(sheetName, "J", "J", 9) // Netto f.SetColWidth(sheetName, "J", "J", 9)
f.SetColWidth(sheetName, "K", "K", 9) // Soll f.SetColWidth(sheetName, "K", "K", 9)
f.SetColWidth(sheetName, "L", "M", 9) // Saldo Tag f.SetColWidth(sheetName, "L", "M", 9)
f.SetColWidth(sheetName, "N", "O", 10) // Saldo Total f.SetColWidth(sheetName, "N", "O", 10)
f.SetActiveSheet(index) f.SetActiveSheet(index)
if err := f.SaveAs(name); err != nil { if err := f.SaveAs(name); err != nil {

View file

@ -5,7 +5,7 @@ import (
"io" "io"
"log" "log"
"net" "net"
"sync" // Mutex hinzufügen für sichereres Logging "sync"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@ -15,7 +15,7 @@ type PortForwarder struct {
localPort string localPort string
remotePort string remotePort string
remoteHost string remoteHost string
logMutex sync.Mutex // Mutex zum Schutz von Log-Ausgaben aus Goroutinen logMutex sync.Mutex
} }
func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost string) *PortForwarder { func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost string) *PortForwarder {
@ -29,7 +29,7 @@ func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost stri
func (pf *PortForwarder) forward() error { func (pf *PortForwarder) forward() error {
localAddr := "127.0.0.1:" + pf.localPort localAddr := "127.0.0.1:" + pf.localPort
remoteAddr := net.JoinHostPort(pf.remoteHost, pf.remotePort) // Sicherer Host:Port kombinieren remoteAddr := net.JoinHostPort(pf.remoteHost, pf.remotePort)
pf.logf("INFO: Starting port forwarder: local %s -> remote %s (via SSH)", localAddr, remoteAddr) pf.logf("INFO: Starting port forwarder: local %s -> remote %s (via SSH)", localAddr, remoteAddr)
@ -44,18 +44,11 @@ func (pf *PortForwarder) forward() error {
for { for {
localConn, err := listener.Accept() localConn, err := listener.Accept()
if err != nil { if err != nil {
// 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" { 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) pf.logf("INFO: Listener on %s closed, stopping forwarder.", localAddr)
return nil // Kein Fehler, normales Beenden return nil
} }
pf.logf("ERROR: Failed to accept incoming connection on %s: %v", localAddr, err) 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 continue
} }
@ -71,9 +64,6 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string)
remoteConn, err := pf.sshCon.Dial("tcp", remoteAddr) remoteConn, err := pf.sshCon.Dial("tcp", remoteAddr)
if err != nil { if err != nil {
pf.logf("ERROR: Failed to dial remote host %s via SSH: %v", remoteAddr, err) 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 return
} }
defer remoteConn.Close() defer remoteConn.Close()
@ -84,21 +74,18 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string)
go func() { go func() {
defer wg.Done() defer wg.Done()
defer localConn.Close() // Schließe die eine Seite, wenn die andere endet defer localConn.Close()
bytesCopied, err := io.Copy(localConn, remoteConn) bytesCopied, err := io.Copy(localConn, remoteConn)
if err != nil { 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()) pf.logf("INFO: Finished copying remote->local (%d bytes) for %s", bytesCopied, localConn.RemoteAddr())
}() }()
go func() { go func() {
defer wg.Done() defer wg.Done()
defer remoteConn.Close() // Schließe die andere Seite, wenn diese endet defer remoteConn.Close()
bytesCopied, err := io.Copy(remoteConn, localConn) bytesCopied, err := io.Copy(remoteConn, localConn)
if err != nil { 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()) pf.logf("INFO: Finished copying local->remote (%d bytes) for %s", bytesCopied, localConn.RemoteAddr())
}() }()
@ -107,8 +94,8 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string)
pf.logf("INFO: Closing forwarded connection for %s", localConn.RemoteAddr()) pf.logf("INFO: Closing forwarded connection for %s", localConn.RemoteAddr())
} }
func (pf *PortForwarder) logf(format string, v ...interface{}) { func (pf *PortForwarder) logf(format string, v ...any) {
pf.logMutex.Lock() pf.logMutex.Lock()
defer pf.logMutex.Unlock() defer pf.logMutex.Unlock()
log.Printf(format, v...) // Verwende den Standard-Logger log.Printf(format, v...)
} }

4
go.mod
View file

@ -1,4 +1,4 @@
module work module workctl
go 1.24.2 go 1.24.2
@ -8,6 +8,7 @@ require (
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/xuri/excelize/v2 v2.9.0 github.com/xuri/excelize/v2 v2.9.0
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.36.0
golang.org/x/text v0.23.0
modernc.org/sqlite v1.37.0 modernc.org/sqlite v1.37.0
) )
@ -56,7 +57,6 @@ require (
golang.org/x/net v0.33.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.12.0 // indirect golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.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 gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.62.1 // indirect modernc.org/libc v1.62.1 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

3
ssh.go
View file

@ -8,12 +8,11 @@ import (
type SSHConnection struct { type SSHConnection struct {
client *ssh.Client client *ssh.Client
// session *ssh.Session // Session wird für Forwarding nicht direkt benötigt
} }
func (s *SSHConnection) Close() error { func (s *SSHConnection) Close() error {
if s.client != nil { if s.client != nil {
log.Println("DEBUG: Closing SSH client connection.") // Optional Debug Log log.Println("DEBUG: Closing SSH client connection.")
return s.client.Close() return s.client.Close()
} }
return nil return nil

View file

@ -9,6 +9,8 @@ import (
"strings" "strings"
"time" "time"
"golang.org/x/text/cases"
"golang.org/x/text/language"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@ -70,7 +72,7 @@ func NewTimeStore(cfg Config) (*TimeStore, error) {
return &TimeStore{db: db, dbPath: dbPath}, nil return &TimeStore{db: db, dbPath: dbPath}, nil
} }
func ensureDatabasePath(cfg Config) (string, error) { func ensureDatabasePath(_ Config) (string, error) {
configDir, err := os.UserConfigDir() configDir, err := os.UserConfigDir()
if err != nil { if err != nil {
return "", fmt.Errorf("could not get user config dir: %w", err) return "", fmt.Errorf("could not get user config dir: %w", err)
@ -201,7 +203,7 @@ func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration,
defer rows.Close() defer rows.Close()
summary := make(map[string]time.Duration) summary := make(map[string]time.Duration)
now := time.Now() // Aktuelle Zeit für laufende Einträge now := time.Now()
for rows.Next() { for rows.Next() {
var entry TimeEntry var entry TimeEntry
@ -215,7 +217,7 @@ func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration,
} }
effectiveEnd := entry.EndTime.Time effectiveEnd := entry.EndTime.Time
if !entry.EndTime.Valid { // Eintrag läuft noch if !entry.EndTime.Valid {
effectiveEnd = now effectiveEnd = now
} }
@ -276,14 +278,6 @@ func getTimeRangeFromPeriod(period string) (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 { func (ts *TimeStore) ShowSummary(period string) error {
summary, err := ts.CalculateSummary(period) summary, err := ts.CalculateSummary(period)
if err != nil { if err != nil {
@ -320,11 +314,12 @@ func (ts *TimeStore) ShowSummary(period string) error {
tags = append(tags, tag) tags = append(tags, tag)
} }
titleCaser := cases.Title(language.English)
totalDuration := time.Duration(0) totalDuration := time.Duration(0)
fmt.Println("------------------------------") fmt.Println("------------------------------")
for _, tag := range tags { for _, tag := range tags {
duration := summary[tag] duration := summary[tag]
fmt.Printf(" %-12s: %s\n", strings.Title(tag), formatDuration(duration)) fmt.Printf(" %-12s: %s\n", titleCaser.String(tag), formatDuration(duration))
totalDuration += duration totalDuration += duration
} }
fmt.Println("------------------------------") fmt.Println("------------------------------")
@ -440,7 +435,8 @@ func (ts *TimeStore) LogFullDay(tag string, date time.Time) error {
return fmt.Errorf("failed to commit transaction for full-day entry: %w", err) return fmt.Errorf("failed to commit transaction for full-day entry: %w", err)
} }
titleCaser := cases.Title(language.English)
log.Printf("INFO: Successfully logged full day entry: Tag='%s', Start='%s', End='%s'", tag, dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)) 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 fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr) // Benutzerfeedback
return nil return nil
} }