krz/nfogen
A scene-style ASCII .nfo generator.
clone: git clone https://gitbay.org/krz/nfogen.git
v1.1.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 sys
14import textwrap
15from datetime import date
16from itertools import zip_longest
17from pathlib import Path
18
19# --- field schema ------------------------------------------------------------
20# One source of truth. Drives argparse flags, interactive prompts, and render.
21# key : internal name / config key / flag name
22# label : shown in the rendered nfo (info rows) or as a section header
23# help : argparse + prompt help text
24# kind : "line" scalar shown as an aligned INFO row
25# "list" many values, shown space-joined in its own section
26# "block" free text, wrapped in its own section
27# col : "L"/"R" panel column for "line" fields; None otherwise
28
29FIELDS = [
30 ("title", "TITLE", "release title (required)", "line", None),
31 # --- release information (left panel) ---
32 ("date", "DATE", "release date", "line", "L"),
33 ("source", "SOURCE", "source (BluRay, WEB-DL, CD, ...)", "line", "L"),
34 ("cracker", "CRACKER", "cracker / cracked by", "line", "L"),
35 ("supplier", "SUPPLIER", "supplier / supplied by", "line", "L"),
36 ("packager", "PACKAGER", "packager / packed by", "line", "L"),
37 ("protection", "PROTECTION", "protection type", "line", "L"),
38 ("install", "INSTALL", "install method", "line", "L"),
39 ("rating", "RATING", "rating", "line", "L"),
40 # --- game / media information (right panel) ---
41 ("type", "TYPE", "release type (MOVIE, TV, APP, ...)", "line", "R"),
42 ("publisher", "PUBLISHER", "publisher / vendor", "line", "R"),
43 ("format", "FORMAT", "container / format", "line", "R"),
44 ("disks", "DISKS", "number of disks / archives", "line", "R"),
45 ("video", "VIDEO", "video codec / details", "line", "R"),
46 ("audio", "AUDIO", "audio codec / details", "line", "R"),
47 ("resolution", "RESOLUTION", "resolution", "line", "R"),
48 ("language", "LANGUAGE", "language(s)", "line", "R"),
49 ("genre", "GENRE", "genre", "line", "R"),
50 ("runtime", "RUNTIME", "runtime / duration", "line", "R"),
51 ("size", "SIZE", "total size", "line", "R"),
52 ("files", "FILES", "file count / listing", "line", "R"),
53 ("url", "URL", "reference url (imdb, homepage, ...)", "line", "R"),
54 # --- free-form sections ---
55 ("notes", "NOTES", "free-text notes / description", "block", None),
56 ("greets", "GREETS", "groups to greet (space/comma sep)", "list", None),
57]
58FIELD_KINDS = {k: kind for k, _, _, kind, _ in FIELDS}
59INFO_KEYS = [k for k, _, _, kind, _ in FIELDS if kind == "line" and k != "title"]
60
61DEFAULT_SITE = "krz.sh"
62DEFAULT_GROUP = "KRZ"
63DEFAULT_WIDTH = 79 # DOS 80-column standard, less one margin column
64DEFAULT_STYLE = "double"
65DEFAULT_FOOTER = (
66 "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n"
67 "if you enjoyed this release, buy it!"
68)
69DEFAULT_LAYOUT = "rows"
70PANEL_TITLES = ("Release Information", "Game Information")
71
72# Box-drawing character sets. "single"/"double" are CP437-encodable; "block"
73# is a solid fill frame. Keys: corners + horizontal/vertical + tee separators.
74# jt/jm/jb are the down-tee, cross, and up-tee used where the panel's
75# vertical column divider meets a horizontal rule.
76STYLES = {
77 "single": dict(tl="┌", tr="┐", bl="└", br="┘", h="─", v="│",
78 ls="├", rs="┤", jt="┬", jm="┼", jb="┴"),
79 "double": dict(tl="╔", tr="╗", bl="╚", br="╝", h="═", v="║",
80 ls="╠", rs="╣", jt="╦", jm="╬", jb="╩"),
81 "block": dict(tl="█", tr="█", bl="█", br="█", h="█", v="█",
82 ls="█", rs="█", jt="█", jm="█", jb="█"),
83}
84
85CONFIG_CANDIDATES = [
86 Path("nfogen.toml"),
87 Path(".nfogen.toml"),
88 Path.home() / ".config" / "nfogen" / "config.toml",
89]
90
91
92# --- config ------------------------------------------------------------------
93def load_config(explicit: str | None) -> dict:
94 """Load defaults from a TOML or JSON file. Returns {} if none found."""
95 path = None
96 if explicit:
97 path = Path(explicit)
98 if not path.exists():
99 sys.exit(f"nfogen: config not found: {explicit}")
100 else:
101 path = next((p for p in CONFIG_CANDIDATES if p.exists()), None)
102 if path is None:
103 return {}
104
105 text = path.read_text(encoding="utf-8")
106 if path.suffix == ".json":
107 return json.loads(text)
108 try:
109 import tomllib
110 except ModuleNotFoundError:
111 sys.exit("nfogen: TOML config needs Python 3.11+ (or use a .json file)")
112 return tomllib.loads(text)
113
114
115# --- interactive -------------------------------------------------------------
116def prompt_missing(data: dict, force_all: bool) -> None:
117 """Fill fields from stdin. Prompts every field when force_all, else only
118 the missing ones. Enter keeps the current/blank value."""
119 keys = [k for k, *_ in FIELDS] if force_all else \
120 [k for k, *_ in FIELDS if not data.get(k)]
121 if not keys:
122 return
123 print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr)
124 for key in keys:
125 _, label, help_text, _, _ = next(f for f in FIELDS if f[0] == key)
126 current = data.get(key, "")
127 suffix = f" [{current}]" if current else ""
128 try:
129 answer = input(f"{label} ({help_text}){suffix}: ").strip()
130 except EOFError:
131 break
132 if answer:
133 data[key] = answer
134
135
136# --- rendering ---------------------------------------------------------------
137def _split_list(value) -> list[str]:
138 if isinstance(value, (list, tuple)):
139 return [str(v).strip() for v in value if str(v).strip()]
140 return [p for p in str(value).replace(",", " ").split() if p]
141
142
143def _col_widths(available: int, fracs: list[float]) -> list[int]:
144 """Split `available` chars into columns by fraction; last column absorbs
145 the remainder so the widths always sum exactly."""
146 widths = [max(4, int(available * f)) for f in fracs[:-1]]
147 widths.append(available - sum(widths))
148 return widths
149
150
151def render(data: dict, group: str, site: str, width: int,
152 style: str = DEFAULT_STYLE, presents: bool = False,
153 footer: str | None = None, layout: str = DEFAULT_LAYOUT,
154 logo: str | None = None, roster: dict | None = None,
155 panel_titles: tuple[str, str] = PANEL_TITLES) -> str:
156 s = STYLES[style]
157 h, v = s["h"], s["v"]
158 inner = width - 4 # single-column content width (1 space padding)
159 cl = (width - 7) // 2 # left panel content width
160 cr = (width - 7) - cl # right panel content width
161 rfill = width - cl - 5 # h-run right of the divider in a rule
162
163 def hrule(left: str, right: str, joint: str | None = None) -> str:
164 if joint is None:
165 return left + h * (width - 2) + right
166 return left + h * (cl + 2) + joint + h * rfill + right
167
168 def line(content: str = "") -> str:
169 return f"{v} " + content.ljust(inner) + f" {v}"
170
171 def center(content: str) -> str:
172 return f"{v} " + content.center(inner) + f" {v}"
173
174 def prow(left: str, right: str, centered: bool = False) -> str:
175 fn = str.center if centered else str.ljust
176 return (f"{v} " + fn(_clip(left, cl), cl)
177 + f" {v} " + fn(_clip(right, cr), cr) + f" {v}")
178
179 out: list[str] = []
180
181 # logo art above the box, block-centered to the full width
182 if logo:
183 art = logo.rstrip("\n").split("\n")
184 pad = max((width - max((len(a) for a in art), default=0)) // 2, 0)
185 out.extend((" " * pad + a).rstrip() for a in art)
186 out.append("")
187
188 out.append(hrule(s["tl"], s["tr"]))
189
190 # header: spaced title, then group tag + site, optional presents banner
191 title = str(data.get("title") or "UNTITLED").upper()
192 spaced = " ".join(title)
193 heading = spaced if len(spaced) <= inner else title
194 out.append(center(_clip(heading, inner)))
195 out.append(center(_clip(f"[ {group} ] {site}", inner)))
196 if presents:
197 out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner)))
198
199 line_fields = [(k, lbl, col) for k, lbl, _, kind, col in FIELDS
200 if kind == "line" and k != "title" and data.get(k)]
201 prev_panel = False
202
203 def sep_before() -> str:
204 nonlocal prev_panel
205 joint = s["jb"] if prev_panel else None
206 prev_panel = False
207 return hrule(s["ls"], s["rs"], joint)
208
209 # info: two-column panel or single-column rows
210 if layout == "panel" and line_fields:
211 left = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "L"]
212 right = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "R"]
213 lw_l = max((len(lbl) for lbl, _ in left), default=0)
214 lw_r = max((len(lbl) for lbl, _ in right), default=0)
215
216 def cell(pair, lw):
217 return f" {pair[0]:<{lw}} : {pair[1]}" if pair else ""
218
219 out.append(hrule(s["ls"], s["rs"], s["jt"]))
220 out.append(prow(panel_titles[0], panel_titles[1], centered=True))
221 out.append(hrule(s["ls"], s["rs"], s["jm"]))
222 for lp, rp in zip_longest(left, right):
223 out.append(prow(cell(lp, lw_l), cell(rp, lw_r)))
224 prev_panel = True
225 elif line_fields:
226 out.append(hrule(s["ls"], s["rs"]))
227 lw = max(len(lbl) for _, lbl, _ in line_fields)
228 avail = inner - (2 + lw + 2)
229 for k, lbl, _ in line_fields:
230 wrapped = textwrap.wrap(str(data[k]), avail) or [""]
231 out.append(line(f" {lbl:<{lw}} {wrapped[0]}"))
232 for cont in wrapped[1:]:
233 out.append(line(" " * (2 + lw + 2) + cont))
234
235 # notes block
236 notes = str(data.get("notes") or "").strip()
237 if notes:
238 out.append(sep_before())
239 out.append(line(" NOTES"))
240 out.append(line())
241 for para in notes.splitlines() or [""]:
242 for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
243 out.append(line(" " + wrapped))
244
245 # greets block
246 greets = _split_list(data.get("greets") or [])
247 if greets:
248 out.append(sep_before())
249 out.append(line(" GREETS"))
250 out.append(line())
251 for wrapped in textwrap.wrap(" ".join(greets), inner - 4):
252 out.append(line(" " + wrapped))
253
254 # group roster sections (from a profile): news, members, couriers,
255 # boards/affiliates tables, outposts
256 r = roster or {}
257
258 def section(header: str) -> None:
259 out.append(sep_before())
260 out.append(line(f" {header}"))
261 out.append(line())
262
263 def names_block(names: list[str]) -> None:
264 for wrapped in textwrap.wrap(" ".join(names), inner - 4):
265 out.append(center(wrapped))
266 out.append(line())
267
268 def table(header: str, entries: list) -> None:
269 section(header)
270 if entries and isinstance(entries[0], dict):
271 ws = _col_widths(inner - 2, [0.34, 0.24, 0.22, 0.20])
272 keys = ["name", "role", "sysop", "phone"]
273
274 def row(vals: list[str]) -> str:
275 cells = "".join(_clip(str(x), w - 1).ljust(w)
276 for x, w in zip(vals, ws))
277 return line(" " + cells)
278
279 out.append(row(["BOARD", "ROLE", "SYSOP", "CONTACT"]))
280 for e in entries:
281 out.append(row([e.get(k, "") for k in keys]))
282 else:
283 names_block(_split_list(entries))
284
285 news = str(r.get("news") or "").strip()
286 if news:
287 section("GROUP NEWS")
288 for para in news.splitlines():
289 for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
290 out.append(line(" " + wrapped))
291
292 members = _split_list(r.get("members") or [])
293 if members:
294 section("MEMBERS")
295 names_block(members)
296
297 couriers = r.get("couriers") or {}
298 if couriers:
299 section("COURIERS")
300 for tier, who in couriers.items():
301 out.append(center(f"- {tier} -"))
302 names_block(_split_list(who))
303
304 if r.get("boards"):
305 table("BOARDS", r["boards"])
306 if r.get("affiliates"):
307 table("AFFILIATES", r["affiliates"])
308
309 outposts = _split_list(r.get("outposts") or [])
310 if outposts:
311 section("OUTPOSTS")
312 names_block(outposts)
313
314 # footer disclaimer, centered
315 if footer:
316 out.append(sep_before())
317 out.append(line())
318 for para in footer.splitlines():
319 for wrapped in (textwrap.wrap(para, inner - 4) or [""]):
320 out.append(center(wrapped))
321 out.append(line())
322
323 out.append(hrule(s["bl"], s["br"], s["jb"] if prev_panel else None))
324 return "\n".join(out) + "\n"
325
326
327def _clip(text: str, width: int) -> str:
328 return text if len(text) <= width else text[: width - 2] + ".."
329
330
331# --- cli ---------------------------------------------------------------------
332def build_parser() -> argparse.ArgumentParser:
333 p = argparse.ArgumentParser(
334 prog="nfogen",
335 description="Generate a scene-style ASCII .nfo file.",
336 )
337 p.add_argument("-c", "--config", metavar="PATH",
338 help="config file (.toml or .json) with field defaults")
339 p.add_argument("-p", "--profile", metavar="PATH",
340 help="group profile (logo, roster, boards) merged under the config")
341 p.add_argument("-o", "--output", metavar="PATH",
342 help="write to PATH instead of stdout")
343 p.add_argument("-g", "--group", help=f"release group (default {DEFAULT_GROUP})")
344 p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})")
345 p.add_argument("-w", "--width", type=int,
346 help=f"box width in chars (default {DEFAULT_WIDTH})")
347 p.add_argument("--style", choices=sorted(STYLES),
348 help=f"box-drawing style (default {DEFAULT_STYLE})")
349 p.add_argument("--layout", choices=["rows", "panel"],
350 help=f"info layout (default {DEFAULT_LAYOUT})")
351 p.add_argument("--logo", metavar="PATH",
352 help="art file to place above the box")
353 p.add_argument("--logo-encoding", choices=["cp437", "utf8"],
354 help="encoding of the logo file (default cp437)")
355 p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8",
356 help="output encoding (default utf8; cp437 for true DOS nfos)")
357 p.add_argument("--presents", action=argparse.BooleanOptionalAction,
358 default=None, help="show the 'proudly presents' banner")
359 p.add_argument("--footer", action=argparse.BooleanOptionalAction,
360 default=None, help="show the closing disclaimer block")
361 p.add_argument("--footer-text", help="custom disclaimer text")
362 p.add_argument("-i", "--interactive", action="store_true",
363 help="prompt for every field")
364 p.add_argument("--no-input", action="store_true",
365 help="never prompt, even if fields are missing")
366 for key, _, help_text, _, _ in FIELDS:
367 p.add_argument(f"--{key}", help=help_text)
368 return p
369
370
371def main(argv: list[str] | None = None) -> int:
372 args = build_parser().parse_args(argv)
373 release = load_config(args.config)
374
375 # a group profile supplies base values (logo, roster, style, footer, ...);
376 # the release config overrides it, and CLI flags override both.
377 profile_path = args.profile or release.get("profile")
378 profile = load_config(profile_path) if profile_path else {}
379 config = {**profile, **release}
380
381 # merge: config defaults, then any CLI flag that was supplied
382 data = {k: config[k] for k, *_ in FIELDS if k in config and config[k] != ""}
383 for key, *_ in FIELDS:
384 val = getattr(args, key)
385 if val is not None:
386 data[key] = val
387 data.setdefault("date", date.today().isoformat())
388
389 group = args.group or config.get("group") or DEFAULT_GROUP
390 site = args.site or config.get("site") or DEFAULT_SITE
391 width = args.width or config.get("width") or DEFAULT_WIDTH
392 style = args.style or config.get("style") or DEFAULT_STYLE
393
394 def resolve(flag, key, default):
395 if flag is not None:
396 return flag
397 return config.get(key, default)
398
399 presents = resolve(args.presents, "presents", False)
400 show_footer = resolve(args.footer, "footer", False)
401 footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER
402 footer = footer_text if show_footer else None
403
404 layout = args.layout or config.get("layout") or DEFAULT_LAYOUT
405 panel_titles = (config.get("panel_left") or PANEL_TITLES[0],
406 config.get("panel_right") or PANEL_TITLES[1])
407
408 logo = None
409 logo_path = args.logo or config.get("logo")
410 if logo_path:
411 enc = args.logo_encoding or config.get("logo_encoding") or "cp437"
412 try:
413 logo = Path(logo_path).read_text(encoding=enc, errors="replace")
414 except FileNotFoundError:
415 sys.exit(f"nfogen: logo not found: {logo_path}")
416
417 roster = {k: config[k] for k in
418 ("news", "members", "couriers", "boards", "affiliates", "outposts")
419 if config.get(k)}
420
421 interactive = sys.stdin.isatty() and not args.no_input
422 if args.interactive:
423 prompt_missing(data, force_all=True)
424 elif not data.get("title") and interactive:
425 prompt_missing(data, force_all=False)
426
427 if not data.get("title"):
428 sys.exit("nfogen: a title is required (use --title, a config, or -i)")
429
430 text = render(data, group=group, site=site, width=width, style=style,
431 presents=presents, footer=footer, layout=layout,
432 logo=logo, roster=roster, panel_titles=panel_titles)
433
434 if args.encoding == "cp437":
435 raw = text.encode("cp437", errors="replace")
436 if args.output:
437 Path(args.output).write_bytes(raw)
438 else:
439 sys.stdout.buffer.write(raw)
440 elif args.output:
441 Path(args.output).write_text(text, encoding="utf-8")
442 else:
443 sys.stdout.write(text)
444 return 0
445
446
447if __name__ == "__main__":
448 raise SystemExit(main())