Compare commits

...

4 commits

12 changed files with 866 additions and 54 deletions

View file

@ -16,7 +16,7 @@ import pycountry
import requests import requests
import yaml 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" USER_AGENT = "add-new-conference/0.1"
COORDINATE_PATTERNS = ( COORDINATE_PATTERNS = (
@ -36,58 +36,139 @@ def read_api_key() -> str:
return parser["openai"]["api_key"] return parser["openai"]["api_key"]
def build_prompt( def conference_yaml_format_description() -> str:
url: str, """Return the conference YAML format description for LLM prompts."""
source_text: str, return """
detected_coordinates: tuple[float, float] | None, Use this YAML format for one conference entry.
) -> str:
"""Build prompt with embedded YAML examples.""" Required fields:
examples = """ - `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 - name: Geomob London
series: geomob-london
topic: Maps topic: Maps
location: London location: London
country: gb country: gb
start: 2026-01-28 18:00:00+00:00 dates:
end: 2026-01-28 23:00:00+00:00 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 url: https://thegeomob.com/post/jan-28th-2026-geomoblon-details
venue: Geovation Hub venue: Geovation Hub
address: Sutton Yard, 65 Goswell Rd, London EC1V 7EN address: Sutton Yard, 65 Goswell Rd, London EC1V 7EN
latitude: 51.5242464 latitude: 51.5242464
longitude: -0.0997024 longitude: -0.0997024
free: true free: true
going: true
hashtag: '#geomobLON' hashtag: '#geomobLON'
- name: DebConf 25 - name: DebConf 25
series: debconf
topic: Debian topic: Debian
location: Plouzané (Breast) location: Plouzane
country: fr country: fr
start: 2025-07-07 dates:
end: 2025-07-20 status: exact
start: 2025-07-07
end: 2025-07-20
url: https://wiki.debian.org/DebConf/25 url: https://wiki.debian.org/DebConf/25
going: true
cfp_url: https://debconf25.debconf.org/talks/new/ 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 campus de Brest
latitude: 48.35934 latitude: 48.35934
longitude: -4.569889 longitude: -4.569889
- name: Wikimedia Hackathon - name: Wikimedia Hackathon
series: wikimedia-hackathon
topic: Wikimedia topic: Wikimedia
location: Istanbul location: Albania
country: tr country: al
start: 2025-05-02 dates:
end: 2025-05-04 status: approximate
venue: Renaissance Polat Istanbul Hotel label: mid-April 2027
address: Yeşilyurt, Sahil Yolu Cd. No:2, 34149 Bakırköy/İstanbul earliest: 2027-04-11
latitude: 40.959946 latest: 2027-04-20
longitude: 28.838763 url: https://www.mediawiki.org/wiki/Wikimedia_Hackathon_2027
url: https://www.mediawiki.org/wiki/Wikimedia_Hackathon_2025
going: true
free: true
hackathon: true 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 = "" coordinate_note = ""
if detected_coordinates is not None: if detected_coordinates is not None:
coordinate_note = ( coordinate_note = (
@ -99,28 +180,26 @@ def build_prompt(
prompt = f""" prompt = f"""
I keep a record of interesting conferences in a YAML file. 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: Here are some examples of the format I use:
{examples} {yaml_example_text()}
Now here is a new conference of interest: Now here is a new conference of interest:
Conference URL: {url} Conference URL: {url}
Return the YAML representation for this conference following the Return the YAML representation for this conference following the same style and
same style and keys as the examples. Only include keys if the keys as the examples. Only include keys if the information is available. Do not
information is available. Do not invent details. invent details.
Important: the `country` field must always be a valid ISO 3166-1 alpha-2 Important: if this is a Geomob event, use a `dates.end` datetime of 22:00 local
country code (two lowercase letters, e.g. `ca` for Canada, `gb` for United Kingdom). time on the event date unless the page explicitly provides a different end time.
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.
{coordinate_note} {coordinate_note}
Wrap your answer in a JSON object with a single key "yaml". 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 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: def url_has_year_component(url: str) -> bool:
"""Return True if the URL contains any digit.""" """Return True if the URL contains a year or edition path component."""
return any(ch.isdigit() for ch in url) 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( def insert_sorted(
@ -273,6 +369,13 @@ def insert_sorted(
new_url = new_conf.get("url") new_url = new_conf.get("url")
new_start = conference_sort_datetime(new_conf) new_start = conference_sort_datetime(new_conf)
new_year = new_start.year 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: if new_url:
for conf in conferences: for conf in conferences:
@ -299,6 +402,56 @@ def insert_sorted(
return conferences 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: def conference_sort_datetime(conf: dict[str, typing.Any]) -> datetime:
"""Return conference sort date as a datetime.""" """Return conference sort date as a datetime."""
sort_date = conference_date_fields(conf)["sort_date"] sort_date = conference_date_fields(conf)["sort_date"]
@ -349,6 +502,42 @@ def parse_yaml_datetime(value: typing.Any) -> datetime | None:
return 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( def same_type_as_start(
start_value: typing.Any, start_value: typing.Any,
new_dt: datetime, new_dt: datetime,
@ -374,6 +563,46 @@ def same_type_as_start(
return new_dt 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: def maybe_extract_explicit_end_time(source_text: str) -> int | None:
"""Extract an explicit 12-hour clock end time for Geomob-style pages.""" """Extract an explicit 12-hour clock end time for Geomob-style pages."""
lowered = source_text.lower() 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) 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: def dump_conferences(yaml_path: str, conferences: list[dict[str, typing.Any]]) -> None:
"""Write conference YAML.""" """Write conference YAML."""
with open(yaml_path, "w") as file: 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): if url_has_year_component(url):
for conf in conferences: for conf in conferences:
if conf.get("url") == url: if conf.get("url") == url:
fields = conference_date_fields(conf)
if fields["date_status"] != "exact":
continue
print( print(
"⚠️ Conference already exists in YAML " "⚠️ Conference already exists in YAML "
+ f"(url={url}), skipping before API call." + 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) soup = fetch_webpage(url)
source_text = webpage_to_text(soup) source_text = webpage_to_text(soup)
detected_coordinates = detect_page_coordinates(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_yaml_text = get_from_open_ai(prompt)["yaml"]
new_conf = yaml.safe_load(new_yaml_text) 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) assert isinstance(new_conf, dict)
validate_country(new_conf) validate_country(new_conf)
normalize_dates_field(new_conf)
normalise_end_field(new_conf, source_text) normalise_end_field(new_conf, source_text)
normalize_dates_field(new_conf)
validate_generated_conference(new_conf)
if detected_coordinates is not None: if detected_coordinates is not None:
new_conf["latitude"] = detected_coordinates[0] new_conf["latitude"] = detected_coordinates[0]

View file

@ -2,6 +2,7 @@
import dataclasses import dataclasses
import decimal import decimal
import os
import typing import typing
from datetime import date, datetime from datetime import date, datetime
@ -16,6 +17,18 @@ DATE_STATUSES = {"exact", "approximate", "tentative"}
DATED_STATUSES = {"exact", "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 @dataclasses.dataclass
class Conference: class Conference:
"""Conference.""" """Conference."""
@ -23,6 +36,7 @@ class Conference:
name: str name: str
topic: str topic: str
location: str location: str
series: str | None = None
start: date | datetime | None = None start: date | datetime | None = None
end: date | datetime | None = None end: date | datetime | None = None
trip: date | None = None trip: date | None = None
@ -166,6 +180,20 @@ def validate_conference_date_fields(item: StrDict) -> StrDict:
return fields 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]: def get_list(filepath: str) -> list[Event]:
"""Read conferences from a YAML file and return a list of Event objects.""" """Read conferences from a YAML file and return a list of Event objects."""
events: list[Event] = [] events: list[Event] = []

View file

@ -11,7 +11,7 @@ import flask
import pycountry import pycountry
import yaml 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.types import StrDict, Trip, TripElement
from agenda.utils import as_date, as_datetime, depart_datetime 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) yaml_trip_list = travel.parse_yaml("trips", data_dir)
flight_bookings = load_flight_bookings(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 = { data = {
"flight_bookings": flight_bookings, "flight_bookings": flight_bookings,
"travel": collect_travel_items(flight_bookings, data_dir, route_distances), "travel": collect_travel_items(flight_bookings, data_dir, route_distances),
"accommodation": travel.parse_yaml("accommodation", data_dir), "accommodation": travel.parse_yaml("accommodation", data_dir),
"conferences": travel.parse_yaml("conferences", data_dir), "conferences": conferences,
"events": travel.parse_yaml("events", data_dir), "events": travel.parse_yaml("events", data_dir),
} }
@ -467,10 +473,10 @@ def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]:
return {} return {}
def conf_attend_start(c: StrDict) -> date: 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: 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) sorted_confs = sorted(trip.conferences, key=conf_attend_start)
result: dict[str, tuple[int, int]] = {} result: dict[str, tuple[int, int]] = {}
@ -864,7 +870,7 @@ def _trip_element_label(element: TripElement) -> str:
return start_loc return start_loc
if isinstance(end_loc, str): if isinstance(end_loc, str):
return end_loc return end_loc
return element.title return typing.cast(str, element.title)
def _trip_element_summary(trip: Trip, element: TripElement) -> str: def _trip_element_summary(trip: Trip, element: TripElement) -> str:

View file

@ -283,6 +283,7 @@ Legacy fields:
Common optional fields: Common optional fields:
- Series: `series`, a key from `conference_series.yaml`.
- Trip/location: `trip`, `country`, `venue`, `address`, `latitude`, `longitude`. - Trip/location: `trip`, `country`, `venue`, `address`, `latitude`, `longitude`.
- Attendance: `going`, `registered`, `speaking`, `online`, `accommodation_booked`, `transport_booked`. - 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. - 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 ```yaml
- name: FOSDEM - name: FOSDEM
series: fosdem
topic: FOSDEM topic: FOSDEM
location: Brussels location: Brussels
country: be country: be
@ -319,6 +321,7 @@ Tentative example:
```yaml ```yaml
- name: FOSDEM - name: FOSDEM
series: fosdem
topic: FOSDEM topic: FOSDEM
location: Brussels location: Brussels
country: be country: be
@ -335,6 +338,7 @@ Approximate examples:
```yaml ```yaml
- name: Wikimedia Hackathon 2027 - name: Wikimedia Hackathon 2027
series: wikimedia-hackathon
topic: Wikimedia topic: Wikimedia
location: Albania location: Albania
country: al country: al
@ -346,6 +350,7 @@ Approximate examples:
hackathon: true hackathon: true
- name: PyCascades 2027 - name: PyCascades 2027
series: pycascades
topic: Python topic: Python
location: TBC location: TBC
dates: dates:
@ -355,6 +360,46 @@ Approximate examples:
latest: 2027-03-31 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` ## `entities.yaml`
Top-level shape: list of people/entities. Top-level shape: list of people/entities.

View file

@ -134,7 +134,7 @@ tr.conf-hl > td {
{% if item_list %} {% if item_list %}
{% set count = item_list | length %} {% set count = item_list | length %}
<tr class="conf-section-row"> <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> </tr>
{% set ns = namespace(prev_month="") %} {% set ns = namespace(prev_month="") %}
{% for item in item_list %} {% for item in item_list %}
@ -142,7 +142,7 @@ tr.conf-hl > td {
{% if month_label != ns.prev_month %} {% if month_label != ns.prev_month %}
{% set ns.prev_month = month_label %} {% set ns.prev_month = month_label %}
<tr class="conf-month-row"> <tr class="conf-month-row">
<td colspan="6">{{ month_label }}</td> <td colspan="7">{{ month_label }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr{% if item.going %} class="conf-going"{% endif %} data-conf-key="{{ item.sort_date.isoformat() }}|{{ item.name }}"> <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 %} {% endif %}
</td> </td>
<td class="text-muted small">{{ item.topic }}</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"> <td class="text-nowrap">
{% set country = get_country(item.country) if item.country else None %} {% set country = get_country(item.country) if item.country else None %}
{% if country %}{{ country.flag }} {{ item.location }} {% if country %}{{ country.flag }} {{ item.location }}
@ -213,6 +220,7 @@ tr.conf-hl > td {
<col> <col>
<col style="width: 18rem"> <col style="width: 18rem">
<col style="width: 14rem"> <col style="width: 14rem">
<col style="width: 14rem">
<col style="width: 7rem"> <col style="width: 7rem">
<col style="width: 10rem"> <col style="width: 10rem">
</colgroup> </colgroup>
@ -221,6 +229,7 @@ tr.conf-hl > td {
<th>Dates</th> <th>Dates</th>
<th>Conference</th> <th>Conference</th>
<th>Topic</th> <th>Topic</th>
<th>Series</th>
<th>Location</th> <th>Location</th>
<th>CFP ends</th> <th>CFP ends</th>
<th>Price</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 = [ {% set conference_pages = [
{"endpoint": "conference_list", "label": "Conferences" }, {"endpoint": "conference_list", "label": "Conferences" },
{"endpoint": "past_conference_list", "label": "Past conferences" }, {"endpoint": "past_conference_list", "label": "Past conferences" },
{"endpoint": "conference_series_list", "label": "Conference series" },
] %} ] %}
@ -63,7 +64,7 @@
</li> </li>
<!-- Conference dropdown --> <!-- 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"> <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"> <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 Conferences

View file

@ -26,6 +26,19 @@ def test_extract_google_maps_latlon_at_pattern() -> None:
assert result == (51.5242464, -0.0997024) 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: 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.""" """The same non-year-specific URL can be reused for a different year."""
conferences: list[dict[str, typing.Any]] = [ 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"] 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: def test_validate_country_normalises_name() -> None:
"""Country names should be normalised to alpha-2 codes.""" """Country names should be normalised to alpha-2 codes."""
conf: dict[str, typing.Any] = {"country": "United Kingdom"} conf: dict[str, typing.Any] = {"country": "United Kingdom"}
@ -197,6 +319,76 @@ def test_add_new_conference_updates_yaml(
assert len(written) == 2 assert len(written) == 2
assert written[1]["name"] == "NewConf" assert written[1]["name"] == "NewConf"
assert written[1]["country"] == "us" 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]["latitude"] == 40.0
assert written[1]["longitude"] == -74.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 from datetime import date
import typing import typing
from types import SimpleNamespace
import yaml import yaml
@ -16,6 +17,7 @@ def test_build_conference_list_supports_inexact_dates(
conferences = [ conferences = [
{ {
"name": "PyCascades 2027", "name": "PyCascades 2027",
"series": "pycascades",
"topic": "Python", "topic": "Python",
"location": "TBC", "location": "TBC",
"dates": { "dates": {
@ -40,6 +42,19 @@ def test_build_conference_list_supports_inexact_dates(
(tmp_path / "conferences.yaml").write_text( (tmp_path / "conferences.yaml").write_text(
yaml.safe_dump(conferences), encoding="utf-8" 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.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: []) 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]["date_status"] == "approximate"
assert items[1]["display_date"] == "March 2027" assert items[1]["display_date"] == "March 2027"
assert items[1]["latest_date"] == date(2027, 3, 31) 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") filepath = os.path.join(data_dir, "conferences.yaml")
conferences_data = yaml.safe_load(open(filepath, "r")) conferences_data = yaml.safe_load(open(filepath, "r"))
conferences = [agenda.conference.Conference(**conf) for conf in conferences_data] conferences = [agenda.conference.Conference(**conf) for conf in conferences_data]
series = agenda.conference.load_series(data_dir)
prev_start = None prev_start = None
prev_conf_data = None prev_conf_data = None
@ -297,6 +298,11 @@ def check_conferences() -> None:
check_country_code(conf_data, "conference", required=False) check_country_code(conf_data, "conference", required=False)
check_conference_dates(conf_data) 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) date_fields = agenda.conference.conference_date_fields(conf_data)
current_start = normalize_datetime(date_fields["sort_date"]) current_start = normalize_datetime(date_fields["sort_date"])
@ -316,6 +322,21 @@ def check_conferences() -> None:
print(len(conferences), "conferences") 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: def check_events() -> None:
"""Check events.""" """Check events."""
today = date.today() today = date.today()
@ -505,6 +526,7 @@ def check() -> None:
check_trains() check_trains()
check_ferries() check_ferries()
check_buses() check_buses()
check_conference_series()
check_conferences() check_conferences()
check_events() check_events()
check_accommodation() check_accommodation()

View file

@ -372,6 +372,7 @@ def build_conference_list() -> list[StrDict]:
data_dir = app.config["PERSONAL_DATA"] data_dir = app.config["PERSONAL_DATA"]
filepath = os.path.join(data_dir, "conferences.yaml") filepath = os.path.join(data_dir, "conferences.yaml")
items: list[StrDict] = yaml.safe_load(open(filepath)) items: list[StrDict] = yaml.safe_load(open(filepath))
series_lookup = agenda.conference.load_series(data_dir)
conference_trip_lookup = {} conference_trip_lookup = {}
for trip in agenda.trip.build_trip_list(): for trip in agenda.trip.build_trip_list():
@ -386,6 +387,10 @@ def build_conference_list() -> list[StrDict]:
if price: if price:
conf["price"] = decimal.Decimal(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: if "start" in conf:
key = (conf["start"], conf["name"]) key = (conf["start"], conf["name"])
if this_trip := conference_trip_lookup.get(key): if this_trip := conference_trip_lookup.get(key):
@ -395,6 +400,34 @@ def build_conference_list() -> list[StrDict]:
return items 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: def _conference_uid(conf: StrDict) -> str:
"""Generate deterministic UID for conference events.""" """Generate deterministic UID for conference events."""
start = agenda.utils.as_date(conf["start"]) 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") @app.route("/conference/ical")
def conference_ical() -> werkzeug.Response: def conference_ical() -> werkzeug.Response:
"""Return all conferences as an iCalendar feed.""" """Return all conferences as an iCalendar feed."""