From 1dad8ca69be7891565c9ca0669efa259069a7dda Mon Sep 17 00:00:00 2001 From: Menno van Leeuwen Date: Tue, 20 May 2025 17:52:58 +0200 Subject: [PATCH] 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`. --- .vscode/launch.json | 21 ++++ .vscode/settings.json | 5 + Makefile | 20 ++++ bin/helpers/func.sh | 186 ++++++++++++++++++++++++++++++++++ bin/scripts/build-binary.sh | 48 +++++++++ bin/scripts/clean.sh | 19 ++++ bin/scripts/install-global.sh | 27 +++++ bin/scripts/install.sh | 16 +++ bin/scripts/uninstall.sh | 29 ++++++ config.yml | 13 +++ go.mod | 50 +++++++++ go.sum | 140 +++++++++++++++++++++++++ src/commands/cmd_clear.go | 20 ++++ src/commands/cmd_copy.go | 39 +++++++ src/commands/cmd_list.go | 20 ++++ src/commands/cmd_watch.go | 64 ++++++++++++ src/config/config.go | 36 +++++++ src/main.go | 85 ++++++++++++++++ src/models/history.go | 107 +++++++++++++++++++ src/models/history_item.go | 14 +++ src/utils/hash.go | 11 ++ src/utils/summary.go | 20 ++++ 22 files changed, 990 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Makefile create mode 100755 bin/helpers/func.sh create mode 100755 bin/scripts/build-binary.sh create mode 100755 bin/scripts/clean.sh create mode 100755 bin/scripts/install-global.sh create mode 100755 bin/scripts/install.sh create mode 100755 bin/scripts/uninstall.sh create mode 100644 config.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 src/commands/cmd_clear.go create mode 100644 src/commands/cmd_copy.go create mode 100644 src/commands/cmd_list.go create mode 100644 src/commands/cmd_watch.go create mode 100644 src/config/config.go create mode 100644 src/main.go create mode 100644 src/models/history.go create mode 100644 src/models/history_item.go create mode 100644 src/utils/hash.go create mode 100644 src/utils/summary.go diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..bed8569 --- /dev/null +++ b/.vscode/launch.json @@ -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", + ], + + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fa00bff --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "yaml.schemas": { + "https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json": "**/docker/**/*.yml" + } +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c998e07 --- /dev/null +++ b/Makefile @@ -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 diff --git a/bin/helpers/func.sh b/bin/helpers/func.sh new file mode 100755 index 0000000..636d9b8 --- /dev/null +++ b/bin/helpers/func.sh @@ -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 +} diff --git a/bin/scripts/build-binary.sh b/bin/scripts/build-binary.sh new file mode 100755 index 0000000..4847751 --- /dev/null +++ b/bin/scripts/build-binary.sh @@ -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." diff --git a/bin/scripts/clean.sh b/bin/scripts/clean.sh new file mode 100755 index 0000000..2cdf67a --- /dev/null +++ b/bin/scripts/clean.sh @@ -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 " + exit 1 +fi + +printfe "%s\n" "cyan" "Cleaning up old binaries and completion scripts..." +rm -f $BINARY_PATH +rm -f $COMPLETION_SCRIPT diff --git a/bin/scripts/install-global.sh b/bin/scripts/install-global.sh new file mode 100755 index 0000000..bf82c69 --- /dev/null +++ b/bin/scripts/install-global.sh @@ -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." diff --git a/bin/scripts/install.sh b/bin/scripts/install.sh new file mode 100755 index 0000000..d625ee7 --- /dev/null +++ b/bin/scripts/install.sh @@ -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 diff --git a/bin/scripts/uninstall.sh b/bin/scripts/uninstall.sh new file mode 100755 index 0000000..00c194f --- /dev/null +++ b/bin/scripts/uninstall.sh @@ -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." \ No newline at end of file diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..ad26662 --- /dev/null +++ b/config.yml @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..75a6086 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..78c853c --- /dev/null +++ b/go.sum @@ -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= diff --git a/src/commands/cmd_clear.go b/src/commands/cmd_clear.go new file mode 100644 index 0000000..c879fde --- /dev/null +++ b/src/commands/cmd_clear.go @@ -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.") + }, + } +} diff --git a/src/commands/cmd_copy.go b/src/commands/cmd_copy.go new file mode 100644 index 0000000..77cf3cf --- /dev/null +++ b/src/commands/cmd_copy.go @@ -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) + }, + } +} diff --git a/src/commands/cmd_list.go b/src/commands/cmd_list.go new file mode 100644 index 0000000..adc67fa --- /dev/null +++ b/src/commands/cmd_list.go @@ -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) + }, + } +} diff --git a/src/commands/cmd_watch.go b/src/commands/cmd_watch.go new file mode 100644 index 0000000..be9315a --- /dev/null +++ b/src/commands/cmd_watch.go @@ -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") + } + } + }, + } +} diff --git a/src/config/config.go b/src/config/config.go new file mode 100644 index 0000000..8bf2821 --- /dev/null +++ b/src/config/config.go @@ -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, "" +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..c0cc193 --- /dev/null +++ b/src/main.go @@ -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 +} diff --git a/src/models/history.go b/src/models/history.go new file mode 100644 index 0000000..d43f508 --- /dev/null +++ b/src/models/history.go @@ -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() +} diff --git a/src/models/history_item.go b/src/models/history_item.go new file mode 100644 index 0000000..1006780 --- /dev/null +++ b/src/models/history_item.go @@ -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 +} diff --git a/src/utils/hash.go b/src/utils/hash.go new file mode 100644 index 0000000..5dd1f3e --- /dev/null +++ b/src/utils/hash.go @@ -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[:]) +} diff --git a/src/utils/summary.go b/src/utils/summary.go new file mode 100644 index 0000000..612ffa0 --- /dev/null +++ b/src/utils/summary.go @@ -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]" +}