Compare commits

..
19 changed files with 259 additions and 318 deletions

View file

@ -9,6 +9,11 @@ charset = utf-8
end_of_line = lf end_of_line = lf
insert_final_newline = true insert_final_newline = true
[*.{js,yaml}] [*.py]
indent_style = space
indent_size = 4
[*.yaml]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.ics text eol=crlf

135
.gitignore vendored
View file

@ -1,9 +1,8 @@
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv static/all.ics
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo,pycharm+all,vim,direnv
### direnv ###
.direnv
.envrc .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 ### ### Hugo ###
# Generated files by hugo # Generated files by hugo
@ -42,7 +41,8 @@ hugo.linux
.LSOverride .LSOverride
# Icon must end with two \r # Icon must end with two \r
Icon Icon
# Thumbnails # Thumbnails
._* ._*
@ -67,114 +67,6 @@ Temporary Items
# iCloud generated files # iCloud generated files
*.icloud *.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 ###
# Windows thumbnail cache files # Windows thumbnail cache files
Thumbs.db Thumbs.db
@ -201,4 +93,17 @@ $RECYCLE.BIN/
# Windows shortcuts # Windows shortcuts
*.lnk *.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

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.11

View file

@ -27,20 +27,24 @@ This is the website of the CCCB.
5. Point your browser to: <http://localhost:1313/> 5. Point your browser to: <http://localhost:1313/>
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 The *"Nächste Veranstaltungen"* table on the home page is generated by
it is **not** visible under `hugo serve`. To preview the fully built site (including the home-page calendar), or to post-processing in `./build.sh`, not by Hugo, so it is **not** visible under
ready the site for upload, run: `hugo serve`. To preview the fully-built site (including the home-page
calendar), or to ready the site for upload, run:
```shell ```shell
./build.sh ./build.sh
python3 -m http.server -d public 1313 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 `build.sh` generates `all.ics` and replaces the `CALENDAR` placeholder in
with the `icalendar`, `python-dateutil`, and `pytz` packages, plus a `de_DE.UTF-8` locale (used to format weekday `index.html` with the upcoming-events table. It depends on Python with the
names). Inside `nix develop` these are provided automatically. `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'` 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. 3. Commit (and push) your change.
4. ~~GitHub Actions is running the release workflow.~~ 4. ~~GitHub Actions is running the release workflow.~~
- If successful, check [Staging Website](https://staging.berlin.ccc.de/) if change is correct. - 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 5. Create merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or if small change, merge yourself.
if small change, merge yourself.
6. ~~GitHub Actions is running the release workflow.~~ 6. ~~GitHub Actions is running the release workflow.~~
- If successfull, check [Website](https://berlin.ccc.de/) if change is correct. - If successfull, check [Website](https://berlin.ccc.de/) if change is correct.
7. Profit! 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 - 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 - 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 - You can build the staging and production builds with `nix build .#staging-content` and `nix build .#production-content`
`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)
- 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). Made with ❤️ and [Hugo](https://gohugo.io).

View file

@ -19,10 +19,10 @@ document.addEventListener('DOMContentLoaded', function() {
if (colonIndex > -1) { if (colonIndex > -1) {
let key = line.substring(0, colonIndex); let key = line.substring(0, colonIndex);
let value = line.substring(colonIndex + 1); let value = line.substring(colonIndex + 1);
// Handle properties with parameters (like TZID) // Handle properties with parameters (like TZID)
const baseKey = key.split(";")[0]; const baseKey = key.split(";")[0];
if (baseKey === "DTSTART") { if (baseKey === "DTSTART") {
event.start = value; event.start = value;
event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null; event.startParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
@ -46,14 +46,14 @@ document.addEventListener('DOMContentLoaded', function() {
function parseDateString(icsDateStr) { function parseDateString(icsDateStr) {
// Handle different date formats // Handle different date formats
if (!icsDateStr) return null; if (!icsDateStr) return null;
// For basic date format: YYYYMMDD // For basic date format: YYYYMMDD
if (icsDateStr.length === 8) { if (icsDateStr.length === 8) {
let year = icsDateStr.substring(0, 4); let year = icsDateStr.substring(0, 4);
let month = icsDateStr.substring(4, 6); let month = icsDateStr.substring(4, 6);
let day = icsDateStr.substring(6, 8); let day = icsDateStr.substring(6, 8);
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
// For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss // For datetime formats: YYYYMMDDTHHmmssZ or YYYYMMDDTHHmmss
else if (icsDateStr.includes("T")) { else if (icsDateStr.includes("T")) {
let year = icsDateStr.substring(0, 4); let year = icsDateStr.substring(0, 4);
@ -67,34 +67,34 @@ document.addEventListener('DOMContentLoaded', function() {
// Extract date components from different date formats // Extract date components from different date formats
function getDateComponents(icsDateStr) { function getDateComponents(icsDateStr) {
if (!icsDateStr) return null; if (!icsDateStr) return null;
// Basic handling - extract YYYY, MM, DD regardless of format // Basic handling - extract YYYY, MM, DD regardless of format
const year = parseInt(icsDateStr.substring(0, 4)); const year = parseInt(icsDateStr.substring(0, 4));
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months
const day = parseInt(icsDateStr.substring(6, 8)); const day = parseInt(icsDateStr.substring(6, 8));
return { year, month, day }; return { year, month, day };
} }
function expandRecurringEvents(event, year, month) { function expandRecurringEvents(event, year, month) {
if (!event.rrule) return [event]; if (!event.rrule) return [event];
const rruleStr = event.rrule; const rruleStr = event.rrule;
// Get start date components // Get start date components
const startComponents = getDateComponents(event.start); const startComponents = getDateComponents(event.start);
if (!startComponents) return [event]; if (!startComponents) return [event];
const startDate = new Date( const startDate = new Date(
startComponents.year, startComponents.year,
startComponents.month, startComponents.month,
startComponents.day startComponents.day
); );
const rangeStart = new Date(year, month, 1); const rangeStart = new Date(year, month, 1);
const rangeEnd = new Date(year, month + 1, 0); const rangeEnd = new Date(year, month + 1, 0);
const expandedEvents = []; const expandedEvents = [];
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) { if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/); const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
if (bydayMatch) { if (bydayMatch) {
@ -103,7 +103,7 @@ document.addEventListener('DOMContentLoaded', function() {
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0 'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
}; };
const targetDay = dayMap[dayCode]; const targetDay = dayMap[dayCode];
if (targetDay !== undefined) { if (targetDay !== undefined) {
// Create events for each matching day in the month // Create events for each matching day in the month
let day = 1; let day = 1;
@ -112,13 +112,13 @@ document.addEventListener('DOMContentLoaded', function() {
if (testDate.getDay() === targetDay && testDate >= startDate) { if (testDate.getDay() === targetDay && testDate >= startDate) {
const newEvent = {...event}; const newEvent = {...event};
const eventDate = formatDateForICS(testDate); const eventDate = formatDateForICS(testDate);
// Preserve time portion from original event // 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')) : ''; 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')) : ''; event.end.substring(event.end.indexOf('T')) : '';
newEvent.start = eventDate + timePart; newEvent.start = eventDate + timePart;
newEvent.end = eventDate + endTimePart; newEvent.end = eventDate + endTimePart;
expandedEvents.push(newEvent); expandedEvents.push(newEvent);
@ -141,15 +141,15 @@ document.addEventListener('DOMContentLoaded', function() {
const dayMap = { const dayMap = {
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0 'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
}; };
bydays.forEach(byday => { bydays.forEach(byday => {
const occurrence = parseInt(byday) || 1; const occurrence = parseInt(byday) || 1;
const dayCode = byday.slice(-2); const dayCode = byday.slice(-2);
const dayIndex = dayMap[dayCode]; const dayIndex = dayMap[dayCode];
let day = 1; let day = 1;
let count = 0; let count = 0;
while (day <= rangeEnd.getDate()) { while (day <= rangeEnd.getDate()) {
const testDate = new Date(year, month, day); const testDate = new Date(year, month, day);
if (testDate.getDay() === dayIndex) { if (testDate.getDay() === dayIndex) {
@ -157,13 +157,13 @@ document.addEventListener('DOMContentLoaded', function() {
if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) { if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) {
const newEvent = {...event}; const newEvent = {...event};
const eventDate = new Date(year, month, day); const eventDate = new Date(year, month, day);
// Preserve time portion from original event // 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')) : ''; 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')) : ''; event.end.substring(event.end.indexOf('T')) : '';
newEvent.start = formatDateForICS(eventDate) + timePart; newEvent.start = formatDateForICS(eventDate) + timePart;
newEvent.end = formatDateForICS(eventDate) + endTimePart; newEvent.end = formatDateForICS(eventDate) + endTimePart;
expandedEvents.push(newEvent); expandedEvents.push(newEvent);
@ -174,7 +174,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
} }
return expandedEvents.length > 0 ? expandedEvents : [event]; return expandedEvents.length > 0 ? expandedEvents : [event];
} }
@ -206,7 +206,7 @@ document.addEventListener('DOMContentLoaded', function() {
function updateEventsForMonth(year, month) { function updateEventsForMonth(year, month) {
// Clear existing events for this month view // Clear existing events for this month view
eventsByDate = {}; eventsByDate = {};
// Process each event, expanding recurring ones // Process each event, expanding recurring ones
events.forEach(ev => { events.forEach(ev => {
if (ev.rrule) { if (ev.rrule) {
@ -228,7 +228,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Check if this event belongs to current month view // Check if this event belongs to current month view
const eventYear = parseInt(dateKey.split('-')[0]); const eventYear = parseInt(dateKey.split('-')[0]);
const eventMonth = parseInt(dateKey.split('-')[1]) - 1; const eventMonth = parseInt(dateKey.split('-')[1]) - 1;
if (eventYear === year && eventMonth === month) { if (eventYear === year && eventMonth === month) {
if (!eventsByDate[dateKey]) { if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = []; eventsByDate[dateKey] = [];
@ -238,7 +238,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
}); });
renderCalendar(year, month); renderCalendar(year, month);
} }
@ -267,16 +267,16 @@ document.addEventListener('DOMContentLoaded', function() {
} }
let cell = document.createElement("td"); let cell = document.createElement("td");
cell.innerHTML = "<strong>" + day + "</strong>"; cell.innerHTML = "<strong>" + day + "</strong>";
let dayStr = day < 10 ? "0" + day : day; let dayStr = day < 10 ? "0" + day : day;
let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1); let monthStr = (month + 1) < 10 ? "0" + (month + 1) : (month + 1);
let dateKey = year + "-" + monthStr + "-" + dayStr; let dateKey = year + "-" + monthStr + "-" + dayStr;
if (eventsByDate[dateKey]) { if (eventsByDate[dateKey]) {
let dotsContainer = document.createElement("div"); let dotsContainer = document.createElement("div");
dotsContainer.className = "event-dots-container"; dotsContainer.className = "event-dots-container";
cell.classList.add("has-event"); cell.classList.add("has-event");
// Gruppe Events nach Typ // Gruppe Events nach Typ
const events = eventsByDate[dateKey]; const events = eventsByDate[dateKey];
const hasMembersOnly = events.some(e => e.summary.toLowerCase().includes("members only")); 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 hasSpieleabend = events.some(e => e.summary.toLowerCase().includes("spieleabend"));
const hasRegular = events.some(e => { const hasRegular = events.some(e => {
const title = e.summary.toLowerCase(); const title = e.summary.toLowerCase();
return !title.includes("members only") && return !title.includes("members only") &&
!title.includes("subbotnik") && !title.includes("subbotnik") &&
!title.includes("bastelabend") && !title.includes("bastelabend") &&
!title.includes("spieleabend"); !title.includes("spieleabend");
}); });
// Füge Dots entsprechend der Event-Typen hinzu // Füge Dots entsprechend der Event-Typen hinzu
if (hasMembersOnly) { if (hasMembersOnly) {
let dot = document.createElement("div"); let dot = document.createElement("div");
@ -307,7 +307,7 @@ document.addEventListener('DOMContentLoaded', function() {
dot.className = "event-dot event-dot-greenyellow"; dot.className = "event-dot event-dot-greenyellow";
dotsContainer.appendChild(dot); dotsContainer.appendChild(dot);
} }
cell.appendChild(dotsContainer); cell.appendChild(dotsContainer);
cell.dataset.dateKey = dateKey; cell.dataset.dateKey = dateKey;
cell.addEventListener("click", function() { cell.addEventListener("click", function() {
@ -336,8 +336,8 @@ document.addEventListener('DOMContentLoaded', function() {
if (match && match[1]) { if (match && match[1]) {
return `https://berlin.ccc.de/datengarten/${match[1]}/`; return `https://berlin.ccc.de/datengarten/${match[1]}/`;
} }
} }
// For other titles, convert to lowercase and use as path // For other titles, convert to lowercase and use as path
const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, ''); const slug = eventTitle.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
return `https://berlin.ccc.de/page/${slug}/`; return `https://berlin.ccc.de/page/${slug}/`;
@ -355,13 +355,13 @@ document.addEventListener('DOMContentLoaded', function() {
let eventTitle = document.createElement("div"); let eventTitle = document.createElement("div");
eventTitle.className = "event-title"; eventTitle.className = "event-title";
// Create a link for the event title // Create a link for the event title
let titleLink = document.createElement("h"); let titleLink = document.createElement("h");
titleLink.textContent = ev.summary; titleLink.textContent = ev.summary;
titleLink.target = "_blank"; titleLink.target = "_blank";
eventTitle.appendChild(titleLink); eventTitle.appendChild(titleLink);
eventItem.appendChild(eventTitle); eventItem.appendChild(eventTitle);
let eventTime = document.createElement("div"); let eventTime = document.createElement("div");
@ -372,7 +372,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (ev.description) { if (ev.description) {
let eventDescription = document.createElement("div"); let eventDescription = document.createElement("div");
eventDescription.className = "event-description"; eventDescription.className = "event-description";
// Check if the description is a URL and make it a clickable link // Check if the description is a URL and make it a clickable link
if (ev.description.trim().startsWith('http')) { if (ev.description.trim().startsWith('http')) {
let linkElement = document.createElement("a"); let linkElement = document.createElement("a");
@ -384,7 +384,7 @@ document.addEventListener('DOMContentLoaded', function() {
} else { } else {
eventDescription.textContent = ev.description; eventDescription.textContent = ev.description;
} }
eventItem.appendChild(eventDescription); eventItem.appendChild(eventDescription);
} }
@ -409,7 +409,7 @@ document.addEventListener('DOMContentLoaded', function() {
function formatTime(icsTimeStr) { function formatTime(icsTimeStr) {
// Format time for display // Format time for display
if (!icsTimeStr) return ""; if (!icsTimeStr) return "";
if (icsTimeStr.length === 8) { if (icsTimeStr.length === 8) {
// All-day event // All-day event
return "Ganztägig"; return "Ganztägig";
@ -431,18 +431,20 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// ICS-Datei abrufen und Events verarbeiten // ICS-Datei abrufen und Events verarbeiten
fetch('/calendars/all.ics') fetch('/all.ics')
.then(response => response.text()) .then(response => response.text())
.then(data => { .then(data => {
events = parseICS(data); events = parseICS(data);
// Initialize with current date // Initialize with current date
let today = new Date(); let today = new Date();
currentYear = today.getFullYear(); currentYear = today.getFullYear();
currentMonth = today.getMonth(); currentMonth = today.getMonth();
// Process events for current month // Process events for current month
updateEventsForMonth(currentYear, currentMonth);
updateEventsForMonth(currentYear, currentMonth);
}) })
.catch(err => console.error('Fehler beim Laden der ICS-Datei:', err)); .catch(err => console.error('Fehler beim Laden der ICS-Datei:', err));
})(); })();

View file

@ -9,11 +9,11 @@ function timeDistanceDE(pastDate) {
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24) 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 (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 (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` if (minutes > 1) return `since ${minutes}min`
return `gerade eben` return `gerade eben`
} }

View file

@ -1,7 +1,12 @@
#!/bin/sh #!/bin/sh
set -ex set -e
set -x
rm -rf public/ # delete old generated files rm -rf public/ # delete old generated files
hugo $(cat .hugo-params) 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

View file

@ -28,7 +28,7 @@ filename = "sitemap.xml"
priority = 0.5 priority = 0.5
[permalinks] [permalinks]
post = "/post/:year/:month/:day/:slug/" post = "/post/:year/:month/:day/:title/"
[outputs] [outputs]
home = ["html", "rss", "json"] home = ["html", "rss", "json"]

View file

@ -25,9 +25,6 @@ description: "Startseite CCCB mit Kurzkalender"
### Nächste Veranstaltungen ### Nächste Veranstaltungen
<table id="upcoming" class="table table-condensed"> CALENDAR
</table>
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/). Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).
<script type="module" src="/js/upcoming.js"></script>

View file

@ -1,6 +1,5 @@
--- ---
title: "Der Neujahresempfang 2026 #NJE26" title: "Der Neujahresempfang 2026 #NJE26"
slug: "neujahresempfang-2026"
date: 2026-03-05T12:00:00+01:00 date: 2026-03-05T12:00:00+01:00
showHero: false showHero: false
tags: ["NJE", "Ankündigung"] tags: ["NJE", "Ankündigung"]

View file

@ -10,4 +10,4 @@ menu:
tag: ["Veranstaltung"] 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/) 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/)

View file

@ -12,15 +12,4 @@ herostyle: big
{{< calendar >}} {{< calendar >}}
Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/calendars/all.ics)! Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/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)

8
flake.lock generated
View file

@ -2,16 +2,16 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1786313170, "lastModified": 1748995628,
"narHash": "sha256-9BG7OgUWdu0ONDO5X2q6+K4bsuBITkX/3W4nNJu1Ito=", "narHash": "sha256-bFufQGSAEYQgjtc4wMrobS5HWN0hDP+ZX+zthYcml9U=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "fcb8fcd6bf2d0adecae5bd491afaaaf8311b758d", "rev": "8eb3b6a2366a7095939cd22f0dc0e9991313294b",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nixos",
"ref": "nixos-26.05", "ref": "nixos-24.11",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"
} }

View file

@ -2,7 +2,7 @@
description = "A flake containing a development environment for the CCCB website."; description = "A flake containing a development environment for the CCCB website.";
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05"; nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
}; };
outputs = outputs =
@ -34,7 +34,7 @@
devShells = forAllSystems (import ./devShells.nix); devShells = forAllSystems (import ./devShells.nix);
formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree); formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-rfc-style);
lib.mkWwwContent = lib.mkWwwContent =
{ domain, system }: { domain, system }:
@ -50,6 +50,10 @@
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.hugo pkgs.hugo
pkgs.glibcLocales 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"; # LOCALE_ARCHIVE = builtins.trace pkgs.glibcLocales "${pkgs.glibcLocales}/lib/locale/locale-archive";
@ -58,6 +62,11 @@
buildPhase = '' buildPhase = ''
mkdir -p public mkdir -p public
hugo --baseURL=https://${domain}/ 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 # Install phase - copy the public directory to the output

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
icalendar==5.0.7
pytz

View file

@ -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);
});
});
});

108
tools/gen_upcoming.py Executable file
View file

@ -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("<table class=\"table table-condensed\">")
for event in events:
print(
"<tr>"
f"<td>{event['begin'].strftime('%A, %d.%m um %H:%M Uhr')}</td>"
f"<td><a href=\"{event['url'].decode('utf-8')}\">{event['name'].decode('utf-8')}</a></td>"
"</tr>"
)
print("</table><!--/.table .table-condensed-->")
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)

31
tools/merge_cals.py Executable file
View file

@ -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())