work/cmd.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
}