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,370 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter, defaultdict
4
+ from datetime import datetime, timezone
5
+ from typing import Iterable, Literal
6
+
7
+ from .model import AnchoredComment, SourceContext, Thread, TrackedChange
8
+
9
+ SCHEMA_VERSION = "1.3"
10
+ RenderMode = Literal["compact", "detailed"]
11
+
12
+ # How aggressively to clip the captured context window when rendering.
13
+ COMPACT_CONTEXT_CHARS = 70
14
+ DETAILED_CONTEXT_CHARS = 160
15
+
16
+
17
+ def _fmt_ts(ms: int | None) -> str:
18
+ if not ms:
19
+ return "?"
20
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
21
+
22
+
23
+ def _humanize_user(name: str | None, email: str | None, user_id: str | None) -> str:
24
+ """Best-effort display name. If `name` is set, use it. Else if the email
25
+ local part looks like firstname.lastname, title-case it. Else fall back to
26
+ the email local part, then a short id."""
27
+ if name:
28
+ return name
29
+ if email:
30
+ local = email.split("@", 1)[0]
31
+ # firstname.lastname → "Firstname Lastname"
32
+ parts = [p for p in local.replace("_", ".").replace("-", ".").split(".") if p]
33
+ if len(parts) >= 2 and all(p.isalpha() for p in parts):
34
+ return " ".join(p.capitalize() for p in parts)
35
+ return local
36
+ return (user_id or "unknown")[:8]
37
+
38
+
39
+ def _clip_left(s: str, n: int) -> tuple[str, bool]:
40
+ """Return rightmost n chars; True if clipped on the left."""
41
+ if len(s) <= n:
42
+ return s, False
43
+ return s[-n:].lstrip(), True
44
+
45
+
46
+ def _clip_right(s: str, n: int) -> tuple[str, bool]:
47
+ """Return leftmost n chars; True if clipped on the right."""
48
+ if len(s) <= n:
49
+ return s, False
50
+ return s[:n].rstrip(), True
51
+
52
+
53
+ def _inline_context(ctx: SourceContext | None, anchored_text_raw: str) -> str:
54
+ """Single-line blockquote: `…before ▸anchor◂ after…`"""
55
+ if ctx is None:
56
+ text = (anchored_text_raw or "").strip()
57
+ return f"> **▸{text}◂**" if text else "> _(anchor text unavailable)_"
58
+ before, clipped_b = _clip_left(ctx.before, COMPACT_CONTEXT_CHARS)
59
+ after, clipped_a = _clip_right(ctx.after, COMPACT_CONTEXT_CHARS)
60
+ anchor = ctx.anchor or anchored_text_raw or ""
61
+ lead = "…" if (ctx.truncated_before or clipped_b) else ""
62
+ tail = "…" if (ctx.truncated_after or clipped_a) else ""
63
+ parts = []
64
+ if before:
65
+ parts.append(f"{lead}{before} ")
66
+ elif lead:
67
+ parts.append(lead)
68
+ parts.append(f"**▸{anchor}◂**")
69
+ if after:
70
+ parts.append(f" {after}{tail}")
71
+ elif tail:
72
+ parts.append(tail)
73
+ return "> " + "".join(parts)
74
+
75
+
76
+ def _detailed_context(ctx: SourceContext | None, anchored_text_raw: str) -> list[str]:
77
+ """Multi-line code fence with the anchor on its own line."""
78
+ if ctx is None:
79
+ text = (anchored_text_raw or "").strip()
80
+ if not text:
81
+ return ["> _(anchor text unavailable)_"]
82
+ return ["```tex", f"▸ {text}", "```"]
83
+ before, clipped_b = _clip_left(ctx.before, DETAILED_CONTEXT_CHARS)
84
+ after, clipped_a = _clip_right(ctx.after, DETAILED_CONTEXT_CHARS)
85
+ anchor = ctx.anchor or anchored_text_raw or ""
86
+ lead = "…" if (ctx.truncated_before or clipped_b) else ""
87
+ tail = "…" if (ctx.truncated_after or clipped_a) else ""
88
+ out = ["```tex"]
89
+ if before:
90
+ out.append(f"{lead}{before}")
91
+ out.append(f"▸ {anchor}")
92
+ if after:
93
+ out.append(f"{after}{tail}")
94
+ out.append("```")
95
+ return out
96
+
97
+
98
+ def _group_label(path: str) -> str:
99
+ if path.startswith("<unknown-") and path.endswith(">"):
100
+ return "Unmapped doc (filename not available)"
101
+ return path
102
+
103
+
104
+ def _slug(s: str) -> str:
105
+ return "f-" + "".join(c if c.isalnum() else "-" for c in s.lower()).strip("-")[:80]
106
+
107
+
108
+ def render_markdown(
109
+ project_title: str,
110
+ project_id: str,
111
+ threads: dict[str, Thread],
112
+ anchored: list[AnchoredComment],
113
+ orphan_threads: list[Thread],
114
+ changes: list[TrackedChange],
115
+ *,
116
+ mode: RenderMode = "compact",
117
+ ) -> str:
118
+ pulled_at_iso = datetime.now(timezone.utc).isoformat(timespec="seconds")
119
+
120
+ open_count = sum(1 for t in threads.values() if not t.resolved)
121
+ resolved_count = sum(1 for t in threads.values() if t.resolved)
122
+ stale_count = sum(1 for c in anchored if c.stale)
123
+ file_count = len({c.pathname for c in anchored} | {ch.pathname for ch in changes})
124
+
125
+ reviewer_counter: Counter[str] = Counter()
126
+ for t in threads.values():
127
+ for m in t.messages:
128
+ reviewer_counter[_humanize_user(m.user_name, m.user_email, m.user_id)] += 1
129
+
130
+ out: list[str] = []
131
+
132
+ # ---- YAML front-matter ----
133
+ out.append("---")
134
+ out.append(f"schema_version: {SCHEMA_VERSION}")
135
+ out.append(f"project_id: {project_id}")
136
+ out.append(f'project_title: "{project_title}"')
137
+ out.append(f"pulled_at: {pulled_at_iso}")
138
+ out.append(f"thread_count: {len(threads)}")
139
+ out.append(f"open_count: {open_count}")
140
+ out.append(f"resolved_count: {resolved_count}")
141
+ out.append(f"tracked_change_count: {len(changes)}")
142
+ out.append(f"stale_anchor_count: {stale_count}")
143
+ out.append(f"file_count: {file_count}")
144
+ out.append(f"reviewer_count: {len(reviewer_counter)}")
145
+ out.append("companion_json: comments.json")
146
+ out.append("companion_agents: agents.md")
147
+ out.append("---")
148
+ out.append("")
149
+
150
+ # ---- Human-friendly header ----
151
+ out.append(f"# Overleaf comments — {project_title}")
152
+ out.append("")
153
+ out.append(
154
+ "Stable IDs like `C001` are assigned in file → line order. Cite them when "
155
+ "asking an AI to address specific comments. The full structured data is in "
156
+ "`comments.json` next to this file."
157
+ )
158
+ out.append("")
159
+
160
+ # ---- Summary ----
161
+ out.append("## Summary")
162
+ out.append("")
163
+ out.append(f"- **Threads:** {len(threads)} ({open_count} open, {resolved_count} resolved)")
164
+ out.append(f"- **Tracked changes:** {len(changes)}")
165
+ if stale_count:
166
+ out.append(
167
+ f"- **Stale anchors:** {stale_count} "
168
+ f"(quoted text moved or no longer matches the live doc — best-effort relocation applied)"
169
+ )
170
+ if reviewer_counter:
171
+ top = reviewer_counter.most_common(5)
172
+ out.append(
173
+ "- **Most active reviewers:** "
174
+ + ", ".join(f"{name} ({n})" for name, n in top)
175
+ )
176
+ out.append("")
177
+
178
+ by_file_comments: dict[str, list[AnchoredComment]] = defaultdict(list)
179
+ for c in anchored:
180
+ by_file_comments[c.pathname].append(c)
181
+ by_file_changes: dict[str, list[TrackedChange]] = defaultdict(list)
182
+ for ch in changes:
183
+ by_file_changes[ch.pathname].append(ch)
184
+ all_paths = sorted(set(by_file_comments) | set(by_file_changes))
185
+
186
+ # ---- Table of contents (skip if only one file) ----
187
+ if len(all_paths) > 1:
188
+ out.append("## Table of contents")
189
+ out.append("")
190
+ for path in all_paths:
191
+ n_c = len(by_file_comments.get(path, []))
192
+ n_t = len(by_file_changes.get(path, []))
193
+ parts = []
194
+ if n_c:
195
+ parts.append(f"{n_c} comment{'' if n_c == 1 else 's'}")
196
+ if n_t:
197
+ parts.append(f"{n_t} tracked change{'' if n_t == 1 else 's'}")
198
+ out.append(
199
+ f"- [{_group_label(path)}](#{_slug(path)}) — {', '.join(parts)}"
200
+ )
201
+ out.append("")
202
+
203
+ # ---- Per-file sections ----
204
+ single_file = len(all_paths) == 1
205
+ for path in all_paths:
206
+ comments_in_file = sorted(by_file_comments.get(path, []), key=lambda c: (c.line_no, c.col, c.offset))
207
+ changes_in_file = sorted(by_file_changes.get(path, []), key=lambda c: (c.line_no, c.col, c.offset))
208
+
209
+ if not single_file:
210
+ out.append(f"## {_group_label(path)}")
211
+ out.append("")
212
+ out.append(f'<a id="{_slug(path)}"></a>')
213
+ out.append("")
214
+
215
+ # Group comments by (section, line) so we emit context once.
216
+ groups: dict[tuple[str, int], list[AnchoredComment]] = defaultdict(list)
217
+ section_for_line: dict[int, str] = {}
218
+ for c in comments_in_file:
219
+ heading = c.nearest_heading or "_(no enclosing section)_"
220
+ groups[(heading, c.line_no)].append(c)
221
+ section_for_line[c.line_no] = heading
222
+
223
+ last_section: str | None = None
224
+ for (heading, line_no), group in sorted(groups.items(), key=lambda kv: (kv[1][0].line_no if False else 0, kv[0][1])):
225
+ # Stable order: by line within section, sections by their first-line position
226
+ pass
227
+ # Re-do ordering: sort by (first_line_in_section, line_no)
228
+ section_first_line = {}
229
+ for (heading, line_no), _ in groups.items():
230
+ section_first_line[heading] = min(line_no, section_first_line.get(heading, line_no))
231
+
232
+ ordered_keys = sorted(
233
+ groups.keys(),
234
+ key=lambda k: (section_first_line[k[0]], k[1]),
235
+ )
236
+
237
+ for heading, line_no in ordered_keys:
238
+ group = groups[(heading, line_no)]
239
+ if heading != last_section:
240
+ out.append(f"### § {heading}")
241
+ out.append("")
242
+ last_section = heading
243
+
244
+ # Pick the most informative context from any comment in the group
245
+ # (they all anchor to the same line so the surrounding chars are
246
+ # similar; we just need one rendition).
247
+ sample = group[0]
248
+ out.append(f"**Line {line_no}** — {len(group)} comment{'' if len(group) == 1 else 's'}")
249
+ out.append("")
250
+ if mode == "detailed":
251
+ out.extend(_detailed_context(sample.context, sample.anchored_text))
252
+ else:
253
+ out.append(_inline_context(sample.context, sample.anchored_text))
254
+ out.append("")
255
+ for c in group:
256
+ thread = threads.get(c.thread_id)
257
+ _emit_comment_compact(out, c, thread)
258
+
259
+ if changes_in_file:
260
+ out.append("### § Tracked changes")
261
+ out.append("")
262
+ for ch in changes_in_file:
263
+ _emit_change(out, ch, mode=mode)
264
+
265
+ if orphan_threads:
266
+ out.append("## Threads without resolvable anchors")
267
+ out.append("")
268
+ out.append(
269
+ "_These threads exist but we couldn't locate them in the live source._"
270
+ )
271
+ out.append("")
272
+ for thread in orphan_threads:
273
+ _emit_orphan_thread(out, thread)
274
+
275
+ return "\n".join(out).rstrip() + "\n"
276
+
277
+
278
+ def _status_badge(thread: Thread | None, stale: bool) -> str:
279
+ bits = []
280
+ if thread and thread.resolved:
281
+ bits.append("resolved")
282
+ else:
283
+ bits.append("open")
284
+ if stale:
285
+ bits.append("⚠ stale")
286
+ return " · ".join(bits)
287
+
288
+
289
+ def _emit_comment_compact(out: list[str], c: AnchoredComment, thread: Thread | None) -> None:
290
+ """One comment in compact form: header + quoted phrase + replies. No
291
+ standalone source context (that's emitted once per (file, line) group)."""
292
+ status = _status_badge(thread, c.stale)
293
+ quote = (c.anchored_text or "").strip().replace("\n", " ")
294
+ if quote:
295
+ if len(quote) > 80:
296
+ quote = quote[:77].rstrip() + "…"
297
+ head = f"**{c.short_id}** _{status}_ — “{quote}”"
298
+ else:
299
+ head = f"**{c.short_id}** _{status}_ — _(empty anchor)_"
300
+ out.append(head)
301
+ if thread is None or not thread.messages:
302
+ out.append("- _(no messages)_")
303
+ else:
304
+ for msg in sorted(thread.messages, key=lambda m: m.timestamp_ms):
305
+ who = _humanize_user(msg.user_name, msg.user_email, msg.user_id)
306
+ when = _fmt_ts(msg.timestamp_ms)
307
+ edited = " _(edited)_" if msg.edited_at_ms else ""
308
+ body = (msg.content or "").strip()
309
+ # If body is single-line, render inline; multi-line gets a
310
+ # blockquote so it stays readable.
311
+ if "\n" in body:
312
+ out.append(f"- **{who}** · {when}{edited}:")
313
+ for line in body.splitlines():
314
+ out.append(f" > {line}")
315
+ else:
316
+ out.append(f"- **{who}** · {when}{edited}: {body}")
317
+ out.append("")
318
+
319
+
320
+ def _emit_change(out: list[str], ch: TrackedChange, *, mode: RenderMode = "compact") -> None:
321
+ sign = "+" if ch.kind == "insertion" else "-"
322
+ who = _humanize_user(ch.user_name, ch.user_email, ch.user_id)
323
+ when = _fmt_ts(ch.timestamp_ms)
324
+ content = (ch.content or "").strip()
325
+ out.append(
326
+ f"**{ch.short_id}** _{ch.kind}_ — line {ch.line_no} — {who} · {when}"
327
+ )
328
+
329
+ ctx = ch.context
330
+ if mode == "detailed" and ctx is not None:
331
+ before, clipped_b = _clip_left(ctx.before, DETAILED_CONTEXT_CHARS)
332
+ after, clipped_a = _clip_right(ctx.after, DETAILED_CONTEXT_CHARS)
333
+ lead = "…" if (ctx.truncated_before or clipped_b) else ""
334
+ tail = "…" if (ctx.truncated_after or clipped_a) else ""
335
+ out.append("```diff")
336
+ if before:
337
+ out.append(f" {lead}{before}")
338
+ for ln in content.splitlines() or [""]:
339
+ out.append(f"{sign} {ln}")
340
+ if after:
341
+ out.append(f" {after}{tail}")
342
+ out.append("```")
343
+ else:
344
+ # Compact: one-line diff with truncation
345
+ flat = content.replace("\n", "⏎ ")
346
+ if len(flat) > 120:
347
+ flat = flat[:117].rstrip() + "…"
348
+ out.append(f"- `{sign} {flat}`")
349
+ if ctx is not None:
350
+ before, clipped_b = _clip_left(ctx.before, COMPACT_CONTEXT_CHARS)
351
+ after, clipped_a = _clip_right(ctx.after, COMPACT_CONTEXT_CHARS)
352
+ lead = "…" if (ctx.truncated_before or clipped_b) else ""
353
+ tail = "…" if (ctx.truncated_after or clipped_a) else ""
354
+ if before or after:
355
+ out.append(f" > {lead}{before} **▸here◂** {after}{tail}")
356
+ out.append("")
357
+
358
+
359
+ def _emit_orphan_thread(out: list[str], thread: Thread) -> None:
360
+ status = "resolved" if thread.resolved else "open"
361
+ out.append(f"- **Thread `{thread.id[:8]}…`** _{status}_")
362
+ if not thread.messages:
363
+ out.append(" - _(no messages)_")
364
+ else:
365
+ for msg in sorted(thread.messages, key=lambda m: m.timestamp_ms):
366
+ who = _humanize_user(msg.user_name, msg.user_email, msg.user_id)
367
+ when = _fmt_ts(msg.timestamp_ms)
368
+ body = (msg.content or "").strip().replace("\n", " ")
369
+ out.append(f" - {who} · {when}: {body}")
370
+ out.append("")
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from bisect import bisect_right
5
+
6
+ from .model import Heading
7
+
8
+ # Standard LaTeX sectioning commands.
9
+ _HEADING_RE = re.compile(
10
+ r"^\s*\\(?P<cmd>section|subsection|subsubsection|chapter|paragraph|part)\*?\s*"
11
+ r"(?:\[[^\]]*\])?\s*\{(?P<text>.*?)\}",
12
+ re.MULTILINE,
13
+ )
14
+
15
+ # Front-matter / pseudo-sections so comments inside the abstract or near the
16
+ # title aren't lumped under "no enclosing section".
17
+ _PSEUDO_RE = re.compile(
18
+ r"^\s*\\(?:begin\{(?P<env>abstract|titlepage)\}|"
19
+ r"(?P<cmd>title|maketitle|tableofcontents|frontmatter|mainmatter|backmatter|appendix))"
20
+ r"(?:\s*\{(?P<arg>[^}]*)\})?",
21
+ re.MULTILINE,
22
+ )
23
+
24
+ _LEVEL = {
25
+ "part": -1,
26
+ "chapter": 0,
27
+ "section": 1,
28
+ "subsection": 2,
29
+ "subsubsection": 3,
30
+ "paragraph": 4,
31
+ }
32
+
33
+ _PSEUDO_LEVEL = 1 # treat front-matter pseudo-sections at section level
34
+
35
+
36
+ def find_headings(text: str, line_starts: list[int]) -> list[Heading]:
37
+ """Scan LaTeX source for headings, including title/abstract pseudo-sections.
38
+
39
+ line_starts[i] is the char offset of the start of line (i+1); used to
40
+ convert match offsets back into 1-indexed line numbers.
41
+ """
42
+ headings: list[Heading] = []
43
+
44
+ for m in _HEADING_RE.finditer(text):
45
+ line_no = bisect_right(line_starts, m.start())
46
+ cmd = m.group("cmd")
47
+ headings.append(
48
+ Heading(line_no=line_no, level=_LEVEL.get(cmd, 99), text=m.group("text").strip())
49
+ )
50
+
51
+ for m in _PSEUDO_RE.finditer(text):
52
+ line_no = bisect_right(line_starts, m.start())
53
+ env = m.group("env")
54
+ cmd = m.group("cmd")
55
+ arg = (m.group("arg") or "").strip()
56
+ if env == "abstract":
57
+ label = "Abstract"
58
+ elif env == "titlepage":
59
+ label = "Title page"
60
+ elif cmd == "title":
61
+ label = f"Title: {arg}" if arg else "Title"
62
+ elif cmd == "maketitle":
63
+ label = "Title block"
64
+ elif cmd == "tableofcontents":
65
+ label = "Table of contents"
66
+ elif cmd == "frontmatter":
67
+ label = "Front matter"
68
+ elif cmd == "mainmatter":
69
+ label = "Main matter"
70
+ elif cmd == "backmatter":
71
+ label = "Back matter"
72
+ elif cmd == "appendix":
73
+ label = "Appendix"
74
+ else:
75
+ continue
76
+ headings.append(Heading(line_no=line_no, level=_PSEUDO_LEVEL, text=label))
77
+
78
+ headings.sort(key=lambda h: (h.line_no, h.level))
79
+ return headings
80
+
81
+
82
+ def nearest_heading(headings: list[Heading], line_no: int) -> str | None:
83
+ """Return a path like "§ 3.2 Method overview" for the nearest enclosing
84
+ heading at-or-above line_no."""
85
+ enclosing: dict[int, Heading] = {}
86
+ for h in headings:
87
+ if h.line_no > line_no:
88
+ break
89
+ enclosing[h.level] = h
90
+ for deeper in list(enclosing):
91
+ if deeper > h.level:
92
+ enclosing.pop(deeper)
93
+ if not enclosing:
94
+ return None
95
+ parts = [enclosing[lvl].text for lvl in sorted(enclosing)]
96
+ return " > ".join(parts)
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: overleaf-comments-export
3
+ Version: 0.2.0
4
+ Summary: Export comment threads and tracked changes from an Overleaf project to Markdown + JSON, optimized for AI-agent consumption.
5
+ Author: Shivang
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Mangluu/overleaf-comments-export
8
+ Project-URL: Issues, https://github.com/Mangluu/overleaf-comments-export/issues
9
+ Project-URL: Source, https://github.com/Mangluu/overleaf-comments-export
10
+ Keywords: overleaf,latex,research,comments,review,academic,tracked-changes,ai-agents
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Text Processing :: Markup :: LaTeX
23
+ Classifier: Topic :: Scientific/Engineering
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: pyoverleaf>=0.1.7
28
+ Requires-Dist: browser-cookie3>=0.19
29
+ Requires-Dist: requests>=2.31
30
+ Requires-Dist: platformdirs>=4.0
31
+ Provides-Extra: gui
32
+ Requires-Dist: sv-ttk>=2.6; extra == "gui"
33
+ Provides-Extra: test
34
+ Requires-Dist: pytest>=8.0; extra == "test"
35
+ Dynamic: license-file
36
+
37
+ # overleaf-comments-export
38
+
39
+ > **⚠️ Unofficial tool.** This is a third-party utility that talks to Overleaf's
40
+ > undocumented internal HTTP endpoints. It is not affiliated with or endorsed
41
+ > by Overleaf. Endpoints may change without notice. Use at your own risk and
42
+ > in accordance with [Overleaf's Terms of Service](https://www.overleaf.com/legal).
43
+
44
+ Export the comment threads and tracked changes from an Overleaf project into
45
+ clean Markdown + structured JSON — designed so an AI assistant (Claude,
46
+ ChatGPT, etc.) can ingest reviewer feedback and help you address it.
47
+
48
+ [![CI](https://github.com/Mangluu/overleaf-comments-export/actions/workflows/ci.yml/badge.svg)](https://github.com/Mangluu/overleaf-comments-export/actions/workflows/ci.yml)
49
+ [![PyPI](https://img.shields.io/pypi/v/overleaf-comments-export.svg)](https://pypi.org/project/overleaf-comments-export/)
50
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
51
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/)
52
+
53
+ ## Why
54
+
55
+ Overleaf doesn't provide a way to export comments or tracked changes for use
56
+ outside the editor. If you want to:
57
+
58
+ - have an AI agent draft point-by-point replies to reviewers,
59
+ - archive reviewer discussions outside of Overleaf,
60
+ - batch-address feedback across a large paper, or
61
+ - split feedback by reviewer to delegate work,
62
+
63
+ … you currently have to copy comments by hand. This tool automates that, given
64
+ an Overleaf project URL and a logged-in browser session.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pip install overleaf-comments-export
70
+ ```
71
+
72
+ Requires Python 3.10+. Works on macOS, Linux, and Windows.
73
+
74
+ ## Quick start
75
+
76
+ ### CLI
77
+
78
+ ```bash
79
+ overleaf-comments-export \
80
+ --project-url https://www.overleaf.com/project/<24-hex-id> \
81
+ --out ./paper-comments \
82
+ --browser safari
83
+ ```
84
+
85
+ The first time you run it, sign in to Overleaf in your browser of choice;
86
+ the tool reads the session cookie from there.
87
+
88
+ ### GUI
89
+
90
+ ```bash
91
+ overleaf-comments-export --gui
92
+ ```
93
+
94
+ Opens a small Tkinter window with all options surfaced. Best for non-technical
95
+ users.
96
+
97
+ ## What it produces
98
+
99
+ In your output folder, by default:
100
+
101
+ | File | Purpose |
102
+ |---|---|
103
+ | `comments-<date>.md` | Human-readable Markdown grouped by file → section → line, with stable IDs (`C001`, `C002`, …) and source-context snippets around each anchor. |
104
+ | `comments.json` | Structured data — `summary`, top-level `threads`, `files`, `comments`, `tracked_changes`, etc. Schema described in `agents.md`. |
105
+ | `comments.jsonl` | One self-contained JSON record per comment for streaming/pipelines. |
106
+ | `agents.md` | A brief instruction file telling an AI agent how to consume the batch. |
107
+ | `by-reviewer/<name>.md` | (Optional, `--per-reviewer`) One Markdown per reviewer with only their threads. |
108
+ | `comments.log` | Diagnostic log for the run. |
109
+
110
+ ## Filtering
111
+
112
+ ```bash
113
+ # Only open comments
114
+ overleaf-comments-export --project-url … --out ./out --no-resolved
115
+
116
+ # Only one reviewer's threads
117
+ overleaf-comments-export --project-url … --out ./out --reviewer "Emma"
118
+
119
+ # Compact (default) vs. detailed (multi-line code-fence) layout
120
+ overleaf-comments-export --project-url … --out ./out --render-mode detailed
121
+
122
+ # Per-reviewer sub-reports under ./out/by-reviewer/
123
+ overleaf-comments-export --project-url … --out ./out --per-reviewer
124
+ ```
125
+
126
+ Full flag reference: `overleaf-comments-export --help`.
127
+
128
+ ## Browser authentication
129
+
130
+ The tool reads the `overleaf_session2` cookie from your browser. Trade-offs
131
+ by browser on macOS:
132
+
133
+ | Browser | Notes |
134
+ |---|---|
135
+ | Safari | Recommended. No Keychain prompt; macOS may ask once for permission to read `~/Library/Cookies/`. |
136
+ | Firefox | No Keychain prompt; plain SQLite cookie store. |
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
+
139
+ On Windows, Chrome 127+ uses App-Bound Encryption that `browser-cookie3`
140
+ doesn't fully decrypt yet — prefer Firefox or Edge on Windows.
141
+
142
+ On Linux, snap-packaged browsers sandbox their cookies — install browsers as
143
+ native packages if you can.
144
+
145
+ ## Status & maintenance
146
+
147
+ This is a personal research utility published in case it's useful to others.
148
+ It is provided as-is, with no guaranteed maintenance, no SLA, and no roadmap.
149
+ Pull requests are welcome; issues may or may not be acted upon.
150
+
151
+ If Overleaf changes their internal API, this tool may stop working until
152
+ someone (you?) adapts it.
153
+
154
+ ## License
155
+
156
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,15 @@
1
+ overleaf_comments_export/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ overleaf_comments_export/__main__.py,sha256=TSDOH9YmglmQBDjHBWSmUCagotGeCVASjCRTDQ7CKFA,4481
3
+ overleaf_comments_export/anchors.py,sha256=qcOm5Vd2tMw7rJCiOncGGhA-jgnsfXUWiNv5WdV3BYc,2280
4
+ overleaf_comments_export/client.py,sha256=x3ZPufzq2nGw9ZQo-6HSjxzZEDXC7e9k2o_ZFsImQzo,17767
5
+ overleaf_comments_export/export.py,sha256=71LzD7fo32Y0a5MwznwYwFXI7hZGFrKaLQBVQr-BsWQ,32855
6
+ overleaf_comments_export/gui.py,sha256=b536MeUAO1KnEjuf0l2912DyjMoSUe2_EXJmWjU8qXE,21956
7
+ overleaf_comments_export/model.py,sha256=iWIwsbHaiXNcXjFsAkuZQNsiMOvxnmh78fi4apfyL-Y,2152
8
+ overleaf_comments_export/render.py,sha256=dksxu9wpNBq8mUDpsPgrkrU05U_AnfxEDaIfLMvC11Q,14426
9
+ overleaf_comments_export/sections.py,sha256=jTy8TaqWv_NvfgsZoGr9AlbjmWJBcLwMc8BoHke5G6A,3060
10
+ overleaf_comments_export-0.2.0.dist-info/licenses/LICENSE,sha256=7uUOMuNZY8cZU3Nmh1YuNNbmRzSO6G2WOKbIDiv3H14,1093
11
+ overleaf_comments_export-0.2.0.dist-info/METADATA,sha256=IhZlIvvk5lrsesJvz5ZsqjuXq21fW9ndgKWxyDzz95Q,6060
12
+ overleaf_comments_export-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ overleaf_comments_export-0.2.0.dist-info/entry_points.txt,sha256=FmqUnxpe5la8RhJHh7FsYHIoQZZHYN0yjZVV44U_zJQ,84
14
+ overleaf_comments_export-0.2.0.dist-info/top_level.txt,sha256=27fW8ViBdpmt05GMD25EYLZwzih5a7xozT7CgMbkG28,25
15
+ overleaf_comments_export-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ overleaf-comments-export = overleaf_comments_export.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shivang (https://github.com/Mangluu)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ overleaf_comments_export