Skip to content

Commit bb7b132

Browse files
committed
Add Python script to generate tags
1 parent 525b2e2 commit bb7b132

1 file changed

Lines changed: 339 additions & 0 deletions

File tree

scripts/gen_wiki.py

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Generate AoC wiki markdown files from Elixir day solution headers.
4+
Headers are in @moduledoc at the top of each lib/{year}/day_{day}.ex file:
5+
--- Day XX: Title ---
6+
Problem Link: ...
7+
Difficulty: ...
8+
Tags: tag1 tag2 ...
9+
10+
Output layout:
11+
README.md — stats block injected between <!-- STATS_START/END -->
12+
wiki/difficulty.md — solutions by difficulty tier
13+
wiki/tags/index.md — tag directory
14+
wiki/tags/{tag}.md — one page per tag
15+
lib/{year}/README.md — per-year solution table
16+
"""
17+
18+
import re
19+
import shutil
20+
from pathlib import Path
21+
from collections import defaultdict
22+
23+
REPO_DIR = Path("/home/mafinar/repos/elixir/advent_of_code")
24+
SRC_DIR = REPO_DIR / "lib"
25+
WIKI_DIR = REPO_DIR / "wiki"
26+
README = REPO_DIR / "README.md"
27+
28+
STATS_START = "<!-- STATS_START -->"
29+
STATS_END = "<!-- STATS_END -->"
30+
31+
ALL_DAYS = list(range(1, 26))
32+
33+
DIFF_ICON = {
34+
"xs": "🟢",
35+
"s": "🟡",
36+
"m": "🟠",
37+
"l": "🔴",
38+
"xl": "💀",
39+
}
40+
41+
42+
def normalize_tag(t):
43+
t = t.lower().strip(':').strip().replace('_', '-')
44+
# Keep only alphanumeric and hyphens
45+
t = re.sub(r'[^a-z0-9\-]', '', t)
46+
return t
47+
48+
49+
# ─── Parsing ───────────────────────────────────────────────────────────────────
50+
51+
def parse_day_file(path):
52+
meta = {}
53+
content = path.read_text()
54+
55+
# Title
56+
m_title = re.search(r'--- Day \d+: (.*) ---', content)
57+
if m_title:
58+
meta["title"] = m_title.group(1).strip()
59+
60+
# Link
61+
m_link = re.search(r'Problem Link: (.*)', content)
62+
if m_link:
63+
meta["link"] = m_link.group(1).strip()
64+
65+
# Difficulty
66+
m_diff = re.search(r'Difficulty:\s*(.*)', content)
67+
if m_diff:
68+
meta["difficulty"] = m_diff.group(1).strip().lower()
69+
70+
# Tags
71+
m_tags = re.search(r'Tags:\s*(.*)', content)
72+
if m_tags:
73+
tags_str = m_tags.group(1).strip()
74+
tags = [t.strip(",") for t in re.split(r'[,\s]+', tags_str) if t.strip(",")]
75+
meta["tags"] = [normalize_tag(t) for t in tags if normalize_tag(t)]
76+
else:
77+
meta["tags"] = []
78+
79+
if not all(k in meta for k in ("title", "link", "difficulty")):
80+
return None
81+
82+
try:
83+
# Expected format: day_XX.ex
84+
day_num = int(re.search(r'day_(\d+)', path.name).group(1))
85+
meta["day"] = day_num
86+
except (ValueError, AttributeError):
87+
return None
88+
89+
meta["difficulty"] = meta["difficulty"] if meta["difficulty"] in DIFF_ICON else "m"
90+
return meta
91+
92+
93+
def collect_all_solutions():
94+
solutions = []
95+
# Year directories are like lib/2024/
96+
for year_dir in sorted(SRC_DIR.glob("20*")):
97+
if not year_dir.is_dir() or not year_dir.name.isdigit():
98+
continue
99+
year = int(year_dir.name)
100+
for day_file in sorted(year_dir.glob("day_*.ex")):
101+
meta = parse_day_file(day_file)
102+
if meta:
103+
meta["year"] = year
104+
meta["year_dir"] = year_dir.name
105+
meta["day_file"] = day_file.name
106+
solutions.append(meta)
107+
return solutions
108+
109+
110+
def diff_icon(d):
111+
return DIFF_ICON.get(d, d.upper())
112+
113+
114+
def tag_cloud(tag_counts, link_prefix):
115+
"""tag_counts: {tag: count}; link_prefix: relative path to wiki/tags/ dir."""
116+
parts = sorted(tag_counts.items(), key=lambda kv: (-kv[1], kv[0]))
117+
return " ".join(
118+
f"[{tag}]({link_prefix}{tag}.md)&nbsp;`{count}`"
119+
for tag, count in parts
120+
)
121+
122+
123+
# ─── README stats block (injected between markers) ────────────────────────────
124+
125+
def gen_stats_block(solutions, tag_map):
126+
solved = {(s["year"], s["day"]): s for s in solutions}
127+
all_years = sorted({s["year"] for s in solutions})
128+
total = len(solutions)
129+
130+
# Year nav → lib/XXXX/README.md
131+
year_links = " | ".join(f"[{y}](lib/{y}/README.md)" for y in all_years)
132+
133+
lines = [
134+
f"> **{total} problems solved** across **{len(all_years)} years**"
135+
f" — [Tags](wiki/tags/index.md) · [Difficulty](wiki/difficulty.md)\n\n",
136+
f"**Years:** {year_links}\n\n",
137+
]
138+
139+
# Progress grid
140+
year_header = " | ".join(f"[{y}](lib/{y}/README.md)" for y in all_years)
141+
lines.append(f"| Day | {year_header} |\n")
142+
lines.append("|:---:|" + ":-:|" * len(all_years) + "\n")
143+
for day in ALL_DAYS:
144+
cells = [f"[⭐]({solved[(y, day)]['link']})" if (y, day) in solved else " " for y in all_years]
145+
lines.append(f"| {day} | " + " | ".join(cells) + " |\n")
146+
147+
# Global tag cloud; links relative from repo root
148+
global_counts = {tag: len(sols) for tag, sols in tag_map.items()}
149+
lines.append("\n### 🏷️ Tags\n\n")
150+
lines.append(tag_cloud(global_counts, "wiki/tags/") + "\n")
151+
152+
return "".join(lines)
153+
154+
155+
def patch_readme(stats_block):
156+
"""Replace content between STATS_START / STATS_END markers in README.md."""
157+
if not README.exists():
158+
print(f"Warning: {README} does not exist. Skipping.")
159+
return
160+
161+
text = README.read_text()
162+
pattern = re.compile(
163+
rf"{re.escape(STATS_START)}.*?{re.escape(STATS_END)}",
164+
re.DOTALL,
165+
)
166+
replacement = f"{STATS_START}\n{stats_block}{STATS_END}"
167+
new_text, count = pattern.subn(replacement, text)
168+
if count == 0:
169+
# If not found, try to append it at the end or look for the old star line
170+
# but better to ask user to add markers or I can try to find the table start.
171+
# For now, let's just append it and warn.
172+
print("Markers not found, appending to README.md")
173+
new_text = text + f"\n\n{replacement}\n"
174+
175+
README.write_text(new_text)
176+
177+
178+
# ─── Per-year README ──────────────────────────────────────────────────────────
179+
180+
def gen_year(year, solutions, all_years):
181+
"""Written to lib/{year}/README.md."""
182+
sols = sorted(solutions, key=lambda s: s["day"])
183+
184+
nav_parts = ["[Home](../../README.md)"]
185+
for y in all_years:
186+
nav_parts.append(str(y) if y == year else f"[{y}](../{y}/README.md)")
187+
nav = " | ".join(nav_parts)
188+
189+
year_tag_counts = {}
190+
for s in sols:
191+
for t in s["tags"]:
192+
year_tag_counts[t] = year_tag_counts.get(t, 0) + 1
193+
194+
lines = [
195+
f"# Advent of Code {year}\n\n",
196+
f"{nav}\n\n",
197+
f"## ⭐ {len(sols) * 2}/50\n\n",
198+
tag_cloud(year_tag_counts, "../../wiki/tags/") + "\n\n",
199+
"| Day | Title | Difficulty | Tags | Source |\n",
200+
"|:---:|-------|:----------:|------|--------|\n",
201+
]
202+
203+
for s in sols:
204+
tags = ", ".join(f"[{t}](../../wiki/tags/{t}.md)" for t in s["tags"])
205+
lines.append(
206+
f"| [{s['day']}]({s['link']}) "
207+
f"| [{s['title']}]({s['link']}) "
208+
f"| {diff_icon(s['difficulty'])} "
209+
f"| {tags} "
210+
f"| [{s['day_file']}]({s['day_file']}) |\n"
211+
)
212+
213+
return "".join(lines)
214+
215+
216+
# ─── Tags ─────────────────────────────────────────────────────────────────────
217+
218+
def gen_tag_index(tag_map):
219+
lines = [
220+
"# 🏷️ Tags Index\n\n",
221+
"[← Home](../../README.md)\n\n",
222+
"| Tag | Problems |\n",
223+
"|-----|--------:|\n",
224+
]
225+
for tag in sorted(tag_map.keys()):
226+
lines.append(f"| [{tag}]({tag}.md) | {len(tag_map[tag])} |\n")
227+
return "".join(lines)
228+
229+
230+
def gen_tag_page(tag, solutions):
231+
"""Lives at wiki/tags/{tag}.md."""
232+
sols = sorted(solutions, key=lambda s: (s["year"], s["day"]))
233+
lines = [
234+
f"# Tag: `{tag}`\n\n",
235+
"[← Tags Index](index.md) | [← Home](../../README.md)\n\n",
236+
"| Year | Day | Title | Difficulty | Other Tags | Source |\n",
237+
"|------|:---:|-------|:----------:|------------|--------|\n",
238+
]
239+
for s in sols:
240+
other = ", ".join(f"[{t}]({t}.md)" for t in s["tags"] if t != tag)
241+
src = f"[{s['day_file']}](../../lib/{s['year_dir']}/{s['day_file']})"
242+
lines.append(
243+
f"| {s['year']} "
244+
f"| [{s['day']}]({s['link']}) "
245+
f"| [{s['title']}]({s['link']}) "
246+
f"| {diff_icon(s['difficulty'])} "
247+
f"| {other} "
248+
f"| {src} |\n"
249+
)
250+
return "".join(lines)
251+
252+
253+
# ─── Difficulty ───────────────────────────────────────────────────────────────
254+
255+
def gen_difficulty(solutions):
256+
diff_map = defaultdict(list)
257+
for s in solutions:
258+
diff_map[s["difficulty"]].append(s)
259+
260+
lines = [
261+
"# 🎯 Solutions by Difficulty\n\n",
262+
"[← Home](../README.md)\n\n",
263+
]
264+
for diff in ["xs", "s", "m", "l", "xl"]:
265+
sols = sorted(diff_map.get(diff, []), key=lambda s: (s["year"], s["day"]))
266+
if not sols:
267+
continue
268+
lines.append(f"## {diff_icon(diff)} {diff.upper()}\n\n")
269+
lines.append("| Year | Day | Title | Tags | Source |\n")
270+
lines.append("|------|:---:|-------|------|--------|\n")
271+
for s in sols:
272+
tags = ", ".join(f"[{t}](tags/{t}.md)" for t in s["tags"])
273+
src = f"[{s['day_file']}](../lib/{s['year_dir']}/{s['day_file']})"
274+
lines.append(
275+
f"| {s['year']} "
276+
f"| [{s['day']}]({s['link']}) "
277+
f"| [{s['title']}]({s['link']}) "
278+
f"| {tags} "
279+
f"| {src} |\n"
280+
)
281+
lines.append("\n")
282+
return "".join(lines)
283+
284+
285+
# ─── Main ─────────────────────────────────────────────────────────────────────
286+
287+
def main():
288+
# Create or clean wiki/
289+
if WIKI_DIR.exists():
290+
for path in WIKI_DIR.iterdir():
291+
if path.name == "benchmarks.md": # Keep benchmarks if any
292+
continue
293+
if path.is_file():
294+
path.unlink()
295+
elif path.is_dir():
296+
shutil.rmtree(path)
297+
else:
298+
WIKI_DIR.mkdir()
299+
300+
tags_dir = WIKI_DIR / "tags"
301+
tags_dir.mkdir(exist_ok=True)
302+
303+
solutions = collect_all_solutions()
304+
all_years = sorted({s["year"] for s in solutions})
305+
print(f"Collected {len(solutions)} solutions across {len(all_years)} years.")
306+
307+
tag_map = defaultdict(list)
308+
for s in solutions:
309+
for t in s["tags"]:
310+
tag_map[t].append(s)
311+
312+
# Patch README.md in-place
313+
patch_readme(gen_stats_block(solutions, tag_map))
314+
print(" Patched README.md (<!-- STATS_START/END -->)")
315+
316+
# wiki/difficulty.md
317+
(WIKI_DIR / "difficulty.md").write_text(gen_difficulty(solutions))
318+
print(" Wrote wiki/difficulty.md")
319+
320+
# wiki/tags/
321+
(tags_dir / "index.md").write_text(gen_tag_index(tag_map))
322+
for tag, sols in sorted(tag_map.items()):
323+
(tags_dir / f"{tag}.md").write_text(gen_tag_page(tag, sols))
324+
print(f" Wrote {len(tag_map)} tag pages + index under wiki/tags/")
325+
326+
# lib/{year}/README.md
327+
by_year = defaultdict(list)
328+
for s in solutions:
329+
by_year[s["year"]].append(s)
330+
331+
for year, sols in sorted(by_year.items()):
332+
(SRC_DIR / f"{year}" / "README.md").write_text(gen_year(year, sols, all_years))
333+
print(f" Wrote lib/{year}/README.md")
334+
335+
print("\nDone!")
336+
337+
338+
if __name__ == "__main__":
339+
main()

0 commit comments

Comments
 (0)