feat: Enhance clipboard manager with logging, search, and status commands
This commit is contained in:
parent
1dad8ca69b
commit
71c7dd060f
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
bin/kcm*
|
@ -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
|
||||
|
@ -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")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
40
src/commands/cmd_search.go
Normal file
40
src/commands/cmd_search.go
Normal 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")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
30
src/commands/cmd_status.go
Normal file
30
src/commands/cmd_status.go
Normal 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`.")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -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, ""
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user