merge: import clip-vault sources with history

git-subtree-dir: clip-vault
git-subtree-mainline: c263a13641
git-subtree-split: 013c550047
This commit is contained in:
dualfroz committed 2026-08-05 19:46:07 +02:00
commit e3622e32d6
8 files changed
+549

No files matched your search

View File
Whitespace-only changes.
+130
View File
@@ -0,0 +1,130 @@
import argparse
import subprocess
import sys
import time
from clip_vault import storage, webhook
from clip_vault.config import loadConfig, configPath, logPath
def formatRow(row):
clipId, content, sourceWindow, createdAt, pinned = row
preview = content.replace("\n", " ").strip()
if len(preview) > 80:
preview = preview[:77] + "..."
when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(createdAt))
pinMark = "*" if pinned else " "
source = f" [{sourceWindow}]" if sourceWindow else ""
return f"{clipId:>5} {pinMark} {when}{source} {preview}"
def commandList(args):
rows = storage.listClips(limit=args.limit, search=args.search)
for row in rows:
print(formatRow(row))
def commandShow(args):
row = storage.getClip(args.id)
if not row:
print(f"no clip with id {args.id}", file=sys.stderr)
sys.exit(1)
print(row[1])
def commandCopy(args):
row = storage.getClip(args.id)
if not row:
print(f"no clip with id {args.id}", file=sys.stderr)
sys.exit(1)
subprocess.run(["xclip", "-selection", "clipboard"], input=row[1].encode("utf-8"))
print(f"copied clip {args.id} to clipboard")
def commandPin(args):
storage.setPinned(args.id, True)
print(f"pinned {args.id}")
def commandUnpin(args):
storage.setPinned(args.id, False)
print(f"unpinned {args.id}")
def commandDelete(args):
storage.deleteClip(args.id)
print(f"deleted {args.id}")
def commandClear(args):
storage.clearHistory(keepPinned=not args.all)
print("history cleared" + ("" if args.all else " (pinned items kept)"))
def commandConfig(args):
print(f"config: {configPath}")
print(f"log: {logPath}")
def commandTestWebhook(args):
config = loadConfig()
target = next((w for w in config.get("webhooks", []) if w.get("name") == args.name), None)
if not target:
print(f"no webhook named '{args.name}' in config", file=sys.stderr)
sys.exit(1)
ok = webhook.sendWebhook(target, args.content or "clip-vault test payload", "clip-vault-cli")
print("sent ok" if ok else "send failed, check " + logPath)
def buildParser():
parser = argparse.ArgumentParser(prog="clip-vault", description="Clipboard history manager with configurable webhook forwarding")
subparsers = parser.add_subparsers(dest="command", required=True)
listParser = subparsers.add_parser("list", help="show recent clipboard history")
listParser.add_argument("--limit", type=int, default=30)
listParser.add_argument("--search", type=str, default=None)
listParser.set_defaults(func=commandList)
showParser = subparsers.add_parser("show", help="print full content of a clip")
showParser.add_argument("id", type=int)
showParser.set_defaults(func=commandShow)
copyParser = subparsers.add_parser("copy", help="copy a stored clip back to the clipboard")
copyParser.add_argument("id", type=int)
copyParser.set_defaults(func=commandCopy)
pinParser = subparsers.add_parser("pin", help="pin a clip so it survives trimming/clear")
pinParser.add_argument("id", type=int)
pinParser.set_defaults(func=commandPin)
unpinParser = subparsers.add_parser("unpin", help="unpin a clip")
unpinParser.add_argument("id", type=int)
unpinParser.set_defaults(func=commandUnpin)
deleteParser = subparsers.add_parser("delete", help="delete a clip")
deleteParser.add_argument("id", type=int)
deleteParser.set_defaults(func=commandDelete)
clearParser = subparsers.add_parser("clear", help="clear history")
clearParser.add_argument("--all", action="store_true", help="also delete pinned clips")
clearParser.set_defaults(func=commandClear)
configParser = subparsers.add_parser("config", help="show config and log file paths")
configParser.set_defaults(func=commandConfig)
testWebhookParser = subparsers.add_parser("test-webhook", help="send a test payload through a configured webhook")
testWebhookParser.add_argument("name", type=str)
testWebhookParser.add_argument("--content", type=str, default=None)
testWebhookParser.set_defaults(func=commandTestWebhook)
return parser
def main():
parser = buildParser()
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
import json
import os
configDir = os.path.expanduser("~/.config/clip-vault")
configPath = os.path.join(configDir, "config.json")
defaultConfigPath = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.default.json")
dbPath = os.path.join(configDir, "history.sqlite3")
logPath = os.path.join(configDir, "webhook.log")
def loadConfig():
if not os.path.exists(configPath):
installDefaultConfig()
with open(configPath) as configFile:
return json.load(configFile)
def installDefaultConfig():
os.makedirs(configDir, exist_ok=True)
with open(defaultConfigPath) as defaultFile:
defaultContent = defaultFile.read()
with open(configPath, "w") as configFile:
configFile.write(defaultContent)
def saveConfig(config):
os.makedirs(configDir, exist_ok=True)
with open(configPath, "w") as configFile:
json.dump(config, configFile, indent=2)
+166
View File
@@ -0,0 +1,166 @@
import hashlib
import re
import subprocess
import gi
gi.require_version("Gtk", "3.0")
try:
gi.require_version("AyatanaAppIndicator3", "0.1")
from gi.repository import AyatanaAppIndicator3 as AppIndicator3
except ValueError:
gi.require_version("AppIndicator3", "0.1")
from gi.repository import AppIndicator3
from gi.repository import GLib, Gtk
from clip_vault import storage, webhook
from clip_vault.config import loadConfig
def activeWindowTitle():
try:
output = subprocess.run(
["xdotool", "getactivewindow", "getwindowname"], capture_output=True, text=True, timeout=1
)
return output.stdout.strip() if output.returncode == 0 else None
except Exception:
return None
class ClipVaultDaemon:
def __init__(self):
self.config = loadConfig()
self.paused = False
self.lastHash = None
from gi.repository import Gdk
self.clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
self.clipboard.connect("owner-change", self.onOwnerChange)
self.indicator = self.buildIndicator()
def buildIndicator(self):
indicator = AppIndicator3.Indicator.new(
"clip-vault", "edit-copy-symbolic", AppIndicator3.IndicatorCategory.APPLICATION_STATUS
)
indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
indicator.set_menu(self.buildMenu())
return indicator
def buildMenu(self):
menu = Gtk.Menu()
pauseItem = Gtk.CheckMenuItem(label="Pause capture")
pauseItem.set_active(self.paused)
pauseItem.connect("toggled", self.onTogglePause)
menu.append(pauseItem)
menu.append(Gtk.SeparatorMenuItem())
recent = storage.listClips(limit=10)
if recent:
for clipId, content, sourceWindow, createdAt, pinned in recent:
preview = content.replace("\n", " ").strip()
if len(preview) > 60:
preview = preview[:57] + "..."
label = ("* " if pinned else "") + preview
item = Gtk.MenuItem(label=label or "(empty)")
item.connect("activate", self.onRecopy, content)
menu.append(item)
else:
emptyItem = Gtk.MenuItem(label="(no history yet)")
emptyItem.set_sensitive(False)
menu.append(emptyItem)
menu.append(Gtk.SeparatorMenuItem())
reloadItem = Gtk.MenuItem(label="Reload config")
reloadItem.connect("activate", self.onReloadConfig)
menu.append(reloadItem)
quitItem = Gtk.MenuItem(label="Quit")
quitItem.connect("activate", self.onQuit)
menu.append(quitItem)
menu.show_all()
return menu
def refreshMenu(self):
self.indicator.set_menu(self.buildMenu())
def onTogglePause(self, checkItem):
self.paused = checkItem.get_active()
def onRecopy(self, _menuItem, content):
self.clipboard.set_text(content, -1)
self.clipboard.store()
def onReloadConfig(self, _menuItem):
self.config = loadConfig()
def onQuit(self, _menuItem):
Gtk.main_quit()
def isIgnored(self, content, sourceWindow):
if sourceWindow:
for pattern in self.config.get("ignoreWindowPatterns", []):
if re.search(pattern, sourceWindow, re.IGNORECASE):
return True
for pattern in self.config.get("ignoreContentPatterns", []):
if re.search(pattern, content):
return True
return False
def onOwnerChange(self, clipboard, _event):
if self.paused:
return
GLib.idle_add(self.captureCurrentClipboard)
def captureCurrentClipboard(self):
text = self.clipboard.wait_for_text()
if not text:
return False
content = text
minLength = self.config.get("minContentLength", 2)
maxLength = self.config.get("maxContentLength", 200000)
if len(content) < minLength or len(content) > maxLength:
return False
contentHash = hashlib.sha256(content.encode("utf-8")).hexdigest()
if contentHash == self.lastHash:
return False
self.lastHash = contentHash
sourceWindow = activeWindowTitle()
if self.isIgnored(content, sourceWindow):
return False
storage.addClip(content, contentHash, sourceWindow)
storage.trimHistory(self.config.get("maxHistory", 500))
def onWebhookDone(sentNames):
if sentNames:
GLib.idle_add(self.markWebhookSent, contentHash, sentNames)
webhook.dispatchAsync(content, sourceWindow, self.config, onDone=onWebhookDone)
GLib.idle_add(self.refreshMenu)
return False
def markWebhookSent(self, contentHash, sentNames):
connection = storage.connect()
connection.execute(
"UPDATE clips SET webhookSentTo = ? WHERE id = (SELECT id FROM clips WHERE contentHash = ? ORDER BY id DESC LIMIT 1)",
(",".join(sentNames), contentHash),
)
connection.commit()
connection.close()
return False
def main():
daemon = ClipVaultDaemon()
Gtk.main()
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
import os
import sqlite3
import time
from clip_vault.config import configDir, dbPath
def connect():
os.makedirs(configDir, exist_ok=True)
connection = sqlite3.connect(dbPath)
connection.execute("""
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
contentHash TEXT NOT NULL,
sourceWindow TEXT,
createdAt REAL NOT NULL,
pinned INTEGER NOT NULL DEFAULT 0,
webhookSentTo TEXT
)
""")
connection.execute("CREATE INDEX IF NOT EXISTS idxContentHash ON clips(contentHash)")
return connection
def addClip(content, contentHash, sourceWindow, webhookSentTo=None):
connection = connect()
existing = connection.execute(
"SELECT id FROM clips WHERE contentHash = ? ORDER BY id DESC LIMIT 1", (contentHash,)
).fetchone()
if existing:
connection.execute("DELETE FROM clips WHERE id = ?", (existing[0],))
connection.execute(
"INSERT INTO clips (content, contentHash, sourceWindow, createdAt, pinned, webhookSentTo) VALUES (?, ?, ?, ?, 0, ?)",
(content, contentHash, sourceWindow, time.time(), webhookSentTo),
)
connection.commit()
connection.close()
def trimHistory(maxHistory):
connection = connect()
connection.execute(
"""
DELETE FROM clips WHERE id IN (
SELECT id FROM clips WHERE pinned = 0
ORDER BY createdAt DESC LIMIT -1 OFFSET ?
)
""",
(maxHistory,),
)
connection.commit()
connection.close()
def listClips(limit=50, search=None):
connection = connect()
if search:
rows = connection.execute(
"SELECT id, content, sourceWindow, createdAt, pinned FROM clips WHERE content LIKE ? ORDER BY createdAt DESC LIMIT ?",
(f"%{search}%", limit),
).fetchall()
else:
rows = connection.execute(
"SELECT id, content, sourceWindow, createdAt, pinned FROM clips ORDER BY createdAt DESC LIMIT ?",
(limit,),
).fetchall()
connection.close()
return rows
def getClip(clipId):
connection = connect()
row = connection.execute("SELECT id, content, sourceWindow, createdAt, pinned FROM clips WHERE id = ?", (clipId,)).fetchone()
connection.close()
return row
def setPinned(clipId, pinned):
connection = connect()
connection.execute("UPDATE clips SET pinned = ? WHERE id = ?", (1 if pinned else 0, clipId))
connection.commit()
connection.close()
def deleteClip(clipId):
connection = connect()
connection.execute("DELETE FROM clips WHERE id = ?", (clipId,))
connection.commit()
connection.close()
def clearHistory(keepPinned=True):
connection = connect()
if keepPinned:
connection.execute("DELETE FROM clips WHERE pinned = 0")
else:
connection.execute("DELETE FROM clips")
connection.commit()
connection.close()
+61
View File
@@ -0,0 +1,61 @@
import json
import re
import threading
import time
import urllib.request
from clip_vault.config import logPath
def logLine(line):
with open(logPath, "a") as logFile:
logFile.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {line}\n")
def matchingWebhooks(content, config):
matches = []
for webhook in config.get("webhooks", []):
if not webhook.get("enabled"):
continue
pattern = webhook.get("matchPattern", ".*")
try:
if re.search(pattern, content):
matches.append(webhook)
except re.error as regexError:
logLine(f"invalid matchPattern in webhook '{webhook.get('name')}': {regexError}")
return matches
def sendWebhook(webhook, content, sourceWindow):
body = {"content": content, "timestamp": time.time()}
if webhook.get("includeSourceWindow", True):
body["sourceWindow"] = sourceWindow
data = json.dumps(body).encode("utf-8")
headers = {"Content-Type": "application/json"}
headers.update(webhook.get("headers", {}))
request = urllib.request.Request(webhook["url"], data=data, headers=headers, method=webhook.get("method", "POST"))
try:
with urllib.request.urlopen(request, timeout=webhook.get("timeoutSeconds", 4)) as response:
logLine(f"webhook '{webhook.get('name')}' -> {response.status}")
return True
except Exception as sendError:
logLine(f"webhook '{webhook.get('name')}' failed: {sendError}")
return False
def dispatchAsync(content, sourceWindow, config, onDone=None):
matches = matchingWebhooks(content, config)
if not matches:
if onDone:
onDone([])
return
def worker():
sentNames = []
for webhook in matches:
if sendWebhook(webhook, content, sourceWindow):
sentNames.append(webhook.get("name", webhook["url"]))
if onDone:
onDone(sentNames)
threading.Thread(target=worker, daemon=True).start()
+26
View File
@@ -0,0 +1,26 @@
{
"maxHistory": 500,
"minContentLength": 2,
"maxContentLength": 200000,
"pollFallbackSeconds": 2,
"ignoreWindowPatterns": [
"KeePassXC",
"Bitwarden",
"1Password"
],
"ignoreContentPatterns": [
"-----BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY-----"
],
"webhooks": [
{
"name": "example-disabled",
"enabled": false,
"url": "https://example.com/webhook",
"method": "POST",
"matchPattern": ".*",
"headers": {},
"timeoutSeconds": 4,
"includeSourceWindow": true
}
]
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
sourceDir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
installDir="$HOME/.local/share/clip-vault"
binDir="$HOME/.local/bin"
autostartDir="$HOME/.config/autostart"
mkdir -p "$installDir" "$binDir" "$autostartDir"
rsync -a --delete "$sourceDir/clip_vault" "$installDir/"
cp "$sourceDir/config.default.json" "$installDir/config.default.json"
cat > "$binDir/clip-vault" <<EOF
#!/usr/bin/env bash
exec python3 -m clip_vault.cli "\$@"
EOF
chmod +x "$binDir/clip-vault"
cat > "$binDir/clip-vault-daemon" <<EOF
#!/usr/bin/env bash
exec python3 -m clip_vault.daemon
EOF
chmod +x "$binDir/clip-vault-daemon"
cat > "$autostartDir/clip-vault.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Clip Vault
Exec=env PYTHONPATH=$installDir $binDir/clip-vault-daemon
X-GNOME-Autostart-enabled=true
EOF
echo "installed. PYTHONPATH must include $installDir when running clip-vault/clip-vault-daemon by hand:"
echo " export PYTHONPATH=$installDir:\$PYTHONPATH"
echo " clip-vault-daemon & # starts tray + clipboard watcher"
echo " clip-vault list # view history from the CLI"
echo "config file: ~/.config/clip-vault/config.json (edit webhooks there)"