forked from
lass.pds.lassul.us/superconfig
This repository has no description
3.7 kB
138 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: re-exec under nix shell with websocket-client
16 os.execvp(
17 "nix",
18 [
19 "nix",
20 "shell",
21 "nixpkgs#python3Packages.websocket-client",
22 "-c",
23 "python3",
24 __file__,
25 ]
26 + sys.argv[1:],
27 )
28
29HEDGEDOC_URL = "https://pad.lassul.us"
30
31
32def js_length(s):
33 """Return JavaScript string length (UTF-16 code units, not Unicode codepoints)."""
34 return len(s.encode("utf-16-le")) // 2
35
36
37def main():
38 if len(sys.argv) < 2:
39 print("Usage: pad-edit.py <pad-id> [file]", file=sys.stderr)
40 print(" echo 'content' | pad-edit.py <pad-id>", file=sys.stderr)
41 sys.exit(1)
42
43 pad_id = sys.argv[1]
44
45 # Read new content
46 if len(sys.argv) >= 3 and sys.argv[2] != "-":
47 with open(sys.argv[2]) as f:
48 new_content = f.read()
49 else:
50 new_content = sys.stdin.read()
51
52 # Strip single trailing newline (added by shell heredoc/echo)
53 if new_content.endswith("\n"):
54 new_content = new_content[:-1]
55
56 # Step 1: Get session cookie
57 ctx = ssl.create_default_context()
58 cj = http.cookiejar.CookieJar()
59 opener = urllib.request.build_opener(
60 urllib.request.HTTPCookieProcessor(cj),
61 urllib.request.HTTPSHandler(context=ctx),
62 )
63 opener.open(f"{HEDGEDOC_URL}/{pad_id}")
64 cookie_str = "; ".join(f"{c.name}={c.value}" for c in cj)
65 if not cookie_str:
66 print("ERROR: Failed to get session cookie", file=sys.stderr)
67 sys.exit(1)
68
69 # Step 2: Connect websocket
70 ws_url = f"wss://pad.lassul.us/socket.io/?noteId={pad_id}&EIO=4&transport=websocket"
71 ws = websocket.create_connection(
72 ws_url,
73 header=[f"Cookie: {cookie_str}", f"Origin: {HEDGEDOC_URL}"],
74 sslopt={"cert_reqs": ssl.CERT_REQUIRED},
75 )
76
77 ws.recv() # handshake: 0{"sid":...}
78 ws.send("40") # namespace connect
79
80 # Read until doc event, handle pings
81 revision = None
82 old_js_len = None
83 for _ in range(20):
84 ws.settimeout(5)
85 msg = ws.recv()
86 if msg == "2":
87 ws.send("3")
88 continue
89 if '"doc"' in msg:
90 payload = json.loads(msg[2:])
91 doc_data = payload[1]
92 revision = doc_data["revision"]
93 old_js_len = js_length(doc_data["str"])
94 break
95
96 if revision is None:
97 print("ERROR: Did not receive document state", file=sys.stderr)
98 ws.close()
99 sys.exit(1)
100
101 # Step 3: Build and send OT operation
102 escaped_new = json.dumps(new_content, ensure_ascii=False)
103
104 if old_js_len == 0:
105 op = f"[{escaped_new}]"
106 else:
107 op = f"[-{old_js_len},{escaped_new}]"
108
109 ws.send(f'42["operation",{revision},{op}]')
110
111 # Step 4: Wait for ack
112 ack_received = False
113 for _ in range(20):
114 try:
115 ws.settimeout(5)
116 msg = ws.recv()
117 if msg == "2":
118 ws.send("3")
119 continue
120 if '"ack"' in msg:
121 ack_received = True
122 break
123 except websocket.WebSocketTimeoutException:
124 break
125
126 ws.close()
127
128 if ack_received:
129 print(f"✅ Updated {HEDGEDOC_URL}/{pad_id}")
130 else:
131 print(
132 f"⚠️ No ack received. Check {HEDGEDOC_URL}/{pad_id}", file=sys.stderr
133 )
134 sys.exit(1)
135
136
137if __name__ == "__main__":
138 main()