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.
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/PKG-INFO +17 -1
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/README.md +16 -0
- overleaf_comments_export-0.22.0/overleaf_comments_export/docorder.py +209 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/export.py +108 -1
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/gui.py +19 -10
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/sections.py +28 -4
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/PKG-INFO +17 -1
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/SOURCES.txt +2 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/pyproject.toml +1 -1
- overleaf_comments_export-0.22.0/tests/test_docorder.py +208 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_gui.py +16 -5
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/LICENSE +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/__init__.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/__main__.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/anchors.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/annotate.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/client.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/doctor.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/filenames.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/highlight.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/model.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/pdfannotate.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/render.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/since.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/dependency_links.txt +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/entry_points.txt +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/requires.txt +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export.egg-info/top_level.txt +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/setup.cfg +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_anchors_sections.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_annotate.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_cancel.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_client.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_doctor.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_errors_and_auth.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_export.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_export_wiring.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_extension_parity.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_filenames.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_floats.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_highlight.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_output_host.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_pdfannotate.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_render.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_replies_and_changes.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_response_letter.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_review_fixes.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_schema_parity.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_since.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_stable_output.py +0 -0
- {overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_stress.py +0 -0
- {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.
|
|
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 .
|
|
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
|
]
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/overleaf_comments_export/gui.py
RENAMED
|
@@ -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
|
-
|
|
1017
|
-
|
|
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
|
-
#
|
|
1024
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
|
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]
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
320
|
-
"""-topmost
|
|
321
|
-
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_anchors_sections.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_errors_and_auth.py
RENAMED
|
File without changes
|
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_export_wiring.py
RENAMED
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_extension_parity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_output_host.py
RENAMED
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_pdfannotate.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_response_letter.py
RENAMED
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_review_fixes.py
RENAMED
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_schema_parity.py
RENAMED
|
File without changes
|
|
File without changes
|
{overleaf_comments_export-0.21.2 → overleaf_comments_export-0.22.0}/tests/test_stable_output.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|