feat: Implement clipboard manager commands and history management
- Added command to clear clipboard history (`cmd_clear.go`). - Implemented command to copy a history item back to the clipboard (`cmd_copy.go`). - Created command to list clipboard history (`cmd_list.go`). - Developed command to watch clipboard changes and store history (`cmd_watch.go`). - Introduced configuration loading for logging and clipboard settings (`config.go`). - Established main application logic with command registration and configuration handling (`main.go`). - Implemented history management with loading, saving, and clearing functionality (`history.go`). - Defined history item structure to store clipboard data (`history_item.go`). - Added utility functions for hashing data and summarizing clipboard content (`hash.go`, `summary.go`). - Updated dependencies in `go.sum`.
This commit is contained in:
20
src/commands/cmd_clear.go
Normal file
20
src/commands/cmd_clear.go
Normal file
@ -0,0 +1,20 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/vleeuwenmenno/kcm/src/models"
|
||||
)
|
||||
|
||||
func NewClearCmd(history *models.History) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "clear",
|
||||
Short: "Clear clipboard history",
|
||||
Aliases: []string{"--clear"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
history.Clear()
|
||||
fmt.Println("Clipboard history cleared.")
|
||||
},
|
||||
}
|
||||
}
|
39
src/commands/cmd_copy.go
Normal file
39
src/commands/cmd_copy.go
Normal file
@ -0,0 +1,39 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/vleeuwenmenno/kcm/src/models"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func NewCopyCmd(history *models.History) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "copy [index]",
|
||||
Short: "Copy a history item back to the clipboard",
|
||||
Aliases: []string{"--copy"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
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
|
||||
}
|
||||
history.ReloadIfChanged()
|
||||
historyLen := len(history.Items)
|
||||
if index > historyLen {
|
||||
fmt.Printf("Index out of range. There are only %d items.\n", historyLen)
|
||||
return
|
||||
}
|
||||
item := history.Items[index-1]
|
||||
if err := clipboard.Init(); err != nil {
|
||||
fmt.Println("Failed to initialize clipboard:", err)
|
||||
return
|
||||
}
|
||||
clipboard.Write(item.DataType, item.Data)
|
||||
fmt.Printf("Copied item %d to clipboard.\n", index)
|
||||
},
|
||||
}
|
||||
}
|
20
src/commands/cmd_list.go
Normal file
20
src/commands/cmd_list.go
Normal file
@ -0,0 +1,20 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/vleeuwenmenno/kcm/src/models"
|
||||
)
|
||||
|
||||
func NewListCmd(history *models.History) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List clipboard history",
|
||||
Aliases: []string{"--list"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
history.ReloadIfChanged()
|
||||
history.List(os.Stdout)
|
||||
},
|
||||
}
|
||||
}
|
64
src/commands/cmd_watch.go
Normal file
64
src/commands/cmd_watch.go
Normal file
@ -0,0 +1,64 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/vleeuwenmenno/kcm/src/models"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func NewWatchCmd(history *models.History) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "watch",
|
||||
Short: "Watch clipboard and store history",
|
||||
Aliases: []string{"--watch"},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
log.Info().Msg("Starting clipboard watcher (golang.design/x/clipboard)...")
|
||||
|
||||
if err := clipboard.Init(); err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to initialize clipboard")
|
||||
}
|
||||
|
||||
var (
|
||||
lastText string
|
||||
lastImage []byte
|
||||
)
|
||||
|
||||
for {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
textOut := clipboard.Read(clipboard.FmtText)
|
||||
imgOut := clipboard.Read(clipboard.FmtImage)
|
||||
|
||||
if len(textOut) > 0 {
|
||||
text := string(textOut)
|
||||
if text != lastText {
|
||||
item := models.HistoryItem{
|
||||
Data: []byte(text),
|
||||
DataType: 0, // 0 for text
|
||||
Timestamp: time.Now(),
|
||||
Pinned: false,
|
||||
}
|
||||
history.Add(item)
|
||||
lastText = text
|
||||
log.Info().Str("content", text).Msg("Text clipboard item added to history")
|
||||
}
|
||||
}
|
||||
|
||||
if len(imgOut) > 0 && (lastImage == nil || string(imgOut) != string(lastImage)) {
|
||||
item := models.HistoryItem{
|
||||
Data: imgOut,
|
||||
DataType: 1, // 1 for image/png
|
||||
Timestamp: time.Now(),
|
||||
Pinned: false,
|
||||
}
|
||||
history.Add(item)
|
||||
lastImage = imgOut
|
||||
log.Info().Msg("Image clipboard item added to history")
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
36
src/config/config.go
Normal file
36
src/config/config.go
Normal file
@ -0,0 +1,36 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Logging struct {
|
||||
Format string `yaml:"format"`
|
||||
Level string `yaml:"level"`
|
||||
} `yaml:"logging"`
|
||||
Clipboard struct {
|
||||
MaxItems int `yaml:"max_items"`
|
||||
} `yaml:"clipboard"`
|
||||
}
|
||||
|
||||
func LoadConfig() (Config, string) {
|
||||
paths := []string{"/etc/kcm/config.yml", "./config.yml"}
|
||||
var cfg Config
|
||||
for _, path := range paths {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer file.Close()
|
||||
d := yaml.NewDecoder(file)
|
||||
if err := d.Decode(&cfg); err == nil {
|
||||
return cfg, path
|
||||
}
|
||||
}
|
||||
cfg.Logging.Format = "console"
|
||||
cfg.Logging.Level = "info"
|
||||
return cfg, ""
|
||||
}
|
85
src/main.go
Normal file
85
src/main.go
Normal file
@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/vleeuwenmenno/kcm/src/commands"
|
||||
"github.com/vleeuwenmenno/kcm/src/config"
|
||||
"github.com/vleeuwenmenno/kcm/src/models"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, cfgPath := config.LoadConfig()
|
||||
|
||||
// Set log level
|
||||
level, err := zerolog.ParseLevel(cfg.Logging.Level)
|
||||
if err != nil {
|
||||
level = zerolog.InfoLevel
|
||||
}
|
||||
zerolog.SetGlobalLevel(level)
|
||||
|
||||
// Set log format
|
||||
if cfg.Logging.Format == "console" {
|
||||
zerolog.TimeFieldFormat = "[" + time.RFC3339 + "] - "
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
|
||||
} else {
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
}
|
||||
|
||||
log.Debug().Str("config", cfgPath).Msg("Loaded configuration")
|
||||
|
||||
history := &models.History{MaxItems: cfg.Clipboard.MaxItems}
|
||||
history.Load()
|
||||
|
||||
shouldReturn := handleDebugging()
|
||||
if shouldReturn {
|
||||
return
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "kcm",
|
||||
Short: "Clipboard Manager CLI",
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(
|
||||
commands.NewWatchCmd(history),
|
||||
commands.NewListCmd(history),
|
||||
commands.NewClearCmd(history),
|
||||
commands.NewCopyCmd(history),
|
||||
)
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Fatal().AnErr("err", err).Msg("Error executing command")
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
handleDebugging enables interactive debugging mode.
|
||||
If "debug" is the first argument, prompts for subcommand/args and injects them into os.Args.
|
||||
Returns true if debugging mode was triggered.
|
||||
*/
|
||||
func handleDebugging() bool {
|
||||
if len(os.Args) > 1 && os.Args[1] == "debug" {
|
||||
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||
log.Debug().Msg("Enter the subcommand and arguments you want to run: ")
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
command, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
log.Fatal().AnErr("err", err).Msg("Error reading input")
|
||||
return false
|
||||
}
|
||||
command = strings.TrimSpace(command)
|
||||
os.Args = append(os.Args[:1], strings.Split(command, " ")...)
|
||||
log.Debug().
|
||||
Str("input", command).
|
||||
Msg("Executing command")
|
||||
}
|
||||
return false
|
||||
}
|
107
src/models/history.go
Normal file
107
src/models/history.go
Normal file
@ -0,0 +1,107 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/vleeuwenmenno/kcm/src/utils"
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
type History struct {
|
||||
Items []HistoryItem
|
||||
mu sync.Mutex
|
||||
MaxItems int
|
||||
}
|
||||
|
||||
func historyFilePath() string {
|
||||
usr, err := user.Current()
|
||||
if err != nil {
|
||||
return "./history.gob"
|
||||
}
|
||||
dir := filepath.Join(usr.HomeDir, ".local", "share", "kcm")
|
||||
os.MkdirAll(dir, 0700)
|
||||
return filepath.Join(dir, "history.gob")
|
||||
}
|
||||
|
||||
func (h *History) Save() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
file, err := os.Create(historyFilePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
enc := gob.NewEncoder(file)
|
||||
return enc.Encode(h.Items)
|
||||
}
|
||||
|
||||
func (h *History) Load() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
file, err := os.Open(historyFilePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
dec := gob.NewDecoder(file)
|
||||
return dec.Decode(&h.Items)
|
||||
}
|
||||
|
||||
func (h *History) Add(item HistoryItem) {
|
||||
h.mu.Lock()
|
||||
// Prevent duplicates: remove any existing item with the same hash
|
||||
itemHash := utils.HashBytes(item.Data)
|
||||
var newItems []HistoryItem
|
||||
for _, existing := range h.Items {
|
||||
if utils.HashBytes(existing.Data) != itemHash {
|
||||
newItems = append(newItems, existing)
|
||||
} 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.")
|
||||
}
|
||||
}
|
||||
h.Items = newItems
|
||||
// Add the new item
|
||||
h.Items = append(h.Items, item)
|
||||
if h.MaxItems > 0 && len(h.Items) > h.MaxItems {
|
||||
over := len(h.Items) - h.MaxItems
|
||||
h.Items = h.Items[over:]
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.Save()
|
||||
}
|
||||
|
||||
func (h *History) Clear() error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.Items = nil
|
||||
return h.Save()
|
||||
}
|
||||
|
||||
func (h *History) List(w io.Writer) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for i, item := range h.Items {
|
||||
typeStr := "text"
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func (h *History) ReloadIfChanged() error {
|
||||
return h.Load()
|
||||
}
|
14
src/models/history_item.go
Normal file
14
src/models/history_item.go
Normal file
@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
type HistoryItem struct {
|
||||
Data []byte
|
||||
DataType clipboard.Format
|
||||
Timestamp time.Time
|
||||
Pinned bool
|
||||
}
|
11
src/utils/hash.go
Normal file
11
src/utils/hash.go
Normal file
@ -0,0 +1,11 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
)
|
||||
|
||||
// HashBytes returns a SHA256 hash of the given byte slice as a string.
|
||||
func HashBytes(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return string(h[:])
|
||||
}
|
20
src/utils/summary.go
Normal file
20
src/utils/summary.go
Normal file
@ -0,0 +1,20 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"golang.design/x/clipboard"
|
||||
)
|
||||
|
||||
func Summary(data []byte, format clipboard.Format) string {
|
||||
if format == clipboard.FmtText {
|
||||
if len(data) > 40 {
|
||||
return string(data[:40]) + "..."
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
if format == clipboard.FmtImage {
|
||||
return "[image] " + strconv.Itoa(len(data)) + " bytes"
|
||||
}
|
||||
return "[unknown format]"
|
||||
}
|
Reference in New Issue
Block a user