Compare commits

..
Author SHA1 Message Date
35d35161a7 Merge branch 'production' into staging 2026-08-25 20:58:13 +02:00
3173d0766c Update .forgejo/workflows/deploy.yaml 2026-08-25 20:57:44 +02:00
2d688a14a3 Update .forgejo/workflows/deploy.yaml 2026-08-25 20:47:17 +02:00
4d692c5b48 Update .forgejo/workflows/deploy.yaml 2026-08-25 20:45:27 +02:00
746583df5b Merge pull request 'Fixes fuer unseren Kalender auf der website' (#57) from hauke/www:kalender into staging
Reviewed-on: cccb-website-team/www#57
2026-08-23 01:14:45 +02:00
6782da49d4 Move what the two calendar views share into one module
Both views read the same file and ask it the same question, only the
answer is presented differently: the start page lists the next few
occurrences, the calendar page marks the occurrences of one month. Since
they were taught to read a calendar properly they also carry the same
code for it, twice and word for word, some 130 lines of `eventUrl()`,
`exceptionsByUid()`, `iterationEnd()`, the fetch with its check of the
response and the walk over the events of the calendar. Every fix so far
had to be written twice, and the next one that is only written once
leaves the two views disagreeing about the same calendar.

Put it into `assets/js/events.js`, which offers what both need:

- `loadCalendar()` fetches and parses `/calendars/all.ics`
- `occurrencesBetween()` walks the occurrences that touch a window,
  expanding recurring events and resolving the ones that were modified
  on their own
- `eventUrl()` reads the URL of an event

`upcoming.js` and `calendar.js` keep what is really theirs, the shape of
their entries and how they are drawn, and both lose their own import of
ical.js: it is the concern of the module that reads the calendar now.
The two of them shrink from 259 and 549 lines to 105 and 406.

Hugo bundles the module into both scripts, so no shortcode changes and
no second request.

The walk keeps an occurrence when it starts at or before the end of the
window and ends after its start. That is the condition the start page
used; the calendar page compared against the start of the month with "<"
instead of "<=". Its window reaches two days past the month, so the day
this can differ on is nowhere near it.

No behaviour changes with this: the upcoming list over 120 days and
every day of the month view from 2025 to 2028, taken from
https://berlin.ccc.de/calendars/all.ics with the name, URL, start, end
and all day flag of each entry, are identical before and after, and so
are the results of the tests for moved occurrences, for modifications
between two series and for all day events in Berlin, Tokyo, Los Angeles
and Kiritimati.

Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
75dd9ca997 Separate date and name in the upcoming events table
The two columns of the table on the start page touched each other, so the entry
read "Donnerstag, 27.08., 19:00 UhrClub Discordia".

The table is created with the classes "table table-condensed", which no
stylesheet of the site defines, and the table styling that the theme applies
inside prose addresses "tbody td". The table is delivered empty and its rows
are added through the DOM, where a tr appended to a table stays a direct child
instead of being put into a tbody the way the HTML parser would. The rows are
therefore outside of any tbody and the padding of the theme never applied.

Give the column holding the date its own padding, and keep the date on one
line, it is one piece of information and reads badly broken after the weekday.

The padding alone does not fit, though. The table stands in a prose column that
the theme limits to 65 characters so that running text stays readable, and a
date and the name of an event next to each other are wider than that, so the
names would be 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 heading and the paragraph around it keep their width.

That much space then has to be filled sensibly. The theme lays a table out as a
block, "table { display: block; overflow: auto }", so that a wide one can be
scrolled sideways, and a block fills its parent instead of shrinking to its
content the way a table does. Spanning the page the entries would all sit at
its left edge. Ask for the width of the content with fit-content, which the
automatic margins then centre.

Addressing the table by its id keeps all of this to the start page and takes
precedence over the theme, whose prose rules are written with :where() and
carry no specificity.

Measured in a browser at 1280, 768 and 500 pixels: date and name share one line
at the first two, the table is 602 pixels wide with the same distance left and
right, and at 500 the column is narrower than the table, so the table fills it
and only the longest name wraps. The page never scrolls sideways.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
60ab628270 Parse the calendar with ical.js
The calendar page brought its own ICS parser and its own RRULE expansion. Both
only covered the cases that happened to be needed when they were written, and
the calendar has moved on since. Against the published calendar, for September
2026:

  Spieleabend  FREQ=WEEKLY;INTERVAL=2;BYDAY=SA  shown 05. 12. 19. 26., correct 05. 19.
  CCCB Plenum  FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2 shown 01.,               correct 08.
  CCCB Plenum  FREQ=MONTHLY;BYDAY=TU;BYSETPOS=4 shown 01.,               correct 22.

INTERVAL was only read for monthly rules, so the Spieleabend was shown twice as
often as it takes place. BYSETPOS was not implemented at all, and since
parseInt("TU") is NaN the fallback turned both Plenum rules into "first
Tuesday", putting two Plenums on a day without one and none on the two days
with one. UNTIL, COUNT, EXDATE, RECURRENCE-ID, BYMONTHDAY and a BYDAY listing
more than one weekday were not handled either.

The text was no better. Content lines longer than 75 characters are continued
on the next line, of which there are 477 in the calendar, and the parser did
not join them, so it cut values off in the middle of a word. It also split
every line at the first colon, which lands inside the parameter of
DESCRIPTION;ALTREP="data:text/html,...". And it never resolved the escaping, so
"\n" was shown as those two characters. 30 of 31 descriptions were wrong:

  before: "Der Club Discordia ist ein öffentliches Treffen in den Clubr"
  after:  "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin"

Hand the parsing and the expansion to ical.js, which is vendored for the start
page anyway. The month view now asks the library for the occurrences that touch
the month, which removes the reimplementation along with all of the above.

While the events are being reduced to what the view needs:

- An event is entered on every day it covers, so the Amateurfunk trip from
  30.10. to 01.11. is no longer marked on 30.10. alone. The end of an event is
  not part of it, so one ending at midnight stays on the day before.
- A time that names a zone is converted to Europe/Berlin instead of being read
  off the digits of the ICS string. Every event currently carries
  TZID=Europe/Berlin, so the wall clock time shown does not change, but a UTC
  timestamp would have been shown in UTC. A date is a different matter, see
  further down.
- The URL of the event is used for the link in the detail panel. Events without
  one are shown without a link, as on the start page. This replaces
  createEventLink(), which guessed URLs from the title and was never called,
  and the panel no longer builds an <h> element, which is not an element.

Descriptions may contain line breaks, so keep them in the panel. The times of
an event are labelled in German like the rest of the page, "Beginn" and "Ende"
instead of "Start" and "End".

An all day event carries a date, and a date has neither a time nor a zone: its
digits are the day itself. `toJSDate()` reads them as midnight in the zone of
the browser, and deriving the Berlin day from that afterwards moves the event
by the offset between the two, so the promise of the same days everywhere would
have held for every event except the ones that consist of nothing but days. An
event on 30. and 31.08. would have been marked on:

  Berlin       30. 31.08.
  Los Angeles  30. 31.08. 01.09.
  Tokio        29. 30. 31.08.

Take the day from the ICAL time, which still knows whether it names a day or a
point in time, and convert only the latter. Stepping to the end of the event
moves by a day where it is made of days and by a second where it is not, which
also expresses "the end is not part of the event" in the terms of the event
itself. The occurrences of a day are sorted by their start, which for an all
day event is that same midnight, so order them before the timed events instead.

Resolving an occurrence that was modified on its own needs two more things to
be right. Unless it is told which modifications belong to an event,
`ICAL.Event` relates every VEVENT with a RECURRENCE-ID in the file to every
recurring event and keys them by the recurrence id alone; the UID is only
compared with `strictExceptions`, which then throws instead of skipping. A
modification would therefore also override the occurrence another series holds
at the same instant, so the detail panel of that day would show the wrong event
and the modified one twice. Group the modifications by the UID of the event
they belong to and hand each event its own.

And the expansion walks the unmodified recurrence times, so an occurrence
pulled forward into the month from a later one would never be reached: the walk
stops at its original time, and the month it was moved out of drops it because
it no longer falls into it, which loses it from the calendar altogether.
Iterate far enough that the largest move towards the past can still reach the
month. Moving a Plenum or a Club Discordia to the week before, out of the way
of a holiday, is exactly what produces such a modification. With a weekly
series whose occurrence of 07.09. is moved to 28.08.:

  before: August     03. 10. 17. 24. 31.
          September  14. 21. 28.
  after:  August     03. 10. 17. 24. 28. 31.
          September  14. 21. 28.

The URL 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 is;
`URL:javascript:alert(1)` on an event would run that script when a visitor
clicks the name. Pass on nothing but http and https.

The stylesheet of the page goes through `minify | fingerprint` while the script
next to it is rewritten, so both are delivered the way the assets of the start
page already are: smaller, under a name that carries their content hash, and
with an integrity hash in the tag.

The published calendar has neither all day events nor RECURRENCE-ID today, both
can be created in the CalDAV calendar the export comes from. Checked in Berlin,
Tokyo, Los Angeles and Kiritimati: an all day event over 30. and 31.08. is
marked on those two days in all four, one from 31.08. to 02.09. is marked
across the month boundary, and the month view of the published calendar is the
same in all of them.

Fixes: 4068fab565 ("improved calendar and fixed url temporarily")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
a0e1ef046a Vendor ical.js instead of loading it from unpkg.com
The upcoming events table pulled its ICS parser straight from a CDN with
`import ICAL from "https://unpkg.com/ical.js/dist/ical.min.js"`. That sends
every visitor of the start page to unpkg.com, which hands their IP address and
user agent to a third party before any of our own code runs. The URL is not
even pinned to a version, so whatever ical.js publishes next is executed on our
site without anybody looking at it, and the start page silently breaks when the
CDN is unreachable.

Check the parser into `assets/js/vendor/` and let Hugo bundle it. This is what
`js.Build` is for: it runs the esbuild that is built into Hugo, so it resolves
the import at build time and needs no node_modules and no extra tooling in the
build environment. The result is minified and fingerprinted like the other
scripts of the site, and the script tag carries a subresource integrity hash.

Since the bundle now has a content hash in its name, its URL cannot be written
by hand in the markdown any more. Move the table and the script tag into an
`upcoming` shortcode, which is the same pattern `calendar.html` already uses,
and move `upcoming.js` from `static/` to `assets/` so Hugo can process it.

The vendored file is the unminified `dist/ical.js` of the pinned release: it
carries the MPL-2.0 header and is the source form of what we ship, and Hugo
minifies it for delivery anyway. `assets/js/vendor/README.md` records the
version, where it came from and how to update it.

The built bundle renders the same table as before, checked against
https://berlin.ccc.de/calendars/all.ics.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
4545852327 Report errors while loading the calendar
The response of the fetch went to the parser without ever looking at it. When
the calendar could not be loaded the error page of the web server was parsed as
a calendar, which threw inside a promise nobody was waiting on. The result was
an empty table, an unhandled rejection in the console and an error message
about broken calendar syntax that says nothing about the actual problem, a
calendar that is not there.

Refuse a response that is not ok, naming the status, and log failures in the
chain, like the calendar page already does.

  before: Uncaught (in promise) Error: invalid line (no token ";" or ":")
                                       "<html>404 Not Found</html>"
  after:  Fehler beim Laden der Termine:
          Error: /calendars/all.ics: 404 Not Found

The table stays empty either way, there is nothing to show without a calendar.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
cd39842ac0 Show event times in Berlin time
`toLocaleString()` was given the German locale but no time zone, so it printed
the time in whatever zone the browser of the visitor is set to. The events
happen in Berlin, so this is only correct for visitors who are in Berlin.
Somebody reading the start page from Sydney was told the Plenum of Tuesday
20:00 takes place on Wednesday at 04:00.

Format in Europe/Berlin explicitly. For an event that names a point in time
only the printing was wrong, picking and sorting work on absolute points in
time and were not affected.

An all day event carries a date, and a date has neither a time nor a zone: its
digits are the day itself. `toJSDate()` reads them as midnight in the zone of
the browser, so the point in time that is then printed in Berlin time is off by
the offset between the two, which puts a bogus time of day on the entry and,
far enough east or west, the wrong day. An event on 30.08. was announced as:

  Berlin       Sonntag, 30.08., 00:00 Uhr
  Los Angeles  Sonntag, 30.08., 09:00 Uhr
  Tokio        Samstag, 29.08., 17:00 Uhr

Keep the digits of the date and read them as UTC, which no browser setting
moves, and print an all day event in UTC and without a time of day. Berlin,
Tokyo, Los Angeles and Kiritimati all say "Sonntag, 30.08." now, while a timed
event keeps saying "Sonntag, 30.08., 19:00 Uhr" everywhere. Sorting improves
with it, an all day event no longer changes its place in the list depending on
where the visitor sits.

The published calendar has no all day events today, they can be created in the
CalDAV calendar the export comes from.

Drop the two replacements around the formatted date while touching it. The
first removes the comma after the weekday and the second puts it back, so they
cancel each other out:

  "Samstag, 22.08., 17:00" -> "Samstag 22.08., 17:00" -> "Samstag, 22.08., 17:00"

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
6d99a39190 Do not list a moved event occurrence twice
An occurrence of a recurring event that was changed on its own carries a
RECURRENCE-ID and is stored as an additional VEVENT next to the event it
belongs to. `getAllSubcomponents("vevent")` returns those components as well,
and since they have no RRULE of their own they were handled as separate single
events. The occurrence therefore ended up in the list twice: once from
expanding the recurring event, which resolves the modified time through the
exception, and once more from the extra VEVENT.

To make it worse the two rows disagreed, because name and URL were taken from
the recurring event while the time came from the modification, so the first row
showed the new time under the old name.

Skip components that are a recurrence exception, they are already covered by
the event they modify, and take name and URL from the occurrence details, which
point at the modification where there is one and at the event itself otherwise.
Events whose RECURRENCE-ID refers to an event that is not in the file are
dropped by this, which cannot happen in a full calendar export.

With a recurring Plenum whose 25.08. occurrence is moved two hours earlier and
renamed:

  before: 25.08. 18:00  CCCB Plenum
          25.08. 18:00  CCCB Plenum (verschoben)
  after:  25.08. 18:00  CCCB Plenum (verschoben)

Leaning on the resolution ical.js does here needs two more things to be right.

The first is which modifications an event is asked to resolve. Unless it is
told, `ICAL.Event` relates every VEVENT with a RECURRENCE-ID in the file to
every recurring event and keys them by the recurrence id alone; the UID is only
compared with `strictExceptions`, which then throws instead of skipping. A
modification would therefore also override the occurrence another series holds
at the same instant, so the wrong entry is shown and the modified one twice
over. Group the modifications by the UID of the event they belong to and hand
each event its own, which also turns the relating off for events that have
none. Recognise a modification on the component instead of on the event,
because relating exceptions to an exception throws. With two unrelated weekly
series that both meet on Thursday at 19:00, of which only A has its occurrence
of 03.09. moved to 17:00:

  before: 27.08. 19:00 Serie A          after: 27.08. 19:00 Serie A
          27.08. 19:00 Serie B                 27.08. 19:00 Serie B
          03.09. 17:00 Serie A (versch.)       03.09. 17:00 Serie A (versch.)
          03.09. 17:00 Serie A (versch.)       03.09. 19:00 Serie B

The second is how far the expansion has to run. It walks the unmodified
recurrence times and stopped at the end of the window, but the occurrence
handed to `getOccurrenceDetails()` may have been moved somewhere else entirely.
Both directions were wrong: an occurrence pulled forward from beyond the window
was never reached, because the walk had already stopped at its original time,
and one pushed out of the window was still listed, because only its original
time was ever compared against the window. Iterate far enough that the largest
move towards the past can still reach the window, and decide by the time the
occurrence really takes place at. The stop condition keeps using the raw
recurrence time, which stays monotonic, so the iteration still terminates. With
a weekly series whose occurrence of 05.10. is pulled forward to 25.08. and
whose occurrence of 31.08. is pushed to 02.11., seen from 23.08. through the 20
day window:

  before: 24.08. Serie                 after: 24.08. Serie
          31.08. Serie                        25.08. Serie (vorgezogen)
          07.09. Serie                        07.09. Serie

Reading the URL becomes a function of its own while name and URL move to the
occurrence details, and it now looks at what it reads. The calendar is exported
from a CalDAV server, so whoever may write to it decides what ends up in the
href of the link, and `URL:javascript:alert(1)` on an event would run that
script when a visitor clicks the entry. Pass on nothing but http and https; a
value that is not a URL at all no longer reaches the href either.

The published calendar contains no RECURRENCE-ID at all today, so nothing about
the modifications changes for it. It is exported from a CalDAV server though,
and moving a single Club Discordia or Plenum out of the way of a holiday is
exactly what creates such a modification. Checked against
https://berlin.ccc.de/calendars/all.ics: the list and the links of the 30
events that carry a URL are unchanged.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
8b692a2e88 Keep running events in the upcoming list until they end
The "Nächste Veranstaltungen" table selected events with `start > now`, so an
event disappeared from the list the moment it began. Someone looking at the
start page at 20:30 no longer saw the Plenum that had started at 20:00 and ran
until 22:00, which is exactly when that information is most useful.

Select on the end of the event instead, so an event stays listed for as long as
it is still running. A currently running event sorts first, because the list is
ordered by start time.

For recurring events the end of the individual occurrence is needed, not the
end of the series, so go through `getOccurrenceDetails()`. That also resolves
occurrences overridden via RECURRENCE-ID. The chronological break out of the
iteration keeps using the raw occurrence time, which stays monotonic even when
an override moves a single occurrence. `ICAL.Event.endDate` falls back to
DURATION and, for all-day events, to the following day, so events without an
explicit DTEND keep working.

The `maxDays` window still applies to the start of an event, so a long running
event does not extend the window.

This is not a regression from the commit below, the Python generator it
replaced filtered on `dtstart >= start` in the same way.

Checked against https://berlin.ccc.de/calendars/all.ics: the Plenum (20:00 to
22:00) is now listed at 20:30 and 21:59 and gone at 22:01, and the multi-day
Amateurfunk trip (30.10. 12:00 to 01.11. 18:00) stays listed throughout.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
b8ec457818 Link upcoming events to their ICS URL
The "Nächste Veranstaltungen" table read the event URL via `ICAL.Event.url`,
but ical.js does not expose a `url` getter on `ICAL.Event` (it only has uid,
summary, description, color, location, sequence, the dates, organizer and
attendees). `event.url` was therefore always `undefined` and the `?? ""`
fallback turned it into an empty string, so every row rendered as
`<a href="">`, a dead link that just reloads the start page.

Read the URL from the VEVENT component instead. This also picks up the
`URL;VALUE=URI:` form used by most events in the published calendar, which is
exported from a CalDAV client and does not use a bare `URL:` property.

Not every event has a URL, so only wrap the name in a link when one is
present and emit plain text otherwise.

Checked against https://berlin.ccc.de/calendars/all.ics (31 events, 1 of them
without a URL):

  before: <td><a href="">CCCB Plenum</a></td>
  after:  <td><a href="https://wiki.berlin.ccc.de/Plenum">CCCB Plenum</a></td>
  after:  <td>Aktionstag gegen Überwachung im Chaos Computer Club Berlin</td>

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-23 01:07:25 +02:00
b11d80aac2 README: describe the calendar as it is built today
`build.sh` no longer post-processes anything: since the switch to ICS files it
just deletes `public/` and runs `hugo`. The `CALENDAR` placeholder, the Python
dependency on `icalendar`, `python-dateutil` and `pytz`, and the `de_DE.UTF-8`
locale requirement are all gone, and nothing in `packages.nix`, `devShells.nix`
or `flake.nix` pulls in Python any more. Drop those claims.

Describe instead where the two calendar views actually come from: the
"Nächste Veranstaltungen" table and the calendar page are rendered in the
browser from `/calendars/all.ics`, which is published next to the site and is
not built from this repository. That also replaces the old advice to preview
them via `./build.sh` plus a local HTTP server, which no longer helps.

Without that file the two of them stay empty. Hugo serves everything below
`static/` from the root of the site, so it can simply be put there, and the
command that downloads it is worth writing down. Checked from an empty state:
afterwards the file is served under `/calendars/all.ics` by `hugo serve` and the
table on the start page fills with the upcoming events. It is a copy of
published data and has no business in the repository, so ignore it.

Mention the per-section `.ics` feeds Hugo does generate, so the ICS templates
under `layouts/` are not mistaken for the source of `all.ics`.

Fixes: c28f04c6e8 ("switch to ics files; make calendars work; fix some minor issues")
Assisted-by: Claude:claude-opus-5
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-08-22 21:22:02 +02:00
9be6287a68 Delete .forgejo/workflows/test.yaml 2026-08-22 15:22:52 +02:00
df7add59ee Add .forgejo/workflows/test.yaml 2026-08-21 22:32:15 +02:00
864132202d Merge pull request 'Update .forgejo/workflows/deploy.yaml' (#55) from staging into production
Reviewed-on: cccb-website-team/www#55
2026-08-21 21:37:37 +02:00
e93b399854 Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-08-21 21:36:22 +02:00
d0420d865d Merge pull request 'Now with external ICS files from dav.berlin.ccc.de' (#54) from staging into production
Reviewed-on: cccb-website-team/www#54
2026-08-12 10:46:01 +02:00
f6b5413d19 Delete .forgejo/workflows/miau.yaml 2026-08-12 02:26:23 +02:00
2696f0af4e python is not needed anymore
Signed-off-by: xengi <cccb-git@xengi.de>
2026-08-12 02:06:53 +02:00
b9739ca58c Merge branch 'production' into staging 2026-08-12 02:05:42 +02:00
facc7d15bf Merge pull request 'switch to ics files; make calendars work; fix some minor issues' (#53) from caldav into staging
Reviewed-on: cccb-website-team/www#53
2026-08-12 02:03:34 +02:00
c28f04c6e8
switch to ics files; make calendars work; fix some minor issues 2026-08-12 02:01:58 +02:00
1dcc6c473b Merge pull request 'staging' (#52) from staging into production
Reviewed-on: cccb-website-team/www#52
2026-08-11 23:32:50 +02:00
5368e75240 Add .forgejo/workflows/miau.yaml 2026-08-11 23:31:40 +02:00
b1ff3727c8 Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-08-11 23:21:32 +02:00
4e2c358668 Merge pull request 'fu github pipelines' (#51) from staging into production
Reviewed-on: cccb-website-team/www#51
2026-08-11 23:15:28 +02:00
e690426b3f Merge branch 'production' into staging 2026-08-11 23:15:14 +02:00
7689abaf3e Update .forgejo/workflows/deploy.yaml 2026-08-11 23:13:51 +02:00
bafe2bcb81 Update .forgejo/workflows/deploy.yaml 2026-08-11 23:05:26 +02:00
bac896e60f Update .forgejo/workflows/deploy.yaml 2026-08-11 22:57:01 +02:00
87e1230301
unfix pipeline 2026-08-11 22:45:17 +02:00
f839b1b01a Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-08-11 22:37:45 +02:00
2ba5b8cfa8
fix pipeline 2026-08-11 22:29:30 +02:00
96919a7188
fix pipeline 2026-08-11 22:20:30 +02:00
dfae28d575 Update .forgejo/workflows/deploy.yaml 2026-08-11 22:10:47 +02:00
c2a2f6e160 Update .forgejo/workflows/deploy.yaml 2026-08-11 22:07:01 +02:00
d3fd2ec8a7 Update .forgejo/workflows/deploy.yaml 2026-08-11 22:05:43 +02:00
c18cce53f8 Update .forgejo/workflows/deploy.yaml 2026-08-11 22:05:13 +02:00
9cca35810f Merge pull request 'staging' (#50) from staging into production
Reviewed-on: cccb-website-team/www#50
2026-08-11 21:42:16 +02:00
5f603574e3 :nerd: minor spelling mistake ❇️
Signed-off-by: aprl <aprl@noreply.git.berlin.ccc.de>
2026-08-11 21:41:01 +02:00
3afd801344 Merge pull request '2026_08_23_Aktionstag_gegen_Ueberwachung' (#49) from aprl/www:2026_08_23_Aktionstag_gegen_Ueberwachung into staging
Reviewed-on: cccb-website-team/www#49
2026-08-11 21:39:38 +02:00
April John
9b8fbc0106 Text natürlicher formuliert, Gedankenstriche entfernt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 21:34:13 +02:00
April John
8b4f6d3c95 Aktionstag gegen Überwachung 2026-08-23: Veranstaltung + Ankündigungspost
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 21:16:19 +02:00
43ae6bc869 Update .forgejo/workflows/deploy.yaml 2026-07-22 22:11:21 +02:00
c67560bd94 Merge pull request 'staging' (#48) from staging into production
Reviewed-on: cccb-website-team/www#48
2026-07-05 00:38:00 +02:00
42f999443f Merge pull request 'diday-end-recurrence' (#47) from hauke/www:diday-end-recurrence into staging
Reviewed-on: cccb-website-team/www#47
2026-07-05 00:18:48 +02:00
d8773f4f6f Weise auf Ausfall des DI.Day am 5. Juli im CCCB hin
Der DI.Day findet am 5. Juli 2026 nicht im Chaos Computer Club Berlin
statt. Der neue Blog-Eintrag informiert darüber und verweist auf die
alternativen Berliner DI.Day-Veranstaltungen (c-base, Stadtschloss
Moabit).

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-07-04 23:41:25 +02:00
ba76280a3f Merge pull request 'staging' (#46) from staging into production
Reviewed-on: cccb-website-team/www#46
2026-07-04 23:40:44 +02:00
e2a5fce03b Merge pull request 'fix typo' (#45) from 2026_07_05_DiDay_im_Club into staging
Reviewed-on: cccb-website-team/www#45
2026-07-04 23:37:47 +02:00
Bruno Ranieri
0318a0496a fix typo 2026-07-04 23:37:19 +02:00
1130d7eef0 Beende die Di.Day-Reihe nach dem 3. Mai 2026
Der Digital Independence Day findet ab Juli 2026 nicht mehr im
zweimonatlichen Turnus statt. Mit UNTIL in der RRULE endet die
Wiederholung nach dem letzten Termin am 3. Mai 2026, sodass keine
künftigen Instanzen mehr in den Kalendern (all.ics,
veranstaltungen/index.ics) und in der Terminübersicht erscheinen.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-07-04 23:35:50 +02:00
64ff74e5d2 Behebe Terminübersicht bei Wiederholungen mit UNTIL
gen_upcoming.py normalisiert dtstart auf naive lokale Zeit, reichte die
laut RFC 5545 in UTC anzugebende UNTIL-Zeit der RRULE aber tz-aware an
dateutil weiter. Die Mischung aus naivem dtstart und tz-awarer UNTIL
lässt dateutil.rrule mit "RRULE UNTIL values must be specified in UTC
when DTSTART is timezone-aware" abbrechen -- der Build schlägt fehl,
sobald ein wiederkehrender Termin ein UNTIL enthält.

Mit ignoretz=True wird die UNTIL-Zeit ebenfalls naiv behandelt,
konsistent zur restlichen Verarbeitung. Für Termine ohne UNTIL ist das
ein No-Op.

Fixes: 2e3a02af0c ("refacture all the things!")
Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-07-04 23:35:50 +02:00
aaf48ee2f7 Merge pull request 'remove did-post and update did-page' (#44) from 2026_07_05_DiDay_im_Club into staging
Reviewed-on: cccb-website-team/www#44
2026-07-04 23:34:43 +02:00
Bruno Ranieri
bf1de4633b remove did-post and update did-page 2026-07-04 23:32:57 +02:00
11893e9cb1 Merge pull request 'staging' (#43) from staging into production
Reviewed-on: cccb-website-team/www#43
2026-07-04 22:27:08 +02:00
09b3535525 Merge pull request 'Behebe Layoutauswahl unter aktuellem Hugo.' (#34) from hauke/www:hugo-tempate-fixes into staging
Reviewed-on: cccb-website-team/www#34
2026-07-04 22:24:32 +02:00
9363618157 Behebe Layoutauswahl unter aktuellem Hugo.
Die layouts/_default/*.calendar.html-Vorlagen werden in Hugo
≥0.158 fälschlich für die HTML-Ausgabe ausgewählt, sodass alle
Sektions- und Einzelseiten VCALENDAR- statt HTML-Inhalt
enthielten. Die Vorlagen waren ohnehin nie funktionsfähig
(Warnung „found no layout file for calendar"); die ICS-Feeds
liefern die abschnittsspezifischen Vorlagen unter
layouts/{veranstaltungen,datengarten,page}/.

list.xml.html bekommt aus demselben Grund die korrekte Endung
.xml.

tools/gen_upcoming.py vergleicht Datumsangaben jetzt
zeitzonenneutral, damit Events mit Z-Suffix keinen TypeError
auslösen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-07-04 22:22:53 +02:00
476bf0d6d9 Merge pull request 'wip 1' (#42) from 2026_07_05_DiDay_im_Club into staging
Reviewed-on: cccb-website-team/www#42
2026-06-19 01:41:55 +02:00
Bruno Ranieri
175c15be11 wip 1 2026-06-19 01:37:32 +02:00
b88cb169c6 Merge pull request 'Post DiDay 2026-07-05' (#41) from 2026_07_05_DiDay_im_Club into staging
Reviewed-on: cccb-website-team/www#41
2026-06-19 01:32:19 +02:00
c250009473 Merge branch 'staging' into 2026_07_05_DiDay_im_Club 2026-06-19 01:29:48 +02:00
Bruno Ranieri
5cbeeab329 figure definition 2026-06-19 01:28:39 +02:00
Bruno Ranieri
5f565f769c Merge branch '2026_07_05_DiDay_im_Club' into staging 2026-06-19 01:15:38 +02:00
Bruno Ranieri
781bda1674 Post DiDay 2026-07-05 2026-06-19 01:07:53 +02:00
4ad46ab774 Merge pull request 'staging' (#39) from staging into production
Reviewed-on: cccb-website-team/www#39
2026-06-18 21:56:00 +02:00
fe85bebb59 Merge remote-tracking branch 'origin/staging' into production 2026-06-18 21:13:25 +02:00
8e280c350b Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-05-10 15:18:56 +02:00
991e9f1622 Merge pull request 'Update .forgejo/workflows/deploy.yaml' (#37) from xengi-patch-1 into production
Reviewed-on: cccb-website-team/www#37
2026-05-09 22:38:30 +02:00
f82df9e156 Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-05-09 22:37:55 +02:00
3261862f52 Merge pull request 'Korrigiere Link auf Club Discordia und Rechtschreibfehler.' (#31) from hauke/www:fix-discordia-links into staging
Reviewed-on: cccb-website-team/www#31
2026-05-07 09:29:47 +02:00
1cda263641 Merge pull request 'Korrigiere Datum des Neujahrsempfangs und entferne kaputte rrule.' (#32) from hauke/www:fix-nje-date into staging
Reviewed-on: cccb-website-team/www#32
2026-05-07 09:29:28 +02:00
e36b498092 Merge pull request 'Füge DI.Day als Veranstaltung hinzu.' (#33) from hauke/www:diday into staging
Reviewed-on: cccb-website-team/www#33
2026-05-07 09:29:02 +02:00
0f27c16b64 Merge pull request 'README: Erkläre, wie man den Kalender lokal sieht.' (#35) from hauke/www:improve-readme into staging
Reviewed-on: cccb-website-team/www#35
2026-05-07 09:27:43 +02:00
8d66fffcf0 Merge branch 'staging' into improve-readme 2026-05-07 09:27:07 +02:00
f744215c13 Merge pull request 'Kalender-UI: Berücksichtige INTERVAL und DTSTART bei monatlichen RRULEs.' (#36) from hauke/www:support-calender-interval into staging
Reviewed-on: cccb-website-team/www#36
2026-05-07 09:26:52 +02:00
654e53e8f5 Merge branch 'staging' into improve-readme 2026-05-07 09:26:10 +02:00
e43f3ccda9 Merge branch 'staging' into support-calender-interval 2026-05-07 09:25:43 +02:00
4896fee8d2 Merge pull request 'OpenWrt Stammtisch: Stelle auf vierteljährlichen Rhythmus um.' (#29) from hauke/www:openwrt-date into staging
Reviewed-on: cccb-website-team/www#29
2026-05-07 09:25:07 +02:00
430662fa44 Füge DI.Day als Veranstaltung hinzu.
Der Berliner Di.Day (Digital Independence Day) findet jeden ersten
Sonntag im Monat im Wechsel zwischen CCCB (ungerade Monate) und xHain
(gerade Monate) statt. Im Kalender erscheinen nur die CCCB-Termine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-05-06 02:01:22 +02:00
c145161bc4 README: Erkläre, wie man den Kalender lokal sieht.
`hugo serve` rendert die Tabelle „Nächste Veranstaltungen" auf der
Startseite nicht — die wird erst durch `./build.sh` per sed in
`public/index.html` eingefügt. Die README beschreibt jetzt den
Build-und-Servieren-Workflow inklusive Python-Abhängigkeiten und
benötigtem Locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-05-03 16:54:15 +02:00
0b67ad4cfb Kalender-UI: Berücksichtige INTERVAL und DTSTART bei monatlichen RRULEs.
Der JS-RRULE-Parser im Kalender ignorierte bisher INTERVAL=, sodass
z.B. FREQ=MONTHLY;INTERVAL=3 als reines monatliches Event angezeigt
wurde. Außerdem wurden monatliche Events auch in Monaten vor ihrem
DTSTART angezeigt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-05-03 16:53:50 +02:00
aebd59d283 OpenWrt Stammtisch: Stelle auf vierteljährlichen Rhythmus um.
Das Treffen findet ab November 2025 nur noch jeden 3. Mittwoch
im Februar, Mai, August und November statt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-05-03 15:42:35 +02:00
b27b3899ce Korrigiere Link auf Club Discordia und Rechtschreibfehler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-05-03 15:41:51 +02:00
89f6c9198d Korrigiere Datum des Neujahrsempfangs und entferne kaputte rrule.
Der dtstart stand auf 2025 statt 2026, und die rrule "FREQ=MOTHLY"
enthielt einen Tippfehler, sodass das Event nicht im Kalender auftauchte.
Der Neujahrsempfang ist ein Einzeltermin und braucht keine rrule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Hauke Mehrtens <hauke@hauke-m.de>
2026-05-03 15:41:23 +02:00
74229708d2 Merge pull request 'Füge Datengarten 116 wirklich hinzu.' (#28) from staging into production
Reviewed-on: cccb-website-team/www#28
2026-04-14 17:03:44 +02:00
de5369ac2b Merge pull request 'Änderungsantrag: Füge Datengarten 116 hinzu.' (#27) from staging into production
Reviewed-on: cccb-website-team/www#27
2026-04-14 16:47:26 +02:00
417f5c73b7 Merge pull request 'trying imgur...' (#26) from staging into production
Reviewed-on: cccb-website-team/www#26
2026-03-22 18:48:44 +01:00
db91e8d7d3 Merge pull request 'NJE' (#25) from staging into production
Reviewed-on: cccb-website-team/www#25
2026-03-21 15:57:43 +01:00
fba9457d44 Merge pull request 'removed date from NJE pending plenum' (#24) from staging into production
Reviewed-on: cccb-website-team/www#24
2026-03-10 16:40:55 +01:00
289d8b9642 Merge pull request 'added NJE calendar event' (#23) from staging into production
Reviewed-on: cccb-website-team/www#23
2026-03-06 12:16:33 +01:00
5242c3c2a3 Merge pull request 'added NJE' (#22) from staging into production
Reviewed-on: cccb-website-team/www#22
2026-03-06 11:52:49 +01:00
f908c9526f Update .forgejo/workflows/deploy.yaml
Signed-off-by: xengi <cccb-git@xengi.de>
2026-03-05 17:59:13 +01:00
5e9ac72fb0 Merge pull request 'staging' (#21) from staging into production
Reviewed-on: cccb-website-team/www#21
2026-02-22 20:00:04 +01:00
40 changed files with 10843 additions and 713 deletions

View file

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

View file

@ -1,6 +1,14 @@
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:
- cron: '0 10 * * *' # daily at 10:00
push:
branches:
- staging
@ -8,46 +16,44 @@ on:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
runs-on: alpine-latest
steps:
- 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
run: |
cat /etc/os-release
git 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
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
- 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" "$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
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:$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
if: ${{ always() }}
run: rm -rf ~/.ssh

1
.gitattributes vendored
View file

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

138
.gitignore vendored
View file

@ -1,8 +1,9 @@
static/all.ics
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo,pycharm+all,vim,direnv
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo,pycharm+all,vim,direnv
### direnv ###
.direnv
.envrc
*.swp
# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,hugo
# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,hugo
### Hugo ###
# Generated files by hugo
@ -41,8 +42,7 @@ hugo.linux
.LSOverride
# Icon must end with two \r
Icon
Icon
# Thumbnails
._*
@ -67,6 +67,114 @@ Temporary Items
# iCloud generated files
*.icloud
### PyCharm+all ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### PyCharm+all Patch ###
# Ignore everything but code style settings and run configurations
# that are supposed to be shared within teams.
.idea/*
!.idea/codeStyles
!.idea/runConfigurations
### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
### Windows ###
# Windows thumbnail cache files
Thumbs.db
@ -93,17 +201,9 @@ $RECYCLE.BIN/
# Windows shortcuts
*.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:
.envrc
shell.nix
.direnv
# Python
.venv
# murmeldin
overview.md
/result
### 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/

View file

@ -1 +0,0 @@
3.11

View file

@ -27,12 +27,36 @@ This is the website of the CCCB.
5. Point your browser to: <http://localhost:1313/>
To ready your site for upload, run `./build.sh`, which also generates `all.ics`
and adds the calendar table to `index.html`.
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.
To build with *nix*: `nix build '.?submodules=1#production-content'`
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:
```shell
./build.sh
```
This deletes `public/` and runs `hugo` with the parameters from `.hugo-params`. To build with *nix* instead:
`nix build '.?submodules=1#production-content'`
## Making a change
@ -41,18 +65,21 @@ To build with *nix*: `nix build '.?submodules=1#production-content'`
3. Commit (and push) your change.
4. ~~GitHub Actions is running the release workflow.~~
- If successful, check [Staging Website](https://staging.berlin.ccc.de/) if change is correct.
5. Create merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or if small change, merge yourself.
5. Create a merge request to merge changes from `staging` to `production` branch. Ask somebody to check merge request or
if small change, merge yourself.
6. ~~GitHub Actions is running the release workflow.~~
- If successfull, check [Website](https://berlin.ccc.de/) if change is correct.
7. Profit!
## Nix stuff
- 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)
- 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)
---
Made with ❤️ and [Hugo](https://gohugo.io).

View file

@ -107,6 +107,10 @@
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);

36
assets/css/upcoming.css Normal file
View 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;
}

View file

@ -1,445 +1,406 @@
document.addEventListener('DOMContentLoaded', function() {
(function(){
let events = [];
let eventsByDate = {};
import { eventUrl, loadCalendar, occurrencesBetween } from "./events.js";
// 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;
}
// 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";
// 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 monthNames = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
];
// Extract date components from different date formats
function getDateComponents(icsDateStr) {
if (!icsDateStr) return null;
// Basic handling - extract YYYY, MM, DD regardless of format
const year = parseInt(icsDateStr.substring(0, 4));
const month = parseInt(icsDateStr.substring(4, 6)) - 1; // 0-based months
const day = parseInt(icsDateStr.substring(6, 8));
return { year, month, day };
}
function expandRecurringEvents(event, year, month) {
if (!event.rrule) return [event];
const rruleStr = event.rrule;
// Get start date components
const startComponents = getDateComponents(event.start);
if (!startComponents) return [event];
const startDate = new Date(
startComponents.year,
startComponents.month,
startComponents.day
);
const rangeStart = new Date(year, month, 1);
const rangeEnd = new Date(year, month + 1, 0);
const expandedEvents = [];
if (rruleStr.includes("FREQ=WEEKLY") && rruleStr.includes("BYDAY")) {
const bydayMatch = rruleStr.match(/BYDAY=([^;]+)/);
if (bydayMatch) {
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 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];
}
// 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");
document.getElementById("prev-month").addEventListener("click", function(){
currentMonth--;
if (currentMonth < 0) {
currentMonth = 11;
currentYear--;
}
updateEventsForMonth(currentYear, currentMonth);
const dayKeyFormat = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const timeOfDayFormat = new Intl.DateTimeFormat("de-DE", {
timeZone,
hour: "2-digit",
minute: "2-digit",
});
let calendar = null;
let eventsByDate = {};
let currentYear;
let currentMonth;
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("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";
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) {
// 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);
}
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 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);
}
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}/`;
}
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
let titleLink = document.createElement("h");
titleLink.textContent = ev.summary;
titleLink.target = "_blank";
eventTitle.appendChild(titleLink);
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));
})();
loadCalendar()
.then(loaded => {
calendar = loaded;
updateEventsForMonth(currentYear, currentMonth);
})
.catch(err => console.error("Fehler beim Laden der ICS-Datei:", err));
});

View file

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

194
assets/js/events.js Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,7 @@
#!/bin/sh
set -e
set -x
set -ex
rm -rf public/ # delete old generated files
hugo $(cat .hugo-params)
./tools/merge_cals.py
upcoming="$(tools/gen_upcoming.py static/all.ics 20 5 | tr '\n' ' ')"
cp static/all.ics public/all.ics
sed -i "s#CALENDAR#$upcoming#g" public/index.html

View file

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

View file

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

View 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/).

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

View file

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

View 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

View file

@ -26,7 +26,7 @@ draft: true
{{< alert "circle-info" >}}
Wenn ihr neu seid und den CCCB zum ersten Mal besuchen wollt, kommt am besten an einem Donnerstag zum [Club Discordia](/page/clubdiscordia/), da samstags nicht immer genug Leute da sind, um euch zu empfangen.
Wenn ihr neu seid und den CCCB zum ersten Mal besuchen wollt, kommt am besten an einem Donnerstag zum [Club Discordia](/veranstaltungen/clubdiscordia/), da samstags nicht immer genug Leute da sind, um euch zu empfangen.
Generell sind aber alle, die schonmal im Club waren, herzlich eingeladen, an den Bastelabenden vorbeizukommen.
Generell sind aber alle, die schon mal im Club waren, herzlich eingeladen, an den Bastelabenden vorbeizukommen.
{{< /alert >}}

View file

@ -47,9 +47,14 @@ im Alltag überall Einzug hält. Von:
Regelmäßige Treffen
-------------------
Die Berliner Chaos macht Schule Gruppe trifft sich zur Zeit nicht mehr
regelmäßig. Bei Interesse an einer Mitarbeit oder einem Workshop wendet
euch bitte per Mail an <schule@berlin.ccc.de>.
Die Berliner Chaos macht Schule Gruppe trifft sich jeden 1. Donnerstag
im Monat ab 19 Uhr in den Räumen des CCCB (Marienstraße 11, 10117
Berlin).
- Falls ihr Interesse habt, bei CmS mitzuarbeiten, fühlt euch herzlich
zu einem unserer Treffen eingeladen. Es schadet nicht, sich vorher
per Mail anzukündigen, dann können wir besser planen und
sicherstellen, dass jemand vor Ort ist.
Zielgruppen
-----------

View file

@ -0,0 +1,22 @@
---
title: "Di.Day"
subtitle: "Digital Independence Day im CCCB"
date: 2026-01-04T12:23:00+01:00
dtstart: 20260104T122300
dtend: 20260104T174200
rrule: "FREQ=MONTHLY;INTERVAL=2;BYDAY=1SU;WKST=MO;UNTIL=20260503T235959Z"
menu:
main:
parent: "Veranstaltungen"
tag: ["Veranstaltung"]
---
In Berlin wird der [Digital Independence Day](https://diday.org) an verschiedenen Orten von einer breiten Zahl von Initiativen getragen.
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.
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.
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).

View file

@ -2,13 +2,12 @@
title: "Neujahresempfang"
subtitle: "#NJE 26"
date: 2025-03-05T18:00:00+02:00
dtstart: 20250523T180000
dtend: 20250524T100000
rrule: "FREQ=MOTHLY;BYDAY=4SA;WKST=MO"
dtstart: 20260523T180000
dtend: 20260524T100000
menu:
main:
parent: "Veranstaltungen"
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/)

View file

@ -2,9 +2,9 @@
title: "OpenWrt"
subtitle: "OpenWrt Stammtisch"
date: 2025-01-08T20:00:00+02:00
dtstart: 20250115T200000
dtend: 20250115T230000
rrule: "FREQ=MONTHLY;BYDAY=3WE;WKST=MO"
dtstart: 20251119T200000
dtend: 20251119T230000
rrule: "FREQ=MONTHLY;INTERVAL=3;BYDAY=3WE;WKST=MO"
menu:
main:
parent: "Veranstaltungen"
@ -13,7 +13,7 @@ tag: ["Veranstaltung"]
![Verschiedene Platinen im CCCB](/img/club/42300970272_667569d239.jpg)
**Jeden 3. Mittwoch im Monat ab 20 Uhr** treffen sich die OpenWrt begeisterten und die es werden wollen zum OpenWrt Stammtisch im CCCB.
**Jeden 3. Mittwoch im Februar, Mai, August und November ab 20 Uhr** treffen sich die OpenWrt begeisterten und die es werden wollen zum OpenWrt Stammtisch im CCCB.
Das OpenWrt Meetup richtet sich an alle die OpenWrt benutzen oder benutzen wollen aber auch Leute die an OpenWrt entwickeln.

View file

@ -16,7 +16,7 @@ tag: ["Veranstaltung"]
**Jeden 2. und 4. Samstag im Monat ist ab 17 Uhr Spieleabend im Club.**
{{< alert "circle-info" >}}
Wenn ihr neu seid und den CCCB zum ersten Mal besuchen wollt, kommt am besten an einem Donnerstag zum [Club Discordia](/page/clubdiscordia/), da samstags nicht immer genug Leute da sind, um euch zu empfangen.
Wenn ihr neu seid und den CCCB zum ersten Mal besuchen wollt, kommt am besten an einem Donnerstag zum [Club Discordia](/veranstaltungen/clubdiscordia/), da samstags nicht immer genug Leute da sind, um euch zu empfangen.
Generell sind aber alle, die schonmal im Club waren, herzlich eingeladen, an den Spieleabenden vorbeizukommen.
Generell sind aber alle, die schon mal im Club waren, herzlich eingeladen, an den Spieleabenden vorbeizukommen.
{{< /alert >}}

View file

@ -23,8 +23,8 @@ Der Begriff [Subbotnik](https://de.wikipedia.org/wiki/Subbotnik) ist eine in Sow
---
{{< alert "circle-info" >}}
Wenn ihr neu seid und den CCCB zum ersten Mal besuchen wollt, kommt am besten an einem Donnerstag zum [Club Discordia](/page/clubdiscordia/), da samstags nicht immer genug Leute da sind, um euch zu empfangen.
Wenn ihr neu seid und den CCCB zum ersten Mal besuchen wollt, kommt am besten an einem Donnerstag zum [Club Discordia](/veranstaltungen/clubdiscordia/), da samstags nicht immer genug Leute da sind, um euch zu empfangen.
Generell sind aber alle, die schonmal im Club waren, herzlich eingeladen am Subbotnik vorbeizukommen und mitzuhelfen.
Generell sind aber alle, die schon mal im Club waren, herzlich eingeladen am Subbotnik vorbeizukommen und mitzuhelfen.
{{< /alert >}}

View file

@ -12,4 +12,15 @@ herostyle: big
{{< calendar >}}
Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/all.ics)!
Keinen Termin mehr verpeilen? Einfach den [Veranstaltungskalender abonnieren](/calendars/all.ics)!
Oder willst du nur den Kalender von bestimmten Events?
- [Club Discordia](/calendars/Club_Discordia.ics)
- [Neujahresempfang](/calendars/Neujahresempfang.ics)
- [Datengarten](/calendars/Datengarten.ics)
- [Spieleabend](/calendars/Spieleabend.ics)
- [OpenWRT](/calendars/OpenWRT.ics)
- [Plenum](/calendars/Plenum.ics)
- [Amateurfunk](/calendars/Amateurfunk.ics)
- [Irreguläre Events](/calendars/Random.ics)

8
flake.lock generated
View file

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

View file

@ -2,7 +2,7 @@
description = "A flake containing a development environment for the CCCB website.";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-24.11";
nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05";
};
outputs =
@ -34,7 +34,7 @@
devShells = forAllSystems (import ./devShells.nix);
formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-rfc-style);
formatter = forAllSystems ({ pkgs, ... }: pkgs.nixfmt-tree);
lib.mkWwwContent =
{ domain, system }:
@ -50,10 +50,6 @@
nativeBuildInputs = [
pkgs.hugo
pkgs.glibcLocales
(pkgs.python3.withPackages (python-pkgs: [
python-pkgs.icalendar
python-pkgs.pytz
]))
];
# LOCALE_ARCHIVE = builtins.trace pkgs.glibcLocales "${pkgs.glibcLocales}/lib/locale/locale-archive";
@ -62,11 +58,6 @@
buildPhase = ''
mkdir -p public
hugo --baseURL=https://${domain}/
python3 ./tools/merge_cals.py
upcoming="$(python3 tools/gen_upcoming.py static/all.ics 20 5 | tr '\n' ' ')"
cp static/all.ics public/all.ics
sed -i "s#CALENDAR#$upcoming#g" public/index.html
'';
# Install phase - copy the public directory to the output

View file

@ -1 +0,0 @@
{{ block "main" . }}{{ .Content }}{{ end }}

View file

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

View file

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

View file

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

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

View file

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

View file

@ -1,95 +0,0 @@
#!/usr/bin/env python3
import sys
import logging
import locale
from dateutil.parser import parse
from datetime import datetime, timedelta
from dateutil.rrule import rruleset, rrulestr
import icalendar
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()
rs.rrule(rrulestr(event["RRULE"].to_ical().decode("utf-8"), dtstart=dtstart))
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)

View file

@ -1,31 +0,0 @@
#!/usr/bin/env python3
from glob import glob
import pytz
import icalendar
calendars = []
merged = icalendar.Calendar()
merged.add("prodid", "-//CCCB Calendar Generator//berlin.ccc.de//")
merged.add("version", "2.0")
for icsfilestr in glob("public/*/**/*.ics", recursive=True):
with open(icsfilestr, "r") as icsfile:
print(f"Importing {icsfilestr}")
calendars.append(icalendar.Calendar.from_ical(icsfile.read()))
for calendar in calendars:
for event in calendar.subcomponents:
if event.name != "VEVENT":
continue
if "DTSTART" not in event:
continue
merged.add_component(event)
outfile = "static/all.ics"
with open(outfile, "wb") as f:
print(f"writing to {outfile}...")
f.write(merged.to_ical())