krz/nfogen
A scene-style ASCII .nfo generator.
clone: git clone https://gitbay.org/krz/nfogen.git
v1.2.1: nfogen.py · raw
1#!/usr/bin/env python3
2"""nfogen - a scene-style NFO generator.
3
4Builds a boxed ASCII .nfo from CLI flags, an optional config file, and
5interactive prompts for anything still missing.
6"""
7
8from __future__ import annotations
9
10import argparse
11import json
12import subprocess
13import sys
14import textwrap
15from datetime import date
16from itertools import zip_longest
17from pathlib import Path
18
19SCRIPT_DIR = Path(__file__).resolve().parent
20
21# --- field schema ------------------------------------------------------------
22# One source of truth. Drives argparse flags, interactive prompts, and render.
23# key : internal name / config key / flag name
24# label : shown in the rendered nfo (info rows) or as a section header
25# help : argparse + prompt help text
26# kind : "line" scalar shown as an aligned INFO row
27# "list" many values, shown space-joined in its own section
28# "block" free text, wrapped in its own section
29# col : "L"/"R" panel column for "line" fields; None otherwise
30
31FIELDS = [
32 ("title", "TITLE", "release title (required)", "line", None),
33 # --- release information (left panel) ---
34 ("date", "DATE", "release date", "line", "L"),
35 ("source", "SOURCE", "source (BluRay, WEB-DL, CD, ...)", "line", "L"),
36 ("cracker", "CRACKER", "cracker / cracked by", "line", "L"),
37 ("supplier", "SUPPLIER", "supplier / supplied by", "line", "L"),
38 ("packager", "PACKAGER", "packager / packed by", "line", "L"),
39 ("protection", "PROTECTION", "protection type", "line", "L"),
40 ("install", "INSTALL", "install method", "line", "L"),
41 ("rating", "RATING", "rating", "line", "L"),
42 # --- game / media information (right panel) ---
43 ("type", "TYPE", "release type (MOVIE, TV, APP, ...)", "line", "R"),
44 ("publisher", "PUBLISHER", "publisher / vendor", "line", "R"),
45 ("format", "FORMAT", "container / format", "line", "R"),
46 ("disks", "DISKS", "number of disks / archives", "line", "R"),
47 ("video", "VIDEO", "video codec / details", "line", "R"),
48 ("audio", "AUDIO", "audio codec / details", "line", "R"),
49 ("resolution", "RESOLUTION", "resolution", "line", "R"),
50 ("language", "LANGUAGE", "language(s)", "line", "R"),
51 ("genre", "GENRE", "genre", "line", "R"),
52 ("runtime", "RUNTIME", "runtime / duration", "line", "R"),
53 ("size", "SIZE", "total size", "line", "R"),
54 ("files", "FILES", "file count / listing", "line", "R"),
55 ("url", "URL", "reference url (imdb, homepage, ...)", "line", "R"),
56 # --- free-form sections ---
57 ("notes", "NOTES", "free-text notes / description", "block", None),
58 ("greets", "GREETS", "groups to greet (space/comma sep)", "list", None),
59]
60FIELD_KINDS = {k: kind for k, _, _, kind, _ in FIELDS}
61INFO_KEYS = [k for k, _, _, kind, _ in FIELDS if kind == "line" and k != "title"]
62
63DEFAULT_SITE = "krz.sh"
64DEFAULT_GROUP = "KRZ"
65DEFAULT_WIDTH = 79 # DOS 80-column standard, less one margin column
66DEFAULT_STYLE = "double"
67DEFAULT_FOOTER = (
68 "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n"
69 "if you enjoyed this release, buy it!"
70)
71DEFAULT_LAYOUT = "rows"
72PANEL_TITLES = ("Release Information", "Game Information")
73
74# Box-drawing character sets. "single"/"double" are CP437-encodable; "block"
75# is a solid fill frame. Keys: corners + horizontal/vertical + tee separators.
76# jt/jm/jb are the down-tee, cross, and up-tee used where the panel's
77# vertical column divider meets a horizontal rule.
78STYLES = {
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": "█"},
85}
86
87CONFIG_CANDIDATES = [
88 Path("nfogen.toml"),
89 Path(".nfogen.toml"),
90 Path.home() / ".config" / "nfogen" / "config.toml",
91]
92
93
94# --- config ------------------------------------------------------------------
95def load_config(explicit: str | None) -> dict:
96 """Load defaults from a TOML or JSON file. Returns {} if none found."""
97 path = None
98 if explicit:
99 path = Path(explicit) # NOSONAR local CLI: the user's own path, no trust boundary
100 if not path.exists():
101 sys.exit(f"nfogen: config not found: {explicit}")
102 else:
103 path = next((p for p in CONFIG_CANDIDATES if p.exists()), None)
104 if path is None:
105 return {}
106
107 text = path.read_text(encoding="utf-8") # NOSONAR local CLI: the user's own path
108 if path.suffix == ".json":
109 return json.loads(text)
110 try:
111 import tomllib
112 except ModuleNotFoundError:
113 sys.exit("nfogen: TOML config needs Python 3.11+ (or use a .json file)")
114 return tomllib.loads(text)
115
116
117# --- mediainfo import --------------------------------------------------------
118_MI_TYPE = "@type" # mediainfo track-type key
119
120
121def load_mediainfo(path: str) -> dict:
122 """Read a `mediainfo --Output=JSON` dump (or run mediainfo on a media file)
123 and return values for the video/audio/resolution/size/runtime/format keys."""
124 p = Path(path) # NOSONAR local CLI: the user's own path, no trust boundary
125 if p.suffix.lower() == ".json":
126 raw = p.read_text(encoding="utf-8") # NOSONAR local CLI: the user's own path
127 else:
128 try:
129 proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)], # NOSONAR local CLI: the user's own file
130 capture_output=True, text=True, check=True)
131 except FileNotFoundError:
132 sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo")
133 except subprocess.CalledProcessError as e:
134 sys.exit(f"nfogen: mediainfo failed: {(e.stderr or '').strip()}")
135 raw = proc.stdout
136 try:
137 doc = json.loads(raw)
138 except json.JSONDecodeError:
139 sys.exit(f"nfogen: not valid mediainfo JSON: {path}")
140 return _extract_media(doc)
141
142
143def _mi_int(x) -> str:
144 try:
145 return str(int(float(str(x).replace(" ", ""))))
146 except ValueError:
147 return str(x)
148
149
150def _mi_size(b) -> str:
151 try:
152 n = float(b)
153 except (TypeError, ValueError):
154 return str(b)
155 for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)):
156 if n >= div:
157 return f"{n / div:.2f} {unit}"
158 return f"{int(n)} B"
159
160
161def _mi_duration(s) -> str:
162 try:
163 sec = int(float(s))
164 except (TypeError, ValueError):
165 return str(s)
166 h, m = sec // 3600, (sec % 3600) // 60
167 return f"{h}h {m:02d}m" if h else f"{m}m {sec % 60:02d}s"
168
169
170def _mi_tracks(tracks: list, kind: str) -> list:
171 return [t for t in tracks if t.get(_MI_TYPE) == kind]
172
173
174def _mi_general(g: dict) -> dict:
175 out = {}
176 if g.get("Format"):
177 out["format"] = g["Format"]
178 if g.get("FileSize"):
179 out["size"] = _mi_size(g["FileSize"])
180 return out
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:
213 subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language")))
214 lang = a.get("Language") or ""
215 if subs:
216 return f"{lang} (subs: {', '.join(subs)})".strip()
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:
234 out["language"] = lang
235
236 return {k: val for k, val in out.items() if val}
237
238
239# --- interactive -------------------------------------------------------------
240def prompt_missing(data: dict, force_all: bool) -> None:
241 """Fill fields from stdin. Prompts every field when force_all, else only
242 the missing ones. Enter keeps the current/blank value."""
243 keys = [k for k, *_ in FIELDS] if force_all else \
244 [k for k, *_ in FIELDS if not data.get(k)]
245 if not keys:
246 return
247 print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr)
248 for key in keys:
249 _, label, help_text, _, _ = next(f for f in FIELDS if f[0] == key)
250 current = data.get(key, "")
251 suffix = f" [{current}]" if current else ""
252 try:
253 answer = input(f"{label} ({help_text}){suffix}: ").strip()
254 except EOFError:
255 break
256 if answer:
257 data[key] = answer
258
259
260# --- rendering ---------------------------------------------------------------
261def _split_list(value) -> list[str]:
262 if isinstance(value, (list, tuple)):
263 return [str(v).strip() for v in value if str(v).strip()]
264 return [p for p in str(value).replace(",", " ").split() if p]
265
266
267def _col_widths(available: int, fracs: list[float]) -> list[int]:
268 """Split `available` chars into columns by fraction; last column absorbs
269 the remainder so the widths always sum exactly."""
270 widths = [max(4, int(available * f)) for f in fracs[:-1]]
271 widths.append(available - sum(widths))
272 return widths
273
274
275# A compact 5-row block font for the banner generator. Each glyph is five
276# rows joined by "/". Uppercase letters, digits, space, and a few symbols.
277_FONT = {
278 "A": " ### /# #/#####/# #/# #", "B": "#### /# #/#### /# #/#### ",
279 "C": " ####/# /# /# / ####", "D": "#### /# #/# #/# #/#### ",
280 "E": "#####/# /### /# /#####", "F": "#####/# /### /# /# ",
281 "G": " ####/# /# ##/# #/ ####", "H": "# #/# #/#####/# #/# #",
282 "I": "#####/ # / # / # /#####", "J": "#####/ # / # /# # / ## ",
283 "K": "# #/# # /### /# # /# #", "L": "# /# /# /# /#####",
284 "M": "# #/## ##/# # #/# #/# #", "N": "# #/## #/# # #/# ##/# #",
285 "O": " ### /# #/# #/# #/ ### ", "P": "#### /# #/#### /# /# ",
286 "Q": " ### /# #/# # #/# # / ## #", "R": "#### /# #/#### /# # /# #",
287 "S": " ####/# / ### / #/#### ", "T": "#####/ # / # / # / # ",
288 "U": "# #/# #/# #/# #/ ### ", "V": "# #/# #/# #/ # # / # ",
289 "W": "# #/# #/# # #/## ##/# #", "X": "# #/ # # / # / # # /# #",
290 "Y": "# #/ # # / # / # / # ", "Z": "#####/ # / # / # /#####",
291 "0": " ### /# ##/# # #/## #/ ### ", "1": " # / ## / # / # /#####",
292 "2": " ### /# #/ ## / # /#####", "3": "#### / #/ ### / #/#### ",
293 "4": "# #/# #/#####/ #/ #", "5": "#####/# /#### / #/#### ",
294 "6": " ####/# /#### /# #/ ### ", "7": "#####/ # / # / # /# ",
295 "8": " ### /# #/ ### /# #/ ### ", "9": " ### /# #/ ####/ #/#### ",
296 " ": " / / / / ", "-": " / /#####/ / ",
297 ".": " / / / / # ", "!": " # / # / # / / # ",
298 ":": " / # / / # / ",
299}
300
301
302def banner(text: str) -> str:
303 """Render text as a 5-row ASCII banner using the built-in block font."""
304 rows = ["", "", "", "", ""]
305 for ch in text.upper():
306 glyph = _FONT.get(ch, _FONT[" "]).split("/")
307 for i in range(5):
308 rows[i] += glyph[i] + " "
309 return "\n".join(r.rstrip() for r in rows)
310
311
312class _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"]
330 if joint is None:
331 return left + h * (self.width - 2) + right
332 return left + h * (self.cl + 2) + joint + h * self.rfill + right
333
334 def line(self, content: str = "") -> str:
335 v = self.s["v"]
336 return f"{v} " + content.ljust(self.inner) + f" {v}"
337
338 def center(self, content: str) -> str:
339 v = self.s["v"]
340 return f"{v} " + content.center(self.inner) + f" {v}"
341
342 def prow(self, left: str, right: str, centered: bool = False) -> str:
343 v = self.s["v"]
344 fn = str.center if centered else str.ljust
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())
357
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())
362
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))
367
368
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:
380 title = str(data.get("title") or "UNTITLED").upper()
381 spaced = " ".join(title)
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)))
385 if presents:
386 cvs.add(cvs.center(
387 _clip(f"-={{ {group.upper()} proudly presents }}=-", cvs.inner)))
388
389
390def _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
395def _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
414def _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
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:
437 notes = str(data.get("notes") or "").strip()
438 if not notes:
439 return
440 cvs.section("NOTES")
441 cvs.wrapped_body(notes)
442
443
444def _render_greets(cvs: _Canvas, data: dict) -> None:
445 greets = _split_list(data.get("greets") 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))
451
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 {}
473 news = str(r.get("news") or "").strip()
474 if news:
475 cvs.section("GROUP NEWS")
476 cvs.wrapped_body(news)
477 members = _split_list(r.get("members") or [])
478 if members:
479 cvs.section("MEMBERS")
480 cvs.names_block(members)
481 couriers = r.get("couriers") or {}
482 if couriers:
483 cvs.section("COURIERS")
484 for tier, who in couriers.items():
485 cvs.add(cvs.center(f"- {tier} -"))
486 cvs.names_block(_split_list(who))
487 if r.get("boards"):
488 _render_table(cvs, "BOARDS", r["boards"])
489 if r.get("affiliates"):
490 _render_table(cvs, "AFFILIATES", r["affiliates"])
491 outposts = _split_list(r.get("outposts") or [])
492 if outposts:
493 cvs.section("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())
506
507
508def 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"
525
526
527def _clip(text: str, width: int) -> str:
528 return text if len(text) <= width else text[: width - 2] + ".."
529
530
531# --- cli ---------------------------------------------------------------------
532def build_parser() -> argparse.ArgumentParser:
533 p = argparse.ArgumentParser(
534 prog="nfogen",
535 description="Generate a scene-style ASCII .nfo file.",
536 )
537 p.add_argument("-c", "--config", metavar="PATH",
538 help="config file (.toml or .json) with field defaults")
539 p.add_argument("-p", "--profile", metavar="PATH",
540 help="group profile (logo, roster, boards) merged under the config")
541 p.add_argument("-t", "--template", metavar="NAME",
542 help="bundled style template (see --list-templates)")
543 p.add_argument("--list-templates", action="store_true",
544 help="list bundled templates and exit")
545 p.add_argument("--mediainfo", metavar="PATH",
546 help="mediainfo JSON dump (or media file) to auto-fill media fields")
547 p.add_argument("-o", "--output", metavar="PATH",
548 help="write to PATH instead of stdout")
549 p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})")
550 p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})")
551 p.add_argument("-w", "--width", type=int,
552 help=f"box width in chars (default {DEFAULT_WIDTH})")
553 p.add_argument("--style", choices=sorted(STYLES),
554 help=f"box-drawing style (default {DEFAULT_STYLE})")
555 p.add_argument("--layout", choices=["rows", "panel"],
556 help=f"info layout (default {DEFAULT_LAYOUT})")
557 p.add_argument("--logo", metavar="PATH",
558 help="art file to place above the box")
559 p.add_argument("--logo-encoding", choices=["cp437", "utf8"],
560 help="encoding of the logo file (default cp437)")
561 p.add_argument("--banner", action=argparse.BooleanOptionalAction,
562 default=None, help="generate an ASCII banner logo (defaults to the group)")
563 p.add_argument("--banner-text", help="text for the generated banner")
564 p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8",
565 help="output encoding (default utf8; cp437 for true DOS nfos)")
566 p.add_argument("--presents", action=argparse.BooleanOptionalAction,
567 default=None, help="show the 'proudly presents' banner")
568 p.add_argument("--footer", action=argparse.BooleanOptionalAction,
569 default=None, help="show the closing disclaimer block")
570 p.add_argument("--footer-text", help="custom disclaimer text")
571 p.add_argument("-i", "--interactive", action="store_true",
572 help="prompt for every field")
573 p.add_argument("--no-input", action="store_true",
574 help="never prompt, even if fields are missing")
575 for key, _, help_text, _, _ in FIELDS:
576 p.add_argument(f"--{key}", help=help_text)
577 return p
578
579
580def _templates_dir() -> Path:
581 return SCRIPT_DIR / "templates"
582
583
584def _list_templates() -> list[str]:
585 d = _templates_dir()
586 return sorted(p.stem for p in d.glob("*.toml")) if d.is_dir() else []
587
588
589def _resolve(flag, config: dict, key: str, default):
590 return flag if flag is not None else config.get(key, default)
591
592
593def _layered_config(args) -> dict:
594 """Merge template < profile < release-config (CLI flags applied later)."""
595 release = load_config(args.config)
596 template = {}
597 if args.template:
598 tpath = _templates_dir() / f"{args.template}.toml"
599 if not tpath.exists():
600 sys.exit(f"nfogen: unknown template '{args.template}' "
601 f"(have: {', '.join(_list_templates()) or 'none'})")
602 template = load_config(str(tpath))
603 profile_path = args.profile or release.get("profile")
604 profile = load_config(profile_path) if profile_path else {}
605 return {**template, **profile, **release}
606
607
608def _collect_data(args, config: dict) -> dict:
609 data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""}
610 for key, *_ in FIELDS:
611 val = getattr(args, key)
612 if val is not None:
613 data[key] = val
614 mediainfo_src = args.mediainfo or config.get("mediainfo")
615 if mediainfo_src:
616 for key, val in load_mediainfo(mediainfo_src).items():
617 data.setdefault(key, val)
618 data.setdefault("date", date.today().isoformat())
619 return data
620
621
622def _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)
628
629
630def _resolve_logo(args, config: dict, group: str) -> str | None:
631 """An explicit art file wins; otherwise an optional generated banner."""
632 logo_path = args.logo or config.get("logo")
633 if logo_path:
634 enc = args.logo_encoding or config.get("logo_encoding") or "cp437"
635 try:
636 return Path(logo_path).read_text(encoding=enc, errors="replace") # NOSONAR local CLI: the user's own path
637 except FileNotFoundError:
638 sys.exit(f"nfogen: logo not found: {logo_path}")
639 if _resolve(args.banner, config, "banner", False):
640 return banner(args.banner_text or config.get("banner_text") or group)
641 return None
642
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:
666 if args.encoding == "cp437":
667 raw = text.encode("cp437", errors="replace")
668 if args.output:
669 Path(args.output).write_bytes(raw) # NOSONAR local CLI: the user's own path
670 else:
671 sys.stdout.buffer.write(raw)
672 elif args.output:
673 Path(args.output).write_text(text, encoding="utf-8") # NOSONAR local CLI: the user's own path
674 else:
675 sys.stdout.write(text)
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)
694
695
696if __name__ == "__main__":
697 main()