From a7a6ad786eeb9f94f7882462bccbdd31e1bb4743 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 30 Jun 2026 19:15:22 +0200 Subject: Phase 1: standalone events, external_url on nodes - Migration: node_id nullable on events and occurrences, add title/description/is_primary to events, external_url to nodes - Existing events marked is_primary: true (were all 1:1 with nodes) - Node: has_one :event -> has_many :events - Event: belongs_to :node optional, validates title presence for standalone events, is_primary uniqueness scoped to node_id, display_title helper falling back through node title - Occurrence: belongs_to :node optional, summary falls back to event.display_title - nodes_helper: event_information uses events.first (interim; will be replaced in Phase 3 event UI) - Tests: fix node.event -> node.events.first in event_test --- app/models/event.rb | 29 ++++++++++++++++------------- app/models/node.rb | 2 +- app/models/occurrence.rb | 9 ++++----- 3 files changed, 21 insertions(+), 19 deletions(-) (limited to 'app/models') diff --git a/app/models/event.rb b/app/models/event.rb index 94a22e3..26c79e4 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,22 +1,25 @@ class Event < ApplicationRecord - - # Associations - - has_many :occurrences - belongs_to :node - - # Callbacks - - after_save :generate_occurences - - # Instance Methods - + + belongs_to :node, optional: true + has_many :occurrences + + validates :title, presence: true, unless: -> { node_id.present? } + validates :is_primary, uniqueness: { scope: :node_id, + message: "only one primary event per node allowed" }, + if: -> { is_primary? && node_id.present? } + + after_save :generate_occurences + def occurrences_in_range start_time, end_time self.occurrences.where( "start_time > ? AND end_time < ?", start_time, end_time ) - end + end + + def display_title + title.presence || node&.head&.title || "Untitled event" + end private def generate_occurences diff --git a/app/models/node.rb b/app/models/node.rb index 92ecc12..ba94e2a 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -7,7 +7,7 @@ class Node < ApplicationRecord 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 has_many :permissions, :dependent => :destroy - has_one :event, :dependent => :destroy + has_many :events, :dependent => :destroy belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true # Callbacks diff --git a/app/models/occurrence.rb b/app/models/occurrence.rb index 3baf447..143124f 100644 --- a/app/models/occurrence.rb +++ b/app/models/occurrence.rb @@ -1,13 +1,12 @@ -# TODO Make a gem out of the c wrapper require 'chaos_calendar' class Occurrence < ApplicationRecord # Associations - belongs_to :node + belongs_to :node, optional: true belongs_to :event - + # Class Methods def self.find_in_range start_time, end_time @@ -64,7 +63,7 @@ class Occurrence < ApplicationRecord # Instance Methods def summary - node.head.title + node&.head&.title || event.display_title end - + end -- cgit v1.3 From 4dd49b1eebb0a99d3aee66b7eca539c87a9c6332 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 28 Jun 2026 04:43:28 +0200 Subject: Phase 2: chapter nodes, aggregate partial, fixes - _chapter.html.erb: new partial for erfa/chaostreff aggregated lists; renders title, location, external_url, sanitized body - content_helper: fix aggregate attr regex to allow hyphens in values (erfa-detail tag was silently dropped); add debug logging (remove) - page.rb: suppress libxml stderr noise in rewrite_links_in_body - db/seeds/chapters.rb: one-shot seed script for erfa and chaostreff chapter nodes under parent nodes 548/549; creates bilingual pages, external_url, primary events with RRULEs where known Note: run Node.rebuild!(false) after execution to fix lft/rgt values --- app/helpers/content_helper.rb | 2 +- app/models/page.rb | 8 + app/views/custom/partials/_chapter.html.erb | 10 + db/seeds/chapters.rb | 1329 +++++++++++++++++++++++++++ 4 files changed, 1348 insertions(+), 1 deletion(-) create mode 100644 app/views/custom/partials/_chapter.html.erb create mode 100644 db/seeds/chapters.rb (limited to 'app/models') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index 21cc579..53bf5b2 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -80,7 +80,7 @@ module ContentHelper begin if content =~ /\[aggregate([^\]]*)\]/ tag = $~.to_s - matched_data = $1.scan(/\w+\="[a-zA-Z\s\/_\d,.=]*"/) + matched_data = $1.scan(/\w+\="[a-zA-Z\s\/_\d,.=-]*"/) matched_data.each do |data| splitted_data = data.split("=", 2) diff --git a/app/models/page.rb b/app/models/page.rb index e6baf20..385b3f6 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -63,6 +63,14 @@ class Page < ApplicationRecord end end + if options[:order_by] == "title" + return scope + .joins(:translations) + .where(page_translations: { locale: I18n.locale }) + .order("page_translations.title #{options[:order_direction]}") + .paginate(:page => page, :per_page => options[:limit]) + end + scope.order("#{options[:order_by]} #{options[:order_direction]}") .paginate(:page => page, :per_page => options[:limit]) end diff --git a/app/views/custom/partials/_chapter.html.erb b/app/views/custom/partials/_chapter.html.erb new file mode 100644 index 0000000..a5b8662 --- /dev/null +++ b/app/views/custom/partials/_chapter.html.erb @@ -0,0 +1,10 @@ +
+

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

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

<%= sanitize page.body %>

+
diff --git a/db/seeds/chapters.rb b/db/seeds/chapters.rb new file mode 100644 index 0000000..cb48894 --- /dev/null +++ b/db/seeds/chapters.rb @@ -0,0 +1,1329 @@ +# db/seeds/chapters.rb +# Creates erfa and chaostreff chapter nodes under their respective parent nodes. +# Run with: bundle exec rails runner db/seeds/chapters.rb +# +# Parent nodes: +# 548 = erfas overview node +# 549 = chaostreffs overview node +# +# Each entry requires at minimum: slug, title_de, description_de, external_url +# Optional: title_en, description_en, location, rrule, start_time, review +# Entries with review: true have known discrepancies between DE and EN content. + +require 'date' + +def seed_chapter(parent_id:, slug:, tag:, title_de:, description_de:, + external_url:, title_en: nil, description_en: nil, + location: nil, rrule: nil, start_time: nil, review: false) + + if review + puts " [REVIEW] #{slug} — check DE/EN discrepancy before publishing" + end + + parent = Node.find(parent_id) + + # Skip if already exists + if Node.find_by(slug: slug, parent_id: parent_id) + puts " Skipping #{slug} (already exists)" + return + end + + # Create node + node = parent.children.create!(slug: slug, external_url: external_url) + node.reload + + # Set up draft with German translation + draft = node.draft + I18n.with_locale(:de) do + draft.title = title_de + draft.abstract = location || "" + draft.body = description_de + draft.tag_list = tag + draft.save! + end + + # Add English translation if provided + if title_en || description_en + I18n.with_locale(:en) do + draft.title = title_en || title_de + draft.abstract = location || "" + draft.body = description_en || description_de + draft.save! + end + end + + # Set a system user as author (use first admin user) + draft.user = User.where(admin: true).first + draft.editor = User.where(admin: true).first + draft.save! + + # Publish + node.publish_draft! + node.reload + + # Create primary event if rrule or start_time provided + if rrule || start_time + base_time = Time.parse("#{Date.today.year}-01-01 #{start_time || '19:00'}:00") + node.events.create!( + title: title_de, + location: location, + rrule: rrule, + start_time: base_time, + end_time: base_time + 2.hours, + is_primary: true + ) + end + + puts " Created: #{slug}#{review ? ' [needs review]' : ''}" +end + +puts "Seeding erfas..." + + +erfas = [ + { + slug: "erfa-aachen", + title_de: "CCC Aachen", + title_en: "CCC Aachen", + description_de: "Der CCC Aachen ist regelmäßig zu Themen- und offenen Abenden für Besucher*innen geöffnet. Unsere kleinen aber feinen Clubräume voller Plüschhaie liegen zwischen Hauptbahnhof und Stadtzentrum und sind aus beiden Richtungen in wenigen Minuten Fußweg zu erreichen (Schützenstraße 11, 52062 Aachen). Dank bunter LEDs sind sie besonders abends nicht zu übersehen.", + description_en: "CCC Aachen opens its doors regularly to themed and open evenings. Our small but cozy space full of plush sharks is located within a few minutes by foot from central station and the city center (Schützenstraße 11, 52062 Aachen). Thanks to colorful LEDs it's especially easy to find at night.", + external_url: "https://ccc.ac/", + location: "Schützenstraße 11, 52062 Aachen", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-bamberg", + title_de: "backspace e.V. Bamberg", + title_en: "backspace e.V. Bamberg", + description_de: "Der Bamberger Erfa-Kreis und Hackerspace ist der backspace e.V., ein Zusammenschluss von Menschen, die technische Grenzen überwinden wollen, Innovationen erproben und den freien Wissensaustausch befördern. Der backspace ist Thinktank, Werkstatt, Hackerspace, Freiraum, Zuhause, Labor und Impulsgeber. Es ist fast immer was los, besonders am Dienstag ab 19 Uhr im Spiegelgraben 41, 96052 Bamberg.", + description_en: "The CCC affiliated hackerspace backspace gathers people interested in technical innovation and free information exchange. It is a think tank, workshop, hackerspace, open space, home, laboratory and instigator. There is something going on every day, but most people meet at Spiegelgraben 41 in Bamberg on Tuesday, 7pm.", + external_url: "https://www.hackerspace-bamberg.de/", + location: "Spiegelgraben 41, 96052 Bamberg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-basel", + title_de: "CCC Basel", + title_en: "CCC Basel", + description_de: "Die Türen des CCC Basel sind jeden Dienstagabend ab 19:30 Uhr geöffnet. Uns findet man an der Birsfelderstrasse 6 in CH-4132 Muttenz (Außentreppe zum Kellereingang). Falls du mit der Tram 14 vorfährst, empfehlen wir dir die Haltestelle Käppeli.", + description_en: "CCC Basel opens its doors every Tuesday evening from 19:30. We are located at Birsfelderstrasse 6, 4132 Muttenz, Switzerland; just go down the outdoors stairway to the basement. If you arrive by public transit, we recommend taking tramway 14 to the stop Käppeli.", + external_url: "https://ccc-basel.ch/", + location: "Birsfelderstrasse 6, CH-4132 Muttenz", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:30", + review: false + }, + { + slug: "erfa-berlin", + title_de: "CCC Berlin – Club Discordia", + title_en: "CCC Berlin – Club Discordia", + description_de: "Der Club Discordia ist ein öffentliches Treffen in den Clubräumen des CCC Berlin (Marienstraße 11, 10117 Berlin-Mitte). Die Treffen finden jeden Dienstag und Donnerstag ab ca. 19 Uhr statt.", + description_en: "Club Discordia is a public meeting located at the CCC Berlin (Marienstr. 11, 10117 Berlin-Mitte). Meetings are held every Thursday at 5pm.", + external_url: "http://berlin.ccc.de/", + location: "Marienstraße 11, 10117 Berlin", + rrule: "FREQ=WEEKLY;BYDAY=TU,TH", + start_time: "19:00", + review: true + }, + { + slug: "erfa-bremen", + title_de: "CCC Bremen", + title_en: "CCC Bremen", + description_de: "Ein öffentliches Treffen des CCC Bremen findet jeweils dienstags ab 20 Uhr in der Zweigstraße 1 statt.", + description_en: "The public get together of CCC Bremen takes place every Tuesday at 8pm at Z1 (Zweigstraße 1, 28217 Bremen).", + external_url: "https://www.ccchb.de/", + location: "Zweigstraße 1, 28217 Bremen", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "erfa-chemnitz", + title_de: "CCC Chemnitz (ChCh)", + title_en: nil, + description_de: "Der Chaos Computer Club Chemnitz (ChCh) betreibt seit 2011 einen eigenen Hackspace in der Augustusburger Straße 102. Wir stehen allen technikinteressierten und kreativen Menschen offen und freuen uns immer über neue Gäste. Wir fühlen uns der Informationsfreiheit und der Aufklärung über die Auswirkungen aktueller Technologien auf die Gesellschaft verpflichtet. Trotzdem kommt bei uns auch der Spaß am Gerät nicht zu kurz.", + description_en: nil, + external_url: "https://chaoschemnitz.de", + location: "Augustusburger Straße 102, Chemnitz", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "erfa-darmstadt", + title_de: "CCC Darmstadt", + title_en: "CCC Darmstadt", + description_de: "Wir treffen uns jeden Dienstagabend ab 19 Uhr zum gemeinsamen Basteln, Diskutieren, Hacken, Nerden – eben einfach zum offenen Chaos – in unserem Hackspace in der Wilhelminenstraße 17, mitten in der Darmstädter Innenstadt. Aber auch an jedem anderen Abend ist in der Regel etwas los. Neben der Nutzung unserer Elektronikwerkstatt hast du zum Beispiel die Möglichkeit, bei unserem Capture-the-Flag-Team „Wizards of DoS“ reinzuschauen oder dich bei Freifunk Darmstadt zu engagieren. Aktuelle Termine und Neuigkeiten sowie den Türstatus gibt's auf chaos-darmstadt.de. Im IRC findest du uns unter #chaos-darmstadt auf hackint. Mailingliste: public<ät>lists.darmstadt.ccc.de. Schau doch einfach mal vorbei!", + description_en: "CCC Darmstadt meets Tuesdays from 8pm in their hackspace at Wilhelm-Leuschner-Strasse 36.", + external_url: "https://www.chaos-darmstadt.de/", + location: "Wilhelminenstraße 17, Darmstadt", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: true + }, + { + slug: "erfa-dortmund", + title_de: "CCC Dortmund", + title_en: nil, + description_de: "Der Erfa Dortmund ist ein Treffen von Dortmundern oder Leuten, die in der Dortmunder Umgebung wohnen (Unna, Holzwickede, Schwerte etc.). Wem kreativer Umgang mit Technik nicht fremd ist, ist herzlich zum Treff im Langen August in der Braunschweiger Straße 22 in Dortmund eingeladen. Treffen finden dienstags und donnerstags ab 19 Uhr (+1h Chaos-Verspätung) statt.", + description_en: nil, + external_url: "http://www.chaostreff-dortmund.de", + location: "Braunschweiger Straße 22, Dortmund", + rrule: "FREQ=WEEKLY;BYDAY=TU,TH", + start_time: "19:00", + review: false + }, + { + slug: "erfa-dresden", + title_de: "CCC Dresden (c3d2)", + title_en: "CCC Dresden (c3d2)", + description_de: "Die Chaoten aus dem ganzsächsischen bzw. südbrandenburgischen Raum treffen sich jeden Dienstag in Dresden (Details bitte jeweils per Jabber unter c3d2@muc.hq.c3d2.de erfragen). Darüberhinaus finden auch häufig, aber unregelmäßig Themenabende statt.", + description_en: "The geeks from Saxony and southern Brandenburg meet every Tuesday in Dresden (for details please ask via jabber at c3d2@muc.hq.c3d2.de). Furthermore there are occasional get-togethers for specific subjects.", + external_url: "http://www.c3d2.de/", + location: "Dresden", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-duesseldorf", + title_de: "Chaosdorf Düsseldorf", + title_en: "Chaosdorf Düsseldorf", + description_de: "Der Düsseldorfer Erfa (auch als Chaosdorf bekannt) betreibt einen Hackspace in der Sonnenstr. 58, der nahezu durchgehend geöffnet ist. Der Kennenlernabend, „Freitagsfoo“, findet jeden Freitag ab 18 Uhr statt.", + description_en: "CCC Düsseldorf (aka Chaosdorf) operates a hackspace in Sonnenstraße 58 that is open nearly 24/7. The best method for getting to know it is the \"Freitagsfoo\" event, taking place every Friday from 6pm.", + external_url: "https://chaosdorf.de/", + location: "Sonnenstraße 58, Düsseldorf", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "18:00", + review: false + }, + { + slug: "erfa-erlangen", + title_de: "Bits'n'Bugs e.V. Erlangen", + title_en: "Bits'n'Bugs e.V. Erlangen", + description_de: "Der Bits'n'Bugs e.V. trifft sich jeden Freitag ab 18 Uhr im ZAM, Hauptstr. 65-67, und zu weiteren unregelmäßigen Zeiten je nach Aktivitäten. Wir beteiligen uns außerdem regelmäßig an Veranstaltungen und Projekten des ZAM.", + description_en: "Bits'n'Bugs e.V. meets every Tuesday at 7:30pm in E-Werk Erlangen, Fuchsenwiese 1, group room 5.", + external_url: "http://erlangen.ccc.de/", + location: "Hauptstraße 65-67, Erlangen", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "18:00", + review: true + }, + { + slug: "erfa-essen", + title_de: "Chaospott Essen", + title_en: "Chaospott Essen", + description_de: "Der Chaospott ist die lokale Vertretung des CCC im Herzen des Ruhrgebiets. Wir treffen uns jeden Mittwoch ab 19 Uhr in der Sibyllastraße 9, 45136 Essen (Hofgebäude).", + description_en: "Chaospott is the local subsidiary of the CCC at the heart of the Ruhr area. We meet every Wednesday at 7pm in the »foobar«.", + external_url: "http://chaospott.de/", + location: "Sibyllastraße 9, 45136 Essen", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: true + }, + { + slug: "erfa-flensburg", + title_de: "CCC Flensburg", + title_en: nil, + description_de: "Deutschlands nördlichster Erfa trifft sich jeden Dienstag ab 18 Uhr in der Apenrader Straße 49, 24939 Flensburg. Bei unserem OpenSpace sind neue Gesichter immer willkommen! Folgt uns gerne auch auf Mastodon.", + description_en: nil, + external_url: "https://c3fl.de/", + location: "Apenrader Straße 49, 24939 Flensburg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "18:00", + review: false + }, + { + slug: "erfa-frankfurt", + title_de: "CCC Frankfurt am Main", + title_en: "CCC Frankfurt am Main", + description_de: "Wir treffen uns jeden Dienstag und Donnerstag (auch an den meisten Feiertagen) ab 19 Uhr in unserem Hackerspace, dem HQ. Dazu sind alle Interessierten jederzeit herzlich eingeladen.", + description_en: "We meet every Tuesday (even on most holidays) at 7pm in our hackspace the HQ. All interested people are welcome.", + external_url: "http://ccc-ffm.de/hackerspace/", + location: "Frankfurt am Main", + rrule: "FREQ=WEEKLY;BYDAY=TU,TH", + start_time: "19:00", + review: false + }, + { + slug: "erfa-freiburg", + title_de: "CCC Freiburg", + title_en: "CCC Freiburg", + description_de: "Der Chaos Computer Club Freiburg trifft sich montags und dienstags ab 19 Uhr sowie nach Lust und Laune in seinen Räumen in der Adlerstraße 12a, 79098 Freiburg. Plenum ist jede zweite Woche dienstags ab 20 Uhr.", + description_en: "Erfa Freiburg meets on Tuesdays at 7pm in their own room at ArTik, the former underpass at Siegesdenkmal (Kaiser-Joseph-Strasse 141, 79089 Freiburg).", + external_url: "http://cccfr.de", + location: "Adlerstraße 12a, 79098 Freiburg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: true + }, + { + slug: "erfa-fulda", + title_de: "Magrathea Laboratories e.V. Fulda", + title_en: nil, + description_de: "Der Magrathea Laboratories e.V. (mag.lab) ist die lokale Chaosmanifestation und Treffpunkt einiger Haecksen, Hacker und anderweitig Technikinteressierter in der Lindenstraße 14 in Fulda. Das Chaos steht allen immer freitags ab 19 Uhr offen.", + description_en: nil, + external_url: "https://maglab.space/", + location: "Lindenstraße 14, Fulda", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "19:00", + review: false + }, + { + slug: "erfa-goettingen", + title_de: "CCC Göttingen", + title_en: "CCC Göttingen", + description_de: "Der Erfa-Kreis Göttingen wurde im November 2007 von Hackern gegründet, die sich dem Chaos Computer Club nahefühlen. Open Chaos findet jeden zweiten Dienstag ab 20 Uhr im Neotopia (Von-Bar-Straße 2-4, Keller des MLP-Hauses) statt. Interessierte sind herzlich willkommen.", + description_en: "Erfa Göttingen was founded Nov 2007 by hackers close to the Chaos Computer Club. Open Chaos is every Tuesday from 8pm at NOKLAB (Neustadt 7, Innenstadt). All interested people are welcome.", + external_url: "http://www.chaostreff-goettingen.de/", + location: "Von-Bar-Straße 2-4, Göttingen", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", + start_time: "20:00", + review: true + }, + { + slug: "erfa-hamburg", + title_de: "CCC Hamburg", + title_en: "CCC Hamburg", + description_de: "Der Hamburger Erfa-Kreis trifft sich in der Viktoria-Kaserne (1. Stock, Ostflügel), Zeiseweg 9, 22765 Hamburg. Der zweite Freitag und der letzte Dienstag im Monat sind perfekt zum Kennenlernen und Fragen stellen, weitere Termine finden sich auf dem Kalender des Erfa Hamburg, der mit öffentlichen Veranstaltungen gefüllt ist.", + description_en: "The Erfakreis Hamburg meets at Viktoria-Kaserne, room 119 (1st floor, east wing) Zeiseweg 9, 22765 Hamburg. Every second Friday and last Tuesday of the month are great opportunities to meet people and ask questions. We also have a calendar filled with events open for visitors.", + external_url: "http://hamburg.ccc.de/", + location: "Zeiseweg 9, 22765 Hamburg", + rrule: "FREQ=MONTHLY;BYDAY=2FR", + start_time: "19:00", + review: false + }, + { + slug: "erfa-hannover", + title_de: "CCC Hannover", + title_en: "CCC Hannover", + description_de: "Das regionale Chaos in Hannover trifft sich jeden Mittwoch ab 19 Uhr in der Bürgerschule im Clubraum (Raum 3.1) im Stadtteilzentrum Nordstadt.", + description_en: "The regional Chaos in Hannover meets every second Wednesday of the month from 8pm and on the last Sunday of the month from 4pm at the Bürgerschule in their clubroom (room 3.1) in the community center Nordstadt.", + external_url: "https://hannover.ccc.de/", + location: "Bürgerschule, Raum 3.1, Hannover", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: true + }, + { + slug: "erfa-kaiserslautern", + title_de: "Chaos inKL. e.V. Kaiserslautern", + title_en: nil, + description_de: "Der Chaos inKL. e.V. ist die Kaiserslauterner Niederlassung des Chaos Computer Clubs. Unsere öffentlichen Veranstaltungen sind die Hacknight (der samstägliche Basteltreff ab 19 Uhr), das monatliche Seminar und das monatliche Kneipentreffen.", + description_en: nil, + external_url: "http://www.chaos-inkl.de/", + location: "Kaiserslautern", + rrule: "FREQ=WEEKLY;BYDAY=SA", + start_time: "19:00", + review: false + }, + { + slug: "erfa-karlsruhe", + title_de: "Entropia e.V. Karlsruhe", + title_en: "Entropia e.V. Karlsruhe", + description_de: "Der Erfa-Kreis Karlsruhe ist ein eingetragener Verein mit dem Namen Entropia. Die öffentlichen Treffen finden jeden Samstag ab 19:30 Uhr in den Räumen des Erfa Karlsruhe (Gewerbehof, Steinstraße 23) statt und richten sich an alle aus Karlsruhe und dem Umland.", + description_en: "Erfa Karlsruhe is a registered club with the name 'Entropia'. The public meetings take place every Sunday from 7:30pm at our club (Gewerbehof, Steinstr. 23) and targets people from Karlsruhe and surrounding region.", + external_url: "https://entropia.de/", + location: "Steinstraße 23, Karlsruhe", + rrule: "FREQ=WEEKLY;BYDAY=SA", + start_time: "19:30", + review: true + }, + { + slug: "erfa-kassel", + title_de: "flipdot e.V. Kassel", + title_en: "flipdot e.V. Kassel", + description_de: "flipdot e.V. hackerspace kassel ist der lokale Erfa-Kreis – ein lebendiger Ort mit viel Platz zum Bausteln und Coden. Es gibt gut ausgestattete Werkstatträume, Vortrags- und Kinoraum und eine Küche mit Profi-Pizzaofen. Im flipdot wird sehr oft zusammen gekocht und gegessen. flipdot ist seit 2009 Brutstätte neuer Ideen, Wohnzimmer, anarchistische Volkshochschule, Coder-Cave und umtriebige Werkstatt. Offen für Besucher jeden Dienstag ab 19 Uhr, Schillerstraße 25, 34117 Kassel.", + description_en: "flipdot e.V. hackerspace kassel is the local Erfa circle – a lively place with plenty of space for building and coding. There are well-equipped workshop rooms, a lecture and cinema room, and a kitchen with a professional pizza oven. At flipdot, people often cook and eat together. Since 2009, flipdot has been a breeding ground for new ideas, a living room, an anarchist adult education center, a coder's cave, and a bustling workshop. Open to visitors every Tuesday from 7pm, Schillerstraße 25, 34117 Kassel.", + external_url: "http://kassel.ccc.de/", + location: "Schillerstraße 25, 34117 Kassel", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-koeln", + title_de: "c4 Köln", + title_en: "c4 Cologne", + description_de: "Der c4 ist der westliche Brückenkopf des innovativen Technologieeinsatzes mit allen Features, die zum Chaos gehören. Öffentliche Treffen gibt es jeden Donnerstag ab 19:30 Uhr im Chaoslabor in Köln-Ehrenfeld, an jedem letzten Donnerstag im Monat gibt es ein OpenChaos als Vortragsrahmenprogramm.", + description_en: "The c4 is a westward bridge head of the innovative technology usage with all features that are necessary for chaos. The public meeting is called OpenChaos and takes place on the last Thursday of the month at 19:30 in the Chaoslabor in Cologne-Ehrenfeld.", + external_url: "http://koeln.ccc.de/", + location: "Köln-Ehrenfeld", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:30", + review: false + }, + { + slug: "erfa-leipzig", + title_de: "dezentrale e.V. Leipzig", + title_en: nil, + description_de: "Der dezentrale e.V. vertritt als Erfa das lokale Chaos in Leipzig. Wir bieten einen Anlaufpunkt für Softwarenerds, Künstler:innen, Hardwareschraubende und -löter:innen und alle chaosnahen Themen. Infos über uns findest Du unter dezentrale.space. Unser Vernetzungsabend ist der Chaostreff jeden Freitag ab 19:00.", + description_en: nil, + external_url: "http://dezentrale.space", + location: "Leipzig", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "19:00", + review: false + }, + { + slug: "erfa-luebeck", + title_de: "Chaotikum e.V. Lübeck", + title_en: "Chaotikum e.V. Lübeck", + description_de: "Der Lübecker Erfa ist der Chaotikum e.V., welcher seit 2012 den Hackspace Nobreakspace betreibt. Dort treffen sich seitdem technikinteressierte Menschen, um an diversen Projekten zu arbeiten, über Themen zu reden, die uns beschäftigen, und vor allem um Spaß zu haben. Open Space ist immer Mittwochs ab 19:00 Uhr.", + description_en: "The Lübeck Hackspace group is Chaotikum e.V., which has been running the Nobreakspace hackspace since 2012. Since then, tech enthusiasts have been meeting there to work on various projects, discuss topics that interest them, and above all, have fun. Open Space is every Wednesday from 7:00 PM.", + external_url: "https://chaotikum.org", + location: "Lübeck", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "erfa-mainz-wiesbaden", + title_de: "CCC Mainz/Wiesbaden", + title_en: "CCC Mainz/Wiesbaden", + description_de: "Das Wiesbadener Chaos trifft sich jeden Dienstag ab 19 Uhr in seinen Räumen am Sedanplatz 7 in Wiesbaden. Die Treffen richten sich an alle aus Mainz/Wiesbaden und dem nahen Umland.", + description_en: "The Chaos Computer Club Mainz meets every Tuesday, 7pm, at Sedanplatz 7 in Wiesbaden. The meetup is addressed to everyone from Mainz/Wiesbaden and the near surroundings.", + external_url: "http://www.cccmz.de", + location: "Sedanplatz 7, Wiesbaden", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "erfa-mannheim", + title_de: "CCC Mannheim", + title_en: "CCC Mannheim", + description_de: "Der Erfa-Kreis Mannheim ist eine Anlaufstelle für Computer- und Technikinteressierte, die Gleichgesinnte suchen. Hier kann man sich austauschen, seine Ideen präsentieren und diskutieren. Unsere öffentlichen Treffen finden jeden Freitag ab 19 Uhr statt. Die Termine stehen in unserem Wiki.", + description_en: "Erfa Mannheim is a local contact point for people that are interested in computer and technology, who search for like minded people. You can exchange, present and discuss your ideas. Our public meeting takes place every Friday.", + external_url: "http://www.ccc-mannheim.de", + location: "Mannheim", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "19:00", + review: false + }, + { + slug: "erfa-muenchen", + title_de: "µC³ München", + title_en: "µC³ Munich", + description_de: "Die öffentlichen Treffen des µC³ finden am zweiten Dienstag jeden Monats ab ca. 20 Uhr im Club in der Schleißheimer Straße 39 (Ecke Heßstraße 90) statt. Dies ist natürlich auch ein Ort, um jederzeit mit jemandem des Münchner CCCs ins Gespräch zu kommen.", + description_en: "The public meetup of the µC³ takes places every second Tuesday of the month, starting at about 8pm at Schleißheimer Str. 39 (corner to Heßstraße 90).", + external_url: "https://muc.ccc.de/", + location: "Schleißheimer Straße 39, München", + rrule: "FREQ=MONTHLY;BYDAY=2TU", + start_time: "20:00", + review: false + }, + { + slug: "erfa-offenburg", + title_de: "Section77 e.V. Offenburg", + title_en: nil, + description_de: "Der lockere Treff von Section77 e. V. für alle Chaos-Interessierten aus dem Raum Offenburg findet jeden Dienstag ab 20 Uhr im Hackspace statt. Dieser befindet sich in der Hauptstraße 1 in Offenburg (direkt im Bahnhofsgebäude). Gäste sind immer willkommen.", + description_en: nil, + external_url: "https://section77.de", + location: "Hauptstraße 1, Offenburg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "erfa-paderborn", + title_de: "CCC Paderborn (subraum)", + title_en: "CCC Paderborn", + description_de: "Wir treffen uns immer mittwochs in unserem Hackerspace \"subraum\" in der Westernmauer 12-16.", + description_en: "We meet up Wednesdays at the pottery in the \"Kulturwerkstatt\". Our move to our new rooms in Westernmauer 12 is imminent.", + external_url: "https://www.c3pb.de/", + location: "Westernmauer 12-16, Paderborn", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: true + }, + { + slug: "erfa-salzburg", + title_de: "Erfa Salzburg", + title_en: nil, + description_de: "Der Erfa Salzburg ist eine als eingetragener Verein organisierte und trotzdem lockere Runde von Leuten, die sich mit dem Chaos Computer Club e.V. eng verbunden fühlen. Wir versuchen, einen Blick hinter die Kulissen zu werfen und viele Dinge zu hinterfragen und zu diskutieren.", + description_en: nil, + external_url: "http://sbg.chaostreff.at/", + location: "Salzburg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "erfa-siegen", + title_de: "Chaos Siegen", + title_en: nil, + description_de: "Chaos Siegen besteht aus einer Handvoll freundlicher und offener Menschen mit einer Leidenschaft für Netzpolitik, dem Chaos Computer Club im Hintergrund und gestrandet im Hackspace Siegen durch die Wirren unserer Galaxie. #tuwat und komm vorbei!", + description_en: nil, + external_url: "https://c3si.de/", + location: "Siegen", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "erfa-stralsund", + title_de: "Port39 e.V. Stralsund", + title_en: "Port39 e.V. Stralsund", + description_de: "Als erster Erfa in Mecklenburg-Vorpommern sorgen wir als Port39 e.V. in Stralsund und Umgebung für eine ordentliche Portion Chaos. Mit Chaos macht Schule, Vorträgen und Workshops, Hacking-Sessions, Löt-Workshops, RepairCafés und vielem mehr wollen wir Jung und Alt für Technik, IT und allem, was dazu gehört, begeistern, und mit diversen Projekten im eigenen Hackerspace mal mehr, mal weniger sinnvolle Dinge anstellen. Kommt gerne rum oder schaut auf unserer Website oder auf Mastodon vorbei. Definitiv da sind wir jeden Donnerstag zum Chaostreff ab 19 Uhr und jeden 2. & 4. Samstag ab 14 Uhr zum OpenSpace.", + description_en: "As the first Erfa in Mecklenburg-Western Pomerania, we at Port39 e.V. make sure that there's a healthy dose of chaos in Stralsund and the surrounding area. Through \"Chaos macht Schule\" events, lectures, workshops, hacking sessions, soldering workshops, Repair Cafés, and much more, we aim to inspire people of all ages to get excited about technology, IT, and everything that goes with it. Feel free to drop by or check out our website or Mastodon. We're definitely there every Thursday for the Chaos Meetup starting at 7pm and every 2nd & 4th Saturday starting at 2pm for OpenSpace.", + external_url: "https://port39.de", + location: "Stralsund", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "erfa-stuttgart", + title_de: "CCC Stuttgart", + title_en: "CCC Stuttgart", + description_de: "Der Chaos Computer Club Stuttgart e.V. trifft sich jeden ersten Dienstag im Monat im Lichtblick in der Stadtmitte (Reinsburgstraße 13) ab 18:30 Uhr und jeden dritten Mittwoch im Monat im Shackspace (Ulmer Straße 255) ab 18:30 Uhr. Im Shackspace kann Bastelhardware gerne mitgebracht werden. Jeden zweiten Donnerstag im Monat haben wir unsere Vortragsreihe in der Stadtbibliothek Stuttgart am Mailänder Platz.", + description_en: "Der Chaos Computer Club Stuttgart e.V. meets every first Tuesday of the month at the Zadu-Bar (Reuchlinstraße 4b) at 6:30pm and every third Wednesday of the month at the Shackspace (Ulmer Straße 255). Our monthly Talk is every second Thursday of the month in the public library of Stuttgart at the Mailänder Platz at 7:30pm.", + external_url: "https://www.cccs.de/", + location: "Stuttgart", + rrule: "FREQ=MONTHLY;BYDAY=1TU", + start_time: "18:30", + review: true + }, + { + slug: "erfa-ulm", + title_de: "Chaostreff Ulm", + title_en: "Chaostreff Ulm", + description_de: "Der Chaostreff Ulm findet jeden Montag ab 19:30 Uhr im Café Einstein an der Uni Ulm statt, außer jeden zweiten Montag des Monats. Dieser ist dem Chaosseminar vorbehalten.", + description_en: "The Chaostreff Ulm takes place every Monday at 8:00pm in the Freiraum (3rd floor, Platzgasse 18, Ulm) at Hackerspace Ulm e.V. In addition, the talk series Chaos seminar on various topics is held there every second Monday of the month.", + external_url: "http://www.ulm.ccc.de/", + location: "Café Einstein, Uni Ulm", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "19:30", + review: true + }, + { + slug: "erfa-unna", + title_de: "CCC Unna (UN-Hack-Bar)", + title_en: "CCC Unna (UN-Hack-Bar)", + description_de: "Der CCC Unna trifft sich für gewöhnlich jeden Donnerstag ab ca. 19 Uhr in den Räumen der UN-Hack-Bar. Dort quatschen wir über allerlei Dinge wie z. B. Netzpolitik, Politik im Allgemeinen, Computer & Technik, aber auch schonmal darüber, wie man einen brennenden Feuerlöscher löscht oder das neueste Internet-Meme. ;-) Natürlich wird auch gebastelt, gebaut und im besten Sinne gehackt. Gäste aller Couleur sind gern gesehen.", + description_en: "The CCC Unna usually meets every Thursday at around 7pm in the rooms of the UN-Hack-Bar. We chat about all kinds of topics: from net politics and politics in general to computers and technology, and sometimes even about odd questions like how to extinguish a burning fire extinguisher or the latest internet meme. ;-) Of course, we also tinker, build, and hack things in the best possible sense. Guests of all kinds are very welcome.", + external_url: "https://www.un-hack-bar.de/ccc-unna/", + location: "Unna", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "erfa-wien", + title_de: "CCC Wien", + title_en: "CCC Vienna", + description_de: "Der Chaos Computer Club Wien trifft sich einmal im Monat, meistens im Metalab. Details zu den Treffen werden auf der Webseite des C3W bekanntgegeben. Für Fragen und allgemeine Announcements stehen die öffentliche C3W-Mailingliste oder @C3Wien@chaos.social bereit. Komm vorbei!", + description_en: "The Chaos Computer Club Vienna meets once a month. Usually at Metalab. Find details on our website. Use the public mailinglist or @C3Wien@chaos.social to ask questions or to receive announcements. Come by!", + external_url: "https://c3w.at/", + location: "Metalab, Wien", + rrule: "FREQ=MONTHLY", + start_time: "19:00", + review: false + }, + { + slug: "erfa-wuerzburg", + title_de: "Nerd2Nerd Würzburg", + title_en: nil, + description_de: "Nerd2Nerd trifft sich jeden Donnerstag ab 18 Uhr im FabLab Würzburg.", + description_en: nil, + external_url: "http://nerd2nerd.org/", + location: "FabLab Würzburg", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "18:00", + review: false + }, + { + slug: "erfa-zuerich", + title_de: "CCCZH Zürich", + title_en: "CCCZH Zurich", + description_de: "Der CCCZH ist Teil des Hackerspace bitwäscherei. Von Züri Hardbrücke aus einen halben Katzensprung in die Zentralwäscherei Zürich an der Neuen Hard 12.", + description_en: "The Chaostreff Zurich meets every Wednesday from 7pm at the Hackerspace at Luegislandstrasse 485 in Zürich/Schwamendingen.", + external_url: "https://www.ccczh.ch/", + location: "Neue Hard 12, Zürich", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: true + } +] + +erfas.each do |entry| + seed_chapter(parent_id: 548, tag: "erfa-detail", **entry) +end + +puts "Seeding chaostreffs..." + +chaostreffs = [ + { + slug: "chaostreff-aalen", + title_de: "Chaostreff Aalen", + title_en: nil, + description_de: "Wir sind ein Chaostreff und Hackspace in Aalen im Aaccellerator, Blezingerstraße 15, 73430 Aalen. Wir treffen uns regelmäßig jeden 3. Dienstag im Monat ab 18:30 Uhr. Für Treffen außerhalb von den Regelterminen verabreden wir uns spontan über Matrix.", + description_en: nil, + external_url: "https://aalen.space/", + location: "Blezingerstraße 15, 73430 Aalen", + rrule: "FREQ=MONTHLY;BYDAY=3TU", + start_time: "18:30", + review: false + }, + { + slug: "chaostreff-alzey", + title_de: "Chaostreff Alzey", + title_en: nil, + description_de: "An jedem ersten Sonntag des Monats trifft sich der Chaostreff Alzey um 15 Uhr im Juku Alzey. All creatures welcome!", + description_en: nil, + external_url: "https://chaostreff-alzey.de/", + location: "Juku Alzey", + rrule: "FREQ=MONTHLY;BYDAY=1SU", + start_time: "15:00", + review: false + }, + { + slug: "chaostreff-amberg-sulzbach", + title_de: "Chaostreff Amberg-Sulzbach", + title_en: nil, + description_de: "Wir treffen uns monatlich in Amberg in lockerer Runde. Es wird keine Anmeldung benötigt. All Creatures Welcome! Ort und Zeit der Treffen findet ihr auf der Webseite des Chaostreffs.", + description_en: nil, + external_url: "https://amborg-sulzbyte.de/", + location: "Amberg", + rrule: "FREQ=MONTHLY", + start_time: nil, + review: false + }, + { + slug: "chaostreff-amsterdam", + title_de: "Chaos Amsterdam", + title_en: "Chaos Amsterdam", + description_de: "Chaos Amsterdam meets on Thursdays every other week at 19:00 in a social space close to the city center. We can be found on IRC in #chaosamsterdam on the hackint network.", + description_en: "Chaos Amsterdam meets on Thursdays every other week at 19:00 in a social space close to the city center. We can be found on IRC in #chaosamsterdam on the hackint network.", + external_url: "https://chaos.amsterdam/", + location: "Amsterdam", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-andernach", + title_de: "haxko e.V. Andernach", + title_en: nil, + description_de: "Der Hacker- und Makerspace Mayen-Koblenz (haxko e.V.) befindet sich in der ehemaligen Gastwirtschaft des Bahnhofs Andernach. Unsere Treffen finden in geraden Kalenderwochen freitags und in ungeraden samstags statt. Beginn immer ab 18 Uhr. Thematisch sind wir offen und durch die Location direkt am Bahnhof gut zu erreichen.", + description_en: nil, + external_url: "https://haxko.space", + location: "Bahnhof Andernach", + rrule: "FREQ=WEEKLY;BYDAY=FR", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-augsburg", + title_de: "OpenLab Augsburg", + title_en: nil, + description_de: "Aus dem Chaostreff Augsburg entstanden ist das OpenLab heute der Anlaufpunkt für alle die an nachhaltigem Handeln & Herstellen, dem kreativem Umgang mit Technik, Datenschutz und digitaler Selbstbemächtigung interessiert sind. Jeden Donnerstag offen für Interessierte und jeden dritten Donnerstag auch mit Vortrag im bekannten Chaostreff-Stil.", + description_en: nil, + external_url: "https://openlab-augsburg.de", + location: "Augsburg", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-aschaffenburg", + title_de: "Makerspace Schaffenburg", + title_en: nil, + description_de: "Der Chaostreff im Makerspace Schaffenburg ist ein Zusammenschluss von technikbegeisterten Menschen, die zusammen in (A)schaffenburg einen Ort schaffen, der genau dies bietet: Platz und Werkzeug zum Arbeiten, motivierte Menschen mit Know-How, kreative Ideen und Inspiration. Kurz gesagt: Raum für alle, die etwas machen wollen – ein Makerspace! Zu finden sind wir in der Dorfstraße 1 in Aschaffenburg.", + description_en: nil, + external_url: "http://www.schaffenburg.org", + location: "Dorfstraße 1, Aschaffenburg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-backnang", + title_de: "Chaostreff Backnang", + title_en: nil, + description_de: "Wir treffen uns an jedem 3. Sonntag des Monats zum Erfahrungsaustausch und der gemeinsamen Umsetzung von Projekten sowie an jedem 1. Dienstag im Monat zum offenen Stammtisch im dasWohnzimmer (Willy-Brandt-Platz 2) in Backnang bei Stuttgart und freuen uns auf neue Gesichter. Strom, Freifunk sowie Getränke und Snacks der Bar (auch Mate) werden angeboten.", + description_en: nil, + external_url: "https://chaostreff-backnang.de/", + location: "Willy-Brandt-Platz 2, Backnang", + rrule: "FREQ=MONTHLY;BYDAY=3SU", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-bayreuth", + title_de: "Imaginärraum Bayreuth", + title_en: nil, + description_de: "Der Imaginärraum ist ein junger Hackerspace, der sich jeden Montag um 19 Uhr in seinen Räumen in der Schulstraße 7 in Bayreuth trifft.", + description_en: nil, + external_url: "https://imaginaerraum.de/", + location: "Schulstraße 7, Bayreuth", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-bern", + title_de: "Chaostreff Bern", + title_en: nil, + description_de: "Der Chaostreff Bern trifft sich jeden Dienstag ab 19 Uhr im eigenen Hackerspace an der Zwyssigstrasse 45. Dort gibt es Strom, Internet, Mate und viel Platz zum Hacken.", + description_en: nil, + external_url: "https://www.chaostreffbern.ch/", + location: "Zwyssigstrasse 45, Bern", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-bielefeld", + title_de: "Hackerspace Bielefeld", + title_en: nil, + description_de: "Bielefeld gibt's gar nicht? Weiß man nicht. Gibt es denn einen Chaostreff? Auch das ist ungewiss, aber chaosnahe Leute treffen sich im Hackerspace Bielefeld.", + description_en: nil, + external_url: "http://hackerspace-bielefeld.de/", + location: "Bielefeld", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-bingen", + title_de: "/bin/hacken Bingen", + title_en: nil, + description_de: "Seit Anfang 2019 sind wir /bin/hacken, ein Hacker- & Makerspace in Bingen am Rhein. Wir bieten wöchentliche Treffen und eine offene Werkstatt zum Mitgestalten oder einfach eigene Projekte verwirklichen!", + description_en: nil, + external_url: "https://binhacken.de/", + location: "Bingen am Rhein", + rrule: "FREQ=WEEKLY", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-bochum", + title_de: "Das Labor Bochum", + title_en: nil, + description_de: "Das Labor, Hackspace und Chaostreff, ist in erster Linie ein Ort, an dem praktisch gearbeitet wird. Wir benutzen und entwickeln freie Software, löten, ätzen und programmieren Mikrocontroller, beschäftigen uns mit 3D-Druck, Freifunk, Amateurfunk, IT-Sicherheit, Arduinos, OSM oder Open Science. Wir haben den Anspruch, mit Technologie Neues und Sinnvolles zu erschaffen. Im Labor gibt es Vorträge, Workshops und Diskussionen zu den unterschiedlichsten Bereichen der Technik.", + description_en: nil, + external_url: "https://das-labor.org", + location: "Bochum", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-bonn", + title_de: "Datenburg e.V. Bonn", + title_en: nil, + description_de: "Der Datenburg e.V. ist ein seit 2019 existierender Hackspace und Chaostreff in Bonn und seit Anfang 2024 ein eingetragener Verein. Wir beschäftigen uns mit Themen rund um Technik und Gesellschaft. Die Datenburg ist auch ein Ort, um an eigenen Projekten zu arbeiten und sich auszutauschen. Wir öffnen jeden Dienstag ab 20 Uhr unsere Tore für alle interessierten Wesen.", + description_en: nil, + external_url: "https://datenburg.org/", + location: "Bonn", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-budapest", + title_de: "H.A.C.K. Budapest", + title_en: "H.A.C.K. Budapest", + description_de: "Members of the Hungarian Autonomous Center for Knowledge (H.A.C.K.) usually meet on Tuesdays at 19:00 local time, but it's best to confirm beforehand on #hspbp at IRCnet. We're located in the middle of the city, and we have Mate, sticker exchange, Internet, and friendly hackers.", + description_en: "Members of the Hungarian Autonomous Center for Knowledge (H.A.C.K.) usually meet on Tuesdays at 19:00 local time, but it's best to confirm beforehand on #hspbp at IRCnet. We're located in the middle of the city, and we have Mate, sticker exchange, Internet, and friendly hackers.", + external_url: "https://hsbp.org/contact-us", + location: "Budapest", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-coburg", + title_de: "Hackzogtum Coburg", + title_en: nil, + description_de: "Coburgs Chaostreff/Hackspace ist das Hackzogtum Coburg. Bei uns stehen vor allem Spaß an der Technik und reger Austausch im Vordergrund. Wer vorbeischauen will, ist bei uns immer willkommen. Ihr findet uns physikalisch in der Heiligkreuzstr. 3 in 96450 Coburg. Die meisten Leute gleichzeitig treffen sich immer dienstags ab 20 Uhr.", + description_en: nil, + external_url: "https://hackzogtum-coburg.de", + location: "Heiligkreuzstr. 3, 96450 Coburg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-cottbus", + title_de: "Chaostreff Cottbus", + title_en: nil, + description_de: "Der Treff für alle Chaos-Interessierten aus Cottbus und Umgebung. Angesiedelt am FabLab Cottbus (Walther-Pauer-Straße 7), treffen wir uns regelmäßig am letzten Mittwoch im Monat ab 18 Uhr und freuen uns auf neue Gesichter.", + description_en: nil, + external_url: "https://chaos-cb.de", + location: "Walther-Pauer-Straße 7, Cottbus", + rrule: "FREQ=MONTHLY;BYDAY=-1WE", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-eisenach", + title_de: "WAK-Lab Eisenach", + title_en: nil, + description_de: "Das WAK-Lab ist ein Raum für Hacker, Bastler und technikinteressierte Menschen. Unsere Ziele sind der Aufbau einer offenen Werkstatt, um den Erwerb und das Teilen von technischem Wissen zu fördern. Kontakt über Matrix oder auf unserer Homepage.", + description_en: nil, + external_url: "https://wak-lab.org", + location: "Eisenach", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-erfurt", + title_de: "Bytespeicher Erfurt", + title_en: nil, + description_de: "Der Hackspace Bytespeicher öffnet jeden Mittwoch zum Open Space und trifft sich ab 19 Uhr zum Chaostreff Erfurt in der Liebknechtstraße 8. Wir bieten mit Hackspace, Konferenzraum und Elektronikwerkstatt alle Möglichkeiten zum gemeinsamen Hacken, Lernen, Präsentieren und Experimentieren. Wir unterstützen außerdem Freifunk Erfurt und die Programmier-Initiative Kids@Digital.", + description_en: nil, + external_url: "https://www.bytespeicher.org/", + location: "Liebknechtstraße 8, Erfurt", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-giessen", + title_de: "Chaostreff Gießen", + title_en: nil, + description_de: "Lockerer Treff für alle Chaos-Interessierten aus dem Raum Gießen. Zur Zeit beherbergt uns am ersten Mittwoch des Monats das Jhrings Wirtsstuben (Ludwigstraße 10) ab 19 Uhr. An den anderen Mittwochen des Monats treffen wir uns in den Räumlichkeiten von Mudbytes. Um Verwechslungen vorzubeugen, schaut bitte unter giessen.ccc.de nach dem aktuellen Stand.", + description_en: nil, + external_url: "https://giessen.ccc.de", + location: "Ludwigstraße 10, Gießen", + rrule: "FREQ=MONTHLY;BYDAY=1WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-graz", + title_de: "RealRaum Graz", + title_en: nil, + description_de: "Grundsätzlich ist bei den Workshops und sonstigen Veranstaltungen immer jeder willkommen, der Lust hat, was Neues dazuzulernen oder selbst was dazu beizutragen, egal ob Mitglied oder nicht. Ziel ist es, einen chaotisch gemischten Haufen zu schaffen, der fähig ist, sich mit seinen Ideen gegenseitig zu inspirieren. Besonders sind alle willkommen, die bereits bei anderen Vereinen/Initiativen tätig sind (LUGG, STG, Spektral und ähnliches). Die Treffen finden im RealRaum statt.", + description_en: nil, + external_url: "https://realraum.at/", + location: "Graz", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-goeppingen", + title_de: "Chaostreff Göppingen (SCHAFFEREI)", + title_en: nil, + description_de: "Spaß an der Technik haben, kreativ sein, Altes reparieren, Neues generieren, Wissen teilen – gemeinsam macht das Chaos Laune. Der Chaostreff Göppingen findet monatlich in der Göppinger SCHAFFEREI statt. Freifunk, Lötkolben und Mate sind vorhanden.", + description_en: nil, + external_url: "https://schafferei.org/chaos-treff/", + location: "Göppingen", + rrule: "FREQ=MONTHLY", + start_time: nil, + review: false + }, + { + slug: "chaostreff-gunzenhausen", + title_de: "Chaostreff Gunzenhausen", + title_en: nil, + description_de: "Jeden Dienstag in einer ungeraden Kalenderwoche treffen wir uns ab 19 Uhr im FabLab in Gunzenhausen. Gäste und Interessierte sind gerne willkommen!", + description_en: nil, + external_url: "https://chaostreff-gun.de/", + location: "FabLab Gunzenhausen", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-halle", + title_de: "Chaostreff Halle (Saale)", + title_en: nil, + description_de: "Der Chaostreff trifft sich wöchentlich am Dienstag ab 19 Uhr im Eigenbaukombinat zum Hackerspacetreffen in der Landsberger Straße 3. Reden, tüfteln, hacken inklusive.", + description_en: nil, + external_url: "https://eigenbaukombinat.de", + location: "Landsberger Straße 3, Halle (Saale)", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-hamm", + title_de: "c3hamm", + title_en: nil, + description_de: "Wir, c3hamm, sind seit dem 5.5.2025 Verein. Bauen gerade am HHiC, ein Hackspace & Repair-Cafe zum Mitnehmen im Container. Wir treffen uns regelmässig am 1ten Mittwoch im Monat im Rahmen des eStatischH (Energier-Stammtisch Hamm) VorOrt. Nicht selten treffen wir uns Sonntags zum VRunch inner Brille und erkunden SocialVR Räume, pflegen ein kleines cHaoS-Log: y.lab.nrw/c3h-logs.", + description_en: nil, + external_url: "https://chaos.social/@c3hamm", + location: "Hamm", + rrule: "FREQ=MONTHLY;BYDAY=1WE", + start_time: nil, + review: false + }, + { + slug: "chaostreff-heidelberg", + title_de: "NoName e.V. Heidelberg", + title_en: nil, + description_de: "Wir sind der NoName e.V., ein lustiger zusammengewürfelter Haufen, der sich mindestens einmal pro Woche trifft. Jeder Technikinteressierte ist bei uns gerne gesehen. Der regelmäßige Treff am Donnerstag findet abwechselnd an verschiedenen Orten statt. Infos über spontane Treffen findet man im TWiCEiRC-Channel #chaos-hd.", + description_en: nil, + external_url: "https://www.noname-ev.de", + location: "Heidelberg", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-hildesheim", + title_de: "Freies Labor Hildesheim", + title_en: nil, + description_de: "Der Hackerspace „Freies Labor“ ist ein Ort, an dem sich unterschiedliche Disziplinen treffen, kennenlernen und kooperieren. In unserem Labor wollen wir in gemütlicher Atmosphäre gemeinsam tüfteln, kochen, brauen und entdecken. Wir wollen unsere Fähigkeiten und unser Wissen miteinander teilen und stellen Werkstatt, Küche und gemütliche Räume zur Verfügung.", + description_en: nil, + external_url: "https://blog.freieslabor.org/", + location: "Hildesheim", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-hilpoltstein", + title_de: "Chaostreff Hip Hilpoltstein", + title_en: nil, + description_de: "Jeden zweiten Dienstag (ungerade Kalenderwochen) trifft sich der Chaostreff Hip ab 18 Uhr zum gemeinsamen Tüfteln, Reden und Verkosten von Mate im Haus des Gastes (Maria-Dorothea-Straße 8). Interessierte Gäste sind immer herzlich willkommen.", + description_en: nil, + external_url: "https://chaos-hip.de/", + location: "Maria-Dorothea-Straße 8, Hilpoltstein", + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-hoher-flaeming", + title_de: "ge.hackt.es Hoher Fläming", + title_en: nil, + description_de: "In Brandenburg, mitten im Wald, gibt es ge.hackt.es! In unserem Hackspace im Hohen Fläming beschäftigen wir uns nicht ganz zufällig mit Digitalisierung im ländlichen Raum. Wir basteln außerdem Schönes mit Sensoren und bringen ein Repair-Café an den Start. Schreibt uns, wenn ihr den Weg durch den Wald zu uns finden wollt!", + description_en: nil, + external_url: "https://ge.hackt.es/", + location: "Hoher Fläming, Brandenburg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-ilmenau", + title_de: "ilmspace Ilmenau", + title_en: nil, + description_de: "Wir treffen uns regelmäßig jeden Mittwoch ab 19 Uhr zum open space und einmal im Monat zum Orgatreff. Der Space bietet Raum für alle Chaos-Interessierten aus Ilmenau und Umgebung. Wir helfen euch gern bei Reparaturen, beim Einstieg oder Umstieg in die Linuxwelt oder bei eigenen Projekten. Ihr seid herzlich eingeladen, aktiv unseren Space mitzugestalten. Kontakt via Matrix.", + description_en: nil, + external_url: "http://ilmspace.de/", + location: "Ilmenau", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-ingolstadt", + title_de: "bytewerk Ingolstadt", + title_en: nil, + description_de: "Am 10. April 2010 wurde in Ingolstadt das bytewerk eröffnet, der Hackerspace des Ingolstädter Chaostreff, der aus dem bingo e.V. hervorgegangen ist.", + description_en: nil, + external_url: "http://www.bytewerk.org/", + location: "Ingolstadt", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-innsbruck", + title_de: "IT-Syndikat Innsbruck", + title_en: nil, + description_de: "Das chaosnahe IT-Syndikat ist (mindestens) jeden Dienstag ab 19 Uhr für Besuch offen. Wo? Tschamlerstraße 3, über dem Weekender Club. Sollte unten ein Türsteher-NPC den Weg versperren, kommt man mit einem Verweis auf „die Künstler/Hacker“ immer vorbei.", + description_en: nil, + external_url: "http://it-syndikat.org", + location: "Tschamlerstraße 3, Innsbruck", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-itzehoe", + title_de: "Computerclub Itzehoe e.V.", + title_en: nil, + description_de: "Der Itzehoer Chaostreff ist beim Computerclub Itzehoe e.V. angesiedelt und beschäftigt sich zwanglos mit unterschiedlichsten Hard- und Softwareprojekten. Treffen sind donnerstags ab 19 Uhr.", + description_en: nil, + external_url: "http://cciz.de/", + location: "Itzehoe", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-jena", + title_de: "Offener Chaostreff Jena (Krautspace)", + title_en: nil, + description_de: "Der Offene Chaostreff Jena trifft sich dienstags ab 20 Uhr im Krautspace in der Krautgasse 26.", + description_en: nil, + external_url: "https://kraut.space/", + location: "Krautgasse 26, Jena", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-kiel", + title_de: "Science Monday Kiel (Toppoint)", + title_en: nil, + description_de: "Science Monday - Chaos and more ist der lokale Chaostreff in Kiel. Seit dem ersten Chaostreffen in Kiel finden die Treffen montags ab 19 Uhr in den Räumen der Toppoint statt.", + description_en: nil, + external_url: "http://toppoint.de/", + location: "Kiel", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-klaus-vorarlberg", + title_de: "Open-Lab Klaus (Vorarlberg)", + title_en: nil, + description_de: "Wir treffen uns jeden Dienstag von 16-20 Uhr im Open-Lab in Klaus, Vorarlberg. Ob jung, ob alt, es ist jewesen bei uns willkommen. Wir haben Internet, Werkzeuge, Lötkolben, 3D-Drucker, Sofas und noch einiges mehr zur Verfügung.", + description_en: nil, + external_url: "https://open-lab.at/", + location: "Klaus, Vorarlberg", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "16:00", + review: false + }, + { + slug: "chaostreff-konstanz", + title_de: "hacKNology e.V. Konstanz", + title_en: nil, + description_de: "Der Chaostreff Konstanz trifft sich jeden 2. Dienstag um 19 Uhr in den Räumlichkeiten des Hackerspace hacKNology e. V.", + description_en: nil, + external_url: "https://www.hacknology.de/", + location: "Konstanz", + rrule: "FREQ=MONTHLY;BYDAY=2TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-landau", + title_de: "CTRL-Z Landau", + title_en: nil, + description_de: "CTRL-Z lädt an drei Dienstagen im Monat zum Open Space bis 23 Uhr ein. Als Teil des Zentrums für Technikkultur Landau e. V. kann man uns auch bei den zahlreichen Workshops im ZTL treffen. Wir hoffen, ihr schaut mal bei uns vorbei, in der Klaus-Von-Klitzing-Str. 2, im schönen Landau in der Pfalz. Unser Zugang ist barrierefrei, all creatures welcome!", + description_en: nil, + external_url: "https://ctrl-z.info/", + location: "Klaus-Von-Klitzing-Str. 2, Landau", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-loerrach", + title_de: "technik.cafe Lörrach", + title_en: nil, + description_de: "Im technik.cafe Lörrach trifft sich monatlich der Chaostreff Lörrach. Dort wird gefachsimpelt, gebastelt, gelernt und ausgetauscht. Es gibt Werkzeug, Komponenten, Lötkolben, Hardware und Netzwerkzubehör zum Basteln, Testen und Reparieren. Betrieben wird das technik.cafe von Privatleuten aus Leidenschaft, es gibt keinen Verein, keine Mitgliedschaft, keine Gebühren.", + description_en: nil, + external_url: "https://technik.cafe/", + location: "Lörrach", + rrule: "FREQ=MONTHLY", + start_time: nil, + review: false + }, + { + slug: "chaostreff-ludwigsburg", + title_de: "Chaostreff Ludwigsburg (DemoZ)", + title_en: nil, + description_de: "Der Chaostreff Ludwigsburg ist ein lockeres und offenes Zusammentreffen von Menschen, die sich dem CCC und der Hackerethik nahe fühlen. Wir treffen uns am letzten Donnerstag im Monat ab 18:00 Uhr im DemoZ in Ludwigsburg und ständig bei Matrix.", + description_en: nil, + external_url: "https://complb.de", + location: "DemoZ, Ludwigsburg", + rrule: "FREQ=MONTHLY;BYDAY=-1TH", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-luxemburg", + title_de: "CCC Lëtzebuerg", + title_en: nil, + description_de: "Der Chaos Computer Club Lëtzebuerg trifft sich jeden Montag um 20 Uhr in der Hauptstadt Luxemburgs im Hackerspace ChaosStuff. Interessierte sind jederzeit herzlich willkommen, zu unseren Treffen zu kommen.", + description_en: nil, + external_url: "http://www.c3l.lu/", + location: "Luxemburg", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-marburg", + title_de: "Hackspace Marburg (hsmr)", + title_en: nil, + description_de: "Der Chaostreff Marburg findet jeden Montag ab 18:00 Uhr im Hackspace Marburg statt. Aber auch sonst lohnt ein Besuch des [hsmr] immer. Erreichen könnt Ihr uns über die Mailingliste oder im IRC auf hackint #hsmr.", + description_en: nil, + external_url: "https://hsmr.cc/", + location: "Marburg", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-markdorf", + title_de: "Toolbox Bodensee e.V. Markdorf", + title_en: nil, + description_de: "Der Chaostreff Markdorf trifft sich regelmäßig im Hackerspace Toolbox Bodensee e. V. Alle galaktischen Wesen sind willkommen.", + description_en: nil, + external_url: "https://bodensee.space/chaostreff-markdorf", + location: "Markdorf", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-muenster", + title_de: "warpzone Münster", + title_en: nil, + description_de: "Das Chaos in Münster trifft sich jeden Mittwoch ab 19 Uhr in der warpzone, 51.943374° N, 7.638241° E.", + description_en: nil, + external_url: "http://www.warpzone.ms/", + location: "Münster", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-neuss", + title_de: "fnordeingang e.V. Neuss", + title_en: nil, + description_de: "Der Chaostreff Neuss findet jeden Mittwoch ab 19 Uhr in den Räumen des fnordeingang e. V. statt. Jeder ist herzlich willkommen, es wird keine Anmeldung benötigt.", + description_en: nil, + external_url: "https://fnordeingang.de/chaostreff", + location: "Neuss", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-nuernberg", + title_de: "Chaostreff Nürnberg", + title_en: nil, + description_de: "Der Chaostreff Nürnberg trifft sich abwechselnd im Hackerspace K4CG und Nerdberg.", + description_en: nil, + external_url: "https://chaostreff-nuernberg.de", + location: "Nürnberg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-osnabrueck", + title_de: "Chaostreff Osnabrück", + title_en: nil, + description_de: "Der Chaostreff Osnabrück ist eine lockere Gruppe von Leuten mit Interesse in den Bereichen Sicherheit, Kryptographie, alternative Betriebssysteme, freie Software, Netzpolitik und vielen weiteren Themen. Interessierte sind jederzeit herzlich willkommen, zu unseren Treffen zu kommen.", + description_en: nil, + external_url: "https://chaostreff-osnabrueck.de/", + location: "Osnabrück", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-potsdam", + title_de: "Chaostreff Potsdam (machBar)", + title_en: nil, + description_de: "Der Chaostreff Potsdam trifft sich jeden Mittwoch um 19 Uhr. Die Treffen finden in den Räumen des Hackerspace machBar im freiLand, Friedrich-Engels-Straße 22, Haus 5 statt. Alle galaktischen Lebensformen sind willkommen.", + description_en: nil, + external_url: "https://www.ccc-p.org/", + location: "Friedrich-Engels-Straße 22, Haus 5, Potsdam", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-rapperswil", + title_de: "Coredump Rapperswil-Jona", + title_en: nil, + description_de: "(Fast) jeden Montag treffen wir uns ab 20 Uhr im Hackerspace Coredump auf dem Vinora-Areal in Jona (Schweiz) zur wöchentlichen „Hacknight“. Wie bei all unseren Events sind Gäste dabei herzlich willkommen. Mit Ferienpass-Kursen und regelmäßigen Rust-Meetups engagiert sich der Verein Coredump auch in der Kinder- und Erwachsenen-Bildung.", + description_en: nil, + external_url: "https://www.coredump.ch/", + location: "Vinora-Areal, Jona", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-recklinghausen", + title_de: "Chaostreff Recklinghausen (Hackerhütte)", + title_en: nil, + description_de: "Jeden Mittwoch trifft sich der Chaostreff Recklinghausen in der Hackerhütte, Westcharweg 101, 45659 Recklinghausen.", + description_en: nil, + external_url: "http://c3re.de", + location: "Westcharweg 101, 45659 Recklinghausen", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-regensburg", + title_de: "Binary Kitchen Regensburg", + title_en: nil, + description_de: "Regensburg hackt in der Binary Kitchen. Immer montags. Offen für alle.", + description_en: nil, + external_url: "http://binary.kitchen", + location: "Regensburg", + rrule: "FREQ=WEEKLY;BYDAY=MO", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-rodgau", + title_de: "Chaostreff Rodgau", + title_en: nil, + description_de: "Wir haben Anfang 2025 einen Chaostreff Rodgau gegründet. Wir treffen uns einmal im Monat an unterschiedlichen Orten. Termin und Ort werden auf unserer Website bekanntgegeben.", + description_en: nil, + external_url: "https://chaostreff-rodgau.codeberg.page", + location: "Rodgau", + rrule: "FREQ=MONTHLY", + start_time: nil, + review: false + }, + { + slug: "chaostreff-rotterdam", + title_de: "Chaostreff Rotterdam (Pixelbar)", + title_en: "Chaostreff Rotterdam (Pixelbar)", + description_de: "The Chaostreff of Rotterdam meets every Wednesdays around 20 Uhr at Pixelbar in the Keilewerf at the Vierhavensstraat 56 in Rotterdam close to RET / Metro station Marconiplein.", + description_en: "The Chaostreff of Rotterdam meets every Wednesdays around 20 Uhr at Pixelbar in the Keilewerf at the Vierhavensstraat 56 in Rotterdam close to RET / Metro station Marconiplein.", + external_url: "https://www.pixelbar.nl/contact/", + location: "Vierhavensstraat 56, Rotterdam", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-saarbruecken", + title_de: "hacksaar Saarbrücken", + title_en: nil, + description_de: "Unser regelmäßiges Treffen findet jeden Mittwoch ab 19 Uhr im h´eck in der Rathausstraße 18 in 66125 Saarbrücken statt. Gäste und Interessierte sind immer gerne willkommen.", + description_en: nil, + external_url: "https://www.hacksaar.de", + location: "Rathausstraße 18, 66125 Saarbrücken", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-schwerin", + title_de: "Hacklabor Schwerin", + title_en: nil, + description_de: "Bist Du interessiert an Computerkrams, Programmieren, Capture-the-Flag-Challenges, 3D-Druckern, CO2-Lasern, Microcontrollern, Automatisierung, gesellschaftlichen Auswirkungen von Technik und Spaß am Gerät? Hat es Dich nach Mecklenburg-Vorpommern verschlagen? Dann komm ins Hacklabor in der Hagenower Straße 73, Schwerin. In unserer offenen Werkstatt findet sich vom Lötkolben bis zum 3D-Drucker alles, was das Makerherz höher schlagen lässt. Jugendlichen bieten wir im \"Jugend hackt Lab\" die Möglichkeit, Neues auszuprobieren und kreative Ideen zu verwirklichen. Unsere Treffen sind öffentlich und finden mittwochs und freitags ab 19 Uhr statt.", + description_en: nil, + external_url: "https://hacklabor.de/", + location: "Hagenower Straße 73, Schwerin", + rrule: "FREQ=WEEKLY;BYDAY=WE,FR", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-stralsund", + title_de: "Port39 e.V. Stralsund (Chaostreff)", + title_en: "Port39 e.V. Stralsund", + description_de: "Das Chaos ist jetzt auch in Stralsund eingezogen! Als ein weiterer Space in Mecklenburg-Vorpommern möchten wir auch hier das gute Chaos verbreiten, mit Chaos macht Schule in Stralsund und Umgebung Jung und Alt für Technik und IT begeistern und mit diversen Projekten im Space mal mehr, mal weniger sinnvolle Dinge anstellen. Kommt gerne rum oder schaut auf unserer Website oder den Socials vorbei. Definitiv da sind wir jeden Donnerstag zum Chaostreff ab 19 Uhr und jeden 2. & 4. Samstag ab 14 Uhr zum OpenSpace.", + description_en: "As the first Erfa in Mecklenburg-Western Pomerania, we at Port39 e.V. make sure that there's a healthy dose of chaos in Stralsund. Feel free to drop by or check out our website. We're definitely there every Thursday for the Chaos Meetup starting at 7pm and every 2nd & 4th Saturday starting at 2pm for OpenSpace.", + external_url: "https://port39.de/", + location: "Stralsund", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-trier", + title_de: "Maschinendeck e.V. Trier", + title_en: nil, + description_de: "Mit dem Maschinendeck e. V. haben wir uns zum Ziel gesetzt, in Trier einen Ort zu schaffen, wo alle Arten von Nerdkultur ein Zuhause finden können. Ob Du coden, löten, Kaffee rösten, Brettspiele spielen oder Deine neueste Verschwörungstheorie besprechen willst: Im Maschinendeck gibt es einen Platz für Dich. Das wöchentliche Treffen findet jeden Mittwoch, 20 Uhr, in unseren Räumen statt.", + description_en: nil, + external_url: "http://www.maschinendeck.org", + location: "Trier", + rrule: "FREQ=WEEKLY;BYDAY=WE", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-tuebingen", + title_de: "Chaostreff Tübingen", + title_en: nil, + description_de: "Der Chaostreff Tübingen ist eine lockere Sammlung zumeist Tübinger und Reutlinger Chaos. Wir treffen uns regelmäßig am letzten Sonntag im Monat gegen 18:00 Uhr im KIMS und ungefähr jeden 2. Montag im Monat gegen 19:00 Uhr im FabLab. Beide Orte glücklicherweise gut zu Fuß vom Bahnhof erreichbar.", + description_en: nil, + external_url: "https://cttue.de", + location: "Tübingen", + rrule: "FREQ=MONTHLY;BYDAY=-1SU", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-villingen-schwenningen", + title_de: "vspace.one Villingen-Schwenningen", + title_en: nil, + description_de: "Der Chaostreff Villingen-Schwenningen ist Treffpunkt für alle Chaosnahen aus dem Raum Schwarzwald-Baar. Er ist dem Maker- / Hackerspace vspace.one angeschlossen. Jeden Dienstag ab 19 Uhr finden öffentliche Treffen zur Wissensförderung in der Wilhelm-Binder-Straße 19 in Villingen statt. Für gemütliche Plätze, coole Atmosphäre und Mate ist gesorgt!", + description_en: nil, + external_url: "http://vspace.one", + location: "Wilhelm-Binder-Straße 19, Villingen-Schwenningen", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "19:00", + review: false + }, + { + slug: "chaostreff-waldkraiburg", + title_de: "Chaostreff Waldkraiburg", + title_en: nil, + description_de: "Der Chaostreff Waldkraiburg ist eine dem Chaos Computer Club zugehörige Gruppe und Anlaufstelle für alle chaosnahen und interessierten Wesen für die Landkreise Mühldorf am Inn, Altötting und darüber hinaus. Wir sind technikbegeisterte Menschen und beschäftigen uns mit IT-Sicherheit, Webdesign, Gaming, alternativen Betriebssystemen, Retrocomputing, freier Software, Netzpolitik und vielen weiteren Themen. Ob Anfänger oder Profi spielt keine Rolle, es ist keine Anmeldung erforderlich.", + description_en: nil, + external_url: "https://c3wkb.de", + location: "Waldkraiburg", + rrule: nil, + start_time: nil, + review: false + }, + { + slug: "chaostreff-westerwald", + title_de: "Westwoodlabs Westerwald", + title_en: nil, + description_de: "Der Chaostreff Westerwald trifft sich jeden Dienstag um 18 Uhr für alle Interessierten in den Räumen der Westwoodlabs in Ransbach-Baumbach.", + description_en: nil, + external_url: "https://westwoodlabs.de/", + location: "Ransbach-Baumbach", + rrule: "FREQ=WEEKLY;BYDAY=TU", + start_time: "18:00", + review: false + }, + { + slug: "chaostreff-wuppertal", + title_de: "Chaostreff Wuppertal (chaostal)", + title_en: nil, + description_de: "Der Chaostreff unter der Schwebebahn organisiert in regelmäßigen Abständen Treffen zum gegenseitigen Austausch. Neue Besucher sind jederzeit willkommen. Treffen finden jeden ersten Donnerstag im Monat ab 20 Uhr im Mirker Bahnhof, Mirker Straße 48, Wuppertal-Elberfeld statt. An allen anderen Donnerstagen wechselt der Ort.", + description_en: nil, + external_url: "https://www.chaostal.de/category/meeting", + location: "Mirker Straße 48, Wuppertal-Elberfeld", + rrule: "FREQ=MONTHLY;BYDAY=1TH", + start_time: "20:00", + review: false + }, + { + slug: "chaostreff-zwickau", + title_de: "z-Labor Zwickau", + title_en: nil, + description_de: "Für alle chaosnahen Lebewesen öffnet das z-Labor donnerstags um 19 Uhr die Räumlichkeiten in der Kulturweberei zum kollektiven Hacken, Basteln, Lernen und Philosophieren. Wir mögen Freie Software, IT-Sicherheit, (Analog-)Fotografie, 3D-Druck, Mate und viele Formen von (digitaler) Kunst. Neben Elektroniklabor und Rechentechnik gibt es eine Holz- und Metallwerkstatt, eine Musik-Ecke und einen Retro-Spiel-Bereich.", + description_en: nil, + external_url: "https://www.z-labor.space", + location: "Kulturweberei, Zwickau", + rrule: "FREQ=WEEKLY;BYDAY=TH", + start_time: "19:00", + review: false + } +] + +chaostreffs.each do |entry| + seed_chapter(parent_id: 549, tag: "chaostreff-detail", **entry) +end + +puts "Done." +puts "Nodes flagged for review:" +(erfas + chaostreffs).select { |e| e[:review] }.each do |e| + puts " #{e[:slug]}" +end + +Node.rebuild!(false) -- cgit v1.3 From 51629c5c42270a346885057a441095c964101cc1 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 30 Jun 2026 03:55:42 +0200 Subject: Fix events CRUD for standalone events and add events to admin menu - event_params now permits title, description, is_primary - event_information helper lists all node.events, not just the first - Occurrence.generate handles nil node (standalone events) - Page.aggregate order_by title uses correlated subquery to avoid GROUP BY conflict with tag-filter path; order_direction whitelisted to ASC/DESC to prevent SQL injection - Events link added to admin menu bar - events/index shows title, is_primary; drops latitude/longitude columns --- app/controllers/events_controller.rb | 2 +- app/helpers/nodes_helper.rb | 20 +++++++++----------- app/models/occurrence.rb | 2 +- app/models/page.rb | 8 ++++---- app/views/admin/_menu.html.erb | 1 + app/views/events/index.html.erb | 8 ++++---- 6 files changed, 20 insertions(+), 21 deletions(-) (limited to 'app/models') diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index f50da3e..3a60cf9 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -94,6 +94,6 @@ class EventsController < ApplicationController private def event_params - params.require(:event).permit(:start_time, :end_time, :rrule, :custom_rrule, :allday, :url, :latitude, :longitude, :node_id, :location) + params.require(:event).permit(:title, :description, :is_primary, :start_time, :end_time, :rrule, :custom_rrule, :allday, :url, :latitude, :longitude, :node_id, :location) end end diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index d88d12a..4293628 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -30,19 +30,17 @@ module NodesHelper end def event_information - if @node.events.first - event = @node.events.first + events = @node.events.order(:start_time) + items = events.map do |event| safe_join([ - "#{event.start_time.to_fs(:db)} - #{event.end_time.to_fs(:db)} > ", - link_to('show', event_path(event)), - ' > ', - link_to('edit', edit_event_path(event)) - ]) - else - safe_join([ - 'no event attached > ', - link_to('add', new_event_path(:node_id => @node.id)) + "#{event.start_time&.to_fs(:db)} - #{event.end_time&.to_fs(:db)} > ", + link_to('edit', edit_event_path(event)), ]) end + safe_join([ + safe_join(items, ' | '), + ' > ', + link_to('add event', new_event_path(:node_id => @node.id)) + ]) end end diff --git a/app/models/occurrence.rb b/app/models/occurrence.rb index 143124f..777be24 100644 --- a/app/models/occurrence.rb +++ b/app/models/occurrence.rb @@ -35,7 +35,7 @@ class Occurrence < ApplicationRecord self.create( :start_time => occurrence, :end_time => (occurrence + duration), - :node_id => node.id, + :node_id => node&.id, :event_id => event.id ) end diff --git a/app/models/page.rb b/app/models/page.rb index 385b3f6..c982c2e 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -63,15 +63,15 @@ class Page < ApplicationRecord end end + direction = %w[ASC DESC].include?(options[:order_direction]&.upcase) ? options[:order_direction].upcase : "ASC" + if options[:order_by] == "title" return scope - .joins(:translations) - .where(page_translations: { locale: I18n.locale }) - .order("page_translations.title #{options[:order_direction]}") + .order(Arel.sql("(SELECT pt.title FROM page_translations pt WHERE pt.page_id = pages.id AND pt.locale = #{ActiveRecord::Base.connection.quote(I18n.locale.to_s)}) #{direction}")) .paginate(:page => page, :per_page => options[:limit]) end - scope.order("#{options[:order_by]} #{options[:order_direction]}") + scope.order("#{options[:order_by]} #{direction}") .paginate(:page => page, :per_page => options[:limit]) end diff --git a/app/views/admin/_menu.html.erb b/app/views/admin/_menu.html.erb index 6dba085..c87c5f7 100644 --- a/app/views/admin/_menu.html.erb +++ b/app/views/admin/_menu.html.erb @@ -4,5 +4,6 @@ search <%= link_to 'Nodes', nodes_path, selected?('nodes') %> <%= link_to 'Assets', assets_path, selected?('assets') %> +<%= link_to 'Events', events_path, selected?('events') %> <%= link_to 'User', users_path, selected?('users') %> <%= link_to 'Navigation', menu_items_path, selected?('menu_items') %> >  diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index 19b21ce..064fa86 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -2,27 +2,27 @@ + + - - <% @events.each do |event| %> + + - - -- cgit v1.3 From 95955abaa339098755a214cfcadf87c90211fe64 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 1 Jul 2026 00:24:10 +0200 Subject: Add RRULE humanizer and wire events into nodes#show - app/models/concerns/rrule_humanizer.rb: new concern included into Event, renders recurring schedule as natural-language German or English from RRULE string; handles WEEKLY/MONTHLY, biweekly (INTERVAL=2), ordinal weekday positions (1TU, -1TH, -2WE), BYMONTH single-month exclusions (December pause convention); gracefully returns nil for COUNT/UNTIL/unrecognized shapes - test/models/concerns/rrule_humanizer_test.rb: 15 tests covering all distinct RRULE shapes found in production data - app/helpers/nodes_helper.rb: add event_schedule_text helper combining humanize_rrule with start_time formatting - app/views/nodes/show.html.erb: add events row, conditionally rendered when node has associated events - config/locales/de.yml, en.yml: add event_schedule_time, event_schedule_unrecognized, event_schedule_none keys --- app/helpers/nodes_helper.rb | 16 ++++++ app/models/concerns/rrule_humanizer.rb | 82 +++++++++++++++++++++++++++ app/models/event.rb | 1 + app/views/nodes/show.html.erb | 14 ++++- config/locales/de.yml | 3 + config/locales/en.yml | 3 + test/models/concerns/rrule_humanizer_test.rb | 84 ++++++++++++++++++++++++++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 app/models/concerns/rrule_humanizer.rb create mode 100644 test/models/concerns/rrule_humanizer_test.rb (limited to 'app/models') diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 4293628..329bcc5 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -43,4 +43,20 @@ module NodesHelper link_to('add event', new_event_path(:node_id => @node.id)) ]) end + + def event_schedule_text(event) + if event.rrule.present? + recurrence = event.humanize_rrule(I18n.locale) + if recurrence + time = event.start_time&.strftime("%H:%M") + time ? "#{recurrence} #{t(:event_schedule_time, time: time)}" : recurrence + else + "#{event.rrule} (#{t(:event_schedule_unrecognized)})" + end + elsif event.start_time + I18n.l(event.start_time, format: :long) + else + t(:event_schedule_none) + end + end end diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb new file mode 100644 index 0000000..6cee711 --- /dev/null +++ b/app/models/concerns/rrule_humanizer.rb @@ -0,0 +1,82 @@ +module RruleHumanizer + extend ActiveSupport::Concern + + WEEKDAY_NAMES = { + de: { "MO"=>"Montag","TU"=>"Dienstag","WE"=>"Mittwoch","TH"=>"Donnerstag","FR"=>"Freitag","SA"=>"Samstag","SU"=>"Sonntag" }, + en: { "MO"=>"Monday","TU"=>"Tuesday","WE"=>"Wednesday","TH"=>"Thursday","FR"=>"Friday","SA"=>"Saturday","SU"=>"Sunday" } + }.freeze + + WEEKDAY_NAMES_ADVERBIAL = { + de: { "MO"=>"montags","TU"=>"dienstags","WE"=>"mittwochs","TH"=>"donnerstags","FR"=>"freitags","SA"=>"samstags","SU"=>"sonntags" } + }.freeze + + ORDINAL_NAMES = { + de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, + en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } + }.freeze + + MONTH_NAMES = { + de: %w[Januar Februar März April Mai Juni Juli August September Oktober November Dezember], + en: %w[January February March April May June July August September October November December] + }.freeze + + def humanize_rrule(locale = I18n.locale) + return nil if rrule.blank? + parts = Hash[rrule.split(";").map { |p| p.split("=", 2) }] + return nil if parts["COUNT"] || parts["UNTIL"] # old one-off data, don't guess + + freq, interval, byday, bymonth = parts["FREQ"], parts["INTERVAL"].to_i, parts["BYDAY"], parts["BYMONTH"] + loc = locale.to_sym + weekdays = WEEKDAY_NAMES[loc] || WEEKDAY_NAMES[:en] + ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] + months = MONTH_NAMES[loc] || MONTH_NAMES[:en] + + days = byday&.split(",")&.map do |d| + if d =~ /^(-?\d+)([A-Z]{2})$/ + "#{ordinals[$1.to_i]} #{weekdays[$2]}" + else + weekdays[d] + end + end + + base = + case loc + when :de + case freq + when "WEEKLY" + if days + if interval == 2 + adverbial = byday.split(",").map { |d| WEEKDAY_NAMES_ADVERBIAL[:de][d] } + "Alle zwei Wochen #{adverbial.join(' und ')}" + else + "Jeden #{days.join(' und ')}" + end + else + interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" + end + when "MONTHLY" + days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" + end + else + case freq + when "WEEKLY" + days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") + when "MONTHLY" + days ? "Every #{days.join(' and ')} of the month" : "Monthly" + end + end + return nil unless base + + if bymonth + included = bymonth.split(",").map(&:to_i) + missing = ((1..12).to_a - included) + if missing.size == 1 + excluded_name = months[missing.first - 1] + base += (loc == :de ? ", außer im #{excluded_name}" : ", except in #{excluded_name}") + end + # more than one missing month: bymonth pattern more complex than we handle, leave base as-is silently + end + + base + end +end diff --git a/app/models/event.rb b/app/models/event.rb index 26c79e4..de82674 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -1,4 +1,5 @@ class Event < ApplicationRecord + include RruleHumanizer belongs_to :node, optional: true has_many :occurrences diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index de4d8a2..7223219 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -42,6 +42,18 @@ + <% if @node.events.any? %> + + + + + <% end %> @@ -59,4 +71,4 @@
TitleIs primary Start time End time Rrule Custom rrule Allday UrlLatitudeLongitude Node
<%=h event.display_title %><%=h event.is_primary %> <%=h event.start_time %> <%=h event.end_time %> <%=h event.rrule %> <%=h event.custom_rrule %> <%=h event.allday %> <%=h event.url %><%=h event.latitude %><%=h event.longitude %> <%=h event.node_id %> <%= link_to 'Show', event %> <%= link_to 'Edit', edit_event_path(event) %>Tagged with: <%= @page.tag_list %>
Events +
    + <% @node.events.order(:start_time).each do |event| %> +
  • <%= event_schedule_text(event) %><%= " (primary)" if event.is_primary? %>
  • + <% end %> +
+
Title <%= sanitize( @page.title ) %>
- \ No newline at end of file + diff --git a/config/locales/de.yml b/config/locales/de.yml index 5f77d79..0b42dd3 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -7,6 +7,9 @@ de: sponsors: Sponsoren show_tag_headline: "Seiten mit dem Tag:" old_ccc_de: das alte ccc.de + event_schedule_time: "um %{time} Uhr" + event_schedule_unrecognized: "Termin-Regel nicht automatisch lesbar" + event_schedule_none: "Kein Termin" date: formats: default: "%d.%m.%Y" diff --git a/config/locales/en.yml b/config/locales/en.yml index 2458d4d..93a0d55 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -8,6 +8,9 @@ en: hello: "Hello world" show_tag_headline: "Pages tagged with:" old_ccc_de: the old ccc.de + event_schedule_time: "at %{time}" + event_schedule_unrecognized: "Schedule not automatically readable" + event_schedule_none: "No schedule" time: formats: diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb new file mode 100644 index 0000000..500dbc7 --- /dev/null +++ b/test/models/concerns/rrule_humanizer_test.rb @@ -0,0 +1,84 @@ +require 'test_helper' + +class RruleHumanizerTest < ActiveSupport::TestCase + def humanize(rrule, locale = :de) + Event.new(rrule: rrule).humanize_rrule(locale) + end + + test "weekly single day" do + assert_equal "Jeden Dienstag", humanize("FREQ=WEEKLY;BYDAY=TU") + assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :en) + end + + test "weekly two days" do + assert_equal "Jeden Mittwoch und Freitag", humanize("FREQ=WEEKLY;BYDAY=WE,FR") + assert_equal "Every Wednesday and Friday", humanize("FREQ=WEEKLY;BYDAY=WE,FR", :en) + end + + test "weekly no byday" do + assert_equal "Wöchentlich", humanize("FREQ=WEEKLY") + assert_equal "Weekly", humanize("FREQ=WEEKLY", :en) + end + + test "biweekly with day" do + assert_equal "Alle zwei Wochen donnerstags", humanize("FREQ=WEEKLY;INTERVAL=2;BYDAY=TH") + assert_equal "Every other Thursday", humanize("FREQ=WEEKLY;INTERVAL=2;BYDAY=TH", :en) + end + + test "biweekly no day" do + assert_equal "Alle zwei Wochen", humanize("FREQ=WEEKLY;INTERVAL=2") + assert_equal "Every other week", humanize("FREQ=WEEKLY;INTERVAL=2", :en) + end + + test "monthly nth weekday" do + assert_equal "Jeden ersten Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=1TU") + assert_equal "Jeden zweiten Freitag im Monat", humanize("FREQ=MONTHLY;BYDAY=2FR") + assert_equal "Jeden dritten Sonntag im Monat", humanize("FREQ=MONTHLY;BYDAY=3SU") + assert_equal "Jeden letzten Mittwoch im Monat", humanize("FREQ=MONTHLY;BYDAY=-1WE") + end + + test "monthly nth weekday english" do + assert_equal "Every first Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=1TU", :en) + assert_equal "Every last Wednesday of the month", humanize("FREQ=MONTHLY;BYDAY=-1WE", :en) + end + + test "monthly second-to-last" do + assert_equal "Jeden vorletzten Donnerstag im Monat", humanize("FREQ=MONTHLY;BYDAY=-2TH") + assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) + end + + test "monthly no byday" do + assert_equal "Monatlich", humanize("FREQ=MONTHLY") + assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) + end + + test "monthly with single excluded month" do + assert_equal "Jeden letzten Donnerstag im Monat, außer im Dezember", + humanize("FREQ=MONTHLY;BYDAY=-1TH;BYMONTH=1,2,3,4,5,6,7,8,9,10,11") + assert_equal "Every last Thursday of the month, except in December", + humanize("FREQ=MONTHLY;BYDAY=-1TH;BYMONTH=1,2,3,4,5,6,7,8,9,10,11", :en) + end + + test "monthly excluding january" do + assert_equal "Jeden zweiten Mittwoch im Monat, außer im Januar", + humanize("FREQ=MONTHLY;BYMONTH=2,3,4,5,6,7,8,9,10,11,12;BYDAY=2WE") + end + + test "blank rrule returns nil" do + assert_nil humanize(nil) + assert_nil humanize("") + end + + test "count and until are not guessed at" do + assert_nil humanize("FREQ=MONTHLY;BYDAY=1WE;COUNT=36") + assert_nil humanize("FREQ=MONTHLY;BYDAY=1WE;UNTIL=20050105T222222Z") + end + + test "unrecognized freq returns nil" do + assert_nil humanize("FREQ=YEARLY;BYMONTH=12") + end + + test "falls back to english for unknown locale" do + assert_equal "Every Tuesday", humanize("FREQ=WEEKLY;BYDAY=TU", :fr) + end +end -- cgit v1.3 From 5adbf85ab6de777869b0d47d626212caaec9f34e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 00:12:15 +0200 Subject: Add tagging support to Event model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Event: acts_as_taggable_on :tags — uses existing polymorphic taggings table, no migration needed - events/edit and events/new: tag_list field added, tag_list permitted in events_controller strong params - locales: add open_days (Offene Tage / Open days), upcoming_events (Weitere Termine / Upcoming events), event_schedule_time, event_schedule_unrecognized, event_schedule_none keys --- app/models/event.rb | 4 +--- config/locales/de.yml | 2 ++ config/locales/en.yml | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) (limited to 'app/models') diff --git a/app/models/event.rb b/app/models/event.rb index de82674..e2e71c9 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -3,11 +3,9 @@ class Event < ApplicationRecord belongs_to :node, optional: true has_many :occurrences + acts_as_taggable_on :tags validates :title, presence: true, unless: -> { node_id.present? } - validates :is_primary, uniqueness: { scope: :node_id, - message: "only one primary event per node allowed" }, - if: -> { is_primary? && node_id.present? } after_save :generate_occurences diff --git a/config/locales/de.yml b/config/locales/de.yml index 0b42dd3..9719a1c 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -10,6 +10,8 @@ de: event_schedule_time: "um %{time} Uhr" event_schedule_unrecognized: "Termin-Regel nicht automatisch lesbar" event_schedule_none: "Kein Termin" + open_days: "Offene Tage" + upcoming_events: "Weitere Termine" date: formats: default: "%d.%m.%Y" diff --git a/config/locales/en.yml b/config/locales/en.yml index 93a0d55..aa477f0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -11,6 +11,8 @@ en: event_schedule_time: "at %{time}" event_schedule_unrecognized: "Schedule not automatically readable" event_schedule_none: "No schedule" + open_days: "Open days" + upcoming_events: "Upcoming events" time: formats: -- cgit v1.3 From 9a3274345dc83096a9bdea612ec77cb2406fef21 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 2 Jul 2026 15:13:40 +0200 Subject: Fix DST drift in occurrence generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Occurrence.generate_dates: use ChaosCalendar::occurrences_for_timezone with Time.zone.tzinfo.identifier — occurrences now keep wall-clock time across DST boundaries (19:00 stays 19:00 in summer and winter); requires chaoscalendar gem with occurrences_for_timezone support (Gemfile.lock bumped accordingly) - Occurrence.find_in_range: joins(:event) inner join excludes occurrences whose event no longer exists (107k orphaned rows from the pre-revival calendar era were purged from the test DB); includes(:node) retained for eager loading; column references qualified with table name --- Gemfile.lock | 2 +- app/models/occurrence.rb | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) (limited to 'app/models') diff --git a/Gemfile.lock b/Gemfile.lock index b5c5c77..2e9a1ce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,7 +8,7 @@ GIT GIT remote: https://github.com/erdgeist/chaoscalendar.git - revision: 3c0b34dd260f11d15378a6255d9862d629c8413a + revision: 48fe5c517ae3895cc78d9d2fefbdd35b90d52ba7 branch: erdgeist-ruby1.9 specs: chaos_calendar (0.1.3) diff --git a/app/models/occurrence.rb b/app/models/occurrence.rb index 777be24..41ef4cb 100644 --- a/app/models/occurrence.rb +++ b/app/models/occurrence.rb @@ -10,9 +10,10 @@ class Occurrence < ApplicationRecord # Class Methods def self.find_in_range start_time, end_time - includes(:node) - .where("start_time > ? AND end_time < ?", start_time, end_time) - .order("start_time") + joins(:event) + .includes(:node) + .where("occurrences.start_time > ? AND occurrences.end_time < ?", start_time, end_time) + .order("occurrences.start_time") end def self.find_next @@ -48,10 +49,11 @@ class Occurrence < ApplicationRecord # Return value is always an array of Time objects. def self.generate_dates event if event.rrule && !event.rrule.empty? - ChaosCalendar::occurrences( + ChaosCalendar::occurrences_for_timezone( event.start_time, (Time.now + 5.years), - event.rrule + event.rrule, + Time.zone.tzinfo.identifier ) else [event.start_time] -- cgit v1.3 From bff34fb568f7a83c03e2e51b458a1e44b67b794e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 3 Jul 2026 02:00:53 +0200 Subject: ascade-destroy occurrences; support events without a fixed start time - Event#occurrences is now dependent: :destroy, so destroying an Event (directly, or via Node's cascade) removes its Occurrence rows instead of orphaning them. - Renamed generate_occurences -> generate_occurrences (typo fix); callback and method updated together. - Occurrence.generate returns early when event.start_time is nil, so a chapter with no sourced time produces zero occurrences instead of a guessed one. - seed_chapter no longer falls back to 19:00 when start_time is absent; base_time/end_time are nil in that case. Database-level foreign key cascade on occurrences.event_id is still pending. --- app/models/event.rb | 6 +++--- app/models/occurrence.rb | 1 + db/seeds/chapters.rb | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) (limited to 'app/models') diff --git a/app/models/event.rb b/app/models/event.rb index e2e71c9..b8651a8 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -2,12 +2,12 @@ class Event < ApplicationRecord include RruleHumanizer belongs_to :node, optional: true - has_many :occurrences + has_many :occurrences, dependent: :destroy acts_as_taggable_on :tags validates :title, presence: true, unless: -> { node_id.present? } - after_save :generate_occurences + after_save :generate_occurrences def occurrences_in_range start_time, end_time self.occurrences.where( @@ -21,7 +21,7 @@ class Event < ApplicationRecord end private - def generate_occurences + def generate_occurrences Occurrence.generate self end end diff --git a/app/models/occurrence.rb b/app/models/occurrence.rb index 41ef4cb..c6a2380 100644 --- a/app/models/occurrence.rb +++ b/app/models/occurrence.rb @@ -27,6 +27,7 @@ class Occurrence < ApplicationRecord # event are then calculated and created. def self.generate event self.where(:event_id => event.id).delete_all + return if event.start_time.nil? node = event.node duration = (event.end_time - event.start_time) diff --git a/db/seeds/chapters.rb b/db/seeds/chapters.rb index 0b83987..ad3c9a0 100644 --- a/db/seeds/chapters.rb +++ b/db/seeds/chapters.rb @@ -65,13 +65,13 @@ def seed_chapter(parent_id:, slug:, tag:, title_de:, description_de:, # Create events events.each do |ev| - base_time = Time.parse("#{Date.today.year}-01-01 #{ev[:start_time] || '19:00'}:00") + base_time = ev[:start_time] ? Time.parse("#{Date.today.year}-01-01 #{ev[:start_time]}:00") : nil node.events.create!( title: title_de, location: ev[:location] || location, rrule: ev[:rrule], start_time: base_time, - end_time: base_time + (ev[:duration_hours] || 2).hours, + end_time: base_time && base_time + (ev[:duration_hours] || 2).hours, tag_list: ev[:tag_list] || 'open-day' ) end -- cgit v1.3 From 287569c9bbfdadae767254792cd2b53d0515d928 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 3 Jul 2026 03:22:02 +0200 Subject: Add weekday abbreviation for open-today widget RruleHumanizer gains WEEKDAY_NAMES_ABBR (Mo/Di/Mi/...) alongside the existing WEEKDAY_NAMES and WEEKDAY_NAMES_ADVERBIAL hashes, plus a self.wday_abbr(time, locale) utility mapping a Time's wday to its RFC5545 code and looking up the abbreviation - keeping RFC5545 vocabulary in one place rather than teaching ContentHelper about day codes directly. ContentHelper#weekday_abbr is a thin wrapper passing I18n.locale through. The partial now renders "Fr 19:00" instead of just "19:00", joined with a non-breaking space so the pair can't split across a line wrap. Takes an explicit Time rather than reading Date.today, matching this file's existing style of passing state in rather than reading it ambiently - and staying correct if the currently-unused calendar/_front_page_calendar partial (spanning six weeks, not just today) is ever revived and reuses this helper. --- app/helpers/content_helper.rb | 4 ++++ app/models/concerns/rrule_humanizer.rb | 9 +++++++++ app/views/content/_open_erfas_today.html.erb | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) (limited to 'app/models') diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index bf7287f..6043089 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -39,6 +39,10 @@ module ContentHelper ) end + def weekday_abbr(time) + RruleHumanizer.wday_abbr(time, I18n.locale) + end + def tags render :partial => 'content/tags' end diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb index 6cee711..8231de8 100644 --- a/app/models/concerns/rrule_humanizer.rb +++ b/app/models/concerns/rrule_humanizer.rb @@ -10,6 +10,10 @@ module RruleHumanizer de: { "MO"=>"montags","TU"=>"dienstags","WE"=>"mittwochs","TH"=>"donnerstags","FR"=>"freitags","SA"=>"samstags","SU"=>"sonntags" } }.freeze + WEEKDAY_NAMES_ABBR = { + de: { "MO"=>"Mo","TU"=>"Di","WE"=>"Mi","TH"=>"Do","FR"=>"Fr","SA"=>"Sa","SU"=>"So" } + }.freeze + ORDINAL_NAMES = { de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" } @@ -79,4 +83,9 @@ module RruleHumanizer base end + + def self.wday_abbr(time, locale) + code = %w[SU MO TU WE TH FR SA][time.wday] + (WEEKDAY_NAMES_ABBR[locale.to_sym] || WEEKDAY_NAMES_ABBR[:de])[code] + end end diff --git a/app/views/content/_open_erfas_today.html.erb b/app/views/content/_open_erfas_today.html.erb index 3448af3..468bedc 100644 --- a/app/views/content/_open_erfas_today.html.erb +++ b/app/views/content/_open_erfas_today.html.erb @@ -4,7 +4,7 @@ <% occurrences.each do |occurrence| %>
  • <%= link_to_path occurrence.node.head.title, occurrence.node.unique_name %> - <%= occurrence.start_time.strftime("%H:%M") %> + <%= weekday_abbr(occurrence.start_time) %> <%= occurrence.start_time.strftime("%H:%M") %>
  • <% end %> -- cgit v1.3 From 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/models') 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 b547decd1f85b43d5fb9e86cd7462b455059b1d1 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 6 Jul 2026 06:05:06 +0200 Subject: Add preview_token to pages New column + unique index, plus ensure_preview_token!/revoke_preview_token! on Page. Generated lazily (only when explicitly requested) rather than via has_secure_token's default auto-generate-on-create, so a live, shareable secret isn't silently minted for every page ever created. --- app/models/page.rb | 9 +++++++++ db/migrate/20260706030547_add_preview_token_to_pages.rb | 6 ++++++ 2 files changed, 15 insertions(+) create mode 100644 db/migrate/20260706030547_add_preview_token_to_pages.rb (limited to 'app/models') diff --git a/app/models/page.rb b/app/models/page.rb index 20461bf..4d1e94a 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -136,6 +136,15 @@ class Page < ApplicationRecord "/#{node.unique_name}" end + def ensure_preview_token! + update!(preview_token: SecureRandom.urlsafe_base64(24)) unless preview_token.present? + preview_token + end + + def revoke_preview_token! + update!(preview_token: nil) + end + def clone_attributes_from page return nil unless page diff --git a/db/migrate/20260706030547_add_preview_token_to_pages.rb b/db/migrate/20260706030547_add_preview_token_to_pages.rb new file mode 100644 index 0000000..794cbb2 --- /dev/null +++ b/db/migrate/20260706030547_add_preview_token_to_pages.rb @@ -0,0 +1,6 @@ +class AddPreviewTokenToPages < ActiveRecord::Migration[8.1] + def change + add_column :pages, :preview_token, :string + add_index :pages, :preview_token, unique: true + end +end -- 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/models') 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/models') 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/models') 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 131658e53f34964221e7f031b91f9dc184fe7481 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:51:45 +0200 Subject: Make sure that page title actually is saved through to ORM --- app/models/page.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'app/models') diff --git a/app/models/page.rb b/app/models/page.rb index 6fff7d6..fff044e 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -234,9 +234,7 @@ class Page < ApplicationRecord private def set_page_title - if title.nil? - title = "Untitled" - end + self.title = "Untitled" if title.nil? end def set_template -- 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/models') 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 6ad96c44d04df01e6abde097c681e824dd5fe745 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 17:31:15 +0200 Subject: Fix authorship and published_at loss in autosave promotion Two real bugs surfaced by the full test suite, not by the new tests written for this feature -- both were latent the moment lock_for_editing! and save_draft! replaced find_or_create_draft, and only visible once an existing test exercised the exact path each one lived in. lock_for_editing! never stamped user/editor onto a draft that already existed when the lock was acquired. find_or_create_draft used to do this as part of acquiring the lock; splitting lock acquisition from draft creation dropped it entirely, since it looked like draft-creation logic rather than locking logic. Restored as an explicit step: claim authorship only if none is set yet, editorship unconditionally, matching the old behavior exactly. save_draft!'s "no draft yet" branch set user/editor before calling clone_attributes_from, whose first line is an unconditional self.reload -- silently discarding both, since neither had been persisted yet. clone_attributes_from also always copies published_at from its source without the ||= guard used for template_name, and the source here is always the autosave, whose published_at is never anything but nil -- meaning every single promotion, not just the first, was quietly resetting a published page's published_at, which publish_draft!'s own ||= Time.now would then treat as never-published and re-stamp. Fixed by running clone_attributes_from first on both branches, then applying user/editor/published_at afterward, exactly as the existing-draft branch already happened to do by accident. Four controller tests updated to insert a real put :update between get :edit and assertions that used to be true immediately after visiting edit -- deferred draft creation means edit alone no longer produces one, which is the intended consequence of this whole redesign, not something these tests were meant to catch. --- app/models/node.rb | 36 ++++++++++++++++++++++++++++--- test/controllers/nodes_controller_test.rb | 7 ++++-- test/models/node_test.rb | 3 +++ 3 files changed, 41 insertions(+), 5 deletions(-) (limited to 'app/models') diff --git a/app/models/node.rb b/app/models/node.rb index 9eb0fe4..f15c908 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -56,6 +56,11 @@ class Node < ApplicationRecord def lock_for_editing! current_user if self.lock_owner.nil? || self.lock_owner == current_user lock_for! current_user + if self.draft + self.draft.user = current_user if self.draft.user.nil? + self.draft.editor = current_user + self.draft.save! + end self else raise( @@ -95,15 +100,18 @@ class Node < ApplicationRecord return unless self.autosave if self.draft + preserved_published_at = self.draft.published_at self.draft.clone_attributes_from self.autosave + self.draft.published_at = preserved_published_at 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 = self.pages.create! empty_page.clone_attributes_from self.autosave + empty_page.user = self.autosave.user_id ? self.autosave.user : (self.head ? self.head.user : current_user) + empty_page.editor = current_user + empty_page.published_at = self.head.published_at if self.head empty_page.save! self.draft = empty_page self.save! @@ -150,6 +158,28 @@ class Node < ApplicationRecord self.draft.reload end + # Discards exactly the topmost non-empty layer -- autosave if present, + # else draft -- and reveals whatever's beneath it. Releases the lock + # only once nothing is left to protect (no draft survives); leaves it + # alone whenever a draft remains, since #edit still has real content + # open. + def revert! current_user + assert_locked_by! current_user + + if self.autosave + self.autosave.destroy + self.autosave_id = nil + self.save! + elsif self.draft && self.head + self.draft.destroy + self.draft_id = nil + self.save! + end + + self.unlock! unless self.draft + self.reload + end + def staged_slug=(value) if head.blank? self.slug = value diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index 030cff0..f3e04c2 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -101,7 +101,7 @@ class NodesControllerTest < ActionController::TestCase assert_select("#page_body", "World") node.reload - assert_equal 2, node.pages.length + assert_equal 1, node.pages.length assert_equal "Hello", node.find_or_create_draft( User.first ).title assert_equal "World", node.find_or_create_draft( User.first ).body end @@ -291,6 +291,7 @@ class NodesControllerTest < ActionController::TestCase get :edit, params: { :id => node.id } assert_response :success + put :update, params: { :id => node.id, :page => { :title => "updated" } } put :publish, params: { :id => node.id } node.reload @@ -303,6 +304,7 @@ class NodesControllerTest < ActionController::TestCase node = create_node_with_published_page get :edit, params: { :id => node.id } + put :update, params: { :id => node.id, :page => { :title => "updated" } } put :publish, params: { :id => node.id } node.reload @@ -324,7 +326,8 @@ class NodesControllerTest < ActionController::TestCase assert_equal "quentin", node.head.user.login login_as :aaron - get :edit, params: {:id => node.id } + get :edit, params: { :id => node.id } + put :update, params: { :id => node.id, :page => { :title => "updated" } } node.reload assert_equal "quentin", node.head.user.login diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 2953f8f..bdf556d 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -339,6 +339,9 @@ class NodeTest < ActiveSupport::TestCase assert_equal head_revision, node.head.revision assert_nil node.autosave assert_equal 2, node.pages.count + assert_equal node.head.user, node.draft.user + assert_equal @user1, node.draft.editor + assert_equal node.head.published_at, node.draft.published_at end test "autosave!, save_draft!, and lock_for_editing! raise LockedByAnotherUser for a second user" do -- cgit v1.3 From 0bc112baec8b3d19aba3c1757cb4cf81fda098c5 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 22:49:49 +0200 Subject: Fix blank-title validation and its lost form state on re-render A blank title failed presence and length validation simultaneously, showing two redundant messages for one problem; length now skips whenever slug is already blank, matching presence's own skip condition. Separately, create's failure path never computed @selected_kind/@parent_id/@parent_name the way new does, so re-rendering after a validation error silently lost which kind and parent had been chosen -- for the auto-tagging/auto-templating kinds, that meant a corrected resubmission could silently produce a plain generic node instead. required on the title field closes the common case without a round trip; the server-side fix remains the actual guarantee, since required is trivially bypassed. --- app/controllers/nodes_controller.rb | 5 +++++ app/models/node.rb | 2 +- app/views/nodes/new.html.erb | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) (limited to 'app/models') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 6fd000b..b56d779 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -49,6 +49,11 @@ class NodesController < ApplicationController redirect_to(edit_node_path(@node)) else + @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" + if params[:parent_id].present? + @parent_id = params[:parent_id] + @parent_name = Node.find(@parent_id).title + end render :new end end diff --git a/app/models/node.rb b/app/models/node.rb index f15c908..7675ab6 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -17,7 +17,7 @@ class Node < ApplicationRecord after_save :update_unique_names_of_children # Validations - validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? } + validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } validates_presence_of :slug, :unless => -> { parent_id.nil? } validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } validates_presence_of :parent_id, :unless => -> { Node.root.nil? } diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 71f2fbf..afc632f 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -25,7 +25,7 @@
    Title
    - <%= text_field_tag :title %> + <%= text_field_tag :title, nil, required: true %> A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field.
    -- cgit v1.3 From 9c3217df50d462d6be4399e3e654d591bf704df7 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 15:29:40 +0200 Subject: Fix malformed-HTML fallout across RSS, link rewriting, and flash messaging Editors' TinyMCE output can contain unclosed void elements like
    , which is valid HTML5 but invalid XML -- three different places assumed the stricter rule and broke or silently misbehaved on the looser one. The Atom feed's block required real, well-formed XML structure but was handed a raw, unescaped body string; switched to type="html" with CGI.escapeHTML, matching how title/summary already handle the same content. rewrite_links_in_body used libxml's strict XML parser to rewrite internal links to be locale-prefixed, which raised on exactly this class of malformed markup -- silently, since the whole method was wrapped in rescue; nil, meaning the link rewrite (not the save) quietly failed with no error anywhere. Replaced with Nokogiri's lenient HTML parser, which repairs malformed void elements rather than rejecting them; also drops the bare rescue now that the actual failure mode it was guarding against shouldn't occur, and fixes two adjacent bugs found while in this method: a typo'd /sytem/uploads/ regex that could never match, and a missing https:// exclusion alongside the existing http:// one. Also addresses stale flash messaging surfaced while testing the above: update's save confirmation was being clobbered by edit's own "locked and ready" notice on the very next request, since nothing distinguished a fresh lock acquisition from a redirect back after saving. The save confirmation now names the next step (publish from Status) and flags a stale translation if one exists, using Page#outdated_translations?, already present but previously unused by any controller. --- app/controllers/nodes_controller.rb | 18 ++++++++++++++--- app/models/page.rb | 39 ++++++++++++++----------------------- app/views/layouts/admin.html.erb | 7 +++++++ app/views/rss/updates.xml.builder | 4 +--- public/stylesheets/admin.css | 9 +++++++++ 5 files changed, 47 insertions(+), 30 deletions(-) (limited to 'app/models') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 72d4a3e..38d42d9 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -65,15 +65,16 @@ class NodesController < ApplicationController end def edit + freshly_locked = @node.lock_owner != current_user @node.lock_for_editing!( current_user ) @page = @node.autosave || @node.draft || @node.head - flash.now[:notice] = "Node locked and ready to edit" unless @node.autosave - if @node.autosave flash.now[:notice] = "This page has unsaved changes from a previous session, shown below. " \ "Save to keep them, or use \"Discard changes\" below to go back to the last saved version." + elsif freshly_locked + flash.now[:notice] = "Node locked and ready to edit" end rescue LockedByAnotherUser => e flash[:error] = e.message @@ -85,7 +86,18 @@ class NodesController < ApplicationController @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}" + flash[:notice] = "Draft saved. Publish your changes in the Status section once you're done." + flash[:status_path] = node_path(@node) + + if @node.draft.translated_locales.size > 1 + stale_locale = @node.draft.translated_locales.find do |locale| + @node.draft.outdated_translations?(locale: locale) + end + if stale_locale + flash[:stale_locale] = stale_locale + flash[:stale_locale_path] = edit_node_path(@node, locale: stale_locale) + end + end if params[:commit] == "Save + Unlock + Exit" @node.unlock! diff --git a/app/models/page.rb b/app/models/page.rb index fff044e..1a98e08 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -243,31 +243,22 @@ class Page < ApplicationRecord end def rewrite_links_in_body - begin - if self.body - tmp_body = "
    #{self.body}
    " - xml_string = XML::Parser.string( tmp_body ) - xml_doc = xml_string.parse - links = xml_doc.find("//a[not(starts-with(@href, 'http://'))]") - links = links.reject { |l| l[:href] =~ /system\/uploads/ } - locales = I18n.available_locales.reject {|l| l == :root} - - links.each do |link| - unless locales.include? link[:href].slice(1,2).to_sym - unless link[:href] =~ /sytem\/uploads/ - link[:href] = link[:href].sub(/^\//, "/#{I18n.locale}/") - end - end - end - - tmp_body = xml_doc.to_s.gsub(/(\n\|\<\/div\>\n)/, "") - tmp_body.gsub!("", "") - - self.body = tmp_body + return unless self.body + + doc = Nokogiri::HTML::DocumentFragment.parse(self.body) + locales = I18n.available_locales.reject { |l| l == :root } + + doc.css('a').each do |link| + href = link['href'] + next unless href + next if href.start_with?('http://', 'https://') + next if href =~ /system\/uploads/ + + unless locales.include?(href.slice(1, 2)&.to_sym) + link['href'] = href.sub(/^\//, "/#{I18n.locale}/") end - rescue - nil end - end + self.body = doc.to_html + end end diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 340eaf2..79b1380 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -33,6 +33,13 @@ <% if flash[:notice].present? || flash[:error].present? %>
    <%= flash[:notice] %> + <% if flash[:status_path] %> + <%= link_to 'Go to Status', flash[:status_path] %> + <% end %> + <% if flash[:stale_locale_path] %> + The <%= flash[:stale_locale] %> translation may be out of date — + <%= link_to 'review it', flash[:stale_locale_path] %> too. + <% end %> <% if flash[:error] %> <%= flash[:error] %> <% end %> diff --git a/app/views/rss/updates.xml.builder b/app/views/rss/updates.xml.builder index 4b2e2f7..27845c4 100644 --- a/app/views/rss/updates.xml.builder +++ b/app/views/rss/updates.xml.builder @@ -22,9 +22,7 @@ xml.feed(:xmlns => "http://www.w3.org/2005/Atom", "xml:base" => @host) do xml.updated(item.updated_at.xmlschema) xml.published(item.published_at.xmlschema) xml.summary(CGI.escapeHTML(item.abstract.to_s)) - xml.content(:type => "xhtml") do - xml.div(item.body, :xmlns => "http://www.w3.org/1999/xhtml") - end + xml.content(CGI.escapeHTML(item.body.to_s), :type => "html") end end diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index f00a658..3f95b0c 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -193,6 +193,15 @@ input[type=radio] { margin-left: 5px; } +#flash a { + text-decoration: underline; + -webkit-text-decoration-style: wavy; + text-decoration-style: wavy; + text-decoration-color: #b0b0b0; + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + #flash span { letter-spacing: 1px; margin-right: 10px; -- cgit v1.3 From a25d90335ad3738b6831288190132c2f7498465c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 00:35:24 +0200 Subject: Retire vendored cacycle_diff.js for a diff-lcs-based diff view Revisions#diff now computes an inline or side-by-side word diff server-side (Page#diff_against, lib/html_word_diff.rb) instead of shipping raw/escaped content to the browser for a 2008-era vendored JS differ to mangle. View mode is picked via a `view` param, settable either on the diff page itself or directly from the revisions#index sticky bar next to "Diff revisions". Also fixes a pre-existing bug in RevisionsController#diff's single- revision branch, which set params[:start]/params[:end] instead of params[:start_revision]/params[:end_revision]. --- Gemfile | 3 +- Gemfile.lock | 3 + app/controllers/revisions_controller.rb | 12 +- app/models/page.rb | 16 + app/views/revisions/diff.html.erb | 78 +- app/views/revisions/index.html.erb | 8 + lib/html_word_diff.rb | 52 ++ public/javascripts/cacycle_diff.js | 1112 ------------------------- public/stylesheets/admin.css | 25 + test/controllers/revisions_controller_test.rb | 29 + test/models/page_test.rb | 34 + 11 files changed, 204 insertions(+), 1168 deletions(-) create mode 100644 lib/html_word_diff.rb delete mode 100644 public/javascripts/cacycle_diff.js (limited to 'app/models') diff --git a/Gemfile b/Gemfile index d136eb5..19d92d7 100644 --- a/Gemfile +++ b/Gemfile @@ -45,10 +45,11 @@ gem 'acts-as-taggable-on', git: 'https://github.com/mbleigh/acts-as-taggable-on.git', branch: 'master' -# ── XML / parsing ───────────────────────────────────────────────────────────── +# ── XML / parsing / diffing ─────────────────────────────────────────────────── gem 'libxml-ruby', '~> 5.0', require: 'xml' # body link rewriting in Page model gem 'nokogiri', '~> 1.18' +gem 'diff-lcs', require: 'diff/lcs' # ── Operational ─────────────────────────────────────────────────────────────── diff --git a/Gemfile.lock b/Gemfile.lock index 02c71ff..e6f5fa0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,7 @@ GEM connection_pool (3.0.2) crass (1.0.7) date (3.5.1) + diff-lcs (2.0.0) drb (2.2.3) erb (6.0.4) erubi (1.13.1) @@ -330,6 +331,7 @@ DEPENDENCIES chaos_calendar! coffee-rails (~> 4.0) concurrent-ruby (~> 1.3) + diff-lcs exception_notification (~> 4.5) globalize (~> 7.0) jquery-rails @@ -377,6 +379,7 @@ CHECKSUMS connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + diff-lcs (2.0.0) sha256=708a5d52ec2945b50f8f53a181174aa1ef2c496edf81c05957fe956dabb363d5 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb index 42d667e..9acb26f 100644 --- a/app/controllers/revisions_controller.rb +++ b/app/controllers/revisions_controller.rb @@ -13,16 +13,18 @@ class RevisionsController < ApplicationController def diff @node = Node.find(params[:node_id]) - + if @node.pages.length > 1 params[:start_revision] ||= @node.pages.all[-2].revision params[:end_revision] ||= @node.pages.all[-1].revision else - params[:start], params[:end] = 1, 1 + params[:start_revision], params[:end_revision] = 1, 1 end - - @start = @node.pages.find_by_revision( params[:start_revision] ) - @end = @node.pages.find_by_revision( params[:end_revision] ) + + @start = @node.pages.find_by_revision( params[:start_revision] ) + @end = @node.pages.find_by_revision( params[:end_revision] ) + @diff_view = params[:view] == "side_by_side" ? :side_by_side : :inline + @diff = @end.diff_against( @start, view: @diff_view ) end def show diff --git a/app/models/page.rb b/app/models/page.rb index 1a98e08..ea04cd6 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -175,6 +175,22 @@ class Page < ApplicationRecord self.save end + def diff_against other, view: :inline + if view == :side_by_side + { + title: HtmlWordDiff.side_by_side(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.side_by_side(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.side_by_side(other.body.to_s, body.to_s) + } + else + { + title: HtmlWordDiff.inline(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.inline(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.inline(other.body.to_s, body.to_s) + } + end + end + def public? published_at.nil? ? true : published_at < Time.now end diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d8c6a47..d7bb528 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -7,58 +7,36 @@ <%= form_tag diff_node_revisions_path do %> <%= select_tag :start_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:start_revision].to_i) %> <%= select_tag :end_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:end_revision].to_i) %> + <%= select_tag :view, options_for_select([['Inline', 'inline'], ['Side by side', 'side_by_side']], @diff_view) %> <%= submit_tag 'Diff' %> <% end %> - - - - - - - - -
    -  
    -
    -
    -  
    -
    - - - -
    -

    Title

    -

    - -

    Abstract

    -

    - -

    Body

    -

    + <% if @diff_view == :side_by_side %> +
    +
    +

    Title

    +

    <%= raw @diff[:title][0] %>

    +

    Abstract

    +

    <%= raw @diff[:abstract][0] %>

    +

    Body

    + <%= raw @diff[:body][0] %> +
    +
    +

    Title

    +

    <%= raw @diff[:title][1] %>

    +

    Abstract

    +

    <%= raw @diff[:abstract][1] %>

    +

    Body

    + <%= raw @diff[:body][1] %> +
    +
    + <% else %> +

    Title

    +

    <%= raw @diff[:title] %>

    +

    Abstract

    +

    <%= raw @diff[:abstract] %>

    +

    Body

    + <%= raw @diff[:body] %> + <% end %>
    diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index a6a981a..58c08b7 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -19,6 +19,8 @@ form: { id: 'diff_form', class: 'button_to computation' }, disabled: true %> + + @@ -68,6 +70,7 @@ document.getElementById('diff_form').addEventListener('submit', function(e) { var start = document.querySelector('input[name="start_revision"]:checked'); var end = document.querySelector('input[name="end_revision"]:checked'); + var view = document.querySelector('input[name="view"]:checked'); if (start) { var s = document.createElement('input'); s.type = 'hidden'; s.name = 'start_revision'; s.value = start.value; @@ -78,5 +81,10 @@ en.type = 'hidden'; en.name = 'end_revision'; en.value = end.value; this.appendChild(en); } + if (view) { + var v = document.createElement('input'); + v.type = 'hidden'; v.name = 'view'; v.value = view.value; + this.appendChild(v); + } }); diff --git a/lib/html_word_diff.rb b/lib/html_word_diff.rb new file mode 100644 index 0000000..1f28cf5 --- /dev/null +++ b/lib/html_word_diff.rb @@ -0,0 +1,52 @@ +require 'diff/lcs' + +module HtmlWordDiff + TOKEN_REGEXP = /<[^>]*>|[[:space:]]+|[[:alnum:]]+|[^[:space:][:alnum:]<>]/ + + def self.inline(old_html, new_html) + html = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + html << new_token + when '-' + html << "#{old_token}" + when '+' + html << "#{new_token}" + when '!' + html << "#{old_token}#{new_token}" + end + end + html + end + + def self.side_by_side(old_html, new_html) + old_out = +'' + new_out = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + old_out << old_token + new_out << new_token + when '-' + old_out << "#{old_token}" + when '+' + new_out << "#{new_token}" + when '!' + old_out << "#{old_token}" + new_out << "#{new_token}" + end + end + [old_out, new_out] + end + + def self.each_change(old_html, new_html) + Diff::LCS.sdiff(tokenize(old_html), tokenize(new_html)).each do |change| + yield change.action, change.old_element, change.new_element + end + end + + def self.tokenize(html) + html.to_s.scan(TOKEN_REGEXP) + end +end diff --git a/public/javascripts/cacycle_diff.js b/public/javascripts/cacycle_diff.js deleted file mode 100644 index 24f9d0b..0000000 --- a/public/javascripts/cacycle_diff.js +++ /dev/null @@ -1,1112 +0,0 @@ -//
    
    - 
    -/*
    - 
    -Name:    diff.js
    -Version: 0.9.5a (April 6, 2008)
    -Info:    http://en.wikipedia.org/wiki/User:Cacycle/diff
    -Code:    http://en.wikipedia.org/wiki/User:Cacycle/diff.js
    - 
    -JavaScript diff algorithm by [[en:User:Cacycle]] (http://en.wikipedia.org/wiki/User_talk:Cacycle).
    -Outputs html/css-formatted new text with highlighted deletions, inserts, and block moves.
    - 
    -The program uses cross-browser code and should work with all modern browsers. It has been tested with:
    -* Mozilla Firefox 1.5.0.1
    -* Mozilla SeaMonkey 1.0
    -* Opera 8.53
    -* Internet Explorer 6.0.2900.2180
    -* Internet Explorer 7.0.5730.11
    -This program is also compatibel with Greasemonkey
    - 
    -An implementation of the word-based algorithm from:
    - 
    -Communications of the ACM 21(4):264 (1978)
    -http://doi.acm.org/10.1145/359460.359467
    - 
    -With the following additional feature:
    - 
    -* Word types have been optimized for MediaWiki source texts
    -* Additional post-pass 5 code for resolving islands caused by adding
    -  two common words at the end of sequences of common words
    -* Additional detection of block borders and color coding of moved blocks and their original position
    -* Optional "intelligent" omission of unchanged parts from the output
    - 
    -This code is used by the MediaWiki in-browser text editors [[en:User:Cacycle/editor]] and [[en:User:Cacycle/wikEd]]
    -and the enhanced diff view tool wikEdDiff [[en:User:Cacycle/wikEd]].
    - 
    -Usage: var htmlText = WDiffString(oldText, newText);
    - 
    -This code has been released into the public domain.
    - 
    -Datastructures:
    - 
    -text: an object that holds all text related datastructures
    -  .newWords: consecutive words of the new text (N)
    -  .oldWords: consecutive words of the old text (O)
    -  .newToOld: array of corresponding word number in old text (NA)
    -  .oldToNew: array of corresponding word number in new text (OA)
    -  .message:  output message for testing purposes
    - 
    -symbol['word']: symbol table for passes 1 - 3, holds words as a hash
    -  .newCtr:  new word occurences counter (NC)
    -  .oldCtr:  old word occurences counter (OC)
    -  .toNew:   table last old word number
    -  .toOld:   last new word number (OLNA)
    - 
    -block: an object that holds block move information
    -  blocks indexed after new text:
    -  .newStart:  new text word number of start of this block
    -  .newLength: element number of this block including non-words
    -  .newWords:  true word number of this block
    -  .newNumber: corresponding block index in old text
    -  .newBlock:  moved-block-number of a block that has been moved here
    -  .newLeft:   moved-block-number of a block that has been moved from this border leftwards
    -  .newRight:  moved-block-number of a block that has been moved from this border rightwards
    -  .newLeftIndex:  index number of a block that has been moved from this border leftwards
    -  .newRightIndex: index number of a block that has been moved from this border rightwards
    -  blocks indexed after old text:
    -  .oldStart:  word number of start of this block
    -  .oldToNew:  corresponding new text word number of start
    -  .oldLength: element number of this block including non-words
    -  .oldWords:  true word number of this block
    - 
    -*/
    - 
    - 
    -// css for change indicators
    -if (typeof(wDiffStyleDelete) == 'undefined') { window.wDiffStyleDelete = 'font-weight: normal; text-decoration: none; color: #fff; background-color: #990033;'; }
    -if (typeof(wDiffStyleInsert) == 'undefined') { window.wDiffStyleInsert = 'font-weight: normal; text-decoration: none; color: #fff; background-color: #009933;'; }
    -if (typeof(wDiffStyleMoved)  == 'undefined') { window.wDiffStyleMoved  = 'font-weight: bold;  color: #000; vertical-align: text-bottom; font-size: xx-small; padding: 0; border: solid 1px;'; }
    -if (typeof(wDiffStyleBlock)  == 'undefined') { window.wDiffStyleBlock  = [
    -  'color: #000; background-color: #ffff80;',
    -  'color: #000; background-color: #c0ffff;',
    -  'color: #000; background-color: #ffd0f0;',
    -  'color: #000; background-color: #ffe080;',
    -  'color: #000; background-color: #aaddff;',
    -  'color: #000; background-color: #ddaaff;',
    -  'color: #000; background-color: #ffbbbb;',
    -  'color: #000; background-color: #d8ffa0;',
    -  'color: #000; background-color: #d0d0d0;'
    -]; }
    - 
    -// html for change indicators, {number} is replaced by the block number
    -// {block} is replaced by the block style, class and html comments are important for shortening the output
    -if (typeof(wDiffHtmlMovedRight)  == 'undefined') { window.wDiffHtmlMovedRight  = ''; }
    -if (typeof(wDiffHtmlMovedLeft)   == 'undefined') { window.wDiffHtmlMovedLeft   = ''; }
    - 
    -if (typeof(wDiffHtmlBlockStart)  == 'undefined') { window.wDiffHtmlBlockStart  = ''; }
    -if (typeof(wDiffHtmlBlockEnd)    == 'undefined') { window.wDiffHtmlBlockEnd    = ''; }
    - 
    -if (typeof(wDiffHtmlDeleteStart) == 'undefined') { window.wDiffHtmlDeleteStart = ''; }
    -if (typeof(wDiffHtmlDeleteEnd)   == 'undefined') { window.wDiffHtmlDeleteEnd   = ''; }
    - 
    -if (typeof(wDiffHtmlInsertStart) == 'undefined') { window.wDiffHtmlInsertStart = ''; }
    -if (typeof(wDiffHtmlInsertEnd)   == 'undefined') { window.wDiffHtmlInsertEnd   = ''; }
    - 
    -// minimal number of real words for a moved block (0 for always displaying block move indicators)
    -if (typeof(wDiffBlockMinLength) == 'undefined') { window.wDiffBlockMinLength = 3; }
    - 
    -// exclude identical sequence starts and endings from change marking
    -if (typeof(wDiffWordDiff) == 'undefined') { window.wDiffWordDiff = true; }
    - 
    -// enable recursive diff to resolve problematic sequences
    -if (typeof(wDiffRecursiveDiff) == 'undefined') { window.wDiffRecursiveDiff = true; }
    - 
    -// enable block move display
    -if (typeof(wDiffShowBlockMoves) == 'undefined') { window.wDiffShowBlockMoves = true; }
    - 
    -// remove unchanged parts from final output
    - 
    -// characters before diff tag to search for previous heading, paragraph, line break, cut characters
    -if (typeof(wDiffHeadingBefore)   == 'undefined') { window.wDiffHeadingBefore   = 1500; }
    -if (typeof(wDiffParagraphBefore) == 'undefined') { window.wDiffParagraphBefore = 1500; }
    -if (typeof(wDiffLineBeforeMax)   == 'undefined') { window.wDiffLineBeforeMax   = 1000; }
    -if (typeof(wDiffLineBeforeMin)   == 'undefined') { window.wDiffLineBeforeMin   =  500; }
    -if (typeof(wDiffBlankBeforeMax)  == 'undefined') { window.wDiffBlankBeforeMax  = 1000; }
    -if (typeof(wDiffBlankBeforeMin)  == 'undefined') { window.wDiffBlankBeforeMin  =  500; }
    -if (typeof(wDiffCharsBefore)     == 'undefined') { window.wDiffCharsBefore     =  500; }
    - 
    -// characters after diff tag to search for next heading, paragraph, line break, or characters
    -if (typeof(wDiffHeadingAfter)   == 'undefined') { window.wDiffHeadingAfter   = 1500; }
    -if (typeof(wDiffParagraphAfter) == 'undefined') { window.wDiffParagraphAfter = 1500; }
    -if (typeof(wDiffLineAfterMax)   == 'undefined') { window.wDiffLineAfterMax   = 1000; }
    -if (typeof(wDiffLineAfterMin)   == 'undefined') { window.wDiffLineAfterMin   =  500; }
    -if (typeof(wDiffBlankAfterMax)  == 'undefined') { window.wDiffBlankAfterMax  = 1000; }
    -if (typeof(wDiffBlankAfterMin)  == 'undefined') { window.wDiffBlankAfterMin  =  500; }
    -if (typeof(wDiffCharsAfter)     == 'undefined') { window.wDiffCharsAfter     =  500; }
    - 
    -// maximal fragment distance to join close fragments
    -if (typeof(wDiffFragmentJoin)  == 'undefined') { window.wDiffFragmentJoin = 1000; }
    -if (typeof(wDiffOmittedChars)  == 'undefined') { window.wDiffOmittedChars = '…'; }
    -if (typeof(wDiffOmittedLines)  == 'undefined') { window.wDiffOmittedLines = '
    '; } -if (typeof(wDiffNoChange) == 'undefined') { window.wDiffNoChange = '
    '; } - -// compatibility fix for old name of main function -window.StringDiff = window.WDiffString; - - -// WDiffString: main program -// input: oldText, newText, strings containing the texts -// returns: html diff - -window.WDiffString = function(oldText, newText) { - -// IE / Mac fix - oldText = oldText.replace(/(\r\n)/g, '\n'); - newText = newText.replace(/(\r\n)/g, '\n'); - - var text = {}; - text.newWords = []; - text.oldWords = []; - text.newToOld = []; - text.oldToNew = []; - text.message = ''; - var block = {}; - var outText = ''; - -// trap trivial changes: no change - if (oldText == newText) { - outText = newText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - return(outText); - } - -// trap trivial changes: old text deleted - if ( (oldText == null) || (oldText.length == 0) ) { - outText = newText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - outText = wDiffHtmlInsertStart + outText + wDiffHtmlInsertEnd; - return(outText); - } - -// trap trivial changes: new text deleted - if ( (newText == null) || (newText.length == 0) ) { - outText = oldText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - outText = wDiffHtmlDeleteStart + outText + wDiffHtmlDeleteEnd; - return(outText); - } - -// split new and old text into words - WDiffSplitText(oldText, newText, text); - -// calculate diff information - WDiffText(text); - -//detect block borders and moved blocks - WDiffDetectBlocks(text, block); - -// process diff data into formatted html text - outText = WDiffToHtml(text, block); - -// IE fix - outText = outText.replace(/> ( *) $1<'); - - return(outText); -} - - -// WDiffSplitText: split new and old text into words -// input: oldText, newText, strings containing the texts -// changes: text.newWords and text.oldWords, arrays containing the texts in arrays of words - -window.WDiffSplitText = function(oldText, newText, text) { - -// convert strange spaces - oldText = oldText.replace(/[\t\u000b\u00a0\u2028\u2029]+/g, ' '); - newText = newText.replace(/[\t\u000b\u00a0\u2028\u2029]+/g, ' '); - -// split old text into words - -// / | | | | | | | | | | | | | | / - var pattern = /[\w]+|\[\[|\]\]|\{\{|\}\}|\n+| +|&\w+;|'''|''|=+|\{\||\|\}|\|\-|./g; - var result; - do { - result = pattern.exec(oldText); - if (result != null) { - text.oldWords.push(result[0]); - } - } while (result != null); - -// split new text into words - do { - result = pattern.exec(newText); - if (result != null) { - text.newWords.push(result[0]); - } - } while (result != null); - - return; -} - - -// WDiffText: calculate diff information -// input: text.newWords and text.oldWords, arrays containing the texts in arrays of words -// optionally for recursive calls: newStart, newEnd, oldStart, oldEnd, recursionLevel -// changes: text.newToOld and text.oldToNew, containing the line numbers in the other version - -window.WDiffText = function(text, newStart, newEnd, oldStart, oldEnd, recursionLevel) { - - symbol = new Object(); - symbol.newCtr = []; - symbol.oldCtr = []; - symbol.toNew = []; - symbol.toOld = []; - -// set defaults - newStart = newStart || 0; - newEnd = newEnd || text.newWords.length; - oldStart = oldStart || 0; - oldEnd = oldEnd || text.oldWords.length; - recursionLevel = recursionLevel || 0; - -// limit recursion depth - if (recursionLevel > 10) { - return; - } - -// pass 1: parse new text into symbol table s - - var word; - for (var i = newStart; i < newEnd; i ++) { - word = text.newWords[i]; - -// add new entry to symbol table - if ( symbol[word] == null) { - symbol[word] = { newCtr: 0, oldCtr: 0, toNew: null, toOld: null }; - } - -// increment symbol table word counter for new text - symbol[word].newCtr ++; - -// add last word number in new text - symbol[word].toNew = i; - } - -// pass 2: parse old text into symbol table - - for (var j = oldStart; j < oldEnd; j ++) { - word = text.oldWords[j]; - -// add new entry to symbol table - if ( symbol[word] == null) { - symbol[word] = { newCtr: 0, oldCtr: 0, toNew: null, toOld: null }; - } - -// increment symbol table word counter for old text - symbol[word].oldCtr ++; - -// add last word number in old text - symbol[word].toOld = j; - } - -// pass 3: connect unique words - - for (var i in symbol) { - -// find words in the symbol table that occur only once in both versions - if ( (symbol[i].newCtr == 1) && (symbol[i].oldCtr == 1) ) { - var toNew = symbol[i].toNew; - var toOld = symbol[i].toOld; - -// do not use spaces as unique markers - if ( ! /\s/.test( text.newWords[toNew] ) ) { - -// connect from new to old and from old to new - text.newToOld[toNew] = toOld; - text.oldToNew[toOld] = toNew; - } - } - } - -// pass 4: connect adjacent identical words downwards - - for (var i = newStart; i < newEnd - 1; i ++) { - -// find already connected pairs - if (text.newToOld[i] != null) { - j = text.newToOld[i]; - -// check if the following words are not yet connected - if ( (text.newToOld[i + 1] == null) && (text.oldToNew[j + 1] == null) ) { - -// if the following words are the same connect them - if ( text.newWords[i + 1] == text.oldWords[j + 1] ) { - text.newToOld[i + 1] = j + 1; - text.oldToNew[j + 1] = i + 1; - } - } - } - } - -// pass 5: connect adjacent identical words upwards - - for (var i = newEnd - 1; i > newStart; i --) { - -// find already connected pairs - if (text.newToOld[i] != null) { - j = text.newToOld[i]; - -// check if the preceeding words are not yet connected - if ( (text.newToOld[i - 1] == null) && (text.oldToNew[j - 1] == null) ) { - -// if the preceeding words are the same connect them - if ( text.newWords[i - 1] == text.oldWords[j - 1] ) { - text.newToOld[i - 1] = j - 1; - text.oldToNew[j - 1] = i - 1; - } - } - } - } - -// recursively diff still unresolved regions downwards - - if (wDiffRecursiveDiff) { - i = newStart; - j = oldStart; - while (i < newEnd) { - if (text.newToOld[i - 1] != null) { - j = text.newToOld[i - 1] + 1; - } - -// check for the start of an unresolved sequence - if ( (text.newToOld[i] == null) && (text.oldToNew[j] == null) ) { - -// determine the ends of the sequences - var iStart = i; - var iEnd = i; - while ( (text.newToOld[iEnd] == null) && (iEnd < newEnd) ) { - iEnd ++; - } - var iLength = iEnd - iStart; - - var jStart = j; - var jEnd = j; - while ( (text.oldToNew[jEnd] == null) && (jEnd < oldEnd) ) { - jEnd ++; - } - var jLength = jEnd - jStart; - -// recursively diff the unresolved sequence - if ( (iLength > 0) && (jLength > 0) ) { - if ( (iLength > 1) || (jLength > 1) ) { - if ( (iStart != newStart) || (iEnd != newEnd) || (jStart != oldStart) || (jEnd != oldEnd) ) { - WDiffText(text, iStart, iEnd, jStart, jEnd, recursionLevel + 1); - } - } - } - i = iEnd; - } - else { - i ++; - } - } - } - -// recursively diff still unresolved regions upwards - - if (wDiffRecursiveDiff) { - i = newEnd - 1; - j = oldEnd - 1; - while (i >= newStart) { - if (text.newToOld[i + 1] != null) { - j = text.newToOld[i + 1] - 1; - } - -// check for the start of an unresolved sequence - if ( (text.newToOld[i] == null) && (text.oldToNew[j] == null) ) { - -// determine the ends of the sequences - var iStart = i; - var iEnd = i + 1; - while ( (text.newToOld[iStart - 1] == null) && (iStart >= newStart) ) { - iStart --; - } - var iLength = iEnd - iStart; - - var jStart = j; - var jEnd = j + 1; - while ( (text.oldToNew[jStart - 1] == null) && (jStart >= oldStart) ) { - jStart --; - } - var jLength = jEnd - jStart; - -// recursively diff the unresolved sequence - if ( (iLength > 0) && (jLength > 0) ) { - if ( (iLength > 1) || (jLength > 1) ) { - if ( (iStart != newStart) || (iEnd != newEnd) || (jStart != oldStart) || (jEnd != oldEnd) ) { - WDiffText(text, iStart, iEnd, jStart, jEnd, recursionLevel + 1); - } - } - } - i = iStart - 1; - } - else { - i --; - } - } - } - return; -} - - -// WDiffToHtml: process diff data into formatted html text -// input: text.newWords and text.oldWords, arrays containing the texts in arrays of words -// text.newToOld and text.oldToNew, containing the line numbers in the other version -// block data structure -// returns: outText, a html string - -window.WDiffToHtml = function(text, block) { - - var outText = text.message; - - var blockNumber = 0; - var i = 0; - var j = 0; - var movedAsInsertion; - -// cycle through the new text - do { - var movedIndex = []; - var movedBlock = []; - var movedLeft = []; - var blockText = ''; - var identText = ''; - var delText = ''; - var insText = ''; - var identStart = ''; - -// check if a block ends here and finish previous block - if (movedAsInsertion != null) { - if (movedAsInsertion == false) { - identStart += wDiffHtmlBlockEnd; - } - else { - identStart += wDiffHtmlInsertEnd; - } - movedAsInsertion = null; - } - -// detect block boundary - if ( (text.newToOld[i] != j) || (blockNumber == 0 ) ) { - if ( ( (text.newToOld[i] != null) || (i >= text.newWords.length) ) && ( (text.oldToNew[j] != null) || (j >= text.oldWords.length) ) ) { - -// block moved right - var moved = block.newRight[blockNumber]; - if (moved > 0) { - var index = block.newRightIndex[blockNumber]; - movedIndex.push(index); - movedBlock.push(moved); - movedLeft.push(false); - } - -// block moved left - moved = block.newLeft[blockNumber]; - if (moved > 0) { - var index = block.newLeftIndex[blockNumber]; - movedIndex.push(index); - movedBlock.push(moved); - movedLeft.push(true); - } - -// check if a block starts here - moved = block.newBlock[blockNumber]; - if (moved > 0) { - -// mark block as inserted text - if (block.newWords[blockNumber] < wDiffBlockMinLength) { - identStart += wDiffHtmlInsertStart; - movedAsInsertion = true; - } - -// mark block by color - else { - if (moved > wDiffStyleBlock.length) { - moved = wDiffStyleBlock.length; - } - identStart += WDiffHtmlCustomize(wDiffHtmlBlockStart, moved - 1); - movedAsInsertion = false; - } - } - - if (i >= text.newWords.length) { - i ++; - } - else { - j = text.newToOld[i]; - blockNumber ++; - } - } - } - -// get the correct order if moved to the left as well as to the right from here - if (movedIndex.length == 2) { - if (movedIndex[0] > movedIndex[1]) { - movedIndex.reverse(); - movedBlock.reverse(); - movedLeft.reverse(); - } - } - -// handle left and right block moves from this position - for (var m = 0; m < movedIndex.length; m ++) { - -// insert the block as deleted text - if (block.newWords[ movedIndex[m] ] < wDiffBlockMinLength) { - var movedStart = block.newStart[ movedIndex[m] ]; - var movedLength = block.newLength[ movedIndex[m] ]; - var str = ''; - for (var n = movedStart; n < movedStart + movedLength; n ++) { - str += text.newWords[n]; - } - str = WDiffEscape(str); - str = str.replace(/\n/g, '¶
    '); - blockText += wDiffHtmlDeleteStart + str + wDiffHtmlDeleteEnd; - } - -// add a placeholder / move direction indicator - else { - if (movedBlock[m] > wDiffStyleBlock.length) { - movedBlock[m] = wDiffStyleBlock.length; - } - if (movedLeft[m]) { - blockText += WDiffHtmlCustomize(wDiffHtmlMovedLeft, movedBlock[m] - 1); - } - else { - blockText += WDiffHtmlCustomize(wDiffHtmlMovedRight, movedBlock[m] - 1); - } - } - } - -// collect consecutive identical text - while ( (i < text.newWords.length) && (j < text.oldWords.length) ) { - if ( (text.newToOld[i] == null) || (text.oldToNew[j] == null) ) { - break; - } - if (text.newToOld[i] != j) { - break; - } - identText += text.newWords[i]; - i ++; - j ++; - } - -// collect consecutive deletions - while ( (text.oldToNew[j] == null) && (j < text.oldWords.length) ) { - delText += text.oldWords[j]; - j ++; - } - -// collect consecutive inserts - while ( (text.newToOld[i] == null) && (i < text.newWords.length) ) { - insText += text.newWords[i]; - i ++; - } - -// remove leading and trailing similarities betweein delText and ins from highlighting - var preText = ''; - var postText = ''; - if (wDiffWordDiff) { - if ( (delText != '') && (insText != '') ) { - -// remove leading similarities - while ( delText.charAt(0) == insText.charAt(0) && (delText != '') && (insText != '') ) { - preText = preText + delText.charAt(0); - delText = delText.substr(1); - insText = insText.substr(1); - } - -// remove trailing similarities - while ( delText.charAt(delText.length - 1) == insText.charAt(insText.length - 1) && (delText != '') && (insText != '') ) { - postText = delText.charAt(delText.length - 1) + postText; - delText = delText.substr(0, delText.length - 1); - insText = insText.substr(0, insText.length - 1); - } - } - } - -// output the identical text, deletions and inserts - -// moved from here indicator - if (blockText != '') { - outText += blockText; - } - -// identical text - if (identText != '') { - outText += identStart + WDiffEscape(identText); - } - outText += preText; - -// deleted text - if (delText != '') { - delText = wDiffHtmlDeleteStart + WDiffEscape(delText) + wDiffHtmlDeleteEnd; - delText = delText.replace(/\n/g, '¶
    '); - outText += delText; - } - -// inserted text - if (insText != '') { - insText = wDiffHtmlInsertStart + WDiffEscape(insText) + wDiffHtmlInsertEnd; - insText = insText.replace(/\n/g, '¶
    '); - outText += insText; - } - outText += postText; - } while (i <= text.newWords.length); - - outText += '\n'; - outText = WDiffHtmlFormat(outText); - - return(outText); -} - - -// WDiffEscape: replaces html-sensitive characters in output text with character entities - -window.WDiffEscape = function(text) { - - text = text.replace(/&/g, '&'); - text = text.replace(//g, '>'); - text = text.replace(/\"/g, '"'); - - return(text); -} - - -// HtmlCustomize: customize indicator html: replace {number} with the block number, {block} with the block style - -window.WDiffHtmlCustomize = function(text, block) { - - text = text.replace(/\{number\}/, block); - text = text.replace(/\{block\}/, wDiffStyleBlock[block]); - - return(text); -} - - -// HtmlFormat: replaces newlines and multiple spaces in text with html code - -window.WDiffHtmlFormat = function(text) { - - text = text.replace(/ /g, '  '); - text = text.replace(/\n/g, '
    '); - - return(text); -} - - -// WDiffDetectBlocks: detect block borders and moved blocks -// input: text object, block object - -window.WDiffDetectBlocks = function(text, block) { - - block.oldStart = []; - block.oldToNew = []; - block.oldLength = []; - block.oldWords = []; - block.newStart = []; - block.newLength = []; - block.newWords = []; - block.newNumber = []; - block.newBlock = []; - block.newLeft = []; - block.newRight = []; - block.newLeftIndex = []; - block.newRightIndex = []; - - var blockNumber = 0; - var wordCounter = 0; - var realWordCounter = 0; - -// get old text block order - if (wDiffShowBlockMoves) { - var j = 0; - var i = 0; - do { - -// detect block boundaries on old text - if ( (text.oldToNew[j] != i) || (blockNumber == 0 ) ) { - if ( ( (text.oldToNew[j] != null) || (j >= text.oldWords.length) ) && ( (text.newToOld[i] != null) || (i >= text.newWords.length) ) ) { - if (blockNumber > 0) { - block.oldLength[blockNumber - 1] = wordCounter; - block.oldWords[blockNumber - 1] = realWordCounter; - wordCounter = 0; - realWordCounter = 0; - } - - if (j >= text.oldWords.length) { - j ++; - } - else { - i = text.oldToNew[j]; - block.oldStart[blockNumber] = j; - block.oldToNew[blockNumber] = text.oldToNew[j]; - blockNumber ++; - } - } - } - -// jump over identical pairs - while ( (i < text.newWords.length) && (j < text.oldWords.length) ) { - if ( (text.newToOld[i] == null) || (text.oldToNew[j] == null) ) { - break; - } - if (text.oldToNew[j] != i) { - break; - } - i ++; - j ++; - wordCounter ++; - if ( /\w/.test( text.newWords[i] ) ) { - realWordCounter ++; - } - } - -// jump over consecutive deletions - while ( (text.oldToNew[j] == null) && (j < text.oldWords.length) ) { - j ++; - } - -// jump over consecutive inserts - while ( (text.newToOld[i] == null) && (i < text.newWords.length) ) { - i ++; - } - } while (j <= text.oldWords.length); - -// get the block order in the new text - var lastMin; - var currMinIndex; - lastMin = null; - -// sort the data by increasing start numbers into new text block info - for (var i = 0; i < blockNumber; i ++) { - currMin = null; - for (var j = 0; j < blockNumber; j ++) { - curr = block.oldToNew[j]; - if ( (curr > lastMin) || (lastMin == null) ) { - if ( (curr < currMin) || (currMin == null) ) { - currMin = curr; - currMinIndex = j; - } - } - } - block.newStart[i] = block.oldToNew[currMinIndex]; - block.newLength[i] = block.oldLength[currMinIndex]; - block.newWords[i] = block.oldWords[currMinIndex]; - block.newNumber[i] = currMinIndex; - lastMin = currMin; - } - -// detect not moved blocks - for (var i = 0; i < blockNumber; i ++) { - if (block.newBlock[i] == null) { - if (block.newNumber[i] == i) { - block.newBlock[i] = 0; - } - } - } - -// detect switches of neighbouring blocks - for (var i = 0; i < blockNumber - 1; i ++) { - if ( (block.newBlock[i] == null) && (block.newBlock[i + 1] == null) ) { - if (block.newNumber[i] - block.newNumber[i + 1] == 1) { - if ( (block.newNumber[i + 1] - block.newNumber[i + 2] != 1) || (i + 2 >= blockNumber) ) { - -// the shorter one is declared the moved one - if (block.newLength[i] < block.newLength[i + 1]) { - block.newBlock[i] = 1; - block.newBlock[i + 1] = 0; - } - else { - block.newBlock[i] = 0; - block.newBlock[i + 1] = 1; - } - } - } - } - } - -// mark all others as moved and number the moved blocks - j = 1; - for (var i = 0; i < blockNumber; i ++) { - if ( (block.newBlock[i] == null) || (block.newBlock[i] == 1) ) { - block.newBlock[i] = j++; - } - } - -// check if a block has been moved from this block border - for (var i = 0; i < blockNumber; i ++) { - for (var j = 0; j < blockNumber; j ++) { - - if (block.newNumber[j] == i) { - if (block.newBlock[j] > 0) { - -// block moved right - if (block.newNumber[j] < j) { - block.newRight[i] = block.newBlock[j]; - block.newRightIndex[i] = j; - } - -// block moved left - else { - block.newLeft[i + 1] = block.newBlock[j]; - block.newLeftIndex[i + 1] = j; - } - } - } - } - } - } - return; -} - - -// WDiffShortenOutput: remove unchanged parts from final output -// input: the output of WDiffString -// returns: the text with removed unchanged passages indicated by (...) - -window.WDiffShortenOutput = function(diffText) { - -// html
    to newlines - diffText = diffText.replace(/]*>/g, '\n'); - -// scan for diff html tags - var regExpDiff = new RegExp('<\\w+ class=\\"(\\w+)\\"[^>]*>(.|\\n)*?', 'g'); - var tagStart = []; - var tagEnd = []; - var i = 0; - var found; - while ( (found = regExpDiff.exec(diffText)) != null ) { - -// combine consecutive diff tags - if ( (i > 0) && (tagEnd[i - 1] == found.index) ) { - tagEnd[i - 1] = found.index + found[0].length; - } - else { - tagStart[i] = found.index; - tagEnd[i] = found.index + found[0].length; - i ++; - } - } - -// no diff tags detected - if (tagStart.length == 0) { - return(wDiffNoChange); - } - -// define regexps - var regExpHeading = new RegExp('\\n=+.+?=+ *\\n|\\n\\{\\||\\n\\|\\}', 'g'); - var regExpParagraph = new RegExp('\\n\\n+', 'g'); - var regExpLine = new RegExp('\\n+', 'g'); - var regExpBlank = new RegExp('(<[^>]+>)*\\s+', 'g'); - -// determine fragment border positions around diff tags - var rangeStart = []; - var rangeEnd = []; - var rangeStartType = []; - var rangeEndType = []; - for (var i = 0; i < tagStart.length; i ++) { - var found; - -// find last heading before diff tag - var lastPos = tagStart[i] - wDiffHeadingBefore; - if (lastPos < 0) { - lastPos = 0; - } - regExpHeading.lastIndex = lastPos; - while ( (found = regExpHeading.exec(diffText)) != null ) { - if (found.index > tagStart[i]) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'heading'; - } - -// find last paragraph before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffParagraphBefore; - if (lastPos < 0) { - lastPos = 0; - } - regExpParagraph.lastIndex = lastPos; - while ( (found = regExpParagraph.exec(diffText)) != null ) { - if (found.index > tagStart[i]) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'paragraph'; - } - } - -// find line break before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffLineBeforeMax; - if (lastPos < 0) { - lastPos = 0; - } - regExpLine.lastIndex = lastPos; - while ( (found = regExpLine.exec(diffText)) != null ) { - if (found.index > tagStart[i] - wDiffLineBeforeMin) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'line'; - } - } - -// find blank before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffBlankBeforeMax; - if (lastPos < 0) { - lastPos = 0; - } - regExpBlank.lastIndex = lastPos; - while ( (found = regExpBlank.exec(diffText)) != null ) { - if (found.index > tagStart[i] - wDiffBlankBeforeMin) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'blank'; - } - } - -// fixed number of chars before diff tag - if (rangeStart[i] == null) { - rangeStart[i] = tagStart[i] - wDiffCharsBefore; - rangeStartType[i] = 'chars'; - if (rangeStart[i] < 0) { - rangeStart[i] = 0; - } - } - -// find first heading after diff tag - regExpHeading.lastIndex = tagEnd[i]; - if ( (found = regExpHeading.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffHeadingAfter) { - rangeEnd[i] = found.index + found[0].length; - rangeEndType[i] = 'heading'; - } - } - -// find first paragraph after diff tag - if (rangeEnd[i] == null) { - regExpParagraph.lastIndex = tagEnd[i]; - if ( (found = regExpParagraph.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffParagraphAfter) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'paragraph'; - } - } - } - -// find first line break after diff tag - if (rangeEnd[i] == null) { - regExpLine.lastIndex = tagEnd[i] + wDiffLineAfterMin; - if ( (found = regExpLine.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffLineAfterMax) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'break'; - } - } - } - -// find blank after diff tag - if (rangeEnd[i] == null) { - regExpBlank.lastIndex = tagEnd[i] + wDiffBlankAfterMin; - if ( (found = regExpBlank.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffBlankAfterMax) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'blank'; - } - } - } - -// fixed number of chars after diff tag - if (rangeEnd[i] == null) { - rangeEnd[i] = tagEnd[i] + wDiffCharsAfter; - if (rangeEnd[i] > diffText.length) { - rangeEnd[i] = diffText.length; - rangeEndType[i] = 'chars'; - } - } - } - -// remove overlaps, join close fragments - var fragmentStart = []; - var fragmentEnd = []; - var fragmentStartType = []; - var fragmentEndType = []; - fragmentStart[0] = rangeStart[0]; - fragmentEnd[0] = rangeEnd[0]; - fragmentStartType[0] = rangeStartType[0]; - fragmentEndType[0] = rangeEndType[0]; - var j = 1; - for (var i = 1; i < rangeStart.length; i ++) { - if (rangeStart[i] > fragmentEnd[j - 1] + wDiffFragmentJoin) { - fragmentStart[j] = rangeStart[i]; - fragmentEnd[j] = rangeEnd[i]; - fragmentStartType[j] = rangeStartType[i]; - fragmentEndType[j] = rangeEndType[i]; - j ++; - } - else { - fragmentEnd[j - 1] = rangeEnd[i]; - fragmentEndType[j - 1] = rangeEndType[i]; - } - } - -// assemble the fragments - var outText = ''; - for (var i = 0; i < fragmentStart.length; i ++) { - -// get text fragment - var fragment = diffText.substring(fragmentStart[i], fragmentEnd[i]); - var fragment = fragment.replace(/^\n+|\n+$/g, ''); - -// add inline marks for omitted chars and words - if (fragmentStart[i] > 0) { - if (fragmentStartType[i] == 'chars') { - fragment = wDiffOmittedChars + fragment; - } - else if (fragmentStartType[i] == 'blank') { - fragment = wDiffOmittedChars + ' ' + fragment; - } - } - if (fragmentEnd[i] < diffText.length) { - if (fragmentStartType[i] == 'chars') { - fragment = fragment + wDiffOmittedChars; - } - else if (fragmentStartType[i] == 'blank') { - fragment = fragment + ' ' + wDiffOmittedChars; - } - } - -// add omitted line separator - if (fragmentStart[i] > 0) { - outText += wDiffOmittedLines; - } - -// encapsulate span errors - outText += '
    ' + fragment + '
    '; - } - -// add trailing omitted line separator - if (fragmentEnd[i - 1] < diffText.length) { - outText = outText + wDiffOmittedLines; - } - -// remove leading and trailing empty lines - outText = outText.replace(/^(
    )\n+|\n+(<\/div>)$/g, '$1$2'); - -// convert to html linebreaks - outText = outText.replace(/\n/g, '
    '); - - return(outText); -} - - -//
    
    \ No newline at end of file
    diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
    index 38c9e5a..1bb6cf4 100644
    --- a/public/stylesheets/admin.css
    +++ b/public/stylesheets/admin.css
    @@ -511,6 +511,31 @@ table.revisions_table tr:hover {
       background-color: #f1f1f1;
     }
     
    +#diffview del {
    +  background: #ffd7d5;
    +  color: #82071e;
    +  text-decoration: line-through;
    +  padding: 0 2px;
    +  border-radius: 2px;
    +}
    +
    +#diffview ins {
    +  background: #ccffd8;
    +  color: #055d20;
    +  text-decoration: none;
    +  padding: 0 2px;
    +  border-radius: 2px;
    +}
    +
    +#diffview .diff_side_by_side {
    +  display: flex;
    +  gap: 1rem;
    +}
    +
    +#diffview .diff_column {
    +  flex: 1;
    +}
    +
     table.user_table td.user_login {
       padding-right: 30px;
     }
    diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb
    index b4dcd8f..e2fc976 100644
    --- a/test/controllers/revisions_controller_test.rb
    +++ b/test/controllers/revisions_controller_test.rb
    @@ -59,4 +59,33 @@ class RevisionsControllerTest < ActionController::TestCase
         assert_equal @node.head, @node.pages.first
         assert_equal "first", @node.head.reload.body
       end
    +
    +  test "diffing two revisions renders real markup with only the changed words marked" do
    +    login_as :quentin
    +    post(
    +      :diff, params: {
    +        :node_id => @node.id,
    +        :start_revision => @node.pages.first.revision,
    +        :end_revision => @node.pages.last.revision
    +      }
    +    )
    +    assert_response :success
    +    assert_select "del", "first"
    +    assert_select "ins", "second"
    +    assert_no_match /</, response.body
    +  end
    +
    +  test "diffing two revisions in side by side view renders two columns" do
    +    login_as :quentin
    +    post(
    +      :diff, params: {
    +        :node_id => @node.id,
    +        :start_revision => @node.pages.first.revision,
    +        :end_revision => @node.pages.last.revision,
    +        :view => "side_by_side"
    +      }
    +    )
    +    assert_response :success
    +    assert_select ".diff_column", 2
    +  end
     end
    diff --git a/test/models/page_test.rb b/test/models/page_test.rb
    index ad2742f..ac5691a 100644
    --- a/test/models/page_test.rb
    +++ b/test/models/page_test.rb
    @@ -176,4 +176,38 @@ class PageTest < ActiveSupport::TestCase
         assert_not_equal first_page.id, first_page.node.head_id
         assert first_page.published_at.present?
       end
    +
    +  def test_diff_against_inline_keeps_tags_and_marks_only_the_changed_word
    +    n = Node.root.children.create! :slug => "diff_against_test"
    +    d = n.find_or_create_draft @user1
    +    d.title = "Old heading"
    +    d.save!
    +    n.publish_draft!
    +
    +    d2 = n.find_or_create_draft @user1
    +    d2.title = "New heading"
    +    d2.save!
    +
    +    diff = d2.diff_against(n.head)
    +
    +    assert_match "Old", diff[:title]
    +    assert_match "New", diff[:title]
    +  end
    +
    +  def test_diff_against_side_by_side_returns_two_annotated_strings
    +    n = Node.root.children.create! :slug => "diff_against_sbs_test"
    +    d = n.find_or_create_draft @user1
    +    d.title = "Old heading"
    +    d.save!
    +    n.publish_draft!
    +
    +    d2 = n.find_or_create_draft @user1
    +    d2.title = "New heading"
    +    d2.save!
    +
    +    old_html, new_html = d2.diff_against(n.head, view: :side_by_side)[:title]
    +
    +    assert_match "Old", old_html
    +    assert_match "New", new_html
    +  end
     end
    -- 
    cgit v1.3
    
    
    From 205e6216fc7850fe717122c189e5003d1f9e8afe Mon Sep 17 00:00:00 2001
    From: erdgeist 
    Date: Fri, 10 Jul 2026 02:03:19 +0200
    Subject: Add head/draft/autosave layer comparison at three UI entry points
    
    Node#resolve_page_reference and #available_layer_pairs let
    Page#diff_against compare named layers (head/draft/autosave), not
    just numbered revisions -- autosave was never part of Node#pages, so
    this was the missing piece.
    
    Wired into nodes#show's Status section, nodes#edit right after an
    autosave gets resurrected ("What changed?"), and the admin wizard's
    current-drafts table, which now also lists autosave-only nodes it
    previously never showed.
    
    revisions#diff hides the numbered-revision picker when comparing
    named layers (it can't represent them), shows a plain label instead,
    and offers buttons to switch between whichever other pairs make
    sense for the node's current state. Destroying the topmost layer is
    available directly from the diff view, reusing the existing revert!
    path.
    
    "Discard changes" is renamed "Discard Autosave" everywhere it
    appears, to match "Destroy Draft".
    ---
     app/controllers/admin_controller.rb           |  4 +--
     app/controllers/nodes_controller.rb           |  2 +-
     app/controllers/revisions_controller.rb       | 16 ++++++++---
     app/helpers/revisions_helper.rb               |  5 ++++
     app/models/node.rb                            | 21 ++++++++++++++
     app/views/admin/index.html.erb                | 13 +++++++--
     app/views/nodes/edit.html.erb                 |  9 +++++-
     app/views/nodes/show.html.erb                 | 12 +++++++-
     app/views/revisions/diff.html.erb             | 40 +++++++++++++++++++++++----
     config/routes.rb                              |  1 +
     test/controllers/admin_controller_test.rb     | 15 ++++++++--
     test/controllers/revisions_controller_test.rb | 39 ++++++++++++++++++++++++++
     test/models/node_test.rb                      | 22 +++++++++++++++
     13 files changed, 179 insertions(+), 20 deletions(-)
    
    (limited to 'app/models')
    
    diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
    index 3fa0519..6ab2135 100644
    --- a/app/controllers/admin_controller.rb
    +++ b/app/controllers/admin_controller.rb
    @@ -5,10 +5,10 @@ class AdminController < ApplicationController
       before_action :login_required
     
       def index
    -    @drafts = Node.where("draft_id IS NOT NULL")
    +    @drafts = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL")
           .limit(50).order("updated_at desc")
     
    -    @drafts_count = Node.where("draft_id IS NOT NULL").count
    +    @drafts_count = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").count
     
         @recent_changes = Node.where(
           "updated_at < ? AND updated_at > ? AND parent_id IS NOT NULL",
    diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
    index 38d42d9..d1538e1 100644
    --- a/app/controllers/nodes_controller.rb
    +++ b/app/controllers/nodes_controller.rb
    @@ -72,7 +72,7 @@ class NodesController < ApplicationController
         if @node.autosave
           flash.now[:notice] =
             "This page has unsaved changes from a previous session, shown below. " \
    -        "Save to keep them, or use \"Discard changes\" below to go back to the last saved version."
    +        "Save to keep them, or use \"Discard Autosave\" below to go back to the last saved version."
         elsif freshly_locked
           flash.now[:notice] = "Node locked and ready to edit"
         end
    diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb
    index 9acb26f..4b0c549 100644
    --- a/app/controllers/revisions_controller.rb
    +++ b/app/controllers/revisions_controller.rb
    @@ -21,10 +21,18 @@ class RevisionsController < ApplicationController
           params[:start_revision], params[:end_revision] = 1, 1
         end
     
    -    @start      = @node.pages.find_by_revision( params[:start_revision] )
    -    @end        = @node.pages.find_by_revision( params[:end_revision] )
    -    @diff_view  = params[:view] == "side_by_side" ? :side_by_side : :inline
    -    @diff       = @end.diff_against( @start, view: @diff_view )
    +    @start = @node.resolve_page_reference(params[:start_revision])
    +    @end   = @node.resolve_page_reference(params[:end_revision])
    +
    +    if @start.nil? || @end.nil?
    +      flash[:error] = "That comparison is no longer available."
    +      redirect_to(node_path(@node)) and return
    +    end
    +
    +    @diff_view              = params[:view] == "side_by_side" ? :side_by_side : :inline
    +    @diff                   = @end.diff_against(@start, view: @diff_view)
    +    @available_layer_pairs  = @node.available_layer_pairs
    +    @locked_by_other        = @node.locked? && @node.lock_owner != current_user
       end
     
       def show
    diff --git a/app/helpers/revisions_helper.rb b/app/helpers/revisions_helper.rb
    index fdb51f8..a629013 100644
    --- a/app/helpers/revisions_helper.rb
    +++ b/app/helpers/revisions_helper.rb
    @@ -1,2 +1,7 @@
     module RevisionsHelper
    +  # Human-readable label for a diff endpoint -- "head"/"draft"/"autosave"
    +  # get their name; anything else is a revision number.
    +  def describe_page_reference(ref)
    +    %w[head draft autosave].include?(ref.to_s) ? ref.to_s.capitalize : "revision #{ref}"
    +  end
     end
    diff --git a/app/models/node.rb b/app/models/node.rb
    index 7675ab6..82d9954 100644
    --- a/app/models/node.rb
    +++ b/app/models/node.rb
    @@ -123,6 +123,27 @@ class Node < ApplicationRecord
         self.draft.reload
       end
     
    +  def resolve_page_reference ref
    +    case ref.to_s
    +    when "head" then head
    +    when "draft" then draft
    +    when "autosave" then autosave
    +    else pages.find_by_revision(ref)
    +    end
    +  end
    +
    +  # Which layer-pairs are meaningful to compare right now, given this
    +  # node's actual state. Head vs autosave only shows up when no draft
    +  # sits between them -- with a draft present, autosave is compared
    +  # against the draft, never past it straight to head.
    +  def available_layer_pairs
    +    pairs = []
    +    pairs << [:head, :draft]     if head && draft
    +    pairs << [:draft, :autosave] if draft && autosave
    +    pairs << [:head, :autosave]  if head && autosave && !draft
    +    pairs
    +  end
    +
       def find_or_create_draft current_user
         self.wipe_draft!
         if draft && self.lock_owner == current_user
    diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
    index 77b45f4..c67ccb3 100644
    --- a/app/views/admin/index.html.erb
    +++ b/app/views/admin/index.html.erb
    @@ -82,9 +82,9 @@
     
    - -

    Current Drafts (<%= @drafts_count %>)

    - + +

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

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

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

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

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

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

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

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

    <% end %>
    diff --git a/config/routes.rb b/config/routes.rb index c0aef2f..da6b626 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -47,6 +47,7 @@ Cccms::Application.routes.draw do resources :revisions do collection do post :diff + get :diff end member do put :restore diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 9bbf29b..d6005ba 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -1,8 +1,17 @@ require 'test_helper' class AdminControllerTest < ActionController::TestCase - # Replace this with your real tests. - test "the truth" do - assert true + test "current drafts includes nodes with only an autosave" do + node = Node.root.children.create!(:slug => "admin_autosave_only") + node.lock_for_editing!(User.find_by_login("aaron")) + node.autosave!({title: "in progress"}, User.find_by_login("aaron")) + node.save_draft!(User.find_by_login("aaron")) + node.publish_draft! + node.lock_for_editing!(User.find_by_login("aaron")) + node.autosave!({title: "editing again"}, User.find_by_login("aaron")) + + login_as :quentin + get :index + assert_includes assigns(:drafts), node end end diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index caca6bf..162e6f1 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -100,4 +100,43 @@ class RevisionsControllerTest < ActionController::TestCase assert_response :success assert_select ".diff_column", 2 end + + test "diffing head against draft by name" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.draft.update(:body => "draft body") + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + end + + test "diffing a layer pair that no longer exists redirects with a flash" do + login_as :quentin + post(:diff, params: { :node_id => @node.id, :start_revision => "draft", :end_revision => "autosave" }) + assert_redirected_to node_path(@node) + assert flash[:error].present? + end + + test "diffing by name shows a clear comparison label instead of a misleading revision picker" do + login_as :quentin + @node.find_or_create_draft(@user) + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + assert_select "strong", "Head" + assert_select "strong", "Draft" + assert_select "select[name=?]", "start_revision", :count => 0 + end + + test "pair-switcher buttons carry their params as real hidden fields, not a query string" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.lock_for_editing!(@user) + @node.autosave!({ :body => "unsaved" }, @user) + + post(:diff, params: { :node_id => @node.id, :start_revision => "head", :end_revision => "draft" }) + assert_response :success + assert_select "form.computation input[type=hidden][name=start_revision]" + assert_select "form.computation input[type=hidden][name=end_revision]" + end end diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 2138c19..9e71dec 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -473,4 +473,26 @@ class NodeTest < ActiveSupport::TestCase node.publish_draft! end end + + test "available_layer_pairs matches the six-state table" do + node = Node.root.children.create!(:slug => "layer_pairs_test") + user = @user1 || User.find_by_login("aaron") + + assert_equal [[:draft, :autosave]], (node.lock_for_editing!(user); node.autosave!({title: "v1"}, user); node.available_layer_pairs) # state F + + node.save_draft!(user) + node.publish_draft! + assert_equal [], node.available_layer_pairs # state A + + node.lock_for_editing!(user) + node.autosave!({title: "v2"}, user) + assert_equal [[:head, :autosave]], node.available_layer_pairs # state B + + node.save_draft!(user) + assert_equal [[:head, :draft]], node.available_layer_pairs # state C + + node.lock_for_editing!(user) + node.autosave!({title: "v3"}, user) + assert_equal [[:head, :draft], [:draft, :autosave]], node.available_layer_pairs # state D + end end -- cgit v1.3 From 30ed9fd9a8bffb44e6ab91dfedb8c0e33837769a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 04:41:54 +0200 Subject: Include assets, tags and template in diff between revisions --- app/models/page.rb | 33 ++++++++++++-------- app/views/revisions/diff.html.erb | 27 +++++++++++++++++ public/stylesheets/admin.css | 11 +++++++ test/controllers/revisions_controller_test.rb | 13 ++++++++ test/models/page_test.rb | 43 +++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 13 deletions(-) (limited to 'app/models') diff --git a/app/models/page.rb b/app/models/page.rb index ea04cd6..740d42e 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -176,19 +176,26 @@ class Page < ApplicationRecord end def diff_against other, view: :inline - if view == :side_by_side - { - title: HtmlWordDiff.side_by_side(other.title.to_s, title.to_s), - abstract: HtmlWordDiff.side_by_side(other.abstract.to_s, abstract.to_s), - body: HtmlWordDiff.side_by_side(other.body.to_s, body.to_s) - } - else - { - title: HtmlWordDiff.inline(other.title.to_s, title.to_s), - abstract: HtmlWordDiff.inline(other.abstract.to_s, abstract.to_s), - body: HtmlWordDiff.inline(other.body.to_s, body.to_s) - } - end + text_diffs = + if view == :side_by_side + { + title: HtmlWordDiff.side_by_side(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.side_by_side(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.side_by_side(other.body.to_s, body.to_s) + } + else + { + title: HtmlWordDiff.inline(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.inline(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.inline(other.body.to_s, body.to_s) + } + end + + text_diffs.merge( + tags: { added: tag_list.to_a - other.tag_list.to_a, removed: other.tag_list.to_a - tag_list.to_a }, + template_name: { from: other.template_name, to: template_name, changed: template_name != other.template_name }, + assets: { added: assets.to_a - other.assets.to_a, removed: other.assets.to_a - assets.to_a } + ) end def public? diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d70503c..b9ce6bd 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -85,4 +85,31 @@

    Body

    <%= raw @diff[:body] %> <% end %> + +

    Tags

    + <% if @diff[:tags][:added].empty? && @diff[:tags][:removed].empty? %> +

    No change.

    + <% else %> +
      + <% @diff[:tags][:added].each do |tag| %>
    • <%= tag %>
    • <% end %> + <% @diff[:tags][:removed].each do |tag| %>
    • <%= tag %>
    • <% end %> +
    + <% end %> + +

    Template

    + <% if @diff[:template_name][:changed] %> +

    <%= @diff[:template_name][:from] || '(none)' %> <%= @diff[:template_name][:to] || '(none)' %>

    + <% else %> +

    No change.

    + <% end %> + +

    Assets

    + <% if @diff[:assets][:added].empty? && @diff[:assets][:removed].empty? %> +

    No change.

    + <% else %> +
      + <% @diff[:assets][:added].each do |asset| %>
    • <%= asset.upload_file_name %>
    • <% end %> + <% @diff[:assets][:removed].each do |asset| %>
    • <%= asset.upload_file_name %>
    • <% end %> +
    + <% end %>
    diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index e04499d..7b39c72 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -549,6 +549,17 @@ table.revisions_table tr:hover { cursor: help; } +.diff_unchanged { + color: #969696; + font-style: italic; +} + +.diff_set_list { + list-style: none; + padding-left: 0; + margin: 0; +} + table.user_table td.user_login { padding-right: 30px; } diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb index bf92c5b..53927b4 100644 --- a/test/controllers/revisions_controller_test.rb +++ b/test/controllers/revisions_controller_test.rb @@ -148,4 +148,17 @@ class RevisionsControllerTest < ActionController::TestCase assert_response :success assert_select "a", "Side by side" end + + test "diffing two revisions also shows tag, template, and asset changes" do + login_as :quentin + @node.find_or_create_draft(@user) + @node.draft.tag_list = "update" + @node.draft.save! + + post(:diff, params: { :node_id => @node.id, :start_revision => @node.pages.first.revision, :end_revision => @node.pages.last.revision }) + assert_response :success + assert_select "h3", "Tags" + assert_select "h3", "Template" + assert_select "h3", "Assets" + end end diff --git a/test/models/page_test.rb b/test/models/page_test.rb index 3868a53..bcddc89 100644 --- a/test/models/page_test.rb +++ b/test/models/page_test.rb @@ -228,4 +228,47 @@ class PageTest < ActiveSupport::TestCase assert_equal 2, fragment.css('ins.diff_structural').length assert_match "der Zugang erfolgt über den Hinterhof.", fragment.text end + + test "diff_against reports tag and template changes" do + n = Node.root.children.create! :slug => "field_diff_test" + d = n.find_or_create_draft @user1 + d.tag_list = "update" + d.template_name = "standard_template" + d.save! + n.publish_draft! + + d2 = n.find_or_create_draft @user1 + d2.tag_list = "update, pressemitteilung" + d2.template_name = "title_only" + d2.save! + + diff = d2.diff_against(n.head) + + assert_equal ["pressemitteilung"], diff[:tags][:added] + assert_equal [], diff[:tags][:removed] + assert diff[:template_name][:changed] + assert_equal "standard_template", diff[:template_name][:from] + assert_equal "title_only", diff[:template_name][:to] + end + + test "diff_against reports added and removed assets by filename" do + n = Node.root.children.create! :slug => "asset_diff_test" + d = n.find_or_create_draft @user1 + d.save! + n.publish_draft! + + kept_asset = Asset.create!(:upload_file_name => "kept.png", :upload_content_type => "image/png", :upload_file_size => 1) + removed_asset = Asset.create!(:upload_file_name => "removed.pdf", :upload_content_type => "application/pdf", :upload_file_size => 1) + n.head.update_assets([kept_asset.id, removed_asset.id]) + + d2 = n.find_or_create_draft @user1 + added_asset = Asset.create!(:upload_file_name => "added.png", :upload_content_type => "image/png", :upload_file_size => 1) + d2.update_assets([kept_asset.id, added_asset.id]) + d2.save! + + diff = d2.diff_against(n.head) + + assert_equal [added_asset], diff[:assets][:added] + assert_equal [removed_asset], diff[:assets][:removed] + end end -- cgit v1.3 From ab392d472c15eee3618cb7c02b2b4707151598b6 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 23:54:38 +0200 Subject: Prevent a staged parent move from crashing on its own descendant publish_draft! called move_to_child_of before validating the new parent at all. Staging a node under one of its own descendants created a real, if transient, cycle in parent_id the instant that save landed -- and update_unique_names_of_children's after_save callback, which recurses down through parent_id with no cycle check and no depth limit, bounced between the two nodes forever, crashing the whole process with a SystemStackError rather than just producing bad data. Now rejected up front with a normal ActiveRecord::RecordInvalid. --- app/models/node.rb | 5 +++++ test/models/node_test.rb | 12 ++++++++++++ 2 files changed, 17 insertions(+) (limited to 'app/models') diff --git a/app/models/node.rb b/app/models/node.rb index 82d9954..9cb4840 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -227,6 +227,11 @@ class Node < ApplicationRecord if staged_parent_id && (staged_parent_id != parent_id) new_parent = Node.find(staged_parent_id) + + if new_parent == self || self.descendants.include?(new_parent) + raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants" + end + self.staged_parent_id = nil self.save! self.move_to_child_of(new_parent) diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 280614e..5626384 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -499,4 +499,16 @@ class NodeTest < ActiveSupport::TestCase node.autosave!({title: "v3"}, user) assert_equal [[:head, :draft], [:draft, :autosave]], node.available_layer_pairs # state D end + + test "publishing a staged move under one's own descendant is rejected, not allowed to crash" do + a = Node.root.children.create!(:slug => "cycle_guard_a") + b = a.children.create!(:slug => "cycle_guard_b") + + a.staged_parent_id = b.id + + assert_raises(ActiveRecord::RecordInvalid) { a.publish_draft! } + + a.reload + assert_equal Node.root.id, a.parent_id + end end -- cgit v1.3 From 47beb3fe6ed6fdb75c2dd3a55362ad1aba3bbc3b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 11 Jul 2026 23:41:29 +0200 Subject: Make page_translations' search trigger self-installing search_vector is populated by a raw Postgres trigger created via execute() in a migration -- invisible to schema.rb, which only represents structure Rails understands. Any database rebuilt from schema.rb rather than replayed migrations (test, a fresh db:setup, disaster recovery) silently lost full-text search entirely, with no error -- NULL @@ anything is never true in Postgres. Page.ensure_search_vector_trigger!, called from an after_initialize initializer, reinstalls the trigger via CREATE OR REPLACE on every boot, in every environment. Idempotent and cheap. --- app/models/page.rb | 26 ++++++++++++++++++++++ .../initializers/ensure_search_vector_trigger.rb | 7 ++++++ 2 files changed, 33 insertions(+) create mode 100644 config/initializers/ensure_search_vector_trigger.rb (limited to 'app/models') diff --git a/app/models/page.rb b/app/models/page.rb index 740d42e..db5b688 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -254,6 +254,32 @@ class Page < ApplicationRecord end + # Installs (or re-installs) the trigger that keeps page_translations' + # search_vector in sync. Idempotent, safe to call on every boot. + # search_vector is populated by a raw Postgres trigger, not anything + # Rails' schema dumper can represent -- a database rebuilt from + # schema.rb rather than replayed migrations silently loses it. + def self.ensure_search_vector_trigger! + connection.execute(<<~SQL) + CREATE OR REPLACE FUNCTION page_translations_search_vector_update() + RETURNS trigger AS $$ + BEGIN + NEW.search_vector := to_tsvector( + 'simple', + coalesce(NEW.title, '') || ' ' || + coalesce(NEW.abstract, '') || ' ' || + coalesce(NEW.body, '') + ); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE OR REPLACE TRIGGER page_translations_search_vector_trigger + BEFORE INSERT OR UPDATE ON page_translations + FOR EACH ROW EXECUTE PROCEDURE page_translations_search_vector_update(); + SQL + end + private def set_page_title diff --git a/config/initializers/ensure_search_vector_trigger.rb b/config/initializers/ensure_search_vector_trigger.rb new file mode 100644 index 0000000..a750e9c --- /dev/null +++ b/config/initializers/ensure_search_vector_trigger.rb @@ -0,0 +1,7 @@ +Rails.application.config.after_initialize do + begin + Page.ensure_search_vector_trigger! if Page.connection.table_exists?(:page_translations) + rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished, PG::ConnectionBad + # Database doesn't exist yet -- e.g. mid `rails db:create`. Nothing to install yet. + end +end -- cgit v1.3 From 92c394b43a0603743b914c6298aab986805ca990 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sat, 11 Jul 2026 23:43:02 +0200 Subject: Add drafts/recent/mine/chapters admin node views Four NodesController actions -- drafts, recent, mine, chapters -- each building its own base scope, sharing one private method (index_matching) for search narrowing and pagination. Wizard rewrite to link into these instead of rendering its own tables is a separate, later step. Node.editor_search backs the shared "q" narrowing: an ILIKE substring match against title/abstract on whichever of head or draft is present, splitting the term on whitespace and requiring every word to match somewhere independently, not as one phrase, since real words can end up separated by markup in the underlying HTML. Deliberately separate from Node.search, the public content search, which stays tsvector-based and head-only. chapters generalizes into /admin/nodes/tags/:tags for an arbitrary tag list (OR'd, not AND'd), sharing the controller action but rendering its own template rather than branching inside one view. --- app/controllers/nodes_controller.rb | 45 +++++++++++++ app/models/node.rb | 22 +++++++ app/views/nodes/_node_list.html.erb | 39 +++++++++++ app/views/nodes/chapters.html.erb | 9 +++ app/views/nodes/drafts.html.erb | 3 + app/views/nodes/mine.html.erb | 3 + app/views/nodes/recent.html.erb | 3 + app/views/nodes/tags.html.erb | 3 + config/routes.rb | 5 ++ public/stylesheets/admin.css | 15 +++++ test/controllers/nodes_controller_test.rb | 104 ++++++++++++++++++++++++++++++ test/models/node_test.rb | 26 ++++++++ 12 files changed, 277 insertions(+) create mode 100644 app/views/nodes/_node_list.html.erb create mode 100644 app/views/nodes/chapters.html.erb create mode 100644 app/views/nodes/drafts.html.erb create mode 100644 app/views/nodes/mine.html.erb create mode 100644 app/views/nodes/recent.html.erb create mode 100644 app/views/nodes/tags.html.erb (limited to 'app/models') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 6fcd930..ede91ad 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -183,6 +183,39 @@ class NodesController < ApplicationController render plain: slug_for(params[:title]) end + # Filter functions for admin views + def drafts + base = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") + @nodes = index_matching(base) + end + + def recent + base = Node.where( + "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", + Time.now, Time.now - 14.days + ) + @nodes = index_matching(base) + end + + def mine + base = Node.joins(:pages) + .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) + .distinct + @nodes = index_matching(base) + end + + def chapters + @kind_keys = Array(params[:kinds]) & %w[erfa chaostreff] + @kind_keys = %w[erfa chaostreff] if @kind_keys.empty? + tags = @kind_keys.flat_map { |key| CccConventions::NODE_KINDS[key][:tags] } + @nodes = nodes_matching_tags(tags) + end + + def tags + tags = params[:tags].to_s.split(',').map(&:strip).reject(&:blank?) + @nodes = nodes_matching_tags(tags) + end + private def slug_for(title) @@ -214,4 +247,16 @@ class NodesController < ApplicationController config && config[:parent] ? config[:parent].call.id : nil end end + + def nodes_matching_tags(tags) + matching_pages = Page.tagged_with(tags, any: true).reselect(:id) + base = Node.where(head_id: matching_pages).or(Node.where(draft_id: matching_pages)) + index_matching(base) + end + + def index_matching(base_scope) + scope = base_scope.includes(:head, :draft) + scope = scope.merge(Node.editor_search(params[:q])) if params[:q].present? + scope.order("nodes.updated_at desc").paginate(page: params[:page], per_page: 25) + end end diff --git a/app/models/node.rb b/app/models/node.rb index 9cb4840..24f3cd0 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -345,6 +345,28 @@ class Node < ApplicationRecord .distinct end + # This one is for admin-only views, where finding a draft is the point. + # Substring match on whichever of head/draft is present. + def self.editor_search(term) + words = term.to_s.split(/\s+/).reject(&:blank?) + return none if words.empty? + + conditions = [] + binds = {} + + words.each_with_index do |word, i| + key = "term#{i}" + binds[key.to_sym] = "%#{sanitize_sql_like(word)}%" + conditions << "(head_translations.title ILIKE :#{key} OR head_translations.abstract ILIKE :#{key} " \ + "OR draft_translations.title ILIKE :#{key} OR draft_translations.abstract ILIKE :#{key})" + end + + joins("LEFT JOIN page_translations head_translations ON head_translations.page_id = nodes.head_id") + .joins("LEFT JOIN page_translations draft_translations ON draft_translations.page_id = nodes.draft_id") + .where(conditions.join(" AND "), binds) + .distinct + end + protected def lock_for! current_user self.lock_owner = current_user diff --git a/app/views/nodes/_node_list.html.erb b/app/views/nodes/_node_list.html.erb new file mode 100644 index 0000000..03f38b4 --- /dev/null +++ b/app/views/nodes/_node_list.html.erb @@ -0,0 +1,39 @@ +<%= form_tag url_for(controller: params[:controller], action: params[:action]), method: :get, class: "node_search_form" do %> + <% Array(params[:kinds]).each do |kind| %> + <%= hidden_field_tag "kinds[]", kind %> + <% end %> + <%= hidden_field_tag :tags, params[:tags] if params[:tags].present? %> + <%= text_field_tag :q, params[:q], placeholder: "Search title, abstract, body…" %> + <%= submit_tag "Search", class: "action_button" %> + <% if params[:q].present? || params[:kinds].present? || params[:tags].present? %> + <%= link_to "Reset", url_for(controller: params[:controller], action: params[:action]) %> + <% end %> +<% end %> + +<%= will_paginate @nodes %> + + + + + + + + + <% @nodes.each do |node| %> + "> + + + + + + + <% end %> +
    IDTitleActionsLocked byRev.
    <%= node.id %> +

    <%= link_to title_for_node(node), node_path(node) %>

    +

    <%= link_to_path(node.unique_name, node.unique_name) %>

    +
    + <%= link_to 'show', node_path(node) %> + <%= link_to 'edit', edit_node_path(node) %> + <%= link_to 'revisions', node_revisions_path(node) %> + <%= node.lock_owner.login if node.lock_owner %><%= node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY") %>
    +<%= will_paginate @nodes %> diff --git a/app/views/nodes/chapters.html.erb b/app/views/nodes/chapters.html.erb new file mode 100644 index 0000000..939f19c --- /dev/null +++ b/app/views/nodes/chapters.html.erb @@ -0,0 +1,9 @@ +

    Chapters

    + +<%= form_tag chapters_nodes_path, method: :get do %> + + + <%= submit_tag "Filter", class: "action_button" %> +<% end %> + +<%= render 'node_list' %> diff --git a/app/views/nodes/drafts.html.erb b/app/views/nodes/drafts.html.erb new file mode 100644 index 0000000..6d0830c --- /dev/null +++ b/app/views/nodes/drafts.html.erb @@ -0,0 +1,3 @@ +

    Nodes with drafts or autosaves

    + +<%= render 'node_list' %> diff --git a/app/views/nodes/mine.html.erb b/app/views/nodes/mine.html.erb new file mode 100644 index 0000000..fc5680a --- /dev/null +++ b/app/views/nodes/mine.html.erb @@ -0,0 +1,3 @@ +

    My work

    + +<%= render 'node_list' %> diff --git a/app/views/nodes/recent.html.erb b/app/views/nodes/recent.html.erb new file mode 100644 index 0000000..3602775 --- /dev/null +++ b/app/views/nodes/recent.html.erb @@ -0,0 +1,3 @@ +

    Recently changed

    + +<%= render 'node_list' %> diff --git a/app/views/nodes/tags.html.erb b/app/views/nodes/tags.html.erb new file mode 100644 index 0000000..0ebc6ce --- /dev/null +++ b/app/views/nodes/tags.html.erb @@ -0,0 +1,3 @@ +

    Nodes tagged: <%= params[:tags] %>

    + +<%= render 'node_list' %> diff --git a/config/routes.rb b/config/routes.rb index aebce90..2c165d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,7 +37,12 @@ Cccms::Application.routes.draw do resources :nodes do collection do + get 'tags/:tags', action: :tags, as: :tags, constraints: { tags: /[^\/]+/ } get :parameterize_preview + get :drafts + get :recent + get :mine + get :chapters end member do diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 28b8494..aa8b288 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -748,6 +748,21 @@ form.button_to button[type="submit"] { margin-bottom: 0; } +.node_search_form { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.node_search_form input[type=text] { + padding: 4px 12px; + box-sizing: border-box; + height: 2.25rem; + width: 22rem; + border-radius: 2px; +} + /* Layout only -- the at-rest visibility (wavy underline) for these links comes from the scoped rule in Base elements above. */ .add_child_links { diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index b563d4d..05cb195 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -471,4 +471,108 @@ class NodesControllerTest < ActionController::TestCase assert_response :success assert_select "form.destructive", :count => 0 end + + test "drafts includes a never-published node with only a draft" do + node = Node.root.children.create!(:slug => "drafts_action_test") + login_as :quentin + get :drafts + assert_includes assigns(:nodes), node + end + + test "chapters filters by kind, matching head or draft, and shows both by default" do + erfa_node = Node.root.children.create!(:slug => "chapters_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + chaostreff_node = Node.root.children.create!(:slug => "chapters_chaostreff_test") + chaostreff_node.find_or_create_draft(@user1) + chaostreff_node.draft.tag_list = "chaostreff-detail" + chaostreff_node.draft.save! + chaostreff_node.publish_draft! + + login_as :quentin + + get :chapters, params: { :kinds => "erfa" } + assert_includes assigns(:nodes), erfa_node + assert_not_includes assigns(:nodes), chaostreff_node + + get :chapters + assert_includes assigns(:nodes), erfa_node + assert_includes assigns(:nodes), chaostreff_node + end + + test "recent combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :recent, params: { :q => "Zombies" } + assert_response :success + end + + test "drafts combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :drafts, params: { :q => "Zombies" } + assert_response :success + end + + test "mine combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :mine, params: { :q => "Zombies" } + assert_response :success + end + + test "chapters combined with a search term does not raise an ambiguous column error" do + login_as :quentin + get :chapters, params: { :q => "Zombies" } + assert_response :success + end + + test "tags path filters by an arbitrary raw tag, generalizing chapters" do + presse_node = Node.root.children.create!(:slug => "tags_path_presse_test") + presse_node.find_or_create_draft(@user1) + presse_node.draft.tag_list = "pressemitteilung" + presse_node.draft.save! + presse_node.publish_draft! + + erfa_node = Node.root.children.create!(:slug => "tags_path_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + login_as :quentin + get :tags, params: { :tags => "pressemitteilung" } + + assert_includes assigns(:nodes), presse_node + assert_not_includes assigns(:nodes), erfa_node + + assert_select "h1", "Nodes tagged: pressemitteilung" + assert_select "h1", :text => "Chapters", :count => 0 + end + + test "tags path with multiple tags matches any of them, not all" do + erfa_node = Node.root.children.create!(:slug => "tags_path_multi_erfa_test") + erfa_node.find_or_create_draft(@user1) + erfa_node.draft.tag_list = "erfa-detail" + erfa_node.draft.save! + erfa_node.publish_draft! + + chaostreff_node = Node.root.children.create!(:slug => "tags_path_multi_chaostreff_test") + chaostreff_node.find_or_create_draft(@user1) + chaostreff_node.draft.tag_list = "chaostreff-detail" + chaostreff_node.draft.save! + chaostreff_node.publish_draft! + + login_as :quentin + get :tags, params: {:tags => "erfa-detail,chaostreff-detail" } + + assert_includes assigns(:nodes), erfa_node + assert_includes assigns(:nodes), chaostreff_node + end + + test "chapters renders the curated heading" do + login_as :quentin + get :chapters + assert_select "h1", "Chapters" + end end diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 5626384..15e908b 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -511,4 +511,30 @@ class NodeTest < ActiveSupport::TestCase a.reload assert_equal Node.root.id, a.parent_id end + + test "editor_search matches a partial substring, not just a whole word" do + node = Node.root.children.create!(:slug => "editor_search_substring_test") + node.find_or_create_draft(@user1) + node.draft.update(:title => "Biometrics Conference") + node.publish_draft! + + assert_includes Node.editor_search("bio"), node + assert_includes Node.editor_search("Conf"), node + end + + test "editor_search returns an empty relation for a blank term, not every node" do + assert_equal 0, Node.editor_search("").count + assert_equal 0, Node.editor_search(nil).count + assert_equal 0, Node.editor_search(" ").count + end + + test "editor_search requires every word to match, but each word can match a different field" do + node = Node.root.children.create!(:slug => "editor_search_multiword_test") + node.find_or_create_draft(@user1) + node.draft.update(:title => "Backspace e.V. Bamberg", :abstract => "Spiegelgraben 41, 96052 Bamberg") + node.publish_draft! + + assert_includes Node.editor_search("Backspace Spiegelgraben"), node + assert_equal 0, Node.editor_search("Backspace Nonexistentstreet").count + end end -- cgit v1.3 From 5803c59192b7fb05840d0b452eb64d9f997f3d8f Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 14:26:30 +0200 Subject: Extract drafts/recent query logic from controller into Node Node.drafts_and_autosaves and Node.recently_changed replace inline query logic in NodesController#drafts/#recent -- pure refactor, no behavior change for either action. The real reason: the dashboard's upcoming abridged widgets need the exact same queries the full pages already use, just limited and (for drafts) sorted with the current user's own locked nodes first. Better to share one method than let a widget and a full page quietly drift onto two versions of "what counts as a current draft." current_user_id: is an explicit, optional argument rather than a separate method -- ordering by lock ownership only applies when a user is given; omitted entirely, it's pure recency, exactly today's behavior. Needed Arel.sql wrapping a sanitized CASE expression before .order would accept it -- sanitize_sql_array only vouches for the values inside the string, a separate Rails safety check still refuses any string shaped like more than a plain column reference unless it's explicitly marked as already-vetted. --- app/controllers/nodes_controller.rb | 9 ++------- app/models/node.rb | 16 ++++++++++++++++ test/models/node_test.rb | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 7 deletions(-) (limited to 'app/models') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 772bf4b..c1468be 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -185,16 +185,11 @@ class NodesController < ApplicationController # Filter functions for admin views def drafts - base = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") - @nodes = index_matching(base) + @nodes = index_matching(Node.drafts_and_autosaves) end def recent - base = Node.where( - "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", - Time.now, Time.now - 14.days - ) - @nodes = index_matching(base) + @nodes = index_matching(Node.recently_changed) end def mine diff --git a/app/models/node.rb b/app/models/node.rb index 24f3cd0..aa2f7f3 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -367,6 +367,22 @@ class Node < ApplicationRecord .distinct end + def self.drafts_and_autosaves(current_user_id: nil) + scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") + return scope.order("updated_at DESC") unless current_user_id + + scope.order( + Arel.sql(sanitize_sql_array(["CASE WHEN locking_user_id = ? THEN 0 ELSE 1 END, updated_at DESC", current_user_id])) + ) + end + + def self.recently_changed + where( + "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", + Time.now, Time.now - 14.days + ) + end + protected def lock_for! current_user self.lock_owner = current_user diff --git a/test/models/node_test.rb b/test/models/node_test.rb index 15e908b..de540f8 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -537,4 +537,28 @@ class NodeTest < ActiveSupport::TestCase assert_includes Node.editor_search("Backspace Spiegelgraben"), node assert_equal 0, Node.editor_search("Backspace Nonexistentstreet").count end + + test "drafts_and_autosaves without a user sorts by recency only" do + older = Node.root.children.create!(:slug => "drafts_order_older") + older.find_or_create_draft(@user1) + newer = Node.root.children.create!(:slug => "drafts_order_newer") + newer.find_or_create_draft(@user1) + + result = Node.drafts_and_autosaves.to_a + assert result.index(newer) < result.index(older) + end + + test "drafts_and_autosaves with a user puts their own locked nodes first, regardless of recency" do + mine = Node.root.children.create!(:slug => "drafts_order_mine") + mine.lock_for_editing!(@user1) + mine.autosave!({:title => "mine"}, @user1) + + someone_elses_newer = Node.root.children.create!(:slug => "drafts_order_theirs") + other_user = User.find_by_login("quentin") + someone_elses_newer.lock_for_editing!(other_user) + someone_elses_newer.autosave!({:title => "theirs"}, other_user) + + result = Node.drafts_and_autosaves(current_user_id: @user1.id).to_a + assert result.index(mine) < result.index(someone_elses_newer) + end end -- cgit v1.3 From b271648f89cba7cafafa1b73b1658b1c1bc0e4b0 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 23:42:43 +0200 Subject: Rebuild the admin dashboard: icon nav, search, signposts, widgets Replaces the old admin#index wizard -- accreted over years, never designed as a whole -- with the dashboard settled on this session: a three-icon nav (dashboard/search/log out, no locale selector), a nodes-first search bar, four task signposts, and two symmetric widgets (drafts/autosaves, recent changes) with a quiet housekeeping row beneath them. Node.recently_changed now filters and orders by the head page's own updated_at instead of the node's blanket timestamp, so a lock/unlock cycle with no actual publish no longer surfaces here, and the original publisher is no longer misattributed to someone else's housekeeping action. This also restores the "published" qualifier on each entry, which the query previously couldn't guarantee was true. @mynodes and its dedicated "My Work" table are retired along with the old wizard -- "Continue my work" is a link to the existing, already- correct NodesController#mine instead of a second, duplicate query. Its one dedicated test (dedup across multiple revisions by the same user) is ported to nodes_controller_test.rb, since mine already carries the same .distinct protection the old query did; it just had no test of its own until now. --- app/controllers/admin_controller.rb | 21 +-- app/helpers/nodes_helper.rb | 12 ++ app/models/node.rb | 6 +- app/views/admin/_menu.html.erb | 14 +- app/views/admin/index.html.erb | 210 ++++++++++-------------------- test/controllers/admin_controller_test.rb | 20 --- test/controllers/nodes_controller_test.rb | 20 +++ test/models/node_test.rb | 24 ++++ 8 files changed, 134 insertions(+), 193 deletions(-) (limited to 'app/models') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 8445997..37fd78b 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -5,25 +5,8 @@ class AdminController < ApplicationController before_action :login_required def index - @drafts = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") - .limit(50).order("updated_at desc") - - @drafts_count = Node.where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").count - - @recent_changes = Node.where( - "updated_at < ? AND updated_at > ? AND parent_id IS NOT NULL", - Time.now, Time.now - 14.days - ).limit(50).order("updated_at desc") - - ordered_with_level = Node.root.self_and_descendants_ordered_with_level - @sitemap_depth = {} - ordered_with_level.each { |node, level| @sitemap_depth[node.id] = level } - @sitemap = ordered_with_level.map(&:first).reject(&:update?) - - @mynodes = Node.joins(:pages) - .where("pages.user_id = ? or pages.editor_id = ?", current_user, current_user) - .order("updated_at desc") - .distinct.first(50) + @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5) + @recent_changes = Node.recently_changed.limit(5) end def conventions diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 1268b63..5884c8c 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -28,6 +28,18 @@ module NodesHelper User.all.map {|u| [u.login, u.id]} end + def node_last_editor(node) + editor = node.draft&.editor || node.head&.editor + return nil unless editor + editor == current_user ? t("editor_self") : editor.login + end + + def node_head_editor(node) + editor = node.head&.editor + return nil unless editor + editor == current_user ? t("publisher_self") : editor.login + end + DEFAULT_EVENT_TAG_BY_PAGE_TAG = { 'erfa-detail' => 'open-day', 'chaostreff-detail' => 'open-day' diff --git a/app/models/node.rb b/app/models/node.rb index aa2f7f3..1d0a089 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -377,10 +377,10 @@ class Node < ApplicationRecord end def self.recently_changed - where( - "nodes.updated_at < ? AND nodes.updated_at > ? AND nodes.parent_id IS NOT NULL", + includes(:head).where( + "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", Time.now, Time.now - 14.days - ) + ).order("pages.updated_at desc").references(:head) end protected diff --git a/app/views/admin/_menu.html.erb b/app/views/admin/_menu.html.erb index 4302987..970429d 100644 --- a/app/views/admin/_menu.html.erb +++ b/app/views/admin/_menu.html.erb @@ -1,9 +1,5 @@ -<%= language_selector %> -<%= button_to 'Logout', logout_path, method: :delete %> -<%= link_to 'Overview', admin_path %> -search -<%= link_to 'Nodes', nodes_path, selected?('nodes') %> -<%= link_to 'Assets', assets_path, selected?('assets') %> -<%= link_to 'Events', events_path, selected?('events') %> -<%= link_to 'User', users_path, selected?('users') %> -<%= link_to 'Navigation', menu_items_path, selected?('menu_items') %> +<%= link_to icon("home", library: "tabler", "aria-hidden": true), admin_path, "aria-label": "Dashboard" %> +<%= icon("search", library: "tabler", "aria-hidden": true) %> +<%= button_to logout_path, method: :delete, aria: { label: "Log out" } do %> + <%= icon("logout", library: "tabler", "aria-hidden": true) %> +<% end %> diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index c67ccb3..bdb555e 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -1,155 +1,81 @@ -
    -

    Quick links

    -
    <%= link_to 'Create post or page', new_node_path, :only_path => false %>
    - - -
    <%= link_to("Upload a new asset", new_asset_path, :only_path => false) %>
    +

    cccms dashboard

    + -

    - recent changes - my work - current drafts (<%= @drafts_count %>) - site map -

    +
    + <%= link_to new_node_path, class: "action_button" do %> + <%= icon("plus", library: "tabler", "aria-hidden": true) %> Create post + <% end %> + <%= link_to chapters_nodes_path, class: "action_button" do %> + <%= icon("map-pin", library: "tabler", "aria-hidden": true) %> Find a chapter + <% end %> + <%= link_to new_asset_path, class: "action_button" do %> + <%= icon("upload", library: "tabler", "aria-hidden": true) %> Upload asset + <% end %> + <%= link_to sitemap_nodes_path, class: "action_button" do %> + <%= icon("list-tree", library: "tabler", "aria-hidden": true) %> Add a page in the page tree + <% end %> +
    -
    - -

    Recent Changes

    +
    +
    +

    Drafts and autosaves

    +
      + <% @drafts.each do |node| %> +
    • +
      + <%= link_to title_for_node(node), node_path(node) %> + <%= link_to_path("#{node.unique_name} ↗", node.unique_name) %> +
      + + <% if (editor = node_last_editor(node)) %><%= editor %>, <% end %><%= relative_time_phrase(node.updated_at) %> + +
    • + <% end %> +
    + <%= link_to "See all drafts →", drafts_nodes_path %> +
    - - - - - - - - +
    +

    Recent changes

    +
      <% @recent_changes.each do |node| %> -
    "> - - - - - - +
  • +
    + <%= link_to title_for_node(node), node_path(node) %> + <%= link_to_path("#{node.unique_name} ↗", node.unique_name) %> +
    + + <%= (editor = node_head_editor(node)) ? t("published_by", editor: editor) : t("published") %>, <%= relative_time_phrase(node.head.updated_at) %> + +
  • <% end %> -
    IDTitleActionsLocked byRev.
    <%= node.id %> -

    <%= link_to title_for_node(node), node_path(node) %>

    -

    <%= link_to_path(node.unique_name, node.unique_name) %>

    -
    - <%= link_to 'show', node_path(node) %> - <%= link_to 'Revisions', node_revisions_path(node) %> - - <%= node.lock_owner.login if node.lock_owner %> - - <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> -
    + + <%= link_to "See all recent changes →", recent_nodes_path %> +
    -
    - -

    My Work

    - - - - - - - - - - <% @mynodes.each do |node| %> - "> - - - - - - +
    +

    Housekeeping

    +
    + <%= link_to nodes_path, class: "action_button" do %> + <%= icon("list", library: "tabler", "aria-hidden": true) %> Nodes <% end %> -
    IDTitleActionsLocked byRev.
    <%= node.id %> -

    <%= link_to title_for_node(node), node_path(node) %>

    -

    <%= link_to_path(node.unique_name, node.unique_name) %>

    -
    - <%= link_to 'show', node_path(node) %> - <%= link_to 'Revisions', node_revisions_path(node) %> - - <%= node.lock_owner.login if node.lock_owner %> - - <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> -
    -
    - -
    - -

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

    - - - - - - - - - - - <% @drafts.each do |node| %> - "> - - - - - - - + <%= link_to events_path, class: "action_button" do %> + <%= icon("calendar", library: "tabler", "aria-hidden": true) %> Events <% end %> -
    IDTitleActionsLocked byRev.Autosave
    <%= node.id %> -

    <%= link_to title_for_node(node), node_path(node) %>

    -

    <%= link_to_path(node.unique_name, node.unique_name) %>

    -
    - <%= link_to 'show', node_path(node) %> - <%= link_to 'Revisions', node_revisions_path(node) %> - <% if pair = node.available_layer_pairs.last %> - <%= link_to 'diff', diff_node_revisions_path(node, start_revision: pair.first, end_revision: pair.last) %> - <% end %> - - <%= node.lock_owner.login if node.lock_owner %> - - <%= link_to ( node.draft ? node.draft.revision : (node.head ? node.head.revision : "EMPTY" ) ), node_revisions_path(node) %> - - <%= node.autosave ? "unsaved changes" : "" %> -
    -
    - -
    - -

    Sitemap

    - - - - - - - - - <% @sitemap.each do |node| %> - <% if !node.nil? %> - "> - - - - - + <%= link_to assets_path, class: "action_button" do %> + <%= icon("folder", library: "tabler", "aria-hidden": true) %> Assets + <% end %> + <%= link_to users_path, class: "action_button" do %> + <%= icon("users", library: "tabler", "aria-hidden": true) %> Users <% end %> + <%= link_to menu_items_path, class: "action_button" do %> + <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation <% end %> -
    IDTitleActionsLocked by
    <%= node.id %> -

    <%= link_to title_for_node(node), node_path(node) %>

    -

    <%= link_to_path(node.unique_name, node.unique_name) %>

    -
    - <%= link_to 'create subpage', new_node_path(:parent_id => node.id) %> - <%= link_to 'Revisions', node_revisions_path(node) %> - - <%= node.lock_owner.login if node.lock_owner %> -
    +
    diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 9beaf58..00a51e2 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -15,26 +15,6 @@ class AdminControllerTest < ActionController::TestCase assert_includes assigns(:drafts), node end - test "my work list shows each matching node only once, even with several revisions by the same user" do - login_as :quentin - user = User.find_by_login("quentin") - node = Node.root.children.create!(:slug => "dedup_test") - node.lock_for_editing!(user) - node.autosave!({:title => "v1"}, user) - node.save_draft!(user) - node.publish_draft! - node.lock_for_editing!(user) - node.autosave!({:title => "v2"}, user) - node.save_draft!(user) - node.publish_draft! - # three pages now exist on this node, all touched by quentin -- - # without DISTINCT, the join would return this node three times - - get :index - matches = assigns(:mynodes).select { |n| n.id == node.id } - assert_equal 1, matches.length - end - test "dashboard_search returns matching tags and nodes grouped separately" do node = Node.root.children.create!(:slug => "dashboard_search_test") node.find_or_create_draft(User.find_by_login("aaron")) diff --git a/test/controllers/nodes_controller_test.rb b/test/controllers/nodes_controller_test.rb index b43d2de..81e3f45 100644 --- a/test/controllers/nodes_controller_test.rb +++ b/test/controllers/nodes_controller_test.rb @@ -521,6 +521,26 @@ class NodesControllerTest < ActionController::TestCase assert_response :success end + test "mine shows each matching node only once, even with several revisions by the same user" do + login_as :quentin + user = User.find_by_login("quentin") + node = Node.root.children.create!(:slug => "dedup_test") + node.lock_for_editing!(user) + node.autosave!({:title => "v1"}, user) + node.save_draft!(user) + node.publish_draft! + node.lock_for_editing!(user) + node.autosave!({:title => "v2"}, user) + node.save_draft!(user) + node.publish_draft! + # three pages now exist on this node, all touched by quentin -- + # without DISTINCT, the join would return this node three times + + get :mine + matches = assigns(:nodes).select { |n| n.id == node.id } + assert_equal 1, matches.length + end + test "chapters combined with a search term does not raise an ambiguous column error" do login_as :quentin get :chapters, params: { :q => "Zombies" } diff --git a/test/models/node_test.rb b/test/models/node_test.rb index de540f8..0bce222 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -561,4 +561,28 @@ class NodeTest < ActiveSupport::TestCase result = Node.drafts_and_autosaves(current_user_id: @user1.id).to_a assert result.index(mine) < result.index(someone_elses_newer) end + + test "recently_changed includes a node whose head was recently published" do + node = Node.root.children.create!(:slug => "recent_changed_published") + node.find_or_create_draft(@user1) + node.autosave!({:title => "v1"}, @user1) + node.save_draft!(@user1) + node.publish_draft! + + assert_includes Node.recently_changed, node + end + + test "recently_changed excludes a node only touched by locking or unlocking after an old publish" do + node = Node.root.children.create!(:slug => "recent_changed_lock_only") + node.find_or_create_draft(@user1) + node.autosave!({:title => "v1"}, @user1) + node.save_draft!(@user1) + node.publish_draft! + node.head.update_column(:updated_at, 20.days.ago) + + node.lock_for_editing!(@user1) + node.unlock! + + assert_not_includes Node.recently_changed, node + end end -- cgit v1.3 From 70653b681d10917b77dced08f577446ced7568f1 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 03:22:37 +0200 Subject: Add RelatedAssetsController: search, attach, detach, reorder Backend for the asset-picker rebuild -- replaces the plan to dump every image asset in the system into a hidden, unfiltered browse panel on every node edit (the actual current behavior, confirmed by reading nodes/edit.html.erb directly) with a small, name-scoped search endpoint plus create/destroy/update for attach, detach, and reorder. No schema change needed -- RelatedAsset already had everything this requires (asset_id, page_id, an acts_as_list position). search excludes assets already attached to the page, keeping results meaningfully small given hundreds of assets total but only a handful per node in practice. create is find_or_create_by! rather than a bare create!, guarding against the same asset being attached twice from two separate search results. update leans on acts_as_list's own insert_at rather than custom position-shifting logic. Node#editable_page (autosave || draft || head) is extracted since this is now its third call site with identical logic -- deliberately not touching nodes#show, which resolves draft || head without autosave on purpose, a different and correct semantic for "current committed state" versus "what's actively being edited." --- app/controllers/related_assets_controller.rb | 50 +++++++++++++ app/models/node.rb | 4 ++ config/routes.rb | 6 ++ test/controllers/related_assets_controller_test.rb | 83 ++++++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 app/controllers/related_assets_controller.rb create mode 100644 test/controllers/related_assets_controller_test.rb (limited to 'app/models') diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb new file mode 100644 index 0000000..906c02c --- /dev/null +++ b/app/controllers/related_assets_controller.rb @@ -0,0 +1,50 @@ +class RelatedAssetsController < ApplicationController + before_action :login_required + before_action :find_node + + def search + term = params[:q].to_s.strip + if term.blank? + render json: [] + return + end + + attached_ids = @node.editable_page.related_assets.pluck(:asset_id) + results = Asset.images + .where("name ILIKE ?", "%#{term}%") + .where.not(id: attached_ids) + .limit(10) + + render json: results.map { |a| + { id: a.id, name: a.name, thumb_url: a.upload.url(:thumb) } + } + end + + def create + asset = Asset.find(params[:asset_id]) + related = @node.editable_page.related_assets.find_or_create_by!(asset: asset) + + render json: { + id: related.id, + asset_id: asset.id, + name: asset.name, + thumb_url: asset.upload.url(:thumb) + } + end + + def destroy + @node.editable_page.related_assets.find(params[:id]).destroy + head :ok + end + + def update + @node.editable_page.related_assets.find(params[:id]).insert_at(params[:position].to_i) + head :ok + end + + private + + def find_node + @node = Node.find(params[:node_id]) + end +end diff --git a/app/models/node.rb b/app/models/node.rb index 1d0a089..0361c1e 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -328,6 +328,10 @@ class Node < ApplicationRecord unique_path.length == 3 && unique_path[0] == "updates" end + def editable_page + autosave || draft || head + end + # Returns immutable node id for all new nodes so that the atom feed entry ids # stay the same eventhough the slug or positions changes. # Can be removed after a year or so ;) diff --git a/config/routes.rb b/config/routes.rb index 92301e5..5d61bae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,6 +55,12 @@ Cccms::Application.routes.draw do put :revert end + resources :related_assets, only: [:create, :destroy, :update] do + collection do + get :search + end + end + resources :revisions do collection do post :diff diff --git a/test/controllers/related_assets_controller_test.rb b/test/controllers/related_assets_controller_test.rb new file mode 100644 index 0000000..a3082c2 --- /dev/null +++ b/test/controllers/related_assets_controller_test.rb @@ -0,0 +1,83 @@ +require 'test_helper' + +class RelatedAssetsControllerTest < ActionController::TestCase + test "search finds assets by name, excluding ones already attached" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_search_test") + + attached = Asset.create!(:name => "biometrics-scan", :upload_content_type => "image/png") + node.draft.assets << attached + + Asset.create!(:name => "biometrics-poster", :upload_content_type => "image/png") + Asset.create!(:name => "chaostreff-flyer", :upload_content_type => "image/png") + + get :search, params: { :node_id => node.id, :q => "biometrics" } + + json = JSON.parse(response.body) + names = json.map { |a| a["name"] } + assert_includes names, "biometrics-poster" + assert_not_includes names, "biometrics-scan" + assert_not_includes names, "chaostreff-flyer" + end + + test "search returns an empty list for a blank term" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_blank_search_test") + + get :search, params: { :node_id => node.id, :q => "" } + + assert_equal [], JSON.parse(response.body) + end + + test "create attaches an asset to the node's editable page" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_create_test") + asset = Asset.create!(:name => "erfa-photo", :upload_content_type => "image/png") + + post :create, params: { :node_id => node.id, :asset_id => asset.id } + + assert_response :success + assert_includes node.draft.reload.related_assets.map(&:asset_id), asset.id + end + + test "create does not duplicate an already-attached asset" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_dup_test") + asset = Asset.create!(:name => "erfa-photo-2", :upload_content_type => "image/png") + node.draft.assets << asset + + post :create, params: { :node_id => node.id, :asset_id => asset.id } + + assert_response :success + assert_equal 1, node.draft.reload.related_assets.count + end + + test "destroy removes the attached asset" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_destroy_test") + asset = Asset.create!(:name => "old-photo", :upload_content_type => "image/png") + node.draft.assets << asset + related = node.draft.related_assets.first + + delete :destroy, params: { :node_id => node.id, :id => related.id } + + assert_response :success + assert_equal 0, node.draft.reload.related_assets.count + end + + test "update reorders the attached assets" do + login_as :quentin + node = Node.root.children.create!(:slug => "related_assets_reorder_test") + first = Asset.create!(:name => "first-photo", :upload_content_type => "image/png") + second = Asset.create!(:name => "second-photo", :upload_content_type => "image/png") + node.draft.assets << first + node.draft.assets << second + + second_related = node.draft.related_assets.find_by(:asset_id => second.id) + patch :update, params: { :node_id => node.id, :id => second_related.id, :position => 1 } + + assert_response :success + ordered_asset_ids = node.draft.reload.related_assets.map(&:asset_id) + assert_equal [second.id, first.id], ordered_asset_ids + end +end -- cgit v1.3 From 8baac265059b70da0148487458ee4077b15f155e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 04:54:33 +0200 Subject: Asset picker: attach/detach/reorder UI, read-only view, autosave fix Replaces nodes#edit's old Images section -- a hidden panel dumping every image asset in the system unfiltered (#image_browser) plus a raw drag-and-drop box (#image_box) -- with a small search-and-click picker built on the endpoint from the last two commits. Attaching posts immediately and appends the new thumbnail via a cloned