overleaf-comments-export 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overleaf_comments_export/__init__.py +1 -0
- overleaf_comments_export/__main__.py +143 -0
- overleaf_comments_export/anchors.py +61 -0
- overleaf_comments_export/client.py +440 -0
- overleaf_comments_export/export.py +892 -0
- overleaf_comments_export/gui.py +583 -0
- overleaf_comments_export/model.py +92 -0
- overleaf_comments_export/render.py +370 -0
- overleaf_comments_export/sections.py +96 -0
- overleaf_comments_export-0.2.0.dist-info/METADATA +156 -0
- overleaf_comments_export-0.2.0.dist-info/RECORD +15 -0
- overleaf_comments_export-0.2.0.dist-info/WHEEL +5 -0
- overleaf_comments_export-0.2.0.dist-info/entry_points.txt +2 -0
- overleaf_comments_export-0.2.0.dist-info/licenses/LICENSE +21 -0
- overleaf_comments_export-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .client import OverleafClient
|
|
9
|
+
from .export import ExportResult, run_export
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="overleaf-comments-export",
|
|
15
|
+
description="Export Overleaf comment threads and tracked changes to Markdown.",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--gui",
|
|
19
|
+
action="store_true",
|
|
20
|
+
help="Launch the graphical interface (default if no other args).",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--project-url",
|
|
24
|
+
help="Full Overleaf project URL, e.g. https://www.overleaf.com/project/<24-hex-id>.",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--out",
|
|
28
|
+
type=Path,
|
|
29
|
+
help="Directory to write comments-<date>.md, comments.json, and comments.log into.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--project-title",
|
|
33
|
+
default=None,
|
|
34
|
+
help="Optional human-readable title for the report header. Defaults to the project id.",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--browser",
|
|
38
|
+
default="auto",
|
|
39
|
+
choices=list(OverleafClient.SUPPORTED_BROWSERS),
|
|
40
|
+
help="Which browser to read cookies from. Default: auto-detect.",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--base-url",
|
|
44
|
+
default="https://www.overleaf.com",
|
|
45
|
+
help="Override the Overleaf base URL (for self-hosted instances).",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"-v", "--verbose", action="store_true", help="More logging."
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--render-mode",
|
|
52
|
+
choices=["compact", "detailed"],
|
|
53
|
+
default="compact",
|
|
54
|
+
help="Markdown layout: 'compact' (one line per comment, default) or "
|
|
55
|
+
"'detailed' (multi-line code-fence with anchor highlighted).",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--no-open",
|
|
59
|
+
dest="include_open",
|
|
60
|
+
action="store_false",
|
|
61
|
+
help="Skip open (unresolved) comments.",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--no-resolved",
|
|
65
|
+
dest="include_resolved",
|
|
66
|
+
action="store_false",
|
|
67
|
+
help="Skip resolved comments.",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--no-changes",
|
|
71
|
+
dest="include_changes",
|
|
72
|
+
action="store_false",
|
|
73
|
+
help="Skip tracked changes.",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--reviewer",
|
|
77
|
+
action="append",
|
|
78
|
+
default=[],
|
|
79
|
+
metavar="NAME",
|
|
80
|
+
help="Only include threads/changes touching this reviewer "
|
|
81
|
+
"(case-insensitive substring match against name and email). "
|
|
82
|
+
"Pass multiple times for OR-of-reviewers.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--include-raw",
|
|
86
|
+
action="store_true",
|
|
87
|
+
help="Embed the unprocessed Overleaf API payloads inside comments.json.",
|
|
88
|
+
)
|
|
89
|
+
parser.add_argument(
|
|
90
|
+
"--no-jsonl",
|
|
91
|
+
dest="write_jsonl",
|
|
92
|
+
action="store_false",
|
|
93
|
+
help="Skip writing comments.jsonl (the streaming-friendly companion).",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--per-reviewer",
|
|
97
|
+
action="store_true",
|
|
98
|
+
help="Also write one Markdown per reviewer into by-reviewer/.",
|
|
99
|
+
)
|
|
100
|
+
parser.set_defaults(
|
|
101
|
+
include_open=True,
|
|
102
|
+
include_resolved=True,
|
|
103
|
+
include_changes=True,
|
|
104
|
+
write_jsonl=True,
|
|
105
|
+
)
|
|
106
|
+
args = parser.parse_args(argv)
|
|
107
|
+
|
|
108
|
+
if args.gui or (not args.project_url and not args.out):
|
|
109
|
+
from .gui import launch_gui
|
|
110
|
+
return launch_gui()
|
|
111
|
+
|
|
112
|
+
if not args.project_url or not args.out:
|
|
113
|
+
parser.error("--project-url and --out are required in CLI mode (or pass --gui).")
|
|
114
|
+
|
|
115
|
+
logging.basicConfig(
|
|
116
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
117
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
118
|
+
stream=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
|
|
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
|
+
)
|
|
138
|
+
print(f"\nDone. Open: {result.markdown_path}")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
sys.exit(main())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from bisect import bisect_right
|
|
4
|
+
|
|
5
|
+
from .model import DocText
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_line_starts(text: str) -> list[int]:
|
|
9
|
+
"""line_starts[i] = char offset where line (i+1) starts. So
|
|
10
|
+
bisect_right(line_starts, offset) gives the 1-indexed line number."""
|
|
11
|
+
starts = [0]
|
|
12
|
+
for i, ch in enumerate(text):
|
|
13
|
+
if ch == "\n":
|
|
14
|
+
starts.append(i + 1)
|
|
15
|
+
return starts
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def offset_to_line_col(line_starts: list[int], offset: int) -> tuple[int, int]:
|
|
19
|
+
"""Convert a flat character offset into a 1-indexed (line, column).
|
|
20
|
+
Column is 0-indexed (chars after the line start)."""
|
|
21
|
+
if offset < 0:
|
|
22
|
+
offset = 0
|
|
23
|
+
line_no = bisect_right(line_starts, offset)
|
|
24
|
+
if line_no <= 0:
|
|
25
|
+
line_no = 1
|
|
26
|
+
col = offset - line_starts[line_no - 1]
|
|
27
|
+
return line_no, col
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_anchor(
|
|
31
|
+
doc: DocText, offset: int, anchored_text: str, search_window: int = 200
|
|
32
|
+
) -> tuple[int, int, int, bool]:
|
|
33
|
+
"""Map an offset+expected-text anchor to (resolved_offset, line, col, stale).
|
|
34
|
+
|
|
35
|
+
If text[offset:offset+len(anchored_text)] matches anchored_text, we trust it.
|
|
36
|
+
Otherwise, search +/- search_window characters for the anchored text and
|
|
37
|
+
re-anchor. If still not found, return the original offset's coords and
|
|
38
|
+
mark stale=True."""
|
|
39
|
+
text = doc.text
|
|
40
|
+
n = len(anchored_text)
|
|
41
|
+
if n > 0 and 0 <= offset <= len(text) - n and text[offset : offset + n] == anchored_text:
|
|
42
|
+
line, col = offset_to_line_col(doc.line_starts, offset)
|
|
43
|
+
return offset, line, col, False
|
|
44
|
+
|
|
45
|
+
if n > 0:
|
|
46
|
+
# Nearby search
|
|
47
|
+
lo = max(0, offset - search_window)
|
|
48
|
+
hi = min(len(text), offset + search_window + n)
|
|
49
|
+
idx = text.find(anchored_text, lo, hi)
|
|
50
|
+
if idx != -1:
|
|
51
|
+
line, col = offset_to_line_col(doc.line_starts, idx)
|
|
52
|
+
return idx, line, col, False
|
|
53
|
+
# Last-resort whole-document search; mark stale (since it moved far)
|
|
54
|
+
# but still produce a usable line/col rather than guessing.
|
|
55
|
+
idx = text.find(anchored_text)
|
|
56
|
+
if idx != -1:
|
|
57
|
+
line, col = offset_to_line_col(doc.line_starts, idx)
|
|
58
|
+
return idx, line, col, True
|
|
59
|
+
|
|
60
|
+
line, col = offset_to_line_col(doc.line_starts, min(offset, max(0, len(text) - 1)))
|
|
61
|
+
return offset, line, col, True
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from urllib.parse import unquote, urlparse
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
OVERLEAF_BASE = "https://www.overleaf.com"
|
|
15
|
+
|
|
16
|
+
PROJECT_URL_RE = re.compile(r"/project/(?P<id>[0-9a-fA-F]{24})")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_project_id(project_url: str) -> str:
|
|
20
|
+
parsed = urlparse(project_url)
|
|
21
|
+
m = PROJECT_URL_RE.search(parsed.path)
|
|
22
|
+
if not m:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"Could not extract a 24-char project id from URL: {project_url!r}. "
|
|
25
|
+
"Expected something like https://www.overleaf.com/project/<24-hex-chars>."
|
|
26
|
+
)
|
|
27
|
+
return m.group("id")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OverleafClient:
|
|
31
|
+
"""Thin wrapper around pyoverleaf for cookie auth + a few extra endpoints
|
|
32
|
+
(threads, ranges, plain-text doc download, file tree)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, base_url: str = OVERLEAF_BASE) -> None:
|
|
35
|
+
self.base_url = base_url.rstrip("/")
|
|
36
|
+
self._api = None
|
|
37
|
+
self._session: Optional[requests.Session] = None
|
|
38
|
+
|
|
39
|
+
SUPPORTED_BROWSERS = ("safari", "firefox", "auto", "chrome", "chromium", "edge", "brave")
|
|
40
|
+
# Browsers that don't trigger macOS Keychain or password prompts:
|
|
41
|
+
PRIVACY_FRIENDLY_BROWSERS = ("safari", "firefox")
|
|
42
|
+
|
|
43
|
+
def connect(self, browser: str = "auto") -> None:
|
|
44
|
+
"""Authenticate via the user's browser cookie.
|
|
45
|
+
|
|
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.
|
|
49
|
+
"""
|
|
50
|
+
browser = (browser or "auto").lower()
|
|
51
|
+
if browser not in self.SUPPORTED_BROWSERS:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Unsupported browser {browser!r}. Choose one of: "
|
|
54
|
+
+ ", ".join(self.SUPPORTED_BROWSERS)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if browser == "auto":
|
|
58
|
+
session = self._connect_via_pyoverleaf()
|
|
59
|
+
else:
|
|
60
|
+
session = self._connect_via_browser_cookie3(browser)
|
|
61
|
+
|
|
62
|
+
session.headers.setdefault(
|
|
63
|
+
"User-Agent",
|
|
64
|
+
"overleaf-comments-export/0.1 (Mozilla/5.0 compatible)",
|
|
65
|
+
)
|
|
66
|
+
session.headers.setdefault("Accept", "application/json, text/plain, */*")
|
|
67
|
+
session.headers.setdefault("Referer", f"{self.base_url}/")
|
|
68
|
+
self._session = session
|
|
69
|
+
|
|
70
|
+
def _connect_via_pyoverleaf(self) -> requests.Session:
|
|
71
|
+
try:
|
|
72
|
+
import pyoverleaf # type: ignore
|
|
73
|
+
except ImportError as e:
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
"pyoverleaf is not installed. Install it with `pip install pyoverleaf`."
|
|
76
|
+
) from e
|
|
77
|
+
|
|
78
|
+
api = pyoverleaf.Api()
|
|
79
|
+
try:
|
|
80
|
+
api.login_from_browser()
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise RuntimeError(
|
|
83
|
+
"Could not read an Overleaf session cookie from any browser. "
|
|
84
|
+
"Open https://www.overleaf.com in Chrome or Firefox, sign in, then "
|
|
85
|
+
"re-run this tool. Or pick a specific browser in the dropdown."
|
|
86
|
+
) from e
|
|
87
|
+
self._api = api
|
|
88
|
+
return self._extract_session(api)
|
|
89
|
+
|
|
90
|
+
def _connect_via_browser_cookie3(self, browser: str) -> requests.Session:
|
|
91
|
+
try:
|
|
92
|
+
import browser_cookie3 # type: ignore
|
|
93
|
+
except ImportError as e:
|
|
94
|
+
import sys
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"browser-cookie3 could not be imported by this Python "
|
|
97
|
+
f"({sys.executable}). Underlying error: {e}. "
|
|
98
|
+
"If this is the bundled .app, try quitting and relaunching it — "
|
|
99
|
+
"the launcher will reinstall dependencies on the next start."
|
|
100
|
+
) from e
|
|
101
|
+
|
|
102
|
+
loader = getattr(browser_cookie3, browser, None)
|
|
103
|
+
if loader is None:
|
|
104
|
+
raise RuntimeError(
|
|
105
|
+
f"browser-cookie3 has no loader for {browser!r}. Available: "
|
|
106
|
+
+ ", ".join(n for n in dir(browser_cookie3) if not n.startswith("_"))
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
jar = loader(domain_name="overleaf.com")
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"Could not read Overleaf cookies from {browser}. Make sure {browser} "
|
|
114
|
+
"is installed and you are signed in to overleaf.com in it. On macOS, "
|
|
115
|
+
"browsers like Chrome may require granting Terminal/the launcher app "
|
|
116
|
+
"Full Disk Access in System Settings → Privacy & Security to read "
|
|
117
|
+
"their cookie store."
|
|
118
|
+
) from e
|
|
119
|
+
|
|
120
|
+
session_cookie = next(
|
|
121
|
+
(c for c in jar if c.name in ("overleaf_session2", "overleaf.sid")),
|
|
122
|
+
None,
|
|
123
|
+
)
|
|
124
|
+
if session_cookie is None:
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
f"No Overleaf session cookie found in {browser}. Sign in to "
|
|
127
|
+
"https://www.overleaf.com in that browser and retry."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
session = requests.Session()
|
|
131
|
+
session.cookies.update(jar)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
import pyoverleaf # type: ignore
|
|
135
|
+
api = pyoverleaf.Api()
|
|
136
|
+
api.login_from_browser()
|
|
137
|
+
self._api = api
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.warning(
|
|
140
|
+
"pyoverleaf init failed (file tree may be unavailable, paths will "
|
|
141
|
+
"fall back to <unknown-doc-id>): %s",
|
|
142
|
+
e,
|
|
143
|
+
)
|
|
144
|
+
self._api = None
|
|
145
|
+
|
|
146
|
+
return session
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _extract_session(api: Any) -> requests.Session:
|
|
150
|
+
for attr in ("_session", "session", "_client", "client"):
|
|
151
|
+
candidate = getattr(api, attr, None)
|
|
152
|
+
if isinstance(candidate, requests.Session):
|
|
153
|
+
return candidate
|
|
154
|
+
for attr in dir(api):
|
|
155
|
+
try:
|
|
156
|
+
candidate = getattr(api, attr)
|
|
157
|
+
except Exception:
|
|
158
|
+
continue
|
|
159
|
+
if isinstance(candidate, requests.Session):
|
|
160
|
+
return candidate
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
"Could not find a requests.Session on the pyoverleaf Api object. "
|
|
163
|
+
"This tool may need to be updated for the installed pyoverleaf version."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def session(self) -> requests.Session:
|
|
168
|
+
if self._session is None:
|
|
169
|
+
raise RuntimeError("call connect() before using the client")
|
|
170
|
+
return self._session
|
|
171
|
+
|
|
172
|
+
def _get(self, path: str, expect_json: bool = True) -> Any:
|
|
173
|
+
url = f"{self.base_url}{path}"
|
|
174
|
+
r = self.session.get(url, timeout=30)
|
|
175
|
+
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."
|
|
180
|
+
)
|
|
181
|
+
r.raise_for_status()
|
|
182
|
+
if not expect_json:
|
|
183
|
+
return r.text
|
|
184
|
+
ctype = r.headers.get("Content-Type", "")
|
|
185
|
+
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."
|
|
189
|
+
)
|
|
190
|
+
return r.json()
|
|
191
|
+
|
|
192
|
+
def get_threads(self, project_id: str) -> dict[str, Any]:
|
|
193
|
+
"""GET /project/:id/threads -> dict keyed by thread_id."""
|
|
194
|
+
data = self._get(f"/project/{project_id}/threads")
|
|
195
|
+
if not isinstance(data, dict):
|
|
196
|
+
raise RuntimeError(f"Unexpected /threads response shape: {type(data).__name__}")
|
|
197
|
+
return data
|
|
198
|
+
|
|
199
|
+
def get_resolved_thread_ids(self, project_id: str) -> list[str]:
|
|
200
|
+
try:
|
|
201
|
+
data = self._get(f"/project/{project_id}/resolved-thread-ids")
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.warning("resolved-thread-ids fetch failed: %s", e)
|
|
204
|
+
return []
|
|
205
|
+
if isinstance(data, dict) and "resolvedThreadIds" in data:
|
|
206
|
+
return list(data["resolvedThreadIds"])
|
|
207
|
+
if isinstance(data, list):
|
|
208
|
+
return list(data)
|
|
209
|
+
return []
|
|
210
|
+
|
|
211
|
+
def get_project_ranges(self, project_id: str) -> Optional[dict[str, Any]]:
|
|
212
|
+
"""GET /project/:id/ranges -> { docs: [{ id, ranges: { comments, changes } }] }.
|
|
213
|
+
Returns None if the endpoint isn't accessible (e.g. 404 on older deployments)."""
|
|
214
|
+
try:
|
|
215
|
+
return self._get(f"/project/{project_id}/ranges")
|
|
216
|
+
except requests.HTTPError as e:
|
|
217
|
+
if e.response is not None and e.response.status_code == 404:
|
|
218
|
+
logger.warning(
|
|
219
|
+
"/project/%s/ranges returned 404 — anchors and tracked changes "
|
|
220
|
+
"will be omitted from the export.",
|
|
221
|
+
project_id,
|
|
222
|
+
)
|
|
223
|
+
return None
|
|
224
|
+
raise
|
|
225
|
+
except RuntimeError as e:
|
|
226
|
+
logger.warning("ranges fetch failed: %s", e)
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def download_doc_text(self, project_id: str, doc_id: str) -> str:
|
|
230
|
+
"""GET /Project/:id/doc/:doc_id/download -> plain text body."""
|
|
231
|
+
return self._get(
|
|
232
|
+
f"/Project/{project_id}/doc/{doc_id}/download", expect_json=False
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def get_project_metadata(self, project_id: str) -> dict[str, Any]:
|
|
236
|
+
"""Best-effort fetch of project name + file tree.
|
|
237
|
+
|
|
238
|
+
Tries (in order): pyoverleaf socket call, then an HTML scrape of the
|
|
239
|
+
project editor page (parses <meta name="ol-*"> tags). The two paths
|
|
240
|
+
return different shapes; we merge them.
|
|
241
|
+
"""
|
|
242
|
+
result: dict[str, Any] = {"files": None, "name": None, "rootDocId": None, "raw_meta": {}}
|
|
243
|
+
|
|
244
|
+
api = self._api
|
|
245
|
+
if api is not None:
|
|
246
|
+
for method_name in ("project_get_files", "get_project_files", "get_files"):
|
|
247
|
+
method = getattr(api, method_name, None)
|
|
248
|
+
if callable(method):
|
|
249
|
+
try:
|
|
250
|
+
files = method(project_id)
|
|
251
|
+
if files:
|
|
252
|
+
result["files"] = files
|
|
253
|
+
logger.info("pyoverleaf.%s returned file tree", method_name)
|
|
254
|
+
break
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.warning("pyoverleaf.%s failed: %s", method_name, e)
|
|
257
|
+
|
|
258
|
+
# HTML fallback — works even when the socket path fails.
|
|
259
|
+
try:
|
|
260
|
+
meta = self.scrape_project_html(project_id)
|
|
261
|
+
result["raw_meta"] = meta
|
|
262
|
+
if meta.get("ol-project"):
|
|
263
|
+
proj = meta["ol-project"]
|
|
264
|
+
if isinstance(proj, dict):
|
|
265
|
+
if not result["files"] and proj.get("rootFolder"):
|
|
266
|
+
result["files"] = proj["rootFolder"]
|
|
267
|
+
result["name"] = result["name"] or proj.get("name")
|
|
268
|
+
result["rootDocId"] = result["rootDocId"] or proj.get("rootDoc_id")
|
|
269
|
+
for key in ("ol-projectName", "ol-project-name"):
|
|
270
|
+
if meta.get(key) and not result["name"]:
|
|
271
|
+
result["name"] = meta[key]
|
|
272
|
+
for key in ("ol-rootDocId", "ol-root-doc-id"):
|
|
273
|
+
if meta.get(key) and not result["rootDocId"]:
|
|
274
|
+
result["rootDocId"] = meta[key]
|
|
275
|
+
except Exception as e:
|
|
276
|
+
logger.warning("HTML scrape of project page failed: %s", e)
|
|
277
|
+
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
def scrape_project_html(self, project_id: str) -> dict[str, Any]:
|
|
281
|
+
"""GET /project/:id and parse <meta name="ol-*" content="..."> tags.
|
|
282
|
+
|
|
283
|
+
Values are HTML-entity-decoded and JSON-parsed when possible.
|
|
284
|
+
Returns a dict { 'ol-<key>': decoded_value }.
|
|
285
|
+
"""
|
|
286
|
+
path = f"/project/{project_id}"
|
|
287
|
+
url = f"{self.base_url}{path}"
|
|
288
|
+
r = self.session.get(url, timeout=30, allow_redirects=False)
|
|
289
|
+
if r.status_code in (301, 302, 303, 307, 308):
|
|
290
|
+
loc = r.headers.get("Location", "")
|
|
291
|
+
if "login" in loc.lower():
|
|
292
|
+
raise RuntimeError(
|
|
293
|
+
"Overleaf redirected the project page to a login URL. "
|
|
294
|
+
"Your session cookie is missing or expired."
|
|
295
|
+
)
|
|
296
|
+
if r.status_code in (401, 403):
|
|
297
|
+
raise RuntimeError(
|
|
298
|
+
f"Overleaf returned {r.status_code} for {path}. Session likely expired."
|
|
299
|
+
)
|
|
300
|
+
r.raise_for_status()
|
|
301
|
+
body = r.text
|
|
302
|
+
|
|
303
|
+
# Match <meta name="ol-*" content="..." /> with attributes in either order.
|
|
304
|
+
meta_re = re.compile(
|
|
305
|
+
r"<meta\b[^>]*\bname\s*=\s*\"(?P<name>ol-[^\"]+)\"[^>]*\bcontent\s*=\s*\"(?P<content>[^\"]*)\"",
|
|
306
|
+
re.IGNORECASE,
|
|
307
|
+
)
|
|
308
|
+
meta_re_alt = re.compile(
|
|
309
|
+
r"<meta\b[^>]*\bcontent\s*=\s*\"(?P<content>[^\"]*)\"[^>]*\bname\s*=\s*\"(?P<name>ol-[^\"]+)\"",
|
|
310
|
+
re.IGNORECASE,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
found: dict[str, Any] = {}
|
|
314
|
+
for regex in (meta_re, meta_re_alt):
|
|
315
|
+
for m in regex.finditer(body):
|
|
316
|
+
name = m.group("name")
|
|
317
|
+
if name in found:
|
|
318
|
+
continue
|
|
319
|
+
raw = m.group("content")
|
|
320
|
+
found[name] = _decode_meta_content(raw)
|
|
321
|
+
|
|
322
|
+
if not found:
|
|
323
|
+
logger.warning(
|
|
324
|
+
"Project HTML at %s contained no <meta name=\"ol-*\"> tags — "
|
|
325
|
+
"the editor may not have been rendered (login wall? error page?).",
|
|
326
|
+
path,
|
|
327
|
+
)
|
|
328
|
+
else:
|
|
329
|
+
logger.info("HTML scrape found %d ol-meta attributes", len(found))
|
|
330
|
+
return found
|
|
331
|
+
|
|
332
|
+
def flatten_files(self, files_root: Any, debug_logger=None) -> list[dict[str, str]]:
|
|
333
|
+
"""Walk a project file tree and return a flat list of
|
|
334
|
+
{doc_id, pathname} for each editable doc.
|
|
335
|
+
|
|
336
|
+
Handles three shapes:
|
|
337
|
+
1. pyoverleaf.ProjectFolder dataclass (`id`, `name`, `children` of
|
|
338
|
+
ProjectFolder/ProjectFile, type=="doc"/"file"/"folder")
|
|
339
|
+
2. dict with `docs` / `folders` keys (raw Overleaf rootFolder JSON)
|
|
340
|
+
3. dict with `_id` / `name` / `type` (single entity)
|
|
341
|
+
"""
|
|
342
|
+
out: list[dict[str, str]] = []
|
|
343
|
+
|
|
344
|
+
def walk_pyo(node: Any, parent: str) -> None:
|
|
345
|
+
# pyoverleaf ProjectFolder / ProjectFile
|
|
346
|
+
node_type = getattr(node, "type", None)
|
|
347
|
+
if node_type == "folder":
|
|
348
|
+
name = getattr(node, "name", "") or ""
|
|
349
|
+
new_parent = (
|
|
350
|
+
f"{parent}{name}/" if name and name != "rootFolder" else parent
|
|
351
|
+
)
|
|
352
|
+
for child in getattr(node, "children", []) or []:
|
|
353
|
+
walk_pyo(child, new_parent)
|
|
354
|
+
return
|
|
355
|
+
if node_type == "doc":
|
|
356
|
+
node_id = getattr(node, "id", None)
|
|
357
|
+
name = getattr(node, "name", None)
|
|
358
|
+
if node_id and name:
|
|
359
|
+
out.append({"doc_id": str(node_id), "pathname": f"{parent}{name}"})
|
|
360
|
+
return
|
|
361
|
+
# Skip type=="file" (binary attachments — figures, etc.)
|
|
362
|
+
if node_type == "file":
|
|
363
|
+
return
|
|
364
|
+
# Fall through to dict/list walkers
|
|
365
|
+
walk_any(node, parent)
|
|
366
|
+
|
|
367
|
+
def walk_dict(node: dict[str, Any], parent: str) -> None:
|
|
368
|
+
for doc in node.get("docs", []) or []:
|
|
369
|
+
doc_id = doc.get("_id") or doc.get("id")
|
|
370
|
+
name = doc.get("name") or ""
|
|
371
|
+
if doc_id and name:
|
|
372
|
+
out.append({"doc_id": str(doc_id), "pathname": f"{parent}{name}"})
|
|
373
|
+
for folder in node.get("folders", []) or []:
|
|
374
|
+
fname = folder.get("name") or ""
|
|
375
|
+
new_parent = (
|
|
376
|
+
f"{parent}{fname}/" if fname and fname != "rootFolder" else parent
|
|
377
|
+
)
|
|
378
|
+
walk_dict(folder, new_parent)
|
|
379
|
+
|
|
380
|
+
def walk_any(node: Any, parent: str) -> None:
|
|
381
|
+
if hasattr(node, "type") and hasattr(node, "name"):
|
|
382
|
+
walk_pyo(node, parent)
|
|
383
|
+
return
|
|
384
|
+
if isinstance(node, dict):
|
|
385
|
+
if "docs" in node or "folders" in node:
|
|
386
|
+
walk_dict(node, parent)
|
|
387
|
+
return
|
|
388
|
+
doc_id = node.get("_id") or node.get("id") or node.get("doc_id")
|
|
389
|
+
name = node.get("name")
|
|
390
|
+
kind = node.get("type") or node.get("kind")
|
|
391
|
+
if doc_id and name and kind in (None, "doc", "file"):
|
|
392
|
+
out.append({"doc_id": str(doc_id), "pathname": f"{parent}{name}"})
|
|
393
|
+
return
|
|
394
|
+
if isinstance(node, list):
|
|
395
|
+
for item in node:
|
|
396
|
+
walk_any(item, parent)
|
|
397
|
+
|
|
398
|
+
if hasattr(files_root, "type") and hasattr(files_root, "name"):
|
|
399
|
+
walk_pyo(files_root, "")
|
|
400
|
+
elif isinstance(files_root, list):
|
|
401
|
+
for entry in files_root:
|
|
402
|
+
walk_any(entry, "")
|
|
403
|
+
else:
|
|
404
|
+
walk_any(files_root, "")
|
|
405
|
+
|
|
406
|
+
if debug_logger is not None and not out:
|
|
407
|
+
try:
|
|
408
|
+
preview = json.dumps(files_root, default=str)[:1200]
|
|
409
|
+
except Exception:
|
|
410
|
+
preview = repr(files_root)[:1200]
|
|
411
|
+
debug_logger(
|
|
412
|
+
"flatten_files returned 0 entries. Raw shape (truncated): %s",
|
|
413
|
+
preview,
|
|
414
|
+
)
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _decode_meta_content(raw: str) -> Any:
|
|
419
|
+
"""Best-effort decode of a <meta content="..."> value.
|
|
420
|
+
|
|
421
|
+
Overleaf URL-encodes JSON values in meta content. We try (in order):
|
|
422
|
+
HTML entity unescape, URL-decode, then JSON parse. If JSON parse fails,
|
|
423
|
+
return the unescaped string.
|
|
424
|
+
"""
|
|
425
|
+
s = html.unescape(raw)
|
|
426
|
+
if "%" in s:
|
|
427
|
+
try:
|
|
428
|
+
s_decoded = unquote(s)
|
|
429
|
+
if s_decoded != s:
|
|
430
|
+
s = s_decoded
|
|
431
|
+
except Exception:
|
|
432
|
+
pass
|
|
433
|
+
if not s:
|
|
434
|
+
return s
|
|
435
|
+
if s[0] in "{[" or s in ("true", "false", "null") or (s and s[0].isdigit()):
|
|
436
|
+
try:
|
|
437
|
+
return json.loads(s)
|
|
438
|
+
except Exception:
|
|
439
|
+
return s
|
|
440
|
+
return s
|