summaryrefslogtreecommitdiff
path: root/app/models/concerns
diff options
context:
space:
mode:
authorerdgeist <erdgeist@erdgeist.org>2026-07-13 23:33:11 +0200
committererdgeist <erdgeist@erdgeist.org>2026-07-13 23:33:11 +0200
commit8eab68f2c5a3c126e2fec2ecdfa7ace87ce0937b (patch)
treef446ebc26a7707c7b64a937aa51a155df146c80a /app/models/concerns
parent42714c697273a7117c6b355fab26c8c35e336ad1 (diff)
parentcdf5d9941ca866d437612d2f863eac6eb0b3db12 (diff)
Merge branch 'erdgeist-revive-events'
Diffstat (limited to 'app/models/concerns')
-rw-r--r--app/models/concerns/nested_tree.rb86
-rw-r--r--app/models/concerns/rrule_humanizer.rb91
2 files changed, 177 insertions, 0 deletions
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 @@
1# app/models/concerns/nested_tree.rb
2#
3# Minimal parent_id-based replacement for the tree-traversal subset of
4# awesome_nested_set actually used by this app (descendants, ancestors,
5# level, root, move_to_child_of)
6module NestedTree
7 extend ActiveSupport::Concern
8
9 included do
10 belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children
11 has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent
12
13 before_destroy :delete_descendants
14 end
15
16 class_methods do
17 def root
18 roots.first
19 end
20
21 def roots
22 where(parent_id: nil)
23 end
24
25 def valid?
26 # Every non-root row's parent_id must point at a real, existing row.
27 where.not(parent_id: nil).where.missing(:parent).none?
28 end
29 end
30
31 def root?
32 parent_id.nil?
33 end
34
35 def ancestors
36 result = []
37 current = parent
38 while current
39 result << current
40 current = current.parent
41 end
42 result
43 end
44
45 def level
46 ancestors.size
47 end
48
49 def descendants
50 ids = []
51 queue = self.class.where(parent_id: id).pluck(:id)
52 until queue.empty?
53 ids.concat(queue)
54 queue = self.class.where(parent_id: queue).pluck(:id)
55 end
56 self.class.where(id: ids)
57 end
58
59 def self_and_descendants
60 self.class.where(id: [id] + descendants.pluck(:id))
61 end
62
63 def self_and_descendants_ordered_with_level
64 nodes = [self] + descendants.to_a
65 children_by_parent = nodes.group_by(&:parent_id)
66 children_by_parent.each_value { |list| list.sort_by!(&:id) }
67
68 result = []
69 visit = ->(node, level) do
70 result << [node, level]
71 (children_by_parent[node.id] || []).each { |child| visit.call(child, level + 1) }
72 end
73 visit.call(self, 0)
74 result
75 end
76
77 def move_to_child_of(new_parent)
78 update!(parent_id: new_parent.id)
79 end
80
81 private
82
83 def delete_descendants
84 self.class.where(id: descendants.pluck(:id)).delete_all
85 end
86end
diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb
new file mode 100644
index 0000000..8231de8
--- /dev/null
+++ b/app/models/concerns/rrule_humanizer.rb
@@ -0,0 +1,91 @@
1module RruleHumanizer
2 extend ActiveSupport::Concern
3
4 WEEKDAY_NAMES = {
5 de: { "MO"=>"Montag","TU"=>"Dienstag","WE"=>"Mittwoch","TH"=>"Donnerstag","FR"=>"Freitag","SA"=>"Samstag","SU"=>"Sonntag" },
6 en: { "MO"=>"Monday","TU"=>"Tuesday","WE"=>"Wednesday","TH"=>"Thursday","FR"=>"Friday","SA"=>"Saturday","SU"=>"Sunday" }
7 }.freeze
8
9 WEEKDAY_NAMES_ADVERBIAL = {
10 de: { "MO"=>"montags","TU"=>"dienstags","WE"=>"mittwochs","TH"=>"donnerstags","FR"=>"freitags","SA"=>"samstags","SU"=>"sonntags" }
11 }.freeze
12
13 WEEKDAY_NAMES_ABBR = {
14 de: { "MO"=>"Mo","TU"=>"Di","WE"=>"Mi","TH"=>"Do","FR"=>"Fr","SA"=>"Sa","SU"=>"So" }
15 }.freeze
16
17 ORDINAL_NAMES = {
18 de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" },
19 en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", -1=>"last", -2=>"second-to-last" }
20 }.freeze
21
22 MONTH_NAMES = {
23 de: %w[Januar Februar März April Mai Juni Juli August September Oktober November Dezember],
24 en: %w[January February March April May June July August September October November December]
25 }.freeze
26
27 def humanize_rrule(locale = I18n.locale)
28 return nil if rrule.blank?
29 parts = Hash[rrule.split(";").map { |p| p.split("=", 2) }]
30 return nil if parts["COUNT"] || parts["UNTIL"] # old one-off data, don't guess
31
32 freq, interval, byday, bymonth = parts["FREQ"], parts["INTERVAL"].to_i, parts["BYDAY"], parts["BYMONTH"]
33 loc = locale.to_sym
34 weekdays = WEEKDAY_NAMES[loc] || WEEKDAY_NAMES[:en]
35 ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en]
36 months = MONTH_NAMES[loc] || MONTH_NAMES[:en]
37
38 days = byday&.split(",")&.map do |d|
39 if d =~ /^(-?\d+)([A-Z]{2})$/
40 "#{ordinals[$1.to_i]} #{weekdays[$2]}"
41 else
42 weekdays[d]
43 end
44 end
45
46 base =
47 case loc
48 when :de
49 case freq
50 when "WEEKLY"
51 if days
52 if interval == 2
53 adverbial = byday.split(",").map { |d| WEEKDAY_NAMES_ADVERBIAL[:de][d] }
54 "Alle zwei Wochen #{adverbial.join(' und ')}"
55 else
56 "Jeden #{days.join(' und ')}"
57 end
58 else
59 interval == 2 ? "Alle zwei Wochen" : "Wöchentlich"
60 end
61 when "MONTHLY"
62 days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich"
63 end
64 else
65 case freq
66 when "WEEKLY"
67 days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly")
68 when "MONTHLY"
69 days ? "Every #{days.join(' and ')} of the month" : "Monthly"
70 end
71 end
72 return nil unless base
73
74 if bymonth
75 included = bymonth.split(",").map(&:to_i)
76 missing = ((1..12).to_a - included)
77 if missing.size == 1
78 excluded_name = months[missing.first - 1]
79 base += (loc == :de ? ", außer im #{excluded_name}" : ", except in #{excluded_name}")
80 end
81 # more than one missing month: bymonth pattern more complex than we handle, leave base as-is silently
82 end
83
84 base
85 end
86
87 def self.wday_abbr(time, locale)
88 code = %w[SU MO TU WE TH FR SA][time.wday]
89 (WEEKDAY_NAMES_ABBR[locale.to_sym] || WEEKDAY_NAMES_ABBR[:de])[code]
90 end
91end