overleaf-comments-export 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (27) hide show
  1. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/PKG-INFO +24 -4
  2. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/README.md +23 -3
  3. overleaf_comments_export-0.3.0/overleaf_comments_export/__init__.py +8 -0
  4. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/__main__.py +40 -18
  5. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/client.py +179 -19
  6. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/export.py +69 -13
  7. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/gui.py +147 -23
  8. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/render.py +23 -7
  9. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/PKG-INFO +24 -4
  10. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/SOURCES.txt +3 -1
  11. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/pyproject.toml +1 -1
  12. overleaf_comments_export-0.3.0/tests/test_errors_and_auth.py +157 -0
  13. overleaf_comments_export-0.3.0/tests/test_replies_and_changes.py +156 -0
  14. overleaf_comments_export-0.2.0/overleaf_comments_export/__init__.py +0 -1
  15. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/LICENSE +0 -0
  16. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/anchors.py +0 -0
  17. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/model.py +0 -0
  18. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/sections.py +0 -0
  19. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/dependency_links.txt +0 -0
  20. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/entry_points.txt +0 -0
  21. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/requires.txt +0 -0
  22. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/top_level.txt +0 -0
  23. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/setup.cfg +0 -0
  24. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/tests/test_anchors_sections.py +0 -0
  25. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/tests/test_client.py +0 -0
  26. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/tests/test_export.py +0 -0
  27. {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/tests/test_render.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overleaf-comments-export
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Export comment threads and tracked changes from an Overleaf project to Markdown + JSON, optimized for AI-agent consumption.
5
5
  Author: Shivang
6
6
  License: MIT
@@ -137,10 +137,30 @@ by browser on macOS:
137
137
  | Chrome / Edge / Brave | Cookies are AES-encrypted with a Keychain-stored key; **you'll get a Keychain password prompt every run.** Hidden behind an opt-in in the GUI. |
138
138
 
139
139
  On Windows, Chrome 127+ uses App-Bound Encryption that `browser-cookie3`
140
- doesn't fully decrypt yet — prefer Firefox or Edge on Windows.
140
+ can't decrypt. On Linux, snap-packaged browsers sandbox their cookie stores.
141
141
 
142
- On Linux, snap-packaged browsers sandbox their cookies — install browsers as
143
- native packages if you can.
142
+ **If reading the cookie from your browser fails, paste it instead** — this
143
+ works on every OS and browser:
144
+
145
+ ```bash
146
+ overleaf-comments-export --project-url <url> --out ./out --cookie "PASTE_HERE"
147
+ ```
148
+
149
+ Or set it once: `export OVERLEAF_SESSION="PASTE_HERE"`. In the GUI, choose
150
+ **"Paste the cookie myself"** and click **How?** for step-by-step instructions.
151
+
152
+ To find it: open Overleaf, press F12, go to Application (or Storage) →
153
+ Cookies → `https://www.overleaf.com`, and copy the Value of `overleaf_session2`.
154
+ Treat it like a password; it stops working when you sign out.
155
+
156
+ ## Troubleshooting
157
+
158
+ | What you see | What it means |
159
+ |---|---|
160
+ | "Could not look up www.overleaf.com" | This computer is offline, or a VPN/DNS problem. Not an Overleaf issue. |
161
+ | "Overleaf refused the request (not signed in)" | Your session expired. Sign in again in the browser, then re-run. |
162
+ | "Could not read Overleaf cookies from chrome" | Use the paste-the-cookie method above. |
163
+ | "Overleaf could not find that project" | Wrong link, or this account has no access. |
144
164
 
145
165
  ## Status & maintenance
146
166
 
@@ -101,10 +101,30 @@ by browser on macOS:
101
101
  | Chrome / Edge / Brave | Cookies are AES-encrypted with a Keychain-stored key; **you'll get a Keychain password prompt every run.** Hidden behind an opt-in in the GUI. |
102
102
 
103
103
  On Windows, Chrome 127+ uses App-Bound Encryption that `browser-cookie3`
104
- doesn't fully decrypt yet — prefer Firefox or Edge on Windows.
104
+ can't decrypt. On Linux, snap-packaged browsers sandbox their cookie stores.
105
105
 
106
- On Linux, snap-packaged browsers sandbox their cookies — install browsers as
107
- native packages if you can.
106
+ **If reading the cookie from your browser fails, paste it instead** — this
107
+ works on every OS and browser:
108
+
109
+ ```bash
110
+ overleaf-comments-export --project-url <url> --out ./out --cookie "PASTE_HERE"
111
+ ```
112
+
113
+ Or set it once: `export OVERLEAF_SESSION="PASTE_HERE"`. In the GUI, choose
114
+ **"Paste the cookie myself"** and click **How?** for step-by-step instructions.
115
+
116
+ To find it: open Overleaf, press F12, go to Application (or Storage) →
117
+ Cookies → `https://www.overleaf.com`, and copy the Value of `overleaf_session2`.
118
+ Treat it like a password; it stops working when you sign out.
119
+
120
+ ## Troubleshooting
121
+
122
+ | What you see | What it means |
123
+ |---|---|
124
+ | "Could not look up www.overleaf.com" | This computer is offline, or a VPN/DNS problem. Not an Overleaf issue. |
125
+ | "Overleaf refused the request (not signed in)" | Your session expired. Sign in again in the browser, then re-run. |
126
+ | "Could not read Overleaf cookies from chrome" | Use the paste-the-cookie method above. |
127
+ | "Overleaf could not find that project" | Wrong link, or this account has no access. |
108
128
 
109
129
  ## Status & maintenance
110
130
 
@@ -0,0 +1,8 @@
1
+ """Export Overleaf comment threads and tracked changes."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("overleaf-comments-export")
7
+ except PackageNotFoundError: # running from a source checkout
8
+ __version__ = "0.0.0-dev"
@@ -2,10 +2,12 @@ from __future__ import annotations
2
2
 
3
3
  import argparse
4
4
  import logging
5
+ import os
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
8
- from .client import OverleafClient
9
+ from . import __version__
10
+ from .client import OverleafClient, UserFacingError
9
11
  from .export import ExportResult, run_export
10
12
 
11
13
 
@@ -39,6 +41,15 @@ def main(argv: list[str] | None = None) -> int:
39
41
  choices=list(OverleafClient.SUPPORTED_BROWSERS),
40
42
  help="Which browser to read cookies from. Default: auto-detect.",
41
43
  )
44
+ parser.add_argument(
45
+ "--cookie",
46
+ default=None,
47
+ metavar="VALUE",
48
+ help="Overleaf session cookie, pasted from your browser (DevTools → "
49
+ "Application → Cookies → overleaf_session2). Use this when reading "
50
+ "cookies from the browser fails. Also read from the OVERLEAF_SESSION "
51
+ "environment variable.",
52
+ )
42
53
  parser.add_argument(
43
54
  "--base-url",
44
55
  default="https://www.overleaf.com",
@@ -47,6 +58,9 @@ def main(argv: list[str] | None = None) -> int:
47
58
  parser.add_argument(
48
59
  "-v", "--verbose", action="store_true", help="More logging."
49
60
  )
61
+ parser.add_argument(
62
+ "--version", action="version", version=f"%(prog)s {__version__}"
63
+ )
50
64
  parser.add_argument(
51
65
  "--render-mode",
52
66
  choices=["compact", "detailed"],
@@ -118,23 +132,31 @@ def main(argv: list[str] | None = None) -> int:
118
132
  stream=sys.stderr,
119
133
  )
120
134
 
121
- result: ExportResult = run_export(
122
- project_url=args.project_url,
123
- out_dir=args.out,
124
- project_title=args.project_title,
125
- base_url=args.base_url,
126
- browser=args.browser,
127
- verbose=args.verbose,
128
- include_raw=args.include_raw,
129
- include_open=args.include_open,
130
- include_resolved=args.include_resolved,
131
- include_changes=args.include_changes,
132
- reviewer_filter=args.reviewer,
133
- render_mode=args.render_mode,
134
- write_jsonl=args.write_jsonl,
135
- per_reviewer_reports=args.per_reviewer,
136
- progress=lambda msg: print(msg, file=sys.stderr),
137
- )
135
+ cookie_value = args.cookie or os.environ.get("OVERLEAF_SESSION") or None
136
+
137
+ try:
138
+ result: ExportResult = run_export(
139
+ project_url=args.project_url,
140
+ out_dir=args.out,
141
+ project_title=args.project_title,
142
+ base_url=args.base_url,
143
+ browser=args.browser,
144
+ cookie_value=cookie_value,
145
+ verbose=args.verbose,
146
+ include_raw=args.include_raw,
147
+ include_open=args.include_open,
148
+ include_resolved=args.include_resolved,
149
+ include_changes=args.include_changes,
150
+ reviewer_filter=args.reviewer,
151
+ render_mode=args.render_mode,
152
+ write_jsonl=args.write_jsonl,
153
+ per_reviewer_reports=args.per_reviewer,
154
+ progress=lambda msg: print(msg, file=sys.stderr),
155
+ )
156
+ except UserFacingError as e:
157
+ # Expected, explainable failures: no traceback, just what to do next.
158
+ print(f"\n{e}", file=sys.stderr)
159
+ return 1
138
160
  print(f"\nDone. Open: {result.markdown_path}")
139
161
  return 0
140
162
 
@@ -4,6 +4,7 @@ import html
4
4
  import json
5
5
  import logging
6
6
  import re
7
+ import time
7
8
  from typing import Any, Optional
8
9
  from urllib.parse import unquote, urlparse
9
10
 
@@ -15,6 +16,52 @@ OVERLEAF_BASE = "https://www.overleaf.com"
15
16
 
16
17
  PROJECT_URL_RE = re.compile(r"/project/(?P<id>[0-9a-fA-F]{24})")
17
18
 
19
+ RETRY_STATUSES = (429, 500, 502, 503, 504)
20
+
21
+
22
+ class UserFacingError(RuntimeError):
23
+ """An error with a message written for a non-technical user.
24
+
25
+ The GUI/CLI print these verbatim without a traceback; anything else is
26
+ treated as an unexpected bug and shown with full detail.
27
+ """
28
+
29
+
30
+ def _network_hint(exc: Exception) -> str:
31
+ """Plain-English explanation for a requests connection failure."""
32
+ text = str(exc)
33
+ if (
34
+ "NameResolution" in text
35
+ or "Failed to resolve" in text
36
+ or "nodename nor servname" in text
37
+ or "Name or service not known" in text
38
+ or "getaddrinfo" in text
39
+ ):
40
+ return (
41
+ "Could not look up www.overleaf.com.\n\n"
42
+ "This is a network problem on this computer, not a problem with your "
43
+ "Overleaf project. Check that you are online, then try again. If you "
44
+ "are on a VPN or a work/university network, try turning the VPN off "
45
+ "or switching networks."
46
+ )
47
+ if isinstance(exc, requests.exceptions.SSLError):
48
+ return (
49
+ "The secure connection to Overleaf could not be verified.\n\n"
50
+ "This usually means a VPN, antivirus, or corporate proxy is "
51
+ "intercepting traffic. Try again on a different network."
52
+ )
53
+ if isinstance(exc, requests.exceptions.Timeout):
54
+ return (
55
+ "Overleaf did not respond in time.\n\n"
56
+ "The connection may be slow or Overleaf may be busy. Wait a moment "
57
+ "and try again."
58
+ )
59
+ return (
60
+ "Could not reach Overleaf.\n\n"
61
+ "Check that you are online and that https://www.overleaf.com opens in "
62
+ "your browser, then try again."
63
+ )
64
+
18
65
 
19
66
  def parse_project_id(project_url: str) -> str:
20
67
  parsed = urlparse(project_url)
@@ -36,16 +83,20 @@ class OverleafClient:
36
83
  self._api = None
37
84
  self._session: Optional[requests.Session] = None
38
85
 
39
- SUPPORTED_BROWSERS = ("safari", "firefox", "auto", "chrome", "chromium", "edge", "brave")
86
+ SUPPORTED_BROWSERS = (
87
+ "safari", "firefox", "auto", "chrome", "chromium", "edge", "brave", "manual",
88
+ )
40
89
  # Browsers that don't trigger macOS Keychain or password prompts:
41
90
  PRIVACY_FRIENDLY_BROWSERS = ("safari", "firefox")
42
91
 
43
- def connect(self, browser: str = "auto") -> None:
44
- """Authenticate via the user's browser cookie.
92
+ def connect(self, browser: str = "auto", cookie_value: str | None = None) -> None:
93
+ """Authenticate as the user.
45
94
 
46
- If browser is "auto", we let pyoverleaf auto-detect.
47
- Otherwise we read the overleaf.com cookies for the named browser
48
- directly via browser-cookie3 and build our own session.
95
+ `cookie_value` (or browser="manual") uses a session cookie pasted by the
96
+ user — this works identically on every OS and browser, and is the
97
+ fallback when a browser's cookie store can't be read (Chrome 127+ on
98
+ Windows, snap-packaged browsers on Linux, locked-down macOS).
99
+ Otherwise cookies are read from the named browser, or auto-detected.
49
100
  """
50
101
  browser = (browser or "auto").lower()
51
102
  if browser not in self.SUPPORTED_BROWSERS:
@@ -54,19 +105,58 @@ class OverleafClient:
54
105
  + ", ".join(self.SUPPORTED_BROWSERS)
55
106
  )
56
107
 
57
- if browser == "auto":
108
+ if cookie_value:
109
+ session = self._connect_via_cookie_value(cookie_value)
110
+ elif browser == "manual":
111
+ raise UserFacingError(
112
+ "No session cookie was provided.\n\n"
113
+ "Paste the value of your Overleaf 'overleaf_session2' cookie, or "
114
+ "choose a browser to read it from automatically."
115
+ )
116
+ elif browser == "auto":
58
117
  session = self._connect_via_pyoverleaf()
59
118
  else:
60
119
  session = self._connect_via_browser_cookie3(browser)
61
120
 
62
- session.headers.setdefault(
63
- "User-Agent",
64
- "overleaf-comments-export/0.1 (Mozilla/5.0 compatible)",
121
+ # Assign, don't setdefault: requests.Session pre-fills User-Agent with
122
+ # "python-requests/x.y", which setdefault would leave in place.
123
+ session.headers["User-Agent"] = (
124
+ f"overleaf-comments-export/{_tool_version()} (Mozilla/5.0 compatible)"
65
125
  )
66
126
  session.headers.setdefault("Accept", "application/json, text/plain, */*")
67
127
  session.headers.setdefault("Referer", f"{self.base_url}/")
68
128
  self._session = session
69
129
 
130
+ def _connect_via_cookie_value(self, pasted: str) -> requests.Session:
131
+ """Build a session from a cookie the user pasted.
132
+
133
+ Accepts a bare value, `overleaf_session2=<value>`, or a whole
134
+ `document.cookie` dump — whatever the user actually managed to copy.
135
+ """
136
+ pairs = _parse_cookie_string(pasted)
137
+ if not pairs:
138
+ raise UserFacingError(
139
+ "That does not look like an Overleaf session cookie.\n\n"
140
+ "In your browser open Overleaf, press F12, go to "
141
+ "Application → Cookies → https://www.overleaf.com, and copy the "
142
+ "Value of the row named 'overleaf_session2'."
143
+ )
144
+ if not any(k in ("overleaf_session2", "overleaf.sid") for k in pairs):
145
+ raise UserFacingError(
146
+ "The pasted cookie does not contain 'overleaf_session2'.\n\n"
147
+ "Copy the Value of the cookie named exactly 'overleaf_session2' "
148
+ "(Application → Cookies → https://www.overleaf.com)."
149
+ )
150
+
151
+ session = requests.Session()
152
+ domain = urlparse(self.base_url).hostname or "www.overleaf.com"
153
+ # Set on the registrable domain so it is sent to www. and api. hosts.
154
+ cookie_domain = ".overleaf.com" if domain.endswith("overleaf.com") else domain
155
+ for name, value in pairs.items():
156
+ session.cookies.set(name, value, domain=cookie_domain, path="/")
157
+ self._api = None # no pyoverleaf; file tree comes from the HTML scrape
158
+ return session
159
+
70
160
  def _connect_via_pyoverleaf(self) -> requests.Session:
71
161
  try:
72
162
  import pyoverleaf # type: ignore
@@ -169,23 +259,64 @@ class OverleafClient:
169
259
  raise RuntimeError("call connect() before using the client")
170
260
  return self._session
171
261
 
262
+ def _request(self, url: str, *, attempts: int = 3, **kwargs: Any) -> requests.Response:
263
+ """GET with retry on transient failures, and plain-English errors.
264
+
265
+ Retries connection errors, timeouts, and 429/5xx (honouring Retry-After).
266
+ Never retries 4xx other than 429 — those won't fix themselves.
267
+ """
268
+ last_exc: Exception | None = None
269
+ for attempt in range(attempts):
270
+ try:
271
+ r = self.session.get(url, timeout=30, **kwargs)
272
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
273
+ last_exc = e
274
+ if attempt == attempts - 1:
275
+ raise UserFacingError(_network_hint(e)) from e
276
+ time.sleep(2**attempt)
277
+ continue
278
+ if r.status_code in RETRY_STATUSES and attempt < attempts - 1:
279
+ wait = r.headers.get("Retry-After")
280
+ try:
281
+ delay = min(float(wait), 30.0) if wait else 2**attempt
282
+ except ValueError:
283
+ delay = 2**attempt
284
+ logger.warning(
285
+ "%s returned %s — retrying in %.0fs", url, r.status_code, delay
286
+ )
287
+ time.sleep(delay)
288
+ continue
289
+ return r
290
+ raise UserFacingError(_network_hint(last_exc or Exception())) # pragma: no cover
291
+
172
292
  def _get(self, path: str, expect_json: bool = True) -> Any:
173
293
  url = f"{self.base_url}{path}"
174
- r = self.session.get(url, timeout=30)
294
+ r = self._request(url)
175
295
  if r.status_code in (401, 403):
176
- raise RuntimeError(
177
- f"Overleaf returned {r.status_code} for {path}. Your session may have "
178
- "expired or you may not have access to this project. Refresh the "
179
- "Overleaf tab in your browser and re-run."
296
+ raise UserFacingError(
297
+ "Overleaf refused the request (not signed in).\n\n"
298
+ "Your saved session has expired. Open https://www.overleaf.com in "
299
+ "your browser, make sure you are signed in and can see this "
300
+ "project, then run the export again."
301
+ )
302
+ if r.status_code == 404:
303
+ raise UserFacingError(
304
+ "Overleaf could not find that project.\n\n"
305
+ "Check the project link is correct and that this account has "
306
+ "access to it. Open the project in your browser and copy the "
307
+ "address from the address bar."
180
308
  )
181
309
  r.raise_for_status()
182
310
  if not expect_json:
183
311
  return r.text
184
312
  ctype = r.headers.get("Content-Type", "")
185
313
  if "application/json" not in ctype:
186
- raise RuntimeError(
187
- f"Expected JSON from {path} but got Content-Type={ctype!r}. "
188
- "This usually means the endpoint moved or you got redirected to login."
314
+ raise UserFacingError(
315
+ "Overleaf returned a web page instead of data.\n\n"
316
+ "This usually means you were signed out. Open Overleaf in your "
317
+ "browser, sign in, and try again. If you are already signed in, "
318
+ "Overleaf may have changed its internal API — please report this "
319
+ "at https://github.com/Mangluu/overleaf-comments-export/issues"
189
320
  )
190
321
  return r.json()
191
322
 
@@ -213,6 +344,8 @@ class OverleafClient:
213
344
  Returns None if the endpoint isn't accessible (e.g. 404 on older deployments)."""
214
345
  try:
215
346
  return self._get(f"/project/{project_id}/ranges")
347
+ except UserFacingError:
348
+ raise # auth/network problems are fatal, not "no ranges"
216
349
  except requests.HTTPError as e:
217
350
  if e.response is not None and e.response.status_code == 404:
218
351
  logger.warning(
@@ -285,7 +418,7 @@ class OverleafClient:
285
418
  """
286
419
  path = f"/project/{project_id}"
287
420
  url = f"{self.base_url}{path}"
288
- r = self.session.get(url, timeout=30, allow_redirects=False)
421
+ r = self._request(url, allow_redirects=False)
289
422
  if r.status_code in (301, 302, 303, 307, 308):
290
423
  loc = r.headers.get("Location", "")
291
424
  if "login" in loc.lower():
@@ -415,6 +548,33 @@ class OverleafClient:
415
548
  return out
416
549
 
417
550
 
551
+ def _tool_version() -> str:
552
+ from . import __version__
553
+
554
+ return __version__
555
+
556
+
557
+ def _parse_cookie_string(pasted: str) -> dict[str, str]:
558
+ """Parse whatever the user pasted into {cookie_name: value}.
559
+
560
+ Handles a bare cookie value, `name=value`, and a full `document.cookie`
561
+ dump. Falls back to treating the whole string as the session value.
562
+ """
563
+ s = (pasted or "").strip().strip('"').strip("'")
564
+ if not s:
565
+ return {}
566
+ out: dict[str, str] = {}
567
+ for part in s.split(";"):
568
+ part = part.strip()
569
+ if not part or "=" not in part:
570
+ continue
571
+ name, _, value = part.partition("=")
572
+ name, value = name.strip(), value.strip().strip('"')
573
+ if name and value:
574
+ out[name] = value
575
+ return out or {"overleaf_session2": s}
576
+
577
+
418
578
  def _decode_meta_content(raw: str) -> Any:
419
579
  """Best-effort decode of a <meta content="..."> value.
420
580
 
@@ -8,8 +8,9 @@ from datetime import date, datetime, timezone
8
8
  from pathlib import Path
9
9
  from typing import Any, Callable
10
10
 
11
+ from . import __version__
11
12
  from .anchors import build_line_starts, resolve_anchor
12
- from .client import OverleafClient, parse_project_id
13
+ from .client import OverleafClient, UserFacingError, parse_project_id
13
14
  from .model import (
14
15
  AnchoredComment,
15
16
  DocText,
@@ -216,6 +217,24 @@ def _extract_context(
216
217
  )
217
218
 
218
219
 
220
+ def _deletion_context(doc: DocText, offset: int, deleted: str, line_no: int) -> SourceContext:
221
+ """Context for a tracked deletion.
222
+
223
+ The deleted text is no longer in the document, so it can't be located by
224
+ offset — it *is* the anchor, sitting between the surrounding live text.
225
+ """
226
+ text = doc.text
227
+ offset = max(0, min(offset, len(text)))
228
+ return SourceContext(
229
+ before=_normalize_ws(text[max(0, offset - CONTEXT_CHARS_BEFORE) : offset]),
230
+ anchor=_normalize_ws(deleted),
231
+ after=_normalize_ws(text[offset : offset + CONTEXT_CHARS_AFTER]),
232
+ truncated_before=offset > CONTEXT_CHARS_BEFORE,
233
+ truncated_after=offset + CONTEXT_CHARS_AFTER < len(text),
234
+ line_no=line_no,
235
+ )
236
+
237
+
219
238
  def _thread_matches_reviewer(thread: Thread | None, reviewer_filter: list[str]) -> bool:
220
239
  """True if the thread has at least one message from any reviewer in the
221
240
  filter list. `reviewer_filter` is a list of case-insensitive substrings
@@ -261,7 +280,9 @@ def _slug_reviewer(name: str) -> str:
261
280
 
262
281
 
263
282
  def _comment_to_jsonl_record(
264
- c: AnchoredComment, thread: Thread | None
283
+ c: AnchoredComment,
284
+ thread: Thread | None,
285
+ user_map: dict[str, dict[str, str]] | None = None,
265
286
  ) -> dict[str, Any]:
266
287
  """Self-contained JSONL record per comment (with embedded thread, since
267
288
  JSONL records are read independently)."""
@@ -275,8 +296,9 @@ def _comment_to_jsonl_record(
275
296
  "nearest_heading": c.nearest_heading,
276
297
  "anchored_text": c.anchored_text,
277
298
  "stale": c.stale,
299
+ "reply_count": max(0, len(thread.messages) - 1) if thread else 0,
278
300
  "context": _serialize_context(c.context),
279
- "thread": _serialize_thread(thread) if thread is not None else None,
301
+ "thread": _serialize_thread(thread, user_map) if thread is not None else None,
280
302
  }
281
303
 
282
304
 
@@ -308,6 +330,7 @@ def run_export(
308
330
  project_title: str | None = None,
309
331
  base_url: str = "https://www.overleaf.com",
310
332
  browser: str = "auto",
333
+ cookie_value: str | None = None,
311
334
  verbose: bool = False,
312
335
  include_raw: bool = False,
313
336
  include_open: bool = True,
@@ -342,8 +365,11 @@ def run_export(
342
365
  logger.info("Project id: %s", project_id)
343
366
 
344
367
  client = OverleafClient(base_url=base_url)
345
- progress(f"Authenticating via {browser} browser cookie…")
346
- client.connect(browser=browser)
368
+ if cookie_value:
369
+ progress("Authenticating with the pasted session cookie…")
370
+ else:
371
+ progress(f"Authenticating via {browser} browser cookie…")
372
+ client.connect(browser=browser, cookie_value=cookie_value)
347
373
 
348
374
  progress("Fetching threads…")
349
375
  threads_raw = client.get_threads(project_id)
@@ -444,9 +470,10 @@ def run_export(
444
470
  heading = nearest_heading(doc.headings, line)
445
471
  uid = str(meta.get("user_id") or "") or None
446
472
  user = user_map.get(uid or "", {}) if uid else {}
447
- context = _extract_context(
448
- doc, ro, content if kind == "insertion" else "", line
449
- )
473
+ if kind == "insertion":
474
+ context = _extract_context(doc, ro, content, line)
475
+ else:
476
+ context = _deletion_context(doc, ro, content, line)
450
477
  changes.append(
451
478
  TrackedChange(
452
479
  id=str(ch.get("id") or ch.get("_id") or ""),
@@ -574,6 +601,7 @@ def run_export(
574
601
  threads_raw=threads_raw,
575
602
  ranges_payload=ranges_payload,
576
603
  include_raw=include_raw,
604
+ user_map=user_map,
577
605
  )
578
606
  json_payload["filters_applied"] = {
579
607
  "include_open": include_open,
@@ -591,7 +619,7 @@ def run_export(
591
619
  jsonl_path = out_dir / "comments.jsonl"
592
620
  with jsonl_path.open("w", encoding="utf-8") as f:
593
621
  for c in anchored:
594
- rec = _comment_to_jsonl_record(c, threads.get(c.thread_id))
622
+ rec = _comment_to_jsonl_record(c, threads.get(c.thread_id), user_map)
595
623
  f.write(json.dumps(rec, default=str))
596
624
  f.write("\n")
597
625
  progress(f"Wrote {jsonl_path.name} ({len(anchored)} record(s))")
@@ -682,15 +710,31 @@ def _serialize_context(ctx: SourceContext | None) -> dict[str, Any] | None:
682
710
  }
683
711
 
684
712
 
685
- def _serialize_thread(thread: Thread) -> dict[str, Any]:
713
+ def _serialize_thread(
714
+ thread: Thread, user_map: dict[str, dict[str, str]] | None = None
715
+ ) -> dict[str, Any]:
716
+ """Serialize a thread, tagging the first message as the comment itself and
717
+ the rest as replies (Overleaf threads are flat, ordered by time)."""
718
+ ordered = sorted(thread.messages, key=lambda x: x.timestamp_ms)
719
+ resolver = user_map or {}
720
+ resolved_by = resolver.get(thread.resolved_by_user_id or "", {})
686
721
  return {
687
722
  "id": thread.id,
688
723
  "resolved": thread.resolved,
689
724
  "resolved_at": _iso(thread.resolved_at_ms),
690
- "resolved_by_user_id": thread.resolved_by_user_id,
725
+ "resolved_by": {
726
+ "id": thread.resolved_by_user_id,
727
+ "name": resolved_by.get("name"),
728
+ "email": resolved_by.get("email"),
729
+ }
730
+ if thread.resolved_by_user_id
731
+ else None,
732
+ "reply_count": max(0, len(ordered) - 1),
691
733
  "messages": [
692
734
  {
693
735
  "id": m.id,
736
+ "role": "comment" if i == 0 else "reply",
737
+ "reply_index": None if i == 0 else i - 1,
694
738
  "user": {
695
739
  "id": m.user_id,
696
740
  "name": m.user_name,
@@ -700,7 +744,7 @@ def _serialize_thread(thread: Thread) -> dict[str, Any]:
700
744
  "timestamp": _iso(m.timestamp_ms),
701
745
  "edited_at": _iso(m.edited_at_ms),
702
746
  }
703
- for m in sorted(thread.messages, key=lambda x: x.timestamp_ms)
747
+ for i, m in enumerate(ordered)
704
748
  ],
705
749
  }
706
750
 
@@ -720,6 +764,7 @@ def _build_structured_json(
720
764
  threads_raw: dict[str, Any],
721
765
  ranges_payload: Any,
722
766
  include_raw: bool = False,
767
+ user_map: dict[str, dict[str, str]] | None = None,
723
768
  ) -> dict[str, Any]:
724
769
  """Produce a clean, AI-ingestion-friendly JSON document.
725
770
 
@@ -750,6 +795,7 @@ def _build_structured_json(
750
795
 
751
796
  payload: dict[str, Any] = {
752
797
  "schema_version": SCHEMA_VERSION,
798
+ "tool_version": __version__,
753
799
  "project": {
754
800
  "id": project_id,
755
801
  "title": project_title,
@@ -774,7 +820,9 @@ def _build_structured_json(
774
820
  # Threads are stored ONCE at the top level keyed by thread_id;
775
821
  # comments reference them via `thread_id`. This avoids duplicating
776
822
  # potentially long discussions inside every comment.
777
- "threads": {tid: _serialize_thread(t) for tid, t in threads.items()},
823
+ "threads": {
824
+ tid: _serialize_thread(t, user_map) for tid, t in threads.items()
825
+ },
778
826
  "files": files,
779
827
  "comments": [
780
828
  {
@@ -788,6 +836,9 @@ def _build_structured_json(
788
836
  "nearest_heading": c.nearest_heading,
789
837
  "anchored_text": c.anchored_text,
790
838
  "stale": c.stale,
839
+ "reply_count": max(
840
+ 0, len(threads[c.thread_id].messages) - 1
841
+ ) if c.thread_id in threads else 0,
791
842
  "context": _serialize_context(c.context),
792
843
  }
793
844
  for c in anchored
@@ -872,6 +923,11 @@ rendered output where truncation is true.
872
923
  ## How to address comments
873
924
 
874
925
  - Refer to comments by `short_id` (e.g., "C014"), not by `thread_id`.
926
+ - Each thread's FIRST message (`role: "comment"`) is the reviewer's actual
927
+ ask. Later messages (`role: "reply"`, shown indented with `↳` in the
928
+ Markdown) are follow-up discussion, often between co-authors. Answer the
929
+ ask, taking the discussion into account; do not re-litigate sub-points the
930
+ replies already settled.
875
931
  - For each open comment, propose an edit to the .tex source. If the comment
876
932
  is a question, answer it; if it's a request, attempt the change.
877
933
  - Stale comments (`stale: true`) may not point to the current location in the