| @@ -47,7 +47,24 @@ INFO_KEYS = [k for k, _, _, kind in FIELDS if kind == "line" and k != "title"] |
| 47 | |
47 | |
| 48 | DEFAULT_SITE = "krz.sh" |
48 | DEFAULT_SITE = "krz.sh" |
| 49 | DEFAULT_GROUP = "KRZ" |
49 | DEFAULT_GROUP = "KRZ" |
| 50 | DEFAULT_WIDTH = 64 |
50 | DEFAULT_WIDTH = 79 # DOS 80-column standard, less one margin column |
| |
51 | DEFAULT_STYLE = "double" |
| |
52 | DEFAULT_FOOTER = ( |
| |
53 | "SUPPORT THE COMPANIES THAT PRODUCE QUALITY SOFTWARE\n" |
| |
54 | "if you enjoyed this release, buy it!" |
| |
55 | ) |
| |
56 | |
| |
57 | # Box-drawing character sets. "single"/"double" are CP437-encodable; "block" |
| |
58 | # is a solid fill frame. Keys: corners + horizontal/vertical + tee separators. |
| |
59 | 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="█"), |
| |
66 | } |
| |
67 | |
| 51 | CONFIG_CANDIDATES = [ |
68 | CONFIG_CANDIDATES = [ |
| 52 | Path("nfogen.toml"), |
69 | Path("nfogen.toml"), |
| 53 | Path(".nfogen.toml"), |
70 | Path(".nfogen.toml"), |
| @@ -106,26 +123,31 @@ def _split_list(value) -> list[str]: |
| 106 | return [p for p in str(value).replace(",", " ").split() if p] |
123 | return [p for p in str(value).replace(",", " ").split() if p] |
| 107 | |
124 | |
| 108 | |
125 | |
| 109 | def render(data: dict, group: str, site: str, width: int) -> str: |
126 | def render(data: dict, group: str, site: str, width: int, |
| |
127 | style: str = DEFAULT_STYLE, presents: bool = False, |
| |
128 | footer: str | None = None) -> str: |
| 110 | inner = width - 4 # borders + one space of padding on each side |
129 | inner = width - 4 # borders + one space of padding on each side |
| 111 | top = "┌" + "─" * (width - 2) + "┐" |
130 | s = STYLES[style] |
| 112 | sep = "├" + "─" * (width - 2) + "┤" |
131 | top = s["tl"] + s["h"] * (width - 2) + s["tr"] |
| 113 | bot = "└" + "─" * (width - 2) + "┘" |
132 | sep = s["ls"] + s["h"] * (width - 2) + s["rs"] |
| |
133 | bot = s["bl"] + s["h"] * (width - 2) + s["br"] |
| 114 | |
134 | |
| 115 | def line(content: str = "") -> str: |
135 | def line(content: str = "") -> str: |
| 116 | return "│ " + content.ljust(inner) + " │" |
136 | return f"{s['v']} " + content.ljust(inner) + f" {s['v']}" |
| 117 | |
137 | |
| 118 | def center(content: str) -> str: |
138 | def center(content: str) -> str: |
| 119 | return "│ " + content.center(inner) + " │" |
139 | return f"{s['v']} " + content.center(inner) + f" {s['v']}" |
| 120 | |
140 | |
| 121 | out: list[str] = [top] |
141 | out: list[str] = [top] |
| 122 | |
142 | |
| 123 | # header: spaced title, then group tag + site |
143 | # header: spaced title, then group tag + site, optional presents banner |
| 124 | title = str(data.get("title") or "UNTITLED").upper() |
144 | title = str(data.get("title") or "UNTITLED").upper() |
| 125 | spaced = " ".join(title) |
145 | spaced = " ".join(title) |
| 126 | heading = spaced if len(spaced) <= inner else title |
146 | heading = spaced if len(spaced) <= inner else title |
| 127 | out.append(center(_clip(heading, inner))) |
147 | out.append(center(_clip(heading, inner))) |
| 128 | out.append(center(_clip(f"[ {group} ] {site}", inner))) |
148 | out.append(center(_clip(f"[ {group} ] {site}", inner))) |
| |
149 | if presents: |
| |
150 | out.append(center(_clip(f"-={{ {group.upper()} proudly presents }}=-", inner))) |
| 129 | |
151 | |
| 130 | # info rows |
152 | # info rows |
| 131 | rows = [(label, str(data[key])) |
153 | rows = [(label, str(data[key])) |
| @@ -160,12 +182,21 @@ def render(data: dict, group: str, site: str, width: int) -> str: |
| 160 | for wrapped in textwrap.wrap(" ".join(greets), inner - 4): |
182 | for wrapped in textwrap.wrap(" ".join(greets), inner - 4): |
| 161 | out.append(line(" " + wrapped)) |
183 | out.append(line(" " + wrapped)) |
| 162 | |
184 | |
| |
185 | # footer disclaimer, centered |
| |
186 | if footer: |
| |
187 | out.append(sep) |
| |
188 | out.append(line()) |
| |
189 | for para in footer.splitlines(): |
| |
190 | for wrapped in (textwrap.wrap(para, inner - 4) or [""]): |
| |
191 | out.append(center(wrapped)) |
| |
192 | out.append(line()) |
| |
193 | |
| 163 | out.append(bot) |
194 | out.append(bot) |
| 164 | return "\n".join(out) + "\n" |
195 | return "\n".join(out) + "\n" |
| 165 | |
196 | |
| 166 | |
197 | |
| 167 | def _clip(text: str, width: int) -> str: |
198 | def _clip(text: str, width: int) -> str: |
| 168 | return text if len(text) <= width else text[: width - 1] + "…" |
199 | return text if len(text) <= width else text[: width - 2] + ".." |
| 169 | |
200 | |
| 170 | |
201 | |
| 171 | # --- cli --------------------------------------------------------------------- |
202 | # --- cli --------------------------------------------------------------------- |
| @@ -182,6 +213,15 @@ def build_parser() -> argparse.ArgumentParser: |
| 182 | p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})") |
213 | p.add_argument("-s", "--site", help=f"site tag (default {DEFAULT_SITE})") |
| 183 | p.add_argument("-w", "--width", type=int, |
214 | p.add_argument("-w", "--width", type=int, |
| 184 | help=f"box width in chars (default {DEFAULT_WIDTH})") |
215 | help=f"box width in chars (default {DEFAULT_WIDTH})") |
| |
216 | p.add_argument("--style", choices=sorted(STYLES), |
| |
217 | help=f"box-drawing style (default {DEFAULT_STYLE})") |
| |
218 | p.add_argument("--encoding", choices=["utf8", "cp437"], default="utf8", |
| |
219 | help="output encoding (default utf8; cp437 for true DOS nfos)") |
| |
220 | p.add_argument("--presents", action=argparse.BooleanOptionalAction, |
| |
221 | default=None, help="show the 'proudly presents' banner") |
| |
222 | p.add_argument("--footer", action=argparse.BooleanOptionalAction, |
| |
223 | default=None, help="show the closing disclaimer block") |
| |
224 | p.add_argument("--footer-text", help="custom disclaimer text") |
| 185 | p.add_argument("-i", "--interactive", action="store_true", |
225 | p.add_argument("-i", "--interactive", action="store_true", |
| 186 | help="prompt for every field") |
226 | help="prompt for every field") |
| 187 | p.add_argument("--no-input", action="store_true", |
227 | p.add_argument("--no-input", action="store_true", |
| @@ -206,6 +246,17 @@ def main(argv: list[str] | None = None) -> int: |
| 206 | group = args.group or config.get("group") or DEFAULT_GROUP |
246 | group = args.group or config.get("group") or DEFAULT_GROUP |
| 207 | site = args.site or config.get("site") or DEFAULT_SITE |
247 | site = args.site or config.get("site") or DEFAULT_SITE |
| 208 | width = args.width or config.get("width") or DEFAULT_WIDTH |
248 | width = args.width or config.get("width") or DEFAULT_WIDTH |
| |
249 | style = args.style or config.get("style") or DEFAULT_STYLE |
| |
250 | |
| |
251 | def resolve(flag, key, default): |
| |
252 | if flag is not None: |
| |
253 | return flag |
| |
254 | return config.get(key, default) |
| |
255 | |
| |
256 | presents = resolve(args.presents, "presents", False) |
| |
257 | show_footer = resolve(args.footer, "footer", False) |
| |
258 | footer_text = args.footer_text or config.get("footer_text") or DEFAULT_FOOTER |
| |
259 | footer = footer_text if show_footer else None |
| 209 | |
260 | |
| 210 | interactive = sys.stdin.isatty() and not args.no_input |
261 | interactive = sys.stdin.isatty() and not args.no_input |
| 211 | if args.interactive: |
262 | if args.interactive: |
| @@ -216,8 +267,16 @@ def main(argv: list[str] | None = None) -> int: |
| 216 | if not data.get("title"): |
267 | if not data.get("title"): |
| 217 | sys.exit("nfogen: a title is required (use --title, a config, or -i)") |
268 | sys.exit("nfogen: a title is required (use --title, a config, or -i)") |
| 218 | |
269 | |
| 219 | text = render(data, group=group, site=site, width=width) |
270 | text = render(data, group=group, site=site, width=width, style=style, |
| 220 | if args.output: |
271 | presents=presents, footer=footer) |
| |
272 | |
| |
273 | if args.encoding == "cp437": |
| |
274 | raw = text.encode("cp437", errors="replace") |
| |
275 | if args.output: |
| |
276 | Path(args.output).write_bytes(raw) |
| |
277 | else: |
| |
278 | sys.stdout.buffer.write(raw) |
| |
279 | elif args.output: |
| 221 | Path(args.output).write_text(text, encoding="utf-8") |
280 | Path(args.output).write_text(text, encoding="utf-8") |
| 222 | else: |
281 | else: |
| 223 | sys.stdout.write(text) |
282 | sys.stdout.write(text) |