457 lines
15 KiB
Go
457 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"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.CompletionOptions.DisableDefaultCmd = true
|
|
|
|
return rootCmd
|
|
}
|
|
|
|
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. The command blocks until this session ends.
|
|
Use --background (-b) to keep tunnels running in the background without auto-connecting. Press Ctrl+C to stop background tunnels.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
fmt.Println("Starting workday procedures...")
|
|
|
|
sshCon, err := a.connect()
|
|
if err != nil {
|
|
fmt.Printf("ERROR: Failed to start connections: %v\n", err)
|
|
return fmt.Errorf("connection setup failed: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
log.Println("INFO: Closing SSH connection to jump host (defer)...")
|
|
if err := sshCon.Close(); err != nil {
|
|
log.Printf("WARN: Error closing SSH connection in defer: %v", err)
|
|
} else {
|
|
log.Println("INFO: SSH connection closed via defer.")
|
|
}
|
|
}()
|
|
|
|
if a.flags.StartInBackground {
|
|
fmt.Println("\nINFO: Tunnels are active in background.")
|
|
fmt.Println(" Connect manually via SSH: ssh -p 2048 <user>@127.0.0.1")
|
|
fmt.Println(" Connect manually via RDP: xfreerdp /v:127.0.0.1:6000 ...")
|
|
fmt.Println("INFO: Press Ctrl+C to stop tunnels.")
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
<-sigChan
|
|
|
|
fmt.Println("\nINFO: Received interrupt signal. Shutting down background process...")
|
|
log.Println("INFO: Received signal, cleanup via defer sshCon.Close() will run.")
|
|
if err := a.timeStore.StopTracking(); err != nil {
|
|
log.Printf("WARN: Failed to stop time tracking: %v", err)
|
|
} else {
|
|
log.Println("INFO: Time tracking stopped.")
|
|
}
|
|
fmt.Println("INFO: Background shutdown complete.")
|
|
|
|
} else {
|
|
fmt.Println("INFO: Automatically connecting to workstation via SSH tunnel...")
|
|
a.connectToWorkstation()
|
|
|
|
fmt.Println("INFO: Workstation SSH session finished.")
|
|
log.Println("INFO: Foreground session ended, cleanup via defer sshCon.Close() will run.")
|
|
if err := a.timeStore.StopTracking(); err != nil {
|
|
log.Printf("WARN: Failed to stop time tracking: %v", err)
|
|
} else {
|
|
log.Println("INFO: Time tracking stopped.")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVarP(&a.flags.StartInBackground, "background", "b", false, "Run tunnels in the background instead of connecting immediately")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func (a *App) stopCommand() *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.",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
fmt.Println("Stopping workday procedures...")
|
|
if err := a.timeStore.StopTracking(); err != nil {
|
|
log.Printf("ERROR: Failed to stop time tracking: %v", err)
|
|
} else {
|
|
fmt.Println("Time tracking stopped.")
|
|
}
|
|
|
|
if err := a.killForwardings(); err != nil {
|
|
log.Printf("WARN: Could not kill all forwarding processes: %v", err)
|
|
} else {
|
|
fmt.Println("Attempted to stop SSH tunnels.")
|
|
}
|
|
|
|
fmt.Println("Workday stop procedures finished.")
|
|
},
|
|
}
|
|
}
|
|
|
|
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 := 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.timeStore.LogFullDay(tagLower, today); err != nil {
|
|
return fmt.Errorf("could not log '%s' for today: %w", tagLower, err)
|
|
}
|
|
return nil // Erfolg
|
|
|
|
default:
|
|
fmt.Printf("Attempting to start tracking interval '%s'...\n", tag)
|
|
if err := a.timeStore.StartTracking(tag); err != nil {
|
|
log.Printf("ERROR: Failed to start tracking '%s': %v", tag, err)
|
|
return fmt.Errorf("could not start tracking '%s': %w", tag, err)
|
|
}
|
|
return nil // Erfolg
|
|
}
|
|
},
|
|
}
|
|
|
|
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.timeStore.StartTracking(TagBreak); err != nil {
|
|
log.Printf("ERROR: 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"
|
|
log.Printf("INFO: No export name specified, using default: %s", filename)
|
|
}
|
|
fmt.Printf("Exporting yearly timetable to '%s'...\n", filename)
|
|
if err := a.timeStore.ExportSummary(filename); err != nil {
|
|
log.Printf("ERROR: 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.timeStore.ShowSummary("week"); err != nil {
|
|
log.Printf("ERROR: Failed to show week summary: %v", err)
|
|
}
|
|
} else if a.flags.ShowMonth {
|
|
fmt.Println("Showing monthly summary...")
|
|
if err := a.timeStore.ShowSummary("month"); err != nil {
|
|
log.Printf("ERROR: Failed to show month summary: %v", err)
|
|
}
|
|
} else {
|
|
fmt.Printf("Showing summary for period: %s...\n", period)
|
|
if err := a.timeStore.ShowSummary(period); err != nil {
|
|
log.Printf("ERROR: 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.timeStore.ExportSummary(filename); err != nil {
|
|
log.Printf("ERROR: 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)",
|
|
Long: "Establishes an SSH connection to the Jump Host and forwards local port 2048 to the workstation's SSH port (22). This command blocks.",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
a.connectToJump()
|
|
},
|
|
})
|
|
|
|
cmd.AddCommand(&cobra.Command{
|
|
Use: "workstation",
|
|
Short: "Connect to Workstation via SSH tunnel",
|
|
Long: "Establishes an SSH connection to the Workstation via the local tunnel on port 2048. Also sets up RDP tunnel on local port 6000. Requires the 'jump' tunnel to be active. This command blocks.",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
a.connectToWorkstation()
|
|
},
|
|
})
|
|
|
|
cmd.AddCommand(&cobra.Command{
|
|
Use: "rdp",
|
|
Short: "Start RDP session via tunnel",
|
|
Long: "Starts an RDP client (xfreerdp) connecting to localhost:6000. Requires an active tunnel forwarding this port to the workstation's RDP port (3389). This command blocks.",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
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",
|
|
Long: `Parses the output of 'timewarrior summary :year' (or similar) stored in a text file
|
|
and inserts the individual time intervals into the workctl SQLite database.
|
|
It expects the standard timewarrior summary format.
|
|
Example: workctl import-timew /path/to/timew-summary.txt`,
|
|
Args: cobra.ExactArgs(1),
|
|
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 {
|
|
log.Printf("ERROR: Import failed: %v", err)
|
|
return fmt.Errorf("import failed: %w", err)
|
|
}
|
|
|
|
fmt.Printf("Successfully imported %d time entries.\n", count)
|
|
log.Printf("INFO: 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.timeStore.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 current_date_str string
|
|
imported_count := 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, start_str, end_str string
|
|
has_date := false
|
|
|
|
if len(fields) >= 7 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 {
|
|
current_date_str = fields[1]
|
|
tag = fields[3]
|
|
start_str = fields[4]
|
|
end_str = fields[5]
|
|
has_date = true
|
|
} else if len(fields) >= 4 && strings.Contains(fields[1], ":") && strings.Contains(fields[2], ":") {
|
|
if current_date_str == "" {
|
|
log.Printf("WARN: Skipping line without preceding date: %s", line)
|
|
continue
|
|
}
|
|
tag = fields[0]
|
|
start_str = fields[1]
|
|
end_str = fields[2]
|
|
has_date = false
|
|
} else if len(fields) >= 6 && strings.Contains(fields[1], "-") && len(fields[1]) == 10 {
|
|
current_date_str = fields[1]
|
|
tag = fields[3]
|
|
start_str = fields[4]
|
|
end_str = fields[5]
|
|
has_date = true
|
|
if start_str == "0:00:00" && end_str == "0:00:00" {
|
|
start_time, err := time.ParseInLocation("2006-01-02", current_date_str, location)
|
|
if err != nil {
|
|
log.Printf("WARN: Skipping line with invalid date '%s': %v", current_date_str, err)
|
|
continue
|
|
}
|
|
end_time := start_time.Add(24 * time.Hour)
|
|
|
|
_, err = stmt.Exec(tag, start_time, end_time)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to insert full-day entry for %s (%s): %v", current_date_str, tag, err)
|
|
} else {
|
|
imported_count++
|
|
}
|
|
continue
|
|
}
|
|
|
|
} else {
|
|
log.Printf("WARN: Skipping unrecognized line format: %s", line)
|
|
continue
|
|
}
|
|
|
|
if end_str == "-" {
|
|
log.Printf("INFO: Skipping currently running entry: %s", line)
|
|
continue
|
|
}
|
|
|
|
start_datetime_str := current_date_str + " " + start_str
|
|
end_datetime_str := current_date_str + " " + end_str
|
|
|
|
start_time, err_start := time.ParseInLocation("2006-01-02 15:04:05", start_datetime_str, location)
|
|
end_time, err_end := time.ParseInLocation("2006-01-02 15:04:05", end_datetime_str, location)
|
|
|
|
if err_start != nil || err_end != nil {
|
|
log.Printf("WARN: Skipping line with invalid date/time format ('%s' / '%s'): %v / %v", start_datetime_str, end_datetime_str, err_start, err_end)
|
|
continue
|
|
}
|
|
|
|
if end_time.Before(start_time) {
|
|
if has_date {
|
|
log.Printf("WARN: End time is before start time on the same date line, skipping: %s", line)
|
|
continue
|
|
}
|
|
}
|
|
|
|
db_tag := strings.ToLower(tag)
|
|
switch db_tag {
|
|
case "work":
|
|
db_tag = TagWork
|
|
case "break":
|
|
db_tag = TagBreak
|
|
}
|
|
|
|
_, err = stmt.Exec(db_tag, start_time, end_time)
|
|
if err != nil {
|
|
log.Printf("ERROR: Failed to insert entry for %s (%s, %s -> %s): %v", current_date_str, db_tag, start_time.Format(time.RFC3339), end_time.Format(time.RFC3339), err)
|
|
} else {
|
|
imported_count++
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return imported_count, fmt.Errorf("failed to commit transaction: %w", err)
|
|
}
|
|
|
|
return imported_count, nil
|
|
}
|