diff --git a/agenda/generate_booking_yaml.py b/agenda/generate_booking_yaml.py index f9743c1..3ce8ddd 100644 --- a/agenda/generate_booking_yaml.py +++ b/agenda/generate_booking_yaml.py @@ -37,6 +37,8 @@ class BookingConfig: booking_type: str yaml_filename: str spec_heading: str + start_field: str + excluded_top_level_keys: tuple[str, ...] json_key: str = "booking" @@ -45,11 +47,22 @@ BOOKING_CONFIGS: dict[str, BookingConfig] = { booking_type="flight", yaml_filename="flights.yaml", spec_heading="flights.yaml", + start_field="depart", + excluded_top_level_keys=("trip",), ), "train": BookingConfig( booking_type="train", yaml_filename="trains.yaml", spec_heading="trains.yaml", + start_field="depart", + excluded_top_level_keys=("trip",), + ), + "accommodation": BookingConfig( + booking_type="accommodation", + yaml_filename="accommodation.yaml", + spec_heading="accommodation.yaml", + start_field="from", + excluded_top_level_keys=("trip", "latitude", "longitude"), ), } @@ -109,6 +122,7 @@ def build_prompt( bookings = current_bookings if bookings is None: bookings = read_existing_bookings(config) + excluded_keys = ", ".join(f'"{key}"' for key in config.excluded_top_level_keys) return f""" I keep a record of all my {config.booking_type} bookings in a YAML file. @@ -131,7 +145,7 @@ Rules: - Wrap the response in a JSON object with a single key "{config.json_key}" that contains the booking in YAML. - The value of "{config.json_key}" must be YAML text, not JSON. -- Exclude the top-level "trip" key from the YAML. +- Exclude these top-level keys from the YAML: {excluded_keys}. - Do not invent details that are not present in the booking text. - Quote prices and identifiers that might otherwise be parsed as numbers. @@ -218,7 +232,7 @@ def first_departure(booking: dict[str, typing.Any], config: BookingConfig) -> da assert isinstance(first_flight, dict) return datetime_from_yaml_value(first_flight["depart"]) - return datetime_from_yaml_value(booking["depart"]) + return datetime_from_yaml_value(booking[config.start_field]) def comparable_departure( @@ -246,6 +260,13 @@ def trip_key_position(booking: dict[str, typing.Any], config: BookingConfig) -> return keys.index("booking_reference") + 1 return 0 + if config.booking_type == "accommodation": + if "country" in booking: + return keys.index("country") + 1 + if "location" in booking: + return keys.index("location") + 1 + return 0 + if "to" in booking: return keys.index("to") + 1 if "from" in booking: @@ -455,3 +476,8 @@ def train_main(argv: list[str] | None = None) -> int: def flight_main(argv: list[str] | None = None) -> int: """CLI entrypoint for flight booking YAML generation.""" return main_for_type("flight", argv) + + +def accommodation_main(argv: list[str] | None = None) -> int: + """CLI entrypoint for accommodation booking YAML generation.""" + return main_for_type("accommodation", argv) diff --git a/docs/personal-data-yaml.md b/docs/personal-data-yaml.md index 10e8674..471f632 100644 --- a/docs/personal-data-yaml.md +++ b/docs/personal-data-yaml.md @@ -13,6 +13,7 @@ This document describes the YAML files read from `../personal-data/`. It is inte - Currencies must be in `config.CURRENCIES` or `GBP`. - Travel and trip-related entries are grouped by the `trip` date. That date should match an entry in `trips.yaml` when a named trip is needed, but trip groups can also be created from travel/accommodation/conference entries. - Keep chronological files sorted by their natural start field. `validate_yaml.py` checks ordering for trips, flights, trains, ferries, conferences, and accommodation. +- Preserve the existing whitespace style. Long top-level list files such as `accommodation.yaml`, `buses.yaml`, `car_journeys.yaml`, `coaches.yaml`, `conferences.yaml`, `ferries.yaml`, `flights.yaml`, `stations.yaml`, `trains.yaml`, and `trips.yaml` use one blank line between top-level items. Mapping files such as `airports.yaml` do not use this list-item spacing. - Coordinates are `latitude` then `longitude`, both numeric. ## Cross-File References diff --git a/tests/test_generate_booking_yaml.py b/tests/test_generate_booking_yaml.py index 51ff471..87c1735 100644 --- a/tests/test_generate_booking_yaml.py +++ b/tests/test_generate_booking_yaml.py @@ -40,10 +40,25 @@ def test_build_prompt_uses_spec_and_keeps_trip_exclusion() -> None: assert "Use this YAML format specification" in prompt assert "## `trains.yaml`" in prompt - assert 'Exclude the top-level "trip" key' in prompt + assert 'Exclude these top-level keys from the YAML: "trip"' in prompt assert "Eurostar booking details" in prompt +def test_build_prompt_for_accommodation_excludes_coordinates() -> None: + """Accommodation prompt should exclude trip and coordinate fields.""" + prompt = generate_booking_yaml.build_prompt( + "Airbnb booking details", + generate_booking_yaml.BOOKING_CONFIGS["accommodation"], + current_bookings="- type: airbnb\n", + ) + + assert "## `accommodation.yaml`" in prompt + assert ( + 'Exclude these top-level keys from the YAML: "trip", "latitude", "longitude"' + in prompt + ) + + def test_booking_text_from_args_fetches_url(monkeypatch: pytest.MonkeyPatch) -> None: """URL arguments should be fetched instead of reading stdin.""" monkeypatch.setattr( @@ -185,3 +200,58 @@ flights: assert [item["booking_reference"] for item in written] == ["OLD", "MID", "NEWER"] assert written[1]["trip"] == date(2026, 2, 6) assert list(written[1]).index("trip") == 1 + + +def test_import_accommodation_booking_adds_trip_and_inserts_chronologically( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Generated accommodation should be written into accommodation.yaml in order.""" + (tmp_path / "accommodation.yaml").write_text("""--- +- type: hotel + name: Earlier Hotel + location: Brussels + country: be + trip: 2026-02-01 + from: 2026-02-01 15:00:00+01:00 + to: 2026-02-02 11:00:00+01:00 + +- type: hotel + name: Later Hotel + location: Paris + country: fr + trip: 2026-02-10 + from: 2026-02-10 15:00:00+01:00 + to: 2026-02-11 11:00:00+01:00 +""") + monkeypatch.setattr( + generate_booking_yaml, + "matching_trip_date", + lambda depart, data_dir: date(2026, 2, 6), + ) + + count = generate_booking_yaml.import_booking_yaml( + """type: airbnb +name: Paris Airbnb +location: Paris +country: fr +from: 2026-02-06 15:00:00+01:00 +to: 2026-02-09 11:00:00+01:00 +price: '300.00' +currency: EUR +""", + generate_booking_yaml.BOOKING_CONFIGS["accommodation"], + data_dir=tmp_path, + ) + + text = (tmp_path / "accommodation.yaml").read_text() + written = yaml.safe_load(text) + assert count == 1 + assert [item["name"] for item in written] == [ + "Earlier Hotel", + "Paris Airbnb", + "Later Hotel", + ] + assert written[1]["trip"] == date(2026, 2, 6) + assert list(written[1]).index("trip") == 4 + assert "\n\n- type: airbnb\n" in text