Surface stale deadlines instead of showing them as upcoming
All checks were successful
Build and deploy static pages / build-and-push (push) Successful in 14s
All checks were successful
Build and deploy static pages / build-and-push (push) Successful in 14s
The calendar's deadline table rendered every recorded submission cycle, so elapsed rounds sorted to the top and read as the next thing due — 13 of its 17 rows were already in the past. Underneath that, the fetcher preserves `source: manual` entries forever without ever checking whether they still describe a future deadline, and 11 of 13 cloud-edge venues are manual. Preserving them is correct: WikiCFP cannot express multi-cycle venues and does not carry most systems conferences at all, so re-fetching would replace researched rounds with worse data or nothing. What was missing is a signal. - Extract the cycle helpers into cycles.py so the fetcher and the generator share one definition of which round a reader should act on. cycles_of() reads an entry without folding flat fields into a cycles list, which would otherwise rewrite every single-round entry the fetcher writes back. - The calendar table lists open cycles only; a venue whose every round has elapsed moves to an "Awaiting the next call" table instead of vanishing. - pa-fetch-deadlines records those venues under a new stale: key and probes the year-bumped CFP URL to say which ones already have a next edition online. A candidate must return 200, mention the target year, and be newer than any year the entry records — venues.yaml still points SC at sc24. - README: Task 7 and a matching agent prompt for keeping deadlines current, since nothing in this repo refreshes them on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,14 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from publish_assistant.cycles import (
|
||||
cycle_deadline,
|
||||
next_cycle,
|
||||
normalise_cycles,
|
||||
open_cycles,
|
||||
parse_date,
|
||||
)
|
||||
|
||||
PRESERVED_VENUE_FIELDS = {"notes", "deadline_source"}
|
||||
|
||||
# Fields removed from existing front matter on every regeneration:
|
||||
@@ -120,26 +128,6 @@ 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():
|
||||
@@ -149,7 +137,7 @@ def load_deadlines(path: str) -> dict[str, dict]:
|
||||
deadlines = data.get("deadlines") or {}
|
||||
for entry in deadlines.values():
|
||||
if entry:
|
||||
_normalise_cycles(entry)
|
||||
normalise_cycles(entry)
|
||||
return deadlines
|
||||
|
||||
|
||||
@@ -353,52 +341,6 @@ def _journal_body(fm: dict, digests: list[dict] | None = None, base_path: str =
|
||||
# Date helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_date(s: str, month_only: bool = False) -> str | None:
|
||||
"""'Apr 1, 2026' → '2026-04-01', or None if unparseable.
|
||||
|
||||
With month_only, also accepts a bare month and year ('Jan 2027' →
|
||||
'2027-01-01'). Journal special issues often announce notifications at
|
||||
month granularity; conference deadlines are always full dates, so this
|
||||
stays opt-in to keep their output unchanged.
|
||||
"""
|
||||
for fmt in ("%b %d, %Y", "%B %d, %Y"):
|
||||
try:
|
||||
return datetime.strptime(s.strip(), fmt).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
if month_only:
|
||||
for fmt in ("%b %Y", "%B %Y"):
|
||||
try:
|
||||
return datetime.strptime(s.strip(), fmt).strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass
|
||||
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.
|
||||
|
||||
@@ -410,22 +352,22 @@ def _parse_date_range(s: str) -> tuple[str | None, str | None]:
|
||||
s = s.strip()
|
||||
if " - " in s:
|
||||
left, right = s.split(" - ", 1)
|
||||
start = _parse_date(left)
|
||||
end_s = _parse_date(right)
|
||||
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}")
|
||||
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
|
||||
return parse_date(s), None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -455,7 +397,7 @@ def _conferences_section_body(venues: dict, icore: dict, deadlines: dict,
|
||||
dl = deadlines.get(acronym, {})
|
||||
# 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)
|
||||
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"):
|
||||
@@ -539,7 +481,7 @@ def _calendar_events(venues: dict, deadlines: dict, base_path: str = "") -> list
|
||||
("notification", "notification", "notification"),
|
||||
]:
|
||||
color, text_color = EVENT_COLORS[kind]
|
||||
iso = _parse_date(cycle.get(field) or "")
|
||||
iso = parse_date(cycle.get(field) or "")
|
||||
if iso:
|
||||
ev: dict = {"title": f"{prefix} — {label}", "start": iso,
|
||||
"url": url, "color": color,
|
||||
@@ -580,7 +522,7 @@ def _calendar_events(venues: dict, deadlines: dict, base_path: str = "") -> list
|
||||
("notification", "notification"),
|
||||
]:
|
||||
# Notifications are often month-only ('Jan 2027'); deadlines are not.
|
||||
iso = _parse_date(si.get(field) or "", month_only=(field == "notification"))
|
||||
iso = parse_date(si.get(field) or "", month_only=(field == "notification"))
|
||||
if iso:
|
||||
ev = {"title": f"{acronym} SI — {si_title} — {label}",
|
||||
"start": iso, "url": url, "color": journal_color,
|
||||
@@ -603,14 +545,26 @@ def _calendar_body(venues: dict, icore: dict, deadlines: dict, base_path: str =
|
||||
lines: list[str] = []
|
||||
|
||||
conf_rows = []
|
||||
stale_rows = []
|
||||
for conf in venues.get("conferences") or []:
|
||||
acronym = conf.get("acronym", "")
|
||||
rank = icore.get(acronym.upper(), "—") or "—"
|
||||
dl_data = deadlines.get(acronym, {})
|
||||
event = dl_data.get("event_dates") or "—"
|
||||
location = dl_data.get("location") or ""
|
||||
# One row per submission cycle — a venue's later rounds are the point.
|
||||
for cycle in dl_data.get("cycles") or [{}]:
|
||||
# One row per still-open submission cycle — a venue's later rounds are
|
||||
# the point, but an elapsed round is not something a reader can act on,
|
||||
# and sorting by deadline parks it at the top of the table.
|
||||
still_open = open_cycles(dl_data)
|
||||
if not still_open:
|
||||
# Every recorded round has passed: the venue is still tracked, its
|
||||
# next CFP just isn't out (or isn't fetched) yet. Listed separately
|
||||
# below so a stale date never reads as an upcoming deadline.
|
||||
last = next_cycle(dl_data)
|
||||
stale_rows.append((acronym, conf.get("full_name", ""),
|
||||
cycle_deadline(last) or "TBD", event, location, rank))
|
||||
continue
|
||||
for cycle in still_open:
|
||||
abstract = cycle.get("abstract_deadline") or ""
|
||||
paper = cycle.get("submission_deadline") or cycle.get("abstract_deadline") or "TBD"
|
||||
notif = cycle.get("notification") or ""
|
||||
@@ -618,19 +572,38 @@ def _calendar_body(venues: dict, icore: dict, deadlines: dict, base_path: str =
|
||||
abstract, paper, notif, event, location, rank))
|
||||
|
||||
conf_rows.sort(key=lambda r: (parse_deadline(r[4]), r[0]))
|
||||
stale_rows.sort(key=lambda r: (parse_deadline(r[2]), r[0]))
|
||||
|
||||
lines += [
|
||||
"\n## Conference Deadlines\n",
|
||||
"| Venue | Name | Cycle | Abstract | Paper deadline | Notification | Event dates | Location | ICORE |",
|
||||
"|---|---|---|---|---|---|---|---|---|",
|
||||
]
|
||||
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} | {cycle_name} | {abs_cell} | {paper} | {notif} "
|
||||
f"| {event} | {location} | {rank} |"
|
||||
)
|
||||
lines.append("\n## Conference Deadlines\n")
|
||||
if conf_rows:
|
||||
lines += [
|
||||
"| Venue | Name | Cycle | Abstract | Paper deadline | Notification | Event dates | Location | ICORE |",
|
||||
"|---|---|---|---|---|---|---|---|---|",
|
||||
]
|
||||
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} | {cycle_name} | {abs_cell} | {paper} | {notif} "
|
||||
f"| {event} | {location} | {rank} |"
|
||||
)
|
||||
else:
|
||||
lines.append("No open call at any tracked conference.")
|
||||
|
||||
if stale_rows:
|
||||
lines += [
|
||||
"\n### Awaiting the next call\n",
|
||||
"Tracked venues whose recorded rounds have all elapsed — the next CFP "
|
||||
"has not been published or picked up yet. Dates below are the last "
|
||||
"round that ran, kept for reference only.\n",
|
||||
"| Venue | Name | Last recorded deadline | Event dates | Location | ICORE |",
|
||||
"|---|---|---|---|---|---|",
|
||||
]
|
||||
for acronym, full_name, last_deadline, event, location, rank in stale_rows:
|
||||
link = f"[{acronym}]({base_path}/venues/conferences/{acronym.lower()}/)"
|
||||
lines.append(
|
||||
f"| {link} | {full_name} | {last_deadline} | {event} | {location} | {rank} |"
|
||||
)
|
||||
|
||||
si_rows = []
|
||||
for journal in venues.get("journals") or []:
|
||||
@@ -732,7 +705,7 @@ def gen_conferences(venues: dict, icore: dict, deadlines: dict, root: Path,
|
||||
}
|
||||
dl = deadlines.get(acronym, {})
|
||||
cycles = dl.get("cycles") or []
|
||||
nxt = _next_cycle(dl)
|
||||
nxt = next_cycle(dl)
|
||||
if nxt.get("submission_deadline"):
|
||||
new_fm["next_deadline"] = nxt["submission_deadline"]
|
||||
elif nxt.get("abstract_deadline"):
|
||||
|
||||
Reference in New Issue
Block a user