forked from cccb-website-team/www
Merge branch 'main-staging' into staging
This commit is contained in:
commit
75ae1c30a2
35 changed files with 10781 additions and 761 deletions
|
|
@ -9,11 +9,6 @@ charset = utf-8
|
||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[*.py]
|
[*.{js,yaml}]
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
|
|
||||||
[*.yaml]
|
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
name: deploy blog
|
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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
|
|
@ -10,46 +16,44 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: alpine-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: apk --no-cache add hugo python3 py3-pip git openssh-client rsync
|
run: apk --no-cache add hugo git openssh-client rsync
|
||||||
|
|
||||||
- name: Check versions
|
- name: Check versions
|
||||||
run: |
|
run: |
|
||||||
cat /etc/os-release
|
cat /etc/os-release
|
||||||
git version
|
git version
|
||||||
hugo version
|
hugo version
|
||||||
python --version
|
rsync --version
|
||||||
|
ssh -V
|
||||||
|
|
||||||
|
- name: Set envionment vars
|
||||||
|
run: echo "GIT_BRANCH=${{ forgejo.event_name == 'schedule' && 'production' || forgejo.ref_name }}" >> "$FORGEJO_ENV"
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
run: |
|
run: |
|
||||||
git clone -b ${{ forgejo.ref_name }} --recursive https://git.berlin.ccc.de/cccb-website-team/www.git .
|
git clone -b $GIT_BRANCH --recursive https://git.berlin.ccc.de/cccb-website-team/www.git .
|
||||||
git status
|
git status
|
||||||
|
|
||||||
- name: Install Python depenndencies
|
|
||||||
run: python -m pip install -r requirements.txt --break-system-packages
|
|
||||||
|
|
||||||
- name: Render site
|
- name: Render site
|
||||||
run: ./build.sh
|
run: ./build.sh
|
||||||
|
|
||||||
- name: Setup SSH
|
- name: Setup SSH
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ forgejo.ref_name == 'production' && secrets.SSH_PRIVATE_KEY_PRODUCTION || secrets.SSH_PRIVATE_KEY_STAGING }}
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
printf "%s" "${{ secrets.KNOWN_HOSTS }}" | base64 -d > ~/.ssh/known_hosts
|
printf "%s" "${{ secrets.KNOWN_HOSTS }}" | base64 -d > ~/.ssh/known_hosts
|
||||||
printf "%s" "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_ed25519
|
printf "%s" "${{ env.GIT_BRANCH == 'production' && secrets.SSH_PRIVATE_KEY_PRODUCTION || secrets.SSH_PRIVATE_KEY_STAGING }}" | base64 -d > ~/.ssh/id_ed25519
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
ssh-keygen -f ~/.ssh/id_ed25519 -y > ~/.ssh/id_ed25519.pub
|
ssh-keygen -f ~/.ssh/id_ed25519 -y > ~/.ssh/id_ed25519.pub
|
||||||
cat ~/.ssh/id_ed25519.pub
|
cat ~/.ssh/id_ed25519.pub
|
||||||
|
|
||||||
- name: Rsync rendered site
|
- name: Rsync rendered site
|
||||||
env:
|
|
||||||
DEPLOY_DIR: ${{ forgejo.ref_name == 'production' && '/srv/http/www/' || '/srv/http/www-staging/' }}
|
|
||||||
# TODO: add --delete
|
# TODO: add --delete
|
||||||
run: rsync -var -e 'ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=yes' ./public/ deploy@www.berlin.ccc.de:$DEPLOY_DIR
|
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/' }}
|
||||||
|
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
run: rm -rf ~/.ssh
|
run: rm -rf ~/.ssh
|
||||||
|
|
||||||
|
|
|
||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
|
|
@ -1 +0,0 @@
|
||||||
*.ics text eol=crlf
|
|
||||||
138
.gitignore
vendored
138
.gitignore
vendored
|
|
@ -1,8 +1,9 @@
|
||||||
static/all.ics
|
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo,pycharm+all,vim,direnv
|
||||||
|
|
||||||
|
### direnv ###
|
||||||
|
.direnv
|
||||||
.envrc
|
.envrc
|
||||||
*.swp
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo
|
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo
|
|
||||||
|
|
||||||
### Hugo ###
|
### Hugo ###
|
||||||
# Generated files by hugo
|
# Generated files by hugo
|
||||||
|
|
@ -41,8 +42,7 @@ hugo.linux
|
||||||
.LSOverride
|
.LSOverride
|
||||||
|
|
||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon
|
Icon
|
||||||
|
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|
@ -67,6 +67,114 @@ Temporary Items
|
||||||
# iCloud generated files
|
# iCloud generated files
|
||||||
*.icloud
|
*.icloud
|
||||||
|
|
||||||
|
### PyCharm+all ###
|
||||||
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
|
# AWS User-specific
|
||||||
|
.idea/**/aws.xml
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
cmake-build-*/
|
||||||
|
|
||||||
|
# Mongo Explorer plugin
|
||||||
|
.idea/**/mongoSettings.xml
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
out/
|
||||||
|
|
||||||
|
# mpeltonen/sbt-idea plugin
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Cursive Clojure plugin
|
||||||
|
.idea/replstate.xml
|
||||||
|
|
||||||
|
# SonarLint plugin
|
||||||
|
.idea/sonarlint/
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Editor-based Rest Client
|
||||||
|
.idea/httpRequests
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
### PyCharm+all Patch ###
|
||||||
|
# Ignore everything but code style settings and run configurations
|
||||||
|
# that are supposed to be shared within teams.
|
||||||
|
|
||||||
|
.idea/*
|
||||||
|
|
||||||
|
!.idea/codeStyles
|
||||||
|
!.idea/runConfigurations
|
||||||
|
|
||||||
|
### Vim ###
|
||||||
|
# Swap
|
||||||
|
[._]*.s[a-v][a-z]
|
||||||
|
!*.svg # comment out if you don't need vector files
|
||||||
|
[._]*.sw[a-p]
|
||||||
|
[._]s[a-rt-v][a-z]
|
||||||
|
[._]ss[a-gi-z]
|
||||||
|
[._]sw[a-p]
|
||||||
|
|
||||||
|
# Session
|
||||||
|
Session.vim
|
||||||
|
Sessionx.vim
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
.netrwhist
|
||||||
|
# Auto-generated tag files
|
||||||
|
tags
|
||||||
|
# Persistent undo
|
||||||
|
[._]*.un~
|
||||||
|
|
||||||
### Windows ###
|
### Windows ###
|
||||||
# Windows thumbnail cache files
|
# Windows thumbnail cache files
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
@ -93,17 +201,9 @@ $RECYCLE.BIN/
|
||||||
# Windows shortcuts
|
# Windows shortcuts
|
||||||
*.lnk
|
*.lnk
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo
|
# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv
|
||||||
|
|
||||||
# NixOS:
|
### CCCB ###
|
||||||
.envrc
|
# The calendar the site reads its events from is published next to the site and
|
||||||
shell.nix
|
# only downloaded here to look at the site locally, see the README.
|
||||||
.direnv
|
/static/calendars/
|
||||||
|
|
||||||
# Python
|
|
||||||
.venv
|
|
||||||
|
|
||||||
# murmeldin
|
|
||||||
overview.md
|
|
||||||
|
|
||||||
/result
|
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
3.11
|
|
||||||
17
README.md
17
README.md
|
|
@ -27,8 +27,7 @@ This is the website of the CCCB.
|
||||||
|
|
||||||
5. Point your browser to: <http://localhost:1313/>
|
5. Point your browser to: <http://localhost:1313/>
|
||||||
|
|
||||||
Every change you make on the project will be reflected in your browser as long
|
Every change you make on the project will be reflected in your browser as long as `hugo serve` is running.
|
||||||
as `hugo serve` is running.
|
|
||||||
|
|
||||||
The *"Nächste Veranstaltungen"* calendar on the home page is rendered by
|
The *"Nächste Veranstaltungen"* calendar on the home page is rendered by
|
||||||
Hugo via the `calendar` shortcode, which fetches `all.ics` client-side.
|
Hugo via the `calendar` shortcode, which fetches `all.ics` client-side.
|
||||||
|
|
@ -38,7 +37,6 @@ upload, run:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
./build.sh
|
./build.sh
|
||||||
python3 -m http.server -d public 1313
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.sh` runs `hugo`, generates `all.ics`, and copies it into `public/`.
|
`build.sh` runs `hugo`, generates `all.ics`, and copies it into `public/`.
|
||||||
|
|
@ -54,18 +52,21 @@ To build with *nix*: `nix build '.?submodules=1#production-content'`
|
||||||
3. Commit (and push) your change.
|
3. Commit (and push) your change.
|
||||||
4. ~~GitHub Actions is running the release workflow.~~
|
4. ~~GitHub Actions is running the release workflow.~~
|
||||||
- If successful, check [Staging Website](https://staging.berlin.ccc.de/) if change is correct.
|
- If successful, check [Staging Website](https://staging.berlin.ccc.de/) if change is correct.
|
||||||
5. Create merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or if small change, merge yourself.
|
5. Create a merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or
|
||||||
|
if small change, merge yourself.
|
||||||
6. ~~GitHub Actions is running the release workflow.~~
|
6. ~~GitHub Actions is running the release workflow.~~
|
||||||
- If successfull, check [Website](https://berlin.ccc.de/) if change is correct.
|
- If successfull, check [Website](https://berlin.ccc.de/) if change is correct.
|
||||||
7. Profit!
|
7. Profit!
|
||||||
|
|
||||||
## Nix stuff
|
## Nix stuff
|
||||||
|
|
||||||
- After entering the shell with `nix develop`, hugo is available and `hugo serve` should work
|
- After entering the shell with `nix develop`, hugo is available and `hugo serve` and `./build.sh` 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
|
||||||
- You can build the staging and production builds with `nix build .#staging-content` and `nix build .#production-content`
|
`nix build .#production-content`
|
||||||
- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last checked June 2025)
|
- Do not update the nixpkgs branch - 25.05 contains a newer hugo version that is incompatible with the theme (last
|
||||||
|
checked June 2025)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Made with ❤️ and [Hugo](https://gohugo.io).
|
Made with ❤️ and [Hugo](https://gohugo.io).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,10 @@
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
.event-description {
|
||||||
|
/* Descriptions carry their own line breaks, keep them. */
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
.no-events {
|
.no-events {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|
|
||||||
36
assets/css/upcoming.css
Normal file
36
assets/css/upcoming.css
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/* 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,443 +1,406 @@
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
|
||||||
(function(){
|
|
||||||
let events = [];
|
|
||||||
let eventsByDate = {};
|
|
||||||
|
|
||||||
// Funktion zum Parsen der ICS-Datei
|
// The club is in Berlin, so the calendar shows Berlin days and Berlin times,
|
||||||
function parseICS(icsText) {
|
// no matter which time zone the browser of the visitor is set to.
|
||||||
let events = [];
|
const timeZone = "Europe/Berlin";
|
||||||
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;
|
|
||||||
} else if (baseKey === "URL") {
|
|
||||||
event.url = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hilfsfunktion: Parst einen ICS-Datum-String ins Format "YYYY-MM-DD"
|
const monthNames = [
|
||||||
function parseDateString(icsDateStr) {
|
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||||
// Handle different date formats
|
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract date components from different date formats
|
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
|
||||||
function getDateComponents(icsDateStr) {
|
timeZone,
|
||||||
if (!icsDateStr) return null;
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
// Basic handling - extract YYYY, MM, DD regardless of format
|
day: "2-digit",
|
||||||
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));
|
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
timeZone,
|
||||||
return { year, month, day };
|
hour: "2-digit",
|
||||||
}
|
minute: "2-digit",
|
||||||
|
});
|
||||||
function expandRecurringEvents(event, year, month) {
|
|
||||||
if (!event.rrule) return [event];
|
let calendar = null;
|
||||||
|
let eventsByDate = {};
|
||||||
const rruleStr = event.rrule;
|
let currentYear;
|
||||||
|
let currentMonth;
|
||||||
// Get start date components
|
|
||||||
const startComponents = getDateComponents(event.start);
|
let currentMonthElem;
|
||||||
if (!startComponents) return [event];
|
let calendarBody;
|
||||||
|
let eventPanel;
|
||||||
const startDate = new Date(
|
let eventDateElem;
|
||||||
startComponents.year,
|
let eventDetailsElem;
|
||||||
startComponents.month,
|
|
||||||
startComponents.day
|
/**
|
||||||
);
|
* The day a point in time falls on in Berlin.
|
||||||
|
*
|
||||||
const rangeStart = new Date(year, month, 1);
|
* @param {Date} date The point in time
|
||||||
const rangeEnd = new Date(year, month + 1, 0);
|
* @returns {string} The day as "YYYY-MM-DD"
|
||||||
const expandedEvents = [];
|
*/
|
||||||
|
function dayKey(date) {
|
||||||
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
|
const parts = {};
|
||||||
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
|
|
||||||
if (bydayMatch) {
|
for (const part of dayKeyFormat.formatToParts(date)) {
|
||||||
const dayCode = bydayMatch[1];
|
parts[part.type] = part.value;
|
||||||
const dayMap = {
|
}
|
||||||
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
|
|
||||||
};
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
const targetDay = dayMap[dayCode];
|
}
|
||||||
|
|
||||||
if (targetDay !== undefined) {
|
/**
|
||||||
// Create events for each matching day in the month
|
* The day an occurrence time falls on in Berlin.
|
||||||
let day = 1;
|
*
|
||||||
while (day <= rangeEnd.getDate()) {
|
* A date has neither a time nor a zone, its digits are the day itself.
|
||||||
const testDate = new Date(year, month, day);
|
* toJSDate() would read them as midnight in the zone of the browser, which
|
||||||
if (testDate.getDay() === targetDay && testDate >= startDate) {
|
* far enough east or west of Berlin lands on the day before or after.
|
||||||
const newEvent = {...event};
|
*
|
||||||
const eventDate = formatDateForICS(testDate);
|
* @param {ICAL.Time} time The time
|
||||||
|
* @returns {string} The day as "YYYY-MM-DD"
|
||||||
// Preserve time portion from original event
|
*/
|
||||||
const timePart = event.start.includes('T') ?
|
function timeDayKey(time) {
|
||||||
event.start.substring(event.start.indexOf('T')) : '';
|
if (time.isDate) {
|
||||||
const endTimePart = event.end.includes('T') ?
|
const month = String(time.month).padStart(2, "0");
|
||||||
event.end.substring(event.end.indexOf('T')) : '';
|
const day = String(time.day).padStart(2, "0");
|
||||||
|
|
||||||
newEvent.start = eventDate + timePart;
|
return `${time.year}-${month}-${day}`;
|
||||||
newEvent.end = eventDate + endTimePart;
|
}
|
||||||
expandedEvents.push(newEvent);
|
|
||||||
}
|
return dayKey(time.toJSDate());
|
||||||
day++;
|
}
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
}
|
* 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.
|
||||||
else if (rruleStr.includes("FREQ=MONTHLY") && rruleStr.includes("BYDAY")) {
|
*
|
||||||
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
|
* @param {ICAL.Time} start Start of the event
|
||||||
if (bydayMatch) {
|
* @param {ICAL.Time} end End of the event
|
||||||
const intervalMatch = rruleStr.match(/INTERVAL=(\d+)/);
|
* @returns {string[]} The days as "YYYY-MM-DD"
|
||||||
const interval = intervalMatch ? parseInt(intervalMatch[1]) : 1;
|
*/
|
||||||
const monthsFromStart = (year - startDate.getFullYear()) * 12 + (month - startDate.getMonth());
|
function daysCovered(start, end) {
|
||||||
if (monthsFromStart < 0 || monthsFromStart % interval !== 0) {
|
// The end is not part of the event: one ending at midnight belongs to the day
|
||||||
return [];
|
// before, and an all day event ends on the day before its DTEND.
|
||||||
}
|
const last = end.clone();
|
||||||
const bydays = bydayMatch[1].split(',');
|
|
||||||
const dayMap = {
|
if (end.isDate) {
|
||||||
'MO': 1, 'TU': 2, 'WE': 3, 'TH': 4, 'FR': 5, 'SA': 6, 'SU': 0
|
last.adjust(-1, 0, 0, 0);
|
||||||
};
|
} else {
|
||||||
|
last.adjust(0, 0, 0, -1);
|
||||||
bydays.forEach(byday => {
|
}
|
||||||
const occurrence = parseInt(byday) || 1;
|
|
||||||
const dayCode = byday.slice(-2);
|
const lastKey = timeDayKey(last);
|
||||||
const dayIndex = dayMap[dayCode];
|
const days = [];
|
||||||
|
|
||||||
let day = 1;
|
let key = timeDayKey(start);
|
||||||
let count = 0;
|
|
||||||
|
// The guard keeps a broken event from looping forever, a year of dots on the
|
||||||
while (day <= rangeEnd.getDate()) {
|
// same event is well past the point where the calendar is still useful.
|
||||||
const testDate = new Date(year, month, day);
|
while (days.length <= 366) {
|
||||||
if (testDate.getDay() === dayIndex) {
|
days.push(key);
|
||||||
count++;
|
|
||||||
if (count === occurrence || (occurrence < 0 && day > rangeEnd.getDate() + occurrence * 7)) {
|
// The keys sort as the days do, so this also stops an event whose end lies
|
||||||
const newEvent = {...event};
|
// before its start after the day it starts on.
|
||||||
const eventDate = new Date(year, month, day);
|
if (key >= lastKey) {
|
||||||
|
break;
|
||||||
// Preserve time portion from original event
|
}
|
||||||
const timePart = event.start.includes('T') ?
|
|
||||||
event.start.substring(event.start.indexOf('T')) : '';
|
// Step to noon UTC of the next day, which is inside the same Berlin day
|
||||||
const endTimePart = event.end.includes('T') ?
|
// whether daylight saving time is in effect or not.
|
||||||
event.end.substring(event.end.indexOf('T')) : '';
|
const [year, month, day] = key.split("-").map(Number);
|
||||||
|
key = dayKey(new Date(Date.UTC(year, month - 1, day + 1, 12)));
|
||||||
newEvent.start = formatDateForICS(eventDate) + timePart;
|
}
|
||||||
newEvent.end = formatDateForICS(eventDate) + endTimePart;
|
|
||||||
expandedEvents.push(newEvent);
|
return days;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
day++;
|
/**
|
||||||
}
|
* 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
|
||||||
return expandedEvents.length > 0 ? expandedEvents : [event];
|
* @returns {{summary: string, description: string, url: string, start: Date, end: Date, allDay: boolean, days: string[]}}
|
||||||
}
|
*/
|
||||||
|
function toOccurrence(event, startDate, endDate) {
|
||||||
// Kalender initialisieren
|
return {
|
||||||
let currentYear, currentMonth;
|
summary: event.summary ?? "",
|
||||||
const currentMonthElem = document.getElementById("current-month");
|
description: event.description ?? "",
|
||||||
const calendarBody = document.getElementById("calendar-body");
|
url: eventUrl(event),
|
||||||
const eventPanel = document.getElementById("event-panel");
|
start: startDate.toJSDate(),
|
||||||
const eventDateElem = document.getElementById("event-date");
|
end: endDate.toJSDate(),
|
||||||
const eventDetailsElem = document.getElementById("event-details");
|
allDay: startDate.isDate,
|
||||||
|
// Taken from the ICAL times, which still know whether they name a day or a
|
||||||
document.getElementById("prev-month").addEventListener("click", function(){
|
// point in time; the JS dates above no longer do.
|
||||||
currentMonth--;
|
days: daysCovered(startDate, endDate),
|
||||||
if (currentMonth < 0) {
|
};
|
||||||
currentMonth = 11;
|
}
|
||||||
currentYear--;
|
|
||||||
}
|
/**
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
* 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("next-month").addEventListener("click", function(){
|
cell.classList.add('selected-day');
|
||||||
currentMonth++;
|
showEventDetails(dateKey);
|
||||||
if (currentMonth > 11) {
|
});
|
||||||
currentMonth = 0;
|
}
|
||||||
currentYear++;
|
row.appendChild(cell);
|
||||||
}
|
}
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
// 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";
|
||||||
|
|
||||||
|
const title = document.createElement("div");
|
||||||
|
title.className = "event-title";
|
||||||
|
|
||||||
|
if (occurrence.url) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = occurrence.url;
|
||||||
|
link.textContent = occurrence.summary;
|
||||||
|
title.appendChild(link);
|
||||||
|
} else {
|
||||||
|
title.textContent = occurrence.summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.appendChild(title);
|
||||||
|
|
||||||
|
const time = document.createElement("div");
|
||||||
|
time.className = "event-time";
|
||||||
|
time.textContent = formatTimeRange(occurrence);
|
||||||
|
item.appendChild(time);
|
||||||
|
|
||||||
|
if (occurrence.description) {
|
||||||
|
const description = document.createElement("div");
|
||||||
|
description.className = "event-description";
|
||||||
|
description.textContent = occurrence.description;
|
||||||
|
item.appendChild(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEventDetails(dateKey) {
|
||||||
|
const occurrences = eventsByDate[dateKey];
|
||||||
|
eventDateElem.textContent = formatDate(dateKey);
|
||||||
|
eventDetailsElem.innerHTML = "";
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
eventPanel.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
// Convert YYYY-MM-DD to DD.MM.YYYY
|
||||||
|
const parts = dateStr.split("-");
|
||||||
|
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = timeOfDayFormat.format(occurrence.start);
|
||||||
|
const end = timeOfDayFormat.format(occurrence.end);
|
||||||
|
|
||||||
|
return `Beginn: ${start}, Ende: ${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
function updateEventsForMonth(year, month) {
|
loadCalendar()
|
||||||
// Clear existing events for this month view
|
.then(loaded => {
|
||||||
eventsByDate = {};
|
calendar = loaded;
|
||||||
|
updateEventsForMonth(currentYear, currentMonth);
|
||||||
// Process each event, expanding recurring ones
|
})
|
||||||
events.forEach(ev => {
|
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = "";
|
|
||||||
|
|
||||||
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 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("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 || 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showEventDetails(dateKey) {
|
|
||||||
const events = 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";
|
|
||||||
|
|
||||||
let eventTitle = document.createElement("div");
|
|
||||||
eventTitle.className = "event-title";
|
|
||||||
|
|
||||||
// Create a link for the event title
|
|
||||||
if (ev.url) {
|
|
||||||
let titleLink = document.createElement("a");
|
|
||||||
titleLink.textContent = ev.summary;
|
|
||||||
titleLink.href = ev.url;
|
|
||||||
titleLink.target = "_blank";
|
|
||||||
titleLink.rel = "noopener noreferrer";
|
|
||||||
eventTitle.appendChild(titleLink);
|
|
||||||
} else {
|
|
||||||
eventTitle.textContent = ev.summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
eventItem.appendChild(eventTitle);
|
|
||||||
|
|
||||||
let eventTime = document.createElement("div");
|
|
||||||
eventTime.className = "event-time";
|
|
||||||
eventTime.textContent = `Start: ${formatTime(ev.start)}, End: ${formatTime(ev.end)}`;
|
|
||||||
eventItem.appendChild(eventTime);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
eventDetailsElem.appendChild(eventItem);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let noEvents = document.createElement("div");
|
|
||||||
noEvents.className = "no-events";
|
|
||||||
noEvents.textContent = "Keine Veranstaltungen an diesem Tag.";
|
|
||||||
eventDetailsElem.appendChild(noEvents);
|
|
||||||
}
|
|
||||||
|
|
||||||
eventPanel.style.display = "block";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
// Convert YYYY-MM-DD to DD.MM.YYYY
|
|
||||||
const parts = dateStr.split("-");
|
|
||||||
return `${parts[2]}.${parts[1]}.${parts[0]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
updateEventsForMonth(currentYear, currentMonth);
|
|
||||||
|
|
||||||
})
|
|
||||||
.catch(err => console.error('Fehler beim Laden der ICS-Datei:', err));
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,12 @@ function timeDistanceDE(pastDate) {
|
||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
if (days >= 1) return `seit ${days}d`
|
if (days === 1) return `since ${days}d`
|
||||||
if (hours >= 1) return `seit ${hours}h`
|
if (days > 1) return `since ${days}d`
|
||||||
if (minutes >= 1) return `seit ${minutes}min`
|
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`
|
||||||
return `gerade eben`
|
return `gerade eben`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
194
assets/js/events.js
Normal file
194
assets/js/events.js
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
assets/js/upcoming.js
Normal file
105
assets/js/upcoming.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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
Normal file
19
assets/js/vendor/README.md
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
# 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
Normal file
9732
assets/js/vendor/ical.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
3
build.sh
3
build.sh
|
|
@ -1,7 +1,6 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
set -e
|
set -ex
|
||||||
set -x
|
|
||||||
|
|
||||||
rm -rf public/ # delete old generated files
|
rm -rf public/ # delete old generated files
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ filename = "sitemap.xml"
|
||||||
priority = 0.5
|
priority = 0.5
|
||||||
|
|
||||||
[permalinks]
|
[permalinks]
|
||||||
post = "/post/:year/:month/:day/:title/"
|
post = "/post/:year/:month/:day/:slug/"
|
||||||
|
|
||||||
[outputs]
|
[outputs]
|
||||||
home = ["html", "rss", "json"]
|
home = ["html", "rss", "json"]
|
||||||
|
|
|
||||||
15
content/post/2026-07-04-kein-diday-im-cccb/index.md
Normal file
15
content/post/2026-07-04-kein-diday-im-cccb/index.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
---
|
||||||
|
title: "Kein DI.Day am 5. Juli im CCCB"
|
||||||
|
date: 2026-07-04T12:00:00+02:00
|
||||||
|
showHero: false
|
||||||
|
tags: ["di.day", "Ankündigung"]
|
||||||
|
---
|
||||||
|
|
||||||
|
**Kurzfristige Änderung:** Der [Digital Independence Day](/veranstaltungen/didit/) findet am **Sonntag, dem 5. Juli 2026, nicht** im Chaos Computer Club Berlin (Marienstraße 11) statt.
|
||||||
|
|
||||||
|
Wer trotzdem am DI.Day teilnehmen und den Weg in die digitale Unabhängigkeit weitergehen möchte, ist herzlich bei den anderen Berliner Veranstaltungen willkommen:
|
||||||
|
|
||||||
|
- [„Let's DID it!" mit Linux & Sommerfest der BELUG](https://f.termine.di.day/events/ee48fdda-a134-4f27-932e-b87c7a9fa24a) in der c-base
|
||||||
|
- [DI.Day im Stadtschloss Moabit](https://f.termine.di.day/events/41ab17fe-05ab-4308-9abb-d38d9485fd4f)
|
||||||
|
|
||||||
|
Weitere Termine und Orte findet ihr wie immer auf [termine.di.day](https://termine.di.day/).
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 26 KiB |
|
|
@ -1,58 +0,0 @@
|
||||||
---
|
|
||||||
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.
|
|
||||||
57
content/post/2026-08-23-aktionstag-ueberwachung/index.md
Normal file
57
content/post/2026-08-23-aktionstag-ueberwachung/index.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
---
|
||||||
|
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)
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
title: "Der Neujahresempfang 2026 #NJE26"
|
title: "Der Neujahresempfang 2026 #NJE26"
|
||||||
date: 2026-05-23T12:00:00+01:00
|
slug: "neujahresempfang-2026"
|
||||||
|
date: 2026-03-05T12:00:00+01:00
|
||||||
showHero: false
|
showHero: false
|
||||||
tags: ["NJE", "Ankündigung"]
|
tags: ["NJE", "Ankündigung"]
|
||||||
|
|
||||||
|
|
|
||||||
23
content/veranstaltungen/aktionstag-ueberwachung.md
Normal file
23
content/veranstaltungen/aktionstag-ueberwachung.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
---
|
||||||
|
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
|
||||||
|
|
@ -4,17 +4,19 @@ subtitle: "Digital Independence Day im CCCB"
|
||||||
date: 2026-01-04T12:23:00+01:00
|
date: 2026-01-04T12:23:00+01:00
|
||||||
dtstart: 20260104T122300
|
dtstart: 20260104T122300
|
||||||
dtend: 20260104T174200
|
dtend: 20260104T174200
|
||||||
rrule: "FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU;WKST=MO"
|
rrule: "FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU;WKST=MO;UNTIL=20260503T235959Z"
|
||||||
menu:
|
menu:
|
||||||
main:
|
main:
|
||||||
parent: "Veranstaltungen"
|
parent: "Veranstaltungen"
|
||||||
tag: ["Veranstaltung"]
|
tag: ["Veranstaltung"]
|
||||||
---
|
---
|
||||||
|
|
||||||
**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).
|
In Berlin wird der [Digital Independence Day](https://diday.org) an verschiedenen Orten von einer breiten Zahl von Initiativen getragen.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Weitere Informationen gibt es im [Ankündigungsbeitrag](https://berlin.ccc.de/post/2026/07/05/berlin-%23didit/).
|
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).
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,4 @@ menu:
|
||||||
tag: ["Veranstaltung"]
|
tag: ["Veranstaltung"]
|
||||||
---
|
---
|
||||||
|
|
||||||
Am 23. Mai lädt der Chaos Computer Club Berlin zum Neujahresempfang. [Hier geht es zur Infoseite!](https://berlin.ccc.de/post/2026/03/05/der-neujahresempfang-2026-%23nje26/)
|
Am 23. Mai lädt der Chaos Computer Club Berlin zum Neujahresempfang. [Hier geht es zur Infoseite!](https://berlin.ccc.de/post/2026/03/05/neujahresempfang-2026/)
|
||||||
|
|
@ -12,4 +12,15 @@ herostyle: big
|
||||||
|
|
||||||
{{< calendar >}}
|
{{< calendar >}}
|
||||||
|
|
||||||
Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/all.ics)!
|
Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/calendars/all.ics)!
|
||||||
|
|
||||||
|
Oder willst du nur den Kalender von bestimmten Events?
|
||||||
|
|
||||||
|
- [Club Discordia](/calendars/Club_Discordia.ics)
|
||||||
|
- [Neujahresempfang](/calendars/Neujahresempfang.ics)
|
||||||
|
- [Datengarten](/calendars/Datengarten.ics)
|
||||||
|
- [Spieleabend](/calendars/Spieleabend.ics)
|
||||||
|
- [OpenWRT](/calendars/OpenWRT.ics)
|
||||||
|
- [Plenum](/calendars/Plenum.ics)
|
||||||
|
- [Amateurfunk](/calendars/Amateurfunk.ics)
|
||||||
|
- [Irreguläre Events](/calendars/Random.ics)
|
||||||
|
|
|
||||||
8
flake.lock
generated
8
flake.lock
generated
|
|
@ -2,16 +2,16 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1748995628,
|
"lastModified": 1786313170,
|
||||||
"narHash": "sha256-bFufQGSAEYQgjtc4wMrobS5HWN0hDP+ZX+zthYcml9U=",
|
"narHash": "sha256-9BG7OgUWdu0ONDO5X2q6+K4bsuBITkX/3W4nNJu1Ito=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8eb3b6a2366a7095939cd22f0dc0e9991313294b",
|
"rev": "fcb8fcd6bf2d0adecae5bd491afaaaf8311b758d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"ref": "nixos-24.11",
|
"ref": "nixos-26.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
description = "A flake containing a development environment for the CCCB website.";
|
description = "A flake containing a development environment for the CCCB website.";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs =
|
outputs =
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
|
|
||||||
devShells = forAllSystems (import ./devShells.nix);
|
devShells = forAllSystems (import ./devShells.nix);
|
||||||
|
|
||||||
formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-rfc-style);
|
formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree);
|
||||||
|
|
||||||
lib.mkWwwContent =
|
lib.mkWwwContent =
|
||||||
{ domain, system }:
|
{ domain, system }:
|
||||||
|
|
@ -50,10 +50,6 @@
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
pkgs.hugo
|
pkgs.hugo
|
||||||
pkgs.glibcLocales
|
pkgs.glibcLocales
|
||||||
(pkgs.python3.withPackages (python-pkgs: [
|
|
||||||
python-pkgs.icalendar
|
|
||||||
python-pkgs.pytz
|
|
||||||
]))
|
|
||||||
];
|
];
|
||||||
|
|
||||||
# LOCALE_ARCHIVE = builtins.trace pkgs.glibcLocales "${pkgs.glibcLocales}/lib/locale/locale-archive";
|
# LOCALE_ARCHIVE = builtins.trace pkgs.glibcLocales "${pkgs.glibcLocales}/lib/locale/locale-archive";
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{{ block "main" . }}{{ .Content }}{{ end }}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//CCCB//Calendar//DE
|
|
||||||
{{ with .Title }}
|
|
||||||
X-WR-CALNAME:{{ . }}
|
|
||||||
{{ end }}
|
|
||||||
CALSCALE:GREGORIAN
|
|
||||||
METHOD:PUBLISH
|
|
||||||
{{ range .Pages }}
|
|
||||||
{{ if .Date }}
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:{{ .File.UniqueID }}@berlin.ccc.de
|
|
||||||
DTSTAMP:{{ .Date.Format "20060102T150405Z" }}
|
|
||||||
DTSTART:{{ .Date.Format "20060102T150405Z" }}
|
|
||||||
SUMMARY:{{ .Title }}
|
|
||||||
DESCRIPTION:{{ .Summary | plainify }}
|
|
||||||
URL:{{ .Permalink }}
|
|
||||||
END:VEVENT
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
END:VCALENDAR
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
BEGIN:VCALENDAR
|
|
||||||
VERSION:2.0
|
|
||||||
PRODID:-//CCCB//Calendar//DE
|
|
||||||
{{ with .Title }}
|
|
||||||
X-WR-CALNAME:{{ . }}
|
|
||||||
{{ end }}
|
|
||||||
CALSCALE:GREGORIAN
|
|
||||||
METHOD:PUBLISH
|
|
||||||
{{ if .Date }}
|
|
||||||
BEGIN:VEVENT
|
|
||||||
UID:{{ .File.UniqueID }}@berlin.ccc.de
|
|
||||||
DTSTAMP:{{ .Date.Format "20060102T150405Z" }}
|
|
||||||
DTSTART:{{ .Date.Format "20060102T150405Z" }}
|
|
||||||
{{ with .Params.event.end }}DTEND:{{ dateFormat "20060102T150405Z" . }}{{ end }}
|
|
||||||
SUMMARY:{{ .Title }}
|
|
||||||
DESCRIPTION:{{ .Summary | plainify }}
|
|
||||||
URL:{{ .Permalink }}
|
|
||||||
{{ with .Params.location }}LOCATION:{{ . }}{{ end }}
|
|
||||||
END:VEVENT
|
|
||||||
{{ end }}
|
|
||||||
END:VCALENDAR
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{{ $js := resources.Get "js/calendar.js" }}
|
{{ $js := resources.Get "js/calendar.js" | js.Build (dict "minify" true "format" "esm" "target" "es2020") | fingerprint }}
|
||||||
{{ $css := resources.Get "css/calendar.css" }}
|
{{ $css := resources.Get "css/calendar.css" | minify | fingerprint }}
|
||||||
|
|
||||||
<div class="calendar-container">
|
<div class="calendar-container">
|
||||||
{{ with $css }}
|
{{ with $css }}
|
||||||
<link rel="stylesheet" href="{{ .RelPermalink }}">
|
<link rel="stylesheet" href="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}">
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ with $js }}
|
{{ with $js }}
|
||||||
<script src="{{ .RelPermalink }}"></script>
|
<script type="module" src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}"></script>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<div id="calendar">
|
<div id="calendar">
|
||||||
|
|
|
||||||
5
layouts/shortcodes/upcoming.html
Normal file
5
layouts/shortcodes/upcoming.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{{- $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>
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import html
|
|
||||||
import logging
|
|
||||||
import locale
|
|
||||||
from datetime import datetime, timedelta, timezone, date
|
|
||||||
from dateutil.rrule import rruleset, rrulestr
|
|
||||||
|
|
||||||
import icalendar
|
|
||||||
|
|
||||||
|
|
||||||
def _to_aware(dt):
|
|
||||||
"""Normalize a date or datetime to a timezone-aware UTC datetime."""
|
|
||||||
if isinstance(dt, date) and not isinstance(dt, datetime):
|
|
||||||
return datetime(dt.year, dt.month, dt.day, tzinfo=timezone.utc)
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
return dt.replace(tzinfo=timezone.utc)
|
|
||||||
return dt.astimezone(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def vevent_to_event(event, rrstart=None):
|
|
||||||
if rrstart is None:
|
|
||||||
begin = _to_aware(event["DTSTART"].dt)
|
|
||||||
else:
|
|
||||||
begin = _to_aware(rrstart)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": str(event.get("SUMMARY", "")),
|
|
||||||
"url": str(event.get("URL", "")),
|
|
||||||
"begin": begin
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_single_event(event, start, end):
|
|
||||||
logging.info(f"Processing single event {event.get('SUMMARY', '')}")
|
|
||||||
dtstart = _to_aware(event["DTSTART"].dt)
|
|
||||||
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.get('SUMMARY', '')}")
|
|
||||||
dtstart = _to_aware(event["DTSTART"].dt)
|
|
||||||
rs = rruleset()
|
|
||||||
rs.rrule(rrulestr(event["RRULE"].to_ical().decode("utf-8"), dtstart=dtstart))
|
|
||||||
if "EXDATE" in event.keys():
|
|
||||||
exdates = event["EXDATE"]
|
|
||||||
if not isinstance(exdates, list):
|
|
||||||
exdates = [exdates]
|
|
||||||
for exdate_list in exdates:
|
|
||||||
for dt in exdate_list.dts:
|
|
||||||
rs.exdate(_to_aware(dt.dt))
|
|
||||||
|
|
||||||
events = []
|
|
||||||
for dt in rs.between(start, end, inc=True):
|
|
||||||
events.append(vevent_to_event(event, dt))
|
|
||||||
|
|
||||||
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:
|
|
||||||
name = html.escape(event['name'])
|
|
||||||
url = html.escape(event['url'])
|
|
||||||
print(
|
|
||||||
"<tr>"
|
|
||||||
f"<td>{event['begin'].strftime('%A, %d.%m um %H:%M Uhr')}</td>"
|
|
||||||
f"<td><a href=\"{url}\">{name}</a></td>"
|
|
||||||
"</tr>"
|
|
||||||
)
|
|
||||||
print("</table><!--/.table .table-condensed-->")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) < 4:
|
|
||||||
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(timezone.utc)
|
|
||||||
events = find_events(calendar, now, now + timedelta(days=max_days), max_items)
|
|
||||||
format_events(events)
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from glob import glob
|
|
||||||
|
|
||||||
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):
|
|
||||||
try:
|
|
||||||
with open(icsfilestr, "r") as icsfile:
|
|
||||||
print(f"Importing {icsfilestr}")
|
|
||||||
calendars.append(icalendar.Calendar.from_ical(icsfile.read()))
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Skipping {icsfilestr}: {e}")
|
|
||||||
|
|
||||||
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