Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d2e87c562 | |||
| 5de9ff7961 | |||
| 4ed6a61b1d | |||
| 5b16cef525 | |||
| 99fb97dff3 | |||
| c0a83b5892 | |||
| 127018b565 | |||
| 20b4b7ba2d | |||
| 54979319ff | |||
| d8743e54c1 | |||
| fcffccc145 |
16 changed files with 1225 additions and 1133 deletions
|
|
@ -32,110 +32,3 @@ jobs:
|
||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||||
|
|
||||||
# jobs:
|
|
||||||
# build:
|
|
||||||
# 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:
|
|
||||||
# - name: Check out repository
|
|
||||||
# uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# - name: Set up Go
|
|
||||||
# uses: actions/setup-go@v5
|
|
||||||
# with:
|
|
||||||
# go-version: '1.24'
|
|
||||||
|
|
||||||
# - name: Get dependencies
|
|
||||||
# run: go mod tidy
|
|
||||||
|
|
||||||
# - name: Build for ${{ matrix.goos }}
|
|
||||||
# run: |
|
|
||||||
# BUILD_NAME="workctl-${{ matrix.goos }}"
|
|
||||||
# if [ "${{ matrix.goos }}" = "windows" ]; then
|
|
||||||
# BUILD_NAME="${BUILD_NAME}.exe"
|
|
||||||
# fi
|
|
||||||
# GOOS=${{ matrix.goos }} GOARCH=amd64 go build -o ${BUILD_NAME} .
|
|
||||||
# ls -la
|
|
||||||
|
|
||||||
# - name: Upload artifact
|
|
||||||
# uses: actions/upload-artifact@v3
|
|
||||||
# with:
|
|
||||||
# name: workctl-${{ matrix.goos }}
|
|
||||||
# path: workctl-${{ matrix.goos }}${{ matrix.goos == 'windows' && '.exe' || '' }}
|
|
||||||
# retention-days: 7
|
|
||||||
|
|
||||||
# create_release:
|
|
||||||
# needs: build
|
|
||||||
# runs-on: ubuntu-22.04
|
|
||||||
# steps:
|
|
||||||
# - name: Create Release
|
|
||||||
# id: create_release
|
|
||||||
# uses: actions/create-release@v1
|
|
||||||
# env:
|
|
||||||
# # Dies ist der Token, der Forgejo erlaubt, das Release zu erstellen
|
|
||||||
# # Du musst einen Gitea/Forgejo API-Token als Repository Secret (z.B. GITEA_TOKEN) hinterlegen.
|
|
||||||
# # Der Token benötigt die Berechtigung "write:releases" oder "repo".
|
|
||||||
# GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
|
||||||
# with:
|
|
||||||
# url: https://git.patanix.de
|
|
||||||
# tag_name: ${{ github.ref }}
|
|
||||||
# release_name: Release ${{ github.ref }}
|
|
||||||
# body: |
|
|
||||||
# Dies ist ein automatisches Release von ${{ github.ref }}.
|
|
||||||
# draft: false
|
|
||||||
# prerelease: false
|
|
||||||
|
|
||||||
# - name: Download all workflow artifacts
|
|
||||||
# uses: actions/download-artifact@v3
|
|
||||||
# with:
|
|
||||||
# path: ./artifacts
|
|
||||||
|
|
||||||
# - name: Upload Release Asset (Linux)
|
|
||||||
# uses: actions/upload-release-asset@v4
|
|
||||||
# env:
|
|
||||||
# GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
|
||||||
# with:
|
|
||||||
# name: workctl-linux
|
|
||||||
# path: ./artifacts/workctl-linux/workctl-linux
|
|
||||||
# upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
# asset_path: ./artifacts/workctl-linux/workctl-linux
|
|
||||||
# asset_name: workctl-linux
|
|
||||||
# asset_content_type: application/octet-stream
|
|
||||||
|
|
||||||
# - name: Upload Release Asset (macOS)
|
|
||||||
# uses: actions/upload-release-asset@v1
|
|
||||||
# env:
|
|
||||||
# GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$
|
|
||||||
# with:
|
|
||||||
# upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
# asset_path: ./artifacts/workctl-darwin/workctl-darwin
|
|
||||||
# asset_name: workctl-darwin
|
|
||||||
# asset_content_type: application/octet-stream
|
|
||||||
|
|
||||||
# - name: Upload Release Asset (Windows)
|
|
||||||
# uses: actions/upload-release-asset@v1
|
|
||||||
# env:
|
|
||||||
# GITEA_TOKEN: ${{ secrets.GITEATOKEN }}$
|
|
||||||
# with:
|
|
||||||
# upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
# asset_path: ./artifacts/workctl-windows/workctl-windows.exe
|
|
||||||
# asset_name: workctl-windows.exe
|
|
||||||
# asset_content_type: application/octet-stream
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
# This is an example .goreleaser.yml file with some sensible defaults.
|
|
||||||
# Make sure to check the documentation at https://goreleaser.com
|
|
||||||
|
|
||||||
# The lines below are called `modelines`. See `:help modeline`
|
|
||||||
# Feel free to remove those if you don't want/need to use them.
|
|
||||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
|
||||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
|
|
@ -55,8 +49,9 @@ changelog:
|
||||||
- "^test:"
|
- "^test:"
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
name_template: "{{.ProjectName}}-v{{.Version}} {{.Env.USER}}"
|
||||||
footer: >-
|
footer: >-
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
|
Released by {{.Env.USER}}.
|
||||||
|
|
|
||||||
395
app.go
395
app.go
|
|
@ -1,155 +1,180 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sshPkg "golang.org/x/crypto/ssh"
|
||||||
|
"workctl/internal/config"
|
||||||
|
"workctl/internal/ssh"
|
||||||
|
"workctl/internal/store"
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Flags struct {
|
||||||
|
ShowWeek bool
|
||||||
|
ShowMonth bool
|
||||||
|
ShowExport bool
|
||||||
|
ExportName string
|
||||||
|
StartInBackground bool
|
||||||
|
WithoutTimew bool
|
||||||
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg Config
|
cfg config.Config
|
||||||
|
store *store.Store
|
||||||
flags Flags
|
flags Flags
|
||||||
timeStore *TimeStore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp() (*App, error) {
|
func NewApp() (*App, error) {
|
||||||
cfg, err := loadConfig()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error loading config: %w", err)
|
return nil, fmt.Errorf("error loading config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ts, err := NewTimeStore(cfg)
|
st, err := store.NewStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error initializing time store: %w", err)
|
return nil, fmt.Errorf("error initializing time store: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
timeStore: ts,
|
store: st,
|
||||||
|
flags: Flags{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
if a.timeStore != nil {
|
if a.store != nil {
|
||||||
return a.timeStore.Close()
|
return a.store.Close()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) connect() (*SSHConnection, error) { // Rückgabetyp geändert
|
func (a *App) Execute(ctx context.Context) error {
|
||||||
if err := a.timeStore.StartTracking(TagWork); err != nil {
|
if len(os.Args) > 1 {
|
||||||
log.Printf("WARN: Failed to start time tracking for '%s': %v", TagWork, err)
|
return a.setupCommands().ExecuteContext(ctx)
|
||||||
|
}
|
||||||
|
return a.makeChoice(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) StartTracking(ctx context.Context, tag string) error {
|
||||||
|
if err := a.store.StartTracking(ctx, tag); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !a.flags.WithoutTimew {
|
||||||
|
_ = a.runCommand("timew", "start", tag)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) StopTracking(ctx context.Context) error {
|
||||||
|
if err := a.store.StopTracking(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !a.flags.WithoutTimew {
|
||||||
|
_ = a.runCommand("timew", "stop")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) connect(ctx context.Context) error {
|
||||||
|
if err := a.StartTracking(ctx, store.TagWork); err != nil {
|
||||||
|
slog.Warn("Failed to start time tracking", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
a.wakeWorkstation()
|
a.wakeWorkstation()
|
||||||
|
|
||||||
sshCon, err := a.newSSHConnection()
|
sshCon, err := ssh.NewConnection(a.cfg.SSHUser, a.cfg.SSHHost, a.cfg.SSHPort, a.getSSHAuth())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to establish primary SSH connection: %w", err)
|
return fmt.Errorf("failed to establish primary SSH connection: %w", err)
|
||||||
}
|
}
|
||||||
|
defer sshCon.Close()
|
||||||
|
slog.Info("SSH connection established. Setting up tunnels...")
|
||||||
|
|
||||||
log.Println("INFO: SSH connection established. Setting up tunnels...")
|
tunnelCtx, cancelTunnels := context.WithCancel(ctx)
|
||||||
|
defer cancelTunnels()
|
||||||
|
|
||||||
|
sshForwarder := ssh.NewForwarder(sshCon.Client, config.PortLocalSSH, config.PortRemoteSSH, a.cfg.WorkstationIP)
|
||||||
|
rdpForwarder := ssh.NewForwarder(sshCon.Client, config.PortLocalRDP, config.PortRemoteRDP, a.cfg.WorkstationIP)
|
||||||
|
|
||||||
|
sshReady := make(chan struct{})
|
||||||
|
rdpReady := make(chan struct{})
|
||||||
|
|
||||||
sshForwarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP)
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Println("INFO: Starting SSH forwarder (local :2048 -> remote workstation:22)")
|
if err := sshForwarder.Start(tunnelCtx, sshReady); err != nil {
|
||||||
if err := sshForwarder.forward(); err != nil {
|
slog.Error("SSH forwarder stopped", "error", err)
|
||||||
log.Printf("ERROR: SSH forwarder failed: %v", err)
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
if err := rdpForwarder.Start(tunnelCtx, rdpReady); err != nil {
|
||||||
|
slog.Error("RDP forwarder stopped", "error", err)
|
||||||
}
|
}
|
||||||
log.Println("INFO: SSH forwarder stopped.")
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
rdpForwarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP)
|
slog.Info("Waiting for tunnels to initialize...")
|
||||||
go func() {
|
|
||||||
log.Println("INFO: Starting RDP forwarder (local :6000 -> remote workstation:3389)")
|
readyCtx, cancelReady := context.WithTimeout(ctx, 5*time.Second)
|
||||||
if err := rdpForwarder.forward(); err != nil {
|
defer cancelReady()
|
||||||
log.Printf("ERROR: RDP forwarder failed: %v", err)
|
|
||||||
|
select {
|
||||||
|
case <-sshReady:
|
||||||
|
slog.Debug("SSH Tunnel ready")
|
||||||
|
case <-readyCtx.Done():
|
||||||
|
return fmt.Errorf("timeout waiting for SSH tunnel readiness")
|
||||||
}
|
}
|
||||||
log.Println("INFO: RDP forwarder stopped.")
|
|
||||||
}()
|
|
||||||
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
select {
|
||||||
|
case <-rdpReady:
|
||||||
|
slog.Debug("RDP Tunnel ready")
|
||||||
|
case <-readyCtx.Done():
|
||||||
|
return fmt.Errorf("timeout waiting for RDP tunnel readiness")
|
||||||
|
}
|
||||||
|
|
||||||
return sshCon, nil
|
slog.Info("All tunnels established and listening.")
|
||||||
|
|
||||||
|
if a.flags.StartInBackground {
|
||||||
|
fmt.Println("\nINFO: Tunnels are active in background.")
|
||||||
|
fmt.Println(" Connect manually via SSH: ssh -p 2048 <user>@127.0.0.1")
|
||||||
|
fmt.Println(" Connect manually via RDP: xfreerdp /v:127.0.0.1:6000 ...")
|
||||||
|
fmt.Println("INFO: Press Ctrl+C to stop.")
|
||||||
|
<-ctx.Done()
|
||||||
|
slog.Info("Context cancelled, shutting down tunnels...")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Automatically connecting to workstation via SSH tunnel...")
|
||||||
|
a.connectToWorkstation()
|
||||||
|
fmt.Println("Workstation SSH session finished.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.StopTracking(context.Background()); err != nil {
|
||||||
|
slog.Warn("Failed to stop time tracking", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("Time tracking stopped.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
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("Executing command", "cmd", name, "args", args)
|
||||||
cmd := exec.Command(name, args...)
|
cmd := exec.Command(name, args...)
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = os.Stdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
cmd.Stdin = os.Stdin
|
cmd.Stdin = os.Stdin
|
||||||
err := cmd.Run()
|
if err := cmd.Run(); err != nil {
|
||||||
if err != nil {
|
slog.Error("Command failed", "cmd", name, "error", err)
|
||||||
log.Printf("ERROR: Command failed: %s %s -> %v", name, strings.Join(args, " "), err)
|
return err
|
||||||
return fmt.Errorf("command execution failed: %w", err)
|
|
||||||
}
|
}
|
||||||
log.Printf("INFO: Command finished successfully: %s", name)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) wakeWorkstation() {
|
func (a *App) makeChoice(ctx context.Context) error {
|
||||||
log.Println("INFO: Attempting to wake workstation...")
|
|
||||||
innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Wake-on-LAN packet sent.' && exit\"",
|
|
||||||
a.cfg.JumpUser,
|
|
||||||
a.cfg.JumpHost,
|
|
||||||
a.cfg.WorkstationMac)
|
|
||||||
|
|
||||||
outerSSHCmd := []string{
|
|
||||||
"-tt",
|
|
||||||
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
|
|
||||||
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
|
|
||||||
innerSSHCmd,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := a.runCommand("ssh", outerSSHCmd...); err != nil {
|
|
||||||
log.Println("WARN: Failed to send Wake-on-LAN packet via SSH jump. Workstation might already be awake or command failed.")
|
|
||||||
} else {
|
|
||||||
log.Println("INFO: Wake-on-LAN command executed.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) connectToJump() {
|
|
||||||
log.Println("INFO: Connecting to Jump Host with Port Forwarding...")
|
|
||||||
sshArgs := []string{
|
|
||||||
"-tt",
|
|
||||||
"-L", fmt.Sprintf("2048:%s:22", a.cfg.WorkstationHost),
|
|
||||||
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
|
|
||||||
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
|
|
||||||
}
|
|
||||||
if err := a.runCommand("ssh", sshArgs...); err != nil {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) connectToWorkstation() {
|
|
||||||
log.Println("INFO: Connecting to Workstation via local tunnel (localhost:2048)...")
|
|
||||||
sshArgs := []string{
|
|
||||||
"-tt",
|
|
||||||
"-L", fmt.Sprintf("6000:%s:3389", a.cfg.WorkstationHost),
|
|
||||||
"-p", "2048",
|
|
||||||
fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser),
|
|
||||||
}
|
|
||||||
if err := a.runCommand("ssh", sshArgs...); err != nil {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) startRDPConnection() {
|
|
||||||
log.Println("INFO: Starting RDP connection to localhost:6000...")
|
|
||||||
rdpCommand := fmt.Sprintf("xfreerdp /u:%s /p:%s /v:127.0.0.1:6000 /size:3000x1350 +clipboard /dynamic-resolution",
|
|
||||||
a.cfg.RDPUser,
|
|
||||||
a.cfg.SSHPassword,
|
|
||||||
)
|
|
||||||
if err := a.runCommand("bash", "-c", rdpCommand); err != nil {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) makeChoice() {
|
|
||||||
var choice string
|
var choice string
|
||||||
|
|
||||||
form := huh.NewForm(
|
form := huh.NewForm(
|
||||||
|
|
@ -165,65 +190,47 @@ func (a *App) makeChoice() {
|
||||||
huh.NewOption("Show Week Summary", "show week summary"),
|
huh.NewOption("Show Week Summary", "show week summary"),
|
||||||
huh.NewOption("Show Month Summary", "show month summary"),
|
huh.NewOption("Show Month Summary", "show month summary"),
|
||||||
huh.NewOption("Export Yearly Timetable", "export"),
|
huh.NewOption("Export Yearly Timetable", "export"),
|
||||||
huh.NewOption("Connect to Jump Host (Tunnel to Workstation)", "connect to jump"),
|
huh.NewOption("Connect to Jump Host (Tunnel)", "connect to jump"),
|
||||||
huh.NewOption("Connect to Workstation (via Tunnel)", "connect to workstation"),
|
huh.NewOption("Connect to Workstation (Tunnel)", "connect to workstation"),
|
||||||
huh.NewOption("Start RDP Connection (via Tunnel)", "start rdp connection"),
|
huh.NewOption("Start RDP Connection", "start rdp connection"),
|
||||||
huh.NewOption("Wake Workstation", "wake workstation"),
|
huh.NewOption("Wake Workstation", "wake workstation"),
|
||||||
huh.NewOption("Kill Active Tunnels (Ports 2048, 6000)", "kill tunnels"),
|
huh.NewOption("Kill Active Tunnels", "kill tunnels"),
|
||||||
|
huh.NewOption("Config: Set Secrets", "set secrets"),
|
||||||
huh.NewOption("Exit", "exit"),
|
huh.NewOption("Exit", "exit"),
|
||||||
).
|
).
|
||||||
Value(&choice),
|
Value(&choice),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
err := form.Run()
|
if err := form.Run(); err != nil {
|
||||||
if err != nil {
|
return nil
|
||||||
if err == huh.ErrUserAborted {
|
|
||||||
fmt.Println("Operation cancelled.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("ERROR: Form execution failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch choice {
|
switch choice {
|
||||||
case "start work":
|
case "start work":
|
||||||
a.connect()
|
return a.connect(ctx)
|
||||||
case "stop work":
|
case "stop work":
|
||||||
if err := a.timeStore.StopTracking(); err != nil {
|
if err := a.StopTracking(ctx); err != nil {
|
||||||
log.Printf("ERROR: Failed to stop time tracking: %v", err)
|
slog.Error("Failed to stop time tracking", "error", err)
|
||||||
}
|
|
||||||
if err := a.killForwardings(); err != nil {
|
|
||||||
log.Printf("WARN: Could not kill all forwardings: %v", err)
|
|
||||||
}
|
}
|
||||||
|
_ = a.killForwardings()
|
||||||
case "start break":
|
case "start break":
|
||||||
if err := a.timeStore.StartTracking(TagBreak); err != nil {
|
if err := a.StartTracking(ctx, store.TagBreak); err != nil {
|
||||||
log.Printf("ERROR: Failed to start break tracking: %v", err)
|
slog.Error("Failed to start break", "error", err)
|
||||||
}
|
}
|
||||||
case "stop break":
|
case "stop break":
|
||||||
if err := a.timeStore.StartTracking(TagWork); err != nil {
|
if err := a.StartTracking(ctx, store.TagWork); err != nil {
|
||||||
log.Printf("ERROR: Failed to stop break (start work): %v", err)
|
slog.Error("Failed to stop break", "error", err)
|
||||||
}
|
}
|
||||||
case "show day summary":
|
case "show day summary":
|
||||||
if err := a.timeStore.ShowSummary("today"); err != nil {
|
_ = a.store.ShowSummary(ctx, "today")
|
||||||
log.Printf("ERROR: Failed to show day summary: %v", err)
|
|
||||||
}
|
|
||||||
case "show week summary":
|
case "show week summary":
|
||||||
if err := a.timeStore.ShowSummary("week"); err != nil {
|
_ = a.store.ShowSummary(ctx, "week")
|
||||||
log.Printf("ERROR: Failed to show week summary: %v", err)
|
|
||||||
}
|
|
||||||
case "show month summary":
|
case "show month summary":
|
||||||
if err := a.timeStore.ShowSummary("month"); err != nil {
|
_ = a.store.ShowSummary(ctx, "month")
|
||||||
log.Printf("ERROR: Failed to show month summary: %v", err)
|
|
||||||
}
|
|
||||||
case "export":
|
case "export":
|
||||||
filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
|
filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
|
||||||
if a.flags.ExportName != "" && a.flags.ExportName != "Arbeitszeiten.xlsx" {
|
_ = a.store.ExportSummary(ctx, filename)
|
||||||
filename = a.flags.ExportName
|
|
||||||
}
|
|
||||||
if err := a.timeStore.ExportSummary(filename); err != nil {
|
|
||||||
log.Printf("ERROR: Failed to export summary to '%s': %v", filename, err)
|
|
||||||
}
|
|
||||||
case "connect to jump":
|
case "connect to jump":
|
||||||
a.connectToJump()
|
a.connectToJump()
|
||||||
case "connect to workstation":
|
case "connect to workstation":
|
||||||
|
|
@ -233,86 +240,90 @@ func (a *App) makeChoice() {
|
||||||
case "wake workstation":
|
case "wake workstation":
|
||||||
a.wakeWorkstation()
|
a.wakeWorkstation()
|
||||||
case "kill tunnels":
|
case "kill tunnels":
|
||||||
if err := a.killForwardings(); err != nil {
|
_ = a.killForwardings()
|
||||||
log.Printf("ERROR: Failed to kill forwardings: %v", err)
|
case "set secrets":
|
||||||
} else {
|
fmt.Println("Please run 'workctl config set-secrets' directly from CLI.")
|
||||||
log.Println("INFO: Attempted to kill processes on ports 2048 and 6000.")
|
|
||||||
}
|
|
||||||
case "exit":
|
case "exit":
|
||||||
fmt.Println("Exiting.")
|
return nil
|
||||||
return
|
|
||||||
default:
|
|
||||||
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 != "start work" {
|
||||||
fmt.Println("\nPress Enter to continue...")
|
fmt.Println("\nPress Enter to continue...")
|
||||||
fmt.Scanln()
|
fmt.Scanln()
|
||||||
a.makeChoice()
|
return a.makeChoice(ctx)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) getSSHAuth() ssh.AuthMethod {
|
|
||||||
keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg")
|
|
||||||
|
|
||||||
keyBytes, err := os.ReadFile(keyPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ERROR: Unable to read private key '%s': %v", keyPath, err)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var key ssh.Signer
|
func (a *App) getSSHAuth() sshPkg.AuthMethod {
|
||||||
key, err = ssh.ParsePrivateKey(keyBytes)
|
keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg")
|
||||||
|
keyBytes, err := os.ReadFile(keyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := err.(*ssh.PassphraseMissingError); ok {
|
slog.Error("Unable to read private key", "path", keyPath, "error", err)
|
||||||
log.Printf("INFO: Private key '%s' requires a passphrase. Trying with RDP password from config.", keyPath)
|
return nil
|
||||||
key, err = ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword))
|
}
|
||||||
|
|
||||||
|
key, err := sshPkg.ParsePrivateKey(keyBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ERROR: Unable to parse private key '%s' with passphrase: %v", keyPath, err)
|
if _, ok := err.(*sshPkg.PassphraseMissingError); ok {
|
||||||
|
slog.Info("Key requires passphrase, trying RDP password from config/keyring")
|
||||||
|
key, err = sshPkg.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to parse key with passphrase", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Printf("ERROR: Unable to parse private key '%s': %v", keyPath, err)
|
slog.Error("Failed to parse private key", "error", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return sshPkg.PublicKeys(key)
|
||||||
log.Printf("INFO: Successfully loaded private key '%s'", keyPath)
|
|
||||||
return ssh.PublicKeys(key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) newSSHConnection() (*SSHConnection, error) {
|
func (a *App) wakeWorkstation() {
|
||||||
authMethod := a.getSSHAuth()
|
slog.Info("Attempting to wake workstation...")
|
||||||
if authMethod == nil {
|
innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Packet sent' && exit\"",
|
||||||
return nil, fmt.Errorf("SSH authentication method could not be obtained")
|
a.cfg.JumpUser, a.cfg.JumpHost, a.cfg.WorkstationMac)
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"-tt",
|
||||||
|
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
|
||||||
|
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
|
||||||
|
innerSSHCmd,
|
||||||
|
}
|
||||||
|
_ = a.runCommand("ssh", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
sshConfig := &ssh.ClientConfig{
|
func (a *App) connectToJump() {
|
||||||
User: a.cfg.SSHUser,
|
args := []string{
|
||||||
Auth: []ssh.AuthMethod{authMethod},
|
"-tt",
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
"-L", fmt.Sprintf("%s:%s:%s", config.PortLocalSSH, a.cfg.WorkstationHost, config.PortRemoteSSH),
|
||||||
Timeout: 10 * time.Second, // Etwas längerer Timeout
|
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
|
||||||
|
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
|
||||||
|
}
|
||||||
|
_ = a.runCommand("ssh", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
target := fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort)
|
func (a *App) connectToWorkstation() {
|
||||||
log.Printf("INFO: Dialing SSH to %s...", target)
|
args := []string{
|
||||||
|
"-tt",
|
||||||
client, err := ssh.Dial("tcp", target, sshConfig)
|
"-L", fmt.Sprintf("%s:%s:%s", config.PortLocalRDP, a.cfg.WorkstationHost, config.PortRemoteRDP),
|
||||||
if err != nil {
|
"-p", config.PortLocalSSH,
|
||||||
return nil, fmt.Errorf("SSH dial to %s failed: %w", target, err)
|
fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser),
|
||||||
}
|
}
|
||||||
log.Printf("INFO: SSH connection to %s successful.", target)
|
_ = a.runCommand("ssh", args...)
|
||||||
|
|
||||||
session, err := client.NewSession()
|
|
||||||
if err != nil {
|
|
||||||
client.Close() // Client schließen, wenn Session fehlschlägt
|
|
||||||
return nil, fmt.Errorf("failed to create SSH session: %w", err)
|
|
||||||
}
|
}
|
||||||
session.Close()
|
|
||||||
|
|
||||||
return &SSHConnection{
|
func (a *App) startRDPConnection() {
|
||||||
client: client,
|
args := []string{
|
||||||
}, nil
|
fmt.Sprintf("/u:%s", a.cfg.RDPUser),
|
||||||
|
fmt.Sprintf("/p:%s", a.cfg.RDPPassword),
|
||||||
|
fmt.Sprintf("/v:127.0.0.1:%s", config.PortLocalRDP),
|
||||||
|
"/size:3000x1350",
|
||||||
|
"+clipboard",
|
||||||
|
"/dynamic-resolution",
|
||||||
|
}
|
||||||
|
_ = a.runCommand("xfreerdp", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) killForwardings() error {
|
func (a *App) killForwardings() error {
|
||||||
|
|
@ -320,16 +331,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 +352,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
|
||||||
|
|
|
||||||
273
cmd.go
273
cmd.go
|
|
@ -2,13 +2,15 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"workctl/internal/config"
|
||||||
|
"workctl/internal/store"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -28,93 +30,103 @@ and other utilities.`,
|
||||||
rootCmd.AddCommand(a.connectCommands())
|
rootCmd.AddCommand(a.connectCommands())
|
||||||
rootCmd.AddCommand(a.wakeCommand())
|
rootCmd.AddCommand(a.wakeCommand())
|
||||||
rootCmd.AddCommand(a.importTimewarriorCommand())
|
rootCmd.AddCommand(a.importTimewarriorCommand())
|
||||||
|
rootCmd.AddCommand(a.configCommand())
|
||||||
|
|
||||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) configCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Manage configuration and secrets",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "set-secrets",
|
||||||
|
Short: "Interactively set passwords in the system keyring",
|
||||||
|
Long: "Prompts for SSH and RDP passwords and stores them securely in the operating system's keychain/keyring.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var sshPw, rdpPw string
|
||||||
|
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("SSH Password").
|
||||||
|
Description("Leave empty to keep existing").
|
||||||
|
EchoMode(huh.EchoModePassword).
|
||||||
|
Value(&sshPw),
|
||||||
|
huh.NewInput().
|
||||||
|
Title("RDP Password").
|
||||||
|
Description("Leave empty to keep existing").
|
||||||
|
EchoMode(huh.EchoModePassword).
|
||||||
|
Value(&rdpPw),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if sshPw != "" {
|
||||||
|
if err := config.SetSecret(config.KeySSHPassword(), sshPw); err != nil {
|
||||||
|
return fmt.Errorf("failed to save SSH password: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✓ SSH password saved to keyring.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rdpPw != "" {
|
||||||
|
if err := config.SetSecret(config.KeyRDPPassword(), rdpPw); err != nil {
|
||||||
|
return fmt.Errorf("failed to save RDP password: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Println("✓ RDP password saved to keyring.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if sshPw == "" && rdpPw == "" {
|
||||||
|
fmt.Println("No changes made.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) startCommand() *cobra.Command {
|
func (a *App) startCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "start",
|
Use: "start",
|
||||||
Short: "Start work: Track time, WOL, setup tunnels, optionally connect or run in background",
|
Short: "Start work: Track time, WOL, setup tunnels, optionally connect or run in background",
|
||||||
Long: `Starts time tracking, attempts WOL, sets up SSH tunnels.
|
Long: `Starts time tracking, attempts WOL, sets up SSH tunnels.
|
||||||
Default behavior: Immediately starts an interactive SSH session to the workstation via the tunnel. The command blocks until this session ends.
|
Default behavior: Immediately starts an interactive SSH session to the workstation via the tunnel.
|
||||||
Use --background (-b) to keep tunnels running in the background without auto-connecting. Press Ctrl+C to stop background tunnels.`,
|
Use --background (-b) to keep tunnels running in the background without auto-connecting.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Println("Starting workday procedures...")
|
return a.connect(cmd.Context())
|
||||||
|
|
||||||
sshCon, err := a.connect()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("ERROR: Failed to start connections: %v\n", err)
|
|
||||||
return fmt.Errorf("connection setup failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
log.Println("INFO: Closing SSH connection to jump host (defer)...")
|
|
||||||
if err := sshCon.Close(); err != nil {
|
|
||||||
log.Printf("WARN: Error closing SSH connection in defer: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Println("INFO: SSH connection closed via defer.")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if a.flags.StartInBackground {
|
|
||||||
fmt.Println("\nINFO: Tunnels are active in background.")
|
|
||||||
fmt.Println(" Connect manually via SSH: ssh -p 2048 <user>@127.0.0.1")
|
|
||||||
fmt.Println(" Connect manually via RDP: xfreerdp /v:127.0.0.1:6000 ...")
|
|
||||||
fmt.Println("INFO: Press Ctrl+C to stop tunnels.")
|
|
||||||
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
||||||
<-sigChan
|
|
||||||
|
|
||||||
fmt.Println("\nINFO: Received interrupt signal. Shutting down background process...")
|
|
||||||
log.Println("INFO: Received signal, cleanup via defer sshCon.Close() will run.")
|
|
||||||
if err := a.timeStore.StopTracking(); err != nil {
|
|
||||||
log.Printf("WARN: Failed to stop time tracking: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Println("INFO: Time tracking stopped.")
|
|
||||||
}
|
|
||||||
fmt.Println("INFO: Background shutdown complete.")
|
|
||||||
|
|
||||||
} else {
|
|
||||||
fmt.Println("INFO: Automatically connecting to workstation via SSH tunnel...")
|
|
||||||
a.connectToWorkstation()
|
|
||||||
|
|
||||||
fmt.Println("INFO: Workstation SSH session finished.")
|
|
||||||
log.Println("INFO: Foreground session ended, cleanup via defer sshCon.Close() will run.")
|
|
||||||
if err := a.timeStore.StopTracking(); err != nil {
|
|
||||||
log.Printf("WARN: Failed to stop time tracking: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Println("INFO: Time tracking stopped.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately")
|
cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately")
|
||||||
|
cmd.Flags().BoolVarP(&a.flags.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.StopTracking(cmd.Context()); 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 +134,8 @@ func (a *App) stopCommand() *cobra.Command {
|
||||||
fmt.Println("Workday stop procedures finished.")
|
fmt.Println("Workday stop procedures finished.")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmd.Flags().BoolVarP(&a.flags.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 {
|
||||||
|
|
@ -137,7 +151,7 @@ it will mark the *current day* with that tag instead of starting an interval tim
|
||||||
This also stops any currently running timer.`,
|
This also stops any currently running timer.`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
tag := TagWork
|
tag := store.TagWork
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
tag = args[0]
|
tag = args[0]
|
||||||
}
|
}
|
||||||
|
|
@ -152,18 +166,18 @@ This also stops any currently running timer.`,
|
||||||
case "uni", "urlaub", "feiertag", "krank", "free":
|
case "uni", "urlaub", "feiertag", "krank", "free":
|
||||||
today := time.Now()
|
today := time.Now()
|
||||||
fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02"))
|
fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02"))
|
||||||
if err := a.timeStore.LogFullDay(tagLower, today); err != nil {
|
if err := a.store.LogFullDay(cmd.Context(), tagLower, today); err != nil {
|
||||||
return fmt.Errorf("could not log '%s' for today: %w", tagLower, err)
|
return fmt.Errorf("could not log '%s' for today: %w", tagLower, err)
|
||||||
}
|
}
|
||||||
return nil // 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.StartTracking(cmd.Context(), tag); 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 +187,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.StartTracking(cmd.Context(), store.TagBreak); 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 +218,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.store.ExportSummary(cmd.Context(), 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.store.ShowSummary(cmd.Context(), "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.store.ShowSummary(cmd.Context(), "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.store.ShowSummary(cmd.Context(), 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -247,8 +261,8 @@ Export: Use the --export flag or the 'export' subcommand.`,
|
||||||
filename = args[0]
|
filename = args[0]
|
||||||
}
|
}
|
||||||
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
|
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
|
||||||
if err := a.timeStore.ExportSummary(filename); err != nil {
|
if err := a.store.ExportSummary(cmd.Context(), filename); err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -279,7 +293,6 @@ func (a *App) connectCommands() *cobra.Command {
|
||||||
cmd.AddCommand(&cobra.Command{
|
cmd.AddCommand(&cobra.Command{
|
||||||
Use: "jump",
|
Use: "jump",
|
||||||
Short: "Connect to Jump Host (with tunnel to workstation)",
|
Short: "Connect to Jump Host (with tunnel to workstation)",
|
||||||
Long: "Establishes an SSH connection to the Jump Host and forwards local port 2048 to the workstation's SSH port (22). This command blocks.",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
a.connectToJump()
|
a.connectToJump()
|
||||||
},
|
},
|
||||||
|
|
@ -288,7 +301,6 @@ func (a *App) connectCommands() *cobra.Command {
|
||||||
cmd.AddCommand(&cobra.Command{
|
cmd.AddCommand(&cobra.Command{
|
||||||
Use: "workstation",
|
Use: "workstation",
|
||||||
Short: "Connect to Workstation via SSH tunnel",
|
Short: "Connect to Workstation via SSH tunnel",
|
||||||
Long: "Establishes an SSH connection to the Workstation via the local tunnel on port 2048. Also sets up RDP tunnel on local port 6000. Requires the 'jump' tunnel to be active. This command blocks.",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
a.connectToWorkstation()
|
a.connectToWorkstation()
|
||||||
},
|
},
|
||||||
|
|
@ -297,7 +309,6 @@ func (a *App) connectCommands() *cobra.Command {
|
||||||
cmd.AddCommand(&cobra.Command{
|
cmd.AddCommand(&cobra.Command{
|
||||||
Use: "rdp",
|
Use: "rdp",
|
||||||
Short: "Start RDP session via tunnel",
|
Short: "Start RDP session via tunnel",
|
||||||
Long: "Starts an RDP client (xfreerdp) connecting to localhost:6000. Requires an active tunnel forwarding this port to the workstation's RDP port (3389). This command blocks.",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
a.startRDPConnection()
|
a.startRDPConnection()
|
||||||
},
|
},
|
||||||
|
|
@ -310,10 +321,6 @@ func (a *App) importTimewarriorCommand() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "import-timew [filepath]",
|
Use: "import-timew [filepath]",
|
||||||
Short: "Import time entries from 'timewarrior summary' output file",
|
Short: "Import time entries from 'timewarrior summary' output file",
|
||||||
Long: `Parses the output of 'timewarrior summary :year' (or similar) stored in a text file
|
|
||||||
and inserts the individual time intervals into the workctl SQLite database.
|
|
||||||
It expects the standard timewarrior summary format.
|
|
||||||
Example: workctl import-timew /path/to/timew-summary.txt`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
filepath := args[0]
|
filepath := args[0]
|
||||||
|
|
@ -321,12 +328,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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -341,7 +348,7 @@ func (a *App) runImport(filepath string) (int, error) {
|
||||||
content := string(contentBytes)
|
content := string(contentBytes)
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
|
|
||||||
tx, err := a.timeStore.db.Begin()
|
tx, err := a.store.DB().Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("could not begin database transaction: %w", err)
|
return 0, fmt.Errorf("could not begin database transaction: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -353,8 +360,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 +371,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 = store.TagWork
|
||||||
case "break":
|
case "break":
|
||||||
db_tag = TagBreak
|
dbTag = store.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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
forwarder.go
101
forwarder.go
|
|
@ -1,101 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PortForwarder struct {
|
|
||||||
sshCon *ssh.Client
|
|
||||||
localPort string
|
|
||||||
remotePort string
|
|
||||||
remoteHost string
|
|
||||||
logMutex sync.Mutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost string) *PortForwarder {
|
|
||||||
return &PortForwarder{
|
|
||||||
sshCon: sshCon,
|
|
||||||
localPort: localPort,
|
|
||||||
remotePort: remotePort,
|
|
||||||
remoteHost: remoteHost,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pf *PortForwarder) forward() error {
|
|
||||||
localAddr := "127.0.0.1:" + pf.localPort
|
|
||||||
remoteAddr := net.JoinHostPort(pf.remoteHost, pf.remotePort)
|
|
||||||
|
|
||||||
pf.logf("INFO: Starting port forwarder: local %s -> remote %s (via SSH)", localAddr, remoteAddr)
|
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", localAddr)
|
|
||||||
if err != nil {
|
|
||||||
pf.logf("ERROR: Failed to open local listener on %s: %v", localAddr, err)
|
|
||||||
return fmt.Errorf("failed to listen on %s: %w", localAddr, err)
|
|
||||||
}
|
|
||||||
defer listener.Close()
|
|
||||||
pf.logf("INFO: Listener active on %s", localAddr)
|
|
||||||
|
|
||||||
for {
|
|
||||||
localConn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" {
|
|
||||||
pf.logf("INFO: Listener on %s closed, stopping forwarder.", localAddr)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
pf.logf("ERROR: Failed to accept incoming connection on %s: %v", localAddr, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
pf.logf("INFO: Accepted connection from %s on %s", localConn.RemoteAddr(), localAddr)
|
|
||||||
go pf.handleConnection(localConn, remoteAddr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pf *PortForwarder) handleConnection(localConn net.Conn, remoteAddr string) {
|
|
||||||
defer localConn.Close()
|
|
||||||
|
|
||||||
pf.logf("INFO: Dialing remote host %s via SSH tunnel for %s", remoteAddr, localConn.RemoteAddr())
|
|
||||||
remoteConn, err := pf.sshCon.Dial("tcp", remoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
pf.logf("ERROR: Failed to dial remote host %s via SSH: %v", remoteAddr, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer remoteConn.Close()
|
|
||||||
pf.logf("INFO: Connection to %s established. Starting data copy.", remoteAddr)
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer localConn.Close()
|
|
||||||
bytesCopied, err := io.Copy(localConn, remoteConn)
|
|
||||||
if err != nil {
|
|
||||||
}
|
|
||||||
pf.logf("INFO: Finished copying remote->local (%d bytes) for %s", bytesCopied, localConn.RemoteAddr())
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
defer remoteConn.Close()
|
|
||||||
bytesCopied, err := io.Copy(remoteConn, localConn)
|
|
||||||
if err != nil {
|
|
||||||
}
|
|
||||||
pf.logf("INFO: Finished copying local->remote (%d bytes) for %s", bytesCopied, localConn.RemoteAddr())
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
pf.logf("INFO: Closing forwarded connection for %s", localConn.RemoteAddr())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pf *PortForwarder) logf(format string, v ...any) {
|
|
||||||
pf.logMutex.Lock()
|
|
||||||
defer pf.logMutex.Unlock()
|
|
||||||
log.Printf(format, v...)
|
|
||||||
}
|
|
||||||
86
go.mod
86
go.mod
|
|
@ -3,63 +3,73 @@ module workctl
|
||||||
go 1.24.2
|
go 1.24.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/huh v0.8.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.21.0
|
||||||
github.com/xuri/excelize/v2 v2.9.1
|
github.com/xuri/excelize/v2 v2.10.0
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.46.0
|
||||||
golang.org/x/text v0.25.0
|
golang.org/x/text v0.33.0
|
||||||
modernc.org/sqlite v1.37.0
|
modernc.org/sqlite v1.43.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
al.essio.dev/pkg/shellescape v1.5.1 // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/catppuccin/go v0.2.0 // indirect
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.20.0 // indirect
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||||
github.com/charmbracelet/bubbletea v1.1.0 // indirect
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.2.3 // indirect
|
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20260109001716-2fbdffcb221f // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||||
|
github.com/danieljoos/wincred v1.2.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
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.19 // 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.16.0 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
github.com/richardlehane/mscfb v1.0.5 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.12.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.7.1 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tiendc/go-deepcopy v1.6.0 // indirect
|
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/xuri/efp v0.0.1 // indirect
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
github.com/xuri/nfp v0.0.1 // indirect
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
github.com/zalando/go-keyring v0.2.6 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/net v0.40.0 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.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.67.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.9.1 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
124
go.sum
124
go.sum
|
|
@ -1,3 +1,5 @@
|
||||||
|
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
|
||||||
|
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
|
@ -6,21 +8,49 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
|
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
|
||||||
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
|
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||||
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
|
github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
|
||||||
github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
|
github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
|
github.com/charmbracelet/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
|
||||||
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
|
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
|
||||||
|
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||||
|
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||||
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
|
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
|
||||||
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
|
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
|
github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
|
||||||
github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20260109001716-2fbdffcb221f h1:c0cKImYFPrOEEzMsYss56Q7Q69HD7H4ss3Yu9Mw9vqQ=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
|
||||||
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
|
github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
|
||||||
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
|
github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
||||||
|
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
|
||||||
|
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|
@ -32,8 +62,14 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
|
@ -48,32 +84,42 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
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/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
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=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
|
||||||
|
github.com/richardlehane/mscfb v1.0.5 h1:OoQkDV2Bf2bIoSacCfJhSwm7BJN05fYFkwFUpxExtdY=
|
||||||
|
github.com/richardlehane/mscfb v1.0.5/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||||
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||||
|
|
@ -85,18 +131,32 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||||
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
|
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
|
||||||
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
|
@ -105,52 +165,64 @@ 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/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
|
github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
|
||||||
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
|
github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
|
||||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
|
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||||
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||||
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
|
|
||||||
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
|
|
||||||
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
|
github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
|
||||||
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
|
github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
|
||||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
|
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
|
||||||
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
|
||||||
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||||
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
|
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
|
||||||
|
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
|
||||||
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/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
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=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
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/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||||
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
|
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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|
||||||
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
|
||||||
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
|
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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
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.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.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
golang.org/x/sys v0.40.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 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
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=
|
||||||
|
|
@ -160,24 +232,32 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
|
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
|
||||||
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
|
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
|
||||||
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
|
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||||
|
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||||
|
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||||
|
modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA=
|
||||||
|
modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
package main
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
serviceName = "workctl"
|
||||||
|
keySSHPassword = "ssh-password"
|
||||||
|
keyRDPPassword = "rdp-password"
|
||||||
|
|
||||||
|
PortLocalSSH = "2048"
|
||||||
|
PortLocalRDP = "6000"
|
||||||
|
PortRemoteSSH = "22"
|
||||||
|
PortRemoteRDP = "3389"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|
@ -22,18 +34,9 @@ type Config struct {
|
||||||
RDPPassword string `mapstructure:"RDP_PASSWORD"`
|
RDPPassword string `mapstructure:"RDP_PASSWORD"`
|
||||||
WorkstationIP string `mapstructure:"WORKSTATION_IP"`
|
WorkstationIP string `mapstructure:"WORKSTATION_IP"`
|
||||||
SSHPort int `mapstructure:"SSH_PORT"`
|
SSHPort int `mapstructure:"SSH_PORT"`
|
||||||
// DatabasePath string `mapstructure:"DATABASE_PATH"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Flags struct {
|
func Load() (Config, error) {
|
||||||
ShowWeek bool
|
|
||||||
ShowMonth bool
|
|
||||||
ShowExport bool
|
|
||||||
ExportName string
|
|
||||||
StartInBackground bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadConfig() (Config, error) {
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
configPath, err := os.UserConfigDir()
|
configPath, err := os.UserConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -43,7 +46,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 +58,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.Debug(fmt.Sprintf("Config file '%s' not found, using defaults/env vars.", configFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := viper.UnmarshalKey("default", &cfg); err != nil {
|
if err := viper.UnmarshalKey("default", &cfg); err != nil {
|
||||||
|
|
@ -68,5 +71,32 @@ func loadConfig() (Config, error) {
|
||||||
cfg.SSHPort = 22
|
cfg.SSHPort = 22
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.SSHPassword == "" {
|
||||||
|
if secret, err := GetSecret(keySSHPassword); err == nil {
|
||||||
|
cfg.SSHPassword = secret
|
||||||
|
slog.Debug("Loaded SSH password from keyring.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.RDPPassword == "" {
|
||||||
|
if secret, err := GetSecret(keyRDPPassword); err == nil {
|
||||||
|
cfg.RDPPassword = secret
|
||||||
|
slog.Debug("Loaded RDP password from keyring.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetSecret(key string) (string, error) {
|
||||||
|
return keyring.Get(serviceName, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetSecret(key, value string) error {
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("secret cannot be empty")
|
||||||
|
}
|
||||||
|
return keyring.Set(serviceName, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func KeySSHPassword() string { return keySSHPassword }
|
||||||
|
func KeyRDPPassword() string { return keyRDPPassword }
|
||||||
55
internal/ssh/client.go
Normal file
55
internal/ssh/client.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/crypto/ssh/knownhosts"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Connection struct {
|
||||||
|
Client *ssh.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnection(user, host string, port int, auth ssh.AuthMethod) (*Connection, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get home dir: %w", err)
|
||||||
|
}
|
||||||
|
knownHostsPath := filepath.Join(home, ".ssh", "known_hosts")
|
||||||
|
|
||||||
|
hkCallback, err := knownhosts.New(knownHostsPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Could not load known_hosts, ensure you connected manually once.", "path", knownHostsPath)
|
||||||
|
return nil, fmt.Errorf("known_hosts error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &ssh.ClientConfig{
|
||||||
|
User: user,
|
||||||
|
Auth: []ssh.AuthMethod{auth},
|
||||||
|
HostKeyCallback: hkCallback,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
slog.Debug("Dialing SSH", "target", addr)
|
||||||
|
|
||||||
|
client, err := ssh.Dial("tcp", addr, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ssh dial failed: %w", err)
|
||||||
|
}
|
||||||
|
slog.Debug("SSH connection established", "target", addr)
|
||||||
|
|
||||||
|
return &Connection{Client: client}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connection) Close() error {
|
||||||
|
if c.Client != nil {
|
||||||
|
return c.Client.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
107
internal/ssh/forwarder.go
Normal file
107
internal/ssh/forwarder.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Forwarder struct {
|
||||||
|
sshClient *ssh.Client
|
||||||
|
localPort string
|
||||||
|
remotePort string
|
||||||
|
remoteHost string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewForwarder(client *ssh.Client, localPort, remotePort, remoteHost string) *Forwarder {
|
||||||
|
return &Forwarder{
|
||||||
|
sshClient: client,
|
||||||
|
localPort: localPort,
|
||||||
|
remotePort: remotePort,
|
||||||
|
remoteHost: remoteHost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Forwarder) Start(ctx context.Context, ready chan<- struct{}) error {
|
||||||
|
localAddr := "127.0.0.1:" + f.localPort
|
||||||
|
remoteAddr := net.JoinHostPort(f.remoteHost, f.remotePort)
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", localAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to listen on %s: %w", localAddr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ready != nil {
|
||||||
|
close(ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
listener.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
slog.Info("Port forwarder active", "local", localAddr, "remote", remoteAddr)
|
||||||
|
|
||||||
|
for {
|
||||||
|
localConn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
slog.Error("Accept failed", "error", err)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
go f.handleConnection(ctx, localConn, remoteAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Forwarder) handleConnection(ctx context.Context, localConn net.Conn, remoteAddr string) {
|
||||||
|
defer localConn.Close()
|
||||||
|
|
||||||
|
_, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
remoteConn, err := f.sshClient.Dial("tcp", remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to dial remote via SSH", "target", remoteAddr, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer remoteConn.Close()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = io.Copy(localConn, remoteConn)
|
||||||
|
// localConn.SetWriteDeadline(time.Now())
|
||||||
|
localConn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = io.Copy(remoteConn, localConn)
|
||||||
|
remoteConn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
package main
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -29,13 +30,69 @@ type ExcelEntry struct {
|
||||||
Tag string
|
Tag string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ExportSummary(ctx context.Context, filename string) error {
|
||||||
|
slog.Info(fmt.Sprintf("Starting export to '%s'...", filename))
|
||||||
|
|
||||||
|
currentYear := time.Now().Year()
|
||||||
|
location := time.Local
|
||||||
|
yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location)
|
||||||
|
yearEnd := yearStart.AddDate(1, 0, 0)
|
||||||
|
slog.Info(fmt.Sprintf("Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02")))
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, tag, start_time, end_time
|
||||||
|
FROM time_entries
|
||||||
|
WHERE start_time < ?
|
||||||
|
AND (end_time IS NULL OR end_time > ?)
|
||||||
|
ORDER BY start_time ASC;`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, yearEnd, yearStart)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to query entries for year export: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []TimeEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry TimeEntry
|
||||||
|
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
|
||||||
|
return fmt.Errorf("failed to scan entry row: %w", err)
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("error during export row iteration: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info(fmt.Sprintf("Found %d potentially relevant time entries.", len(entries)))
|
||||||
|
|
||||||
|
dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to aggregate entries for export: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
excelEntries := convertDailyToExcelEntries(dailySummaries)
|
||||||
|
|
||||||
|
if len(excelEntries) == 0 {
|
||||||
|
slog.Warn("No daily summaries generated for the export period.")
|
||||||
|
fmt.Println("No data available to generate the export for the specified period.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeExcelSheet(excelEntries, filename); err != nil {
|
||||||
|
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info(fmt.Sprintf("Successfully exported timetable to %s", filename))
|
||||||
|
fmt.Printf("Successfully exported timetable to %s\n", filename)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) {
|
func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd time.Time) (map[string]*DailySummary, error) {
|
||||||
dailyMap := make(map[string]*DailySummary)
|
dailyMap := make(map[string]*DailySummary)
|
||||||
location := yearStart.Location()
|
location := yearStart.Location()
|
||||||
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,14 +113,13 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.StartTime.IsZero() {
|
if entry.StartTime.IsZero() {
|
||||||
log.Printf("WARN: Skipping entry with zero start time (ID: %d)", entry.ID)
|
slog.Warn("Skipping entry with zero start time", "ID", entry.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := entry.StartTime.In(location)
|
startTime := entry.StartTime.In(location)
|
||||||
endTime := entry.EndTime.Time.In(location)
|
endTime := entry.EndTime.Time.In(location)
|
||||||
validEndTime := entry.EndTime.Valid
|
if !entry.EndTime.Valid {
|
||||||
if !validEndTime {
|
|
||||||
endTime = now
|
endTime = now
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +156,6 @@ 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)
|
|
||||||
loopTime = dayEnd
|
loopTime = dayEnd
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +188,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
|
||||||
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
|
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
|
||||||
summary.WorkEnd = entryEndTimeOnThisDayStr
|
summary.WorkEnd = entryEndTimeOnThisDayStr
|
||||||
}
|
}
|
||||||
|
|
||||||
if summary.Tag == "" || summary.Tag == "free" {
|
if summary.Tag == "" || summary.Tag == "free" {
|
||||||
summary.Tag = TagWork
|
summary.Tag = TagWork
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +195,6 @@ 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)
|
|
||||||
summary.WorkDuration += segmentDuration
|
summary.WorkDuration += segmentDuration
|
||||||
if summary.WorkStart == "" || timeStr < summary.WorkStart {
|
if summary.WorkStart == "" || timeStr < summary.WorkStart {
|
||||||
summary.WorkStart = timeStr
|
summary.WorkStart = timeStr
|
||||||
|
|
@ -158,7 +211,6 @@ func aggregateEntriesToDailySummaries(entries []TimeEntry, yearStart, yearEnd ti
|
||||||
summary.Tag = TagWork
|
summary.Tag = TagWork
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loopTime = dayEnd
|
loopTime = dayEnd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -231,23 +283,6 @@ func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []Excel
|
||||||
return excelEntries
|
return excelEntries
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatDuration(d time.Duration) string {
|
|
||||||
if d < 0 {
|
|
||||||
d = -d
|
|
||||||
sign := "-"
|
|
||||||
d = d.Round(time.Second)
|
|
||||||
h := int64(d.Hours())
|
|
||||||
m := int64(d.Minutes()) % 60
|
|
||||||
s := int64(d.Seconds()) % 60
|
|
||||||
return fmt.Sprintf("%s%02d:%02d:%02d", sign, h, m, s)
|
|
||||||
}
|
|
||||||
d = d.Round(time.Second)
|
|
||||||
h := int64(d.Hours())
|
|
||||||
m := int64(d.Minutes()) % 60
|
|
||||||
s := int64(d.Seconds()) % 60
|
|
||||||
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSollExcelTime(dayOfWeek string) any {
|
func getSollExcelTime(dayOfWeek string) any {
|
||||||
var sollString string
|
var sollString string
|
||||||
switch dayOfWeek {
|
switch dayOfWeek {
|
||||||
|
|
@ -261,7 +296,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 +306,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
|
||||||
f := excelize.NewFile()
|
f := excelize.NewFile()
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := f.Close(); err != nil {
|
if err := f.Close(); err != nil {
|
||||||
log.Printf("ERROR: Failed to close excel file handle: %v", err)
|
slog.Error("Failed to close excel file handle", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -289,7 +324,7 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
|
||||||
sheetName = "Sheet1"
|
sheetName = "Sheet1"
|
||||||
index, _ = f.GetSheetIndex(sheetName)
|
index, _ = f.GetSheetIndex(sheetName)
|
||||||
if index == -1 {
|
if index == -1 {
|
||||||
return fmt.Errorf("could not create or find sheet '%s' or 'Sheet1': %w", sheetName, err)
|
return fmt.Errorf("could not create sheet '%s': %w", sheetName, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
index = existingIndex
|
index = existingIndex
|
||||||
|
|
@ -328,6 +363,10 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
|
||||||
f.SetCellValue(sheetName, "N4", "Total")
|
f.SetCellValue(sheetName, "N4", "Total")
|
||||||
f.SetCellValue(sheetName, "O4", "")
|
f.SetCellValue(sheetName, "O4", "")
|
||||||
|
|
||||||
|
toExcelTime := func(t time.Time) float64 {
|
||||||
|
return float64(t.Hour())/24.0 + float64(t.Minute())/(24.0*60.0) + float64(t.Second())/(24.0*60.0*60.0)
|
||||||
|
}
|
||||||
|
|
||||||
timeStyleCode := "hh:mm"
|
timeStyleCode := "hh:mm"
|
||||||
timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode})
|
timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode})
|
||||||
dateStyleCode := "dd.mm.yyyy"
|
dateStyleCode := "dd.mm.yyyy"
|
||||||
|
|
@ -371,8 +410,8 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
|
||||||
if entry.WorkStart != "" && entry.WorkEnd != "" {
|
if entry.WorkStart != "" && entry.WorkEnd != "" {
|
||||||
startTime, _ := time.Parse("15:04:05", entry.WorkStart)
|
startTime, _ := time.Parse("15:04:05", entry.WorkStart)
|
||||||
endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
|
endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
|
||||||
startExcelTime := float64(startTime.Hour())/24.0 + float64(startTime.Minute())/(24.0*60.0) + float64(startTime.Second())/(24.0*60.0*60.0)
|
startExcelTime := toExcelTime(startTime)
|
||||||
endExcelTime := float64(endTime.Hour())/24.0 + float64(endTime.Minute())/(24.0*60.0) + float64(endTime.Second())/(24.0*60.0*60.0)
|
endExcelTime := toExcelTime(endTime)
|
||||||
if endExcelTime < startExcelTime {
|
if endExcelTime < startExcelTime {
|
||||||
endExcelTime += 1.0
|
endExcelTime += 1.0
|
||||||
}
|
}
|
||||||
|
|
@ -383,10 +422,10 @@ func writeExcelSheet(entries []ExcelEntry, name string) error {
|
||||||
f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle)
|
f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle)
|
||||||
|
|
||||||
f.SetCellFormula(sheetName, "G"+rowStr, fmt.Sprintf("E%d-D%d", row, row))
|
f.SetCellFormula(sheetName, "G"+rowStr, fmt.Sprintf("E%d-D%d", row, row))
|
||||||
f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle) // Saldo-Style für Dauer
|
f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle)
|
||||||
|
|
||||||
breakDur, _ := time.Parse("15:04:05", entry.BreakDuration)
|
breakDur, _ := time.Parse("15:04:05", entry.BreakDuration)
|
||||||
breakExcelTime := float64(breakDur.Hour())/24.0 + float64(breakDur.Minute())/(24.0*60.0) + float64(breakDur.Second())/(24.0*60.0*60.0)
|
breakExcelTime := toExcelTime(breakDur)
|
||||||
thirtyMinBreak := float64(30) / (24 * 60)
|
thirtyMinBreak := float64(30) / (24 * 60)
|
||||||
if breakExcelTime < thirtyMinBreak {
|
if breakExcelTime < thirtyMinBreak {
|
||||||
breakExcelTime = thirtyMinBreak
|
breakExcelTime = thirtyMinBreak
|
||||||
|
|
@ -442,11 +481,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)
|
||||||
}
|
}
|
||||||
376
internal/store/store.go
Normal file
376
internal/store/store.go
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TagWork = "work"
|
||||||
|
TagBreak = "break"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TimeEntry struct {
|
||||||
|
ID int64
|
||||||
|
Tag string
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime sql.NullTime
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
db *sql.DB
|
||||||
|
dbPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore() (*Store, error) {
|
||||||
|
dbPath, err := ensureDatabasePath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not determine database path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Using database at:", "path", dbPath)
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database '%s': %w", dbPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("failed to connect to database '%s': %w", dbPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrate(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("migration failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Store{db: db, dbPath: dbPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(db *sql.DB) error {
|
||||||
|
createTableSQL := `
|
||||||
|
CREATE TABLE IF NOT EXISTS time_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tag TEXT NOT NULL CHECK(tag <> ''),
|
||||||
|
start_time DATETIME NOT NULL,
|
||||||
|
end_time DATETIME NULL,
|
||||||
|
CHECK (end_time IS NULL OR end_time >= start_time)
|
||||||
|
);`
|
||||||
|
if _, err := db.Exec(createTableSQL); err != nil {
|
||||||
|
return fmt.Errorf("failed to create table 'time_entries': %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);`
|
||||||
|
if _, err := db.Exec(createIndexSQL); err != nil {
|
||||||
|
slog.Warn("Failed to create index on start_time:", "error", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDatabasePath() (string, error) {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("could not get user config dir: %w", err)
|
||||||
|
}
|
||||||
|
workConfigDir := filepath.Join(configDir, "work")
|
||||||
|
if err := os.MkdirAll(workConfigDir, 0o750); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err)
|
||||||
|
}
|
||||||
|
return filepath.Join(workConfigDir, "worktime.sqlite"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
if s.db != nil {
|
||||||
|
slog.Debug("Closing database connection", "path", s.dbPath)
|
||||||
|
return s.db.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) stopCurrentEntry(ctx context.Context, now time.Time) (bool, error) {
|
||||||
|
query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;`
|
||||||
|
result, err := s.db.ExecContext(ctx, query, now)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to execute stop current entry query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
return rowsAffected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) StartTracking(ctx context.Context, tag string) error {
|
||||||
|
if tag == "" {
|
||||||
|
return fmt.Errorf("cannot start tracking with an empty tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
stopped, err := s.stopCurrentEntry(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if stopped {
|
||||||
|
slog.Info("Stopped previous time entry.")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);`
|
||||||
|
_, err = s.db.ExecContext(ctx, query, tag, now)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err)
|
||||||
|
}
|
||||||
|
slog.Info(fmt.Sprintf("Started tracking: %s", tag))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) StopTracking(ctx context.Context) error {
|
||||||
|
now := time.Now()
|
||||||
|
stopped, err := s.stopCurrentEntry(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if stopped {
|
||||||
|
slog.Info(fmt.Sprintf("Stopped tracking at %s", now.Format(time.RFC3339)))
|
||||||
|
} else {
|
||||||
|
slog.Info("No active time entry found to stop.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) LogFullDay(ctx context.Context, tag string, date time.Time) error {
|
||||||
|
if tag == "" {
|
||||||
|
return fmt.Errorf("cannot log full day with an empty tag")
|
||||||
|
}
|
||||||
|
tag = strings.ToLower(tag)
|
||||||
|
location := date.Location()
|
||||||
|
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
||||||
|
dayEnd := dayStart.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
_, err := s.stopCurrentEntry(ctx, dayStart)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to stop current entry before logging full day", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
|
||||||
|
if _, err := tx.ExecContext(ctx, query, tag, dayStart, dayEnd); err != nil {
|
||||||
|
return fmt.Errorf("failed to insert full-day entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleCaser := cases.Title(language.English)
|
||||||
|
slog.Info(fmt.Sprintf("Successfully logged full day entry: Tag='%s', Date='%s'", tag, dayStart.Format("2006-01-02")))
|
||||||
|
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStart.Format("2006-01-02"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetEntriesInRange(ctx context.Context, start, end time.Time) ([]TimeEntry, error) {
|
||||||
|
if start.IsZero() || end.IsZero() || end.Before(start) {
|
||||||
|
return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, tag, start_time, end_time
|
||||||
|
FROM time_entries
|
||||||
|
WHERE start_time >= ? AND start_time < ?
|
||||||
|
ORDER BY start_time ASC;`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var entries []TimeEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var entry TimeEntry
|
||||||
|
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan entry row: %w", err)
|
||||||
|
}
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("error during row iteration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CalculateSummary(ctx context.Context, period string) (map[string]time.Duration, error) {
|
||||||
|
start, end := GetTimeRangeFromPeriod(period)
|
||||||
|
if start.IsZero() {
|
||||||
|
return nil, fmt.Errorf("invalid period string: '%s'", period)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, tag, start_time, end_time
|
||||||
|
FROM time_entries
|
||||||
|
WHERE (end_time IS NULL OR end_time > ?)
|
||||||
|
AND start_time < ?
|
||||||
|
ORDER BY start_time ASC;`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to query entries: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
summary := make(map[string]time.Duration)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var entry TimeEntry
|
||||||
|
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
effStart := entry.StartTime
|
||||||
|
if effStart.Before(start) {
|
||||||
|
effStart = start
|
||||||
|
}
|
||||||
|
effEnd := now
|
||||||
|
if entry.EndTime.Valid {
|
||||||
|
effEnd = entry.EndTime.Time
|
||||||
|
}
|
||||||
|
if effEnd.After(end) {
|
||||||
|
effEnd = end
|
||||||
|
}
|
||||||
|
|
||||||
|
if effEnd.After(effStart) {
|
||||||
|
summary[entry.Tag] += effEnd.Sub(effStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return summary, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ShowSummary(ctx context.Context, period string) error {
|
||||||
|
summary, err := s.CalculateSummary(ctx, period)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
start, _ := GetTimeRangeFromPeriod(period)
|
||||||
|
titlePeriod := period
|
||||||
|
if !start.IsZero() {
|
||||||
|
_, end := GetTimeRangeFromPeriod(period)
|
||||||
|
if period == ":day" || period == "today" {
|
||||||
|
titlePeriod = fmt.Sprintf("Today (%s)", start.Format("2006-01-02"))
|
||||||
|
} else if period == ":week" {
|
||||||
|
titlePeriod = fmt.Sprintf("Week starting %s", start.Format("Mon, 2006-01-02"))
|
||||||
|
} else if period == ":month" {
|
||||||
|
titlePeriod = fmt.Sprintf("Month %s", start.Format("January 2006"))
|
||||||
|
} else if period == ":year" {
|
||||||
|
titlePeriod = fmt.Sprintf("Year %d", start.Year())
|
||||||
|
} else if _, err := time.Parse("2006-01-02", period); err == nil {
|
||||||
|
titlePeriod = fmt.Sprintf("Day %s", start.Format("2006-01-02"))
|
||||||
|
} else {
|
||||||
|
titlePeriod = fmt.Sprintf("Period '%s' (%s to %s)", period, start.Format("2006-01-02"), end.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nTime Summary for %s\n", titlePeriod)
|
||||||
|
if len(summary) == 0 {
|
||||||
|
fmt.Println(" No recorded time entries for this period.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := make([]string, 0, len(summary))
|
||||||
|
for tag := range summary {
|
||||||
|
tags = append(tags, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleCaser := cases.Title(language.English)
|
||||||
|
totalDuration := time.Duration(0)
|
||||||
|
fmt.Println("------------------------------")
|
||||||
|
for _, tag := range tags {
|
||||||
|
duration := summary[tag]
|
||||||
|
fmt.Printf(" %-12s: %s\n", titleCaser.String(tag), formatDuration(duration))
|
||||||
|
totalDuration += duration
|
||||||
|
}
|
||||||
|
fmt.Println("------------------------------")
|
||||||
|
fmt.Printf(" Total : %s\n\n", formatDuration(totalDuration))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < 0 {
|
||||||
|
d = -d
|
||||||
|
sign := "-"
|
||||||
|
d = d.Round(time.Second)
|
||||||
|
h := int64(d.Hours())
|
||||||
|
m := int64(d.Minutes()) % 60
|
||||||
|
s := int64(d.Seconds()) % 60
|
||||||
|
return fmt.Sprintf("%s%02d:%02d:%02d", sign, h, m, s)
|
||||||
|
}
|
||||||
|
d = d.Round(time.Second)
|
||||||
|
h := int64(d.Hours())
|
||||||
|
m := int64(d.Minutes()) % 60
|
||||||
|
s := int64(d.Seconds()) % 60
|
||||||
|
return fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetTimeRangeFromPeriod(period string) (time.Time, time.Time) {
|
||||||
|
now := time.Now()
|
||||||
|
year, month, day := now.Date()
|
||||||
|
loc := now.Location()
|
||||||
|
|
||||||
|
normalizedPeriod := strings.ToLower(strings.TrimPrefix(period, ":"))
|
||||||
|
|
||||||
|
switch normalizedPeriod {
|
||||||
|
case "week":
|
||||||
|
weekday := now.Weekday()
|
||||||
|
daysToMonday := time.Duration(weekday - time.Monday)
|
||||||
|
if weekday == time.Sunday {
|
||||||
|
daysToMonday = 6
|
||||||
|
}
|
||||||
|
start := time.Date(year, month, day, 0, 0, 0, 0, loc).Add(-daysToMonday * 24 * time.Hour)
|
||||||
|
end := start.Add(7 * 24 * time.Hour)
|
||||||
|
return start, end
|
||||||
|
case "month":
|
||||||
|
start := time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
||||||
|
end := start.AddDate(0, 1, 0)
|
||||||
|
return start, end
|
||||||
|
case "year":
|
||||||
|
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
|
||||||
|
end := start.AddDate(1, 0, 0)
|
||||||
|
return start, end
|
||||||
|
case "day", "today":
|
||||||
|
start := time.Date(year, month, day, 0, 0, 0, 0, loc)
|
||||||
|
end := start.AddDate(0, 0, 1)
|
||||||
|
return start, end
|
||||||
|
default:
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", period, loc); err == nil {
|
||||||
|
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||||
|
end := start.AddDate(0, 0, 1)
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
slog.Warn(fmt.Sprintf("Unrecognized period string '%s'. Cannot calculate time range.", period))
|
||||||
|
return time.Time{}, time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DB() *sql.DB {
|
||||||
|
return s.db
|
||||||
|
}
|
||||||
40
main.go
40
main.go
|
|
@ -1,26 +1,50 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
configDir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting config dir: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
logFile := filepath.Join(configDir, "work", "workctl.log")
|
||||||
|
_ = os.MkdirAll(filepath.Dir(logFile), 0750)
|
||||||
|
|
||||||
|
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0666)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Failed to open log file: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
logger := slog.New(slog.NewTextHandler(file, nil))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
app, err := NewApp()
|
app, err := NewApp()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("ERROR: Unable to setup application: %v", err)
|
slog.Error("Unable to setup application", "error", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "Error setting up application: %v\n", 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)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if len(os.Args) > 1 {
|
if err := app.Execute(ctx); err != nil {
|
||||||
if err := app.setupCommands().Execute(); err != nil {
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
app.makeChoice()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
secrets.go
Normal file
28
secrets.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/zalando/go-keyring"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
serviceName = "workctl"
|
||||||
|
keySSHPassword = "ssh-password"
|
||||||
|
keyRDPPassword = "rdp-password"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getSecret(key string) (string, error) {
|
||||||
|
val, err := keyring.Get(serviceName, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSecret(key, value string) error {
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("secret cannot be empty")
|
||||||
|
}
|
||||||
|
return keyring.Set(serviceName, key, value)
|
||||||
|
}
|
||||||
19
ssh.go
19
ssh.go
|
|
@ -1,19 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SSHConnection struct {
|
|
||||||
client *ssh.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SSHConnection) Close() error {
|
|
||||||
if s.client != nil {
|
|
||||||
log.Println("DEBUG: Closing SSH client connection.")
|
|
||||||
return s.client.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
442
store.go
442
store.go
|
|
@ -1,442 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/text/cases"
|
|
||||||
"golang.org/x/text/language"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
TagWork = "work"
|
|
||||||
TagBreak = "break"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TimeEntry struct {
|
|
||||||
ID int64
|
|
||||||
Tag string
|
|
||||||
StartTime time.Time
|
|
||||||
EndTime sql.NullTime
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
dbPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTimeStore(cfg Config) (*TimeStore, error) {
|
|
||||||
dbPath, err := ensureDatabasePath(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not determine database path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("INFO: Using database at: %s", dbPath)
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", fmt.Sprintf("%s?_pragma=journal_mode(WAL)", dbPath))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open database '%s': %w", dbPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = db.Ping(); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, fmt.Errorf("failed to connect to database '%s': %w", dbPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
createTableSQL := `
|
|
||||||
CREATE TABLE IF NOT EXISTS time_entries (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
tag TEXT NOT NULL CHECK(tag <> ''), -- Stelle sicher, dass Tag nicht leer ist
|
|
||||||
start_time DATETIME NOT NULL,
|
|
||||||
end_time DATETIME NULL,
|
|
||||||
-- Optional: Stelle sicher, dass nur ein Eintrag NULL end_time haben kann (falls DB unterstützt)
|
|
||||||
-- UNIQUE (end_time) WHERE end_time IS NULL -- SQLite unterstützt dies nicht direkt
|
|
||||||
CHECK (end_time IS NULL OR end_time >= start_time) -- Endzeit muss nach Startzeit liegen
|
|
||||||
);`
|
|
||||||
if _, err = db.Exec(createTableSQL); err != nil {
|
|
||||||
db.Close()
|
|
||||||
return nil, fmt.Errorf("failed to create table 'time_entries': %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
createIndexSQL := `CREATE INDEX IF NOT EXISTS idx_time_entries_start_time ON time_entries (start_time);`
|
|
||||||
if _, err = db.Exec(createIndexSQL); err != nil {
|
|
||||||
log.Printf("WARN: Failed to create index on start_time: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &TimeStore{db: db, dbPath: dbPath}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureDatabasePath(_ Config) (string, error) {
|
|
||||||
configDir, err := os.UserConfigDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("could not get user config dir: %w", err)
|
|
||||||
}
|
|
||||||
workConfigDir := filepath.Join(configDir, "work")
|
|
||||||
dbPath := filepath.Join(workConfigDir, "worktime.sqlite")
|
|
||||||
|
|
||||||
if err := os.MkdirAll(workConfigDir, 0750); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create config directory '%s': %w", workConfigDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return dbPath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) Close() error {
|
|
||||||
if ts.db != nil {
|
|
||||||
log.Printf("INFO: Closing database connection to %s", ts.dbPath)
|
|
||||||
return ts.db.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) stopCurrentEntry(now time.Time) (bool, error) {
|
|
||||||
query := `UPDATE time_entries SET end_time = ? WHERE end_time IS NULL;`
|
|
||||||
result, err := ts.db.Exec(query, now)
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to execute stop current entry query: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsAffected, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to get affected rows after stopping entry: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if rowsAffected > 1 {
|
|
||||||
log.Printf("WARN: Stopped %d entries. Expected 0 or 1. Manual DB check might be needed.", rowsAffected)
|
|
||||||
}
|
|
||||||
return rowsAffected > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) StartTracking(tag string) error {
|
|
||||||
if tag == "" {
|
|
||||||
return fmt.Errorf("cannot start tracking with an empty tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
stopped, err := ts.stopCurrentEntry(now)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if stopped {
|
|
||||||
log.Println("INFO: Stopped previous time entry.")
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, NULL);`
|
|
||||||
_, err = ts.db.Exec(query, tag, now)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to start tracking tag '%s': %w", tag, err)
|
|
||||||
}
|
|
||||||
log.Printf("INFO: Started tracking: %s at %s", tag, now.Format(time.RFC3339))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) StopTracking() error {
|
|
||||||
now := time.Now()
|
|
||||||
stopped, err := ts.stopCurrentEntry(now)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if stopped {
|
|
||||||
log.Printf("INFO: Stopped tracking at %s", now.Format(time.RFC3339))
|
|
||||||
} else {
|
|
||||||
log.Println("INFO: No active time entry found to stop.")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) GetEntriesInRange(start, end time.Time) ([]TimeEntry, error) {
|
|
||||||
if start.IsZero() || end.IsZero() || end.Before(start) {
|
|
||||||
return nil, fmt.Errorf("invalid time range: start=%v, end=%v", start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT id, tag, start_time, end_time
|
|
||||||
FROM time_entries
|
|
||||||
WHERE start_time >= ? AND start_time < ?
|
|
||||||
ORDER BY start_time ASC;`
|
|
||||||
|
|
||||||
rows, err := ts.db.Query(query, start, end)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query entries in range [%v, %v): %w", start, end, err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var entries []TimeEntry
|
|
||||||
for rows.Next() {
|
|
||||||
var entry TimeEntry
|
|
||||||
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan entry row: %w", err)
|
|
||||||
}
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("error during row iteration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) CalculateSummary(period string) (map[string]time.Duration, error) {
|
|
||||||
start, end := getTimeRangeFromPeriod(period)
|
|
||||||
if start.IsZero() {
|
|
||||||
return nil, fmt.Errorf("invalid period string: '%s'", period)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT id, tag, start_time, end_time
|
|
||||||
FROM time_entries
|
|
||||||
WHERE (end_time IS NULL OR end_time > ?) -- Endet nach dem Start des Zeitraums
|
|
||||||
AND start_time < ? -- Beginnt vor dem Ende des Zeitraums
|
|
||||||
ORDER BY start_time ASC;`
|
|
||||||
|
|
||||||
rows, err := ts.db.Query(query, start, end)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query overlapping entries for range [%v, %v): %w", start, end, err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
summary := make(map[string]time.Duration)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var entry TimeEntry
|
|
||||||
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to scan overlapping entry row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
effectiveStart := entry.StartTime
|
|
||||||
if effectiveStart.Before(start) {
|
|
||||||
effectiveStart = start
|
|
||||||
}
|
|
||||||
|
|
||||||
effectiveEnd := entry.EndTime.Time
|
|
||||||
if !entry.EndTime.Valid {
|
|
||||||
effectiveEnd = now
|
|
||||||
}
|
|
||||||
|
|
||||||
if effectiveEnd.After(end) {
|
|
||||||
effectiveEnd = end
|
|
||||||
}
|
|
||||||
|
|
||||||
if effectiveEnd.After(effectiveStart) {
|
|
||||||
duration := effectiveEnd.Sub(effectiveStart)
|
|
||||||
summary[entry.Tag] += duration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = rows.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("error during overlapping row iteration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTimeRangeFromPeriod(period string) (time.Time, time.Time) {
|
|
||||||
now := time.Now()
|
|
||||||
year, month, day := now.Date()
|
|
||||||
loc := now.Location()
|
|
||||||
|
|
||||||
normalizedPeriod := strings.ToLower(strings.TrimPrefix(period, ":"))
|
|
||||||
|
|
||||||
switch normalizedPeriod {
|
|
||||||
case "week":
|
|
||||||
weekday := now.Weekday()
|
|
||||||
daysToMonday := time.Duration(weekday - time.Monday)
|
|
||||||
if weekday == time.Sunday {
|
|
||||||
daysToMonday = 6
|
|
||||||
}
|
|
||||||
start := time.Date(year, month, day, 0, 0, 0, 0, loc).Add(-daysToMonday * 24 * time.Hour)
|
|
||||||
end := start.Add(7 * 24 * time.Hour)
|
|
||||||
return start, end
|
|
||||||
case "month":
|
|
||||||
start := time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
|
||||||
end := start.AddDate(0, 1, 0)
|
|
||||||
return start, end
|
|
||||||
case "year":
|
|
||||||
start := time.Date(year, 1, 1, 0, 0, 0, 0, loc)
|
|
||||||
end := start.AddDate(1, 0, 0)
|
|
||||||
return start, end
|
|
||||||
case "day", "today":
|
|
||||||
start := time.Date(year, month, day, 0, 0, 0, 0, loc)
|
|
||||||
end := start.AddDate(0, 0, 1)
|
|
||||||
return start, end
|
|
||||||
default:
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02", period, loc); err == nil {
|
|
||||||
start := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
|
||||||
end := start.AddDate(0, 0, 1)
|
|
||||||
return start, end
|
|
||||||
}
|
|
||||||
log.Printf("WARN: Unrecognized period string '%s'. Cannot calculate time range.", period)
|
|
||||||
return time.Time{}, time.Time{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) ShowSummary(period string) error {
|
|
||||||
summary, err := ts.CalculateSummary(period)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error calculating summary for '%s': %w", period, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
start, _ := getTimeRangeFromPeriod(period)
|
|
||||||
titlePeriod := period
|
|
||||||
if !start.IsZero() {
|
|
||||||
_, end := getTimeRangeFromPeriod(period)
|
|
||||||
if period == ":day" || period == "today" {
|
|
||||||
titlePeriod = fmt.Sprintf("Today (%s)", start.Format("2006-01-02"))
|
|
||||||
} else if period == ":week" {
|
|
||||||
titlePeriod = fmt.Sprintf("Week starting %s", start.Format("Mon, 2006-01-02"))
|
|
||||||
} else if period == ":month" {
|
|
||||||
titlePeriod = fmt.Sprintf("Month %s", start.Format("January 2006"))
|
|
||||||
} else if period == ":year" {
|
|
||||||
titlePeriod = fmt.Sprintf("Year %d", start.Year())
|
|
||||||
} else if _, err := time.Parse("2006-01-02", period); err == nil {
|
|
||||||
titlePeriod = fmt.Sprintf("Day %s", start.Format("2006-01-02"))
|
|
||||||
} else {
|
|
||||||
titlePeriod = fmt.Sprintf("Period '%s' (%s to %s)", period, start.Format("2006-01-02"), end.Format("2006-01-02"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nTime Summary for %s\n", titlePeriod)
|
|
||||||
if len(summary) == 0 {
|
|
||||||
fmt.Println(" No recorded time entries for this period.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tags := make([]string, 0, len(summary))
|
|
||||||
for tag := range summary {
|
|
||||||
tags = append(tags, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
titleCaser := cases.Title(language.English)
|
|
||||||
totalDuration := time.Duration(0)
|
|
||||||
fmt.Println("------------------------------")
|
|
||||||
for _, tag := range tags {
|
|
||||||
duration := summary[tag]
|
|
||||||
fmt.Printf(" %-12s: %s\n", titleCaser.String(tag), formatDuration(duration))
|
|
||||||
totalDuration += duration
|
|
||||||
}
|
|
||||||
fmt.Println("------------------------------")
|
|
||||||
fmt.Printf(" Total : %s\n\n", formatDuration(totalDuration))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) ExportSummary(filename string) error {
|
|
||||||
log.Printf("INFO: Starting export to '%s'...", filename)
|
|
||||||
|
|
||||||
currentYear := time.Now().Year()
|
|
||||||
|
|
||||||
location := time.Local
|
|
||||||
yearStart := time.Date(currentYear, 1, 1, 0, 0, 0, 0, location)
|
|
||||||
yearEnd := yearStart.AddDate(1, 0, 0)
|
|
||||||
log.Printf("INFO: Exporting data for year %d (%s to %s)", currentYear, yearStart.Format("2006-01-02"), yearEnd.Format("2006-01-02"))
|
|
||||||
|
|
||||||
query := `
|
|
||||||
SELECT id, tag, start_time, end_time
|
|
||||||
FROM time_entries
|
|
||||||
WHERE start_time < ? -- Beginnt vor Anfang des nächsten Jahres
|
|
||||||
AND (end_time IS NULL OR end_time > ?) -- Endet nach Anfang des Jahres
|
|
||||||
ORDER BY start_time ASC;`
|
|
||||||
|
|
||||||
rows, err := ts.db.Query(query, yearEnd, yearStart)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to query entries for year export [%v, %v): %w", yearStart, yearEnd, err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var entries []TimeEntry
|
|
||||||
for rows.Next() {
|
|
||||||
var entry TimeEntry
|
|
||||||
if err := rows.Scan(&entry.ID, &entry.Tag, &entry.StartTime, &entry.EndTime); err != nil {
|
|
||||||
return fmt.Errorf("failed to scan entry row (ID: %d) for export: %w", entry.ID, err)
|
|
||||||
}
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
if err = rows.Err(); err != nil {
|
|
||||||
return fmt.Errorf("error during export row iteration: %w", err)
|
|
||||||
}
|
|
||||||
log.Printf("INFO: Found %d potentially relevant time entries for year %d.", len(entries), currentYear)
|
|
||||||
|
|
||||||
dailySummaries, err := aggregateEntriesToDailySummaries(entries, yearStart, yearEnd)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to aggregate entries for export: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
excelEntries := convertDailyToExcelEntries(dailySummaries)
|
|
||||||
|
|
||||||
if len(excelEntries) == 0 {
|
|
||||||
log.Println("WARN: No daily summaries generated for the export period.")
|
|
||||||
fmt.Println("No data available to generate the export for the specified period.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Printf("INFO: Generated %d daily entries for the Excel export.", len(excelEntries))
|
|
||||||
|
|
||||||
if err := writeExcelSheet(excelEntries, filename); err != nil { // Aufruf der geänderten Funktion
|
|
||||||
return fmt.Errorf("failed to write excel sheet '%s': %w", filename, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("INFO: Successfully exported timetable to %s", filename)
|
|
||||||
fmt.Printf("Successfully exported timetable to %s\n", filename)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ts *TimeStore) LogFullDay(tag string, date time.Time) error {
|
|
||||||
if tag == "" {
|
|
||||||
return fmt.Errorf("cannot log full day with an empty tag")
|
|
||||||
}
|
|
||||||
tag = strings.ToLower(tag) // Stelle sicher, dass der Tag klein geschrieben ist
|
|
||||||
|
|
||||||
location := date.Location() // Verwende die Zeitzone des übergebenen Datums
|
|
||||||
// Berechne Start (00:00:00 des Tages) und Ende (00:00:00 des nächsten Tages)
|
|
||||||
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
|
||||||
dayEnd := dayStart.Add(24 * time.Hour)
|
|
||||||
dayStr := dayStart.Format("2006-01-02")
|
|
||||||
|
|
||||||
log.Printf("INFO: Attempting to log '%s' for the full day %s", tag, dayStr)
|
|
||||||
|
|
||||||
// 1. Stoppe den aktuell laufenden Timer (falls vorhanden)
|
|
||||||
// Wir verwenden dayStart als Zeitpunkt für das Stoppen, um Konsistenz zu wahren
|
|
||||||
stopped, err := ts.stopCurrentEntry(dayStart)
|
|
||||||
if err != nil {
|
|
||||||
// Nur loggen, weitermachen. Der Nutzer will diesen Tag ja explizit setzen.
|
|
||||||
log.Printf("WARN: Failed to stop current entry before logging full day '%s': %v", tag, err)
|
|
||||||
} else if stopped {
|
|
||||||
log.Printf("INFO: Stopped active timer before logging '%s' for %s.", tag, dayStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := ts.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not begin transaction to log full day: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback() // Stellt sicher, dass bei Fehlern nichts gespeichert wird
|
|
||||||
|
|
||||||
query := `INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?);`
|
|
||||||
stmt, err := tx.Prepare(query)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not prepare statement to log full day: %w", err)
|
|
||||||
}
|
|
||||||
defer stmt.Close()
|
|
||||||
|
|
||||||
_, err = stmt.Exec(tag, dayStart, dayEnd)
|
|
||||||
if err != nil {
|
|
||||||
// Spezifischere Fehlermeldung, falls es UNIQUE Constraints gäbe
|
|
||||||
return fmt.Errorf("failed to insert full-day entry for tag '%s' on %s: %w", tag, dayStr, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transaktion erfolgreich abschließen
|
|
||||||
if err = tx.Commit(); err != nil {
|
|
||||||
return fmt.Errorf("failed to commit transaction for full-day entry: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
fmt.Printf("Successfully logged '%s' for %s.\n", titleCaser.String(tag), dayStr) // Benutzerfeedback
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue