diff --git a/.github/workflows/ics.yaml b/.github/workflows/ics.yaml
new file mode 100644
index 0000000..6dd4524
--- /dev/null
+++ b/.github/workflows/ics.yaml
@@ -0,0 +1,49 @@
+name: Publish ICS
+
+on:
+ push:
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+permissions: {}
+
+env:
+ FORCE_COLOR: 1
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: astral-sh/setup-uv@v9.0.0
+ - run: uv run scripts/generate_ics.py site/conferences.ics
+ - run: |
+ cat > site/index.html << 'EOF'
+
+
+
+
Python conferences calendar
+ Python conferences calendar
+ Subscribe to conferences.ics
+ in your calendar app to get all conferences listed in
+
+ python-organizers/conferences.
+ EOF
+ - uses: actions/upload-pages-artifact@v5
+ with:
+ path: site
+
+ deploy:
+ if: github.ref == 'refs/heads/main'
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/.gitignore b/.gitignore
index ef238c1..6b59c96 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ dist/
.~lock.*
# data
conferences.csv
+conferences.ics
diff --git a/2022.csv b/2022.csv
index ee6dd84..971b5e6 100644
--- a/2022.csv
+++ b/2022.csv
@@ -11,7 +11,6 @@ PyCon Russia,2022-07-30,2022-07-31,"Moscow, Central Federal District, Russia",RU
EuroSciPy,2022-08-29,2022-09-02,"Basel, Basel-Stadt, Switzerland",CHE,University of Basel,2022-05-30,2022-05-30,https://www.euroscipy.org/2022,https://pretalx.com/euroscipy-2022/cfp,
PyCon Slovakia,2022-09-09,2022-09-11,"Bratislava, Bratislava, Slovakia",SVK,Faculty of Informatics and Information Technologies STU,,,https://2022.pycon.sk,,
PyBay,2022-09-10,2022-09-10,"San Francisco, California, United States of America",USA,Parklab Gardens,,,https://pybay.com,,https://pybay.com/sponsors-prospectus
-PyBay,2022-09-10,2022-09-10,"San Francisco, California, USA",USA,Parklab Gardens,,,,,
PyCon Portugal,2022-09-24,2022-09-24,"Porto, Oporto, Portugal",PRT,University of Porto,2022-06-30,2022-06-03,https://2022.pycon.pt,https://2022.pycon.pt/talks/cfp,https://2022.pycon.pt/sponsors/sponsorship
SciPy Latin America,2022-09-26,2022-09-28,"Salta, Salta, Argentina",ARG,Universidad Nacional de Salta,,,https://pythoncientifico.ar,https://pythoncientifico.ar/pages/llamado-a-propuestas,https://pythoncientifico.ar/sponsors
PyCon Ghana,2022-10-13,2022-10-15,"Accra, Greater Accra, Ghana",GHA,,,,https://gh.pycon.org,https://gh.pycon.org/talks/speaking,https://gh.pycon.org/support-us
diff --git a/README.md b/README.md
index 9b8a554..11fae43 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,13 @@ Each CSV file is [compatible with Google Calendar](https://support.google.com/ca
| Proposal URL | Proposal information | URL |
| Sponsorship URL | Sponsorship information | URL |
+## Calendar Feed
+
+Subscribe to
+in your calendar app to get all listed conferences as all-day events, with
+proposal deadlines and links in the event description. The feed is regenerated
+from the CSV files on every push to `main`.
+
## Tests & Linting
There are tests to ensure that the files are written in the correct format
diff --git a/scripts/generate_ics.py b/scripts/generate_ics.py
new file mode 100644
index 0000000..e35072d
--- /dev/null
+++ b/scripts/generate_ics.py
@@ -0,0 +1,91 @@
+"""Generate an iCalendar file from the year CSV files.
+
+Usage: uv run scripts/generate_ics.py [output.ics]
+"""
+
+# /// script
+# requires-python = ">=3.10"
+# dependencies = [
+# "icalendar==7.2.2",
+# ]
+# ///
+
+import csv
+import datetime as dt
+import sys
+from pathlib import Path
+
+from icalendar import Calendar, Event
+
+CALENDAR_NAME = "Python Conferences"
+UID_DOMAIN = "python-organizers.github.io"
+
+
+def make_event(row: dict[str, str], dtstamp: dt.datetime, seen_uids: set[str]) -> Event:
+ event = Event()
+
+ # Stable UIDs so subscribed calendars update events instead of
+ # duplicating them
+ slug = "".join(c if c.isalnum() else "-" for c in row["Subject"].lower())
+ uid = f"{row['Start Date']}-{slug}@{UID_DOMAIN}"
+ while uid in seen_uids:
+ uid = "x" + uid
+ seen_uids.add(uid)
+ event.add("uid", uid)
+
+ event.add("dtstamp", dtstamp)
+ event.add("dtstart", dt.date.fromisoformat(row["Start Date"]))
+ # DTEND is exclusive, so all-day events end the day after the last day
+ event.add("dtend", dt.date.fromisoformat(row["End Date"]) + dt.timedelta(days=1))
+ event.add("summary", row["Subject"])
+
+ if location := ", ".join(filter(None, [row["Venue"], row["Location"]])):
+ event.add("location", location)
+
+ description = "\n".join(
+ f"{label}: {value}"
+ for label, value in (
+ ("Tutorial deadline", row["Tutorial Deadline"]),
+ ("Talk deadline", row["Talk Deadline"]),
+ ("Website", row["Website URL"]),
+ ("Proposals", row["Proposal URL"]),
+ ("Sponsorship", row["Sponsorship URL"]),
+ )
+ if value
+ )
+ if description:
+ event.add("description", description)
+ if row["Website URL"]:
+ event.add("url", row["Website URL"])
+
+ return event
+
+
+def main() -> None:
+ output = Path(sys.argv[1] if len(sys.argv) > 1 else "conferences.ics")
+ repository_folder = Path(__file__).parents[1]
+ dtstamp = dt.datetime.now(dt.timezone.utc)
+
+ calendar = Calendar()
+ calendar.add("prodid", "-//python-organizers//conferences//EN")
+ calendar.add("version", "2.0")
+ calendar.add("calscale", "GREGORIAN")
+ calendar.add("method", "PUBLISH")
+ calendar.add("x-wr-calname", CALENDAR_NAME)
+ calendar.add("name", CALENDAR_NAME)
+
+ count = 0
+ seen_uids = set()
+ for csv_file in sorted(repository_folder.glob("20*.csv")):
+ with open(csv_file, newline="") as f:
+ for row in csv.DictReader(f):
+ calendar.add_component(make_event(row, dtstamp, seen_uids))
+ count += 1
+
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_bytes(calendar.to_ical())
+ print(f"Wrote {count} events to {output}")
+
+
+if __name__ == "__main__":
+ main()