Commit 3d2fb0c76b
Verified · cmc
Layout: unified · split
nfogen.py +316 −246
| @@ -9,7 +9,6 @@ from __future__ import annotations | |||
| 9 | 9 | ||
| 10 | import argparse | 10 | import argparse |
| 11 | import json | 11 | import json |
| 12 | import os | ||
| 13 | import subprocess | 12 | import subprocess |
| 14 | import sys | 13 | import sys |
| 15 | import textwrap | 14 | import textwrap |
| @@ -77,12 +76,12 @@ PANEL_TITLES = ("Release Information", "Game Information") | |||
| 77 | # jt/jm/jb are the down-tee, cross, and up-tee used where the panel's | 76 | # jt/jm/jb are the down-tee, cross, and up-tee used where the panel's |
| 78 | # vertical column divider meets a horizontal rule. | 77 | # vertical column divider meets a horizontal rule. |
| 79 | STYLES = { | 78 | STYLES = { |
| 80 | "single": dict(tl="┌", tr="┐", bl="└", br="┘", h="─", v="│", | 79 | "single": {"tl": "┌", "tr": "┐", "bl": "└", "br": "┘", "h": "─", "v": "│", |
| 81 | ls="├", rs="┤", jt="┬", jm="┼", jb="┴"), | 80 | "ls": "├", "rs": "┤", "jt": "┬", "jm": "┼", "jb": "┴"}, |
| 82 | "double": dict(tl="╔", tr="╗", bl="╚", br="╝", h="═", v="║", | 81 | "double": {"tl": "╔", "tr": "╗", "bl": "╚", "br": "╝", "h": "═", "v": "║", |
| 83 | ls="╠", rs="╣", jt="╦", jm="╬", jb="╩"), | 82 | "ls": "╠", "rs": "╣", "jt": "╦", "jm": "╬", "jb": "╩"}, |
| 84 | "block": dict(tl="█", tr="█", bl="█", br="█", h="█", v="█", | 83 | "block": {"tl": "█", "tr": "█", "bl": "█", "br": "█", "h": "█", "v": "█", |
| 85 | ls="█", rs="█", jt="█", jm="█", jb="█"), | 84 | "ls": "█", "rs": "█", "jt": "█", "jm": "█", "jb": "█"}, |
| 86 | } | 85 | } |
| 87 | 86 | ||
| 88 | CONFIG_CANDIDATES = [ | 87 | CONFIG_CANDIDATES = [ |
| @@ -97,7 +96,7 @@ def load_config(explicit: str | None) -> dict: | |||
| 97 | """Load defaults from a TOML or JSON file. Returns {} if none found.""" | 96 | """Load defaults from a TOML or JSON file. Returns {} if none found.""" |
| 98 | path = None | 97 | path = None |
| 99 | if explicit: | 98 | if explicit: |
| 100 | path = Path(explicit) | 99 | path = Path(explicit) # NOSONAR local CLI: the user's own path, no trust boundary |
| 101 | if not path.exists(): | 100 | if not path.exists(): |
| 102 | sys.exit(f"nfogen: config not found: {explicit}") | 101 | sys.exit(f"nfogen: config not found: {explicit}") |
| 103 | else: | 102 | else: |
| @@ -105,7 +104,7 @@ def load_config(explicit: str | None) -> dict: | |||
| 105 | if path is None: | 104 | if path is None: |
| 106 | return {} | 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 | if path.suffix == ".json": | 108 | if path.suffix == ".json": |
| 110 | return json.loads(text) | 109 | return json.loads(text) |
| 111 | try: | 110 | try: |
| @@ -116,15 +115,18 @@ def load_config(explicit: str | None) -> dict: | |||
| 116 | 115 | ||
| 117 | 116 | ||
| 118 | # --- mediainfo import -------------------------------------------------------- | 117 | # --- mediainfo import -------------------------------------------------------- |
| 118 | _MI_TYPE = "@type" # mediainfo track-type key | ||
| 119 | |||
| 120 | |||
| 119 | def load_mediainfo(path: str) -> dict: | 121 | def load_mediainfo(path: str) -> dict: |
| 120 | """Read a `mediainfo --Output=JSON` dump (or run mediainfo on a media file) | 122 | """Read a `mediainfo --Output=JSON` dump (or run mediainfo on a media file) |
| 121 | and return values for the video/audio/resolution/size/runtime/format keys.""" | 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 | if p.suffix.lower() == ".json": | 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 | else: | 127 | else: |
| 126 | try: | 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 | capture_output=True, text=True, check=True) | 130 | capture_output=True, text=True, check=True) |
| 129 | except FileNotFoundError: | 131 | except FileNotFoundError: |
| 130 | sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo") | 132 | sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo") |
| @@ -165,49 +167,70 @@ def _mi_duration(s) -> str: | |||
| 165 | return f"{h}h {m:02d}m" if h else f"{m}m {sec % 60:02d}s" | 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: | 170 | def _mi_tracks(tracks: list, kind: str) -> list: |
| 169 | tracks = (doc.get("media") or {}).get("track") or [] | 171 | return [t for t in tracks if t.get(_MI_TYPE) == kind] |
| 170 | g = next((t for t in tracks if t.get("@type") == "General"), {}) | 172 | |
| 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 = {} | ||
| 177 | 173 | ||
| 174 | def _mi_general(g: dict) -> dict: | ||
| 175 | out = {} | ||
| 178 | if g.get("Format"): | 176 | if g.get("Format"): |
| 179 | out["format"] = g["Format"] | 177 | out["format"] = g["Format"] |
| 180 | if g.get("FileSize"): | 178 | if g.get("FileSize"): |
| 181 | out["size"] = _mi_size(g["FileSize"]) | 179 | out["size"] = _mi_size(g["FileSize"]) |
| 182 | if g.get("Duration") or v.get("Duration"): | 180 | return out |
| 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 | ||
| 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 | subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language"))) | 213 | subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language"))) |
| 207 | lang = a.get("Language") or "" | 214 | lang = a.get("Language") or "" |
| 208 | if subs: | 215 | if subs: |
| 209 | out["language"] = (f"{lang} (subs: {', '.join(subs)})").strip() | 216 | return f"{lang} (subs: {', '.join(subs)})".strip() |
| 210 | elif lang: | 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 | out["language"] = lang | 234 | out["language"] = lang |
| 212 | 235 | ||
| 213 | return {k: val for k, val in out.items() if val} | 236 | return {k: val for k, val in out.items() if val} |
| @@ -286,180 +309,219 @@ def banner(text: str) -> str: | |||
| 286 | return "\n".join(r.rstrip() for r in rows) | 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, | 312 | class _Canvas: |
| 290 | style: str = DEFAULT_STYLE, presents: bool = False, | 313 | """Accumulates boxed lines and holds the geometry for a width and style.""" |
| 291 | footer: str | None = None, layout: str = DEFAULT_LAYOUT, | 314 | |
| 292 | logo: str | None = None, roster: dict | None = None, | 315 | def __init__(self, width: int, style: str): |
| 293 | panel_titles: tuple[str, str] = PANEL_TITLES) -> str: | 316 | self.s = STYLES[style] |
| 294 | s = STYLES[style] | 317 | self.width = width |
| 295 | h, v = s["h"], s["v"] | 318 | self.inner = width - 4 # single-column content width |
| 296 | inner = width - 4 # single-column content width (1 space padding) | 319 | self.cl = (width - 7) // 2 # left panel content width |
| 297 | cl = (width - 7) // 2 # left panel content width | 320 | self.cr = (width - 7) - self.cl |
| 298 | cr = (width - 7) - cl # right panel content width | 321 | self.rfill = width - self.cl - 5 |
| 299 | rfill = width - cl - 5 # h-run right of the divider in a rule | 322 | self.out: list[str] = [] |
| 300 | 323 | self.prev_panel = False | |
| 301 | def hrule(left: str, right: str, joint: str | None = None) -> str: | 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 | if joint is None: | 330 | if joint is None: |
| 303 | return left + h * (width - 2) + right | 331 | return left + h * (self.width - 2) + right |
| 304 | return left + h * (cl + 2) + joint + h * rfill + right | 332 | return left + h * (self.cl + 2) + joint + h * self.rfill + right |
| 305 | 333 | ||
| 306 | def line(content: str = "") -> str: | 334 | def line(self, content: str = "") -> str: |
| 307 | return f"{v} " + content.ljust(inner) + f" {v}" | 335 | v = self.s["v"] |
| 336 | return f"{v} " + content.ljust(self.inner) + f" {v}" | ||
| 308 | 337 | ||
| 309 | def center(content: str) -> str: | 338 | def center(self, content: str) -> str: |
| 310 | return f"{v} " + content.center(inner) + f" {v}" | 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 | fn = str.center if centered else str.ljust | 344 | fn = str.center if centered else str.ljust |
| 314 | return (f"{v} " + fn(_clip(left, cl), cl) | 345 | return (f"{v} " + fn(_clip(left, self.cl), self.cl) |
| 315 | + f" {v} " + fn(_clip(right, cr), cr) + f" {v}") | 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 | 363 | def wrapped_body(self, text: str) -> None: |
| 320 | if logo: | 364 | for para in text.splitlines() or [""]: |
| 321 | art = logo.rstrip("\n").split("\n") | 365 | for chunk in (textwrap.wrap(para, self.inner - 4) or [""]): |
| 322 | pad = max((width - max((len(a) for a in art), default=0)) // 2, 0) | 366 | self.add(self.line(" " + chunk)) |
| 323 | out.extend((" " * pad + a).rstrip() for a in art) | ||
| 324 | out.append("") | ||
| 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 | title = str(data.get("title") or "UNTITLED").upper() | 380 | title = str(data.get("title") or "UNTITLED").upper() |
| 330 | spaced = " ".join(title) | 381 | spaced = " ".join(title) |
| 331 | heading = spaced if len(spaced) <= inner else title | 382 | heading = spaced if len(spaced) <= cvs.inner else title |
| 332 | out.append(center(_clip(heading, inner))) | 383 | cvs.add(cvs.center(_clip(heading, cvs.inner))) |
| 333 | out.append(center(_clip(f"[ {group} ] {site}", inner))) | 384 | cvs.add(cvs.center(_clip(f"[ {group} ] {site}", cvs.inner))) |
| 334 | if presents: | 385 | if presents: |
| 335 | out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner))) | 386 | cvs.add(cvs.center( |
| 336 | 387 | _clip(f"-={{ {group.upper()} proudly presents }}=-", cvs.inner))) | |
| 337 | line_fields = [(k, lbl, col) for k, lbl, _, kind, col in FIELDS | 388 | |
| 338 | if kind == "line" and k != "title" and data.get(k)] | 389 | |
| 339 | prev_panel = False | 390 | def _line_fields(data: dict) -> list: |
| 340 | 391 | return [(k, lbl, col) for k, lbl, _, kind, col in FIELDS | |
| 341 | def sep_before() -> str: | 392 | if kind == "line" and k != "title" and data.get(k)] |
| 342 | nonlocal prev_panel | 393 | |
| 343 | joint = s["jb"] if prev_panel else None | 394 | |
| 344 | prev_panel = False | 395 | def _render_panel(cvs: _Canvas, data: dict, fields: list, |
| 345 | return hrule(s["ls"], s["rs"], joint) | 396 | panel_titles: tuple[str, str]) -> None: |
| 346 | 397 | s = cvs.s | |
| 347 | # info: two-column panel or single-column rows | 398 | left = [(lbl, str(data[k])) for k, lbl, col in fields if col == "L"] |
| 348 | if layout == "panel" and line_fields: | 399 | right = [(lbl, str(data[k])) for k, lbl, col in fields if col == "R"] |
| 349 | left = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "L"] | 400 | lw_l = max((len(lbl) for lbl, _ in left), default=0) |
| 350 | right = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "R"] | 401 | lw_r = max((len(lbl) for lbl, _ in right), default=0) |
| 351 | lw_l = max((len(lbl) for lbl, _ in left), default=0) | 402 | |
| 352 | lw_r = max((len(lbl) for lbl, _ in right), default=0) | 403 | def cell(pair, lw): |
| 353 | 404 | return f" {pair[0]:<{lw}} : {pair[1]}" if pair else "" | |
| 354 | def cell(pair, lw): | 405 | |
| 355 | return f" {pair[0]:<{lw}} : {pair[1]}" if pair else "" | 406 | cvs.add(cvs.hrule(s["ls"], s["rs"], s["jt"])) |
| 356 | 407 | cvs.add(cvs.prow(panel_titles[0], panel_titles[1], centered=True)) | |
| 357 | out.append(hrule(s["ls"], s["rs"], s["jt"])) | 408 | cvs.add(cvs.hrule(s["ls"], s["rs"], s["jm"])) |
| 358 | out.append(prow(panel_titles[0], panel_titles[1], centered=True)) | 409 | for lp, rp in zip_longest(left, right): |
| 359 | out.append(hrule(s["ls"], s["rs"], s["jm"])) | 410 | cvs.add(cvs.prow(cell(lp, lw_l), cell(rp, lw_r))) |
| 360 | for lp, rp in zip_longest(left, right): | 411 | cvs.prev_panel = True |
| 361 | out.append(prow(cell(lp, lw_l), cell(rp, lw_r))) | 412 | |
| 362 | prev_panel = True | 413 | |
| 363 | elif line_fields: | 414 | def _render_rows(cvs: _Canvas, data: dict, fields: list) -> None: |
| 364 | out.append(hrule(s["ls"], s["rs"])) | 415 | cvs.add(cvs.hrule(cvs.s["ls"], cvs.s["rs"])) |
| 365 | lw = max(len(lbl) for _, lbl, _ in line_fields) | 416 | lw = max(len(lbl) for _, lbl, _ in fields) |
| 366 | avail = inner - (2 + lw + 2) | 417 | avail = cvs.inner - (2 + lw + 2) |
| 367 | for k, lbl, _ in line_fields: | 418 | for k, lbl, _ in fields: |
| 368 | wrapped = textwrap.wrap(str(data[k]), avail) or [""] | 419 | wrapped = textwrap.wrap(str(data[k]), avail) or [""] |
| 369 | out.append(line(f" {lbl:<{lw}} {wrapped[0]}")) | 420 | cvs.add(cvs.line(f" {lbl:<{lw}} {wrapped[0]}")) |
| 370 | for cont in wrapped[1:]: | 421 | for cont in wrapped[1:]: |
| 371 | out.append(line(" " * (2 + lw + 2) + cont)) | 422 | cvs.add(cvs.line(" " * (2 + lw + 2) + cont)) |
| 372 | 423 | ||
| 373 | # notes block | 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 | notes = str(data.get("notes") or "").strip() | 437 | notes = str(data.get("notes") or "").strip() |
| 375 | if notes: | 438 | if not notes: |
| 376 | out.append(sep_before()) | 439 | return |
| 377 | out.append(line(" NOTES")) | 440 | cvs.section("NOTES") |
| 378 | out.append(line()) | 441 | cvs.wrapped_body(notes) |
| 379 | for para in notes.splitlines() or [""]: | 442 | |
| 380 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): | 443 | |
| 381 | out.append(line(" " + wrapped)) | 444 | def _render_greets(cvs: _Canvas, data: dict) -> None: |
| 382 | |||
| 383 | # greets block | ||
| 384 | greets = _split_list(data.get("greets") or []) | 445 | greets = _split_list(data.get("greets") or []) |
| 385 | if greets: | 446 | if not greets: |
| 386 | out.append(sep_before()) | 447 | return |
| 387 | out.append(line(" GREETS")) | 448 | cvs.section("GREETS") |
| 388 | out.append(line()) | 449 | for wrapped in textwrap.wrap(" ".join(greets), cvs.inner - 4): |
| 389 | for wrapped in textwrap.wrap(" ".join(greets), inner - 4): | 450 | cvs.add(cvs.line(" " + wrapped)) |
| 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 {} | ||
| 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 | news = str(r.get("news") or "").strip() | 473 | news = str(r.get("news") or "").strip() |
| 424 | if news: | 474 | if news: |
| 425 | section("GROUP NEWS") | 475 | cvs.section("GROUP NEWS") |
| 426 | for para in news.splitlines(): | 476 | cvs.wrapped_body(news) |
| 427 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): | ||
| 428 | out.append(line(" " + wrapped)) | ||
| 429 | |||
| 430 | members = _split_list(r.get("members") or []) | 477 | members = _split_list(r.get("members") or []) |
| 431 | if members: | 478 | if members: |
| 432 | section("MEMBERS") | 479 | cvs.section("MEMBERS") |
| 433 | names_block(members) | 480 | cvs.names_block(members) |
| 434 | |||
| 435 | couriers = r.get("couriers") or {} | 481 | couriers = r.get("couriers") or {} |
| 436 | if couriers: | 482 | if couriers: |
| 437 | section("COURIERS") | 483 | cvs.section("COURIERS") |
| 438 | for tier, who in couriers.items(): | 484 | for tier, who in couriers.items(): |
| 439 | out.append(center(f"- {tier} -")) | 485 | cvs.add(cvs.center(f"- {tier} -")) |
| 440 | names_block(_split_list(who)) | 486 | cvs.names_block(_split_list(who)) |
| 441 | |||
| 442 | if r.get("boards"): | 487 | if r.get("boards"): |
| 443 | table("BOARDS", r["boards"]) | 488 | _render_table(cvs, "BOARDS", r["boards"]) |
| 444 | if r.get("affiliates"): | 489 | if r.get("affiliates"): |
| 445 | table("AFFILIATES", r["affiliates"]) | 490 | _render_table(cvs, "AFFILIATES", r["affiliates"]) |
| 446 | |||
| 447 | outposts = _split_list(r.get("outposts") or []) | 491 | outposts = _split_list(r.get("outposts") or []) |
| 448 | if outposts: | 492 | if outposts: |
| 449 | section("OUTPOSTS") | 493 | cvs.section("OUTPOSTS") |
| 450 | names_block(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)) | 508 | def render(data: dict, group: str, site: str, width: int, |
| 462 | return "\n".join(out) + "\n" | 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 | def _clip(text: str, width: int) -> str: | 527 | def _clip(text: str, width: int) -> str: |
| @@ -524,18 +586,13 @@ def _list_templates() -> list[str]: | |||
| 524 | return sorted(p.stem for p in d.glob("*.toml")) if d.is_dir() else [] | 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: | 589 | def _resolve(flag, config: dict, key: str, default): |
| 528 | args = build_parser().parse_args(argv) | 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 | release = load_config(args.config) | 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 | template = {} | 596 | template = {} |
| 540 | if args.template: | 597 | if args.template: |
| 541 | tpath = _templates_dir() / f"{args.template}.toml" | 598 | tpath = _templates_dir() / f"{args.template}.toml" |
| @@ -545,83 +602,96 @@ def main(argv: list[str] | None = None) -> int: | |||
| 545 | template = load_config(str(tpath)) | 602 | template = load_config(str(tpath)) |
| 546 | profile_path = args.profile or release.get("profile") | 603 | profile_path = args.profile or release.get("profile") |
| 547 | profile = load_config(profile_path) if profile_path else {} | 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 | data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""} | 609 | data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""} |
| 552 | for key, *_ in FIELDS: | 610 | for key, *_ in FIELDS: |
| 553 | val = getattr(args, key) | 611 | val = getattr(args, key) |
| 554 | if val is not None: | 612 | if val is not None: |
| 555 | data[key] = val | 613 | data[key] = val |
| 556 | |||
| 557 | # mediainfo fills any media field not already set by config or flags | ||
| 558 | mediainfo_src = args.mediainfo or config.get("mediainfo") | 614 | mediainfo_src = args.mediainfo or config.get("mediainfo") |
| 559 | if mediainfo_src: | 615 | if mediainfo_src: |
| 560 | for key, val in load_mediainfo(mediainfo_src).items(): | 616 | for key, val in load_mediainfo(mediainfo_src).items(): |
| 561 | data.setdefault(key, val) | 617 | data.setdefault(key, val) |
| 562 | |||
| 563 | data.setdefault("date", date.today().isoformat()) | 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) | 622 | def _fill_interactive(args, data: dict) -> None: |
| 576 | show_footer = resolve(args.footer, "footer", False) | 623 | interactive = sys.stdin.isatty() and not args.no_input |
| 577 | footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER | 624 | if args.interactive: |
| 578 | footer = footer_text if show_footer else None | 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 | 630 | def _resolve_logo(args, config: dict, group: str) -> str | None: |
| 585 | logo = None | 631 | """An explicit art file wins; otherwise an optional generated banner.""" |
| 586 | logo_path = args.logo or config.get("logo") | 632 | logo_path = args.logo or config.get("logo") |
| 587 | if logo_path: | 633 | if logo_path: |
| 588 | enc = args.logo_encoding or config.get("logo_encoding") or "cp437" | 634 | enc = args.logo_encoding or config.get("logo_encoding") or "cp437" |
| 589 | try: | 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 | except FileNotFoundError: | 637 | except FileNotFoundError: |
| 592 | sys.exit(f"nfogen: logo not found: {logo_path}") | 638 | sys.exit(f"nfogen: logo not found: {logo_path}") |
| 593 | elif resolve(args.banner, "banner", False): | 639 | if _resolve(args.banner, config, "banner", False): |
| 594 | logo = banner(args.banner_text or config.get("banner_text") or group) | 640 | return banner(args.banner_text or config.get("banner_text") or group) |
| 595 | 641 | return None | |
| 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) | ||
| 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 | if args.encoding == "cp437": | 666 | if args.encoding == "cp437": |
| 614 | raw = text.encode("cp437", errors="replace") | 667 | raw = text.encode("cp437", errors="replace") |
| 615 | if args.output: | 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 | else: | 670 | else: |
| 618 | sys.stdout.buffer.write(raw) | 671 | sys.stdout.buffer.write(raw) |
| 619 | elif args.output: | 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 | else: | 674 | else: |
| 622 | sys.stdout.write(text) | 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 | if __name__ == "__main__": | 696 | if __name__ == "__main__": |
| 627 | raise SystemExit(main()) | 697 | main() |