Compare commits

..
37 changed files with 744 additions and 10958 deletions

View file

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

View file

@ -1,10 +1,4 @@
name: deploy blog
# Create secrets with:
# KNOWN_HOST=$(ssh-keyscan -H www.berlin.ccc.de | grep ssh-ed25519 | base64 -w0)
# SSH_PRIVATE_KEY_PRODUCTION=$(agenix -d id_ed25519_www-production.age | base64 -w0)
# SSH_PRIVATE_KEY_STAGING=$(agenix -d id_ed25519_www-staging.age | base64 -w0)
on:
workflow_dispatch:
schedule:
@ -16,44 +10,46 @@ on:
jobs:
deploy:
runs-on: alpine-latest
steps:
runs-on: ubuntu-latest
steps:
- name: Install dependencies
run: apk --no-cache add hugo git openssh-client rsync
run: apk --no-cache add hugo python3 py3-pip git openssh-client rsync
- name: Check versions
run: |
cat /etc/os-release
git version
hugo version
rsync --version
ssh -V
- name: Set envionment vars
run: echo "GIT_BRANCH=${{ forgejo.event_name == 'schedule' && 'production' || forgejo.ref_name }}" >> "$FORGEJO_ENV"
python --version
- name: Checkout repository
run: |
git clone -b $GIT_BRANCH --recursive https://git.berlin.ccc.de/cccb-website-team/www.git .
git clone -b ${{ forgejo.ref_name }} --recursive https://git.berlin.ccc.de/cccb-website-team/www.git .
git status
- name: Install Python depenndencies
run: python -m pip install -r requirements.txt --break-system-packages
- name: Render site
run: ./build.sh
- name: Setup SSH
env:
SSH_PRIVATE_KEY: ${{ forgejo.ref_name == 'production' && secrets.SSH_PRIVATE_KEY_PRODUCTION || secrets.SSH_PRIVATE_KEY_STAGING }}
run: |
mkdir -p ~/.ssh
printf "%s" "${{ secrets.KNOWN_HOSTS }}" | base64 -d > ~/.ssh/known_hosts
printf "%s" "${{ env.GIT_BRANCH == 'production' && secrets.SSH_PRIVATE_KEY_PRODUCTION || secrets.SSH_PRIVATE_KEY_STAGING }}" | base64 -d > ~/.ssh/id_ed25519
printf "%s" "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keygen -f ~/.ssh/id_ed25519 -y > ~/.ssh/id_ed25519.pub
cat ~/.ssh/id_ed25519.pub
- name: Rsync rendered site
env:
DEPLOY_DIR: ${{ forgejo.ref_name == 'production' && '/srv/http/www/' || '/srv/http/www-staging/' }}
# TODO: add --delete
run: rsync -var -e 'ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes' ./public/ deploy@www.berlin.ccc.de:${{ env.GIT_BRANCH == 'production' && '/srv/http/www/' || '/srv/http/www-staging/' }}
run: rsync -var -e 'ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes' ./public/ deploy@www.berlin.ccc.de:$DEPLOY_DIR
- name: Cleanup
if: ${{ always() }}
run: rm -rf ~/.ssh

1
.gitattributes vendored Normal file
View file

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

138
.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
# 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,9 +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
### CCCB ###
# The calendar the site reads its events from is published next to the site and
# only downloaded here to look at the site locally, see the README.
/static/calendars/
# 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,36 +27,26 @@ 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 and the calendar under `/verein/calendar/` are rendered in the
browser by `assets/js/upcoming.js` and `assets/js/calendar.js`. Both fetch `/calendars/all.ics`, which is **not**
generated by Hugo and is not part of this repo — it is published separately on the web server. Without it both tables
stay empty. Download the published calendar into `static/`, which Hugo serves under the same path, and they work
locally, under `hugo serve` as well as in a built site:
```shell
mkdir -p static/calendars
curl -o static/calendars/all.ics https://berlin.ccc.de/calendars/all.ics
```
The file is only there to look at the site, it is not checked in. Download it again whenever you want the events that
are currently published.
Hugo does generate an `.ics` feed per section (for example `/veranstaltungen/index.ics`) from the `dtstart`, `dtend`
and `rrule` front matter of the pages in `content/veranstaltungen/`, using the `.ics` templates under `layouts/`.
The site must not make the browser load anything from an external server, so third party JavaScript is checked into
`assets/js/vendor/` and bundled in by Hugo instead of being pulled from a CDN. See the README there before updating it.
To build 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
```
This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`. To build with *nix* instead:
`nix build '.?submodules=1#production-content'`
`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'`
## Making a change
@ -65,21 +55,18 @@ This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`.
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!
## Nix stuff
- After entering the shell with `nix develop`, hugo is available and `hugo serve` and `./build.sh` should work
- 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)
- 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)
---
Made with ❤️ and [Hugo](https://gohugo.io).

View file

@ -107,10 +107,6 @@
font-size: 0.9em;
margin-bottom: 5px;
}
.event-description {
/* Descriptions carry their own line breaks, keep them. */
white-space: pre-line;
}
.no-events {
font-style: italic;
color: var(--color-text-secondary);

View file

@ -1,36 +0,0 @@
/* The rows of the table are added by JavaScript and end up as direct children
of the table, so the table styling of the theme, which addresses tbody, does
not reach them. Separate the date from the name of the event ourselves, and
keep the date on one line, it is one piece of information and reads badly
broken after the weekday. */
#upcoming td:first-child {
padding-inline-end: 1em;
white-space: nowrap;
}
/* The table stands in a prose column, which the theme limits to 65 characters
so that running text stays readable. A date and the name of an event next to
each other do not fit into that, so the names were wrapped over several
lines. Lift the limit off the column and put it back on everything in it
except the table, which leaves the table room to grow while the text around
it keeps its width. */
section.prose:has(> #upcoming) {
max-width: none;
}
section.prose:has(> #upcoming) > :not(#upcoming) {
max-width: 65ch;
margin-inline: auto;
}
/* The theme lays a table out as a block, "table { display: block; overflow:
auto }", so that a wide one can be scrolled sideways. A block fills its
parent instead of shrinking to its content the way a table does, so across
the whole width of the page the entries would stay at its left edge.
fit-content asks for the width of the content, which the automatic margins
then centre, and it never exceeds the column, so a display too narrow for
the table still wraps the names instead of overflowing. */
#upcoming {
width: fit-content;
margin-inline: auto;
}

View file

@ -1,406 +1,451 @@
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
document.addEventListener('DOMContentLoaded', function() {
(function(){
let events = [];
let eventsByDate = {};
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
// no matter which time zone the browser of the visitor is set to.
const timeZone = "Europe/Berlin";
// Funktion zum Parsen der ICS-Datei
function parseICS(icsText) {
let events = [];
let lines = icsText.split(/\r?\n/);
let event = null;
lines.forEach(line => {
if (line.startsWith("BEGIN:VEVENT")) {
event = {};
} else if (line.startsWith("END:VEVENT")) {
if (event) events.push(event);
event = null;
} else if (event) {
let colonIndex = line.indexOf(":");
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;
} else if (baseKey === "DTEND") {
event.end = value;
event.endParams = key.includes(";") ? key.substring(key.indexOf(";") + 1) : null;
} else if (baseKey === "SUMMARY") {
event.summary = value;
} else if (baseKey === "DESCRIPTION") {
event.description = value;
} else if (baseKey === "RRULE") {
event.rrule = value;
}
}
}
});
return events;
}
const monthNames = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
];
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
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);
let month = icsDateStr.substring(4, 6);
let day = icsDateStr.substring(6, 8);
return `${year}-${month}-${day}`;
}
return null;
}
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// 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 };
}
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
timeZone,
hour: "2-digit",
minute: "2-digit",
});
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) {
const dayCode = bydayMatch[1];
const dayMap = {
'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;
while (day <= rangeEnd.getDate()) {
const testDate = new Date(year, month, day);
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') ?
event.start.substring(event.start.indexOf('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);
}
day++;
}
}
}
}
else if (rruleStr.includes("FREQ=MONTHLY") && rruleStr.includes("BYDAY")) {
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
if (bydayMatch) {
const intervalMatch = rruleStr.match(/INTERVAL=(\d+)/);
const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1;
const monthsFromStart = (year - startDate.getFullYear()) * 12 + (month - startDate.getMonth());
if (monthsFromStart < 0 || monthsFromStart % interval !== 0) {
return [];
}
const bydays = bydayMatch[1].split(',');
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) {
count++;
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') ?
event.start.substring(event.start.indexOf('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);
}
}
day++;
}
});
}
}
return expandedEvents.length > 0 ? expandedEvents : [event];
}
let calendar = null;
let eventsByDate = {};
let currentYear;
let currentMonth;
// Kalender initialisieren
let currentYear, currentMonth;
const currentMonthElem = document.getElementById("current-month");
const calendarBody = document.getElementById("calendar-body");
const eventPanel = document.getElementById("event-panel");
const eventDateElem = document.getElementById("event-date");
const eventDetailsElem = document.getElementById("event-details");
let currentMonthElem;
let calendarBody;
let eventPanel;
let eventDateElem;
let eventDetailsElem;
/**
* The day a point in time falls on in Berlin.
*
* @param {Date} date The point in time
* @returns {string} The day as "YYYY-MM-DD"
*/
function dayKey(date) {
const parts = {};
for (const part of dayKeyFormat.formatToParts(date)) {
parts[part.type] = part.value;
}
return `${parts.year}-${parts.month}-${parts.day}`;
}
/**
* The day an occurrence time falls on in Berlin.
*
* A date has neither a time nor a zone, its digits are the day itself.
* toJSDate() would read them as midnight in the zone of the browser, which
* far enough east or west of Berlin lands on the day before or after.
*
* @param {ICAL.Time} time The time
* @returns {string} The day as "YYYY-MM-DD"
*/
function timeDayKey(time) {
if (time.isDate) {
const month = String(time.month).padStart(2, "0");
const day = String(time.day).padStart(2, "0");
return `${time.year}-${month}-${day}`;
}
return dayKey(time.toJSDate());
}
/**
* The days an event covers, so that an event running over several days is
* shown on each of them and not only on the day it starts.
*
* @param {ICAL.Time} start Start of the event
* @param {ICAL.Time} end End of the event
* @returns {string[]} The days as "YYYY-MM-DD"
*/
function daysCovered(start, end) {
// The end is not part of the event: one ending at midnight belongs to the day
// before, and an all day event ends on the day before its DTEND.
const last = end.clone();
if (end.isDate) {
last.adjust(-1, 0, 0, 0);
} else {
last.adjust(0, 0, 0, -1);
}
const lastKey = timeDayKey(last);
const days = [];
let key = timeDayKey(start);
// The guard keeps a broken event from looping forever, a year of dots on the
// same event is well past the point where the calendar is still useful.
while (days.length <= 366) {
days.push(key);
// The keys sort as the days do, so this also stops an event whose end lies
// before its start after the day it starts on.
if (key >= lastKey) {
break;
}
// Step to noon UTC of the next day, which is inside the same Berlin day
// whether daylight saving time is in effect or not.
const [year, month, day] = key.split("-").map(Number);
key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
}
return days;
}
/**
* Reduce one occurrence of an event to what the calendar displays.
*
* @param {ICAL.Event} event The event the occurrence belongs to
* @param {ICAL.Time} startDate Start of this occurrence
* @param {ICAL.Time} endDate End of this occurrence
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean, days: string[]}}
*/
function toOccurrence(event, startDate, endDate) {
return {
summary: event.summary ?? "",
description: event.description ?? "",
url: eventUrl(event),
start: startDate.toJSDate(),
end: endDate.toJSDate(),
allDay: startDate.isDate,
// Taken from the ICAL times, which still know whether they name a day or a
// point in time; the JS dates above no longer do.
days: daysCovered(startDate, endDate),
};
}
/**
* Collect everything that takes place in the given month, keyed by day.
*
* @param {number} year The year of the month
* @param {number} month The month, January is 0
* @returns {Object<string, Array>} The occurrences per "YYYY-MM-DD"
*/
function occurrencesOfMonth(year, month) {
const byDate = {};
if (!calendar) {
return byDate;
}
// These bounds only limit how far the recurrences have to be expanded, which
// day an occurrence ends up on is decided by its Berlin day below. They are
// deliberately generous so that no occurrence is cut off at the edge of the
// month by a time zone difference.
const from = new Date(year, month, 1);
const to = new Date(year, month + 1, 1);
from.setDate(from.getDate() - 2);
to.setDate(to.getDate() + 2);
const monthPrefix = `${year}-${String(month + 1).padStart(2, "0")}-`;
const add = (occurrence) => {
for (const key of occurrence.days) {
if (!key.startsWith(monthPrefix)) {
continue;
}
if (!byDate[key]) {
byDate[key] = [];
}
byDate[key].push(occurrence);
}
};
for (const { event, startDate, endDate } of occurrencesBetween(calendar, from, to)) {
add(toOccurrence(event, startDate, endDate));
}
for (const occurrences of Object.values(byDate)) {
occurrences.sort((a, b) => {
// An all day event has no time of day to sort by, the JS date of its
// start is midnight in the zone of the browser. Put it first instead.
if (a.allDay !== b.allDay) {
return a.allDay ? -1 : 1;
}
return a.start - b.start;
});
}
return byDate;
}
function updateEventsForMonth(year, month) {
eventsByDate = occurrencesOfMonth(year, month);
renderCalendar(year, month);
}
function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch)
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
let firstDay = new Date(year, month, 1);
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
let daysInMonth = new Date(year, month + 1, 0).getDate();
let row = document.createElement("tr");
// Leere Zellen vor dem 1. Tag
for (let i = 0; i < firstDayIndex; i++) {
let cell = document.createElement("td");
row.appendChild(cell);
}
// Tage hinzufügen
for (let day = 1; day <= daysInMonth; day++) {
if (row.children.length === 7) {
calendarBody.appendChild(row);
row = document.createElement("tr");
}
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"));
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
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") &&
!title.includes("spieleabend");
});
// Füge Dots entsprechend der Event-Typen hinzu
if (hasMembersOnly) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-red";
dotsContainer.appendChild(dot);
}
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-orange";
dotsContainer.appendChild(dot);
}
if (hasRegular) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-greenyellow";
dotsContainer.appendChild(dot);
}
cell.appendChild(dotsContainer);
cell.dataset.dateKey = dateKey;
cell.addEventListener("click", function() {
// Clear previous selections
document.querySelectorAll('.selected-day').forEach(el => {
el.classList.remove('selected-day');
document.getElementById("prev-month").addEventListener("click", function(){
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
});
document.getElementById("next-month").addEventListener("click", function(){
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
updateEventsForMonth(currentYear, currentMonth);
});
cell.classList.add('selected-day');
showEventDetails(dateKey);
});
}
row.appendChild(cell);
}
// Falls die letzte Zeile nicht komplett ist
while (row.children.length < 7) {
let cell = document.createElement("td");
row.appendChild(cell);
}
calendarBody.appendChild(row);
}
/**
* Build the entry of a single event in the detail panel.
*
* @param {Object} occurrence The occurrence to show
* @returns {HTMLElement} The entry
*/
function renderEventItem(occurrence) {
const item = document.createElement("div");
item.className = "event-item";
function updateEventsForMonth(year, month) {
// Clear existing events for this month view
eventsByDate = {};
// Process each event, expanding recurring ones
events.forEach(ev => {
if (ev.rrule) {
// For recurring events, expand them for current month
const expandedEvents = expandRecurringEvents(ev, year, month);
expandedEvents.forEach(expandedEv => {
let dateKey = parseDateString(expandedEv.start);
if (dateKey) {
if (!eventsByDate[dateKey]) {
eventsByDate[dateKey] = [];
}
eventsByDate[dateKey].push(expandedEv);
}
});
} else {
// For regular events, check if they fall in current month
let dateKey = parseDateString(ev.start);
if (dateKey) {
// 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] = [];
}
eventsByDate[dateKey].push(ev);
}
}
}
});
renderCalendar(year, month);
}
const title = document.createElement("div");
title.className = "event-title";
function renderCalendar(year, month) {
// Setze die Monatsbeschriftung (in Deutsch)
const monthNames = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
currentMonthElem.textContent = monthNames[month] + " " + year;
calendarBody.innerHTML = "";
if (occurrence.url) {
const link = document.createElement("a");
link.href = occurrence.url;
link.textContent = occurrence.summary;
title.appendChild(link);
} else {
title.textContent = occurrence.summary;
}
let firstDay = new Date(year, month, 1);
let firstDayIndex = (firstDay.getDay() + 6) % 7; // Montag = 0, Dienstag = 1, etc.
let daysInMonth = new Date(year, month + 1, 0).getDate();
item.appendChild(title);
let row = document.createElement("tr");
// Leere Zellen vor dem 1. Tag
for (let i = 0; i < firstDayIndex; i++){
let cell = document.createElement("td");
row.appendChild(cell);
}
const time = document.createElement("div");
time.className = "event-time";
time.textContent = formatTimeRange(occurrence);
item.appendChild(time);
// Tage hinzufügen
for (let day = 1; day <= daysInMonth; day++){
if (row.children.length === 7) {
calendarBody.appendChild(row);
row = document.createElement("tr");
}
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"));
const hasSubbotnik = events.some(e => e.summary.toLowerCase().includes("subbotnik"));
const hasBastelabend = events.some(e => e.summary.toLowerCase().includes("bastelabend"));
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") &&
!title.includes("spieleabend");
});
// Füge Dots entsprechend der Event-Typen hinzu
if (hasMembersOnly) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-red";
dotsContainer.appendChild(dot);
}
if (hasSubbotnik || hasBastelabend || hasSpieleabend) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-orange";
dotsContainer.appendChild(dot);
}
if (hasRegular) {
let dot = document.createElement("div");
dot.className = "event-dot event-dot-greenyellow";
dotsContainer.appendChild(dot);
}
cell.appendChild(dotsContainer);
cell.dataset.dateKey = dateKey;
cell.addEventListener("click", function() {
// Clear previous selections
document.querySelectorAll('.selected-day').forEach(el => {
el.classList.remove('selected-day');
});
cell.classList.add('selected-day');
showEventDetails(dateKey);
});
}
row.appendChild(cell);
}
// Falls die letzte Zeile nicht komplett ist
while (row.children.length < 7) {
let cell = document.createElement("td");
row.appendChild(cell);
}
calendarBody.appendChild(row);
}
if (occurrence.description) {
const description = document.createElement("div");
description.className = "event-description";
description.textContent = occurrence.description;
item.appendChild(description);
}
function createEventLink(eventTitle) {
if (eventTitle.startsWith("Datengarten")) {
// Extract the number after "Datengarten "
const match = eventTitle.match(/Datengarten\s+(\d+)/i);
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}/`;
}
return item;
}
function showEventDetails(dateKey) {
const events = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = "";
function showEventDetails(dateKey) {
const occurrences = eventsByDate[dateKey];
eventDateElem.textContent = formatDate(dateKey);
eventDetailsElem.innerHTML = "";
if (events && events.length > 0) {
events.forEach(ev => {
let eventItem = document.createElement("div");
eventItem.className = "event-item";
if (occurrences && occurrences.length > 0) {
for (const occurrence of occurrences) {
eventDetailsElem.appendChild(renderEventItem(occurrence));
}
} else {
let noEvents = document.createElement("div");
noEvents.className = "no-events";
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
eventDetailsElem.appendChild(noEvents);
}
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);
eventPanel.style.display = "block";
}
let eventTime = document.createElement("div");
eventTime.className = "event-time";
eventTime.textContent = `Start: ${formatTime(ev.start)}, End: ${formatTime(ev.end)}`;
eventItem.appendChild(eventTime);
function formatDate(dateStr) {
// Convert YYYY-MM-DD to DD.MM.YYYY
const parts = dateStr.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
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");
linkElement.href = ev.description.trim();
linkElement.textContent = ev.description.trim();
linkElement.target = "_blank";
eventDescription.innerHTML = '';
eventDescription.appendChild(linkElement);
} else {
eventDescription.textContent = ev.description;
}
eventItem.appendChild(eventDescription);
}
/**
* Describe when an occurrence takes place, in Berlin time.
*
* @param {Object} occurrence The occurrence to describe
* @returns {string} The description
*/
function formatTimeRange(occurrence) {
if (occurrence.allDay) {
return "Ganztägig";
}
eventDetailsElem.appendChild(eventItem);
});
} else {
let noEvents = document.createElement("div");
noEvents.className = "no-events";
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
eventDetailsElem.appendChild(noEvents);
}
const start = timeOfDayFormat.format(occurrence.start);
const end = timeOfDayFormat.format(occurrence.end);
eventPanel.style.display = "block";
}
return `Beginn: ${start}, Ende: ${end}`;
}
function formatDate(dateStr) {
// Convert YYYY-MM-DD to DD.MM.YYYY
const parts = dateStr.split("-");
return `${parts[2]}.${parts[1]}.${parts[0]}`;
}
document.addEventListener("DOMContentLoaded", function() {
currentMonthElem = document.getElementById("current-month");
calendarBody = document.getElementById("calendar-body");
eventPanel = document.getElementById("event-panel");
eventDateElem = document.getElementById("event-date");
eventDetailsElem = document.getElementById("event-details");
function formatTime(icsTimeStr) {
// Format time for display
if (!icsTimeStr) return "";
if (icsTimeStr.length === 8) {
// All-day event
return "Ganztägig";
} else if (icsTimeStr.includes("T")) {
// Time-specific event (with or without timezone)
const timeStart = icsTimeStr.indexOf("T") + 1;
const hour = icsTimeStr.substring(timeStart, timeStart + 2);
const minute = icsTimeStr.substring(timeStart + 2, timeStart + 4);
return `${hour}:${minute}`;
}
return icsTimeStr;
}
document.getElementById("prev-month").addEventListener("click", function() {
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
});
document.getElementById("next-month").addEventListener("click", function() {
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
updateEventsForMonth(currentYear, currentMonth);
});
function formatDateForICS(date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}${month}${day}`;
}
// Show the grid of the current month right away, the events are filled in
// once the calendar has been loaded.
const today = new Date();
currentYear = today.getFullYear();
currentMonth = today.getMonth();
updateEventsForMonth(currentYear, currentMonth);
// ICS-Datei abrufen und Events verarbeiten
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
loadCalendar()
.then(loaded => {
calendar = loaded;
updateEventsForMonth(currentYear, currentMonth);
})
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
updateEventsForMonth(currentYear, currentMonth);
})
.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 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`
}

View file

@ -1,194 +0,0 @@
import ICAL from "./vendor/ical.js";
// The calendar is published next to the site and is not built from this
// repository, see the README.
const icsUrl = "/calendars/all.ics";
/**
* Read the URL of an event.
*
* ICAL.Event does not expose the URL property, so read it from the component.
*
* The value ends up in the href of a link, and the calendar is exported from a
* CalDAV server, so whoever may write to it decides what that value is. A
* "javascript:" URL there would run on our page as soon as a visitor clicks
* the event, so hand on nothing but http and https.
*
* @param {ICAL.Event} event The event to read the URL of
* @returns {string} The URL, empty when the event has none or it is not http(s)
*/
export function eventUrl(event) {
const url = event.component.getFirstPropertyValue("url") ?? "";
if (!url) {
return "";
}
try {
// A relative URL is resolved against the page and keeps its scheme.
const { protocol } = new URL(url, document.baseURI);
return protocol === "http:" || protocol === "https:" ? url : "";
} catch {
// Not a URL at all.
return "";
}
}
/**
* Group the occurrences that were modified on their own by the UID of the event
* they belong to.
*
* Unless it is told which exceptions belong to an event, ICAL.Event relates
* every VEVENT with a RECURRENCE-ID in the file to every recurring event, and
* it keys them by the recurrence id alone. Two series that meet at the same
* time would therefore take over each other's modifications.
*
* @param {ICAL.Component[]} components The VEVENTs of the calendar
* @returns {Map<string, ICAL.Component[]>} The exceptions per UID
*/
function exceptionsByUid(components) {
const exceptions = new Map();
for (const component of components) {
if (!component.hasProperty("recurrence-id")) {
continue;
}
const uid = component.getFirstPropertyValue("uid");
const ofEvent = exceptions.get(uid);
if (ofEvent) {
ofEvent.push(component);
} else {
exceptions.set(uid, [component]);
}
}
return exceptions;
}
/**
* How far the recurrences of an event have to be iterated.
*
* The iteration walks the unmodified recurrence times, so an occurrence that
* was moved to an earlier time is only reached through the time it originally
* had, which can lie past the end of the window. Keep going for as long as the
* largest move towards the past can still carry an occurrence into it.
*
* @param {ICAL.Event} event The event whose recurrences are iterated
* @param {Date} to End of the window
* @returns {Date} The recurrence time to stop at
*/
function iterationEnd(event, to) {
let last = to.getTime();
for (const exception of Object.values(event.exceptions)) {
const movedBy = exception.recurrenceId.toJSDate().getTime()
- exception.startDate.toJSDate().getTime();
if (movedBy > 0) {
last = Math.max(last, to.getTime() + movedBy);
}
}
return new Date(last);
}
/**
* Whether an occurrence touches a window.
*
* @param {ICAL.Time} startDate Start of the occurrence
* @param {ICAL.Time} endDate End of the occurrence
* @param {Date} from Start of the window
* @param {Date} to End of the window
* @returns {boolean} True when the two overlap
*/
function touches(startDate, endDate, from, to) {
return startDate.toJSDate() <= to && endDate.toJSDate() > from;
}
/**
* Load and parse the calendar of the club.
*
* @returns {Promise<ICAL.Component>} The calendar
*/
export async function loadCalendar() {
const response = await fetch(icsUrl);
// Without this an error page would be handed to the parser below, which then
// fails with a confusing complaint about the calendar syntax.
if (!response.ok) {
throw new Error(`${icsUrl}: ${response.status} ${response.statusText}`);
}
return new ICAL.Component(ICAL.parse(await response.text()));
}
/**
* Every occurrence of the calendar that touches the given window.
*
* A recurring event is expanded, and an occurrence that was modified on its own
* is reported with the time, the name and the URL of that modification.
*
* @param {ICAL.Component} calendar The parsed calendar
* @param {Date} from An occurrence has to still be running at this time
* @param {Date} to An occurrence has to have started by this time
* @yields {{event: ICAL.Event, startDate: ICAL.Time, endDate: ICAL.Time}}
*/
export function* occurrencesBetween(calendar, from, to) {
const components = calendar.getAllSubcomponents("vevent");
const exceptions = exceptionsByUid(components);
for (const component of components) {
// Occurrences modified via RECURRENCE-ID are reached through the event they
// belong to, handling them here as well would report them twice.
if (component.hasProperty("recurrence-id")) {
continue;
}
const event = new ICAL.Event(component, {
exceptions: exceptions.get(component.getFirstPropertyValue("uid")) ?? [],
});
if (!event.startDate) {
continue;
}
if (!event.isRecurring()) {
if (touches(event.startDate, event.endDate, from, to)) {
yield { event, startDate: event.startDate, endDate: event.endDate };
}
continue;
}
const iterator = event.iterator();
const iterateUntil = iterationEnd(event, to);
while (true) {
const occurrence = iterator.next();
// Recurrences are chronological, so we are done once one starts after the
// window, and after the occurrences a modification can still move back
// into it.
if (!occurrence || occurrence.toJSDate() > iterateUntil) {
break;
}
// Details resolve time, name and URL of an occurrence that was modified
// via RECURRENCE-ID.
const details = event.getOccurrenceDetails(occurrence);
// A modification may have moved the occurrence out of the window, so
// judge it by the time it really takes place at.
if (touches(details.startDate, details.endDate, from, to)) {
yield {
event: details.item,
startDate: details.startDate,
endDate: details.endDate,
};
}
}
}
}

View file

@ -1,105 +0,0 @@
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
/**
* When an occurrence starts, as a point in time.
*
* A date has no time and no zone, its digits are the day itself. toJSDate()
* reads them as midnight in the zone of the browser, which moves an all day
* event by the offset that zone has to Berlin and, far enough east or west,
* onto the day before or after. Keep the digits and read them as UTC instead,
* the table prints an all day event in UTC as well.
*
* @param {ICAL.Time} time Start of the occurrence
* @returns {Date} The point in time to sort and print by
*/
function startOf(time) {
if (time.isDate) {
return new Date(Date.UTC(time.year, time.month - 1, time.day));
}
return time.toJSDate();
}
/**
* The upcoming occurrences of a calendar.
*
* @param {ICAL.Component} calendar The parsed calendar
* @param {Date} now Events must still be running at this date
* @param {number} maxEvents Maximum number of events to return
* @param {number} maxDays Maximum number of days into the future
* @returns {{start: Date, allDay: boolean, name: string, url: string}[]} url is empty when the event has no URL
*/
function getUpcomingEvents(calendar, now, maxEvents, maxDays) {
const end = new Date(now.getTime());
end.setDate(end.getDate() + maxDays);
const events = [];
// A running event stays listed until it is over, so the window starts at now
// and the walk keeps everything that has not ended yet.
for (const { event, startDate } of occurrencesBetween(calendar, now, end)) {
events.push({
start: startOf(startDate),
allDay: startDate.isDate,
name: event.summary ?? "",
url: eventUrl(event),
});
}
// 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 max_days = 20;
const max_items = 5;
const now = new Date();
const table = document.getElementById("upcoming");
loadCalendar()
.then(calendar => {
getUpcomingEvents(calendar, now, max_items, max_days).forEach(event => {
const row = document.createElement("tr");
const colBegin = document.createElement("td");
// The events take place in Berlin, so name their time in Berlin time
// instead of in the time zone the visitor happens to be in. An all day
// event has no time of day and carries its date in UTC, see startOf().
const whenFormat = event.allDay
? { timeZone: "UTC" }
: { timeZone: "Europe/Berlin", hour: "2-digit", minute: "2-digit" };
const formattedStart = event.start.toLocaleString("de-DE", {
weekday: "long",
day: "2-digit",
month: "2-digit",
...whenFormat,
});
colBegin.innerText = event.allDay ? formattedStart : `${formattedStart} Uhr`;
row.appendChild(colBegin);
const colName = document.createElement("td");
if (event.url) {
const a = document.createElement("a");
a.href = event.url;
a.text = event.name;
colName.appendChild(a);
} else {
colName.innerText = event.name;
}
row.appendChild(colName);
table.appendChild(row);
});
})
.catch(err => console.error("Fehler beim Laden der Termine:", err));
});

View file

@ -1,19 +0,0 @@
# Vendored JavaScript
Third party code is checked in here instead of being loaded from a CDN, so that the website does not make the visitor's
browser fetch anything from an external server.
Hugo bundles and minifies these files into the scripts that reference them, so the unminified source is checked in.
## ical.js
- Version: 2.2.1
- Source: <https://unpkg.com/ical.js@2.2.1/dist/ical.js>
- Upstream: <https://github.com/kewisch/ical.js>
- License: MPL-2.0 (see the header of `ical.js`)
To update, download the `dist/ical.js` of the wanted release and replace the file, keeping the version above in sync:
```shell
curl -o assets/js/vendor/ical.js https://unpkg.com/ical.js@<version>/dist/ical.js
```

9732
assets/js/vendor/ical.js vendored

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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"]

View file

@ -25,6 +25,6 @@ description: "Startseite CCCB mit Kurzkalender"
### Nächste Veranstaltungen
{{< upcoming >}}
CALENDAR
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,58 @@
---
title: "Berlin #DIDit"
date: 2026-06-18T12:00:00+01:00
showHero: false
tags: ["di.day", "Ankündigung"]
---
{{< figure
src="di_day_logo.png"
title="DIDay"
alt="DIDay"
>}}
<!-- <img src="di_day_logo.png" alt="di.day Logo" height="50%"> -->
### Was?
Am Sonntag, dem 5. Juli, öffnet der Chaos Computer Club Berlin e.V. wieder seine Türen zum DID.
Zusammen mit dem [xHain hack+makespace](https://x-hain.de/) unterstützen wir auf dem Weg in die digitale Unabhängigkeit.
Gemeinsam arbeiten wir daran, unsere digitale Souveränität zurückzugewinnen.
Statt Abhängigkeiten von großen Plattformen setzen wir auf offene Alternativen und selbstbestimmte digitale Werkzeuge.
So holen wir uns Schritt für Schritt die Kontrolle über unser digitales Leben zurück und beschreiten den Weg zur digitalen Unabhängigkeit.
Ob du jetzt
- von WhatsApp zu einer datenschutzfreundlichen Alternative wechseln möchtest,
- dein Smartphone gerne ohne Google oder Apple betreiben willst,
- oder auf deinem Notebook lieber Linux anstelle von Windows benutzen würdest,
wir helfen euch dabei gerne. Bringt gerne auch eure Geräte dafür mit.
Natürlich kannst du auch einfach nur so vorbeikommen und uns Fragen stellen.
### Wo?
**Marienstraße 11, 10117 Berlin**
### Wann?
**Sonntag, 5. Juli 2026** \
**12:00 bis 16:00 Uhr**
Schaut aber auch gerne bei der
[Veranstaltung](tbd)
von [Digital-Zebra](https://termine.di.day/organisation/digitalzebra) und
der [c-base](https://termine.di.day/organisation/cbase) vorbei.
**Gemeinsam mehr erreichen** \
Diese Veranstaltung ist Teil eines Netzwerks aus mehreren Organisationen und Initiativen in Berlin.
Wir unterstützen uns gegenseitig mit Kompetenz, Erfahrung und praktischer Hilfe.
Uns alle eint der Wunsch, Wissen zu teilen, voneinander zu lernen und solidarisch zusammenzuarbeiten.
Der DI.Day bildet dabei den gemeinsamen Rahmen,
in dem wir uns für digitale Selbstbestimmung und nachhaltige digitale Strukturen einsetzen.
Schau deshalb gern auch bei den Veranstaltungen der anderen beteiligten Organisationen vorbei.
So entsteht ein vielfältiges Angebot, das Austausch, Vernetzung und gegenseitige Unterstützung lebendig macht.

View file

@ -1,57 +0,0 @@
---
title: "Aktionstag gegen Überwachung"
date: 2026-08-11T12:00:00+02:00
showHero: false
tags: ["Überwachung", "Ankündigung"]
---
### Was?
KI-gestützte Überwachungskameras sollen jetzt auch nach Berlin kommen.
Das [im Dezember 2025 verschärfte Berliner Polizeigesetz](https://www.beck-aktuell.de/heute-im-recht/rechtspolitik-gesetzgebung/berlin-verschaerfung-polizeigesetz-2025-12-04) erlaubt der Polizei, solche Kameras an öffentlichen Plätzen aufzuhängen. Erste Versuche laufen schon.
Darüber wollen wir reden.
Deshalb laden wir am 23. August zum Aktionstag gegen Überwachung in den Club ein.
### Worum geht es?
Die KI-Videoüberwachung soll [ein Jahr lang an sechs sogenannten „kriminalitätsbelasteten Orten" getestet werden](https://taz.de/KI-Videoueberwachung-in-Berlin/!6160851/), darunter am Alexanderplatz, am Roten Rathaus und am Alten Stadthaus. Vor dem Abgeordnetenhaus kommt mobile Technik dazu.
Diese Kameras zeichnen nicht nur auf. Sogenannte Verhaltensscanner sollen automatisch melden, wenn sich jemand „auffällig" verhält. Trainiert wird das Ganze [mit den Daten von Passant:innen, die einfach nur vorbeilaufen](https://netzpolitik.org/2026/ki-gestuetzte-videoueberwachung-in-berlin-so-wehrt-man-sich-gegen-verhaltensscanner/).
In Mannheim läuft so ein System seit 2018, in Hamburg seit 2023. Dort hält die Software [eine Umarmung schon mal für einen Ringkampf](https://taz.de/Kuenstliche-Intelligenz-im-Einsatz/!6099559/). In Mannheim gab es laut Polizei [jeden Tag Fehlalarme im „niedrigen zweistelligen Bereich"](https://netzpolitik.org/2023/intelligente-videoueberwachung-polizei-hamburg-will-ab-juli-verhalten-automatisch-scannen/). Wer sich hinlegt oder taumelt, löst Alarm aus. Was überhaupt als „auffällig" gilt, entscheidet der Algorithmus. Warum das [Menschen diskriminieren kann, erklärt der Verfassungsblog](https://verfassungsblog.de/berlin-asog-novelle-kbos/).
Die Kameras sind außerdem nur ein Teil der Gesetzesänderung. Das neue ASOG erlaubt auch [längere Datenspeicherung und weitere Überwachungsbefugnisse](https://www.telepolis.de/article/Berliner-Polizeigesetz-Koalition-weitet-Ueberwachung-aus-11083345.html). Die Berliner Datenschutzbeauftragte hat das kritisiert. Wir auch: Der CCC war dazu bereits im Innenausschuss des Abgeordnetenhauses.
Ihr braucht kein Vorwissen. Kommt einfach vorbei.
### Programm
**13:00 Uhr: Open Space** \
Wir fangen mit offenem Austausch an. Bringt eure Fragen mit, egal ob ihr euch schon lange mit dem Thema beschäftigt oder gerade zum ersten Mal davon hört.
**15:00 Uhr: Vortrag** \
Was in Berlin geplant ist, was die Technik wirklich kann und was das für uns alle heißt.
**16:00 Uhr: Podiumsdiskussion** \
Danach diskutieren wir auf dem Podium, wie wir uns gegen die KI-Videoüberwachung wehren können. Fragen aus dem Publikum sind ausdrücklich erwünscht.
### Wo?
📍 **Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin**
🚈 Etwa 5 Minuten ab dem S-Bahnhof Friedrichstraße
### Wann?
**📆 Sonntag, 23. August 2026** \
**ab 13:00 Uhr**
### Zum Weiterlesen
- taz: [KI-Videoüberwachung in Berlin: Verhaltensscanner bald auch vor dem Abgeordnetenhaus](https://taz.de/KI-Videoueberwachung-in-Berlin/!6160851/)
- netzpolitik.org: [So wehrt man sich gegen Verhaltensscanner](https://netzpolitik.org/2026/ki-gestuetzte-videoueberwachung-in-berlin-so-wehrt-man-sich-gegen-verhaltensscanner/) (mit Tools und Material für die Zivilgesellschaft)
- Verfassungsblog: [„Try harder hilft selten": die ASOG-Novelle verfassungsrechtlich eingeordnet](https://verfassungsblog.de/berlin-asog-novelle-kbos/)
- taz: [Reform des Berliner Polizeigesetzes: Riskantes Manöver](https://taz.de/Reform-des-Berliner-Polizeigesetzes/!6096087/)
- Telepolis: [Berliner Polizeigesetz: Koalition weitet Überwachung aus](https://www.telepolis.de/article/Berliner-Polizeigesetz-Koalition-weitet-Ueberwachung-aus-11083345.html)
- beck-aktuell: [Kameras und KI-Einsatz: Berlin verschärft Polizeigesetz](https://www.beck-aktuell.de/heute-im-recht/rechtspolitik-gesetzgebung/berlin-verschaerfung-polizeigesetz-2025-12-04)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 365 KiB

View file

@ -1,66 +0,0 @@
---
title: "Linke und Grüne ziehen gegen das Berliner Polizeigesetz vor Gericht"
date: 2026-08-28T12:00:00+02:00
showHero: false
tags: ["Überwachung", "Polizeigesetz", "ASOG"]
---
{{< figure
src="feature-ki-kamera.jpg"
alt="KI-Überwachungskamera"
caption="KI-Überwachungskamera. Foto: netzpolitik.org, CC BY-NC-SA 4.0"
>}}
Als CDU und SPD Ende 2025 das Berliner Polizeigesetz verschärften, war die Kritik laut. Jetzt wird sie juristisch: Am 28. August 2026 haben Bündnis 90/Die Grünen und Die Linke beim Verfassungsgerichtshof des Landes Berlin einen Antrag auf Normenkontrolle eingereicht. Das höchste Berliner Gericht soll nun prüfen, ob die neuen Überwachungsbefugnisse mit der Landesverfassung vereinbar sind oder schlicht zu weit gehen.
### Vor fünf Tagen war das noch ein Thema im Club
Am 23. August fand unser [Aktionstag gegen Überwachung](/post/2026/08/11/aktionstag-gegen-überwachung/) statt. Rund 80 Leute kamen, um über KI-Kameras, Verhaltensscanner und das verschärfte Polizeigesetz zu reden. Auch Abgeordnete der Linksfraktion waren dabei und erzählten dort schon, dass sie gegen das Gesetz vorgehen wollen. Ein Normenkontrollantrag war im Gespräch.
Jetzt liegt er vor.
### Kurzer Rückblick
Im Dezember 2025 beschloss die schwarz-rote Koalition eine Novelle des Allgemeinen Sicherheits- und Ordnungsgesetzes (ASOG). Heraus kam ein Gesetzespaket voller neuer digitaler Befugnisse: von Kameras, die Verhalten automatisch analysieren, bis zu einer zentralen Plattform, auf der ganz unterschiedliche Polizeidaten zusammengeführt werden dürfen.
Die Berliner Datenschutzbeauftragte warnte, die Opposition warnte, unabhängige Sachverständige im Innenausschuss warnten. Passiert ist trotzdem wenig: die Kritik wurde im Verfahren nicht ausgeräumt. Genau hier setzt der Normenkontrollantrag an.
### Worum es konkret geht
Die Kanzlei KM8, die die Fraktionen vertritt, zählt in ihrer Begründung fünf Punkte auf, die besonders stechen:
1. Die Polizei darf künftig mit Spähsoftware auf Smartphones und Computer zugreifen und zwar nicht nur bei schwersten Straftaten, sondern schon zur Verhinderung vergleichsweise kleiner Delikte. Wer solche Software baut, braucht Sicherheitslücken. Das Gesetz setzt also einen Anreiz, Lücken offenzuhalten, statt sie zu stopfen.
2. Der überwachte öffentliche Raum soll automatisiert ausgewertet werden: Wer sich „auffällig" verhält, löst Alarm aus. Was auffällig ist, entscheidet der Algorithmus - ohne klare Einsatzregeln und ohne echten Schutz gegen Fehler, Diskriminierung oder Missbrauch.
3. Öffentlich zugängliche Bilder aus dem Internet darf die Polizei in großem Stil biometrisch auswerten. Die Kanzlei spricht von biometrischer Massenüberwachung und von einem Abschreckungseffekt, der vor allem diejenigen trifft, die demonstrieren wollen.
4. Polizeilich erhobene Daten sollen für einen komplett neuen Zweck weiterverwendet werden dürfen: das Training von KI-Systemen, teilweise sogar unter Weitergabe an private Firmen. Was genau mit welchen Daten passiert, lässt das Gesetz offen. Der Grundsatz, dass Daten nur für den Zweck genutzt werden, für den sie erhoben wurden, wird damit faktisch ausgehebelt.
5. Verschiedenste Bestände dürfen auf einer zentralen Analyseplattform zusammengeführt und automatisiert ausgewertet werden auch mit KI. Übrig bleibt eine Art Riesendatenbank, in der Daten aus völlig unterschiedlichen Zusammenhängen für neue Zwecke zur Verfügung stehen. Ausgeschlossen ist dabei auch der Einsatz von Palantir nicht.
### Was eine Normenkontrolle überhaupt ist
Anders als bei einer konkreten Klage geht es bei der abstrakten Normenkontrolle nicht um einen einzelnen Fall. Das Gericht schaut sich das Gesetz als solches an und prüft, ob es mit der Verfassung vereinbar ist. Antragsberechtigt ist unter anderem ein Viertel der Abgeordneten genau dieses Quorum bringen Die Linke und die Grünen zusammen. Hält das Gericht einzelne Regelungen für verfassungswidrig, erklärt es sie für nichtig.
### Was die Beteiligten sagen
Die innenpolitischen Sprecher Vasili Franco (Grüne) und Niklas Schrader (Die Linke) nennen die Novelle einen massiven Ausbau der Überwachung. Zusammen mit der automatisierten Auswertung von Polizeidaten könnte eine „riesige Superdatenbank" entstehen, die „jegliche Grenzen des Rechtsstaates sprengt". Und sie kritisieren: Es sei völlig unklar, welche Daten künftig wofür verarbeitet werden dürfen. Für ein Parlament, das die Polizei kontrollieren soll, sei das nicht hinnehmbar.
Ihr Kernargument: Polizeigesetze sind keine gewöhnlichen Gesetze. Sie entscheiden, was die Polizei in einem Rechtsstaat darf und das Gewaltmonopol des Staates muss sich an den Grundrechten messen lassen. „Verfassungswidrige Polizeigesetze sind keine guten Polizeigesetze." Deshalb halten sie eine verfassungsrechtliche Prüfung für unabdingbar.
Rechtsanwalt David Werdermann, der die Fraktionen vertritt, bringt es ähnlich auf den Punkt: Die Novelle bedeute einen grundlegenden Kurswechsel, mit Befugnissen, die aus mehreren Gründen gegen Grundrechte verstoßen.
### Warum das jetzt wichtig ist
Der Ball liegt beim Verfassungsgerichtshof. Dessen Entscheidung wird nicht nur über die einzelnen Punkte entscheiden, sondern grundsätzlich darüber, wie viel Überwachung die Landesverfassung überhaupt hergibt vom Verhaltensscanner im öffentlichen Raum bis zur zentralen Datenanalyse.
Wie das ausgeht, ist offen. Sicher ist nur: Der Streit um das Polizeigesetz wird nicht mehr allein politisch geführt, sondern jetzt auch vor Gericht.
### Zum Weiterlesen
- Linksfraktion Berlin: [Linke und Grüne erheben Normenkontrolle gegen verfassungswidrige Regelungen im schwarz-roten Polizeigesetz](https://www.linksfraktion.berlin/politik/presse/detail/news/linke-und-gruene-erheben-normenkontrolle-gegen-verfassungswidrige-regelungen-im-schwarz-roten-polizeigesetz/)
- Verfassungsblog: [„Try harder hilft selten": die ASOG-Novelle verfassungsrechtlich eingeordnet](https://verfassungsblog.de/berlin-asog-novelle-kbos/)
- taz: [KI-Videoüberwachung in Berlin: Verhaltensscanner bald auch vor dem Abgeordnetenhaus](https://taz.de/KI-Videoueberwachung-in-Berlin/!6160851/)
- netzpolitik.org: [So wehrt man sich gegen Verhaltensscanner](https://netzpolitik.org/2026/ki-gestuetzte-videoueberwachung-in-berlin-so-wehrt-man-sich-gegen-verhaltensscanner/)
- beck-aktuell: [Kameras und KI-Einsatz: Berlin verschärft Polizeigesetz](https://www.beck-aktuell.de/heute-im-recht/rechtspolitik-gesetzgebung/berlin-verschaerfung-polizeigesetz-2025-12-04)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

View file

@ -1,85 +0,0 @@
---
title: "Peter Thiel ante portas"
date: 2026-08-28T12:00:00+02:00
showHero: false
tags: ["Überwachung", "Palantir", "Ankündigung"]
---
{{< figure
src="feature-peter-thiel.jpg"
alt="Peter Thiel"
caption="Peter Thiel. Foto: Gage Skidmore, CC BY-SA 2.0"
>}}
### Was?
Am 24. September nimmt Peter Thiel in Berlin den Axel-Springer-Award entgegen. Drei Tage vorher, am 21. September, schauen wir uns im Club an, wer da eigentlich geehrt wird. Denn der Mann, den Axel Springer da auszeichnet, ist kein harmloser Tech-Unternehmer.
Peter Thiel hat PayPal mitgegründet, früh in Facebook investiert und mit Palantir eines der wichtigsten Überwachungsunternehmen der Welt aufgebaut. Er ist einer der reichsten und einflussreichsten Männer des Silicon Valley, und seine Vorstellungen davon, wie die Welt aussehen soll, sind radikal. Darüber wollen wir reden.
### Wer ist Peter Thiel?
Peter Thiel wurde 1967 in Frankfurt am Main geboren und wanderte als Kind mit seiner Familie in die USA aus. Er studierte Philosophie in Stanford, machte einen Abschluss in Jura und arbeitete kurz als Anwalt, bevor er in die Tech-Welt wechselte. 1998 gründete er mit Max Levchin das Unternehmen Confinity, aus dem später PayPal wurde. Als eBay PayPal 2002 für 1,5 Milliarden Dollar kaufte, war Thiel als Vorstandschef der große Gewinner.
Danach wurde er zu einer der zentralen Figuren des Silicon Valley. 2004 gab er als erster externer Investor eine halbe Million Dollar in ein damals unbekanntes Studentenprojekt namens Facebook. Mit seiner Wagniskapitalfirma Founders Fund steckte er früh Geld in Unternehmen wie SpaceX, Airbnb und die Rüstungsfirma Anduril. Und mit dem Thiel Fellowship bezahlt er seit 2011 junge Menschen dafür, ihr Studium abzubrechen und stattdessen Firmen zu gründen.
Vor allem aber steht sein Name für Palantir.
### Palantir
Palantir ist nach den Sehsteinen aus Tolkiens „Herr der Ringe" benannt, den Palantíri. Das passt, denn die Firma baut genau das: Software, die alles sehen soll. Ihre Werkzeuge durchforsten riesige Datenmengen nach Mustern, Verbindungen und Auffälligkeiten.
Die Kundschaft sind Geheimdienste, Militärs und Polizeibehörden. Palantir-Software half der US-Einwanderungsbehörde ICE dabei, Abschiebungen zu organisieren. Sie unterstützt Militärs bei der Auswahl von Zielen und steckt hinter polizeilichen Analyse- und Vorhersagesystemen. In Großbritannien hat sich Palantir einen umstrittenen Vertrag über die Gesundheitsdaten des staatlichen NHS gesichert.
Auch bei uns wird darum gestritten. Erst kürzlich haben wir [darüber geschrieben]({{< ref "post/2026-08-28-normenkontrolle-asog" >}}), dass im neuen Berliner Polizeigesetz der Einsatz von Palantir ausdrücklich nicht ausgeschlossen ist. Die zentrale Analyseplattform, die dieses Gesetz vorsieht, ist genau das Geschäftsmodell von Palantir.
### Thiels Weltbild
Thiel ist nicht nur Geschäftsmann. Er hat eine klare politische Agenda, und die ist erklärtermaßen antidemokratisch. 2009 schrieb er in einem Essay: „I no longer believe that freedom and democracy are compatible." Zu Deutsch: Ich glaube nicht mehr daran, dass Freiheit und Demokratie vereinbar sind. Im selben Text machte er das Wahlrecht für Frauen und den Sozialstaat dafür verantwortlich, dass die liberale Demokratie seiner Ansicht nach gescheitert ist.
Er steht dem Umfeld der sogenannten Neoreaktion nahe, einer Denkrichtung, die die Demokratie für gescheitert hält und stattdessen autoritäre Strukturen anstrebt. Ein Vordenker dieser Szene, Curtis Yarvin, träumt öffentlich von der Abschaffung des Parlamentarismus und von einem Staat, der wie ein Unternehmen geführt wird, mit einem Chef an der Spitze statt gewählter Vertreter:innen. Thiel hat Yarvin über Jahre gefördert und gepriesen.
Thiels Geld fließt auch in die Politik. Er unterstützte Donald Trump bereits 2016 und finanzierte 2022 mit 15 Millionen Dollar die Senatskampagne von JD Vance, dem heutigen Vizepräsidenten der USA. Es war damals die größte Einzelspende an einen Senatskandidaten. Und mit dem Seasteading Institute wollte er schwimmende Stadtstaaten errichten, die sich staatlicher Regeln komplett entziehen.
Kurz gesagt: Hier geht es um jemanden, der die Demokratie nicht nur kritisiert, sondern sie abschaffen will.
### Thiels Glaube und der Antichrist
Was viele bei Thiel übersehen: Seine Weltsicht ist tief religiös geprägt, allerdings auf eine sehr eigene Art. Thiel ist gläubiger Christ, aber weit entfernt vom Mainstream. Sein Denken ist stark von dem französischen Philosophen René Girard beeinflusst, dessen Theorie um Nachahmung, Sündenböcke und Apokalypse kreist. Thiel hat Girard über Jahre studiert und dessen apokalyptische Grundhaltung übernommen.
Das zeigt sich besonders bei einem Thema, über das Thiel immer wieder spricht: den Antichristen. Thiel nimmt das wörtlich. In Interviews beschreibt er den Antichristen als eine Figur, die einen weltweiten Einheitsstaat errichtet, einen falschen Frieden verspricht und dabei jede Freiheit abschafft. Zentrale Datenmacht, allgegenwärtige Überwachung und Dinge wie das chinesische Social-Credit-System ordnet er in dieses Endzeitszenario ein. Er beschreibt sich selbst als jemand, der die Zeichen dafür überall erkennt.
An dieser Stelle lohnt es sich, kurz innezuhalten. Denn hier steckt ein bemerkenswerter Widerspruch: Thiel warnt vor einem totalitären Überwachungsstaat als Vorboten des Antichristen, während er gleichzeitig mit Palantir das leistungsfähigste Überwachungsunternehmen der Welt aufbaut und an genau die Staaten verkauft, vor denen er angeblich warnt. Ob man das nun für aufrichtige Angst, für Zynismus oder für beides hält: Es sagt eine Menge über diesen Mann aus, dass sein Geschäft genau das herstellt, was sein Glaube verdammt.
### Der Axel-Springer-Award
Vor diesem Hintergrund ist es bemerkenswert, dass ausgerechnet Axel Springer diesen Mann auszeichnet. Axel Springer ist einer der größten Medienkonzerte Europas und gibt Blätter wie Bild und Welt heraus. Den Axel-Springer-Award verleiht der Konzern seit Jahren an Persönlichkeiten der Digitalwirtschaft, unter den bisherigen Preisträgern waren Mark Zuckerberg, Jeff Bezos und Elon Musk.
Mit Peter Thiel ehrt Axel Springer nun einen Mann, der offen erklärt hat, dass er Demokratie und Freiheit für unvereinbar hält. Die Verleihung am 24. September in Berlin ist eine Bühne. Wir finden: Sie sollte nicht unwidersprochen bleiben. Deshalb laden wir drei Tage vorher in den Club ein.
### Programm
**19:00 Uhr: Vortrag** \
Wer ist Peter Thiel? Was macht Palantir? Welche Netzwerke, welches Weltbild und welcher Glaube stecken dahinter? Und warum ist das alles gefährlich?
**Anschließend: Austausch** \
Diskussion und offener Austausch, offenes Ende.
Ihr braucht kein Vorwissen. Kommt einfach vorbei.
### Wo?
📍 **Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin**
🚈 Etwa 5 Minuten ab dem S-Bahnhof Friedrichstraße
### Wann?
**📆 Montag, 21. September 2026** \
**ab 19:00 Uhr**
### Zum Weiterlesen
- Wikipedia: [Peter Thiel](https://de.wikipedia.org/wiki/Peter_Thiel)
- Wikipedia: [Palantir Technologies](https://de.wikipedia.org/wiki/Palantir_Technologies)
- Wikipedia: [Axel Springer SE](https://de.wikipedia.org/wiki/Axel_Springer_SE)

View file

@ -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"]

View file

@ -1,23 +0,0 @@
---
title: "Aktionstag gegen Überwachung"
subtitle: "KI-Überwachungskameras in Berlin: Vortrag, Podium und Austausch"
date: 2026-08-11T12:00:00+02:00
dtstart: 20260823T130000
dtend: 20260823T180000
menu:
main:
parent: "Veranstaltungen"
tag: ["Veranstaltung"]
---
**Am 23. August lädt der Chaos Computer Club Berlin zum Aktionstag gegen Überwachung.**
KI-gestützte Überwachungskameras sollen jetzt auch nach Berlin kommen. Das neue Berliner Polizeigesetz erlaubt es, sie an öffentlichen Plätzen aufzuhängen. Erste Versuche laufen schon, Verhaltenstracking inklusive. Darüber wollen wir reden.
Das Programm:
- **13:00 Uhr:** Open Space, offener Austausch untereinander
- **15:00 Uhr:** Vortrag zu den KI-Kameras und dem neuen Berliner Polizeigesetz
- **16:00 Uhr:** Podiumsdiskussion
📍 Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin

View file

@ -11,12 +11,10 @@ menu:
tag: ["Veranstaltung"]
---
In Berlin wird der [Digital Independence Day](https://diday.org) an verschiedenen Orten von einer breiten Zahl von Initiativen getragen.
**Jeden ersten Sonntag im Monat von 12:23 bis 17:42 Uhr** findet der Berliner Digital Independence Day statt. Der Veranstaltungsort wechselt monatlich zwischen den Räumen des CCCB (in den ungeraden Monaten Januar, März, Mai, …) und dem [xHain](https://x-hain.de/) (in den geraden Monaten).
Der CCCB beteiligt sich an dieser Graswurzelbewegung und ruft dazu auf, zu demokratiefreundlichen digitalen Alternativen zu wechseln, um die Abhängigkeit von großen Technologiekonzernen zu verringern.
Der CCCB und das xHain beteiligen sich gemeinsam an der internationalen [Digital Independence Day](https://diday.org)-Initiative. Diese Graswurzelbewegung ruft dazu auf, zu demokratiefreundlichen digitalen Alternativen zu wechseln, um die Abhängigkeit von großen Technologiekonzernen zu verringern.
Am ersten Sonntag im Monat gibt es praktische Hilfe beim Wechsel zu datenschutzfreundlichen Diensten, beim Einrichten von Linux oder beim Betrieb von Smartphones ohne große Tech-Konzerne. Egal, ob Anfänger:in oder Fortgeschrittene:r. Kommt vorbei und bringt eure Geräte und Fragen mit.
Vor Ort gibt es praktische Hilfe beim Wechsel zu datenschutzfreundlichen Diensten, beim Einrichten von Linux oder beim Betrieb von Smartphones ohne große Tech-Konzerne. Egal ob Anfänger:in oder Fortgeschrittene:r. Kommt vorbei und bringt eure Geräte und Fragen mit.
Die Aktiven im CCCB unterstützen dabei auf verschiedenen Veranstaltungen in Berlin.
Weitere Informationen zu konkreten Veranstaltungen gibt es im Terminkalender unter [https://events.diday.org/](https://events.diday.org/) bzw [https://events.diday.org/organisation/cccb](https://events.diday.org/organisation/cccb).
Weitere Informationen gibt es im [Ankündigungsbeitrag](https://berlin.ccc.de/post/2026/07/05/berlin-%23didit/).

View file

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

@ -1,22 +0,0 @@
---
title: "Peter Thiel ante portas"
subtitle: "Vortrag und Austausch über Peter Thiel, Palantir und den Axel-Springer-Preis"
date: 2026-08-28T12:00:00+02:00
dtstart: 20260921T190000
dtend: 20260921T220000
menu:
main:
parent: "Veranstaltungen"
tag: ["Veranstaltung"]
---
**Am 21. September schauen wir uns an, wer da am 24. September in Berlin ausgezeichnet wird.**
Peter Thiel kommt nach Berlin, um den Axel-Springer-Award entgegenzunehmen. Er ist Mitgründer von PayPal und Palantir, früher Investor bei Facebook und einer der einflussreichsten Geldgeber der Tech-Rechten. Grund genug, sich ihn genauer anzusehen.
Das Programm:
- **19:00 Uhr:** Vortrag zu Peter Thiel, Palantir und seinen Netzwerken
- **anschließend:** Offener Austausch und Diskussion, offenes Ende
📍 Chaos Computer Club Berlin, Marienstraße 11, 10117 Berlin

View file

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

8
flake.lock generated
View file

@ -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"
}

View file

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

View file

@ -1,12 +1,12 @@
{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }}
{{ $css := resources.Get "css/calendar.css" | minify | fingerprint }}
{{ $js := resources.Get "js/calendar.js" }}
{{ $css := resources.Get "css/calendar.css" }}
<div class="calendar-container">
{{ with $css }}
<link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}">
<link rel="stylesheet" href="{{ .RelPermalink }}">
{{ end }}
{{ with $js }}
<script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>
<script src="{{ .RelPermalink }}"></script>
{{ end }}
<div id="calendar">

View file

@ -1,5 +0,0 @@
{{- $css := resources.Get "css/upcoming.css" | minify | fingerprint -}}
{{- $js := resources.Get "js/upcoming.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint -}}
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}">
<table id="upcoming" class="table table-condensed"></table>
<script type="module" src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>

2
requirements.txt Normal file
View file

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

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