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())