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,583 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import queue
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import tkinter as tk
|
|
9
|
+
import traceback
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from tkinter import filedialog, messagebox, ttk
|
|
12
|
+
|
|
13
|
+
from .export import ExportResult, run_export
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _config_path() -> Path:
|
|
17
|
+
"""Per-user config location, cross-platform.
|
|
18
|
+
|
|
19
|
+
Uses platformdirs when available (preferred for cross-platform correctness);
|
|
20
|
+
falls back to ~/.overleaf_comments_export.json if the dependency is missing
|
|
21
|
+
(lets the library run without it as a soft dep)."""
|
|
22
|
+
try:
|
|
23
|
+
from platformdirs import user_config_dir # type: ignore
|
|
24
|
+
d = Path(user_config_dir("overleaf-comments-export", "overleaf-comments-export"))
|
|
25
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return d / "config.json"
|
|
27
|
+
except ImportError:
|
|
28
|
+
return Path.home() / ".overleaf_comments_export.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CONFIG_PATH = _config_path()
|
|
32
|
+
|
|
33
|
+
BROWSER_LABELS = {
|
|
34
|
+
"safari": "Safari (recommended — no Keychain prompt)",
|
|
35
|
+
"firefox": "Firefox (no Keychain prompt)",
|
|
36
|
+
"auto": "Auto-detect (try all installed browsers)",
|
|
37
|
+
"chrome": "Google Chrome (will prompt for Keychain password)",
|
|
38
|
+
"chromium": "Chromium (will prompt for Keychain password)",
|
|
39
|
+
"edge": "Microsoft Edge (will prompt for Keychain password)",
|
|
40
|
+
"brave": "Brave (will prompt for Keychain password)",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
PRIVACY_FRIENDLY = ("safari", "firefox")
|
|
44
|
+
ADVANCED_BROWSERS = ("auto", "chrome", "chromium", "edge", "brave")
|
|
45
|
+
|
|
46
|
+
PRIVACY_INFO_TEXT = """\
|
|
47
|
+
What this app reads from your machine
|
|
48
|
+
─────────────────────────────────────
|
|
49
|
+
• Your Overleaf session cookie. This is the same cookie your browser uses to
|
|
50
|
+
stay signed in to overleaf.com. Without it, the Overleaf server won't return
|
|
51
|
+
your comments.
|
|
52
|
+
|
|
53
|
+
Where it reads the cookie from, by browser:
|
|
54
|
+
• Safari → ~/Library/Cookies/Cookies.binarycookies (file is binary; macOS
|
|
55
|
+
may ask permission to read it the first time)
|
|
56
|
+
• Firefox → ~/Library/Application Support/Firefox/Profiles/*/cookies.sqlite
|
|
57
|
+
(plain SQLite, no Keychain access required)
|
|
58
|
+
• Chrome / Edge / Brave / Chromium → cookies are AES-encrypted on disk; the
|
|
59
|
+
decryption key lives in macOS Keychain, so reading them prompts
|
|
60
|
+
for your login password every single time.
|
|
61
|
+
|
|
62
|
+
The cookie is used only to make HTTPS requests to www.overleaf.com.
|
|
63
|
+
Nothing is sent anywhere else.
|
|
64
|
+
|
|
65
|
+
What's saved to disk
|
|
66
|
+
────────────────────
|
|
67
|
+
• Your last-used inputs (browser choice, project URL, output folder) are saved
|
|
68
|
+
to ~/.overleaf_comments_export.json so the form pre-fills next time. You can
|
|
69
|
+
delete this file at any time.
|
|
70
|
+
• The export itself (Markdown + JSON + log) goes only to the folder you choose.
|
|
71
|
+
• Diagnostic logs are written to ~/Library/Logs/OverleafCommentsExport/.
|
|
72
|
+
|
|
73
|
+
What's never stored
|
|
74
|
+
───────────────────
|
|
75
|
+
• Your Overleaf password — the app never asks for it.
|
|
76
|
+
• The session cookie — it's only held in memory during one export and discarded
|
|
77
|
+
when the app quits.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_config() -> dict:
|
|
82
|
+
if CONFIG_PATH.exists():
|
|
83
|
+
try:
|
|
84
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
85
|
+
except Exception:
|
|
86
|
+
return {}
|
|
87
|
+
return {}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _save_config(data: dict) -> None:
|
|
91
|
+
try:
|
|
92
|
+
CONFIG_PATH.write_text(json.dumps(data, indent=2))
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class App:
|
|
98
|
+
def __init__(self, root: tk.Tk) -> None:
|
|
99
|
+
self.root = root
|
|
100
|
+
root.title("Overleaf Comments Export")
|
|
101
|
+
root.geometry("720x780")
|
|
102
|
+
root.minsize(560, 600)
|
|
103
|
+
|
|
104
|
+
self.config = _load_config()
|
|
105
|
+
self.queue: queue.Queue[tuple[str, object]] = queue.Queue()
|
|
106
|
+
self.worker: threading.Thread | None = None
|
|
107
|
+
self.last_result: ExportResult | None = None
|
|
108
|
+
|
|
109
|
+
self._apply_theme()
|
|
110
|
+
|
|
111
|
+
outer = ttk.Frame(root, padding=14)
|
|
112
|
+
outer.pack(fill="both", expand=True)
|
|
113
|
+
outer.columnconfigure(1, weight=1)
|
|
114
|
+
|
|
115
|
+
row = 0
|
|
116
|
+
ttk.Label(
|
|
117
|
+
outer,
|
|
118
|
+
text="Export comment threads and tracked changes from an Overleaf paper.",
|
|
119
|
+
wraplength=620,
|
|
120
|
+
).grid(row=row, column=0, columnspan=3, sticky="w", pady=(0, 12))
|
|
121
|
+
row += 1
|
|
122
|
+
|
|
123
|
+
# Browser
|
|
124
|
+
ttk.Label(outer, text="Browser:").grid(row=row, column=0, sticky="w", pady=4)
|
|
125
|
+
default_browser = self.config.get("browser", "safari")
|
|
126
|
+
if default_browser not in BROWSER_LABELS:
|
|
127
|
+
default_browser = "safari"
|
|
128
|
+
self.browser_var = tk.StringVar(value=default_browser)
|
|
129
|
+
self.browser_box = ttk.Combobox(
|
|
130
|
+
outer,
|
|
131
|
+
textvariable=self.browser_var,
|
|
132
|
+
state="readonly",
|
|
133
|
+
width=20,
|
|
134
|
+
)
|
|
135
|
+
self.browser_box.grid(row=row, column=1, sticky="w", pady=4)
|
|
136
|
+
self.browser_box.bind(
|
|
137
|
+
"<<ComboboxSelected>>", lambda _e: self._on_browser_change()
|
|
138
|
+
)
|
|
139
|
+
self.browser_help = ttk.Label(
|
|
140
|
+
outer, text="", foreground="#666", wraplength=320
|
|
141
|
+
)
|
|
142
|
+
self.browser_help.grid(row=row, column=2, sticky="w", padx=6, pady=4)
|
|
143
|
+
row += 1
|
|
144
|
+
|
|
145
|
+
# Show-advanced checkbox + privacy info button
|
|
146
|
+
adv_row = ttk.Frame(outer)
|
|
147
|
+
adv_row.grid(row=row, column=1, columnspan=2, sticky="w", pady=(0, 4))
|
|
148
|
+
self.show_advanced_var = tk.BooleanVar(
|
|
149
|
+
value=bool(self.config.get("show_advanced_browsers", False))
|
|
150
|
+
or default_browser in ADVANCED_BROWSERS
|
|
151
|
+
)
|
|
152
|
+
ttk.Checkbutton(
|
|
153
|
+
adv_row,
|
|
154
|
+
text="Show Chrome / Edge / Brave (uses Keychain)",
|
|
155
|
+
variable=self.show_advanced_var,
|
|
156
|
+
command=self._refresh_browser_choices,
|
|
157
|
+
).pack(side="left")
|
|
158
|
+
ttk.Button(
|
|
159
|
+
adv_row, text="Privacy info…", command=self._show_privacy_info
|
|
160
|
+
).pack(side="left", padx=(12, 0))
|
|
161
|
+
row += 1
|
|
162
|
+
|
|
163
|
+
self._refresh_browser_choices()
|
|
164
|
+
|
|
165
|
+
# Project URL
|
|
166
|
+
ttk.Label(outer, text="Overleaf project URL:").grid(
|
|
167
|
+
row=row, column=0, sticky="w", pady=4
|
|
168
|
+
)
|
|
169
|
+
self.url_var = tk.StringVar(value=self.config.get("project_url", ""))
|
|
170
|
+
ttk.Entry(outer, textvariable=self.url_var).grid(
|
|
171
|
+
row=row, column=1, columnspan=2, sticky="ew", pady=4
|
|
172
|
+
)
|
|
173
|
+
row += 1
|
|
174
|
+
ttk.Label(
|
|
175
|
+
outer,
|
|
176
|
+
text="(Open your paper in Overleaf and copy the URL from the address bar.)",
|
|
177
|
+
foreground="#666",
|
|
178
|
+
).grid(row=row, column=1, columnspan=2, sticky="w")
|
|
179
|
+
row += 1
|
|
180
|
+
|
|
181
|
+
# Title
|
|
182
|
+
ttk.Label(outer, text="Paper title (optional):").grid(
|
|
183
|
+
row=row, column=0, sticky="w", pady=4
|
|
184
|
+
)
|
|
185
|
+
self.title_var = tk.StringVar(value=self.config.get("project_title", ""))
|
|
186
|
+
ttk.Entry(outer, textvariable=self.title_var).grid(
|
|
187
|
+
row=row, column=1, columnspan=2, sticky="ew", pady=4
|
|
188
|
+
)
|
|
189
|
+
row += 1
|
|
190
|
+
|
|
191
|
+
# Output folder
|
|
192
|
+
ttk.Label(outer, text="Save to folder:").grid(
|
|
193
|
+
row=row, column=0, sticky="w", pady=4
|
|
194
|
+
)
|
|
195
|
+
self.out_var = tk.StringVar(value=self.config.get("out_dir", ""))
|
|
196
|
+
out_entry = ttk.Entry(outer, textvariable=self.out_var)
|
|
197
|
+
out_entry.grid(row=row, column=1, sticky="ew", pady=4)
|
|
198
|
+
ttk.Button(outer, text="Browse…", command=self._pick_folder).grid(
|
|
199
|
+
row=row, column=2, sticky="w", padx=6, pady=4
|
|
200
|
+
)
|
|
201
|
+
row += 1
|
|
202
|
+
|
|
203
|
+
# ---- Options (expandable) ----
|
|
204
|
+
self.show_options_var = tk.BooleanVar(
|
|
205
|
+
value=bool(self.config.get("show_options", False))
|
|
206
|
+
)
|
|
207
|
+
ttk.Checkbutton(
|
|
208
|
+
outer,
|
|
209
|
+
text="Show options (filters, output format, extras)",
|
|
210
|
+
variable=self.show_options_var,
|
|
211
|
+
command=self._toggle_options_visibility,
|
|
212
|
+
).grid(row=row, column=0, columnspan=3, sticky="w", pady=(8, 0))
|
|
213
|
+
row += 1
|
|
214
|
+
|
|
215
|
+
self.options_frame = ttk.LabelFrame(outer, text="Options", padding=8)
|
|
216
|
+
self.options_frame.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(4, 6))
|
|
217
|
+
self.options_frame.columnconfigure(1, weight=1)
|
|
218
|
+
self._build_options_panel(self.options_frame)
|
|
219
|
+
row += 1
|
|
220
|
+
|
|
221
|
+
# Action buttons
|
|
222
|
+
button_row = ttk.Frame(outer)
|
|
223
|
+
button_row.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(12, 6))
|
|
224
|
+
self.run_btn = ttk.Button(
|
|
225
|
+
button_row, text="Export Comments", command=self._on_run
|
|
226
|
+
)
|
|
227
|
+
self.run_btn.pack(side="left")
|
|
228
|
+
self.open_md_btn = ttk.Button(
|
|
229
|
+
button_row,
|
|
230
|
+
text="Open Markdown",
|
|
231
|
+
command=self._open_markdown,
|
|
232
|
+
state="disabled",
|
|
233
|
+
)
|
|
234
|
+
self.open_md_btn.pack(side="left", padx=8)
|
|
235
|
+
self.open_folder_btn = ttk.Button(
|
|
236
|
+
button_row,
|
|
237
|
+
text="Open Output Folder",
|
|
238
|
+
command=self._open_folder,
|
|
239
|
+
state="disabled",
|
|
240
|
+
)
|
|
241
|
+
self.open_folder_btn.pack(side="left")
|
|
242
|
+
row += 1
|
|
243
|
+
|
|
244
|
+
# Progress + log
|
|
245
|
+
self.progress = ttk.Progressbar(outer, mode="indeterminate")
|
|
246
|
+
self.progress.grid(row=row, column=0, columnspan=3, sticky="ew", pady=(8, 4))
|
|
247
|
+
row += 1
|
|
248
|
+
|
|
249
|
+
ttk.Label(outer, text="Log:").grid(row=row, column=0, sticky="w")
|
|
250
|
+
row += 1
|
|
251
|
+
|
|
252
|
+
log_frame = ttk.Frame(outer)
|
|
253
|
+
log_frame.grid(row=row, column=0, columnspan=3, sticky="nsew")
|
|
254
|
+
outer.rowconfigure(row, weight=1)
|
|
255
|
+
log_frame.columnconfigure(0, weight=1)
|
|
256
|
+
log_frame.rowconfigure(0, weight=1)
|
|
257
|
+
self.log = tk.Text(log_frame, height=12, wrap="word", state="disabled")
|
|
258
|
+
self.log.grid(row=0, column=0, sticky="nsew")
|
|
259
|
+
log_scroll = ttk.Scrollbar(log_frame, command=self.log.yview)
|
|
260
|
+
log_scroll.grid(row=0, column=1, sticky="ns")
|
|
261
|
+
self.log.configure(yscrollcommand=log_scroll.set)
|
|
262
|
+
|
|
263
|
+
self.root.after(80, self._pump_queue)
|
|
264
|
+
|
|
265
|
+
def _apply_theme(self) -> None:
|
|
266
|
+
"""Use sv-ttk (modern theme) when available — looks consistent across
|
|
267
|
+
macOS/Windows/Linux. Falls back to native aqua/clam if not installed."""
|
|
268
|
+
try:
|
|
269
|
+
import sv_ttk # type: ignore
|
|
270
|
+
sv_ttk.set_theme("light")
|
|
271
|
+
return
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
try:
|
|
275
|
+
style = ttk.Style()
|
|
276
|
+
names = style.theme_names()
|
|
277
|
+
for preferred in ("aqua", "vista", "clam"):
|
|
278
|
+
if preferred in names:
|
|
279
|
+
style.theme_use(preferred)
|
|
280
|
+
return
|
|
281
|
+
except Exception:
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
def _build_options_panel(self, parent: ttk.LabelFrame) -> None:
|
|
285
|
+
r = 0
|
|
286
|
+
# Filters
|
|
287
|
+
ttk.Label(parent, text="Include:", foreground="#333").grid(
|
|
288
|
+
row=r, column=0, sticky="w"
|
|
289
|
+
)
|
|
290
|
+
filters_row = ttk.Frame(parent)
|
|
291
|
+
filters_row.grid(row=r, column=1, columnspan=2, sticky="w")
|
|
292
|
+
self.include_open_var = tk.BooleanVar(
|
|
293
|
+
value=bool(self.config.get("include_open", True))
|
|
294
|
+
)
|
|
295
|
+
self.include_resolved_var = tk.BooleanVar(
|
|
296
|
+
value=bool(self.config.get("include_resolved", True))
|
|
297
|
+
)
|
|
298
|
+
self.include_changes_var = tk.BooleanVar(
|
|
299
|
+
value=bool(self.config.get("include_changes", True))
|
|
300
|
+
)
|
|
301
|
+
ttk.Checkbutton(
|
|
302
|
+
filters_row, text="Open comments", variable=self.include_open_var
|
|
303
|
+
).pack(side="left", padx=(0, 12))
|
|
304
|
+
ttk.Checkbutton(
|
|
305
|
+
filters_row, text="Resolved comments", variable=self.include_resolved_var
|
|
306
|
+
).pack(side="left", padx=(0, 12))
|
|
307
|
+
ttk.Checkbutton(
|
|
308
|
+
filters_row, text="Tracked changes", variable=self.include_changes_var
|
|
309
|
+
).pack(side="left")
|
|
310
|
+
r += 1
|
|
311
|
+
|
|
312
|
+
ttk.Label(parent, text="Reviewers:", foreground="#333").grid(
|
|
313
|
+
row=r, column=0, sticky="w", pady=(6, 0)
|
|
314
|
+
)
|
|
315
|
+
self.reviewer_filter_var = tk.StringVar(
|
|
316
|
+
value=self.config.get("reviewer_filter", "")
|
|
317
|
+
)
|
|
318
|
+
ttk.Entry(parent, textvariable=self.reviewer_filter_var).grid(
|
|
319
|
+
row=r, column=1, columnspan=2, sticky="ew", pady=(6, 0)
|
|
320
|
+
)
|
|
321
|
+
r += 1
|
|
322
|
+
ttk.Label(
|
|
323
|
+
parent,
|
|
324
|
+
text="(comma-separated name/email substrings; leave empty for all)",
|
|
325
|
+
foreground="#666",
|
|
326
|
+
).grid(row=r, column=1, columnspan=2, sticky="w")
|
|
327
|
+
r += 1
|
|
328
|
+
|
|
329
|
+
ttk.Label(parent, text="Format:", foreground="#333").grid(
|
|
330
|
+
row=r, column=0, sticky="w", pady=(8, 0)
|
|
331
|
+
)
|
|
332
|
+
self.render_mode_var = tk.StringVar(
|
|
333
|
+
value=self.config.get("render_mode", "compact")
|
|
334
|
+
)
|
|
335
|
+
fmt_row = ttk.Frame(parent)
|
|
336
|
+
fmt_row.grid(row=r, column=1, columnspan=2, sticky="w", pady=(8, 0))
|
|
337
|
+
ttk.Radiobutton(
|
|
338
|
+
fmt_row, text="Compact (one-line per comment)",
|
|
339
|
+
variable=self.render_mode_var, value="compact",
|
|
340
|
+
).pack(side="left", padx=(0, 12))
|
|
341
|
+
ttk.Radiobutton(
|
|
342
|
+
fmt_row, text="Detailed (multi-line code fence)",
|
|
343
|
+
variable=self.render_mode_var, value="detailed",
|
|
344
|
+
).pack(side="left")
|
|
345
|
+
r += 1
|
|
346
|
+
|
|
347
|
+
ttk.Label(parent, text="Extras:", foreground="#333").grid(
|
|
348
|
+
row=r, column=0, sticky="w", pady=(8, 0)
|
|
349
|
+
)
|
|
350
|
+
extras_row = ttk.Frame(parent)
|
|
351
|
+
extras_row.grid(row=r, column=1, columnspan=2, sticky="w", pady=(8, 0))
|
|
352
|
+
self.write_jsonl_var = tk.BooleanVar(
|
|
353
|
+
value=bool(self.config.get("write_jsonl", True))
|
|
354
|
+
)
|
|
355
|
+
self.per_reviewer_var = tk.BooleanVar(
|
|
356
|
+
value=bool(self.config.get("per_reviewer_reports", False))
|
|
357
|
+
)
|
|
358
|
+
self.include_raw_var = tk.BooleanVar(
|
|
359
|
+
value=bool(self.config.get("include_raw", False))
|
|
360
|
+
)
|
|
361
|
+
ttk.Checkbutton(
|
|
362
|
+
extras_row,
|
|
363
|
+
text="comments.jsonl (streaming companion)",
|
|
364
|
+
variable=self.write_jsonl_var,
|
|
365
|
+
).pack(anchor="w")
|
|
366
|
+
ttk.Checkbutton(
|
|
367
|
+
extras_row,
|
|
368
|
+
text="Per-reviewer reports (by-reviewer/<name>.md)",
|
|
369
|
+
variable=self.per_reviewer_var,
|
|
370
|
+
).pack(anchor="w")
|
|
371
|
+
ttk.Checkbutton(
|
|
372
|
+
extras_row,
|
|
373
|
+
text="Embed raw Overleaf API data in comments.json (larger file)",
|
|
374
|
+
variable=self.include_raw_var,
|
|
375
|
+
).pack(anchor="w")
|
|
376
|
+
r += 1
|
|
377
|
+
|
|
378
|
+
self._toggle_options_visibility()
|
|
379
|
+
|
|
380
|
+
def _toggle_options_visibility(self) -> None:
|
|
381
|
+
if self.show_options_var.get():
|
|
382
|
+
self.options_frame.grid()
|
|
383
|
+
else:
|
|
384
|
+
self.options_frame.grid_remove()
|
|
385
|
+
|
|
386
|
+
def _on_browser_change(self) -> None:
|
|
387
|
+
key = self.browser_var.get()
|
|
388
|
+
self.browser_help.config(text=BROWSER_LABELS.get(key, key))
|
|
389
|
+
|
|
390
|
+
def _refresh_browser_choices(self) -> None:
|
|
391
|
+
"""Update the combobox's items based on the advanced toggle."""
|
|
392
|
+
if self.show_advanced_var.get():
|
|
393
|
+
values = list(BROWSER_LABELS.keys())
|
|
394
|
+
else:
|
|
395
|
+
values = list(PRIVACY_FRIENDLY)
|
|
396
|
+
self.browser_box.configure(values=values)
|
|
397
|
+
if self.browser_var.get() not in values:
|
|
398
|
+
self.browser_var.set(values[0])
|
|
399
|
+
self._on_browser_change()
|
|
400
|
+
|
|
401
|
+
def _show_privacy_info(self) -> None:
|
|
402
|
+
win = tk.Toplevel(self.root)
|
|
403
|
+
win.title("Privacy info")
|
|
404
|
+
win.geometry("620x500")
|
|
405
|
+
win.transient(self.root)
|
|
406
|
+
frame = ttk.Frame(win, padding=12)
|
|
407
|
+
frame.pack(fill="both", expand=True)
|
|
408
|
+
ttk.Label(
|
|
409
|
+
frame,
|
|
410
|
+
text="What this app touches on your machine",
|
|
411
|
+
font=("", 14, "bold"),
|
|
412
|
+
).pack(anchor="w", pady=(0, 8))
|
|
413
|
+
txt = tk.Text(frame, wrap="word", height=22)
|
|
414
|
+
txt.insert("1.0", PRIVACY_INFO_TEXT)
|
|
415
|
+
txt.configure(state="disabled")
|
|
416
|
+
txt.pack(fill="both", expand=True)
|
|
417
|
+
scroll = ttk.Scrollbar(frame, command=txt.yview)
|
|
418
|
+
txt.configure(yscrollcommand=scroll.set)
|
|
419
|
+
ttk.Button(frame, text="Close", command=win.destroy).pack(
|
|
420
|
+
anchor="e", pady=(8, 0)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def _pick_folder(self) -> None:
|
|
424
|
+
initial = self.out_var.get() or str(Path.home())
|
|
425
|
+
chosen = filedialog.askdirectory(
|
|
426
|
+
initialdir=initial,
|
|
427
|
+
title="Choose a folder to save the comments export into",
|
|
428
|
+
mustexist=False,
|
|
429
|
+
)
|
|
430
|
+
if chosen:
|
|
431
|
+
self.out_var.set(chosen)
|
|
432
|
+
|
|
433
|
+
def _append_log(self, msg: str) -> None:
|
|
434
|
+
self.log.configure(state="normal")
|
|
435
|
+
self.log.insert("end", msg.rstrip("\n") + "\n")
|
|
436
|
+
self.log.see("end")
|
|
437
|
+
self.log.configure(state="disabled")
|
|
438
|
+
|
|
439
|
+
def _on_run(self) -> None:
|
|
440
|
+
url = self.url_var.get().strip()
|
|
441
|
+
out_dir = self.out_var.get().strip()
|
|
442
|
+
if not url:
|
|
443
|
+
messagebox.showerror(
|
|
444
|
+
"Missing input", "Please paste your Overleaf project URL."
|
|
445
|
+
)
|
|
446
|
+
return
|
|
447
|
+
if not out_dir:
|
|
448
|
+
messagebox.showerror(
|
|
449
|
+
"Missing input", "Please choose an output folder."
|
|
450
|
+
)
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
reviewer_text = self.reviewer_filter_var.get().strip()
|
|
454
|
+
reviewer_filter = [r.strip() for r in reviewer_text.split(",") if r.strip()]
|
|
455
|
+
|
|
456
|
+
_save_config(
|
|
457
|
+
{
|
|
458
|
+
"browser": self.browser_var.get(),
|
|
459
|
+
"show_advanced_browsers": bool(self.show_advanced_var.get()),
|
|
460
|
+
"show_options": bool(self.show_options_var.get()),
|
|
461
|
+
"project_url": url,
|
|
462
|
+
"project_title": self.title_var.get().strip(),
|
|
463
|
+
"out_dir": out_dir,
|
|
464
|
+
"include_open": bool(self.include_open_var.get()),
|
|
465
|
+
"include_resolved": bool(self.include_resolved_var.get()),
|
|
466
|
+
"include_changes": bool(self.include_changes_var.get()),
|
|
467
|
+
"reviewer_filter": reviewer_text,
|
|
468
|
+
"render_mode": self.render_mode_var.get(),
|
|
469
|
+
"write_jsonl": bool(self.write_jsonl_var.get()),
|
|
470
|
+
"per_reviewer_reports": bool(self.per_reviewer_var.get()),
|
|
471
|
+
"include_raw": bool(self.include_raw_var.get()),
|
|
472
|
+
}
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
self.run_btn.configure(state="disabled")
|
|
476
|
+
self.open_md_btn.configure(state="disabled")
|
|
477
|
+
self.open_folder_btn.configure(state="disabled")
|
|
478
|
+
self.progress.start(10)
|
|
479
|
+
self._append_log("─" * 60)
|
|
480
|
+
self._append_log("Starting export…")
|
|
481
|
+
|
|
482
|
+
params = dict(
|
|
483
|
+
project_url=url,
|
|
484
|
+
out_dir=Path(out_dir).expanduser(),
|
|
485
|
+
project_title=self.title_var.get().strip() or None,
|
|
486
|
+
browser=self.browser_var.get(),
|
|
487
|
+
include_open=bool(self.include_open_var.get()),
|
|
488
|
+
include_resolved=bool(self.include_resolved_var.get()),
|
|
489
|
+
include_changes=bool(self.include_changes_var.get()),
|
|
490
|
+
reviewer_filter=reviewer_filter,
|
|
491
|
+
render_mode=self.render_mode_var.get(),
|
|
492
|
+
write_jsonl=bool(self.write_jsonl_var.get()),
|
|
493
|
+
per_reviewer_reports=bool(self.per_reviewer_var.get()),
|
|
494
|
+
include_raw=bool(self.include_raw_var.get()),
|
|
495
|
+
)
|
|
496
|
+
self.worker = threading.Thread(
|
|
497
|
+
target=self._worker, args=(params,), daemon=True
|
|
498
|
+
)
|
|
499
|
+
self.worker.start()
|
|
500
|
+
|
|
501
|
+
def _worker(self, params: dict) -> None:
|
|
502
|
+
def progress(msg: str) -> None:
|
|
503
|
+
self.queue.put(("log", msg))
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
result = run_export(progress=progress, **params)
|
|
507
|
+
self.queue.put(("done", result))
|
|
508
|
+
except Exception as e:
|
|
509
|
+
self.queue.put(("error", (e, traceback.format_exc())))
|
|
510
|
+
|
|
511
|
+
def _pump_queue(self) -> None:
|
|
512
|
+
try:
|
|
513
|
+
while True:
|
|
514
|
+
kind, payload = self.queue.get_nowait()
|
|
515
|
+
if kind == "log":
|
|
516
|
+
self._append_log(str(payload))
|
|
517
|
+
elif kind == "done":
|
|
518
|
+
self._on_done(payload) # type: ignore[arg-type]
|
|
519
|
+
elif kind == "error":
|
|
520
|
+
err, tb = payload # type: ignore[misc]
|
|
521
|
+
self._on_error(err, tb)
|
|
522
|
+
except queue.Empty:
|
|
523
|
+
pass
|
|
524
|
+
self.root.after(80, self._pump_queue)
|
|
525
|
+
|
|
526
|
+
def _on_done(self, result: ExportResult) -> None:
|
|
527
|
+
self.last_result = result
|
|
528
|
+
self.progress.stop()
|
|
529
|
+
self.run_btn.configure(state="normal")
|
|
530
|
+
self.open_md_btn.configure(state="normal")
|
|
531
|
+
self.open_folder_btn.configure(state="normal")
|
|
532
|
+
summary = (
|
|
533
|
+
f"\nDone. {result.thread_count} thread(s) — "
|
|
534
|
+
f"{result.open_count} open, {result.resolved_count} resolved. "
|
|
535
|
+
f"{result.tracked_change_count} tracked change(s). "
|
|
536
|
+
f"{result.stale_anchor_count} stale anchor(s)."
|
|
537
|
+
)
|
|
538
|
+
self._append_log(summary)
|
|
539
|
+
self._append_log(f"Markdown: {result.markdown_path}")
|
|
540
|
+
if result.jsonl_path is not None:
|
|
541
|
+
self._append_log(f"JSONL: {result.jsonl_path}")
|
|
542
|
+
if result.agents_path is not None:
|
|
543
|
+
self._append_log(f"Agents: {result.agents_path}")
|
|
544
|
+
if result.by_reviewer_dir is not None:
|
|
545
|
+
self._append_log(f"Per-reviewer: {result.by_reviewer_dir}")
|
|
546
|
+
|
|
547
|
+
def _on_error(self, err: BaseException, tb: str) -> None:
|
|
548
|
+
self.progress.stop()
|
|
549
|
+
self.run_btn.configure(state="normal")
|
|
550
|
+
self._append_log(f"ERROR: {err}")
|
|
551
|
+
self._append_log(tb)
|
|
552
|
+
messagebox.showerror("Export failed", f"{type(err).__name__}: {err}")
|
|
553
|
+
|
|
554
|
+
def _open_markdown(self) -> None:
|
|
555
|
+
if not self.last_result:
|
|
556
|
+
return
|
|
557
|
+
_open_path(self.last_result.markdown_path)
|
|
558
|
+
|
|
559
|
+
def _open_folder(self) -> None:
|
|
560
|
+
if not self.last_result:
|
|
561
|
+
return
|
|
562
|
+
_open_path(self.last_result.markdown_path.parent)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _open_path(p: Path) -> None:
|
|
566
|
+
if sys.platform == "darwin":
|
|
567
|
+
subprocess.Popen(["open", str(p)])
|
|
568
|
+
elif sys.platform.startswith("win"):
|
|
569
|
+
import os
|
|
570
|
+
os.startfile(str(p)) # type: ignore[attr-defined]
|
|
571
|
+
else:
|
|
572
|
+
subprocess.Popen(["xdg-open", str(p)])
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def launch_gui() -> int:
|
|
576
|
+
root = tk.Tk()
|
|
577
|
+
App(root)
|
|
578
|
+
root.mainloop()
|
|
579
|
+
return 0
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
if __name__ == "__main__":
|
|
583
|
+
sys.exit(launch_gui())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Message:
|
|
9
|
+
id: str
|
|
10
|
+
content: str
|
|
11
|
+
timestamp_ms: int
|
|
12
|
+
user_id: str
|
|
13
|
+
user_name: Optional[str] = None
|
|
14
|
+
user_email: Optional[str] = None
|
|
15
|
+
edited_at_ms: Optional[int] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Thread:
|
|
20
|
+
id: str
|
|
21
|
+
messages: list[Message] = field(default_factory=list)
|
|
22
|
+
resolved: bool = False
|
|
23
|
+
resolved_at_ms: Optional[int] = None
|
|
24
|
+
resolved_by_user_id: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SourceContext:
|
|
29
|
+
"""A compact snippet of LaTeX around an anchor.
|
|
30
|
+
|
|
31
|
+
`before` / `after` are short character windows (whitespace-normalized) that
|
|
32
|
+
immediately precede and follow the anchored phrase on the same logical line.
|
|
33
|
+
`anchor` is the exact phrase the comment is attached to. `truncated_before`
|
|
34
|
+
/ `truncated_after` indicate whether content was clipped on either side
|
|
35
|
+
(so a renderer can show "…").
|
|
36
|
+
"""
|
|
37
|
+
before: str = ""
|
|
38
|
+
anchor: str = ""
|
|
39
|
+
after: str = ""
|
|
40
|
+
truncated_before: bool = False
|
|
41
|
+
truncated_after: bool = False
|
|
42
|
+
line_no: int = 0 # 1-indexed line number the snippet was taken from
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class AnchoredComment:
|
|
47
|
+
thread_id: str
|
|
48
|
+
short_id: str # human-friendly stable id like "C001"
|
|
49
|
+
doc_id: str
|
|
50
|
+
pathname: str
|
|
51
|
+
offset: int
|
|
52
|
+
anchored_text: str
|
|
53
|
+
line_no: int
|
|
54
|
+
col: int
|
|
55
|
+
nearest_heading: Optional[str]
|
|
56
|
+
stale: bool
|
|
57
|
+
context: Optional[SourceContext] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class TrackedChange:
|
|
62
|
+
id: str
|
|
63
|
+
short_id: str # like "T001"
|
|
64
|
+
doc_id: str
|
|
65
|
+
pathname: str
|
|
66
|
+
kind: str # "insertion" or "deletion"
|
|
67
|
+
content: str
|
|
68
|
+
offset: int
|
|
69
|
+
line_no: int
|
|
70
|
+
col: int
|
|
71
|
+
nearest_heading: Optional[str]
|
|
72
|
+
user_id: Optional[str]
|
|
73
|
+
user_name: Optional[str]
|
|
74
|
+
user_email: Optional[str]
|
|
75
|
+
timestamp_ms: Optional[int]
|
|
76
|
+
context: Optional[SourceContext] = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Heading:
|
|
81
|
+
line_no: int
|
|
82
|
+
level: int # 1=section, 2=subsection, 3=subsubsection
|
|
83
|
+
text: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class DocText:
|
|
88
|
+
doc_id: str
|
|
89
|
+
pathname: str
|
|
90
|
+
text: str
|
|
91
|
+
line_starts: list[int]
|
|
92
|
+
headings: list[Heading]
|