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:
Menno van Leeuwen 2025-05-20 17:52:58 +02:00
commit 1dad8ca69b
Signed by: vleeuwenmenno
SSH Key Fingerprint: SHA256:OJFmjANpakwD3F2Rsws4GLtbdz1TJ5tkQF0RZmF0TRE
22 changed files with 990 additions and 0 deletions

21
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/src/main.go",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"args": [
"debug",
],
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"yaml.schemas": {
"https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json": "**/docker/**/*.yml"
}
}

20
Makefile Normal file
View File

@ -0,0 +1,20 @@
# Define paths and installation directories
BINARY_NAME := kcm
BINARY_PATH := bin/$(BINARY_NAME)
COMPLETION_SCRIPT := bin/${BINARY_NAME}-completion.bash
# Build the Go application
build: clean
@bin/scripts/build-binary.sh $(BINARY_NAME) $(BINARY_PATH) $(COMPLETION_SCRIPT)
clean:
@bin/scripts/clean.sh $(BINARY_PATH) $(COMPLETION_SCRIPT)
install:
@bin/scripts/install.sh
install-global:
@bin/scripts/install-global.sh
uninstall:
@bin/scripts/uninstall.sh

186
bin/helpers/func.sh Executable file
View File

@ -0,0 +1,186 @@
#!/usr/bin/env bash
#Color print function, usage: println "message" "color"
println() {
color=$2
printfe "%s\n" $color "$1"
}
# print colored with printf (args: format, color, message ...)
printfe() {
format=$1
color=$2
message=$3
show_time=true
# Check if $4 is explicitly set to false, otherwise default to true
if [ ! -z "$4" ] && [ "$4" == "false" ]; then
show_time=false
fi
red=$(tput setaf 1)
green=$(tput setaf 2)
yellow=$(tput setaf 3)
blue=$(tput setaf 4)
magenta=$(tput setaf 5)
cyan=$(tput setaf 6)
normal=$(tput sgr0)
grey=$(tput setaf 8)
case $color in
"red")
color=$red
;;
"green")
color=$green
;;
"yellow")
color=$yellow
;;
"blue")
color=$blue
;;
"magenta")
color=$magenta
;;
"cyan")
color=$cyan
;;
"grey")
color=$grey
;;
*)
color=$normal
;;
esac
if [ "$show_time" == "false" ]; then
printf "$color$format$normal" "$message"
return
fi
printf $grey"%s" "$(date +'%H:%M:%S')"$normal
case $color in
$green | $cyan | $blue | $magenta | $normal)
printf "$green INF $normal"
;;
$yellow)
printf "$yellow WRN $normal"
;;
$red)
printf "$red ERR $normal"
;;
*)
printf "$normal"
;;
esac
printf "$color$format$normal" "$message"
}
run_docker_command() {
cmd=$1
log_level="$2"
shift
shift
params=$@
composer_image="composer/composer:2.7.8"
php_image="php:8.3-cli-alpine3.20"
phpstan_image="atishoo/phpstan:latest"
AUTH_SOCK_DIRNAME=$(dirname $SSH_AUTH_SOCK)
# It's possible $SSH_AUTH_SOCK is not set, in that case we should set it to /tmp/ssh_auth_sock
if [ -z "$SSH_AUTH_SOCK" ]; then
AUTH_SOCK_DIRNAME="/tmp/ssh_auth_sock:/tmp/ssh_auth_sock"
fi
# Take the name of the current directory
container=$(basename $(pwd) | tr '[:upper:]' '[:lower:]')
# Check if the $container is an actual container from the $TRADAWARE_PATH/docker-compose.yml
result=$(docker compose -f $TRADAWARE_PATH/docker-compose.yml ps -q $container 2>/dev/null)
if [ -z "$result" ]; then
# Ensure /home/$USER/.config/composer/auth.json exists, if not prefill it with an empty JSON object
if [ ! -f /home/$USER/.config/composer/auth.json ]; then
mkdir -p /home/$USER/.config/composer
touch /home/$USER/.config/composer/auth.json
echo "{
\"github-oauth\": {
\"github.com\": \"KEY_HERE\"
}
}" > /home/$USER/.config/composer/auth.json
printfe "%s" "yellow" "Created an empty auth.json file at '"
printfe "%s" "cyan" "/home/$USER/.config/composer/auth.json"
printfe "%s\n" "yellow" "', you should edit this file and add your GitHub OAuth key."
return
fi
# In case cmd is composer run it with composer image
if [ "$cmd" == "composer" ]; then
if [ "$log_level" == "0" ] || [ "$log_level" == "-1" ]; then
printfe "%s" "cyan" "Running '"
printfe "%s" "yellow" "$cmd $params"
printfe "%s" "cyan" "' in "
printfe "%s" "yellow" "'$composer_image'"
printfe "%s\n" "cyan" " container..."
fi
docker run --rm --interactive --tty \
--volume $PWD:/app \
--volume $AUTH_SOCK_DIRNAME \
--volume /etc/passwd:/etc/passwd:ro \
--volume /etc/group:/etc/group:ro \
--volume /home/$USER/.ssh:/root/.ssh \
--volume /home/$USER/.config/composer/auth.json:/tmp/auth.json \
--env SSH_AUTH_SOCK=$SSH_AUTH_SOCK \
--user $(id -u):$(id -g) \
$composer_image $cmd $params
elif [ "$cmd" == "php" ]; then
if [ "$log_level" == "0" ] || [ "$log_level" == "-1" ]; then
printfe "%s" "cyan" "Running '"
printfe "%s" "yellow" "$cmd $params"
printfe "%s" "cyan" "' in "
printfe "%s" "yellow" "'$php_image'"
printfe "%s\n" "cyan" " container..."
fi
docker run --rm --interactive --tty \
--volume $PWD:/app \
--volume $AUTH_SOCK_DIRNAME \
--volume /etc/passwd:/etc/passwd:ro \
--volume /etc/group:/etc/group:ro \
--volume /home/$USER/.ssh:/root/.ssh \
--volume /home/$USER/.config/composer/auth.json:/tmp/auth.json \
--env SSH_AUTH_SOCK=$SSH_AUTH_SOCK \
--user $(id -u):$(id -g) \
$php_image $cmd $params
elif [ "$cmd" == "phpstan" ]; then
if [ "$log_level" == "0" ] || [ "$log_level" == "-1" ]; then
printfe "%s" "cyan" "Running '"
printfe "%s" "yellow" "$cmd $params"
printfe "%s" "cyan" "' in "
printfe "%s" "yellow" "'$phpstan_image'"
printfe "%s\n" "cyan" " container..."
fi
docker run --rm --interactive --tty \
--volume $PWD:/app \
--user $(id -u):$(id -g) \
$phpstan_image $params
else
println "No container found named $container and given command is not composer or php." "red"
fi
return
fi
docker_user=docker
if [ "$log_level" == "0" ] || [ "$log_level" == "-1" ]; then
printfe "%s" "cyan" "Running '"
printfe "%s" "yellow" "$cmd $params"
printfe "%s" "cyan" "' in "
printfe "%s" "yellow" "'$container'"
printfe "%s\n" "cyan" " container..."
fi
docker compose -f $TRADAWARE_PATH/docker-compose.yml exec -u $docker_user --interactive --tty $container $cmd $params
}

48
bin/scripts/build-binary.sh Executable file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
BINARY_NAME=$1
BINARY_PATH=$2
COMPLETION_SCRIPT=$3
BINARY_PATH_VERSION=$BINARY_PATH.version
source bin/helpers/func.sh
# Check if HEAD is clean, if not abort
if [ -n "$(git status --porcelain)" ]; then
printfe "%s\n" "yellow" "You have uncomitted and/or untracked changes in your working directory."
fi
# Get the current tag checked out to HEAD and hash
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
LATEST_COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null)
LATEST_TAG_HASH=$(git rev-list -n 1 --abbrev-commit $LATEST_TAG 2>/dev/null)
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
# If BRANCH is HEAD and latest commit hash equals latest tag hash, we are on a tag and up to date
if [ "$BRANCH" == "HEAD" ] && [ "$LATEST_COMMIT_HASH" == "$LATEST_TAG_HASH" ]; then
BRANCH=$LATEST_TAG
fi
# In case the current head has uncomitted and/or untracked changes, append a postfix to the version saying (dirty)
if [ -n "$(git status --porcelain)" ]; then
POSTFIX=" (dirty)"
fi
printfe "%s\n" "cyan" "Building $BINARY_NAME binary for $BRANCH ($LATEST_COMMIT_HASH)$POSTFIX..."
go build -o $BINARY_PATH ./src
if [ $? -ne 0 ]; then
printf "\033[0;31m"
echo "Build failed."
printf "\033[0m"
exit 1
fi
# Put tag and hash in .kcm_version file
echo "$BRANCH ($LATEST_COMMIT_HASH)$POSTFIX" > $BINARY_PATH_VERSION
printfe "%s\n" "cyan" "Generating Bash completion script..."
$BINARY_PATH completion bash > $COMPLETION_SCRIPT
printfe "%s\n" "green" "Bash completion script installed to $COMPLETION_SCRIPT."
printfe "%s\n" "green" "Restart or 'source ~/.bashrc' to update your shell."

19
bin/scripts/clean.sh Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
source bin/helpers/func.sh
# $1 should be binary path
BINARY_PATH=$1
# $2 should be completion script path
COMPLETION_SCRIPT=$2
# Confirm these are paths
if [ -z "$BINARY_PATH" ] || [ -z "$COMPLETION_SCRIPT" ]; then
printfe "%s\n" "red" "Usage: $0 <binary_path> <completion_script_path>"
exit 1
fi
printfe "%s\n" "cyan" "Cleaning up old binaries and completion scripts..."
rm -f $BINARY_PATH
rm -f $COMPLETION_SCRIPT

27
bin/scripts/install-global.sh Executable file
View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
source bin/helpers/func.sh
# Remove any existing kcm installation
if [ -f "/usr/local/bin/kcm" ]; then
printfe "%s\n" "yellow" "Removing existing kcm installation..."
rm /usr/local/bin/kcm
fi
if [ -f "/usr/share/bash-completion/completions/kcm" ]; then
printfe "%s\n" "yellow" "Removing existing kcm bash completion..."
rm /usr/share/bash-completion/completions/kcm
fi
# Copy binary files to /usr/local/bin
printfe "%s\n" "cyan" "Installing kcm..."
cp $(pwd)/bin/kcm /usr/local/bin/kcm
cp $(pwd)/bin/kcm-completion.bash /usr/share/bash-completion/completions/kcm
# In case /etc/kcm/config.yaml does not exist, create it
if [ ! -f "/etc/kcm/config.local.yaml" ]; then
printfe "%s\n" "cyan" "Creating default configuration file..."
mkdir -p /etc/kcm
cp $(pwd)/config/config.local.example.yaml /etc/kcm/config.local.yaml
fi
printfe "%s\n" "green" "Installation complete."

16
bin/scripts/install.sh Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Create any missing directories/files
touch ~/.bash_completion
mkdir -p $HOME/.local/bin/
# Symbolically link binaries
ln -sf $(pwd)/bin/kcm $HOME/.local/bin/kcm
ln -sf $(pwd)/bin/kcm-completion.bash $HOME/.local/bin/kcm-completion.bash
# Add completion to bash_completion for kcm
sed -i '/kcm/d' ~/.bash_completion
echo "source $HOME/.local/bin/kcm-completion.bash" >> ~/.bash_completion
echo "Installation complete."
source ~/.bash_completion

29
bin/scripts/uninstall.sh Executable file
View File

@ -0,0 +1,29 @@
#!/bin/usr/env bash
if [ -f $HOME/.local/bin/kcm ]; then
echo "Removing kcm from $HOME/.local/bin"
rm $HOME/.local/bin/kcm
rm $HOME/.local/bin/T
fi
if [ -f $HOME/.local/bin/kcm-completion.bash ]; then
echo "Removing kcm-completion.bash from $HOME/.local/bin"
rm $HOME/.local/bin/kcm-completion.bash
fi
if [ -f $HOME/.local/bin/php ]; then
echo "Removing php from $HOME/.local/bin"
rm $HOME/.local/bin/php
fi
if [ -f $HOME/.local/bin/composer ]; then
echo "Removing composer from $HOME/.local/bin"
rm $HOME/.local/bin/composer
fi
if [ -f $HOME/.local/bin/phpstan ]; then
echo "Removing phpstan from $HOME/.local/bin"
rm $HOME/.local/bin/phpstan
fi
echo "Uninstall complete."

13
config.yml Normal file
View File

@ -0,0 +1,13 @@
# Example config file for clipboard manager
logging:
# Format of the log output. Can be 'console' or 'json'.
# 'console' will output to the console, 'json' will output in JSON format.
# 'json' is useful for structured logging and can be parsed by log management systems.
format: console
# Logging level. Can be 'debug', 'info', 'warning', 'error', or 'critical'.
level: info
clipboard:
# Maximum number of items to keep in the clipboard history.
max_items: 100

50
go.mod Normal file
View File

@ -0,0 +1,50 @@
module github.com/vleeuwenmenno/kcm
go 1.24.3
require (
github.com/rs/zerolog v1.34.0
golang.design/x/clipboard v0.7.0
gopkg.in/yaml.v3 v3.0.1
)
require (
fyne.io/fyne/v2 v2.6.1 // indirect
fyne.io/systray v1.11.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fyne-io/gl-js v0.1.0 // indirect
github.com/fyne-io/glfw-js v0.2.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.1.0 // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.1 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

140
go.sum Normal file
View File

@ -0,0 +1,140 @@
fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is=
fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4=
golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c h1:Gk61ECugwEHL6IiyyNLXNzmu8XslmRP2dS0xjIYhbb4=
golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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