diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81166e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/kcm* diff --git a/config.yml b/config.yml index ad26662..66bddea 100644 --- a/config.yml +++ b/config.yml @@ -8,6 +8,10 @@ logging: # Logging level. Can be 'debug', 'info', 'warning', 'error', or 'critical'. level: info + # Path to the log file. If not specified, the default will be ~/.local/share/kcm/kcm.log + # Logging to a file is only done when the watchdog is running as deamon. + # path: + clipboard: # Maximum number of items to keep in the clipboard history. max_items: 100 diff --git a/src/commands/cmd_clear.go b/src/commands/cmd_clear.go index c879fde..caef16e 100644 --- a/src/commands/cmd_clear.go +++ b/src/commands/cmd_clear.go @@ -1,8 +1,7 @@ package commands import ( - "fmt" - + "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/vleeuwenmenno/kcm/src/models" ) @@ -14,7 +13,7 @@ func NewClearCmd(history *models.History) *cobra.Command { Aliases: []string{"--clear"}, Run: func(cmd *cobra.Command, args []string) { history.Clear() - fmt.Println("Clipboard history cleared.") + log.Info().Msg("Clipboard history cleared") }, } } diff --git a/src/commands/cmd_copy.go b/src/commands/cmd_copy.go index 77cf3cf..aa1d0bf 100644 --- a/src/commands/cmd_copy.go +++ b/src/commands/cmd_copy.go @@ -1,9 +1,9 @@ package commands import ( - "fmt" "strconv" + "github.com/rs/zerolog/log" "github.com/spf13/cobra" "github.com/vleeuwenmenno/kcm/src/models" "golang.design/x/clipboard" @@ -18,22 +18,21 @@ func NewCopyCmd(history *models.History) *cobra.Command { Run: func(cmd *cobra.Command, args []string) { index, err := strconv.Atoi(args[0]) if err != nil || index < 1 { - fmt.Println("Invalid index. Use the number shown in 'kcm list'.") - return + log.Fatal().Err(err).Msg("Invalid index") } history.ReloadIfChanged() historyLen := len(history.Items) if index > historyLen { - fmt.Printf("Index out of range. There are only %d items.\n", historyLen) - return + log.Fatal().Int("index", index).Int("historyLen", historyLen).Msg("Index out of range") } item := history.Items[index-1] if err := clipboard.Init(); err != nil { - fmt.Println("Failed to initialize clipboard:", err) - return + log.Fatal().Err(err).Msg("Failed to initialize clipboard") } - clipboard.Write(item.DataType, item.Data) - fmt.Printf("Copied item %d to clipboard.\n", index) + + log.Info().Str("item", string(item.Data)).Msg("Active on the clipboard, you can now paste it...") + done := clipboard.Write(clipboard.FmtText, []byte(item.Data)) + <-done // Wait for clipboard write to complete }, } } diff --git a/src/commands/cmd_list.go b/src/commands/cmd_list.go index adc67fa..b33609a 100644 --- a/src/commands/cmd_list.go +++ b/src/commands/cmd_list.go @@ -8,13 +8,16 @@ import ( ) func NewListCmd(history *models.History) *cobra.Command { - return &cobra.Command{ + var noTrunc bool + cmd := &cobra.Command{ Use: "list", Short: "List clipboard history", Aliases: []string{"--list"}, Run: func(cmd *cobra.Command, args []string) { history.ReloadIfChanged() - history.List(os.Stdout) + history.List(os.Stdout, noTrunc) }, } + cmd.Flags().BoolVar(&noTrunc, "no-trunc", false, "Do not truncate clipboard entries") + return cmd } diff --git a/src/commands/cmd_search.go b/src/commands/cmd_search.go new file mode 100644 index 0000000..01f2b55 --- /dev/null +++ b/src/commands/cmd_search.go @@ -0,0 +1,40 @@ +package commands + +import ( + "regexp" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/vleeuwenmenno/kcm/src/models" + "golang.design/x/clipboard" +) + +// NewSearchCmd creates a new search command for history entries using regex +func NewSearchCmd(history *models.History) *cobra.Command { + return &cobra.Command{ + Use: "search [regex]", + Short: "Search clipboard history entries using a regex pattern", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + pattern := args[0] + re, err := regexp.Compile(pattern) + if err != nil { + log.Fatal().Err(err).Str("pattern", pattern).Msg("Invalid regex") + } + + found := false + for i, item := range history.Items { + // Only search text entries + if item.DataType == clipboard.FmtText { + if re.Match(item.Data) { + log.Info().Int("index", i).Str("match", string(item.Data)).Msg("Regex match found") + found = true + } + } + } + if !found { + log.Info().Str("pattern", pattern).Msg("No matches found") + } + }, + } +} diff --git a/src/commands/cmd_status.go b/src/commands/cmd_status.go new file mode 100644 index 0000000..ac89376 --- /dev/null +++ b/src/commands/cmd_status.go @@ -0,0 +1,30 @@ +package commands + +import ( + "os/exec" + "strings" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +// NewStatusCmd creates a new status command to check if the daemon is running. +func NewStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Check if the clipboard watcher daemon is running", + Run: func(cmd *cobra.Command, args []string) { + // Check for the process by name (simple approach) + out, err := exec.Command("ps", "-C", "kcm").Output() + if err != nil { + log.Fatal().Err(err).Msg("Failed to check kcm daemon status") + } + lines := strings.Split(string(out), "\n") + if len(lines) > 1 { + log.Info().Msg("kcm daemon is running.") + } else { + log.Warn().Msg("kcm daemon is not running, start a new one using `kcm watch`.") + } + }, + } +} diff --git a/src/commands/cmd_watch.go b/src/commands/cmd_watch.go index be9315a..e75bf44 100644 --- a/src/commands/cmd_watch.go +++ b/src/commands/cmd_watch.go @@ -1,20 +1,56 @@ package commands import ( + "os" "time" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/spf13/cobra" + "github.com/vleeuwenmenno/kcm/src/config" "github.com/vleeuwenmenno/kcm/src/models" "golang.design/x/clipboard" ) func NewWatchCmd(history *models.History) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: "watch", Short: "Watch clipboard and store history", Aliases: []string{"--watch"}, Run: func(cmd *cobra.Command, args []string) { + noDaemon, _ := cmd.Flags().GetBool("no-daemon") + if !noDaemon { + cfg, _ := config.LoadConfig() + + // Set log file path since we are daemonizing + if cfg.Logging.Path != "" { + file, err := os.OpenFile(cfg.Logging.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + log.Fatal().AnErr("err", err).Msg("Failed to open log file") + } + multi := zerolog.MultiLevelWriter(file, os.Stderr) + log.Logger = log.Output(multi) + } + + // Daemonize: fork and detach + execPath, err := os.Executable() + if err != nil { + log.Fatal().Err(err).Msg("Failed to get executable path for daemonizing") + } + if os.Getppid() != 1 { + attr := &os.ProcAttr{ + Files: []*os.File{os.Stdin, os.Stdout, os.Stderr}, + Env: os.Environ(), + } + proc, err := os.StartProcess(execPath, os.Args, attr) + if err != nil { + log.Fatal().Err(err).Msg("Failed to daemonize") + } + log.Info().Msgf("Daemon started with PID %d", proc.Pid) + os.Exit(0) + } + } + log.Info().Msg("Starting clipboard watcher (golang.design/x/clipboard)...") if err := clipboard.Init(); err != nil { @@ -61,4 +97,6 @@ func NewWatchCmd(history *models.History) *cobra.Command { } }, } + cmd.Flags().Bool("no-daemon", false, "Run in foreground (do not daemonize)") + return cmd } diff --git a/src/config/config.go b/src/config/config.go index 8bf2821..7619a2b 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -2,6 +2,8 @@ package config import ( "os" + "os/user" + "path/filepath" "gopkg.in/yaml.v3" ) @@ -10,12 +12,23 @@ type Config struct { Logging struct { Format string `yaml:"format"` Level string `yaml:"level"` + Path string `yaml:"path"` } `yaml:"logging"` Clipboard struct { MaxItems int `yaml:"max_items"` } `yaml:"clipboard"` } +func logFilePath() string { + usr, err := user.Current() + if err != nil { + return "./kcm.log" + } + dir := filepath.Join(usr.HomeDir, ".local", "share", "kcm") + os.MkdirAll(dir, 0700) + return filepath.Join(dir, "kcm.log") +} + func LoadConfig() (Config, string) { paths := []string{"/etc/kcm/config.yml", "./config.yml"} var cfg Config @@ -30,7 +43,9 @@ func LoadConfig() (Config, string) { return cfg, path } } + cfg.Logging.Format = "console" cfg.Logging.Level = "info" + cfg.Logging.Path = logFilePath() return cfg, "" } diff --git a/src/main.go b/src/main.go index c0cc193..7546de6 100644 --- a/src/main.go +++ b/src/main.go @@ -53,6 +53,8 @@ func main() { commands.NewListCmd(history), commands.NewClearCmd(history), commands.NewCopyCmd(history), + commands.NewSearchCmd(history), + commands.NewStatusCmd(), ) if err := rootCmd.Execute(); err != nil { diff --git a/src/models/history.go b/src/models/history.go index d43f508..b9eee51 100644 --- a/src/models/history.go +++ b/src/models/history.go @@ -2,7 +2,6 @@ package models import ( "encoding/gob" - "fmt" "io" "os" "os/user" @@ -66,8 +65,6 @@ func (h *History) Add(item HistoryItem) { } else { log. Info(). - Str("hash", itemHash). - Str("hash_existing", utils.HashBytes(existing.Data)). Str("timestamp", item.Timestamp.Format(time.RFC3339)). Msg("Duplicate detected: replaced previous entry with new timestamp.") } @@ -90,7 +87,7 @@ func (h *History) Clear() error { return h.Save() } -func (h *History) List(w io.Writer) { +func (h *History) List(w io.Writer, noTrunc bool) { h.mu.Lock() defer h.mu.Unlock() for i, item := range h.Items { @@ -98,7 +95,18 @@ func (h *History) List(w io.Writer) { if item.DataType == clipboard.FmtImage { typeStr = "image" } - fmt.Fprintf(w, "%d: [%s] %s @ %s\n", i+1, typeStr, utils.Summary(item.Data, item.DataType), item.Timestamp.Format(time.RFC3339)) + var msg string + if noTrunc { + msg = string(item.Data) + } else { + msg = utils.Summary(item.Data, item.DataType) + } + log. + Info(). + Int("index", i+1). + Str("type", typeStr). + Str("timestamp", item.Timestamp.Format(time.RFC3339)). + Msg(msg) } } diff --git a/src/utils/summary.go b/src/utils/summary.go index 612ffa0..89000d1 100644 --- a/src/utils/summary.go +++ b/src/utils/summary.go @@ -8,10 +8,17 @@ import ( func Summary(data []byte, format clipboard.Format) string { if format == clipboard.FmtText { - if len(data) > 40 { - return string(data[:40]) + "..." + // Remove newlines for summary + clean := make([]byte, 0, len(data)) + for _, b := range data { + if b != '\n' && b != '\r' { + clean = append(clean, b) + } } - return string(data) + if len(clean) > 40 { + return string(clean[:40]) + "..." + } + return string(clean) } if format == clipboard.FmtImage { return "[image] " + strconv.Itoa(len(data)) + " bytes"