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:
2025-05-20 17:52:58 +02:00
commit 1dad8ca69b
22 changed files with 990 additions and 0 deletions

20
src/commands/cmd_clear.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
}

View 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
View 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
View 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]"
}