749 lines
29 KiB
Python
749 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Generate Hugo content pages from data files.
|
||
|
||
Each page has a minimal front matter block (for Hugo taxonomy / metadata) plus
|
||
a rendered Markdown body so generic themes display content without custom templates.
|
||
|
||
Reads:
|
||
site/data/venues.yaml master venue list
|
||
site/data/rankings/icore.csv ICORE conference ranks
|
||
site/data/rankings/scimago.csv SCImago journal ranks (optional)
|
||
site/data/deadlines.yaml deadline data
|
||
site/data/papers/<V>-<Y>-candidates.yaml paper pools
|
||
site/data/papers/<V>-<Y>-digest.yaml agent-curated selections
|
||
|
||
Writes:
|
||
site/content/venues/conferences/<acronym>/_index.md
|
||
site/content/venues/journals/<acronym>/_index.md
|
||
site/content/calendar/_index.md
|
||
site/content/digests/<VENUE>-<YEAR>/index.md
|
||
|
||
PRESERVED_VENUE_FIELDS are never overwritten once set (manual edits survive).
|
||
"""
|
||
import argparse
|
||
import csv
|
||
import re
|
||
import sys
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
PRESERVED_VENUE_FIELDS = {"notes", "deadline_source"}
|
||
|
||
# Fields removed from existing front matter on every regeneration:
|
||
# - "url" reserved by Hugo (overrides page URL)
|
||
# - "papers" moved from front matter to rendered markdown body
|
||
_STRIP_FROM_EXISTING = {"url", "papers"}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Front matter I/O
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def read_hugo_file(path: Path) -> tuple[dict, str]:
|
||
if not path.exists():
|
||
return {}, ""
|
||
text = path.read_text(encoding="utf-8")
|
||
if text.startswith("---"):
|
||
parts = text.split("---", 2)
|
||
if len(parts) == 3:
|
||
return yaml.safe_load(parts[1]) or {}, parts[2]
|
||
return {}, text
|
||
|
||
|
||
def write_hugo_file(path: Path, fm: dict, body: str) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
fm_str = yaml.dump(fm, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
||
path.write_text(f"---\n{fm_str}---\n{body}", encoding="utf-8")
|
||
|
||
|
||
def merge_fm(existing: dict, new_data: dict, preserved: set[str]) -> dict:
|
||
result = {k: v for k, v in existing.items() if k not in _STRIP_FROM_EXISTING}
|
||
for key, value in new_data.items():
|
||
if key in preserved and result.get(key):
|
||
continue
|
||
result[key] = value
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Data loaders
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def load_icore(path: str) -> dict[str, str]:
|
||
p = Path(path)
|
||
if not p.exists():
|
||
return {}
|
||
ranks: dict[str, str] = {}
|
||
with open(p, newline="", encoding="utf-8") as f:
|
||
sample = f.read(4096); f.seek(0)
|
||
sep = ";" if sample.count(";") > sample.count(",") else ","
|
||
for row in csv.DictReader(f, delimiter=sep):
|
||
acronym = (row.get("Acronym") or row.get("acronym") or "").strip()
|
||
rank = (row.get("Rank") or row.get("rank") or row.get("CORE Rank") or "").strip()
|
||
if acronym:
|
||
ranks[acronym.upper()] = rank
|
||
return ranks
|
||
|
||
|
||
def load_scimago(path: str) -> dict[str, dict]:
|
||
p = Path(path)
|
||
if not p.exists():
|
||
return {}
|
||
journals: dict[str, dict] = {}
|
||
with open(p, newline="", encoding="utf-8") as f:
|
||
sample = f.read(4096); f.seek(0)
|
||
sep = ";" if sample.count(";") > sample.count(",") else ","
|
||
for row in csv.DictReader(f, delimiter=sep):
|
||
title = (row.get("Title") or row.get("title") or "").strip().lower()
|
||
if title:
|
||
journals[title] = {
|
||
"scimago_quartile": (row.get("SJR Best Quartile") or row.get("Best Quartile") or "").strip(),
|
||
"scimago_sjr": (row.get("SJR") or "").strip(),
|
||
"scimago_h_index": (row.get("H index") or row.get("H Index") or "").strip(),
|
||
}
|
||
return journals
|
||
|
||
|
||
def scimago_lookup(data: dict[str, dict], full_name: str, issn: str) -> dict:
|
||
entry = data.get(full_name.lower())
|
||
if entry:
|
||
return entry
|
||
needle = full_name.lower()
|
||
for title, entry in data.items():
|
||
if needle in title or title in needle:
|
||
return entry
|
||
return {}
|
||
|
||
|
||
def load_deadlines(path: str) -> dict[str, dict]:
|
||
p = Path(path)
|
||
if not p.exists():
|
||
return {}
|
||
with open(p) as f:
|
||
data = yaml.safe_load(f) or {}
|
||
return data.get("deadlines", {})
|
||
|
||
|
||
def load_venue_digests(papers_dir: Path) -> dict[str, list[dict]]:
|
||
"""Return {acronym: [digest_info_dict, ...]} sorted newest-first."""
|
||
result: dict[str, list[dict]] = {}
|
||
if not papers_dir.exists():
|
||
return result
|
||
for f in sorted(papers_dir.glob("*-digest.yaml"), reverse=True):
|
||
with open(f, encoding="utf-8") as fp:
|
||
d = yaml.safe_load(fp) or {}
|
||
venue = d.get("venue", "")
|
||
year = d.get("year")
|
||
if venue and year:
|
||
result.setdefault(venue, []).append({
|
||
"year": int(year),
|
||
"slug": f"{venue}-{year}",
|
||
"paper_count": len(d.get("selected") or d.get("papers") or []),
|
||
"tags": d.get("tags", []),
|
||
"date": str(d.get("date", f"{year}-01-01")),
|
||
})
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Markdown body renderers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _conf_body(fm: dict, digests: list[dict] | None = None) -> str:
|
||
rank = fm.get("icore_rank") or "—"
|
||
hp = fm.get("homepage") or ""
|
||
dl = fm.get("next_deadline") or "TBD"
|
||
abstract_dl = fm.get("abstract_deadline") or ""
|
||
notification = fm.get("notification") or ""
|
||
camera_ready = fm.get("camera_ready") or ""
|
||
event = fm.get("next_event") or "—"
|
||
location = fm.get("location") or ""
|
||
cfp_url = fm.get("cfp_url") or ""
|
||
domains = ", ".join(f"`{d}`" for d in (fm.get("domain") or []))
|
||
notes = fm.get("notes") or ""
|
||
src = fm.get("deadline_source") or ""
|
||
title = fm.get("title", "")
|
||
|
||
lines = [f"\n*{fm.get('full_name', '')}*\n"]
|
||
lines += [
|
||
"| | |", "|---|---|",
|
||
f"| **ICORE Rank** | **{rank}** |",
|
||
f"| **Domains** | {domains} |",
|
||
]
|
||
if hp:
|
||
lines.append(f"| **Website** | [{hp}]({hp}) |")
|
||
lines += [
|
||
"\n## Upcoming Deadline\n",
|
||
"| | |", "|---|---|",
|
||
]
|
||
if abstract_dl and abstract_dl != dl:
|
||
lines.append(f"| **Abstract deadline** | {abstract_dl} |")
|
||
lines.append(f"| **Paper deadline** | {dl} |")
|
||
if notification:
|
||
lines.append(f"| **Notification** | {notification} |")
|
||
if camera_ready:
|
||
lines.append(f"| **Camera-ready** | {camera_ready} |")
|
||
lines.append(f"| **Event dates** | {event} |")
|
||
if location:
|
||
lines.append(f"| **Location** | {location} |")
|
||
if cfp_url:
|
||
lines.append(f"| **CFP** | [{cfp_url}]({cfp_url}) |")
|
||
if src:
|
||
lines.append(f"| **Source** | `{src}` |")
|
||
|
||
if digests:
|
||
lines += ["\n## Digests\n"]
|
||
for d in sorted(digests, key=lambda x: x["year"], reverse=True):
|
||
slug = d["slug"].lower()
|
||
year = d["year"]
|
||
count = d.get("paper_count", 0)
|
||
suffix = f"— {count} papers" if count else "*(digest pending)*"
|
||
lines.append(f"- [{title} {year}](/digests/{slug}/) {suffix}")
|
||
|
||
if notes:
|
||
lines += ["\n## Notes\n", notes]
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _journal_body(fm: dict, digests: list[dict] | None = None) -> str:
|
||
q = fm.get("scimago_quartile") or "—"
|
||
sjr = fm.get("scimago_sjr") or "—"
|
||
h = fm.get("scimago_h_index") or "—"
|
||
issn = fm.get("issn") or "—"
|
||
hp = fm.get("homepage") or ""
|
||
model = (fm.get("submission_model") or "rolling").title()
|
||
domains = ", ".join(f"`{d}`" for d in (fm.get("domain") or []))
|
||
notes = fm.get("notes") or ""
|
||
title = fm.get("title", "")
|
||
|
||
lines = [f"\n*{fm.get('full_name', '')}*\n"]
|
||
lines += [
|
||
"| | |", "|---|---|",
|
||
f"| **SCImago Quartile** | **{q}** |",
|
||
f"| **SJR** | {sjr} |",
|
||
f"| **H-Index** | {h} |",
|
||
f"| **ISSN** | {issn} |",
|
||
f"| **Submission model** | {model} |",
|
||
f"| **Domains** | {domains} |",
|
||
]
|
||
if hp:
|
||
lines.append(f"| **Website** | [{hp}]({hp}) |")
|
||
|
||
if digests:
|
||
lines += ["\n## Digests\n"]
|
||
for d in sorted(digests, key=lambda x: x["year"], reverse=True):
|
||
slug = d["slug"].lower()
|
||
year = d["year"]
|
||
count = d.get("paper_count", 0)
|
||
suffix = f"— {count} papers" if count else "*(digest pending)*"
|
||
lines.append(f"- [{title} {year}](/digests/{slug}/) {suffix}")
|
||
|
||
if notes:
|
||
lines += ["\n## Notes\n", notes]
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Date helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _parse_date(s: str) -> str | None:
|
||
"""'Apr 1, 2026' → '2026-04-01', or None if unparseable."""
|
||
for fmt in ("%b %d, %Y", "%B %d, %Y"):
|
||
try:
|
||
return datetime.strptime(s.strip(), fmt).strftime("%Y-%m-%d")
|
||
except ValueError:
|
||
pass
|
||
return None
|
||
|
||
|
||
def _parse_date_range(s: str) -> tuple[str | None, str | None]:
|
||
"""Return (start_iso, end_iso_exclusive) from a date-range string.
|
||
|
||
Handles:
|
||
'Apr 13, 2026 - Apr 16, 2026' → ('2026-04-13', '2026-04-17')
|
||
'Nov 17-19, 2026' → ('2026-11-17', '2026-11-20')
|
||
'Apr 1, 2026' → ('2026-04-01', None)
|
||
"""
|
||
s = s.strip()
|
||
if " - " in s:
|
||
left, right = s.split(" - ", 1)
|
||
start = _parse_date(left)
|
||
end_s = _parse_date(right)
|
||
if start and end_s:
|
||
end_dt = datetime.strptime(end_s, "%Y-%m-%d") + timedelta(days=1)
|
||
return start, end_dt.strftime("%Y-%m-%d")
|
||
m = re.match(r"(\w+)\s+(\d+)-(\d+),\s+(\d{4})", s)
|
||
if m:
|
||
month, d1, d2, year = m.groups()
|
||
start = _parse_date(f"{month} {d1}, {year}")
|
||
try:
|
||
end_dt = datetime.strptime(f"{month} {d2}, {year}", "%b %d, %Y") + timedelta(days=1)
|
||
if start:
|
||
return start, end_dt.strftime("%Y-%m-%d")
|
||
except ValueError:
|
||
pass
|
||
return _parse_date(s), None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Venue section index body renderers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_ICORE_ORDER = {"A*": 0, "A": 1, "B": 2, "C": 3}
|
||
_QUARTILE_ORDER = {"Q1": 0, "Q2": 1, "Q3": 2, "Q4": 3}
|
||
|
||
|
||
def _venues_body(venues: dict) -> str:
|
||
n_conf = len(venues.get("conferences") or [])
|
||
n_jour = len(venues.get("journals") or [])
|
||
return (
|
||
f"\nTracking **{n_conf} conferences** and **{n_jour} journals** for this domain.\n\n"
|
||
"- **[Conferences →](/venues/conferences/)** ranked by ICORE (A*, A, B, C)\n"
|
||
"- **[Journals →](/venues/journals/)** ranked by SCImago quartile (Q1–Q4)\n"
|
||
)
|
||
|
||
|
||
def _conferences_section_body(venues: dict, icore: dict, deadlines: dict) -> str:
|
||
rows = []
|
||
for conf in venues.get("conferences") or []:
|
||
acronym = conf.get("acronym", "")
|
||
rank = icore.get(acronym.upper(), "—") or "—"
|
||
dl = deadlines.get(acronym, {})
|
||
abstract = dl.get("abstract_deadline") or ""
|
||
paper = dl.get("submission_deadline") or dl.get("abstract_deadline") or "TBD"
|
||
notif = dl.get("notification") or ""
|
||
event = dl.get("event_dates") or "—"
|
||
location = dl.get("location") or ""
|
||
rows.append((_ICORE_ORDER.get(rank, 99), acronym, conf.get("full_name", ""),
|
||
rank, abstract, paper, notif, event, location))
|
||
rows.sort(key=lambda r: (r[0], r[1]))
|
||
|
||
lines = [
|
||
"\n| Conference | Full name | ICORE | Abstract | Paper deadline | Notification | Event | Location |",
|
||
"|---|---|---|---|---|---|---|---|",
|
||
]
|
||
for _, acronym, full_name, rank, abstract, paper, notif, event, location in rows:
|
||
link = f"[{acronym}](/venues/conferences/{acronym.lower()}/)"
|
||
abs_cell = abstract if (abstract and abstract != paper) else ""
|
||
lines.append(f"| {link} | {full_name} | **{rank}** | {abs_cell} | {paper} | {notif} | {event} | {location} |")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _journals_section_body(venues: dict) -> str:
|
||
rows = []
|
||
for j in venues.get("journals") or []:
|
||
acronym = j.get("acronym", "")
|
||
q = j.get("scimago_quartile") or "—"
|
||
sjr = j.get("scimago_sjr") or "—"
|
||
h = j.get("scimago_h_index") or "—"
|
||
model = (j.get("submission_model") or "rolling").title()
|
||
domains = ", ".join(f"`{d}`" for d in j.get("domain", []))
|
||
rows.append((_QUARTILE_ORDER.get(q, 99), acronym, j.get("full_name", ""), q, sjr, h, model, domains))
|
||
rows.sort(key=lambda r: (r[0], r[1]))
|
||
|
||
lines = [
|
||
"\n| Journal | Full name | SCImago | SJR | H-Index | Model | Domains |",
|
||
"|---|---|---|---|---|---|---|",
|
||
]
|
||
for _, acronym, full_name, q, sjr, h, model, domains in rows:
|
||
link = f"[{acronym}](/venues/journals/{acronym.lower()}/)"
|
||
lines.append(f"| {link} | {full_name} | **{q}** | {sjr} | {h} | {model} | {domains} |")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Calendar events builder (for FullCalendar)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _calendar_events(venues: dict, deadlines: dict) -> list[dict]:
|
||
events: list[dict] = []
|
||
for conf in venues.get("conferences") or []:
|
||
acronym = conf.get("acronym", "")
|
||
dl = deadlines.get(acronym, {})
|
||
url = f"/venues/conferences/{acronym.lower()}/"
|
||
location = dl.get("location") or ""
|
||
cfp_url = dl.get("cfp_url") or ""
|
||
|
||
for field, label, color in [
|
||
("abstract_deadline", "abstract deadline", "#f4a261"),
|
||
("submission_deadline", "paper deadline", "#e63946"),
|
||
("notification", "notification", "#2a9d8f"),
|
||
]:
|
||
iso = _parse_date(dl.get(field) or "")
|
||
if iso:
|
||
ev: dict = {"title": f"{acronym} — {label}", "start": iso,
|
||
"url": url, "color": color, "allDay": True}
|
||
props: dict = {}
|
||
if location:
|
||
props["location"] = location
|
||
if cfp_url:
|
||
props["cfp_url"] = cfp_url
|
||
if props:
|
||
ev["extendedProps"] = props
|
||
events.append(ev)
|
||
|
||
start, end = _parse_date_range(dl.get("event_dates") or "")
|
||
if start:
|
||
ev = {"title": acronym, "start": start, "url": url,
|
||
"color": "#457b9d", "allDay": True}
|
||
if end:
|
||
ev["end"] = end
|
||
if location:
|
||
ev["extendedProps"] = {"location": location}
|
||
events.append(ev)
|
||
return events
|
||
|
||
|
||
def _calendar_body(venues: dict, icore: dict, deadlines: dict) -> str:
|
||
def parse_deadline(s: str):
|
||
for fmt in ("%b %d, %Y", "%b %d %Y"):
|
||
try:
|
||
return datetime.strptime(s.strip(), fmt)
|
||
except ValueError:
|
||
pass
|
||
return datetime.max
|
||
|
||
rows = []
|
||
for conf in venues.get("conferences") or []:
|
||
acronym = conf.get("acronym", "")
|
||
rank = icore.get(acronym.upper(), "—") or "—"
|
||
dl_data = deadlines.get(acronym, {})
|
||
abstract = dl_data.get("abstract_deadline") or ""
|
||
paper = dl_data.get("submission_deadline") or dl_data.get("abstract_deadline") or "TBD"
|
||
notif = dl_data.get("notification") or ""
|
||
event = dl_data.get("event_dates") or "—"
|
||
location = dl_data.get("location") or ""
|
||
rows.append((acronym, abstract, paper, notif, event, location, rank))
|
||
|
||
rows.sort(key=lambda r: (parse_deadline(r[2]), r[0]))
|
||
|
||
lines = [
|
||
"\n| Conference | Abstract | Paper deadline | Notification | Event dates | Location | ICORE |",
|
||
"|---|---|---|---|---|---|---|",
|
||
]
|
||
for acronym, abstract, paper, notif, event, location, rank in rows:
|
||
abs_cell = abstract if (abstract and abstract != paper) else ""
|
||
lines.append(
|
||
f"| [{acronym}](/venues/conferences/{acronym.lower()}/) "
|
||
f"| {abs_cell} | {paper} | {notif} | {event} | {location} | {rank} |"
|
||
)
|
||
|
||
lines += [
|
||
"",
|
||
"> Deadlines sourced from conference websites and [WikiCFP](http://www.wikicfp.com/).",
|
||
"",
|
||
]
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def _digest_body(papers: list[dict]) -> str:
|
||
if not papers:
|
||
return "\nNo papers selected yet.\n"
|
||
lines = [f"\n{len(papers)} papers selected.\n"]
|
||
for p in papers:
|
||
title = p.get("title") or "Untitled"
|
||
authors = p.get("authors") or []
|
||
author_str = (
|
||
", ".join(authors[:4]) + (" *et al.*" if len(authors) > 4 else "")
|
||
if authors else ""
|
||
)
|
||
tldr = p.get("tldr") or ""
|
||
why = p.get("why_notable") or ""
|
||
link = p.get("url") or (f"https://doi.org/{p['doi']}" if p.get("doi") else "")
|
||
|
||
lines += ["---", f"\n### {title}\n"]
|
||
if author_str:
|
||
lines.append(f"*{author_str}*\n")
|
||
if tldr:
|
||
lines.append(f"**TL;DR** — {tldr}\n")
|
||
if why:
|
||
lines.append(f"**Why notable** — {why}\n")
|
||
if link:
|
||
lines.append(f"[→ Read paper]({link})\n")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Content generators
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def gen_conferences(venues: dict, icore: dict, deadlines: dict, root: Path,
|
||
venue_digests: dict[str, list[dict]] | None = None) -> int:
|
||
count = 0
|
||
for conf in venues.get("conferences") or []:
|
||
acronym = conf.get("acronym", "").strip()
|
||
if not acronym:
|
||
continue
|
||
path = root / "venues" / "conferences" / acronym / "_index.md"
|
||
existing_fm, _ = read_hugo_file(path)
|
||
|
||
new_fm: dict = {
|
||
"title": acronym,
|
||
"full_name": conf.get("full_name", ""),
|
||
"domain": conf.get("domain", []),
|
||
"type": "conference",
|
||
"icore_rank": icore.get(acronym.upper(), ""),
|
||
"homepage": conf.get("url", ""),
|
||
"dblp_key": conf.get("dblp_key", ""),
|
||
"draft": False,
|
||
}
|
||
dl = deadlines.get(acronym, {})
|
||
if dl.get("submission_deadline"):
|
||
new_fm["next_deadline"] = dl["submission_deadline"]
|
||
elif dl.get("abstract_deadline"):
|
||
new_fm["next_deadline"] = dl["abstract_deadline"]
|
||
if dl.get("abstract_deadline") and dl.get("submission_deadline"):
|
||
new_fm["abstract_deadline"] = dl["abstract_deadline"]
|
||
if dl.get("notification"):
|
||
new_fm["notification"] = dl["notification"]
|
||
if dl.get("camera_ready"):
|
||
new_fm["camera_ready"] = dl["camera_ready"]
|
||
if dl.get("event_dates"):
|
||
new_fm["next_event"] = dl["event_dates"]
|
||
if dl.get("location"):
|
||
new_fm["location"] = dl["location"]
|
||
if dl.get("cfp_url"):
|
||
new_fm["cfp_url"] = dl["cfp_url"]
|
||
if dl.get("wikicfp_event_id"):
|
||
new_fm["deadline_source"] = f"wikicfp:{dl['wikicfp_event_id']}"
|
||
|
||
fm = merge_fm(existing_fm, new_fm, PRESERVED_VENUE_FIELDS)
|
||
digests = (venue_digests or {}).get(acronym)
|
||
write_hugo_file(path, fm, _conf_body(fm, digests))
|
||
count += 1
|
||
print(f" {path.relative_to(root.parent)}", file=sys.stderr)
|
||
return count
|
||
|
||
|
||
def gen_journals(venues: dict, scimago: dict[str, dict], root: Path,
|
||
venue_digests: dict[str, list[dict]] | None = None) -> int:
|
||
count = 0
|
||
for journal in venues.get("journals") or []:
|
||
acronym = journal.get("acronym", "").strip()
|
||
if not acronym:
|
||
continue
|
||
path = root / "venues" / "journals" / acronym / "_index.md"
|
||
existing_fm, _ = read_hugo_file(path)
|
||
|
||
inline_sjr = {
|
||
k: journal[k]
|
||
for k in ("scimago_quartile", "scimago_sjr", "scimago_h_index")
|
||
if journal.get(k)
|
||
}
|
||
sjr_data = inline_sjr or scimago_lookup(scimago, journal.get("full_name", ""), journal.get("issn", ""))
|
||
|
||
new_fm: dict = {
|
||
"title": acronym,
|
||
"full_name": journal.get("full_name", ""),
|
||
"domain": journal.get("domain", []),
|
||
"type": "journal",
|
||
"issn": journal.get("issn", ""),
|
||
"homepage": journal.get("url", ""),
|
||
"submission_model": journal.get("submission_model", "rolling"),
|
||
"dblp_key": journal.get("dblp_key", ""),
|
||
"draft": False,
|
||
**sjr_data,
|
||
}
|
||
fm = merge_fm(existing_fm, new_fm, PRESERVED_VENUE_FIELDS)
|
||
digests = (venue_digests or {}).get(acronym)
|
||
write_hugo_file(path, fm, _journal_body(fm, digests))
|
||
count += 1
|
||
print(f" {path.relative_to(root.parent)}", file=sys.stderr)
|
||
return count
|
||
|
||
|
||
def gen_calendar(venues: dict, icore: dict, deadlines: dict, root: Path) -> None:
|
||
path = root / "calendar" / "_index.md"
|
||
existing_fm, _ = read_hugo_file(path)
|
||
new_fm = {
|
||
"title": "Submission Deadlines",
|
||
"layout": "calendar",
|
||
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||
"events": _calendar_events(venues, deadlines),
|
||
"draft": False,
|
||
}
|
||
fm = merge_fm(existing_fm, new_fm, set())
|
||
write_hugo_file(path, fm, _calendar_body(venues, icore, deadlines))
|
||
print(f" {path.relative_to(root.parent)}", file=sys.stderr)
|
||
|
||
|
||
def gen_section_indexes(venues: dict, icore: dict, deadlines: dict, root: Path) -> None:
|
||
# venues/_index.md
|
||
p = root / "venues" / "_index.md"
|
||
fm = merge_fm(read_hugo_file(p)[0], {"title": "Venues", "draft": False}, set())
|
||
write_hugo_file(p, fm, _venues_body(venues))
|
||
print(f" {p.relative_to(root.parent)}", file=sys.stderr)
|
||
|
||
# venues/conferences/_index.md
|
||
p = root / "venues" / "conferences" / "_index.md"
|
||
n = len(venues.get("conferences") or [])
|
||
fm = merge_fm(read_hugo_file(p)[0],
|
||
{"title": "Conferences", "description": f"{n} tracked conferences", "draft": False}, set())
|
||
write_hugo_file(p, fm, _conferences_section_body(venues, icore, deadlines))
|
||
print(f" {p.relative_to(root.parent)}", file=sys.stderr)
|
||
|
||
# venues/journals/_index.md
|
||
p = root / "venues" / "journals" / "_index.md"
|
||
n = len(venues.get("journals") or [])
|
||
fm = merge_fm(read_hugo_file(p)[0],
|
||
{"title": "Journals", "description": f"{n} tracked journals", "draft": False}, set())
|
||
write_hugo_file(p, fm, _journals_section_body(venues))
|
||
print(f" {p.relative_to(root.parent)}", file=sys.stderr)
|
||
|
||
|
||
def _upcoming_year(acronym: str, deadlines: dict) -> int:
|
||
"""Extract the year of the upcoming event from deadlines, falling back to current year."""
|
||
event_dates = (deadlines.get(acronym) or {}).get("event_dates") or ""
|
||
m = re.search(r'\b(20\d\d)\b', event_dates)
|
||
return int(m.group(1)) if m else datetime.now().year
|
||
|
||
|
||
def gen_digests(papers_dir: Path, root: Path,
|
||
venues: dict | None = None, deadlines: dict | None = None) -> int:
|
||
real_slugs: set[str] = set()
|
||
count = 0
|
||
|
||
if papers_dir.exists():
|
||
for digest_file in sorted(papers_dir.glob("*-digest.yaml")):
|
||
with open(digest_file, encoding="utf-8") as f:
|
||
digest = yaml.safe_load(f) or {}
|
||
venue = digest.get("venue", "")
|
||
year = digest.get("year")
|
||
if not venue or not year:
|
||
print(f" Skipping {digest_file.name}: missing venue or year", file=sys.stderr)
|
||
continue
|
||
|
||
candidates: dict[str, dict] = {}
|
||
candidate_file = papers_dir / f"{venue}-{year}-candidates.yaml"
|
||
if candidate_file.exists():
|
||
with open(candidate_file, encoding="utf-8") as f:
|
||
pool = yaml.safe_load(f) or {}
|
||
for p in pool.get("papers", []):
|
||
key = p.get("dblp_key") or p.get("openalex_id") or p.get("doi") or p.get("title", "")
|
||
candidates[key] = p
|
||
|
||
items = digest.get("selected") or []
|
||
if not items:
|
||
for p in digest.get("papers") or []:
|
||
items.append({
|
||
"title": p.get("title", ""),
|
||
"authors": p.get("authors", []),
|
||
"tldr": p.get("reason", ""),
|
||
"why_notable": "",
|
||
})
|
||
|
||
papers_out = []
|
||
for sel in items:
|
||
key = sel.get("dblp_key") or sel.get("openalex_id") or sel.get("doi") or sel.get("title", "")
|
||
papers_out.append({**candidates.get(key, {}), **sel})
|
||
|
||
slug = f"{venue}-{year}"
|
||
real_slugs.add(slug)
|
||
path = root / "digests" / slug / "index.md"
|
||
existing_fm, _ = read_hugo_file(path)
|
||
|
||
new_fm = {
|
||
"title": f"{venue} {year} Digest",
|
||
"venue": venue,
|
||
"year": int(year),
|
||
"date": str(digest.get("date", f"{year}-01-01")),
|
||
"tags": digest.get("tags", []),
|
||
"paper_count": len(papers_out),
|
||
"draft": False,
|
||
}
|
||
fm = merge_fm(existing_fm, new_fm, set())
|
||
write_hugo_file(path, fm, _digest_body(papers_out))
|
||
count += 1
|
||
print(f" {path.relative_to(root.parent)}", file=sys.stderr)
|
||
|
||
# Generate stubs for every venue that has no digest for its upcoming edition
|
||
if venues and deadlines is not None:
|
||
all_acronyms = (
|
||
[c.get("acronym", "") for c in (venues.get("conferences") or [])] +
|
||
[j.get("acronym", "") for j in (venues.get("journals") or [])]
|
||
)
|
||
for acronym in all_acronyms:
|
||
if not acronym:
|
||
continue
|
||
year = _upcoming_year(acronym, deadlines)
|
||
slug = f"{acronym}-{year}"
|
||
if slug in real_slugs:
|
||
continue
|
||
path = root / "digests" / slug / "index.md"
|
||
if path.exists():
|
||
continue # never overwrite existing stubs (may have been customised)
|
||
new_fm = {
|
||
"title": f"{acronym} {year} Digest",
|
||
"venue": acronym,
|
||
"year": year,
|
||
"date": f"{year}-01-01",
|
||
"tags": [],
|
||
"paper_count": 0,
|
||
"draft": False,
|
||
"build": {"list": "never"},
|
||
}
|
||
write_hugo_file(path, new_fm, _digest_body([]))
|
||
count += 1
|
||
print(f" {path.relative_to(root.parent)} (stub)", file=sys.stderr)
|
||
|
||
return count
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Entry point
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description="Generate Hugo content from data files.")
|
||
ap.add_argument("--venues", default="site/data/venues.yaml")
|
||
ap.add_argument("--icore", default="site/data/rankings/icore.csv")
|
||
ap.add_argument("--scimago", default="site/data/rankings/scimago.csv")
|
||
ap.add_argument("--deadlines", default="site/data/deadlines.yaml")
|
||
ap.add_argument("--papers-dir", default="site/data/papers", dest="papers_dir")
|
||
ap.add_argument("--content", default="site/content")
|
||
args = ap.parse_args()
|
||
|
||
venues_path = Path(args.venues)
|
||
if not venues_path.exists():
|
||
print(f"Error: {venues_path} not found. Populate site/data/venues.yaml first.", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
with open(venues_path, encoding="utf-8") as f:
|
||
venues = yaml.safe_load(f) or {}
|
||
|
||
content_root = Path(args.content)
|
||
papers_dir = Path(args.papers_dir)
|
||
icore = load_icore(args.icore)
|
||
scimago = load_scimago(args.scimago)
|
||
deadlines = load_deadlines(args.deadlines)
|
||
venue_digests = load_venue_digests(papers_dir)
|
||
|
||
if icore: print(f"Loaded {len(icore)} ICORE entries", file=sys.stderr)
|
||
if scimago: print(f"Loaded {len(scimago)} SCImago entries", file=sys.stderr)
|
||
if deadlines: print(f"Loaded deadlines for {len(deadlines)} venues", file=sys.stderr)
|
||
|
||
print("\nGenerating venue section indexes …", file=sys.stderr)
|
||
gen_section_indexes(venues, icore, deadlines, content_root)
|
||
|
||
print("\nGenerating conference pages …", file=sys.stderr)
|
||
n_conf = gen_conferences(venues, icore, deadlines, content_root, venue_digests)
|
||
|
||
print("\nGenerating journal pages …", file=sys.stderr)
|
||
n_jour = gen_journals(venues, scimago, content_root, venue_digests)
|
||
|
||
print("\nGenerating calendar page …", file=sys.stderr)
|
||
gen_calendar(venues, icore, deadlines, content_root)
|
||
|
||
print("\nGenerating digest pages …", file=sys.stderr)
|
||
n_dig = gen_digests(papers_dir, content_root, venues, deadlines)
|
||
|
||
print(f"\nDone: {n_conf} conferences, {n_jour} journals, {n_dig} digests", file=sys.stderr)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|