| @@ -13,6 +13,7 @@ import os |
| 13 | 13 | import sys |
| 14 | 14 | import textwrap |
| 15 | 15 | from datetime import date |
| 16 | from itertools import zip_longest |
| 16 | 17 | from pathlib import Path |
| 17 | 18 | |
| 18 | 19 | # --- field schema ------------------------------------------------------------ |
| @@ -23,27 +24,39 @@ from pathlib import Path |
| 23 | 24 | # kind : "line" scalar shown as an aligned INFO row |
| 24 | 25 | # "list" many values, shown space-joined in its own section |
| 25 | 26 | # "block" free text, wrapped in its own section |
| 27 | # col : "L"/"R" panel column for "line" fields; None otherwise |
| 26 | 28 | |
| 27 | 29 | FIELDS = [ |
| 28 | | ("title", "TITLE", "release title (required)", "line"), |
| 29 | | ("date", "DATE", "release date", "line"), |
| 30 | | ("type", "TYPE", "release type (MOVIE, TV, APP, ...)", "line"), |
| 31 | | ("source", "SOURCE", "source (BluRay, WEB-DL, CD, ...)", "line"), |
| 32 | | ("format", "FORMAT", "container / format", "line"), |
| 33 | | ("video", "VIDEO", "video codec / details", "line"), |
| 34 | | ("audio", "AUDIO", "audio codec / details", "line"), |
| 35 | | ("resolution", "RESOLUTION", "resolution", "line"), |
| 36 | | ("language", "LANGUAGE", "language(s)", "line"), |
| 37 | | ("genre", "GENRE", "genre", "line"), |
| 38 | | ("runtime", "RUNTIME", "runtime / duration", "line"), |
| 39 | | ("size", "SIZE", "total size", "line"), |
| 40 | | ("files", "FILES", "file count / listing", "line"), |
| 41 | | ("url", "URL", "reference url (imdb, homepage, ...)", "line"), |
| 42 | | ("notes", "NOTES", "free-text notes / description", "block"), |
| 43 | | ("greets", "GREETS", "groups to greet (space/comma sep)", "list"), |
| 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), |
| 44 | 57 | ] |
| 45 | | FIELD_KINDS = {k: kind for k, _, _, kind in FIELDS} |
| 46 | | INFO_KEYS = [k for k, _, _, kind in FIELDS if kind == "line" and k != "title"] |
| 58 | FIELD_KINDS = {k: kind for k, _, _, kind, _ in FIELDS} |
| 59 | INFO_KEYS = [k for k, _, _, kind, _ in FIELDS if kind == "line" and k != "title"] |
| 47 | 60 | |
| 48 | 61 | DEFAULT_SITE = "krz.sh" |
| 49 | 62 | DEFAULT_GROUP = "KRZ" |
| @@ -53,16 +66,20 @@ DEFAULT_FOOTER = ( |
| 53 | 66 | "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n" |
| 54 | 67 | "if you enjoyed this release, buy it!" |
| 55 | 68 | ) |
| 69 | DEFAULT_LAYOUT = "rows" |
| 70 | PANEL_TITLES = ("Release Information", "Game Information") |
| 56 | 71 | |
| 57 | 72 | # Box-drawing character sets. "single"/"double" are CP437-encodable; "block" |
| 58 | 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. |
| 59 | 76 | STYLES = { |
| 60 | | "single": dict(tl="┌", tr="�", bl="└", br="┘", |
| 61 | | h="─", v="│", ls="├", rs="┤"), |
| 62 | | "double": dict(tl="╔", tr="╗", bl="╚", br="�", |
| 63 | | h="�", v="║", ls="╠", rs="╣"), |
| 64 | | "block": dict(tl="â–ˆ", tr="â–ˆ", bl="â–ˆ", br="â–ˆ", |
| 65 | | h="â–ˆ", v="â–ˆ", ls="â–ˆ", rs="â–ˆ"), |
| 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="â–ˆ"), |
| 66 | 83 | } |
| 67 | 84 | |
| 68 | 85 | CONFIG_CANDIDATES = [ |
| @@ -105,7 +122,7 @@ def prompt_missing(data: dict, force_all: bool) -> None: |
| 105 | 122 | return |
| 106 | 123 | print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr) |
| 107 | 124 | for key in keys: |
| 108 | | _, label, help_text, _ = next(f for f in FIELDS if f[0] == key) |
| 125 | _, label, help_text, _, _ = next(f for f in FIELDS if f[0] == key) |
| 109 | 126 | current = data.get(key, "") |
| 110 | 127 | suffix = f" [{current}]" if current else "" |
| 111 | 128 | try: |
| @@ -125,20 +142,42 @@ def _split_list(value) -> list[str]: |
| 125 | 142 | |
| 126 | 143 | def render(data: dict, group: str, site: str, width: int, |
| 127 | 144 | style: str = DEFAULT_STYLE, presents: bool = False, |
| 128 | | footer: str | None = None) -> str: |
| 129 | | inner = width - 4 # borders + one space of padding on each side |
| 145 | footer: str | None = None, layout: str = DEFAULT_LAYOUT, |
| 146 | logo: str | None = None, |
| 147 | panel_titles: tuple[str, str] = PANEL_TITLES) -> str: |
| 130 | 148 | s = STYLES[style] |
| 131 | | top = s["tl"] + s["h"] * (width - 2) + s["tr"] |
| 132 | | sep = s["ls"] + s["h"] * (width - 2) + s["rs"] |
| 133 | | bot = s["bl"] + s["h"] * (width - 2) + s["br"] |
| 149 | h, v = s["h"], s["v"] |
| 150 | inner = width - 4 # single-column content width (1 space padding) |
| 151 | cl = (width - 7) // 2 # left panel content width |
| 152 | cr = (width - 7) - cl # right panel content width |
| 153 | rfill = width - cl - 5 # h-run right of the divider in a rule |
| 154 | |
| 155 | def hrule(left: str, right: str, joint: str | None = None) -> str: |
| 156 | if joint is None: |
| 157 | return left + h * (width - 2) + right |
| 158 | return left + h * (cl + 2) + joint + h * rfill + right |
| 134 | 159 | |
| 135 | 160 | def line(content: str = "") -> str: |
| 136 | | return f"{s['v']} " + content.ljust(inner) + f" {s['v']}" |
| 161 | return f"{v} " + content.ljust(inner) + f" {v}" |
| 137 | 162 | |
| 138 | 163 | def center(content: str) -> str: |
| 139 | | return f"{s['v']} " + content.center(inner) + f" {s['v']}" |
| 164 | return f"{v} " + content.center(inner) + f" {v}" |
| 165 | |
| 166 | def prow(left: str, right: str, centered: bool = False) -> str: |
| 167 | fn = str.center if centered else str.ljust |
| 168 | return (f"{v} " + fn(_clip(left, cl), cl) |
| 169 | + f" {v} " + fn(_clip(right, cr), cr) + f" {v}") |
| 170 | |
| 171 | out: list[str] = [] |
| 172 | |
| 173 | # logo art above the box, block-centered to the full width |
| 174 | if logo: |
| 175 | art = logo.rstrip("\n").split("\n") |
| 176 | pad = max((width - max((len(a) for a in art), default=0)) // 2, 0) |
| 177 | out.extend((" " * pad + a).rstrip() for a in art) |
| 178 | out.append("") |
| 140 | 179 | |
| 141 | | out: list[str] = [top] |
| 180 | out.append(hrule(s["tl"], s["tr"])) |
| 142 | 181 | |
| 143 | 182 | # header: spaced title, then group tag + site, optional presents banner |
| 144 | 183 | title = str(data.get("title") or "UNTITLED").upper() |
| @@ -149,24 +188,46 @@ def render(data: dict, group: str, site: str, width: int, |
| 149 | 188 | if presents: |
| 150 | 189 | out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner))) |
| 151 | 190 | |
| 152 | | # info rows |
| 153 | | rows = [(label, str(data[key])) |
| 154 | | for key, label, _, _ in FIELDS |
| 155 | | if key in INFO_KEYS and data.get(key)] |
| 156 | | if rows: |
| 157 | | out.append(sep) |
| 158 | | lw = max(len(label) for label, _ in rows) |
| 191 | line_fields = [(k, lbl, col) for k, lbl, _, kind, col in FIELDS |
| 192 | if kind == "line" and k != "title" and data.get(k)] |
| 193 | prev_panel = False |
| 194 | |
| 195 | def sep_before() -> str: |
| 196 | nonlocal prev_panel |
| 197 | joint = s["jb"] if prev_panel else None |
| 198 | prev_panel = False |
| 199 | return hrule(s["ls"], s["rs"], joint) |
| 200 | |
| 201 | # info: two-column panel or single-column rows |
| 202 | if layout == "panel" and line_fields: |
| 203 | left = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "L"] |
| 204 | right = [(lbl, str(data[k])) for k, lbl, col in line_fields if col == "R"] |
| 205 | lw_l = max((len(lbl) for lbl, _ in left), default=0) |
| 206 | lw_r = max((len(lbl) for lbl, _ in right), default=0) |
| 207 | |
| 208 | def cell(pair, lw): |
| 209 | return f" {pair[0]:<{lw}} : {pair[1]}" if pair else "" |
| 210 | |
| 211 | out.append(hrule(s["ls"], s["rs"], s["jt"])) |
| 212 | out.append(prow(panel_titles[0], panel_titles[1], centered=True)) |
| 213 | out.append(hrule(s["ls"], s["rs"], s["jm"])) |
| 214 | for lp, rp in zip_longest(left, right): |
| 215 | out.append(prow(cell(lp, lw_l), cell(rp, lw_r))) |
| 216 | prev_panel = True |
| 217 | elif line_fields: |
| 218 | out.append(hrule(s["ls"], s["rs"])) |
| 219 | lw = max(len(lbl) for _, lbl, _ in line_fields) |
| 159 | 220 | avail = inner - (2 + lw + 2) |
| 160 | | for label, value in rows: |
| 161 | | wrapped = textwrap.wrap(value, avail) or [""] |
| 162 | | out.append(line(f" {label:<{lw}} {wrapped[0]}")) |
| 221 | for k, lbl, _ in line_fields: |
| 222 | wrapped = textwrap.wrap(str(data[k]), avail) or [""] |
| 223 | out.append(line(f" {lbl:<{lw}} {wrapped[0]}")) |
| 163 | 224 | for cont in wrapped[1:]: |
| 164 | 225 | out.append(line(" " * (2 + lw + 2) + cont)) |
| 165 | 226 | |
| 166 | 227 | # notes block |
| 167 | 228 | notes = str(data.get("notes") or "").strip() |
| 168 | 229 | if notes: |
| 169 | | out.append(sep) |
| 230 | out.append(sep_before()) |
| 170 | 231 | out.append(line(" NOTES")) |
| 171 | 232 | out.append(line()) |
| 172 | 233 | for para in notes.splitlines() or [""]: |
| @@ -176,7 +237,7 @@ def render(data: dict, group: str, site: str, width: int, |
| 176 | 237 | # greets block |
| 177 | 238 | greets = _split_list(data.get("greets") or []) |
| 178 | 239 | if greets: |
| 179 | | out.append(sep) |
| 240 | out.append(sep_before()) |
| 180 | 241 | out.append(line(" GREETS")) |
| 181 | 242 | out.append(line()) |
| 182 | 243 | for wrapped in textwrap.wrap(" ".join(greets), inner - 4): |
| @@ -184,14 +245,14 @@ def render(data: dict, group: str, site: str, width: int, |
| 184 | 245 | |
| 185 | 246 | # footer disclaimer, centered |
| 186 | 247 | if footer: |
| 187 | | out.append(sep) |
| 248 | out.append(sep_before()) |
| 188 | 249 | out.append(line()) |
| 189 | 250 | for para in footer.splitlines(): |
| 190 | 251 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): |
| 191 | 252 | out.append(center(wrapped)) |
| 192 | 253 | out.append(line()) |
| 193 | 254 | |
| 194 | | out.append(bot) |
| 255 | out.append(hrule(s["bl"], s["br"], s["jb"] if prev_panel else None)) |
| 195 | 256 | return "\n".join(out) + "\n" |
| 196 | 257 | |
| 197 | 258 | |
| @@ -215,6 +276,12 @@ def build_parser() -> argparse.ArgumentParser: |
| 215 | 276 | help=f"box width in chars (default {DEFAULT_WIDTH})") |
| 216 | 277 | p.add_argument("--style", choices=sorted(STYLES), |
| 217 | 278 | help=f"box-drawing style (default {DEFAULT_STYLE})") |
| 279 | p.add_argument("--layout", choices=["rows", "panel"], |
| 280 | help=f"info layout (default {DEFAULT_LAYOUT})") |
| 281 | p.add_argument("--logo", metavar="PATH", |
| 282 | help="art file to place above the box") |
| 283 | p.add_argument("--logo-encoding", choices=["cp437", "utf8"], |
| 284 | help="encoding of the logo file (default cp437)") |
| 218 | 285 | p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8", |
| 219 | 286 | help="output encoding (default utf8; cp437 for true DOS nfos)") |
| 220 | 287 | p.add_argument("--presents", action=argparse.BooleanOptionalAction, |
| @@ -226,7 +293,7 @@ def build_parser() -> argparse.ArgumentParser: |
| 226 | 293 | help="prompt for every field") |
| 227 | 294 | p.add_argument("--no-input", action="store_true", |
| 228 | 295 | help="never prompt, even if fields are missing") |
| 229 | | for key, _, help_text, _ in FIELDS: |
| 296 | for key, _, help_text, _, _ in FIELDS: |
| 230 | 297 | p.add_argument(f"--{key}", help=help_text) |
| 231 | 298 | return p |
| 232 | 299 | |
| @@ -258,6 +325,19 @@ def main(argv: list[str] | None = None) -> int: |
| 258 | 325 | footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER |
| 259 | 326 | footer = footer_text if show_footer else None |
| 260 | 327 | |
| 328 | layout = args.layout or config.get("layout") or DEFAULT_LAYOUT |
| 329 | panel_titles = (config.get("panel_left") or PANEL_TITLES[0], |
| 330 | config.get("panel_right") or PANEL_TITLES[1]) |
| 331 | |
| 332 | logo = None |
| 333 | logo_path = args.logo or config.get("logo") |
| 334 | if logo_path: |
| 335 | enc = args.logo_encoding or config.get("logo_encoding") or "cp437" |
| 336 | try: |
| 337 | logo = Path(logo_path).read_text(encoding=enc, errors="replace") |
| 338 | except FileNotFoundError: |
| 339 | sys.exit(f"nfogen: logo not found: {logo_path}") |
| 340 | |
| 261 | 341 | interactive = sys.stdin.isatty() and not args.no_input |
| 262 | 342 | if args.interactive: |
| 263 | 343 | prompt_missing(data, force_all=True) |
| @@ -268,7 +348,8 @@ def main(argv: list[str] | None = None) -> int: |
| 268 | 348 | sys.exit("nfogen: a title is required (use --title, a config, or -i)") |
| 269 | 349 | |
| 270 | 350 | text = render(data, group=group, site=site, width=width, style=style, |
| 271 | | presents=presents, footer=footer) |
| 351 | presents=presents, footer=footer, layout=layout, |
| 352 | logo=logo, panel_titles=panel_titles) |
| 272 | 353 | |
| 273 | 354 | if args.encoding == "cp437": |
| 274 | 355 | raw = text.encode("cp437", errors="replace") |