merge: import clip-vault sources with history
git-subtree-dir: clip-vault git-subtree-mainline:c263a13641git-subtree-split:013c550047
This commit is contained in:
commit
e3622e32d6
8 files changed
+549
No files matched your search
Whitespace-only changes.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+37
@@ -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)"
|
||||
Reference in new issue
Block a user