Files
custom-streamdeck/plugins/clipboard_tools.py
2026-05-10 12:46:33 +02:00

280 lines
10 KiB
Python

from __future__ import annotations
import asyncio
import re
from typing import Any
TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_.-]+)\s*}}")
class ClipboardToolsPlugin:
name = "Clipboard Tools"
desc = "Copy preset text, paste snippets, and transform clipboard text."
version = "0.1.0"
actions = [
{
"id": "copy_text",
"name": "Copy Preset Text",
"desc": "Put preset text on the system clipboard.",
"fields": [
{
"id": "text",
"label": "Text",
"type": "textarea",
"required": True,
"placeholder": "Paste the text or template to copy.",
}
],
},
{
"id": "paste_snippet",
"name": "Paste Snippet",
"desc": "Copy text to the clipboard, send Ctrl+V, and optionally restore the previous clipboard value.",
"fields": [
{
"id": "text",
"label": "Snippet",
"type": "textarea",
"required": True,
"placeholder": "Snippet to paste into the active app.",
},
{
"id": "restore_clipboard",
"label": "Restore previous clipboard",
"type": "boolean",
"default": True,
},
{
"id": "restore_delay_ms",
"label": "Restore Delay (ms)",
"type": "number",
"default": 120,
},
],
},
{
"id": "transform_clipboard",
"name": "Transform Clipboard Text",
"desc": "Read text from the clipboard, transform it, then write it back.",
"fields": [
{
"id": "transform",
"label": "Transform",
"type": "select",
"default": "uppercase",
"options": [
{"label": "UPPERCASE", "value": "uppercase"},
{"label": "lowercase", "value": "lowercase"},
{"label": "Title Case", "value": "titlecase"},
{"label": "Sentence case", "value": "sentencecase"},
{"label": "Trim edges", "value": "trim"},
{"label": "Collapse whitespace", "value": "collapse_whitespace"},
{"label": "snake_case", "value": "snake_case"},
{"label": "kebab-case", "value": "kebab_case"},
{"label": "camelCase", "value": "camel_case"},
{"label": "Replace text", "value": "replace"},
{"label": "Add prefix", "value": "prefix"},
{"label": "Add suffix", "value": "suffix"},
],
},
{
"id": "find",
"label": "Find",
"type": "text",
"placeholder": "Used by Replace text",
},
{
"id": "replace",
"label": "Replace With",
"type": "text",
"placeholder": "Used by Replace text",
},
{
"id": "extra_text",
"label": "Prefix / Suffix Text",
"type": "text",
"placeholder": "Used by Add prefix or Add suffix",
},
{
"id": "auto_paste",
"label": "Paste after transform",
"type": "boolean",
"default": False,
},
],
},
]
def on_load(self, ctx):
ctx.db.add_event("plugin.loaded", {"plugin": self.name})
async def execute_action(self, ctx, action_id, config, event):
if action_id == "copy_text":
text = self._render_text(config.get("text", ""), event)
self._set_clipboard_text(text)
ctx.db.add_event("plugin.clipboard.copy", {"plugin": self.name, "chars": len(text)})
return
if action_id == "paste_snippet":
text = self._render_text(config.get("text", ""), event)
restore_clipboard = bool(config.get("restore_clipboard", True))
restore_delay_ms = max(0, min(5000, int(config.get("restore_delay_ms", 120) or 0)))
previous = self._get_clipboard_text() if restore_clipboard else None
self._set_clipboard_text(text)
await self._paste_via_shortcut(ctx)
if restore_clipboard:
if restore_delay_ms:
await asyncio.sleep(restore_delay_ms / 1000)
self._set_clipboard_text(previous or "")
ctx.db.add_event(
"plugin.clipboard.paste",
{"plugin": self.name, "chars": len(text), "restore_clipboard": restore_clipboard},
)
return
if action_id == "transform_clipboard":
original = self._get_clipboard_text()
if not original:
raise ValueError("Clipboard does not contain text to transform.")
transformed = self._transform_text(original, config)
self._set_clipboard_text(transformed)
if config.get("auto_paste"):
await self._paste_via_shortcut(ctx)
ctx.db.add_event(
"plugin.clipboard.transform",
{
"plugin": self.name,
"transform": str(config.get("transform", "uppercase")),
"before_chars": len(original),
"after_chars": len(transformed),
},
)
return
raise ValueError(f"Unknown clipboard action '{action_id}'.")
def _render_text(self, value: Any, event: dict[str, Any] | None) -> str:
template = str(value or "")
context = {"event": event or {}}
def replace(match: re.Match[str]) -> str:
current: Any = context
for part in match.group(1).split("."):
if isinstance(current, dict):
current = current.get(part, "")
else:
current = getattr(current, part, "")
return "" if current is None else str(current)
return TOKEN_RE.sub(replace, template)
def _transform_text(self, text: str, config: dict[str, Any]) -> str:
transform = str(config.get("transform", "uppercase") or "uppercase")
if transform == "uppercase":
return text.upper()
if transform == "lowercase":
return text.lower()
if transform == "titlecase":
return text.title()
if transform == "sentencecase":
stripped = text.strip()
if not stripped:
return ""
return stripped[:1].upper() + stripped[1:].lower()
if transform == "trim":
return text.strip()
if transform == "collapse_whitespace":
return re.sub(r"\s+", " ", text).strip()
if transform == "snake_case":
return self._to_delimited_case(text, "_")
if transform == "kebab_case":
return self._to_delimited_case(text, "-")
if transform == "camel_case":
words = self._split_words(text)
if not words:
return ""
return words[0] + "".join(word[:1].upper() + word[1:] for word in words[1:])
if transform == "replace":
find = str(config.get("find", ""))
if not find:
raise ValueError("Replace text requires a Find value.")
return text.replace(find, str(config.get("replace", "")))
if transform == "prefix":
return f"{str(config.get('extra_text', ''))}{text}"
if transform == "suffix":
return f"{text}{str(config.get('extra_text', ''))}"
raise ValueError(f"Unknown transform '{transform}'.")
def _split_words(self, text: str) -> list[str]:
normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", text)
tokens = re.split(r"[^a-zA-Z0-9]+", normalized)
return [token.lower() for token in tokens if token]
def _to_delimited_case(self, text: str, delimiter: str) -> str:
return delimiter.join(self._split_words(text))
async def _paste_via_shortcut(self, ctx) -> None:
keys = getattr(getattr(ctx.app, "actions", None), "keys", None)
if keys is None:
raise RuntimeError("Action engine is not available for paste shortcuts.")
result = keys.press_combo("ctrl+v")
if hasattr(result, "__await__"):
await result
def _get_clipboard_text(self) -> str:
try:
import win32clipboard
import win32con
except ImportError:
return self._get_clipboard_text_tk()
win32clipboard.OpenClipboard()
try:
if not win32clipboard.IsClipboardFormatAvailable(win32con.CF_UNICODETEXT):
return ""
data = win32clipboard.GetClipboardData(win32con.CF_UNICODETEXT)
return str(data or "")
finally:
win32clipboard.CloseClipboard()
def _set_clipboard_text(self, text: str) -> None:
try:
import win32clipboard
import win32con
except ImportError:
self._set_clipboard_text_tk(text)
return
win32clipboard.OpenClipboard()
try:
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32con.CF_UNICODETEXT, text)
finally:
win32clipboard.CloseClipboard()
def _get_clipboard_text_tk(self) -> str:
import tkinter
root = tkinter.Tk()
root.withdraw()
try:
return root.clipboard_get()
except tkinter.TclError:
return ""
finally:
root.destroy()
def _set_clipboard_text_tk(self, text: str) -> None:
import tkinter
root = tkinter.Tk()
root.withdraw()
root.clipboard_clear()
root.clipboard_append(text)
root.update()
root.destroy()
PLUGIN = ClipboardToolsPlugin()