From 9c0dcef048f68809994adb95b337d9e2831a3121 Mon Sep 17 00:00:00 2001 From: yosh Date: Mon, 18 Dec 2023 23:14:55 -0600 Subject: [PATCH] initial commit --- LICENSE | 5 ++ README.md | 22 ++++++ config | 43 ++++++++++++ filter.sh | 6 ++ shclip-daemon | 183 ++++++++++++++++++++++++++++++++++++++++++++++++++ shclip-menu | 104 ++++++++++++++++++++++++++++ 6 files changed, 363 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config create mode 100644 filter.sh create mode 100755 shclip-daemon create mode 100755 shclip-menu diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ea9256a --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or distribute this +software, either in source code form or as a compiled binary, for any purpose, +commercial or non-commercial, and by any means. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ef3f6d --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +# shclip +`shclip` is an X11 clipboard manager and menu implemented in POSIX shell, largely inspired by [clipmenu](https://github.com/cdown/clipmenu) + +## dependencies +- A POSIX compliant shell +- An implementation of POSIX Utilities (`coreutils`, `bsdutils`, `busybox`, ...) +- An implementation of `file(1)` that supports `-b` and `-i` for mime-types (yours should) +- `ed` (mentioning this separately since many unixes/distributions have it separately) +- A newline-delimited menu program (`dmenu`, `rofi`, `bemenu`, `fzfmenu`, ...) +- (Optional, but recommended) `notify-send` + +## using +put `shclip-{daemon,menu}` in your `$PATH` and start `shclip-daemon` in a way where it can read `$DISPLAY`. everything should Just Work, hopefully. to use the menu, run `shclip-menu` + +`shclip` reads a config file at `$XDG_CONFIG_HOME/shclip/config` and reads filters from `$XDG_CONFIG_HOME/shclip/filter.sh`. if `config` doesn't exist, then the defaults as specified in the file are used. if `filter.sh` doesn't exist, then filtering won't happen whether it's enabled or not. + +if filtering is enabled, every text clip will be sent to the stdin of `filter.sh` and be replaced by its stdout. A simple replacement filter is provided as an example + +`shclip-menu` should hopefully be straightforward. you have the option to copy a previous clip, delete any individual clip (or all clips), edit any individual clip, toggle caching clips, and toggle clip filtering + +## rationale +I made this because I really liked `clipmenu`, but felt as if it fell short in very specific aspects that I didn't like (mainly, I wanted image support). I decided to use posix shell because I'm more comfortable with it than including bashisms in my scripts, and also to maybe serve as being useful for users of more minimal shells or other unixes. diff --git a/config b/config new file mode 100644 index 0000000..9e48ed1 --- /dev/null +++ b/config @@ -0,0 +1,43 @@ +### DAEMON OPTIONS ### + +# enable shclip on startup +# blank for no, anything for yes +# default: yes +SHCLIP_ENABLED="1" + +# max clips to store +# default: 25 +SHCLIP_MAX_CLIPS=25 + +# memory limit + 4 in kb +# default: 4100 (4MB) +SHCLIP_MEMORY_LIMIT=4100 + +# enable filtering for clips (see filter.sh) +# blank for no, anything for yes +# default: no +SHCLIP_FILTER_ENABLED="" + +# enable syncing the clipboard to the primary selection +# blank for no, anything for yes +# default: yes +SHCLIP_SYNC_CLIPBOARD="1" + +### SHCLIP-MENU OPTIONS ### + +# text editor for editing clips +# default: xdg-open +SHCLIP_TEXT_EDITOR="xdg-open" + +# image editor for editing clips +# default: gimp -n +SHCLIP_IMAGE_EDITOR="gimp -n" + +# shclip menu commands +# defaults: +# MAIN: fzfmenu -p shclip +# COPY, DEL, and EDIT copy MAIN if blank +SHCLIP_MENU_MAIN_CMD="fzfmenu -p shclip" +SHCLIP_MENU_COPY_CMD="" +SHCLIP_MENU_DEL_CMD="" +SHCLIP_MENU_EDIT_CMD="" diff --git a/filter.sh b/filter.sh new file mode 100644 index 0000000..af07438 --- /dev/null +++ b/filter.sh @@ -0,0 +1,6 @@ +# this file is run as a shell script for filtering text clips +# stdin is the current text clip, stdout is what gets put back in buffer + +sed -E ' + s/nitter.salastil.com/fxtwitter.com/g +' diff --git a/shclip-daemon b/shclip-daemon new file mode 100755 index 0000000..4f0d85f --- /dev/null +++ b/shclip-daemon @@ -0,0 +1,183 @@ +#!/bin/sh + +# shclip, by yosh +# https://git.unix.dog/yosh/shclip + +set -eu +TAB=" " +BN="${0##*/}" + +# config file if exists +SHCLIP_CONFIG_DIR="${SHCLIP_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/shclip}" +. "$SHCLIP_CONFIG_DIR/config" + +SHCLIP_MAX_CLIPS="${SHCLIP_MAX_CLIPS:-25}" +SHCLIP_MEMORY_LIMIT="${SHCLIP_MEMORY_LIMIT:-4100}" # memory limit in kb +SHCLIP_ENABLED="${SHCLIP_ENABLED-1}" +SHCLIP_FILTER_ENABLED="${SHCLIP_FILTER_ENABLED-}" +SHCLIP_SYNC_CLIPBOARD="${SHCLIP_SYNC_CLIPBOARD-1}" + +SHCLIP_TEMP_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/shclip-$USER-$DISPLAY" + +errecho() { printf '%s\n' "$*" >&2; } +exists() { command -v "$@" >/dev/null 2>&1; } + +fail() { + errecho "error: $BN: $*" + exists notify-send && notify-send "error: $BN: $*" + exit 1 +} + +cleanup() { + # TODO: better cleanup by sorting shit, etc. + trap 'exit' INT HUP QUIT TERM EXIT + set +e + rmdir "$SHCLIP_TEMP_DIR/lock" + kill "$clipnotify_pid" + exit +} + +toggle() { + if [ -z "$SHCLIP_ENABLED" ]; then + SHCLIP_ENABLED=1 + errecho "shclip enabled!" + exists notify-send && notify-send "shclip enabled!" + else + SHCLIP_ENABLED="" + errecho "shclip disabled!" + exists notify-send && notify-send "shclip disabled!" + fi + kill "$clipnotify_pid" +} + +filter_toggle() { + if [ -z "$SHCLIP_FILTER_ENABLED" ]; then + SHCLIP_FILTER_ENABLED=1 + errecho "shclip filter enabled!" + exists notify-send && notify-send "shclip filter enabled!" + else + SHCLIP_FILTER_ENABLED="" + errecho "shclip filter disabled!" + exists notify-send && notify-send "shclip filter disabled!" + fi + kill "$clipnotify_pid" +} + +# setup process +setup() { + mkdir -p -m 700 "$SHCLIP_TEMP_DIR" + + # test the temp/lock directory + if mkdir -m 700 "$SHCLIP_TEMP_DIR/lock"; then + errecho "successfully acquired lock" + else + fail "cannot acquire lock. is another instance running? +if not, delete the directory at $SHCLIP_TEMP_DIR/lock" + fi + + trap 'cleanup' INT HUP QUIT TERM EXIT + trap 'toggle' USR1 + trap 'filter_toggle' USR2 + + BUFFER="$SHCLIP_TEMP_DIR/buffer" + ORDER="$SHCLIP_TEMP_DIR/order" + PID="$SHCLIP_TEMP_DIR/pid" + printf '%s' $$ > "$PID" + + # create ORDER file + if [ ! -f "$ORDER" ]; then + digits=${#SHCLIP_MAX_CLIPS} + i=1 + while [ "$i" -le "$SHCLIP_MAX_CLIPS" ]; do + printf "%0${digits}d.clip\n" "$i" + i="$((i + 1))" + done > "$ORDER" + fi +} + +# reclip top of the stack +reclip() { + n="$(head -n 1 "$ORDER")" + mime="$(file -bi "$SHCLIP_TEMP_DIR/$n")" + xclip -sel clipboard -t "${mime%%;*}" "$SHCLIP_TEMP_DIR/$n" +} +updateclip() { + # some good variables to have + firstind="$(head -n 1 "$ORDER")" # top of clip stack + lastind="$(tail -n 1 "$ORDER")" # bottom of stack + targets="$(xclip -sel clipboard -t TARGETS -o)" || { reclip && return 0; } # targets of clip, will also return if clip doesn't exist + # this covers if we've copied a file in a GUI file manager + # probably don't want that in your history + case "$targets" in + *gnome-copied-files*) return 0 ;; + *image/png*) xclip -sel clipboard -t image/png -o > "$BUFFER" ;; + *) xclip -sel clipboard -o > "$BUFFER" ;; + esac + + # check if clip exceeds memory limit + sz="$(du -k "$BUFFER")" + if [ "${sz%%${TAB}*}" -gt "$SHCLIP_MEMORY_LIMIT" ]; then + errecho 'clip exceeds memory threshold! not caching...' + exists notify-send && notify-send 'clip exceeds memory threshold! not caching...' + rm -f "$BUFFER" + return 0 + fi + + # buffer mime matching + mime="$(file -bi "$BUFFER")" + case "${mime%%;}" in + inode/x-empty*) # empty clip + reclip + return 0 + ;; + text*) + # apply text filters + [ -n "$SHCLIP_FILTER_ENABLED" ] && [ -f "$SHCLIP_CONFIG_DIR/filter.sh" ] && \ + sh "$SHCLIP_CONFIG_DIR/filter.sh" < "$BUFFER" | diff "$BUFFER" - | patch "$BUFFER" + xclip -sel clipboard "$BUFFER" + [ -n "$SHCLIP_SYNC_CLIPBOARD" ] && xclip -sel primary "$BUFFER" + ;; + *) ;; + esac + + # top of stack & buffer are identical, don't cache + if cmp "$BUFFER" "$SHCLIP_TEMP_DIR/$firstind" >/dev/null 2>&1; then + errecho 'duplicate clip! not caching...' + rm -f "$BUFFER" + return 0 + fi + + mv -f "$BUFFER" "$SHCLIP_TEMP_DIR/$lastind" + + # edit order file in place + ed -s "$ORDER" <<-EOF + ${SHCLIP_MAX_CLIPS}m0 + wq + EOF +} + +[ -z "${DISPLAY:-}" ] && fail 'Cannot find $DISPLAY. Is your X server running?' + +# parse options +while getopts :pnf OPT; do + case $OPT in + n) SHCLIP_ENABLED="" ;; + f) SHCLIP_FILTER_ENABLED=1 ;; + *) fail "$BN: unknown option: -$OPTARG" ;; + esac +done + +setup +while + if [ -z "$SHCLIP_ENABLED" ]; then + : + else + updateclip + fi +do + set +e + clipnotify -s clipboard & + clipnotify_pid=$! + wait + set -e +done diff --git a/shclip-menu b/shclip-menu new file mode 100755 index 0000000..df3fba9 --- /dev/null +++ b/shclip-menu @@ -0,0 +1,104 @@ +#!/bin/sh + +# shclip, by yosh +# https://git.unix.dog/yosh/shclip + +set -eu + +# config file if exists +SHCLIP_CONFIG_DIR="${SHCLIP_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/shclip}" +. "$SHCLIP_CONFIG_DIR/config" + +SHCLIP_MENU_MAIN_CMD=${SHCLIP_MENU_MAIN_CMD:-fzfmenu -p shclip} +SHCLIP_MENU_DEL_CMD=${SHCLIP_MENU_DEL_CMD:-${SHCLIP_MENU_MAIN_CMD}} +SHCLIP_MENU_COPY_CMD=${SHCLIP_MENU_COPY_CMD:-${SHCLIP_MENU_MAIN_CMD}} +SHCLIP_MENU_EDIT_CMD=${SHCLIP_MENU_EDIT_CMD:-${SHCLIP_MENU_MAIN_CMD}} +SHCLIP_TEXT_EDITOR=${SHCLIP_TEXT_EDITOR:-xdg-open} +SHCLIP_IMAGE_EDITOR=${SHCLIP_IMAGE_EDITOR:-gimp -n} + +SHCLIP_TEMP_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/shclip-$USER-$DISPLAY" + +showmenu() { + choice="$(printf 'copy\ndelete\nedit\nshclip toggle\nfilter toggle\nexit' | $SHCLIP_MENU_MAIN_CMD)" + case $choice in + delete) delmenu ;; + copy) copymenu ;; + edit) editmenu ;; + shclip*) [ -d "$SHCLIP_TEMP_DIR/lock" ] && kill -s USR1 "$(cat "$SHCLIP_TEMP_DIR/pid")" ;; + filter*) [ -d "$SHCLIP_TEMP_DIR/lock" ] && kill -s USR2 "$(cat "$SHCLIP_TEMP_DIR/pid")" ;; + *) exit ;; + esac +} + +menu_gen() { + while read -r ind; do + file="$SHCLIP_TEMP_DIR/$ind" + [ ! -f "$file" ] && continue + + # build menu + mime="$(file -bi "$file")" + if [ "${mime%%/*}" = "text" ]; then + preview="${ind%%.*} | $(dd if="$file" bs=128 count=1 2>/dev/null | tr '\n' ' ')" + else + preview="${ind%%.*} * --- ${mime%%;*} --- $(du -k "$file" | cut -f 1)KiB" + fi + printf '%s\n' "${preview}" + done < "$SHCLIP_TEMP_DIR/order" +} + +delmenu() { + choice="$({ echo "ALL"; menu_gen; } | $SHCLIP_MENU_DEL_CMD | cut -d ' ' -f 1)" + case $choice in + ALL) + set +f + rm -f "$SHCLIP_TEMP_DIR"/*.clip + set -f + ;; + [0-9]*) rm -f "$SHCLIP_TEMP_DIR/$choice.clip" ;; + *) exit 1 ;; + esac +} + +copymenu() { + choice="$(menu_gen | $SHCLIP_MENU_COPY_CMD | cut -d ' ' -f 1)" + case $choice in + [!0-9]*) exit 1 ;; + *) ;; + esac + mime="$(file -bi "$SHCLIP_TEMP_DIR/$choice.clip")" + + ln="$(grep -n "$choice" "$SHCLIP_TEMP_DIR/order")" + ed -s "$SHCLIP_TEMP_DIR/order" - <<-EOF + ${ln%%:*}m0 + wq + EOF + + case $mime in + text*) xclip -sel clipboard "$SHCLIP_TEMP_DIR/$choice.clip" ;; + *) xclip -sel clipboard -t "${mime%%;*}" "$SHCLIP_TEMP_DIR/$choice.clip" ;; + esac + exit +} + +editmenu() { + choice="$(menu_gen | $SHCLIP_MENU_EDIT_CMD | cut -d ' ' -f 1)" + case $choice in + [!0-9]*) exit 1 ;; + *) ;; + esac + mime="$(file -bi "$SHCLIP_TEMP_DIR/$choice.clip")" + + case $mime in + image/*) + mv "$SHCLIP_TEMP_DIR/$choice.clip" "$SHCLIP_TEMP_DIR/$choice.png" + $SHCLIP_IMAGE_EDITOR "$SHCLIP_TEMP_DIR/$choice.png" + mv "$SHCLIP_TEMP_DIR/$choice.png" "$SHCLIP_TEMP_DIR/$choice.clip" + ;; + *) $SHCLIP_TEXT_EDITOR "$SHCLIP_TEMP_DIR/$choice.clip" ;; + esac + exit +} + +while true; do + showmenu +done