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