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'.
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

View File

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

View File

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

View File

@ -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
}

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
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
}

View File

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

View File

@ -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 {

View File

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

View File

@ -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"