1b7faabe8SLeonard Heyman# DokuWiki Plugin: localopen 2b7faabe8SLeonard Heyman 3b7faabe8SLeonard HeymanOpen local files and folders directly from DokuWiki pages — **without browser popups or protocol handlers**. 4b7faabe8SLeonard Heyman 5b7faabe8SLeonard HeymanThis plugin replaces `localexplorer://`-style links with a modern, secure approach using a local HTTP service and background requests (`fetch()`), resulting in a seamless, one-click experience. 6b7faabe8SLeonard Heyman 7b7faabe8SLeonard Heyman--- 8b7faabe8SLeonard Heyman 9b7faabe8SLeonard Heyman## ✨ Features 10b7faabe8SLeonard Heyman 11b7faabe8SLeonard Heyman- Open local files (`.docx`, `.pdf`, etc.) and folders from wiki links 12b7faabe8SLeonard Heyman- No browser confirmation dialogs 13b7faabe8SLeonard Heyman- No blank tabs or navigation away from the wiki 14b7faabe8SLeonard Heyman- Works with: 15b7faabe8SLeonard Heyman - Local drives (`C:\`, `Z:\`, etc.) 16b7faabe8SLeonard Heyman - Network shares (`\\server\share`) 17b7faabe8SLeonard Heyman- Uses standard HTTP instead of custom protocol handlers 18b7faabe8SLeonard Heyman- Lightweight and fast 19b7faabe8SLeonard Heyman 20b7faabe8SLeonard Heyman--- 21b7faabe8SLeonard Heyman 22b7faabe8SLeonard Heyman## How It Works 23b7faabe8SLeonard Heyman 24b7faabe8SLeonard Heyman1. The plugin renders links like: \[\[localopen>c:\windows|Windows\]\] 25b7faabe8SLeonard Heyman 26b7faabe8SLeonard Heyman2. When clicked: 27b7faabe8SLeonard Heyman- A background `fetch()` request is sent to: 28b7faabe8SLeonard Heyman 29b7faabe8SLeonard Heyman ``` 30b7faabe8SLeonard Heyman http://127.0.0.1:2222/open?path=...&token=... 31b7faabe8SLeonard Heyman ``` 32b7faabe8SLeonard Heyman- A small Python service running locally receives the request 33b7faabe8SLeonard Heyman- The service opens the file using Windows (`os.startfile`) 34b7faabe8SLeonard Heyman 35b7faabe8SLeonard Heyman The browser never navigates away from the page. 36b7faabe8SLeonard Heyman 37b7faabe8SLeonard Heyman--- 38b7faabe8SLeonard Heyman 39b7faabe8SLeonard Heyman## Requirements 40b7faabe8SLeonard Heyman 41b7faabe8SLeonard Heyman- DokuWiki 42b7faabe8SLeonard Heyman- Python 3 (Windows) 43b7faabe8SLeonard Heyman- A local Python service (included below) 44b7faabe8SLeonard Heyman 45b7faabe8SLeonard Heyman--- 46b7faabe8SLeonard Heyman 47b7faabe8SLeonard Heyman## Installation 48b7faabe8SLeonard Heyman 49b7faabe8SLeonard Heyman### 1. Install the Plugin 50b7faabe8SLeonard Heyman 51b7faabe8SLeonard HeymanCopy the plugin into: lib/plugins/localopen/ 52b7faabe8SLeonard Heyman 53b7faabe8SLeonard Heyman--- 54b7faabe8SLeonard Heyman 55b7faabe8SLeonard Heyman### 2. Set Your Token 56b7faabe8SLeonard Heyman 57b7faabe8SLeonard HeymanChoose a secret token (any random string) 58b7faabe8SLeonard Heyman 59b7faabe8SLeonard Heyman 60b7faabe8SLeonard HeymanUpdate it in: 61b7faabe8SLeonard Heyman 62b7faabe8SLeonard Heyman- the plugin PHP file 63b7faabe8SLeonard Heyman- the Python service 64b7faabe8SLeonard Heyman 65b7faabe8SLeonard Heyman--- 66b7faabe8SLeonard Heyman 67b7faabe8SLeonard Heyman### 3. Run the Local Python Service 68b7faabe8SLeonard Heyman 69b7faabe8SLeonard HeymanSave this as `localopen_service.py`: 70b7faabe8SLeonard Heyman 71b7faabe8SLeonard Heyman```python 72b7faabe8SLeonard Heymanfrom http.server import BaseHTTPRequestHandler, HTTPServer 73b7faabe8SLeonard Heymanfrom urllib.parse import urlparse, parse_qs, unquote 74b7faabe8SLeonard Heymanimport os 75*02cf58fdSLeonard Heymanimport re 76*02cf58fdSLeonard Heymanimport sys 77*02cf58fdSLeonard Heymanimport subprocess 78*02cf58fdSLeonard Heymanimport time 79b7faabe8SLeonard Heyman 80b7faabe8SLeonard HeymanHOST = "127.0.0.1" 81b7faabe8SLeonard HeymanPORT = 2222 82*02cf58fdSLeonard HeymanTOKEN = "PUT-YOUR-TOKEN-HERE" 83b7faabe8SLeonard Heyman 84*02cf58fdSLeonard Heymandef normalize_path(path: str) -> str: 85*02cf58fdSLeonard Heyman path = path.strip().strip('"').strip("'") 86b7faabe8SLeonard Heyman 87*02cf58fdSLeonard Heyman # Decode URL encoding first 88b7faabe8SLeonard Heyman path = unquote(path) 89b7faabe8SLeonard Heyman 90*02cf58fdSLeonard Heyman # Replace forward slashes with backslashes (Windows-native) 91*02cf58fdSLeonard Heyman path = path.replace("/", "\\") 92*02cf58fdSLeonard Heyman 93*02cf58fdSLeonard Heyman return path 94*02cf58fdSLeonard Heyman 95*02cf58fdSLeonard Heymandef is_allowed(path: str) -> bool: 96*02cf58fdSLeonard Heyman # Allow any drive letter like C:\, D:\, H:\, etc. 97*02cf58fdSLeonard Heyman if re.match(r'^[a-zA-Z]:\\', path): 98*02cf58fdSLeonard Heyman return True 99*02cf58fdSLeonard Heyman 100*02cf58fdSLeonard Heyman # Allow UNC paths (network shares) 101*02cf58fdSLeonard Heyman if path.startswith('\\\\'): 102*02cf58fdSLeonard Heyman return True 103*02cf58fdSLeonard Heyman 104*02cf58fdSLeonard Heyman return False 105*02cf58fdSLeonard Heyman 106*02cf58fdSLeonard Heyman 107*02cf58fdSLeonard Heymandef focus_window(title): 108*02cf58fdSLeonard Heyman subprocess.Popen([ 109*02cf58fdSLeonard Heyman # "powershell", 110*02cf58fdSLeonard Heyman "-NoProfile", 111*02cf58fdSLeonard Heyman "-Command", 112*02cf58fdSLeonard Heyman f"$wshell = New-Object -ComObject wscript.shell; $wshell.AppActivate('{title}')" 113*02cf58fdSLeonard Heyman ]) 114*02cf58fdSLeonard Heyman 115*02cf58fdSLeonard Heyman 116*02cf58fdSLeonard Heymanimport os 117*02cf58fdSLeonard Heymanimport subprocess 118*02cf58fdSLeonard Heyman 119*02cf58fdSLeonard Heymandef open_path(path): 120*02cf58fdSLeonard Heyman ext = os.path.splitext(path)[1].lower() 121*02cf58fdSLeonard Heyman 122*02cf58fdSLeonard Heyman if os.path.isdir(path): 123*02cf58fdSLeonard Heyman subprocess.Popen(["explorer.exe", path]) 124*02cf58fdSLeonard Heyman time.sleep(0.5) 125*02cf58fdSLeonard Heyman focus_window("File Explorer") 126*02cf58fdSLeonard Heyman else: 127*02cf58fdSLeonard Heyman os.startfile(path) 128*02cf58fdSLeonard Heyman time.sleep(1) 129*02cf58fdSLeonard Heyman 130*02cf58fdSLeonard Heyman if ext in [".xls", ".xlsx", ".xlsm"]: 131*02cf58fdSLeonard Heyman focus_window("Excel") 132*02cf58fdSLeonard Heyman elif ext == ".pdf": 133*02cf58fdSLeonard Heyman focus_window(os.path.basename(path)) 134b7faabe8SLeonard Heyman 135b7faabe8SLeonard Heymanclass Handler(BaseHTTPRequestHandler): 136b7faabe8SLeonard Heyman def do_GET(self): 137b7faabe8SLeonard Heyman parsed = urlparse(self.path) 138b7faabe8SLeonard Heyman 139b7faabe8SLeonard Heyman if parsed.path != "/open": 140*02cf58fdSLeonard Heyman self.send_response(404) 141*02cf58fdSLeonard Heyman self.send_header("Content-Type", "text/plain; charset=utf-8") 142*02cf58fdSLeonard Heyman self.end_headers() 143*02cf58fdSLeonard Heyman self.wfile.write(b"Not found") 144b7faabe8SLeonard Heyman return 145b7faabe8SLeonard Heyman 146b7faabe8SLeonard Heyman qs = parse_qs(parsed.query) 147b7faabe8SLeonard Heyman token = qs.get("token", [""])[0] 148*02cf58fdSLeonard Heyman raw_path = qs.get("path", [""])[0] 149*02cf58fdSLeonard Heyman path = normalize_path(unquote(raw_path)) 150b7faabe8SLeonard Heyman 151b7faabe8SLeonard Heyman if token != TOKEN: 152*02cf58fdSLeonard Heyman self.send_response(403) 153*02cf58fdSLeonard Heyman self.send_header("Content-Type", "text/plain; charset=utf-8") 154*02cf58fdSLeonard Heyman self.end_headers() 155*02cf58fdSLeonard Heyman self.wfile.write(b"Forbidden") 156*02cf58fdSLeonard Heyman return 157*02cf58fdSLeonard Heyman 158*02cf58fdSLeonard Heyman if not path: 159*02cf58fdSLeonard Heyman self.send_response(400) 160*02cf58fdSLeonard Heyman self.send_header("Content-Type", "text/plain; charset=utf-8") 161*02cf58fdSLeonard Heyman self.end_headers() 162*02cf58fdSLeonard Heyman self.wfile.write(b"Missing path") 163b7faabe8SLeonard Heyman return 164b7faabe8SLeonard Heyman 165b7faabe8SLeonard Heyman if not is_allowed(path): 166*02cf58fdSLeonard Heyman self.send_response(403) 167*02cf58fdSLeonard Heyman self.send_header("Content-Type", "text/plain; charset=utf-8") 168b7faabe8SLeonard Heyman self.end_headers() 169*02cf58fdSLeonard Heyman self.wfile.write(b"Path not allowed") 170*02cf58fdSLeonard Heyman return 171b7faabe8SLeonard Heyman 172*02cf58fdSLeonard Heyman # Convert to Windows path for existence check 173*02cf58fdSLeonard Heyman win_path = path.replace("/", "\\") 174*02cf58fdSLeonard Heyman 175*02cf58fdSLeonard Heyman if not os.path.exists(win_path): 176*02cf58fdSLeonard Heyman self.send_response(404) 177*02cf58fdSLeonard Heyman self.send_header("Content-Type", "text/plain; charset=utf-8") 178*02cf58fdSLeonard Heyman self.end_headers() 179*02cf58fdSLeonard Heyman self.wfile.write(f"Path does not exist: {win_path}".encode("utf-8")) 180*02cf58fdSLeonard Heyman return 181*02cf58fdSLeonard Heyman 182*02cf58fdSLeonard Heyman try: 183*02cf58fdSLeonard Heyman open_path(path) 184*02cf58fdSLeonard Heyman self.send_response(200) 185*02cf58fdSLeonard Heyman self.send_header("Content-Type", "text/html; charset=utf-8") 186*02cf58fdSLeonard Heyman self.end_headers() 187*02cf58fdSLeonard Heyman self.wfile.write(b"""<html><body>Opened.</body></html>""") 188*02cf58fdSLeonard Heyman except Exception as e: 189*02cf58fdSLeonard Heyman self.send_response(500) 190*02cf58fdSLeonard Heyman self.send_header("Content-Type", "text/plain; charset=utf-8") 191*02cf58fdSLeonard Heyman self.end_headers() 192*02cf58fdSLeonard Heyman self.wfile.write(f"Error: {e}".encode("utf-8")) 193*02cf58fdSLeonard Heyman 194*02cf58fdSLeonard Heyman def log_message(self, format, *args): 195*02cf58fdSLeonard Heyman # Stay silent 196b7faabe8SLeonard Heyman pass 197b7faabe8SLeonard Heyman 198*02cf58fdSLeonard Heymandef main(): 199*02cf58fdSLeonard Heyman try: 200*02cf58fdSLeonard Heyman server = HTTPServer((HOST, PORT), Handler) 201*02cf58fdSLeonard Heyman except OSError as e: 202*02cf58fdSLeonard Heyman sys.exit(f"Could not bind to {HOST}:{PORT}: {e}") 203b7faabe8SLeonard Heyman 204*02cf58fdSLeonard Heyman server.serve_forever() 205*02cf58fdSLeonard Heyman 206*02cf58fdSLeonard Heymanif __name__ == "__main__": 207*02cf58fdSLeonard Heyman main() 208