This repository has no description
4.6 kB
153 lines
1#!/usr/bin/env python3
2"""Edit a HedgeDoc pad in-place via websocket (socket.io protocol)."""
3import sys
4import json
5import http.cookiejar
6import urllib.request
7import ssl
8
9try:
10 import websocket
11except ImportError:
12 import subprocess
13 import os
14
15 # Bootstrap: get the store path for websocket-client and re-exec with PYTHONPATH.
16 # Much faster than `nix shell` which sets up a full environment.
17 # Cache the store path in /tmp so subsequent runs skip nix entirely.
18 cache = "/tmp/.pad-edit-websocket-path"
19 store_path = None
20 if os.path.exists(cache):
21 store_path = open(cache).read().strip()
22 if not os.path.exists(store_path):
23 store_path = None
24
25 if not store_path:
26 store_path = subprocess.check_output(
27 ["nix", "build", "nixpkgs#python3Packages.websocket-client",
28 "--no-link", "--print-out-paths"],
29 text=True,
30 ).strip()
31 open(cache, "w").write(store_path)
32
33 # Find the site-packages dir inside the store path
34 import glob
35 site_dirs = glob.glob(f"{store_path}/lib/python*/site-packages")
36 if not site_dirs:
37 print("ERROR: Could not find site-packages in websocket-client store path", file=sys.stderr)
38 sys.exit(1)
39
40 env = os.environ.copy()
41 env["PYTHONPATH"] = site_dirs[0] + (":" + env["PYTHONPATH"] if "PYTHONPATH" in env else "")
42 os.execve(sys.executable, [sys.executable, __file__] + sys.argv[1:], env)
43
44HEDGEDOC_URL = "https://pad.lassul.us"
45
46
47def js_length(s):
48 """Return JavaScript string length (UTF-16 code units, not Unicode codepoints)."""
49 return len(s.encode("utf-16-le")) // 2
50
51
52def main():
53 if len(sys.argv) < 2:
54 print("Usage: pad-edit.py <pad-id> [file]", file=sys.stderr)
55 print(" echo 'content' | pad-edit.py <pad-id>", file=sys.stderr)
56 sys.exit(1)
57
58 pad_id = sys.argv[1]
59
60 # Read new content
61 if len(sys.argv) >= 3 and sys.argv[2] != "-":
62 with open(sys.argv[2]) as f:
63 new_content = f.read()
64 else:
65 new_content = sys.stdin.read()
66
67 # Strip single trailing newline (added by shell heredoc/echo)
68 if new_content.endswith("\n"):
69 new_content = new_content[:-1]
70
71 # Step 1: Get session cookie
72 ctx = ssl.create_default_context()
73 cj = http.cookiejar.CookieJar()
74 opener = urllib.request.build_opener(
75 urllib.request.HTTPCookieProcessor(cj),
76 urllib.request.HTTPSHandler(context=ctx),
77 )
78 opener.open(f"{HEDGEDOC_URL}/{pad_id}")
79 cookie_str = "; ".join(f"{c.name}={c.value}" for c in cj)
80 if not cookie_str:
81 print("ERROR: Failed to get session cookie", file=sys.stderr)
82 sys.exit(1)
83
84 # Step 2: Connect websocket
85 ws_url = f"wss://pad.lassul.us/socket.io/?noteId={pad_id}&EIO=4&transport=websocket"
86 ws = websocket.create_connection(
87 ws_url,
88 header=[f"Cookie: {cookie_str}", f"Origin: {HEDGEDOC_URL}"],
89 sslopt={"cert_reqs": ssl.CERT_REQUIRED},
90 )
91
92 ws.recv() # handshake: 0{"sid":...}
93 ws.send("40") # namespace connect
94
95 # Read until doc event, handle pings
96 revision = None
97 old_js_len = None
98 for _ in range(20):
99 ws.settimeout(5)
100 msg = ws.recv()
101 if msg == "2":
102 ws.send("3")
103 continue
104 if '"doc"' in msg:
105 payload = json.loads(msg[2:])
106 doc_data = payload[1]
107 revision = doc_data["revision"]
108 old_js_len = js_length(doc_data["str"])
109 break
110
111 if revision is None:
112 print("ERROR: Did not receive document state", file=sys.stderr)
113 ws.close()
114 sys.exit(1)
115
116 # Step 3: Build and send OT operation
117 escaped_new = json.dumps(new_content, ensure_ascii=False)
118
119 if old_js_len == 0:
120 op = f"[{escaped_new}]"
121 else:
122 op = f"[-{old_js_len},{escaped_new}]"
123
124 ws.send(f'42["operation",{revision},{op}]')
125
126 # Step 4: Wait for ack
127 ack_received = False
128 for _ in range(20):
129 try:
130 ws.settimeout(5)
131 msg = ws.recv()
132 if msg == "2":
133 ws.send("3")
134 continue
135 if '"ack"' in msg:
136 ack_received = True
137 break
138 except websocket.WebSocketTimeoutException:
139 break
140
141 ws.close()
142
143 if ack_received:
144 print(f"✅ Updated {HEDGEDOC_URL}/{pad_id}")
145 else:
146 print(
147 f"⚠️ No ack received. Check {HEDGEDOC_URL}/{pad_id}", file=sys.stderr
148 )
149 sys.exit(1)
150
151
152if __name__ == "__main__":
153 main()