diff --git a/agenda/add_new_conference.py b/agenda/add_new_conference.py
index 71c12ce..e74455c 100644
--- a/agenda/add_new_conference.py
+++ b/agenda/add_new_conference.py
@@ -16,7 +16,7 @@ import pycountry
import requests
import yaml
-from agenda.conference import conference_date_fields
+from agenda.conference import ConferenceSeries, conference_date_fields, load_series
USER_AGENT = "add-new-conference/0.1"
COORDINATE_PATTERNS = (
@@ -36,58 +36,139 @@ def read_api_key() -> str:
return parser["openai"]["api_key"]
-def build_prompt(
- url: str,
- source_text: str,
- detected_coordinates: tuple[float, float] | None,
-) -> str:
- """Build prompt with embedded YAML examples."""
- examples = """
+def conference_yaml_format_description() -> str:
+ """Return the conference YAML format description for LLM prompts."""
+ return """
+Use this YAML format for one conference entry.
+
+Required fields:
+- `name`: event name.
+- `topic`: topic/category.
+- `location`: city or location label. Use `TBC` if the page confirms a future
+ event but not a city.
+- Date information in nested `dates`.
+
+Preferred date shape:
+- `dates.status`: one of `exact`, `tentative`, or `approximate`.
+- For `exact`: use when the page confirms specific dates/times. Include
+ `dates.start` and `dates.end` as YAML dates or timezone-aware datetimes.
+- For `tentative`: use when specific dates are guessed or explicitly
+ unconfirmed. Include `dates.start`, `dates.end`, and preferably `dates.label`
+ and `dates.basis`.
+- For `approximate`: use when only a broad date phrase is known. Include
+ `dates.label`, `dates.earliest`, and `dates.latest`. Examples: `March 2027`
+ should become earliest `2027-03-01`, latest `2027-03-31`; `mid-April 2027`
+ should become a sensible bounded range such as `2027-04-11` to `2027-04-20`.
+
+Important date rule:
+- If the source page contains exact dates, output `dates.status: exact` even if
+ the existing agenda entry or conference announcement previously had only
+ approximate dates.
+- Always include an end date for `exact` and `tentative`. For a single-day
+ event, `dates.end` can be the same as `dates.start`.
+- Do not output legacy top-level `start`, `end`, or `date_status`.
+
+Common optional fields:
+- `series`: a key from the known conference series list, when this event belongs
+ to a listed series.
+- `country`: valid ISO 3166-1 alpha-2 country code in lowercase, for example
+ `ca`, `gb`, `us`. Do not output country names.
+- `venue`, `address`, `latitude`, `longitude`, `url`, `cfp_url`, `cfp_end`,
+ `hashtag`, `description`.
+- `free`, `price`, `currency`, `hackathon`, `online`, `attendees`.
+- Do not include `going`, `registered`, `accommodation_booked`,
+ `transport_booked`, or `trip` unless the source explicitly says they apply to
+ my attendance.
+"""
+
+
+def yaml_example_text() -> str:
+ """Return examples of the conference YAML format."""
+ return """
- name: Geomob London
+ series: geomob-london
topic: Maps
location: London
country: gb
- start: 2026-01-28 18:00:00+00:00
- end: 2026-01-28 23:00:00+00:00
+ dates:
+ status: exact
+ start: 2026-01-28 18:00:00+00:00
+ end: 2026-01-28 22:00:00+00:00
url: https://thegeomob.com/post/jan-28th-2026-geomoblon-details
venue: Geovation Hub
address: Sutton Yard, 65 Goswell Rd, London EC1V 7EN
latitude: 51.5242464
longitude: -0.0997024
free: true
- going: true
hashtag: '#geomobLON'
- name: DebConf 25
+ series: debconf
topic: Debian
- location: Plouzané (Breast)
+ location: Plouzane
country: fr
- start: 2025-07-07
- end: 2025-07-20
+ dates:
+ status: exact
+ start: 2025-07-07
+ end: 2025-07-20
url: https://wiki.debian.org/DebConf/25
- going: true
cfp_url: https://debconf25.debconf.org/talks/new/
- venue: École nationale supérieure Mines-Télécom Atlantique Bretagne Pays de la Loire
+ venue: Ecole nationale superieure Mines-Telecom Atlantique Bretagne Pays de la Loire
campus de Brest
latitude: 48.35934
longitude: -4.569889
- name: Wikimedia Hackathon
+ series: wikimedia-hackathon
topic: Wikimedia
- location: Istanbul
- country: tr
- start: 2025-05-02
- end: 2025-05-04
- venue: Renaissance Polat Istanbul Hotel
- address: Yeşilyurt, Sahil Yolu Cd. No:2, 34149 Bakırköy/İstanbul
- latitude: 40.959946
- longitude: 28.838763
- url: https://www.mediawiki.org/wiki/Wikimedia_Hackathon_2025
- going: true
- free: true
+ location: Albania
+ country: al
+ dates:
+ status: approximate
+ label: mid-April 2027
+ earliest: 2027-04-11
+ latest: 2027-04-20
+ url: https://www.mediawiki.org/wiki/Wikimedia_Hackathon_2027
hackathon: true
- registered: true
+
+- name: PyCascades
+ series: pycascades
+ topic: Python
+ location: Seattle, Washington
+ country: us
+ dates:
+ status: approximate
+ label: March 2027
+ earliest: 2027-03-01
+ latest: 2027-03-31
"""
+
+
+def series_prompt_text(series: dict[str, ConferenceSeries]) -> str:
+ """Return compact known series text for the LLM prompt."""
+ if not series:
+ return "No known conference series loaded."
+
+ lines = ["Known conference series IDs:"]
+ for series_id, item in sorted(series.items()):
+ details = [item["name"]]
+ if topic := item.get("topic"):
+ details.append(f"topic: {topic}")
+ if location := item.get("usual_location"):
+ details.append(f"usual location: {location}")
+ if country := item.get("country"):
+ details.append(f"country: {country}")
+ lines.append(f"- {series_id}: " + "; ".join(details))
+ return "\n".join(lines)
+
+
+def build_prompt(
+ url: str,
+ source_text: str,
+ detected_coordinates: tuple[float, float] | None,
+ series: dict[str, ConferenceSeries] | None = None,
+) -> str:
+ """Build prompt with embedded YAML format details and examples."""
coordinate_note = ""
if detected_coordinates is not None:
coordinate_note = (
@@ -99,28 +180,26 @@ def build_prompt(
prompt = f"""
I keep a record of interesting conferences in a YAML file.
+Format rules:
+
+{conference_yaml_format_description()}
+
+{series_prompt_text(series or {})}
+
Here are some examples of the format I use:
-{examples}
+{yaml_example_text()}
Now here is a new conference of interest:
Conference URL: {url}
-Return the YAML representation for this conference following the
-same style and keys as the examples. Only include keys if the
-information is available. Do not invent details.
+Return the YAML representation for this conference following the same style and
+keys as the examples. Only include keys if the information is available. Do not
+invent details.
-Important: the `country` field must always be a valid ISO 3166-1 alpha-2
-country code (two lowercase letters, e.g. `ca` for Canada, `gb` for United Kingdom).
-Do not output full country names.
-
-Important: always include an `end` field. If the event is a single-day event,
-the `end` can be the same date as `start`, or a same-day datetime if the page
-provides an end time.
-
-Important: if this is a Geomob event, use an `end` datetime of 22:00 local time
-on the event date unless the page explicitly provides a different end time.
+Important: if this is a Geomob event, use a `dates.end` datetime of 22:00 local
+time on the event date unless the page explicitly provides a different end time.
{coordinate_note}
Wrap your answer in a JSON object with a single key "yaml".
@@ -261,9 +340,26 @@ def parse_date(date_str: str) -> datetime:
return dt
+def data_dir_from_conferences_path(yaml_path: str) -> str:
+ """Return personal-data directory from a conferences.yaml path."""
+ return os.path.dirname(os.path.abspath(yaml_path))
+
+
def url_has_year_component(url: str) -> bool:
- """Return True if the URL contains any digit."""
- return any(ch.isdigit() for ch in url)
+ """Return True if the URL contains a year or edition path component."""
+ parsed = urlparse(url)
+ components = [part for part in parsed.path.split("/") if part]
+ if parsed.netloc:
+ components.extend(part for part in parsed.netloc.split(".") if part)
+
+ for component in components:
+ if re.fullmatch(r"20\d{2}", component):
+ return True
+ if re.search(r"(?:^|[-_/])20\d{2}(?:$|[-_/])", component):
+ return True
+ if re.fullmatch(r"\d{1,2}x", component, flags=re.IGNORECASE):
+ return True
+ return False
def insert_sorted(
@@ -273,6 +369,13 @@ def insert_sorted(
new_url = new_conf.get("url")
new_start = conference_sort_datetime(new_conf)
new_year = new_start.year
+ update_idx = find_inexact_existing_conference(conferences, new_conf)
+ if update_idx is not None:
+ existing = conferences.pop(update_idx)
+ merged = dict(existing)
+ merged.update(new_conf)
+ print(f"Updating inexact conference entry: {existing.get('name')}")
+ return insert_sorted(conferences, merged)
if new_url:
for conf in conferences:
@@ -299,6 +402,56 @@ def insert_sorted(
return conferences
+def date_ranges_overlap(
+ first: dict[str, typing.Any], second: dict[str, typing.Any]
+) -> bool:
+ """Return True if two conference date ranges overlap."""
+ first_fields = conference_date_fields(first)
+ second_fields = conference_date_fields(second)
+ return typing.cast(date, first_fields["start_date"]) <= typing.cast(
+ date, second_fields["end_date"]
+ ) and typing.cast(date, second_fields["start_date"]) <= typing.cast(
+ date, first_fields["end_date"]
+ )
+
+
+def same_conference_identity(
+ existing: dict[str, typing.Any], new_conf: dict[str, typing.Any]
+) -> bool:
+ """Return True if two entries appear to represent the same conference."""
+ existing_url = existing.get("url")
+ new_url = new_conf.get("url")
+ if existing_url and new_url and existing_url == new_url:
+ return True
+
+ existing_series = existing.get("series")
+ new_series = new_conf.get("series")
+ if existing_series and new_series and existing_series == new_series:
+ return date_ranges_overlap(existing, new_conf)
+
+ return str(existing.get("name", "")).casefold() == str(
+ new_conf.get("name", "")
+ ).casefold() and date_ranges_overlap(existing, new_conf)
+
+
+def find_inexact_existing_conference(
+ conferences: list[dict[str, typing.Any]], new_conf: dict[str, typing.Any]
+) -> int | None:
+ """Return index of an inexact existing entry that exact new data can update."""
+ new_fields = conference_date_fields(new_conf)
+ if new_fields["date_status"] != "exact":
+ return None
+
+ for idx, existing in enumerate(conferences):
+ existing_fields = conference_date_fields(existing)
+ if existing_fields["date_status"] == "exact":
+ continue
+ if same_conference_identity(existing, new_conf):
+ return idx
+
+ return None
+
+
def conference_sort_datetime(conf: dict[str, typing.Any]) -> datetime:
"""Return conference sort date as a datetime."""
sort_date = conference_date_fields(conf)["sort_date"]
@@ -349,6 +502,42 @@ def parse_yaml_datetime(value: typing.Any) -> datetime | None:
return None
+def parse_yaml_date_value(value: typing.Any) -> date | datetime | None:
+ """Convert YAML date/datetime strings to date-like values."""
+ if isinstance(value, datetime):
+ return value
+
+ if isinstance(value, date):
+ return value
+
+ if not isinstance(value, str):
+ return None
+
+ try:
+ if " " in value or "T" in value:
+ return datetime.fromisoformat(value)
+ return date.fromisoformat(value)
+ except ValueError:
+ return None
+
+
+def normalize_date_values(conf: dict[str, typing.Any]) -> None:
+ """Normalize quoted ISO date/datetime values produced by the LLM."""
+ dates = conf.get("dates")
+ if isinstance(dates, dict):
+ for field in ("start", "end", "earliest", "latest"):
+ if field in dates:
+ parsed = parse_yaml_date_value(dates[field])
+ if parsed is not None:
+ dates[field] = parsed
+
+ for field in ("start", "end"):
+ if field in conf:
+ parsed = parse_yaml_date_value(conf[field])
+ if parsed is not None:
+ conf[field] = parsed
+
+
def same_type_as_start(
start_value: typing.Any,
new_dt: datetime,
@@ -374,6 +563,46 @@ def same_type_as_start(
return new_dt
+def normalize_dates_field(conf: dict[str, typing.Any]) -> None:
+ """Move legacy top-level date fields into the nested dates mapping."""
+ normalize_date_values(conf)
+ raw_dates = conf.get("dates")
+ dates = raw_dates if isinstance(raw_dates, dict) else None
+
+ if dates is None and ("start" in conf or "end" in conf):
+ start = conf.pop("start", None)
+ end = conf.pop("end", start)
+ status = str(conf.pop("date_status", "exact"))
+ conf["dates"] = {"status": status, "start": start, "end": end}
+ return
+
+ if dates is not None:
+ if "start" in conf and "start" not in dates:
+ dates["start"] = conf["start"]
+ if "end" in conf and "end" not in dates:
+ dates["end"] = conf["end"]
+ if "date_status" in conf and "status" not in dates:
+ dates["status"] = conf["date_status"]
+
+ conf.pop("start", None)
+ conf.pop("end", None)
+ conf.pop("date_status", None)
+ normalize_date_values(conf)
+
+
+def validate_generated_conference(conf: dict[str, typing.Any]) -> None:
+ """Validate generated conference YAML before inserting it."""
+ try:
+ conference_date_fields(conf)
+ except ValueError as exc:
+ generated_yaml = yaml.dump(conf, sort_keys=False, allow_unicode=True).strip()
+ raise ValueError(
+ "Generated conference YAML is missing valid date information. "
+ "Expected nested `dates:` with exact/tentative start/end or "
+ f"approximate earliest/latest.\n\nGenerated YAML:\n{generated_yaml}"
+ ) from exc
+
+
def maybe_extract_explicit_end_time(source_text: str) -> int | None:
"""Extract an explicit 12-hour clock end time for Geomob-style pages."""
lowered = source_text.lower()
@@ -435,6 +664,14 @@ def load_conferences(yaml_path: str) -> list[dict[str, typing.Any]]:
return typing.cast(list[dict[str, typing.Any]], loaded)
+def load_conference_series_for_path(yaml_path: str) -> dict[str, ConferenceSeries]:
+ """Load conference series next to the target conferences YAML file."""
+ return typing.cast(
+ dict[str, ConferenceSeries],
+ load_series(data_dir_from_conferences_path(yaml_path)),
+ )
+
+
def dump_conferences(yaml_path: str, conferences: list[dict[str, typing.Any]]) -> None:
"""Write conference YAML."""
with open(yaml_path, "w") as file:
@@ -450,6 +687,9 @@ def add_new_conference(url: str, yaml_path: str) -> bool:
if url_has_year_component(url):
for conf in conferences:
if conf.get("url") == url:
+ fields = conference_date_fields(conf)
+ if fields["date_status"] != "exact":
+ continue
print(
"⚠️ Conference already exists in YAML "
+ f"(url={url}), skipping before API call."
@@ -459,7 +699,8 @@ def add_new_conference(url: str, yaml_path: str) -> bool:
soup = fetch_webpage(url)
source_text = webpage_to_text(soup)
detected_coordinates = detect_page_coordinates(soup)
- prompt = build_prompt(url, source_text, detected_coordinates)
+ series = load_conference_series_for_path(yaml_path)
+ prompt = build_prompt(url, source_text, detected_coordinates, series)
new_yaml_text = get_from_open_ai(prompt)["yaml"]
new_conf = yaml.safe_load(new_yaml_text)
@@ -468,7 +709,10 @@ def add_new_conference(url: str, yaml_path: str) -> bool:
assert isinstance(new_conf, dict)
validate_country(new_conf)
+ normalize_dates_field(new_conf)
normalise_end_field(new_conf, source_text)
+ normalize_dates_field(new_conf)
+ validate_generated_conference(new_conf)
if detected_coordinates is not None:
new_conf["latitude"] = detected_coordinates[0]
diff --git a/agenda/conference.py b/agenda/conference.py
index f9e15b4..89f43fe 100644
--- a/agenda/conference.py
+++ b/agenda/conference.py
@@ -2,6 +2,7 @@
import dataclasses
import decimal
+import os
import typing
from datetime import date, datetime
@@ -16,6 +17,18 @@ DATE_STATUSES = {"exact", "approximate", "tentative"}
DATED_STATUSES = {"exact", "tentative"}
+class ConferenceSeries(typing.TypedDict, total=False):
+ """Conference series metadata."""
+
+ name: str
+ topic: str
+ url: str
+ notes: str
+ cadence: str
+ usual_location: str
+ country: str
+
+
@dataclasses.dataclass
class Conference:
"""Conference."""
@@ -23,6 +36,7 @@ class Conference:
name: str
topic: str
location: str
+ series: str | None = None
start: date | datetime | None = None
end: date | datetime | None = None
trip: date | None = None
@@ -166,6 +180,20 @@ def validate_conference_date_fields(item: StrDict) -> StrDict:
return fields
+def load_series(data_dir: str) -> dict[str, ConferenceSeries]:
+ """Load conference series metadata."""
+ filepath = os.path.join(data_dir, "conference_series.yaml")
+ if not os.path.exists(filepath):
+ return {}
+
+ loaded = yaml.safe_load(open(filepath, "r"))
+ if loaded is None:
+ return {}
+ if not isinstance(loaded, dict):
+ raise ValueError("conference_series.yaml must be a mapping")
+ return typing.cast(dict[str, ConferenceSeries], loaded)
+
+
def get_list(filepath: str) -> list[Event]:
"""Read conferences from a YAML file and return a list of Event objects."""
events: list[Event] = []
diff --git a/agenda/trip.py b/agenda/trip.py
index 6ab69ff..2561094 100644
--- a/agenda/trip.py
+++ b/agenda/trip.py
@@ -11,7 +11,7 @@ import flask
import pycountry
import yaml
-from agenda import ical, travel, trip_schengen
+from agenda import conference, ical, travel, trip_schengen
from agenda.types import StrDict, Trip, TripElement
from agenda.utils import as_date, as_datetime, depart_datetime
@@ -333,12 +333,18 @@ def build_trip_list(
yaml_trip_list = travel.parse_yaml("trips", data_dir)
flight_bookings = load_flight_bookings(data_dir)
+ conferences = travel.parse_yaml("conferences", data_dir)
+ for conf in conferences:
+ date_fields = conference.conference_date_fields(conf)
+ if "start" in date_fields:
+ conf["start"] = date_fields["start"]
+ conf["end"] = date_fields["end"]
data = {
"flight_bookings": flight_bookings,
"travel": collect_travel_items(flight_bookings, data_dir, route_distances),
"accommodation": travel.parse_yaml("accommodation", data_dir),
- "conferences": travel.parse_yaml("conferences", data_dir),
+ "conferences": conferences,
"events": travel.parse_yaml("events", data_dir),
}
@@ -467,10 +473,10 @@ def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]:
return {}
def conf_attend_start(c: StrDict) -> date:
- return as_date(c.get("attend_start") or c["start"])
+ return typing.cast(date, as_date(c.get("attend_start") or c["start"]))
def conf_attend_end(c: StrDict) -> date:
- return as_date(c.get("attend_end") or c["end"])
+ return typing.cast(date, as_date(c.get("attend_end") or c["end"]))
sorted_confs = sorted(trip.conferences, key=conf_attend_start)
result: dict[str, tuple[int, int]] = {}
@@ -864,7 +870,7 @@ def _trip_element_label(element: TripElement) -> str:
return start_loc
if isinstance(end_loc, str):
return end_loc
- return element.title
+ return typing.cast(str, element.title)
def _trip_element_summary(trip: Trip, element: TripElement) -> str:
diff --git a/docs/personal-data-yaml.md b/docs/personal-data-yaml.md
index a538fff..08c391e 100644
--- a/docs/personal-data-yaml.md
+++ b/docs/personal-data-yaml.md
@@ -283,6 +283,7 @@ Legacy fields:
Common optional fields:
+- Series: `series`, a key from `conference_series.yaml`.
- Trip/location: `trip`, `country`, `venue`, `address`, `latitude`, `longitude`.
- Attendance: `going`, `registered`, `speaking`, `online`, `accommodation_booked`, `transport_booked`.
- Partial attendance: `attend_start`, `attend_end`. These may be dates or timezone-aware datetimes and are used on trip pages instead of official dates.
@@ -294,6 +295,7 @@ Exact example:
```yaml
- name: FOSDEM
+ series: fosdem
topic: FOSDEM
location: Brussels
country: be
@@ -319,6 +321,7 @@ Tentative example:
```yaml
- name: FOSDEM
+ series: fosdem
topic: FOSDEM
location: Brussels
country: be
@@ -335,6 +338,7 @@ Approximate examples:
```yaml
- name: Wikimedia Hackathon 2027
+ series: wikimedia-hackathon
topic: Wikimedia
location: Albania
country: al
@@ -346,6 +350,7 @@ Approximate examples:
hackathon: true
- name: PyCascades 2027
+ series: pycascades
topic: Python
location: TBC
dates:
@@ -355,6 +360,46 @@ Approximate examples:
latest: 2027-03-31
```
+## `conference_series.yaml`
+
+Top-level shape: mapping from stable series ID to series metadata.
+
+Used by: conference list pages, conference series index/detail pages, and validation of `conferences.yaml` `series` references.
+
+Required fields for each series:
+
+- `name`: display name for the series.
+
+Common optional fields:
+
+- `topic`: default topic/category.
+- `cadence`: for example `annual` or `recurring`.
+- `usual_location`: common city/place when the event usually stays in one place.
+- `country`: common lowercase country code when stable.
+- `url`: series homepage.
+- `notes`: free-text generation or scheduling notes.
+
+Example:
+
+```yaml
+fosdem:
+ name: FOSDEM
+ topic: FOSDEM
+ cadence: annual
+ usual_location: Brussels
+ country: be
+ url: https://fosdem.org/
+ notes: Usually the weekend where Sunday is the first Sunday in February.
+
+geomob-london:
+ name: Geomob London
+ topic: Maps
+ cadence: recurring
+ usual_location: London
+ country: gb
+ url: https://thegeomob.com/
+```
+
## `entities.yaml`
Top-level shape: list of people/entities.
diff --git a/templates/conference_list.html b/templates/conference_list.html
index 6529b22..e20b154 100644
--- a/templates/conference_list.html
+++ b/templates/conference_list.html
@@ -134,7 +134,7 @@ tr.conf-hl > td {
{% if item_list %}
{% set count = item_list | length %}
- | {{ heading }} {{ count }} conference{{ "" if count == 1 else "s" }} |
+ {{ heading }} {{ count }} conference{{ "" if count == 1 else "s" }} |
{% set ns = namespace(prev_month="") %}
{% for item in item_list %}
@@ -142,7 +142,7 @@ tr.conf-hl > td {
{% if month_label != ns.prev_month %}
{% set ns.prev_month = month_label %}
- | {{ month_label }} |
+ {{ month_label }} |
{% endif %}
@@ -176,6 +176,13 @@ tr.conf-hl > td {
{% endif %}
| {{ item.topic }} |
+
+ {% if item.series and item.series_detail %}
+ {{ item.series_detail.name }}
+ {% elif item.series %}
+ {{ item.series }}
+ {% endif %}
+ |
{% set country = get_country(item.country) if item.country else None %}
{% if country %}{{ country.flag }} {{ item.location }}
@@ -213,6 +220,7 @@ tr.conf-hl > td {
+
@@ -221,6 +229,7 @@ tr.conf-hl > td {
| Dates |
Conference |
Topic |
+ Series |
Location |
CFP ends |
Price |
diff --git a/templates/conference_series.html b/templates/conference_series.html
new file mode 100644
index 0000000..10bfdb7
--- /dev/null
+++ b/templates/conference_series.html
@@ -0,0 +1,85 @@
+{% extends "base.html" %}
+
+{% block title %}{{ series.name }} - Edward Betts{% endblock %}
+
+{% block content %}
+
+
{{ series.name }}
+
+
+ {% if series.topic %}
+ - Topic
+ - {{ series.topic }}
+ {% endif %}
+ {% if series.usual_location or series.country %}
+ - Usual location
+ -
+ {% set country = get_country(series.country) if series.country else None %}
+ {% if country %}{{ country.flag }} {% endif %}{{ series.usual_location or "" }}
+
+ {% endif %}
+ {% if series.cadence %}
+ - Cadence
+ - {{ series.cadence }}
+ {% endif %}
+ {% if series.url %}
+ - Website
+ - {{ series.url }}
+ {% endif %}
+ {% if series.notes %}
+ - Notes
+ - {{ series.notes }}
+ {% endif %}
+
+
+
Editions
+
+
+
+ | Dates |
+ Conference |
+ Location |
+ Attendance |
+
+
+
+ {% for item in conferences %}
+
+ |
+ {{ item.display_date }}
+ {% if item.date_status == "tentative" %}
+ tentative
+ {% elif item.date_status == "approximate" %}
+ approximate
+ {% endif %}
+ |
+
+ {% if item.url %}{{ item.name }}
+ {% else %}{{ item.name }}{% endif %}
+ |
+
+ {% set country = get_country(item.country) if item.country else None %}
+ {% if country %}{{ country.flag }} {% endif %}{{ item.location }}
+ |
+
+ {% if item.going %}
+ going
+ {% endif %}
+ {% if item.registered %}
+ registered
+ {% endif %}
+ {% if item.linked_trip %}
+ {% set trip = item.linked_trip %}
+
+ trip{% if trip.title != item.name %}: {{ trip.title }}{% endif %}
+
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/templates/conference_series_list.html b/templates/conference_series_list.html
new file mode 100644
index 0000000..f9ccc5f
--- /dev/null
+++ b/templates/conference_series_list.html
@@ -0,0 +1,47 @@
+{% extends "base.html" %}
+
+{% block title %}Conference Series - Edward Betts{% endblock %}
+
+{% block content %}
+
+
Conference Series
+
+
+
+
+ | Series |
+ Topic |
+ Usual location |
+ Conferences |
+ Next |
+
+
+
+ {% for item in series_list %}
+
+ |
+ {{ item.name }}
+ {% if item.attended %}
+ attended
+ {% endif %}
+ {% if item.url %}
+ ↗
+ {% endif %}
+ |
+ {{ item.topic or "" }} |
+
+ {% set country = get_country(item.country) if item.country else None %}
+ {% if country %}{{ country.flag }} {% endif %}{{ item.usual_location or "" }}
+ |
+ {{ item.count }} |
+
+ {% if item.next_conf %}
+ {{ item.next_conf.display_date }} · {{ item.next_conf.name }}
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/templates/navbar.html b/templates/navbar.html
index fe5b98a..373c053 100644
--- a/templates/navbar.html
+++ b/templates/navbar.html
@@ -28,6 +28,7 @@
{% set conference_pages = [
{"endpoint": "conference_list", "label": "Conferences" },
{"endpoint": "past_conference_list", "label": "Past conferences" },
+ {"endpoint": "conference_series_list", "label": "Conference series" },
] %}
@@ -63,7 +64,7 @@
- {% set conference_active = request.endpoint in ['conference_list', 'past_conference_list'] %}
+ {% set conference_active = request.endpoint in ['conference_list', 'past_conference_list', 'conference_series_list', 'conference_series_page'] %}
Conferences
diff --git a/tests/test_add_new_conference.py b/tests/test_add_new_conference.py
index 21338d9..02d19df 100644
--- a/tests/test_add_new_conference.py
+++ b/tests/test_add_new_conference.py
@@ -26,6 +26,19 @@ def test_extract_google_maps_latlon_at_pattern() -> None:
assert result == (51.5242464, -0.0997024)
+def test_url_has_year_component() -> None:
+ """Only actual year or edition components should count as year-specific."""
+ cases = [
+ ("https://www.foss4gna.org/", False),
+ ("https://foss4g.asia/2026/", True),
+ ("https://2027.fossy.ca/", True),
+ ("https://www.socallinuxexpo.org/scale/24x/", True),
+ ("https://2026.stateofthebrowser.com/", True),
+ ]
+ for url, expected in cases:
+ assert add_new_conference.url_has_year_component(url) is expected
+
+
def test_insert_sorted_allows_same_url_different_year_without_year_component() -> None:
"""The same non-year-specific URL can be reused for a different year."""
conferences: list[dict[str, typing.Any]] = [
@@ -74,6 +87,115 @@ def test_insert_sorted_supports_nested_dates() -> None:
assert [conf["name"] for conf in updated] == ["FOSDEM", "PyCascades"]
+def test_insert_sorted_updates_inexact_existing_entry() -> None:
+ """Exact dates should replace an existing inexact series entry."""
+ conferences: list[dict[str, typing.Any]] = [
+ {
+ "name": "PyCascades",
+ "series": "pycascades",
+ "topic": "Python",
+ "location": "Seattle, Washington",
+ "dates": {
+ "status": "approximate",
+ "label": "March 2027",
+ "earliest": date(2027, 3, 1),
+ "latest": date(2027, 3, 31),
+ },
+ "url": "https://2027.pycascades.com/",
+ }
+ ]
+ new_conf: dict[str, typing.Any] = {
+ "name": "PyCascades",
+ "series": "pycascades",
+ "topic": "Python",
+ "location": "Seattle, Washington",
+ "dates": {
+ "status": "exact",
+ "start": date(2027, 3, 12),
+ "end": date(2027, 3, 14),
+ },
+ "url": "https://2027.pycascades.com/",
+ "venue": "Example Hall",
+ }
+
+ updated = add_new_conference.insert_sorted(conferences, new_conf)
+
+ assert len(updated) == 1
+ assert updated[0]["dates"]["status"] == "exact"
+ assert updated[0]["dates"]["start"] == date(2027, 3, 12)
+ assert updated[0]["venue"] == "Example Hall"
+
+
+def test_normalize_dates_field_moves_legacy_dates() -> None:
+ """Legacy start/end model output should be converted before writing YAML."""
+ conf: dict[str, typing.Any] = {
+ "name": "PyCon",
+ "start": date(2026, 4, 10),
+ "end": date(2026, 4, 12),
+ }
+
+ add_new_conference.normalize_dates_field(conf)
+
+ assert "start" not in conf
+ assert "end" not in conf
+ assert conf["dates"] == {
+ "status": "exact",
+ "start": date(2026, 4, 10),
+ "end": date(2026, 4, 12),
+ }
+
+
+def test_normalize_dates_field_parses_quoted_dates() -> None:
+ """Quoted ISO dates from generated YAML should become date objects."""
+ conf: dict[str, typing.Any] = {
+ "name": "Git Merge",
+ "dates": {
+ "status": "exact",
+ "start": "2026-09-16",
+ "end": "2026-09-17",
+ },
+ }
+
+ add_new_conference.normalize_dates_field(conf)
+
+ assert conf["dates"]["start"] == date(2026, 9, 16)
+ assert conf["dates"]["end"] == date(2026, 9, 17)
+
+
+def test_validate_generated_conference_reports_missing_dates() -> None:
+ """Missing generated dates should raise a clear importer error."""
+ conf: dict[str, typing.Any] = {
+ "name": "Git Merge",
+ "topic": "Git",
+ "location": "TBC",
+ }
+
+ with pytest.raises(ValueError, match="missing valid date information"):
+ add_new_conference.validate_generated_conference(conf)
+
+
+def test_build_prompt_includes_nested_dates_and_series() -> None:
+ """The prompt should describe nested dates and known series IDs."""
+ prompt = add_new_conference.build_prompt(
+ "https://example.com",
+ "Conference details",
+ None,
+ {
+ "pycascades": {
+ "name": "PyCascades",
+ "topic": "Python",
+ "usual_location": "Seattle, Washington",
+ "country": "us",
+ }
+ },
+ )
+
+ assert "Do not output legacy top-level `start`, `end`, or `date_status`" in prompt
+ assert "dates.status" in prompt
+ assert "- pycascades: PyCascades" in prompt
+ assert "March 2027" in prompt
+
+
def test_validate_country_normalises_name() -> None:
"""Country names should be normalised to alpha-2 codes."""
conf: dict[str, typing.Any] = {"country": "United Kingdom"}
@@ -197,6 +319,76 @@ def test_add_new_conference_updates_yaml(
assert len(written) == 2
assert written[1]["name"] == "NewConf"
assert written[1]["country"] == "us"
- assert written[1]["end"] == date(2026, 5, 3)
+ assert written[1]["dates"] == {
+ "status": "exact",
+ "start": date(2026, 5, 3),
+ "end": date(2026, 5, 3),
+ }
assert written[1]["latitude"] == 40.0
assert written[1]["longitude"] == -74.0
+
+
+def test_add_new_conference_reuses_generic_url_for_new_year(
+ tmp_path: typing.Any, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Generic URLs with digits in the domain should not be skipped early."""
+ yaml_path = tmp_path / "conferences.yaml"
+ yaml_path.write_text(
+ yaml.dump(
+ [
+ {
+ "name": "FOSS4G North America",
+ "series": "foss4g-north-america",
+ "dates": {
+ "status": "exact",
+ "start": date(2025, 11, 3),
+ "end": date(2025, 11, 5),
+ },
+ "url": "https://www.foss4gna.org/",
+ }
+ ],
+ sort_keys=False,
+ )
+ )
+
+ root = lxml.html.fromstring("Conference details")
+ monkeypatch.setattr(add_new_conference, "fetch_webpage", lambda url: root)
+ monkeypatch.setattr(
+ add_new_conference,
+ "webpage_to_text",
+ lambda parsed: "FOSS4G North America 2026",
+ )
+ monkeypatch.setattr(
+ add_new_conference, "detect_page_coordinates", lambda parsed: None
+ )
+ monkeypatch.setattr(
+ add_new_conference,
+ "get_from_open_ai",
+ lambda prompt: {
+ "yaml": yaml.dump(
+ {
+ "name": "FOSS4G North America",
+ "series": "foss4g-north-america",
+ "topic": "Geospatial",
+ "location": "St. Louis, Missouri",
+ "country": "us",
+ "dates": {
+ "status": "exact",
+ "start": date(2026, 10, 26),
+ "end": date(2026, 10, 29),
+ },
+ "url": "https://www.foss4gna.org/",
+ },
+ sort_keys=False,
+ )
+ },
+ )
+
+ added = add_new_conference.add_new_conference(
+ "https://www.foss4gna.org/", str(yaml_path)
+ )
+
+ assert added is True
+ written = yaml.safe_load(yaml_path.read_text())
+ assert len(written) == 2
+ assert [conf["dates"]["start"].year for conf in written] == [2025, 2026]
diff --git a/tests/test_conference_list.py b/tests/test_conference_list.py
index 7a09802..6eb26f7 100644
--- a/tests/test_conference_list.py
+++ b/tests/test_conference_list.py
@@ -2,6 +2,7 @@
from datetime import date
import typing
+from types import SimpleNamespace
import yaml
@@ -16,6 +17,7 @@ def test_build_conference_list_supports_inexact_dates(
conferences = [
{
"name": "PyCascades 2027",
+ "series": "pycascades",
"topic": "Python",
"location": "TBC",
"dates": {
@@ -40,6 +42,19 @@ def test_build_conference_list_supports_inexact_dates(
(tmp_path / "conferences.yaml").write_text(
yaml.safe_dump(conferences), encoding="utf-8"
)
+ (tmp_path / "conference_series.yaml").write_text(
+ yaml.safe_dump(
+ {
+ "pycascades": {
+ "name": "PyCascades",
+ "topic": "Python",
+ "cadence": "annual",
+ "url": "https://pycascades.com/",
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
@@ -53,3 +68,56 @@ def test_build_conference_list_supports_inexact_dates(
assert items[1]["date_status"] == "approximate"
assert items[1]["display_date"] == "March 2027"
assert items[1]["latest_date"] == date(2027, 3, 31)
+ assert items[1]["series_detail"]["name"] == "PyCascades"
+
+
+def test_conference_series_pages(tmp_path: typing.Any, monkeypatch: typing.Any) -> None:
+ """Series index and detail pages should render linked conferences."""
+ conferences = [
+ {
+ "name": "PyCascades 2027",
+ "series": "pycascades",
+ "topic": "Python",
+ "location": "TBC",
+ "trip": date(2027, 3, 1),
+ "dates": {
+ "status": "exact",
+ "start": date(2027, 3, 5),
+ "end": date(2027, 3, 6),
+ },
+ "going": True,
+ }
+ ]
+ series = {
+ "pycascades": {
+ "name": "PyCascades",
+ "topic": "Python",
+ "cadence": "annual",
+ "url": "https://pycascades.com/",
+ }
+ }
+ (tmp_path / "conferences.yaml").write_text(
+ yaml.safe_dump(conferences), encoding="utf-8"
+ )
+ (tmp_path / "conference_series.yaml").write_text(
+ yaml.safe_dump(series), encoding="utf-8"
+ )
+
+ monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
+ fake_trip = SimpleNamespace(
+ start=date(2027, 3, 1),
+ title="Seattle Python trip",
+ conferences=[{"start": date(2027, 3, 5), "name": "PyCascades 2027"}],
+ )
+ monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [fake_trip])
+
+ web_view.app.config["TESTING"] = True
+ with web_view.app.test_client() as client:
+ index_response = client.get("/conference/series")
+ detail_response = client.get("/conference/series/pycascades")
+
+ assert index_response.status_code == 200
+ assert b"PyCascades" in index_response.data
+ assert b"attended" in index_response.data
+ assert detail_response.status_code == 200
+ assert b"trip: Seattle Python trip" in detail_response.data
diff --git a/validate_yaml.py b/validate_yaml.py
index 8baa314..9a41271 100755
--- a/validate_yaml.py
+++ b/validate_yaml.py
@@ -283,6 +283,7 @@ def check_conferences() -> None:
filepath = os.path.join(data_dir, "conferences.yaml")
conferences_data = yaml.safe_load(open(filepath, "r"))
conferences = [agenda.conference.Conference(**conf) for conf in conferences_data]
+ series = agenda.conference.load_series(data_dir)
prev_start = None
prev_conf_data = None
@@ -297,6 +298,11 @@ def check_conferences() -> None:
check_country_code(conf_data, "conference", required=False)
check_conference_dates(conf_data)
+ series_id = conf_data.get("series")
+ if series_id is not None and series_id not in series:
+ pprint(conf_data)
+ print(f"conference references unknown series {series_id!r}")
+ sys.exit(-1)
date_fields = agenda.conference.conference_date_fields(conf_data)
current_start = normalize_datetime(date_fields["sort_date"])
@@ -316,6 +322,21 @@ def check_conferences() -> None:
print(len(conferences), "conferences")
+def check_conference_series() -> None:
+ """Check conference series metadata."""
+ series = agenda.conference.load_series(data_dir)
+ for series_id, item in series.items():
+ if not isinstance(series_id, str):
+ print(f"conference series id must be a string: {series_id!r}")
+ sys.exit(-1)
+ if "name" not in item:
+ pprint(item)
+ print(f"conference series {series_id!r} missing name")
+ sys.exit(-1)
+ check_country_code(item, "conference series", required=False)
+ print(len(series), "conference series")
+
+
def check_events() -> None:
"""Check events."""
today = date.today()
@@ -505,6 +526,7 @@ def check() -> None:
check_trains()
check_ferries()
check_buses()
+ check_conference_series()
check_conferences()
check_events()
check_accommodation()
diff --git a/web_view.py b/web_view.py
index e4981fa..de824e0 100755
--- a/web_view.py
+++ b/web_view.py
@@ -372,6 +372,7 @@ def build_conference_list() -> list[StrDict]:
data_dir = app.config["PERSONAL_DATA"]
filepath = os.path.join(data_dir, "conferences.yaml")
items: list[StrDict] = yaml.safe_load(open(filepath))
+ series_lookup = agenda.conference.load_series(data_dir)
conference_trip_lookup = {}
for trip in agenda.trip.build_trip_list():
@@ -386,6 +387,10 @@ def build_conference_list() -> list[StrDict]:
if price:
conf["price"] = decimal.Decimal(price)
+ series_id = conf.get("series")
+ if isinstance(series_id, str):
+ conf["series_detail"] = series_lookup.get(series_id)
+
if "start" in conf:
key = (conf["start"], conf["name"])
if this_trip := conference_trip_lookup.get(key):
@@ -395,6 +400,34 @@ def build_conference_list() -> list[StrDict]:
return items
+def build_conference_series_list() -> list[StrDict]:
+ """Build conference series list with conference counts."""
+ data_dir = app.config["PERSONAL_DATA"]
+ series_lookup = agenda.conference.load_series(data_dir)
+ conferences = build_conference_list()
+
+ series_items: list[StrDict] = []
+ for series_id, series in series_lookup.items():
+ linked = [conf for conf in conferences if conf.get("series") == series_id]
+ latest = max((conf["sort_date"] for conf in linked), default=None)
+ next_conf = next(
+ (conf for conf in linked if conf["latest_date"] >= date.today()), None
+ )
+ attended = any(conf.get("going") or conf.get("linked_trip") for conf in linked)
+ item: StrDict = {
+ "id": series_id,
+ **series,
+ "count": len(linked),
+ "latest": latest,
+ "next_conf": next_conf,
+ "attended": attended,
+ }
+ series_items.append(item)
+
+ series_items.sort(key=lambda item: str(item["name"]).lower())
+ return series_items
+
+
def _conference_uid(conf: StrDict) -> str:
"""Generate deterministic UID for conference events."""
start = agenda.utils.as_date(conf["start"])
@@ -596,6 +629,38 @@ def past_conference_list() -> str:
)
+@app.route("/conference/series")
+def conference_series_list() -> str:
+ """Page showing conference series."""
+ return flask.render_template(
+ "conference_series_list.html",
+ series_list=build_conference_series_list(),
+ get_country=agenda.get_country,
+ )
+
+
+@app.route("/conference/series/")
+def conference_series_page(series_id: str) -> str:
+ """Page showing one conference series."""
+ series_lookup = agenda.conference.load_series(app.config["PERSONAL_DATA"])
+ series = series_lookup.get(series_id)
+ if series is None:
+ flask.abort(404)
+
+ conferences = [
+ conf for conf in build_conference_list() if conf.get("series") == series_id
+ ]
+ return flask.render_template(
+ "conference_series.html",
+ series_id=series_id,
+ series=series,
+ conferences=conferences,
+ today=date.today(),
+ get_country=agenda.get_country,
+ fx_rate=agenda.fx.get_rates(app.config),
+ )
+
+
@app.route("/conference/ical")
def conference_ical() -> werkzeug.Response:
"""Return all conferences as an iCalendar feed."""