overleaf-comments-export 0.2.0__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.
- overleaf_comments_export/__init__.py +1 -0
- overleaf_comments_export/__main__.py +143 -0
- overleaf_comments_export/anchors.py +61 -0
- overleaf_comments_export/client.py +440 -0
- overleaf_comments_export/export.py +892 -0
- overleaf_comments_export/gui.py +583 -0
- overleaf_comments_export/model.py +92 -0
- overleaf_comments_export/render.py +370 -0
- overleaf_comments_export/sections.py +96 -0
- overleaf_comments_export-0.2.0.dist-info/METADATA +156 -0
- overleaf_comments_export-0.2.0.dist-info/RECORD +15 -0
- overleaf_comments_export-0.2.0.dist-info/WHEEL +5 -0
- overleaf_comments_export-0.2.0.dist-info/entry_points.txt +2 -0
- overleaf_comments_export-0.2.0.dist-info/licenses/LICENSE +21 -0
- overleaf_comments_export-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from .anchors import build_line_starts, resolve_anchor
|
|
12
|
+
from .client import OverleafClient, parse_project_id
|
|
13
|
+
from .model import (
|
|
14
|
+
AnchoredComment,
|
|
15
|
+
DocText,
|
|
16
|
+
Message,
|
|
17
|
+
SourceContext,
|
|
18
|
+
Thread,
|
|
19
|
+
TrackedChange,
|
|
20
|
+
)
|
|
21
|
+
from .render import render_markdown
|
|
22
|
+
from .sections import find_headings, nearest_heading
|
|
23
|
+
|
|
24
|
+
SCHEMA_VERSION = "1.3"
|
|
25
|
+
# Characters of surrounding text captured on either side of an anchor. The
|
|
26
|
+
# renderer clips this to ~70 chars for compact mode and shows the full window
|
|
27
|
+
# for detailed mode, so the larger capture costs us at most a few KB per
|
|
28
|
+
# project but gives detailed mode actual extra context to show.
|
|
29
|
+
CONTEXT_CHARS_BEFORE = 160
|
|
30
|
+
CONTEXT_CHARS_AFTER = 160
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("overleaf_comments_export")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_ms(value: Any) -> int | None:
|
|
36
|
+
"""Accept ms-int, numeric string, or ISO 8601 string. Return ms since epoch."""
|
|
37
|
+
if value is None or value == "":
|
|
38
|
+
return None
|
|
39
|
+
if isinstance(value, bool):
|
|
40
|
+
return None
|
|
41
|
+
if isinstance(value, (int, float)):
|
|
42
|
+
return int(value)
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
s = value.strip()
|
|
45
|
+
if s.isdigit() or (s.startswith("-") and s[1:].isdigit()):
|
|
46
|
+
return int(s)
|
|
47
|
+
try:
|
|
48
|
+
iso = s.replace("Z", "+00:00")
|
|
49
|
+
dt = datetime.fromisoformat(iso)
|
|
50
|
+
if dt.tzinfo is None:
|
|
51
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
52
|
+
return int(dt.timestamp() * 1000)
|
|
53
|
+
except ValueError:
|
|
54
|
+
return None
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
ProgressCallback = Callable[[str], None]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _noop_progress(_: str) -> None:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class ExportResult:
|
|
66
|
+
project_id: str
|
|
67
|
+
markdown_path: Path
|
|
68
|
+
json_path: Path
|
|
69
|
+
log_path: Path
|
|
70
|
+
thread_count: int
|
|
71
|
+
open_count: int
|
|
72
|
+
resolved_count: int
|
|
73
|
+
tracked_change_count: int
|
|
74
|
+
stale_anchor_count: int
|
|
75
|
+
jsonl_path: Path | None = None
|
|
76
|
+
by_reviewer_dir: Path | None = None
|
|
77
|
+
agents_path: Path | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_user_map(threads_raw: dict[str, Any]) -> dict[str, dict[str, str]]:
|
|
81
|
+
users: dict[str, dict[str, str]] = {}
|
|
82
|
+
for thread in threads_raw.values():
|
|
83
|
+
if not isinstance(thread, dict):
|
|
84
|
+
continue
|
|
85
|
+
for msg in thread.get("messages", []) or []:
|
|
86
|
+
uid = msg.get("user_id") or msg.get("userId")
|
|
87
|
+
if not uid:
|
|
88
|
+
continue
|
|
89
|
+
user = msg.get("user") or {}
|
|
90
|
+
name = (
|
|
91
|
+
user.get("name")
|
|
92
|
+
or " ".join(
|
|
93
|
+
p for p in [user.get("first_name"), user.get("last_name")] if p
|
|
94
|
+
).strip()
|
|
95
|
+
or None
|
|
96
|
+
)
|
|
97
|
+
email = user.get("email")
|
|
98
|
+
existing = users.setdefault(str(uid), {})
|
|
99
|
+
if name and not existing.get("name"):
|
|
100
|
+
existing["name"] = name
|
|
101
|
+
if email and not existing.get("email"):
|
|
102
|
+
existing["email"] = email
|
|
103
|
+
return users
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _parse_threads(threads_raw: dict[str, Any]) -> dict[str, Thread]:
|
|
107
|
+
out: dict[str, Thread] = {}
|
|
108
|
+
for tid, t in threads_raw.items():
|
|
109
|
+
if not isinstance(t, dict):
|
|
110
|
+
continue
|
|
111
|
+
messages: list[Message] = []
|
|
112
|
+
for m in t.get("messages", []) or []:
|
|
113
|
+
user = m.get("user") or {}
|
|
114
|
+
name = (
|
|
115
|
+
user.get("name")
|
|
116
|
+
or " ".join(
|
|
117
|
+
p for p in [user.get("first_name"), user.get("last_name")] if p
|
|
118
|
+
).strip()
|
|
119
|
+
or None
|
|
120
|
+
)
|
|
121
|
+
messages.append(
|
|
122
|
+
Message(
|
|
123
|
+
id=str(m.get("id") or m.get("_id") or ""),
|
|
124
|
+
content=m.get("content") or "",
|
|
125
|
+
timestamp_ms=_to_ms(m.get("timestamp")) or 0,
|
|
126
|
+
user_id=str(m.get("user_id") or m.get("userId") or ""),
|
|
127
|
+
user_name=name,
|
|
128
|
+
user_email=user.get("email"),
|
|
129
|
+
edited_at_ms=_to_ms(m.get("edited_at")),
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
out[tid] = Thread(
|
|
133
|
+
id=tid,
|
|
134
|
+
messages=messages,
|
|
135
|
+
resolved=bool(t.get("resolved", False)),
|
|
136
|
+
resolved_at_ms=_to_ms(t.get("resolved_at")),
|
|
137
|
+
resolved_by_user_id=(
|
|
138
|
+
str(t["resolved_by_user_id"]) if t.get("resolved_by_user_id") else None
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _build_doc_text(doc_id: str, pathname: str, text: str) -> DocText:
|
|
145
|
+
# Always extract LaTeX headings — the doc text we get from Overleaf IS
|
|
146
|
+
# LaTeX, even when our file-tree mapping fell back to "<unknown-...>".
|
|
147
|
+
line_starts = build_line_starts(text)
|
|
148
|
+
headings = find_headings(text, line_starts)
|
|
149
|
+
return DocText(
|
|
150
|
+
doc_id=doc_id,
|
|
151
|
+
pathname=pathname,
|
|
152
|
+
text=text,
|
|
153
|
+
line_starts=line_starts,
|
|
154
|
+
headings=headings,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_WHITESPACE_RE = re.compile(r"\s+")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _normalize_ws(s: str) -> str:
|
|
162
|
+
return _WHITESPACE_RE.sub(" ", s).strip()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _extract_context(
|
|
166
|
+
doc: DocText,
|
|
167
|
+
offset: int,
|
|
168
|
+
anchored_text: str,
|
|
169
|
+
line_no: int,
|
|
170
|
+
*,
|
|
171
|
+
before: int = CONTEXT_CHARS_BEFORE,
|
|
172
|
+
after: int = CONTEXT_CHARS_AFTER,
|
|
173
|
+
) -> SourceContext:
|
|
174
|
+
"""Build a compact char-window snippet around the anchor.
|
|
175
|
+
|
|
176
|
+
Slices `before` chars before the offset and `after` chars after the
|
|
177
|
+
end of the anchored text. Newlines/extra whitespace are collapsed so the
|
|
178
|
+
snippet renders on a single line. If we couldn't resolve `anchored_text`
|
|
179
|
+
to a slice of the doc, the anchor field is the original phrase verbatim.
|
|
180
|
+
"""
|
|
181
|
+
text = doc.text
|
|
182
|
+
n_text = len(text)
|
|
183
|
+
if n_text == 0:
|
|
184
|
+
return SourceContext(anchor=anchored_text, line_no=line_no)
|
|
185
|
+
|
|
186
|
+
n_anchor = len(anchored_text)
|
|
187
|
+
end_offset = offset + n_anchor
|
|
188
|
+
|
|
189
|
+
# If the doc text doesn't match at offset, we still want a useful snippet
|
|
190
|
+
# — center on whatever's at `offset`.
|
|
191
|
+
actual_anchor = anchored_text
|
|
192
|
+
if not (
|
|
193
|
+
n_anchor > 0
|
|
194
|
+
and 0 <= offset <= n_text - n_anchor
|
|
195
|
+
and text[offset:end_offset] == anchored_text
|
|
196
|
+
):
|
|
197
|
+
# Fall back to whatever's at the offset (anchored_text may be empty
|
|
198
|
+
# or stale). Use a short clip so we still bound the snippet.
|
|
199
|
+
if n_anchor == 0:
|
|
200
|
+
anchor_clip_len = min(40, max(0, n_text - offset))
|
|
201
|
+
actual_anchor = text[offset : offset + anchor_clip_len]
|
|
202
|
+
end_offset = offset + anchor_clip_len
|
|
203
|
+
# If anchored_text was non-empty but didn't match, keep it as the
|
|
204
|
+
# nominal anchor — the inline renderer will show it in brackets.
|
|
205
|
+
|
|
206
|
+
before_slice = text[max(0, offset - before) : offset]
|
|
207
|
+
after_slice = text[end_offset : min(n_text, end_offset + after)]
|
|
208
|
+
|
|
209
|
+
return SourceContext(
|
|
210
|
+
before=_normalize_ws(before_slice),
|
|
211
|
+
anchor=_normalize_ws(actual_anchor) if actual_anchor else anchored_text,
|
|
212
|
+
after=_normalize_ws(after_slice),
|
|
213
|
+
truncated_before=offset > before,
|
|
214
|
+
truncated_after=end_offset < n_text - after,
|
|
215
|
+
line_no=line_no,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _thread_matches_reviewer(thread: Thread | None, reviewer_filter: list[str]) -> bool:
|
|
220
|
+
"""True if the thread has at least one message from any reviewer in the
|
|
221
|
+
filter list. `reviewer_filter` is a list of case-insensitive substrings
|
|
222
|
+
matched against the message author's name OR email."""
|
|
223
|
+
if not reviewer_filter or thread is None:
|
|
224
|
+
return True
|
|
225
|
+
needles = [r.lower().strip() for r in reviewer_filter if r and r.strip()]
|
|
226
|
+
if not needles:
|
|
227
|
+
return True
|
|
228
|
+
for msg in thread.messages:
|
|
229
|
+
hay = " ".join(
|
|
230
|
+
x for x in (msg.user_name, msg.user_email, msg.user_id) if x
|
|
231
|
+
).lower()
|
|
232
|
+
if any(n in hay for n in needles):
|
|
233
|
+
return True
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _change_matches_reviewer(change: TrackedChange, reviewer_filter: list[str]) -> bool:
|
|
238
|
+
if not reviewer_filter:
|
|
239
|
+
return True
|
|
240
|
+
needles = [r.lower().strip() for r in reviewer_filter if r and r.strip()]
|
|
241
|
+
if not needles:
|
|
242
|
+
return True
|
|
243
|
+
hay = " ".join(
|
|
244
|
+
x for x in (change.user_name, change.user_email, change.user_id) if x
|
|
245
|
+
).lower()
|
|
246
|
+
return any(n in hay for n in needles)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _slug_reviewer(name: str) -> str:
|
|
250
|
+
"""Filesystem-safe slug for a reviewer name."""
|
|
251
|
+
out = []
|
|
252
|
+
for ch in name.lower():
|
|
253
|
+
if ch.isalnum():
|
|
254
|
+
out.append(ch)
|
|
255
|
+
elif ch in (" ", "-", "_", ".", "@"):
|
|
256
|
+
out.append("-")
|
|
257
|
+
s = "".join(out).strip("-")
|
|
258
|
+
while "--" in s:
|
|
259
|
+
s = s.replace("--", "-")
|
|
260
|
+
return s[:60] or "reviewer"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _comment_to_jsonl_record(
|
|
264
|
+
c: AnchoredComment, thread: Thread | None
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
"""Self-contained JSONL record per comment (with embedded thread, since
|
|
267
|
+
JSONL records are read independently)."""
|
|
268
|
+
return {
|
|
269
|
+
"short_id": c.short_id,
|
|
270
|
+
"thread_id": c.thread_id,
|
|
271
|
+
"pathname": c.pathname,
|
|
272
|
+
"line": c.line_no,
|
|
273
|
+
"col": c.col,
|
|
274
|
+
"offset": c.offset,
|
|
275
|
+
"nearest_heading": c.nearest_heading,
|
|
276
|
+
"anchored_text": c.anchored_text,
|
|
277
|
+
"stale": c.stale,
|
|
278
|
+
"context": _serialize_context(c.context),
|
|
279
|
+
"thread": _serialize_thread(thread) if thread is not None else None,
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _iter_doc_ranges(ranges_payload: Any):
|
|
284
|
+
if isinstance(ranges_payload, list):
|
|
285
|
+
docs = ranges_payload
|
|
286
|
+
elif isinstance(ranges_payload, dict):
|
|
287
|
+
docs = ranges_payload.get("docs") or ranges_payload.get("ranges") or []
|
|
288
|
+
else:
|
|
289
|
+
docs = []
|
|
290
|
+
for entry in docs:
|
|
291
|
+
if not isinstance(entry, dict):
|
|
292
|
+
continue
|
|
293
|
+
doc_id = entry.get("id") or entry.get("_id") or entry.get("doc_id")
|
|
294
|
+
if not doc_id:
|
|
295
|
+
continue
|
|
296
|
+
ranges = entry.get("ranges") or {}
|
|
297
|
+
yield (
|
|
298
|
+
str(doc_id),
|
|
299
|
+
ranges.get("comments") or [],
|
|
300
|
+
ranges.get("changes") or [],
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def run_export(
|
|
305
|
+
project_url: str,
|
|
306
|
+
out_dir: Path,
|
|
307
|
+
*,
|
|
308
|
+
project_title: str | None = None,
|
|
309
|
+
base_url: str = "https://www.overleaf.com",
|
|
310
|
+
browser: str = "auto",
|
|
311
|
+
verbose: bool = False,
|
|
312
|
+
include_raw: bool = False,
|
|
313
|
+
include_open: bool = True,
|
|
314
|
+
include_resolved: bool = True,
|
|
315
|
+
include_changes: bool = True,
|
|
316
|
+
reviewer_filter: list[str] | None = None,
|
|
317
|
+
render_mode: str = "compact",
|
|
318
|
+
write_jsonl: bool = True,
|
|
319
|
+
per_reviewer_reports: bool = False,
|
|
320
|
+
progress: ProgressCallback | None = None,
|
|
321
|
+
) -> ExportResult:
|
|
322
|
+
"""Programmatic entry point used by both the CLI and the GUI."""
|
|
323
|
+
progress = progress or _noop_progress
|
|
324
|
+
out_dir = Path(out_dir).expanduser()
|
|
325
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
log_path = out_dir / "comments.log"
|
|
327
|
+
|
|
328
|
+
handler_file = logging.FileHandler(log_path, mode="w", encoding="utf-8")
|
|
329
|
+
handler_file.setFormatter(
|
|
330
|
+
logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
331
|
+
)
|
|
332
|
+
handler_file.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
333
|
+
if not any(
|
|
334
|
+
isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", None) == str(log_path)
|
|
335
|
+
for h in logger.handlers
|
|
336
|
+
):
|
|
337
|
+
logger.addHandler(handler_file)
|
|
338
|
+
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
339
|
+
|
|
340
|
+
project_id = parse_project_id(project_url)
|
|
341
|
+
progress(f"Project id: {project_id}")
|
|
342
|
+
logger.info("Project id: %s", project_id)
|
|
343
|
+
|
|
344
|
+
client = OverleafClient(base_url=base_url)
|
|
345
|
+
progress(f"Authenticating via {browser} browser cookie…")
|
|
346
|
+
client.connect(browser=browser)
|
|
347
|
+
|
|
348
|
+
progress("Fetching threads…")
|
|
349
|
+
threads_raw = client.get_threads(project_id)
|
|
350
|
+
progress(f"Got {len(threads_raw)} thread(s).")
|
|
351
|
+
logger.info("Got %d threads.", len(threads_raw))
|
|
352
|
+
|
|
353
|
+
resolved_ids = set(client.get_resolved_thread_ids(project_id))
|
|
354
|
+
for tid in resolved_ids:
|
|
355
|
+
if tid in threads_raw and isinstance(threads_raw[tid], dict):
|
|
356
|
+
threads_raw[tid]["resolved"] = True
|
|
357
|
+
|
|
358
|
+
user_map = _build_user_map(threads_raw)
|
|
359
|
+
threads = _parse_threads(threads_raw)
|
|
360
|
+
|
|
361
|
+
progress("Fetching project file tree…")
|
|
362
|
+
metadata = client.get_project_metadata(project_id)
|
|
363
|
+
doc_id_to_path: dict[str, str] = {}
|
|
364
|
+
if metadata.get("files"):
|
|
365
|
+
for entry in client.flatten_files(metadata["files"], debug_logger=logger.info):
|
|
366
|
+
doc_id_to_path[entry["doc_id"]] = entry["pathname"]
|
|
367
|
+
if not doc_id_to_path:
|
|
368
|
+
progress(
|
|
369
|
+
"File tree empty — comments will be grouped by section in each "
|
|
370
|
+
"doc instead of by filename."
|
|
371
|
+
)
|
|
372
|
+
project_display_name = metadata.get("name") or project_title or project_id
|
|
373
|
+
progress(f"Mapped {len(doc_id_to_path)} doc(s) to paths. Project: {project_display_name}")
|
|
374
|
+
|
|
375
|
+
progress("Fetching project ranges (anchors + tracked changes)…")
|
|
376
|
+
ranges_payload = client.get_project_ranges(project_id)
|
|
377
|
+
if ranges_payload is not None:
|
|
378
|
+
logger.info(
|
|
379
|
+
"ranges payload type=%s len=%s",
|
|
380
|
+
type(ranges_payload).__name__,
|
|
381
|
+
(len(ranges_payload) if hasattr(ranges_payload, "__len__") else "?"),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
anchored: list[AnchoredComment] = []
|
|
385
|
+
changes: list[TrackedChange] = []
|
|
386
|
+
referenced_thread_ids: set[str] = set()
|
|
387
|
+
|
|
388
|
+
if ranges_payload:
|
|
389
|
+
docs_with_anchors = list(_iter_doc_ranges(ranges_payload))
|
|
390
|
+
anchor_doc_count = sum(1 for _, c, ch in docs_with_anchors if c or ch)
|
|
391
|
+
progress(f"Downloading text for {anchor_doc_count} doc(s) with anchors…")
|
|
392
|
+
for doc_id, comments_list, changes_list in docs_with_anchors:
|
|
393
|
+
if not comments_list and not changes_list:
|
|
394
|
+
continue
|
|
395
|
+
pathname = doc_id_to_path.get(doc_id, f"<unknown-{doc_id}>")
|
|
396
|
+
try:
|
|
397
|
+
text = client.download_doc_text(project_id, doc_id)
|
|
398
|
+
except Exception as e:
|
|
399
|
+
logger.warning("Could not download doc %s (%s): %s", doc_id, pathname, e)
|
|
400
|
+
progress(f" skipped {pathname}: {e}")
|
|
401
|
+
continue
|
|
402
|
+
doc = _build_doc_text(doc_id, pathname, text)
|
|
403
|
+
|
|
404
|
+
for c in comments_list:
|
|
405
|
+
op = c.get("op") or {}
|
|
406
|
+
thread_id = op.get("t") or c.get("t")
|
|
407
|
+
if not thread_id:
|
|
408
|
+
continue
|
|
409
|
+
offset = int(op.get("p", 0))
|
|
410
|
+
anchored_text = op.get("c") or ""
|
|
411
|
+
resolved_offset, line, col, stale = resolve_anchor(doc, offset, anchored_text)
|
|
412
|
+
heading = nearest_heading(doc.headings, line)
|
|
413
|
+
context = _extract_context(doc, resolved_offset, anchored_text, line)
|
|
414
|
+
anchored.append(
|
|
415
|
+
AnchoredComment(
|
|
416
|
+
thread_id=str(thread_id),
|
|
417
|
+
short_id="", # assigned below in stable sort order
|
|
418
|
+
doc_id=doc_id,
|
|
419
|
+
pathname=pathname,
|
|
420
|
+
offset=resolved_offset,
|
|
421
|
+
anchored_text=anchored_text,
|
|
422
|
+
line_no=line,
|
|
423
|
+
col=col,
|
|
424
|
+
nearest_heading=heading,
|
|
425
|
+
stale=stale,
|
|
426
|
+
context=context,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
referenced_thread_ids.add(str(thread_id))
|
|
430
|
+
|
|
431
|
+
for ch in changes_list:
|
|
432
|
+
op = ch.get("op") or {}
|
|
433
|
+
meta = ch.get("metadata") or {}
|
|
434
|
+
if "i" in op:
|
|
435
|
+
kind, content = "insertion", op.get("i") or ""
|
|
436
|
+
elif "d" in op:
|
|
437
|
+
kind, content = "deletion", op.get("d") or ""
|
|
438
|
+
else:
|
|
439
|
+
continue
|
|
440
|
+
offset = int(op.get("p", 0))
|
|
441
|
+
ro, line, col, _ = resolve_anchor(
|
|
442
|
+
doc, offset, content if kind == "insertion" else ""
|
|
443
|
+
)
|
|
444
|
+
heading = nearest_heading(doc.headings, line)
|
|
445
|
+
uid = str(meta.get("user_id") or "") or None
|
|
446
|
+
user = user_map.get(uid or "", {}) if uid else {}
|
|
447
|
+
context = _extract_context(
|
|
448
|
+
doc, ro, content if kind == "insertion" else "", line
|
|
449
|
+
)
|
|
450
|
+
changes.append(
|
|
451
|
+
TrackedChange(
|
|
452
|
+
id=str(ch.get("id") or ch.get("_id") or ""),
|
|
453
|
+
short_id="", # assigned below
|
|
454
|
+
doc_id=doc_id,
|
|
455
|
+
pathname=pathname,
|
|
456
|
+
kind=kind,
|
|
457
|
+
content=content,
|
|
458
|
+
offset=offset,
|
|
459
|
+
line_no=line,
|
|
460
|
+
col=col,
|
|
461
|
+
nearest_heading=heading,
|
|
462
|
+
user_id=uid,
|
|
463
|
+
user_name=user.get("name"),
|
|
464
|
+
user_email=user.get("email"),
|
|
465
|
+
timestamp_ms=_to_ms(meta.get("ts")),
|
|
466
|
+
context=context,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
else:
|
|
470
|
+
progress(
|
|
471
|
+
"Ranges payload unavailable — Markdown will list threads without "
|
|
472
|
+
"file/line anchors."
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
orphan_threads = [
|
|
476
|
+
thread for tid, thread in threads.items() if tid not in referenced_thread_ids
|
|
477
|
+
]
|
|
478
|
+
|
|
479
|
+
# Stable IDs assigned BEFORE filtering so they're consistent across runs.
|
|
480
|
+
anchored.sort(key=lambda c: (c.pathname, c.line_no, c.col, c.offset))
|
|
481
|
+
for i, c in enumerate(anchored, 1):
|
|
482
|
+
c.short_id = f"C{i:03d}"
|
|
483
|
+
changes.sort(key=lambda ch: (ch.pathname, ch.line_no, ch.col, ch.offset))
|
|
484
|
+
for i, ch in enumerate(changes, 1):
|
|
485
|
+
ch.short_id = f"T{i:03d}"
|
|
486
|
+
|
|
487
|
+
# ---- Apply filters ----
|
|
488
|
+
reviewer_filter = reviewer_filter or []
|
|
489
|
+
pre_filter_anchored = list(anchored)
|
|
490
|
+
pre_filter_changes = list(changes)
|
|
491
|
+
|
|
492
|
+
def keep_comment(c: AnchoredComment) -> bool:
|
|
493
|
+
t = threads.get(c.thread_id)
|
|
494
|
+
if t is not None:
|
|
495
|
+
if t.resolved and not include_resolved:
|
|
496
|
+
return False
|
|
497
|
+
if not t.resolved and not include_open:
|
|
498
|
+
return False
|
|
499
|
+
if reviewer_filter and not _thread_matches_reviewer(t, reviewer_filter):
|
|
500
|
+
return False
|
|
501
|
+
return True
|
|
502
|
+
|
|
503
|
+
anchored = [c for c in anchored if keep_comment(c)]
|
|
504
|
+
if not include_changes:
|
|
505
|
+
changes = []
|
|
506
|
+
else:
|
|
507
|
+
changes = [ch for ch in changes if _change_matches_reviewer(ch, reviewer_filter)]
|
|
508
|
+
|
|
509
|
+
# Orphan threads: also filter by open/resolved + reviewer
|
|
510
|
+
def keep_orphan(t: Thread) -> bool:
|
|
511
|
+
if t.resolved and not include_resolved:
|
|
512
|
+
return False
|
|
513
|
+
if not t.resolved and not include_open:
|
|
514
|
+
return False
|
|
515
|
+
if reviewer_filter and not _thread_matches_reviewer(t, reviewer_filter):
|
|
516
|
+
return False
|
|
517
|
+
return True
|
|
518
|
+
|
|
519
|
+
orphan_threads = [t for t in orphan_threads if keep_orphan(t)]
|
|
520
|
+
|
|
521
|
+
filtered_msg_bits = []
|
|
522
|
+
if not include_open:
|
|
523
|
+
filtered_msg_bits.append("open hidden")
|
|
524
|
+
if not include_resolved:
|
|
525
|
+
filtered_msg_bits.append("resolved hidden")
|
|
526
|
+
if not include_changes:
|
|
527
|
+
filtered_msg_bits.append("tracked changes hidden")
|
|
528
|
+
if reviewer_filter:
|
|
529
|
+
filtered_msg_bits.append(f"reviewer filter: {', '.join(reviewer_filter)}")
|
|
530
|
+
if filtered_msg_bits:
|
|
531
|
+
progress(
|
|
532
|
+
f"Filter applied ({'; '.join(filtered_msg_bits)}): "
|
|
533
|
+
f"{len(pre_filter_anchored)}→{len(anchored)} comments, "
|
|
534
|
+
f"{len(pre_filter_changes)}→{len(changes)} tracked changes"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
title = project_title or metadata.get("name") or project_id
|
|
538
|
+
open_count = sum(
|
|
539
|
+
1 for c in anchored if not (threads.get(c.thread_id) and threads[c.thread_id].resolved)
|
|
540
|
+
)
|
|
541
|
+
resolved_count = sum(
|
|
542
|
+
1 for c in anchored if threads.get(c.thread_id) and threads[c.thread_id].resolved
|
|
543
|
+
)
|
|
544
|
+
# If filters hide most threads, also surface the totals over the surviving set
|
|
545
|
+
thread_count_after = len({c.thread_id for c in anchored} | {t.id for t in orphan_threads})
|
|
546
|
+
stale_count = sum(1 for c in anchored if c.stale)
|
|
547
|
+
|
|
548
|
+
mode_lit = "detailed" if (render_mode or "").lower() == "detailed" else "compact"
|
|
549
|
+
markdown = render_markdown(
|
|
550
|
+
project_title=title,
|
|
551
|
+
project_id=project_id,
|
|
552
|
+
threads=threads,
|
|
553
|
+
anchored=anchored,
|
|
554
|
+
orphan_threads=orphan_threads,
|
|
555
|
+
changes=changes,
|
|
556
|
+
mode=mode_lit,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
md_path = out_dir / f"comments-{date.today().isoformat()}.md"
|
|
560
|
+
md_path.write_text(markdown, encoding="utf-8")
|
|
561
|
+
progress(f"Wrote {md_path.name}")
|
|
562
|
+
|
|
563
|
+
json_payload = _build_structured_json(
|
|
564
|
+
project_id=project_id,
|
|
565
|
+
project_title=title,
|
|
566
|
+
threads=threads,
|
|
567
|
+
anchored=anchored,
|
|
568
|
+
changes=changes,
|
|
569
|
+
orphan_threads=orphan_threads,
|
|
570
|
+
doc_id_to_path=doc_id_to_path,
|
|
571
|
+
open_count=open_count,
|
|
572
|
+
resolved_count=resolved_count,
|
|
573
|
+
stale_count=stale_count,
|
|
574
|
+
threads_raw=threads_raw,
|
|
575
|
+
ranges_payload=ranges_payload,
|
|
576
|
+
include_raw=include_raw,
|
|
577
|
+
)
|
|
578
|
+
json_payload["filters_applied"] = {
|
|
579
|
+
"include_open": include_open,
|
|
580
|
+
"include_resolved": include_resolved,
|
|
581
|
+
"include_changes": include_changes,
|
|
582
|
+
"reviewer_filter": reviewer_filter,
|
|
583
|
+
"render_mode": mode_lit,
|
|
584
|
+
}
|
|
585
|
+
json_path = out_dir / "comments.json"
|
|
586
|
+
json_path.write_text(json.dumps(json_payload, indent=2, default=str), encoding="utf-8")
|
|
587
|
+
progress(f"Wrote {json_path.name}")
|
|
588
|
+
|
|
589
|
+
# JSONL companion (one comment per line, self-contained)
|
|
590
|
+
if write_jsonl:
|
|
591
|
+
jsonl_path = out_dir / "comments.jsonl"
|
|
592
|
+
with jsonl_path.open("w", encoding="utf-8") as f:
|
|
593
|
+
for c in anchored:
|
|
594
|
+
rec = _comment_to_jsonl_record(c, threads.get(c.thread_id))
|
|
595
|
+
f.write(json.dumps(rec, default=str))
|
|
596
|
+
f.write("\n")
|
|
597
|
+
progress(f"Wrote {jsonl_path.name} ({len(anchored)} record(s))")
|
|
598
|
+
|
|
599
|
+
# Per-reviewer sub-reports
|
|
600
|
+
if per_reviewer_reports:
|
|
601
|
+
by_reviewer_dir = out_dir / "by-reviewer"
|
|
602
|
+
by_reviewer_dir.mkdir(exist_ok=True)
|
|
603
|
+
reviewers: dict[str, str] = {} # display name -> slug
|
|
604
|
+
for t in threads.values():
|
|
605
|
+
for m in t.messages:
|
|
606
|
+
name = m.user_name or m.user_email or m.user_id
|
|
607
|
+
if not name:
|
|
608
|
+
continue
|
|
609
|
+
reviewers.setdefault(name, _slug_reviewer(name))
|
|
610
|
+
for change in changes:
|
|
611
|
+
name = change.user_name or change.user_email or change.user_id
|
|
612
|
+
if name:
|
|
613
|
+
reviewers.setdefault(name, _slug_reviewer(name))
|
|
614
|
+
written = 0
|
|
615
|
+
for reviewer_name, slug in reviewers.items():
|
|
616
|
+
sub_anchored = [
|
|
617
|
+
c for c in anchored
|
|
618
|
+
if _thread_matches_reviewer(threads.get(c.thread_id), [reviewer_name])
|
|
619
|
+
]
|
|
620
|
+
sub_changes = [
|
|
621
|
+
ch for ch in changes
|
|
622
|
+
if _change_matches_reviewer(ch, [reviewer_name])
|
|
623
|
+
]
|
|
624
|
+
sub_orphans = [
|
|
625
|
+
t for t in orphan_threads
|
|
626
|
+
if _thread_matches_reviewer(t, [reviewer_name])
|
|
627
|
+
]
|
|
628
|
+
if not sub_anchored and not sub_changes and not sub_orphans:
|
|
629
|
+
continue
|
|
630
|
+
sub_md = render_markdown(
|
|
631
|
+
project_title=f"{title} — {reviewer_name}",
|
|
632
|
+
project_id=project_id,
|
|
633
|
+
threads=threads,
|
|
634
|
+
anchored=sub_anchored,
|
|
635
|
+
orphan_threads=sub_orphans,
|
|
636
|
+
changes=sub_changes,
|
|
637
|
+
mode=mode_lit,
|
|
638
|
+
)
|
|
639
|
+
(by_reviewer_dir / f"{slug}.md").write_text(sub_md, encoding="utf-8")
|
|
640
|
+
written += 1
|
|
641
|
+
progress(f"Wrote {written} per-reviewer report(s) into by-reviewer/")
|
|
642
|
+
|
|
643
|
+
agents_path = out_dir / "agents.md"
|
|
644
|
+
agents_path.write_text(_build_agents_md(title, project_id, json_path.name, md_path.name), encoding="utf-8")
|
|
645
|
+
progress(f"Wrote {agents_path.name}")
|
|
646
|
+
|
|
647
|
+
if stale_count:
|
|
648
|
+
progress(f"{stale_count} comment anchor(s) were stale (text moved or removed).")
|
|
649
|
+
|
|
650
|
+
return ExportResult(
|
|
651
|
+
project_id=project_id,
|
|
652
|
+
markdown_path=md_path,
|
|
653
|
+
json_path=json_path,
|
|
654
|
+
log_path=log_path,
|
|
655
|
+
thread_count=thread_count_after,
|
|
656
|
+
open_count=open_count,
|
|
657
|
+
resolved_count=resolved_count,
|
|
658
|
+
tracked_change_count=len(changes),
|
|
659
|
+
stale_anchor_count=stale_count,
|
|
660
|
+
jsonl_path=(out_dir / "comments.jsonl") if write_jsonl else None,
|
|
661
|
+
by_reviewer_dir=(out_dir / "by-reviewer") if per_reviewer_reports else None,
|
|
662
|
+
agents_path=agents_path,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _iso(ms: int | None) -> str | None:
|
|
667
|
+
if not ms:
|
|
668
|
+
return None
|
|
669
|
+
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _serialize_context(ctx: SourceContext | None) -> dict[str, Any] | None:
|
|
673
|
+
if ctx is None:
|
|
674
|
+
return None
|
|
675
|
+
return {
|
|
676
|
+
"line": ctx.line_no,
|
|
677
|
+
"before": ctx.before,
|
|
678
|
+
"anchor": ctx.anchor,
|
|
679
|
+
"after": ctx.after,
|
|
680
|
+
"truncated_before": ctx.truncated_before,
|
|
681
|
+
"truncated_after": ctx.truncated_after,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _serialize_thread(thread: Thread) -> dict[str, Any]:
|
|
686
|
+
return {
|
|
687
|
+
"id": thread.id,
|
|
688
|
+
"resolved": thread.resolved,
|
|
689
|
+
"resolved_at": _iso(thread.resolved_at_ms),
|
|
690
|
+
"resolved_by_user_id": thread.resolved_by_user_id,
|
|
691
|
+
"messages": [
|
|
692
|
+
{
|
|
693
|
+
"id": m.id,
|
|
694
|
+
"user": {
|
|
695
|
+
"id": m.user_id,
|
|
696
|
+
"name": m.user_name,
|
|
697
|
+
"email": m.user_email,
|
|
698
|
+
},
|
|
699
|
+
"content": m.content,
|
|
700
|
+
"timestamp": _iso(m.timestamp_ms),
|
|
701
|
+
"edited_at": _iso(m.edited_at_ms),
|
|
702
|
+
}
|
|
703
|
+
for m in sorted(thread.messages, key=lambda x: x.timestamp_ms)
|
|
704
|
+
],
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _build_structured_json(
|
|
709
|
+
*,
|
|
710
|
+
project_id: str,
|
|
711
|
+
project_title: str,
|
|
712
|
+
threads: dict[str, Thread],
|
|
713
|
+
anchored: list[AnchoredComment],
|
|
714
|
+
changes: list[TrackedChange],
|
|
715
|
+
orphan_threads: list[Thread],
|
|
716
|
+
doc_id_to_path: dict[str, str],
|
|
717
|
+
open_count: int,
|
|
718
|
+
resolved_count: int,
|
|
719
|
+
stale_count: int,
|
|
720
|
+
threads_raw: dict[str, Any],
|
|
721
|
+
ranges_payload: Any,
|
|
722
|
+
include_raw: bool = False,
|
|
723
|
+
) -> dict[str, Any]:
|
|
724
|
+
"""Produce a clean, AI-ingestion-friendly JSON document.
|
|
725
|
+
|
|
726
|
+
Top-level shape:
|
|
727
|
+
schema_version, project, pulled_at, summary, files (grouped),
|
|
728
|
+
comments (flat with short_id), tracked_changes, orphan_threads,
|
|
729
|
+
raw (the unprocessed payloads for advanced users).
|
|
730
|
+
"""
|
|
731
|
+
by_file_comments: dict[str, list[AnchoredComment]] = {}
|
|
732
|
+
for c in anchored:
|
|
733
|
+
by_file_comments.setdefault(c.pathname, []).append(c)
|
|
734
|
+
by_file_changes: dict[str, list[TrackedChange]] = {}
|
|
735
|
+
for ch in changes:
|
|
736
|
+
by_file_changes.setdefault(ch.pathname, []).append(ch)
|
|
737
|
+
|
|
738
|
+
files: list[dict[str, Any]] = []
|
|
739
|
+
for path in sorted(set(list(by_file_comments) + list(by_file_changes))):
|
|
740
|
+
files.append(
|
|
741
|
+
{
|
|
742
|
+
"pathname": path,
|
|
743
|
+
"doc_id": _doc_id_for_path(path, doc_id_to_path),
|
|
744
|
+
"comment_count": len(by_file_comments.get(path, [])),
|
|
745
|
+
"change_count": len(by_file_changes.get(path, [])),
|
|
746
|
+
"comment_short_ids": [c.short_id for c in by_file_comments.get(path, [])],
|
|
747
|
+
"change_short_ids": [ch.short_id for ch in by_file_changes.get(path, [])],
|
|
748
|
+
}
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
payload: dict[str, Any] = {
|
|
752
|
+
"schema_version": SCHEMA_VERSION,
|
|
753
|
+
"project": {
|
|
754
|
+
"id": project_id,
|
|
755
|
+
"title": project_title,
|
|
756
|
+
},
|
|
757
|
+
"pulled_at": datetime.now(timezone.utc).isoformat(),
|
|
758
|
+
"summary": {
|
|
759
|
+
"thread_count": len(threads),
|
|
760
|
+
"open_count": open_count,
|
|
761
|
+
"resolved_count": resolved_count,
|
|
762
|
+
"tracked_change_count": len(changes),
|
|
763
|
+
"stale_anchor_count": stale_count,
|
|
764
|
+
"file_count": len(files),
|
|
765
|
+
"reviewer_count": len(
|
|
766
|
+
{
|
|
767
|
+
m.user_id
|
|
768
|
+
for t in threads.values()
|
|
769
|
+
for m in t.messages
|
|
770
|
+
if m.user_id
|
|
771
|
+
}
|
|
772
|
+
),
|
|
773
|
+
},
|
|
774
|
+
# Threads are stored ONCE at the top level keyed by thread_id;
|
|
775
|
+
# comments reference them via `thread_id`. This avoids duplicating
|
|
776
|
+
# potentially long discussions inside every comment.
|
|
777
|
+
"threads": {tid: _serialize_thread(t) for tid, t in threads.items()},
|
|
778
|
+
"files": files,
|
|
779
|
+
"comments": [
|
|
780
|
+
{
|
|
781
|
+
"short_id": c.short_id,
|
|
782
|
+
"thread_id": c.thread_id,
|
|
783
|
+
"doc_id": c.doc_id,
|
|
784
|
+
"pathname": c.pathname,
|
|
785
|
+
"line": c.line_no,
|
|
786
|
+
"col": c.col,
|
|
787
|
+
"offset": c.offset,
|
|
788
|
+
"nearest_heading": c.nearest_heading,
|
|
789
|
+
"anchored_text": c.anchored_text,
|
|
790
|
+
"stale": c.stale,
|
|
791
|
+
"context": _serialize_context(c.context),
|
|
792
|
+
}
|
|
793
|
+
for c in anchored
|
|
794
|
+
],
|
|
795
|
+
"tracked_changes": [
|
|
796
|
+
{
|
|
797
|
+
"short_id": ch.short_id,
|
|
798
|
+
"id": ch.id,
|
|
799
|
+
"doc_id": ch.doc_id,
|
|
800
|
+
"pathname": ch.pathname,
|
|
801
|
+
"kind": ch.kind,
|
|
802
|
+
"content": ch.content,
|
|
803
|
+
"line": ch.line_no,
|
|
804
|
+
"col": ch.col,
|
|
805
|
+
"offset": ch.offset,
|
|
806
|
+
"nearest_heading": ch.nearest_heading,
|
|
807
|
+
"user": {
|
|
808
|
+
"id": ch.user_id,
|
|
809
|
+
"name": ch.user_name,
|
|
810
|
+
"email": ch.user_email,
|
|
811
|
+
},
|
|
812
|
+
"timestamp": _iso(ch.timestamp_ms),
|
|
813
|
+
"context": _serialize_context(ch.context),
|
|
814
|
+
}
|
|
815
|
+
for ch in changes
|
|
816
|
+
],
|
|
817
|
+
"orphan_thread_ids": [t.id for t in orphan_threads],
|
|
818
|
+
}
|
|
819
|
+
if include_raw:
|
|
820
|
+
payload["raw"] = {
|
|
821
|
+
"threads": threads_raw,
|
|
822
|
+
"ranges": ranges_payload,
|
|
823
|
+
"doc_id_to_path": doc_id_to_path,
|
|
824
|
+
}
|
|
825
|
+
return payload
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _doc_id_for_path(path: str, doc_id_to_path: dict[str, str]) -> str | None:
|
|
829
|
+
for did, p in doc_id_to_path.items():
|
|
830
|
+
if p == path:
|
|
831
|
+
return did
|
|
832
|
+
return None
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _build_agents_md(project_title: str, project_id: str, json_name: str, md_name: str) -> str:
|
|
836
|
+
"""A short instruction file for AI agents who'll ingest this batch."""
|
|
837
|
+
return f"""# Agent brief — Overleaf comments for {project_title}
|
|
838
|
+
|
|
839
|
+
You are reading an Overleaf comment export produced by
|
|
840
|
+
`overleaf_comments_export`. Two files in this folder are relevant:
|
|
841
|
+
|
|
842
|
+
- `{md_name}` — human-readable Markdown, with YAML front-matter and
|
|
843
|
+
comments grouped by file → section → line. Every comment has a stable
|
|
844
|
+
short ID like `C001` (assigned in file → line order). The Markdown is the
|
|
845
|
+
canonical user-facing view.
|
|
846
|
+
- `{json_name}` — the same data in structured form. Use this when you need
|
|
847
|
+
to enumerate, filter, or programmatically address comments.
|
|
848
|
+
|
|
849
|
+
## JSON schema (key parts)
|
|
850
|
+
|
|
851
|
+
- `schema_version` (string)
|
|
852
|
+
- `project` — `{{ id, title }}`
|
|
853
|
+
- `summary` — counts (threads, open/resolved, tracked changes, stale, files,
|
|
854
|
+
reviewers)
|
|
855
|
+
- `threads` — `{{ "<thread_id>": {{ id, resolved, resolved_at,
|
|
856
|
+
resolved_by_user_id, messages: [...] }} }}` — stored once at top level,
|
|
857
|
+
not duplicated inside each comment.
|
|
858
|
+
- `files` — list of `{{ pathname, doc_id, comment_count, change_count,
|
|
859
|
+
comment_short_ids, change_short_ids }}`
|
|
860
|
+
- `comments` — list of `{{ short_id, thread_id, doc_id, pathname, line, col,
|
|
861
|
+
offset, nearest_heading, anchored_text, stale, context }}`. To get the
|
|
862
|
+
discussion, look up `threads[thread_id]`.
|
|
863
|
+
- `tracked_changes` — list of `{{ short_id, id, doc_id, pathname, kind
|
|
864
|
+
(insertion|deletion), content, line, col, offset, nearest_heading, user,
|
|
865
|
+
timestamp, context }}`
|
|
866
|
+
- `orphan_thread_ids` — IDs of threads that don't anchor to live source.
|
|
867
|
+
|
|
868
|
+
`context` is a compact char-window snippet: `before`, `anchor`, `after`,
|
|
869
|
+
with `truncated_before`/`truncated_after` flags. `…` should be used in
|
|
870
|
+
rendered output where truncation is true.
|
|
871
|
+
|
|
872
|
+
## How to address comments
|
|
873
|
+
|
|
874
|
+
- Refer to comments by `short_id` (e.g., "C014"), not by `thread_id`.
|
|
875
|
+
- For each open comment, propose an edit to the .tex source. If the comment
|
|
876
|
+
is a question, answer it; if it's a request, attempt the change.
|
|
877
|
+
- Stale comments (`stale: true`) may not point to the current location in the
|
|
878
|
+
doc. Use `anchored_text` and `nearest_heading` to find the right spot.
|
|
879
|
+
- Tracked changes (`T001`-prefixed) are not comment threads; they are
|
|
880
|
+
insertions/deletions someone made with "Track Changes" enabled. Treat them
|
|
881
|
+
as suggested edits to accept, reject, or modify.
|
|
882
|
+
|
|
883
|
+
## What you do NOT have
|
|
884
|
+
|
|
885
|
+
- The full `.tex` source of the paper. You only see ~80 chars around each
|
|
886
|
+
anchor. If you need more context, ask the user to share the relevant
|
|
887
|
+
`.tex` file.
|
|
888
|
+
- The ability to push edits back to Overleaf. Output any proposed edits as
|
|
889
|
+
diffs or rewrites; the user will apply them.
|
|
890
|
+
|
|
891
|
+
Project ID for reference: `{project_id}`.
|
|
892
|
+
"""
|