xref: /plugin/localopen/README.md (revision f14deb164c2bf1c7648bd907566bf759fe418a8f)
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
69*f14deb16SLeonard Heyman1. Save 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
7502cf58fdSLeonard Heymanimport re
7602cf58fdSLeonard Heymanimport sys
7702cf58fdSLeonard Heymanimport subprocess
7802cf58fdSLeonard Heymanimport time
79b7faabe8SLeonard Heyman
80b7faabe8SLeonard HeymanHOST = "127.0.0.1"
81b7faabe8SLeonard HeymanPORT = 2222
8202cf58fdSLeonard HeymanTOKEN = "PUT-YOUR-TOKEN-HERE"
83b7faabe8SLeonard Heyman
8402cf58fdSLeonard Heymandef normalize_path(path: str) -> str:
8502cf58fdSLeonard Heyman    path = path.strip().strip('"').strip("'")
86b7faabe8SLeonard Heyman
8702cf58fdSLeonard Heyman    # Decode URL encoding first
88b7faabe8SLeonard Heyman    path = unquote(path)
89b7faabe8SLeonard Heyman
9002cf58fdSLeonard Heyman    # Replace forward slashes with backslashes (Windows-native)
9102cf58fdSLeonard Heyman    path = path.replace("/", "\\")
9202cf58fdSLeonard Heyman
9302cf58fdSLeonard Heyman    return path
9402cf58fdSLeonard Heyman
9502cf58fdSLeonard Heymandef is_allowed(path: str) -> bool:
9602cf58fdSLeonard Heyman    # Allow any drive letter like C:\, D:\, H:\, etc.
9702cf58fdSLeonard Heyman    if re.match(r'^[a-zA-Z]:\\', path):
9802cf58fdSLeonard Heyman        return True
9902cf58fdSLeonard Heyman
10002cf58fdSLeonard Heyman    # Allow UNC paths (network shares)
10102cf58fdSLeonard Heyman    if path.startswith('\\\\'):
10202cf58fdSLeonard Heyman        return True
10302cf58fdSLeonard Heyman
10402cf58fdSLeonard Heyman    return False
10502cf58fdSLeonard Heyman
10602cf58fdSLeonard Heyman
10702cf58fdSLeonard Heymandef focus_window(title):
10802cf58fdSLeonard Heyman    subprocess.Popen([
10902cf58fdSLeonard Heyman        # "powershell",
11002cf58fdSLeonard Heyman        "-NoProfile",
11102cf58fdSLeonard Heyman        "-Command",
11202cf58fdSLeonard Heyman        f"$wshell = New-Object -ComObject wscript.shell; $wshell.AppActivate('{title}')"
11302cf58fdSLeonard Heyman    ])
11402cf58fdSLeonard Heyman
11502cf58fdSLeonard Heyman
11602cf58fdSLeonard Heymanimport os
11702cf58fdSLeonard Heymanimport subprocess
11802cf58fdSLeonard Heyman
11902cf58fdSLeonard Heymandef open_path(path):
12002cf58fdSLeonard Heyman    ext = os.path.splitext(path)[1].lower()
12102cf58fdSLeonard Heyman
12202cf58fdSLeonard Heyman    if os.path.isdir(path):
12302cf58fdSLeonard Heyman        subprocess.Popen(["explorer.exe", path])
12402cf58fdSLeonard Heyman        time.sleep(0.5)
12502cf58fdSLeonard Heyman        focus_window("File Explorer")
12602cf58fdSLeonard Heyman    else:
12702cf58fdSLeonard Heyman        os.startfile(path)
12802cf58fdSLeonard Heyman        time.sleep(1)
12902cf58fdSLeonard Heyman
13002cf58fdSLeonard Heyman        if ext in [".xls", ".xlsx", ".xlsm"]:
13102cf58fdSLeonard Heyman            focus_window("Excel")
13202cf58fdSLeonard Heyman        elif ext == ".pdf":
13302cf58fdSLeonard 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":
14002cf58fdSLeonard Heyman            self.send_response(404)
14102cf58fdSLeonard Heyman            self.send_header("Content-Type", "text/plain; charset=utf-8")
14202cf58fdSLeonard Heyman            self.end_headers()
14302cf58fdSLeonard 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]
14802cf58fdSLeonard Heyman        raw_path = qs.get("path", [""])[0]
14902cf58fdSLeonard Heyman        path = normalize_path(unquote(raw_path))
150b7faabe8SLeonard Heyman
151b7faabe8SLeonard Heyman        if token != TOKEN:
15202cf58fdSLeonard Heyman            self.send_response(403)
15302cf58fdSLeonard Heyman            self.send_header("Content-Type", "text/plain; charset=utf-8")
15402cf58fdSLeonard Heyman            self.end_headers()
15502cf58fdSLeonard Heyman            self.wfile.write(b"Forbidden")
15602cf58fdSLeonard Heyman            return
15702cf58fdSLeonard Heyman
15802cf58fdSLeonard Heyman        if not path:
15902cf58fdSLeonard Heyman            self.send_response(400)
16002cf58fdSLeonard Heyman            self.send_header("Content-Type", "text/plain; charset=utf-8")
16102cf58fdSLeonard Heyman            self.end_headers()
16202cf58fdSLeonard Heyman            self.wfile.write(b"Missing path")
163b7faabe8SLeonard Heyman            return
164b7faabe8SLeonard Heyman
165b7faabe8SLeonard Heyman        if not is_allowed(path):
16602cf58fdSLeonard Heyman            self.send_response(403)
16702cf58fdSLeonard Heyman            self.send_header("Content-Type", "text/plain; charset=utf-8")
168b7faabe8SLeonard Heyman            self.end_headers()
16902cf58fdSLeonard Heyman            self.wfile.write(b"Path not allowed")
17002cf58fdSLeonard Heyman            return
171b7faabe8SLeonard Heyman
17202cf58fdSLeonard Heyman        # Convert to Windows path for existence check
17302cf58fdSLeonard Heyman        win_path = path.replace("/", "\\")
17402cf58fdSLeonard Heyman
17502cf58fdSLeonard Heyman        if not os.path.exists(win_path):
17602cf58fdSLeonard Heyman            self.send_response(404)
17702cf58fdSLeonard Heyman            self.send_header("Content-Type", "text/plain; charset=utf-8")
17802cf58fdSLeonard Heyman            self.end_headers()
17902cf58fdSLeonard Heyman            self.wfile.write(f"Path does not exist: {win_path}".encode("utf-8"))
18002cf58fdSLeonard Heyman            return
18102cf58fdSLeonard Heyman
18202cf58fdSLeonard Heyman        try:
18302cf58fdSLeonard Heyman            open_path(path)
18402cf58fdSLeonard Heyman            self.send_response(200)
18502cf58fdSLeonard Heyman            self.send_header("Content-Type", "text/html; charset=utf-8")
18602cf58fdSLeonard Heyman            self.end_headers()
18702cf58fdSLeonard Heyman            self.wfile.write(b"""<html><body>Opened.</body></html>""")
18802cf58fdSLeonard Heyman        except Exception as e:
18902cf58fdSLeonard Heyman            self.send_response(500)
19002cf58fdSLeonard Heyman            self.send_header("Content-Type", "text/plain; charset=utf-8")
19102cf58fdSLeonard Heyman            self.end_headers()
19202cf58fdSLeonard Heyman            self.wfile.write(f"Error: {e}".encode("utf-8"))
19302cf58fdSLeonard Heyman
19402cf58fdSLeonard Heyman    def log_message(self, format, *args):
19502cf58fdSLeonard Heyman        # Stay silent
196b7faabe8SLeonard Heyman        pass
197b7faabe8SLeonard Heyman
19802cf58fdSLeonard Heymandef main():
19902cf58fdSLeonard Heyman    try:
20002cf58fdSLeonard Heyman        server = HTTPServer((HOST, PORT), Handler)
20102cf58fdSLeonard Heyman    except OSError as e:
20202cf58fdSLeonard Heyman        sys.exit(f"Could not bind to {HOST}:{PORT}: {e}")
203b7faabe8SLeonard Heyman
20402cf58fdSLeonard Heyman    server.serve_forever()
20502cf58fdSLeonard Heyman
20602cf58fdSLeonard Heymanif __name__ == "__main__":
20702cf58fdSLeonard Heyman    main()
208*f14deb16SLeonard Heyman```
209*f14deb16SLeonard Heyman
210*f14deb16SLeonard Heyman# Run the LocalOpen Python Service at Windows Login (Task Scheduler)
211*f14deb16SLeonard Heyman
212*f14deb16SLeonard HeymanThis guide configures your Python script to run **silently** when you log into Windows, using Task Scheduler.
213*f14deb16SLeonard Heyman
214*f14deb16SLeonard Heyman---
215*f14deb16SLeonard Heyman
216*f14deb16SLeonard Heyman## Prerequisites
217*f14deb16SLeonard Heyman
218*f14deb16SLeonard Heyman- Python installed
219*f14deb16SLeonard Heyman- Your script (e.g., `local_linker.py`) working when run manually
220*f14deb16SLeonard Heyman- Path to `pythonw.exe` (important for no console window)
221*f14deb16SLeonard Heyman
222*f14deb16SLeonard HeymanExample Python path:  C:\Users\me\AppData\Local\Programs\Python\Python310\pythonw.exe
223*f14deb16SLeonard Heyman
224*f14deb16SLeonard Heyman
225*f14deb16SLeonard Heyman---
226*f14deb16SLeonard Heyman
227*f14deb16SLeonard Heyman## Step 1 — Open Task Scheduler
228*f14deb16SLeonard Heyman
229*f14deb16SLeonard Heyman1. Press `Win + R`
230*f14deb16SLeonard Heyman2. Enter:  `taskschd.msc`
231*f14deb16SLeonard Heyman3. Press Enter
232*f14deb16SLeonard Heyman
233*f14deb16SLeonard Heyman---
234*f14deb16SLeonard Heyman
235*f14deb16SLeonard Heyman## Step 2 — Create a New Task
236*f14deb16SLeonard Heyman
237*f14deb16SLeonard Heyman1. In Task Scheduler, click: `Create Task`
238*f14deb16SLeonard Heyman
239*f14deb16SLeonard Heyman> Do **not** use "Create Basic Task"
240*f14deb16SLeonard Heyman
241*f14deb16SLeonard Heyman---
242*f14deb16SLeonard Heyman
243*f14deb16SLeonard Heyman## Step 3 — Configure the General Tab
244*f14deb16SLeonard Heyman
245*f14deb16SLeonard Heyman- **Name:** `LocalOpen Service`
246*f14deb16SLeonard Heyman- Select:
247*f14deb16SLeonard Heyman  - ✅ Run only when user is logged on
248*f14deb16SLeonard Heyman  - ✅ Run with highest privileges *(optional but recommended)*
249*f14deb16SLeonard Heyman
250*f14deb16SLeonard Heyman---
251*f14deb16SLeonard Heyman
252*f14deb16SLeonard Heyman## Step 4 — Configure the Trigger
253*f14deb16SLeonard Heyman
254*f14deb16SLeonard Heyman1. Go to the **Triggers** tab
255*f14deb16SLeonard Heyman2. Click **New...**
256*f14deb16SLeonard Heyman
257*f14deb16SLeonard HeymanSet:
258*f14deb16SLeonard Heyman- **Begin the task:** `At log on`
259*f14deb16SLeonard Heyman- **User:** Your user account
260*f14deb16SLeonard Heyman
261*f14deb16SLeonard HeymanClick **OK**
262*f14deb16SLeonard Heyman
263*f14deb16SLeonard Heyman---
264*f14deb16SLeonard Heyman
265*f14deb16SLeonard Heyman## Step 5 — Configure the Action
266*f14deb16SLeonard Heyman
267*f14deb16SLeonard Heyman1. Go to the **Actions** tab
268*f14deb16SLeonard Heyman2. Click **New...**
269*f14deb16SLeonard Heyman
270*f14deb16SLeonard Heyman### Program/script
271*f14deb16SLeonard HeymanC:\Users\me\AppData\Local\Programs\Python\Python310\pythonw.exe
272*f14deb16SLeonard Heyman
273*f14deb16SLeonard Heyman
274*f14deb16SLeonard Heyman### Add arguments
275*f14deb16SLeonard Heyman"C:\Users\me\OneDrive\Apps\General Python Scripts\local_linker.py"
276*f14deb16SLeonard Heyman
277*f14deb16SLeonard Heyman
278*f14deb16SLeonard Heyman### Start in
279*f14deb16SLeonard HeymanC:\Users\me\OneDrive\Apps\General Python Scripts
280*f14deb16SLeonard Heyman
281*f14deb16SLeonard Heyman
282*f14deb16SLeonard HeymanClick **OK**
283*f14deb16SLeonard Heyman
284*f14deb16SLeonard Heyman---
285*f14deb16SLeonard Heyman
286*f14deb16SLeonard Heyman## Step 6 — Save the Task
287*f14deb16SLeonard Heyman
288*f14deb16SLeonard Heyman- Click **OK**
289*f14deb16SLeonard Heyman- Enter your Windows password if prompted
290*f14deb16SLeonard Heyman
291*f14deb16SLeonard Heyman---
292*f14deb16SLeonard Heyman
293*f14deb16SLeonard Heyman## Step 7 — Test the Task
294*f14deb16SLeonard Heyman
295*f14deb16SLeonard Heyman1. In Task Scheduler:
296*f14deb16SLeonard Heyman   - Right-click the task
297*f14deb16SLeonard Heyman   - Click **Run**
298*f14deb16SLeonard Heyman
299*f14deb16SLeonard Heyman2. Test a link in your wiki
300*f14deb16SLeonard Heyman
301*f14deb16SLeonard Heyman---
302*f14deb16SLeonard Heyman
303*f14deb16SLeonard Heyman## Verify It Is Running
304*f14deb16SLeonard Heyman
305*f14deb16SLeonard HeymanOpen PowerShell:
306*f14deb16SLeonard Heyman
307*f14deb16SLeonard Heyman```powershell
308*f14deb16SLeonard HeymanGet-Process pythonw
309