prosediff 0.1.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.
- prosediff/__init__.py +6 -0
- prosediff/__main__.py +5 -0
- prosediff/cli.py +244 -0
- prosediff/comments.py +206 -0
- prosediff/diff.py +1337 -0
- prosediff/footnotes.py +180 -0
- prosediff/gui.py +599 -0
- prosediff/mdstyle.py +91 -0
- prosediff/render.py +40 -0
- prosediff/sentences.py +184 -0
- prosediff/sources.py +56 -0
- prosediff/templates/report.html.j2 +647 -0
- prosediff/word.py +423 -0
- prosediff-0.1.0.dist-info/METADATA +324 -0
- prosediff-0.1.0.dist-info/RECORD +18 -0
- prosediff-0.1.0.dist-info/WHEEL +4 -0
- prosediff-0.1.0.dist-info/entry_points.txt +6 -0
- prosediff-0.1.0.dist-info/licenses/LICENSE +21 -0
prosediff/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Side-by-side HTML diff between two commits of a git repository."""
|
|
2
|
+
|
|
3
|
+
from prosediff.diff import Comparison, FileDiff, Row, compare, compare_paths
|
|
4
|
+
from prosediff.render import render
|
|
5
|
+
|
|
6
|
+
__all__ = ["Comparison", "FileDiff", "Row", "compare", "compare_paths", "render"]
|
prosediff/__main__.py
ADDED
prosediff/cli.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Command line: prosediff REPO BASE [TARGET], or prosediff --files OLD NEW."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import git
|
|
9
|
+
|
|
10
|
+
from prosediff.diff import (
|
|
11
|
+
CONTEXT,
|
|
12
|
+
MAX_HIDDEN,
|
|
13
|
+
MOVE_SIMILARITY,
|
|
14
|
+
PROSE_CONTEXT,
|
|
15
|
+
FilterError,
|
|
16
|
+
compare,
|
|
17
|
+
compare_paths,
|
|
18
|
+
)
|
|
19
|
+
from prosediff.render import ALIGNMENTS, render
|
|
20
|
+
from prosediff.sources import DOCX_CHANGES, SourceError
|
|
21
|
+
|
|
22
|
+
PROG = "prosediff"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def package_version() -> str:
|
|
26
|
+
try:
|
|
27
|
+
return version(PROG)
|
|
28
|
+
except PackageNotFoundError:
|
|
29
|
+
return "unknown"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
ap = argparse.ArgumentParser(
|
|
34
|
+
prog=PROG,
|
|
35
|
+
description="Write an HTML page showing the differences between two "
|
|
36
|
+
"commits of a git repository (or a commit and the working tree or the "
|
|
37
|
+
"index), or between two files or two folders, side by side.",
|
|
38
|
+
epilog="Examples: prosediff . HEAD~1 HEAD; prosediff . HEAD --untracked; "
|
|
39
|
+
"prosediff --files draft_v1.docx draft_v2.docx",
|
|
40
|
+
)
|
|
41
|
+
ap.add_argument(
|
|
42
|
+
"repo",
|
|
43
|
+
metavar="REPO|OLD",
|
|
44
|
+
help="the repository (or any folder inside it); with --files, the old file or folder",
|
|
45
|
+
)
|
|
46
|
+
ap.add_argument(
|
|
47
|
+
"base",
|
|
48
|
+
metavar="BASE|NEW",
|
|
49
|
+
help="the older commit: hash, branch, tag, HEAD~2, ...; with "
|
|
50
|
+
"--files, the new file or folder",
|
|
51
|
+
)
|
|
52
|
+
ap.add_argument(
|
|
53
|
+
"target",
|
|
54
|
+
nargs="?",
|
|
55
|
+
metavar="TARGET",
|
|
56
|
+
help="the newer commit; without it, BASE is compared with the "
|
|
57
|
+
"working tree (tracked files only), as git diff BASE does",
|
|
58
|
+
)
|
|
59
|
+
ap.add_argument(
|
|
60
|
+
"--files", action="store_true", help="compare two files or two folders, outside git"
|
|
61
|
+
)
|
|
62
|
+
ap.add_argument(
|
|
63
|
+
"-p",
|
|
64
|
+
"--path",
|
|
65
|
+
action="append",
|
|
66
|
+
dest="paths",
|
|
67
|
+
default=[],
|
|
68
|
+
metavar="PATH",
|
|
69
|
+
help="restrict the diff to this path (repeatable)",
|
|
70
|
+
)
|
|
71
|
+
ap.add_argument(
|
|
72
|
+
"-o",
|
|
73
|
+
"--output",
|
|
74
|
+
type=Path,
|
|
75
|
+
default=Path("diff.html"),
|
|
76
|
+
help="output file (default: diff.html)",
|
|
77
|
+
)
|
|
78
|
+
lines = ap.add_mutually_exclusive_group()
|
|
79
|
+
lines.add_argument(
|
|
80
|
+
"-U",
|
|
81
|
+
"--context",
|
|
82
|
+
type=int,
|
|
83
|
+
default=None,
|
|
84
|
+
metavar="N",
|
|
85
|
+
help="unchanged lines shown around each change, in every file (default: "
|
|
86
|
+
f"{PROSE_CONTEXT} in Markdown files and Word documents, whose lines are "
|
|
87
|
+
f"paragraphs, {CONTEXT} in the others); the page can reveal the rest",
|
|
88
|
+
)
|
|
89
|
+
lines.add_argument("--full", action="store_true", help="show every line of the changed files")
|
|
90
|
+
ap.add_argument(
|
|
91
|
+
"--max-hidden",
|
|
92
|
+
type=int,
|
|
93
|
+
default=MAX_HIDDEN,
|
|
94
|
+
metavar="N",
|
|
95
|
+
help="unchanged lines embedded per gap, for the page to reveal; "
|
|
96
|
+
f"longer gaps are left out (default: {MAX_HIDDEN:,})",
|
|
97
|
+
)
|
|
98
|
+
ap.add_argument(
|
|
99
|
+
"--align",
|
|
100
|
+
choices=ALIGNMENTS,
|
|
101
|
+
default="left",
|
|
102
|
+
help="alignment of wrapped lines (default: left)",
|
|
103
|
+
)
|
|
104
|
+
ap.add_argument(
|
|
105
|
+
"--md-filter",
|
|
106
|
+
metavar="COMMAND",
|
|
107
|
+
help="shell command both versions of every Markdown file are "
|
|
108
|
+
"piped through before comparing",
|
|
109
|
+
)
|
|
110
|
+
ap.add_argument(
|
|
111
|
+
"--cached",
|
|
112
|
+
"--staged",
|
|
113
|
+
action="store_true",
|
|
114
|
+
help="compare BASE with the index (the staged changes) instead of the working tree",
|
|
115
|
+
)
|
|
116
|
+
ap.add_argument(
|
|
117
|
+
"--untracked",
|
|
118
|
+
action="store_true",
|
|
119
|
+
help="include untracked files not excluded by .gitignore (working tree only)",
|
|
120
|
+
)
|
|
121
|
+
ap.add_argument(
|
|
122
|
+
"-w",
|
|
123
|
+
"--ignore-whitespace",
|
|
124
|
+
action="store_true",
|
|
125
|
+
help="ignore whitespace when comparing lines",
|
|
126
|
+
)
|
|
127
|
+
ap.add_argument(
|
|
128
|
+
"--fold-comments",
|
|
129
|
+
action=argparse.BooleanOptionalAction,
|
|
130
|
+
default=True,
|
|
131
|
+
help="in Markdown files and Word documents, show each comment "
|
|
132
|
+
"as a marker whose tooltip is the comment, and list them in a panel "
|
|
133
|
+
"(default: on; --no-fold-comments compares the comment markup as text)",
|
|
134
|
+
)
|
|
135
|
+
ap.add_argument(
|
|
136
|
+
"--empty-comments",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="show the comments that have no text too (left out by default)",
|
|
139
|
+
)
|
|
140
|
+
ap.add_argument(
|
|
141
|
+
"--docx-changes",
|
|
142
|
+
choices=DOCX_CHANGES,
|
|
143
|
+
default="accept",
|
|
144
|
+
help="the tracked changes of Word documents: accept them, reject them, "
|
|
145
|
+
"or show them as markup (default: accept)",
|
|
146
|
+
)
|
|
147
|
+
ap.add_argument(
|
|
148
|
+
"--move-similarity",
|
|
149
|
+
type=float,
|
|
150
|
+
default=MOVE_SIMILARITY,
|
|
151
|
+
metavar="X",
|
|
152
|
+
help="how alike, above 0 and at most 1, an edited line must be to where it "
|
|
153
|
+
"reappears to count as moved: the share of its words and punctuation in "
|
|
154
|
+
f"common, in order (default: {MOVE_SIMILARITY}; 1: only lines moved unchanged)",
|
|
155
|
+
)
|
|
156
|
+
ap.add_argument(
|
|
157
|
+
"--by-sentence",
|
|
158
|
+
action="store_true",
|
|
159
|
+
help="compare the prose of Markdown files and Word documents sentence by "
|
|
160
|
+
"sentence instead of paragraph by paragraph: moved sentences are recognised, "
|
|
161
|
+
"lines are labelled 12.1, 12.2, ...",
|
|
162
|
+
)
|
|
163
|
+
ap.add_argument(
|
|
164
|
+
"--sentence-language",
|
|
165
|
+
default="en",
|
|
166
|
+
metavar="CODE",
|
|
167
|
+
help="the language whose rules split sentences with --by-sentence "
|
|
168
|
+
"(default: en; e.g. it, de, fr; others fall back to a simple rule)",
|
|
169
|
+
)
|
|
170
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {package_version()}")
|
|
171
|
+
args = ap.parse_args(argv)
|
|
172
|
+
|
|
173
|
+
if args.context is not None and args.context < 0:
|
|
174
|
+
ap.error("--context must be 0 or more")
|
|
175
|
+
if args.max_hidden < 0:
|
|
176
|
+
ap.error("--max-hidden must be 0 or more")
|
|
177
|
+
if not 0 < args.move_similarity <= 1:
|
|
178
|
+
ap.error("--move-similarity must be above 0 and at most 1")
|
|
179
|
+
if args.files:
|
|
180
|
+
if args.target:
|
|
181
|
+
ap.error("--files compares OLD with NEW: give no TARGET")
|
|
182
|
+
if args.cached or args.untracked:
|
|
183
|
+
ap.error("--cached and --untracked need a git repository, not --files")
|
|
184
|
+
if args.cached and args.target:
|
|
185
|
+
ap.error("--cached compares BASE with the index: give no TARGET")
|
|
186
|
+
if args.untracked and (args.target or args.cached):
|
|
187
|
+
ap.error("--untracked needs the working tree: give no TARGET and no --cached")
|
|
188
|
+
|
|
189
|
+
options = dict(
|
|
190
|
+
paths=args.paths,
|
|
191
|
+
context=None if args.full else ("auto" if args.context is None else args.context),
|
|
192
|
+
md_filter=args.md_filter,
|
|
193
|
+
ignore_whitespace=args.ignore_whitespace,
|
|
194
|
+
fold_comments_md=args.fold_comments,
|
|
195
|
+
empty_comments=args.empty_comments,
|
|
196
|
+
max_hidden=args.max_hidden,
|
|
197
|
+
docx_changes=args.docx_changes,
|
|
198
|
+
move_similarity=args.move_similarity,
|
|
199
|
+
by_sentence=args.by_sentence,
|
|
200
|
+
sentence_language=args.sentence_language,
|
|
201
|
+
)
|
|
202
|
+
try:
|
|
203
|
+
if args.files:
|
|
204
|
+
comparison = compare_paths(args.repo, args.base, **options)
|
|
205
|
+
else:
|
|
206
|
+
comparison = compare(
|
|
207
|
+
Path(args.repo),
|
|
208
|
+
args.base,
|
|
209
|
+
args.target,
|
|
210
|
+
cached=args.cached,
|
|
211
|
+
untracked=args.untracked,
|
|
212
|
+
**options,
|
|
213
|
+
)
|
|
214
|
+
except git.InvalidGitRepositoryError:
|
|
215
|
+
print(
|
|
216
|
+
f"{PROG}: not a git repository: {args.repo} (use --files to compare files or folders)",
|
|
217
|
+
file=sys.stderr,
|
|
218
|
+
)
|
|
219
|
+
return 1
|
|
220
|
+
except git.NoSuchPathError:
|
|
221
|
+
print(f"{PROG}: no such folder: {args.repo}", file=sys.stderr)
|
|
222
|
+
return 1
|
|
223
|
+
except (git.BadName, ValueError) as e:
|
|
224
|
+
print(f"{PROG}: not a commit: {e}", file=sys.stderr)
|
|
225
|
+
return 1
|
|
226
|
+
except (FilterError, SourceError) as e:
|
|
227
|
+
print(f"{PROG}: {e}", file=sys.stderr)
|
|
228
|
+
return 1
|
|
229
|
+
|
|
230
|
+
# newline="\n" keeps the page LF on Windows too.
|
|
231
|
+
with open(args.output, "w", encoding="utf-8", newline="\n") as f:
|
|
232
|
+
f.write(render(comparison, args.paths, align=args.align))
|
|
233
|
+
|
|
234
|
+
c = comparison
|
|
235
|
+
files = "file" if len(c.files) == 1 else "files"
|
|
236
|
+
print(
|
|
237
|
+
f"{PROG}: {c.base.short}..{c.target.short}: {len(c.files):,} {files}, "
|
|
238
|
+
f"+{c.additions:,} -{c.deletions:,} -> {args.output}"
|
|
239
|
+
)
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|
prosediff/comments.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Pandoc comment spans: folded into markers, and listed in a panel.
|
|
2
|
+
|
|
3
|
+
pandoc --track-changes=all writes a Word comment as
|
|
4
|
+
[note]{.comment-start id=... author="A" date=...}anchor[]{.comment-end id=...}.
|
|
5
|
+
Folding replaces each comment-start span with one character of the Unicode
|
|
6
|
+
private use area standing for (author, note), and drops comment-end: the
|
|
7
|
+
comment is then compared like a word, the same comment matches on both sides
|
|
8
|
+
even when a new conversion renumbered its id, and a filter cannot cut it in
|
|
9
|
+
two. The characters become markers when the page is built.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from markupsafe import Markup
|
|
16
|
+
|
|
17
|
+
PUA_FIRST, PUA_LAST = 0xE000, 0xF8FF
|
|
18
|
+
PLACEHOLDER = re.compile(f"[{chr(PUA_FIRST)}-{chr(PUA_LAST)}]")
|
|
19
|
+
COMMENT_MARK = "\N{SPEECH BALLOON}"
|
|
20
|
+
# A comment only the new side has: added since the base.
|
|
21
|
+
NEW_COMMENT_MARK = "\N{SQUARED NEW}"
|
|
22
|
+
|
|
23
|
+
COMMENT_CLASS = re.compile(r"^\{\s*\.(comment-start|comment-end)\b")
|
|
24
|
+
AUTHOR = re.compile(r'\bauthor="((?:[^"\\]|\\.)*)"')
|
|
25
|
+
DATE = re.compile(r'\bdate="(\d{4}-\d\d-\d\d)T(\d\d:\d\d)')
|
|
26
|
+
ESCAPE = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|'\"<>~^$])")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Comment:
|
|
31
|
+
author: str
|
|
32
|
+
text: str
|
|
33
|
+
date: str = "" # "YYYY-MM-DD HH:MM", as Word stamped it
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def label(self) -> str:
|
|
37
|
+
"""The comment in one line, for screen readers."""
|
|
38
|
+
who = f"comment by {self.author}" if self.author else "comment"
|
|
39
|
+
when = f", {self.date}" if self.date else ""
|
|
40
|
+
return f"{who}{when}: {self.text or '(no text)'}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class CommentEntry:
|
|
45
|
+
"""One comment of one file, for the comments panel."""
|
|
46
|
+
|
|
47
|
+
author: str
|
|
48
|
+
text: str
|
|
49
|
+
status: str # "new", "removed" or "unchanged"
|
|
50
|
+
path: str
|
|
51
|
+
anchor: str # id of the row it sits in
|
|
52
|
+
line: int | None
|
|
53
|
+
date: str = ""
|
|
54
|
+
label: str = "" # the line as the gutter shows it ("12.3")
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def icon(self) -> str:
|
|
58
|
+
return NEW_COMMENT_MARK if self.status == "new" else COMMENT_MARK
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Comments:
|
|
62
|
+
"""The comments folded out of a comparison, each behind one placeholder.
|
|
63
|
+
|
|
64
|
+
A comment is identified by its author and text, not by its id: the ids
|
|
65
|
+
are renumbered each time a document is converted, and the same comment
|
|
66
|
+
must compare equal on both sides.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self._by_key: dict[tuple[str, str], str] = {}
|
|
71
|
+
self._items: list[Comment] = []
|
|
72
|
+
|
|
73
|
+
def placeholder(self, author: str, text: str, date: str = "") -> str | None:
|
|
74
|
+
key = (author, text)
|
|
75
|
+
if key not in self._by_key:
|
|
76
|
+
if PUA_FIRST + len(self._items) > PUA_LAST:
|
|
77
|
+
return None # out of placeholders: leave the span as it is
|
|
78
|
+
self._by_key[key] = chr(PUA_FIRST + len(self._items))
|
|
79
|
+
self._items.append(Comment(author, text, date))
|
|
80
|
+
return self._by_key[key]
|
|
81
|
+
|
|
82
|
+
def get(self, placeholder: str) -> Comment:
|
|
83
|
+
return self._items[ord(placeholder) - PUA_FIRST]
|
|
84
|
+
|
|
85
|
+
def __len__(self) -> int:
|
|
86
|
+
return len(self._items)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def match_bracket(s: str, i: int) -> int:
|
|
90
|
+
"""Index of the ']' closing the '[' at s[i], or -1."""
|
|
91
|
+
depth = 0
|
|
92
|
+
j = i
|
|
93
|
+
while j < len(s):
|
|
94
|
+
c = s[j]
|
|
95
|
+
if c == "\\":
|
|
96
|
+
j += 2
|
|
97
|
+
continue
|
|
98
|
+
if c == "[":
|
|
99
|
+
depth += 1
|
|
100
|
+
elif c == "]":
|
|
101
|
+
depth -= 1
|
|
102
|
+
if depth == 0:
|
|
103
|
+
return j
|
|
104
|
+
j += 1
|
|
105
|
+
return -1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def match_attrs(s: str, i: int) -> int:
|
|
109
|
+
"""Index of the '}' closing the attribute block opened at s[i], or -1.
|
|
110
|
+
|
|
111
|
+
The block may span lines, but not a blank line.
|
|
112
|
+
"""
|
|
113
|
+
in_quote = False
|
|
114
|
+
j = i + 1
|
|
115
|
+
while j < len(s):
|
|
116
|
+
c = s[j]
|
|
117
|
+
if c == "\\":
|
|
118
|
+
j += 2
|
|
119
|
+
continue
|
|
120
|
+
if c == '"':
|
|
121
|
+
in_quote = not in_quote
|
|
122
|
+
elif c == "}" and not in_quote:
|
|
123
|
+
return j
|
|
124
|
+
elif c == "\n" and not in_quote and s[j + 1 : j + 2] == "\n":
|
|
125
|
+
return -1
|
|
126
|
+
j += 1
|
|
127
|
+
return -1
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def fold_comments(text: str, comments: Comments, keep_empty: bool = False) -> str:
|
|
131
|
+
"""Replace the pandoc comment spans of a Markdown text with placeholders.
|
|
132
|
+
|
|
133
|
+
A comment without text is left out, unless keep_empty.
|
|
134
|
+
"""
|
|
135
|
+
out = []
|
|
136
|
+
i = 0
|
|
137
|
+
while i < len(text):
|
|
138
|
+
c = text[i]
|
|
139
|
+
if c == "\\":
|
|
140
|
+
out.append(text[i : i + 2])
|
|
141
|
+
i += 2
|
|
142
|
+
continue
|
|
143
|
+
if c == "[":
|
|
144
|
+
close = match_bracket(text, i)
|
|
145
|
+
if close != -1 and text[close + 1 : close + 2] == "{":
|
|
146
|
+
end = match_attrs(text, close + 1)
|
|
147
|
+
attrs = text[close + 1 : end + 1] if end != -1 else ""
|
|
148
|
+
m = COMMENT_CLASS.match(attrs)
|
|
149
|
+
if m:
|
|
150
|
+
if m[1] == "comment-end":
|
|
151
|
+
i = end + 1
|
|
152
|
+
continue
|
|
153
|
+
author = AUTHOR.search(attrs)
|
|
154
|
+
date = DATE.search(attrs)
|
|
155
|
+
# Markdown escapes (\[ \* \_ ...) are not part of the comment
|
|
156
|
+
note = ESCAPE.sub(r"\1", " ".join(text[i + 1 : close].split()))
|
|
157
|
+
if not note and not keep_empty:
|
|
158
|
+
# a comment with no text says nothing: left out
|
|
159
|
+
i = end + 1
|
|
160
|
+
continue
|
|
161
|
+
mark = comments.placeholder(
|
|
162
|
+
author[1] if author else "",
|
|
163
|
+
note,
|
|
164
|
+
f"{date[1]} {date[2]}" if date else "",
|
|
165
|
+
)
|
|
166
|
+
if mark is not None:
|
|
167
|
+
out.append(mark)
|
|
168
|
+
i = end + 1
|
|
169
|
+
continue
|
|
170
|
+
out.append(c)
|
|
171
|
+
i += 1
|
|
172
|
+
return "".join(out)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def plain(text: str) -> str:
|
|
176
|
+
"""Text for a tooltip: each folded comment shown as a speech balloon."""
|
|
177
|
+
return PLACEHOLDER.sub(COMMENT_MARK, text)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
MARKER = Markup(
|
|
181
|
+
'<span class="comment{}" tabindex="0" role="note" data-author="{}" data-date="{}" '
|
|
182
|
+
'data-text="{}" aria-label="{}">{}</span>'
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def show_comments(markup: Markup, comments: Comments, new: frozenset[str] = frozenset()) -> Markup:
|
|
187
|
+
"""Replace the placeholders of a cell with comment markers.
|
|
188
|
+
|
|
189
|
+
The page shows the author, the comment and its date when a marker is
|
|
190
|
+
hovered or focused. The comments in new (placeholders) were added since
|
|
191
|
+
the base and get their own icon. Tooltips never hold placeholders (see
|
|
192
|
+
plain), so every one left in the markup is in the text of the line.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def marker(m: re.Match) -> str:
|
|
196
|
+
c = comments.get(m[0])
|
|
197
|
+
is_new = m[0] in new
|
|
198
|
+
label = ("new " if is_new else "") + c.label
|
|
199
|
+
icon = NEW_COMMENT_MARK if is_new else COMMENT_MARK
|
|
200
|
+
return str(MARKER.format(" new" if is_new else "", c.author, c.date, c.text, label, icon))
|
|
201
|
+
|
|
202
|
+
return Markup(PLACEHOLDER.sub(marker, str(markup)))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def placeholders_in(markup: Markup | str) -> list[str]:
|
|
206
|
+
return PLACEHOLDER.findall(str(markup))
|