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,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
|
+
[](https://github.com/Mangluu/overleaf-comments-export/actions/workflows/ci.yml)
|
|
49
|
+
[](https://pypi.org/project/overleaf-comments-export/)
|
|
50
|
+
[](LICENSE)
|
|
51
|
+
[](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,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
|