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/__init__.py +0 -0
- margin/batch.py +41 -0
- margin/claude.py +89 -0
- margin/cli.py +294 -0
- margin/config.py +188 -0
- margin/fold.py +750 -0
- margin/images.py +66 -0
- margin/inbox.py +335 -0
- margin/outline.py +282 -0
- margin/patches.py +243 -0
- margin/pinch_check.py +363 -0
- margin/skills/margin/SKILL.md +95 -0
- margin/store.py +186 -0
- margin/sync.py +666 -0
- margin-0.2.1.dist-info/METADATA +113 -0
- margin-0.2.1.dist-info/RECORD +20 -0
- margin-0.2.1.dist-info/WHEEL +5 -0
- margin-0.2.1.dist-info/entry_points.txt +2 -0
- margin-0.2.1.dist-info/licenses/LICENSE +21 -0
- margin-0.2.1.dist-info/top_level.txt +1 -0
margin/outline.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""A small client for Outline's RPC-style API (POST /api/<method>, JSON in and out).
|
|
2
|
+
|
|
3
|
+
Only what margin needs: collections, documents, comments and webhook subscriptions. Standard library
|
|
4
|
+
only. Errors raise OutlineError with the HTTP status and Outline's own error code, so a caller can
|
|
5
|
+
tell "the text to patch was not found" (404) from "not allowed" (403) from "the server is down".
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import socket
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OutlineError(Exception):
|
|
18
|
+
def __init__(self, method: str, status: int, code: str, message: str):
|
|
19
|
+
super().__init__(f"{method}: {status} {code} {message}")
|
|
20
|
+
self.method, self.status, self.code, self.message = method, status, code, message
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pin_host(host: str, ip: str) -> None:
|
|
24
|
+
"""Resolve `host` to `ip` for this process. TLS still checks the certificate against `host`.
|
|
25
|
+
|
|
26
|
+
Needed on a machine that serves Outline over Tailscale but cannot resolve its own MagicDNS name.
|
|
27
|
+
"""
|
|
28
|
+
real = socket.getaddrinfo
|
|
29
|
+
|
|
30
|
+
def gai(h, *a, **k):
|
|
31
|
+
return real(ip if h == host else h, *a, **k)
|
|
32
|
+
|
|
33
|
+
socket.getaddrinfo = gai
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: what a proxy answers while Outline itself is down or restarting: worth waiting for, not a fault
|
|
37
|
+
GATEWAY = (502, 503, 504)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Outline:
|
|
41
|
+
def __init__(self, url: str, api_key: str, timeout: float = 30.0):
|
|
42
|
+
self.url = url.rstrip("/")
|
|
43
|
+
self.api_key = api_key
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self._me: dict | None = None
|
|
46
|
+
|
|
47
|
+
def call(self, method: str, body: dict | None = None, retries: int = 3,
|
|
48
|
+
headers: dict | None = None) -> dict:
|
|
49
|
+
data = json.dumps(body or {}).encode()
|
|
50
|
+
req = urllib.request.Request(
|
|
51
|
+
f"{self.url}/api/{method}", data,
|
|
52
|
+
{"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}",
|
|
53
|
+
**(headers or {})})
|
|
54
|
+
for attempt in range(retries + 1):
|
|
55
|
+
try:
|
|
56
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
57
|
+
return json.loads(r.read())
|
|
58
|
+
except urllib.error.HTTPError as e:
|
|
59
|
+
raw = e.read().decode(errors="replace")
|
|
60
|
+
try:
|
|
61
|
+
j = json.loads(raw)
|
|
62
|
+
except ValueError:
|
|
63
|
+
j = {}
|
|
64
|
+
if e.code == 429 and attempt < retries:
|
|
65
|
+
time.sleep(float(e.headers.get("Retry-After") or 2 ** attempt))
|
|
66
|
+
continue
|
|
67
|
+
if e.code in GATEWAY and attempt < retries:
|
|
68
|
+
time.sleep(2 ** attempt)
|
|
69
|
+
continue
|
|
70
|
+
raise OutlineError(method, e.code, j.get("error", ""), j.get("message", raw[:200]))
|
|
71
|
+
except urllib.error.URLError:
|
|
72
|
+
if attempt < retries:
|
|
73
|
+
time.sleep(2 ** attempt)
|
|
74
|
+
continue
|
|
75
|
+
raise
|
|
76
|
+
raise AssertionError("unreachable")
|
|
77
|
+
|
|
78
|
+
def data(self, method: str, body: dict | None = None):
|
|
79
|
+
return self.call(method, body).get("data")
|
|
80
|
+
|
|
81
|
+
def structure(self, doc_id: str) -> dict | None:
|
|
82
|
+
"""A document as Outline's editor holds it (ProseMirror JSON), comment marks included.
|
|
83
|
+
API version 3 returns it in place of the markdown; the markdown has no comment marks."""
|
|
84
|
+
got = self.call("documents.info", {"id": doc_id}, headers={"x-api-version": "3"})
|
|
85
|
+
doc = (got.get("data") or {})
|
|
86
|
+
return (doc.get("document") or doc).get("data")
|
|
87
|
+
|
|
88
|
+
def paged(self, method: str, body: dict | None = None, limit: int = 100):
|
|
89
|
+
"""Every row of a paginated list method."""
|
|
90
|
+
offset = 0
|
|
91
|
+
while True:
|
|
92
|
+
page = self.data(method, {**(body or {}), "limit": limit, "offset": offset}) or []
|
|
93
|
+
yield from page
|
|
94
|
+
if len(page) < limit:
|
|
95
|
+
return
|
|
96
|
+
offset += limit
|
|
97
|
+
|
|
98
|
+
def wait_ready(self, timeout: float = 600.0, every: float = 10.0, log=None) -> dict:
|
|
99
|
+
"""Who the key belongs to, once Outline answers: a server that is down or restarting (a
|
|
100
|
+
gateway error, or no connection) is waited for until `timeout` seconds, then raised. Any
|
|
101
|
+
other error (a bad key, say) is raised at once."""
|
|
102
|
+
deadline = time.monotonic() + timeout
|
|
103
|
+
while True:
|
|
104
|
+
try:
|
|
105
|
+
return self.me()
|
|
106
|
+
except (OutlineError, urllib.error.URLError) as e:
|
|
107
|
+
if isinstance(e, OutlineError) and e.status not in GATEWAY:
|
|
108
|
+
raise
|
|
109
|
+
if time.monotonic() >= deadline:
|
|
110
|
+
raise
|
|
111
|
+
if log:
|
|
112
|
+
log(f"waiting for Outline: {e}")
|
|
113
|
+
time.sleep(every)
|
|
114
|
+
|
|
115
|
+
# ---- who am I
|
|
116
|
+
def me(self) -> dict:
|
|
117
|
+
if self._me is None:
|
|
118
|
+
self._me = self.data("auth.info")["user"]
|
|
119
|
+
return self._me
|
|
120
|
+
|
|
121
|
+
# ---- collections
|
|
122
|
+
def collection_by_name(self, name: str) -> dict | None:
|
|
123
|
+
for c in self.paged("collections.list"):
|
|
124
|
+
if c["name"] == name:
|
|
125
|
+
return c
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def ensure_collection(self, name: str, description: str = "") -> dict:
|
|
129
|
+
return self.collection_by_name(name) or self.data(
|
|
130
|
+
"collections.create", {"name": name, "description": description,
|
|
131
|
+
"permission": "read_write", "sharing": False})
|
|
132
|
+
|
|
133
|
+
# ---- documents
|
|
134
|
+
def create_document(self, collection_id: str, title: str, text: str,
|
|
135
|
+
parent_id: str | None = None) -> dict:
|
|
136
|
+
body = {"collectionId": collection_id, "title": title, "text": text, "publish": True}
|
|
137
|
+
if parent_id:
|
|
138
|
+
body["parentDocumentId"] = parent_id
|
|
139
|
+
return self.data("documents.create", body)
|
|
140
|
+
|
|
141
|
+
def document(self, doc_id: str) -> dict:
|
|
142
|
+
return self.data("documents.info", {"id": doc_id})
|
|
143
|
+
|
|
144
|
+
def patch(self, doc_id: str, find: str, text: str) -> dict:
|
|
145
|
+
"""Replace the first occurrence of `find` (markdown) with `text`. Keeps comments elsewhere."""
|
|
146
|
+
return self.data("documents.update",
|
|
147
|
+
{"id": doc_id, "editMode": "patch", "findText": find, "text": text})
|
|
148
|
+
|
|
149
|
+
def append(self, doc_id: str, text: str) -> dict:
|
|
150
|
+
"""Add `text` after the last block. Touches no existing block, so every comment stays."""
|
|
151
|
+
return self.data("documents.update", {"id": doc_id, "editMode": "append", "text": text})
|
|
152
|
+
|
|
153
|
+
def replace(self, doc_id: str, text: str) -> dict:
|
|
154
|
+
"""Replace the whole body. Removes every comment anchor, so only for docs margin owns
|
|
155
|
+
outright (the scratch document), never for a document someone may have commented on."""
|
|
156
|
+
return self.data("documents.update", {"id": doc_id, "editMode": "replace", "text": text})
|
|
157
|
+
|
|
158
|
+
def revisions(self, doc_id: str) -> list[dict]:
|
|
159
|
+
"""Every saved revision of a document, each with `createdBy`."""
|
|
160
|
+
return list(self.paged("revisions.list", {"documentId": doc_id}))
|
|
161
|
+
|
|
162
|
+
def set_title(self, doc_id: str, title: str) -> dict:
|
|
163
|
+
return self.data("documents.update", {"id": doc_id, "title": title})
|
|
164
|
+
|
|
165
|
+
# ---- attachments
|
|
166
|
+
def upload(self, name: str, content_type: str, data: bytes, document_id: str) -> str:
|
|
167
|
+
"""Upload a file as an attachment of `document_id` and return the address a document uses
|
|
168
|
+
for it (`/api/attachments.redirect?id=...`).
|
|
169
|
+
|
|
170
|
+
Outline answers attachments.create with where to send the bytes: its own files.create when
|
|
171
|
+
it stores files itself, or a storage service's pre-signed address. The API key goes only to
|
|
172
|
+
Outline's own address, never to a storage service."""
|
|
173
|
+
import uuid
|
|
174
|
+
d = self.data("attachments.create", {"name": name, "contentType": content_type,
|
|
175
|
+
"size": len(data), "documentId": document_id,
|
|
176
|
+
"preset": "documentAttachment"})
|
|
177
|
+
up = d["uploadUrl"]
|
|
178
|
+
own = up.startswith("/") or up.startswith(self.url + "/")
|
|
179
|
+
if up.startswith("/"):
|
|
180
|
+
up = self.url + up
|
|
181
|
+
b = "----margin" + uuid.uuid4().hex
|
|
182
|
+
parts = [f'--{b}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
|
|
183
|
+
for k, v in (d.get("form") or {}).items()]
|
|
184
|
+
parts.append(f'--{b}\r\nContent-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
|
185
|
+
f"Content-Type: {content_type}\r\n\r\n".encode() + data + b"\r\n")
|
|
186
|
+
parts.append(f"--{b}--\r\n".encode())
|
|
187
|
+
headers = {"Content-Type": f"multipart/form-data; boundary={b}"}
|
|
188
|
+
if own:
|
|
189
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
190
|
+
req = urllib.request.Request(up, b"".join(parts), headers)
|
|
191
|
+
try:
|
|
192
|
+
with urllib.request.urlopen(req, timeout=max(self.timeout, 120)) as r:
|
|
193
|
+
r.read()
|
|
194
|
+
except urllib.error.HTTPError as e:
|
|
195
|
+
raise OutlineError("upload", e.code, "", e.read().decode(errors="replace")[:200])
|
|
196
|
+
return d["attachment"]["url"]
|
|
197
|
+
|
|
198
|
+
# ---- comments
|
|
199
|
+
def comments(self, collection_id: str | None = None, document_id: str | None = None):
|
|
200
|
+
body: dict = {"includeAnchorText": True, "sort": "createdAt", "direction": "DESC"}
|
|
201
|
+
if collection_id:
|
|
202
|
+
body["collectionId"] = collection_id
|
|
203
|
+
if document_id:
|
|
204
|
+
body["documentId"] = document_id
|
|
205
|
+
return self.paged("comments.list", body)
|
|
206
|
+
|
|
207
|
+
def comment(self, comment_id: str) -> dict:
|
|
208
|
+
return self.data("comments.info", {"id": comment_id, "includeAnchorText": True})
|
|
209
|
+
|
|
210
|
+
def reply(self, document_id: str, parent_id: str | None, text: str,
|
|
211
|
+
anchor: str | None = None) -> dict:
|
|
212
|
+
body: dict = {"documentId": document_id, "text": text}
|
|
213
|
+
if parent_id:
|
|
214
|
+
body["parentCommentId"] = parent_id
|
|
215
|
+
if anchor:
|
|
216
|
+
body["anchorText"] = anchor
|
|
217
|
+
return self.data("comments.create", body)
|
|
218
|
+
|
|
219
|
+
def resolve(self, comment_id: str) -> dict:
|
|
220
|
+
return self.data("comments.resolve", {"id": comment_id})
|
|
221
|
+
|
|
222
|
+
# ---- webhooks
|
|
223
|
+
def webhooks(self) -> list[dict]:
|
|
224
|
+
return list(self.paged("webhookSubscriptions.list"))
|
|
225
|
+
|
|
226
|
+
def ensure_webhook(self, name: str, url: str, secret: str, events: list[str]) -> dict:
|
|
227
|
+
"""Create the subscription, or repair it. Outline switches a subscription off after
|
|
228
|
+
repeated failed deliveries, and an update switches it back on."""
|
|
229
|
+
for w in self.webhooks():
|
|
230
|
+
if w.get("url") == url:
|
|
231
|
+
if w.get("enabled") is False or sorted(w.get("events") or []) != sorted(events):
|
|
232
|
+
w = self.data("webhookSubscriptions.update",
|
|
233
|
+
{"id": w["id"], "name": name, "url": url, "secret": secret,
|
|
234
|
+
"events": events})
|
|
235
|
+
return w
|
|
236
|
+
return self.data("webhookSubscriptions.create",
|
|
237
|
+
{"name": name, "url": url, "secret": secret, "events": events})
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def doc_url(base: str, doc: dict) -> str:
|
|
241
|
+
return f"{base.rstrip('/')}{doc.get('url') or '/doc/' + doc['id']}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def comment_text(data: dict | None) -> str:
|
|
245
|
+
"""Plain text of a ProseMirror comment body. Mentions render as @Label."""
|
|
246
|
+
out: list[str] = []
|
|
247
|
+
|
|
248
|
+
def walk(node: dict) -> None:
|
|
249
|
+
t = node.get("type")
|
|
250
|
+
if t == "text":
|
|
251
|
+
out.append(node.get("text", ""))
|
|
252
|
+
elif t == "mention":
|
|
253
|
+
out.append("@" + (node.get("attrs", {}).get("label") or ""))
|
|
254
|
+
elif t == "hard_break":
|
|
255
|
+
out.append("\n")
|
|
256
|
+
for child in node.get("content", []) or []:
|
|
257
|
+
walk(child)
|
|
258
|
+
if t in ("paragraph", "heading", "list_item", "blockquote"):
|
|
259
|
+
out.append("\n")
|
|
260
|
+
|
|
261
|
+
walk(data or {})
|
|
262
|
+
return "".join(out).strip()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def mentions(data: dict | None) -> set[str]:
|
|
266
|
+
"""User ids mentioned in a ProseMirror comment body."""
|
|
267
|
+
found: set[str] = set()
|
|
268
|
+
|
|
269
|
+
def walk(node: dict) -> None:
|
|
270
|
+
if node.get("type") == "mention":
|
|
271
|
+
a = node.get("attrs", {})
|
|
272
|
+
if a.get("type", "user") == "user" and a.get("modelId"):
|
|
273
|
+
found.add(a["modelId"])
|
|
274
|
+
for child in node.get("content", []) or []:
|
|
275
|
+
walk(child)
|
|
276
|
+
|
|
277
|
+
walk(data or {})
|
|
278
|
+
return found
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def quote(s: str) -> str:
|
|
282
|
+
return urllib.parse.quote(s, safe="")
|
margin/patches.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Turn "the document is X, it should be Y" into the smallest patches Outline can apply.
|
|
2
|
+
|
|
3
|
+
Outline's patch edit replaces the FIRST occurrence of a piece of markdown and keeps every comment
|
|
4
|
+
anchored outside it. A comment anchored inside the replaced span loses its anchor (the thread stays,
|
|
5
|
+
pointing at nothing). So the patches must be as small as possible, and each one must be unique in
|
|
6
|
+
the document at the moment it is applied, or it lands on the wrong occurrence.
|
|
7
|
+
|
|
8
|
+
The planner works on lines. Hunks are applied bottom-up, so everything below a hunk already
|
|
9
|
+
matches the target and can always serve as context.
|
|
10
|
+
|
|
11
|
+
⭐ A CHANGED LINE IS PATCHED WORD BY WORD (2026-10-13). A paragraph is one line, so a line-sized
|
|
12
|
+
patch replaced a whole paragraph, and a comment anchored on words the edit had kept lost its
|
|
13
|
+
highlight (the anchor "Between 23:00 and 08:00" went when only a bracket after it was removed).
|
|
14
|
+
Outline patches a match inside one paragraph in place and keeps every mark outside the match, so
|
|
15
|
+
a line that changed into one other line is patched as the runs of words that changed, each with
|
|
16
|
+
just enough words around it to be unique. Only when that cannot be done does the whole line go. Context above a hunk is taken only from the
|
|
17
|
+
unchanged run directly above it. `apply` replays a plan locally, and `plan` checks its own result
|
|
18
|
+
with it before returning, so a plan that would not produce the target is never returned.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from difflib import SequenceMatcher
|
|
24
|
+
|
|
25
|
+
Patch = tuple[str, str] # (find, replace)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CannotPatch(Exception):
|
|
29
|
+
"""No sequence of unique patches reaches the target. The caller must not fall back to a full
|
|
30
|
+
replace on a document that may carry comments; it reports the document instead."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
#: a run of asterisks that starts with an escaped one: Outline writes two literal asterisks as `\**`
|
|
34
|
+
#: when it saves a whole body and as `\*\*` when it saves a patch
|
|
35
|
+
_ESCAPED_RUN = re.compile(r"\\\*(?:\\?\*)*")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: a run of spaces after text and before an inline picture
|
|
39
|
+
_BEFORE_PICTURE = re.compile(r"(?<=\S) +(?=!\[)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def spelled(line: str) -> str:
|
|
43
|
+
r"""A line with the spellings Outline itself varies made one: an escaped run of asterisks
|
|
44
|
+
(`\**` and `\*\*`), a hard line break (a backslash at the end of the line), which a patch
|
|
45
|
+
writes where a whole-body save wrote a plain newline, and the spaces before an inline picture. Nothing else: `\*a\*` and `*a*` stay apart,
|
|
46
|
+
because they mean different things."""
|
|
47
|
+
line = _ESCAPED_RUN.sub(lambda m: "\\" + "*" * m.group(0).count("*"), line)
|
|
48
|
+
# spaces between text and an inline picture: a patch writes one more (`[ ![`, three between two
|
|
49
|
+
# pictures), and markdown shows a run of spaces as one; indentation at a line's start is kept
|
|
50
|
+
line = _BEFORE_PICTURE.sub(" ", line)
|
|
51
|
+
return line[:-1] if line.endswith("\\") and not line.endswith("\\\\") else line
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def spelled_alike(a: str, b: str) -> bool:
|
|
55
|
+
"""Whether two texts differ only in the spellings `spelled` makes one."""
|
|
56
|
+
return a == b or [spelled(x) for x in a.split("\n")] == [spelled(x) for x in b.split("\n")]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def adopt(current: str, target: str) -> str:
|
|
60
|
+
"""The target with every line that is spelled alike in `current` written as `current` writes
|
|
61
|
+
it, so a plan changes only what really differs and never chases Outline's own spelling."""
|
|
62
|
+
a, b = current.split("\n"), target.split("\n")
|
|
63
|
+
out = b[:]
|
|
64
|
+
ops = SequenceMatcher(None, [spelled(x) for x in a], [spelled(x) for x in b], autojunk=False).get_opcodes()
|
|
65
|
+
for tag, i1, i2, j1, j2 in ops:
|
|
66
|
+
if tag == "equal":
|
|
67
|
+
out[j1:j2] = a[i1:i2]
|
|
68
|
+
return "\n".join(out)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def apply(text: str, patches: list[Patch]) -> str:
|
|
72
|
+
for find, repl in patches:
|
|
73
|
+
if find not in text:
|
|
74
|
+
raise CannotPatch(f"find text not present: {find[:60]!r}")
|
|
75
|
+
text = text.replace(find, repl, 1)
|
|
76
|
+
return text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _only_at(lines: list[str], lo: int, find: str) -> bool:
|
|
80
|
+
"""`find` occurs exactly once in the joined lines, starting at line `lo`. Overlapping matches
|
|
81
|
+
count, because Outline replaces the first occurrence wherever it starts."""
|
|
82
|
+
joined = "\n".join(lines)
|
|
83
|
+
at = sum(len(x) + 1 for x in lines[:lo])
|
|
84
|
+
return joined.find(find) == at and joined.find(find, at + 1) == -1
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_TOKEN = re.compile(r"\s+|\w+|[^\w\s]")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _offsets(tokens: list[str]) -> list[int]:
|
|
91
|
+
out = [0]
|
|
92
|
+
for t in tokens:
|
|
93
|
+
out.append(out[-1] + len(t))
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: the markers that come in pairs: bold, bold, strike-through and a code span
|
|
98
|
+
_PAIRED = re.compile(r"(?<!\\)(\*\*|__|~~|`)")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _cuts_markup(piece: str) -> bool:
|
|
102
|
+
r"""Whether a piece of a line holds one half of a paired marker. ⛔ Outline parses a patch's text
|
|
103
|
+
on its own, so a lone `**` comes back escaped: a patch that changed the words after `**Sweep`
|
|
104
|
+
left `\**Sweep ...:\**` in the document, the bold gone (2026-10-16, fifteen documents at
|
|
105
|
+
cannot-patch). Such a change is patched as its whole line."""
|
|
106
|
+
counts: dict[str, int] = {}
|
|
107
|
+
for m in _PAIRED.finditer(piece):
|
|
108
|
+
counts[m.group(1)] = counts.get(m.group(1), 0) + 1
|
|
109
|
+
return any(n % 2 for n in counts.values())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_row(line: str) -> bool:
|
|
113
|
+
"""A line of a markdown table."""
|
|
114
|
+
return line.lstrip().startswith("|")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _words(lines: list[str], i: int, new: str) -> list[Patch] | None:
|
|
118
|
+
"""Patches that turn line `i` of `lines` into `new`, touching only the words that changed. Each
|
|
119
|
+
is unique in the whole document when it is applied, and they go right to left so the rest of
|
|
120
|
+
the line still reads as it did. None when a change cannot be made unique inside its line.
|
|
121
|
+
`lines` is updated as the patches are planned."""
|
|
122
|
+
if _is_row(lines[i]) or _is_row(new):
|
|
123
|
+
return None # a table is patched whole (plan)
|
|
124
|
+
old_t, new_t = _TOKEN.findall(lines[i]), _TOKEN.findall(new)
|
|
125
|
+
if not old_t or not new_t or "".join(old_t) != lines[i]:
|
|
126
|
+
return None
|
|
127
|
+
out: list[Patch] = []
|
|
128
|
+
ops: list[list] = []
|
|
129
|
+
for op in SequenceMatcher(None, old_t, new_t, autojunk=False).get_opcodes():
|
|
130
|
+
# two changes with only spaces between them are one change ("an hour" to "a day")
|
|
131
|
+
if (len(ops) >= 2 and op[0] != "equal" and ops[-1][0] == "equal" and ops[-2][0] != "equal"
|
|
132
|
+
and not "".join(old_t[ops[-1][1]:ops[-1][2]]).strip()):
|
|
133
|
+
ops.pop()
|
|
134
|
+
ops[-1] = ["replace", ops[-1][1], op[2], ops[-1][3], op[4]]
|
|
135
|
+
else:
|
|
136
|
+
ops.append(list(op))
|
|
137
|
+
for tag, s1, s2, t1, t2 in reversed(ops):
|
|
138
|
+
if tag == "equal":
|
|
139
|
+
continue
|
|
140
|
+
at = _offsets(old_t)
|
|
141
|
+
left, right = s1, s2 # the span of old tokens the patch covers
|
|
142
|
+
while True:
|
|
143
|
+
find = lines[i][at[left]:at[right]]
|
|
144
|
+
if find.strip() and _only_at_col(lines, i, at[left], find):
|
|
145
|
+
break
|
|
146
|
+
# one more word of context, on the side with more room, never past the line
|
|
147
|
+
if left > 0 and (right >= len(old_t) or s1 - left <= right - s2):
|
|
148
|
+
left -= 1
|
|
149
|
+
elif right < len(old_t):
|
|
150
|
+
right += 1
|
|
151
|
+
else:
|
|
152
|
+
return None
|
|
153
|
+
repl = "".join(old_t[left:s1]) + "".join(new_t[t1:t2]) + "".join(old_t[s2:right])
|
|
154
|
+
if _cuts_markup(find) or _cuts_markup(repl):
|
|
155
|
+
return None # the whole line, so Outline reads both halves of every marker
|
|
156
|
+
lines[i] = lines[i][:at[left]] + repl + lines[i][at[right]:]
|
|
157
|
+
out.append((find, repl))
|
|
158
|
+
old_t = old_t[:s1] + new_t[t1:t2] + old_t[s2:] # right of here now reads as the target
|
|
159
|
+
return out if lines[i] == new else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _only_at_col(lines: list[str], i: int, col: int, find: str) -> bool:
|
|
163
|
+
"""`find` occurs exactly once in the joined lines, starting at column `col` of line `i`."""
|
|
164
|
+
joined = "\n".join(lines)
|
|
165
|
+
at = sum(len(x) + 1 for x in lines[:i]) + col
|
|
166
|
+
return joined.find(find) == at and joined.find(find, at + 1) == -1
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def plan(current: str, target: str, widen: int = 0) -> list[Patch]:
|
|
170
|
+
"""Patches from `current` to `target`. `widen` > 0 makes every patch also cross that many
|
|
171
|
+
blank lines (block boundaries). Outline patches a match inside ONE top-level block by
|
|
172
|
+
re-parsing just that block, and drops anything the replacement adds outside it; a match that
|
|
173
|
+
spans two blocks takes its region path instead, which keeps everything. The syncer widens
|
|
174
|
+
only after it sees a patch come back different from what it expected."""
|
|
175
|
+
if current == target:
|
|
176
|
+
return []
|
|
177
|
+
a, b = current.split("\n"), target.split("\n")
|
|
178
|
+
ops = SequenceMatcher(None, a, b, autojunk=False).get_opcodes()
|
|
179
|
+
work = a[:]
|
|
180
|
+
patches: list[Patch] = []
|
|
181
|
+
k = len(ops) - 1
|
|
182
|
+
while k >= 0:
|
|
183
|
+
tag, i1, i2, j1, j2 = ops[k]
|
|
184
|
+
if tag == "equal":
|
|
185
|
+
k -= 1
|
|
186
|
+
continue
|
|
187
|
+
if widen == 0 and tag == "replace" and i2 - i1 == j2 - j1:
|
|
188
|
+
trial, fine = work[:], []
|
|
189
|
+
for li in range(i2 - 1, i1 - 1, -1): # bottom-up, as the hunks are
|
|
190
|
+
if trial[li] == b[j1 + li - i1]:
|
|
191
|
+
continue
|
|
192
|
+
got = _words(trial, li, b[j1 + li - i1])
|
|
193
|
+
if got is None:
|
|
194
|
+
fine = None
|
|
195
|
+
break
|
|
196
|
+
fine += got
|
|
197
|
+
if fine is not None:
|
|
198
|
+
work = trial
|
|
199
|
+
patches += fine
|
|
200
|
+
k -= 1
|
|
201
|
+
continue
|
|
202
|
+
lo, hi, blo, bhi = i1, i2, j1, j2
|
|
203
|
+
# ⛔ A TABLE IS PATCHED WHOLE (2026-10-16). Outline took no patch that began inside a table:
|
|
204
|
+
# rows added at its end came back as a paragraph after it, the second row lost, and every
|
|
205
|
+
# later pass failed on the same table. A hunk that touches a table line, or sits right
|
|
206
|
+
# against one, covers the whole table and the blank line on each side, so it crosses blocks
|
|
207
|
+
# and Outline takes the path that keeps everything.
|
|
208
|
+
up_limit = ops[k - 1][1] if k > 0 and ops[k - 1][0] == "equal" else lo
|
|
209
|
+
near = work[lo:hi] + b[blo:bhi] + work[max(lo - 1, 0):lo] + work[hi:hi + 1]
|
|
210
|
+
if any(_is_row(x) for x in near):
|
|
211
|
+
# lines above the hunk, down to the run left unchanged, are the current's and the
|
|
212
|
+
# target's alike; lines below it already read as the target (the hunks go bottom-up)
|
|
213
|
+
while lo > up_limit and _is_row(work[lo - 1]):
|
|
214
|
+
lo, blo = lo - 1, blo - 1
|
|
215
|
+
if lo > up_limit and not work[lo - 1].strip():
|
|
216
|
+
lo, blo = lo - 1, blo - 1
|
|
217
|
+
while hi < len(work) and _is_row(work[hi]):
|
|
218
|
+
hi, bhi = hi + 1, bhi + 1
|
|
219
|
+
if hi < len(work) and not work[hi].strip():
|
|
220
|
+
hi, bhi = hi + 1, bhi + 1
|
|
221
|
+
while True:
|
|
222
|
+
# how far up we may borrow context: the unchanged run directly above the hunk
|
|
223
|
+
up_limit = ops[k - 1][1] if k > 0 and ops[k - 1][0] == "equal" else lo
|
|
224
|
+
find = "\n".join(work[lo:hi])
|
|
225
|
+
repl = "\n".join(b[blo:bhi])
|
|
226
|
+
if (find.strip() and (repl or bhi > blo) and _only_at(work, lo, find)
|
|
227
|
+
and sum(1 for x in work[lo:hi] if not x.strip()) >= widen):
|
|
228
|
+
break
|
|
229
|
+
if hi < len(work): # below the hunk already matches the target
|
|
230
|
+
hi, bhi = hi + 1, bhi + 1
|
|
231
|
+
elif lo > up_limit:
|
|
232
|
+
lo, blo = lo - 1, blo - 1
|
|
233
|
+
elif k >= 2: # swallow the run above and the hunk above it
|
|
234
|
+
k -= 2
|
|
235
|
+
lo, blo = ops[k][1], ops[k][3]
|
|
236
|
+
else:
|
|
237
|
+
raise CannotPatch("no unique context around a change")
|
|
238
|
+
work[lo:hi] = b[blo:bhi]
|
|
239
|
+
patches.append((find, repl))
|
|
240
|
+
k -= 1
|
|
241
|
+
if apply(current, patches) != target:
|
|
242
|
+
raise CannotPatch("plan does not reproduce the target")
|
|
243
|
+
return patches
|