From c28f04c6e85cd0acac8b81294c9b430056ba3263 Mon Sep 17 00:00:00 2001 From: "Ricardo (XenGi) Band" Date: Wed, 12 Aug 2026 02:01:58 +0200 Subject: [PATCH] switch to ics files; make calendars work; fix some minor issues --- .editorconfig | 7 +- .gitattributes | 1 - .gitignore | 135 +++++++++++++++++--- .python-version | 1 - README.md | 28 ++-- assets/js/calendar.js | 94 +++++++------- assets/js/clubstatus.js | 6 +- build.sh | 7 +- config/_default/hugo.toml | 2 +- content/_index.md | 5 +- content/post/Neujahresempfang-2026/index.md | 1 + content/veranstaltungen/neujahresempfang.md | 2 +- content/verein/calendar/index.md | 13 +- flake.lock | 8 +- flake.nix | 13 +- requirements.txt | 2 - static/js/upcoming.js | 113 ++++++++++++++++ tools/gen_upcoming.py | 108 ---------------- tools/merge_cals.py | 31 ----- 19 files changed, 318 insertions(+), 259 deletions(-) delete mode 100644 .gitattributes delete mode 100644 .python-version delete mode 100644 requirements.txt create mode 100644 static/js/upcoming.js delete mode 100755 tools/gen_upcoming.py delete mode 100755 tools/merge_cals.py diff --git a/.editorconfig b/.editorconfig index 4e8e071..6e5b9f9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,11 +9,6 @@ charset = utf-8 end_of_line = lf insert_final_newline = true -[*.py] -indent_style = space -indent_size = 4 - -[*.yaml] +[*.{js,yaml}] indent_style = space indent_size = 2 - diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index ae018fa..0000000 --- a/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -*.ics text eol=crlf diff --git a/.gitignore b/.gitignore index 18286ce..008e122 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ -static/all.ics +# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv +# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo,pycharm+all,vim,direnv + +### direnv ### +.direnv .envrc -*.swp -# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo -# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo ### Hugo ### # Generated files by hugo @@ -41,8 +42,7 @@ hugo.linux .LSOverride # Icon must end with two \r -Icon - +Icon # Thumbnails ._* @@ -67,6 +67,114 @@ Temporary Items # iCloud generated files *.icloud +### PyCharm+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +!*.svg # comment out if you don't need vector files +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +# Auto-generated tag files +tags +# Persistent undo +[._]*.un~ + ### Windows ### # Windows thumbnail cache files Thumbs.db @@ -93,17 +201,4 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo - -# NixOS: -.envrc -shell.nix -.direnv - -# Python -.venv - -# murmeldin -overview.md - -/result +# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv diff --git a/.python-version b/.python-version deleted file mode 100644 index 2c07333..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.11 diff --git a/README.md b/README.md index 3b0af0f..5f72bd0 100644 --- a/README.md +++ b/README.md @@ -27,24 +27,20 @@ This is the website of the CCCB. 5. Point your browser to: -Every change you make on the project will be reflected in your browser as long -as `hugo serve` is running. +Every change you make on the project will be reflected in your browser as long as `hugo serve` is running. -The *"Nächste Veranstaltungen"* table on the home page is generated by -post-processing in `./build.sh`, not by Hugo, so it is **not** visible under -`hugo serve`. To preview the fully-built site (including the home-page -calendar), or to ready the site for upload, run: +The *"Nächste Veranstaltungen"* table on the home page is generated by post-processing in `./build.sh`, not by Hugo, so +it is **not** visible under `hugo serve`. To preview the fully built site (including the home-page calendar), or to +ready the site for upload, run: ```shell ./build.sh python3 -m http.server -d public 1313 ``` -`build.sh` generates `all.ics` and replaces the `CALENDAR` placeholder in -`index.html` with the upcoming-events table. It depends on Python with the -`icalendar`, `python-dateutil`, and `pytz` packages, plus a `de_DE.UTF-8` -locale (used to format weekday names). Inside `nix develop` these are -provided automatically. +`build.sh` replaces the `CALENDAR` placeholder in `index.html` with the upcoming-events table. It depends on Python +with the `icalendar`, `python-dateutil`, and `pytz` packages, plus a `de_DE.UTF-8` locale (used to format weekday +names). Inside `nix develop` these are provided automatically. To build with *nix*: `nix build '.?submodules=1#production-content'` @@ -55,7 +51,8 @@ To build with *nix*: `nix build '.?submodules=1#production-content'` 3. Commit (and push) your change. 4. ~~GitHub Actions is running the release workflow.~~ - If successful, check [Staging Website](https://staging.berlin.ccc.de/) if change is correct. -5. Create merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or if small change, merge yourself. +5. Create a merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or + if small change, merge yourself. 6. ~~GitHub Actions is running the release workflow.~~ - If successfull, check [Website](https://berlin.ccc.de/) if change is correct. 7. Profit! @@ -64,9 +61,12 @@ To build with *nix*: `nix build '.?submodules=1#production-content'` - After entering the shell with `nix develop`, hugo is available and `hugo serve` should work - Python including required packages will be available, so the `build.sh` should work without a venv -- You can build the staging and production builds with `nix build .#staging-content` and `nix build .#production-content` -- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last checked June 2025) +- You can build the staging and production builds with `nix build .#staging-content` and + `nix build .#production-content` +- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last + checked June 2025) --- Made with ❤️ and [Hugo](https://gohugo.io). + diff --git a/assets/js/calendar.js b/assets/js/calendar.js index ffb3c7e..18b4768 100644 --- a/assets/js/calendar.js +++ b/assets/js/calendar.js @@ -19,10 +19,10 @@ document.addEventListener('DOMContentLoaded', function() { if (colonIndex > -1) { let key = line.substring(0, colonIndex); let value = line.substring(colonIndex + 1); - + // Handle properties with parameters (like TZID) const baseKey = key.split(";")[0]; - + if (baseKey === "DTSTART") { event.start = value; event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null; @@ -46,14 +46,14 @@ document.addEventListener('DOMContentLoaded', function() { function parseDateString(icsDateStr) { // Handle different date formats if (!icsDateStr) return null; - + // For basic date format: YYYYMMDD if (icsDateStr.length === 8) { let year = icsDateStr.substring(0, 4); let month = icsDateStr.substring(4, 6); let day = icsDateStr.substring(6, 8); return `${year}-${month}-${day}`; - } + } // For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss else if (icsDateStr.includes("T")) { let year = icsDateStr.substring(0, 4); @@ -67,34 +67,34 @@ document.addEventListener('DOMContentLoaded', function() { // Extract date components from different date formats function getDateComponents(icsDateStr) { if (!icsDateStr) return null; - + // Basic handling - extract YYYY, MM, DD regardless of format const year = parseInt(icsDateStr.substring(0, 4)); const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months const day = parseInt(icsDateStr.substring(6, 8)); - + return { year, month, day }; } function expandRecurringEvents(event, year, month) { if (!event.rrule) return [event]; - + const rruleStr = event.rrule; - + // Get start date components const startComponents = getDateComponents(event.start); if (!startComponents) return [event]; - + const startDate = new Date( startComponents.year, startComponents.month, startComponents.day ); - + const rangeStart = new Date(year, month, 1); const rangeEnd = new Date(year, month + 1, 0); const expandedEvents = []; - + if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) { const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/); if (bydayMatch) { @@ -103,7 +103,7 @@ document.addEventListener('DOMContentLoaded', function() { 'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0 }; const targetDay = dayMap[dayCode]; - + if (targetDay !== undefined) { // Create events for each matching day in the month let day = 1; @@ -112,13 +112,13 @@ document.addEventListener('DOMContentLoaded', function() { if (testDate.getDay() === targetDay && testDate >= startDate) { const newEvent = {...event}; const eventDate = formatDateForICS(testDate); - + // Preserve time portion from original event - const timePart = event.start.includes('T') ? + const timePart = event.start.includes('T') ? event.start.substring(event.start.indexOf('T')) : ''; - const endTimePart = event.end.includes('T') ? + const endTimePart = event.end.includes('T') ? event.end.substring(event.end.indexOf('T')) : ''; - + newEvent.start = eventDate + timePart; newEvent.end = eventDate + endTimePart; expandedEvents.push(newEvent); @@ -141,15 +141,15 @@ document.addEventListener('DOMContentLoaded', function() { const dayMap = { 'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0 }; - + bydays.forEach(byday => { const occurrence = parseInt(byday) || 1; const dayCode = byday.slice(-2); const dayIndex = dayMap[dayCode]; - + let day = 1; let count = 0; - + while (day <= rangeEnd.getDate()) { const testDate = new Date(year, month, day); if (testDate.getDay() === dayIndex) { @@ -157,13 +157,13 @@ document.addEventListener('DOMContentLoaded', function() { if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) { const newEvent = {...event}; const eventDate = new Date(year, month, day); - + // Preserve time portion from original event - const timePart = event.start.includes('T') ? + const timePart = event.start.includes('T') ? event.start.substring(event.start.indexOf('T')) : ''; - const endTimePart = event.end.includes('T') ? + const endTimePart = event.end.includes('T') ? event.end.substring(event.end.indexOf('T')) : ''; - + newEvent.start = formatDateForICS(eventDate) + timePart; newEvent.end = formatDateForICS(eventDate) + endTimePart; expandedEvents.push(newEvent); @@ -174,7 +174,7 @@ document.addEventListener('DOMContentLoaded', function() { }); } } - + return expandedEvents.length > 0 ? expandedEvents : [event]; } @@ -206,7 +206,7 @@ document.addEventListener('DOMContentLoaded', function() { function updateEventsForMonth(year, month) { // Clear existing events for this month view eventsByDate = {}; - + // Process each event, expanding recurring ones events.forEach(ev => { if (ev.rrule) { @@ -228,7 +228,7 @@ document.addEventListener('DOMContentLoaded', function() { // Check if this event belongs to current month view const eventYear = parseInt(dateKey.split('-')[0]); const eventMonth = parseInt(dateKey.split('-')[1]) - 1; - + if (eventYear === year && eventMonth === month) { if (!eventsByDate[dateKey]) { eventsByDate[dateKey] = []; @@ -238,7 +238,7 @@ document.addEventListener('DOMContentLoaded', function() { } } }); - + renderCalendar(year, month); } @@ -267,16 +267,16 @@ document.addEventListener('DOMContentLoaded', function() { } let cell = document.createElement("td"); cell.innerHTML = "" + day + ""; - + let dayStr = day < 10 ? "0" + day : day; let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1); let dateKey = year + "-" + monthStr + "-" + dayStr; - + if (eventsByDate[dateKey]) { let dotsContainer = document.createElement("div"); dotsContainer.className = "event-dots-container"; cell.classList.add("has-event"); - + // Gruppe Events nach Typ const events = eventsByDate[dateKey]; const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only")); @@ -285,12 +285,12 @@ document.addEventListener('DOMContentLoaded', function() { const hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend")); const hasRegular = events.some(e => { const title = e.summary.toLowerCase(); - return !title.includes("members only") && - !title.includes("subbotnik") && - !title.includes("bastelabend") && + return !title.includes("members only") && + !title.includes("subbotnik") && + !title.includes("bastelabend") && !title.includes("spieleabend"); }); - + // Füge Dots entsprechend der Event-Typen hinzu if (hasMembersOnly) { let dot = document.createElement("div"); @@ -307,7 +307,7 @@ document.addEventListener('DOMContentLoaded', function() { dot.className = "event-dot event-dot-greenyellow"; dotsContainer.appendChild(dot); } - + cell.appendChild(dotsContainer); cell.dataset.dateKey = dateKey; cell.addEventListener("click", function() { @@ -336,8 +336,8 @@ document.addEventListener('DOMContentLoaded', function() { if (match && match[1]) { return `https://berlin.ccc.de/datengarten/${match[1]}/`; } - } - + } + // For other titles, convert to lowercase and use as path const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''); return `https://berlin.ccc.de/page/${slug}/`; @@ -355,13 +355,13 @@ document.addEventListener('DOMContentLoaded', function() { let eventTitle = document.createElement("div"); eventTitle.className = "event-title"; - + // Create a link for the event title let titleLink = document.createElement("h"); titleLink.textContent = ev.summary; titleLink.target = "_blank"; eventTitle.appendChild(titleLink); - + eventItem.appendChild(eventTitle); let eventTime = document.createElement("div"); @@ -372,7 +372,7 @@ document.addEventListener('DOMContentLoaded', function() { if (ev.description) { let eventDescription = document.createElement("div"); eventDescription.className = "event-description"; - + // Check if the description is a URL and make it a clickable link if (ev.description.trim().startsWith('http')) { let linkElement = document.createElement("a"); @@ -384,7 +384,7 @@ document.addEventListener('DOMContentLoaded', function() { } else { eventDescription.textContent = ev.description; } - + eventItem.appendChild(eventDescription); } @@ -409,7 +409,7 @@ document.addEventListener('DOMContentLoaded', function() { function formatTime(icsTimeStr) { // Format time for display if (!icsTimeStr) return ""; - + if (icsTimeStr.length === 8) { // All-day event return "Ganztägig"; @@ -431,20 +431,18 @@ document.addEventListener('DOMContentLoaded', function() { } // ICS-Datei abrufen und Events verarbeiten - fetch('/all.ics') + fetch('/calendars/all.ics') .then(response => response.text()) .then(data => { events = parseICS(data); - + // Initialize with current date let today = new Date(); currentYear = today.getFullYear(); currentMonth = today.getMonth(); - + // Process events for current month - - updateEventsForMonth(currentYear, currentMonth); - + updateEventsForMonth(currentYear, currentMonth); }) .catch(err => console.error('Fehler beim Laden der ICS-Datei:', err)); })(); diff --git a/assets/js/clubstatus.js b/assets/js/clubstatus.js index d87eb04..fffd93d 100644 --- a/assets/js/clubstatus.js +++ b/assets/js/clubstatus.js @@ -9,11 +9,11 @@ function timeDistanceDE(pastDate) { const hours = Math.floor(minutes / 60) const days = Math.floor(hours / 24) - if (days = 1) return `since ${days}d` + if (days === 1) return `since ${days}d` if (days > 1) return `since ${days}d` - if (hours = 1) return `since ${hours}h` + if (hours === 1) return `since ${hours}h` if (hours > 1) return `since ${hours}h` - if (minutes = 1) return `since ${minutes}min` + if (minutes === 1) return `since ${minutes}min` if (minutes > 1) return `since ${minutes}min` return `gerade eben` } diff --git a/build.sh b/build.sh index 6bc707a..625a262 100755 --- a/build.sh +++ b/build.sh @@ -1,12 +1,7 @@ #!/bin/sh -set -e -set -x +set -ex rm -rf public/ # delete old generated files hugo $(cat .hugo-params) -./tools/merge_cals.py -upcoming="$(tools/gen_upcoming.py static/all.ics 20 5 | tr '\n' ' ')" -cp static/all.ics public/all.ics -sed -i "s#CALENDAR#$upcoming#g" public/index.html diff --git a/config/_default/hugo.toml b/config/_default/hugo.toml index 13192d6..218ffbb 100644 --- a/config/_default/hugo.toml +++ b/config/_default/hugo.toml @@ -28,7 +28,7 @@ filename = "sitemap.xml" priority = 0.5 [permalinks] -post = "/post/:year/:month/:day/:title/" +post = "/post/:year/:month/:day/:slug/" [outputs] home = ["html", "rss", "json"] diff --git a/content/_index.md b/content/_index.md index 531fa85..4d73f01 100644 --- a/content/_index.md +++ b/content/_index.md @@ -25,6 +25,9 @@ description: "Startseite CCCB mit Kurzkalender" ### Nächste Veranstaltungen -CALENDAR + +
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/). + + diff --git a/content/post/Neujahresempfang-2026/index.md b/content/post/Neujahresempfang-2026/index.md index 61844e1..17a6269 100644 --- a/content/post/Neujahresempfang-2026/index.md +++ b/content/post/Neujahresempfang-2026/index.md @@ -1,5 +1,6 @@ --- title: "Der Neujahresempfang 2026 #NJE26" +slug: "neujahresempfang-2026" date: 2026-03-05T12:00:00+01:00 showHero: false tags: ["NJE", "Ankündigung"] diff --git a/content/veranstaltungen/neujahresempfang.md b/content/veranstaltungen/neujahresempfang.md index 9a97796..98de614 100644 --- a/content/veranstaltungen/neujahresempfang.md +++ b/content/veranstaltungen/neujahresempfang.md @@ -10,4 +10,4 @@ menu: tag: ["Veranstaltung"] --- -Am 23. Mai lädt der Chaos Computer Club Berlin zum Neujahresempfang. [Hier geht es zur Infoseite!](https://berlin.ccc.de/post/2026/03/05/der-neujahresempfang-2026-%23nje26/) \ No newline at end of file +Am 23. Mai lädt der Chaos Computer Club Berlin zum Neujahresempfang. [Hier geht es zur Infoseite!](https://berlin.ccc.de/post/2026/03/05/neujahresempfang-2026/) \ No newline at end of file diff --git a/content/verein/calendar/index.md b/content/verein/calendar/index.md index fdac4e4..3fccf38 100644 --- a/content/verein/calendar/index.md +++ b/content/verein/calendar/index.md @@ -12,4 +12,15 @@ herostyle: big {{< calendar >}} -Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/all.ics)! +Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/calendars/all.ics)! + +Oder willst du nur den Kalender von bestimmten Events? + +- [Club Discordia](/calendars/Club_Discordia.ics) +- [Neujahresempfang](/calendars/Neujahresempfang.ics) +- [Datengarten](/calendars/Datengarten.ics) +- [Spieleabend](/calendars/Spieleabend.ics) +- [OpenWRT](/calendars/OpenWRT.ics) +- [Plenum](/calendars/Plenum.ics) +- [Amateurfunk](/calendars/Amateurfunk.ics) +- [Irreguläre Events](/calendars/Random.ics) diff --git a/flake.lock b/flake.lock index 2869045..33ab9db 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1748995628, - "narHash": "sha256-bFufQGSAEYQgjtc4wMrobS5HWN0hDP+ZX+zthYcml9U=", + "lastModified": 1786313170, + "narHash": "sha256-9BG7OgUWdu0ONDO5X2q6+K4bsuBITkX/3W4nNJu1Ito=", "owner": "nixos", "repo": "nixpkgs", - "rev": "8eb3b6a2366a7095939cd22f0dc0e9991313294b", + "rev": "fcb8fcd6bf2d0adecae5bd491afaaaf8311b758d", "type": "github" }, "original": { "owner": "nixos", - "ref": "nixos-24.11", + "ref": "nixos-26.05", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 39d9b6d..c29b73a 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "A flake containing a development environment for the CCCB website."; inputs = { - nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05"; }; outputs = @@ -34,7 +34,7 @@ devShells = forAllSystems (import ./devShells.nix); - formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-rfc-style); + formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree); lib.mkWwwContent = { domain, system }: @@ -50,10 +50,6 @@ nativeBuildInputs = [ pkgs.hugo pkgs.glibcLocales - (pkgs.python3.withPackages (python-pkgs: [ - python-pkgs.icalendar - python-pkgs.pytz - ])) ]; # LOCALE_ARCHIVE = builtins.trace pkgs.glibcLocales "${pkgs.glibcLocales}/lib/locale/locale-archive"; @@ -62,11 +58,6 @@ buildPhase = '' mkdir -p public hugo --baseURL=https://${domain}/ - - python3 ./tools/merge_cals.py - upcoming="$(python3 tools/gen_upcoming.py static/all.ics 20 5 | tr '\n' ' ')" - cp static/all.ics public/all.ics - sed -i "s#CALENDAR#$upcoming#g" public/index.html ''; # Install phase - copy the public directory to the output diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index c61ba7f..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -icalendar==5.0.7 -pytz \ No newline at end of file diff --git a/static/js/upcoming.js b/static/js/upcoming.js new file mode 100644 index 0000000..6b7a711 --- /dev/null +++ b/static/js/upcoming.js @@ -0,0 +1,113 @@ +import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js"; + +/** + * Parse an ICS calendar and return upcoming event occurrences. + * + * @param {string} icsText The contents of the .ics file + * @param {Date} now Events must start after this date + * @param {number} maxEvents Maximum number of events to return + * @param {number} maxDays Maximum number of days into the future + * @returns {{start: Date, name: string, url: string}[]} + */ +function getUpcomingEvents(icsText, now, maxEvents, maxDays) { + const jcal = ICAL.parse(icsText); + const calendar = new ICAL.Component(jcal); + + const end = new Date(now.getTime()); + end.setDate(end.getDate() + maxDays); + + const events = []; + + for (const component of calendar.getAllSubcomponents("vevent")) { + const event = new ICAL.Event(component); + + if (!event.startDate) { + continue; + } + + if (event.isRecurring()) { + const iterator = event.iterator(); + + while (true) { + const occurrence = iterator.next(); + + if (!occurrence) { + break; + } + + const start = occurrence.toJSDate(); + + // Recurrences are chronological, so we're done + // once we pass the end of our search window. + if (start > end) { + break; + } + + if (start > now) { + events.push({ + start, + name: event.summary ?? "", + url: event.url ?? "", + }); + } + } + } else { + const start = event.startDate.toJSDate(); + + if (start > now && start <= end) { + events.push({ + start, + name: event.summary ?? "", + url: event.url ?? "", + }); + } + } + } + + // We have occurrences from multiple events, so sort them + // before applying the maximum event count. + events.sort((a, b) => a.start - b.start); + + return events.slice(0, maxEvents); +} + +document.addEventListener("DOMContentLoaded", () => { + const ics = "/calendars/all.ics"; + const max_days = 20; + const max_items = 5; + + const now = new Date(); + + const table = document.getElementById("upcoming"); + fetch(ics) + .then(response => response.text()) + .then(icsText => { + getUpcomingEvents(icsText, now, max_items, max_days).forEach(event => { + const row = document.createElement("tr"); + + const colBegin = document.createElement("td"); + + const formattedStart = event.start.toLocaleString("de-DE", { + weekday: "long", + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).replace(",", ""); + + colBegin.innerText = `${formattedStart.replace(" ", ", ")} Uhr`; + row.appendChild(colBegin); + + const colName = document.createElement("td"); + + const a = document.createElement("a"); + a.href = event.url; + a.text = event.name; + + colName.appendChild(a); + row.appendChild(colName); + + table.appendChild(row); + }); + }); +}); diff --git a/tools/gen_upcoming.py b/tools/gen_upcoming.py deleted file mode 100755 index 31763f7..0000000 --- a/tools/gen_upcoming.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import logging -import locale -from dateutil.parser import parse as _parse -from datetime import datetime, timedelta -from dateutil.rrule import rruleset, rrulestr - -import icalendar - - -def parse(value): - # Some templates emit DTSTART with a trailing "Z" (e.g. 20260507T190000Z) - # which produces tz-aware datetimes, while others emit naive local times. - # Normalize to naive so comparisons and sorting don't mix the two. - dt = _parse(value) - if dt.tzinfo is not None: - dt = dt.replace(tzinfo=None) - return dt - - -def vevent_to_event(event, rrstart=None): - if rrstart == None: - begin = parse(event["DTSTART"].to_ical()) - else: - begin = rrstart - - return { - "name": event["SUMMARY"].to_ical(), - "url": event["URL"].to_ical(), - "begin": begin - } - - -def parse_single_event(event, start, end): - logging.info(f"Processing single event {event['SUMMARY'].to_ical().decode('utf-8')}") - dtstart = parse(event["DTSTART"].to_ical()) - if dtstart >= start and dtstart < end: - return vevent_to_event(event) - - -def parse_recurring_event(event, start, end): - logging.info(f"Processing recurring event {event['SUMMARY'].to_ical().decode('utf-8')}") - dtstart = parse(event["DTSTART"].to_ical()) - rs = rruleset() - # dtstart is normalized to naive local time above; pass ignoretz so a - # tz-aware UNTIL (RFC 5545 requires UTC for it) is treated as naive too, - # otherwise dateutil rejects the naive/aware mismatch. - rs.rrule(rrulestr(event["RRULE"].to_ical().decode("utf-8"), dtstart=dtstart, ignoretz=True)) - if "EXDATE" in event.keys(): - for exdate in event["EXDATE"]: - rs.exdate(parse(exdate.to_ical())) - - events = [] - for date in list(rs): - if date >= start and date < end: - events.append(vevent_to_event(event, date)) - - return events - - -def find_events(icsfilestr, start, end, num): - with open(icsfilestr, "r") as icsfile: - cal = icalendar.Calendar.from_ical(icsfile.read()) - - events = [] - for event in cal.subcomponents: - if event.name == "VEVENT": - if "RRULE" in event.keys(): - events.extend(parse_recurring_event(event, start, end)) - else: - ev = parse_single_event(event, start, end) - if ev is not None: - events.append(ev) - - events = sorted(events, key=lambda k: k["begin"]) - events = events[0:num] - - return events - - -def format_events(events): - print("") - for event in events: - print( - "" - f"" - f"" - "" - ) - print("
{event['begin'].strftime('%A, %d.%m um %H:%M Uhr')}{event['name'].decode('utf-8')}
") - - -if __name__ == "__main__": - if len(sys.argv) < 3: - print(f"Usage: {sys.argv[0]} calendar max_days max_items") - sys.exit(-1) - - locale.setlocale(locale.LC_TIME, "de_DE.UTF-8") - calendar = sys.argv[1] - max_days = int(sys.argv[2]) - max_items = int(sys.argv[3]) - - now = datetime.now() - events = find_events(calendar, now, now + timedelta(days=max_days), max_items) - format_events(events) - diff --git a/tools/merge_cals.py b/tools/merge_cals.py deleted file mode 100755 index 136904e..0000000 --- a/tools/merge_cals.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 - -from glob import glob - -import pytz -import icalendar - - -calendars = [] -merged = icalendar.Calendar() -merged.add("prodid", "-//CCCB Calendar Generator//berlin.ccc.de//") -merged.add("version", "2.0") - -for icsfilestr in glob("public/*/**/*.ics", recursive=True): - with open(icsfilestr, "r") as icsfile: - print(f"Importing {icsfilestr}") - calendars.append(icalendar.Calendar.from_ical(icsfile.read())) - -for calendar in calendars: - for event in calendar.subcomponents: - if event.name != "VEVENT": - continue - if "DTSTART" not in event: - continue - merged.add_component(event) - -outfile = "static/all.ics" -with open(outfile, "wb") as f: - print(f"writing to {outfile}...") - f.write(merged.to_ical()) -