Compare commits
37 changed files with 744 additions and 10958 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.ics text eol=crlf
|
||||
138
.gitignore
vendored
138
.gitignore
vendored
|
|
@ -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
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.11
|
||||
51
README.md
51
README.md
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
})();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
19
assets/js/vendor/README.md
vendored
19
assets/js/vendor/README.md
vendored
|
|
@ -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
9732
assets/js/vendor/ical.js
vendored
File diff suppressed because it is too large
Load diff
7
build.sh
7
build.sh
|
|
@ -1,7 +1,12 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -ex
|
||||
set -e
|
||||
set -x
|
||||
|
||||
rm -rf public/ # delete old generated files
|
||||
|
||||
hugo $(cat .hugo-params)
|
||||
./tools/merge_cals.py
|
||||
upcoming="$(tools/gen_upcoming.py static/all.ics 20 5 | tr '\n' ' ')"
|
||||
cp static/all.ics public/all.ics
|
||||
sed -i "s#CALENDAR#$upcoming#g" public/index.html
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -25,6 +25,6 @@ description: "Startseite CCCB mit Kurzkalender"
|
|||
|
||||
### Nächste Veranstaltungen
|
||||
|
||||
{{< upcoming >}}
|
||||
CALENDAR
|
||||
|
||||
Weitere Termine findest du im [Veranstaltungskalender](/verein/calendar/).
|
||||
|
|
|
|||
BIN
content/post/2026-07-05-di_day/di_day_logo.png
Normal file
BIN
content/post/2026-07-05-di_day/di_day_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
58
content/post/2026-07-05-di_day/index.md
Normal file
58
content/post/2026-07-05-di_day/index.md
Normal 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.
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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/).
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
8
flake.lock
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
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-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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
icalendar==5.0.7
|
||||
pytz
|
||||
108
tools/gen_upcoming.py
Executable file
108
tools/gen_upcoming.py
Executable 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
31
tools/merge_cals.py
Executable 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())
|
||||
|
||||
Loading…
Reference in a new issue