Last active
December 12, 2025 13:31
-
-
Save qianlifeng/43e8f545017fddea69e1198468d76776 to your computer and use it in GitHub Desktop.
Wox.Plugin.Script.RecentFile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # { | |
| # "Id": "d4cf7d41-5cc2-4c21-8ee6-fbd6ca950c6a", | |
| # "Name": "Recent Files", | |
| # "Author": "qianlifeng", | |
| # "Version": "1.0.0", | |
| # "MinWoxVersion": "2.0.0", | |
| # "Description": "List recently used files.", | |
| # "Icon": "emoji:🕘", | |
| # "TriggerKeywords": ["recent"], | |
| # "SupportedOS": ["windows"] | |
| # } | |
| """ | |
| Wox Python Script Plugin for Recent Files | |
| This plugin lists recently used files on Windows. | |
| """ | |
| from __future__ import annotations | |
| import datetime | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| from pathlib import Path | |
| from typing import Any, List, TypedDict | |
| class WoxPluginBase: | |
| """Wox plugin base class for script plugins. Don't modify this class directly.""" | |
| class ActionItem(TypedDict, total=False): | |
| """Type hint for an action in a result.""" | |
| id: str | |
| name: str | |
| text: str | |
| url: str | |
| path: str | |
| message: str | |
| data: Any | |
| class QueryResult(TypedDict, total=False): | |
| """Type hint for a query result item.""" | |
| title: str | |
| subtitle: str | |
| icon: str | |
| """ | |
| support following icon formats: | |
| - "base64:data:image/png;base64,xxx" | |
| - "emoji:😀" | |
| - "svg:<svg>...</svg>" | |
| - "fileicon:/absolute/path/to/file" (get system file icon) | |
| - "absolute:/absolute/path/to/image.png" | |
| - "url:https://example.com/image.png" | |
| """ | |
| score: int | |
| actions: List[WoxPluginBase.ActionItem] | |
| class ActionResult(TypedDict, total=False): | |
| """Type hint for action method return value.""" | |
| action: str | |
| message: str | |
| def __init__(self): | |
| self.log_file_path = __file__ + ".log" | |
| def log(self, message: str) -> None: | |
| """Log message to log file for debugging.""" | |
| ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| message = f"[{ts}] {message}" | |
| if self.is_invoke_from_wox(): | |
| with open(self.log_file_path, "a", encoding="utf-8") as f: | |
| f.write(f"{message}\n") | |
| else: | |
| print(f"LOG: {message}") | |
| def is_invoke_from_wox(self) -> bool: | |
| """Check if the script is invoked from Wox.""" | |
| return "WOX_PLUGIN_ID" in os.environ | |
| def _build_response(self, result: Any, request_id: Any) -> dict: | |
| """Build a successful JSON-RPC 2.0 response.""" | |
| return {"jsonrpc": "2.0", "result": result, "id": request_id} | |
| def _build_error_response( | |
| self, code: int, message: str, data: Any = None, request_id: Any = None | |
| ) -> dict: | |
| """Build a JSON-RPC 2.0 error response.""" | |
| error = {"code": code, "message": message} | |
| if data is not None: | |
| error["data"] = data | |
| return {"jsonrpc": "2.0", "error": error, "id": request_id} | |
| def query( | |
| self, raw_query: str, trigger_keyword: str, command: str, search: str | |
| ) -> List[WoxPluginBase.QueryResult]: | |
| """ | |
| Process user queries and return results. | |
| Args: | |
| raw_query: The full raw query string entered by the user | |
| trigger_keyword: The trigger keyword that activated this plugin | |
| command: The command part of the query (after trigger keyword) | |
| search: The search part of the query (after command) | |
| take `wpm install plugin-name` as an example | |
| raw_query: wpm install plugin-name | |
| trigger_keyword: wpm | |
| command: install | |
| search: plugin-name | |
| Returns: | |
| List of result dictionaries | |
| """ | |
| # Default implementation - override in your plugin class | |
| return [ | |
| { | |
| "title": "Hello Wox!", | |
| "subtitle": "This is a default result. Override the query method in your plugin class.", | |
| "score": 100, | |
| "actions": [ | |
| {"name": "Copy", "id": "copy-to-clipboard", "text": "Hello Wox!"} | |
| ], | |
| } | |
| ] | |
| def action(self, action_id: str, data: Any) -> None: | |
| """ | |
| Handle action requests (OPTIONAL - only needed for custom actions) | |
| Built-in actions (copy-to-clipboard, open-url, open-directory, notify) are handled | |
| automatically by Wox. You only need to implement this method if you have custom actions. | |
| Note: This method is called as a hook for ALL actions (built-in and custom), so you can | |
| optionally add additional logic for built-in actions if needed. | |
| Args: | |
| action_id: The ID of the action | |
| data: Additional data for the action | |
| """ | |
| # Default implementation - override for custom actions | |
| if action_id == "custom-action": | |
| # Custom action handling logic can be added here | |
| pass | |
| def handle_query(self, params, request_id): | |
| """Internal method to handle query requests""" | |
| search = params.get("search", "") | |
| trigger_keyword = params.get("trigger_keyword", "") | |
| command = params.get("command", "") | |
| raw_query = params.get("raw_query", "") | |
| results = self.query(raw_query, trigger_keyword, command, search) | |
| return self._build_response({"items": results}, request_id) | |
| def handle_action(self, params, request_id): | |
| """Internal method to handle action requests""" | |
| action_id = params.get("id", "") | |
| action_data = params.get("data", "") | |
| self.action(action_id, action_data) | |
| return self._build_response({}, request_id) | |
| def run(self): | |
| """Main entry point for the script plugin""" | |
| # Parse input | |
| if self.is_invoke_from_wox(): | |
| # Running from Wox - read from stdin | |
| try: | |
| stdin_text = sys.stdin.read() | |
| except Exception: | |
| return 1 | |
| else: | |
| # Manual testing mode | |
| print("Manual mode - please enter query:") | |
| query_input = input() | |
| stdin_text = f'{{"jsonrpc": "2.0", "method": "query", "params": {{"query": "{query_input}"}}, "id": 1}}' | |
| # Parse JSON-RPC 2.0 request | |
| try: | |
| request = json.loads(stdin_text) | |
| except json.JSONDecodeError as e: | |
| error_response = self._build_error_response( | |
| -32700, "Parse error", str(e), None | |
| ) | |
| print(json.dumps(error_response, ensure_ascii=False)) | |
| return 1 | |
| # Validate JSON-RPC 2.0 format | |
| if request.get("jsonrpc") != "2.0": | |
| error_response = self._build_error_response( | |
| -32600, "Invalid Request", None, request.get("id") | |
| ) | |
| print(json.dumps(error_response, ensure_ascii=False)) | |
| return 1 | |
| method = request.get("method") | |
| params = request.get("params", {}) | |
| request_id = request.get("id") | |
| # Handle different methods | |
| if method == "query": | |
| response = self.handle_query(params, request_id) | |
| elif method == "action": | |
| response = self.handle_action(params, request_id) | |
| else: | |
| # Method not found | |
| response = self._build_error_response( | |
| -32601, | |
| "Method not found", | |
| f"Method '{method}' not supported", | |
| request_id, | |
| ) | |
| # Output response | |
| print(json.dumps(response, ensure_ascii=False)) | |
| return 0 | |
| class RecentFilePlugin(WoxPluginBase): | |
| def _parse_lnk_targets_batch(self, lnk_files: List[Path]) -> dict: | |
| """Parse multiple .lnk files in a single PowerShell call for better performance.""" | |
| if not lnk_files: | |
| return {} | |
| try: | |
| # Build PowerShell script to parse all shortcuts at once | |
| ps_lines = ['[Console]::OutputEncoding = [System.Text.Encoding]::UTF8'] | |
| ps_lines.append('$shell = New-Object -ComObject WScript.Shell') | |
| for i, lnk_path in enumerate(lnk_files): | |
| # Escape quotes in path | |
| escaped_path = str(lnk_path).replace('"', '`"') | |
| ps_lines.append(f'try {{ $target = $shell.CreateShortcut("{escaped_path}").TargetPath; Write-Output "IDX{i}|$target" }} catch {{ Write-Output "IDX{i}|" }}') | |
| ps_script = '; '.join(ps_lines) | |
| result = subprocess.run( | |
| ["powershell", "-NoProfile", "-Command", ps_script], | |
| capture_output=True, | |
| text=True, | |
| timeout=5, | |
| encoding="utf-8", | |
| errors="replace" | |
| ) | |
| # Parse results | |
| targets = {} | |
| if result.stdout: | |
| for line in result.stdout.strip().split('\n'): | |
| if '|' in line: | |
| idx_part, target = line.split('|', 1) | |
| if idx_part.startswith('IDX'): | |
| idx = int(idx_part[3:]) | |
| target = target.strip() | |
| if target and Path(target).exists(): | |
| targets[idx] = target | |
| return targets | |
| except Exception as e: | |
| self.log(f"batch parse error: {e}") | |
| return {} | |
| def _parse_lnk_target(self, path: Path) -> str: | |
| """Parse .lnk file using PowerShell COM interface.""" | |
| try: | |
| # Use PowerShell to call Windows Shell COM interface | |
| ps_script = f'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; (New-Object -ComObject WScript.Shell).CreateShortcut("{path}").TargetPath' | |
| result = subprocess.run( | |
| ["powershell", "-NoProfile", "-Command", ps_script], | |
| capture_output=True, | |
| text=True, | |
| timeout=2, | |
| encoding="utf-8", | |
| errors="replace" | |
| ) | |
| target = result.stdout.strip() if result.stdout else "" | |
| if target and Path(target).exists(): | |
| return target | |
| return "" | |
| except Exception as e: | |
| self.log(f"parse error {path}: {e}") | |
| return "" | |
| def _get_recent_files(self, max_items: int, query: str = "") -> List[WoxPluginBase.QueryResult]: | |
| appdata = os.environ.get("APPDATA", "") | |
| if not appdata: | |
| self.log("APPDATA missing") | |
| return [] | |
| recent_folder = Path(appdata) / "Microsoft" / "Windows" / "Recent" | |
| if not recent_folder.is_dir(): | |
| self.log(f"recent folder not found: {recent_folder}") | |
| return [] | |
| lnk_files = list(recent_folder.glob("*.lnk")) | |
| # Sort by modification time (when the shortcut was created/updated) | |
| lnk_files.sort(key=lambda p: p.stat().st_mtime, reverse=True) | |
| # Pre-filter by lnk filename if query exists | |
| # .lnk files usually have the same name as the target file | |
| query_lower = query.lower() if query else "" | |
| if query_lower: | |
| filtered_lnk_files = [ | |
| lnk for lnk in lnk_files | |
| if query_lower in lnk.stem.lower() | |
| ] | |
| # If filtering reduces too much, expand to check full lnk path | |
| if len(filtered_lnk_files) < max_items * 2: | |
| filtered_lnk_files = [ | |
| lnk for lnk in lnk_files | |
| if query_lower in str(lnk).lower() | |
| ] | |
| lnk_files = filtered_lnk_files | |
| self.log(f"pre-filtered to {len(lnk_files)} lnk files with query={query!r}") | |
| # Parse only what we need (with buffer for directories) | |
| parse_count = min(max_items * 2, len(lnk_files)) | |
| lnk_to_parse = lnk_files[:parse_count] | |
| targets = self._parse_lnk_targets_batch(lnk_to_parse) | |
| self.log(f"parsed {len(targets)} targets from {len(lnk_to_parse)} lnk files") | |
| # Build results | |
| results: List[WoxPluginBase.QueryResult] = [] | |
| for idx, lnk in enumerate(lnk_to_parse): | |
| if len(results) >= max_items: | |
| break | |
| if idx not in targets: | |
| continue | |
| target = targets[idx] | |
| # Skip directories, only show files | |
| if not os.path.isfile(target): | |
| continue | |
| # Double-check query filter on actual target path (in case lnk name differs) | |
| if query_lower: | |
| filename = Path(target).name.lower() | |
| fullpath = target.lower() | |
| if query_lower not in filename and query_lower not in fullpath: | |
| continue | |
| results.append({ | |
| "title": Path(target).name, | |
| "subtitle": target, | |
| "icon": "fileicon:" + target, | |
| "score": 100, | |
| "actions": [ | |
| {"name": "Open", "id": "open-file", "data": target} | |
| ], | |
| }) | |
| return results | |
| def query(self, raw_query: str, trigger_keyword: str, command: str, search: str) -> List[WoxPluginBase.QueryResult]: | |
| return self._get_recent_files(10, search) | |
| def action(self, action_id: str, data: Any) -> None: | |
| if action_id == "open-file": | |
| self.log(f"action: open-file {data}") | |
| file_path = data | |
| if os.path.isfile(file_path): | |
| os.startfile(file_path) | |
| if __name__ == "__main__": | |
| sys.exit(RecentFilePlugin().run()) |
Author
qianlifeng
commented
Dec 12, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment