forked from cccb-website-team/www
switch to ics files; make calendars work; fix some minor issues
This commit is contained in:
parent
5368e75240
commit
c28f04c6e8
19 changed files with 318 additions and 259 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
|||
*.ics text eol=crlf
|
||||
135
.gitignore
vendored
135
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
3.11
|
||||
28
README.md
28
README.md
|
|
@ -27,24 +27,20 @@ This is the website of the CCCB.
|
|||
|
||||
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 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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "<strong>" + day + "</strong>";
|
||||
|
||||
|
||||
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));
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
|
|
|||
7
build.sh
7
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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ description: "Startseite CCCB mit Kurzkalender"
|
|||
|
||||
### Nächste Veranstaltungen
|
||||
|
||||
CALENDAR
|
||||
<table id="upcoming" class="table table-condensed">
|
||||
</table>
|
||||
|
||||
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).
|
||||
|
||||
<script type="module" src="/js/upcoming.js"></script>
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
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/)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
8
flake.lock
generated
8
flake.lock
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
13
flake.nix
13
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
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
icalendar==5.0.7
|
||||
pytz
|
||||
113
static/js/upcoming.js
Normal file
113
static/js/upcoming.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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("<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)
|
||||
|
||||
|
|
@ -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())
|
||||
|
||||
Loading…
Reference in a new issue