diff --git a/agenda/add_new_conference.py b/agenda/add_new_conference.py index e46df3c..71c12ce 100644 --- a/agenda/add_new_conference.py +++ b/agenda/add_new_conference.py @@ -16,6 +16,8 @@ import pycountry import requests import yaml +from agenda.conference import conference_date_fields + USER_AGENT = "add-new-conference/0.1" COORDINATE_PATTERNS = ( re.compile(r"@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)"), @@ -160,7 +162,9 @@ def webpage_to_text(root: lxml.html.HtmlElement) -> str: text_maker = html2text.HTML2Text() text_maker.ignore_links = True text_maker.ignore_images = True - return text_maker.handle(lxml.html.tostring(root_copy, encoding="unicode")) + return typing.cast( + str, text_maker.handle(lxml.html.tostring(root_copy, encoding="unicode")) + ) def parse_osm_url(url: str) -> tuple[float, float] | None: @@ -267,13 +271,13 @@ def insert_sorted( ) -> list[dict[str, typing.Any]]: """Insert a conference sorted by start date and skip duplicate URLs.""" new_url = new_conf.get("url") - new_start = parse_date(str(new_conf["start"])) + new_start = conference_sort_datetime(new_conf) new_year = new_start.year if new_url: for conf in conferences: if conf.get("url") == new_url: - existing_start = parse_date(str(conf["start"])) + existing_start = conference_sort_datetime(conf) existing_year = existing_start.year if url_has_year_component(new_url): @@ -287,7 +291,7 @@ def insert_sorted( return conferences for idx, conf in enumerate(conferences): - existing_start = parse_date(str(conf["start"])) + existing_start = conference_sort_datetime(conf) if new_start < existing_start: conferences.insert(idx, new_conf) return conferences @@ -295,6 +299,14 @@ def insert_sorted( return conferences +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"] + if isinstance(sort_date, datetime): + return sort_date + return datetime.combine(sort_date, time()) + + def validate_country(conf: dict[str, typing.Any]) -> None: """Ensure country is a valid ISO 3166-1 alpha-2 code, normalise if possible.""" country = conf.get("country") @@ -377,7 +389,11 @@ def maybe_extract_explicit_end_time(source_text: str) -> int | None: def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> None: """Ensure an end value exists, with a Geomob-specific fallback.""" - start_value = new_conf.get("start") + dates = new_conf.get("dates") + nested_dates = dates if isinstance(dates, dict) else None + start_value = ( + nested_dates.get("start") if nested_dates is not None else new_conf.get("start") + ) if start_value is None: return @@ -395,9 +411,16 @@ def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> No end_hour = 22 geomob_end = start_dt.replace(hour=end_hour, minute=0, second=0, microsecond=0) - new_conf["end"] = same_type_as_start( - start_value, geomob_end, prefer_datetime=True - ) + end_value = same_type_as_start(start_value, geomob_end, prefer_datetime=True) + if nested_dates is not None: + nested_dates["end"] = end_value + else: + new_conf["end"] = end_value + return + + if nested_dates is not None: + if "end" not in nested_dates: + nested_dates["end"] = same_type_as_start(start_value, start_dt) return if "end" not in new_conf: diff --git a/agenda/conference.py b/agenda/conference.py index 642f792..f9e15b4 100644 --- a/agenda/conference.py +++ b/agenda/conference.py @@ -2,14 +2,18 @@ import dataclasses import decimal +import typing from datetime import date, datetime import yaml from . import utils from .event import Event +from .types import DateOrDateTime, StrDict MAX_CONF_DAYS = 20 +DATE_STATUSES = {"exact", "approximate", "tentative"} +DATED_STATUSES = {"exact", "tentative"} @dataclasses.dataclass @@ -19,8 +23,8 @@ class Conference: name: str topic: str location: str - start: date | datetime - end: date | datetime + start: date | datetime | None = None + end: date | datetime | None = None trip: date | None = None country: str | None = None venue: str | None = None @@ -46,6 +50,7 @@ class Conference: attendees: int | None = None hashtag: str | None = None description: str | None = None + dates: StrDict | None = None @property def display_name(self) -> str: @@ -57,22 +62,135 @@ class Conference: ) +def _date_range_label(start: date, end: date) -> str: + """Format conference date range for display.""" + if start == end: + return start.strftime("%a %-d %b %Y") + if start.year == end.year and start.month == end.month: + return f"{start.strftime('%a %-d')}-{end.strftime('%-d %b %Y')}" + return f"{start.strftime('%a %-d %b')}-{end.strftime('%-d %b %Y')}" + + +def _require_date_value(value: typing.Any, field_name: str) -> DateOrDateTime: + """Return date-like field value or raise ValueError.""" + if isinstance(value, (date, datetime)): + return value + raise ValueError(f"conference dates field {field_name!r} must be a date/datetime") + + +def _require_date_only(value: typing.Any, field_name: str) -> date: + """Return field value as a date or raise ValueError.""" + return typing.cast(date, utils.as_date(_require_date_value(value, field_name))) + + +def conference_date_fields(item: StrDict) -> StrDict: + """Return derived date fields for a conference YAML item.""" + raw_dates = item.get("dates") + if raw_dates is None: + status = typing.cast(str, item.get("date_status", "exact")) + if status not in DATE_STATUSES: + raise ValueError(f"unknown conference date status {status!r}") + start = _require_date_value(item.get("start"), "start") + end = _require_date_value(item.get("end", start), "end") + start_date = utils.as_date(start) + end_date = utils.as_date(end) + return { + "date_status": status, + "start": start, + "end": end, + "start_date": start_date, + "end_date": end_date, + "sort_date": start_date, + "latest_date": end_date, + "display_date": _date_range_label(start_date, end_date), + "has_exact_dates": status == "exact", + } + + if not isinstance(raw_dates, dict): + raise ValueError("conference dates must be a mapping") + + status_value = raw_dates.get("status", "exact") + if not isinstance(status_value, str) or status_value not in DATE_STATUSES: + raise ValueError(f"unknown conference date status {status_value!r}") + + if status_value in DATED_STATUSES: + start = _require_date_value(raw_dates.get("start", item.get("start")), "start") + end = _require_date_value(raw_dates.get("end", item.get("end", start)), "end") + start_date = utils.as_date(start) + end_date = utils.as_date(end) + label = raw_dates.get("label") + display_date = ( + label if isinstance(label, str) else _date_range_label(start_date, end_date) + ) + return { + "date_status": status_value, + "start": start, + "end": end, + "start_date": start_date, + "end_date": end_date, + "sort_date": start_date, + "latest_date": end_date, + "display_date": display_date, + "has_exact_dates": status_value == "exact", + } + + earliest = _require_date_only(raw_dates.get("earliest"), "earliest") + latest = _require_date_only(raw_dates.get("latest"), "latest") + label = raw_dates.get("label") + display_date = ( + label if isinstance(label, str) else _date_range_label(earliest, latest) + ) + return { + "date_status": status_value, + "start_date": earliest, + "end_date": latest, + "sort_date": earliest, + "latest_date": latest, + "display_date": display_date, + "has_exact_dates": False, + } + + +def validate_conference_date_fields(item: StrDict) -> StrDict: + """Validate conference date fields and return derived values.""" + fields = conference_date_fields(item) + if fields["start_date"] > fields["end_date"]: + raise ValueError("conference ends before it starts") + + if fields["date_status"] in DATED_STATUSES: + duration = (fields["end_date"] - fields["start_date"]).days + if duration >= MAX_CONF_DAYS: + raise ValueError( + f"conference is {duration} days; maximum is {MAX_CONF_DAYS - 1}" + ) + return fields + + def get_list(filepath: str) -> list[Event]: """Read conferences from a YAML file and return a list of Event objects.""" events: list[Event] = [] - for conf in (Conference(**conf) for conf in yaml.safe_load(open(filepath, "r"))): - assert conf.start <= conf.end - duration = (utils.as_date(conf.end) - utils.as_date(conf.start)).days - assert duration < MAX_CONF_DAYS - event = Event( - name="conference", - date=conf.start, - end_date=conf.end, - title=conf.display_name, - url=conf.url, - going=conf.going, - ) - events.append(event) + for item in yaml.safe_load(open(filepath, "r")): + try: + fields = validate_conference_date_fields(item) + except ValueError as exc: + raise AssertionError(str(exc)) from exc + normalized_item = dict(item) + if "start" in fields: + normalized_item["start"] = fields["start"] + normalized_item["end"] = fields["end"] + conf = Conference(**normalized_item) + + if fields["has_exact_dates"]: + assert conf.start is not None and conf.end is not None + event = Event( + name="conference", + date=conf.start, + end_date=conf.end, + title=conf.display_name, + url=conf.url, + going=conf.going, + ) + events.append(event) if not conf.cfp_end: continue cfp_end_event = Event( diff --git a/docs/personal-data-yaml.md b/docs/personal-data-yaml.md index 572ece6..a538fff 100644 --- a/docs/personal-data-yaml.md +++ b/docs/personal-data-yaml.md @@ -261,8 +261,25 @@ Required fields: - `name`: event name. - `topic`: topic/category. - `location`: city or location label. -- `start`: date or datetime. -- `end`: date or datetime. Must be no earlier than `start`, and duration must be under 20 days. +- Date information, either as legacy top-level `start` and `end`, or preferred nested `dates`. + +Preferred `dates` fields: + +- `status`: one of `exact`, `tentative`, or `approximate`. +- For `exact` and `tentative`: `start` and `end` dates/datetimes. `end` must be no earlier than `start`, and duration must be under 20 days. +- For `approximate`: `earliest` and `latest` dates for sorting/past-future filtering. +- `label`: optional human-readable date text. Recommended for `tentative` and `approximate`, for example `likely first weekend of February 2027` or `March 2027`. +- `basis`: optional explanation of why a tentative date is expected. + +Date status behavior: + +- `exact`: confirmed dates. These create agenda events, iCalendar entries, and timeline bars. +- `tentative`: guessed or unconfirmed exact dates. These appear on the conference list with a status badge, but do not create agenda/iCalendar events or timeline bars. +- `approximate`: only a broad date range is known. These appear on the conference list with a status badge, but do not create agenda/iCalendar events or timeline bars. + +Legacy fields: + +- Existing top-level `start` and `end` are still supported and are treated as `exact` unless `date_status` says otherwise. Common optional fields: @@ -273,7 +290,7 @@ Common optional fields: - Money/tickets: `free`, `price`, `currency`, `ticket_type`. - Other flags: `hackathon`, `attendees`. -Example: +Exact example: ```yaml - name: FOSDEM @@ -281,8 +298,10 @@ Example: location: Brussels country: be trip: 2026-02-06 - start: 2026-02-07 - end: 2026-02-08 + dates: + status: exact + start: 2026-02-07 + end: 2026-02-08 attend_start: 2026-02-07 14:00:00+01:00 attend_end: 2026-02-08 going: true @@ -296,6 +315,46 @@ Example: longitude: 4.3822 ``` +Tentative example: + +```yaml +- name: FOSDEM + topic: FOSDEM + location: Brussels + country: be + dates: + status: tentative + start: 2027-01-30 + end: 2027-01-31 + label: likely first weekend of February 2027 + basis: FOSDEM is usually on the weekend where Sunday is the first Sunday in February + url: https://fosdem.org/2027/ +``` + +Approximate examples: + +```yaml +- name: Wikimedia Hackathon 2027 + topic: Wikimedia + location: Albania + country: al + dates: + status: approximate + label: mid-April 2027 + earliest: 2027-04-11 + latest: 2027-04-20 + hackathon: true + +- name: PyCascades 2027 + topic: Python + location: TBC + dates: + status: approximate + label: March 2027 + earliest: 2027-03-01 + latest: 2027-03-31 +``` + ## `entities.yaml` Top-level shape: list of people/entities. diff --git a/templates/conference_list.html b/templates/conference_list.html index 4e49074..6529b22 100644 --- a/templates/conference_list.html +++ b/templates/conference_list.html @@ -138,22 +138,21 @@ tr.conf-hl > td { {% set ns = namespace(prev_month="") %} {% for item in item_list %} - {% set month_label = item.start_date.strftime("%B %Y") %} + {% set month_label = item.sort_date.strftime("%B %Y") %} {% if month_label != ns.prev_month %} {% set ns.prev_month = month_label %}