overleaf-comments-export 0.21.2__tar.gz → 0.22.0__tar.gz

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.
Files changed (52) hide show
  1. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/PKG-INFO +17 -1
  2. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/README.md +16 -0
  3. overleaf_comments_export-0.22.0/overleaf_comments_export/docorder.py +209 -0
  4. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/export.py +108 -1
  5. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/gui.py +19 -10
  6. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/sections.py +28 -4
  7. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/PKG-INFO +17 -1
  8. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/SOURCES.txt +2 -0
  9. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/pyproject.toml +1 -1
  10. overleaf_comments_export-0.22.0/tests/test_docorder.py +208 -0
  11. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_gui.py +16 -5
  12. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/LICENSE +0 -0
  13. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/__init__.py +0 -0
  14. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/__main__.py +0 -0
  15. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/anchors.py +0 -0
  16. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/annotate.py +0 -0
  17. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/client.py +0 -0
  18. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/doctor.py +0 -0
  19. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/filenames.py +0 -0
  20. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/highlight.py +0 -0
  21. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/model.py +0 -0
  22. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/pdfannotate.py +0 -0
  23. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/render.py +0 -0
  24. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/since.py +0 -0
  25. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/dependency_links.txt +0 -0
  26. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/entry_points.txt +0 -0
  27. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/requires.txt +0 -0
  28. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/top_level.txt +0 -0
  29. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/setup.cfg +0 -0
  30. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_anchors_sections.py +0 -0
  31. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_annotate.py +0 -0
  32. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_cancel.py +0 -0
  33. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_client.py +0 -0
  34. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_doctor.py +0 -0
  35. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_errors_and_auth.py +0 -0
  36. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_export.py +0 -0
  37. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_export_wiring.py +0 -0
  38. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_extension_parity.py +0 -0
  39. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_filenames.py +0 -0
  40. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_floats.py +0 -0
  41. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_highlight.py +0 -0
  42. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_output_host.py +0 -0
  43. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_pdfannotate.py +0 -0
  44. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_render.py +0 -0
  45. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_replies_and_changes.py +0 -0
  46. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_response_letter.py +0 -0
  47. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_review_fixes.py +0 -0
  48. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_schema_parity.py +0 -0
  49. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_since.py +0 -0
  50. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_stable_output.py +0 -0
  51. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_stress.py +0 -0
  52. {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_workflows.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overleaf-comments-export
3
- Version: 0.21.2
3
+ Version: 0.22.0
4
4
  Summary: Export comment threads and tracked changes from an Overleaf project to Markdown + JSON, optimized for AI-agent consumption.
5
5
  Author-email: Shivang Gupta <47645533+Mangluu@users.noreply.github.com>
6
6
  License: MIT
@@ -213,6 +213,22 @@ ids, so renumbering never shows up as change. And if you run with different
213
213
  filters than last time, deletions are not reported, because a comment hidden by
214
214
  `--no-resolved` has not gone anywhere.
215
215
 
216
+ ## Papers split across files
217
+
218
+ Most real papers are. The whole project is read in the order LaTeX reads it,
219
+ following `\input`, `\include` and `\subfile` from the root document, so a
220
+ comment gets the section it is actually under even when the `\section` is in
221
+ `main.tex` and the prose is in `results-body.tex`. Figure and table numbers
222
+ run through the whole paper the way LaTeX numbers them, rather than restarting
223
+ in every file.
224
+
225
+ Files with no comments in them are read too, because a figure in one still
226
+ decides what number the next one gets.
227
+
228
+ If any included file cannot be read, figure numbers are left off rather than
229
+ guessed, and the export says so. A missing number sends nobody anywhere. A
230
+ wrong one sends them to the wrong figure.
231
+
216
232
  ## Working through comments with an AI
217
233
 
218
234
  This is what the export is shaped for. Hand an assistant the folder and it can
@@ -169,6 +169,22 @@ ids, so renumbering never shows up as change. And if you run with different
169
169
  filters than last time, deletions are not reported, because a comment hidden by
170
170
  `--no-resolved` has not gone anywhere.
171
171
 
172
+ ## Papers split across files
173
+
174
+ Most real papers are. The whole project is read in the order LaTeX reads it,
175
+ following `\input`, `\include` and `\subfile` from the root document, so a
176
+ comment gets the section it is actually under even when the `\section` is in
177
+ `main.tex` and the prose is in `results-body.tex`. Figure and table numbers
178
+ run through the whole paper the way LaTeX numbers them, rather than restarting
179
+ in every file.
180
+
181
+ Files with no comments in them are read too, because a figure in one still
182
+ decides what number the next one gets.
183
+
184
+ If any included file cannot be read, figure numbers are left off rather than
185
+ guessed, and the export says so. A missing number sends nobody anywhere. A
186
+ wrong one sends them to the wrong figure.
187
+
172
188
  ## Working through comments with an AI
173
189
 
174
190
  This is what the export is shaped for. Hand an assistant the folder and it can
@@ -0,0 +1,209 @@
1
+ """Work out the order LaTeX reads a project in.
2
+
3
+ A paper split across files is read in the order the root document pulls the
4
+ pieces in, and two things depend on that order rather than on any one file.
5
+
6
+ Section headings, because `main.tex` can say `\\section{Results}` and then
7
+ `\\input{results-body}`, so the prose and the heading that names it live in
8
+ different files. Read on its own, `results-body.tex` has no heading at all.
9
+
10
+ Figure and table numbers, because LaTeX counts them straight through the whole
11
+ document. Counted per file they restart, and a comment on Figure 2 gets
12
+ reported as Figure 1, which is worse than saying nothing: it sends somebody to
13
+ the wrong figure.
14
+
15
+ This module answers only "what order, and did we see everything". Whether a
16
+ number can be claimed at all is decided by the caller, because the answer
17
+ depends on whether the whole chain was readable.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from typing import Iterable, Mapping
24
+
25
+ # \include always takes a .tex file. \input usually does. \subfile is the
26
+ # subfiles package, which is common enough in theses to be worth reading.
27
+ _INCLUDE_RE = re.compile(r"\\(?:input|include|subfile)\s*\{([^}]*)\}")
28
+ # `\input path` without braces is legal TeX and rare in practice. Reading it
29
+ # wrong would invent a file, so it is left alone.
30
+
31
+
32
+ def _strip_comments(text: str) -> str:
33
+ """Blank out commented-out text, keeping offsets so nothing shifts.
34
+
35
+ A commented `\\input` is not read by LaTeX and must not be read here. An
36
+ escaped \\% is not a comment.
37
+ """
38
+ out = []
39
+ for line in text.split("\n"):
40
+ i, n = 0, len(line)
41
+ while i < n:
42
+ if line[i] == "%" and (i == 0 or line[i - 1] != "\\"):
43
+ line = line[:i] + " " * (n - i)
44
+ break
45
+ i += 1
46
+ out.append(line)
47
+ return "\n".join(out)
48
+
49
+
50
+ def find_includes(text: str) -> list[str]:
51
+ """The files this one pulls in, in the order it pulls them."""
52
+ return [m.group(1).strip() for m in _INCLUDE_RE.finditer(_strip_comments(text))
53
+ if m.group(1).strip()]
54
+
55
+
56
+ def _candidates(ref: str) -> list[str]:
57
+ """What `\\input{sections/intro}` could name on disk.
58
+
59
+ TeX lets you leave the extension off, and people write `./` and
60
+ backslashes, so the reference is normalised before matching.
61
+ """
62
+ ref = ref.replace("\\", "/").strip()
63
+ while ref.startswith("./"):
64
+ ref = ref[2:]
65
+ names = [ref] if ref.endswith(".tex") else [f"{ref}.tex", ref]
66
+ return [n for n in names if n]
67
+
68
+
69
+ def _match(ref: str, available: Mapping[str, str]) -> str | None:
70
+ for name in _candidates(ref):
71
+ if name in available:
72
+ return name
73
+ # A project can say `\input{intro}` for a file the tree calls
74
+ # `sections/intro.tex`. Match on the tail, but only when it is unambiguous.
75
+ for name in _candidates(ref):
76
+ hits = [p for p in available if p == name or p.endswith("/" + name)]
77
+ if len(hits) == 1:
78
+ return hits[0]
79
+ return None
80
+
81
+
82
+ def flatten(root: str, texts: Mapping[str, str]
83
+ ) -> tuple[str, list[tuple[str, int, int]], list[str]]:
84
+ """Splice the whole project into the single document LaTeX reads.
85
+
86
+ Returns the joined source, a list of (pathname, offset in that file,
87
+ offset in the joined source) marking where each piece landed, and any
88
+ reference that could not be found.
89
+
90
+ Whole files are not enough. `\\input` happens at a point, so a file pulled
91
+ in halfway through the root inherits only the headings above that point,
92
+ and its content sits before everything below it. Treating inclusion as
93
+ file-level puts the pieces in the wrong order, which is how a first
94
+ attempt at this gave an included file the heading that came after it.
95
+ """
96
+ parts: list[str] = []
97
+ marks: list[tuple[str, int, int]] = []
98
+ missing: list[str] = []
99
+ depth: set[str] = set()
100
+ total = 0
101
+
102
+ def emit(path: str, text: str, lo: int, hi: int) -> None:
103
+ nonlocal total
104
+ marks.append((path, lo, total))
105
+ chunk = text[lo:hi]
106
+ parts.append(chunk)
107
+ total += len(chunk)
108
+
109
+ def walk(path: str) -> None:
110
+ nonlocal total
111
+ if path in depth:
112
+ return # a ring of includes; LaTeX would fail too
113
+ depth.add(path)
114
+ text = texts[path]
115
+ stripped = _strip_comments(text)
116
+ cursor = 0
117
+ for m in _INCLUDE_RE.finditer(stripped):
118
+ ref = m.group(1).strip()
119
+ if not ref:
120
+ continue
121
+ emit(path, text, cursor, m.start())
122
+ cursor = m.end()
123
+ target = _match(ref, texts)
124
+ if target is None:
125
+ missing.append(ref)
126
+ else:
127
+ walk(target)
128
+ # \include starts a new page, so anything counted per page
129
+ # would reset here. Nothing here counts per page.
130
+ emit(path, text, cursor, len(text))
131
+ depth.discard(path)
132
+
133
+ if root not in texts:
134
+ return "", [], [root]
135
+ walk(root)
136
+ return "".join(parts), marks, missing
137
+
138
+
139
+ def locate(marks: list[tuple[str, int, int]], path: str, offset: int) -> int | None:
140
+ """Where a position in one file landed in the joined source."""
141
+ best = None
142
+ for mark_path, lo, at in marks:
143
+ if mark_path == path and lo <= offset:
144
+ if best is None or lo > best[0]:
145
+ best = (lo, at)
146
+ return None if best is None else best[1] + (offset - best[0])
147
+
148
+
149
+ def resolve_order(root: str, texts: Mapping[str, str]) -> tuple[list[str], list[str]]:
150
+ """Files in the order LaTeX reads them, and any reference we could not find.
151
+
152
+ `texts` maps pathname to source. Returns (ordered pathnames, unresolved
153
+ references). A non-empty second list means the picture is incomplete, and
154
+ anything counted across the whole document cannot be trusted.
155
+ """
156
+ if root not in texts:
157
+ return [], [root]
158
+
159
+ order: list[str] = []
160
+ missing: list[str] = []
161
+ seen: set[str] = set()
162
+
163
+ def walk(path: str) -> None:
164
+ # A file that includes itself, directly or in a ring, would recurse
165
+ # forever. LaTeX would also fail; here it just stops.
166
+ if path in seen:
167
+ return
168
+ seen.add(path)
169
+ order.append(path)
170
+ for ref in find_includes(texts[path]):
171
+ target = _match(ref, texts)
172
+ if target is None:
173
+ missing.append(ref)
174
+ else:
175
+ walk(target)
176
+
177
+ walk(root)
178
+ return order, missing
179
+
180
+
181
+ def reachable(root: str, texts: Mapping[str, str],
182
+ known_paths: Iterable[str]) -> list[str]:
183
+ """Files named from `root` that we have not read yet.
184
+
185
+ Used to fetch the rest of the chain: a file with no comments in it still
186
+ holds figures that decide what number the next one gets.
187
+ """
188
+ known = set(known_paths)
189
+ want: list[str] = []
190
+ seen: set[str] = set()
191
+
192
+ def walk(path: str, source: str) -> None:
193
+ if path in seen:
194
+ return
195
+ seen.add(path)
196
+ for ref in find_includes(source):
197
+ target = _match(ref, texts)
198
+ if target is not None:
199
+ walk(target, texts[target])
200
+ continue
201
+ for name in _candidates(ref):
202
+ hits = [p for p in known if p == name or p.endswith("/" + name)]
203
+ if len(hits) == 1 and hits[0] not in want:
204
+ want.append(hits[0])
205
+ break
206
+
207
+ if root in texts:
208
+ walk(root, texts[root])
209
+ return want
@@ -5,6 +5,7 @@ import logging
5
5
  import os
6
6
  import re
7
7
  import shutil
8
+ from bisect import bisect_right
8
9
  from dataclasses import dataclass
9
10
  from datetime import date, datetime, timezone
10
11
  from pathlib import Path
@@ -24,7 +25,9 @@ from .model import (
24
25
  TrackedChange,
25
26
  )
26
27
  from .render import render_markdown, render_response_letter
27
- from .sections import enclosing_float, find_floats, find_headings, nearest_heading
28
+ from .docorder import flatten, locate, reachable
29
+ from .sections import (enclosing_float, find_floats, find_headings,
30
+ nearest_heading)
28
31
  from .since import (SINCE_FILENAME, compare, load_previous, render_since,
29
32
  short_ids)
30
33
 
@@ -341,6 +344,98 @@ def _slug_reviewer(name: str) -> str:
341
344
  return s[:60] or "reviewer"
342
345
 
343
346
 
347
+ def _apply_document_order(client, project_id, doc_texts, doc_id_to_path,
348
+ root_doc_id, anchored, changes, progress) -> None:
349
+ """Number floats and name sections across the whole document, not per file.
350
+
351
+ Only the files carrying comments were downloaded, and that is not enough:
352
+ a file with no comments in it still holds figures that decide what number
353
+ the next one gets. The rest of the chain is fetched here.
354
+
355
+ The pieces are then spliced into the single document LaTeX reads, and the
356
+ headings and float counters are worked out over that. Doing it file by
357
+ file is not equivalent, because `\\input` happens at a point: a file pulled
358
+ in halfway through the root inherits only the headings above that point.
359
+
360
+ If any part of the chain cannot be read, no number is claimed at all. A
361
+ missing number sends nobody anywhere. A wrong one sends them to the wrong
362
+ figure.
363
+ """
364
+ by_path = {d.pathname: d for d in doc_texts.values()}
365
+ root_path = doc_id_to_path.get(root_doc_id or "")
366
+ if not root_path or root_path not in by_path and root_path not in doc_id_to_path.values():
367
+ return
368
+
369
+ path_to_doc_id = {v: k for k, v in doc_id_to_path.items()}
370
+ texts = {p: d.text for p, d in by_path.items()}
371
+
372
+ if root_path not in texts:
373
+ doc_id = path_to_doc_id.get(root_path)
374
+ try:
375
+ texts[root_path] = client.download_doc_text(project_id, doc_id)
376
+ except Exception as e:
377
+ logger.warning("Could not read the root document %s: %s", root_path, e)
378
+ return
379
+
380
+ # Includes nest, so widen the set until nothing new is named.
381
+ for _ in range(8):
382
+ wanted = [p for p in reachable(root_path, texts, path_to_doc_id)
383
+ if p not in texts]
384
+ if not wanted:
385
+ break
386
+ got = False
387
+ for path in wanted:
388
+ doc_id = path_to_doc_id.get(path)
389
+ if not doc_id:
390
+ continue
391
+ try:
392
+ texts[path] = client.download_doc_text(project_id, doc_id)
393
+ got = True
394
+ except Exception as e:
395
+ logger.warning("Could not read %s for numbering: %s", path, e)
396
+ if not got:
397
+ break
398
+
399
+ joined, marks, missing = flatten(root_path, texts)
400
+ if not joined:
401
+ return
402
+
403
+ if missing:
404
+ progress(f"Could not read {len(missing)} included file(s), so figure and "
405
+ f"table numbers are left off rather than guessed.")
406
+ logger.info("Unresolved includes: %s", ", ".join(sorted(set(missing))[:10]))
407
+ for doc in doc_texts.values():
408
+ for f in doc.floats:
409
+ f.number = None
410
+ return
411
+
412
+ files_read = len({p for p, _, _ in marks})
413
+ if files_read < 2:
414
+ return # one file: per-file numbering is right
415
+
416
+ starts = build_line_starts(joined)
417
+ floats = find_floats(joined, starts)
418
+ headings = find_headings(joined, starts)
419
+
420
+ def relocate(doc_id: str, offset: int) -> int | None:
421
+ doc = doc_texts.get(doc_id)
422
+ return None if doc is None else locate(marks, doc.pathname, offset)
423
+
424
+ for c in anchored:
425
+ at = relocate(c.doc_id, c.offset)
426
+ if at is None:
427
+ continue
428
+ c.float_ref = enclosing_float(floats, at)
429
+ c.nearest_heading = nearest_heading(headings, bisect_right(starts, at))
430
+ for ch in changes:
431
+ at = relocate(ch.doc_id, ch.offset)
432
+ if at is not None:
433
+ ch.nearest_heading = nearest_heading(headings, bisect_right(starts, at))
434
+
435
+ progress(f"Read {files_read} file(s) in document order, so sections and "
436
+ f"figure numbers follow the whole paper.")
437
+
438
+
344
439
  def _commit(stage: Path, out_dir: Path) -> int:
345
440
  """Move a finished export into the folder the user chose.
346
441
 
@@ -654,6 +749,18 @@ def run_export(
654
749
  "file/line anchors."
655
750
  )
656
751
 
752
+ # ---- Put the files in the order LaTeX reads them ----
753
+ #
754
+ # Two things depend on that order and on nothing in any single file:
755
+ # which section a comment sits under, when the heading is in the root
756
+ # and the prose is in an included file, and what number a figure
757
+ # carries, since LaTeX counts straight through the whole document.
758
+ # Counted per file the numbers restart, so a comment on Figure 2 was
759
+ # reported as Figure 1.
760
+ _apply_document_order(
761
+ client, project_id, doc_texts, doc_id_to_path,
762
+ metadata.get("rootDocId"), anchored, changes, progress)
763
+
657
764
  orphan_threads = [
658
765
  thread for tid, thread in threads.items() if tid not in referenced_thread_ids
659
766
  ]
@@ -1013,25 +1013,34 @@ class App:
1013
1013
  as a bundled application, so it usually is not. The dialog appears
1014
1014
  behind whatever the person was looking at, the window stops redrawing
1015
1015
  because it is waiting on it, and the whole thing reads as a hang.
1016
- Measured: without this the app never becomes the frontmost process, and
1017
- `parent=` on its own does not do it either.
1016
+
1017
+ Lift and focus only. An earlier version toggled `-topmost` around the
1018
+ call and the app died with "cannot run nil sheetWindow": toggling it
1019
+ churns the underlying NSWindow, and a sheet opening on a window in
1020
+ that state throws. Turning a hang into a crash is not an improvement.
1018
1021
  """
1019
1022
  try:
1020
1023
  self.root.lift()
1021
- self.root.attributes("-topmost", True)
1022
1024
  self.root.focus_force()
1023
- # Dropped again straight away, or the window would sit above
1024
- # everything else for the rest of the session.
1025
- self.root.after_idle(self.root.attributes, "-topmost", False)
1025
+ # Let the window settle before anything modal opens on top of it.
1026
+ self.root.update_idletasks()
1026
1027
  except tk.TclError:
1027
1028
  pass
1028
1029
 
1029
1030
  def _pick_folder(self) -> None:
1030
1031
  self._to_front()
1031
- chosen = filedialog.askdirectory(
1032
- parent=self.root,
1033
- initialdir=self.out_var.get() or str(Path.home()),
1034
- title="Choose a folder to save the comments into", mustexist=False)
1032
+ try:
1033
+ chosen = filedialog.askdirectory(
1034
+ initialdir=self.out_var.get() or str(Path.home()),
1035
+ title="Choose a folder to save the comments into",
1036
+ mustexist=False)
1037
+ except tk.TclError as e:
1038
+ # The box beside the button is an ordinary text field, so a picker
1039
+ # that will not open is an annoyance rather than a dead end.
1040
+ logger.warning("The folder picker failed: %s", e)
1041
+ self._set_status("The folder picker would not open. Type or paste "
1042
+ "the folder path into the box instead.", "bad")
1043
+ return
1035
1044
  if chosen:
1036
1045
  self.out_var.set(chosen)
1037
1046
 
@@ -79,10 +79,19 @@ def find_headings(text: str, line_starts: list[int]) -> list[Heading]:
79
79
  return headings
80
80
 
81
81
 
82
- def nearest_heading(headings: list[Heading], line_no: int) -> str | None:
82
+ def nearest_heading(headings: list[Heading], line_no: int,
83
+ inherited: list[Heading] | None = None) -> str | None:
83
84
  """Return a path like "§ 3.2 Method overview" for the nearest enclosing
84
- heading at-or-above line_no."""
85
+ heading at-or-above line_no.
86
+
87
+ `inherited` is the heading context in force where this file was pulled in.
88
+ A paper can say `\\section{Results}` in the root and then
89
+ `\\input{results-body}`, so read on its own the included file has no
90
+ heading at all and every comment in it came back with nothing.
91
+ """
85
92
  enclosing: dict[int, Heading] = {}
93
+ for h in inherited or []:
94
+ enclosing[h.level] = h
86
95
  for h in headings:
87
96
  if h.line_no > line_no:
88
97
  break
@@ -141,15 +150,21 @@ def _balanced_argument(text: str, open_brace: int) -> str:
141
150
  return text[open_brace + 1:]
142
151
 
143
152
 
144
- def find_floats(text: str, line_starts: list[int]) -> list[Float]:
153
+ def find_floats(text: str, line_starts: list[int],
154
+ start: dict[str, int] | None = None) -> list[Float]:
145
155
  """Every figure and table in the source, numbered as LaTeX would.
146
156
 
147
157
  Only captioned floats take a number, because LaTeX only steps the counter
148
158
  when there is a caption. An uncaptioned float still gets recorded, so a
149
159
  comment inside one can say it is in a figure even when it cannot say which.
160
+
161
+ `start` carries the counters in from the files LaTeX read before this one.
162
+ Without it every file starts at one, so in a paper split across files the
163
+ second file's Figure 2 is reported as Figure 1. `count_floats` produces
164
+ the value to pass in.
150
165
  """
151
166
  floats: list[Float] = []
152
- counters = {"figure": 0, "table": 0}
167
+ counters = {"figure": 0, "table": 0} | dict(start or {})
153
168
 
154
169
  for m in _FLOAT_BEGIN_RE.finditer(text):
155
170
  if _commented_out(text, m.start()):
@@ -174,6 +189,15 @@ def find_floats(text: str, line_starts: list[int]) -> list[Float]:
174
189
  return floats
175
190
 
176
191
 
192
+ def count_floats(floats: list[Float]) -> dict[str, int]:
193
+ """The counters after these floats, for handing to the next file."""
194
+ out = {"figure": 0, "table": 0}
195
+ for f in floats:
196
+ if f.number is not None:
197
+ out[f.kind] = max(out[f.kind], f.number)
198
+ return out
199
+
200
+
177
201
  def _matching_end(text: str, after_begin: int) -> int:
178
202
  """Where this float closes, counting nested floats on the way."""
179
203
  depth = 1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: overleaf-comments-export
3
- Version: 0.21.2
3
+ Version: 0.22.0
4
4
  Summary: Export comment threads and tracked changes from an Overleaf project to Markdown + JSON, optimized for AI-agent consumption.
5
5
  Author-email: Shivang Gupta <47645533+Mangluu@users.noreply.github.com>
6
6
  License: MIT
@@ -213,6 +213,22 @@ ids, so renumbering never shows up as change. And if you run with different
213
213
  filters than last time, deletions are not reported, because a comment hidden by
214
214
  `--no-resolved` has not gone anywhere.
215
215
 
216
+ ## Papers split across files
217
+
218
+ Most real papers are. The whole project is read in the order LaTeX reads it,
219
+ following `\input`, `\include` and `\subfile` from the root document, so a
220
+ comment gets the section it is actually under even when the `\section` is in
221
+ `main.tex` and the prose is in `results-body.tex`. Figure and table numbers
222
+ run through the whole paper the way LaTeX numbers them, rather than restarting
223
+ in every file.
224
+
225
+ Files with no comments in them are read too, because a figure in one still
226
+ decides what number the next one gets.
227
+
228
+ If any included file cannot be read, figure numbers are left off rather than
229
+ guessed, and the export says so. A missing number sends nobody anywhere. A
230
+ wrong one sends them to the wrong figure.
231
+
216
232
  ## Working through comments with an AI
217
233
 
218
234
  This is what the export is shaped for. Hand an assistant the folder and it can
@@ -6,6 +6,7 @@ overleaf_comments_export/__main__.py
6
6
  overleaf_comments_export/anchors.py
7
7
  overleaf_comments_export/annotate.py
8
8
  overleaf_comments_export/client.py
9
+ overleaf_comments_export/docorder.py
9
10
  overleaf_comments_export/doctor.py
10
11
  overleaf_comments_export/export.py
11
12
  overleaf_comments_export/filenames.py
@@ -26,6 +27,7 @@ tests/test_anchors_sections.py
26
27
  tests/test_annotate.py
27
28
  tests/test_cancel.py
28
29
  tests/test_client.py
30
+ tests/test_docorder.py
29
31
  tests/test_doctor.py
30
32
  tests/test_errors_and_auth.py
31
33
  tests/test_export.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "overleaf-comments-export"
7
- version = "0.21.2"
7
+ version = "0.22.0"
8
8
  description = "Export comment threads and tracked changes from an Overleaf project to Markdown + JSON, optimized for AI-agent consumption."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,208 @@
1
+ """Multi-file papers: the order LaTeX reads them in, and what depends on it.
2
+
3
+ Two things depend on the whole document rather than on any one file. Which
4
+ section a comment sits under, when the heading is in the root and the prose is
5
+ in an included file. And what number a figure carries, since LaTeX counts
6
+ straight through. Counted per file the numbers restart, and a comment on
7
+ Figure 2 came back as Figure 1, which is worse than saying nothing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ import pytest
15
+
16
+ from overleaf_comments_export import export as export_mod
17
+ from overleaf_comments_export.docorder import (find_includes, flatten, locate,
18
+ resolve_order)
19
+ from tests.test_export_wiring import FakeClient
20
+
21
+ URL = "https://www.overleaf.com/project/" + "a" * 24
22
+
23
+
24
+ # --- reading the includes ---------------------------------------------------
25
+
26
+ def test_every_way_a_paper_pulls_in_a_file():
27
+ text = (r"\input{a}" "\n" r"\include{b}" "\n" r"\subfile{c}" "\n"
28
+ r"\input{sub/dir/d.tex}")
29
+ assert find_includes(text) == ["a", "b", "c", "sub/dir/d.tex"]
30
+
31
+
32
+ def test_a_commented_out_include_is_not_read():
33
+ """LaTeX does not read it, so neither does this. Reading it would invent a
34
+ file and, worse, invent figures that shift every later number."""
35
+ assert find_includes("% \\input{draft}\n\\input{real}") == ["real"]
36
+ assert find_includes("\\input{real} % \\input{draft}") == ["real"]
37
+ # An escaped percent is not a comment.
38
+ assert find_includes(r"100\% \input{real}") == ["real"]
39
+
40
+
41
+ @pytest.mark.parametrize("ref", ["intro", "intro.tex", "./intro", "sections/intro"])
42
+ def test_a_reference_finds_its_file_however_it_is_written(ref):
43
+ texts = {"main.tex": "\\input{%s}" % ref, "sections/intro.tex": "x"}
44
+ order, missing = resolve_order("main.tex", texts)
45
+ assert missing == []
46
+ assert order == ["main.tex", "sections/intro.tex"]
47
+
48
+
49
+ def test_a_ring_of_includes_stops_instead_of_recursing():
50
+ texts = {"a.tex": "\\input{b}", "b.tex": "\\input{a}"}
51
+ joined, marks, missing = flatten("a.tex", texts)
52
+ assert missing == [] # both were found; it simply stops
53
+
54
+
55
+ def test_a_file_that_is_not_there_is_reported():
56
+ texts = {"main.tex": "\\input{gone}"}
57
+ _, _, missing = flatten("main.tex", texts)
58
+ assert missing == ["gone"]
59
+
60
+
61
+ # --- the splice -------------------------------------------------------------
62
+
63
+ def test_an_included_file_lands_where_the_include_sits():
64
+ """Not at the end of the parent. `\\input` happens at a point, and treating
65
+ it as file-level is what gave an included file the heading below it."""
66
+ joined, marks, _ = flatten("main.tex", {"main.tex": "A \\input{b} C", "b.tex": "BBB"})
67
+ assert joined == "A BBB C"
68
+ assert locate(marks, "b.tex", 0) == 2
69
+ assert locate(marks, "main.tex", 12) == 6 # the C, after the splice
70
+
71
+
72
+ # --- what it is all for -----------------------------------------------------
73
+
74
+ FILES = {
75
+ "d_main": ("main.tex",
76
+ "\\documentclass{article}\n\\begin{document}\n"
77
+ "\\section{Results}\n\\input{results-body}\n"
78
+ "\\begin{figure}\\caption{First}\\label{fig:a}\\end{figure}\n"
79
+ "\\input{extra-figures}\n"
80
+ "\\section{Discussion}\n\\input{discussion-body}\n\\end{document}\n"),
81
+ "d_results": ("results-body.tex", "Task time fell by twelve percent.\n"),
82
+ # No comment in here at all, but its figure still moves the counter on.
83
+ "d_extra": ("extra-figures.tex",
84
+ "\\begin{figure}\\caption{Nobody commented}\\end{figure}\n"),
85
+ "d_disc": ("discussion-body.tex",
86
+ "\\begin{figure}\\caption{Fourth}\\label{fig:d}\\end{figure}\n"
87
+ "The effect is smaller than we expected.\n"),
88
+ }
89
+ ANCHORS = {"d_results": ("twelve percent", "t1"),
90
+ "d_disc": ("Fourth", "t2"),
91
+ "d_main": ("First", "t3")}
92
+
93
+
94
+ def _threads():
95
+ return {tid: {"resolved": False,
96
+ "messages": [{"id": f"m{tid}", "content": "note",
97
+ "timestamp": 1_700_000_000_000, "user_id": "u1",
98
+ "user": {"name": "R"}}]}
99
+ for _, tid in ANCHORS.values()}
100
+
101
+
102
+ class MultiFile(FakeClient):
103
+ unreadable: set[str] = set()
104
+
105
+ def get_threads(self, project_id):
106
+ return _threads()
107
+
108
+ def get_resolved_thread_ids(self, project_id):
109
+ return []
110
+
111
+ def get_project_metadata(self, project_id):
112
+ return {"files": {"docs": []}, "name": "Paper",
113
+ "rootDocId": "d_main", "raw_meta": {}}
114
+
115
+ def flatten_files(self, files_root, debug_logger=None):
116
+ return [{"doc_id": d, "pathname": p} for d, (p, _) in FILES.items()]
117
+
118
+ def get_project_ranges(self, project_id):
119
+ return [{"id": d, "ranges": {
120
+ "comments": [{"op": {"p": FILES[d][1].index(txt), "c": txt, "t": tid}}],
121
+ "changes": []}} for d, (txt, tid) in ANCHORS.items()]
122
+
123
+ def download_doc_text(self, project_id, doc_id):
124
+ if doc_id in self.unreadable:
125
+ raise RuntimeError("cannot read this one")
126
+ return FILES[doc_id][1]
127
+
128
+
129
+ @pytest.fixture()
130
+ def multi(monkeypatch):
131
+ MultiFile.unreadable = set()
132
+ monkeypatch.setattr(export_mod, "OverleafClient", MultiFile)
133
+
134
+
135
+ def _comments(tmp_path):
136
+ result = export_mod.run_export(project_url=URL, out_dir=tmp_path)
137
+ payload = json.loads(result.json_path.read_text(encoding="utf-8"))
138
+ return {c["short_id"]: c for c in payload["comments"]}
139
+
140
+
141
+ def test_figure_numbers_run_through_the_whole_paper(tmp_path, multi):
142
+ """The bug in #12. Counted per file these were Figure 1 and Figure 1."""
143
+ by_path = {c["pathname"]: c for c in _comments(tmp_path).values()}
144
+ assert by_path["main.tex"]["enclosing_float"]["number"] == 1
145
+ # 2 is in extra-figures.tex, which carries no comment and was never
146
+ # downloaded until this feature went in. Miss it and this reads 2.
147
+ assert by_path["discussion-body.tex"]["enclosing_float"]["number"] == 3
148
+
149
+
150
+ def test_a_comment_in_an_included_file_gets_its_section(tmp_path, multi):
151
+ """The heading is in main.tex and the prose is not, so on its own the
152
+ included file has no heading at all."""
153
+ by_path = {c["pathname"]: c for c in _comments(tmp_path).values()}
154
+ assert by_path["results-body.tex"]["nearest_heading"] == "Results"
155
+ assert by_path["discussion-body.tex"]["nearest_heading"] == "Discussion"
156
+
157
+
158
+ def test_the_section_is_the_one_above_the_include_not_below_it(tmp_path, multi):
159
+ """results-body is pulled in under Results, and Discussion comes after.
160
+ A first attempt at this handed it Discussion, because it carried the whole
161
+ parent file's headings across instead of only those above the include."""
162
+ by_path = {c["pathname"]: c for c in _comments(tmp_path).values()}
163
+ assert by_path["results-body.tex"]["nearest_heading"] != "Discussion"
164
+
165
+
166
+ def test_an_unreadable_file_means_no_numbers_rather_than_wrong_ones(tmp_path,
167
+ multi):
168
+ """Missing sends nobody anywhere. Wrong sends them to the wrong figure."""
169
+ MultiFile.unreadable = {"d_extra"}
170
+ numbers = [c["enclosing_float"]["number"]
171
+ for c in _comments(tmp_path).values() if c["enclosing_float"]]
172
+ assert numbers and all(n is None for n in numbers), numbers
173
+
174
+
175
+ def test_a_single_file_paper_is_unaffected(tmp_path, monkeypatch):
176
+ """The common case must not change, and must not fetch anything extra."""
177
+ text = ("\\section{Method}\n"
178
+ "\\begin{figure}\\caption{Only}\\label{fig:o}\\end{figure}\n"
179
+ "We crossed three sensory environments in a controlled study.\n")
180
+
181
+ class Single(FakeClient):
182
+ def get_threads(self, project_id):
183
+ return {"t1": {"resolved": False, "messages": [
184
+ {"id": "m1", "content": "note", "timestamp": 1_700_000_000_000,
185
+ "user_id": "u1", "user": {"name": "R"}}]}}
186
+
187
+ def get_resolved_thread_ids(self, project_id):
188
+ return []
189
+
190
+ def get_project_metadata(self, project_id):
191
+ return {"files": {"docs": []}, "name": "P", "rootDocId": "d1",
192
+ "raw_meta": {}}
193
+
194
+ def flatten_files(self, files_root, debug_logger=None):
195
+ return [{"doc_id": "d1", "pathname": "main.tex"}]
196
+
197
+ def get_project_ranges(self, project_id):
198
+ return [{"id": "d1", "ranges": {"comments": [
199
+ {"op": {"p": text.index("Only"), "c": "Only", "t": "t1"}}],
200
+ "changes": []}}]
201
+
202
+ def download_doc_text(self, project_id, doc_id):
203
+ return text
204
+
205
+ monkeypatch.setattr(export_mod, "OverleafClient", Single)
206
+ c = list(_comments(tmp_path).values())[0]
207
+ assert c["enclosing_float"]["number"] == 1
208
+ assert c["nearest_heading"] == "Method"
@@ -293,7 +293,11 @@ def test_the_window_comes_forward_before_the_folder_dialog(app, monkeypatch):
293
293
 
294
294
  def fake_dialog(**kwargs):
295
295
  order.append("dialog")
296
- assert kwargs.get("parent") is app.root, "the dialog is not attached"
296
+ # No parent. On macOS that makes the picker a sheet attached to the
297
+ # window, and a sheet opening on a window whose NSWindow is not in a
298
+ # good state dies with "cannot run nil sheetWindow". A free-standing
299
+ # panel cannot fail that way.
300
+ assert "parent" not in kwargs, "parent= brings back the sheet crash"
297
301
  return "/tmp/chosen"
298
302
 
299
303
  monkeypatch.setattr(gui.filedialog, "askdirectory", fake_dialog)
@@ -316,9 +320,16 @@ def test_every_modal_brings_the_window_forward_first(app):
316
320
  f"this dialog can open behind the window:\n {line.strip()}")
317
321
 
318
322
 
319
- def test_bringing_the_window_forward_does_not_pin_it_there(app):
320
- """-topmost has to come off again, or the window sits above everything
321
- else for the rest of the session."""
323
+ def test_bringing_the_window_forward_never_touches_topmost(app):
324
+ """Toggling -topmost around the dialog churns the underlying NSWindow, and
325
+ the app died with "cannot run nil sheetWindow" because of it. Lifting and
326
+ focusing is enough and cannot fail that way."""
327
+ import inspect
328
+
329
+ # The call, not the word. The docstring says "topmost" precisely because
330
+ # it explains why this does not use it.
331
+ source = inspect.getsource(type(app)._to_front)
332
+ assert '"-topmost"' not in source, "topmost is back, and so is the crash"
333
+ assert "'-topmost'" not in source
322
334
  app._to_front()
323
- app.root.update_idletasks() # runs the after_idle that drops it
324
335
  assert not app.root.attributes("-topmost")