xref: /plugin/localopen/README.md (revision 02cf58fd2b4d666e93689b7cb26cadb95340f487)
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