From 35c2b29dc46da4eb84028dafdf7d3b59d06af5d9 Mon Sep 17 00:00:00 2001 From: egeakman Date: Sun, 23 Nov 2025 04:30:56 -0500 Subject: [PATCH 1/2] add static site to view the data --- .github/workflows/deploy.yml | 46 +++ .gitignore | 4 + website/README.md | 16 + website/app.js | 570 ++++++++++++++++++++++++++++++++ website/calendarLinks.js | 169 ++++++++++ website/countries.js | 31 ++ website/dates.js | 63 ++++ website/generate_conferences.py | 160 +++++++++ website/ical.js | 143 ++++++++ website/index.html | 336 +++++++++++++++++++ 10 files changed, 1538 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 website/README.md create mode 100644 website/app.js create mode 100644 website/calendarLinks.js create mode 100644 website/countries.js create mode 100644 website/dates.js create mode 100644 website/generate_conferences.py create mode 100644 website/ical.js create mode 100644 website/index.html diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..e784943 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Deploy Python Conferences Website + +on: + push: + branches: + - main + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + name: Deploy to GitHub Pages + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Generate json and ical files + run: python generate_conferences.py + working-directory: website + + - name: Upload site artifact + uses: actions/upload-pages-artifact@v4 + with: + path: website + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index ef238c1..360f034 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,7 @@ dist/ .~lock.* # data conferences.csv + +website/*.csv +website/conferences.ics +website/conferences.json diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000..b7f4c78 --- /dev/null +++ b/website/README.md @@ -0,0 +1,16 @@ + +# Conferences Website + +This website displays a list of Python conferences and allows users to download calendar files. It generates conferences.ics and conferences.json from the CSV files in the repository root. + +## How it works + +A GitHub Actions workflow is set up to run on pushes to the main branch (also by hand and on a schedule). This workflow runs the `website/generate_conferences.py` script to produce the ICS and JSON files. Then all the content in `website/` is deployed to GitHub Pages. + +## How to run locally +1. Make sure you're in the `website/` directory. +2. And run: + ```sh + python generate_conferences.py && python -m http.server + ``` +3. It is fully static, so you can just go to `http://localhost:8000`. diff --git a/website/app.js b/website/app.js new file mode 100644 index 0000000..49e40de --- /dev/null +++ b/website/app.js @@ -0,0 +1,570 @@ +import { getCountryName, countriesPromise } from "./countries.js"; +import { getStartDate, getEndDate, getToday, formatDate } from "./dates.js"; +import { renderCalendarLinks } from "./calendarLinks.js"; + +let allConferences = []; +let filteredConferences = []; + +function saveFiltersToURL() { + const params = new URLSearchParams(); + + const search = document.getElementById("search-input").value; + const status = document.getElementById("status-filter").value; + const year = document.getElementById("year-filter").value; + const country = document.getElementById("country-filter").value; + const sort = document.getElementById("sort-filter").value; + + if (search) params.set("search", search); + if (status) params.set("status", status); + if (year) params.set("year", year); + if (country) params.set("country", country); + if (sort && sort !== "date-asc") params.set("sort", sort); + + const newURL = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.replaceState({}, "", newURL); +} + +function loadFiltersFromURL() { + const params = new URLSearchParams(window.location.search); + + if (params.has("search")) { + document.getElementById("search-input").value = params.get("search"); + } + if (params.has("status")) { + document.getElementById("status-filter").value = params.get("status"); + } else { + document.getElementById("status-filter").value = "upcoming"; + } + if (params.has("year")) { + document.getElementById("year-filter").value = params.get("year"); + } + if (params.has("country")) { + document.getElementById("country-filter").value = params.get("country"); + } + if (params.has("sort")) { + document.getElementById("sort-filter").value = params.get("sort"); + } +} + +async function loadConferences() { + try { + const response = await fetch("conferences.json"); + if (!response.ok) { + console.error("Failed to load conferences.json", response.status); + return []; + } + const data = await response.json(); + if (!Array.isArray(data)) { + console.error("conferences.json is not an array"); + return []; + } + + const conferences = data.map((row) => ({ + ...row, + year: String(row.year || (row["Start Date"] || "").slice(0, 4) || ""), + })); + + console.log(`Total conferences loaded from JSON: ${conferences.length}`); + + const today = getToday(); + conferences.sort((a, b) => { + const aStart = getStartDate(a); + const bStart = getStartDate(b); + + if (!aStart && !bStart) return 0; + if (!aStart) return 1; + if (!bStart) return -1; + + const aIsUpcoming = aStart >= today; + const bIsUpcoming = bStart >= today; + + if (aIsUpcoming && !bIsUpcoming) return -1; + if (!aIsUpcoming && bIsUpcoming) return 1; + + if (aIsUpcoming && bIsUpcoming) { + return aStart - bStart; + } else { + return bStart - aStart; + } + }); + + return conferences; + } catch (err) { + console.error("Error loading conferences.json", err); + return []; + } +} + +async function init() { + await countriesPromise; + + allConferences = await loadConferences(); + filteredConferences = [...allConferences]; + + await populateFilters(); + updateStats(); + + loadFiltersFromURL(); + applyFilters(); + setupEventListeners(); +} + +async function populateFilters() { + const years = [...new Set(allConferences.map((c) => c.year))] + .filter(Boolean) + .sort() + .reverse(); + const yearSelect = document.getElementById("year-filter"); + years.forEach((year) => { + const option = document.createElement("option"); + option.value = year; + option.textContent = year; + yearSelect.appendChild(option); + }); + + const countries = [ + ...new Set(allConferences.map((c) => c.Country).filter((c) => c)), + ].sort(); + + const countrySelect = document.getElementById("country-filter"); + + for (const country of countries) { + const option = document.createElement("option"); + option.value = country; + option.textContent = await getCountryName(country); + countrySelect.appendChild(option); + } +} + +function updateStats() { + const today = getToday(); + + const upcomingCount = allConferences.filter((c) => { + const start = getStartDate(c); + return start && start >= today; + }).length; + + const countries = new Set( + allConferences.map((c) => c.Country).filter((c) => c), + ); + const years = new Set(allConferences.map((c) => c.year).filter((y) => y)); + const yearsArray = Array.from(years) + .map((y) => parseInt(y, 10)) + .filter((y) => !isNaN(y)); + + document.getElementById("total-events").textContent = allConferences.length; + document.getElementById("total-countries").textContent = countries.size; + document.getElementById("upcoming-events").textContent = upcomingCount; + + if (yearsArray.length > 0) { + document.getElementById("years-span").textContent = + `${Math.min(...yearsArray)}-${Math.max(...yearsArray)}`; + } else { + document.getElementById("years-span").textContent = "N/A"; + } +} + +function applyFilters() { + const searchTerm = document + .getElementById("search-input") + .value.toLowerCase(); + const yearFilter = document.getElementById("year-filter").value; + const countryFilter = document.getElementById("country-filter").value; + const statusFilter = document.getElementById("status-filter").value; + const sortFilter = document.getElementById("sort-filter").value; + const today = getToday(); + + saveFiltersToURL(); + + filteredConferences = allConferences.filter((conf) => { + if (searchTerm) { + const searchableText = [ + conf.Subject, + conf.Location, + conf.Country, + conf.Venue, + ] + .join(" ") + .toLowerCase(); + + if (!searchableText.includes(searchTerm)) return false; + } + + if (yearFilter && conf.year !== yearFilter) return false; + + if (countryFilter && conf.Country !== countryFilter) return false; + + const start = getStartDate(conf); + if (statusFilter === "upcoming") { + if (!start || start < today) return false; + } else if (statusFilter === "past") { + if (!start || start >= today) return false; + } + + return true; + }); + + filteredConferences.sort((a, b) => { + const aStart = getStartDate(a); + const bStart = getStartDate(b); + + switch (sortFilter) { + case "date-asc": { + if (!aStart && !bStart) return 0; + if (!aStart) return 1; + if (!bStart) return -1; + return aStart - bStart; + } + case "date-desc": { + if (!aStart && !bStart) return 0; + if (!aStart) return 1; + if (!bStart) return -1; + return bStart - aStart; + } + case "name-asc": + return (a.Subject || "").localeCompare(b.Subject || ""); + case "name-desc": + return (b.Subject || "").localeCompare(a.Subject || ""); + default: + return 0; + } + }); + + renderConferences(); +} + +async function renderConferences() { + const container = document.getElementById("conferences-container"); + document.getElementById("results-count").textContent = + filteredConferences.length; + + if (filteredConferences.length === 0) { + const searchTerm = document + .getElementById("search-input") + .value.toLowerCase(); + const hasActiveFilters = + document.getElementById("status-filter").value !== "" || + document.getElementById("year-filter").value !== "" || + document.getElementById("country-filter").value !== ""; + + let matchingBeforeFilters = 0; + if (hasActiveFilters && searchTerm) { + matchingBeforeFilters = allConferences.filter((conf) => { + const searchableText = [ + conf.Subject, + conf.Location, + conf.Country, + conf.Venue, + ] + .join(" ") + .toLowerCase(); + return searchableText.includes(searchTerm); + }).length; + } + + if (hasActiveFilters && allConferences.length > 0) { + container.innerHTML = ` +
+
+
⚠️
+
+

No Results Found

+

+ ${ + matchingBeforeFilters > 0 + ? `Found ${matchingBeforeFilters} conference${matchingBeforeFilters !== 1 ? "s" : ""} matching your search, but your filters are hiding them.` + : "Your current filter combination has no matching conferences." + } +

+ +
+
+
+ `; + + document + .getElementById("clear-filters-btn") + .addEventListener("click", () => { + document.getElementById("year-filter").value = ""; + document.getElementById("country-filter").value = ""; + document.getElementById("status-filter").value = ""; + document.getElementById("sort-filter").value = "date-asc"; + applyFilters(); + }); + } else { + container.innerHTML = ` +
+
🔍
+

No conferences found

+

Try adjusting your filters

+
+ `; + } + return; + } + + const today = getToday(); + + const countryNames = {}; + const uniqueCountries = [ + ...new Set(filteredConferences.map((c) => c.Country).filter((c) => c)), + ]; + await Promise.all( + uniqueCountries.map(async (code) => { + countryNames[code] = await getCountryName(code); + }), + ); + + container.innerHTML = filteredConferences + .map((conf, idx) => { + const start = getStartDate(conf); + const end = getEndDate(conf); + const isUpcoming = start && start >= today; + + return ` +
+
+
+

+ ${conf.Subject || "Untitled Conference"} +

+
+ ${ + isUpcoming + ? 'Upcoming' + : "" + } + ${ + conf.year + ? `${conf.year}` + : "" + } + ${ + conf.Country + ? `${countryNames[conf.Country] || conf.Country}` + : "" + } +
+
+ ${ + conf.Location + ? `
📍${conf.Location}
` + : "" + } + ${ + conf.Venue + ? `
🏢${conf.Venue}
` + : "" + } + ${ + start + ? `
+ 📆 + ${formatDate(start)}${end ? " - " + formatDate(end) : ""} + ${renderCalendarLinks(conf, idx)} +
` + : "" + } + ${ + conf["Talk Deadline"] + ? `
💬Talk Deadline: ${formatDate(conf["Talk Deadline"])}
` + : "" + } + ${ + conf["Tutorial Deadline"] + ? `
🎓Tutorial Deadline: ${formatDate(conf["Tutorial Deadline"])}
` + : "" + } +
+
+
+ ${ + conf["Website URL"] + ? `🌐 Website` + : "" + } + ${ + conf["Proposal URL"] + ? `📝 Submit Talk` + : "" + } + ${ + conf["Sponsorship URL"] + ? `💰 Sponsor` + : "" + } +
+
+
+ `; + }) + .join(""); + + import("./ical.js").then(({ exportICal }) => { + filteredConferences.forEach((conf, idx) => { + const icsId = `download-ics-${idx}`; + const el = document.getElementById(icsId); + if (el) { + el.addEventListener("click", (e) => { + e.preventDefault(); + exportICal(conf); + const dropdown = document.getElementById(`cal-dropdown-${idx}`); + if (dropdown) { + dropdown.classList.add("hidden"); + } + }); + } + }); + }); +} + +function setupEventListeners() { + document + .getElementById("search-input") + .addEventListener("input", applyFilters); + document + .getElementById("year-filter") + .addEventListener("change", applyFilters); + document + .getElementById("country-filter") + .addEventListener("change", applyFilters); + document + .getElementById("status-filter") + .addEventListener("change", applyFilters); + document + .getElementById("sort-filter") + .addEventListener("change", applyFilters); + + document.getElementById("reset-filters").addEventListener("click", () => { + document.getElementById("search-input").value = ""; + document.getElementById("year-filter").value = ""; + document.getElementById("country-filter").value = ""; + document.getElementById("status-filter").value = "upcoming"; + document.getElementById("sort-filter").value = "date-asc"; + window.history.replaceState({}, "", window.location.pathname); + applyFilters(); + }); + + import("./ical.js").then(({ exportICal }) => { + document.getElementById("ical-export-btn").addEventListener("click", () => { + exportICal(filteredConferences); + }); + }); + + const themeSliders = document.querySelectorAll(".theme-slider"); + const navbarIcon = document.getElementById("navbar-theme-icon"); + const floatingIcon = document.getElementById("floating-theme-icon"); + + let currentTheme = localStorage.getItem("themePreference") || "system"; + const themeOrder = ["light", "system", "dark"]; + + function getSystemTheme() { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + + function getThemeIcon(theme) { + const icons = { light: "☀️", system: "💻", dark: "🌙" }; + return icons[theme]; + } + + function applyTheme(theme) { + currentTheme = theme; + + themeSliders.forEach((slider) => { + slider.setAttribute("data-theme", theme); + }); + + const icon = getThemeIcon(theme); + if (navbarIcon) navbarIcon.textContent = icon; + if (floatingIcon) floatingIcon.textContent = icon; + + const effectiveTheme = theme === "system" ? getSystemTheme() : theme; + + if (effectiveTheme === "dark") { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + } + + applyTheme(currentTheme); + + const systemThemeQuery = window.matchMedia("(prefers-color-scheme: dark)"); + systemThemeQuery.addEventListener("change", () => { + if (localStorage.getItem("themePreference") === "system") { + applyTheme("system"); + } + }); + + themeSliders.forEach((slider) => { + slider.addEventListener("click", (e) => { + e.stopPropagation(); + const currentIndex = themeOrder.indexOf(currentTheme); + const nextIndex = (currentIndex + 1) % themeOrder.length; + const newTheme = themeOrder[nextIndex]; + + localStorage.setItem("themePreference", newTheme); + applyTheme(newTheme); + }); + }); + + const scrollToTopBtn = document.getElementById("scroll-to-top"); + const floatingThemeToggle = document.getElementById("floating-theme-toggle"); + const navbarThemeToggle = document.getElementById("navbar-theme-toggle"); + + window.addEventListener("scroll", () => { + const navbarRect = navbarThemeToggle.getBoundingClientRect(); + const navbarOutOfView = navbarRect.bottom < 0; + + if (window.pageYOffset > 300) { + scrollToTopBtn.classList.remove("opacity-0", "invisible"); + scrollToTopBtn.classList.add("opacity-100", "visible"); + } else { + scrollToTopBtn.classList.add("opacity-0", "invisible"); + scrollToTopBtn.classList.remove("opacity-100", "visible"); + } + + if (navbarOutOfView) { + floatingThemeToggle.classList.remove("opacity-0", "invisible"); + floatingThemeToggle.classList.add("opacity-100", "visible"); + } else { + floatingThemeToggle.classList.add("opacity-0", "invisible"); + floatingThemeToggle.classList.remove("opacity-100", "visible"); + } + }); + + scrollToTopBtn.addEventListener("click", () => { + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + }); + + document.addEventListener("click", (e) => { + if ( + e.target.closest(".calendar-toggle-btn") || + e.target.closest('[id^="cal-dropdown-"]') + ) { + return; + } + + document.querySelectorAll('[id^="cal-dropdown-"]').forEach((el) => { + el.classList.add("hidden"); + }); + document.querySelectorAll(".conference-card").forEach((card) => { + card.classList.remove("z-20"); + }); + }); +} + +document.addEventListener("DOMContentLoaded", init); diff --git a/website/calendarLinks.js b/website/calendarLinks.js new file mode 100644 index 0000000..7d0ad61 --- /dev/null +++ b/website/calendarLinks.js @@ -0,0 +1,169 @@ +import { formatDate } from "./dates.js"; + +window.toggleCalDropdown = function (idx, btnEl, event) { + if (event) { + event.stopPropagation(); + } + + const dropdownId = `cal-dropdown-${idx}`; + const dropdown = document.getElementById(dropdownId); + if (!dropdown) return; + + const isHidden = dropdown.classList.contains("hidden"); + + document.querySelectorAll('[id^="cal-dropdown-"]').forEach((el) => { + el.classList.add("hidden"); + }); + document.querySelectorAll(".conference-card").forEach((card) => { + card.classList.remove("z-20"); + }); + + if (isHidden) { + dropdown.classList.remove("hidden"); + const card = btnEl.closest(".conference-card"); + if (card) { + card.classList.add("z-20"); + } + } else { + dropdown.classList.add("hidden"); + } +}; + +export function renderCalendarLinks(conf, idx) { + const startStr = conf["Start Date"]; + if (!startStr) return ""; + + const endStr = conf["End Date"] || startStr; + + function parseYmd(str) { + const [y, m, d] = (str || "").split("-").map(Number); + if (!y || !m || !d) return null; + return { y, m, d }; + } + + function ymdCompact({ y, m, d }) { + const mm = String(m).padStart(2, "0"); + const dd = String(d).padStart(2, "0"); + return `${y}${mm}${dd}`; + } + + function toYmd({ y, m, d }) { + const mm = String(m).padStart(2, "0"); + const dd = String(d).padStart(2, "0"); + return `${y}-${mm}-${dd}`; + } + + function addDays(parts, days) { + const dt = new Date(Date.UTC(parts.y, parts.m - 1, parts.d + days)); + return { + y: dt.getUTCFullYear(), + m: dt.getUTCMonth() + 1, + d: dt.getUTCDate(), + }; + } + + const startParts = parseYmd(startStr); + const endParts = parseYmd(endStr) || startParts; + + if (!startParts || !endParts) { + return ""; + } + + const startYmdCompact = ymdCompact(startParts); + const endInclusiveYmdCompact = ymdCompact(endParts); + + const endExclusiveParts = addDays(endParts, 1); + const endExclusiveYmdCompact = ymdCompact(endExclusiveParts); + const outlookEndExclusiveStr = toYmd(endExclusiveParts); + + const title = conf.Subject || "Untitled Conference"; + const location = conf.Location || ""; + + let details = ""; + if (conf.Venue) details += "Venue: " + conf.Venue + "\n"; + if (conf["Website URL"]) details += "Website: " + conf["Website URL"] + "\n"; + if (conf.Location) details += "Location: " + conf.Location + "\n"; + + const excludeFields = new Set([ + "Subject", + "Start Date", + "End Date", + "Venue", + "Website URL", + "Location", + "Country", + "_startDateObj", + "_endDateObj", + "year", + ]); + + Object.keys(conf).forEach((key) => { + const value = conf[key]; + if (!value) return; + + if (key.startsWith("__")) return; + if (excludeFields.has(key)) return; + + let displayValue = value; + + if (/deadline/i.test(key)) { + const formatted = formatDate(value); + if (formatted) { + displayValue = formatted; + } + } + + details += `${key}: ${displayValue}\n`; + }); + + const description = encodeURIComponent(details.trim()); + const titleEnc = encodeURIComponent(title); + const locationEnc = encodeURIComponent(location); + + const googleUrl = + `https://calendar.google.com/calendar/render?action=TEMPLATE` + + `&text=${titleEnc}` + + `&dates=${startYmdCompact}/${endExclusiveYmdCompact}` + + (description ? `&details=${description}` : "") + + (location ? `&location=${locationEnc}` : ""); + + const outlookUrl = + `https://outlook.live.com/calendar/deeplink/compose` + + `?path=/calendar/action/compose` + + `&rru=addevent` + + `&allday=true` + + `&startdt=${encodeURIComponent(startStr)}` + + `&enddt=${encodeURIComponent(outlookEndExclusiveStr)}` + + `&subject=${titleEnc}` + + (description ? `&body=${description}` : "") + + (location ? `&location=${locationEnc}` : ""); + + const yahooUrl = + `https://calendar.yahoo.com/` + + `?v=60` + + `&TITLE=${titleEnc}` + + `&ST=${startYmdCompact}` + + `&ET=${endInclusiveYmdCompact}` + + (description ? `&DESC=${description}` : "") + + (location ? `&in_loc=${locationEnc}` : ""); + + const icsId = `download-ics-${idx}`; + + return ` +
+ + +
+ `; +} diff --git a/website/countries.js b/website/countries.js new file mode 100644 index 0000000..e95514a --- /dev/null +++ b/website/countries.js @@ -0,0 +1,31 @@ +let countryCache = null; + +async function fetchCountryData() { + if (countryCache) return countryCache; + + try { + const response = await fetch( + "https://restcountries.com/v3.1/all?fields=name,cca3,flag", + ); + const data = await response.json(); + + countryCache = {}; + data.forEach((country) => { + if (country.cca3 && country.name && country.name.common) { + countryCache[country.cca3] = `${country.flag} ${country.name.common}`; + } + }); + + return countryCache; + } catch (error) { + console.error("Error fetching country data:", error); + return {}; + } +} + +export const countriesPromise = fetchCountryData(); + +export async function getCountryName(code) { + const countries = await countriesPromise; + return countries[code] || code; +} diff --git a/website/dates.js b/website/dates.js new file mode 100644 index 0000000..ce2db23 --- /dev/null +++ b/website/dates.js @@ -0,0 +1,63 @@ +export function getConfDate(conf, key) { + const cacheKey = `__${key.replace(/\s+/g, "_")}Obj`; + + if (conf[cacheKey] !== undefined) { + return conf[cacheKey]; + } + + const raw = conf[key]; + if (!raw) { + conf[cacheKey] = null; + return null; + } + + const parts = String(raw).split("-"); + if (parts.length !== 3) { + conf[cacheKey] = null; + return null; + } + + const [y, m, d] = parts.map(Number); + if (!y || !m || !d) { + conf[cacheKey] = null; + return null; + } + + const dt = new Date(y, m - 1, d); + dt.setHours(0, 0, 0, 0); + conf[cacheKey] = dt; + return dt; +} + +export function getStartDate(conf) { + return getConfDate(conf, "Start Date"); +} + +export function getEndDate(conf) { + return getConfDate(conf, "End Date") || getStartDate(conf); +} + +export function getToday() { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; +} + +export function formatDate(dateOrStr) { + if (!dateOrStr) return ""; + + let date = dateOrStr; + if (!(dateOrStr instanceof Date)) { + const parts = String(dateOrStr).split("-"); + if (parts.length !== 3) return ""; + const [y, m, d] = parts.map(Number); + if (!y || !m || !d) return ""; + date = new Date(y, m - 1, d); + } + + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} diff --git a/website/generate_conferences.py b/website/generate_conferences.py new file mode 100644 index 0000000..dd672b6 --- /dev/null +++ b/website/generate_conferences.py @@ -0,0 +1,160 @@ +import csv +import glob +import json +import os +from datetime import datetime, date, timedelta, timezone +from typing import Any, Dict, List, Optional + +JSON_PATH = "conferences.json" +ICAL_PATH = "conferences.ics" + +EXCLUDE_FOR_DESC = { + "Subject", + "Start Date", + "End Date", + "Venue", + "Website URL", + "Location", + "Country", + "year", +} + + +def infer_year_from_filename(path: str) -> Optional[str]: + base = os.path.basename(path) + name, _ext = os.path.splitext(base) + name = name.strip() + if len(name) == 4 and name.isdigit(): + return name + return None + + +def normalize_row(row: Dict[str, Any]) -> Dict[str, str]: + cleaned: Dict[str, str] = {} + for k, v in row.items(): + if v is None: + cleaned[k] = "" + else: + cleaned[k] = str(v).strip() + return cleaned + + +def esc_ics(s: str) -> str: + return ( + str(s or "") + .replace("\\", "\\\\") + .replace(",", "\\,") + .replace(";", "\\;") + .replace("\n", "\\n") + ) + + +def parse_date(date_str: str) -> Optional[date]: + date_str = (date_str or "").strip() + if not date_str: + return None + try: + return datetime.strptime(date_str, "%Y-%m-%d").date() + except ValueError: + return None + + +def build_ical_events(confs: List[Dict[str, str]]) -> List[str]: + events: List[str] = [] + + for row in confs: + start = parse_date(row.get("Start Date", "")) + if not start: + continue + + end_inclusive = parse_date(row.get("End Date", "")) or start + + dtstart = start.strftime("%Y%m%d") + dtend = (end_inclusive + timedelta(days=1)).strftime("%Y%m%d") + + subject = row.get("Subject", "") or "Untitled Conference" + dtstamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + lines = [ + "BEGIN:VEVENT", + f"UID:{esc_ics(subject)}-{dtstart}", + f"DTSTAMP:{dtstamp}", + f"DTSTART;VALUE=DATE:{dtstart}", + f"DTEND;VALUE=DATE:{dtend}", + f"SUMMARY:{esc_ics(subject)}", + ] + + desc_parts: List[str] = [] + + if row.get("Venue"): + desc_parts.append(f"Venue: {row['Venue']}") + if row.get("Website URL"): + desc_parts.append(f"Website: {row['Website URL']}") + + for k, v in row.items(): + if k in EXCLUDE_FOR_DESC or not v: + continue + desc_parts.append(f"{k}: {v}") + + if desc_parts: + desc = "\\n".join(esc_ics(p) for p in desc_parts) + lines.append(f"DESCRIPTION:{desc}") + + if row.get("Location"): + lines.append(f"LOCATION:{esc_ics(row['Location'])}") + + lines.append("END:VEVENT") + events.append("\r\n".join(lines)) + + return events + + +def main() -> None: + conferences: List[Dict[str, str]] = [] + + for csvfile in sorted(glob.glob("../*.csv")): + year = infer_year_from_filename(csvfile) + + with open(csvfile, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + if not any(row.values()): + continue + + if not (row.get("Subject") or row.get("Start Date")): + continue + + cleaned = normalize_row(row) + if year is not None: + cleaned["year"] = year + + conferences.append(cleaned) + + def sort_key(conf: Dict[str, str]): + return (conf.get("Start Date", ""), conf.get("Subject", "")) + + conferences.sort(key=sort_key) + + with open(JSON_PATH, "w", encoding="utf-8") as f: + json.dump(conferences, f, ensure_ascii=False, indent=2) + + print(f"Wrote {len(conferences)} conferences to {JSON_PATH}") + + events = build_ical_events(conferences) + + ical_lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Python Conferences//EN", + *events, + "END:VCALENDAR", + ] + + with open(ICAL_PATH, "w", encoding="utf-8") as f: + f.write("\r\n".join(ical_lines)) + + print(f"Wrote {len(events)} events to {ICAL_PATH}") + + +if __name__ == "__main__": + main() diff --git a/website/ical.js b/website/ical.js new file mode 100644 index 0000000..33b4e3c --- /dev/null +++ b/website/ical.js @@ -0,0 +1,143 @@ +export function exportICal(confsOrSingle) { + const confs = Array.isArray(confsOrSingle) ? confsOrSingle : [confsOrSingle]; + + function esc(str) { + return String(str ?? "") + .replace(/\\/g, "\\\\") + .replace(/,/g, "\\,") + .replace(/;/g, "\\;") + .replace(/\n/g, "\\n"); + } + + function parseYmd(str) { + if (!str) return null; + const [y, m, d] = String(str).split("-").map(Number); + if (!y || !m || !d) return null; + return new Date(Date.UTC(y, m - 1, d)); + } + + function toYmd(date) { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d = String(date.getUTCDate()).padStart(2, "0"); + return `${y}${m}${d}`; + } + + function addDaysUTC(date, days) { + const d = new Date(date.getTime()); + d.setUTCDate(d.getUTCDate() + days); + return d; + } + + function getStartDate(conf) { + if (conf._startDateObj instanceof Date) return conf._startDateObj; + if (conf.__Start_DateObj instanceof Date) return conf.__Start_DateObj; + return parseYmd(conf["Start Date"]); + } + + function getEndDate(conf) { + if (conf._endDateObj instanceof Date) return conf._endDateObj; + if (conf.__End_DateObj instanceof Date) return conf.__End_DateObj; + return parseYmd(conf["End Date"]) || getStartDate(conf); + } + + const exclude = new Set([ + "Subject", + "Start Date", + "End Date", + "Venue", + "Website URL", + "Location", + "Country", + "_startDateObj", + "_endDateObj", + "year", + ]); + + const ical = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Python Conferences//EN", + ]; + + for (const conf of confs) { + const startDate = getStartDate(conf); + if (!startDate) continue; + + const endInclusive = getEndDate(conf) || startDate; + + const dtstart = toYmd(startDate); + const dtend = toYmd(addDaysUTC(endInclusive, 1)); + + const subject = conf.Subject || "Untitled Conference"; + + const dtstamp = new Date() + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}Z$/, "Z"); + + ical.push("BEGIN:VEVENT"); + ical.push("UID:" + esc(subject) + "-" + dtstart); + ical.push("DTSTAMP:" + dtstamp); + ical.push("DTSTART;VALUE=DATE:" + dtstart); + ical.push("DTEND;VALUE=DATE:" + dtend); + ical.push("SUMMARY:" + esc(subject)); + + const descParts = []; + + if (conf.Venue) descParts.push("Venue: " + conf.Venue); + if (conf["Website URL"]) descParts.push("Website: " + conf["Website URL"]); + + Object.keys(conf).forEach((key) => { + if (exclude.has(key)) return; + if (key.startsWith("__")) return; + const value = conf[key]; + if (!value) return; + descParts.push(`${key}: ${value}`); + }); + + if (descParts.length > 0) { + const desc = esc(descParts.join("\n")); + ical.push("DESCRIPTION:" + desc); + } + + if (conf.Location) { + ical.push("LOCATION:" + esc(conf.Location)); + } + + ical.push("END:VEVENT"); + } + + ical.push("END:VCALENDAR"); + + if (ical.length <= 4) { + console.warn("No valid conferences to export as iCal"); + return; + } + + const blob = new Blob([ical.join("\r\n")], { + type: "text/calendar;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const many = confs.length > 1; + + let filename = "python-conferences.ics"; + if (!many && confs[0]) { + const subj = String(confs[0].Subject || "event") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + filename = (subj || "event") + ".ics"; + } + + a.href = url; + a.download = filename; + + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); +} diff --git a/website/index.html b/website/index.html new file mode 100644 index 0000000..9779857 --- /dev/null +++ b/website/index.html @@ -0,0 +1,336 @@ + + + + + + Python Conferences Worldwide + + + + + + +
+ + +
+

+ 🐍 Python Conferences +

+

+ Discover Python conferences around the world. Find events, submission + deadlines, and sponsorship opportunities. +

+ + +
+ +
+
+
+ - +
+
+ Total Events +
+
+
+
+ - +
+
+ Countries +
+
+
+
+ - +
+
+ Upcoming Events +
+
+
+
+ - +
+
+ Years of Data +
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ Showing + 0 + conferences + +
+ +
+
+
+

Loading conferences...

+
+
+
+ +
+ + +
+ + + + From 165d257094d82f55d9f05d778eb600c8f9717d54 Mon Sep 17 00:00:00 2001 From: egeakman Date: Sun, 23 Nov 2025 04:39:50 -0500 Subject: [PATCH 2/2] oopsie --- website/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/website/README.md b/website/README.md index b7f4c78..9424726 100644 --- a/website/README.md +++ b/website/README.md @@ -8,6 +8,7 @@ This website displays a list of Python conferences and allows users to download A GitHub Actions workflow is set up to run on pushes to the main branch (also by hand and on a schedule). This workflow runs the `website/generate_conferences.py` script to produce the ICS and JSON files. Then all the content in `website/` is deployed to GitHub Pages. ## How to run locally + 1. Make sure you're in the `website/` directory. 2. And run: ```sh