krz/nfogen

A scene-style ASCII .nfo generator. ascii cli generator nfo

Commit 3d2fb0c76b

3d2fb0c76bc3886935e942fafee94b2d08ae630a

parent: 8bc87b709e

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-08 06:31 UTC

resolve sonarcloud issues: dedupe render/main via extraction, dict literals, @type constant, drop unused import; accept CLI path-taint findings as by-design (NOSONAR)

Layout: unified · split

nfogen.py +316 −246
@@ -9,7 +9,6 @@ from __future__ import annotations
9 9
10import argparse 10import argparse
11import json 11import json
12import os
13import subprocess 12import subprocess
14import sys 13import sys
15import textwrap 14import 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.
79STYLES = { 78STYLES = {
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
88CONFIG_CANDIDATES = [ 87CONFIG_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
119def load_mediainfo(path: str) -> dict: 121def 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
168def _extract_media(doc: dict) -> dict: 170def _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
174def _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
183def _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
197def _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
212def _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
220def _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
289def render(data: dict, group: str, site: str, width: int, 312class _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 369def _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
378def _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 390def _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 395def _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: 414def _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
425def _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
436def _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)) 444def _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
453def _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
471def _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
497def _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)) 508def 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
465def _clip(text: str, width: int) -> str: 527def _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
527def main(argv: list[str] | None = None) -> int: 589def _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
593def _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 608def _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) 622def _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 630def _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
644def _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
665def _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
678def 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
626if __name__ == "__main__": 696if __name__ == "__main__":
627 raise SystemExit(main()) 697 main()