Compare commits

..

4 commits

12 changed files with 866 additions and 54 deletions

View file

@ -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]

View file

@ -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] = []

View file

@ -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:

View file

@ -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.

View file

@ -134,7 +134,7 @@ tr.conf-hl > td {
{% if item_list %}
{% set count = item_list | length %}
<tr class="conf-section-row">
<td colspan="6">{{ heading }} <span class="fw-normal opacity-75">{{ count }} conference{{ "" if count == 1 else "s" }}</span></td>
<td colspan="7">{{ heading }} <span class="fw-normal opacity-75">{{ count }} conference{{ "" if count == 1 else "s" }}</span></td>
</tr>
{% 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 %}
<tr class="conf-month-row">
<td colspan="6">{{ month_label }}</td>
<td colspan="7">{{ month_label }}</td>
</tr>
{% endif %}
<tr{% if item.going %} class="conf-going"{% endif %} data-conf-key="{{ item.sort_date.isoformat() }}|{{ item.name }}">
@ -176,6 +176,13 @@ tr.conf-hl > td {
{% endif %}
</td>
<td class="text-muted small">{{ item.topic }}</td>
<td class="text-nowrap text-muted small">
{% if item.series and item.series_detail %}
<a href="{{ url_for('conference_series_page', series_id=item.series) }}">{{ item.series_detail.name }}</a>
{% elif item.series %}
{{ item.series }}
{% endif %}
</td>
<td class="text-nowrap">
{% 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 {
<col>
<col style="width: 18rem">
<col style="width: 14rem">
<col style="width: 14rem">
<col style="width: 7rem">
<col style="width: 10rem">
</colgroup>
@ -221,6 +229,7 @@ tr.conf-hl > td {
<th>Dates</th>
<th>Conference</th>
<th>Topic</th>
<th>Series</th>
<th>Location</th>
<th>CFP ends</th>
<th>Price</th>

View file

@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}{{ series.name }} - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid mt-2">
<h1>{{ series.name }}</h1>
<dl class="row">
{% if series.topic %}
<dt class="col-sm-2">Topic</dt>
<dd class="col-sm-10">{{ series.topic }}</dd>
{% endif %}
{% if series.usual_location or series.country %}
<dt class="col-sm-2">Usual location</dt>
<dd class="col-sm-10">
{% set country = get_country(series.country) if series.country else None %}
{% if country %}{{ country.flag }} {% endif %}{{ series.usual_location or "" }}
</dd>
{% endif %}
{% if series.cadence %}
<dt class="col-sm-2">Cadence</dt>
<dd class="col-sm-10">{{ series.cadence }}</dd>
{% endif %}
{% if series.url %}
<dt class="col-sm-2">Website</dt>
<dd class="col-sm-10"><a href="{{ series.url }}">{{ series.url }}</a></dd>
{% endif %}
{% if series.notes %}
<dt class="col-sm-2">Notes</dt>
<dd class="col-sm-10">{{ series.notes }}</dd>
{% endif %}
</dl>
<h2>Editions</h2>
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Dates</th>
<th>Conference</th>
<th>Location</th>
<th>Attendance</th>
</tr>
</thead>
<tbody>
{% for item in conferences %}
<tr>
<td class="text-nowrap text-muted small">
{{ item.display_date }}
{% if item.date_status == "tentative" %}
<span class="badge text-bg-warning ms-1">tentative</span>
{% elif item.date_status == "approximate" %}
<span class="badge text-bg-secondary ms-1">approximate</span>
{% endif %}
</td>
<td>
{% if item.url %}<a href="{{ item.url }}">{{ item.name }}</a>
{% else %}{{ item.name }}{% endif %}
</td>
<td>
{% set country = get_country(item.country) if item.country else None %}
{% if country %}{{ country.flag }} {% endif %}{{ item.location }}
</td>
<td>
{% if item.going %}
<span class="badge text-bg-success">going</span>
{% endif %}
{% if item.registered %}
<span class="badge text-bg-primary">registered</span>
{% endif %}
{% if item.linked_trip %}
{% set trip = item.linked_trip %}
<a href="{{ url_for('trip_page', start=trip.start.isoformat()) }}"
class="ms-1"
title="Trip: {{ trip.title }}">
trip{% if trip.title != item.name %}: {{ trip.title }}{% endif %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Conference Series - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid mt-2">
<h1>Conference Series</h1>
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Series</th>
<th>Topic</th>
<th>Usual location</th>
<th>Conferences</th>
<th>Next</th>
</tr>
</thead>
<tbody>
{% for item in series_list %}
<tr>
<td>
<a href="{{ url_for('conference_series_page', series_id=item.id) }}">{{ item.name }}</a>
{% if item.attended %}
<span class="badge text-bg-success ms-1">attended</span>
{% endif %}
{% if item.url %}
<a class="text-muted ms-1 text-decoration-none" href="{{ item.url }}"></a>
{% endif %}
</td>
<td class="text-muted small">{{ item.topic or "" }}</td>
<td>
{% set country = get_country(item.country) if item.country else None %}
{% if country %}{{ country.flag }} {% endif %}{{ item.usual_location or "" }}
</td>
<td>{{ item.count }}</td>
<td class="text-muted small">
{% if item.next_conf %}
{{ item.next_conf.display_date }} · {{ item.next_conf.name }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -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 @@
</li>
<!-- Conference dropdown -->
{% 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'] %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle{% if conference_active %} border border-white border-2 active{% endif %}" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Conferences

View file

@ -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("<html><body>Conference details</body></html>")
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]

View file

@ -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

View file

@ -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()

View file

@ -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/<series_id>")
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."""