464 lines
14 KiB
Go
464 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"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",
|
|
}
|
|
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{
|
|
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())
|
|
},
|
|
}
|
|
|
|
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{
|
|
Use: "stop",
|
|
Short: "Stop work: Stop time tracking, kill tunnels",
|
|
Long: "Stops the current time tracking entry and attempts to kill active SSH tunnels.",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
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.")
|
|
}
|
|
|
|
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.")
|
|
}
|
|
|
|
fmt.Println("Workday stop procedures finished.")
|
|
},
|
|
}
|
|
cmd.Flags().BoolVarP(&a.flags.WithoutTimew, "timew", "t", false, "Set this flag if you dont want to use Timewarrior Timestorage as well")
|
|
return cmd
|
|
}
|
|
|
|
func (a *App) trackCommand() *cobra.Command {
|
|
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"},
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
period := "today"
|
|
if len(args) > 0 {
|
|
period = args[0]
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
},
|
|
})
|
|
|
|
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
|
|
}
|