Commit 3d2fb0c76b
Verified · cmc
Layout: unified · split
nfogen.py +316 −246
| @@ -9,7 +9,6 @@ from __future__ import annotations | ||
| 9 | 9 | |
| 10 | 10 | import argparse |
| 11 | 11 | import json |
| 12 | import os | |
| 13 | 12 | import subprocess |
| 14 | 13 | import sys |
| 15 | 14 | import textwrap |
| @@ -77,12 +76,12 @@ PANEL_TITLES = ("Release Information", "Game Information") | ||
| 77 | 76 | # jt/jm/jb are the down-tee, cross, and up-tee used where the panel's |
| 78 | 77 | # vertical column divider meets a horizontal rule. |
| 79 | 78 | STYLES = { |
| 80 | "single": dict(tl="┌", tr="┐", bl="└", br="┘", h="─", v="│", | |
| 81 | ls="├", rs="┤", jt="┬", jm="┼", jb="┴"), | |
| 82 | "double": dict(tl="╔", tr="╗", bl="╚", br="╝", h="═", v="║", | |
| 83 | ls="╠", rs="╣", jt="╦", jm="╬", jb="╩"), | |
| 84 | "block": dict(tl="█", tr="█", bl="█", br="█", h="█", v="█", | |
| 85 | ls="█", rs="█", jt="█", jm="█", jb="█"), | |
| 79 | "single": {"tl": "┌", "tr": "┐", "bl": "└", "br": "┘", "h": "─", "v": "│", | |
| 80 | "ls": "├", "rs": "┤", "jt": "┬", "jm": "┼", "jb": "┴"}, | |
| 81 | "double": {"tl": "╔", "tr": "╗", "bl": "╚", "br": "╝", "h": "═", "v": "║", | |
| 82 | "ls": "╠", "rs": "╣", "jt": "╦", "jm": "╬", "jb": "╩"}, | |
| 83 | "block": {"tl": "█", "tr": "█", "bl": "█", "br": "█", "h": "█", "v": "█", | |
| 84 | "ls": "█", "rs": "█", "jt": "█", "jm": "█", "jb": "█"}, | |
| 86 | 85 | } |
| 87 | 86 | |
| 88 | 87 | CONFIG_CANDIDATES = [ |
| @@ -97,7 +96,7 @@ def load_config(explicit: str | None) -> dict: | ||
| 97 | 96 | """Load defaults from a TOML or JSON file. Returns {} if none found.""" |
| 98 | 97 | path = None |
| 99 | 98 | if explicit: |
| 100 | path = Path(explicit) | |
| 99 | path = Path(explicit) # NOSONAR local CLI: the user's own path, no trust boundary | |
| 101 | 100 | if not path.exists(): |
| 102 | 101 | sys.exit(f"nfogen: config not found: {explicit}") |
| 103 | 102 | else: |
| @@ -105,7 +104,7 @@ def load_config(explicit: str | None) -> dict: | ||
| 105 | 104 | if path is None: |
| 106 | 105 | return {} |
| 107 | 106 | |
| 108 | text = path.read_text(encoding="utf-8") | |
| 107 | text = path.read_text(encoding="utf-8") # NOSONAR local CLI: the user's own path | |
| 109 | 108 | if path.suffix == ".json": |
| 110 | 109 | return json.loads(text) |
| 111 | 110 | try: |
| @@ -116,15 +115,18 @@ def load_config(explicit: str | None) -> dict: | ||
| 116 | 115 | |
| 117 | 116 | |
| 118 | 117 | # --- mediainfo import -------------------------------------------------------- |
| 118 | _MI_TYPE = "@type" # mediainfo track-type key | |
| 119 | ||
| 120 | ||
| 119 | 121 | def load_mediainfo(path: str) -> dict: |
| 120 | 122 | """Read a `mediainfo --Output=JSON` dump (or run mediainfo on a media file) |
| 121 | 123 | and return values for the video/audio/resolution/size/runtime/format keys.""" |
| 122 | p = Path(path) | |
| 124 | p = Path(path) # NOSONAR local CLI: the user's own path, no trust boundary | |
| 123 | 125 | if p.suffix.lower() == ".json": |
| 124 | raw = p.read_text(encoding="utf-8") | |
| 126 | raw = p.read_text(encoding="utf-8") # NOSONAR local CLI: the user's own path | |
| 125 | 127 | else: |
| 126 | 128 | try: |
| 127 | proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)], | |
| 129 | proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)], # NOSONAR local CLI: the user's own file | |
| 128 | 130 | capture_output=True, text=True, check=True) |
| 129 | 131 | except FileNotFoundError: |
| 130 | 132 | sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo") |
| @@ -165,49 +167,70 @@ def _mi_duration(s) -> str: | ||
| 165 | 167 | return f"{h}h {m:02d}m" if h else f"{m}m {sec % 60:02d}s" |
| 166 | 168 | |
| 167 | 169 | |
| 168 | def _extract_media(doc: dict) -> dict: | |
| 169 | tracks = (doc.get("media") or {}).get("track") or [] | |
| 170 | g = next((t for t in tracks if t.get("@type") == "General"), {}) | |
| 171 | v = next((t for t in tracks if t.get("@type") == "Video"), {}) | |
| 172 | auds = [t for t in tracks if t.get("@type") == "Audio"] | |
| 173 | txts = [t for t in tracks if t.get("@type") == "Text"] | |
| 174 | a = auds[0] if auds else {} | |
| 175 | channels = {"1": "1.0", "2": "2.0", "6": "5.1", "8": "7.1"} | |
| 176 | out: dict = {} | |
| 170 | def _mi_tracks(tracks: list, kind: str) -> list: | |
| 171 | return [t for t in tracks if t.get(_MI_TYPE) == kind] | |
| 172 | ||
| 177 | 173 | |
| 174 | def _mi_general(g: dict) -> dict: | |
| 175 | out = {} | |
| 178 | 176 | if g.get("Format"): |
| 179 | 177 | out["format"] = g["Format"] |
| 180 | 178 | if g.get("FileSize"): |
| 181 | 179 | out["size"] = _mi_size(g["FileSize"]) |
| 182 | if g.get("Duration") or v.get("Duration"): | |
| 183 | out["runtime"] = _mi_duration(g.get("Duration") or v.get("Duration")) | |
| 184 | ||
| 185 | if v: | |
| 186 | parts = [v.get("Encoded_Library_Name") or v.get("Format")] | |
| 187 | if v.get("BitRate"): | |
| 188 | parts.append(f"{int(float(v['BitRate'])) // 1000} kbps") | |
| 189 | if v.get("FrameRate"): | |
| 190 | parts.append(f"{float(v['FrameRate']):g} fps") | |
| 191 | out["video"] = ", ".join(p for p in parts if p) | |
| 192 | if v.get("Width") and v.get("Height"): | |
| 193 | out["resolution"] = f"{_mi_int(v['Width'])}x{_mi_int(v['Height'])}" | |
| 194 | ||
| 195 | if a: | |
| 196 | parts = [a.get("Format")] | |
| 197 | if a.get("Channels"): | |
| 198 | parts.append(channels.get(str(a["Channels"]), f"{a['Channels']}ch")) | |
| 199 | if a.get("BitRate"): | |
| 200 | parts.append(f"{int(float(a['BitRate'])) // 1000} kbps") | |
| 201 | audio = ", ".join(p for p in parts if p) | |
| 202 | if a.get("Language"): | |
| 203 | audio = f"{audio} ({a['Language']})" if audio else a["Language"] | |
| 204 | out["audio"] = audio | |
| 180 | return out | |
| 205 | 181 | |
| 182 | ||
| 183 | def _mi_video(v: dict) -> dict: | |
| 184 | if not v: | |
| 185 | return {} | |
| 186 | parts = [v.get("Encoded_Library_Name") or v.get("Format")] | |
| 187 | if v.get("BitRate"): | |
| 188 | parts.append(f"{int(float(v['BitRate'])) // 1000} kbps") | |
| 189 | if v.get("FrameRate"): | |
| 190 | parts.append(f"{float(v['FrameRate']):g} fps") | |
| 191 | out = {"video": ", ".join(p for p in parts if p)} | |
| 192 | if v.get("Width") and v.get("Height"): | |
| 193 | out["resolution"] = f"{_mi_int(v['Width'])}x{_mi_int(v['Height'])}" | |
| 194 | return out | |
| 195 | ||
| 196 | ||
| 197 | def _mi_audio(a: dict) -> dict: | |
| 198 | if not a: | |
| 199 | return {} | |
| 200 | channels = {"1": "1.0", "2": "2.0", "6": "5.1", "8": "7.1"} | |
| 201 | parts = [a.get("Format")] | |
| 202 | if a.get("Channels"): | |
| 203 | parts.append(channels.get(str(a["Channels"]), f"{a['Channels']}ch")) | |
| 204 | if a.get("BitRate"): | |
| 205 | parts.append(f"{int(float(a['BitRate'])) // 1000} kbps") | |
| 206 | audio = ", ".join(p for p in parts if p) | |
| 207 | if a.get("Language"): | |
| 208 | audio = f"{audio} ({a['Language']})" if audio else a["Language"] | |
| 209 | return {"audio": audio} | |
| 210 | ||
| 211 | ||
| 212 | def _mi_language(a: dict, txts: list) -> str: | |
| 206 | 213 | subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language"))) |
| 207 | 214 | lang = a.get("Language") or "" |
| 208 | 215 | if subs: |
| 209 | out["language"] = (f"{lang} (subs: {', '.join(subs)})").strip() | |
| 210 | elif lang: | |
| 216 | return f"{lang} (subs: {', '.join(subs)})".strip() | |
| 217 | return lang | |
| 218 | ||
| 219 | ||
| 220 | def _extract_media(doc: dict) -> dict: | |
| 221 | tracks = (doc.get("media") or {}).get("track") or [] | |
| 222 | g = next(iter(_mi_tracks(tracks, "General")), {}) | |
| 223 | v = next(iter(_mi_tracks(tracks, "Video")), {}) | |
| 224 | auds = _mi_tracks(tracks, "Audio") | |
| 225 | txts = _mi_tracks(tracks, "Text") | |
| 226 | a = auds[0] if auds else {} | |
| 227 | ||
| 228 | out = {**_mi_general(g), **_mi_video(v), **_mi_audio(a)} | |
| 229 | dur = g.get("Duration") or v.get("Duration") | |
| 230 | if dur: | |
| 231 | out["runtime"] = _mi_duration(dur) | |
| 232 | lang = _mi_language(a, txts) | |
| 233 | if lang: | |
| 211 | 234 | out["language"] = lang |
| 212 | 235 | |
| 213 | 236 | return {k: val for k, val in out.items() if val} |
| @@ -286,180 +309,219 @@ def banner(text: str) -> str: | ||
| 286 | 309 | return "\n".join(r.rstrip() for r in rows) |
| 287 | 310 | |
| 288 | 311 | |
| 289 | def render(data: dict, group: str, site: str, width: int, | |
| 290 | style: str = DEFAULT_STYLE, presents: bool = False, | |
| 291 | footer: str | None = None, layout: str = DEFAULT_LAYOUT, | |
| 292 | logo: str | None = None, roster: dict | None = None, | |
| 293 | panel_titles: tuple[str, str] = PANEL_TITLES) -> str: | |
| 294 | s = STYLES[style] | |
| 295 | h, v = s["h"], s["v"] | |
| 296 | inner = width - 4 # single-column content width (1 space padding) | |
| 297 | cl = (width - 7) // 2 # left panel content width | |
| 298 | cr = (width - 7) - cl # right panel content width | |
| 299 | rfill = width - cl - 5 # h-run right of the divider in a rule | |
| 300 | ||
| 301 | def hrule(left: str, right: str, joint: str | None = None) -> str: | |
| 312 | class _Canvas: | |
| 313 | """Accumulates boxed lines and holds the geometry for a width and style.""" | |
| 314 | ||
| 315 | def __init__(self, width: int, style: str): | |
| 316 | self.s = STYLES[style] | |
| 317 | self.width = width | |
| 318 | self.inner = width - 4 # single-column content width | |
| 319 | self.cl = (width - 7) // 2 # left panel content width | |
| 320 | self.cr = (width - 7) - self.cl | |
| 321 | self.rfill = width - self.cl - 5 | |
| 322 | self.out: list[str] = [] | |
| 323 | self.prev_panel = False | |
| 324 | ||
| 325 | def add(self, text: str = "") -> None: | |
| 326 | self.out.append(text) | |
| 327 | ||
| 328 | def hrule(self, left: str, right: str, joint: str | None = None) -> str: | |
| 329 | h = self.s["h"] | |
| 302 | 330 | if joint is None: |
| 303 | return left + h * (width - 2) + right | |
| 304 | return left + h * (cl + 2) + joint + h * rfill + right | |
| 331 | return left + h * (self.width - 2) + right | |
| 332 | return left + h * (self.cl + 2) + joint + h * self.rfill + right | |
| 305 | 333 | |
| 306 | def line(content: str = "") -> str: | |
| 307 | return f"{v} " + content.ljust(inner) + f" {v}" | |
| 334 | def line(self, content: str = "") -> str: | |
| 335 | v = self.s["v"] | |
| 336 | return f"{v} " + content.ljust(self.inner) + f" {v}" | |
| 308 | 337 | |
| 309 | def center(content: str) -> str: | |
| 310 | return f"{v} " + content.center(inner) + f" {v}" | |
| 338 | def center(self, content: str) -> str: | |
| 339 | v = self.s["v"] | |
| 340 | return f"{v} " + content.center(self.inner) + f" {v}" | |
| 311 | 341 | |
| 312 | def prow(left: str, right: str, centered: bool = False) -> str: | |
| 342 | def prow(self, left: str, right: str, centered: bool = False) -> str: | |
| 343 | v = self.s["v"] | |
| 313 | 344 | fn = str.center if centered else str.ljust |
| 314 | return (f"{v} " + fn(_clip(left, cl), cl) | |
| 315 | + f" {v} " + fn(_clip(right, cr), cr) + f" {v}") | |
| 345 | return (f"{v} " + fn(_clip(left, self.cl), self.cl) | |
| 346 | + f" {v} " + fn(_clip(right, self.cr), self.cr) + f" {v}") | |
| 347 | ||
| 348 | def sep_before(self) -> str: | |
| 349 | joint = self.s["jb"] if self.prev_panel else None | |
| 350 | self.prev_panel = False | |
| 351 | return self.hrule(self.s["ls"], self.s["rs"], joint) | |
| 352 | ||
| 353 | def section(self, header: str) -> None: | |
| 354 | self.add(self.sep_before()) | |
| 355 | self.add(self.line(f" {header}")) | |
| 356 | self.add(self.line()) | |
| 316 | 357 | |
| 317 | out: list[str] = [] | |
| 358 | def names_block(self, names: list[str]) -> None: | |
| 359 | for wrapped in textwrap.wrap(" ".join(names), self.inner - 4): | |
| 360 | self.add(self.center(wrapped)) | |
| 361 | self.add(self.line()) | |
| 318 | 362 | |
| 319 | # logo art above the box, block-centered to the full width | |
| 320 | if logo: | |
| 321 | art = logo.rstrip("\n").split("\n") | |
| 322 | pad = max((width - max((len(a) for a in art), default=0)) // 2, 0) | |
| 323 | out.extend((" " * pad + a).rstrip() for a in art) | |
| 324 | out.append("") | |
| 363 | def wrapped_body(self, text: str) -> None: | |
| 364 | for para in text.splitlines() or [""]: | |
| 365 | for chunk in (textwrap.wrap(para, self.inner - 4) or [""]): | |
| 366 | self.add(self.line(" " + chunk)) | |
| 325 | 367 | |
| 326 | out.append(hrule(s["tl"], s["tr"])) | |
| 327 | 368 | |
| 328 | # header: spaced title, then group tag + site, optional presents banner | |
| 369 | def _render_logo(cvs: _Canvas, logo: str | None) -> None: | |
| 370 | if not logo: | |
| 371 | return | |
| 372 | art = logo.rstrip("\n").split("\n") | |
| 373 | pad = max((cvs.width - max((len(a) for a in art), default=0)) // 2, 0) | |
| 374 | cvs.out.extend((" " * pad + a).rstrip() for a in art) | |
| 375 | cvs.add("") | |
| 376 | ||
| 377 | ||
| 378 | def _render_header(cvs: _Canvas, data: dict, group: str, site: str, | |
| 379 | presents: bool) -> None: | |
| 329 | 380 | title = str(data.get("title") or "UNTITLED").upper() |
| 330 | 381 | spaced = " ".join(title) |
| 331 | heading = spaced if len(spaced) <= inner else title | |
| 332 | out.append(center(_clip(heading, inner))) | |
| 333 | out.append(center(_clip(f"[ {group} ] {site}", inner))) | |
| 382 | heading = spaced if len(spaced) <= cvs.inner else title | |
| 383 | cvs.add(cvs.center(_clip(heading, cvs.inner))) | |
| 384 | cvs.add(cvs.center(_clip(f"[ {group} ] {site}", cvs.inner))) | |
| 334 | 385 | if presents: |
| 335 | out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner))) | |
| 336 | ||
| 337 | line_fields = [(k, lbl, col) for k, lbl, _, kind, col in FIELDS | |
| 338 | if kind == "line" and k != "title" and data.get(k)] | |
| 339 | prev_panel = False | |
| 340 | ||
| 341 | def sep_before() -> str: | |
| 342 | nonlocal prev_panel | |
| 343 | joint = s["jb"] if prev_panel else None | |
| 344 | prev_panel = False | |
| 345 | return hrule(s["ls"], s["rs"], joint) | |
| 346 | ||
| 347 | # info: two-column panel or single-column rows | |
| 348 | if layout == "panel" and line_fields: | |
| 349 | left = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "L"] | |
| 350 | right = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "R"] | |
| 351 | lw_l = max((len(lbl) for lbl, _ in left), default=0) | |
| 352 | lw_r = max((len(lbl) for lbl, _ in right), default=0) | |
| 353 | ||
| 354 | def cell(pair, lw): | |
| 355 | return f" {pair[0]:<{lw}} : {pair[1]}" if pair else "" | |
| 356 | ||
| 357 | out.append(hrule(s["ls"], s["rs"], s["jt"])) | |
| 358 | out.append(prow(panel_titles[0], panel_titles[1], centered=True)) | |
| 359 | out.append(hrule(s["ls"], s["rs"], s["jm"])) | |
| 360 | for lp, rp in zip_longest(left, right): | |
| 361 | out.append(prow(cell(lp, lw_l), cell(rp, lw_r))) | |
| 362 | prev_panel = True | |
| 363 | elif line_fields: | |
| 364 | out.append(hrule(s["ls"], s["rs"])) | |
| 365 | lw = max(len(lbl) for _, lbl, _ in line_fields) | |
| 366 | avail = inner - (2 + lw + 2) | |
| 367 | for k, lbl, _ in line_fields: | |
| 368 | wrapped = textwrap.wrap(str(data[k]), avail) or [""] | |
| 369 | out.append(line(f" {lbl:<{lw}} {wrapped[0]}")) | |
| 370 | for cont in wrapped[1:]: | |
| 371 | out.append(line(" " * (2 + lw + 2) + cont)) | |
| 372 | ||
| 373 | # notes block | |
| 386 | cvs.add(cvs.center( | |
| 387 | _clip(f"-={{ {group.upper()} proudly presents }}=-", cvs.inner))) | |
| 388 | ||
| 389 | ||
| 390 | def _line_fields(data: dict) -> list: | |
| 391 | return [(k, lbl, col) for k, lbl, _, kind, col in FIELDS | |
| 392 | if kind == "line" and k != "title" and data.get(k)] | |
| 393 | ||
| 394 | ||
| 395 | def _render_panel(cvs: _Canvas, data: dict, fields: list, | |
| 396 | panel_titles: tuple[str, str]) -> None: | |
| 397 | s = cvs.s | |
| 398 | left = [(lbl, str(data[k])) for k, lbl, col in fields if col == "L"] | |
| 399 | right = [(lbl, str(data[k])) for k, lbl, col in fields if col == "R"] | |
| 400 | lw_l = max((len(lbl) for lbl, _ in left), default=0) | |
| 401 | lw_r = max((len(lbl) for lbl, _ in right), default=0) | |
| 402 | ||
| 403 | def cell(pair, lw): | |
| 404 | return f" {pair[0]:<{lw}} : {pair[1]}" if pair else "" | |
| 405 | ||
| 406 | cvs.add(cvs.hrule(s["ls"], s["rs"], s["jt"])) | |
| 407 | cvs.add(cvs.prow(panel_titles[0], panel_titles[1], centered=True)) | |
| 408 | cvs.add(cvs.hrule(s["ls"], s["rs"], s["jm"])) | |
| 409 | for lp, rp in zip_longest(left, right): | |
| 410 | cvs.add(cvs.prow(cell(lp, lw_l), cell(rp, lw_r))) | |
| 411 | cvs.prev_panel = True | |
| 412 | ||
| 413 | ||
| 414 | def _render_rows(cvs: _Canvas, data: dict, fields: list) -> None: | |
| 415 | cvs.add(cvs.hrule(cvs.s["ls"], cvs.s["rs"])) | |
| 416 | lw = max(len(lbl) for _, lbl, _ in fields) | |
| 417 | avail = cvs.inner - (2 + lw + 2) | |
| 418 | for k, lbl, _ in fields: | |
| 419 | wrapped = textwrap.wrap(str(data[k]), avail) or [""] | |
| 420 | cvs.add(cvs.line(f" {lbl:<{lw}} {wrapped[0]}")) | |
| 421 | for cont in wrapped[1:]: | |
| 422 | cvs.add(cvs.line(" " * (2 + lw + 2) + cont)) | |
| 423 | ||
| 424 | ||
| 425 | def _render_info(cvs: _Canvas, data: dict, layout: str, | |
| 426 | panel_titles: tuple[str, str]) -> None: | |
| 427 | fields = _line_fields(data) | |
| 428 | if not fields: | |
| 429 | return | |
| 430 | if layout == "panel": | |
| 431 | _render_panel(cvs, data, fields, panel_titles) | |
| 432 | else: | |
| 433 | _render_rows(cvs, data, fields) | |
| 434 | ||
| 435 | ||
| 436 | def _render_notes(cvs: _Canvas, data: dict) -> None: | |
| 374 | 437 | notes = str(data.get("notes") or "").strip() |
| 375 | if notes: | |
| 376 | out.append(sep_before()) | |
| 377 | out.append(line(" NOTES")) | |
| 378 | out.append(line()) | |
| 379 | for para in notes.splitlines() or [""]: | |
| 380 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): | |
| 381 | out.append(line(" " + wrapped)) | |
| 382 | ||
| 383 | # greets block | |
| 438 | if not notes: | |
| 439 | return | |
| 440 | cvs.section("NOTES") | |
| 441 | cvs.wrapped_body(notes) | |
| 442 | ||
| 443 | ||
| 444 | def _render_greets(cvs: _Canvas, data: dict) -> None: | |
| 384 | 445 | greets = _split_list(data.get("greets") or []) |
| 385 | if greets: | |
| 386 | out.append(sep_before()) | |
| 387 | out.append(line(" GREETS")) | |
| 388 | out.append(line()) | |
| 389 | for wrapped in textwrap.wrap(" ".join(greets), inner - 4): | |
| 390 | out.append(line(" " + wrapped)) | |
| 391 | ||
| 392 | # group roster sections (from a profile): news, members, couriers, | |
| 393 | # boards/affiliates tables, outposts | |
| 394 | r = roster or {} | |
| 446 | if not greets: | |
| 447 | return | |
| 448 | cvs.section("GREETS") | |
| 449 | for wrapped in textwrap.wrap(" ".join(greets), cvs.inner - 4): | |
| 450 | cvs.add(cvs.line(" " + wrapped)) | |
| 395 | 451 | |
| 396 | def section(header: str) -> None: | |
| 397 | out.append(sep_before()) | |
| 398 | out.append(line(f" {header}")) | |
| 399 | out.append(line()) | |
| 400 | ||
| 401 | def names_block(names: list[str]) -> None: | |
| 402 | for wrapped in textwrap.wrap(" ".join(names), inner - 4): | |
| 403 | out.append(center(wrapped)) | |
| 404 | out.append(line()) | |
| 405 | ||
| 406 | def table(header: str, entries: list) -> None: | |
| 407 | section(header) | |
| 408 | if entries and isinstance(entries[0], dict): | |
| 409 | ws = _col_widths(inner - 2, [0.34, 0.24, 0.22, 0.20]) | |
| 410 | keys = ["name", "role", "sysop", "phone"] | |
| 411 | ||
| 412 | def row(vals: list[str]) -> str: | |
| 413 | cells = "".join(_clip(str(x), w - 1).ljust(w) | |
| 414 | for x, w in zip(vals, ws)) | |
| 415 | return line(" " + cells) | |
| 416 | ||
| 417 | out.append(row(["BOARD", "ROLE", "SYSOP", "CONTACT"])) | |
| 418 | for e in entries: | |
| 419 | out.append(row([e.get(k, "") for k in keys])) | |
| 420 | else: | |
| 421 | names_block(_split_list(entries)) | |
| 422 | 452 | |
| 453 | def _render_table(cvs: _Canvas, header: str, entries: list) -> None: | |
| 454 | cvs.section(header) | |
| 455 | if entries and isinstance(entries[0], dict): | |
| 456 | ws = _col_widths(cvs.inner - 2, [0.34, 0.24, 0.22, 0.20]) | |
| 457 | keys = ["name", "role", "sysop", "phone"] | |
| 458 | ||
| 459 | def row(vals): | |
| 460 | cells = "".join(_clip(str(x), w - 1).ljust(w) | |
| 461 | for x, w in zip(vals, ws)) | |
| 462 | return cvs.line(" " + cells) | |
| 463 | ||
| 464 | cvs.add(row(["BOARD", "ROLE", "SYSOP", "CONTACT"])) | |
| 465 | for e in entries: | |
| 466 | cvs.add(row([e.get(k, "") for k in keys])) | |
| 467 | else: | |
| 468 | cvs.names_block(_split_list(entries)) | |
| 469 | ||
| 470 | ||
| 471 | def _render_roster(cvs: _Canvas, roster: dict | None) -> None: | |
| 472 | r = roster or {} | |
| 423 | 473 | news = str(r.get("news") or "").strip() |
| 424 | 474 | if news: |
| 425 | section("GROUP NEWS") | |
| 426 | for para in news.splitlines(): | |
| 427 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): | |
| 428 | out.append(line(" " + wrapped)) | |
| 429 | ||
| 475 | cvs.section("GROUP NEWS") | |
| 476 | cvs.wrapped_body(news) | |
| 430 | 477 | members = _split_list(r.get("members") or []) |
| 431 | 478 | if members: |
| 432 | section("MEMBERS") | |
| 433 | names_block(members) | |
| 434 | ||
| 479 | cvs.section("MEMBERS") | |
| 480 | cvs.names_block(members) | |
| 435 | 481 | couriers = r.get("couriers") or {} |
| 436 | 482 | if couriers: |
| 437 | section("COURIERS") | |
| 483 | cvs.section("COURIERS") | |
| 438 | 484 | for tier, who in couriers.items(): |
| 439 | out.append(center(f"- {tier} -")) | |
| 440 | names_block(_split_list(who)) | |
| 441 | ||
| 485 | cvs.add(cvs.center(f"- {tier} -")) | |
| 486 | cvs.names_block(_split_list(who)) | |
| 442 | 487 | if r.get("boards"): |
| 443 | table("BOARDS", r["boards"]) | |
| 488 | _render_table(cvs, "BOARDS", r["boards"]) | |
| 444 | 489 | if r.get("affiliates"): |
| 445 | table("AFFILIATES", r["affiliates"]) | |
| 446 | ||
| 490 | _render_table(cvs, "AFFILIATES", r["affiliates"]) | |
| 447 | 491 | outposts = _split_list(r.get("outposts") or []) |
| 448 | 492 | if outposts: |
| 449 | section("OUTPOSTS") | |
| 450 | names_block(outposts) | |
| 493 | cvs.section("OUTPOSTS") | |
| 494 | cvs.names_block(outposts) | |
| 495 | ||
| 496 | ||
| 497 | def _render_footer(cvs: _Canvas, footer: str | None) -> None: | |
| 498 | if not footer: | |
| 499 | return | |
| 500 | cvs.add(cvs.sep_before()) | |
| 501 | cvs.add(cvs.line()) | |
| 502 | for para in footer.splitlines(): | |
| 503 | for wrapped in (textwrap.wrap(para, cvs.inner - 4) or [""]): | |
| 504 | cvs.add(cvs.center(wrapped)) | |
| 505 | cvs.add(cvs.line()) | |
| 451 | 506 | |
| 452 | # footer disclaimer, centered | |
| 453 | if footer: | |
| 454 | out.append(sep_before()) | |
| 455 | out.append(line()) | |
| 456 | for para in footer.splitlines(): | |
| 457 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): | |
| 458 | out.append(center(wrapped)) | |
| 459 | out.append(line()) | |
| 460 | 507 | |
| 461 | out.append(hrule(s["bl"], s["br"], s["jb"] if prev_panel else None)) | |
| 462 | return "\n".join(out) + "\n" | |
| 508 | def render(data: dict, group: str, site: str, width: int, | |
| 509 | style: str = DEFAULT_STYLE, presents: bool = False, | |
| 510 | footer: str | None = None, layout: str = DEFAULT_LAYOUT, | |
| 511 | logo: str | None = None, roster: dict | None = None, | |
| 512 | panel_titles: tuple[str, str] = PANEL_TITLES) -> str: | |
| 513 | cvs = _Canvas(width, style) | |
| 514 | _render_logo(cvs, logo) | |
| 515 | cvs.add(cvs.hrule(cvs.s["tl"], cvs.s["tr"])) | |
| 516 | _render_header(cvs, data, group, site, presents) | |
| 517 | _render_info(cvs, data, layout, panel_titles) | |
| 518 | _render_notes(cvs, data) | |
| 519 | _render_greets(cvs, data) | |
| 520 | _render_roster(cvs, roster) | |
| 521 | _render_footer(cvs, footer) | |
| 522 | cvs.add(cvs.hrule(cvs.s["bl"], cvs.s["br"], | |
| 523 | cvs.s["jb"] if cvs.prev_panel else None)) | |
| 524 | return "\n".join(cvs.out) + "\n" | |
| 463 | 525 | |
| 464 | 526 | |
| 465 | 527 | def _clip(text: str, width: int) -> str: |
| @@ -524,18 +586,13 @@ def _list_templates() -> list[str]: | ||
| 524 | 586 | return sorted(p.stem for p in d.glob("*.toml")) if d.is_dir() else [] |
| 525 | 587 | |
| 526 | 588 | |
| 527 | def main(argv: list[str] | None = None) -> int: | |
| 528 | args = build_parser().parse_args(argv) | |
| 589 | def _resolve(flag, config: dict, key: str, default): | |
| 590 | return flag if flag is not None else config.get(key, default) | |
| 529 | 591 | |
| 530 | if args.list_templates: | |
| 531 | names = _list_templates() | |
| 532 | print("\n".join(names) if names else "nfogen: no templates bundled") | |
| 533 | return 0 | |
| 534 | 592 | |
| 593 | def _layered_config(args) -> dict: | |
| 594 | """Merge template < profile < release-config (CLI flags applied later).""" | |
| 535 | 595 | release = load_config(args.config) |
| 536 | ||
| 537 | # layered base: a style template underneath a group profile; the release | |
| 538 | # config overrides both, and CLI flags override everything. | |
| 539 | 596 | template = {} |
| 540 | 597 | if args.template: |
| 541 | 598 | tpath = _templates_dir() / f"{args.template}.toml" |
| @@ -545,83 +602,96 @@ def main(argv: list[str] | None = None) -> int: | ||
| 545 | 602 | template = load_config(str(tpath)) |
| 546 | 603 | profile_path = args.profile or release.get("profile") |
| 547 | 604 | profile = load_config(profile_path) if profile_path else {} |
| 548 | config = {**template, **profile, **release} | |
| 605 | return {**template, **profile, **release} | |
| 606 | ||
| 549 | 607 | |
| 550 | # merge: config defaults, then any CLI flag that was supplied | |
| 608 | def _collect_data(args, config: dict) -> dict: | |
| 551 | 609 | data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""} |
| 552 | 610 | for key, *_ in FIELDS: |
| 553 | 611 | val = getattr(args, key) |
| 554 | 612 | if val is not None: |
| 555 | 613 | data[key] = val |
| 556 | ||
| 557 | # mediainfo fills any media field not already set by config or flags | |
| 558 | 614 | mediainfo_src = args.mediainfo or config.get("mediainfo") |
| 559 | 615 | if mediainfo_src: |
| 560 | 616 | for key, val in load_mediainfo(mediainfo_src).items(): |
| 561 | 617 | data.setdefault(key, val) |
| 562 | ||
| 563 | 618 | data.setdefault("date", date.today().isoformat()) |
| 619 | return data | |
| 564 | 620 | |
| 565 | group = args.group or config.get("group") or DEFAULT_GROUP | |
| 566 | site = args.site or config.get("site") or DEFAULT_SITE | |
| 567 | width = args.width or config.get("width") or DEFAULT_WIDTH | |
| 568 | style = args.style or config.get("style") or DEFAULT_STYLE | |
| 569 | ||
| 570 | def resolve(flag, key, default): | |
| 571 | if flag is not None: | |
| 572 | return flag | |
| 573 | return config.get(key, default) | |
| 574 | 621 | |
| 575 | presents = resolve(args.presents, "presents", False) | |
| 576 | show_footer = resolve(args.footer, "footer", False) | |
| 577 | footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER | |
| 578 | footer = footer_text if show_footer else None | |
| 622 | def _fill_interactive(args, data: dict) -> None: | |
| 623 | interactive = sys.stdin.isatty() and not args.no_input | |
| 624 | if args.interactive: | |
| 625 | prompt_missing(data, force_all=True) | |
| 626 | elif not data.get("title") and interactive: | |
| 627 | prompt_missing(data, force_all=False) | |
| 579 | 628 | |
| 580 | layout = args.layout or config.get("layout") or DEFAULT_LAYOUT | |
| 581 | panel_titles = (config.get("panel_left") or PANEL_TITLES[0], | |
| 582 | config.get("panel_right") or PANEL_TITLES[1]) | |
| 583 | 629 | |
| 584 | # logo: an art file wins; otherwise an optional generated banner | |
| 585 | logo = None | |
| 630 | def _resolve_logo(args, config: dict, group: str) -> str | None: | |
| 631 | """An explicit art file wins; otherwise an optional generated banner.""" | |
| 586 | 632 | logo_path = args.logo or config.get("logo") |
| 587 | 633 | if logo_path: |
| 588 | 634 | enc = args.logo_encoding or config.get("logo_encoding") or "cp437" |
| 589 | 635 | try: |
| 590 | logo = Path(logo_path).read_text(encoding=enc, errors="replace") | |
| 636 | return Path(logo_path).read_text(encoding=enc, errors="replace") # NOSONAR local CLI: the user's own path | |
| 591 | 637 | except FileNotFoundError: |
| 592 | 638 | sys.exit(f"nfogen: logo not found: {logo_path}") |
| 593 | elif resolve(args.banner, "banner", False): | |
| 594 | logo = banner(args.banner_text or config.get("banner_text") or group) | |
| 595 | ||
| 596 | roster = {k: config[k] for k in | |
| 597 | ("news", "members", "couriers", "boards", "affiliates", "outposts") | |
| 598 | if config.get(k)} | |
| 599 | ||
| 600 | interactive = sys.stdin.isatty() and not args.no_input | |
| 601 | if args.interactive: | |
| 602 | prompt_missing(data, force_all=True) | |
| 603 | elif not data.get("title") and interactive: | |
| 604 | prompt_missing(data, force_all=False) | |
| 639 | if _resolve(args.banner, config, "banner", False): | |
| 640 | return banner(args.banner_text or config.get("banner_text") or group) | |
| 641 | return None | |
| 605 | 642 | |
| 606 | if not data.get("title"): | |
| 607 | sys.exit("nfogen: a title is required (use --title, a config, or -i)") | |
| 608 | ||
| 609 | text = render(data, group=group, site=site, width=width, style=style, | |
| 610 | presents=presents, footer=footer, layout=layout, | |
| 611 | logo=logo, roster=roster, panel_titles=panel_titles) | |
| 612 | 643 | |
| 644 | def _resolve_options(args, config: dict) -> dict: | |
| 645 | group = args.group or config.get("group") or DEFAULT_GROUP | |
| 646 | show_footer = _resolve(args.footer, config, "footer", False) | |
| 647 | footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER | |
| 648 | return { | |
| 649 | "group": group, | |
| 650 | "site": args.site or config.get("site") or DEFAULT_SITE, | |
| 651 | "width": args.width or config.get("width") or DEFAULT_WIDTH, | |
| 652 | "style": args.style or config.get("style") or DEFAULT_STYLE, | |
| 653 | "presents": _resolve(args.presents, config, "presents", False), | |
| 654 | "footer": footer_text if show_footer else None, | |
| 655 | "layout": args.layout or config.get("layout") or DEFAULT_LAYOUT, | |
| 656 | "logo": _resolve_logo(args, config, group), | |
| 657 | "roster": {k: config[k] for k in | |
| 658 | ("news", "members", "couriers", "boards", "affiliates", "outposts") | |
| 659 | if config.get(k)}, | |
| 660 | "panel_titles": (config.get("panel_left") or PANEL_TITLES[0], | |
| 661 | config.get("panel_right") or PANEL_TITLES[1]), | |
| 662 | } | |
| 663 | ||
| 664 | ||
| 665 | def _write_output(text: str, args) -> None: | |
| 613 | 666 | if args.encoding == "cp437": |
| 614 | 667 | raw = text.encode("cp437", errors="replace") |
| 615 | 668 | if args.output: |
| 616 | Path(args.output).write_bytes(raw) | |
| 669 | Path(args.output).write_bytes(raw) # NOSONAR local CLI: the user's own path | |
| 617 | 670 | else: |
| 618 | 671 | sys.stdout.buffer.write(raw) |
| 619 | 672 | elif args.output: |
| 620 | Path(args.output).write_text(text, encoding="utf-8") | |
| 673 | Path(args.output).write_text(text, encoding="utf-8") # NOSONAR local CLI: the user's own path | |
| 621 | 674 | else: |
| 622 | 675 | sys.stdout.write(text) |
| 623 | return 0 | |
| 676 | ||
| 677 | ||
| 678 | def main(argv: list[str] | None = None) -> None: | |
| 679 | args = build_parser().parse_args(argv) | |
| 680 | ||
| 681 | if args.list_templates: | |
| 682 | names = _list_templates() | |
| 683 | print("\n".join(names) if names else "nfogen: no templates bundled") | |
| 684 | return | |
| 685 | ||
| 686 | config = _layered_config(args) | |
| 687 | data = _collect_data(args, config) | |
| 688 | _fill_interactive(args, data) | |
| 689 | if not data.get("title"): | |
| 690 | sys.exit("nfogen: a title is required (use --title, a config, or -i)") | |
| 691 | ||
| 692 | text = render(data, **_resolve_options(args, config)) | |
| 693 | _write_output(text, args) | |
| 624 | 694 | |
| 625 | 695 | |
| 626 | 696 | if __name__ == "__main__": |
| 627 | raise SystemExit(main()) | |
| 697 | main() | |