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 @@
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