feat: Enhance clipboard manager with logging, search, and status commands

This commit is contained in:
Menno van Leeuwen 2025-05-20 21:51:39 +02:00
parent 1dad8ca69b
commit 71c7dd060f
Signed by: vleeuwenmenno
SSH Key Fingerprint: SHA256:OJFmjANpakwD3F2Rsws4GLtbdz1TJ5tkQF0RZmF0TRE
12 changed files with 169 additions and 23 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
bin/kcm*

View File

@ -8,6 +8,10 @@ logging:
# Logging level. Can be 'debug', 'info', 'warning', 'error', or 'critical'. # Logging level. Can be 'debug', 'info', 'warning', 'error', or 'critical'.
level: info 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: clipboard:
# Maximum number of items to keep in the clipboard history. # Maximum number of items to keep in the clipboard history.
max_items: 100 max_items: 100

View File

@ -1,8 +1,7 @@
package commands package commands
import ( import (
"fmt" "github.com/rs/zerolog/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/vleeuwenmenno/kcm/src/models" "github.com/vleeuwenmenno/kcm/src/models"
) )
@ -14,7 +13,7 @@ func NewClearCmd(history *models.History) *cobra.Command {
Aliases: []string{"--clear"}, Aliases: []string{"--clear"},
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
history.Clear() history.Clear()
fmt.Println("Clipboard history cleared.") log.Info().Msg("Clipboard history cleared")
}, },
} }
} }

View File

@ -1,9 +1,9 @@
package commands package commands
import ( import (
"fmt"
"strconv" "strconv"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/vleeuwenmenno/kcm/src/models" "github.com/vleeuwenmenno/kcm/src/models"
"golang.design/x/clipboard" "golang.design/x/clipboard"
@ -18,22 +18,21 @@ func NewCopyCmd(history *models.History) *cobra.Command {
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
index, err := strconv.Atoi(args[0]) index, err := strconv.Atoi(args[0])
if err != nil || index < 1 { if err != nil || index < 1 {
fmt.Println("Invalid index. Use the number shown in 'kcm list'.") log.Fatal().Err(err).Msg("Invalid index")
return
} }
history.ReloadIfChanged() history.ReloadIfChanged()
historyLen := len(history.Items) historyLen := len(history.Items)
if index > historyLen { if index > historyLen {
fmt.Printf("Index out of range. There are only %d items.\n", historyLen) log.Fatal().Int("index", index).Int("historyLen", historyLen).Msg("Index out of range")
return
} }
item := history.Items[index-1] item := history.Items[index-1]
if err := clipboard.Init(); err != nil { if err := clipboard.Init(); err != nil {
fmt.Println("Failed to initialize clipboard:", err) log.Fatal().Err(err).Msg("Failed to initialize clipboard")
return
} }
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
}, },
} }
} }

View File

@ -8,13 +8,16 @@ import (
) )
func NewListCmd(history *models.History) *cobra.Command { func NewListCmd(history *models.History) *cobra.Command {
return &cobra.Command{ var noTrunc bool
cmd := &cobra.Command{
Use: "list", Use: "list",
Short: "List clipboard history", Short: "List clipboard history",
Aliases: []string{"--list"}, Aliases: []string{"--list"},
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
history.ReloadIfChanged() 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
} }

View File

@ -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")
}
},
}
}

View File

@ -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`.")
}
},
}
}

View File

@ -1,20 +1,56 @@
package commands package commands
import ( import (
"os"
"time" "time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/vleeuwenmenno/kcm/src/config"
"github.com/vleeuwenmenno/kcm/src/models" "github.com/vleeuwenmenno/kcm/src/models"
"golang.design/x/clipboard" "golang.design/x/clipboard"
) )
func NewWatchCmd(history *models.History) *cobra.Command { func NewWatchCmd(history *models.History) *cobra.Command {
return &cobra.Command{ cmd := &cobra.Command{
Use: "watch", Use: "watch",
Short: "Watch clipboard and store history", Short: "Watch clipboard and store history",
Aliases: []string{"--watch"}, Aliases: []string{"--watch"},
Run: func(cmd *cobra.Command, args []string) { 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)...") log.Info().Msg("Starting clipboard watcher (golang.design/x/clipboard)...")
if err := clipboard.Init(); err != nil { 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
} }

View File

@ -2,6 +2,8 @@ package config
import ( import (
"os" "os"
"os/user"
"path/filepath"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -10,12 +12,23 @@ type Config struct {
Logging struct { Logging struct {
Format string `yaml:"format"` Format string `yaml:"format"`
Level string `yaml:"level"` Level string `yaml:"level"`
Path string `yaml:"path"`
} `yaml:"logging"` } `yaml:"logging"`
Clipboard struct { Clipboard struct {
MaxItems int `yaml:"max_items"` MaxItems int `yaml:"max_items"`
} `yaml:"clipboard"` } `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) { func LoadConfig() (Config, string) {
paths := []string{"/etc/kcm/config.yml", "./config.yml"} paths := []string{"/etc/kcm/config.yml", "./config.yml"}
var cfg Config var cfg Config
@ -30,7 +43,9 @@ func LoadConfig() (Config, string) {
return cfg, path return cfg, path
} }
} }
cfg.Logging.Format = "console" cfg.Logging.Format = "console"
cfg.Logging.Level = "info" cfg.Logging.Level = "info"
cfg.Logging.Path = logFilePath()
return cfg, "" return cfg, ""
} }

View File

@ -53,6 +53,8 @@ func main() {
commands.NewListCmd(history), commands.NewListCmd(history),
commands.NewClearCmd(history), commands.NewClearCmd(history),
commands.NewCopyCmd(history), commands.NewCopyCmd(history),
commands.NewSearchCmd(history),
commands.NewStatusCmd(),
) )
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {

View File

@ -2,7 +2,6 @@ package models
import ( import (
"encoding/gob" "encoding/gob"
"fmt"
"io" "io"
"os" "os"
"os/user" "os/user"
@ -66,8 +65,6 @@ func (h *History) Add(item HistoryItem) {
} else { } else {
log. log.
Info(). Info().
Str("hash", itemHash).
Str("hash_existing", utils.HashBytes(existing.Data)).
Str("timestamp", item.Timestamp.Format(time.RFC3339)). Str("timestamp", item.Timestamp.Format(time.RFC3339)).
Msg("Duplicate detected: replaced previous entry with new timestamp.") Msg("Duplicate detected: replaced previous entry with new timestamp.")
} }
@ -90,7 +87,7 @@ func (h *History) Clear() error {
return h.Save() return h.Save()
} }
func (h *History) List(w io.Writer) { func (h *History) List(w io.Writer, noTrunc bool) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
for i, item := range h.Items { for i, item := range h.Items {
@ -98,7 +95,18 @@ func (h *History) List(w io.Writer) {
if item.DataType == clipboard.FmtImage { if item.DataType == clipboard.FmtImage {
typeStr = "image" 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)
} }
} }

View File

@ -8,10 +8,17 @@ import (
func Summary(data []byte, format clipboard.Format) string { func Summary(data []byte, format clipboard.Format) string {
if format == clipboard.FmtText { if format == clipboard.FmtText {
if len(data) > 40 { // Remove newlines for summary
return string(data[:40]) + "..." 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 { if format == clipboard.FmtImage {
return "[image] " + strconv.Itoa(len(data)) + " bytes" return "[image] " + strconv.Itoa(len(data)) + " bytes"