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.
@@ -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
+ """