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 4705ef970469a852c7bdb4c097ba748e972c8f63 Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Mon, 29 Jun 2026 16:26:31 +0200
Subject: Add chapter_detail template with events and external URL rendering
New public page template for erfa and chaostreff nodes. Renders the
node's external_url and lists associated events below the page content.
Seed script updated to set template_name: 'chapter_detail' on all
chapter drafts so the template is applied on publish.
---
.../page_templates/public/chapter_detail.html.erb | 23 ++++++++++++++++++++++
db/seeds/chapters.rb | 1 +
2 files changed, 24 insertions(+)
create mode 100644 app/views/custom/page_templates/public/chapter_detail.html.erb
(limited to 'app')
diff --git a/app/views/custom/page_templates/public/chapter_detail.html.erb b/app/views/custom/page_templates/public/chapter_detail.html.erb
new file mode 100644
index 0000000..71352d3
--- /dev/null
+++ b/app/views/custom/page_templates/public/chapter_detail.html.erb
@@ -0,0 +1,23 @@
+
\ No newline at end of file
+
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index 7223219..2ce3853 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -48,7 +48,11 @@
<% @node.events.order(:start_time).each do |event| %>
-
<%= event_schedule_text(event) %><%= " (primary)" if event.is_primary? %>
<% 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')
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 @@
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')
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')
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 1fa2f3821497d5529a6753cee84cf5ca679e8bcc Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Sun, 5 Jul 2026 21:49:21 +0200
Subject: Fix admin search results colliding with other search widgets
layouts/admin.html.erb's top-bar search results div shared id
"search_results" with nodes#new/nodes#edit's parent-search widget -
since the layout renders on every admin page, whichever widget's JS
ran last silently won the shared ID, putting top-bar results into the
parent-search box on affected pages. Renamed to "menu_search_results"
and updated admin_search.js to match.
Also modernizes admin_search.js's event bindings from keyup/keydown/
keypress/paste/cut + .attr("value") to input + .val() throughout
(menu_items, parent_search, move_to_search) - the multi-event binding
was working around old IE compatibility that .val() + "input" already
handles correctly. parent_search's result rendering now matches
menu_search's convention (real
"));
- // Sometimes I don't get jquery; wrap() didn't work *sigh*
- // Guess I'll need a book someday or another framework
- var wrapper = $("");
- $(wrapper).append(link);
+ $(link).bind("click", parent_search.link_closure(results[result]));
- $("#search_results").append(wrapper);
+ $("#search_results").append(link);
found = true;
}
if (found)
@@ -166,51 +175,61 @@ parent_search = {
link_closure : function(node) {
var barf = function(){
- $("#parent_search_term").attr("value", node.title);
- $("#parent_id").attr("value", node.node_id);
+ $("#parent_search_term").val(node.title);
+ $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name);
$('#search_results').slideUp();
$('#search_results').empty();
+ parent_search.update_resulting_path();
return false;
}
return barf;
},
- initialize_radio_buttons : function() {
- $("#kind_top_level").bind("change", function(){
- $("#parent_search_field").hide();
- });
+ update_resulting_path : function() {
+ var kind = $("input[name='kind']:checked");
+ var title = $("#title").val();
- $("#kind_update").bind("change", function(){
- $("#parent_search_field").hide();
- });
+ if (title === "") {
+ $("#resulting_path").text("—");
+ return;
+ }
- $("#kind_press_release").bind("change", function(){
- $("#parent_search_field").hide();
- });
+ var prefix = kind.val() === "generic"
+ ? ($("#parent_id").attr("data-unique-name") || "")
+ : (kind.attr("data-path-prefix") || "");
- $("#kind_generic").bind("change", function(){
- $("#parent_search_field").show();
- });
+ clearTimeout(parent_search.path_timeout);
+ parent_search.path_timeout = setTimeout(function() {
+ $.get("/nodes/parameterize_preview", { title: title }, function(slug) {
+ $("#resulting_path").text(window.location.origin + "/" + (prefix ? prefix + "/" : "") + slug);
+ });
+ }, 300);
+ },
+ 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.update_resulting_path();
+ });
}
}
move_to_search = {
initialize_search : function() {
- $("#move_to_search_term").bind("keyup", function() { move_to_search.do_search($(this))});
- $("#move_to_search_term").bind("keydown", function() { move_to_search.do_search($(this))});
- $("#move_to_search_term").bind("keypress", function() { move_to_search.do_search($(this))});
- $("#move_to_search_term").bind("paste", function() { move_to_search.do_search($(this))});
- $("#move_to_search_term").bind("cut", function() { move_to_search.do_search($(this))});
+ $("#move_to_search_term").bind("input", function() {move_to_search.do_search($(this))});
},
do_search : function(_this) {
- if (_this.attr("value")) {
+ if (_this.val()) {
$.ajax({
type: "GET",
url: ADMIN_MENU_SEARCH_URL,
- data: "search_term=" + _this.attr("value"),
+ data: "search_term=" + _this.val(),
dataType: "json",
success : function(results) {
move_to_search.show_results(results);
@@ -248,8 +267,8 @@ move_to_search = {
link_closure : function(node) {
var barf = function(){
- $("#move_to_search_term").attr("value", node.title);
- $("#node_staged_parent_id").attr("value", node.node_id);
+ $("#move_to_search_term").val(node.title);
+ $("#node_staged_parent_id").val(node.node_id);
$('#search_results').slideUp();
$('#search_results').empty();
return false;
--
cgit v1.3
From 51d491a3d67e8c9efde8019ecb8a8e1a8195ec99 Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Sun, 5 Jul 2026 21:51:15 +0200
Subject: Add erfa/chaostreff node-kind creation conventions
lib/ccc_conventions.rb: NODE_KINDS registry replaces the kind-specific
branches previously scattered across nodes_controller#create (tags),
unique_path-position check). Each kind declares its parent lookup
(a Proc - Update's "update"/"press_release" continue to genuinely
find-or-create their year-folder via Update.find_or_create_parent;
erfa/chaostreff use a plain find_by_unique_name!, since their parent
nodes are fixed and a missing one should fail loudly, not be silently
created), its tags, and (new) its default template.
Node gains a default_template_name column (migration, with backfill
for existing update-tree nodes via node.update? - reusing that method
rather than re-deriving its unique_path check in raw SQL). Page#set_template
now inherits from node.default_template_name, falling back to the old
update?-based check only when the column is blank, and only fills in
template_name when nothing's already been explicitly chosen - a
deliberate change from the previous behavior, which unconditionally
overwrote template_name on every save regardless of manual selection.
Node#update? itself is unchanged and still used as-is by
admin_controller's sitemap filtering - a genuinely different, still
valid use of that check.
"generic" stays special-cased in the controller, parametrized by
params[:parent_id] at request time - doesn't fit "kind implies a fixed
lookup" and isn't in the registry.
nodes#new's four hardcoded radio buttons and nodes_controller's
kind-specific case/when branches are both replaced by iterating/looking
up this one registry - adding a new kind now means one new hash entry,
not four scattered edits.
---
app/controllers/nodes_controller.rb | 35 ++++++++++++++++++++---------------
app/models/page.rb | 5 ++---
config/routes.rb | 4 ++++
3 files changed, 26 insertions(+), 18 deletions(-)
(limited to 'app')
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 494887d..1e1def2 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -33,17 +33,17 @@ class NodesController < ApplicationController
@node = Node.new
@node.parent_id = find_parent
- @node.slug = params[:title].parameterize.to_s
-
+ @node.slug = slug_for(params[:title])
+
+ config = CccConventions::NODE_KINDS[params[:kind]]
+
if @node.save
@node.draft.update(:title => params[:title])
- case params[:kind]
- when "update"
- @node.draft.tag_list.add("update")
- when "press_release"
- @node.draft.tag_list.add("update", "pressemitteilung")
- end
+ Array(config && config[:tags]).each { |t| @node.draft.tag_list.add(t) }
@node.draft.save!
+
+ @node.update!(default_template_name: config[:template]) if config && config[:template]
+
redirect_to(edit_node_path(@node))
else
render :new
@@ -103,9 +103,17 @@ class NodesController < ApplicationController
redirect_to node_path(@node)
end
-
+
+ def parameterize_preview
+ render plain: slug_for(params[:title])
+ end
+
private
+ def slug_for(title)
+ title.to_s.parameterize
+ end
+
def node_params
params.fetch(:node, {}).permit(:slug, :parent_id, :staged_slug, :staged_parent_id)
end
@@ -120,18 +128,15 @@ class NodesController < ApplicationController
def find_parent
case params[:kind]
- when "top_level"
- Node.root.id
- when "update"
- Update.find_or_create_parent.id
- when "press_release"
- Update.find_or_create_parent.id
when "generic"
if params[:parent_id] && Node.find(params[:parent_id])
params[:parent_id]
else
nil
end
+ else
+ config = CccConventions::NODE_KINDS[params[:kind]]
+ config && config[:parent] ? config[:parent].call.id : nil
end
end
end
diff --git a/app/models/page.rb b/app/models/page.rb
index c982c2e..20461bf 100644
--- a/app/models/page.rb
+++ b/app/models/page.rb
@@ -225,9 +225,8 @@ class Page < ApplicationRecord
end
def set_template
- if node && node.update?
- self.template_name = "update"
- end
+ return if template_name.present?
+ self.template_name = node&.default_template_name || (node&.update? ? "update" : nil)
end
def rewrite_links_in_body
diff --git a/config/routes.rb b/config/routes.rb
index a7775b3..cf4733c 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -31,6 +31,10 @@ Cccms::Application.routes.draw do
end
resources :nodes do
+ collection do
+ get :parameterize_preview
+ end
+
member do
put :unlock
put :publish
--
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')
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.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
+ Set automatically based on how this node was created - change it if needed.
+
- <%= 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 d95947f9d738fd74d1fc16c6a1843b7f46bc0484 Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Sun, 5 Jul 2026 21:52:56 +0200
Subject: Whitespace fixes
---
app/controllers/admin_controller.rb | 4 ++--
app/controllers/nodes_controller.rb | 28 ++++++++++++++--------------
2 files changed, 16 insertions(+), 16 deletions(-)
(limited to 'app')
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index e0098b0..bfc6cd8 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -1,7 +1,7 @@
class AdminController < ApplicationController
-
+
# Private
-
+
before_action :login_required
def index
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index 1e1def2..380a659 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -1,14 +1,14 @@
class NodesController < ApplicationController
-
+
# Private
-
+
layout 'admin'
-
+
before_action :login_required
before_action :find_node, :only => [
- :show,
- :edit,
- :update,
+ :show,
+ :edit,
+ :update,
:destroy,
:publish,
:unlock
@@ -27,10 +27,10 @@ class NodesController < ApplicationController
@parent_name = Node.find(@parent_id).title
end
end
-
+
def create
params[:title] ||= ""
-
+
@node = Node.new
@node.parent_id = find_parent
@node.slug = slug_for(params[:title])
@@ -49,7 +49,7 @@ class NodesController < ApplicationController
render :new
end
end
-
+
def show
node = Node.find(params[:id])
node.wipe_draft!
@@ -87,20 +87,20 @@ class NodesController < ApplicationController
def destroy
@node.destroy
end
-
+
def publish
@node.publish_draft!
flash[:notice] = "Draft has been published"
redirect_to node_path(@node)
end
-
+
def unlock
if @node.unlock!
flash[:notice] = "Node unlocked"
else
flash[:notice] = "Already unlocked"
end
-
+
redirect_to node_path(@node)
end
@@ -121,11 +121,11 @@ class NodesController < ApplicationController
def page_params
params.fetch(:page, {}).permit(:title, :abstract, :body, :template_name, :published_at, :user_id)
end
-
+
def find_node
@node = Node.find(params[:id])
end
-
+
def find_parent
case params[:kind]
when "generic"
--
cgit v1.3
From 4c47031f9693fc04b01cbb005ad864cd1e3d2425 Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Mon, 6 Jul 2026 04:15:46 +0200
Subject: Add erfa/chaostreff node-kind creation conventions, part 2
The following changes were already announced in the last commit, but
the files forgotten. Here's them actually attached.
lib/ccc_conventions.rb: NODE_KINDS registry replaces the kind-specific
branches previously scattered across nodes_controller#create (tags),
unique_path-position check). Each kind declares its parent lookup
(a Proc - Update's "update"/"press_release" continue to genuinely
find-or-create their year-folder via Update.find_or_create_parent;
erfa/chaostreff use a plain find_by_unique_name!, since their parent
nodes are fixed and a missing one should fail loudly, not be silently
created), its tags, and (new) its default template.
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.
---
app/views/admin/conventions.html.erb | 11 +++++++++
lib/ccc_conventions.rb | 47 ++++++++++++++++++++++++++++++++++++
2 files changed, 58 insertions(+)
create mode 100644 app/views/admin/conventions.html.erb
create mode 100644 lib/ccc_conventions.rb
(limited to 'app')
diff --git a/app/views/admin/conventions.html.erb b/app/views/admin/conventions.html.erb
new file mode 100644
index 0000000..72f6214
--- /dev/null
+++ b/app/views/admin/conventions.html.erb
@@ -0,0 +1,11 @@
+
+ <% 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." } } %>
+ <% else %>
+ <%= button_to 'Create public preview link', generate_shared_preview_node_path(@node), method: :put %>
+ <% end %>
+
+
+ <% end %>
Author
<%= @page.user.try(:login) %>
diff --git a/config/routes.rb b/config/routes.rb
index 6c07414..26c3d4d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -38,6 +38,8 @@ Cccms::Application.routes.draw do
member do
put :unlock
put :publish
+ put :generate_shared_preview
+ put :revoke_shared_preview
end
resources :revisions do
@@ -50,6 +52,8 @@ Cccms::Application.routes.draw do
end
end
+ get 'preview/:token', to: 'shared_previews#show', as: :shared_preview
+
scope '/admin' do
resources :assets
end
--
cgit v1.3
From 889e15eabbe107d2642fdd8aa3f03821058c00dc Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Mon, 6 Jul 2026 13:52:28 +0200
Subject: Fix shared preview links breaking after a second publish
A token's page could stop being node.head_id (superseded by a newer
draft) while still being published_at.present? - the old check only
compared against the current head, so a superseded page fell through
to direct rendering instead of redirecting, serving a stale frozen
snapshot indefinitely instead of the current live content.
Also handles scheduled publishing correctly: a page can be head_id but
not yet public? (published_at in the future) - that case must still
render directly, not redirect into a 404 on the not-yet-live public
URL.
---
app/controllers/shared_previews_controller.rb | 9 +++++--
test/models/page_test.rb | 35 ++++++++++++++++++++++++++-
2 files changed, 41 insertions(+), 3 deletions(-)
(limited to 'app')
diff --git a/app/controllers/shared_previews_controller.rb b/app/controllers/shared_previews_controller.rb
index a8a540f..d482466 100644
--- a/app/controllers/shared_previews_controller.rb
+++ b/app/controllers/shared_previews_controller.rb
@@ -1,9 +1,14 @@
class SharedPreviewsController < ApplicationController
def show
@page = Page.find_by!(preview_token: params[:token])
+ node = @page.node
- if @page.node && @page.node.head_id == @page.id
- redirect_to node_path(@page.node)
+ was_published = @page.published_at.present?
+ superseded = was_published && node && node.head_id != @page.id
+ currently_public = was_published && node && node.head_id == @page.id && @page.public?
+
+ if superseded || currently_public
+ redirect_to node_path(node)
return
end
diff --git a/test/models/page_test.rb b/test/models/page_test.rb
index afba8b5..ad2742f 100644
--- a/test/models/page_test.rb
+++ b/test/models/page_test.rb
@@ -142,5 +142,38 @@ class PageTest < ActiveSupport::TestCase
update = updates2009.children.create!( :slug => "my-first-update" )
assert_equal "update", update.draft.template_name
end
-
+
+ test "a page scheduled for future publication is not yet public even after being published" do
+ node = Node.root.children.create!(slug: "preview-scheduled-test")
+ draft = node.find_or_create_draft(@user1)
+ draft.title = "Scheduled test"
+ draft.published_at = 1.day.from_now
+ draft.save!
+ token = draft.ensure_preview_token!
+
+ node.publish_draft!
+ page = Page.find_by(preview_token: token)
+
+ assert_equal page.id, page.node.head_id
+ assert_not page.public?
+ end
+
+ test "a superseded page is no longer the head, even though it was once published" do
+ node = Node.root.children.create!(slug: "preview-superseded-test")
+ first_draft = node.find_or_create_draft(@user1)
+ first_draft.title = "First version"
+ first_draft.save!
+ first_token = first_draft.ensure_preview_token!
+ node.publish_draft!
+
+ second_draft = node.find_or_create_draft(@user1)
+ second_draft.title = "Second version"
+ second_draft.save!
+ node.publish_draft!
+
+ first_page = Page.find_by(preview_token: first_token)
+
+ assert_not_equal first_page.id, first_page.node.head_id
+ assert first_page.published_at.present?
+ end
end
--
cgit v1.3
From 36b5324900968afc7498950c14ad13be5c061a7c Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Mon, 6 Jul 2026 16:49:45 +0200
Subject: Restructure nodes#show and nodes#new around
node_description/node_content
nodes#show's flat
of label/value pairs grew unreadable once
Admin Preview and Public Preview were added alongside Public Link -
switched to the div.node_description/div.node_content pattern already
used by nodes#edit, grouped into People/Dates/Links/Revisions/Tags/
Events/Children sections rather than one undifferentiated list.
People/Dates/Links use a flex-based item layout, not a second
-
multiple independent tables sharing one outer width but different
column counts produced misaligned columns with no shared grid. Flex
items size to their own content with a shared minimum instead.
Revisions and Children are both collapsed via , no JS needed -
previously nodes#show didn't list a node's children at all, blocking
the ability to find/navigate erfa and chaostreff pages without already
knowing they're now standalone nodes. Revision list items link to
node_revision_path(@node, page) - the actual per-revision diff/restore
view, not the plain index.
nodes#new converted to the same pattern for visual consistency, plus a
few things surfaced along the way: submit buttons were unstyled
site-wide (a bare input[type=submit] with no border, fill, or hover
state - Create on this exact page was easy to miss entirely), fixed
with a bordered/bold treatment reusing the existing form.button_to
visual language rather than introducing a fourth button style. Title
regained the bold weight it had before the table rewrite dropped it
silently. Tag list grouping and full link/button semantic taxonomy
(show vs edit/add vs publish/revoke vs destroy, applied consistently
across every link on the page, not just buttons) are known follow-ups,
not attempted here.
---
app/views/nodes/new.html.erb | 57 ++++++------
app/views/nodes/show.html.erb | 204 ++++++++++++++++++++++++------------------
public/stylesheets/admin.css | 64 +++++++++----
3 files changed, 192 insertions(+), 133 deletions(-)
(limited to 'app')
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb
index f557454..0a05325 100644
--- a/app/views/nodes/new.html.erb
+++ b/app/views/nodes/new.html.erb
@@ -6,13 +6,11 @@
<% end %>
-
What kind of node do you want to create?
-
<%= form_tag nodes_path do %>
-
-
-
Type
-
+
+
+
Type
+
<% CccConventions::NODE_KINDS.each do |kind, config| %>
+ <%= 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.
-
—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).
-
+ <%# Assets not yet addressed - no confirmed model/association seen for this page's attached assets %>
+
+
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index f240bb2..1196d83 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -106,7 +106,6 @@ div.pagination span.current, div.pagination a:hover {
#main_navigation a,
#sub_navigation a,
#metadata a,
-input[type=submit],
#overview_toggle a {
letter-spacing: 1px;
padding-left: 5px;
@@ -316,17 +315,23 @@ input[type=password] {
border: 1px solid #989898;
}
-input[type=submit] {
- border: none;
- padding-top: 0px;
- padding-bottom: 1px;
- background-color: #ffffff;
+input[type="submit"] {
+ -webkit-appearance: none;
+ appearance: none;
+ background: none;
+ border: 1px solid #000000;
+ border-radius: 2px;
+ padding: 4px 12px;
+ font: inherit;
+ font-weight: bold;
+ color: inherit;
+ cursor: pointer;
+ text-decoration: none;
}
-input[type=submit]:hover {
- border: none;
+input[type="submit"]:hover {
color: #ffffff;
- background-color: #ff9600;
+ background-color: #000000;
}
input[type=radio] {
@@ -516,6 +521,39 @@ table tr.header {
padding-bottom: 4px;
}
+.node_content.node_info_group {
+ border: 1px solid #e8e8e8;
+ border-radius: 6px;
+ padding: 0.5rem 0.75rem;
+}
+
+.node_info_group_items {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 2rem;
+}
+
+.node_info_item {
+ min-width: 140px;
+}
+
+.node_info_label {
+ display: block;
+ font-weight: normal;
+ color: #969696;
+ font-size: 0.85rem;
+ text-transform: lowercase;
+}
+
+.node_info_group ul {
+ margin: 0;
+ padding-left: 1.25rem;
+}
+
+.node_info_group li {
+ margin-bottom: 0.25rem;
+}
+
#search_widget {
position: absolute;
top: 20px;
@@ -569,14 +607,6 @@ table tr.header {
border-bottom: none;
}
-table#content th.description {
- width: 100px;
-}
-
-table#content th.content {
- width: 690px;
-}
-
#menu_item_list {
border-collapse: collapse;
padding: 5px 5px 5px 5px;
--
cgit v1.3
From 3dbf522783dfce496884e05559dc962cae4b1e1b Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Mon, 6 Jul 2026 16:59:19 +0200
Subject: Apply slug immediately for never-published nodes
staged_slug existed to protect a live public URL from changing until
an editor explicitly publishes - correct once a node has a head, but
nodes_controller#update wrote to it unconditionally, so a brand-new
node being renamed before its first-ever publish showed a stale slug
in previews (e.g. nodes#new's resulting-path preview) that silently
diverged from what would actually go live.
Node#staged_slug= now applies directly to slug when head is blank -
nothing is live yet, so there's nothing to protect. Once head is
present, defers to staged_slug exactly as before. Verified both paths
directly: a never-published node's slug now updates immediately, and
an already-published node's rename still stays deferred until the
next publish, unchanged from existing behavior.
---
app/models/node.rb | 8 ++++++++
1 file changed, 8 insertions(+)
(limited to 'app')
diff --git a/app/models/node.rb b/app/models/node.rb
index ba94e2a..2177f15 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -94,6 +94,14 @@ class Node < ApplicationRecord
self.draft.reload
end
+ def staged_slug=(value)
+ if head.blank?
+ self.slug = value
+ else
+ super
+ end
+ end
+
def publish_draft!
# Return nil if nothing to publish and no staged changes
return nil unless self.draft || staged_slug || staged_parent_id
--
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')
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 7b6af89509e8439fe2474e623ee97e4db67ab011 Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Tue, 7 Jul 2026 02:46:37 +0200
Subject: Replace awesome_nested_set with a plain parent_id-based NestedTree
concern
Root-caused this session: appending a child to any node never widened
that parent's own rgt boundary, on the pinned revision (Gemfile tracked
main directly, chasing a too-conservative gemspec constraint - not, as
first assumed, a deliberate pin to avoid a known bug). Reproduced
cleanly on a single ordinary create with no concurrency and no bulk
operation involved, confirmed via the gem's own SetValidator, then
confirmed as the root cause of nodes_controller_test.rb's 3 long-standing
"pre-existing" failures - not three separate mysteries, one bug.
admin_controller's sitemap needed its own real conversion, not just a
drop-in: awesome_nested_set's lft column implicitly provided correct
depth-first tree order for free, which the old code combined with a
separate class-level each_with_level iterator. Both replaced by one
method, self_and_descendants_ordered_with_level, computing an ordered
[node, level] list in a single query-then-walk pass - checked against
the actual view template first (admin/index.html.erb) rather than
assumed, since it relies on list order alone to render correct visual
nesting.
lft/rgt/depth columns intentionally left in schema, unused - dropping
them is a separate, deliberately deferred migration once this is proven
running for a while, not bundled with the behavior change.
---
Gemfile | 3 --
Gemfile.lock | 10 -----
app/controllers/admin_controller.rb | 8 ++--
app/models/concerns/nested_tree.rb | 86 +++++++++++++++++++++++++++++++++++++
app/models/node.rb | 25 +++--------
db/seeds/chapters.rb | 3 --
6 files changed, 95 insertions(+), 40 deletions(-)
create mode 100644 app/models/concerns/nested_tree.rb
(limited to 'app')
diff --git a/Gemfile b/Gemfile
index 13bb4ca..d136eb5 100644
--- a/Gemfile
+++ b/Gemfile
@@ -44,9 +44,6 @@ gem 'will_paginate', '~> 3.0'
gem 'acts-as-taggable-on',
git: 'https://github.com/mbleigh/acts-as-taggable-on.git',
branch: 'master'
-gem 'awesome_nested_set',
- git: 'https://github.com/collectiveidea/awesome_nested_set.git',
- branch: 'main'
# ── XML / parsing ─────────────────────────────────────────────────────────────
diff --git a/Gemfile.lock b/Gemfile.lock
index 2e9a1ce..02c71ff 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,11 +1,3 @@
-GIT
- remote: https://github.com/collectiveidea/awesome_nested_set.git
- revision: 7a642749af74519b4bf3642d939061beb36835e1
- branch: main
- specs:
- awesome_nested_set (3.9.0)
- activerecord (>= 4.0.0, < 8.2)
-
GIT
remote: https://github.com/erdgeist/chaoscalendar.git
revision: 48fe5c517ae3895cc78d9d2fefbdd35b90d52ba7
@@ -335,7 +327,6 @@ PLATFORMS
DEPENDENCIES
acts-as-taggable-on!
acts_as_list
- awesome_nested_set!
chaos_calendar!
coffee-rails (~> 4.0)
concurrent-ruby (~> 1.3)
@@ -374,7 +365,6 @@ CHECKSUMS
activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e
acts-as-taggable-on (13.0.0)
acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8
- awesome_nested_set (3.9.0)
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index bfc6cd8..3fa0519 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -15,12 +15,10 @@ class AdminController < ApplicationController
Time.now, Time.now - 14.days
).limit(50).order("updated_at desc")
- all_nodes = Node.root.self_and_descendants
+ ordered_with_level = Node.root.self_and_descendants_ordered_with_level
@sitemap_depth = {}
- Node.each_with_level(all_nodes) do |node, level|
- @sitemap_depth[node.id] = level
- end
- @sitemap = all_nodes.to_a.sort! { |node1,node2| node1.lft <=> node2.lft }.delete_if { |node| node.update? }
+ ordered_with_level.each { |node, level| @sitemap_depth[node.id] = level }
+ @sitemap = ordered_with_level.map(&:first).reject(&:update?)
@mypages = Page.where("user_id = ? or editor_id = ?", @current_user, @current_user)
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb
new file mode 100644
index 0000000..9a2eb0e
--- /dev/null
+++ b/app/models/concerns/nested_tree.rb
@@ -0,0 +1,86 @@
+# app/models/concerns/nested_tree.rb
+#
+# Minimal parent_id-based replacement for the tree-traversal subset of
+# awesome_nested_set actually used by this app (descendants, ancestors,
+# level, root, move_to_child_of)
+module NestedTree
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children
+ has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent
+
+ before_destroy :delete_descendants
+ end
+
+ class_methods do
+ def root
+ roots.first
+ end
+
+ def roots
+ where(parent_id: nil)
+ end
+
+ def valid?
+ # Every non-root row's parent_id must point at a real, existing row.
+ where.not(parent_id: nil).where.missing(:parent).none?
+ end
+ end
+
+ def root?
+ parent_id.nil?
+ end
+
+ def ancestors
+ result = []
+ current = parent
+ while current
+ result << current
+ current = current.parent
+ end
+ result
+ end
+
+ def level
+ ancestors.size
+ end
+
+ def descendants
+ ids = []
+ queue = self.class.where(parent_id: id).pluck(:id)
+ until queue.empty?
+ ids.concat(queue)
+ queue = self.class.where(parent_id: queue).pluck(:id)
+ end
+ self.class.where(id: ids)
+ end
+
+ def self_and_descendants
+ self.class.where(id: [id] + descendants.pluck(:id))
+ end
+
+ def self_and_descendants_ordered_with_level
+ nodes = [self] + descendants.to_a
+ children_by_parent = nodes.group_by(&:parent_id)
+ children_by_parent.each_value { |list| list.sort_by!(&:id) }
+
+ result = []
+ visit = ->(node, level) do
+ result << [node, level]
+ (children_by_parent[node.id] || []).each { |child| visit.call(child, level + 1) }
+ end
+ visit.call(self, 0)
+ result
+ end
+
+ def move_to_child_of(new_parent)
+ update!(parent_id: new_parent.id)
+ end
+
+ private
+
+ def delete_descendants
+ self.class.where(id: descendants.pluck(:id)).delete_all
+ end
+end
diff --git a/app/models/node.rb b/app/models/node.rb
index 2177f15..fc23dc1 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -1,6 +1,6 @@
class Node < ApplicationRecord
# Mixins and Plugins
- acts_as_nested_set
+ include NestedTree
# Associations
has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
@@ -21,16 +21,6 @@ class Node < ApplicationRecord
validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? }
validates_presence_of :parent_id, :unless => -> { Node.root.nil? }
- validate :borders # This should never ever happen.
-
- # Index for Fulltext Search
- # define_index do
- # indexes head.translations.title
- # indexes slug
- # indexes unique_name
- # indexes head.translations.body
- # end
-
# Class methods
# Returns a page for a given node. If no revision is supplied, it returns
@@ -254,10 +244,13 @@ class Node < ApplicationRecord
# Watch out recursion ahead! update_unique_name itself triggers this
# after_save callback which invokes update_unique_name on its children.
# Hopefully until no childrens occur
+ #
+ # Queries parent_id directly rather than the NestedTree#children
+ # association out of habit from the old awesome_nested_set-avoidance
+ # workaround - no longer strictly necessary now that children is
+ # equally safe, but left as-is since it already works correctly.
def update_unique_names_of_children
unless root?
- # Use parent_id-based traversal instead of lft/rgt descendants
- # due to awesome_nested_set not refreshing parent lft/rgt in memory
Node.where(:parent_id => self.id).each do |child|
child.reload
child.update_unique_name
@@ -265,10 +258,4 @@ class Node < ApplicationRecord
end
end
end
-
- def borders
- if lft && rgt && (lft > rgt)
- errors.add("Fuck!. lft should never be smaller than rgt")
- end
- end
end
diff --git a/db/seeds/chapters.rb b/db/seeds/chapters.rb
index bebc8da..4d81e3c 100644
--- a/db/seeds/chapters.rb
+++ b/db/seeds/chapters.rb
@@ -1409,6 +1409,3 @@ 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 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')
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.
+ Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them.
+
- <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
- Set automatically based on how this node was created - change it if needed.
-
+ <%= d.select :template_name, custom_page_templates, {:prompt => 'Select Template'} %>
+ Set automatically based on how this node was created - change it if needed.
--
cgit v1.3
From b555a3e05c44f21067855b6bbafa70a5e32c9a40 Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Wed, 8 Jul 2026 11:56:39 +0200
Subject: Promote page title to headline in nodes#show
---
app/views/nodes/show.html.erb | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
(limited to 'app')
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb
index 71dbaa3..2ab7986 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -4,8 +4,8 @@
<% end %>
+
<%= title_for_node(@node) %>
-
People
@@ -128,9 +128,6 @@
<% end %>
-
Title
-
<%= sanitize(@page.title) %>
-
Abstract
<%= sanitize(@page.abstract) %>
--
cgit v1.3
From a006440f59bf99380e179cc2963cb98d4d894d3a Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Wed, 8 Jul 2026 16:22:17 +0200
Subject: Add head/draft/autosave hierarchy to Node
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduces autosave_id as a third, unversioned layer above draft/head,
with lock_for_editing!, autosave!, and save_draft! as the new entry
points. Also fixes a real bug in wipe_draft!: its "no draft" branch
unconditionally released the lock, which was safe when "no draft" only
ever meant "nothing is happening" — no longer true now that a lock can
exist with only an autosave beneath it. lock_for_editing! deliberately
does not call wipe_draft! at all, for the same reason: an intruder
calling it while a lock was genuinely held would otherwise silently
steal it via wipe_draft!'s own unlock side effect, caught by the new
two-user lock test.
---
app/models/node.rb | 80 ++++++++++++++++++
.../20260708095943_add_autosave_id_to_nodes.rb | 5 ++
test/models/node_test.rb | 96 ++++++++++++++++++++++
3 files changed, 181 insertions(+)
create mode 100644 db/migrate/20260708095943_add_autosave_id_to_nodes.rb
(limited to 'app')
diff --git a/app/models/node.rb b/app/models/node.rb
index fc23dc1..9eb0fe4 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -6,6 +6,7 @@ class Node < ApplicationRecord
has_many :pages, -> { order("revision ASC") }, :dependent => :destroy
belongs_to :head, :class_name => "Page", :foreign_key => :head_id, :dependent => :destroy, optional: true
belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, :dependent => :destroy, optional: true
+ belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true
has_many :permissions, :dependent => :destroy
has_many :events, :dependent => :destroy
belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true
@@ -49,6 +50,71 @@ class Node < ApplicationRecord
# Instance Methods
+ # Acquires (or reaffirms) the editing lock without creating a draft or
+ # an autosave -- both are now deferred until there is real content to
+ # hold.
+ def lock_for_editing! current_user
+ if self.lock_owner.nil? || self.lock_owner == current_user
+ lock_for! current_user
+ self
+ else
+ raise(
+ LockedByAnotherUser,
+ "Page is locked by another user who is working on it! " \
+ "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
+ )
+ end
+ end
+
+ # Creates or updates the autosave buffer from the given attributes.
+ # Autosave rows are never associated to the node via node_id -- they
+ # must never appear in self.pages / the revisions list, which is the
+ # whole reason autosave exists as a separate, unversioned layer.
+ def autosave! attributes, current_user
+ assert_locked_by! current_user
+
+ unless self.autosave
+ self.autosave = Page.create!(:editor => current_user)
+ self.save!
+ end
+ self.autosave.assign_attributes(attributes)
+ self.autosave.save!
+ self.autosave
+ end
+
+ # Promotes the current autosave into the draft (creating the draft if
+ # none exists yet) and destroys the autosave afterward. This is what
+ # the explicit "Save" action does; it never creates a new revision --
+ # same as any other in-place draft edit. The new draft is created via
+ # self.pages.create! rather than by repointing the autosave's own
+ # node_id, because acts_as_list assigns the revision number at create
+ # time, scoped to node_id -- a page created with node_id nil and
+ # reassigned afterward would carry a wrong or missing revision number.
+ def save_draft! current_user
+ assert_locked_by! current_user
+ return unless self.autosave
+
+ if self.draft
+ self.draft.clone_attributes_from self.autosave
+ self.draft.user_id = self.autosave.user_id if self.autosave.user_id
+ self.draft.editor = current_user
+ self.draft.save!
+ else
+ empty_page = self.pages.create!
+ empty_page.user = self.autosave.user_id ? self.autosave.user : (self.head ? self.head.user : current_user)
+ empty_page.editor = current_user
+ empty_page.clone_attributes_from self.autosave
+ empty_page.save!
+ self.draft = empty_page
+ self.save!
+ end
+
+ self.autosave.destroy
+ self.autosave_id = nil
+ self.save!
+ self.draft.reload
+ end
+
def find_or_create_draft current_user
self.wipe_draft!
if draft && self.lock_owner == current_user
@@ -129,8 +195,13 @@ class Node < ApplicationRecord
# removes a draft and the lock if it is older than a day and still
# identical to head
def wipe_draft!
+ return if self.autosave && self.autosave.updated_at > 1.day.ago
+
unless self.draft
+ self.autosave&.destroy
+ self.autosave_id = nil
self.unlock!
+ self.save!
return
end
return unless self.head
@@ -224,6 +295,15 @@ class Node < ApplicationRecord
self.save
end
+ def assert_locked_by! current_user
+ return if self.lock_owner == current_user
+ raise(
+ LockedByAnotherUser,
+ "Page is locked by another user who is working on it! " \
+ "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}"
+ )
+ end
+
# Creates an empty page and associates it to the given node. This means
# freshly created node has an empty draft. A user can create nodes as he
# wants to which will not appear on the public page until the author edits
diff --git a/db/migrate/20260708095943_add_autosave_id_to_nodes.rb b/db/migrate/20260708095943_add_autosave_id_to_nodes.rb
new file mode 100644
index 0000000..eedcc00
--- /dev/null
+++ b/db/migrate/20260708095943_add_autosave_id_to_nodes.rb
@@ -0,0 +1,5 @@
+class AddAutosaveIdToNodes < ActiveRecord::Migration[8.1]
+ def change
+ add_column :nodes, :autosave_id, :integer
+ end
+end
diff --git a/test/models/node_test.rb b/test/models/node_test.rb
index 514ba3f..2953f8f 100644
--- a/test/models/node_test.rb
+++ b/test/models/node_test.rb
@@ -98,6 +98,7 @@ class NodeTest < ActiveSupport::TestCase
assert_not_nil node.draft
assert_nil node.draft.user
assert_nil node.head
+ assert_nil node.autosave
end
def test_create_new_draft_of_published_page
@@ -280,6 +281,101 @@ class NodeTest < ActiveSupport::TestCase
node = Node.root.children.create( :slug => "wow" )
assert_nil node.draft.published_at
end
+
+ test "lock_for_editing! acquires the lock without creating a draft or autosave" do
+ node = create_node_with_published_page
+
+ node.lock_for_editing!(@user1)
+
+ assert_equal @user1, node.lock_owner
+ assert_nil node.draft
+ assert_nil node.autosave
+ end
+
+ test "autosave! creates a buffer that never appears among a node's pages, leaving the draft untouched" do
+ node = create_node_with_draft
+ node.lock_for_editing!(@user1)
+ page_count_before = node.pages.count
+
+ node.autosave!({ :title => "in progress" }, @user1)
+ node.reload
+
+ assert_not_nil node.autosave
+ assert_nil node.autosave.node_id
+ assert_equal page_count_before, node.pages.count
+ assert_not_equal "in progress", node.draft.title
+ end
+
+ test "save_draft! promotes an autosave into an existing draft without creating a new revision" do
+ node = create_node_with_draft
+ node.lock_for_editing!(@user1)
+ node.autosave!({ :title => "in progress" }, @user1)
+ page_count_before = node.pages.count
+
+ node.save_draft!(@user1)
+ node.reload
+
+ assert_nil node.autosave
+ assert_equal "in progress", node.draft.title
+ assert_equal page_count_before, node.pages.count
+ end
+
+ test "save_draft! promotes an autosave into a brand new, correctly-revisioned draft when none exists" do
+ node = create_node_with_published_page
+ head_revision = node.head.revision
+
+ node.lock_for_editing!(@user1)
+ node.autosave!({ :title => "updated version" }, @user1)
+ node.reload
+
+ assert_nil node.draft
+ assert_nil node.autosave.node_id
+
+ node.save_draft!(@user1)
+ node.reload
+
+ assert_not_nil node.draft
+ assert_equal head_revision + 1, node.draft.revision
+ assert_equal head_revision, node.head.revision
+ assert_nil node.autosave
+ assert_equal 2, node.pages.count
+ end
+
+ test "autosave!, save_draft!, and lock_for_editing! raise LockedByAnotherUser for a second user" do
+ node = create_node_with_published_page
+ node.lock_for_editing!(@user1)
+
+ assert_raise(LockedByAnotherUser) { node.autosave!({ :title => "x" }, @user2) }
+ assert_raise(LockedByAnotherUser) { node.save_draft!(@user2) }
+ assert_raise(LockedByAnotherUser) { node.lock_for_editing!(@user2) }
+
+ assert_equal @user1, node.reload.lock_owner
+ end
+
+ test "wipe_draft! leaves a fresh autosave and its lock untouched" do
+ node = create_node_with_published_page
+ node.lock_for_editing!(@user1)
+ node.autosave!({ :title => "still typing" }, @user1)
+
+ node.wipe_draft!
+ node.reload
+
+ assert_not_nil node.autosave
+ assert_equal @user1, node.lock_owner
+ end
+
+ test "wipe_draft! destroys a stale, orphaned autosave and releases its lock" do
+ node = create_node_with_published_page
+ node.lock_for_editing!(@user1)
+ node.autosave!({ :title => "abandoned mid-session" }, @user1)
+ node.autosave.update_column(:updated_at, 2.days.ago)
+
+ node.wipe_draft!
+ node.reload
+
+ assert_nil node.autosave
+ assert_nil node.lock_owner
+ end
def create_revisions node, count
count.times do
--
cgit v1.3
From c6bf63a82007c275d13e9e9e0857434b3b7890c0 Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Wed, 8 Jul 2026 16:23:01 +0200
Subject: Fix tests for the new autosave routing
Route Save and autosave through the new Node methods
update now promotes the current autosave into the draft via
save_draft! rather than writing submitted params directly; autosave
gets its own PUT member route so PATCH update can mean "deliberate
save" specifically, matching every other custom action on this
resource. Both now require the lock to already be held rather than
acquiring it themselves, which is a deliberate narrowing: through the
real UI this is always true, since neither can fire before edit has
loaded. Two existing tests called update directly with no prior
edit, which the old code tolerated by acquiring the lock as a side
effect; updated to call edit first instead of loosening the
guarantee back open.
NodesController#edit itself is unchanged and still calls the old
find_or_create_draft, so drafts are still created eagerly on entering
edit for now -- switching that over is the next step, not this one.
---
app/controllers/nodes_controller.rb | 39 +++++++++++++++++++++++--------
test/controllers/nodes_controller_test.rb | 2 ++
2 files changed, 31 insertions(+), 10 deletions(-)
(limited to 'app')
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index a72be68..042963b 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -11,7 +11,8 @@ class NodesController < ApplicationController
:update,
:destroy,
:publish,
- :unlock
+ :unlock,
+ :autosave
]
def index
@@ -72,17 +73,35 @@ class NodesController < ApplicationController
def update
@node.update(node_params)
- @draft = @node.find_or_create_draft current_user
- @draft.tag_list = params[:tag_list]
- if @draft.update( page_params )
- flash[:notice] = "Draft has been saved: #{Time.now}"
- respond_to do |format|
- format.html { redirect_to edit_node_path(@node) }
- format.js
- end
+ @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user )
+ @node.save_draft!(current_user)
+
+ flash[:notice] = "Draft has been saved: #{Time.now}"
+
+ if params[:commit] == "Save + Unlock + Exit"
+ @node.unlock!
+ redirect_to node_path(@node)
else
- render :action => :edit
+ redirect_to edit_node_path(@node)
end
+ rescue LockedByAnotherUser => e
+ flash[:error] = e.message
+ redirect_to node_path(@node)
+ rescue ActiveRecord::RecordInvalid
+ @page = @node.autosave || @node.draft || @node.head
+ render :action => :edit
+ end
+
+ def autosave
+ @node.update(node_params)
+ @node.autosave!( page_params.merge(:tag_list => params[:tag_list]), current_user )
+ head :ok
+ rescue LockedByAnotherUser => e
+ render plain: e.message, status: :locked
+ rescue ActiveRecord::RecordInvalid => e
+ render plain: e.message, status: :unprocessable_entity
+ rescue StandardError => e
+ render plain: "Autosave failed", status: :internal_server_error
end
def destroy
diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb
index 61f7db4..f0be8c9 100644
--- a/test/controllers/nodes_controller_test.rb
+++ b/test/controllers/nodes_controller_test.rb
@@ -123,6 +123,7 @@ class NodesControllerTest < ActionController::TestCase
def test_update_a_draft
test_node = Node.root.children.create! :slug => "test_node"
login_as :quentin
+ get :edit, params: { :id => test_node.id }
put :update, params: { :id => test_node.id, :page => {:title => "Hello", :body => "There"} }
test_node.reload
assert_equal "Hello", test_node.draft.title
@@ -133,6 +134,7 @@ class NodesControllerTest < ActionController::TestCase
test_node = Node.root.children.create! :slug => "test_node"
login_as :quentin
+ get :edit, params: { :id => test_node.id }
put :update, params: {
:id => test_node.id,
:page => {
--
cgit v1.3
From 43e7743ebff1b1f41bbb5ae39bcc5bed7d8982e3 Mon Sep 17 00:00:00 2001
From: erdgeist
Date: Wed, 8 Jul 2026 16:24:05 +0200
Subject: Autosave posts to its own endpoint surface lock loss immediately
Previously piggybacked on the main form's action and relied on a
.js.erb response to update #flash; now posts to the dedicated
autosave route with an explicit _method=put override, and reacts to
a 423 by stopping the timer and showing a persistent banner rather
than letting the next Save discover the conflict on its own.
---
app/views/nodes/edit.html.erb | 2 +-
public/javascripts/admin_interface.js | 38 ++++++++++++++++++++++++++++++++---
public/stylesheets/admin.css | 5 +++++
3 files changed, 41 insertions(+), 4 deletions(-)
(limited to 'app')
diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb
index 4693569..1c6cc3a 100644
--- a/app/views/nodes/edit.html.erb
+++ b/app/views/nodes/edit.html.erb
@@ -5,7 +5,7 @@
<% end %>
-<%= form_for(@node) do |f| %>
+ <%= form_for(@node, html: { data: { autosave_url: autosave_node_path(@node), show_url: node_path(@node) } }) do |f| %>
<% if @node.errors.any? %>