import re import subprocess from typing import List, Dict, Any import gi gi.require_version('GdkPixbuf', '2.0') from gi.repository import GdkPixbuf from . import LauncherPlugin class CliphistPlugin(LauncherPlugin): """Plugin for clipboard history functionality using cliphist""" @property def name(self) -> str: return "Clipboard History" @property def category(self) -> str: return "Utilities" @property def icon_name(self) -> str: return "edit-paste-symbolic" def search(self, query: str) -> List[Dict[str, Any]]: if not query: return [] query_lower = query.lower() # Show clipboard history when user types "clip" (like Albert launcher) if query_lower == "clip" or query_lower == "clipboard": return self._get_clipboard_history() # Also show clipboard history if query starts with "clip " for further filtering if query_lower.startswith("clip ") and len(query) > 5: search_term = query[5:].strip() # Remove "clip " prefix return self._get_clipboard_history(search_term) return [] def _get_clipboard_history(self, search_term: str = "") -> List[Dict[str, Any]]: """Get clipboard history, optionally filtered by search term""" try: # Get clipboard history from cliphist result = subprocess.run( ["cliphist", "list"], capture_output=True, text=True, timeout=5 ) if result.returncode != 0: return [] entries = result.stdout.strip().split('\n') if not entries or entries == ['']: return [] results = [] search_lower = search_term.lower() if search_term else "" for entry in entries[:50]: # Limit to first 50 entries for performance if not entry.strip(): continue # Parse entry format: "ID\tCONTENT" parts = entry.split('\t', 1) if len(parts) != 2: continue entry_id, content = parts content = content.strip() # Check if this is an image is_image = self._is_image_data(content) # Skip binary data entries that aren't images if content.startswith('[[') and 'binary data' in content and not is_image: continue # If we have a search term, filter by it if search_term and search_lower not in content.lower(): continue # Prepare display content and icon if is_image: display_content = "[Image]" # Try to load image preview image_pixbuf = self._load_image_preview(entry_id) if image_pixbuf: result_item = { "title": display_content, "image_pixbuf": image_pixbuf, # Custom field for image preview "action": lambda eid=entry_id: self.copy_to_clipboard(eid), "clip_id": entry_id, } else: result_item = { "title": display_content, "icon_name": "image-x-generic-symbolic", "action": lambda eid=entry_id: self.copy_to_clipboard(eid), "clip_id": entry_id, } else: # Truncate long content for display display_content = content if len(display_content) > 80: display_content = display_content[:77] + "..." # Replace newlines with spaces for display display_content = display_content.replace('\n', ' ').replace('\r', ' ') result_item = { "title": display_content, "icon_name": "edit-paste-symbolic", "action": lambda eid=entry_id: self.copy_to_clipboard(eid), "clip_id": entry_id, } results.append(result_item) # # Limit results to prevent overwhelming the UI # if len(results) >= 15: # break # Sort results by relevance if we have a search term if search_term: def sort_key(item): title = item["title"].lower() if title.startswith(search_lower): return 0 # Starts with query elif search_lower in title: return 1 # Contains query else: return 2 # Other matches results.sort(key=sort_key) return results except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): return [] except Exception as e: print(f"Error in cliphist plugin: {e}") return [] def copy_to_clipboard(self, entry_id: str): """Copy clipboard history entry back to clipboard""" try: # Use cliphist decode to get the content and pipe it to wl-copy decode_process = subprocess.Popen( ["cliphist", "decode", entry_id], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL ) copy_process = subprocess.Popen( ["wl-copy"], stdin=decode_process.stdout, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) decode_process.stdout.close() # Allow decode_process to receive SIGPIPE copy_process.wait(timeout=2) decode_process.wait(timeout=2) print(f"Copied clipboard history entry {entry_id}") except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): try: # Fallback to xclip decode_process = subprocess.Popen( ["cliphist", "decode", entry_id], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True ) content, _ = decode_process.communicate(timeout=2) if decode_process.returncode == 0: subprocess.Popen( ["xclip", "-selection", "clipboard"], input=content, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) print(f"Copied clipboard history entry {entry_id} (xclip)") except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): print(f"Could not copy clipboard history entry {entry_id}") except Exception as e: print(f"Error copying clipboard entry: {e}") def _is_image_data(self, content): """Determine if clipboard content is likely an image""" return ( content.startswith("data:image/") or content.startswith("\x89PNG") or content.startswith("GIF8") or content.startswith("\xff\xd8\xff") or re.match(r'^\s* height: new_width = max_size new_height = int(height * (max_size / width)) else: new_height = max_size new_width = int(width * (max_size / height)) scaled_pixbuf = pixbuf.scale_simple( new_width, new_height, GdkPixbuf.InterpType.BILINEAR ) return scaled_pixbuf except Exception as e: pass return None def get_action_items(self, query: str) -> List[Dict[str, Any]]: """Get quick action items for clipboard history""" if not query or len(query) < 2: return [] return [ { "title": f'Search clipboard history for "{query}"', "icon_name": "edit-paste-symbolic", "action": lambda: None, # No specific action needed } ]