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.
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/PKG-INFO +24 -4
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/README.md +23 -3
- overleaf_comments_export-0.3.0/overleaf_comments_export/__init__.py +8 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/__main__.py +40 -18
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/client.py +179 -19
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/export.py +69 -13
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/gui.py +147 -23
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/render.py +23 -7
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/PKG-INFO +24 -4
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/SOURCES.txt +3 -1
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/pyproject.toml +1 -1
- overleaf_comments_export-0.3.0/tests/test_errors_and_auth.py +157 -0
- overleaf_comments_export-0.3.0/tests/test_replies_and_changes.py +156 -0
- overleaf_comments_export-0.2.0/overleaf_comments_export/__init__.py +0 -1
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/LICENSE +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/anchors.py +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/model.py +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/sections.py +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/dependency_links.txt +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/entry_points.txt +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/requires.txt +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export.egg-info/top_level.txt +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/setup.cfg +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/tests/test_anchors_sections.py +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/tests/test_client.py +0 -0
- {overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/tests/test_export.py +0 -0
- {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.
|
|
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
|
-
|
|
140
|
+
can't decrypt. On Linux, snap-packaged browsers sandbox their cookie stores.
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
104
|
+
can't decrypt. On Linux, snap-packaged browsers sandbox their cookie stores.
|
|
105
105
|
|
|
106
|
-
|
|
107
|
-
|
|
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 .
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
{overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/client.py
RENAMED
|
@@ -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 = (
|
|
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
|
|
92
|
+
def connect(self, browser: str = "auto", cookie_value: str | None = None) -> None:
|
|
93
|
+
"""Authenticate as the user.
|
|
45
94
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
294
|
+
r = self._request(url)
|
|
175
295
|
if r.status_code in (401, 403):
|
|
176
|
-
raise
|
|
177
|
-
|
|
178
|
-
"
|
|
179
|
-
"
|
|
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
|
|
187
|
-
|
|
188
|
-
"This usually means
|
|
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.
|
|
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
|
|
{overleaf_comments_export-0.2.0 → overleaf_comments_export-0.3.0}/overleaf_comments_export/export.py
RENAMED
|
@@ -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,
|
|
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
|
-
|
|
346
|
-
|
|
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
|
-
|
|
448
|
-
doc, ro, content
|
|
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(
|
|
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
|
-
"
|
|
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
|
|
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": {
|
|
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
|