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'.
|
# 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
|
||||||
|
@ -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")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
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
|
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
|
||||||
}
|
}
|
||||||
|
@ -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, ""
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user