Compare commits

...

21 commits

Author SHA1 Message Date
Renovate Bot
cb99f0f7a1 chore(deps): update module golang.org/x/crypto to v0.46.0
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
2025-12-14 14:57:30 +00:00
c0a83b5892 feat: add possibility to track time in timewarrior as well
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
2025-10-10 09:07:15 +02:00
127018b565 refactor: perform more clean up in codebase
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
2025-07-02 11:37:14 +02:00
20b4b7ba2d fix: fix wrong formatting in log-output
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
2025-07-01 07:50:05 +02:00
54979319ff refactor: perform cleanup
All checks were successful
Go CI Pipeline / ci (push) Successful in 28s
Release Builds / GoReleaser build (push) Successful in 1m7s
2025-06-30 08:07:53 +02:00
d8743e54c1 refactor: use slog instead of log
Some checks are pending
Go CI Pipeline / ci (push) Waiting to run
Release Builds / GoReleaser build (push) Successful in 1m4s
2025-06-11 22:41:48 +02:00
fcffccc145 ci: update goreleaser files
All checks were successful
Go CI Pipeline / ci (push) Successful in 41s
2025-06-04 20:08:49 +02:00
23e3d4919f fix: next
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
Release Builds / GoReleaser build (push) Successful in 1m4s
2025-06-04 16:22:38 +02:00
ac943acae2 fix: next
Some checks failed
Go CI Pipeline / ci (push) Successful in 25s
Release Builds / GoReleaser build (push) Failing after 54s
2025-06-04 16:16:16 +02:00
bb75925d1f fix: next one
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
Release Builds / GoReleaser build (push) Failing after 1m2s
2025-06-04 16:02:08 +02:00
9a67429b45 fix: and another try
Some checks failed
Go CI Pipeline / ci (push) Successful in 26s
Release Builds / GoReleaser build (push) Failing after 16s
2025-06-04 15:56:56 +02:00
ea906ca862 fix: wrong field
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
Release Builds / GoReleaser build (push) Failing after 14s
2025-06-04 15:50:22 +02:00
deb15af40e fix: try a different approuch
Some checks failed
Go CI Pipeline / ci (push) Successful in 25s
Release Builds / GoReleaser build (push) Failing after 34s
2025-06-04 15:47:00 +02:00
f9945a2685 fix: fix wrong parameter in upload task
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
Release Builds / build (darwin, macos-latest) (push) Successful in 29s
Release Builds / build (linux, ubuntu-latest) (push) Successful in 27s
Release Builds / build (windows, windows-latest) (push) Successful in 29s
Release Builds / create_release (push) Failing after 16s
2025-06-04 15:18:18 +02:00
9732a0a0ed fix: fix smaller issues in workflow
Some checks failed
Go CI Pipeline / ci (push) Has been cancelled
Release Builds / build (darwin, macos-latest) (push) Successful in 45s
Release Builds / build (linux, ubuntu-latest) (push) Successful in 29s
Release Builds / build (windows, windows-latest) (push) Successful in 34s
Release Builds / create_release (push) Failing after 20s
2025-06-04 14:41:42 +02:00
efe25d3f2e ci: update release workflow
Some checks failed
Go CI Pipeline / ci (push) Successful in 24s
Release Builds / build (darwin, macos-latest) (push) Successful in 31s
Release Builds / build (linux, ubuntu-latest) (push) Successful in 33s
Release Builds / build (windows, windows-latest) (push) Successful in 30s
Release Builds / create_release (push) Has been cancelled
2025-06-04 14:37:53 +02:00
git
1f588c771a Merge pull request 'chore(deps): update module github.com/xuri/excelize/v2 to v2.9.1' (#3) from renovate/github.com-xuri-excelize-v2-2.x into main
All checks were successful
Go CI Pipeline / ci (push) Successful in 36s
Release Builds / build (darwin, macos-latest) (push) Successful in 44s
Release Builds / build (linux, ubuntu-latest) (push) Successful in 30s
Release Builds / build (windows, windows-latest) (push) Successful in 39s
Reviewed-on: #3
2025-06-04 14:11:55 +02:00
7decdf6254 fix: change version of upload task to v3
All checks were successful
Go CI Pipeline / ci (push) Successful in 52s
2025-06-04 14:09:23 +02:00
3b4835026f fix: change version for lint to latest
Some checks failed
Go CI Pipeline / ci (push) Successful in 24s
Release Builds / build (darwin, macos-latest) (push) Failing after 28s
Release Builds / build (linux, ubuntu-latest) (push) Failing after 20s
Release Builds / build (windows, windows-latest) (push) Failing after 31s
2025-06-04 14:03:35 +02:00
63c0b0c953 fix: change checkout out of colangci-lint
Some checks failed
Go CI Pipeline / ci (push) Failing after 15s
2025-06-04 14:02:18 +02:00
Renovate Bot
3956ef91ab chore(deps): update module github.com/xuri/excelize/v2 to v2.9.1
Some checks failed
Build workctl / build-and-run (pull_request) Has been cancelled
Build workctl / build-and-run (push) Has been cancelled
2025-06-04 11:01:59 +00:00
15 changed files with 369 additions and 273 deletions

View file

@ -18,15 +18,12 @@ jobs:
- name: Get dependencies - name: Get dependencies
run: go mod tidy run: go mod tidy
- name: golangci-lint - name: Install golangci-lint
uses: golangci/golangci-lint-action@v3 run: |
with: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin latest
version: v2-latest echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
- name: Run golangci-lint
# - name: Lint code run: golangci-lint run
# run: |
# go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# golangci-lint run
- name: Run tests - name: Run tests
run: go test -v ./... run: go test -v ./...

View file

@ -7,44 +7,28 @@ on:
jobs: jobs:
build: build:
name: GoReleaser build
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
goos: [linux, darwin, windows]
exclude:
- os: ubuntu-latest
goos: darwin
- os: ubuntu-latest
goos: windows
- os: macos-latest
goos: linux
- os: macos-latest
goos: windows
- os: windows-latest
goos: linux
- os: windows-latest
goos: darwin
steps: steps:
- name: Check out repository - name: Check out code into the Go module directory
uses: actions/checkout@v4 uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Go - name: Set up Go 1.24
uses: actions/setup-go@v5 uses: actions/setup-go@v2
with: with:
go-version: '1.24' go-version: 1.24
id: go
- name: Get dependencies - name: Unset GITHUB_TOKEN (if present)
run: go mod tidy run: unset GITHUB_TOKEN
- name: Build for ${{ matrix.goos }} - name: Run GoReleaser
run: | uses: goreleaser/goreleaser-action@master
GOOS=${{ matrix.goos }} GOARCH=amd64 go build -o workctl-${{ matrix.goos }} . with:
ls -la version: latest
args: release --clean
- name: Upload artifact env:
uses: actions/upload-artifact@v4 GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
with:
name: workctl-${{ matrix.goos }}
path: workctl-${{ matrix.goos }}

2
.gitignore vendored
View file

@ -1 +1,3 @@
work-config.toml work-config.toml
# Added by goreleaser init:
dist/

57
.goreleaser.yaml Normal file
View file

@ -0,0 +1,57 @@
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
gitea_urls:
api: https://git.patanix.de/api/v1
download: https://git.patanix.de
env_files:
gitlab_token: ~/nope
github_token: ~/nope
force_token: "gitea"
archives:
- formats: [tar.gz]
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
formats: [zip]
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
release:
name_template: "{{.ProjectName}}-v{{.Version}} {{.Env.USER}}"
footer: >-
---
Released by {{.Env.USER}}.

107
app.go
View file

@ -2,7 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@ -42,9 +42,9 @@ func (a *App) Close() error {
return nil return nil
} }
func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert func (a *App) connect(withoutTimew bool) (*SSHConnection, error) { // Rückgabetyp geändert
if err := a.timeStore.StartTracking(TagWork); err != nil { if err := a.timeStore.StartTracking(TagWork, withoutTimew); err != nil {
log.Printf("WARN: Failed to start time tracking for '%s': %v", TagWork, err) slog.Warn(fmt.Sprintf("Failed to start time tracking for '%s': %v", TagWork, err))
} }
a.wakeWorkstation() a.wakeWorkstation()
@ -54,24 +54,25 @@ func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert
return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err) return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err)
} }
log.Println("INFO: SSH connection established. Setting up tunnels...") slog.Info("SSH connection established. Setting up tunnels...")
sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP) sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP)
go func() { go func() {
log.Println("INFO: Starting SSH forwarder (local :2048 -> remote workstation:22)") slog.Info("Starting SSH forwarder (local :2048 -> remote workstation:22)")
if err := sshForwarder.forward(); err != nil { if err := sshForwarder.forward(); err != nil {
log.Printf("ERROR: SSH forwarder failed: %v", err) slog.Error(fmt.Sprintf("SSH forwarder failed: %v", err))
} }
log.Println("INFO: SSH forwarder stopped.") slog.Info("SSH forwarder stopped.")
}() }()
rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP) rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP)
go func() { go func() {
log.Println("INFO: Starting RDP forwarder (local :6000 -> remote workstation:3389)") slog.Info("Starting RDP forwarder (local :6000 -> remote workstation:3389)")
if err := rdpForwarder.forward(); err != nil { if err := rdpForwarder.forward(); err != nil {
log.Printf("ERROR: RDP forwarder failed: %v", err) // slog.Error(fmt.Sprintf("RDP forwarder failed: %v", err)
slog.Error(fmt.Sprintf("ERROR: RDP forwarder failed: %v", err))
} }
log.Println("INFO: RDP forwarder stopped.") slog.Info("RDP forwarder stopped.")
}() }()
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
@ -80,22 +81,22 @@ func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert
} }
func (a *App) runCommand(name string, args ...string) error { func (a *App) runCommand(name string, args ...string) error {
log.Printf("INFO: Executing command: %s %s", name, strings.Join(args, " ")) slog.Info(fmt.Sprintf("Executing command: %s %s", name, strings.Join(args, " ")))
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin cmd.Stdin = os.Stdin
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
log.Printf("ERROR: Command failed: %s %s -> %v", name, strings.Join(args, " "), err) slog.Error(fmt.Sprintf("Command failed: %s %s -> %v", name, strings.Join(args, " "), err))
return fmt.Errorf("command execution failed: %w", err) return fmt.Errorf("command execution failed: %w", err)
} }
log.Printf("INFO: Command finished successfully: %s", name) slog.Info(fmt.Sprintf("Command finished successfully: %s", name))
return nil return nil
} }
func (a *App) wakeWorkstation() { func (a *App) wakeWorkstation() {
log.Println("INFO: Attempting to wake workstation...") slog.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, a.cfg.JumpUser,
a.cfg.JumpHost, a.cfg.JumpHost,
@ -109,14 +110,14 @@ func (a *App) wakeWorkstation() {
} }
if err := a.runCommand("ssh", outerSSHCmd...); err != nil { 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.") slog.Warn("Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.")
} else { } else {
log.Println("INFO: Wake-on-LAN command executed.") slog.Info("Wake-on-LAN command executed.")
} }
} }
func (a *App) connectToJump() { func (a *App) connectToJump() {
log.Println("INFO: Connecting to Jump Host with Port Forwarding...") slog.Info("Connecting to Jump Host with Port Forwarding...")
sshArgs := []string{ sshArgs := []string{
"-tt", "-tt",
"-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost), "-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost),
@ -128,7 +129,7 @@ func (a *App) connectToJump() {
} }
func (a *App) connectToWorkstation() { func (a *App) connectToWorkstation() {
log.Println("INFO: Connecting to Workstation via local tunnel (localhost:2048)...") slog.Info("Connecting to Workstation via local tunnel (localhost:2048)...")
sshArgs := []string{ sshArgs := []string{
"-tt", "-tt",
"-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost), "-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost),
@ -140,7 +141,7 @@ func (a *App) connectToWorkstation() {
} }
func (a *App) startRDPConnection() { func (a *App) startRDPConnection() {
log.Println("INFO: Starting RDP connection to localhost:6000...") slog.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.SSHPassword, a.cfg.SSHPassword,
@ -182,39 +183,39 @@ func (a *App) makeChoice() {
fmt.Println("Operation cancelled.") fmt.Println("Operation cancelled.")
return return
} }
log.Printf("ERROR: Form execution failed: %v", err) slog.Error(fmt.Sprintf("Form execution failed: %v", err))
return return
} }
switch choice { switch choice {
case "start work": case "start work":
a.connect() a.connect(withoutTimew)
case "stop work": case "stop work":
if err := a.timeStore.StopTracking(); err != nil { if err := a.timeStore.StopTracking(withoutTimew); err != nil {
log.Printf("ERROR: Failed to stop time tracking: %v", err) slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err))
} }
if err := a.killForwardings(); err != nil { if err := a.killForwardings(); err != nil {
log.Printf("WARN: Could not kill all forwardings: %v", err) slog.Warn(fmt.Sprintf("Could not kill all forwardings: %v", err))
} }
case "start break": case "start break":
if err := a.timeStore.StartTracking(TagBreak); err != nil { if err := a.timeStore.StartTracking(TagBreak, withoutTimew); err != nil {
log.Printf("ERROR: Failed to start break tracking: %v", err) slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err))
} }
case "stop break": case "stop break":
if err := a.timeStore.StartTracking(TagWork); err != nil { if err := a.timeStore.StartTracking(TagWork, withoutTimew); err != nil {
log.Printf("ERROR: Failed to stop break (start work): %v", err) slog.Error(fmt.Sprintf("Failed to stop break (start work): %v", err))
} }
case "show day summary": case "show day summary":
if err := a.timeStore.ShowSummary("today"); err != nil { if err := a.timeStore.ShowSummary("today"); err != nil {
log.Printf("ERROR: Failed to show day summary: %v", err) slog.Error(fmt.Sprintf("Failed to show day summary: %v", err))
} }
case "show week summary": case "show week summary":
if err := a.timeStore.ShowSummary("week"); err != nil { if err := a.timeStore.ShowSummary("week"); err != nil {
log.Printf("ERROR: Failed to show week summary: %v", err) slog.Error(fmt.Sprintf("ERROR: Failed to show week summary: %v", err))
} }
case "show month summary": case "show month summary":
if err := a.timeStore.ShowSummary("month"); err != nil { if err := a.timeStore.ShowSummary("month"); err != nil {
log.Printf("ERROR: Failed to show month summary: %v", err) slog.Error(fmt.Sprintf("Failed to show month summary: %v", err))
} }
case "export": case "export":
filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx" filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
@ -222,7 +223,7 @@ func (a *App) makeChoice() {
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) slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
} }
case "connect to jump": case "connect to jump":
a.connectToJump() a.connectToJump()
@ -234,15 +235,15 @@ func (a *App) makeChoice() {
a.wakeWorkstation() a.wakeWorkstation()
case "kill tunnels": case "kill tunnels":
if err := a.killForwardings(); err != nil { if err := a.killForwardings(); err != nil {
log.Printf("ERROR: Failed to kill forwardings: %v", err) slog.Error(fmt.Sprintf("Failed to kill forwardings: %v", err))
} else { } else {
log.Println("INFO: Attempted to kill processes on ports 2048 and 6000.") slog.Info("Attempted to kill processes on ports 2048 and 6000.")
} }
case "exit": case "exit":
fmt.Println("Exiting.") fmt.Println("Exiting.")
return return
default: default:
log.Printf("WARN: Unhandled choice '%s'", choice) slog.Warn(fmt.Sprintf("Unhandled choice '%s'", choice))
} }
if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" { if choice != "exit" && choice != "connect to jump" && choice != "connect to workstation" && choice != "start rdp connection" {
@ -257,7 +258,7 @@ func (a *App) getSSHAuth() ssh.AuthMethod {
keyBytes, err := os.ReadFile(keyPath) keyBytes, err := os.ReadFile(keyPath)
if err != nil { if err != nil {
log.Printf("ERROR: Unable to read private key '%s': %v", keyPath, err) slog.Error(fmt.Sprintf("Unable to read private key '%s': %v", keyPath, err))
return nil return nil
} }
@ -265,19 +266,19 @@ func (a *App) getSSHAuth() ssh.AuthMethod {
key, err = ssh.ParsePrivateKey(keyBytes) key, err = ssh.ParsePrivateKey(keyBytes)
if err != nil { if err != nil {
if _, ok := err.(*ssh.PassphraseMissingError); ok { if _, ok := err.(*ssh.PassphraseMissingError); ok {
log.Printf("INFO: Private key '%s' requires a passphrase. Trying with RDP password from config.", keyPath) slog.Info(fmt.Sprintf("Private key '%s' requires a passphrase. Trying with RDP password from config.", keyPath))
key, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword)) key, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword))
if err != nil { if err != nil {
log.Printf("ERROR: Unable to parse private key '%s' with passphrase: %v", keyPath, err) slog.Error(fmt.Sprintf("Unable to parse private key '%s' with passphrase: %v", keyPath, err))
return nil return nil
} }
} else { } else {
log.Printf("ERROR: Unable to parse private key '%s': %v", keyPath, err) slog.Error(fmt.Sprintf("Unable to parse private key '%s': %v", keyPath, err))
return nil return nil
} }
} }
log.Printf("INFO: Successfully loaded private key '%s'", keyPath) slog.Info(fmt.Sprintf("Successfully loaded private key '%s'", keyPath))
return ssh.PublicKeys(key) return ssh.PublicKeys(key)
} }
@ -295,13 +296,13 @@ func (a *App) newSSHConnection() (*SSHConnection, error) {
} }
target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort) target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort)
log.Printf("INFO: Dialing SSH to %s...", target) slog.Info(fmt.Sprintf("Dialing SSH to %s...", target))
client, err := ssh.Dial("tcp", target, sshConfig) client, err := ssh.Dial("tcp", target, sshConfig)
if err != nil { if err != nil {
return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err) return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err)
} }
log.Printf("INFO: SSH connection to %s successful.", target) slog.Info(fmt.Sprintf("SSH connection to %s successful.", target))
session, err := client.NewSession() session, err := client.NewSession()
if err != nil { if err != nil {
@ -320,16 +321,16 @@ func (a *App) killForwardings() error {
killedSomething := false killedSomething := false
var lastErr error var lastErr error
log.Println("INFO: Attempting to kill processes listening on ports:", strings.Join(ports, ", ")) slog.Info(fmt.Sprintf("Attempting to kill processes listening on ports: %v", strings.Join(ports, ", ")))
for _, port := range ports { for _, port := range ports {
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 {
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
log.Printf("INFO: No process found listening on port %s.", port) slog.Info(fmt.Sprintf("No process found listening on port %s.", port))
} else { } else {
log.Printf("WARN: 'lsof' command failed for port %s: %v", port, err) slog.Warn(fmt.Sprintf("'lsof' command failed for port %s: %v", port, err))
lastErr = fmt.Errorf("lsof failed for port %s: %w", port, err) lastErr = fmt.Errorf("lsof failed for port %s: %w", port, err)
} }
continue continue
@ -341,29 +342,29 @@ func (a *App) killForwardings() error {
if pid == "" { if pid == "" {
continue continue
} }
log.Printf("INFO: Found process PID %s on port %s. Attempting to kill...", pid, port) slog.Info(fmt.Sprintf("Found process PID %s on port %s. Attempting to kill...", pid, port))
killCmd := exec.Command("kill", pid) 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) slog.Warn(fmt.Sprintf("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)
if err := forceKillCmd.Run(); err != nil { if err := forceKillCmd.Run(); err != nil {
log.Printf("ERROR: Failed to force kill PID %s (port %s): %v", pid, port, err) slog.Error(fmt.Sprintf("Failed to force kill PID %s (port %s): %v", pid, port, err))
lastErr = fmt.Errorf("kill -9 failed for PID %s: %w", pid, err) lastErr = fmt.Errorf("kill -9 failed for PID %s: %w", pid, err)
} else { } else {
log.Printf("INFO: Force killed PID %s (port %s).", pid, port) slog.Info(fmt.Sprintf("Force killed PID %s (port %s).", pid, port))
killedSomething = true killedSomething = true
} }
} else { } else {
log.Printf("INFO: Killed PID %s (port %s).", pid, port) slog.Info(fmt.Sprintf("Killed PID %s (port %s).", pid, port))
killedSomething = true killedSomething = true
} }
} }
} }
if killedSomething { if killedSomething {
log.Println("INFO: Finished attempting to kill forwarding processes.") slog.Info("Finished attempting to kill forwarding processes.")
} else { } else {
log.Println("INFO: No forwarding processes found or killed.") slog.Info("No forwarding processes found or killed.")
} }
return lastErr return lastErr

161
cmd.go
View file

@ -2,7 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"os/signal" "os/signal"
"strings" "strings"
@ -12,6 +12,8 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var withoutTimew bool
func (a *App) setupCommands() *cobra.Command { func (a *App) setupCommands() *cobra.Command {
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: "workctl", Use: "workctl",
@ -44,18 +46,18 @@ Use --background (-b) to keep tunnels running in the background without auto-con
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Starting workday procedures...") fmt.Println("Starting workday procedures...")
sshCon, err := a.connect() sshCon, err := a.connect(withoutTimew)
if err != nil { if err != nil {
fmt.Printf("ERROR: Failed to start connections: %v\n", err) fmt.Printf("ERROR: Failed to start connections: %v\n", err)
return fmt.Errorf("connection setup failed: %w", err) return fmt.Errorf("connection setup failed: %w", err)
} }
defer func() { defer func() {
log.Println("INFO: Closing SSH connection to jump host (defer)...") slog.Info("Closing SSH connection to jump host (defer)...")
if err := sshCon.Close(); err != nil { if err := sshCon.Close(); err != nil {
log.Printf("WARN: Error closing SSH connection in defer: %v", err) slog.Warn(fmt.Sprintf("Error closing SSH connection in defer: %v", err))
} else { } else {
log.Println("INFO: SSH connection closed via defer.") slog.Info("SSH connection closed via defer.")
} }
}() }()
@ -70,24 +72,24 @@ Use --background (-b) to keep tunnels running in the background without auto-con
<-sigChan <-sigChan
fmt.Println("\nINFO: Received interrupt signal. Shutting down background process...") fmt.Println("\nINFO: Received interrupt signal. Shutting down background process...")
log.Println("INFO: Received signal, cleanup via defer sshCon.Close() will run.") slog.Info("Received signal, cleanup via defer sshCon.Close() will run.")
if err := a.timeStore.StopTracking(); err != nil { if err := a.timeStore.StopTracking(withoutTimew); err != nil {
log.Printf("WARN: Failed to stop time tracking: %v", err) slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else { } else {
log.Println("INFO: Time tracking stopped.") slog.Info("Time tracking stopped.")
} }
fmt.Println("INFO: Background shutdown complete.") fmt.Println("INFO: Background shutdown complete.")
} else { } else {
fmt.Println("INFO: Automatically connecting to workstation via SSH tunnel...") fmt.Println("Automatically connecting to workstation via SSH tunnel...")
a.connectToWorkstation() a.connectToWorkstation()
fmt.Println("INFO: Workstation SSH session finished.") fmt.Println("Workstation SSH session finished.")
log.Println("INFO: Foreground session ended, cleanup via defer sshCon.Close() will run.") slog.Info("Foreground session ended, cleanup via defer sshCon.Close() will run.")
if err := a.timeStore.StopTracking(); err != nil { if err := a.timeStore.StopTracking(withoutTimew); err != nil {
log.Printf("WARN: Failed to stop time tracking: %v", err) slog.Warn(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else { } else {
log.Println("INFO: Time tracking stopped.") slog.Info("Time tracking stopped.")
} }
} }
@ -96,25 +98,26 @@ Use --background (-b) to keep tunnels running in the background without auto-con
} }
cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately") cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately")
cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
return cmd return cmd
} }
func (a *App) stopCommand() *cobra.Command { func (a *App) stopCommand() *cobra.Command {
return &cobra.Command{ cmd := &cobra.Command{
Use: "stop", Use: "stop",
Short: "Stop work: Stop time tracking, kill tunnels", Short: "Stop work: Stop time tracking, kill tunnels",
Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.", Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Stopping workday procedures...") fmt.Println("Stopping workday procedures...")
if err := a.timeStore.StopTracking(); err != nil { if err := a.timeStore.StopTracking(withoutTimew); err != nil {
log.Printf("ERROR: Failed to stop time tracking: %v", err) slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err))
} else { } else {
fmt.Println("Time tracking stopped.") fmt.Println("Time tracking stopped.")
} }
if err := a.killForwardings(); err != nil { if err := a.killForwardings(); err != nil {
log.Printf("WARN: Could not kill all forwarding processes: %v", err) slog.Warn(fmt.Sprintf("Could not kill all forwarding processes: %v", err))
} else { } else {
fmt.Println("Attempted to stop SSH tunnels.") fmt.Println("Attempted to stop SSH tunnels.")
} }
@ -122,6 +125,8 @@ func (a *App) stopCommand() *cobra.Command {
fmt.Println("Workday stop procedures finished.") fmt.Println("Workday stop procedures finished.")
}, },
} }
cmd.Flags().BoolVarP(&withoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
return cmd
} }
func (a *App) trackCommand() *cobra.Command { func (a *App) trackCommand() *cobra.Command {
@ -155,15 +160,15 @@ This also stops any currently running timer.`,
if err := a.timeStore.LogFullDay(tagLower, today); err != nil { if err := a.timeStore.LogFullDay(tagLower, today); err != nil {
return fmt.Errorf("could not log '%s' for today: %w", tagLower, err) return fmt.Errorf("could not log '%s' for today: %w", tagLower, err)
} }
return nil // Erfolg return nil
default: default:
fmt.Printf("Attempting to start tracking interval '%s'...\n", tag) fmt.Printf("Attempting to start tracking interval '%s'...\n", tag)
if err := a.timeStore.StartTracking(tag); err != nil { if err := a.timeStore.StartTracking(tag, withoutTimew); err != nil {
log.Printf("ERROR: Failed to start tracking '%s': %v", tag, err) slog.Error(fmt.Sprintf("Failed to start tracking '%s': %v", tag, err))
return fmt.Errorf("could not start tracking '%s': %w", tag, err) return fmt.Errorf("could not start tracking '%s': %w", tag, err)
} }
return nil // Erfolg return nil
} }
}, },
} }
@ -173,8 +178,8 @@ This also stops any currently running timer.`,
Short: "Start tracking 'break'", Short: "Start tracking 'break'",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Starting break...") fmt.Println("Starting break...")
if err := a.timeStore.StartTracking(TagBreak); err != nil { if err := a.timeStore.StartTracking(TagBreak, withoutTimew); err != nil {
log.Printf("ERROR: Failed to start break tracking: %v", err) slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err))
return fmt.Errorf("could not start break: %w", err) return fmt.Errorf("could not start break: %w", err)
} }
return nil return nil
@ -204,27 +209,27 @@ Export: Use the --export flag or the 'export' subcommand.`,
filename := a.flags.ExportName filename := a.flags.ExportName
if filename == "" || filename == "Arbeitszeiten.xlsx" { 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) slog.Info(fmt.Sprintf("No export name specified, using default: %s", filename))
} }
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename) fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
if err := a.timeStore.ExportSummary(filename); err != nil { if err := a.timeStore.ExportSummary(filename); err != nil {
log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
fmt.Printf("Error: Could not export to '%s'.\n", filename) fmt.Printf("Error: Could not export to '%s'.\n", filename)
} }
} else if a.flags.ShowWeek { } else if a.flags.ShowWeek {
fmt.Println("Showing weekly summary...") fmt.Println("Showing weekly summary...")
if err := a.timeStore.ShowSummary("week"); err != nil { if err := a.timeStore.ShowSummary("week"); err != nil {
log.Printf("ERROR: Failed to show week summary: %v", err) slog.Error(fmt.Sprintf("Failed to show week summary: %v", err))
} }
} else if a.flags.ShowMonth { } else if a.flags.ShowMonth {
fmt.Println("Showing monthly summary...") fmt.Println("Showing monthly summary...")
if err := a.timeStore.ShowSummary("month"); err != nil { if err := a.timeStore.ShowSummary("month"); err != nil {
log.Printf("ERROR: Failed to show month summary: %v", err) slog.Error(fmt.Sprintf("Failed to show month summary: %v", err))
} }
} else { } else {
fmt.Printf("Showing summary for period: %s...\n", period) fmt.Printf("Showing summary for period: %s...\n", period)
if err := a.timeStore.ShowSummary(period); err != nil { if err := a.timeStore.ShowSummary(period); err != nil {
log.Printf("ERROR: Failed to show summary for '%s': %v", period, err) slog.Error(fmt.Sprintf("Failed to show summary for '%s': %v", period, err))
} }
} }
}, },
@ -248,7 +253,7 @@ Export: Use the --export flag or the 'export' subcommand.`,
} }
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename) fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
if err := a.timeStore.ExportSummary(filename); err != nil { if err := a.timeStore.ExportSummary(filename); err != nil {
log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err) slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
fmt.Printf("Error: Could not export to '%s'.\n", filename) fmt.Printf("Error: Could not export to '%s'.\n", filename)
} }
}, },
@ -321,12 +326,12 @@ Example: workctl import-timew /path/to/timew-summary.txt`,
count, err := a.runImport(filepath) count, err := a.runImport(filepath)
if err != nil { if err != nil {
log.Printf("ERROR: Import failed: %v", err) slog.Error(fmt.Sprintf("Import failed: %v", err))
return fmt.Errorf("import failed: %w", err) return fmt.Errorf("import failed: %w", err)
} }
fmt.Printf("Successfully imported %d time entries.\n", count) fmt.Printf("Successfully imported %d time entries.\n", count)
log.Printf("INFO: Successfully imported %d time entries from %s", count, filepath) slog.Info(fmt.Sprintf("Successfully imported %d time entries from %s", count, filepath))
return nil return nil
}, },
} }
@ -353,8 +358,8 @@ func (a *App) runImport(filepath string) (int, error) {
} }
defer stmt.Close() defer stmt.Close()
var current_date_str string var currentDateStr string
imported_count := 0 importedCount := 0
location := time.Local location := time.Local
for i, line := range lines { for i, line := range lines {
@ -364,94 +369,94 @@ func (a *App) runImport(filepath string) (int, error) {
} }
fields := strings.Fields(line) fields := strings.Fields(line)
var tag, start_str, end_str string var tag, startStr, endStr string
has_date := false hasDate := false
if len(fields) >= 7 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 { if len(fields) >= 7 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 {
current_date_str = fields[1] currentDateStr = fields[1]
tag = fields[3] tag = fields[3]
start_str = fields[4] startStr = fields[4]
end_str = fields[5] endStr = fields[5]
has_date = true hasDate = true
} 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 currentDateStr == "" {
log.Printf("WARN: Skipping line without preceding date: %s", line) slog.Warn(fmt.Sprintf("Skipping line without preceding date: %s", line))
continue continue
} }
tag = fields[0] tag = fields[0]
start_str = fields[1] startStr = fields[1]
end_str = fields[2] endStr = fields[2]
has_date = false hasDate = false
} else if len(fields) >= 6 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 { } else if len(fields) >= 6 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 {
current_date_str = fields[1] currentDateStr = fields[1]
tag = fields[3] tag = fields[3]
start_str = fields[4] startStr = fields[4]
end_str = fields[5] endStr = fields[5]
has_date = true hasDate = true
if start_str == "0:00:00" && end_str == "0:00:00" { if startStr == "0:00:00" && endStr == "0:00:00" {
start_time, err := time.ParseInLocation("2006-01-02", current_date_str, location) startTime, err := time.ParseInLocation("2006-01-02", currentDateStr, location)
if err != nil { if err != nil {
log.Printf("WARN: Skipping line with invalid date '%s': %v", current_date_str, err) slog.Warn(fmt.Sprintf("Skipping line with invalid date '%s': %v", currentDateStr, err))
continue continue
} }
end_time := start_time.Add(24 * time.Hour) endTime := startTime.Add(24 * time.Hour)
_, err = stmt.Exec(tag, start_time, end_time) _, err = stmt.Exec(tag, startTime, endTime)
if err != nil { if err != nil {
log.Printf("ERROR: Failed to insert full-day entry for %s (%s): %v", current_date_str, tag, err) slog.Error(fmt.Sprintf("Failed to insert full-day entry for %s (%s): %v", currentDateStr, tag, err))
} else { } else {
imported_count++ importedCount++
} }
continue continue
} }
} else { } else {
log.Printf("WARN: Skipping unrecognized line format: %s", line) slog.Warn(fmt.Sprintf("Skipping unrecognized line format: %s", line))
continue continue
} }
if end_str == "-" { if endStr == "-" {
log.Printf("INFO: Skipping currently running entry: %s", line) slog.Info(fmt.Sprintf("Skipping currently running entry: %s", line))
continue continue
} }
start_datetime_str := current_date_str + " " + start_str startDatetimeStr := currentDateStr + " " + startStr
end_datetime_str := current_date_str + " " + end_str endDatetimeStr := currentDateStr + " " + endStr
start_time, err_start := time.ParseInLocation("2006-01-02 15:04:05", start_datetime_str, location) startTime, errStart := time.ParseInLocation("2006-01-02 15:04:05", startDatetimeStr, location)
end_time, err_end := time.ParseInLocation("2006-01-02 15:04:05", end_datetime_str, location) endTime, errEnd := time.ParseInLocation("2006-01-02 15:04:05", endDatetimeStr, location)
if err_start != nil || err_end != nil { if errStart != nil || errEnd != 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) slog.Warn(fmt.Sprintf("Skipping line with invalid date/time format ('%s' / '%s'): %v / %v", startDatetimeStr, endDatetimeStr, errStart, errEnd))
continue continue
} }
if end_time.Before(start_time) { if endTime.Before(startTime) {
if has_date { if hasDate {
log.Printf("WARN: End time is before start time on the same date line, skipping: %s", line) slog.Warn(fmt.Sprintf("End time is before start time on the same date line, skipping: %s", line))
continue continue
} }
} }
db_tag := strings.ToLower(tag) dbTag := strings.ToLower(tag)
switch db_tag { switch dbTag {
case "work": case "work":
db_tag = TagWork dbTag = TagWork
case "break": case "break":
db_tag = TagBreak dbTag = TagBreak
} }
_, err = stmt.Exec(db_tag, start_time, end_time) _, err = stmt.Exec(dbTag, startTime, endTime)
if err != nil { 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) slog.Error(fmt.Sprintf("Failed to insert entry for %s (%s, %s -> %s): %v", currentDateStr, dbTag, startTime.Format(time.RFC3339), endTime.Format(time.RFC3339), err))
} else { } else {
imported_count++ importedCount++
} }
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return imported_count, fmt.Errorf("failed to commit transaction: %w", err) return importedCount, fmt.Errorf("failed to commit transaction: %w", err)
} }
return imported_count, nil return importedCount, nil
} }

View file

@ -2,7 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@ -43,7 +43,7 @@ func loadConfig() (Config, error) {
workConfigPath := filepath.Join(configPath, "work") workConfigPath := filepath.Join(configPath, "work")
configFile := filepath.Join(workConfigPath, "config.toml") configFile := filepath.Join(workConfigPath, "config.toml")
if err := os.MkdirAll(workConfigPath, 0750); err != nil { if err := os.MkdirAll(workConfigPath, 0o750); err != nil {
return cfg, fmt.Errorf("could not create config directory '%s': %w", workConfigPath, err) return cfg, fmt.Errorf("could not create config directory '%s': %w", workConfigPath, err)
} }
@ -55,7 +55,7 @@ func loadConfig() (Config, error) {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok { if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err) return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err)
} }
log.Printf("INFO: Config file '%s' not found, using defaults/env vars.", configFile) slog.Info(fmt.Sprintf("Config file '%s' not found, using defaults/env vars.", configFile))
} }
if err := viper.UnmarshalKey("default", &cfg); err != nil { if err := viper.UnmarshalKey("default", &cfg); err != nil {

View file

@ -2,7 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log" "log/slog"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -35,7 +35,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
now := time.Now().In(location) now := time.Now().In(location)
currentDay := yearStart currentDay := yearStart
log.Println(currentDay)
for currentDay.Before(yearEnd) { for currentDay.Before(yearEnd) {
dayStr := currentDay.Format("2006-01-02") dayStr := currentDay.Format("2006-01-02")
weekday := currentDay.Weekday() weekday := currentDay.Weekday()
@ -56,7 +55,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
for _, entry := range entries { for _, entry := range entries {
if entry.StartTime.IsZero() { if entry.StartTime.IsZero() {
log.Printf("WARN: Skipping entry with zero start time (ID: %d)", entry.ID) slog.Warn(fmt.Sprintf("Skipping entry with zero start time (ID: %d)", entry.ID))
continue continue
} }
@ -100,7 +99,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) slog.Warn(fmt.Sprintf("Day %s not found in initial map during entry processing (ID: %d)", dayStr, entry.ID))
loopTime = dayEnd loopTime = dayEnd
continue continue
} }
@ -141,7 +140,7 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
case TagBreak: 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) slog.Info(fmt.Sprintf("Encountered unknown tag '%s' during interval processing for entry ID %d on %s. Counting duration as 'work'.", entry.Tag, entry.ID, dayStr))
summary.WorkDuration += segmentDuration summary.WorkDuration += segmentDuration
if summary.WorkStart == "" || timeStr < summary.WorkStart { if summary.WorkStart == "" || timeStr < summary.WorkStart {
summary.WorkStart = timeStr summary.WorkStart = timeStr
@ -261,7 +260,7 @@ func getSollExcelTime(dayOfWeek string) any {
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) slog.Error(fmt.Sprintf("Could not parse hardcoded soll string '%s': %v", sollString, err))
return nil 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)
@ -271,7 +270,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f := excelize.NewFile() f := excelize.NewFile()
defer func() { defer func() {
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
log.Printf("ERROR: Failed to close excel file handle: %v", err) slog.Error(fmt.Sprintf("Failed to close excel file handle: %v", err))
} }
}() }()
@ -442,11 +441,10 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
f.SetCellValue(sheetName, "D"+rowStr, "") f.SetCellValue(sheetName, "D"+rowStr, "")
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr) 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
f.SetCellValue(sheetName, "J"+rowStr, 0.0) f.SetCellValue(sheetName, "J"+rowStr, 0.0)
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
default: // Unbekannte Tags oder Tage ohne Eintrag default:
f.SetCellValue(sheetName, "J"+rowStr, 0.0) f.SetCellValue(sheetName, "J"+rowStr, 0.0)
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle) f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
} }

View file

@ -3,7 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"log" "log/slog"
"net" "net"
"sync" "sync"
@ -31,28 +31,28 @@ 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) 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 -> remote (via SSH)", "Local Address", localAddr, "Remote Address", remoteAddr)
listener, err := net.Listen("tcp", localAddr) listener, err := net.Listen("tcp", localAddr)
if err != nil { if err != nil {
pf.logf("ERROR: Failed to open local listener on %s: %v", localAddr, 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) return fmt.Errorf("failed to listen on %s: %w", localAddr, err)
} }
defer listener.Close() defer listener.Close()
pf.logf("INFO: Listener active on %s", localAddr) pf.logf("INFO", "Listener active", "Local Address", localAddr)
for { for {
localConn, err := listener.Accept() localConn, err := listener.Accept()
if err != nil { if err != nil {
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 closed, stopping forwarder.", "Local Address", localAddr)
return nil return nil
} }
pf.logf("ERROR: Failed to accept incoming connection on %s: %v", localAddr, err) pf.logf("ERROR", "Failed to accept incoming connection:", "Local Address", localAddr, "Error", err)
continue continue
} }
pf.logf("INFO: Accepted connection from %s on %s", localConn.RemoteAddr(), localAddr) pf.logf("INFO", "Accepted connection:", "Remote Address", localConn.RemoteAddr(), "Local Address", localAddr)
go pf.handleConnection(localConn, remoteAddr) go pf.handleConnection(localConn, remoteAddr)
} }
} }
@ -60,14 +60,14 @@ func (pf *PortForwarder) forward() error {
func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) { func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) {
defer localConn.Close() defer localConn.Close()
pf.logf("INFO: Dialing remote host %s via SSH tunnel for %s", remoteAddr, localConn.RemoteAddr()) pf.logf("INFO", "Dialing remote host via SSH tunnel", "Local Address", remoteAddr, "Remote Address", localConn.RemoteAddr())
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 via SSH:", "Remote Address", remoteAddr, "Error:", err)
return return
} }
defer remoteConn.Close() defer remoteConn.Close()
pf.logf("INFO: Connection to %s established. Starting data copy.", remoteAddr) pf.logf("INFO", "Connection established. Starting data copy.", "Remote Address", remoteAddr)
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(2)
@ -78,7 +78,7 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string)
bytesCopied, err := io.Copy(localConn, remoteConn) bytesCopied, err := io.Copy(localConn, remoteConn)
if err != nil { if err != nil {
} }
pf.logf("INFO: Finished copying remote->local (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) pf.logf("INFO", "Finished copying remote->local", "Bytes copied", bytesCopied, "Remote Address", localConn.RemoteAddr())
}() }()
go func() { go func() {
@ -87,15 +87,22 @@ func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string)
bytesCopied, err := io.Copy(remoteConn, localConn) bytesCopied, err := io.Copy(remoteConn, localConn)
if err != nil { if err != nil {
} }
pf.logf("INFO: Finished copying local->remote (%d bytes) for %s", bytesCopied, localConn.RemoteAddr()) pf.logf("INFO", "Finished copying local->remote", "Bytes copied", bytesCopied, "Remote Address", localConn.RemoteAddr())
}() }()
wg.Wait() wg.Wait()
pf.logf("INFO: Closing forwarded connection for %s", localConn.RemoteAddr()) pf.logf("INFO", "Closing forwarded connection", "Remote Address", localConn.RemoteAddr())
} }
func (pf *PortForwarder) logf(format string, v ...any) { func (pf *PortForwarder) logf(level, format string, v ...any) {
pf.logMutex.Lock() pf.logMutex.Lock()
defer pf.logMutex.Unlock() defer pf.logMutex.Unlock()
log.Printf(format, v...) switch level {
case "INFO":
slog.Info(format, v...)
case "WARN":
slog.Warn(format, v...)
case "ERROR":
slog.Error(format, v...)
}
} }

18
go.mod
View file

@ -6,9 +6,9 @@ require (
github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/huh v0.6.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
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.1
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.46.0
golang.org/x/text v0.23.0 golang.org/x/text v0.32.0
modernc.org/sqlite v1.37.0 modernc.org/sqlite v1.37.0
) )
@ -33,7 +33,6 @@ require (
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
@ -49,14 +48,15 @@ require (
github.com/spf13/cast v1.7.1 // indirect github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect github.com/tiendc/go-deepcopy v1.6.0 // indirect
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.1 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.33.0 // indirect golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.12.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.39.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

54
go.sum
View file

@ -56,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/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 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
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= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@ -103,36 +101,48 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY= github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A= github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 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/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 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= 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/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.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 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/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

18
helpers.go Normal file
View file

@ -0,0 +1,18 @@
package main
import (
"log/slog"
"os"
"os/exec"
)
func runCommand(name string, args ...string) {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
err := cmd.Run()
if err != nil {
slog.Error("Command execution error", "command", name, "args", args, "error", err)
}
}

23
main.go
View file

@ -1,18 +1,35 @@
package main package main
import ( import (
"log" "log/slog"
"os" "os"
"path/filepath"
) )
func main() { func main() {
configDir, err := os.UserConfigDir()
if err != nil {
slog.Error("Cant get user config dir")
panic(err)
}
file, err := os.OpenFile(filepath.Join(configDir, "work", "workctl.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
if err != nil {
panic(err)
}
defer file.Close()
logger := slog.New(slog.NewTextHandler(file, nil))
slog.SetDefault(logger)
app, err := NewApp() app, err := NewApp()
if err != nil { if err != nil {
log.Fatalf("ERROR: Unable to setup application: %v", err) slog.Error("Unable to setup application", "Error", err)
os.Exit(1)
} }
defer func() { defer func() {
if err := app.Close(); err != nil { if err := app.Close(); err != nil {
log.Printf("ERROR: Failed to close application resources: %v", err) slog.Error("Failed to close application resources", "Error", err)
} }
}() }()

4
ssh.go
View file

@ -1,7 +1,7 @@
package main package main
import ( import (
"log" "log/slog"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@ -12,7 +12,7 @@ type SSHConnection struct {
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.") slog.Debug("Closing SSH client connection.")
return s.client.Close() return s.client.Close()
} }
return nil return nil

View file

@ -3,7 +3,7 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -37,7 +37,7 @@ func NewTimeStore(cfg Config) (*TimeStore, error) {
return nil, fmt.Errorf("could not determine database path: %w", err) return nil, fmt.Errorf("could not determine database path: %w", err)
} }
log.Printf("INFO: Using database at: %s", dbPath) slog.Info("Using database at:", "Database Path", dbPath)
db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath)) db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath))
if err != nil { if err != nil {
@ -66,7 +66,7 @@ func NewTimeStore(cfg Config) (*TimeStore, error) {
createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);` createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);`
if _, err = db.Exec(createIndexSQL); err != nil { if _, err = db.Exec(createIndexSQL); err != nil {
log.Printf("WARN: Failed to create index on start_time: %v", err) slog.Warn("Failed to create index on start_time:", "Error:", err)
} }
return &TimeStore{db: db, dbPath: dbPath}, nil return &TimeStore{db: db, dbPath: dbPath}, nil
@ -80,7 +80,7 @@ func ensureDatabasePath(_ Config) (string, error) {
workConfigDir := filepath.Join(configDir, "work") workConfigDir := filepath.Join(configDir, "work")
dbPath := filepath.Join(workConfigDir, "worktime.sqlite") dbPath := filepath.Join(workConfigDir, "worktime.sqlite")
if err := os.MkdirAll(workConfigDir, 0750); err != nil { if err := os.MkdirAll(workConfigDir, 0o750); err != nil {
return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err) return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err)
} }
@ -89,7 +89,7 @@ func ensureDatabasePath(_ Config) (string, error) {
func (ts *TimeStore) Close() error { func (ts *TimeStore) Close() error {
if ts.db != nil { if ts.db != nil {
log.Printf("INFO: Closing database connection to %s", ts.dbPath) slog.Info("Closing database connection", "Database Path", ts.dbPath)
return ts.db.Close() return ts.db.Close()
} }
return nil return nil
@ -108,12 +108,12 @@ func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) {
} }
if rowsAffected > 1 { if rowsAffected > 1 {
log.Printf("WARN: Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected) slog.Warn(fmt.Sprintf("Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected))
} }
return rowsAffected > 0, nil return rowsAffected > 0, nil
} }
func (ts *TimeStore) StartTracking(tag string) error { func (ts *TimeStore) StartTracking(tag string, withoutTimew bool) error {
if tag == "" { if tag == "" {
return fmt.Errorf("cannot start tracking with an empty tag") return fmt.Errorf("cannot start tracking with an empty tag")
} }
@ -124,7 +124,10 @@ func (ts *TimeStore) StartTracking(tag string) error {
return err return err
} }
if stopped { if stopped {
log.Println("INFO: Stopped previous time entry.") slog.Info("Stopped previous time entry.")
}
if !withoutTimew {
runCommand("timew", "start", "work")
} }
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);` query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);`
@ -132,20 +135,23 @@ func (ts *TimeStore) StartTracking(tag string) error {
if err != nil { if err != nil {
return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err) return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err)
} }
log.Printf("INFO: Started tracking: %s at %s", tag, now.Format(time.RFC3339)) slog.Info(fmt.Sprintf("Started tracking: %s at %s", tag, now.Format(time.RFC3339)))
return nil return nil
} }
func (ts *TimeStore) StopTracking() error { func (ts *TimeStore) StopTracking(withoutTimew bool) error {
now := time.Now() now := time.Now()
stopped, err := ts.stopCurrentEntry(now) stopped, err := ts.stopCurrentEntry(now)
if err != nil { if err != nil {
return err return err
} }
if !withoutTimew {
runCommand("timew", "stop", "work")
}
if stopped { if stopped {
log.Printf("INFO: Stopped tracking at %s", now.Format(time.RFC3339)) slog.Info(fmt.Sprintf("Stopped tracking at %s", now.Format(time.RFC3339)))
} else { } else {
log.Println("INFO: No active time entry found to stop.") slog.Info("No active time entry found to stop.")
} }
return nil return nil
} }
@ -273,7 +279,7 @@ func getTimeRangeFromPeriod(period string) (time.Time, time.Time) {
end := start.AddDate(0, 0, 1) end := start.AddDate(0, 0, 1)
return start, end return start, end
} }
log.Printf("WARN: Unrecognized period string '%s'. Cannot calculate time range.", period) slog.Warn(fmt.Sprintf("Unrecognized period string '%s'. Cannot calculate time range.", period))
return time.Time{}, time.Time{} return time.Time{}, time.Time{}
} }
} }
@ -329,14 +335,14 @@ func (ts *TimeStore) ShowSummary(period string) error {
} }
func (ts *TimeStore) ExportSummary(filename string) error { func (ts *TimeStore) ExportSummary(filename string) error {
log.Printf("INFO: Starting export to '%s'...", filename) slog.Info(fmt.Sprintf("Starting export to '%s'...", filename))
currentYear := time.Now().Year() currentYear := time.Now().Year()
location := time.Local location := time.Local
yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location) yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location)
yearEnd := yearStart.AddDate(1, 0, 0) 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")) slog.Info(fmt.Sprintf("Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02")))
query := ` query := `
SELECT id, tag, start_time, end_time SELECT id, tag, start_time, end_time
@ -362,7 +368,7 @@ func (ts *TimeStore) ExportSummary(filename string) error {
if err = rows.Err(); err != nil { if err = rows.Err(); err != nil {
return fmt.Errorf("error during export row iteration: %w", err) 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) slog.Info(fmt.Sprintf("Found %d potentially relevant time entries for year %d.", len(entries), currentYear))
dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd) dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd)
if err != nil { if err != nil {
@ -372,17 +378,17 @@ func (ts *TimeStore) ExportSummary(filename string) error {
excelEntries := convertDailyToExcelEntries(dailySummaries) excelEntries := convertDailyToExcelEntries(dailySummaries)
if len(excelEntries) == 0 { if len(excelEntries) == 0 {
log.Println("WARN: No daily summaries generated for the export period.") slog.Warn("No daily summaries generated for the export period.")
fmt.Println("No data available to generate the export for the specified period.") fmt.Println("No data available to generate the export for the specified period.")
return nil return nil
} }
log.Printf("INFO: Generated %d daily entries for the Excel export.", len(excelEntries)) slog.Info(fmt.Sprintf("Generated %d daily entries for the Excel export.", len(excelEntries)))
if err := writeExcelSheet(excelEntries, filename); err != nil { // Aufruf der geänderten Funktion if err := writeExcelSheet(excelEntries, filename); err != nil {
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err) return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
} }
log.Printf("INFO: Successfully exported timetable to %s", filename) slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename))
fmt.Printf("Successfully exported timetable to %s\n", filename) fmt.Printf("Successfully exported timetable to %s\n", filename)
return nil return nil
} }
@ -391,31 +397,27 @@ func (ts *TimeStore) LogFullDay(tag string, date time.Time) error {
if tag == "" { if tag == "" {
return fmt.Errorf("cannot log full day with an empty tag") return fmt.Errorf("cannot log full day with an empty tag")
} }
tag = strings.ToLower(tag) // Stelle sicher, dass der Tag klein geschrieben ist tag = strings.ToLower(tag)
location := date.Location() // Verwende die Zeitzone des übergebenen Datums location := date.Location()
// 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) dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
dayEnd := dayStart.Add(24 * time.Hour) dayEnd := dayStart.Add(24 * time.Hour)
dayStr := dayStart.Format("2006-01-02") dayStr := dayStart.Format("2006-01-02")
log.Printf("INFO: Attempting to log '%s' for the full day %s", tag, dayStr) slog.Info(fmt.Sprintf("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) stopped, err := ts.stopCurrentEntry(dayStart)
if err != nil { if err != nil {
// Nur loggen, weitermachen. Der Nutzer will diesen Tag ja explizit setzen. slog.Warn(fmt.Sprintf("Failed to stop current entry before logging full day '%s': %v", tag, err))
log.Printf("WARN: Failed to stop current entry before logging full day '%s': %v", tag, err)
} else if stopped { } else if stopped {
log.Printf("INFO: Stopped active timer before logging '%s' for %s.", tag, dayStr) slog.Info(fmt.Sprintf("Stopped active timer before logging '%s' for %s.", tag, dayStr))
} }
tx, err := ts.db.Begin() tx, err := ts.db.Begin()
if err != nil { if err != nil {
return fmt.Errorf("could not begin transaction to log full day: %w", err) return fmt.Errorf("could not begin transaction to log full day: %w", err)
} }
defer tx.Rollback() // Stellt sicher, dass bei Fehlern nichts gespeichert wird defer tx.Rollback()
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);` query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
stmt, err := tx.Prepare(query) stmt, err := tx.Prepare(query)
@ -426,17 +428,15 @@ func (ts *TimeStore) LogFullDay(tag string, date time.Time) error {
_, err = stmt.Exec(tag, dayStart, dayEnd) _, err = stmt.Exec(tag, dayStart, dayEnd)
if err != nil { 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) 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 { if err = tx.Commit(); err != nil {
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) 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)) slog.Info(fmt.Sprintf("Successfully logged full day entry: Tag='%s', Start='%s', End='%s'", tag, dayStart.Format(time.RFC3339), dayEnd.Format(time.RFC3339)))
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr) // Benutzerfeedback fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr)
return nil return nil
} }