margin 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
margin/images.py ADDED
@@ -0,0 +1,66 @@
1
+ """Pictures in a published file: a local image a markdown file shows is uploaded to Outline.
2
+
3
+ A file in the repository refers to its pictures by a relative path (`![The Time tab](shots/time.png)`),
4
+ which works on GitHub and in an editor but means nothing to Outline. Before a body is sent, each
5
+ such reference is uploaded once as an attachment of that document and the reference is pointed at
6
+ it. The repository file keeps its relative path.
7
+
8
+ ⭐ A PICTURE IS PART OF THE FILE. Its bytes count in what margin compares, so a changed picture is
9
+ published even when the markdown did not change, and an unchanged picture is never uploaded twice
10
+ (each upload is remembered by document and content).
11
+
12
+ ⭐ ATTACHED TO ITS DOCUMENT. An attachment belongs to the document it was uploaded for, so everyone
13
+ who can read that document can see it. One uploaded for another document (the private draft margin
14
+ uses to learn Outline's markdown) would be visible to margin's own account only.
15
+
16
+ Only images that are files next to the markdown are touched: a web address, an address Outline
17
+ already serves, a data: URI or a missing file is left as it is.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import re
23
+ from pathlib import Path
24
+
25
+ TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
26
+ ".webp": "image/webp", ".svg": "image/svg+xml"}
27
+
28
+ # ![alt](path) and ![alt](path "title"), the path optionally in <angle brackets>
29
+ IMAGE = re.compile(r'!\[(?P<alt>[^\]]*)\]\(\s*(?P<path><[^>]+>|[^)\s]+)(?P<title>\s+"[^"]*")?\s*\)')
30
+
31
+
32
+ def _target(ref: str) -> str:
33
+ return ref[1:-1] if ref.startswith("<") and ref.endswith(">") else ref
34
+
35
+
36
+ def local(body: str, base: Path) -> list[tuple[str, Path]]:
37
+ """(reference as written, file) for each local image the body shows, in order, once each."""
38
+ out, seen = [], set()
39
+ for m in IMAGE.finditer(body):
40
+ ref = m.group("path")
41
+ target = _target(ref)
42
+ if re.match(r"^[a-z][a-z0-9+.-]*:", target, re.I) or target.startswith(("/", "#")):
43
+ continue # a web address, data:, or one Outline already serves
44
+ path = (base / target.split("#", 1)[0].split("?", 1)[0]).resolve()
45
+ if path.suffix.lower() in TYPES and path.is_file() and ref not in seen:
46
+ seen.add(ref)
47
+ out.append((ref, path))
48
+ return out
49
+
50
+
51
+ def digest(body: str, base: Path) -> str:
52
+ """A line per local image with its content's hash, or "" when there is none. Added to the
53
+ file's text where margin compares it, so a changed picture counts as a changed file."""
54
+ lines = [f"<!-- margin image {ref} {hashlib.sha256(p.read_bytes()).hexdigest()} -->"
55
+ for ref, p in local(body, base)]
56
+ return "\n" + "\n".join(lines) if lines else ""
57
+
58
+
59
+ def point(body: str, urls: dict[str, str]) -> str:
60
+ """The body with each reference in `urls` replaced by its Outline address."""
61
+ def swap(m: re.Match) -> str:
62
+ url = urls.get(m.group("path"))
63
+ if url is None:
64
+ return m.group(0)
65
+ return f"![{m.group('alt')}]({url}{m.group('title') or ''})"
66
+ return IMAGE.sub(swap, body)
margin/inbox.py ADDED
@@ -0,0 +1,335 @@
1
+ """Comments in: the webhook receiver, the catch-up poll, and handing ready batches to a notifier.
2
+
3
+ The webhook is the fast path. The poll is the guarantee: every `poll_seconds` it asks Outline for
4
+ comments newer than the newest one it has seen, so a comment made while this process was down or
5
+ while the Mac slept is still delivered. Both paths write to the same table, keyed by comment id,
6
+ so a comment that arrives both ways is recorded once.
7
+
8
+ The agent's own comments are recorded as already delivered and with no route, so an app can show
9
+ a whole thread while the agent never wakes itself.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
22
+
23
+ from . import batch
24
+ from .config import Config
25
+ from .outline import Outline, OutlineError, comment_text, doc_url, mentions
26
+ from .store import Store
27
+
28
+ EVENTS = ["comments.create", "comments.update", "documents.update"]
29
+ #: how many characters before a passage are kept to find it again
30
+ BEFORE = 40
31
+
32
+
33
+ def anchor_before(doc: dict | None, comment_id: str) -> str | None:
34
+ """The text before a comment's passage in its paragraph (at most BEFORE characters, "" when the
35
+ passage opens the paragraph), or None when the document has no mark for the comment. A passage's
36
+ words alone can appear many times in a document ("search", "Network"); with the words before it
37
+ a reader finds the place the comment is on."""
38
+ found: list[str] = []
39
+
40
+ def text_of(n: dict) -> str:
41
+ if n.get("type") == "text":
42
+ return n.get("text") or ""
43
+ if n.get("type") == "mention":
44
+ return "@" + ((n.get("attrs") or {}).get("label") or "")
45
+ return "".join(text_of(c) for c in n.get("content") or [] if isinstance(c, dict))
46
+
47
+ def walk(n) -> None:
48
+ if not isinstance(n, dict) or found:
49
+ return
50
+ kids = [c for c in n.get("content") or [] if isinstance(c, dict)]
51
+ if any(c.get("type") == "text" for c in kids):
52
+ # a block of running text: its marked child opens the passage
53
+ seen = ""
54
+ for c in kids:
55
+ if any(m.get("type") == "comment" and (m.get("attrs") or {}).get("id") == comment_id
56
+ for m in c.get("marks") or []):
57
+ found.append(seen[-BEFORE:])
58
+ return
59
+ seen += text_of(c)
60
+ return
61
+ for c in kids:
62
+ walk(c)
63
+
64
+ walk(doc or {})
65
+ return found[0] if found else None
66
+
67
+
68
+ def verify(secret: str | None, header: str | None, body: bytes, tolerance: float = 300) -> bool:
69
+ """Outline-Signature is `t=<ms>,s=<hex>`, the hex being HMAC-SHA256 of `t + "." + body`."""
70
+ if not secret:
71
+ return True
72
+ if not header:
73
+ return False
74
+ parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
75
+ t, s = parts.get("t"), parts.get("s")
76
+ if not t or not s:
77
+ return False
78
+ want = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
79
+ try:
80
+ fresh = abs(time.time() - int(t) / 1000) <= tolerance
81
+ except ValueError:
82
+ return False
83
+ return fresh and hmac.compare_digest(want, s)
84
+
85
+
86
+ class Inbox:
87
+ def __init__(self, cfg: Config, outline: Outline, store: Store):
88
+ self.cfg, self.o, self.s = cfg, outline, store
89
+ self.agent_id = outline.me()["id"]
90
+ self.repos = {r.name: r for r in cfg.repos}
91
+ self.lock = threading.Lock()
92
+
93
+ # ---- recording
94
+ def route_of(self, document_id: str) -> str | None:
95
+ """Where a document's comments go: the route `routes` names for its path, else whoever last
96
+ linked it (recorded by `margin link`), else the repo's route."""
97
+ row = self.s.doc_by_id(document_id)
98
+ repo = self.repos.get(row["repo"]) if row else None
99
+ if repo is None:
100
+ return None
101
+ return repo.named_route(row["path"]) or self.s.get(f"author:{document_id}") or repo.route
102
+
103
+ def picture_of(self, document_id: str, comment_id: str) -> str | None:
104
+ """The picture a comment is attached to, by its caption, or None. A comment on a picture has
105
+ no anchor text (Outline gives an empty one), so without this it read as a comment on the
106
+ whole document and nobody could tell which picture it meant (2026-10-13)."""
107
+ try:
108
+ doc = self.o.structure(document_id)
109
+ except Exception: # noqa: BLE001 - the comment is kept either way
110
+ return None
111
+ found: list[str] = []
112
+
113
+ def walk(n, heading):
114
+ if not isinstance(n, dict):
115
+ return heading
116
+ if n.get("type") == "heading":
117
+ heading = "".join(c.get("text", "") for c in n.get("content") or [] if isinstance(c, dict))
118
+ if n.get("type") == "image" and any(
119
+ m.get("type") == "comment" and (m.get("attrs") or {}).get("id") == comment_id
120
+ for m in (n.get("attrs") or {}).get("marks") or []):
121
+ found.append((n.get("attrs") or {}).get("alt") or heading or "a picture")
122
+ for c in n.get("content") or []:
123
+ heading = walk(c, heading)
124
+ return heading
125
+
126
+ walk(doc or {}, None)
127
+ return f"the picture {found[0]}" if found else None
128
+
129
+ def before_of(self, document_id: str, comment_id: str) -> str | None:
130
+ """Where a comment's passage sits (anchor_before), read from the document's marks."""
131
+ try:
132
+ return anchor_before(self.o.structure(document_id), comment_id)
133
+ except Exception: # noqa: BLE001 - the comment is kept either way
134
+ return None
135
+
136
+ def place(self, document_id: str) -> tuple[int, int]:
137
+ """Record again where each thread's passage sits in a document, after its text changed
138
+ (margin patched it, or a person edited it in Outline): (placed, lost). A passage whose text
139
+ was rewritten loses its mark in Outline, and a reader must not guess a place for it."""
140
+ try:
141
+ doc = self.o.structure(document_id)
142
+ except Exception: # noqa: BLE001 - try again at the next change
143
+ return 0, 0
144
+ placed = lost = 0
145
+ for cid in self.s.anchored(document_id):
146
+ before = anchor_before(doc, cid)
147
+ self.s.set_place(cid, before)
148
+ placed, lost = placed + (before is not None), lost + (before is None)
149
+ return placed, lost
150
+
151
+ def record(self, c: dict, via: str) -> bool:
152
+ own = c.get("createdById") == self.agent_id
153
+ rec = {
154
+ "id": c["id"], "document_id": c["documentId"], "parent_id": c.get("parentCommentId"),
155
+ "author_id": c.get("createdById"), "author": (c.get("createdBy") or {}).get("name"),
156
+ "text": comment_text(c.get("data")),
157
+ "anchor": c.get("anchorText") or (None if c.get("parentCommentId")
158
+ else self.picture_of(c["documentId"], c["id"])),
159
+ "created_at": c["createdAt"], "mentions_agent": self.agent_id in mentions(c.get("data")),
160
+ "route": None if own else self.route_of(c["documentId"]), "via": via,
161
+ "resolved_at": c.get("resolvedAt"),
162
+ "anchor_before": (self.before_of(c["documentId"], c["id"])
163
+ if c.get("anchorText") and not c.get("parentCommentId") else None),
164
+ # the agent's own comment is kept for reading a thread, and is never work for it
165
+ "delivered_at": time.time() if own else None,
166
+ }
167
+ with self.lock:
168
+ return self.s.add_comment(rec) and not own
169
+
170
+ def on_webhook(self, event: dict) -> str:
171
+ kind = event.get("event")
172
+ payload = event.get("payload") or {}
173
+ if kind == "documents.update":
174
+ if payload.get("id"):
175
+ self.s.put(f"dirty:{payload['id']}", "1")
176
+ return "noted"
177
+ if kind not in ("comments.create", "comments.update"):
178
+ return "ignored"
179
+ model = payload.get("model")
180
+ if not model or model.get("anchorText") is None:
181
+ try: # the webhook body has no anchor text; ask for it
182
+ model = self.o.comment(payload.get("id") or model["id"])
183
+ except (OutlineError, KeyError, TypeError):
184
+ pass
185
+ if not model:
186
+ return "no model"
187
+ if kind == "comments.update":
188
+ with self.lock:
189
+ self.s.db.execute("UPDATE comment SET text=? WHERE id=? AND delivered_at IS NULL",
190
+ (comment_text(model.get("data")), model["id"]))
191
+ # a resolve or a reopen arrives as an update of the thread's first comment
192
+ self.s.set_resolved(model["id"], model.get("resolvedAt"))
193
+ return "updated"
194
+ return "recorded" if self.record(model, "webhook") else "known"
195
+
196
+ # ---- the catch-up poll
197
+ def poll(self) -> int:
198
+ n = 0
199
+ for repo in self.cfg.repos:
200
+ cid = self.s.get(f"collection:{repo.name}")
201
+ if not cid:
202
+ continue
203
+ key = f"poll:{repo.name}"
204
+ mark = self.s.get(key)
205
+ newest = mark
206
+ for c in self.o.comments(collection_id=cid):
207
+ if mark and c["createdAt"] <= mark:
208
+ break
209
+ newest = max(newest or "", c["createdAt"])
210
+ if mark is None:
211
+ continue # first run: learn where we are, deliver nothing old
212
+ n += self.record(c, "poll")
213
+ if mark is None and newest is None:
214
+ # first run on a collection with no comments yet: start from now, or the next
215
+ # pass would think it is the first run again and skip the first comments
216
+ newest = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
217
+ if newest:
218
+ self.s.put(key, newest)
219
+ return n
220
+
221
+ # ---- delivery
222
+ def render(self, route: str, rows) -> dict:
223
+ items, lines = [], []
224
+ by_doc: dict[str, list] = {}
225
+ for r in rows:
226
+ by_doc.setdefault(r["document_id"], []).append(r)
227
+ for doc_id, rs in by_doc.items():
228
+ row = self.s.doc_by_id(doc_id)
229
+ try:
230
+ d = self.o.document(doc_id)
231
+ title, url = d["title"], doc_url(self.cfg.url, d)
232
+ except OutlineError:
233
+ title, url = doc_id, ""
234
+ path = f"{row['repo']}/{row['path']}" if row else ""
235
+ lines.append(f"### {title}\n{path} {url}".rstrip())
236
+ for r in rs:
237
+ where = f'on "{r["anchor"]}"' if r["anchor"] else (
238
+ f"reply in thread {r['parent_id']}" if r["parent_id"] else "on the document")
239
+ lines.append(f"- [{r['id']}] {r['author']} {where}:\n {r['text']}")
240
+ items.append({**dict(r), "doc_title": title, "doc_url": url, "path": path})
241
+ lines.append("")
242
+ text = (f"{len(rows)} new comment(s) in Outline.\n\n" + "\n".join(lines) +
243
+ "\nAnswer each one in its thread: `margin reply <comment-id> \"...\"`. "
244
+ "Change the repo file when the comment asks for a change, then `margin sync`. "
245
+ "`margin resolve <comment-id>` once it is settled.")
246
+ first = self.s.doc_by_id(rows[0]["document_id"]) if rows else None
247
+ repo = self.repos.get(first["repo"]) if first else None
248
+ return {"route": route, "comments": items, "text": text,
249
+ "repo": repo.name if repo else "", "repo_path": str(repo.path) if repo else ""}
250
+
251
+ def deliver(self, now: float | None = None) -> list[str]:
252
+ now = time.time() if now is None else now
253
+ sent = []
254
+ with self.lock:
255
+ due = batch.ready(self.s.pending(), now, self.cfg.quiet_seconds, self.cfg.cap_seconds)
256
+ for route, rows in due.items():
257
+ msg = self.render(route, rows)
258
+ bid = uuid.uuid4().hex[:12]
259
+ msg["batch_id"] = bid
260
+ if self.notify(route, msg):
261
+ with self.lock:
262
+ self.s.delivered([r["id"] for r in rows], bid)
263
+ sent.append(route)
264
+ return sent
265
+
266
+ def notify(self, route: str, msg: dict) -> bool:
267
+ if not self.cfg.notify_command:
268
+ print(msg["text"], flush=True)
269
+ return True
270
+ subs = {"{route}": route, "{text}": msg["text"], "{repo}": msg.get("repo", ""),
271
+ "{repo_path}": msg.get("repo_path", "")}
272
+ argv = []
273
+ for a in self.cfg.notify_command:
274
+ for k, v in subs.items():
275
+ a = a.replace(k, v)
276
+ argv.append(a)
277
+ p = subprocess.run(argv, input=json.dumps(msg), text=True, capture_output=True,
278
+ cwd=self.cfg.notify_cwd)
279
+ if p.returncode != 0:
280
+ print(f"notifier failed for {route} ({p.returncode}): {p.stderr.strip()[:300]}",
281
+ file=sys.stderr, flush=True)
282
+ return p.returncode == 0
283
+
284
+
285
+ def serve(inbox: Inbox, sync_pass=None, sync_every: float = 60.0) -> None:
286
+ """Run the webhook receiver, then loop: deliver ready batches, poll, and sync."""
287
+ host, port = inbox.cfg.listen.rsplit(":", 1)
288
+ secret = inbox.cfg.secret()
289
+
290
+ class Handler(BaseHTTPRequestHandler):
291
+ def do_POST(self):
292
+ body = self.rfile.read(int(self.headers.get("Content-Length") or 0))
293
+ if self.path.rstrip("/") != "/outline" or not verify(
294
+ secret, self.headers.get("Outline-Signature"), body):
295
+ self.send_response(401)
296
+ self.end_headers()
297
+ return
298
+ try:
299
+ result = inbox.on_webhook(json.loads(body))
300
+ code = 200
301
+ except Exception as e: # never let one bad event stop the server
302
+ result, code = f"error: {e}", 500
303
+ self.send_response(code)
304
+ self.send_header("Content-Type", "text/plain")
305
+ self.end_headers()
306
+ self.wfile.write(result.encode())
307
+
308
+ def do_GET(self):
309
+ ok = self.path.rstrip("/") == "/health"
310
+ self.send_response(200 if ok else 404)
311
+ self.end_headers()
312
+ if ok:
313
+ self.wfile.write(json.dumps({"ok": True, "pending": len(inbox.s.pending())}).encode())
314
+
315
+ def log_message(self, *a):
316
+ pass
317
+
318
+ httpd = ThreadingHTTPServer((host, int(port)), Handler)
319
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
320
+ print(f"margin listening on {inbox.cfg.listen}", flush=True)
321
+ last_poll = last_sync = 0.0
322
+ while True:
323
+ now = time.time()
324
+ try:
325
+ if now - last_sync >= sync_every and sync_pass:
326
+ sync_pass()
327
+ last_sync = now
328
+ if now - last_poll >= inbox.cfg.poll_seconds:
329
+ inbox.poll()
330
+ last_poll = now
331
+ for route in inbox.deliver(now):
332
+ print(f"delivered a batch to {route}", flush=True)
333
+ except Exception as e:
334
+ print(f"loop error: {e}", file=sys.stderr, flush=True)
335
+ time.sleep(5)