280 lines
10 KiB
Python
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()
|