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,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]