krz/nfogen
A scene-style ASCII .nfo generator.
clone: git clone https://gitbay.org/krz/nfogen.git
v1.2.0: 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 os
13import subprocess
14import sys
15import textwrap
16from datetime import date
17from itertools import zip_longest
18from pathlib import Path
19
20SCRIPT_DIR = Path(__file__).resolve().parent
21
22# --- field schema ------------------------------------------------------------
23# One source of truth. Drives argparse flags, interactive prompts, and render.
24# key : internal name / config key / flag name
25# label : shown in the rendered nfo (info rows) or as a section header
26# help : argparse + prompt help text
27# kind : "line" scalar shown as an aligned INFO row
28# "list" many values, shown space-joined in its own section
29# "block" free text, wrapped in its own section
30# col : "L"/"R" panel column for "line" fields; None otherwise
31
32FIELDS = [
33 ("title", "TITLE", "release title (required)", "line", None),
34 # --- release information (left panel) ---
35 ("date", "DATE", "release date", "line", "L"),
36 ("source", "SOURCE", "source (BluRay, WEB-DL, CD, ...)", "line", "L"),
37 ("cracker", "CRACKER", "cracker / cracked by", "line", "L"),
38 ("supplier", "SUPPLIER", "supplier / supplied by", "line", "L"),
39 ("packager", "PACKAGER", "packager / packed by", "line", "L"),
40 ("protection", "PROTECTION", "protection type", "line", "L"),
41 ("install", "INSTALL", "install method", "line", "L"),
42 ("rating", "RATING", "rating", "line", "L"),
43 # --- game / media information (right panel) ---
44 ("type", "TYPE", "release type (MOVIE, TV, APP, ...)", "line", "R"),
45 ("publisher", "PUBLISHER", "publisher / vendor", "line", "R"),
46 ("format", "FORMAT", "container / format", "line", "R"),
47 ("disks", "DISKS", "number of disks / archives", "line", "R"),
48 ("video", "VIDEO", "video codec / details", "line", "R"),
49 ("audio", "AUDIO", "audio codec / details", "line", "R"),
50 ("resolution", "RESOLUTION", "resolution", "line", "R"),
51 ("language", "LANGUAGE", "language(s)", "line", "R"),
52 ("genre", "GENRE", "genre", "line", "R"),
53 ("runtime", "RUNTIME", "runtime / duration", "line", "R"),
54 ("size", "SIZE", "total size", "line", "R"),
55 ("files", "FILES", "file count / listing", "line", "R"),
56 ("url", "URL", "reference url (imdb, homepage, ...)", "line", "R"),
57 # --- free-form sections ---
58 ("notes", "NOTES", "free-text notes / description", "block", None),
59 ("greets", "GREETS", "groups to greet (space/comma sep)", "list", None),
60]
61FIELD_KINDS = {k: kind for k, _, _, kind, _ in FIELDS}
62INFO_KEYS = [k for k, _, _, kind, _ in FIELDS if kind == "line" and k != "title"]
63
64DEFAULT_SITE = "krz.sh"
65DEFAULT_GROUP = "KRZ"
66DEFAULT_WIDTH = 79 # DOS 80-column standard, less one margin column
67DEFAULT_STYLE = "double"
68DEFAULT_FOOTER = (
69 "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n"
70 "if you enjoyed this release, buy it!"
71)
72DEFAULT_LAYOUT = "rows"
73PANEL_TITLES = ("Release Information", "Game Information")
74
75# Box-drawing character sets. "single"/"double" are CP437-encodable; "block"
76# is a solid fill frame. Keys: corners + horizontal/vertical + tee separators.
77# jt/jm/jb are the down-tee, cross, and up-tee used where the panel's
78# vertical column divider meets a horizontal rule.
79STYLES = {
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="█"),
86}
87
88CONFIG_CANDIDATES = [
89 Path("nfogen.toml"),
90 Path(".nfogen.toml"),
91 Path.home() / ".config" / "nfogen" / "config.toml",
92]
93
94
95# --- config ------------------------------------------------------------------
96def load_config(explicit: str | None) -> dict:
97 """Load defaults from a TOML or JSON file. Returns {} if none found."""
98 path = None
99 if explicit:
100 path = Path(explicit)
101 if not path.exists():
102 sys.exit(f"nfogen: config not found: {explicit}")
103 else:
104 path = next((p for p in CONFIG_CANDIDATES if p.exists()), None)
105 if path is None:
106 return {}
107
108 text = path.read_text(encoding="utf-8")
109 if path.suffix == ".json":
110 return json.loads(text)
111 try:
112 import tomllib
113 except ModuleNotFoundError:
114 sys.exit("nfogen: TOML config needs Python 3.11+ (or use a .json file)")
115 return tomllib.loads(text)
116
117
118# --- mediainfo import --------------------------------------------------------
119def load_mediainfo(path: str) -> dict:
120 """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."""
122 p = Path(path)
123 if p.suffix.lower() == ".json":
124 raw = p.read_text(encoding="utf-8")
125 else:
126 try:
127 proc = subprocess.run(["mediainfo", "--Output=JSON", str(p)],
128 capture_output=True, text=True, check=True)
129 except FileNotFoundError:
130 sys.exit("nfogen: mediainfo not installed; pass a JSON dump to --mediainfo")
131 except subprocess.CalledProcessError as e:
132 sys.exit(f"nfogen: mediainfo failed: {(e.stderr or '').strip()}")
133 raw = proc.stdout
134 try:
135 doc = json.loads(raw)
136 except json.JSONDecodeError:
137 sys.exit(f"nfogen: not valid mediainfo JSON: {path}")
138 return _extract_media(doc)
139
140
141def _mi_int(x) -> str:
142 try:
143 return str(int(float(str(x).replace(" ", ""))))
144 except ValueError:
145 return str(x)
146
147
148def _mi_size(b) -> str:
149 try:
150 n = float(b)
151 except (TypeError, ValueError):
152 return str(b)
153 for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)):
154 if n >= div:
155 return f"{n / div:.2f} {unit}"
156 return f"{int(n)} B"
157
158
159def _mi_duration(s) -> str:
160 try:
161 sec = int(float(s))
162 except (TypeError, ValueError):
163 return str(s)
164 h, m = sec // 3600, (sec % 3600) // 60
165 return f"{h}h {m:02d}m" if h else f"{m}m {sec % 60:02d}s"
166
167
168def _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 = {}
177
178 if g.get("Format"):
179 out["format"] = g["Format"]
180 if g.get("FileSize"):
181 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
205
206 subs = list(dict.fromkeys(t.get("Language") for t in txts if t.get("Language")))
207 lang = a.get("Language") or ""
208 if subs:
209 out["language"] = (f"{lang} (subs: {', '.join(subs)})").strip()
210 elif lang:
211 out["language"] = lang
212
213 return {k: val for k, val in out.items() if val}
214
215
216# --- interactive -------------------------------------------------------------
217def prompt_missing(data: dict, force_all: bool) -> None:
218 """Fill fields from stdin. Prompts every field when force_all, else only
219 the missing ones. Enter keeps the current/blank value."""
220 keys = [k for k, *_ in FIELDS] if force_all else \
221 [k for k, *_ in FIELDS if not data.get(k)]
222 if not keys:
223 return
224 print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr)
225 for key in keys:
226 _, label, help_text, _, _ = next(f for f in FIELDS if f[0] == key)
227 current = data.get(key, "")
228 suffix = f" [{current}]" if current else ""
229 try:
230 answer = input(f"{label} ({help_text}){suffix}: ").strip()
231 except EOFError:
232 break
233 if answer:
234 data[key] = answer
235
236
237# --- rendering ---------------------------------------------------------------
238def _split_list(value) -> list[str]:
239 if isinstance(value, (list, tuple)):
240 return [str(v).strip() for v in value if str(v).strip()]
241 return [p for p in str(value).replace(",", " ").split() if p]
242
243
244def _col_widths(available: int, fracs: list[float]) -> list[int]:
245 """Split `available` chars into columns by fraction; last column absorbs
246 the remainder so the widths always sum exactly."""
247 widths = [max(4, int(available * f)) for f in fracs[:-1]]
248 widths.append(available - sum(widths))
249 return widths
250
251
252# A compact 5-row block font for the banner generator. Each glyph is five
253# rows joined by "/". Uppercase letters, digits, space, and a few symbols.
254_FONT = {
255 "A": " ### /# #/#####/# #/# #", "B": "#### /# #/#### /# #/#### ",
256 "C": " ####/# /# /# / ####", "D": "#### /# #/# #/# #/#### ",
257 "E": "#####/# /### /# /#####", "F": "#####/# /### /# /# ",
258 "G": " ####/# /# ##/# #/ ####", "H": "# #/# #/#####/# #/# #",
259 "I": "#####/ # / # / # /#####", "J": "#####/ # / # /# # / ## ",
260 "K": "# #/# # /### /# # /# #", "L": "# /# /# /# /#####",
261 "M": "# #/## ##/# # #/# #/# #", "N": "# #/## #/# # #/# ##/# #",
262 "O": " ### /# #/# #/# #/ ### ", "P": "#### /# #/#### /# /# ",
263 "Q": " ### /# #/# # #/# # / ## #", "R": "#### /# #/#### /# # /# #",
264 "S": " ####/# / ### / #/#### ", "T": "#####/ # / # / # / # ",
265 "U": "# #/# #/# #/# #/ ### ", "V": "# #/# #/# #/ # # / # ",
266 "W": "# #/# #/# # #/## ##/# #", "X": "# #/ # # / # / # # /# #",
267 "Y": "# #/ # # / # / # / # ", "Z": "#####/ # / # / # /#####",
268 "0": " ### /# ##/# # #/## #/ ### ", "1": " # / ## / # / # /#####",
269 "2": " ### /# #/ ## / # /#####", "3": "#### / #/ ### / #/#### ",
270 "4": "# #/# #/#####/ #/ #", "5": "#####/# /#### / #/#### ",
271 "6": " ####/# /#### /# #/ ### ", "7": "#####/ # / # / # /# ",
272 "8": " ### /# #/ ### /# #/ ### ", "9": " ### /# #/ ####/ #/#### ",
273 " ": " / / / / ", "-": " / /#####/ / ",
274 ".": " / / / / # ", "!": " # / # / # / / # ",
275 ":": " / # / / # / ",
276}
277
278
279def banner(text: str) -> str:
280 """Render text as a 5-row ASCII banner using the built-in block font."""
281 rows = ["", "", "", "", ""]
282 for ch in text.upper():
283 glyph = _FONT.get(ch, _FONT[" "]).split("/")
284 for i in range(5):
285 rows[i] += glyph[i] + " "
286 return "\n".join(r.rstrip() for r in rows)
287
288
289def 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:
302 if joint is None:
303 return left + h * (width - 2) + right
304 return left + h * (cl + 2) + joint + h * rfill + right
305
306 def line(content: str = "") -> str:
307 return f"{v} " + content.ljust(inner) + f" {v}"
308
309 def center(content: str) -> str:
310 return f"{v} " + content.center(inner) + f" {v}"
311
312 def prow(left: str, right: str, centered: bool = False) -> str:
313 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}")
316
317 out: list[str] = []
318
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("")
325
326 out.append(hrule(s["tl"], s["tr"]))
327
328 # header: spaced title, then group tag + site, optional presents banner
329 title = str(data.get("title") or "UNTITLED").upper()
330 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)))
334 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
374 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
384 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 {}
395
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
423 news = str(r.get("news") or "").strip()
424 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
430 members = _split_list(r.get("members") or [])
431 if members:
432 section("MEMBERS")
433 names_block(members)
434
435 couriers = r.get("couriers") or {}
436 if couriers:
437 section("COURIERS")
438 for tier, who in couriers.items():
439 out.append(center(f"- {tier} -"))
440 names_block(_split_list(who))
441
442 if r.get("boards"):
443 table("BOARDS", r["boards"])
444 if r.get("affiliates"):
445 table("AFFILIATES", r["affiliates"])
446
447 outposts = _split_list(r.get("outposts") or [])
448 if outposts:
449 section("OUTPOSTS")
450 names_block(outposts)
451
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
461 out.append(hrule(s["bl"], s["br"], s["jb"] if prev_panel else None))
462 return "\n".join(out) + "\n"
463
464
465def _clip(text: str, width: int) -> str:
466 return text if len(text) <= width else text[: width - 2] + ".."
467
468
469# --- cli ---------------------------------------------------------------------
470def build_parser() -> argparse.ArgumentParser:
471 p = argparse.ArgumentParser(
472 prog="nfogen",
473 description="Generate a scene-style ASCII .nfo file.",
474 )
475 p.add_argument("-c", "--config", metavar="PATH",
476 help="config file (.toml or .json) with field defaults")
477 p.add_argument("-p", "--profile", metavar="PATH",
478 help="group profile (logo, roster, boards) merged under the config")
479 p.add_argument("-t", "--template", metavar="NAME",
480 help="bundled style template (see --list-templates)")
481 p.add_argument("--list-templates", action="store_true",
482 help="list bundled templates and exit")
483 p.add_argument("--mediainfo", metavar="PATH",
484 help="mediainfo JSON dump (or media file) to auto-fill media fields")
485 p.add_argument("-o", "--output", metavar="PATH",
486 help="write to PATH instead of stdout")
487 p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})")
488 p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})")
489 p.add_argument("-w", "--width", type=int,
490 help=f"box width in chars (default {DEFAULT_WIDTH})")
491 p.add_argument("--style", choices=sorted(STYLES),
492 help=f"box-drawing style (default {DEFAULT_STYLE})")
493 p.add_argument("--layout", choices=["rows", "panel"],
494 help=f"info layout (default {DEFAULT_LAYOUT})")
495 p.add_argument("--logo", metavar="PATH",
496 help="art file to place above the box")
497 p.add_argument("--logo-encoding", choices=["cp437", "utf8"],
498 help="encoding of the logo file (default cp437)")
499 p.add_argument("--banner", action=argparse.BooleanOptionalAction,
500 default=None, help="generate an ASCII banner logo (defaults to the group)")
501 p.add_argument("--banner-text", help="text for the generated banner")
502 p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8",
503 help="output encoding (default utf8; cp437 for true DOS nfos)")
504 p.add_argument("--presents", action=argparse.BooleanOptionalAction,
505 default=None, help="show the 'proudly presents' banner")
506 p.add_argument("--footer", action=argparse.BooleanOptionalAction,
507 default=None, help="show the closing disclaimer block")
508 p.add_argument("--footer-text", help="custom disclaimer text")
509 p.add_argument("-i", "--interactive", action="store_true",
510 help="prompt for every field")
511 p.add_argument("--no-input", action="store_true",
512 help="never prompt, even if fields are missing")
513 for key, _, help_text, _, _ in FIELDS:
514 p.add_argument(f"--{key}", help=help_text)
515 return p
516
517
518def _templates_dir() -> Path:
519 return SCRIPT_DIR / "templates"
520
521
522def _list_templates() -> list[str]:
523 d = _templates_dir()
524 return sorted(p.stem for p in d.glob("*.toml")) if d.is_dir() else []
525
526
527def main(argv: list[str] | None = None) -> int:
528 args = build_parser().parse_args(argv)
529
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
535 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 = {}
540 if args.template:
541 tpath = _templates_dir() / f"{args.template}.toml"
542 if not tpath.exists():
543 sys.exit(f"nfogen: unknown template '{args.template}' "
544 f"(have: {', '.join(_list_templates()) or 'none'})")
545 template = load_config(str(tpath))
546 profile_path = args.profile or release.get("profile")
547 profile = load_config(profile_path) if profile_path else {}
548 config = {**template, **profile, **release}
549
550 # merge: config defaults, then any CLI flag that was supplied
551 data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""}
552 for key, *_ in FIELDS:
553 val = getattr(args, key)
554 if val is not None:
555 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")
559 if mediainfo_src:
560 for key, val in load_mediainfo(mediainfo_src).items():
561 data.setdefault(key, val)
562
563 data.setdefault("date", date.today().isoformat())
564
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
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
579
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
584 # logo: an art file wins; otherwise an optional generated banner
585 logo = None
586 logo_path = args.logo or config.get("logo")
587 if logo_path:
588 enc = args.logo_encoding or config.get("logo_encoding") or "cp437"
589 try:
590 logo = Path(logo_path).read_text(encoding=enc, errors="replace")
591 except FileNotFoundError:
592 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)
605
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
613 if args.encoding == "cp437":
614 raw = text.encode("cp437", errors="replace")
615 if args.output:
616 Path(args.output).write_bytes(raw)
617 else:
618 sys.stdout.buffer.write(raw)
619 elif args.output:
620 Path(args.output).write_text(text, encoding="utf-8")
621 else:
622 sys.stdout.write(text)
623 return 0
624
625
626if __name__ == "__main__":
627 raise SystemExit(main())