diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-07-13 23:33:11 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-07-13 23:33:11 +0200 |
| commit | 8eab68f2c5a3c126e2fec2ecdfa7ace87ce0937b (patch) | |
| tree | f446ebc26a7707c7b64a937aa51a155df146c80a /app/models/concerns | |
| parent | 42714c697273a7117c6b355fab26c8c35e336ad1 (diff) | |
| parent | cdf5d9941ca866d437612d2f863eac6eb0b3db12 (diff) | |
Merge branch 'erdgeist-revive-events'
Diffstat (limited to 'app/models/concerns')
| -rw-r--r-- | app/models/concerns/nested_tree.rb | 86 | ||||
| -rw-r--r-- | app/models/concerns/rrule_humanizer.rb | 91 |
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) | ||
| 6 | module 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 | ||
| 86 | end | ||
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 @@ | |||
| 1 | module 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 | ||
| 91 | end | ||
