diff --git a/.editorconfig b/.editorconfig index 6e5b9f9..4e8e071 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,11 @@ charset = utf-8 end_of_line = lf insert_final_newline = true -[*.{js,yaml}] +[*.py] +indent_style = space +indent_size = 4 + +[*.yaml] indent_style = space indent_size = 2 + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ae018fa --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.ics text eol=crlf diff --git a/.gitignore b/.gitignore index 008e122..18286ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,8 @@ -# 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 +static/all.ics .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 @@ -42,7 +41,8 @@ hugo.linux .LSOverride # Icon must end with two \r -Icon +Icon + # Thumbnails ._* @@ -67,114 +67,6 @@ 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 @@ -201,4 +93,17 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk -# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv +# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo + +# NixOS: +.envrc +shell.nix +.direnv + +# Python +.venv + +# murmeldin +overview.md + +/result diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/README.md b/README.md index 5f72bd0..3b0af0f 100644 --- a/README.md +++ b/README.md @@ -27,20 +27,24 @@ 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` 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` 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. To build with *nix*: `nix build '.?submodules=1#production-content'` @@ -51,8 +55,7 @@ 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 a merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or - if small change, merge yourself. +5. Create 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! @@ -61,12 +64,9 @@ 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 18b4768..ffb3c7e 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,18 +431,20 @@ document.addEventListener('DOMContentLoaded', function() { } // ICS-Datei abrufen und Events verarbeiten - fetch('/calendars/all.ics') + fetch('/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 fffd93d..d87eb04 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 625a262..6bc707a 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,12 @@ #!/bin/sh -set -ex +set -e +set -x 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 218ffbb..13192d6 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/:slug/" +post = "/post/:year/:month/:day/:title/" [outputs] home = ["html", "rss", "json"] diff --git a/content/_index.md b/content/_index.md index 4d73f01..531fa85 100644 --- a/content/_index.md +++ b/content/_index.md @@ -25,9 +25,6 @@ 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 17a6269..61844e1 100644 --- a/content/post/Neujahresempfang-2026/index.md +++ b/content/post/Neujahresempfang-2026/index.md @@ -1,6 +1,5 @@ --- 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 98de614..9a97796 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/neujahresempfang-2026/) \ 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/der-neujahresempfang-2026-%23nje26/) \ No newline at end of file diff --git a/content/verein/calendar/index.md b/content/verein/calendar/index.md index 3fccf38..fdac4e4 100644 --- a/content/verein/calendar/index.md +++ b/content/verein/calendar/index.md @@ -12,15 +12,4 @@ herostyle: big {{< calendar >}} -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) +Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/all.ics)! diff --git a/flake.lock b/flake.lock index 33ab9db..2869045 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1786313170, - "narHash": "sha256-9BG7OgUWdu0ONDO5X2q6+K4bsuBITkX/3W4nNJu1Ito=", + "lastModified": 1748995628, + "narHash": "sha256-bFufQGSAEYQgjtc4wMrobS5HWN0hDP+ZX+zthYcml9U=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fcb8fcd6bf2d0adecae5bd491afaaaf8311b758d", + "rev": "8eb3b6a2366a7095939cd22f0dc0e9991313294b", "type": "github" }, "original": { "owner": "nixos", - "ref": "nixos-26.05", + "ref": "nixos-24.11", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index c29b73a..39d9b6d 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-26.05"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11"; }; outputs = @@ -34,7 +34,7 @@ devShells = forAllSystems (import ./devShells.nix); - formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree); + formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-rfc-style); lib.mkWwwContent = { domain, system }: @@ -50,6 +50,10 @@ 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"; @@ -58,6 +62,11 @@ 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 new file mode 100644 index 0000000..c61ba7f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +icalendar==5.0.7 +pytz \ No newline at end of file diff --git a/static/js/upcoming.js b/static/js/upcoming.js deleted file mode 100644 index 6b7a711..0000000 --- a/static/js/upcoming.js +++ /dev/null @@ -1,113 +0,0 @@ -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 new file mode 100755 index 0000000..31763f7 --- /dev/null +++ b/tools/gen_upcoming.py @@ -0,0 +1,108 @@ +#!/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 new file mode 100755 index 0000000..136904e --- /dev/null +++ b/tools/merge_cals.py @@ -0,0 +1,31 @@ +#!/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()) +