From a7a6ad786eeb9f94f7882462bccbdd31e1bb4743 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 30 Jun 2026 19:15:22 +0200 Subject: Phase 1: standalone events, external_url on nodes - Migration: node_id nullable on events and occurrences, add title/description/is_primary to events, external_url to nodes - Existing events marked is_primary: true (were all 1:1 with nodes) - Node: has_one :event -> has_many :events - Event: belongs_to :node optional, validates title presence for standalone events, is_primary uniqueness scoped to node_id, display_title helper falling back through node title - Occurrence: belongs_to :node optional, summary falls back to event.display_title - nodes_helper: event_information uses events.first (interim; will be replaced in Phase 3 event UI) - Tests: fix node.event -> node.events.first in event_test --- app/helpers/nodes_helper.rb | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) (limited to 'app/helpers') diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index a054a2e..d88d12a 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -30,12 +30,13 @@ module NodesHelper end def event_information - if @node.event + if @node.events.first + event = @node.events.first safe_join([ - "#{@node.event.start_time.to_fs(:db)} - #{@node.event.end_time.to_fs(:db)} > ", - link_to('show', event_path(@node.event)), - ' ', - link_to('edit', edit_event_path(@node.event)) + "#{event.start_time.to_fs(:db)} - #{event.end_time.to_fs(:db)} > ", + link_to('show', event_path(event)), + ' > ', + link_to('edit', edit_event_path(event)) ]) else safe_join([ -- cgit v1.3 From 4dd49b1eebb0a99d3aee66b7eca539c87a9c6332 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 28 Jun 2026 04:43:28 +0200 Subject: Phase 2: chapter nodes, aggregate partial, fixes - _chapter.html.erb: new partial for erfa/chaostreff aggregated lists; renders title, location, external_url, sanitized body - content_helper: fix aggregate attr regex to allow hyphens in values (erfa-detail tag was silently dropped); add debug logging (remove) - page.rb: suppress libxml stderr noise in rewrite_links_in_body - db/seeds/chapters.rb: one-shot seed script for erfa and chaostreff chapter nodes under parent nodes 548/549; creates bilingual pages, external_url, primary events with RRULEs where known Note: run Node.rebuild!(false) after execution to fix lft/rgt values --- app/helpers/content_helper.rb | 2 +- app/models/page.rb | 8 + app/views/custom/partials/_chapter.html.erb | 10 + db/seeds/chapters.rb | 1329 +++++++++++++++++++++++++++ 4 files changed, 1348 insertions(+), 1 deletion(-) create mode 100644 app/views/custom/partials/_chapter.html.erb create mode 100644 db/seeds/chapters.rb (limited to 'app/helpers') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index 21cc579..53bf5b2 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -80,7 +80,7 @@ module ContentHelper begin if content =~ /\[aggregate([^\]]*)\]/ tag = $~.to_s - matched_data = $1.scan(/\w+\="[a-zA-Z\s\/_\d,.=]*"/) + matched_data = $1.scan(/\w+\="[a-zA-Z\s\/_\d,.=-]*"/) matched_data.each do |data| splitted_data = data.split("=", 2) diff --git a/app/models/page.rb b/app/models/page.rb index e6baf20..385b3f6 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -63,6 +63,14 @@ class Page < ApplicationRecord end end + if options[:order_by] == "title" + return scope + .joins(:translations) + .where(page_translations: { locale: I18n.locale }) + .order("page_translations.title #{options[:order_direction]}") + .paginate(:page => page, :per_page => options[:limit]) + end + scope.order("#{options[:order_by]} #{options[:order_direction]}") .paginate(:page => page, :per_page => options[:limit]) end diff --git a/app/views/custom/partials/_chapter.html.erb b/app/views/custom/partials/_chapter.html.erb new file mode 100644 index 0000000..a5b8662 --- /dev/null +++ b/app/views/custom/partials/_chapter.html.erb @@ -0,0 +1,10 @@ +
+

<%= link_to_path page.title, page.node.unique_name %>

+ <% if page.abstract.present? %> +
<%= page.abstract %>
+ <% end %> + <% if page.node.external_url.present? %> +
<%= link_to page.node.external_url, page.node.external_url, target: '_blank', rel: 'noopener' %>
+ <% end %> +

<%= sanitize page.body %>

+
diff --git a/db/seeds/chapters.rb b/db/seeds/chapters.rb new file mode 100644 index 0000000..cb48894 --- /dev/null +++ b/db/seeds/chapters.rb @@ -0,0 +1,1329 @@ +# db/seeds/chapters.rb +# Creates erfa and chaostreff chapter nodes under their respective parent nodes. +# Run with: bundle exec rails runner db/seeds/chapters.rb +# +# Parent nodes: +# 548 = erfas overview node +# 549 = chaostreffs overview node +# +# Each entry requires at minimum: slug, title_de, description_de, external_url +# Optional: title_en, description_en, location, rrule, start_time, review +# Entries with review: true have known discrepancies between DE and EN content. + +require 'date' + +def seed_chapter(parent_id:, slug:, tag:, title_de:, description_de:, + external_url:, title_en: nil, description_en: nil, + location: nil, rrule: nil, start_time: nil, review: false) + + if review + puts " [REVIEW] #{slug} — check DE/EN discrepancy before publishing" + end + + parent = Node.find(parent_id) + + # Skip if already exists + if Node.find_by(slug: slug, parent_id: parent_id) + puts " Skipping #{slug} (already exists)" + return + end + + # Create node + node = parent.children.create!(slug: slug, external_url: external_url) + node.reload + + # Set up draft with German translation + draft = node.draft + I18n.with_locale(:de) do + draft.title = title_de + draft.abstract = location || "" + draft.body = description_de + draft.tag_list = tag + draft.save! + end + + # Add English translation if provided + if title_en || description_en + I18n.with_locale(:en) do + draft.title = title_en || title_de + draft.abstract = location || "" + draft.body = description_en || description_de + draft.save! + end + end + + # Set a system user as author (use first admin user) + draft.user = User.where(admin: true).first + draft.editor = User.where(admin: true).first + draft.save! + + # Publish + node.publish_draft! + node.reload + + # Create primary event if rrule or start_time provided + if rrule || start_time + base_time = Time.parse("#{Date.today.year}-01-01 #{start_time || '19:00'}:00") + node.events.create!( + title: title_de, + location: location, + rrule: rrule, + start_time: base_time, + end_time: base_time + 2.hours, + is_primary: true + ) + end + + puts " Created: #{slug}#{review ? ' [needs review]' : ''}" +end + +puts "Seeding erfas..." + + +erfas = [ + { + slug: "erfa-aachen", + title_de: "CCC Aachen", + title_en: "CCC Aachen", + description_de: "Der CCC Aachen ist regelmäßig zu Themen- und offenen Abenden für Besucher*innen geöffnet. Unsere kleinen aber feinen Clubräume voller Plüschhaie liegen zwischen Hauptbahnhof und Stadtzentrum und sind aus beiden Richtungen in wenigen Minuten Fußweg zu erreichen (Schützenstraße 11, 52062 Aachen). Dank bunter LEDs sind sie besonders abends nicht zu übersehen.", + description_en: "CCC Aachen opens its doors regularly to themed and open evenings. Our small but cozy space full of plush sharks is located within a few minutes by foot from central station and the city center (Schützenstraße 11, 52062 Aachen). Thanks to colorful LEDs it's especially easy to find at night.", + external_url: "https://ccc.ac/", + location: "Schützenstraße 11, 52062 Aachen", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-bamberg", + title_de: "backspace e.V. Bamberg", + title_en: "backspace e.V. Bamberg", + description_de: "Der Bamberger Erfa-Kreis und Hackerspace ist der backspace e.V., ein Zusammenschluss von Menschen, die technische Grenzen überwinden wollen, Innovationen erproben und den freien Wissensaustausch befördern. Der backspace ist Thinktank, Werkstatt, Hackerspace, Freiraum, Zuhause, Labor und Impulsgeber. Es ist fast immer was los, besonders am Dienstag ab 19 Uhr im Spiegelgraben 41, 96052 Bamberg.", + description_en: "The CCC affiliated hackerspace backspace gathers people interested in technical innovation and free information exchange. It is a think tank, workshop, hackerspace, open space, home, laboratory and instigator. There is something going on every day, but most people meet at Spiegelgraben 41 in Bamberg on Tuesday, 7pm.", + external_url: "https://www.hackerspace-bamberg.de/", + location: "Spiegelgraben 41, 96052 Bamberg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-basel", + title_de: "CCC Basel", + title_en: "CCC Basel", + description_de: "Die Türen des CCC Basel sind jeden Dienstagabend ab 19:30 Uhr geöffnet. Uns findet man an der Birsfelderstrasse 6 in CH-4132 Muttenz (Außentreppe zum Kellereingang). Falls du mit der Tram 14 vorfährst, empfehlen wir dir die Haltestelle Käppeli.", + description_en: "CCC Basel opens its doors every Tuesday evening from 19:30. We are located at Birsfelderstrasse 6, 4132 Muttenz, Switzerland; just go down the outdoors stairway to the basement. If you arrive by public transit, we recommend taking tramway 14 to the stop Käppeli.", + external_url: "https://ccc-basel.ch/", + location: "Birsfelderstrasse 6, CH-4132 Muttenz", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:30", + review: false + }, + { + slug: "erfa-berlin", + title_de: "CCC Berlin – Club Discordia", + title_en: "CCC Berlin – Club Discordia", + description_de: "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin (Marienstraße 11, 10117 Berlin-Mitte). Die Treffen finden jeden Dienstag und Donnerstag ab ca. 19 Uhr statt.", + description_en: "Club Discordia is a public meeting located at the CCC Berlin (Marienstr. 11, 10117 Berlin-Mitte). Meetings are held every Thursday at 5pm.", + external_url: "http://berlin.ccc.de/", + location: "Marienstraße 11, 10117 Berlin", + rrule: "FREQ=WEEKLY;BYDAY=TU,TH", + start_time: "19:00", + review: true + }, + { + slug: "erfa-bremen", + title_de: "CCC Bremen", + title_en: "CCC Bremen", + description_de: "Ein öffentliches Treffen des CCC Bremen findet jeweils dienstags ab 20 Uhr in der Zweigstraße 1 statt.", + description_en: "The public get together of CCC Bremen takes place every Tuesday at 8pm at Z1 (Zweigstraße 1, 28217 Bremen).", + external_url: "https://www.ccchb.de/", + location: "Zweigstraße 1, 28217 Bremen", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "erfa-chemnitz", + title_de: "CCC Chemnitz (ChCh)", + title_en: nil, + description_de: "Der Chaos Computer Club Chemnitz (ChCh) betreibt seit 2011 einen eigenen Hackspace in der Augustusburger Straße 102. Wir stehen allen technikinteressierten und kreativen Menschen offen und freuen uns immer über neue Gäste. Wir fühlen uns der Informationsfreiheit und der Aufklärung über die Auswirkungen aktueller Technologien auf die Gesellschaft verpflichtet. Trotzdem kommt bei uns auch der Spaß am Gerät nicht zu kurz.", + description_en: nil, + external_url: "https://chaoschemnitz.de", + location: "Augustusburger Straße 102, Chemnitz", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "erfa-darmstadt", + title_de: "CCC Darmstadt", + title_en: "CCC Darmstadt", + description_de: "Wir treffen uns jeden Dienstagabend ab 19 Uhr zum gemeinsamen Basteln, Diskutieren, Hacken, Nerden – eben einfach zum offenen Chaos – in unserem Hackspace in der Wilhelminenstraße 17, mitten in der Darmstädter Innenstadt. Aber auch an jedem anderen Abend ist in der Regel etwas los. Neben der Nutzung unserer Elektronikwerkstatt hast du zum Beispiel die Möglichkeit, bei unserem Capture-the-Flag-Team „Wizards of DoS“ reinzuschauen oder dich bei Freifunk Darmstadt zu engagieren. Aktuelle Termine und Neuigkeiten sowie den Türstatus gibt's auf chaos-darmstadt.de. Im IRC findest du uns unter #chaos-darmstadt auf hackint. Mailingliste: public<ät>lists.darmstadt.ccc.de. Schau doch einfach mal vorbei!", + description_en: "CCC Darmstadt meets Tuesdays from 8pm in their hackspace at Wilhelm-Leuschner-Strasse 36.", + external_url: "https://www.chaos-darmstadt.de/", + location: "Wilhelminenstraße 17, Darmstadt", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: true + }, + { + slug: "erfa-dortmund", + title_de: "CCC Dortmund", + title_en: nil, + description_de: "Der Erfa Dortmund ist ein Treffen von Dortmundern oder Leuten, die in der Dortmunder Umgebung wohnen (Unna, Holzwickede, Schwerte etc.). Wem kreativer Umgang mit Technik nicht fremd ist, ist herzlich zum Treff im Langen August in der Braunschweiger Straße 22 in Dortmund eingeladen. Treffen finden dienstags und donnerstags ab 19 Uhr (+1h Chaos-Verspätung) statt.", + description_en: nil, + external_url: "http://www.chaostreff-dortmund.de", + location: "Braunschweiger Straße 22, Dortmund", + rrule: "FREQ=WEEKLY;BYDAY=TU,TH", + start_time: "19:00", + review: false + }, + { + slug: "erfa-dresden", + title_de: "CCC Dresden (c3d2)", + title_en: "CCC Dresden (c3d2)", + description_de: "Die Chaoten aus dem ganzsächsischen bzw. südbrandenburgischen Raum treffen sich jeden Dienstag in Dresden (Details bitte jeweils per Jabber unter c3d2@muc.hq.c3d2.de erfragen). Darüberhinaus finden auch häufig, aber unregelmäßig Themenabende statt.", + description_en: "The geeks from Saxony and southern Brandenburg meet every Tuesday in Dresden (for details please ask via jabber at c3d2@muc.hq.c3d2.de). Furthermore there are occasional get-togethers for specific subjects.", + external_url: "http://www.c3d2.de/", + location: "Dresden", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-duesseldorf", + title_de: "Chaosdorf Düsseldorf", + title_en: "Chaosdorf Düsseldorf", + description_de: "Der Düsseldorfer Erfa (auch als Chaosdorf bekannt) betreibt einen Hackspace in der Sonnenstr. 58, der nahezu durchgehend geöffnet ist. Der Kennenlernabend, „Freitagsfoo“, findet jeden Freitag ab 18 Uhr statt.", + description_en: "CCC Düsseldorf (aka Chaosdorf) operates a hackspace in Sonnenstraße 58 that is open nearly 24/7. The best method for getting to know it is the \"Freitagsfoo\" event, taking place every Friday from 6pm.", + external_url: "https://chaosdorf.de/", + location: "Sonnenstraße 58, Düsseldorf", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "18:00", + review: false + }, + { + slug: "erfa-erlangen", + title_de: "Bits'n'Bugs e.V. Erlangen", + title_en: "Bits'n'Bugs e.V. Erlangen", + description_de: "Der Bits'n'Bugs e.V. trifft sich jeden Freitag ab 18 Uhr im ZAM, Hauptstr. 65-67, und zu weiteren unregelmäßigen Zeiten je nach Aktivitäten. Wir beteiligen uns außerdem regelmäßig an Veranstaltungen und Projekten des ZAM.", + description_en: "Bits'n'Bugs e.V. meets every Tuesday at 7:30pm in E-Werk Erlangen, Fuchsenwiese 1, group room 5.", + external_url: "http://erlangen.ccc.de/", + location: "Hauptstraße 65-67, Erlangen", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "18:00", + review: true + }, + { + slug: "erfa-essen", + title_de: "Chaospott Essen", + title_en: "Chaospott Essen", + description_de: "Der Chaospott ist die lokale Vertretung des CCC im Herzen des Ruhrgebiets. Wir treffen uns jeden Mittwoch ab 19 Uhr in der Sibyllastraße 9, 45136 Essen (Hofgebäude).", + description_en: "Chaospott is the local subsidiary of the CCC at the heart of the Ruhr area. We meet every Wednesday at 7pm in the »foobar«.", + external_url: "http://chaospott.de/", + location: "Sibyllastraße 9, 45136 Essen", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: true + }, + { + slug: "erfa-flensburg", + title_de: "CCC Flensburg", + title_en: nil, + description_de: "Deutschlands nördlichster Erfa trifft sich jeden Dienstag ab 18 Uhr in der Apenrader Straße 49, 24939 Flensburg. Bei unserem OpenSpace sind neue Gesichter immer willkommen! Folgt uns gerne auch auf Mastodon.", + description_en: nil, + external_url: "https://c3fl.de/", + location: "Apenrader Straße 49, 24939 Flensburg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "18:00", + review: false + }, + { + slug: "erfa-frankfurt", + title_de: "CCC Frankfurt am Main", + title_en: "CCC Frankfurt am Main", + description_de: "Wir treffen uns jeden Dienstag und Donnerstag (auch an den meisten Feiertagen) ab 19 Uhr in unserem Hackerspace, dem HQ. Dazu sind alle Interessierten jederzeit herzlich eingeladen.", + description_en: "We meet every Tuesday (even on most holidays) at 7pm in our hackspace the HQ. All interested people are welcome.", + external_url: "http://ccc-ffm.de/hackerspace/", + location: "Frankfurt am Main", + rrule: "FREQ=WEEKLY;BYDAY=TU,TH", + start_time: "19:00", + review: false + }, + { + slug: "erfa-freiburg", + title_de: "CCC Freiburg", + title_en: "CCC Freiburg", + description_de: "Der Chaos Computer Club Freiburg trifft sich montags und dienstags ab 19 Uhr sowie nach Lust und Laune in seinen Räumen in der Adlerstraße 12a, 79098 Freiburg. Plenum ist jede zweite Woche dienstags ab 20 Uhr.", + description_en: "Erfa Freiburg meets on Tuesdays at 7pm in their own room at ArTik, the former underpass at Siegesdenkmal (Kaiser-Joseph-Strasse 141, 79089 Freiburg).", + external_url: "http://cccfr.de", + location: "Adlerstraße 12a, 79098 Freiburg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: true + }, + { + slug: "erfa-fulda", + title_de: "Magrathea Laboratories e.V. Fulda", + title_en: nil, + description_de: "Der Magrathea Laboratories e.V. (mag.lab) ist die lokale Chaosmanifestation und Treffpunkt einiger Haecksen, Hacker und anderweitig Technikinteressierter in der Lindenstraße 14 in Fulda. Das Chaos steht allen immer freitags ab 19 Uhr offen.", + description_en: nil, + external_url: "https://maglab.space/", + location: "Lindenstraße 14, Fulda", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "19:00", + review: false + }, + { + slug: "erfa-goettingen", + title_de: "CCC Göttingen", + title_en: "CCC Göttingen", + description_de: "Der Erfa-Kreis Göttingen wurde im November 2007 von Hackern gegründet, die sich dem Chaos Computer Club nahefühlen. Open Chaos findet jeden zweiten Dienstag ab 20 Uhr im Neotopia (Von-Bar-Straße 2-4, Keller des MLP-Hauses) statt. Interessierte sind herzlich willkommen.", + description_en: "Erfa Göttingen was founded Nov 2007 by hackers close to the Chaos Computer Club. Open Chaos is every Tuesday from 8pm at NOKLAB (Neustadt 7, Innenstadt). All interested people are welcome.", + external_url: "http://www.chaostreff-goettingen.de/", + location: "Von-Bar-Straße 2-4, Göttingen", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", + start_time: "20:00", + review: true + }, + { + slug: "erfa-hamburg", + title_de: "CCC Hamburg", + title_en: "CCC Hamburg", + description_de: "Der Hamburger Erfa-Kreis trifft sich in der Viktoria-Kaserne (1. Stock, Ostflügel), Zeiseweg 9, 22765 Hamburg. Der zweite Freitag und der letzte Dienstag im Monat sind perfekt zum Kennenlernen und Fragen stellen, weitere Termine finden sich auf dem Kalender des Erfa Hamburg, der mit öffentlichen Veranstaltungen gefüllt ist.", + description_en: "The Erfakreis Hamburg meets at Viktoria-Kaserne, room 119 (1st floor, east wing) Zeiseweg 9, 22765 Hamburg. Every second Friday and last Tuesday of the month are great opportunities to meet people and ask questions. We also have a calendar filled with events open for visitors.", + external_url: "http://hamburg.ccc.de/", + location: "Zeiseweg 9, 22765 Hamburg", + rrule: "FREQ=MONTHLY;BYDAY=2FR", + start_time: "19:00", + review: false + }, + { + slug: "erfa-hannover", + title_de: "CCC Hannover", + title_en: "CCC Hannover", + description_de: "Das regionale Chaos in Hannover trifft sich jeden Mittwoch ab 19 Uhr in der Bürgerschule im Clubraum (Raum 3.1) im Stadtteilzentrum Nordstadt.", + description_en: "The regional Chaos in Hannover meets every second Wednesday of the month from 8pm and on the last Sunday of the month from 4pm at the Bürgerschule in their clubroom (room 3.1) in the community center Nordstadt.", + external_url: "https://hannover.ccc.de/", + location: "Bürgerschule, Raum 3.1, Hannover", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: true + }, + { + slug: "erfa-kaiserslautern", + title_de: "Chaos inKL. e.V. Kaiserslautern", + title_en: nil, + description_de: "Der Chaos inKL. e.V. ist die Kaiserslauterner Niederlassung des Chaos Computer Clubs. Unsere öffentlichen Veranstaltungen sind die Hacknight (der samstägliche Basteltreff ab 19 Uhr), das monatliche Seminar und das monatliche Kneipentreffen.", + description_en: nil, + external_url: "http://www.chaos-inkl.de/", + location: "Kaiserslautern", + rrule: "FREQ=WEEKLY;BYDAY=SA", + start_time: "19:00", + review: false + }, + { + slug: "erfa-karlsruhe", + title_de: "Entropia e.V. Karlsruhe", + title_en: "Entropia e.V. Karlsruhe", + description_de: "Der Erfa-Kreis Karlsruhe ist ein eingetragener Verein mit dem Namen Entropia. Die öffentlichen Treffen finden jeden Samstag ab 19:30 Uhr in den Räumen des Erfa Karlsruhe (Gewerbehof, Steinstraße 23) statt und richten sich an alle aus Karlsruhe und dem Umland.", + description_en: "Erfa Karlsruhe is a registered club with the name 'Entropia'. The public meetings take place every Sunday from 7:30pm at our club (Gewerbehof, Steinstr. 23) and targets people from Karlsruhe and surrounding region.", + external_url: "https://entropia.de/", + location: "Steinstraße 23, Karlsruhe", + rrule: "FREQ=WEEKLY;BYDAY=SA", + start_time: "19:30", + review: true + }, + { + slug: "erfa-kassel", + title_de: "flipdot e.V. Kassel", + title_en: "flipdot e.V. Kassel", + description_de: "flipdot e.V. hackerspace kassel ist der lokale Erfa-Kreis – ein lebendiger Ort mit viel Platz zum Bausteln und Coden. Es gibt gut ausgestattete Werkstatträume, Vortrags- und Kinoraum und eine Küche mit Profi-Pizzaofen. Im flipdot wird sehr oft zusammen gekocht und gegessen. flipdot ist seit 2009 Brutstätte neuer Ideen, Wohnzimmer, anarchistische Volkshochschule, Coder-Cave und umtriebige Werkstatt. Offen für Besucher jeden Dienstag ab 19 Uhr, Schillerstraße 25, 34117 Kassel.", + description_en: "flipdot e.V. hackerspace kassel is the local Erfa circle – a lively place with plenty of space for building and coding. There are well-equipped workshop rooms, a lecture and cinema room, and a kitchen with a professional pizza oven. At flipdot, people often cook and eat together. Since 2009, flipdot has been a breeding ground for new ideas, a living room, an anarchist adult education center, a coder's cave, and a bustling workshop. Open to visitors every Tuesday from 7pm, Schillerstraße 25, 34117 Kassel.", + external_url: "http://kassel.ccc.de/", + location: "Schillerstraße 25, 34117 Kassel", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-koeln", + title_de: "c4 Köln", + title_en: "c4 Cologne", + description_de: "Der c4 ist der westliche Brückenkopf des innovativen Technologieeinsatzes mit allen Features, die zum Chaos gehören. Öffentliche Treffen gibt es jeden Donnerstag ab 19:30 Uhr im Chaoslabor in Köln-Ehrenfeld, an jedem letzten Donnerstag im Monat gibt es ein OpenChaos als Vortragsrahmenprogramm.", + description_en: "The c4 is a westward bridge head of the innovative technology usage with all features that are necessary for chaos. The public meeting is called OpenChaos and takes place on the last Thursday of the month at 19:30 in the Chaoslabor in Cologne-Ehrenfeld.", + external_url: "http://koeln.ccc.de/", + location: "Köln-Ehrenfeld", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:30", + review: false + }, + { + slug: "erfa-leipzig", + title_de: "dezentrale e.V. Leipzig", + title_en: nil, + description_de: "Der dezentrale e.V. vertritt als Erfa das lokale Chaos in Leipzig. Wir bieten einen Anlaufpunkt für Softwarenerds, Künstler:innen, Hardwareschraubende und -löter:innen und alle chaosnahen Themen. Infos über uns findest Du unter dezentrale.space. Unser Vernetzungsabend ist der Chaostreff jeden Freitag ab 19:00.", + description_en: nil, + external_url: "http://dezentrale.space", + location: "Leipzig", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "19:00", + review: false + }, + { + slug: "erfa-luebeck", + title_de: "Chaotikum e.V. Lübeck", + title_en: "Chaotikum e.V. Lübeck", + description_de: "Der Lübecker Erfa ist der Chaotikum e.V., welcher seit 2012 den Hackspace Nobreakspace betreibt. Dort treffen sich seitdem technikinteressierte Menschen, um an diversen Projekten zu arbeiten, über Themen zu reden, die uns beschäftigen, und vor allem um Spaß zu haben. Open Space ist immer Mittwochs ab 19:00 Uhr.", + description_en: "The Lübeck Hackspace group is Chaotikum e.V., which has been running the Nobreakspace hackspace since 2012. Since then, tech enthusiasts have been meeting there to work on various projects, discuss topics that interest them, and above all, have fun. Open Space is every Wednesday from 7:00 PM.", + external_url: "https://chaotikum.org", + location: "Lübeck", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "erfa-mainz-wiesbaden", + title_de: "CCC Mainz/Wiesbaden", + title_en: "CCC Mainz/Wiesbaden", + description_de: "Das Wiesbadener Chaos trifft sich jeden Dienstag ab 19 Uhr in seinen Räumen am Sedanplatz 7 in Wiesbaden. Die Treffen richten sich an alle aus Mainz/Wiesbaden und dem nahen Umland.", + description_en: "The Chaos Computer Club Mainz meets every Tuesday, 7pm, at Sedanplatz 7 in Wiesbaden. The meetup is addressed to everyone from Mainz/Wiesbaden and the near surroundings.", + external_url: "http://www.cccmz.de", + location: "Sedanplatz 7, Wiesbaden", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-mannheim", + title_de: "CCC Mannheim", + title_en: "CCC Mannheim", + description_de: "Der Erfa-Kreis Mannheim ist eine Anlaufstelle für Computer- und Technikinteressierte, die Gleichgesinnte suchen. Hier kann man sich austauschen, seine Ideen präsentieren und diskutieren. Unsere öffentlichen Treffen finden jeden Freitag ab 19 Uhr statt. Die Termine stehen in unserem Wiki.", + description_en: "Erfa Mannheim is a local contact point for people that are interested in computer and technology, who search for like minded people. You can exchange, present and discuss your ideas. Our public meeting takes place every Friday.", + external_url: "http://www.ccc-mannheim.de", + location: "Mannheim", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "19:00", + review: false + }, + { + slug: "erfa-muenchen", + title_de: "µC³ München", + title_en: "µC³ Munich", + description_de: "Die öffentlichen Treffen des µC³ finden am zweiten Dienstag jeden Monats ab ca. 20 Uhr im Club in der Schleißheimer Straße 39 (Ecke Heßstraße 90) statt. Dies ist natürlich auch ein Ort, um jederzeit mit jemandem des Münchner CCCs ins Gespräch zu kommen.", + description_en: "The public meetup of the µC³ takes places every second Tuesday of the month, starting at about 8pm at Schleißheimer Str. 39 (corner to Heßstraße 90).", + external_url: "https://muc.ccc.de/", + location: "Schleißheimer Straße 39, München", + rrule: "FREQ=MONTHLY;BYDAY=2TU", + start_time: "20:00", + review: false + }, + { + slug: "erfa-offenburg", + title_de: "Section77 e.V. Offenburg", + title_en: nil, + description_de: "Der lockere Treff von Section77 e. V. für alle Chaos-Interessierten aus dem Raum Offenburg findet jeden Dienstag ab 20 Uhr im Hackspace statt. Dieser befindet sich in der Hauptstraße 1 in Offenburg (direkt im Bahnhofsgebäude). Gäste sind immer willkommen.", + description_en: nil, + external_url: "https://section77.de", + location: "Hauptstraße 1, Offenburg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "erfa-paderborn", + title_de: "CCC Paderborn (subraum)", + title_en: "CCC Paderborn", + description_de: "Wir treffen uns immer mittwochs in unserem Hackerspace \"subraum\" in der Westernmauer 12-16.", + description_en: "We meet up Wednesdays at the pottery in the \"Kulturwerkstatt\". Our move to our new rooms in Westernmauer 12 is imminent.", + external_url: "https://www.c3pb.de/", + location: "Westernmauer 12-16, Paderborn", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: true + }, + { + slug: "erfa-salzburg", + title_de: "Erfa Salzburg", + title_en: nil, + description_de: "Der Erfa Salzburg ist eine als eingetragener Verein organisierte und trotzdem lockere Runde von Leuten, die sich mit dem Chaos Computer Club e.V. eng verbunden fühlen. Wir versuchen, einen Blick hinter die Kulissen zu werfen und viele Dinge zu hinterfragen und zu diskutieren.", + description_en: nil, + external_url: "http://sbg.chaostreff.at/", + location: "Salzburg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "erfa-siegen", + title_de: "Chaos Siegen", + title_en: nil, + description_de: "Chaos Siegen besteht aus einer Handvoll freundlicher und offener Menschen mit einer Leidenschaft für Netzpolitik, dem Chaos Computer Club im Hintergrund und gestrandet im Hackspace Siegen durch die Wirren unserer Galaxie. #tuwat und komm vorbei!", + description_en: nil, + external_url: "https://c3si.de/", + location: "Siegen", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "erfa-stralsund", + title_de: "Port39 e.V. Stralsund", + title_en: "Port39 e.V. Stralsund", + description_de: "Als erster Erfa in Mecklenburg-Vorpommern sorgen wir als Port39 e.V. in Stralsund und Umgebung für eine ordentliche Portion Chaos. Mit Chaos macht Schule, Vorträgen und Workshops, Hacking-Sessions, Löt-Workshops, RepairCafés und vielem mehr wollen wir Jung und Alt für Technik, IT und allem, was dazu gehört, begeistern, und mit diversen Projekten im eigenen Hackerspace mal mehr, mal weniger sinnvolle Dinge anstellen. Kommt gerne rum oder schaut auf unserer Website oder auf Mastodon vorbei. Definitiv da sind wir jeden Donnerstag zum Chaostreff ab 19 Uhr und jeden 2. & 4. Samstag ab 14 Uhr zum OpenSpace.", + description_en: "As the first Erfa in Mecklenburg-Western Pomerania, we at Port39 e.V. make sure that there's a healthy dose of chaos in Stralsund and the surrounding area. Through \"Chaos macht Schule\" events, lectures, workshops, hacking sessions, soldering workshops, Repair Cafés, and much more, we aim to inspire people of all ages to get excited about technology, IT, and everything that goes with it. Feel free to drop by or check out our website or Mastodon. We're definitely there every Thursday for the Chaos Meetup starting at 7pm and every 2nd & 4th Saturday starting at 2pm for OpenSpace.", + external_url: "https://port39.de", + location: "Stralsund", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "erfa-stuttgart", + title_de: "CCC Stuttgart", + title_en: "CCC Stuttgart", + description_de: "Der Chaos Computer Club Stuttgart e.V. trifft sich jeden ersten Dienstag im Monat im Lichtblick in der Stadtmitte (Reinsburgstraße 13) ab 18:30 Uhr und jeden dritten Mittwoch im Monat im Shackspace (Ulmer Straße 255) ab 18:30 Uhr. Im Shackspace kann Bastelhardware gerne mitgebracht werden. Jeden zweiten Donnerstag im Monat haben wir unsere Vortragsreihe in der Stadtbibliothek Stuttgart am Mailänder Platz.", + description_en: "Der Chaos Computer Club Stuttgart e.V. meets every first Tuesday of the month at the Zadu-Bar (Reuchlinstraße 4b) at 6:30pm and every third Wednesday of the month at the Shackspace (Ulmer Straße 255). Our monthly Talk is every second Thursday of the month in the public library of Stuttgart at the Mailänder Platz at 7:30pm.", + external_url: "https://www.cccs.de/", + location: "Stuttgart", + rrule: "FREQ=MONTHLY;BYDAY=1TU", + start_time: "18:30", + review: true + }, + { + slug: "erfa-ulm", + title_de: "Chaostreff Ulm", + title_en: "Chaostreff Ulm", + description_de: "Der Chaostreff Ulm findet jeden Montag ab 19:30 Uhr im Café Einstein an der Uni Ulm statt, außer jeden zweiten Montag des Monats. Dieser ist dem Chaosseminar vorbehalten.", + description_en: "The Chaostreff Ulm takes place every Monday at 8:00pm in the Freiraum (3rd floor, Platzgasse 18, Ulm) at Hackerspace Ulm e.V. In addition, the talk series Chaos seminar on various topics is held there every second Monday of the month.", + external_url: "http://www.ulm.ccc.de/", + location: "Café Einstein, Uni Ulm", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "19:30", + review: true + }, + { + slug: "erfa-unna", + title_de: "CCC Unna (UN-Hack-Bar)", + title_en: "CCC Unna (UN-Hack-Bar)", + description_de: "Der CCC Unna trifft sich für gewöhnlich jeden Donnerstag ab ca. 19 Uhr in den Räumen der UN-Hack-Bar. Dort quatschen wir über allerlei Dinge wie z. B. Netzpolitik, Politik im Allgemeinen, Computer & Technik, aber auch schonmal darüber, wie man einen brennenden Feuerlöscher löscht oder das neueste Internet-Meme. ;-) Natürlich wird auch gebastelt, gebaut und im besten Sinne gehackt. Gäste aller Couleur sind gern gesehen.", + description_en: "The CCC Unna usually meets every Thursday at around 7pm in the rooms of the UN-Hack-Bar. We chat about all kinds of topics: from net politics and politics in general to computers and technology, and sometimes even about odd questions like how to extinguish a burning fire extinguisher or the latest internet meme. ;-) Of course, we also tinker, build, and hack things in the best possible sense. Guests of all kinds are very welcome.", + external_url: "https://www.un-hack-bar.de/ccc-unna/", + location: "Unna", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "erfa-wien", + title_de: "CCC Wien", + title_en: "CCC Vienna", + description_de: "Der Chaos Computer Club Wien trifft sich einmal im Monat, meistens im Metalab. Details zu den Treffen werden auf der Webseite des C3W bekanntgegeben. Für Fragen und allgemeine Announcements stehen die öffentliche C3W-Mailingliste oder @C3Wien@chaos.social bereit. Komm vorbei!", + description_en: "The Chaos Computer Club Vienna meets once a month. Usually at Metalab. Find details on our website. Use the public mailinglist or @C3Wien@chaos.social to ask questions or to receive announcements. Come by!", + external_url: "https://c3w.at/", + location: "Metalab, Wien", + rrule: "FREQ=MONTHLY", + start_time: "19:00", + review: false + }, + { + slug: "erfa-wuerzburg", + title_de: "Nerd2Nerd Würzburg", + title_en: nil, + description_de: "Nerd2Nerd trifft sich jeden Donnerstag ab 18 Uhr im FabLab Würzburg.", + description_en: nil, + external_url: "http://nerd2nerd.org/", + location: "FabLab Würzburg", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "18:00", + review: false + }, + { + slug: "erfa-zuerich", + title_de: "CCCZH Zürich", + title_en: "CCCZH Zurich", + description_de: "Der CCCZH ist Teil des Hackerspace bitwäscherei. Von Züri Hardbrücke aus einen halben Katzensprung in die Zentralwäscherei Zürich an der Neuen Hard 12.", + description_en: "The Chaostreff Zurich meets every Wednesday from 7pm at the Hackerspace at Luegislandstrasse 485 in Zürich/Schwamendingen.", + external_url: "https://www.ccczh.ch/", + location: "Neue Hard 12, Zürich", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: true + } +] + +erfas.each do |entry| + seed_chapter(parent_id: 548, tag: "erfa-detail", **entry) +end + +puts "Seeding chaostreffs..." + +chaostreffs = [ + { + slug: "chaostreff-aalen", + title_de: "Chaostreff Aalen", + title_en: nil, + description_de: "Wir sind ein Chaostreff und Hackspace in Aalen im Aaccellerator, Blezingerstraße 15, 73430 Aalen. Wir treffen uns regelmäßig jeden 3. Dienstag im Monat ab 18:30 Uhr. Für Treffen außerhalb von den Regelterminen verabreden wir uns spontan über Matrix.", + description_en: nil, + external_url: "https://aalen.space/", + location: "Blezingerstraße 15, 73430 Aalen", + rrule: "FREQ=MONTHLY;BYDAY=3TU", + start_time: "18:30", + review: false + }, + { + slug: "chaostreff-alzey", + title_de: "Chaostreff Alzey", + title_en: nil, + description_de: "An jedem ersten Sonntag des Monats trifft sich der Chaostreff Alzey um 15 Uhr im Juku Alzey. All creatures welcome!", + description_en: nil, + external_url: "https://chaostreff-alzey.de/", + location: "Juku Alzey", + rrule: "FREQ=MONTHLY;BYDAY=1SU", + start_time: "15:00", + review: false + }, + { + slug: "chaostreff-amberg-sulzbach", + title_de: "Chaostreff Amberg-Sulzbach", + title_en: nil, + description_de: "Wir treffen uns monatlich in Amberg in lockerer Runde. Es wird keine Anmeldung benötigt. All Creatures Welcome! Ort und Zeit der Treffen findet ihr auf der Webseite des Chaostreffs.", + description_en: nil, + external_url: "https://amborg-sulzbyte.de/", + location: "Amberg", + rrule: "FREQ=MONTHLY", + start_time: nil, + review: false + }, + { + slug: "chaostreff-amsterdam", + title_de: "Chaos Amsterdam", + title_en: "Chaos Amsterdam", + description_de: "Chaos Amsterdam meets on Thursdays every other week at 19:00 in a social space close to the city center. We can be found on IRC in #chaosamsterdam on the hackint network.", + description_en: "Chaos Amsterdam meets on Thursdays every other week at 19:00 in a social space close to the city center. We can be found on IRC in #chaosamsterdam on the hackint network.", + external_url: "https://chaos.amsterdam/", + location: "Amsterdam", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-andernach", + title_de: "haxko e.V. Andernach", + title_en: nil, + description_de: "Der Hacker- und Makerspace Mayen-Koblenz (haxko e.V.) befindet sich in der ehemaligen Gastwirtschaft des Bahnhofs Andernach. Unsere Treffen finden in geraden Kalenderwochen freitags und in ungeraden samstags statt. Beginn immer ab 18 Uhr. Thematisch sind wir offen und durch die Location direkt am Bahnhof gut zu erreichen.", + description_en: nil, + external_url: "https://haxko.space", + location: "Bahnhof Andernach", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-augsburg", + title_de: "OpenLab Augsburg", + title_en: nil, + description_de: "Aus dem Chaostreff Augsburg entstanden ist das OpenLab heute der Anlaufpunkt für alle die an nachhaltigem Handeln & Herstellen, dem kreativem Umgang mit Technik, Datenschutz und digitaler Selbstbemächtigung interessiert sind. Jeden Donnerstag offen für Interessierte und jeden dritten Donnerstag auch mit Vortrag im bekannten Chaostreff-Stil.", + description_en: nil, + external_url: "https://openlab-augsburg.de", + location: "Augsburg", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-aschaffenburg", + title_de: "Makerspace Schaffenburg", + title_en: nil, + description_de: "Der Chaostreff im Makerspace Schaffenburg ist ein Zusammenschluss von technikbegeisterten Menschen, die zusammen in (A)schaffenburg einen Ort schaffen, der genau dies bietet: Platz und Werkzeug zum Arbeiten, motivierte Menschen mit Know-How, kreative Ideen und Inspiration. Kurz gesagt: Raum für alle, die etwas machen wollen – ein Makerspace! Zu finden sind wir in der Dorfstraße 1 in Aschaffenburg.", + description_en: nil, + external_url: "http://www.schaffenburg.org", + location: "Dorfstraße 1, Aschaffenburg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-backnang", + title_de: "Chaostreff Backnang", + title_en: nil, + description_de: "Wir treffen uns an jedem 3. Sonntag des Monats zum Erfahrungsaustausch und der gemeinsamen Umsetzung von Projekten sowie an jedem 1. Dienstag im Monat zum offenen Stammtisch im dasWohnzimmer (Willy-Brandt-Platz 2) in Backnang bei Stuttgart und freuen uns auf neue Gesichter. Strom, Freifunk sowie Getränke und Snacks der Bar (auch Mate) werden angeboten.", + description_en: nil, + external_url: "https://chaostreff-backnang.de/", + location: "Willy-Brandt-Platz 2, Backnang", + rrule: "FREQ=MONTHLY;BYDAY=3SU", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-bayreuth", + title_de: "Imaginärraum Bayreuth", + title_en: nil, + description_de: "Der Imaginärraum ist ein junger Hackerspace, der sich jeden Montag um 19 Uhr in seinen Räumen in der Schulstraße 7 in Bayreuth trifft.", + description_en: nil, + external_url: "https://imaginaerraum.de/", + location: "Schulstraße 7, Bayreuth", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-bern", + title_de: "Chaostreff Bern", + title_en: nil, + description_de: "Der Chaostreff Bern trifft sich jeden Dienstag ab 19 Uhr im eigenen Hackerspace an der Zwyssigstrasse 45. Dort gibt es Strom, Internet, Mate und viel Platz zum Hacken.", + description_en: nil, + external_url: "https://www.chaostreffbern.ch/", + location: "Zwyssigstrasse 45, Bern", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-bielefeld", + title_de: "Hackerspace Bielefeld", + title_en: nil, + description_de: "Bielefeld gibt's gar nicht? Weiß man nicht. Gibt es denn einen Chaostreff? Auch das ist ungewiss, aber chaosnahe Leute treffen sich im Hackerspace Bielefeld.", + description_en: nil, + external_url: "http://hackerspace-bielefeld.de/", + location: "Bielefeld", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-bingen", + title_de: "/bin/hacken Bingen", + title_en: nil, + description_de: "Seit Anfang 2019 sind wir /bin/hacken, ein Hacker- & Makerspace in Bingen am Rhein. Wir bieten wöchentliche Treffen und eine offene Werkstatt zum Mitgestalten oder einfach eigene Projekte verwirklichen!", + description_en: nil, + external_url: "https://binhacken.de/", + location: "Bingen am Rhein", + rrule: "FREQ=WEEKLY", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-bochum", + title_de: "Das Labor Bochum", + title_en: nil, + description_de: "Das Labor, Hackspace und Chaostreff, ist in erster Linie ein Ort, an dem praktisch gearbeitet wird. Wir benutzen und entwickeln freie Software, löten, ätzen und programmieren Mikrocontroller, beschäftigen uns mit 3D-Druck, Freifunk, Amateurfunk, IT-Sicherheit, Arduinos, OSM oder Open Science. Wir haben den Anspruch, mit Technologie Neues und Sinnvolles zu erschaffen. Im Labor gibt es Vorträge, Workshops und Diskussionen zu den unterschiedlichsten Bereichen der Technik.", + description_en: nil, + external_url: "https://das-labor.org", + location: "Bochum", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-bonn", + title_de: "Datenburg e.V. Bonn", + title_en: nil, + description_de: "Der Datenburg e.V. ist ein seit 2019 existierender Hackspace und Chaostreff in Bonn und seit Anfang 2024 ein eingetragener Verein. Wir beschäftigen uns mit Themen rund um Technik und Gesellschaft. Die Datenburg ist auch ein Ort, um an eigenen Projekten zu arbeiten und sich auszutauschen. Wir öffnen jeden Dienstag ab 20 Uhr unsere Tore für alle interessierten Wesen.", + description_en: nil, + external_url: "https://datenburg.org/", + location: "Bonn", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-budapest", + title_de: "H.A.C.K. Budapest", + title_en: "H.A.C.K. Budapest", + description_de: "Members of the Hungarian Autonomous Center for Knowledge (H.A.C.K.) usually meet on Tuesdays at 19:00 local time, but it's best to confirm beforehand on #hspbp at IRCnet. We're located in the middle of the city, and we have Mate, sticker exchange, Internet, and friendly hackers.", + description_en: "Members of the Hungarian Autonomous Center for Knowledge (H.A.C.K.) usually meet on Tuesdays at 19:00 local time, but it's best to confirm beforehand on #hspbp at IRCnet. We're located in the middle of the city, and we have Mate, sticker exchange, Internet, and friendly hackers.", + external_url: "https://hsbp.org/contact-us", + location: "Budapest", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-coburg", + title_de: "Hackzogtum Coburg", + title_en: nil, + description_de: "Coburgs Chaostreff/Hackspace ist das Hackzogtum Coburg. Bei uns stehen vor allem Spaß an der Technik und reger Austausch im Vordergrund. Wer vorbeischauen will, ist bei uns immer willkommen. Ihr findet uns physikalisch in der Heiligkreuzstr. 3 in 96450 Coburg. Die meisten Leute gleichzeitig treffen sich immer dienstags ab 20 Uhr.", + description_en: nil, + external_url: "https://hackzogtum-coburg.de", + location: "Heiligkreuzstr. 3, 96450 Coburg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-cottbus", + title_de: "Chaostreff Cottbus", + title_en: nil, + description_de: "Der Treff für alle Chaos-Interessierten aus Cottbus und Umgebung. Angesiedelt am FabLab Cottbus (Walther-Pauer-Straße 7), treffen wir uns regelmäßig am letzten Mittwoch im Monat ab 18 Uhr und freuen uns auf neue Gesichter.", + description_en: nil, + external_url: "https://chaos-cb.de", + location: "Walther-Pauer-Straße 7, Cottbus", + rrule: "FREQ=MONTHLY;BYDAY=-1WE", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-eisenach", + title_de: "WAK-Lab Eisenach", + title_en: nil, + description_de: "Das WAK-Lab ist ein Raum für Hacker, Bastler und technikinteressierte Menschen. Unsere Ziele sind der Aufbau einer offenen Werkstatt, um den Erwerb und das Teilen von technischem Wissen zu fördern. Kontakt über Matrix oder auf unserer Homepage.", + description_en: nil, + external_url: "https://wak-lab.org", + location: "Eisenach", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-erfurt", + title_de: "Bytespeicher Erfurt", + title_en: nil, + description_de: "Der Hackspace Bytespeicher öffnet jeden Mittwoch zum Open Space und trifft sich ab 19 Uhr zum Chaostreff Erfurt in der Liebknechtstraße 8. Wir bieten mit Hackspace, Konferenzraum und Elektronikwerkstatt alle Möglichkeiten zum gemeinsamen Hacken, Lernen, Präsentieren und Experimentieren. Wir unterstützen außerdem Freifunk Erfurt und die Programmier-Initiative Kids@Digital.", + description_en: nil, + external_url: "https://www.bytespeicher.org/", + location: "Liebknechtstraße 8, Erfurt", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-giessen", + title_de: "Chaostreff Gießen", + title_en: nil, + description_de: "Lockerer Treff für alle Chaos-Interessierten aus dem Raum Gießen. Zur Zeit beherbergt uns am ersten Mittwoch des Monats das Jhrings Wirtsstuben (Ludwigstraße 10) ab 19 Uhr. An den anderen Mittwochen des Monats treffen wir uns in den Räumlichkeiten von Mudbytes. Um Verwechslungen vorzubeugen, schaut bitte unter giessen.ccc.de nach dem aktuellen Stand.", + description_en: nil, + external_url: "https://giessen.ccc.de", + location: "Ludwigstraße 10, Gießen", + rrule: "FREQ=MONTHLY;BYDAY=1WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-graz", + title_de: "RealRaum Graz", + title_en: nil, + description_de: "Grundsätzlich ist bei den Workshops und sonstigen Veranstaltungen immer jeder willkommen, der Lust hat, was Neues dazuzulernen oder selbst was dazu beizutragen, egal ob Mitglied oder nicht. Ziel ist es, einen chaotisch gemischten Haufen zu schaffen, der fähig ist, sich mit seinen Ideen gegenseitig zu inspirieren. Besonders sind alle willkommen, die bereits bei anderen Vereinen/Initiativen tätig sind (LUGG, STG, Spektral und ähnliches). Die Treffen finden im RealRaum statt.", + description_en: nil, + external_url: "https://realraum.at/", + location: "Graz", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-goeppingen", + title_de: "Chaostreff Göppingen (SCHAFFEREI)", + title_en: nil, + description_de: "Spaß an der Technik haben, kreativ sein, Altes reparieren, Neues generieren, Wissen teilen – gemeinsam macht das Chaos Laune. Der Chaostreff Göppingen findet monatlich in der Göppinger SCHAFFEREI statt. Freifunk, Lötkolben und Mate sind vorhanden.", + description_en: nil, + external_url: "https://schafferei.org/chaos-treff/", + location: "Göppingen", + rrule: "FREQ=MONTHLY", + start_time: nil, + review: false + }, + { + slug: "chaostreff-gunzenhausen", + title_de: "Chaostreff Gunzenhausen", + title_en: nil, + description_de: "Jeden Dienstag in einer ungeraden Kalenderwoche treffen wir uns ab 19 Uhr im FabLab in Gunzenhausen. Gäste und Interessierte sind gerne willkommen!", + description_en: nil, + external_url: "https://chaostreff-gun.de/", + location: "FabLab Gunzenhausen", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-halle", + title_de: "Chaostreff Halle (Saale)", + title_en: nil, + description_de: "Der Chaostreff trifft sich wöchentlich am Dienstag ab 19 Uhr im Eigenbaukombinat zum Hackerspacetreffen in der Landsberger Straße 3. Reden, tüfteln, hacken inklusive.", + description_en: nil, + external_url: "https://eigenbaukombinat.de", + location: "Landsberger Straße 3, Halle (Saale)", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-hamm", + title_de: "c3hamm", + title_en: nil, + description_de: "Wir, c3hamm, sind seit dem 5.5.2025 Verein. Bauen gerade am HHiC, ein Hackspace & Repair-Cafe zum Mitnehmen im Container. Wir treffen uns regelmässig am 1ten Mittwoch im Monat im Rahmen des eStatischH (Energier-Stammtisch Hamm) VorOrt. Nicht selten treffen wir uns Sonntags zum VRunch inner Brille und erkunden SocialVR Räume, pflegen ein kleines cHaoS-Log: y.lab.nrw/c3h-logs.", + description_en: nil, + external_url: "https://chaos.social/@c3hamm", + location: "Hamm", + rrule: "FREQ=MONTHLY;BYDAY=1WE", + start_time: nil, + review: false + }, + { + slug: "chaostreff-heidelberg", + title_de: "NoName e.V. Heidelberg", + title_en: nil, + description_de: "Wir sind der NoName e.V., ein lustiger zusammengewürfelter Haufen, der sich mindestens einmal pro Woche trifft. Jeder Technikinteressierte ist bei uns gerne gesehen. Der regelmäßige Treff am Donnerstag findet abwechselnd an verschiedenen Orten statt. Infos über spontane Treffen findet man im TWiCEiRC-Channel #chaos-hd.", + description_en: nil, + external_url: "https://www.noname-ev.de", + location: "Heidelberg", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-hildesheim", + title_de: "Freies Labor Hildesheim", + title_en: nil, + description_de: "Der Hackerspace „Freies Labor“ ist ein Ort, an dem sich unterschiedliche Disziplinen treffen, kennenlernen und kooperieren. In unserem Labor wollen wir in gemütlicher Atmosphäre gemeinsam tüfteln, kochen, brauen und entdecken. Wir wollen unsere Fähigkeiten und unser Wissen miteinander teilen und stellen Werkstatt, Küche und gemütliche Räume zur Verfügung.", + description_en: nil, + external_url: "https://blog.freieslabor.org/", + location: "Hildesheim", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-hilpoltstein", + title_de: "Chaostreff Hip Hilpoltstein", + title_en: nil, + description_de: "Jeden zweiten Dienstag (ungerade Kalenderwochen) trifft sich der Chaostreff Hip ab 18 Uhr zum gemeinsamen Tüfteln, Reden und Verkosten von Mate im Haus des Gastes (Maria-Dorothea-Straße 8). Interessierte Gäste sind immer herzlich willkommen.", + description_en: nil, + external_url: "https://chaos-hip.de/", + location: "Maria-Dorothea-Straße 8, Hilpoltstein", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-hoher-flaeming", + title_de: "ge.hackt.es Hoher Fläming", + title_en: nil, + description_de: "In Brandenburg, mitten im Wald, gibt es ge.hackt.es! In unserem Hackspace im Hohen Fläming beschäftigen wir uns nicht ganz zufällig mit Digitalisierung im ländlichen Raum. Wir basteln außerdem Schönes mit Sensoren und bringen ein Repair-Café an den Start. Schreibt uns, wenn ihr den Weg durch den Wald zu uns finden wollt!", + description_en: nil, + external_url: "https://ge.hackt.es/", + location: "Hoher Fläming, Brandenburg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-ilmenau", + title_de: "ilmspace Ilmenau", + title_en: nil, + description_de: "Wir treffen uns regelmäßig jeden Mittwoch ab 19 Uhr zum open space und einmal im Monat zum Orgatreff. Der Space bietet Raum für alle Chaos-Interessierten aus Ilmenau und Umgebung. Wir helfen euch gern bei Reparaturen, beim Einstieg oder Umstieg in die Linuxwelt oder bei eigenen Projekten. Ihr seid herzlich eingeladen, aktiv unseren Space mitzugestalten. Kontakt via Matrix.", + description_en: nil, + external_url: "http://ilmspace.de/", + location: "Ilmenau", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-ingolstadt", + title_de: "bytewerk Ingolstadt", + title_en: nil, + description_de: "Am 10. April 2010 wurde in Ingolstadt das bytewerk eröffnet, der Hackerspace des Ingolstädter Chaostreff, der aus dem bingo e.V. hervorgegangen ist.", + description_en: nil, + external_url: "http://www.bytewerk.org/", + location: "Ingolstadt", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-innsbruck", + title_de: "IT-Syndikat Innsbruck", + title_en: nil, + description_de: "Das chaosnahe IT-Syndikat ist (mindestens) jeden Dienstag ab 19 Uhr für Besuch offen. Wo? Tschamlerstraße 3, über dem Weekender Club. Sollte unten ein Türsteher-NPC den Weg versperren, kommt man mit einem Verweis auf „die Künstler/Hacker“ immer vorbei.", + description_en: nil, + external_url: "http://it-syndikat.org", + location: "Tschamlerstraße 3, Innsbruck", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-itzehoe", + title_de: "Computerclub Itzehoe e.V.", + title_en: nil, + description_de: "Der Itzehoer Chaostreff ist beim Computerclub Itzehoe e.V. angesiedelt und beschäftigt sich zwanglos mit unterschiedlichsten Hard- und Softwareprojekten. Treffen sind donnerstags ab 19 Uhr.", + description_en: nil, + external_url: "http://cciz.de/", + location: "Itzehoe", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-jena", + title_de: "Offener Chaostreff Jena (Krautspace)", + title_en: nil, + description_de: "Der Offene Chaostreff Jena trifft sich dienstags ab 20 Uhr im Krautspace in der Krautgasse 26.", + description_en: nil, + external_url: "https://kraut.space/", + location: "Krautgasse 26, Jena", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-kiel", + title_de: "Science Monday Kiel (Toppoint)", + title_en: nil, + description_de: "Science Monday - Chaos and more ist der lokale Chaostreff in Kiel. Seit dem ersten Chaostreffen in Kiel finden die Treffen montags ab 19 Uhr in den Räumen der Toppoint statt.", + description_en: nil, + external_url: "http://toppoint.de/", + location: "Kiel", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-klaus-vorarlberg", + title_de: "Open-Lab Klaus (Vorarlberg)", + title_en: nil, + description_de: "Wir treffen uns jeden Dienstag von 16-20 Uhr im Open-Lab in Klaus, Vorarlberg. Ob jung, ob alt, es ist jewesen bei uns willkommen. Wir haben Internet, Werkzeuge, Lötkolben, 3D-Drucker, Sofas und noch einiges mehr zur Verfügung.", + description_en: nil, + external_url: "https://open-lab.at/", + location: "Klaus, Vorarlberg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "16:00", + review: false + }, + { + slug: "chaostreff-konstanz", + title_de: "hacKNology e.V. Konstanz", + title_en: nil, + description_de: "Der Chaostreff Konstanz trifft sich jeden 2. Dienstag um 19 Uhr in den Räumlichkeiten des Hackerspace hacKNology e. V.", + description_en: nil, + external_url: "https://www.hacknology.de/", + location: "Konstanz", + rrule: "FREQ=MONTHLY;BYDAY=2TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-landau", + title_de: "CTRL-Z Landau", + title_en: nil, + description_de: "CTRL-Z lädt an drei Dienstagen im Monat zum Open Space bis 23 Uhr ein. Als Teil des Zentrums für Technikkultur Landau e. V. kann man uns auch bei den zahlreichen Workshops im ZTL treffen. Wir hoffen, ihr schaut mal bei uns vorbei, in der Klaus-Von-Klitzing-Str. 2, im schönen Landau in der Pfalz. Unser Zugang ist barrierefrei, all creatures welcome!", + description_en: nil, + external_url: "https://ctrl-z.info/", + location: "Klaus-Von-Klitzing-Str. 2, Landau", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-loerrach", + title_de: "technik.cafe Lörrach", + title_en: nil, + description_de: "Im technik.cafe Lörrach trifft sich monatlich der Chaostreff Lörrach. Dort wird gefachsimpelt, gebastelt, gelernt und ausgetauscht. Es gibt Werkzeug, Komponenten, Lötkolben, Hardware und Netzwerkzubehör zum Basteln, Testen und Reparieren. Betrieben wird das technik.cafe von Privatleuten aus Leidenschaft, es gibt keinen Verein, keine Mitgliedschaft, keine Gebühren.", + description_en: nil, + external_url: "https://technik.cafe/", + location: "Lörrach", + rrule: "FREQ=MONTHLY", + start_time: nil, + review: false + }, + { + slug: "chaostreff-ludwigsburg", + title_de: "Chaostreff Ludwigsburg (DemoZ)", + title_en: nil, + description_de: "Der Chaostreff Ludwigsburg ist ein lockeres und offenes Zusammentreffen von Menschen, die sich dem CCC und der Hackerethik nahe fühlen. Wir treffen uns am letzten Donnerstag im Monat ab 18:00 Uhr im DemoZ in Ludwigsburg und ständig bei Matrix.", + description_en: nil, + external_url: "https://complb.de", + location: "DemoZ, Ludwigsburg", + rrule: "FREQ=MONTHLY;BYDAY=-1TH", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-luxemburg", + title_de: "CCC Lëtzebuerg", + title_en: nil, + description_de: "Der Chaos Computer Club Lëtzebuerg trifft sich jeden Montag um 20 Uhr in der Hauptstadt Luxemburgs im Hackerspace ChaosStuff. Interessierte sind jederzeit herzlich willkommen, zu unseren Treffen zu kommen.", + description_en: nil, + external_url: "http://www.c3l.lu/", + location: "Luxemburg", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-marburg", + title_de: "Hackspace Marburg (hsmr)", + title_en: nil, + description_de: "Der Chaostreff Marburg findet jeden Montag ab 18:00 Uhr im Hackspace Marburg statt. Aber auch sonst lohnt ein Besuch des [hsmr] immer. Erreichen könnt Ihr uns über die Mailingliste oder im IRC auf hackint #hsmr.", + description_en: nil, + external_url: "https://hsmr.cc/", + location: "Marburg", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-markdorf", + title_de: "Toolbox Bodensee e.V. Markdorf", + title_en: nil, + description_de: "Der Chaostreff Markdorf trifft sich regelmäßig im Hackerspace Toolbox Bodensee e. V. Alle galaktischen Wesen sind willkommen.", + description_en: nil, + external_url: "https://bodensee.space/chaostreff-markdorf", + location: "Markdorf", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-muenster", + title_de: "warpzone Münster", + title_en: nil, + description_de: "Das Chaos in Münster trifft sich jeden Mittwoch ab 19 Uhr in der warpzone, 51.943374° N, 7.638241° E.", + description_en: nil, + external_url: "http://www.warpzone.ms/", + location: "Münster", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-neuss", + title_de: "fnordeingang e.V. Neuss", + title_en: nil, + description_de: "Der Chaostreff Neuss findet jeden Mittwoch ab 19 Uhr in den Räumen des fnordeingang e. V. statt. Jeder ist herzlich willkommen, es wird keine Anmeldung benötigt.", + description_en: nil, + external_url: "https://fnordeingang.de/chaostreff", + location: "Neuss", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-nuernberg", + title_de: "Chaostreff Nürnberg", + title_en: nil, + description_de: "Der Chaostreff Nürnberg trifft sich abwechselnd im Hackerspace K4CG und Nerdberg.", + description_en: nil, + external_url: "https://chaostreff-nuernberg.de", + location: "Nürnberg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-osnabrueck", + title_de: "Chaostreff Osnabrück", + title_en: nil, + description_de: "Der Chaostreff Osnabrück ist eine lockere Gruppe von Leuten mit Interesse in den Bereichen Sicherheit, Kryptographie, alternative Betriebssysteme, freie Software, Netzpolitik und vielen weiteren Themen. Interessierte sind jederzeit herzlich willkommen, zu unseren Treffen zu kommen.", + description_en: nil, + external_url: "https://chaostreff-osnabrueck.de/", + location: "Osnabrück", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-potsdam", + title_de: "Chaostreff Potsdam (machBar)", + title_en: nil, + description_de: "Der Chaostreff Potsdam trifft sich jeden Mittwoch um 19 Uhr. Die Treffen finden in den Räumen des Hackerspace machBar im freiLand, Friedrich-Engels-Straße 22, Haus 5 statt. Alle galaktischen Lebensformen sind willkommen.", + description_en: nil, + external_url: "https://www.ccc-p.org/", + location: "Friedrich-Engels-Straße 22, Haus 5, Potsdam", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-rapperswil", + title_de: "Coredump Rapperswil-Jona", + title_en: nil, + description_de: "(Fast) jeden Montag treffen wir uns ab 20 Uhr im Hackerspace Coredump auf dem Vinora-Areal in Jona (Schweiz) zur wöchentlichen „Hacknight“. Wie bei all unseren Events sind Gäste dabei herzlich willkommen. Mit Ferienpass-Kursen und regelmäßigen Rust-Meetups engagiert sich der Verein Coredump auch in der Kinder- und Erwachsenen-Bildung.", + description_en: nil, + external_url: "https://www.coredump.ch/", + location: "Vinora-Areal, Jona", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-recklinghausen", + title_de: "Chaostreff Recklinghausen (Hackerhütte)", + title_en: nil, + description_de: "Jeden Mittwoch trifft sich der Chaostreff Recklinghausen in der Hackerhütte, Westcharweg 101, 45659 Recklinghausen.", + description_en: nil, + external_url: "http://c3re.de", + location: "Westcharweg 101, 45659 Recklinghausen", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-regensburg", + title_de: "Binary Kitchen Regensburg", + title_en: nil, + description_de: "Regensburg hackt in der Binary Kitchen. Immer montags. Offen für alle.", + description_en: nil, + external_url: "http://binary.kitchen", + location: "Regensburg", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-rodgau", + title_de: "Chaostreff Rodgau", + title_en: nil, + description_de: "Wir haben Anfang 2025 einen Chaostreff Rodgau gegründet. Wir treffen uns einmal im Monat an unterschiedlichen Orten. Termin und Ort werden auf unserer Website bekanntgegeben.", + description_en: nil, + external_url: "https://chaostreff-rodgau.codeberg.page", + location: "Rodgau", + rrule: "FREQ=MONTHLY", + start_time: nil, + review: false + }, + { + slug: "chaostreff-rotterdam", + title_de: "Chaostreff Rotterdam (Pixelbar)", + title_en: "Chaostreff Rotterdam (Pixelbar)", + description_de: "The Chaostreff of Rotterdam meets every Wednesdays around 20 Uhr at Pixelbar in the Keilewerf at the Vierhavensstraat 56 in Rotterdam close to RET / Metro station Marconiplein.", + description_en: "The Chaostreff of Rotterdam meets every Wednesdays around 20 Uhr at Pixelbar in the Keilewerf at the Vierhavensstraat 56 in Rotterdam close to RET / Metro station Marconiplein.", + external_url: "https://www.pixelbar.nl/contact/", + location: "Vierhavensstraat 56, Rotterdam", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-saarbruecken", + title_de: "hacksaar Saarbrücken", + title_en: nil, + description_de: "Unser regelmäßiges Treffen findet jeden Mittwoch ab 19 Uhr im h´eck in der Rathausstraße 18 in 66125 Saarbrücken statt. Gäste und Interessierte sind immer gerne willkommen.", + description_en: nil, + external_url: "https://www.hacksaar.de", + location: "Rathausstraße 18, 66125 Saarbrücken", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-schwerin", + title_de: "Hacklabor Schwerin", + title_en: nil, + description_de: "Bist Du interessiert an Computerkrams, Programmieren, Capture-the-Flag-Challenges, 3D-Druckern, CO2-Lasern, Microcontrollern, Automatisierung, gesellschaftlichen Auswirkungen von Technik und Spaß am Gerät? Hat es Dich nach Mecklenburg-Vorpommern verschlagen? Dann komm ins Hacklabor in der Hagenower Straße 73, Schwerin. In unserer offenen Werkstatt findet sich vom Lötkolben bis zum 3D-Drucker alles, was das Makerherz höher schlagen lässt. Jugendlichen bieten wir im \"Jugend hackt Lab\" die Möglichkeit, Neues auszuprobieren und kreative Ideen zu verwirklichen. Unsere Treffen sind öffentlich und finden mittwochs und freitags ab 19 Uhr statt.", + description_en: nil, + external_url: "https://hacklabor.de/", + location: "Hagenower Straße 73, Schwerin", + rrule: "FREQ=WEEKLY;BYDAY=WE,FR", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-stralsund", + title_de: "Port39 e.V. Stralsund (Chaostreff)", + title_en: "Port39 e.V. Stralsund", + description_de: "Das Chaos ist jetzt auch in Stralsund eingezogen! Als ein weiterer Space in Mecklenburg-Vorpommern möchten wir auch hier das gute Chaos verbreiten, mit Chaos macht Schule in Stralsund und Umgebung Jung und Alt für Technik und IT begeistern und mit diversen Projekten im Space mal mehr, mal weniger sinnvolle Dinge anstellen. Kommt gerne rum oder schaut auf unserer Website oder den Socials vorbei. Definitiv da sind wir jeden Donnerstag zum Chaostreff ab 19 Uhr und jeden 2. & 4. Samstag ab 14 Uhr zum OpenSpace.", + description_en: "As the first Erfa in Mecklenburg-Western Pomerania, we at Port39 e.V. make sure that there's a healthy dose of chaos in Stralsund. Feel free to drop by or check out our website. We're definitely there every Thursday for the Chaos Meetup starting at 7pm and every 2nd & 4th Saturday starting at 2pm for OpenSpace.", + external_url: "https://port39.de/", + location: "Stralsund", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-trier", + title_de: "Maschinendeck e.V. Trier", + title_en: nil, + description_de: "Mit dem Maschinendeck e. V. haben wir uns zum Ziel gesetzt, in Trier einen Ort zu schaffen, wo alle Arten von Nerdkultur ein Zuhause finden können. Ob Du coden, löten, Kaffee rösten, Brettspiele spielen oder Deine neueste Verschwörungstheorie besprechen willst: Im Maschinendeck gibt es einen Platz für Dich. Das wöchentliche Treffen findet jeden Mittwoch, 20 Uhr, in unseren Räumen statt.", + description_en: nil, + external_url: "http://www.maschinendeck.org", + location: "Trier", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-tuebingen", + title_de: "Chaostreff Tübingen", + title_en: nil, + description_de: "Der Chaostreff Tübingen ist eine lockere Sammlung zumeist Tübinger und Reutlinger Chaos. Wir treffen uns regelmäßig am letzten Sonntag im Monat gegen 18:00 Uhr im KIMS und ungefähr jeden 2. Montag im Monat gegen 19:00 Uhr im FabLab. Beide Orte glücklicherweise gut zu Fuß vom Bahnhof erreichbar.", + description_en: nil, + external_url: "https://cttue.de", + location: "Tübingen", + rrule: "FREQ=MONTHLY;BYDAY=-1SU", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-villingen-schwenningen", + title_de: "vspace.one Villingen-Schwenningen", + title_en: nil, + description_de: "Der Chaostreff Villingen-Schwenningen ist Treffpunkt für alle Chaosnahen aus dem Raum Schwarzwald-Baar. Er ist dem Maker- / Hackerspace vspace.one angeschlossen. Jeden Dienstag ab 19 Uhr finden öffentliche Treffen zur Wissensförderung in der Wilhelm-Binder-Straße 19 in Villingen statt. Für gemütliche Plätze, coole Atmosphäre und Mate ist gesorgt!", + description_en: nil, + external_url: "http://vspace.one", + location: "Wilhelm-Binder-Straße 19, Villingen-Schwenningen", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-waldkraiburg", + title_de: "Chaostreff Waldkraiburg", + title_en: nil, + description_de: "Der Chaostreff Waldkraiburg ist eine dem Chaos Computer Club zugehörige Gruppe und Anlaufstelle für alle chaosnahen und interessierten Wesen für die Landkreise Mühldorf am Inn, Altötting und darüber hinaus. Wir sind technikbegeisterte Menschen und beschäftigen uns mit IT-Sicherheit, Webdesign, Gaming, alternativen Betriebssystemen, Retrocomputing, freier Software, Netzpolitik und vielen weiteren Themen. Ob Anfänger oder Profi spielt keine Rolle, es ist keine Anmeldung erforderlich.", + description_en: nil, + external_url: "https://c3wkb.de", + location: "Waldkraiburg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-westerwald", + title_de: "Westwoodlabs Westerwald", + title_en: nil, + description_de: "Der Chaostreff Westerwald trifft sich jeden Dienstag um 18 Uhr für alle Interessierten in den Räumen der Westwoodlabs in Ransbach-Baumbach.", + description_en: nil, + external_url: "https://westwoodlabs.de/", + location: "Ransbach-Baumbach", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-wuppertal", + title_de: "Chaostreff Wuppertal (chaostal)", + title_en: nil, + description_de: "Der Chaostreff unter der Schwebebahn organisiert in regelmäßigen Abständen Treffen zum gegenseitigen Austausch. Neue Besucher sind jederzeit willkommen. Treffen finden jeden ersten Donnerstag im Monat ab 20 Uhr im Mirker Bahnhof, Mirker Straße 48, Wuppertal-Elberfeld statt. An allen anderen Donnerstagen wechselt der Ort.", + description_en: nil, + external_url: "https://www.chaostal.de/category/meeting", + location: "Mirker Straße 48, Wuppertal-Elberfeld", + rrule: "FREQ=MONTHLY;BYDAY=1TH", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-zwickau", + title_de: "z-Labor Zwickau", + title_en: nil, + description_de: "Für alle chaosnahen Lebewesen öffnet das z-Labor donnerstags um 19 Uhr die Räumlichkeiten in der Kulturweberei zum kollektiven Hacken, Basteln, Lernen und Philosophieren. Wir mögen Freie Software, IT-Sicherheit, (Analog-)Fotografie, 3D-Druck, Mate und viele Formen von (digitaler) Kunst. Neben Elektroniklabor und Rechentechnik gibt es eine Holz- und Metallwerkstatt, eine Musik-Ecke und einen Retro-Spiel-Bereich.", + description_en: nil, + external_url: "https://www.z-labor.space", + location: "Kulturweberei, Zwickau", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + } +] + +chaostreffs.each do |entry| + seed_chapter(parent_id: 549, tag: "chaostreff-detail", **entry) +end + +puts "Done." +puts "Nodes flagged for review:" +(erfas + chaostreffs).select { |e| e[:review] }.each do |e| + puts " #{e[:slug]}" +end + +Node.rebuild!(false) -- cgit v1.3 From 51629c5c42270a346885057a441095c964101cc1 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 30 Jun 2026 03:55:42 +0200 Subject: Fix events CRUD for standalone events and add events to admin menu - event_params now permits title, description, is_primary - event_information helper lists all node.events, not just the first - Occurrence.generate handles nil node (standalone events) - Page.aggregate order_by title uses correlated subquery to avoid GROUP BY conflict with tag-filter path; order_direction whitelisted to ASC/DESC to prevent SQL injection - Events link added to admin menu bar - events/index shows title, is_primary; drops latitude/longitude columns --- app/controllers/events_controller.rb | 2 +- app/helpers/nodes_helper.rb | 20 +++++++++----------- app/models/occurrence.rb | 2 +- app/models/page.rb | 8 ++++---- app/views/admin/_menu.html.erb | 1 + app/views/events/index.html.erb | 8 ++++---- 6 files changed, 20 insertions(+), 21 deletions(-) (limited to 'app/helpers') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index f50da3e..3a60cf9 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -94,6 +94,6 @@ class EventsController < ApplicationController private def event_params - params.require(:event).permit(:start_time, :end_time, :rrule, :custom_rrule, :allday, :url, :latitude, :longitude, :node_id, :location) + params.require(:event).permit(:title, :description, :is_primary, :start_time, :end_time, :rrule, :custom_rrule, :allday, :url, :latitude, :longitude, :node_id, :location) end end diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index d88d12a..4293628 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -30,19 +30,17 @@ module NodesHelper end def event_information - if @node.events.first - event = @node.events.first + events = @node.events.order(:start_time) + items = events.map do |event| safe_join([ - "#{event.start_time.to_fs(:db)} - #{event.end_time.to_fs(:db)} > ", - link_to('show', event_path(event)), - ' > ', - link_to('edit', edit_event_path(event)) - ]) - else - safe_join([ - 'no event attached > ', - link_to('add', new_event_path(:node_id => @node.id)) + "#{event.start_time&.to_fs(:db)} - #{event.end_time&.to_fs(:db)} > ", + link_to('edit', edit_event_path(event)), ]) end + safe_join([ + safe_join(items, ' | '), + ' > ', + link_to('add event', new_event_path(:node_id => @node.id)) + ]) end end diff --git a/app/models/occurrence.rb b/app/models/occurrence.rb index 143124f..777be24 100644 --- a/app/models/occurrence.rb +++ b/app/models/occurrence.rb @@ -35,7 +35,7 @@ class Occurrence < ApplicationRecord self.create( :start_time => occurrence, :end_time => (occurrence + duration), - :node_id => node.id, + :node_id => node&.id, :event_id => event.id ) end diff --git a/app/models/page.rb b/app/models/page.rb index 385b3f6..c982c2e 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -63,15 +63,15 @@ class Page < ApplicationRecord end end + direction = %w[ASC DESC].include?(options[:order_direction]&.upcase) ? options[:order_direction].upcase : "ASC" + if options[:order_by] == "title" return scope - .joins(:translations) - .where(page_translations: { locale: I18n.locale }) - .order("page_translations.title #{options[:order_direction]}") + .order(Arel.sql("(SELECT pt.title FROM page_translations pt WHERE pt.page_id = pages.id AND pt.locale = #{ActiveRecord::Base.connection.quote(I18n.locale.to_s)}) #{direction}")) .paginate(:page => page, :per_page => options[:limit]) end - scope.order("#{options[:order_by]} #{options[:order_direction]}") + scope.order("#{options[:order_by]} #{direction}") .paginate(:page => page, :per_page => options[:limit]) end diff --git a/app/views/admin/_menu.html.erb b/app/views/admin/_menu.html.erb index 6dba085..c87c5f7 100644 --- a/app/views/admin/_menu.html.erb +++ b/app/views/admin/_menu.html.erb @@ -4,5 +4,6 @@ search <%= link_to 'Nodes', nodes_path, selected?('nodes') %> <%= link_to 'Assets', assets_path, selected?('assets') %> +<%= link_to 'Events', events_path, selected?('events') %> <%= link_to 'User', users_path, selected?('users') %> <%= link_to 'Navigation', menu_items_path, selected?('menu_items') %> >  diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 19b21ce..064fa86 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -2,27 +2,27 @@ + + - - <% @events.each do |event| %> + + - - -- cgit v1.3 From 95955abaa339098755a214cfcadf87c90211fe64 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 1 Jul 2026 00:24:10 +0200 Subject: Add RRULE humanizer and wire events into nodes#show - app/models/concerns/rrule_humanizer.rb: new concern included into Event, renders recurring schedule as natural-language German or English from RRULE string; handles WEEKLY/MONTHLY, biweekly (INTERVAL=2), ordinal weekday positions (1TU, -1TH, -2WE), BYMONTH single-month exclusions (December pause convention); gracefully returns nil for COUNT/UNTIL/unrecognized shapes - test/models/concerns/rrule_humanizer_test.rb: 15 tests covering all distinct RRULE shapes found in production data - app/helpers/nodes_helper.rb: add event_schedule_text helper combining humanize_rrule with start_time formatting - app/views/nodes/show.html.erb: add events row, conditionally rendered when node has associated events - config/locales/de.yml, en.yml: add event_schedule_time, event_schedule_unrecognized, event_schedule_none keys --- app/helpers/nodes_helper.rb | 16 ++++++ app/models/concerns/rrule_humanizer.rb | 82 +++++++++++++++++++++++++++ app/models/event.rb | 1 + app/views/nodes/show.html.erb | 14 ++++- config/locales/de.yml | 3 + config/locales/en.yml | 3 + test/models/concerns/rrule_humanizer_test.rb | 84 ++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 app/models/concerns/rrule_humanizer.rb create mode 100644 test/models/concerns/rrule_humanizer_test.rb (limited to 'app/helpers') diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 4293628..329bcc5 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -43,4 +43,20 @@ module NodesHelper link_to('add event', new_event_path(:node_id => @node.id)) ]) end + + def event_schedule_text(event) + if event.rrule.present? + recurrence = event.humanize_rrule(I18n.locale) + if recurrence + time = event.start_time&.strftime("%H:%M") + time ? "#{recurrence} #{t(:event_schedule_time, time: time)}" : recurrence + else + "#{event.rrule} (#{t(:event_schedule_unrecognized)})" + end + elsif event.start_time + I18n.l(event.start_time, format: :long) + else + t(:event_schedule_none) + end + end end diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb new file mode 100644 index 0000000..6cee711 --- /dev/null +++ b/app/models/concerns/rrule_humanizer.rb @@ -0,0 +1,82 @@ +module RruleHumanizer + extend ActiveSupport::Concern + + WEEKDAY_NAMES = { + de: { "MO"=>"Montag","TU"=>"Dienstag","WE"=>"Mittwoch","TH"=>"Donnerstag","FR"=>"Freitag","SA"=>"Samstag","SU"=>"Sonntag" }, + en: { "MO"=>"Monday","TU"=>"Tuesday","WE"=>"Wednesday","TH"=>"Thursday","FR"=>"Friday","SA"=>"Saturday","SU"=>"Sunday" } + }.freeze + + WEEKDAY_NAMES_ADVERBIAL = { + de: { "MO"=>"montags","TU"=>"dienstags","WE"=>"mittwochs","TH"=>"donnerstags","FR"=>"freitags","SA"=>"samstags","SU"=>"sonntags" } + }.freeze + + ORDINAL_NAMES = { + de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, + en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } + }.freeze + + MONTH_NAMES = { + de: %w[Januar Februar März April Mai Juni Juli August September Oktober November Dezember], + en: %w[January February March April May June July August September October November December] + }.freeze + + def humanize_rrule(locale = I18n.locale) + return nil if rrule.blank? + parts = Hash[rrule.split(";").map { |p| p.split("=", 2) }] + return nil if parts["COUNT"] || parts["UNTIL"] # old one-off data, don't guess + + freq, interval, byday, bymonth = parts["FREQ"], parts["INTERVAL"].to_i, parts["BYDAY"], parts["BYMONTH"] + loc = locale.to_sym + weekdays = WEEKDAY_NAMES[loc] || WEEKDAY_NAMES[:en] + ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] + months = MONTH_NAMES[loc] || MONTH_NAMES[:en] + + days = byday&.split(",")&.map do |d| + if d =~ /^(-?\d+)([A-Z]{2})$/ + "#{ordinals[$1.to_i]} #{weekdays[$2]}" + else + weekdays[d] + end + end + + base = + case loc + when :de + case freq + when "WEEKLY" + if days + if interval == 2 + adverbial = byday.split(",").map { |d| WEEKDAY_NAMES_ADVERBIAL[:de][d] } + "Alle zwei Wochen #{adverbial.join(' und ')}" + else + "Jeden #{days.join(' und ')}" + end + else + interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" + end + when "MONTHLY" + days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" + end + else + case freq + when "WEEKLY" + days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") + when "MONTHLY" + days ? "Every #{days.join(' and ')} of the month" : "Monthly" + end + end + return nil unless base + + if bymonth + included = bymonth.split(",").map(&:to_i) + missing = ((1..12).to_a - included) + if missing.size == 1 + excluded_name = months[missing.first - 1] + base += (loc == :de ? ", außer im #{excluded_name}" : ", except in #{excluded_name}") + end + # more than one missing month: bymonth pattern more complex than we handle, leave base as-is silently + end + + base + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 26c79e4..de82674 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,4 +1,5 @@ class Event < ApplicationRecord + include RruleHumanizer belongs_to :node, optional: true has_many :occurrences diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index de4d8a2..7223219 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -42,6 +42,18 @@ + <% if @node.events.any? %> + + + + + <% end %> @@ -59,4 +71,4 @@
TitleIs primary Start time End time Rrule Custom rrule Allday UrlLatitudeLongitude Node
<%=h event.display_title %><%=h event.is_primary %> <%=h event.start_time %> <%=h event.end_time %> <%=h event.rrule %> <%=h event.custom_rrule %> <%=h event.allday %> <%=h event.url %><%=h event.latitude %><%=h event.longitude %> <%=h event.node_id %> <%= link_to 'Show', event %> <%= link_to 'Edit', edit_event_path(event) %>Tagged with: <%= @page.tag_list %>
Events +
    + <% @node.events.order(:start_time).each do |event| %> +
  • <%= event_schedule_text(event) %><%= " (primary)" if event.is_primary? %>
  • + <% end %> +
+
Title <%= sanitize( @page.title ) %>
- \ No newline at end of file + diff --git a/config/locales/de.yml b/config/locales/de.yml index 5f77d79..0b42dd3 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -7,6 +7,9 @@ de: sponsors: Sponsoren show_tag_headline: "Seiten mit dem Tag:" old_ccc_de: das alte ccc.de + event_schedule_time: "um %{time} Uhr" + event_schedule_unrecognized: "Termin-Regel nicht automatisch lesbar" + event_schedule_none: "Kein Termin" date: formats: default: "%d.%m.%Y" diff --git a/config/locales/en.yml b/config/locales/en.yml index 2458d4d..93a0d55 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -8,6 +8,9 @@ en: hello: "Hello world" show_tag_headline: "Pages tagged with:" old_ccc_de: the old ccc.de + event_schedule_time: "at %{time}" + event_schedule_unrecognized: "Schedule not automatically readable" + event_schedule_none: "No schedule" time: formats: diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb new file mode 100644 index 0000000..500dbc7 --- /dev/null +++ b/test/models/concerns/rrule_humanizer_test.rb @@ -0,0 +1,84 @@ +require 'test_helper' + +class RruleHumanizerTest < ActiveSupport::TestCase + def humanize(rrule, locale = :de) + Event.new(rrule: rrule).humanize_rrule(locale) + end + + test "weekly single day" do + assert_equal "Jeden Dienstag", humanize("FREQ=WEEKLY;BYDAY=TU") + assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :en) + end + + test "weekly two days" do + assert_equal "Jeden Mittwoch und Freitag", humanize("FREQ=WEEKLY;BYDAY=WE,FR") + assert_equal "Every Wednesday and Friday", humanize("FREQ=WEEKLY;BYDAY=WE,FR", :en) + end + + test "weekly no byday" do + assert_equal "Wöchentlich", humanize("FREQ=WEEKLY") + assert_equal "Weekly", humanize("FREQ=WEEKLY", :en) + end + + test "biweekly with day" do + assert_equal "Alle zwei Wochen donnerstags", humanize("FREQ=WEEKLY;INTERVAL=2;BYDAY=TH") + assert_equal "Every other Thursday", humanize("FREQ=WEEKLY;INTERVAL=2;BYDAY=TH", :en) + end + + test "biweekly no day" do + assert_equal "Alle zwei Wochen", humanize("FREQ=WEEKLY;INTERVAL=2") + assert_equal "Every other week", humanize("FREQ=WEEKLY;INTERVAL=2", :en) + end + + test "monthly nth weekday" do + assert_equal "Jeden ersten Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=1TU") + assert_equal "Jeden zweiten Freitag im Monat", humanize("FREQ=MONTHLY;BYDAY=2FR") + assert_equal "Jeden dritten Sonntag im Monat", humanize("FREQ=MONTHLY;BYDAY=3SU") + assert_equal "Jeden letzten Mittwoch im Monat", humanize("FREQ=MONTHLY;BYDAY=-1WE") + end + + test "monthly nth weekday english" do + assert_equal "Every first Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=1TU", :en) + assert_equal "Every last Wednesday of the month", humanize("FREQ=MONTHLY;BYDAY=-1WE", :en) + end + + test "monthly second-to-last" do + assert_equal "Jeden vorletzten Donnerstag im Monat", humanize("FREQ=MONTHLY;BYDAY=-2TH") + assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) + end + + test "monthly no byday" do + assert_equal "Monatlich", humanize("FREQ=MONTHLY") + assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) + end + + test "monthly with single excluded month" do + assert_equal "Jeden letzten Donnerstag im Monat, außer im Dezember", + humanize("FREQ=MONTHLY;BYDAY=-1TH;BYMONTH=1,2,3,4,5,6,7,8,9,10,11") + assert_equal "Every last Thursday of the month, except in December", + humanize("FREQ=MONTHLY;BYDAY=-1TH;BYMONTH=1,2,3,4,5,6,7,8,9,10,11", :en) + end + + test "monthly excluding january" do + assert_equal "Jeden zweiten Mittwoch im Monat, außer im Januar", + humanize("FREQ=MONTHLY;BYMONTH=2,3,4,5,6,7,8,9,10,11,12;BYDAY=2WE") + end + + test "blank rrule returns nil" do + assert_nil humanize(nil) + assert_nil humanize("") + end + + test "count and until are not guessed at" do + assert_nil humanize("FREQ=MONTHLY;BYDAY=1WE;COUNT=36") + assert_nil humanize("FREQ=MONTHLY;BYDAY=1WE;UNTIL=20050105T222222Z") + end + + test "unrecognized freq returns nil" do + assert_nil humanize("FREQ=YEARLY;BYMONTH=12") + end + + test "falls back to english for unknown locale" do + assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :fr) + end +end -- cgit v1.3 From 175b408948e601d3db568768a309eed8f2edb307 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 15:13:52 +0200 Subject: Add 'open today' sidebar widget and open days in chapter lists - open_erfas_today helper: samples 3 random open-day-tagged occurrences of the current day; widget hidden entirely on days without open chapters; heading shows current weekday - content/_open_erfas_today partial rendered in right column above tags and featured articles - _chapter partial: open-day schedules (event_schedule_text) shown inline in aggregated chapter lists with 'Offene Tage' label - ccc.css: open_erfas_today added to existing right-column widget selector groups (headings, lists, links) --- app/helpers/content_helper.rb | 16 ++++++++++++++++ app/views/content/_open_erfas_today.html.erb | 11 +++++++++++ app/views/custom/partials/_chapter.html.erb | 9 +++++++++ app/views/layouts/application.html.erb | 1 + config/locales/de.yml | 2 ++ config/locales/en.yml | 2 ++ public/stylesheets/ccc.css | 10 +++++----- 7 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 app/views/content/_open_erfas_today.html.erb (limited to 'app/helpers') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index 53bf5b2..bf7287f 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -23,6 +23,22 @@ module ContentHelper ) end + def open_erfas_today + occurrences = Occurrence + .find_in_range(Time.now.beginning_of_day, Time.now.end_of_day) + .joins(event: :tags) + .where(tags: { name: 'open-day' }) + .reject { |o| o.node.nil? || o.node.head.nil? } + .sample(3) + + return if occurrences.empty? + + render( + :partial => 'content/open_erfas_today', + :locals => { :occurrences => occurrences } + ) + end + def tags render :partial => 'content/tags' end diff --git a/app/views/content/_open_erfas_today.html.erb b/app/views/content/_open_erfas_today.html.erb new file mode 100644 index 0000000..3448af3 --- /dev/null +++ b/app/views/content/_open_erfas_today.html.erb @@ -0,0 +1,11 @@ +
+

<%= t(:open_today) %>

+
    + <% occurrences.each do |occurrence| %> +
  • + <%= link_to_path occurrence.node.head.title, occurrence.node.unique_name %> + <%= occurrence.start_time.strftime("%H:%M") %> +
  • + <% end %> +
+
diff --git a/app/views/custom/partials/_chapter.html.erb b/app/views/custom/partials/_chapter.html.erb index a5b8662..805559b 100644 --- a/app/views/custom/partials/_chapter.html.erb +++ b/app/views/custom/partials/_chapter.html.erb @@ -6,5 +6,14 @@ <% if page.node.external_url.present? %>
<%= link_to page.node.external_url, page.node.external_url, target: '_blank', rel: 'noopener' %>
<% end %> + <% open_days = page.node.events.tagged_with('open-day').order(:start_time) %> + <% if open_days.any? %> +
+ <%= t(:open_days_label) %>: + <% open_days.each do |event| %> + <%= event_schedule_text(event) %> + <% end %> +
+ <% end %>

<%= sanitize page.body %>

diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index edab5fc..57d12e6 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -72,6 +72,7 @@
+ <%= open_erfas_today %> <%= tags %> <%= featured_articles %>
diff --git a/config/locales/de.yml b/config/locales/de.yml index 9719a1c..e8ae4e1 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -11,7 +11,9 @@ de: event_schedule_unrecognized: "Termin-Regel nicht automatisch lesbar" event_schedule_none: "Kein Termin" open_days: "Offene Tage" + open_days_label: "Offene Tage" upcoming_events: "Weitere Termine" + open_today: "Heute dabei" date: formats: default: "%d.%m.%Y" diff --git a/config/locales/en.yml b/config/locales/en.yml index aa477f0..980a6b8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -12,7 +12,9 @@ en: event_schedule_unrecognized: "Schedule not automatically readable" event_schedule_none: "No schedule" open_days: "Open days" + open_days_label: "Open days" upcoming_events: "Upcoming events" + open_today: "Open today" time: formats: diff --git a/public/stylesheets/ccc.css b/public/stylesheets/ccc.css index 30eb8dd..422b7c0 100644 --- a/public/stylesheets/ccc.css +++ b/public/stylesheets/ccc.css @@ -216,7 +216,7 @@ div#frontpage_calendar { } } -div#frontpage_calendar h2, div#tags h2, div#featured_articles h2, div.main_navigation h2 { +div#frontpage_calendar h2, div#tags h2, div#featured_articles h2, div#open_erfas_today h2, div.main_navigation h2 { border-top: 2px solid; border-bottom: 2px solid; font-size: 1.1em; @@ -248,7 +248,7 @@ div#frontpage_calendar h2 { } @media(min-width:1016px) { - div#frontpage_calendar h2, div#tags h2, div#featured_articles h2 { + div#frontpage_calendar h2, div#tags h2, div#featured_articles h2, div#open_erfas_today h2 { font-size: 1rem; } @@ -260,7 +260,7 @@ div#frontpage_calendar h2 { } -div#frontpage_calendar ul, div#tags ul, div#featured_articles ul { +div#frontpage_calendar ul, div#tags ul, div#featured_articles ul, div#open_erfas_today ul { padding: 0px; font-size: 1rem; line-height: 1.5em; @@ -278,11 +278,11 @@ div#frontpage_calendar li { margin-bottom: 20px; } -div#frontpage_calendar li, div#tags li, div#featured_articles li { +div#frontpage_calendar li, div#tags li, div#featured_articles li, div#open_erfas_today li { list-style-type: none; } -div#frontpage_calendar li a, div#tags li a, div#featured_articles li a { +div#frontpage_calendar li a, div#tags li a, div#featured_articles li a, div#open_erfas_today li a { text-decoration: none; color: color-mix(in srgb, CanvasText, #808080 25%); } -- cgit v1.3 From 287569c9bbfdadae767254792cd2b53d0515d928 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 3 Jul 2026 03:22:02 +0200 Subject: Add weekday abbreviation for open-today widget RruleHumanizer gains WEEKDAY_NAMES_ABBR (Mo/Di/Mi/...) alongside the existing WEEKDAY_NAMES and WEEKDAY_NAMES_ADVERBIAL hashes, plus a self.wday_abbr(time, locale) utility mapping a Time's wday to its RFC5545 code and looking up the abbreviation - keeping RFC5545 vocabulary in one place rather than teaching ContentHelper about day codes directly. ContentHelper#weekday_abbr is a thin wrapper passing I18n.locale through. The partial now renders "Fr 19:00" instead of just "19:00", joined with a non-breaking space so the pair can't split across a line wrap. Takes an explicit Time rather than reading Date.today, matching this file's existing style of passing state in rather than reading it ambiently - and staying correct if the currently-unused calendar/_front_page_calendar partial (spanning six weeks, not just today) is ever revived and reuses this helper. --- app/helpers/content_helper.rb | 4 ++++ app/models/concerns/rrule_humanizer.rb | 9 +++++++++ app/views/content/_open_erfas_today.html.erb | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) (limited to 'app/helpers') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index bf7287f..6043089 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -39,6 +39,10 @@ module ContentHelper ) end + def weekday_abbr(time) + RruleHumanizer.wday_abbr(time, I18n.locale) + end + def tags render :partial => 'content/tags' end diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb index 6cee711..8231de8 100644 --- a/app/models/concerns/rrule_humanizer.rb +++ b/app/models/concerns/rrule_humanizer.rb @@ -10,6 +10,10 @@ module RruleHumanizer de: { "MO"=>"montags","TU"=>"dienstags","WE"=>"mittwochs","TH"=>"donnerstags","FR"=>"freitags","SA"=>"samstags","SU"=>"sonntags" } }.freeze + WEEKDAY_NAMES_ABBR = { + de: { "MO"=>"Mo","TU"=>"Di","WE"=>"Mi","TH"=>"Do","FR"=>"Fr","SA"=>"Sa","SU"=>"So" } + }.freeze + ORDINAL_NAMES = { de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } @@ -79,4 +83,9 @@ module RruleHumanizer base end + + def self.wday_abbr(time, locale) + code = %w[SU MO TU WE TH FR SA][time.wday] + (WEEKDAY_NAMES_ABBR[locale.to_sym] || WEEKDAY_NAMES_ABBR[:de])[code] + end end diff --git a/app/views/content/_open_erfas_today.html.erb b/app/views/content/_open_erfas_today.html.erb index 3448af3..468bedc 100644 --- a/app/views/content/_open_erfas_today.html.erb +++ b/app/views/content/_open_erfas_today.html.erb @@ -4,7 +4,7 @@ <% occurrences.each do |occurrence| %>
  • <%= link_to_path occurrence.node.head.title, occurrence.node.unique_name %> - <%= occurrence.start_time.strftime("%H:%M") %> + <%= weekday_abbr(occurrence.start_time) %> <%= occurrence.start_time.strftime("%H:%M") %>
  • <% end %> -- cgit v1.3 From 206dc5c50a73c5402b90d7fdc8945d3ba9356758 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 4 Jul 2026 01:24:55 +0200 Subject: Add self-service event creation from nodes#show nodes#show's events table now renders unconditionally (previously hidden entirely for a zero-event node) and gains an "add event" link. The suggested tag_list is derived from the page's own category tags via NodesHelper::DEFAULT_EVENT_TAG_BY_PAGE_TAG (erfa-detail/ chaostreff-detail -> open-day) rather than hardcoded, and degrades to a blank field for any node whose tags don't match - deliberately universal, not chapter-specific, since Updates have historically carried event dates the same way. events#new surfaces *why* the tag was pre-filled via flash.now (not flash - this is a same-request render, not a redirect), using an explicit auto_tag_source param passed alongside tag_list rather than having the controller re-derive the reason from node_id. No destroy link added to nodes#show's events list - deliberate, per existing subnav-semantics convention (destructive actions live on the resource's own views). Covered directly by test. Adds real coverage for EventsController, previously 100% commented-out scaffold, plus unit tests for the new tag-mapping helper and the weekday-abbreviation helpers from the prior commit. --- app/controllers/events_controller.rb | 4 + app/helpers/nodes_helper.rb | 14 +++ app/views/nodes/show.html.erb | 4 +- test/controllers/events_controller_test.rb | 167 ++++++++++++++++++++------- test/controllers/nodes_controller_test.rb | 33 ++++++ test/models/concerns/rrule_humanizer_test.rb | 15 +++ test/models/helpers/content_helper_test.rb | 4 + test/models/helpers/nodes_helper_test.rb | 21 ++++ 8 files changed, 219 insertions(+), 43 deletions(-) (limited to 'app/helpers') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 9bed5dd..b98a38e 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -37,6 +37,10 @@ class EventsController < ApplicationController tag_list: params[:tag_list] ) + if params[:tag_list].present? && params[:auto_tag_source].present? + flash.now[:notice] = "Tag '#{params[:tag_list]}' was pre-filled because this page is tagged '#{params[:auto_tag_source]}'. You can remove it below." + end + respond_to do |format| format.html # new.html.erb format.xml { render :xml => @event } diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 329bcc5..093bfc6 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -44,6 +44,20 @@ module NodesHelper ]) end + DEFAULT_EVENT_TAG_BY_PAGE_TAG = { + 'erfa-detail' => 'open-day', + 'chaostreff-detail' => 'open-day' + }.freeze + + def default_event_tag_mapping(page) + page_tags = page.tag_list + DEFAULT_EVENT_TAG_BY_PAGE_TAG.find { |page_tag, _| page_tags.include?(page_tag) } + end + + def default_event_tag_list(page) + default_event_tag_mapping(page)&.last + end + def event_schedule_text(event) if event.rrule.present? recurrence = event.humanize_rrule(I18n.locale) diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 2ce3853..c533a55 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -42,7 +42,6 @@ Tagged with: <%= @page.tag_list %> - <% if @node.events.any? %> Events @@ -55,9 +54,10 @@ <% end %> + <% mapping = default_event_tag_mapping(@page) %> + <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %> - <% end %> Title <%= sanitize( @page.title ) %> diff --git a/test/controllers/events_controller_test.rb b/test/controllers/events_controller_test.rb index 14e534e..9371ca7 100644 --- a/test/controllers/events_controller_test.rb +++ b/test/controllers/events_controller_test.rb @@ -1,45 +1,130 @@ require 'test_helper' class EventsControllerTest < ActionController::TestCase - # test "should get index" do - # get :index - # assert_response :success - # assert_not_nil assigns(:events) - # end - # - # test "should get new" do - # get :new - # assert_response :success - # end - # - # test "should create event" do - # assert_difference('Event.count') do - # post :create, params: { :event => { } } - # end - # - # assert_redirected_to event_path(assigns(:event)) - # end - # - # test "should show event" do - # get :show, params: { :id => events(:one).to_param } - # assert_response :success - # end - # - # test "should get edit" do - # get :edit, params: { :id => events(:one).to_param } - # assert_response :success - # end - # - # test "should update event" do - # put :update, params: { :id => events(:one).to_param, :event => { } } - # assert_redirected_to event_path(assigns(:event)) - # end - # - # test "should destroy event" do - # assert_difference('Event.count', -1) do - # delete :destroy, params: { :id => events(:one).to_param } - # end - # - # assert_redirected_to events_path - # end + + test "should get index" do + login_as :quentin + get :index + assert_response :success + assert_not_nil assigns(:events) + end + + test "should get new" do + login_as :quentin + get :new + assert_response :success + end + + test "new pre-fills tag_list and explains it via flash when auto_tag_source is given" do + login_as :quentin + node = create_node_with_published_page + + get :new, params: { node_id: node.id, tag_list: "open-day", auto_tag_source: "erfa-detail" } + + assert_response :success + assert_equal "open-day", assigns(:event).tag_list.to_s + assert_match "open-day", flash[:notice] + assert_match "erfa-detail", flash[:notice] + end + + test "new does not flash without an auto_tag_source" do + login_as :quentin + + get :new, params: { tag_list: "open-day" } + + assert_response :success + assert_nil flash[:notice] + end + + test "new with no params renders a blank, unflashed form" do + login_as :quentin + + get :new + + assert_response :success + assert_nil assigns(:event).tag_list.presence + assert_nil flash[:notice] + end + + test "should show event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :show, params: { id: event.id } + assert_response :success + end + + test "should get edit" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :edit, params: { id: event.id } + assert_response :success + end + + test "should create event attached to a node" do + login_as :quentin + node = create_node_with_published_page + + assert_difference('Event.count') do + post :create, params: { + event: { + node_id: node.id, + start_time: Time.now, + end_time: Time.now + 1.hour, + tag_list: "open-day" + } + } + end + + assert_redirected_to edit_node_path(node) + assert_equal 'Event was successfully created.', flash[:notice] + end + + test "should not create an event without a title or a node_id" do + login_as :quentin + + assert_no_difference('Event.count') do + post :create, params: { event: { start_time: Time.now, end_time: Time.now + 1.hour } } + end + + assert_response :success # re-renders :new, not a redirect + end + + test "should honour return_to on create" do + login_as :quentin + node = create_node_with_published_page + + post :create, params: { + event: { node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour }, + return_to: node_path(node) + } + + assert_redirected_to node_path(node) + end + + test "should update event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + put :update, params: { id: event.id, event: { title: "Updated title" } } + + assert_redirected_to events_path + assert_equal "Updated title", event.reload.title + end + + test "should destroy event" do + login_as :quentin + node = create_node_with_published_page + event = Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + assert_difference('Event.count', -1) do + delete :destroy, params: { id: event.id } + end + + assert_redirected_to events_url + end end diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 53799f1..f14e27c 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -379,4 +379,37 @@ class NodesControllerTest < ActionController::TestCase get :index end + test "show renders events row and add-link for zero-event chapter node" do + login_as :quentin + node = create_node_with_published_page + node.head.tag_list = "erfa-detail" + node.head.save! + + get :show, params: { id: node.id } + assert_response :success + assert_select "a", text: "add event" + assert_select "a[href*='tag_list=open-day']" + assert_select "a[href*='auto_tag_source=erfa-detail']" + end + + test "show renders events row without a tag default for untagged node" do + login_as :quentin + node = create_node_with_published_page + + get :show, params: { id: node.id } + assert_response :success + assert_select "a", text: "add event" + assert_select "a[href*='tag_list=']", count: 0 + end + + test "show never renders a destroy link for events" do + login_as :quentin + node = create_node_with_published_page + Event.create!(node_id: node.id, start_time: Time.now, end_time: Time.now + 1.hour) + + get :show, params: { id: node.id } + assert_response :success + assert_select "form.button_to.destructive", count: 0 + end + end diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb index 500dbc7..279ff73 100644 --- a/test/models/concerns/rrule_humanizer_test.rb +++ b/test/models/concerns/rrule_humanizer_test.rb @@ -81,4 +81,19 @@ class RruleHumanizerTest < ActiveSupport::TestCase test "falls back to english for unknown locale" do assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :fr) end + + test "wday_abbr returns the correct German abbreviation for each day" do + monday = Time.parse("2026-07-06") # confirmed Monday + assert_equal "Mo", RruleHumanizer.wday_abbr(monday, :de) + assert_equal "Di", RruleHumanizer.wday_abbr(monday + 1.day, :de) + assert_equal "Mi", RruleHumanizer.wday_abbr(monday + 2.days, :de) + assert_equal "Do", RruleHumanizer.wday_abbr(monday + 3.days, :de) + assert_equal "Fr", RruleHumanizer.wday_abbr(monday + 4.days, :de) + assert_equal "Sa", RruleHumanizer.wday_abbr(monday + 5.days, :de) + assert_equal "So", RruleHumanizer.wday_abbr(monday + 6.days, :de) + end + + test "wday_abbr falls back to :de for an unrecognized locale" do + assert_equal "Mo", RruleHumanizer.wday_abbr(Time.parse("2026-07-06"), :fr) + end end diff --git a/test/models/helpers/content_helper_test.rb b/test/models/helpers/content_helper_test.rb index 2da82d7..a7ed478 100644 --- a/test/models/helpers/content_helper_test.rb +++ b/test/models/helpers/content_helper_test.rb @@ -1,4 +1,8 @@ require 'test_helper' class ContentHelperTest < ActionView::TestCase + test "weekday_abbr delegates through the current I18n locale" do + I18n.locale = :de + assert_equal "Mo", weekday_abbr(Time.parse("2026-07-06")) + end end diff --git a/test/models/helpers/nodes_helper_test.rb b/test/models/helpers/nodes_helper_test.rb index 13011de..5d91a88 100644 --- a/test/models/helpers/nodes_helper_test.rb +++ b/test/models/helpers/nodes_helper_test.rb @@ -1,4 +1,25 @@ require 'test_helper' class NodesHelperTest < ActionView::TestCase + FakePage = Struct.new(:tag_list) + + test "default_event_tag_mapping matches erfa-detail" do + page = FakePage.new(["erfa-detail"]) + assert_equal ["erfa-detail", "open-day"], default_event_tag_mapping(page) + end + + test "default_event_tag_mapping matches chaostreff-detail" do + page = FakePage.new(["chaostreff-detail"]) + assert_equal ["chaostreff-detail", "open-day"], default_event_tag_mapping(page) + end + + test "default_event_tag_mapping returns nil for unrelated tags" do + page = FakePage.new(["update"]) + assert_nil default_event_tag_mapping(page) + end + + test "default_event_tag_list is nil without a matching tag" do + page = FakePage.new([]) + assert_nil default_event_tag_list(page) + end end -- cgit v1.3 From 4596b32a0787a574881284d042bb77c970df8a4d Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 4 Jul 2026 01:25:24 +0200 Subject: Fix safe_return_to being uncallable from controllers Defined only in ApplicationHelper, which Rails auto-mixes into views but not controllers - so events#create and events#update, which call it directly, have been broken since introduction. Likely unexercised until now because every existing event was created via node.events.create! in the seed script, never through a real POST. Moved to ApplicationController as a protected method + helper_method declaration, so both controllers and views can call it (form_error_ messages stays in ApplicationHelper - it's genuinely view-only, content_tag isn't available in a controller either). Logic unchanged, caught by the new EventsController tests in the previous commit. --- app/controllers/application_controller.rb | 12 ++++++++++++ app/helpers/application_helper.rb | 10 ---------- 2 files changed, 12 insertions(+), 10 deletions(-) (limited to 'app/helpers') diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 75f92c3..cbeb40d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,6 +5,8 @@ class ApplicationController < ActionController::Base before_action :set_locale + helper_method :safe_return_to + protected def set_locale @@ -18,4 +20,14 @@ class ApplicationController < ActionController::Base def default_url_options { locale: I18n.locale == I18n.default_locale ? nil : I18n.locale } end + + def safe_return_to(url) + return events_path if url.blank? + uri = URI.parse(url) + return events_path if uri.host.present? + return events_path unless url.start_with?('/') + url + rescue URI::InvalidURIError + events_path + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 72b76b8..0be66e9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,14 +11,4 @@ module ApplicationHelper end end end - - def safe_return_to(url) - return events_path if url.blank? - uri = URI.parse(url) - return events_path if uri.host.present? - return events_path unless url.start_with?('/') - url - rescue URI::InvalidURIError - events_path - end end -- cgit v1.3 From 9c630d186003a47563be2e3547485060dac7dd98 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 4 Jul 2026 01:34:42 +0200 Subject: Retire event_information, superseded by nodes#show's event manager The metadata-panel "Event" field on nodes#edit called event_information, a second, tag-less implementation of "list events + add one" - now redundant now that nodes#show has the tag-aware version with the auto-tag flash explanation. Confirmed via grep this was the only call site before removing both it and the method definition. --- app/helpers/nodes_helper.rb | 15 --------------- app/views/nodes/edit.html.erb | 3 --- 2 files changed, 18 deletions(-) (limited to 'app/helpers') diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 093bfc6..2baf813 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -29,21 +29,6 @@ module NodesHelper User.all.map {|u| [u.login, u.id]} end - def event_information - events = @node.events.order(:start_time) - items = events.map do |event| - safe_join([ - "#{event.start_time&.to_fs(:db)} - #{event.end_time&.to_fs(:db)} > ", - link_to('edit', edit_event_path(event)), - ]) - end - safe_join([ - safe_join(items, ' | '), - ' > ', - link_to('add event', new_event_path(:node_id => @node.id)) - ]) - end - DEFAULT_EVENT_TAG_BY_PAGE_TAG = { 'erfa-detail' => 'open-day', 'chaostreff-detail' => 'open-day' diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 498fa1f..31f585c 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -15,9 +15,6 @@ <% end %>
    -
    Event
    -
    <%= event_information %>
    -
    Slug
    <%= f.text_field( -- cgit v1.3 From 9a7ce465b06462443ee82e8418f3c070de080ab2 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 5 Jul 2026 21:52:11 +0200 Subject: Surface node-kind conventions in the admin UI nodes#new: each kind's radio button now shows a label plus a smaller, muted .field_hint explaining what happens automatically (parent, tags, template) - previously a single undifferentiated string mixing a short label with a long parenthetical, cluttering the smaller kinds (erfa/chaostreff) worst. Title field gets the same hint treatment, telling editors up front that the slug is auto-generated and where to adjust it later - the previous silence here meant editors only discovered slug generation after already committing to a title, with no visible way to fix it. New "Resulting path" row live-previews the full URL (origin + kind's path prefix + a debounced call to the new parameterize_preview route, reusing the exact same slug_for helper "create" now calls, so the preview can never diverge from what actually gets created) as the editor types, plus a copy-to-clipboard button. For "generic", the prefix comes from whichever parent gets chosen via the search widget, not the registry - parent_search.link_closure now stashes the chosen node's unique_name as a data attribute for exactly this. New read-only admin/conventions view dumps the NODE_KINDS registry as a reference table for whoever's confused later about how a given kind behaves - deliberately just renders the same label/hint text used on nodes#new rather than re-describing parent/tags/template separately, since decomposing the parent Proc would mean either invoking it (Update's has a real side effect - creates the year folder) or re-stating its logic a second time in a different shape, either of which risks drifting from the actual behavior. nodes#edit's Template field gets a one-line hint noting the value may already be pre-filled based on how the node was created - the one place the previous commit's new inheritance mechanism actually surfaces on this view. --- app/helpers/application_helper.rb | 4 ++++ app/views/nodes/edit.html.erb | 5 ++++- app/views/nodes/new.html.erb | 34 +++++++++++++++++++--------------- config/routes.rb | 1 + public/stylesheets/admin.css | 12 ++++++++++++ 5 files changed, 40 insertions(+), 16 deletions(-) (limited to 'app/helpers') diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0be66e9..87aa82c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -11,4 +11,8 @@ module ApplicationHelper end end end + + def resolve_kind_text(value) + value.respond_to?(:call) ? value.call : value + end end diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 31f585c..2d0edc1 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -44,7 +44,10 @@
    <%= d.datetime_select :published_at, :value => @draft.published_at %>
    Template
    -
    <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
    +
    + <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %> + Set automatically based on how this node was created - change it if needed. +
    Author
    <%= d.select :user_id, user_list %>
    diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 028d727..f557454 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -13,27 +13,23 @@ Type -
    - <%= radio_button_tag :kind, "top_level" %> - Top Level -
    -

    - <%= radio_button_tag :kind, "generic", :selected => true %> - Generic ( can be created anywhere ) -

    -

    - <%= radio_button_tag :kind, "update" %> - Update ( is automatically created in /updates/<%= Time.now.year.to_s %>/ and gets tag "update" ) -

    + <% CccConventions::NODE_KINDS.each do |kind, config| %>

    - <%= radio_button_tag :kind, "press_release" %> - Pressemitteilung ( is automatically created in /updates/<%= Time.now.year.to_s %>/ and gets tags "update, pressemitteilung" ) + <%= radio_button_tag :kind, kind, kind == "generic", + data: { path_prefix: resolve_kind_text(config[:path_prefix]) } %> + <%= resolve_kind_text(config[:label]) %> + <% if config[:hint] %> + <%= resolve_kind_text(config[:hint]) %> + <% end %>

    + <% end %> Title - <%= text_field_tag :title %> + <%= text_field_tag :title %> + A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field. + Parent @@ -45,6 +41,14 @@
    + + Resulting path + + + + This preview updates as you type. The final path can still be changed afterward by editing the title (for the last segment) or moving the node to a different parent (for everything before it). + + <%= submit_tag "Create" %> diff --git a/config/routes.rb b/config/routes.rb index cf4733c..6c07414 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -59,6 +59,7 @@ Cccms::Application.routes.draw do match 'admin' => 'admin#index', :as => :admin, :via => :get match 'admin/search' => 'admin#search', :as => :admin_search, :via => :get match 'admin/menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get + match 'admin/conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get match 'search' => 'search#index', :as => :search, :via => :get resources :users diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index a02bd61..f240bb2 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -508,6 +508,14 @@ table tr.header { text-align: left; } +.field_hint { + display: block; + margin-top: 2px; + font-size: 0.75rem; + color: #969696; + padding-bottom: 4px; +} + #search_widget { position: absolute; top: 20px; @@ -527,18 +535,21 @@ table tr.header { font-size: 18px; } +#menu_search_results p, #search_results p { margin: 0; padding: 4px 4px 0 4px; border-bottom: 1px solid #e8e8e8; } +#menu_search_results p a, #search_results p a { display: block; font-weight: bold; font-size: 0.95rem; } +#menu_search_results p span.result_path, #search_results p span.result_path { display: block; margin-top: 2px; @@ -548,6 +559,7 @@ table tr.header { padding-bottom: 4px; } +#menu_search_results p.search_more, #search_results p.search_more { margin: 0; padding: 6px 4px; -- cgit v1.3 From 527376039c527eb8f559c5e6da76429bc3f3ee4f Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 19:40:48 +0200 Subject: Add tree-position scoping to Page.aggregate New :children option ("direct" or "all") on the [aggregate] shortcode, composable with the existing :tags filter - both apply as independent successive .where clauses, ANDing together automatically, no special casing needed. content_helper.rb passes the current @page.node through as :node whenever :children is requested, since Page.aggregate has no way to resolve "which node" from the shortcode's bare option strings on its own. Guarded so nothing changes for any existing [aggregate] shortcode that never uses children= - :node stays nil, the new branch never fires. "all" resolves via node.descendants.pluck(:id) rather than embedding the descendants relation directly as a subquery. Not proven strictly necessary - extensive debugging this session initially pointed at a subquery/table-alias collision, but every actual failure traced instead to accumulated test-node debris left in the dev DB by earlier interrupted runs, confirmed by re-running against a cleaned database. Kept anyway as a one-round-trip-cheaper, more defensive default regardless. --- app/helpers/content_helper.rb | 1 + app/models/page.rb | 6 ++++++ 2 files changed, 7 insertions(+) (limited to 'app/helpers') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index 6043089..6bcd437 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -108,6 +108,7 @@ module ContentHelper end options[:partial] = select_partial(options[:partial]) + options[:node] = @page.node if options[:children].present? sanitize(content.sub(tag, render_collection(options)), :attributes => cccms_attributes) else diff --git a/app/models/page.rb b/app/models/page.rb index 4d1e94a..6fff7d6 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -63,6 +63,12 @@ class Page < ApplicationRecord end end + if options[:node] && options[:children] == "direct" + scope = scope.where(nodes: { parent_id: options[:node].id }) + elsif options[:node] && options[:children] == "all" + scope = scope.where(nodes: { id: options[:node].descendants.pluck(:id) }) + end + direction = %w[ASC DESC].include?(options[:order_direction]&.upcase) ? options[:order_direction].upcase : "ASC" if options[:order_by] == "title" -- cgit v1.3 From 1bf719d6ac58187cf406d92a40b665d3fa6e658b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 03:48:09 +0200 Subject: Add context-aware child-creation shortcuts to nodes#show parent_match Procs on CccConventions::NODE_KINDS, matched against unique_path, decide which "add child" kinds show on a given node. Fixes nodes#new not honoring a pre-selected kind (radio group and parent-field visibility both defaulted to "generic" unconditionally). --- app/controllers/nodes_controller.rb | 1 + app/helpers/nodes_helper.rb | 5 +++ app/views/nodes/new.html.erb | 4 +-- app/views/nodes/show.html.erb | 29 +++++++++++++----- lib/ccc_conventions.rb | 61 ++++++++++++++++++++----------------- public/javascripts/admin_search.js | 18 +++++++---- public/stylesheets/admin.css | 8 +++++ 7 files changed, 82 insertions(+), 44 deletions(-) (limited to 'app/helpers') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 69aa268..a72be68 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -22,6 +22,7 @@ class NodesController < ApplicationController def new @node = Node.new node_params + @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" if params.has_key?(:parent_id) @parent_id = params[:parent_id] @parent_name = Node.find(@parent_id).title diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 2baf813..a89f879 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -58,4 +58,9 @@ module NodesHelper t(:event_schedule_none) end end + + def matching_node_kinds(node) + path = node.unique_path + CccConventions::NODE_KINDS.select { |_, config| config[:parent_match]&.call(path) } + end end diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 0a05325..71f2fbf 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -13,7 +13,7 @@
    <% CccConventions::NODE_KINDS.each do |kind, config| %>

    - <%= radio_button_tag :kind, kind, kind == "generic", + <%= radio_button_tag :kind, kind, kind == @selected_kind, data: { path_prefix: resolve_kind_text(config[:path_prefix]) } %> <%= resolve_kind_text(config[:label]) %> <% if config[:hint] %> @@ -29,7 +29,7 @@ A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field.

    -
    +
    ">
    Parent
    <%= text_field_tag :parent_search_term, @parent_name %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 189adb8..963bc37 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -101,17 +101,30 @@ <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %>
    - <% if @node.children.any? %> + <% matches = matching_node_kinds(@node) %> + <% if @node.children.any? || matches.any? %>
    Children
    -
    - <%= pluralize(@node.children.count, 'child', 'children') %> -
      - <% @node.children.order(:slug).each do |child| %> -
    • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
    • + <% if @node.children.any? %> +
      + <%= pluralize(@node.children.count, 'child', 'children') %> +
        + <% @node.children.order(:slug).each do |child| %> +
      • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
      • + <% end %> +
      +
      + <% end %> + <% if matches.any? %> +
    -
    +

    + <% end %>
    <% end %> diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb index c0f9943..b420452 100644 --- a/lib/ccc_conventions.rb +++ b/lib/ccc_conventions.rb @@ -4,44 +4,49 @@ module CccConventions NODE_KINDS = { "top_level" => { - parent: -> { Node.root }, - path_prefix: "", - label: "Top Level" + parent: -> { Node.root }, + parent_match: ->(path) { path == [] }, + path_prefix: "", + label: "Top Level" }, "generic" => { - label: "Generic", - hint: "Can be created anywhere - choose the parent below." - # no path_prefix - depends on whatever parent gets chosen; see below + parent_match: ->(path) { true }, + label: "Generic", + hint: "Can be created anywhere - choose the parent below." }, "update" => { - parent: -> { Update.find_or_create_parent }, - tags: ["update"], - path_prefix: -> { "updates/#{Time.now.year}" }, - label: "Update", - hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tag \"update\", and inherits the update template." } + parent: -> { Update.find_or_create_parent }, + parent_match: ->(path) { path[0] == "updates" && (path.length == 1 || path[1] =~ /\A\d{4}\z/) }, + tags: ["update"], + path_prefix: -> { "updates/#{Time.now.year}" }, + label: "Update", + hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tag \"update\", and inherits the update template." } }, "press_release" => { - parent: -> { Update.find_or_create_parent }, - tags: ["update", "pressemitteilung"], - path_prefix: -> { "updates/#{Time.now.year}" }, - label: "Pressemitteilung", - hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tags \"update, pressemitteilung\", and inherits the update template." } + parent: -> { Update.find_or_create_parent }, + parent_match: ->(path) { path[0] == "updates" && (path.length == 1 || path[1] =~ /\A\d{4}\z/) }, + tags: ["update", "pressemitteilung"], + path_prefix: -> { "updates/#{Time.now.year}" }, + label: "Pressemitteilung", + hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tags \"update, pressemitteilung\", and inherits the update template." } }, "erfa" => { - parent: -> { Node.find_by_unique_name!(ERFA_PARENT_NAME) }, - tags: ["erfa-detail"], - template: "chapter_detail", - path_prefix: ERFA_PARENT_NAME, - label: "Erfa", - hint: "Automatically created under the Erfa-Kreise overview page, gets tag \"erfa-detail\", and uses the chapter detail template." + parent: -> { Node.find_by_unique_name!(ERFA_PARENT_NAME) }, + parent_match: ->(path) { path == ["club", "erfas"] }, + tags: ["erfa-detail"], + template: "chapter_detail", + path_prefix: ERFA_PARENT_NAME, + label: "Erfa", + hint: "Automatically created under the Erfa-Kreise overview page, gets tag \"erfa-detail\", and uses the chapter detail template." }, "chaostreff" => { - parent: -> { Node.find_by_unique_name!(CHAOSTREFF_PARENT_NAME) }, - tags: ["chaostreff-detail"], - template: "chapter_detail", - path_prefix: CHAOSTREFF_PARENT_NAME, - label: "Chaostreff", - hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." + parent: -> { Node.find_by_unique_name!(CHAOSTREFF_PARENT_NAME) }, + parent_match: ->(path) { path == ["club", "chaostreffs"] }, + tags: ["chaostreff-detail"], + template: "chapter_detail", + path_prefix: CHAOSTREFF_PARENT_NAME, + label: "Chaostreff", + hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." } }.freeze end diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 2565929..6ef9087 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -208,14 +208,20 @@ parent_search = { }, initialize_radio_buttons : function() { - $("input[name='kind']").bind("change", function(){ - if ($(this).val() === "generic") { - $("#parent_search_field").show(); - } else { - $("#parent_search_field").hide(); - } + parent_search.sync_parent_field(); + $("input[name='kind']").bind("change", function() { + parent_search.sync_parent_field(); parent_search.update_resulting_path(); }); + }, + + sync_parent_field : function() { + var kind = $("input[name='kind']:checked").val(); + if (kind === "generic") { + $("#parent_search_field").show(); + } else { + $("#parent_search_field").hide(); + } } } diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1196d83..3033798 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -338,6 +338,14 @@ input[type=radio] { border: 1px solid #989898; } +.add_child_links { + margin-top: 0.5rem; +} + +.add_child_links a { + white-space: nowrap; +} + div#login_form input[type=text], div#login_form input[type=password] { width: 150px; } -- cgit v1.3 From 42f81016be8c55322e0dbb2c20b08812d5d2228b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 21:43:21 +0200 Subject: Adopt button pills in views --- app/helpers/link_helper.rb | 2 +- app/views/admin/_drafts.html.erb | 2 +- app/views/nodes/edit.html.erb | 2 +- app/views/nodes/show.html.erb | 4 ++-- app/views/revisions/index.html.erb | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) (limited to 'app/helpers') diff --git a/app/helpers/link_helper.rb b/app/helpers/link_helper.rb index 878e8e4..9b0b4e1 100644 --- a/app/helpers/link_helper.rb +++ b/app/helpers/link_helper.rb @@ -44,7 +44,7 @@ module LinkHelper "Last modified #{@page.updated_at.to_fs(:db)}" button_to 'Unlock', unlock_node_path(@node), method: :put, - form: { data: { confirm: message } } + form: { data: { confirm: message }, class: 'button_to state_changing' } end def content_path(page_path = nil, options = {}) diff --git a/app/views/admin/_drafts.html.erb b/app/views/admin/_drafts.html.erb index a35b0ab..4ef568f 100644 --- a/app/views/admin/_drafts.html.erb +++ b/app/views/admin/_drafts.html.erb @@ -17,7 +17,7 @@ <%= link_to 'Show', node_path(draft.node) %> <%= link_to "Revisions", revision_path(draft.node.id) %> - <%= button_to "Publish", publish_node_path(draft.node), method: :put, form: { data: { confirm: "Do you really want to publish?" } } %> + <%= button_to "Publish", publish_node_path(draft.node), method: :put, form: { data: { confirm: "Do you really want to publish?" }, class: 'button_to state_changing' } %> <% end %> diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 2d0edc1..4cdb3ae 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -2,7 +2,7 @@ <%= link_to 'metadata', '#', :id => 'button', :class => "unselected" %> <%= link_to 'Show', @node %> <%= link_to 'Preview', preview_page_path(@draft) %> - <%= button_to 'Publish', publish_node_path(@node), method: :put, form: { data: { confirm: "Publish this draft?" } } %> + <%= button_to 'Publish', publish_node_path(@node), method: :put, form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> <%= link_to 'Revisions', node_revisions_path(@node) %> <% end %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 963bc37..71dbaa3 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -59,9 +59,9 @@ Public Preview <% if @node.draft.preview_token.present? %> <%= link_to shared_preview_path(token: @node.draft.preview_token), shared_preview_url(token: @node.draft.preview_token), target: "_blank", rel: "noopener" %> - <%= button_to 'Revoke', revoke_shared_preview_node_path(@node), method: :put, form: { data: { confirm: "Revoke this preview link? Anyone currently using it will immediately lose access." } } %> + <%= button_to 'Revoke', revoke_shared_preview_node_path(@node), method: :put, form: { data: { confirm: "Revoke this preview link? Anyone currently using it will immediately lose access." }, class: 'button_to state_changing' } %> <% else %> - <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put %> + <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put, form: { class: 'button_to state_changing' } %> <% end %>
    <% end %> diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index b875a4f..26a9ff4 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -27,7 +27,7 @@ <%= button_to 'restore', restore_node_revision_path(@node, page), method: :put, - form: { data: { confirm: "Restore this revision?" } } %> + form: { data: { confirm: "Restore this revision?" }, class: 'button_to state_changing' } %> <% end %> -- cgit v1.3 From 205e6216fc7850fe717122c189e5003d1f9e8afe Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 02:03:19 +0200 Subject: Add head/draft/autosave layer comparison at three UI entry points Node#resolve_page_reference and #available_layer_pairs let Page#diff_against compare named layers (head/draft/autosave), not just numbered revisions -- autosave was never part of Node#pages, so this was the missing piece. Wired into nodes#show's Status section, nodes#edit right after an autosave gets resurrected ("What changed?"), and the admin wizard's current-drafts table, which now also lists autosave-only nodes it previously never showed. revisions#diff hides the numbered-revision picker when comparing named layers (it can't represent them), shows a plain label instead, and offers buttons to switch between whichever other pairs make sense for the node's current state. Destroying the topmost layer is available directly from the diff view, reusing the existing revert! path. "Discard changes" is renamed "Discard Autosave" everywhere it appears, to match "Destroy Draft". --- app/controllers/admin_controller.rb | 4 +-- app/controllers/nodes_controller.rb | 2 +- app/controllers/revisions_controller.rb | 16 ++++++++--- app/helpers/revisions_helper.rb | 5 ++++ app/models/node.rb | 21 ++++++++++++++ app/views/admin/index.html.erb | 13 +++++++-- app/views/nodes/edit.html.erb | 9 +++++- app/views/nodes/show.html.erb | 12 +++++++- app/views/revisions/diff.html.erb | 40 +++++++++++++++++++++++---- config/routes.rb | 1 + test/controllers/admin_controller_test.rb | 15 ++++++++-- test/controllers/revisions_controller_test.rb | 39 ++++++++++++++++++++++++++ test/models/node_test.rb | 22 +++++++++++++++ 13 files changed, 179 insertions(+), 20 deletions(-) (limited to 'app/helpers') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 3fa0519..6ab2135 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -5,10 +5,10 @@ class AdminController < ApplicationController before_action :login_required def index - @drafts = Node.where("draft_id IS NOT NULL") + @drafts = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") .limit(50).order("updated_at desc") - @drafts_count = Node.where("draft_id IS NOT NULL").count + @drafts_count = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").count @recent_changes = Node.where( "updated_at < ? AND updated_at > ? AND parent_id IS NOT NULL", diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 38d42d9..d1538e1 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -72,7 +72,7 @@ class NodesController < ApplicationController if @node.autosave flash.now[:notice] = "This page has unsaved changes from a previous session, shown below. " \ - "Save to keep them, or use \"Discard changes\" below to go back to the last saved version." + "Save to keep them, or use \"Discard Autosave\" below to go back to the last saved version." elsif freshly_locked flash.now[:notice] = "Node locked and ready to edit" end diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb index 9acb26f..4b0c549 100644 --- a/app/controllers/revisions_controller.rb +++ b/app/controllers/revisions_controller.rb @@ -21,10 +21,18 @@ class RevisionsController < ApplicationController params[:start_revision], params[:end_revision] = 1, 1 end - @start = @node.pages.find_by_revision( params[:start_revision] ) - @end = @node.pages.find_by_revision( params[:end_revision] ) - @diff_view = params[:view] == "side_by_side" ? :side_by_side : :inline - @diff = @end.diff_against( @start, view: @diff_view ) + @start = @node.resolve_page_reference(params[:start_revision]) + @end = @node.resolve_page_reference(params[:end_revision]) + + if @start.nil? || @end.nil? + flash[:error] = "That comparison is no longer available." + redirect_to(node_path(@node)) and return + end + + @diff_view = params[:view] == "side_by_side" ? :side_by_side : :inline + @diff = @end.diff_against(@start, view: @diff_view) + @available_layer_pairs = @node.available_layer_pairs + @locked_by_other = @node.locked? && @node.lock_owner != current_user end def show diff --git a/app/helpers/revisions_helper.rb b/app/helpers/revisions_helper.rb index fdb51f8..a629013 100644 --- a/app/helpers/revisions_helper.rb +++ b/app/helpers/revisions_helper.rb @@ -1,2 +1,7 @@ module RevisionsHelper + # Human-readable label for a diff endpoint -- "head"/"draft"/"autosave" + # get their name; anything else is a revision number. + def describe_page_reference(ref) + %w[head draft autosave].include?(ref.to_s) ? ref.to_s.capitalize : "revision #{ref}" + end end diff --git a/app/models/node.rb b/app/models/node.rb index 7675ab6..82d9954 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -123,6 +123,27 @@ class Node < ApplicationRecord self.draft.reload end + def resolve_page_reference ref + case ref.to_s + when "head" then head + when "draft" then draft + when "autosave" then autosave + else pages.find_by_revision(ref) + end + end + + # Which layer-pairs are meaningful to compare right now, given this + # node's actual state. Head vs autosave only shows up when no draft + # sits between them -- with a draft present, autosave is compared + # against the draft, never past it straight to head. + def available_layer_pairs + pairs = [] + pairs << [:head, :draft] if head && draft + pairs << [:draft, :autosave] if draft && autosave + pairs << [:head, :autosave] if head && autosave && !draft + pairs + end + def find_or_create_draft current_user self.wipe_draft! if draft && self.lock_owner == current_user diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index 77b45f4..c67ccb3 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -82,9 +82,9 @@
    - -

    Current Drafts (<%= @drafts_count %>)

    - + +

    Current Drafts & Autosaves (<%= @drafts_count %>)

    + @@ -92,6 +92,7 @@ + <% @drafts.each do |node| %> "> @@ -103,6 +104,9 @@ + <% end %>
    IDActions Locked by Rev.Autosave
    <%= link_to 'show', node_path(node) %> <%= link_to 'Revisions', node_revisions_path(node) %> + <% if pair = node.available_layer_pairs.last %> + <%= link_to 'diff', diff_node_revisions_path(node, start_revision: pair.first, end_revision: pair.last) %> + <% end %> <%= node.lock_owner.login if node.lock_owner %> @@ -110,6 +114,9 @@ <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> + <%= node.autosave ? "unsaved changes" : "" %> +
    diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index cdc9b36..1c19410 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -6,9 +6,16 @@ disabled: @node.autosave.present? %> <% if @node.autosave || (@node.draft && @node.head) %> - <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% if pair = @node.available_layer_pairs.find { |p| p.include?(:autosave) } %> + <%= button_to 'What changed?', + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last }, + form: { class: 'button_to computation' } %> + <% end %> <% end %> <%= submit_tag "Save Draft", form: dom_id(@node, :edit) %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 036caf2..2469310 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -32,6 +32,16 @@ <% end %>
    + <% @node.available_layer_pairs.each do |pair| %> +
    + <%= button_to "Diff #{pair.first.to_s.capitalize} vs. #{pair.last.to_s.capitalize}", + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last }, + form: { class: 'button_to computation' } %> +
    + <% end %> + <% unless locked_by_other %> <% if @node.draft && !@node.autosave %>
    @@ -41,7 +51,7 @@ <% end %> <% if @node.draft || @node.autosave %>
    - <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard changes'), + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), revert_node_path(@node), method: :put, form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %>
    diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d7bb528..3157dca 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -4,11 +4,41 @@ <%= link_to 'Revisions', node_revisions_path(@node) %>

    -<%= form_tag diff_node_revisions_path do %> - <%= select_tag :start_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:start_revision].to_i) %> - <%= select_tag :end_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:end_revision].to_i) %> - <%= select_tag :view, options_for_select([['Inline', 'inline'], ['Side by side', 'side_by_side']], @diff_view) %> - <%= submit_tag 'Diff' %> +

    + Comparing <%= describe_page_reference(params[:start_revision]) %> + against <%= describe_page_reference(params[:end_revision]) %> +

    + +<% numeric_comparison = params[:start_revision].to_s =~ /\A\d+\z/ && params[:end_revision].to_s =~ /\A\d+\z/ %> + +<% if numeric_comparison %> + <%= form_tag diff_node_revisions_path do %> + <%= select_tag :start_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:start_revision].to_i) %> + <%= select_tag :end_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:end_revision].to_i) %> + <%= select_tag :view, options_for_select([['Inline', 'inline'], ['Side by side', 'side_by_side']], @diff_view) %> + <%= submit_tag 'Diff' %> + <% end %> +<% else %> +

    <%= link_to 'Compare two numbered revisions instead', node_revisions_path(@node) %>

    +<% end %> + +<% if @available_layer_pairs.present? %> +

    + <% @available_layer_pairs.each do |pair| %> + <% next if [params[:start_revision].to_s, params[:end_revision].to_s].sort == pair.map(&:to_s).sort %> + <%= button_to "Diff #{pair.first.to_s.capitalize} vs. #{pair.last.to_s.capitalize}", + diff_node_revisions_path(@node), + method: :get, + params: { start_revision: pair.first, end_revision: pair.last, view: @diff_view }, + form: { class: 'button_to computation' } %> + <% end %> + + <% if !@locked_by_other && (@node.autosave || @node.draft) %> + <%= button_to (@node.draft && !@node.autosave ? 'Destroy Draft' : 'Discard Autosave'), + revert_node_path(@node), method: :put, + form: { data: { confirm: "This cannot be undone. Continue?" }, class: 'button_to destructive' } %> + <% end %> +

    <% end %>
    diff --git a/config/routes.rb b/config/routes.rb index c0aef2f..da6b626 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -47,6 +47,7 @@ Cccms::Application.routes.draw do resources :revisions do collection do post :diff + get :diff end member do put :restore diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 9bbf29b..d6005ba 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -1,8 +1,17 @@ require 'test_helper' class AdminControllerTest < ActionController::TestCase - # Replace this with your real tests. - test "the truth" do - assert true + test "current drafts includes nodes with only an autosave" do + node = Node.root.children.create!(:slug => "admin_autosave_only") + node.lock_for_editing!(User.find_by_login("aaron")) + node.autosave!({title: "in progress"}, User.find_by_login("aaron")) + node.save_draft!(User.find_by_login("aaron")) + node.publish_draft! + node.lock_for_editing!(User.find_by_login("aaron")) + node.autosave!({title: "editing again"}, User.find_by_login("aaron")) + + login_as :quentin + get :index + assert_includes assigns(:drafts), node end end diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index caca6bf..162e6f1 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -100,4 +100,43 @@ class RevisionsControllerTest < ActionController::TestCase assert_response :success assert_select ".diff_column", 2 end + + test "diffing head against draft by name" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.draft.update(:body => "draft body") + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + end + + test "diffing a layer pair that no longer exists redirects with a flash" do + login_as :quentin + post(:diff, params: { :node_id => @node.id, :start_revision => "draft", :end_revision => "autosave" }) + assert_redirected_to node_path(@node) + assert flash[:error].present? + end + + test "diffing by name shows a clear comparison label instead of a misleading revision picker" do + login_as :quentin + @node.find_or_create_draft(@user) + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + assert_select "strong", "Head" + assert_select "strong", "Draft" + assert_select "select[name=?]", "start_revision", :count => 0 + end + + test "pair-switcher buttons carry their params as real hidden fields, not a query string" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.lock_for_editing!(@user) + @node.autosave!({ :body => "unsaved" }, @user) + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + assert_select "form.computation input[type=hidden][name=start_revision]" + assert_select "form.computation input[type=hidden][name=end_revision]" + end end diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 2138c19..9e71dec 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -473,4 +473,26 @@ class NodeTest < ActiveSupport::TestCase node.publish_draft! end end + + test "available_layer_pairs matches the six-state table" do + node = Node.root.children.create!(:slug => "layer_pairs_test") + user = @user1 || User.find_by_login("aaron") + + assert_equal [[:draft, :autosave]], (node.lock_for_editing!(user); node.autosave!({title: "v1"}, user); node.available_layer_pairs) # state F + + node.save_draft!(user) + node.publish_draft! + assert_equal [], node.available_layer_pairs # state A + + node.lock_for_editing!(user) + node.autosave!({title: "v2"}, user) + assert_equal [[:head, :autosave]], node.available_layer_pairs # state B + + node.save_draft!(user) + assert_equal [[:head, :draft]], node.available_layer_pairs # state C + + node.lock_for_editing!(user) + node.autosave!({title: "v3"}, user) + assert_equal [[:head, :draft], [:draft, :autosave]], node.available_layer_pairs # state D + end end -- cgit v1.3 From 644744183602c68508abb02dbb9f1f321a63f88f Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 04:08:55 +0200 Subject: Fix docstring for the aggregate shortcode --- app/helpers/content_helper.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'app/helpers') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index 6bcd437..5810966 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -87,11 +87,14 @@ module ContentHelper # Syntax of the [aggregate ] short code: # # [aggregate - # flags="update, pressemitteilung" + # children="all" | children="direct" # optional, at least one of children + # tags="update, pressemitteilung" # or tags is required # limit="20" # order_by="published_at" # order_direction="DESC" # ] + + def aggregate? content options = {} -- cgit v1.3 From c9401e45433ea45b46f9a8faf1e7e537e4683244 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 18:07:15 +0200 Subject: Fix rrule/url column overflow on events#index Long RRULEs previously overflowed their column with no wrap point; now escaped and rendered with a after each semicolon, so a long rule wraps at a clause boundary instead of running off the table. Deliberately not truncated -- a cut-off RRULE's trailing clause (BYDAY, BYMONTH, etc.) is usually the most specific part. The url column is now a real link, truncated with an ellipsis at a fixed width -- deliberately no tooltip, since hovering a real link already shows the full address in the browser's own status bar. rrule_with_break_opportunities splits on the raw string's own semicolons before escaping each piece, not after -- escaping first and searching the result for semicolons also matches the ones inside </> entities, corrupting anything containing a literal < or >. --- app/helpers/events_helper.rb | 11 +++++++++++ app/views/events/index.html.erb | 8 ++++++-- public/stylesheets/admin.css | 15 +++++++++++++++ test/models/helpers/events_helper_test.rb | 21 +++++++++++++++++++++ 4 files changed, 53 insertions(+), 2 deletions(-) (limited to 'app/helpers') diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 8a9a878..5e84f53 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,2 +1,13 @@ +require 'cgi' + module EventsHelper + # Insert a zero-width break opportunity after each semicolon, so a long + # RRULE can wrap at a clause boundary instead of overflowing its column. + # Deliberately , not ellipsis -- unlike a URL, an RRULE's trailing + # characters (BYDAY, BYMONTH, etc.) are usually the most specific part, + # and truncating them would hide exactly the wrong end of the string. + def rrule_with_break_opportunities(rrule) + return "" if rrule.blank? + raw(rrule.split(';', -1).map { |part| CGI.escapeHTML(part) }.join(';')) + end end diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 8127e3a..3d953d5 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -22,9 +22,13 @@ <%= link_to event.display_title, event %> <%=h event.start_time %> <%=h event.end_time %> - <%=h event.rrule %> + <%= rrule_with_break_opportunities(event.rrule) %> <%=h event.allday %> - <%=h event.url %> + + <% if event.url.present? %> + <%= link_to event.url, event.url %> + <% end %> + <%= event.node ? link_to(event.node_id, node_path(event.node)) : '' %> <%= link_to 'edit', edit_event_path(event) %> diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1a81728..4723f50 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -519,6 +519,21 @@ table.revisions_table tr:hover { background-color: #f1f1f1; } +.events_table .rrule_text { + display: inline-block; + max-width: 300px; + overflow-wrap: break-word; +} + +.events_table .truncate { + display: inline-block; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + #diffview del { background: #ffd7d5; color: #82071e; diff --git a/test/models/helpers/events_helper_test.rb b/test/models/helpers/events_helper_test.rb index 2e7567e..0486b3e 100644 --- a/test/models/helpers/events_helper_test.rb +++ b/test/models/helpers/events_helper_test.rb @@ -1,4 +1,25 @@ require 'test_helper' class EventsHelperTest < ActionView::TestCase + test "rrule_with_break_opportunities inserts a break opportunity after each semicolon" do + result = rrule_with_break_opportunities("FREQ=MONTHLY;BYMONTH=1,2,3;BYDAY=-1TH") + assert_equal "FREQ=MONTHLY;BYMONTH=1,2,3;BYDAY=-1TH", result + assert result.html_safe? + end + + test "rrule_with_break_opportunities escapes HTML-significant characters" do + result = rrule_with_break_opportunities("FREQ=WEEKLY;BYDAY= - - + + + +