Compare commits
No commits in common. "main" and "dev/refactor-from-functioning-state" have entirely different histories.
main
...
dev/refact
22 changed files with 782 additions and 2458 deletions
|
|
@ -1,32 +0,0 @@
|
|||
name: Go CI Pipeline
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
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: Install golangci-lint
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin latest
|
||||
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||
- name: Run golangci-lint
|
||||
run: golangci-lint run
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v ./...
|
||||
|
||||
- name: Build application
|
||||
run: go build -o workctl .
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
name: Release Builds
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: GoReleaser build
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go 1.24
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: 1.24
|
||||
id: go
|
||||
|
||||
- name: Unset GITHUB_TOKEN (if present)
|
||||
run: unset GITHUB_TOKEN
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@master
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEATOKEN }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,3 +1 @@
|
|||
work-config.toml
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
|
|
|||
118
.golangci.yml
118
.golangci.yml
|
|
@ -1,118 +0,0 @@
|
|||
version: "2"
|
||||
|
||||
linters:
|
||||
enable:
|
||||
# Core Go checks
|
||||
- govet
|
||||
- ineffassign
|
||||
- unused
|
||||
# Style & Complexity
|
||||
# - cyclop
|
||||
# - goconst
|
||||
# - gocritic
|
||||
# - whitespace
|
||||
# Bug Prevention
|
||||
- bodyclose
|
||||
- contextcheck
|
||||
- copyloopvar
|
||||
# - errorlint
|
||||
- nilerr
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
# - unparam
|
||||
# Security
|
||||
# - gosec
|
||||
# Test Helpers
|
||||
- testifylint
|
||||
- thelper
|
||||
- paralleltest
|
||||
|
||||
disable:
|
||||
# Optional: Deaktiviere zu strenge oder störende Linter
|
||||
- wsl
|
||||
- maintidx
|
||||
- nlreturn
|
||||
- errcheck
|
||||
- staticcheck
|
||||
# Weitere Linter nach Bedarf ausschalten
|
||||
|
||||
settings:
|
||||
cyclop:
|
||||
max-complexity: 15
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- experimental
|
||||
- opinionated
|
||||
- performance
|
||||
- style
|
||||
settings:
|
||||
captLocal:
|
||||
paramsOnly: true
|
||||
rangeValCopy:
|
||||
sizeThreshold: 32
|
||||
depguard:
|
||||
rules:
|
||||
main:
|
||||
files:
|
||||
- "$all"
|
||||
- '!$test'
|
||||
deny:
|
||||
- pkg: reflect
|
||||
desc: "Reflection is often unclear; consider alternatives."
|
||||
- pkg: "io/ioutil"
|
||||
desc: "Deprecated since Go 1.16; use 'os' or 'io' instead."
|
||||
varnamelen:
|
||||
min-name-length: 2
|
||||
|
||||
exclusions:
|
||||
generated: lax
|
||||
rules:
|
||||
- linters:
|
||||
- copyloopvar
|
||||
- dupl
|
||||
- errcheck
|
||||
- gocyclo
|
||||
- gosec
|
||||
- maintidx
|
||||
- unparam
|
||||
path: _test(ing)?\.go
|
||||
- linters:
|
||||
- gocritic
|
||||
path: _test\.go
|
||||
text: (unnamedResult|exitAfterDefer)
|
||||
- linters:
|
||||
- gosec
|
||||
text: 'G101:'
|
||||
paths:
|
||||
- zz_generated\..+\.go$
|
||||
- third_party/
|
||||
- internal/vendor/
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-use-default: false
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
# - gci
|
||||
- gofmt
|
||||
- goimports
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- blank
|
||||
- dot
|
||||
- prefix(github.com/your-org/your-repo) # Hier dein Modulname eintragen!
|
||||
gofmt:
|
||||
simplify: true
|
||||
goimports:
|
||||
local-prefixes:
|
||||
- github.com/your-org/your-repo # Hier dein Modulname eintragen!
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- zz_generated\..+\.go$
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
|
||||
gitea_urls:
|
||||
api: https://git.patanix.de/api/v1
|
||||
download: https://git.patanix.de
|
||||
|
||||
env_files:
|
||||
gitlab_token: ~/nope
|
||||
github_token: ~/nope
|
||||
|
||||
force_token: "gitea"
|
||||
|
||||
archives:
|
||||
- formats: [tar.gz]
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- title .Os }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
release:
|
||||
name_template: "{{.ProjectName}}-v{{.Version}} {{.Env.USER}}"
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
||||
Released by {{.Env.USER}}.
|
||||
75
README.md
75
README.md
|
|
@ -1,53 +1,60 @@
|
|||
# Work Control CLI (workctl)
|
||||
# Work Time Management and Remote Connection Tool
|
||||
|
||||
## Description
|
||||
|
||||
This Golang program is a versatile command-line tool designed to streamline common work-related tasks. It offers functions such as work time tracking (using an internal SQLite database), remote computer wake-up (Wake-on-LAN), and establishing SSH tunnels and RDP connections.
|
||||
This Golang program is a versatile tool for work time management and remote connections. It offers various functions such as starting and stopping work time tracking, displaying work time summaries, waking remote computers, and establishing SSH and RDP connections.
|
||||
|
||||
## Main Features
|
||||
|
||||
- **Work Time Tracking:** Start, stop, and track work time and breaks using an internal SQLite database (`~/.config/work/worktime.sqlite`). Summaries (daily, weekly, monthly) and yearly Excel export are available.
|
||||
- **Remote Computer Wake-up:** Wake remote computers using Wake-on-LAN, potentially via an SSH jump host.
|
||||
- **SSH Tunneling:** Establish SSH connections and set up port forwarding for accessing remote services (like SSH or RDP on a workstation) securely.
|
||||
- **RDP Connection:** Helper command to launch an RDP client (`xfreerdp`) through an established tunnel.
|
||||
- Work time tracking with `timew` (TimeWarrior)
|
||||
- Remote computer wake-up function (Wake-on-LAN)
|
||||
- SSH tunneling and port forwarding
|
||||
- RDP connection establishment
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go (Golang) installed (for building or development)
|
||||
- SSH client (`ssh`) installed and configured (keys recommended)
|
||||
- `lsof` command (for killing tunnels, usually pre-installed on Linux/macOS)
|
||||
- `wakeonlan` command installed _on the jump host_ if waking via jump host.
|
||||
- `xfreerdp` command (or another RDP client) installed for RDP connections.
|
||||
- Go (Golang) installed
|
||||
- TimeWarrior (`timew`) installed
|
||||
- SSH access to remote systems
|
||||
- xfreerdp for RDP connections
|
||||
|
||||
## Configuration
|
||||
|
||||
The program expects a configuration file at `~/.config/work/config.toml`. Create this directory and file if they don't exist.
|
||||
The program expects a configuration file with the following settings:
|
||||
|
||||
Example `config.toml`:
|
||||
- SSHHost, SSHPort, SSHUser
|
||||
- VardaHost, VardaUser
|
||||
- LouIP, LouHost, LouMac
|
||||
- RDPUser, RDPPassword
|
||||
|
||||
```toml
|
||||
# ~/.config/work/config.toml
|
||||
Make sure these configuration details are correctly set before running the program.
|
||||
|
||||
[default]
|
||||
# SSH connection details for the first hop (e.g., Jump Host or Gateway)
|
||||
SSH_USER = "your_ssh_user"
|
||||
SSH_HOST = "[jumphost.example.com](https://www.google.com/search?q=jumphost.example.com)"
|
||||
SSH_PORT = 22 # Optional, defaults to 22
|
||||
## Usage
|
||||
|
||||
# Optional: Details for a second SSH hop (if waking requires jumping)
|
||||
JUMP_USER = "user_on_jump_host" # User needed to run wakeonlan on jump host
|
||||
JUMP_HOST = "internal_host_reachable_from_jump" # Host from which wakeonlan is run
|
||||
The program provides various commands that can be invoked through a user interface or command line:
|
||||
|
||||
# Workstation details
|
||||
WORKSTATION_HOST = "workstation.internal.network" # Hostname/IP from Jump Host's perspective
|
||||
WORKSTATION_IP = "192.168.1.100" # IP for direct tunneling target
|
||||
WORKSTATION_MAC = "AA:BB:CC:DD:EE:FF" # MAC address for Wake-on-LAN
|
||||
WORKSTATION_USER = "your_workstation_ssh_user" # SSH user on the workstation
|
||||
- `Start`: Starts work time tracking and establishes connections
|
||||
- `stop Work`: Stops work time tracking
|
||||
- `start break` / `stop break`: Manages breaks
|
||||
- `show week summary` / `show month summary`: Displays work time summaries
|
||||
- `wake lou`: Wakes the remote computer "Lou"
|
||||
- `connect to varda` / `connect to lou`: Establishes connections to remote systems
|
||||
- `start rdp connection`: Initiates an RDP connection
|
||||
|
||||
# RDP connection details (used by the 'connect rdp' command)
|
||||
RDP_USER = "your_windows_domain\\your_rdp_user"
|
||||
RDP_PASSWORD = "your_rdp_password" # SECURITY RISK: Avoid storing passwords here. Consider alternatives.
|
||||
## Security Notes
|
||||
|
||||
# Optional: Specify database path explicitly
|
||||
# DATABASE_PATH = "/path/to/your/worktime.sqlite"
|
||||
```
|
||||
- Use secure passwords and SSH keys.
|
||||
- Check network security settings for port forwarding.
|
||||
- Be cautious when using `ssh.InsecureIgnoreHostKey()` in production environments.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues, check:
|
||||
|
||||
- Network connections and firewalls
|
||||
- SSH keys and permissions
|
||||
- Correct IP addresses and port numbers in the configuration
|
||||
|
||||
## Contribution
|
||||
|
||||
Contributions to improve this tool are welcome. Please create a pull request or report issues using the repository's Issues feature.
|
||||
|
|
|
|||
421
app.go
421
app.go
|
|
@ -1,381 +1,230 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sshPkg "golang.org/x/crypto/ssh"
|
||||
"workctl/internal/config"
|
||||
"workctl/internal/ssh"
|
||||
"workctl/internal/store"
|
||||
|
||||
"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 {
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
cfg Config
|
||||
flags Flags
|
||||
}
|
||||
|
||||
func NewApp() (*App, error) {
|
||||
cfg, err := config.Load()
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading config: %w", err)
|
||||
}
|
||||
|
||||
st, err := store.NewStore()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error initializing time store: %w", err)
|
||||
}
|
||||
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
store: st,
|
||||
flags: Flags{},
|
||||
cfg: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
if a.store != nil {
|
||||
return a.store.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) Execute(ctx context.Context) error {
|
||||
if len(os.Args) > 1 {
|
||||
return a.setupCommands().ExecuteContext(ctx)
|
||||
}
|
||||
return a.makeChoice(ctx)
|
||||
}
|
||||
|
||||
func (a *App) StartTracking(ctx context.Context, tag string) error {
|
||||
if err := a.store.StartTracking(ctx, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func (a *App) connect() {
|
||||
tw := NewTimeWarrior()
|
||||
tw.StartWork()
|
||||
a.wakeWorkstation()
|
||||
|
||||
sshCon, err := ssh.NewConnection(a.cfg.SSHUser, a.cfg.SSHHost, a.cfg.SSHPort, a.getSSHAuth())
|
||||
sshCon, err := a.newSSHConnection()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to establish primary SSH connection: %w", err)
|
||||
log.Fatalf("failed to establish ssh-connection: %v", err)
|
||||
}
|
||||
defer sshCon.Close()
|
||||
slog.Info("SSH connection established. Setting up tunnels...")
|
||||
|
||||
tunnelCtx, cancelTunnels := context.WithCancel(ctx)
|
||||
defer cancelTunnels()
|
||||
sshFowarder := NewPortForwarder(sshCon.client, "2048", "22", a.cfg.WorkstationIP)
|
||||
go sshFowarder.forward()
|
||||
|
||||
sshForwarder := ssh.NewForwarder(sshCon.Client, config.PortLocalSSH, config.PortRemoteSSH, a.cfg.WorkstationIP)
|
||||
rdpForwarder := ssh.NewForwarder(sshCon.Client, config.PortLocalRDP, config.PortRemoteRDP, a.cfg.WorkstationIP)
|
||||
rdpFowarder := NewPortForwarder(sshCon.client, "6000", "3389", a.cfg.WorkstationIP)
|
||||
go rdpFowarder.forward()
|
||||
|
||||
sshReady := make(chan struct{})
|
||||
rdpReady := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
if err := sshForwarder.Start(tunnelCtx, sshReady); err != nil {
|
||||
slog.Error("SSH forwarder stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
if err := rdpForwarder.Start(tunnelCtx, rdpReady); err != nil {
|
||||
slog.Error("RDP forwarder stopped", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("Waiting for tunnels to initialize...")
|
||||
|
||||
readyCtx, cancelReady := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancelReady()
|
||||
|
||||
select {
|
||||
case <-sshReady:
|
||||
slog.Debug("SSH Tunnel ready")
|
||||
case <-readyCtx.Done():
|
||||
return fmt.Errorf("timeout waiting for SSH tunnel readiness")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-rdpReady:
|
||||
slog.Debug("RDP Tunnel ready")
|
||||
case <-readyCtx.Done():
|
||||
return fmt.Errorf("timeout waiting for RDP tunnel readiness")
|
||||
}
|
||||
|
||||
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
|
||||
a.connectToWorkstation()
|
||||
}
|
||||
|
||||
func (a *App) runCommand(name string, args ...string) error {
|
||||
slog.Info("Executing command", "cmd", name, "args", args)
|
||||
func (a *App) makeSSHClient() *ssh.ClientConfig {
|
||||
keypath := os.ExpandEnv("$HOME/.ssh/hegenberg")
|
||||
keyBytes, err := os.ReadFile(keypath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read private key: %s", err)
|
||||
}
|
||||
|
||||
key, err := ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse private key: %s", err)
|
||||
}
|
||||
|
||||
return &ssh.ClientConfig{
|
||||
User: a.cfg.SSHUser,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.PublicKeys(key),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) runCommand(name string, args ...string) {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("Command failed", "cmd", name, "error", err)
|
||||
return err
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) makeChoice(ctx context.Context) error {
|
||||
var choice string
|
||||
func (a *App) wakeWorkstation() {
|
||||
sshCommand := fmt.Sprintf("ssh -tt -p %s %s@%s ssh -tt %s@%s \"wakeonlan %s && exit\"",
|
||||
fmt.Sprintf("%v", a.cfg.SSHPort),
|
||||
a.cfg.SSHUser,
|
||||
a.cfg.SSHHost,
|
||||
a.cfg.JumpUser,
|
||||
a.cfg.JumpHost,
|
||||
a.cfg.WorkstationMac)
|
||||
args := strings.Split(sshCommand, " ")
|
||||
log.Println(args)
|
||||
a.runCommand("ssh", args[1:]...)
|
||||
}
|
||||
|
||||
func (a *App) connectToJump() {
|
||||
sshCommand := fmt.Sprintf("ssh -tt -L 2048:%s:22 %s@%s",
|
||||
a.cfg.WorkstationHost,
|
||||
a.cfg.SSHUser,
|
||||
a.cfg.SSHHost)
|
||||
args := strings.Split(sshCommand, " ")
|
||||
a.runCommand("ssh", args[1:]...)
|
||||
}
|
||||
|
||||
func (a *App) connectToWorkstation() {
|
||||
sshCommand := fmt.Sprintf("ssh -tt -L 6000:%s:3389 -p 2048 %s@127.0.0.1",
|
||||
a.cfg.WorkstationHost,
|
||||
a.cfg.SSHUser)
|
||||
args := strings.Split(sshCommand, " ")
|
||||
a.runCommand("ssh", args[1:]...)
|
||||
}
|
||||
|
||||
func (a *App) startRDPConnection() {
|
||||
rdpCommand := fmt.Sprintf("xfreerdp /u:%s /p:%s /v:127.0.0.1:6000 /size:3000x1350",
|
||||
a.cfg.RDPUser,
|
||||
a.cfg.RDPPassword)
|
||||
a.runCommand("bash", "-c", rdpCommand)
|
||||
}
|
||||
|
||||
func (a *App) makeChoice() {
|
||||
var choice string
|
||||
tw := NewTimeWarrior()
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("What would you like to do?").
|
||||
Options(
|
||||
huh.NewOption("Start Work & Connect", "start work"),
|
||||
huh.NewOption("Start Work", "start work"),
|
||||
huh.NewOption("Stop Work", "stop work"),
|
||||
huh.NewOption("Start Break", "start break"),
|
||||
huh.NewOption("Stop Break", "stop break"),
|
||||
huh.NewOption("Show Today Summary", "show day summary"),
|
||||
huh.NewOption("Show Week Summary", "show week summary"),
|
||||
huh.NewOption("Show Month Summary", "show month summary"),
|
||||
huh.NewOption("Export Yearly Timetable", "export"),
|
||||
huh.NewOption("Connect to Jump Host (Tunnel)", "connect to jump"),
|
||||
huh.NewOption("Connect to Workstation (Tunnel)", "connect to workstation"),
|
||||
huh.NewOption("Start Break", "start break"),
|
||||
huh.NewOption("Stop Break", "stop break"),
|
||||
huh.NewOption("Export Timetable", "export"),
|
||||
huh.NewOption("Connect to Jump", "connect to jump"),
|
||||
huh.NewOption("Connect to Workstation", "connect to workstation"),
|
||||
huh.NewOption("Start RDP Connection", "start rdp connection"),
|
||||
huh.NewOption("Wake Workstation", "wake workstation"),
|
||||
huh.NewOption("Kill Active Tunnels", "kill tunnels"),
|
||||
huh.NewOption("Config: Set Secrets", "set secrets"),
|
||||
huh.NewOption("Exit", "exit"),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return nil
|
||||
err := form.Run()
|
||||
if err != nil {
|
||||
fmt.Println("Error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch choice {
|
||||
case "start work":
|
||||
return a.connect(ctx)
|
||||
a.connect()
|
||||
case "stop work":
|
||||
if err := a.StopTracking(ctx); err != nil {
|
||||
slog.Error("Failed to stop time tracking", "error", err)
|
||||
}
|
||||
_ = a.killForwardings()
|
||||
tw.StopWork()
|
||||
case "start break":
|
||||
if err := a.StartTracking(ctx, store.TagBreak); err != nil {
|
||||
slog.Error("Failed to start break", "error", err)
|
||||
}
|
||||
tw.StartBreak()
|
||||
case "stop break":
|
||||
if err := a.StartTracking(ctx, store.TagWork); err != nil {
|
||||
slog.Error("Failed to stop break", "error", err)
|
||||
}
|
||||
case "show day summary":
|
||||
_ = a.store.ShowSummary(ctx, "today")
|
||||
tw.StopBreak()
|
||||
case "show week summary":
|
||||
_ = a.store.ShowSummary(ctx, "week")
|
||||
tw.ShowSummary(":week")
|
||||
case "show month summary":
|
||||
_ = a.store.ShowSummary(ctx, "month")
|
||||
case "export":
|
||||
filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
|
||||
_ = a.store.ExportSummary(ctx, filename)
|
||||
tw.ShowSummary(":month")
|
||||
case "connect to jump":
|
||||
a.connectToJump()
|
||||
case "connect to workstation":
|
||||
a.connectToWorkstation()
|
||||
case "start rdp connection":
|
||||
a.startRDPConnection()
|
||||
case "wake workstation":
|
||||
a.wakeWorkstation()
|
||||
case "kill tunnels":
|
||||
_ = a.killForwardings()
|
||||
case "set secrets":
|
||||
fmt.Println("Please run 'workctl config set-secrets' directly from CLI.")
|
||||
case "exit":
|
||||
case "export":
|
||||
tw.ExportSummary(a.flags.ExportName)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) getSSHAuth() ssh.AuthMethod {
|
||||
keypath := os.ExpandEnv("$HOME/.ssh/hegenberg")
|
||||
keyBytes, err := os.ReadFile(keypath)
|
||||
if err != nil {
|
||||
fmt.Printf("unable to read private key: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if choice != "exit" && choice != "start work" {
|
||||
fmt.Println("\nPress Enter to continue...")
|
||||
fmt.Scanln()
|
||||
return a.makeChoice(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) getSSHAuth() sshPkg.AuthMethod {
|
||||
keyPath := os.ExpandEnv("$HOME/.ssh/hegenberg")
|
||||
keyBytes, err := os.ReadFile(keyPath)
|
||||
key, err := ssh.ParsePrivateKeyWithPassphrase(keyBytes, []byte(a.cfg.RDPPassword))
|
||||
if err != nil {
|
||||
slog.Error("Unable to read private key", "path", keyPath, "error", err)
|
||||
fmt.Printf("unable to parse privat key: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
key, err := sshPkg.ParsePrivateKey(keyBytes)
|
||||
return ssh.PublicKeys(key)
|
||||
}
|
||||
|
||||
func (a *App) newSSHConnection() (*SSHConnection, error) {
|
||||
config := &ssh.ClientConfig{
|
||||
User: a.cfg.SSHUser,
|
||||
Auth: []ssh.AuthMethod{a.getSSHAuth()},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%d", a.cfg.SSHHost, a.cfg.SSHPort), config)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
} else {
|
||||
slog.Error("Failed to parse private key", "error", err)
|
||||
return nil
|
||||
}
|
||||
return nil, fmt.Errorf("ssh dial failed: %w", err)
|
||||
}
|
||||
return sshPkg.PublicKeys(key)
|
||||
}
|
||||
|
||||
func (a *App) wakeWorkstation() {
|
||||
slog.Info("Attempting to wake workstation...")
|
||||
innerSSHCmd := fmt.Sprintf("ssh -tt %s@%s \"wakeonlan %s && echo 'Packet sent' && exit\"",
|
||||
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,
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
client.Close()
|
||||
return nil, fmt.Errorf("creating ssh session failed: %w", err)
|
||||
}
|
||||
_ = a.runCommand("ssh", args...)
|
||||
}
|
||||
|
||||
func (a *App) connectToJump() {
|
||||
args := []string{
|
||||
"-tt",
|
||||
"-L", fmt.Sprintf("%s:%s:%s", config.PortLocalSSH, a.cfg.WorkstationHost, config.PortRemoteSSH),
|
||||
"-p", fmt.Sprintf("%d", a.cfg.SSHPort),
|
||||
fmt.Sprintf("%s@%s", a.cfg.SSHUser, a.cfg.SSHHost),
|
||||
}
|
||||
_ = a.runCommand("ssh", args...)
|
||||
}
|
||||
|
||||
func (a *App) connectToWorkstation() {
|
||||
args := []string{
|
||||
"-tt",
|
||||
"-L", fmt.Sprintf("%s:%s:%s", config.PortLocalRDP, a.cfg.WorkstationHost, config.PortRemoteRDP),
|
||||
"-p", config.PortLocalSSH,
|
||||
fmt.Sprintf("%s@127.0.0.1", a.cfg.WorkstationUser),
|
||||
}
|
||||
_ = a.runCommand("ssh", args...)
|
||||
}
|
||||
|
||||
func (a *App) startRDPConnection() {
|
||||
args := []string{
|
||||
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...)
|
||||
return &SSHConnection{
|
||||
client: client,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) killForwardings() error {
|
||||
ports := []string{"2048", "6000"}
|
||||
killedSomething := false
|
||||
var lastErr error
|
||||
|
||||
slog.Info(fmt.Sprintf("Attempting to kill processes listening on ports: %v", strings.Join(ports, ", ")))
|
||||
|
||||
for _, port := range ports {
|
||||
cmd := exec.Command("lsof", "-i", "tcp:"+port, "-t")
|
||||
cmd := exec.Command("lsof", "-ti", "tcp:"+port)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 {
|
||||
slog.Info(fmt.Sprintf("No process found listening on port %s.", port))
|
||||
} else {
|
||||
slog.Warn(fmt.Sprintf("'lsof' command failed for port %s: %v", port, err))
|
||||
lastErr = fmt.Errorf("lsof failed for port %s: %w", port, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pids := strings.SplitSeq(strings.TrimSpace(string(output)), "\n")
|
||||
for pidStr := range pids {
|
||||
pid := strings.TrimSpace(pidStr)
|
||||
if pid == "" {
|
||||
continue
|
||||
}
|
||||
slog.Info(fmt.Sprintf("Found process PID %s on port %s. Attempting to kill...", pid, port))
|
||||
killCmd := exec.Command("kill", pid)
|
||||
if err := killCmd.Run(); err != nil {
|
||||
slog.Warn(fmt.Sprintf("Failed to kill PID %s (port %s): %v. Trying kill -9...", pid, port, err))
|
||||
forceKillCmd := exec.Command("kill", "-9", pid)
|
||||
if err := forceKillCmd.Run(); err != nil {
|
||||
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)
|
||||
} else {
|
||||
slog.Info(fmt.Sprintf("Force killed PID %s (port %s).", pid, port))
|
||||
killedSomething = true
|
||||
}
|
||||
} else {
|
||||
slog.Info(fmt.Sprintf("Killed PID %s (port %s).", pid, port))
|
||||
killedSomething = true
|
||||
}
|
||||
}
|
||||
pids := strings.Split(string(output), "\n")
|
||||
pid := strings.TrimSpace(pids[0])
|
||||
killCmd := exec.Command("kill", pid)
|
||||
killCmd.Run()
|
||||
}
|
||||
|
||||
if killedSomething {
|
||||
slog.Info("Finished attempting to kill forwarding processes.")
|
||||
} else {
|
||||
slog.Info("No forwarding processes found or killed.")
|
||||
}
|
||||
|
||||
return lastErr
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
454
cmd.go
454
cmd.go
|
|
@ -1,464 +1,76 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"workctl/internal/config"
|
||||
"workctl/internal/store"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func (a *App) setupCommands() *cobra.Command {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "workctl",
|
||||
Short: "Manage work time, connections, and tasks",
|
||||
Long: `workctl is a command-line interface to streamline common work-related tasks,
|
||||
including time tracking (using an internal SQLite database), remote connections (SSH, RDP),
|
||||
and other utilities.`,
|
||||
Version: "1.0.0-sqlite",
|
||||
Use: "work",
|
||||
Short: "Fast work interactions",
|
||||
Long: `A CLI tool to perform basic work cli tasks.`,
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(a.startCommand())
|
||||
rootCmd.AddCommand(a.stopCommand())
|
||||
rootCmd.AddCommand(a.showCommand())
|
||||
rootCmd.AddCommand(a.trackCommand())
|
||||
rootCmd.AddCommand(a.connectCommands())
|
||||
rootCmd.AddCommand(a.wakeCommand())
|
||||
rootCmd.AddCommand(a.importTimewarriorCommand())
|
||||
rootCmd.AddCommand(a.configCommand())
|
||||
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
|
||||
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 {
|
||||
cmd := &cobra.Command{
|
||||
return &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start work: Track time, WOL, setup tunnels, optionally connect or run in background",
|
||||
Long: `Starts time tracking, attempts WOL, sets up SSH tunnels.
|
||||
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.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return a.connect(cmd.Context())
|
||||
Short: "start work",
|
||||
Long: "command to start the work day",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
a.connect()
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (a *App) stopCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
return &cobra.Command{
|
||||
Use: "stop",
|
||||
Short: "Stop work: Stop time tracking, kill tunnels",
|
||||
Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.",
|
||||
Short: "stop work",
|
||||
Long: "command to stop the work day",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("Stopping workday procedures...")
|
||||
if err := a.StopTracking(cmd.Context()); err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to stop time tracking: %v", err))
|
||||
} else {
|
||||
fmt.Println("Time tracking stopped.")
|
||||
}
|
||||
|
||||
tw := NewTimeWarrior()
|
||||
tw.StopWork()
|
||||
if err := a.killForwardings(); err != nil {
|
||||
slog.Warn(fmt.Sprintf("Could not kill all forwarding processes: %v", err))
|
||||
} else {
|
||||
fmt.Println("Attempted to stop SSH tunnels.")
|
||||
log.Printf("error stoping port forwarding: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Workday stop procedures finished.")
|
||||
os.Exit(0)
|
||||
},
|
||||
}
|
||||
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 {
|
||||
cmd := &cobra.Command{
|
||||
Use: "track [tag]",
|
||||
Short: "Start tracking time with a tag, or log a full day tag",
|
||||
Long: `Starts a new time tracking interval with the specified tag (e.g., 'work', 'break', 'meeting').
|
||||
This automatically stops any currently running timer.
|
||||
If no tag is provided, it stops the current timer and starts 'work'.
|
||||
|
||||
If the provided tag is a special full-day tag ('uni', 'urlaub', 'feiertag', 'krank', 'free'),
|
||||
it will mark the *current day* with that tag instead of starting an interval timer.
|
||||
This also stops any currently running timer.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
tag := store.TagWork
|
||||
if len(args) > 0 {
|
||||
tag = args[0]
|
||||
}
|
||||
|
||||
if tag == "" {
|
||||
return fmt.Errorf("tag cannot be empty")
|
||||
}
|
||||
|
||||
tagLower := strings.ToLower(tag)
|
||||
|
||||
switch tagLower {
|
||||
case "uni", "urlaub", "feiertag", "krank", "free":
|
||||
today := time.Now()
|
||||
fmt.Printf("Logging '%s' for today (%s)...\n", tagLower, today.Format("2006-01-02"))
|
||||
if err := a.store.LogFullDay(cmd.Context(), tagLower, today); err != nil {
|
||||
return fmt.Errorf("could not log '%s' for today: %w", tagLower, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
fmt.Printf("Attempting to start tracking interval '%s'...\n", tag)
|
||||
if err := a.StartTracking(cmd.Context(), tag); err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to start tracking '%s': %v", tag, err))
|
||||
return fmt.Errorf("could not start tracking '%s': %w", tag, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "break",
|
||||
Short: "Start tracking 'break'",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
fmt.Println("Starting break...")
|
||||
if err := a.StartTracking(cmd.Context(), store.TagBreak); err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to start break tracking: %v", err))
|
||||
return fmt.Errorf("could not start break: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) showCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "show [period|export]",
|
||||
Short: "Show time summaries or export data",
|
||||
Long: `Shows time tracking summaries for different periods (day, week, month, year)
|
||||
or exports the yearly data to an Excel file.
|
||||
|
||||
Periods: today, day, week, month, year (or YYYY-MM-DD)
|
||||
Export: Use the --export flag or the 'export' subcommand.`,
|
||||
ValidArgs: []string{"day", "week", "month", "year", "today"},
|
||||
Use: "show",
|
||||
Short: "show timetracking",
|
||||
Long: "show different timetracking",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
period := "today"
|
||||
if len(args) > 0 {
|
||||
period = args[0]
|
||||
}
|
||||
|
||||
tw := NewTimeWarrior()
|
||||
if a.flags.ShowExport {
|
||||
filename := a.flags.ExportName
|
||||
if filename == "" || filename == "Arbeitszeiten.xlsx" {
|
||||
filename = "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
|
||||
slog.Info(fmt.Sprintf("No export name specified, using default: %s", filename))
|
||||
}
|
||||
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
|
||||
if err := a.store.ExportSummary(cmd.Context(), filename); err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
|
||||
fmt.Printf("Error: Could not export to '%s'.\n", filename)
|
||||
}
|
||||
} else if a.flags.ShowWeek {
|
||||
fmt.Println("Showing weekly summary...")
|
||||
if err := a.store.ShowSummary(cmd.Context(), "week"); err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to show week summary: %v", err))
|
||||
}
|
||||
} else if a.flags.ShowMonth {
|
||||
fmt.Println("Showing monthly summary...")
|
||||
if err := a.store.ShowSummary(cmd.Context(), "month"); err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to show month summary: %v", err))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Showing summary for period: %s...\n", period)
|
||||
if err := a.store.ShowSummary(cmd.Context(), period); err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to show summary for '%s': %v", period, err))
|
||||
}
|
||||
tw.ExportSummary(a.flags.ExportName)
|
||||
}
|
||||
if a.flags.ShowWeek {
|
||||
tw.ShowSummary(":week")
|
||||
}
|
||||
if a.flags.ShowMonth {
|
||||
tw.ShowSummary(":month")
|
||||
}
|
||||
os.Exit(0)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&a.flags.ShowWeek, "week", "w", false, "Show summary for the current week")
|
||||
cmd.Flags().BoolVarP(&a.flags.ShowMonth, "month", "m", false, "Show summary for the current month")
|
||||
cmd.Flags().BoolVarP(&a.flags.ShowExport, "export", "e", false, "Export yearly timetable to Excel")
|
||||
cmd.Flags().StringVarP(&a.flags.ExportName, "name", "n", "Arbeitszeiten_"+time.Now().Format("2006")+".xlsx", "Filename for the exported Excel table")
|
||||
|
||||
cmd.MarkFlagsMutuallyExclusive("week", "month", "export")
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "export [filename]",
|
||||
Short: "Export yearly timetable to Excel",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
filename := "Arbeitszeiten_" + time.Now().Format("2006") + ".xlsx"
|
||||
if len(args) > 0 {
|
||||
filename = args[0]
|
||||
}
|
||||
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
|
||||
if err := a.store.ExportSummary(cmd.Context(), filename); err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to export summary to '%s': %v", filename, err))
|
||||
fmt.Printf("Error: Could not export to '%s'.\n", filename)
|
||||
}
|
||||
},
|
||||
})
|
||||
cmd.Flags().BoolVarP(&a.flags.ShowWeek, "week", "w", false, "show timewarrior week summary")
|
||||
cmd.Flags().BoolVarP(&a.flags.ShowMonth, "month", "m", false, "show timewarrior month summary")
|
||||
cmd.Flags().BoolVarP(&a.flags.ShowExport, "export", "e", false, "export timewarrior timetable")
|
||||
cmd.Flags().StringVarP(&a.flags.ExportName, "name", "n", "Arbeitszeiten.xlsx", "name of exported excel table")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) wakeCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "wake",
|
||||
Short: "Wake the configured workstation via Wake-on-LAN",
|
||||
Long: "Sends a Wake-on-LAN magic packet to the workstation defined in the configuration, using an SSH jump host if configured.",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("Attempting to wake workstation...")
|
||||
a.wakeWorkstation()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) connectCommands() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "connect",
|
||||
Short: "Manage remote connections (SSH, RDP)",
|
||||
Long: "Provides subcommands to establish SSH tunnels and RDP connections.",
|
||||
}
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "jump",
|
||||
Short: "Connect to Jump Host (with tunnel to workstation)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
a.connectToJump()
|
||||
},
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "workstation",
|
||||
Short: "Connect to Workstation via SSH tunnel",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
a.connectToWorkstation()
|
||||
},
|
||||
})
|
||||
|
||||
cmd.AddCommand(&cobra.Command{
|
||||
Use: "rdp",
|
||||
Short: "Start RDP session via tunnel",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
a.startRDPConnection()
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) importTimewarriorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "import-timew [filepath]",
|
||||
Short: "Import time entries from 'timewarrior summary' output file",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filepath := args[0]
|
||||
fmt.Printf("Attempting to import timewarrior data from: %s\n", filepath)
|
||||
|
||||
count, err := a.runImport(filepath)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintf("Import failed: %v", err))
|
||||
return fmt.Errorf("import failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully imported %d time entries.\n", count)
|
||||
slog.Info(fmt.Sprintf("Successfully imported %d time entries from %s", count, filepath))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) runImport(filepath string) (int, error) {
|
||||
contentBytes, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("could not read file '%s': %w", filepath, err)
|
||||
}
|
||||
content := string(contentBytes)
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
tx, err := a.store.DB().Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("could not begin database transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT INTO time_entries (tag, start_time, end_time) VALUES (?, ?, ?)")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("could not prepare insert statement: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
var currentDateStr string
|
||||
importedCount := 0
|
||||
location := time.Local
|
||||
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || i < 1 || strings.HasPrefix(line, "Total") || strings.HasPrefix(line, "---") || strings.Contains(line, "Wk Date Day Tags") {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := strings.Fields(line)
|
||||
var tag, startStr, endStr string
|
||||
hasDate := false
|
||||
|
||||
if len(fields) >= 7 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 {
|
||||
currentDateStr = fields[1]
|
||||
tag = fields[3]
|
||||
startStr = fields[4]
|
||||
endStr = fields[5]
|
||||
hasDate = true
|
||||
} else if len(fields) >= 4 && strings.Contains(fields[1], ":") && strings.Contains(fields[2], ":") {
|
||||
if currentDateStr == "" {
|
||||
slog.Warn(fmt.Sprintf("Skipping line without preceding date: %s", line))
|
||||
continue
|
||||
}
|
||||
tag = fields[0]
|
||||
startStr = fields[1]
|
||||
endStr = fields[2]
|
||||
hasDate = false
|
||||
} else if len(fields) >= 6 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 {
|
||||
currentDateStr = fields[1]
|
||||
tag = fields[3]
|
||||
startStr = fields[4]
|
||||
endStr = fields[5]
|
||||
hasDate = true
|
||||
if startStr == "0:00:00" && endStr == "0:00:00" {
|
||||
startTime, err := time.ParseInLocation("2006-01-02", currentDateStr, location)
|
||||
if err != nil {
|
||||
slog.Warn(fmt.Sprintf("Skipping line with invalid date '%s': %v", currentDateStr, err))
|
||||
continue
|
||||
}
|
||||
endTime := startTime.Add(24 * time.Hour)
|
||||
|
||||
_, err = stmt.Exec(tag, startTime, endTime)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintf("Failed to insert full-day entry for %s (%s): %v", currentDateStr, tag, err))
|
||||
} else {
|
||||
importedCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
} else {
|
||||
slog.Warn(fmt.Sprintf("Skipping unrecognized line format: %s", line))
|
||||
continue
|
||||
}
|
||||
|
||||
if endStr == "-" {
|
||||
slog.Info(fmt.Sprintf("Skipping currently running entry: %s", line))
|
||||
continue
|
||||
}
|
||||
|
||||
startDatetimeStr := currentDateStr + " " + startStr
|
||||
endDatetimeStr := currentDateStr + " " + endStr
|
||||
|
||||
startTime, errStart := time.ParseInLocation("2006-01-02 15:04:05", startDatetimeStr, location)
|
||||
endTime, errEnd := time.ParseInLocation("2006-01-02 15:04:05", endDatetimeStr, location)
|
||||
|
||||
if errStart != nil || errEnd != nil {
|
||||
slog.Warn(fmt.Sprintf("Skipping line with invalid date/time format ('%s' / '%s'): %v / %v", startDatetimeStr, endDatetimeStr, errStart, errEnd))
|
||||
continue
|
||||
}
|
||||
|
||||
if endTime.Before(startTime) {
|
||||
if hasDate {
|
||||
slog.Warn(fmt.Sprintf("End time is before start time on the same date line, skipping: %s", line))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
dbTag := strings.ToLower(tag)
|
||||
switch dbTag {
|
||||
case "work":
|
||||
dbTag = store.TagWork
|
||||
case "break":
|
||||
dbTag = store.TagBreak
|
||||
}
|
||||
|
||||
_, err = stmt.Exec(dbTag, startTime, endTime)
|
||||
if err != nil {
|
||||
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 {
|
||||
importedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return importedCount, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return importedCount, nil
|
||||
}
|
||||
|
|
|
|||
55
config.go
Normal file
55
config.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
SSHUser string `mapstructure:"SSH_USER"`
|
||||
SSHHost string `mapstructure:"SSH_HOST"`
|
||||
JumpUser string `mapstructure:"JUMP_USER"`
|
||||
JumpHost string `mapstructure:"JUMP_HOST"`
|
||||
WorkstationHost string `mapstructure:"WORKSTATION_HOST"`
|
||||
WorkstationUser string `mapstructure:"WORKSTATION_USER"`
|
||||
WorkstationMac string `mapstructure:"WORKSTATION_MAC"`
|
||||
RDPUser string `mapstructure:"RDP_USER"`
|
||||
RDPPassword string `mapstructure:"RDP_PASSWORD"`
|
||||
WorkstationIP string `mapstructure:"WORKSTATION_IP"`
|
||||
SSHPort int `mapstructure:"SSH_PORT"`
|
||||
}
|
||||
|
||||
type Flags struct {
|
||||
ShowWeek bool
|
||||
ShowMonth bool
|
||||
ShowExport bool
|
||||
ExportName string
|
||||
}
|
||||
|
||||
func loadConfig() (Config, error) {
|
||||
var cfg Config
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
workConfigPath := filepath.Join(configPath, "work")
|
||||
configFile := filepath.Join(workConfigPath, "config.toml")
|
||||
|
||||
viper.SetConfigFile(configFile)
|
||||
viper.SetConfigType("toml")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AutomaticEnv()
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return cfg, fmt.Errorf("error reading config file: %w", err)
|
||||
}
|
||||
|
||||
if err := viper.UnmarshalKey("default", &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("error decoding config: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
58
forwarder.go
Normal file
58
forwarder.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type PortFowarder struct {
|
||||
sshCon *ssh.Client
|
||||
localPort string
|
||||
remotePort string
|
||||
remoteHost string
|
||||
}
|
||||
|
||||
func NewPortForwarder(sshCon *ssh.Client, localPort, remotePort, remoteHost string) *PortFowarder {
|
||||
return &PortFowarder{
|
||||
sshCon: sshCon,
|
||||
localPort: localPort,
|
||||
remotePort: remotePort,
|
||||
remoteHost: remoteHost,
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *PortFowarder) forward() error {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:"+pw.localPort)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Öffnen des lokalen Ports %s: %v", pw.localPort, err)
|
||||
return err
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
for {
|
||||
localConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Akzeptieren der Verbindung: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
remoteConn, err := pw.sshCon.Dial("tcp", pw.remoteHost+":"+pw.remotePort)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Verbinden zum Remote-Host %s:%s: %v", pw.remoteHost, pw.remotePort, err)
|
||||
localConn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
go pw.copyConn(localConn, remoteConn)
|
||||
go pw.copyConn(remoteConn, localConn)
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *PortFowarder) copyConn(dst, src net.Conn) {
|
||||
defer dst.Close()
|
||||
defer src.Close()
|
||||
io.Copy(dst, src)
|
||||
}
|
||||
94
go.mod
94
go.mod
|
|
@ -1,75 +1,61 @@
|
|||
module workctl
|
||||
module work
|
||||
|
||||
go 1.24.2
|
||||
go 1.23.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/text v0.33.0
|
||||
modernc.org/sqlite v1.43.0
|
||||
github.com/charmbracelet/huh v0.5.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/spf13/viper v1.19.0
|
||||
github.com/xuri/excelize/v2 v2.9.0
|
||||
golang.org/x/crypto v0.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
al.essio.dev/pkg/shellescape v1.5.1 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.3 // 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/catppuccin/go v0.2.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.19.0 // indirect
|
||||
github.com/charmbracelet/bubbletea v0.27.0 // indirect
|
||||
github.com/charmbracelet/lipgloss v0.13.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // 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/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/richardlehane/mscfb v1.0.5 // indirect
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/subosito/gotenv 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/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/zalando/go-keyring v0.2.6 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // 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
|
||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/net v0.30.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.19.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
|
|
|||
265
go.sum
265
go.sum
|
|
@ -1,5 +1,3 @@
|
|||
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/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
|
|
@ -8,74 +6,37 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
|
|||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/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/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/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/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/bubbles v0.19.0 h1:gKZkKXPP6GlDk6EcfujDK19PCQqRjaJZQ7QRERx1UF0=
|
||||
github.com/charmbracelet/bubbles v0.19.0/go.mod h1:WILteEqZ+krG5c3ntGEMeG99nCupcuIk7V0/zOP0tOA=
|
||||
github.com/charmbracelet/bubbletea v0.27.0 h1:Mznj+vvYuYagD9Pn2mY7fuelGvP0HAXtZYGgRBCbHvU=
|
||||
github.com/charmbracelet/bubbletea v0.27.0/go.mod h1:5MdP9XH6MbQkgGhnlxUqCNmBXf9I74KRQ8HIidRxV1Y=
|
||||
github.com/charmbracelet/huh v0.5.3 h1:3KLP4a/K1/S4dq4xFMTNMt3XWhgMl/yx8NYtygQ0bmg=
|
||||
github.com/charmbracelet/huh v0.5.3/go.mod h1:OZC3lshuF+/y8laj//DoZdFSHxC51OrtXLJI8xWVouQ=
|
||||
github.com/charmbracelet/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 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/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/ansi v0.2.2 h1:BC7xzaVpfWIYZRNE8NhO9zo8KA4eGUL6L/JWXDh3GF0=
|
||||
github.com/charmbracelet/x/ansi v0.2.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/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/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/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
|
||||
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
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/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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
|
|
@ -84,42 +45,33 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
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/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/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
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/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||
github.com/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/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/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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/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.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.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
|
||||
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
|
|
@ -129,136 +81,67 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
|||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
|
||||
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/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/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.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/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/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/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/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/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/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
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/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
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/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
|
||||
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.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
|
||||
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/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=
|
||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
|
||||
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/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/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
|
||||
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/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/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
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/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/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
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/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
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/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/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.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/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
|
||||
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
|
||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
|
||||
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/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/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/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||
modernc.org/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/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/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/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/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/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"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 {
|
||||
SSHUser string `mapstructure:"SSH_USER"`
|
||||
SSHPassword string `mapstructure:"SSH_PASSWORD"`
|
||||
SSHHost string `mapstructure:"SSH_HOST"`
|
||||
JumpUser string `mapstructure:"JUMP_USER"`
|
||||
JumpHost string `mapstructure:"JUMP_HOST"`
|
||||
WorkstationHost string `mapstructure:"WORKSTATION_HOST"`
|
||||
WorkstationUser string `mapstructure:"WORKSTATION_USER"`
|
||||
WorkstationMac string `mapstructure:"WORKSTATION_MAC"`
|
||||
RDPUser string `mapstructure:"RDP_USER"`
|
||||
RDPPassword string `mapstructure:"RDP_PASSWORD"`
|
||||
WorkstationIP string `mapstructure:"WORKSTATION_IP"`
|
||||
SSHPort int `mapstructure:"SSH_PORT"`
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
var cfg Config
|
||||
configPath, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("could not get user config dir: %w", err)
|
||||
}
|
||||
|
||||
workConfigPath := filepath.Join(configPath, "work")
|
||||
configFile := filepath.Join(workConfigPath, "config.toml")
|
||||
|
||||
if err := os.MkdirAll(workConfigPath, 0o750); err != nil {
|
||||
return cfg, fmt.Errorf("could not create config directory '%s': %w", workConfigPath, err)
|
||||
}
|
||||
|
||||
viper.SetConfigFile(configFile)
|
||||
viper.SetConfigType("toml")
|
||||
viper.AutomaticEnv()
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return cfg, fmt.Errorf("error reading config file '%s': %w", configFile, err)
|
||||
}
|
||||
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.Unmarshal(&cfg); err != nil {
|
||||
return cfg, fmt.Errorf("error decoding config from '%s': %w", configFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.SSHPort == 0 {
|
||||
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
|
||||
}
|
||||
|
||||
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 }
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
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,521 +0,0 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type DailySummary struct {
|
||||
Date string
|
||||
Day string
|
||||
WorkStart string
|
||||
WorkEnd string
|
||||
BreakDuration time.Duration
|
||||
WorkDuration time.Duration
|
||||
Tag string
|
||||
}
|
||||
|
||||
type ExcelEntry struct {
|
||||
Date string
|
||||
Day string
|
||||
WorkStart string
|
||||
WorkEnd string
|
||||
BreakDuration 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) {
|
||||
dailyMap := make(map[string]*DailySummary)
|
||||
location := yearStart.Location()
|
||||
now := time.Now().In(location)
|
||||
|
||||
currentDay := yearStart
|
||||
for currentDay.Before(yearEnd) {
|
||||
dayStr := currentDay.Format("2006-01-02")
|
||||
weekday := currentDay.Weekday()
|
||||
tag := ""
|
||||
if weekday == time.Saturday || weekday == time.Sunday {
|
||||
tag = "free"
|
||||
}
|
||||
|
||||
dailyMap[dayStr] = &DailySummary{
|
||||
Date: dayStr,
|
||||
Day: weekday.String()[:3],
|
||||
Tag: tag,
|
||||
}
|
||||
currentDay = currentDay.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
fullDayTags := make(map[string]string)
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.StartTime.IsZero() {
|
||||
slog.Warn("Skipping entry with zero start time", "ID", entry.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
startTime := entry.StartTime.In(location)
|
||||
endTime := entry.EndTime.Time.In(location)
|
||||
if !entry.EndTime.Valid {
|
||||
endTime = now
|
||||
}
|
||||
|
||||
if endTime.Before(yearStart) || startTime.After(yearEnd) {
|
||||
continue
|
||||
}
|
||||
|
||||
lowerTag := strings.ToLower(entry.Tag)
|
||||
isPotentiallyFullDaySpecialTag := false
|
||||
switch lowerTag {
|
||||
case "urlaub", "krank", "feiertag", "uni", "free":
|
||||
isPotentiallyFullDaySpecialTag = true
|
||||
}
|
||||
|
||||
if isPotentiallyFullDaySpecialTag {
|
||||
loopTimeForTag := startTime
|
||||
for loopTimeForTag.Before(endTime) || loopTimeForTag.Equal(endTime) {
|
||||
dayStr := loopTimeForTag.Format("2006-01-02")
|
||||
if _, exists := dailyMap[dayStr]; exists {
|
||||
existingTag := fullDayTags[dayStr]
|
||||
if shouldOverwriteTag(existingTag, lowerTag) {
|
||||
fullDayTags[dayStr] = lowerTag
|
||||
}
|
||||
}
|
||||
loopTimeForTag = time.Date(loopTimeForTag.Year(), loopTimeForTag.Month(), loopTimeForTag.Day(), 0, 0, 0, 0, location).Add(24 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
loopTime := startTime
|
||||
for loopTime.Before(endTime) {
|
||||
dayStr := loopTime.Format("2006-01-02")
|
||||
dayStart := time.Date(loopTime.Year(), loopTime.Month(), loopTime.Day(), 0, 0, 0, 0, location)
|
||||
dayEnd := dayStart.Add(24 * time.Hour)
|
||||
|
||||
summary, exists := dailyMap[dayStr]
|
||||
if !exists {
|
||||
loopTime = dayEnd
|
||||
continue
|
||||
}
|
||||
|
||||
segmentStart := loopTime
|
||||
segmentEnd := endTime
|
||||
if segmentEnd.After(dayEnd) {
|
||||
segmentEnd = dayEnd
|
||||
}
|
||||
|
||||
segmentDuration := segmentEnd.Sub(segmentStart)
|
||||
if segmentDuration <= 0 {
|
||||
loopTime = dayEnd
|
||||
continue
|
||||
}
|
||||
|
||||
timeStr := segmentStart.Format("15:04:05")
|
||||
|
||||
switch lowerTag {
|
||||
case TagWork:
|
||||
summary.WorkDuration += segmentDuration
|
||||
if summary.WorkStart == "" || timeStr < summary.WorkStart {
|
||||
summary.WorkStart = timeStr
|
||||
}
|
||||
entryEndTimeOnThisDay := endTime
|
||||
if !endTime.Truncate(24 * time.Hour).Equal(dayStart) {
|
||||
entryEndTimeOnThisDay = segmentEnd
|
||||
}
|
||||
entryEndTimeOnThisDayStr := entryEndTimeOnThisDay.Format("15:04:05")
|
||||
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
|
||||
summary.WorkEnd = entryEndTimeOnThisDayStr
|
||||
}
|
||||
if summary.Tag == "" || summary.Tag == "free" {
|
||||
summary.Tag = TagWork
|
||||
}
|
||||
|
||||
case TagBreak:
|
||||
summary.BreakDuration += segmentDuration
|
||||
default:
|
||||
summary.WorkDuration += segmentDuration
|
||||
if summary.WorkStart == "" || timeStr < summary.WorkStart {
|
||||
summary.WorkStart = timeStr
|
||||
}
|
||||
entryEndTimeOnThisDay := endTime
|
||||
if !endTime.Truncate(24 * time.Hour).Equal(dayStart) {
|
||||
entryEndTimeOnThisDay = segmentEnd
|
||||
}
|
||||
entryEndTimeOnThisDayStr := entryEndTimeOnThisDay.Format("15:04:05")
|
||||
if summary.WorkEnd == "" || entryEndTimeOnThisDayStr > summary.WorkEnd {
|
||||
summary.WorkEnd = entryEndTimeOnThisDayStr
|
||||
}
|
||||
if summary.Tag == "" || summary.Tag == "free" {
|
||||
summary.Tag = TagWork
|
||||
}
|
||||
}
|
||||
loopTime = dayEnd
|
||||
}
|
||||
}
|
||||
|
||||
for dayStr, specialTag := range fullDayTags {
|
||||
if summary, exists := dailyMap[dayStr]; exists {
|
||||
if shouldOverwriteTag(summary.Tag, specialTag) {
|
||||
summary.Tag = specialTag
|
||||
summary.WorkStart = ""
|
||||
summary.WorkEnd = ""
|
||||
summary.WorkDuration = 0
|
||||
summary.BreakDuration = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dailyMap, nil
|
||||
}
|
||||
|
||||
func shouldOverwriteTag(existingTag, newTag string) bool {
|
||||
if newTag != "" && (existingTag == "" || strings.ToLower(existingTag) == "free") {
|
||||
return true
|
||||
}
|
||||
if newTag == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
priority := map[string]int{
|
||||
"krank": 1,
|
||||
"feiertag": 1,
|
||||
"urlaub": 1,
|
||||
"uni": 2,
|
||||
"work": 3,
|
||||
"break": 99,
|
||||
"free": 100,
|
||||
}
|
||||
prioExisting, okExisting := priority[strings.ToLower(existingTag)]
|
||||
if !okExisting {
|
||||
prioExisting = 999
|
||||
}
|
||||
prioNew, okNew := priority[strings.ToLower(newTag)]
|
||||
if !okNew {
|
||||
prioNew = 999
|
||||
}
|
||||
|
||||
return prioNew < prioExisting || (prioNew == prioExisting && strings.ToLower(newTag) != "work")
|
||||
}
|
||||
|
||||
func convertDailyToExcelEntries(dailySummaries map[string]*DailySummary) []ExcelEntry {
|
||||
excelEntries := make([]ExcelEntry, 0, len(dailySummaries))
|
||||
|
||||
dates := make([]string, 0, len(dailySummaries))
|
||||
for d := range dailySummaries {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
sort.Strings(dates)
|
||||
|
||||
for _, dateStr := range dates {
|
||||
summary := dailySummaries[dateStr]
|
||||
entry := ExcelEntry{
|
||||
Date: summary.Date,
|
||||
Day: summary.Day,
|
||||
WorkStart: summary.WorkStart,
|
||||
WorkEnd: summary.WorkEnd,
|
||||
BreakDuration: formatDuration(summary.BreakDuration),
|
||||
Tag: summary.Tag,
|
||||
}
|
||||
excelEntries = append(excelEntries, entry)
|
||||
}
|
||||
return excelEntries
|
||||
}
|
||||
|
||||
func getSollExcelTime(dayOfWeek string) any {
|
||||
var sollString string
|
||||
switch dayOfWeek {
|
||||
case "Mon", "Tue", "Thu", "Fri":
|
||||
sollString = "08:00"
|
||||
case "Wed":
|
||||
sollString = "04:00"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
sollDur, err := time.Parse("15:04", sollString)
|
||||
if err != nil {
|
||||
slog.Error(fmt.Sprintf("Could not parse hardcoded soll string '%s': %v", sollString, err))
|
||||
return nil
|
||||
}
|
||||
return float64(sollDur.Hour())/24.0 + float64(sollDur.Minute())/(24.0*60.0)
|
||||
}
|
||||
|
||||
func writeExcelSheet(entries []ExcelEntry, name string) error {
|
||||
f := excelize.NewFile()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
slog.Error("Failed to close excel file handle", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sheetName := "Zeiten"
|
||||
if len(entries) > 0 {
|
||||
if t, err := time.Parse("2006-01-02", entries[0].Date); err == nil {
|
||||
sheetName = fmt.Sprintf("%d", t.Year())
|
||||
}
|
||||
}
|
||||
|
||||
index, err := f.NewSheet(sheetName)
|
||||
if err != nil {
|
||||
existingIndex, _ := f.GetSheetIndex(sheetName)
|
||||
if existingIndex == -1 {
|
||||
sheetName = "Sheet1"
|
||||
index, _ = f.GetSheetIndex(sheetName)
|
||||
if index == -1 {
|
||||
return fmt.Errorf("could not create sheet '%s': %w", sheetName, err)
|
||||
}
|
||||
} else {
|
||||
index = existingIndex
|
||||
}
|
||||
}
|
||||
|
||||
defaultSheetName := "Sheet1"
|
||||
defaultSheetIndex, _ := f.GetSheetIndex(defaultSheetName)
|
||||
if sheetName != defaultSheetName && defaultSheetIndex != -1 {
|
||||
f.DeleteSheet(defaultSheetName)
|
||||
}
|
||||
|
||||
f.SetCellValue(sheetName, "B1", "Arbeitszeiten "+sheetName)
|
||||
f.MergeCell(sheetName, "B1", "O1")
|
||||
|
||||
f.SetCellValue(sheetName, "B3", "Datum")
|
||||
f.SetCellValue(sheetName, "C3", "Tag")
|
||||
f.SetCellValue(sheetName, "D3", "Status / Zeit")
|
||||
f.MergeCell(sheetName, "D3", "E3")
|
||||
f.SetCellValue(sheetName, "G3", "Dauer")
|
||||
f.MergeCell(sheetName, "G3", "H3")
|
||||
f.SetCellValue(sheetName, "I3", "Pause")
|
||||
f.SetCellValue(sheetName, "J3", "Netto")
|
||||
f.SetCellValue(sheetName, "K3", "Soll")
|
||||
f.SetCellValue(sheetName, "L3", "Saldo")
|
||||
f.SetCellValue(sheetName, "N3", "Saldo Kumuliert")
|
||||
f.MergeCell(sheetName, "N3", "O3")
|
||||
|
||||
f.SetCellValue(sheetName, "D4", "von / Status")
|
||||
f.SetCellValue(sheetName, "E4", "bis")
|
||||
f.SetCellValue(sheetName, "G4", "brutto")
|
||||
f.SetCellValue(sheetName, "H4", "")
|
||||
f.SetCellValue(sheetName, "J4", "Ist (Netto)")
|
||||
f.SetCellValue(sheetName, "K4", "")
|
||||
f.SetCellValue(sheetName, "L4", "Tag")
|
||||
f.SetCellValue(sheetName, "N4", "Total")
|
||||
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"
|
||||
timeStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &timeStyleCode})
|
||||
dateStyleCode := "dd.mm.yyyy"
|
||||
dateStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &dateStyleCode})
|
||||
saldoStyleCode := "[h]:mm;[RED]-[h]:mm"
|
||||
saldoStyle, _ := f.NewStyle(&excelize.Style{CustomNumFmt: &saldoStyleCode})
|
||||
headerStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center"},
|
||||
})
|
||||
centerStyle, _ := f.NewStyle(&excelize.Style{Alignment: &excelize.Alignment{Horizontal: "center"}})
|
||||
|
||||
f.SetCellStyle(sheetName, "B3", "O4", headerStyle)
|
||||
f.SetCellStyle(sheetName, "B1", "O1", headerStyle)
|
||||
|
||||
startRow := 6
|
||||
for i, entry := range entries {
|
||||
row := startRow + i
|
||||
rowStr := fmt.Sprintf("%d", row)
|
||||
tagLower := strings.ToLower(entry.Tag)
|
||||
|
||||
dateValue, err := time.Parse("2006-01-02", entry.Date)
|
||||
if err == nil {
|
||||
f.SetCellValue(sheetName, "B"+rowStr, dateValue)
|
||||
f.SetCellStyle(sheetName, "B"+rowStr, "B"+rowStr, dateStyle)
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "B"+rowStr, entry.Date)
|
||||
}
|
||||
f.SetCellValue(sheetName, "C"+rowStr, entry.Day)
|
||||
|
||||
sollExcelTime := getSollExcelTime(entry.Day)
|
||||
if sollExcelTime != nil {
|
||||
f.SetCellValue(sheetName, "K"+rowStr, sollExcelTime)
|
||||
f.SetCellStyle(sheetName, "K"+rowStr, "K"+rowStr, timeStyle)
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "K"+rowStr, "")
|
||||
}
|
||||
|
||||
switch tagLower {
|
||||
case TagWork, "":
|
||||
if entry.WorkStart != "" && entry.WorkEnd != "" {
|
||||
startTime, _ := time.Parse("15:04:05", entry.WorkStart)
|
||||
endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
|
||||
startExcelTime := toExcelTime(startTime)
|
||||
endExcelTime := toExcelTime(endTime)
|
||||
if endExcelTime < startExcelTime {
|
||||
endExcelTime += 1.0
|
||||
}
|
||||
|
||||
f.SetCellValue(sheetName, "D"+rowStr, startExcelTime)
|
||||
f.SetCellStyle(sheetName, "D"+rowStr, "D"+rowStr, timeStyle)
|
||||
f.SetCellValue(sheetName, "E"+rowStr, endExcelTime)
|
||||
f.SetCellStyle(sheetName, "E"+rowStr, "E"+rowStr, timeStyle)
|
||||
|
||||
f.SetCellFormula(sheetName, "G"+rowStr, fmt.Sprintf("E%d-D%d", row, row))
|
||||
f.SetCellStyle(sheetName, "G"+rowStr, "H"+rowStr, saldoStyle)
|
||||
|
||||
breakDur, _ := time.Parse("15:04:05", entry.BreakDuration)
|
||||
breakExcelTime := toExcelTime(breakDur)
|
||||
thirtyMinBreak := float64(30) / (24 * 60)
|
||||
if breakExcelTime < thirtyMinBreak {
|
||||
breakExcelTime = thirtyMinBreak
|
||||
}
|
||||
f.SetCellValue(sheetName, "I"+rowStr, breakExcelTime)
|
||||
f.SetCellStyle(sheetName, "I"+rowStr, "I"+rowStr, timeStyle)
|
||||
|
||||
f.SetCellFormula(sheetName, "J"+rowStr, fmt.Sprintf("MAX(0, G%d-I%d)", row, row))
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
}
|
||||
|
||||
case "urlaub", "uni":
|
||||
text := ""
|
||||
if tagLower == "urlaub" {
|
||||
text = "Urlaub"
|
||||
} else {
|
||||
text = "Hochschule"
|
||||
}
|
||||
f.SetCellValue(sheetName, "D"+rowStr, text)
|
||||
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
|
||||
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
|
||||
|
||||
if sollExcelTime != nil {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime)
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
}
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
|
||||
case "feiertag", "krank":
|
||||
text := ""
|
||||
if tagLower == "feiertag" {
|
||||
text = "Feiertag"
|
||||
} else {
|
||||
text = "Krank"
|
||||
}
|
||||
f.SetCellValue(sheetName, "D"+rowStr, text)
|
||||
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
|
||||
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
|
||||
|
||||
if sollExcelTime != nil {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, sollExcelTime)
|
||||
} else {
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
}
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
|
||||
case "free":
|
||||
f.SetCellValue(sheetName, "D"+rowStr, "")
|
||||
f.MergeCell(sheetName, "D"+rowStr, "I"+rowStr)
|
||||
f.SetCellStyle(sheetName, "D"+rowStr, "I"+rowStr, centerStyle)
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
|
||||
default:
|
||||
f.SetCellValue(sheetName, "J"+rowStr, 0.0)
|
||||
f.SetCellStyle(sheetName, "J"+rowStr, "J"+rowStr, saldoStyle)
|
||||
}
|
||||
|
||||
f.SetCellFormula(sheetName, "L"+rowStr, fmt.Sprintf("J%d-K%d", row, row))
|
||||
f.SetCellStyle(sheetName, "L"+rowStr, "M"+rowStr, saldoStyle)
|
||||
|
||||
if i == 0 {
|
||||
f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("L%d", row))
|
||||
} else {
|
||||
prevSaldoTotalCell := fmt.Sprintf("N%d", row-1)
|
||||
f.SetCellFormula(sheetName, "N"+rowStr, fmt.Sprintf("%s+L%d", prevSaldoTotalCell, row))
|
||||
}
|
||||
f.SetCellStyle(sheetName, "N"+rowStr, "O"+rowStr, saldoStyle)
|
||||
}
|
||||
|
||||
f.SetColWidth(sheetName, "B", "B", 12)
|
||||
f.SetColWidth(sheetName, "C", "C", 5)
|
||||
f.SetColWidth(sheetName, "D", "E", 10)
|
||||
f.SetColWidth(sheetName, "F", "F", 2)
|
||||
f.SetColWidth(sheetName, "G", "H", 9)
|
||||
f.SetColWidth(sheetName, "I", "I", 9)
|
||||
f.SetColWidth(sheetName, "J", "J", 9)
|
||||
f.SetColWidth(sheetName, "K", "K", 9)
|
||||
f.SetColWidth(sheetName, "L", "M", 9)
|
||||
f.SetColWidth(sheetName, "N", "O", 10)
|
||||
|
||||
f.SetActiveSheet(index)
|
||||
if err := f.SaveAs(name); err != nil {
|
||||
return fmt.Errorf("failed to save excel file as '%s': %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
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
|
||||
}
|
||||
44
main.go
44
main.go
|
|
@ -1,50 +1,20 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
slog.Error("Unable to setup application", "error", err)
|
||||
fmt.Fprintf(os.Stderr, "Error setting up application: %v\n", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("unable to setup application: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := app.Close(); err != nil {
|
||||
slog.Error("Failed to close application resources", "error", err)
|
||||
if len(os.Args) > 1 {
|
||||
if err := app.setupCommands().Execute(); err != nil {
|
||||
log.Fatalf("error executing command: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := app.Execute(ctx); err != nil {
|
||||
os.Exit(1)
|
||||
} else {
|
||||
app.makeChoice()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||
}
|
||||
28
secrets.go
28
secrets.go
|
|
@ -1,28 +0,0 @@
|
|||
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)
|
||||
}
|
||||
18
ssh.go
Normal file
18
ssh.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package main
|
||||
|
||||
import "golang.org/x/crypto/ssh"
|
||||
|
||||
type SSHConnection struct {
|
||||
client *ssh.Client
|
||||
session *ssh.Session
|
||||
}
|
||||
|
||||
func (s *SSHConnection) Close() {
|
||||
if s.session != nil {
|
||||
s.session.Close()
|
||||
}
|
||||
|
||||
if s.client != nil {
|
||||
s.client.Close()
|
||||
}
|
||||
}
|
||||
321
timewarrior.go
Normal file
321
timewarrior.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
type TimeWarrior struct{}
|
||||
|
||||
func NewTimeWarrior() *TimeWarrior {
|
||||
return &TimeWarrior{}
|
||||
}
|
||||
|
||||
func (t *TimeWarrior) runCommand(args ...string) error {
|
||||
cmd := exec.Command("timew", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (t *TimeWarrior) StartWork() error {
|
||||
return t.runCommand("start", "work")
|
||||
}
|
||||
|
||||
func (t *TimeWarrior) StopWork() error {
|
||||
return t.runCommand("stop", "work")
|
||||
}
|
||||
|
||||
func (t *TimeWarrior) StartBreak() error {
|
||||
return t.runCommand("track", "break")
|
||||
}
|
||||
|
||||
func (t *TimeWarrior) StopBreak() error {
|
||||
return t.runCommand("track", "work")
|
||||
}
|
||||
|
||||
func (t *TimeWarrior) ShowSummary(period string) error {
|
||||
return t.runCommand("summary", period, "work")
|
||||
}
|
||||
|
||||
type TimeEntry struct {
|
||||
Week string
|
||||
Date string
|
||||
Day string
|
||||
Start string
|
||||
End string
|
||||
Duration string
|
||||
Tag string
|
||||
}
|
||||
|
||||
func (t *TimeWarrior) ExportSummary(name string) error {
|
||||
fmt.Println("Export Timetable")
|
||||
exportCommand := exec.Command("timew", "summary", ":year")
|
||||
output, err := exportCommand.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(string(output), "\n")
|
||||
if len(lines) > 2 {
|
||||
var entries []TimeEntry
|
||||
for _, line := range lines[3 : len(lines)-4] {
|
||||
words := strings.Fields(line)
|
||||
newLine := strings.Join(words, " ")
|
||||
parts := strings.Split(strings.TrimSpace(newLine), " ")
|
||||
entry := TimeEntry{}
|
||||
|
||||
switch len(parts) {
|
||||
case 4, 5:
|
||||
entry.Tag = parts[0]
|
||||
entry.Start = parts[1]
|
||||
entry.End = parts[2]
|
||||
entry.Duration = parts[3]
|
||||
case 7, 8:
|
||||
entry.Week = parts[0]
|
||||
entry.Date = parts[1]
|
||||
entry.Day = parts[2]
|
||||
entry.Tag = parts[3]
|
||||
entry.Start = parts[4]
|
||||
entry.End = parts[5]
|
||||
entry.Duration = parts[6]
|
||||
default:
|
||||
fmt.Println("Unknown length")
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
dailySummary := make(map[string]*DailySummary)
|
||||
var currentDate string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Date != "" {
|
||||
currentDate = entry.Date
|
||||
}
|
||||
|
||||
if currentDate == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := dailySummary[currentDate]; !exists {
|
||||
dailySummary[currentDate] = &DailySummary{
|
||||
Date: currentDate,
|
||||
Day: entry.Day,
|
||||
}
|
||||
}
|
||||
|
||||
summary := dailySummary[currentDate]
|
||||
|
||||
switch strings.ToLower(entry.Tag) {
|
||||
case "work":
|
||||
if summary.WorkStart == "" {
|
||||
summary.WorkStart = entry.Start
|
||||
}
|
||||
summary.WorkEnd = entry.End
|
||||
case "break":
|
||||
duration, _ := parseDuration(entry.Duration)
|
||||
summary.BreakDuration += duration
|
||||
case "uni", "free", "krank", "urlaub", "feiertag":
|
||||
summary.Tag = entry.Tag
|
||||
}
|
||||
}
|
||||
|
||||
var excelEntries []ExcelEntry
|
||||
for _, summary := range dailySummary {
|
||||
entry := ExcelEntry{
|
||||
Date: summary.Date,
|
||||
Day: summary.Day,
|
||||
WorkStart: summary.WorkStart,
|
||||
WorkEnd: summary.WorkEnd,
|
||||
BreakDuration: formatDuration(summary.BreakDuration),
|
||||
Tag: summary.Tag,
|
||||
}
|
||||
excelEntries = append(excelEntries, entry)
|
||||
}
|
||||
|
||||
for _, entry := range excelEntries {
|
||||
fmt.Printf("%+v\n", entry)
|
||||
}
|
||||
|
||||
err = writeExcelSheet(excelEntries, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fmt.Println("No Data")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type DailySummary struct {
|
||||
Date string
|
||||
Day string
|
||||
WorkStart string
|
||||
WorkEnd string
|
||||
BreakDuration time.Duration
|
||||
Tag string
|
||||
}
|
||||
|
||||
type ExcelEntry struct {
|
||||
Date string
|
||||
Day string
|
||||
WorkStart string
|
||||
WorkEnd string
|
||||
BreakDuration string
|
||||
Tag string
|
||||
}
|
||||
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 3 {
|
||||
return 0, fmt.Errorf("invalid duration format: %s", s)
|
||||
}
|
||||
|
||||
hours, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
minutes, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
seconds, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
duration, err := time.ParseDuration(fmt.Sprintf("%vh%vm%vs", hours, minutes, seconds))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
h := d / time.Hour
|
||||
d -= h * time.Hour
|
||||
m := d / time.Minute
|
||||
d -= m * time.Minute
|
||||
s := d / time.Second
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||
}
|
||||
|
||||
func writeExcelSheet(entries []ExcelEntry, name string) error {
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
dateI, _ := time.Parse("2006-01-02", entries[i].Date)
|
||||
dateJ, _ := time.Parse("2006-01-02", entries[j].Date)
|
||||
return dateI.Before(dateJ)
|
||||
})
|
||||
|
||||
sheetName := fmt.Sprint(time.Now().Year())
|
||||
f := excelize.NewFile()
|
||||
defer func() {
|
||||
if err := f.Close(); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
|
||||
index, err := f.NewSheet(sheetName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f.SetCellValue(sheetName, "B1", "Arbeitszeiten "+sheetName)
|
||||
f.SetCellValue(sheetName, "B3", "Datum")
|
||||
f.SetCellValue(sheetName, "D3", "Arbeitszeit")
|
||||
f.SetCellValue(sheetName, "G3", "Summe")
|
||||
f.SetCellValue(sheetName, "I3", "Pause")
|
||||
f.SetCellValue(sheetName, "J3", "Summe")
|
||||
f.SetCellValue(sheetName, "K3", "Soll")
|
||||
f.SetCellValue(sheetName, "L3", "Saldo")
|
||||
f.SetCellValue(sheetName, "N3", "Saldo")
|
||||
f.SetCellValue(sheetName, "D4", "von")
|
||||
f.SetCellValue(sheetName, "E4", "bis")
|
||||
f.SetCellValue(sheetName, "G4", "brutto")
|
||||
f.SetCellValue(sheetName, "J4", "netto")
|
||||
f.SetCellValue(sheetName, "L4", "Tag")
|
||||
f.SetCellValue(sheetName, "N4", "total")
|
||||
|
||||
strStyle := "hh:mm"
|
||||
timeStyle, err := f.NewStyle(&excelize.Style{
|
||||
CustomNumFmt: &strStyle,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for num, entry := range entries {
|
||||
var soll string
|
||||
switch entry.Day {
|
||||
case "Mon", "Tue":
|
||||
soll = "08:00"
|
||||
case "Wed":
|
||||
soll = "04:00"
|
||||
default:
|
||||
soll = ""
|
||||
}
|
||||
|
||||
row := fmt.Sprint(num + 6)
|
||||
|
||||
dateValue, _ := time.Parse("2006-01-02", entry.Date)
|
||||
f.SetCellValue(sheetName, "B"+row, dateValue)
|
||||
|
||||
if entry.Tag == "" {
|
||||
startTime, _ := time.Parse("15:04:05", entry.WorkStart)
|
||||
endTime, _ := time.Parse("15:04:05", entry.WorkEnd)
|
||||
|
||||
startExcel := float64(startTime.Hour())/24.0 + float64(startTime.Minute())/(24.0*60.0)
|
||||
endExcel := float64(endTime.Hour())/24.0 + float64(endTime.Minute())/(24.0*60.0)
|
||||
|
||||
f.SetCellValue(sheetName, "D"+row, startExcel)
|
||||
f.SetCellStyle(sheetName, "D"+row, "D"+row, timeStyle)
|
||||
f.SetCellValue(sheetName, "E"+row, endExcel)
|
||||
f.SetCellStyle(sheetName, "E"+row, "E"+row, timeStyle)
|
||||
|
||||
// Formeln setzen
|
||||
f.SetCellFormula(sheetName, "G"+row, fmt.Sprintf("E%d-D%d", num+6, num+6))
|
||||
f.SetCellStyle(sheetName, "G"+row, "G"+row, timeStyle)
|
||||
|
||||
f.SetCellValue(sheetName, "I"+row, entry.BreakDuration)
|
||||
f.SetCellFormula(sheetName, "J"+row, fmt.Sprintf("G%d-I%d", num+6, num+6))
|
||||
f.SetCellStyle(sheetName, "J"+row, "J"+row, timeStyle)
|
||||
|
||||
f.SetCellValue(sheetName, "K"+row, soll)
|
||||
f.SetCellStyle(sheetName, "K"+row, "K"+row, timeStyle)
|
||||
f.SetCellFormula(sheetName, "L"+row, fmt.Sprintf("J%d-K%d", num+6, num+6))
|
||||
f.SetCellStyle(sheetName, "L"+row, "L"+row, timeStyle)
|
||||
|
||||
} else {
|
||||
text := ""
|
||||
switch entry.Tag {
|
||||
case "uni":
|
||||
text = "Hochschule"
|
||||
case "urlaub":
|
||||
text = "Urlaub"
|
||||
case "feiertag":
|
||||
text = "Feiertag"
|
||||
default:
|
||||
text = ""
|
||||
}
|
||||
f.SetCellValue(sheetName, "D"+row, text)
|
||||
}
|
||||
f.SetCellFormula(sheetName, "N"+row, fmt.Sprintf("N%d+L%d", num+5, num+6))
|
||||
f.SetCellStyle(sheetName, "N"+row, "N"+row, timeStyle)
|
||||
}
|
||||
|
||||
f.SetActiveSheet(index)
|
||||
if err := f.SaveAs(name); err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue