pytex-preprocessor 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.
- pytex/__init__.py +87 -0
- pytex/commands/__init__.py +51 -0
- pytex/commands/biblatex.py +98 -0
- pytex/commands/builtin.py +598 -0
- pytex/commands/captions.py +56 -0
- pytex/commands/cleveref.py +43 -0
- pytex/commands/colors.py +60 -0
- pytex/commands/conditionals.py +62 -0
- pytex/commands/counters.py +85 -0
- pytex/commands/definitions.py +109 -0
- pytex/commands/floats.py +93 -0
- pytex/commands/font.py +138 -0
- pytex/commands/fontawesome.py +88 -0
- pytex/commands/fontspec.py +75 -0
- pytex/commands/geometry.py +25 -0
- pytex/commands/glossaries.py +126 -0
- pytex/commands/graphics.py +68 -0
- pytex/commands/hooks.py +58 -0
- pytex/commands/hyperref.py +57 -0
- pytex/commands/lengths.py +200 -0
- pytex/commands/listings.py +63 -0
- pytex/commands/mdframed.py +43 -0
- pytex/commands/picture.py +32 -0
- pytex/commands/setspace.py +38 -0
- pytex/commands/tables.py +123 -0
- pytex/helpers/__init__.py +3 -0
- pytex/helpers/coerce.py +13 -0
- pytex/helpers/parenting.py +13 -0
- pytex/helpers/sanitize.py +54 -0
- pytex/helpers/with_package.py +61 -0
- pytex/interface/__init__.py +3 -0
- pytex/interface/control_sequence.py +29 -0
- pytex/interface/package.py +52 -0
- pytex/interface/tex.py +41 -0
- pytex/model/__init__.py +25 -0
- pytex/model/color.py +203 -0
- pytex/model/concat.py +31 -0
- pytex/model/control_sequence.py +72 -0
- pytex/model/document.py +120 -0
- pytex/model/document_class.py +29 -0
- pytex/model/empty.py +19 -0
- pytex/model/environment.py +30 -0
- pytex/model/image.py +137 -0
- pytex/model/include.py +21 -0
- pytex/model/length.py +54 -0
- pytex/model/math.py +401 -0
- pytex/model/package.py +132 -0
- pytex/model/raw.py +61 -0
- pytex/packages.py +221 -0
- pytex/registry.py +49 -0
- pytex_builder/__init__.py +8 -0
- pytex_builder/build.py +175 -0
- pytex_builder/console.py +77 -0
- pytex_builder/render.py +90 -0
- pytex_builder/tectonic.py +370 -0
- pytex_hsrtreport/__init__.py +116 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Bold.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-BoldItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Book.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-BookItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Medium.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-MediumItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Strong.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-Thin.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Blender/Blender-ThinItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Black.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Bold.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-BoldItalic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Italic.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Medium.ttf +0 -0
- pytex_hsrtreport/assets/fonts/DIN/DIN-Regular.ttf +0 -0
- pytex_hsrtreport/assets/fonts/Times New Roman.ttf +0 -0
- pytex_hsrtreport/assets/logos/ASTA.svg +79 -0
- pytex_hsrtreport/assets/logos/DUMMY.png +0 -0
- pytex_hsrtreport/assets/logos/DUMMY_FOOT.png +0 -0
- pytex_hsrtreport/assets/logos/ECHO.svg +226 -0
- pytex_hsrtreport/assets/logos/HSRT.pdf +0 -0
- pytex_hsrtreport/assets/logos/INF.pdf +0 -0
- pytex_hsrtreport/assets/logos/STUPA.pdf +0 -0
- pytex_hsrtreport/assets/logos/Skyline.pdf +0 -0
- pytex_hsrtreport/boxes.py +215 -0
- pytex_hsrtreport/citations.py +21 -0
- pytex_hsrtreport/cleveref_names.py +47 -0
- pytex_hsrtreport/colors.py +30 -0
- pytex_hsrtreport/document.py +307 -0
- pytex_hsrtreport/fonts.py +66 -0
- pytex_hsrtreport/glossary.py +61 -0
- pytex_hsrtreport/hyperref_config.py +49 -0
- pytex_hsrtreport/listings.py +90 -0
- pytex_hsrtreport/logos.py +234 -0
- pytex_hsrtreport/pagebreak.py +67 -0
- pytex_hsrtreport/pagesetup.py +33 -0
- pytex_hsrtreport/tex/pagesetup.tex +76 -0
- pytex_hsrtreport/titlepage.py +136 -0
- pytex_hsrtreport/variants.py +24 -0
- pytex_hsrtreport/voting.py +96 -0
- pytex_hsrtreport/watermark.py +63 -0
- pytex_hsrtreport/wordcount.py +33 -0
- pytex_koma/__init__.py +90 -0
- pytex_koma/commands.py +296 -0
- pytex_koma/document.py +138 -0
- pytex_markdown/__init__.py +62 -0
- pytex_markdown/convert.py +271 -0
- pytex_markdown/escape.py +11 -0
- pytex_preprocessor-0.1.0.dist-info/METADATA +82 -0
- pytex_preprocessor-0.1.0.dist-info/RECORD +119 -0
- pytex_preprocessor-0.1.0.dist-info/WHEEL +5 -0
- pytex_preprocessor-0.1.0.dist-info/entry_points.txt +2 -0
- pytex_preprocessor-0.1.0.dist-info/top_level.txt +7 -0
- pytex_protocol/__init__.py +37 -0
- pytex_protocol/convert.py +202 -0
- pytex_protocol/document.py +91 -0
- pytex_protocol/entries.py +96 -0
- pytex_protocol/frontmatter.py +80 -0
- pytex_protocol/header.py +139 -0
- pytex_protocol/shortcodes.py +130 -0
- pytex_protocol/signatures.py +84 -0
- pytex_tikz/__init__.py +25 -0
- pytex_tikz/tikz.py +272 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""A Markdown converter for meeting protocols.
|
|
2
|
+
|
|
3
|
+
Extends the base :class:`pytex_markdown.convert.MarkdownConverter` with:
|
|
4
|
+
|
|
5
|
+
* protocol callouts - ``> [!beschluss]``, ``> [!abstimmung]`` (parsed into a
|
|
6
|
+
vote tally), ``> [!aufgabe]``, ``> [!frist]`` - on top of the inherited
|
|
7
|
+
GitHub/Obsidian callouts;
|
|
8
|
+
* inline ``{{shortcode}}`` expansion in every run of text.
|
|
9
|
+
|
|
10
|
+
Agenda items (TOPs) are plain Markdown headings, so they need no special
|
|
11
|
+
handling - they become numbered sections via the base converter.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import TYPE_CHECKING, Final, cast, override
|
|
18
|
+
|
|
19
|
+
from pytex.model.concat import Concat
|
|
20
|
+
from pytex_markdown.convert import CALLOUT_RE, MarkdownConverter
|
|
21
|
+
|
|
22
|
+
from .entries import ActionItem, Deadline, Decision, Vote
|
|
23
|
+
from .shortcodes import expand_inline_shortcodes
|
|
24
|
+
from .signatures import SignatureLines
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable, Mapping
|
|
28
|
+
|
|
29
|
+
from pytex.interface.tex import TeX
|
|
30
|
+
|
|
31
|
+
from .frontmatter import FrontmatterValue
|
|
32
|
+
|
|
33
|
+
__all__ = ["ProtocolConverter"]
|
|
34
|
+
|
|
35
|
+
# Callout marker -> single-body entry factory.
|
|
36
|
+
_PROTOCOL_CALLOUTS: Final[dict[str, Callable[[TeX | str], TeX]]] = {
|
|
37
|
+
"BESCHLUSS": Decision,
|
|
38
|
+
"DECISION": Decision,
|
|
39
|
+
"AUFGABE": ActionItem,
|
|
40
|
+
"TODO": ActionItem,
|
|
41
|
+
"ACTION": ActionItem,
|
|
42
|
+
"FRIST": Deadline,
|
|
43
|
+
"DEADLINE": Deadline,
|
|
44
|
+
}
|
|
45
|
+
_VOTE_MARKERS: Final[frozenset[str]] = frozenset({"ABSTIMMUNG", "VOTE"})
|
|
46
|
+
_SIGNATURE_MARKERS: Final[frozenset[str]] = frozenset({"UNTERSCHRIFTEN", "SIGNATURES"})
|
|
47
|
+
|
|
48
|
+
_TALLY_RE: Final[dict[str, re.Pattern[str]]] = {
|
|
49
|
+
"yes": re.compile(r"(?:ja|yes)\s*[:=]?\s*(\d+)", re.IGNORECASE),
|
|
50
|
+
"no": re.compile(r"(?:nein|no)\s*[:=]?\s*(\d+)", re.IGNORECASE),
|
|
51
|
+
"abstain": re.compile(r"(?:enthaltung|enth\.?|abstain)\s*[:=]?\s*(\d+)", re.I),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _kind(node: object) -> str:
|
|
56
|
+
return type(node).__name__
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _text(node: object) -> str | None:
|
|
60
|
+
children = getattr(node, "children", None)
|
|
61
|
+
return children if isinstance(children, str) else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _children(node: object) -> list[object]:
|
|
65
|
+
children = getattr(node, "children", None)
|
|
66
|
+
return cast("list[object]", children) if isinstance(children, list) else []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _all_text(node: object) -> str:
|
|
70
|
+
"""Concatenate every literal text fragment beneath `node`."""
|
|
71
|
+
text = _text(node)
|
|
72
|
+
if text is not None:
|
|
73
|
+
return text
|
|
74
|
+
return "".join(_all_text(c) for c in _children(node))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _tally(text: str, key: str) -> int:
|
|
78
|
+
match = _TALLY_RE[key].search(text)
|
|
79
|
+
return int(match.group(1)) if match else 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _leaf_texts(node: object) -> list[str]:
|
|
83
|
+
"""Every leaf text fragment beneath `node`, in order (one per source line)."""
|
|
84
|
+
text = _text(node)
|
|
85
|
+
if text is not None:
|
|
86
|
+
return [text]
|
|
87
|
+
out: list[str] = []
|
|
88
|
+
for child in _children(node):
|
|
89
|
+
out.extend(_leaf_texts(child))
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ProtocolConverter(MarkdownConverter):
|
|
94
|
+
"""`MarkdownConverter` with protocol callouts and inline shortcodes."""
|
|
95
|
+
|
|
96
|
+
meta: Mapping[str, FrontmatterValue]
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
meta: Mapping[str, FrontmatterValue] | None = None,
|
|
102
|
+
base_level: int = 0,
|
|
103
|
+
callouts: bool = True,
|
|
104
|
+
) -> None:
|
|
105
|
+
super().__init__(base_level=base_level, callouts=callouts)
|
|
106
|
+
self.meta = meta or {}
|
|
107
|
+
|
|
108
|
+
# -- inline: splice {{shortcodes}} into otherwise-plain text ----------
|
|
109
|
+
|
|
110
|
+
@override
|
|
111
|
+
def inline(self, node: object) -> TeX:
|
|
112
|
+
if _kind(node) != "CodeSpan":
|
|
113
|
+
text = _text(node)
|
|
114
|
+
if text is not None and "{{" in text:
|
|
115
|
+
return expand_inline_shortcodes(text, self.meta)
|
|
116
|
+
return super().inline(node)
|
|
117
|
+
|
|
118
|
+
# -- blocks: protocol callouts ----------------------------------------
|
|
119
|
+
|
|
120
|
+
@override
|
|
121
|
+
def _as_callout(self, kids: list[object]) -> TeX | None:
|
|
122
|
+
marker = self._protocol_marker(kids)
|
|
123
|
+
if marker is None:
|
|
124
|
+
return super()._as_callout(kids)
|
|
125
|
+
name, title, head_rest, rest_blocks = marker
|
|
126
|
+
if name in _VOTE_MARKERS:
|
|
127
|
+
return self._vote_callout(title, kids)
|
|
128
|
+
if name in _SIGNATURE_MARKERS:
|
|
129
|
+
return self._signature_callout(kids)
|
|
130
|
+
factory = _PROTOCOL_CALLOUTS.get(name)
|
|
131
|
+
if factory is None:
|
|
132
|
+
return super()._as_callout(kids)
|
|
133
|
+
# First paragraph: marker title + its remaining inlines; then any
|
|
134
|
+
# following blocks of the callout.
|
|
135
|
+
head = Concat(self.inline_text(title), *(self.inline(c) for c in head_rest))
|
|
136
|
+
body_parts = [head, *(self.block(b) for b in rest_blocks)]
|
|
137
|
+
return factory(Concat(*_join(body_parts)))
|
|
138
|
+
|
|
139
|
+
def _protocol_marker(
|
|
140
|
+
self, kids: list[object]
|
|
141
|
+
) -> tuple[str, str, list[object], list[object]] | None:
|
|
142
|
+
"""Return (MARKER, title-after-marker, rest-of-first-paragraph,
|
|
143
|
+
following-blocks) if kids open a protocol callout we own, else None."""
|
|
144
|
+
if not kids or _kind(kids[0]) != "Paragraph":
|
|
145
|
+
return None
|
|
146
|
+
inner = _children(kids[0])
|
|
147
|
+
first = _text(inner[0]) if inner else None
|
|
148
|
+
if first is None:
|
|
149
|
+
return None
|
|
150
|
+
match = CALLOUT_RE.match(first)
|
|
151
|
+
if match is None:
|
|
152
|
+
return None
|
|
153
|
+
name = match.group(1).upper()
|
|
154
|
+
if (
|
|
155
|
+
name not in _PROTOCOL_CALLOUTS
|
|
156
|
+
and name not in _VOTE_MARKERS
|
|
157
|
+
and name not in _SIGNATURE_MARKERS
|
|
158
|
+
):
|
|
159
|
+
return None
|
|
160
|
+
title = first[match.end() :].strip()
|
|
161
|
+
return name, title, inner[1:], kids[1:]
|
|
162
|
+
|
|
163
|
+
def _signature_callout(self, kids: list[object]) -> TeX:
|
|
164
|
+
# Each line "Rolle: Name" becomes one signer; the marker is stripped
|
|
165
|
+
# from the first line.
|
|
166
|
+
lines = [frag for k in kids for frag in _leaf_texts(k)]
|
|
167
|
+
if lines:
|
|
168
|
+
lines[0] = CALLOUT_RE.sub("", lines[0], count=1)
|
|
169
|
+
signers: list[tuple[str, str]] = []
|
|
170
|
+
for line in (entry.strip() for entry in lines):
|
|
171
|
+
if not line:
|
|
172
|
+
continue
|
|
173
|
+
role, _, person = line.partition(":")
|
|
174
|
+
signers.append((role.strip(), person.strip()))
|
|
175
|
+
return SignatureLines(*signers)
|
|
176
|
+
|
|
177
|
+
def _vote_callout(self, title: str, kids: list[object]) -> TeX:
|
|
178
|
+
text = " ".join(_all_text(k) for k in kids)
|
|
179
|
+
return Vote(
|
|
180
|
+
yes=_tally(text, "yes"),
|
|
181
|
+
no=_tally(text, "no"),
|
|
182
|
+
abstain=_tally(text, "abstain"),
|
|
183
|
+
body=self.inline_text(title) if title else "",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def inline_text(self, text: str) -> TeX:
|
|
187
|
+
"""Expand `{{shortcodes}}` and escape the rest of a bare string."""
|
|
188
|
+
return expand_inline_shortcodes(text, self.meta)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _join(blocks: list[TeX]) -> list[TeX]:
|
|
192
|
+
from pytex.model.empty import Empty
|
|
193
|
+
from pytex.model.raw import Raw
|
|
194
|
+
|
|
195
|
+
parbreak = Raw("\n\n")
|
|
196
|
+
kept = [b for b in blocks if b is not Empty]
|
|
197
|
+
out: list[TeX] = []
|
|
198
|
+
for i, b in enumerate(kept):
|
|
199
|
+
if i:
|
|
200
|
+
out.append(parbreak)
|
|
201
|
+
out.append(b)
|
|
202
|
+
return out
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Render a STUPA/AStA meeting protocol from Obsidian-flavoured Markdown.
|
|
2
|
+
|
|
3
|
+
The protocol is an :class:`~pytex_hsrtreport.document.HSRTReport` configured for
|
|
4
|
+
a short minutes document: no title page, a compact header block (built from the
|
|
5
|
+
frontmatter) at the top, agenda items as numbered sections, and protocol
|
|
6
|
+
entries (decisions, votes, action items) rendered as HSRT callout boxes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import marko
|
|
15
|
+
|
|
16
|
+
from pytex.model.concat import Concat
|
|
17
|
+
from pytex.model.raw import Raw
|
|
18
|
+
from pytex.registry import Registry
|
|
19
|
+
from pytex_hsrtreport.document import HSRTReport
|
|
20
|
+
from pytex_hsrtreport.variants import Variant
|
|
21
|
+
|
|
22
|
+
from .convert import ProtocolConverter
|
|
23
|
+
from .frontmatter import FrontmatterValue, split_frontmatter
|
|
24
|
+
from .header import header_from_meta
|
|
25
|
+
from .signatures import signature_block_from_meta
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pytex.interface.tex import TeX
|
|
29
|
+
|
|
30
|
+
__all__ = ["IncludeProtocol", "Protocol", "render_protocol"]
|
|
31
|
+
|
|
32
|
+
_PARSER = marko.Markdown()
|
|
33
|
+
|
|
34
|
+
_VARIANTS: dict[str, Variant] = {
|
|
35
|
+
"stupa": Variant.STUPA,
|
|
36
|
+
"asta": Variant.ASTA,
|
|
37
|
+
"echo": Variant.ECHO,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _variant(meta: dict[str, FrontmatterValue]) -> Variant:
|
|
42
|
+
gremium = meta.get("gremium", "")
|
|
43
|
+
key = gremium.lower().strip() if isinstance(gremium, str) else ""
|
|
44
|
+
return _VARIANTS.get(key, Variant.STUPA)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _document_title(meta: dict[str, FrontmatterValue]) -> str:
|
|
48
|
+
gremium = meta.get("gremium")
|
|
49
|
+
datum = meta.get("datum")
|
|
50
|
+
head = (
|
|
51
|
+
f"{gremium} — Protokoll"
|
|
52
|
+
if isinstance(gremium, str) and gremium
|
|
53
|
+
else "Protokoll"
|
|
54
|
+
)
|
|
55
|
+
return f"{head} ({datum})" if isinstance(datum, str) and datum else head
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_protocol(text: str, *, base_level: int = 0) -> HSRTReport:
|
|
59
|
+
"""Build an `HSRTReport` from the Markdown source of a protocol."""
|
|
60
|
+
meta, body_md = split_frontmatter(text)
|
|
61
|
+
converter = ProtocolConverter(meta=meta, base_level=base_level)
|
|
62
|
+
converted = converter.block(_PARSER.parse(body_md))
|
|
63
|
+
header = header_from_meta(meta)
|
|
64
|
+
# Optional sign-off block, appended when the frontmatter lists `unterschriften`.
|
|
65
|
+
signatures = signature_block_from_meta(meta)
|
|
66
|
+
tail = (Raw("\n\n"), signatures) if signatures is not None else ()
|
|
67
|
+
return HSRTReport(
|
|
68
|
+
variant=_variant(meta),
|
|
69
|
+
document_class="scrbook",
|
|
70
|
+
show_titlepage=False,
|
|
71
|
+
show_toc=False,
|
|
72
|
+
show_footer_logos=True,
|
|
73
|
+
title=_document_title(meta),
|
|
74
|
+
# Agenda items are top-level `#` headings -> \section. In scrbook a
|
|
75
|
+
# chapterless section would number as "0.1"; flatten it to a plain
|
|
76
|
+
# arabic counter so TOPs read 1, 2, 3, ...
|
|
77
|
+
user_preamble=Raw(r"\renewcommand*{\thesection}{\arabic{section}}"),
|
|
78
|
+
body=Concat(header, Raw("\n\n"), converted, *tail),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@Registry.add
|
|
83
|
+
def Protocol(text: str, *, base_level: int = 0) -> TeX:
|
|
84
|
+
"""Convert a protocol Markdown string to a renderable `HSRTReport`."""
|
|
85
|
+
return render_protocol(text, base_level=base_level)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@Registry.add
|
|
89
|
+
def IncludeProtocol(path: str | Path, *, encoding: str = "utf-8") -> TeX:
|
|
90
|
+
"""Read a protocol Markdown file and render it (see :func:`render_protocol`)."""
|
|
91
|
+
return render_protocol(Path(path).read_text(encoding=encoding))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Protocol entry factories, styled to match the HSRTReport callout language.
|
|
2
|
+
|
|
3
|
+
Each entry is a coloured box (or, for votes, the existing ``VotingResults``
|
|
4
|
+
tally) so a protocol reads with the same visual vocabulary as a report. Labels
|
|
5
|
+
are German, matching the STUPA/AStA context.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from pytex.commands.builtin import Textbf, Textit
|
|
13
|
+
from pytex.commands.colors import Textcolor
|
|
14
|
+
from pytex.commands.fontawesome import FaIcon
|
|
15
|
+
from pytex.model.concat import Concat
|
|
16
|
+
from pytex.registry import Registry
|
|
17
|
+
from pytex_hsrtreport.boxes import ColoredBox
|
|
18
|
+
from pytex_hsrtreport.voting import VotingResults
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pytex.interface.tex import TeX
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ActionItem",
|
|
25
|
+
"Deadline",
|
|
26
|
+
"Decision",
|
|
27
|
+
"Timestamp",
|
|
28
|
+
"Vote",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _labelled_box(
|
|
33
|
+
label: str,
|
|
34
|
+
body: TeX | str,
|
|
35
|
+
*,
|
|
36
|
+
icon: str,
|
|
37
|
+
color: str,
|
|
38
|
+
) -> TeX:
|
|
39
|
+
"""A ColoredBox whose body is prefixed with a bold label, matching _preset."""
|
|
40
|
+
return ColoredBox(
|
|
41
|
+
body=Concat(Textbf(f"{label}: "), body),
|
|
42
|
+
icon=FaIcon(icon),
|
|
43
|
+
icon_color=color,
|
|
44
|
+
icon_size="24pt",
|
|
45
|
+
icon_offset_x="1.5pt",
|
|
46
|
+
background_color=color,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@Registry.add
|
|
51
|
+
def Decision(body: TeX | str) -> TeX:
|
|
52
|
+
"""A resolution/`Beschluss` box (gavel, HSRT green)."""
|
|
53
|
+
return _labelled_box("Beschluss", body, icon="gavel", color="britishracinggreen")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@Registry.add
|
|
57
|
+
def Deadline(body: TeX | str, due: str | None = None) -> TeX:
|
|
58
|
+
"""A `Frist` (deadline) box. `due` is appended in parentheses when given."""
|
|
59
|
+
content = Concat(body, Textit(f" (bis {due})")) if due else body
|
|
60
|
+
return _labelled_box("Frist", content, icon="hourglass-half", color="orange")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@Registry.add
|
|
64
|
+
def ActionItem(
|
|
65
|
+
body: TeX | str,
|
|
66
|
+
who: str | None = None,
|
|
67
|
+
due: str | None = None,
|
|
68
|
+
) -> TeX:
|
|
69
|
+
"""An `Aufgabe` (action item) box with optional assignee and due date."""
|
|
70
|
+
meta = ", ".join(
|
|
71
|
+
part
|
|
72
|
+
for part in (
|
|
73
|
+
f"Zuständig: {who}" if who else "",
|
|
74
|
+
f"Frist: {due}" if due else "",
|
|
75
|
+
)
|
|
76
|
+
if part
|
|
77
|
+
)
|
|
78
|
+
content = Concat(body, Textit(f" ({meta})")) if meta else body
|
|
79
|
+
return _labelled_box("Aufgabe", content, icon="clipboard-check", color="navyblue")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@Registry.add
|
|
83
|
+
def Vote(
|
|
84
|
+
yes: int,
|
|
85
|
+
no: int,
|
|
86
|
+
abstain: int = 0,
|
|
87
|
+
body: TeX | str = "",
|
|
88
|
+
) -> TeX:
|
|
89
|
+
"""A voting tally, reusing the report's `VotingResults` box."""
|
|
90
|
+
return VotingResults(yes=yes, no=no, abstain=abstain, body=body)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@Registry.add
|
|
94
|
+
def Timestamp(time: str) -> TeX:
|
|
95
|
+
"""Inline timestamp: a clock glyph plus the time, in HSRT blue."""
|
|
96
|
+
return Textcolor("hanblue", Concat(FaIcon("clock"), " ", Textbf(time)))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Minimal YAML-frontmatter splitter for protocol Markdown files.
|
|
2
|
+
|
|
3
|
+
Obsidian writes a ``---`` fenced YAML block at the top of a note. We only need
|
|
4
|
+
a tiny subset - scalars, inline flow lists (``[a, b]``) and block lists
|
|
5
|
+
(``- item``) - so this is parsed without a YAML dependency.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = ["FrontmatterValue", "split_frontmatter"]
|
|
11
|
+
|
|
12
|
+
type FrontmatterValue = str | list[str]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _strip_quotes(text: str) -> str:
|
|
16
|
+
text = text.strip()
|
|
17
|
+
if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]:
|
|
18
|
+
return text[1:-1]
|
|
19
|
+
return text
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_flow_list(text: str) -> list[str]:
|
|
23
|
+
"""Parse an inline ``[a, b, c]`` list into stripped, unquoted items."""
|
|
24
|
+
inner = text.strip()[1:-1]
|
|
25
|
+
return [_strip_quotes(item) for item in inner.split(",") if item.strip()]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_block(lines: list[str]) -> dict[str, FrontmatterValue]:
|
|
29
|
+
meta: dict[str, FrontmatterValue] = {}
|
|
30
|
+
key: str | None = None
|
|
31
|
+
for raw in lines:
|
|
32
|
+
line = raw.rstrip()
|
|
33
|
+
if not line.strip():
|
|
34
|
+
continue
|
|
35
|
+
# A block-list continuation belongs to the most recent key.
|
|
36
|
+
if line.lstrip().startswith("- ") and key is not None:
|
|
37
|
+
item = _strip_quotes(line.lstrip()[2:])
|
|
38
|
+
existing = meta.get(key)
|
|
39
|
+
if isinstance(existing, list):
|
|
40
|
+
existing.append(item)
|
|
41
|
+
else:
|
|
42
|
+
meta[key] = [item]
|
|
43
|
+
continue
|
|
44
|
+
if ":" not in line:
|
|
45
|
+
continue
|
|
46
|
+
key, _, value = line.partition(":")
|
|
47
|
+
key = key.strip()
|
|
48
|
+
value = value.strip()
|
|
49
|
+
if not value:
|
|
50
|
+
# Either a block list follows, or an empty scalar.
|
|
51
|
+
meta[key] = ""
|
|
52
|
+
elif value.startswith("[") and value.endswith("]"):
|
|
53
|
+
meta[key] = _parse_flow_list(value)
|
|
54
|
+
else:
|
|
55
|
+
meta[key] = _strip_quotes(value)
|
|
56
|
+
return meta
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def split_frontmatter(text: str) -> tuple[dict[str, FrontmatterValue], str]:
|
|
60
|
+
"""Split ``text`` into (frontmatter mapping, remaining body).
|
|
61
|
+
|
|
62
|
+
Returns an empty mapping and the unchanged text when no ``---`` fence opens
|
|
63
|
+
the document.
|
|
64
|
+
"""
|
|
65
|
+
if not text.lstrip().startswith("---"):
|
|
66
|
+
return {}, text
|
|
67
|
+
# Normalise to make the leading fence easy to consume.
|
|
68
|
+
stripped = text.lstrip("\n")
|
|
69
|
+
lines = stripped.splitlines()
|
|
70
|
+
if not lines or lines[0].strip() != "---":
|
|
71
|
+
return {}, text
|
|
72
|
+
end = next(
|
|
73
|
+
(i for i in range(1, len(lines)) if lines[i].strip() == "---"),
|
|
74
|
+
None,
|
|
75
|
+
)
|
|
76
|
+
if end is None:
|
|
77
|
+
return {}, text
|
|
78
|
+
meta = _parse_block(lines[1:end])
|
|
79
|
+
body = "\n".join(lines[end + 1 :]).lstrip("\n")
|
|
80
|
+
return meta, body
|
pytex_protocol/header.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""The compact protocol header block rendered at the top of a protocol.
|
|
2
|
+
|
|
3
|
+
Maps the parsed frontmatter to a single HSRT-styled box: meeting body + date
|
|
4
|
+
on top, then the organisational fields and the attendance lists. German labels
|
|
5
|
+
throughout (STUPA/AStA context).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING, override
|
|
12
|
+
|
|
13
|
+
from pytex.commands.builtin import Textbf
|
|
14
|
+
from pytex.commands.fontawesome import FaIcon
|
|
15
|
+
from pytex.helpers.sanitize import escape_latex
|
|
16
|
+
from pytex.interface.tex import TeX
|
|
17
|
+
from pytex.model.concat import Concat
|
|
18
|
+
from pytex.model.raw import Raw
|
|
19
|
+
from pytex.registry import Registry
|
|
20
|
+
from pytex_hsrtreport.boxes import ColoredBox
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Iterator, Mapping
|
|
24
|
+
|
|
25
|
+
from .frontmatter import FrontmatterValue
|
|
26
|
+
|
|
27
|
+
__all__ = ["ProtocolHeader", "header_from_meta"]
|
|
28
|
+
|
|
29
|
+
# Frontmatter key -> human label for the single-value organisational fields.
|
|
30
|
+
_FIELD_LABELS: tuple[tuple[str, str], ...] = (
|
|
31
|
+
("ort", "Ort"),
|
|
32
|
+
("sitzungsleitung", "Sitzungsleitung"),
|
|
33
|
+
("protokoll", "Protokoll"),
|
|
34
|
+
)
|
|
35
|
+
# Frontmatter key -> label for the attendance lists (rendered with a count).
|
|
36
|
+
_LIST_LABELS: tuple[tuple[str, str], ...] = (
|
|
37
|
+
("anwesend", "Anwesend"),
|
|
38
|
+
("entschuldigt", "Entschuldigt"),
|
|
39
|
+
("abwesend", "Abwesend"),
|
|
40
|
+
("gaeste", "Gäste"),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _as_list(value: FrontmatterValue | None) -> list[str]:
|
|
45
|
+
if value is None:
|
|
46
|
+
return []
|
|
47
|
+
if isinstance(value, list):
|
|
48
|
+
return value
|
|
49
|
+
return [v.strip() for v in value.split(",") if v.strip()]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _line(label: str, value: str) -> TeX:
|
|
53
|
+
return Concat(Textbf(f"{label}: "), Raw(escape_latex(value)))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@Registry.add
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ProtocolHeader(TeX):
|
|
59
|
+
"""A compact meeting-header box: title, date/time, fields, attendance."""
|
|
60
|
+
|
|
61
|
+
gremium: str = ""
|
|
62
|
+
datum: str = ""
|
|
63
|
+
beginn: str = ""
|
|
64
|
+
ende: str = ""
|
|
65
|
+
fields: Mapping[str, str] = field(default_factory=dict[str, str])
|
|
66
|
+
attendance: Mapping[str, list[str]] = field(default_factory=dict[str, list[str]])
|
|
67
|
+
_parent: TeX | None = field(default=None, init=False, compare=False, repr=False)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def title(self) -> str:
|
|
71
|
+
body = self.gremium.strip()
|
|
72
|
+
return f"{body} — Protokoll" if body else "Protokoll"
|
|
73
|
+
|
|
74
|
+
def _datetime_line(self) -> str | None:
|
|
75
|
+
when = " – ".join(t for t in (self.beginn, self.ende) if t) # noqa: RUF001
|
|
76
|
+
parts = ", ".join(p for p in (self.datum, when) if p)
|
|
77
|
+
return parts or None
|
|
78
|
+
|
|
79
|
+
def _lines(self) -> Iterator[TeX]:
|
|
80
|
+
yield Textbf(Raw(escape_latex(self.title)))
|
|
81
|
+
dt = self._datetime_line()
|
|
82
|
+
if dt:
|
|
83
|
+
yield _line("Datum", dt)
|
|
84
|
+
for key, label in _FIELD_LABELS:
|
|
85
|
+
value = self.fields.get(key, "")
|
|
86
|
+
if value:
|
|
87
|
+
yield _line(label, value)
|
|
88
|
+
for key, label in _LIST_LABELS:
|
|
89
|
+
people = self.attendance.get(key) or []
|
|
90
|
+
if people:
|
|
91
|
+
yield _line(f"{label} ({len(people)})", ", ".join(people))
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
@override
|
|
95
|
+
def rendered(self) -> str:
|
|
96
|
+
body = Concat(*_intersperse(self._lines(), Raw(r"\\")))
|
|
97
|
+
return ColoredBox(
|
|
98
|
+
body=body,
|
|
99
|
+
icon=FaIcon("users"),
|
|
100
|
+
icon_color="hanblue",
|
|
101
|
+
icon_size="26pt",
|
|
102
|
+
background_color="hanblue",
|
|
103
|
+
).rendered
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _intersperse(items: Iterator[TeX], sep: TeX) -> Iterator[TeX]:
|
|
107
|
+
for i, item in enumerate(items):
|
|
108
|
+
if i:
|
|
109
|
+
yield sep
|
|
110
|
+
yield item
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def header_from_meta(meta: Mapping[str, FrontmatterValue]) -> ProtocolHeader:
|
|
114
|
+
"""Build a `ProtocolHeader` from parsed frontmatter (German keys, aliases)."""
|
|
115
|
+
|
|
116
|
+
def scalar(*keys: str) -> str:
|
|
117
|
+
for key in keys:
|
|
118
|
+
value = meta.get(key)
|
|
119
|
+
if isinstance(value, str) and value:
|
|
120
|
+
return value
|
|
121
|
+
return ""
|
|
122
|
+
|
|
123
|
+
fields = {key: scalar(key) for key, _ in _FIELD_LABELS if scalar(key)}
|
|
124
|
+
attendance = {
|
|
125
|
+
key: _as_list(meta.get(key))
|
|
126
|
+
for key, _ in _LIST_LABELS
|
|
127
|
+
if _as_list(meta.get(key))
|
|
128
|
+
}
|
|
129
|
+
# `gäste` is the natural German spelling; accept it as an alias for `gaeste`.
|
|
130
|
+
if "gaeste" not in attendance and _as_list(meta.get("gäste")):
|
|
131
|
+
attendance["gaeste"] = _as_list(meta.get("gäste"))
|
|
132
|
+
return ProtocolHeader(
|
|
133
|
+
gremium=scalar("gremium", "gremium"),
|
|
134
|
+
datum=scalar("datum", "date"),
|
|
135
|
+
beginn=scalar("beginn", "start"),
|
|
136
|
+
ende=scalar("ende", "end"),
|
|
137
|
+
fields=fields,
|
|
138
|
+
attendance=attendance,
|
|
139
|
+
)
|