Track multiple submission cycles per conference
All checks were successful
Build and deploy static pages / build-and-push (push) Successful in 13s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:55:07 +02:00
parent f132f6bc46
commit 2e95ede9c3
18 changed files with 595 additions and 218 deletions

View File

@@ -35,7 +35,9 @@ 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"}
# - "cycles" / "next_cycle" rebuilt from deadlines.yaml every run, so a venue
# dropping back to a single round does not keep a stale cycle list
_STRIP_FROM_EXISTING = {"url", "papers", "cycles", "next_cycle"}
# ---------------------------------------------------------------------------
@@ -118,13 +120,37 @@ def scimago_lookup(data: dict[str, dict], full_name: str, issn: str) -> dict:
return {}
# Fields describing one submission cycle. Everything else on a deadline entry
# (event_dates, location, cfp_url, source) describes the event itself.
CYCLE_FIELDS = ("name", "abstract_deadline", "submission_deadline", "notification",
"camera_ready")
def _normalise_cycles(entry: dict) -> None:
"""Fold a flat, single-cycle entry into a one-element `cycles` list, in place.
Venues with a single submission round stay flat in YAML; consumers below only
ever see `cycles`. `cfp_url` stays at the top level — it describes the call,
not one round — but a cycle may override it where a venue publishes separate
calls per round.
"""
if entry.get("cycles"):
return
cycle = {k: entry.pop(k) for k in CYCLE_FIELDS if k in entry}
entry["cycles"] = [cycle] if cycle else []
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", {})
deadlines = data.get("deadlines") or {}
for entry in deadlines.values():
if entry:
_normalise_cycles(entry)
return deadlines
def load_venue_digests(papers_dir: Path) -> dict[str, list[dict]]:
@@ -182,17 +208,40 @@ def _conf_body(fm: dict, digests: list[dict] | None = None, base_path: str = "")
count = latest.get("paper_count", 0)
suffix = f"{count} papers" if count else "*(digest pending)*"
lines.append(f"| **Latest digest** | [{title} {year}]({base_path}/digests/{slug}/) {suffix} |")
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} |")
cycles = fm.get("cycles") or []
if len(cycles) > 1:
lines += [
"\n## Submission Cycles\n",
"| Cycle | Abstract | Paper deadline | Notification | Camera-ready |",
"|---|---|---|---|---|",
]
next_name = fm.get("next_cycle")
marked = False
for cycle in cycles:
name = cycle.get("name") or ""
if not marked and name == next_name:
name = f"**{name}** (next)"
marked = True
paper = cycle.get("submission_deadline") or cycle.get("abstract_deadline") or "TBD"
abstract = cycle.get("abstract_deadline") or ""
abs_cell = abstract if (abstract and abstract != paper) else ""
lines.append(
f"| {name} | {abs_cell} | {paper} | {cycle.get('notification') or ''} "
f"| {cycle.get('camera_ready') or ''} |"
)
lines += ["\n| | |", "|---|---|"]
else:
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} |")
@@ -320,6 +369,30 @@ def _parse_date(s: str, month_only: bool = False) -> str | None:
return None
def _cycle_deadline(cycle: dict) -> str:
"""The date a cycle is judged by: its paper deadline, else its abstract deadline."""
return cycle.get("submission_deadline") or cycle.get("abstract_deadline") or ""
def _next_cycle(entry: dict, today: str | None = None) -> dict:
"""The cycle a reader should act on.
The earliest cycle whose paper deadline has not passed; if every cycle has
elapsed, the last one, so a page shows the most recent round rather than
going blank. Cycles with unparseable dates sort last.
"""
cycles = entry.get("cycles") or []
if not cycles:
return {}
today = today or datetime.now().strftime("%Y-%m-%d")
ordered = sorted(cycles, key=lambda c: _parse_date(_cycle_deadline(c)) or "9999-12-31")
for cycle in ordered:
iso = _parse_date(_cycle_deadline(cycle))
if iso and iso >= today:
return cycle
return ordered[-1]
def _parse_date_range(s: str) -> tuple[str | None, str | None]:
"""Return (start_iso, end_iso_exclusive) from a date-range string.
@@ -374,9 +447,14 @@ def _conferences_section_body(venues: dict, icore: dict, deadlines: dict,
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 ""
# The venue index lists venues, not cycles: show the next open round and
# name it, so a passed round never reads as "you missed this conference".
nxt = _next_cycle(dl)
abstract = nxt.get("abstract_deadline") or ""
paper = nxt.get("submission_deadline") or nxt.get("abstract_deadline") or "TBD"
if len(dl.get("cycles") or []) > 1 and nxt.get("name"):
paper = f"{paper} ({nxt['name']})"
notif = nxt.get("notification") or ""
event = dl.get("event_dates") or ""
location = dl.get("location") or ""
domains = ", ".join(f"`{d}`" for d in conf.get("domain", []))
@@ -432,25 +510,30 @@ def _calendar_events(venues: dict, deadlines: dict, base_path: str = "") -> list
dl = deadlines.get(acronym, {})
url = f"{base_path}/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)
for cycle in dl.get("cycles") or []:
cycle_name = cycle.get("name") or ""
prefix = f"{acronym} {cycle_name}".strip()
cfp_url = cycle.get("cfp_url") or 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(cycle.get(field) or "")
if iso:
ev: dict = {"title": f"{prefix}{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 cycle_name:
props["cycle"] = cycle_name
if props:
ev["extendedProps"] = props
events.append(ev)
start, end = _parse_date_range(dl.get("event_dates") or "")
if start:
@@ -503,26 +586,28 @@ def _calendar_body(venues: dict, icore: dict, deadlines: dict, base_path: str =
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 ""
conf_rows.append((acronym, conf.get("full_name", ""), abstract, paper,
notif, event, location, rank))
# One row per submission cycle — a venue's later rounds are the point.
for cycle in dl_data.get("cycles") or [{}]:
abstract = cycle.get("abstract_deadline") or ""
paper = cycle.get("submission_deadline") or cycle.get("abstract_deadline") or "TBD"
notif = cycle.get("notification") or ""
conf_rows.append((acronym, conf.get("full_name", ""), cycle.get("name") or "",
abstract, paper, notif, event, location, rank))
conf_rows.sort(key=lambda r: (parse_deadline(r[3]), r[0]))
conf_rows.sort(key=lambda r: (parse_deadline(r[4]), r[0]))
lines += [
"\n## Conference Deadlines\n",
"| Venue | Name | Abstract | Paper deadline | Notification | Event dates | Location | ICORE |",
"|---|---|---|---|---|---|---|---|",
"| Venue | Name | Cycle | Abstract | Paper deadline | Notification | Event dates | Location | ICORE |",
"|---|---|---|---|---|---|---|---|---|",
]
for acronym, full_name, abstract, paper, notif, event, location, rank in conf_rows:
for acronym, full_name, cycle_name, abstract, paper, notif, event, location, rank in conf_rows:
abs_cell = abstract if (abstract and abstract != paper) else ""
link = f"[{acronym}]({base_path}/venues/conferences/{acronym.lower()}/)"
lines.append(
f"| {link} | {full_name} | {abs_cell} | {paper} | {notif} "
f"| {link} | {full_name} | {cycle_name} | {abs_cell} | {paper} | {notif} "
f"| {event} | {location} | {rank} |"
)
@@ -617,16 +702,24 @@ def gen_conferences(venues: dict, icore: dict, deadlines: dict, root: Path,
"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"]
cycles = dl.get("cycles") or []
nxt = _next_cycle(dl)
if nxt.get("submission_deadline"):
new_fm["next_deadline"] = nxt["submission_deadline"]
elif nxt.get("abstract_deadline"):
new_fm["next_deadline"] = nxt["abstract_deadline"]
if nxt.get("abstract_deadline") and nxt.get("submission_deadline"):
new_fm["abstract_deadline"] = nxt["abstract_deadline"]
if nxt.get("notification"):
new_fm["notification"] = nxt["notification"]
if nxt.get("camera_ready"):
new_fm["camera_ready"] = nxt["camera_ready"]
# Only multi-cycle venues carry the full list; single-cycle pages keep
# the flat front matter they have always had.
if len(cycles) > 1:
new_fm["cycles"] = cycles
if nxt.get("name"):
new_fm["next_cycle"] = nxt["name"]
if dl.get("event_dates"):
new_fm["next_event"] = dl["event_dates"]
if dl.get("location"):