| @@ -13,6 +13,7 @@ import os |
| 13 | import sys |
13 | import sys |
| 14 | import textwrap |
14 | import textwrap |
| 15 | from datetime import date |
15 | from datetime import date |
| |
16 | from itertools import zip_longest |
| 16 | from pathlib import Path |
17 | from pathlib import Path |
| 17 | |
18 | |
| 18 | # --- field schema ------------------------------------------------------------ |
19 | # --- field schema ------------------------------------------------------------ |
| @@ -23,27 +24,39 @@ from pathlib import Path |
| 23 | # kind : "line" scalar shown as an aligned INFO row |
24 | # kind : "line" scalar shown as an aligned INFO row |
| 24 | # "list" many values, shown space-joined in its own section |
25 | # "list" many values, shown space-joined in its own section |
| 25 | # "block" free text, wrapped in its own section |
26 | # "block" free text, wrapped in its own section |
| |
27 | # col : "L"/"R" panel column for "line" fields; None otherwise |
| 26 | |
28 | |
| 27 | FIELDS = [ |
29 | FIELDS = [ |
| 28 | ("title", "TITLE", "release title (required)", "line"), |
30 | ("title", "TITLE", "release title (required)", "line", None), |
| 29 | ("date", "DATE", "release date", "line"), |
31 | # --- release information (left panel) --- |
| 30 | ("type", "TYPE", "release type (MOVIE, TV, APP, ...)", "line"), |
32 | ("date", "DATE", "release date", "line", "L"), |
| 31 | ("source", "SOURCE", "source (BluRay, WEB-DL, CD, ...)", "line"), |
33 | ("source", "SOURCE", "source (BluRay, WEB-DL, CD, ...)", "line", "L"), |
| 32 | ("format", "FORMAT", "container / format", "line"), |
34 | ("cracker", "CRACKER", "cracker / cracked by", "line", "L"), |
| 33 | ("video", "VIDEO", "video codec / details", "line"), |
35 | ("supplier", "SUPPLIER", "supplier / supplied by", "line", "L"), |
| 34 | ("audio", "AUDIO", "audio codec / details", "line"), |
36 | ("packager", "PACKAGER", "packager / packed by", "line", "L"), |
| 35 | ("resolution", "RESOLUTION", "resolution", "line"), |
37 | ("protection", "PROTECTION", "protection type", "line", "L"), |
| 36 | ("language", "LANGUAGE", "language(s)", "line"), |
38 | ("install", "INSTALL", "install method", "line", "L"), |
| 37 | ("genre", "GENRE", "genre", "line"), |
39 | ("rating", "RATING", "rating", "line", "L"), |
| 38 | ("runtime", "RUNTIME", "runtime / duration", "line"), |
40 | # --- game / media information (right panel) --- |
| 39 | ("size", "SIZE", "total size", "line"), |
41 | ("type", "TYPE", "release type (MOVIE, TV, APP, ...)", "line", "R"), |
| 40 | ("files", "FILES", "file count / listing", "line"), |
42 | ("publisher", "PUBLISHER", "publisher / vendor", "line", "R"), |
| 41 | ("url", "URL", "reference url (imdb, homepage, ...)", "line"), |
43 | ("format", "FORMAT", "container / format", "line", "R"), |
| 42 | ("notes", "NOTES", "free-text notes / description", "block"), |
44 | ("disks", "DISKS", "number of disks / archives", "line", "R"), |
| 43 | ("greets", "GREETS", "groups to greet (space/comma sep)", "list"), |
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} |
58 | FIELD_KINDS = {k: kind for k, _, _, kind, _ in FIELDS} |
| 46 | INFO_KEYS = [k for k, _, _, kind in FIELDS if kind == "line" and k != "title"] |
59 | INFO_KEYS = [k for k, _, _, kind, _ in FIELDS if kind == "line" and k != "title"] |
| 47 | |
60 | |
| 48 | DEFAULT_SITE = "krz.sh" |
61 | DEFAULT_SITE = "krz.sh" |
| 49 | DEFAULT_GROUP = "KRZ" |
62 | DEFAULT_GROUP = "KRZ" |
| @@ -53,16 +66,20 @@ DEFAULT_FOOTER = ( |
| 53 | "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n" |
66 | "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n" |
| 54 | "if you enjoyed this release, buy it!" |
67 | "if you enjoyed this release, buy it!" |
| 55 | ) |
68 | ) |
| |
69 | DEFAULT_LAYOUT = "rows" |
| |
70 | PANEL_TITLES = ("Release Information", "Game Information") |
| 56 | |
71 | |
| 57 | # Box-drawing character sets. "single"/"double" are CP437-encodable; "block" |
72 | # Box-drawing character sets. "single"/"double" are CP437-encodable; "block" |
| 58 | # is a solid fill frame. Keys: corners + horizontal/vertical + tee separators. |
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 | STYLES = { |
76 | STYLES = { |
| 60 | "single": dict(tl="┌", tr="�", bl="└", br="┘", |
77 | "single": dict(tl="┌", tr="�", bl="└", br="┘", h="─", v="│", |
| 61 | h="─", v="│", ls="├", rs="┤"), |
78 | ls="├", rs="┤", jt="┬", jm="┼", jb="┴"), |
| 62 | "double": dict(tl="╔", tr="╗", bl="╚", br="�", |
79 | "double": dict(tl="╔", tr="╗", bl="╚", br="�", h="�", v="║", |
| 63 | h="�", v="║", ls="╠", rs="╣"), |
80 | ls="╠", rs="╣", jt="╦", jm="╬", jb="╩"), |
| 64 | "block": dict(tl="â–ˆ", tr="â–ˆ", bl="â–ˆ", br="â–ˆ", |
81 | "block": dict(tl="â–ˆ", tr="â–ˆ", bl="â–ˆ", br="â–ˆ", h="â–ˆ", v="â–ˆ", |
| 65 | h="â–ˆ", v="â–ˆ", ls="â–ˆ", rs="â–ˆ"), |
82 | ls="â–ˆ", rs="â–ˆ", jt="â–ˆ", jm="â–ˆ", jb="â–ˆ"), |
| 66 | } |
83 | } |
| 67 | |
84 | |
| 68 | CONFIG_CANDIDATES = [ |
85 | CONFIG_CANDIDATES = [ |
| @@ -105,7 +122,7 @@ def prompt_missing(data: dict, force_all: bool) -> None: |
| 105 | return |
122 | return |
| 106 | print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr) |
123 | print("nfogen: interactive mode (blank to skip)\n", file=sys.stderr) |
| 107 | for key in keys: |
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 | current = data.get(key, "") |
126 | current = data.get(key, "") |
| 110 | suffix = f" [{current}]" if current else "" |
127 | suffix = f" [{current}]" if current else "" |
| 111 | try: |
128 | try: |
| @@ -125,20 +142,42 @@ def _split_list(value) -> list[str]: |
| 125 | |
142 | |
| 126 | def render(data: dict, group: str, site: str, width: int, |
143 | def render(data: dict, group: str, site: str, width: int, |
| 127 | style: str = DEFAULT_STYLE, presents: bool = False, |
144 | style: str = DEFAULT_STYLE, presents: bool = False, |
| 128 | footer: str | None = None) -> str: |
145 | footer: str | None = None, layout: str = DEFAULT_LAYOUT, |
| 129 | inner = width - 4 # borders + one space of padding on each side |
146 | logo: str | None = None, |
| |
147 | panel_titles: tuple[str, str] = PANEL_TITLES) -> str: |
| 130 | s = STYLES[style] |
148 | s = STYLES[style] |
| 131 | top = s["tl"] + s["h"] * (width - 2) + s["tr"] |
149 | h, v = s["h"], s["v"] |
| 132 | sep = s["ls"] + s["h"] * (width - 2) + s["rs"] |
150 | inner = width - 4 # single-column content width (1 space padding) |
| 133 | bot = s["bl"] + s["h"] * (width - 2) + s["br"] |
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 | def line(content: str = "") -> str: |
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 | def center(content: str) -> str: |
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 | # header: spaced title, then group tag + site, optional presents banner |
182 | # header: spaced title, then group tag + site, optional presents banner |
| 144 | title = str(data.get("title") or "UNTITLED").upper() |
183 | title = str(data.get("title") or "UNTITLED").upper() |
| @@ -149,24 +188,46 @@ def render(data: dict, group: str, site: str, width: int, |
| 149 | if presents: |
188 | if presents: |
| 150 | out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner))) |
189 | out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner))) |
| 151 | |
190 | |
| 152 | # info rows |
191 | line_fields = [(k, lbl, col) for k, lbl, _, kind, col in FIELDS |
| 153 | rows = [(label, str(data[key])) |
192 | if kind == "line" and k != "title" and data.get(k)] |
| 154 | for key, label, _, _ in FIELDS |
193 | prev_panel = False |
| 155 | if key in INFO_KEYS and data.get(key)] |
194 | |
| 156 | if rows: |
195 | def sep_before() -> str: |
| 157 | out.append(sep) |
196 | nonlocal prev_panel |
| 158 | lw = max(len(label) for label, _ in rows) |
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 | avail = inner - (2 + lw + 2) |
220 | avail = inner - (2 + lw + 2) |
| 160 | for label, value in rows: |
221 | for k, lbl, _ in line_fields: |
| 161 | wrapped = textwrap.wrap(value, avail) or [""] |
222 | wrapped = textwrap.wrap(str(data[k]), avail) or [""] |
| 162 | out.append(line(f" {label:<{lw}} {wrapped[0]}")) |
223 | out.append(line(f" {lbl:<{lw}} {wrapped[0]}")) |
| 163 | for cont in wrapped[1:]: |
224 | for cont in wrapped[1:]: |
| 164 | out.append(line(" " * (2 + lw + 2) + cont)) |
225 | out.append(line(" " * (2 + lw + 2) + cont)) |
| 165 | |
226 | |
| 166 | # notes block |
227 | # notes block |
| 167 | notes = str(data.get("notes") or "").strip() |
228 | notes = str(data.get("notes") or "").strip() |
| 168 | if notes: |
229 | if notes: |
| 169 | out.append(sep) |
230 | out.append(sep_before()) |
| 170 | out.append(line(" NOTES")) |
231 | out.append(line(" NOTES")) |
| 171 | out.append(line()) |
232 | out.append(line()) |
| 172 | for para in notes.splitlines() or [""]: |
233 | for para in notes.splitlines() or [""]: |
| @@ -176,7 +237,7 @@ def render(data: dict, group: str, site: str, width: int, |
| 176 | # greets block |
237 | # greets block |
| 177 | greets = _split_list(data.get("greets") or []) |
238 | greets = _split_list(data.get("greets") or []) |
| 178 | if greets: |
239 | if greets: |
| 179 | out.append(sep) |
240 | out.append(sep_before()) |
| 180 | out.append(line(" GREETS")) |
241 | out.append(line(" GREETS")) |
| 181 | out.append(line()) |
242 | out.append(line()) |
| 182 | for wrapped in textwrap.wrap(" ".join(greets), inner - 4): |
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 | # footer disclaimer, centered |
246 | # footer disclaimer, centered |
| 186 | if footer: |
247 | if footer: |
| 187 | out.append(sep) |
248 | out.append(sep_before()) |
| 188 | out.append(line()) |
249 | out.append(line()) |
| 189 | for para in footer.splitlines(): |
250 | for para in footer.splitlines(): |
| 190 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): |
251 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): |
| 191 | out.append(center(wrapped)) |
252 | out.append(center(wrapped)) |
| 192 | out.append(line()) |
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 | return "\n".join(out) + "\n" |
256 | return "\n".join(out) + "\n" |
| 196 | |
257 | |
| 197 | |
258 | |
| @@ -215,6 +276,12 @@ def build_parser() -> argparse.ArgumentParser: |
| 215 | help=f"box width in chars (default {DEFAULT_WIDTH})") |
276 | help=f"box width in chars (default {DEFAULT_WIDTH})") |
| 216 | p.add_argument("--style", choices=sorted(STYLES), |
277 | p.add_argument("--style", choices=sorted(STYLES), |
| 217 | help=f"box-drawing style (default {DEFAULT_STYLE})") |
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 | p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8", |
285 | p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8", |
| 219 | help="output encoding (default utf8; cp437 for true DOS nfos)") |
286 | help="output encoding (default utf8; cp437 for true DOS nfos)") |
| 220 | p.add_argument("--presents", action=argparse.BooleanOptionalAction, |
287 | p.add_argument("--presents", action=argparse.BooleanOptionalAction, |
| @@ -226,7 +293,7 @@ def build_parser() -> argparse.ArgumentParser: |
| 226 | help="prompt for every field") |
293 | help="prompt for every field") |
| 227 | p.add_argument("--no-input", action="store_true", |
294 | p.add_argument("--no-input", action="store_true", |
| 228 | help="never prompt, even if fields are missing") |
295 | help="never prompt, even if fields are missing") |
| 229 | for key, _, help_text, _ in FIELDS: |
296 | for key, _, help_text, _, _ in FIELDS: |
| 230 | p.add_argument(f"--{key}", help=help_text) |
297 | p.add_argument(f"--{key}", help=help_text) |
| 231 | return p |
298 | return p |
| 232 | |
299 | |
| @@ -258,6 +325,19 @@ def main(argv: list[str] | None = None) -> int: |
| 258 | footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER |
325 | footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER |
| 259 | footer = footer_text if show_footer else None |
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 | interactive = sys.stdin.isatty() and not args.no_input |
341 | interactive = sys.stdin.isatty() and not args.no_input |
| 262 | if args.interactive: |
342 | if args.interactive: |
| 263 | prompt_missing(data, force_all=True) |
343 | prompt_missing(data, force_all=True) |
| @@ -268,7 +348,8 @@ def main(argv: list[str] | None = None) -> int: |
| 268 | sys.exit("nfogen: a title is required (use --title, a config, or -i)") |
348 | sys.exit("nfogen: a title is required (use --title, a config, or -i)") |
| 269 | |
349 | |
| 270 | text = render(data, group=group, site=site, width=width, style=style, |
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 | if args.encoding == "cp437": |
354 | if args.encoding == "cp437": |
| 274 | raw = text.encode("cp437", errors="replace") |
355 | raw = text.encode("cp437", errors="replace") |