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()