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/models/concerns/rrule_humanizer.rb | 82 ++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 app/models/concerns/rrule_humanizer.rb (limited to 'app/models/concerns') 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 -- 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/concerns') 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 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/concerns') 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