diff options
Diffstat (limited to 'app/models')
| -rw-r--r-- | app/models/concerns/file_attachment.rb | 9 | ||||
| -rw-r--r-- | app/models/concerns/nested_tree.rb | 78 | ||||
| -rw-r--r-- | app/models/concerns/rrule_humanizer.rb | 130 | ||||
| -rw-r--r-- | app/models/event.rb | 30 | ||||
| -rw-r--r-- | app/models/node.rb | 514 | ||||
| -rw-r--r-- | app/models/node_action.rb | 169 | ||||
| -rw-r--r-- | app/models/occurrence.rb | 24 | ||||
| -rw-r--r-- | app/models/page.rb | 203 | ||||
| -rw-r--r-- | app/models/user.rb | 31 |
9 files changed, 1044 insertions, 144 deletions
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb index 0e99fa2..613a6f0 100644 --- a/app/models/concerns/file_attachment.rb +++ b/app/models/concerns/file_attachment.rb | |||
| @@ -23,9 +23,10 @@ module FileAttachment | |||
| 23 | extend ActiveSupport::Concern | 23 | extend ActiveSupport::Concern |
| 24 | 24 | ||
| 25 | STYLES = { | 25 | STYLES = { |
| 26 | medium: { geometry: "300x300>", format: nil }, | 26 | medium: { args: ["-resize", "300x300>"] }, |
| 27 | thumb: { geometry: "100x100>", format: nil }, | 27 | thumb: { args: ["-resize", "100x100>"] }, |
| 28 | headline: { geometry: "460x250!", format: nil } | 28 | headline: { args: ["-resize", "460x250^", "-gravity", "center", "-extent", "460x250"] }, |
| 29 | large: { args: ["-resize", "1600x1600>"] } | ||
| 29 | }.freeze | 30 | }.freeze |
| 30 | 31 | ||
| 31 | IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze | 32 | IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze |
| @@ -80,7 +81,7 @@ module FileAttachment | |||
| 80 | STYLES.each do |style, options| | 81 | STYLES.each do |style, options| |
| 81 | dest_path = file_path(style) | 82 | dest_path = file_path(style) |
| 82 | FileUtils.mkdir_p(File.dirname(dest_path)) | 83 | FileUtils.mkdir_p(File.dirname(dest_path)) |
| 83 | system("magick", original_path, "-resize", options[:geometry], dest_path) | 84 | system("magick", original_path, *options[:args], dest_path) |
| 84 | end | 85 | end |
| 85 | end | 86 | end |
| 86 | 87 | ||
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb new file mode 100644 index 0000000..befcdb3 --- /dev/null +++ b/app/models/concerns/nested_tree.rb | |||
| @@ -0,0 +1,78 @@ | |||
| 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 | end | ||
| 13 | |||
| 14 | class_methods do | ||
| 15 | def root | ||
| 16 | roots.first | ||
| 17 | end | ||
| 18 | |||
| 19 | def roots | ||
| 20 | where(parent_id: nil) | ||
| 21 | end | ||
| 22 | |||
| 23 | def valid? | ||
| 24 | # Every non-root row's parent_id must point at a real, existing row. | ||
| 25 | where.not(parent_id: nil).where.missing(:parent).none? | ||
| 26 | end | ||
| 27 | end | ||
| 28 | |||
| 29 | def root? | ||
| 30 | parent_id.nil? | ||
| 31 | end | ||
| 32 | |||
| 33 | def ancestors | ||
| 34 | result = [] | ||
| 35 | current = parent | ||
| 36 | while current | ||
| 37 | result << current | ||
| 38 | current = current.parent | ||
| 39 | end | ||
| 40 | result | ||
| 41 | end | ||
| 42 | |||
| 43 | def level | ||
| 44 | ancestors.size | ||
| 45 | end | ||
| 46 | |||
| 47 | def descendants | ||
| 48 | ids = [] | ||
| 49 | queue = self.class.where(parent_id: id).pluck(:id) | ||
| 50 | until queue.empty? | ||
| 51 | ids.concat(queue) | ||
| 52 | queue = self.class.where(parent_id: queue).pluck(:id) | ||
| 53 | end | ||
| 54 | self.class.where(id: ids) | ||
| 55 | end | ||
| 56 | |||
| 57 | def self_and_descendants | ||
| 58 | self.class.where(id: [id] + descendants.pluck(:id)) | ||
| 59 | end | ||
| 60 | |||
| 61 | def self_and_descendants_ordered_with_level | ||
| 62 | nodes = [self] + descendants.to_a | ||
| 63 | children_by_parent = nodes.group_by(&:parent_id) | ||
| 64 | children_by_parent.each_value { |list| list.sort_by!(&:id) } | ||
| 65 | |||
| 66 | result = [] | ||
| 67 | visit = ->(node, level) do | ||
| 68 | result << [node, level] | ||
| 69 | (children_by_parent[node.id] || []).each { |child| visit.call(child, level + 1) } | ||
| 70 | end | ||
| 71 | visit.call(self, 0) | ||
| 72 | result | ||
| 73 | end | ||
| 74 | |||
| 75 | def move_to_child_of(new_parent) | ||
| 76 | update!(parent_id: new_parent.id) | ||
| 77 | end | ||
| 78 | end | ||
diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb new file mode 100644 index 0000000..07141c1 --- /dev/null +++ b/app/models/concerns/rrule_humanizer.rb | |||
| @@ -0,0 +1,130 @@ | |||
| 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", 5=>"fünften", -1=>"letzten", -2=>"vorletzten" }, | ||
| 19 | en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", 5=>"fifth", -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 | byday_values = byday&.split(",") | ||
| 39 | days = byday_values&.map do |d| | ||
| 40 | if d =~ /^(-?\d+)([A-Z]{2})$/ | ||
| 41 | "#{ordinals[$1.to_i]} #{weekdays[$2]}" | ||
| 42 | else | ||
| 43 | weekdays[d] | ||
| 44 | end | ||
| 45 | end | ||
| 46 | |||
| 47 | excluded_monthly_ordinal = nil | ||
| 48 | excluded_monthly_weekday = nil | ||
| 49 | selected_monthly_ordinals = nil | ||
| 50 | selected_monthly_weekday = nil | ||
| 51 | if freq == "MONTHLY" && byday_values.present? | ||
| 52 | ordinal_days = byday_values.map { |d| d.match(/^([1-5])([A-Z]{2})$/) } | ||
| 53 | if ordinal_days.all? | ||
| 54 | positions = ordinal_days.map { |match| match[1].to_i }.uniq.sort | ||
| 55 | weekdays_in_rule = ordinal_days.map { |match| match[2] }.uniq | ||
| 56 | if weekdays_in_rule.size == 1 | ||
| 57 | selected_monthly_ordinals = positions | ||
| 58 | selected_monthly_weekday = weekdays_in_rule.first | ||
| 59 | missing_positions = (1..5).to_a - positions | ||
| 60 | if positions.size == 4 && missing_positions.size == 1 | ||
| 61 | excluded_monthly_ordinal = missing_positions.first | ||
| 62 | excluded_monthly_weekday = selected_monthly_weekday | ||
| 63 | end | ||
| 64 | end | ||
| 65 | end | ||
| 66 | end | ||
| 67 | |||
| 68 | join_ordinals = lambda do |ordinal_values, conjunction| | ||
| 69 | names = ordinal_values.map { |ordinal| ordinals[ordinal] } | ||
| 70 | names.size > 1 ? "#{names[0..-2].join(', ')} #{conjunction} #{names.last}" : names.first | ||
| 71 | end | ||
| 72 | |||
| 73 | base = | ||
| 74 | case loc | ||
| 75 | when :de | ||
| 76 | case freq | ||
| 77 | when "WEEKLY" | ||
| 78 | if days | ||
| 79 | if interval == 2 | ||
| 80 | adverbial = byday.split(",").map { |d| WEEKDAY_NAMES_ADVERBIAL[:de][d] } | ||
| 81 | "Alle zwei Wochen #{adverbial.join(' und ')}" | ||
| 82 | else | ||
| 83 | "Jeden #{days.join(' und ')}" | ||
| 84 | end | ||
| 85 | else | ||
| 86 | interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" | ||
| 87 | end | ||
| 88 | when "MONTHLY" | ||
| 89 | if excluded_monthly_ordinal | ||
| 90 | "Jeden #{weekdays[excluded_monthly_weekday]} im Monat, außer dem #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}" | ||
| 91 | elsif selected_monthly_ordinals | ||
| 92 | "Jeden #{join_ordinals.call(selected_monthly_ordinals, 'und')} #{weekdays[selected_monthly_weekday]} im Monat" | ||
| 93 | else | ||
| 94 | days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" | ||
| 95 | end | ||
| 96 | end | ||
| 97 | else | ||
| 98 | case freq | ||
| 99 | when "WEEKLY" | ||
| 100 | days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") | ||
| 101 | when "MONTHLY" | ||
| 102 | if excluded_monthly_ordinal | ||
| 103 | "Every #{weekdays[excluded_monthly_weekday]} of the month, except the #{ordinals[excluded_monthly_ordinal]} #{weekdays[excluded_monthly_weekday]}" | ||
| 104 | elsif selected_monthly_ordinals | ||
| 105 | "Every #{join_ordinals.call(selected_monthly_ordinals, 'and')} #{weekdays[selected_monthly_weekday]} of the month" | ||
| 106 | else | ||
| 107 | days ? "Every #{days.join(' and ')} of the month" : "Monthly" | ||
| 108 | end | ||
| 109 | end | ||
| 110 | end | ||
| 111 | return nil unless base | ||
| 112 | |||
| 113 | if bymonth | ||
| 114 | included = bymonth.split(",").map(&:to_i) | ||
| 115 | missing = ((1..12).to_a - included) | ||
| 116 | if missing.size == 1 | ||
| 117 | excluded_name = months[missing.first - 1] | ||
| 118 | base += (loc == :de ? ", außer im #{excluded_name}" : ", except in #{excluded_name}") | ||
| 119 | end | ||
| 120 | # more than one missing month: bymonth pattern more complex than we handle, leave base as-is silently | ||
| 121 | end | ||
| 122 | |||
| 123 | base | ||
| 124 | end | ||
| 125 | |||
| 126 | def self.wday_abbr(time, locale) | ||
| 127 | code = %w[SU MO TU WE TH FR SA][time.wday] | ||
| 128 | (WEEKDAY_NAMES_ABBR[locale.to_sym] || WEEKDAY_NAMES_ABBR[:de])[code] | ||
| 129 | end | ||
| 130 | end | ||
diff --git a/app/models/event.rb b/app/models/event.rb index 94a22e3..b8651a8 100644 --- a/app/models/event.rb +++ b/app/models/event.rb | |||
| @@ -1,25 +1,27 @@ | |||
| 1 | class Event < ApplicationRecord | 1 | class Event < ApplicationRecord |
| 2 | 2 | include RruleHumanizer | |
| 3 | # Associations | 3 | |
| 4 | 4 | belongs_to :node, optional: true | |
| 5 | has_many :occurrences | 5 | has_many :occurrences, dependent: :destroy |
| 6 | belongs_to :node | 6 | acts_as_taggable_on :tags |
| 7 | 7 | ||
| 8 | # Callbacks | 8 | validates :title, presence: true, unless: -> { node_id.present? } |
| 9 | 9 | ||
| 10 | after_save :generate_occurences | 10 | after_save :generate_occurrences |
| 11 | 11 | ||
| 12 | # Instance Methods | ||
| 13 | |||
| 14 | def occurrences_in_range start_time, end_time | 12 | def occurrences_in_range start_time, end_time |
| 15 | self.occurrences.where( | 13 | self.occurrences.where( |
| 16 | "start_time > ? AND end_time < ?", | 14 | "start_time > ? AND end_time < ?", |
| 17 | start_time, end_time | 15 | start_time, end_time |
| 18 | ) | 16 | ) |
| 19 | end | 17 | end |
| 18 | |||
| 19 | def display_title | ||
| 20 | title.presence || node&.head&.title || "Untitled event" | ||
| 21 | end | ||
| 20 | 22 | ||
| 21 | private | 23 | private |
| 22 | def generate_occurences | 24 | def generate_occurrences |
| 23 | Occurrence.generate self | 25 | Occurrence.generate self |
| 24 | end | 26 | end |
| 25 | end | 27 | end |
diff --git a/app/models/node.rb b/app/models/node.rb index 92ecc12..b5b18a1 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -1,35 +1,47 @@ | |||
| 1 | class Node < ApplicationRecord | 1 | class Node < ApplicationRecord |
| 2 | # Mixins and Plugins | 2 | # Mixins and Plugins |
| 3 | acts_as_nested_set | 3 | include NestedTree |
| 4 | 4 | ||
| 5 | # Associations | 5 | # Associations |
| 6 | has_many :pages, -> { order("revision ASC") }, :dependent => :destroy | 6 | has_many :pages, -> { order("revision ASC") }, :dependent => :destroy |
| 7 | belongs_to :head, :class_name => "Page", :foreign_key => :head_id, :dependent => :destroy, optional: true | 7 | has_many :node_actions, :dependent => :nullify |
| 8 | belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, :dependent => :destroy, optional: true | 8 | belongs_to :head, :class_name => "Page", :foreign_key => :head_id, optional: true |
| 9 | belongs_to :draft, :class_name => "Page", :foreign_key => :draft_id, optional: true | ||
| 10 | # Autosave pages carry no node_id, so has_many :pages does not cover | ||
| 11 | # them -- this dependent: :destroy is their only cleanup on node destroy. | ||
| 12 | belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true | ||
| 13 | |||
| 9 | has_many :permissions, :dependent => :destroy | 14 | has_many :permissions, :dependent => :destroy |
| 10 | has_one :event, :dependent => :destroy | 15 | has_many :events, :dependent => :destroy |
| 11 | belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true | 16 | belongs_to :lock_owner, :class_name => "User", :foreign_key => :locking_user_id, optional: true |
| 12 | 17 | ||
| 13 | # Callbacks | 18 | # Callbacks |
| 14 | after_create :initialize_empty_page | 19 | after_create :initialize_empty_page |
| 15 | before_save :check_for_changed_slug | 20 | before_save :check_for_changed_slug |
| 16 | after_save :update_unique_names_of_children | 21 | after_save :update_unique_names_of_children |
| 22 | before_destroy :refuse_destroy_with_children | ||
| 23 | before_destroy :refuse_destroying_trash_node | ||
| 17 | 24 | ||
| 18 | # Validations | 25 | # Validations |
| 19 | validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? } | 26 | validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } |
| 20 | validates_presence_of :slug, :unless => -> { parent_id.nil? } | 27 | validates_presence_of :slug, :unless => -> { parent_id.nil? } |
| 21 | validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } | 28 | validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } |
| 22 | validates_presence_of :parent_id, :unless => -> { Node.root.nil? } | 29 | validates_presence_of :parent_id, :unless => -> { Node.root.nil? } |
| 23 | 30 | ||
| 24 | validate :borders # This should never ever happen. | 31 | validate :reserved_slug_stays_reserved |
| 32 | validate :no_head_inside_trash | ||
| 33 | validates :default_template_name, | ||
| 34 | :inclusion => { :in => ->(_) { Page.custom_templates } }, | ||
| 35 | :allow_nil => true, | ||
| 36 | :if => :default_template_name_changed? | ||
| 25 | 37 | ||
| 26 | # Index for Fulltext Search | 38 | # Everything outside the Trash subtree, the Trash node included. |
| 27 | # define_index do | 39 | # Relies on unique_name being authoritative for tree position -- |
| 28 | # indexes head.translations.title | 40 | # the same trust public routing places in it. |
| 29 | # indexes slug | 41 | scope :not_in_trash, -> { |
| 30 | # indexes unique_name | 42 | where.not(:unique_name => CccConventions::TRASH_SLUG) |
| 31 | # indexes head.translations.body | 43 | .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%") |
| 32 | # end | 44 | } |
| 33 | 45 | ||
| 34 | # Class methods | 46 | # Class methods |
| 35 | 47 | ||
| @@ -57,30 +69,116 @@ class Node < ApplicationRecord | |||
| 57 | nil | 69 | nil |
| 58 | end | 70 | end |
| 59 | 71 | ||
| 72 | # The Trash container node. Lazily self-creating and idempotent, so | ||
| 73 | # every environment acquires it on first touch. | ||
| 74 | # Never call from validations. the positional predicates below | ||
| 75 | # exist for that. | ||
| 76 | def self.trash | ||
| 77 | root.children.find_by(:slug => CccConventions::TRASH_SLUG) || | ||
| 78 | root.children.create!(:slug => CccConventions::TRASH_SLUG).tap do |node| | ||
| 79 | Globalize.with_locale(I18n.default_locale) do | ||
| 80 | node.draft.update!(:title => "Trash") | ||
| 81 | end | ||
| 82 | end | ||
| 83 | end | ||
| 84 | |||
| 60 | # Instance Methods | 85 | # Instance Methods |
| 61 | 86 | ||
| 62 | def find_or_create_draft current_user | 87 | # Acquires (or reaffirms) the editing lock without creating a draft or |
| 63 | self.wipe_draft! | 88 | # an autosave -- both are now deferred until there is real content to |
| 64 | if draft && self.lock_owner == current_user | 89 | # hold. |
| 65 | draft | 90 | def lock_for_editing! current_user |
| 66 | elsif draft && self.lock_owner.nil? | 91 | if self.lock_owner.nil? || self.lock_owner == current_user |
| 67 | lock_for! current_user | 92 | lock_for! current_user |
| 68 | draft.user = current_user if draft.user.nil? | 93 | if self.draft |
| 69 | draft.editor = current_user | 94 | self.draft.user = current_user if self.draft.user.nil? |
| 70 | draft.save | 95 | self.draft.editor = current_user |
| 71 | draft | 96 | self.draft.save! |
| 72 | elsif draft && self.lock_owner != current_user | 97 | end |
| 98 | self | ||
| 99 | else | ||
| 73 | raise( | 100 | raise( |
| 74 | LockedByAnotherUser, | 101 | LockedByAnotherUser, |
| 75 | "Page is locked by another user who is working on it! " \ | 102 | "Page is locked by another user who is working on it! " \ |
| 76 | "Last modification: #{draft.updated_at.to_fs(:db)}" | 103 | "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}" |
| 77 | ) | 104 | ) |
| 105 | end | ||
| 106 | end | ||
| 107 | |||
| 108 | # Creates or updates the autosave buffer from the given attributes. | ||
| 109 | # Autosave rows are never associated to the node via node_id -- they | ||
| 110 | # must never appear in self.pages / the revisions list, which is the | ||
| 111 | # whole reason autosave exists as a separate, unversioned layer. | ||
| 112 | def autosave! attributes, current_user | ||
| 113 | assert_locked_by! current_user | ||
| 114 | |||
| 115 | unless self.autosave | ||
| 116 | self.autosave = Page.create!(:editor => current_user) | ||
| 117 | self.autosave.clone_attributes_from(self.draft || self.head) if self.draft || self.head | ||
| 118 | self.save! | ||
| 119 | end | ||
| 120 | self.autosave.assign_attributes(attributes) | ||
| 121 | self.autosave.save! | ||
| 122 | self.autosave | ||
| 123 | end | ||
| 124 | |||
| 125 | # Promotes the current autosave into the draft (creating the draft if | ||
| 126 | # none exists yet) and destroys the autosave afterward. This is what | ||
| 127 | # the explicit "Save" action does; it never creates a new revision -- | ||
| 128 | # same as any other in-place draft edit. The new draft is created via | ||
| 129 | # self.pages.create! rather than by repointing the autosave's own | ||
| 130 | # node_id, because acts_as_list assigns the revision number at create | ||
| 131 | # time, scoped to node_id -- a page created with node_id nil and | ||
| 132 | # reassigned afterward would carry a wrong or missing revision number. | ||
| 133 | def save_draft! current_user | ||
| 134 | assert_locked_by! current_user | ||
| 135 | return unless self.autosave | ||
| 136 | |||
| 137 | if self.draft | ||
| 138 | preserved_published_at = self.draft.published_at | ||
| 139 | self.draft.clone_attributes_from self.autosave | ||
| 140 | self.draft.published_at = preserved_published_at | ||
| 141 | self.draft.user_id = self.autosave.user_id if self.autosave.user_id | ||
| 142 | self.draft.editor = current_user | ||
| 143 | self.draft.save! | ||
| 78 | else | 144 | else |
| 79 | lock_for! current_user | 145 | empty_page = self.pages.create! |
| 80 | create_new_draft current_user | 146 | empty_page.clone_attributes_from self.autosave |
| 147 | empty_page.user = self.autosave.user_id ? self.autosave.user : (self.head ? self.head.user : current_user) | ||
| 148 | empty_page.editor = current_user | ||
| 149 | empty_page.published_at = self.head.published_at if self.head | ||
| 150 | empty_page.save! | ||
| 151 | self.draft = empty_page | ||
| 152 | self.save! | ||
| 153 | end | ||
| 154 | |||
| 155 | self.autosave.destroy | ||
| 156 | self.autosave_id = nil | ||
| 157 | self.save! | ||
| 158 | self.draft.reload | ||
| 159 | end | ||
| 160 | |||
| 161 | def resolve_page_reference ref | ||
| 162 | case ref.to_s | ||
| 163 | when "head" then head | ||
| 164 | when "draft" then draft | ||
| 165 | when "autosave" then autosave | ||
| 166 | else pages.find_by_revision(ref) | ||
| 81 | end | 167 | end |
| 82 | end | 168 | end |
| 83 | 169 | ||
| 170 | # Which layer-pairs are meaningful to compare right now, given this | ||
| 171 | # node's actual state. Head vs autosave only shows up when no draft | ||
| 172 | # sits between them -- with a draft present, autosave is compared | ||
| 173 | # against the draft, never past it straight to head. | ||
| 174 | def available_layer_pairs | ||
| 175 | pairs = [] | ||
| 176 | pairs << [:head, :draft] if head && draft | ||
| 177 | pairs << [:draft, :autosave] if draft && autosave | ||
| 178 | pairs << [:head, :autosave] if head && autosave && !draft | ||
| 179 | pairs | ||
| 180 | end | ||
| 181 | |||
| 84 | def create_new_draft user | 182 | def create_new_draft user |
| 85 | empty_page = self.pages.create! | 183 | empty_page = self.pages.create! |
| 86 | empty_page.user = (self.head ? self.head.user : user) | 184 | empty_page.user = (self.head ? self.head.user : user) |
| @@ -94,67 +192,221 @@ class Node < ApplicationRecord | |||
| 94 | self.draft.reload | 192 | self.draft.reload |
| 95 | end | 193 | end |
| 96 | 194 | ||
| 97 | def publish_draft! | 195 | # Discards exactly the topmost non-empty layer -- autosave if present, |
| 196 | # else draft -- and reveals whatever's beneath it. Releases the lock | ||
| 197 | # only once nothing is left to protect (no draft survives); leaves it | ||
| 198 | # alone whenever a draft remains, since #edit still has real content | ||
| 199 | # open. | ||
| 200 | def revert! current_user | ||
| 201 | assert_locked_by! current_user | ||
| 202 | |||
| 203 | if self.autosave | ||
| 204 | self.autosave.destroy | ||
| 205 | self.autosave_id = nil | ||
| 206 | self.save! | ||
| 207 | NodeAction.record!(:node => self, :user => current_user, :action => "discard_autosave") | ||
| 208 | elsif self.draft && self.head | ||
| 209 | self.draft.destroy | ||
| 210 | self.draft_id = nil | ||
| 211 | self.save! | ||
| 212 | NodeAction.record!(:node => self, :user => current_user, :action => "destroy_draft") | ||
| 213 | end | ||
| 214 | |||
| 215 | self.unlock! unless self.draft | ||
| 216 | self.reload | ||
| 217 | end | ||
| 218 | |||
| 219 | def staged_slug=(value) | ||
| 220 | if head.blank? | ||
| 221 | self.slug = value | ||
| 222 | else | ||
| 223 | super | ||
| 224 | end | ||
| 225 | end | ||
| 226 | |||
| 227 | def publish_draft! current_user = nil | ||
| 98 | # Return nil if nothing to publish and no staged changes | 228 | # Return nil if nothing to publish and no staged changes |
| 99 | return nil unless self.draft || staged_slug || staged_parent_id | 229 | return nil unless self.draft || staged_slug || staged_parent_id |
| 100 | 230 | ||
| 101 | if self.draft | 231 | if in_trash? || trash_node? |
| 102 | self.head = self.draft | 232 | raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash" |
| 103 | self.head.published_at ||= Time.now | ||
| 104 | self.head.save! | ||
| 105 | self.draft = nil | ||
| 106 | end | 233 | end |
| 107 | 234 | ||
| 108 | if staged_slug && (staged_slug != slug) | 235 | path_before = self.unique_name |
| 109 | self.slug = staged_slug | 236 | |
| 110 | self.staged_slug = nil | 237 | ActiveRecord::Base.transaction do |
| 238 | if self.draft | ||
| 239 | outgoing_head = self.head | ||
| 240 | self.head = self.draft | ||
| 241 | self.head.published_at ||= Time.now | ||
| 242 | self.head.save! | ||
| 243 | self.draft = nil | ||
| 244 | |||
| 245 | NodeAction.record!(:node => self, :page => self.head, :user => current_user, | ||
| 246 | :action => "publish", :via => "draft", | ||
| 247 | **NodeAction.head_diff(outgoing_head, self.head)) | ||
| 248 | end | ||
| 249 | |||
| 250 | if staged_slug && (staged_slug != slug) | ||
| 251 | self.slug = staged_slug | ||
| 252 | self.staged_slug = nil | ||
| 253 | end | ||
| 254 | |||
| 255 | if staged_parent_id && (staged_parent_id != parent_id) | ||
| 256 | new_parent = Node.find(staged_parent_id) | ||
| 257 | |||
| 258 | if new_parent == self || self.descendants.include?(new_parent) | ||
| 259 | raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants" | ||
| 260 | end | ||
| 261 | |||
| 262 | self.staged_parent_id = nil | ||
| 263 | self.save! | ||
| 264 | self.move_to_child_of(new_parent) | ||
| 265 | else | ||
| 266 | unless self.save | ||
| 267 | raise ActiveRecord::RecordInvalid.new(self) | ||
| 268 | end | ||
| 269 | end | ||
| 270 | |||
| 271 | self.reload | ||
| 272 | self.update_unique_name | ||
| 273 | self.send(:update_unique_names_of_children) | ||
| 274 | if self.unique_name != path_before | ||
| 275 | NodeAction.record!(:node => self, :user => current_user, :action => "move", | ||
| 276 | :path => { "from" => path_before, "to" => self.unique_name }) | ||
| 277 | end | ||
| 278 | self.unlock! | ||
| 279 | self | ||
| 111 | end | 280 | end |
| 281 | end | ||
| 112 | 282 | ||
| 113 | if staged_parent_id && (staged_parent_id != parent_id) | 283 | def restore_revision! revision, current_user = nil |
| 114 | new_parent = Node.find(staged_parent_id) | 284 | page = self.pages.find_by_revision(revision) |
| 115 | self.staged_parent_id = nil | 285 | return nil unless page |
| 286 | |||
| 287 | ActiveRecord::Base.transaction do | ||
| 288 | outgoing_head = self.head | ||
| 289 | self.head = page | ||
| 116 | self.save! | 290 | self.save! |
| 117 | self.move_to_child_of(new_parent) | 291 | |
| 118 | else | 292 | NodeAction.record!(:node => self, :page => page, :user => current_user, |
| 119 | unless self.save | 293 | :action => "publish", :via => "revision", |
| 120 | raise ActiveRecord::RecordInvalid.new(self) | 294 | **NodeAction.head_diff(outgoing_head, page)) |
| 121 | end | 295 | self |
| 122 | end | 296 | end |
| 297 | end | ||
| 123 | 298 | ||
| 124 | self.reload | 299 | |
| 125 | self.update_unique_name | 300 | # Moves this node and its subtree into the Trash. Demotes every head |
| 126 | self.send(:update_unique_names_of_children) | 301 | # in the subtree first (aggregators and search operate on heads |
| 127 | self.unlock! | 302 | # regardless of tree position); where a node has no draft, the former |
| 128 | self | 303 | # head becomes its draft so content stays editable and restorable -- |
| 304 | # otherwise the former head remains a plain revision. One log entry, | ||
| 305 | # at the root, carrying the leaving-public-view snapshot. | ||
| 306 | def trash! current_user = nil | ||
| 307 | return nil if in_trash? | ||
| 308 | raise ActiveRecord::RecordInvalid.new(self), "The Trash node itself cannot be trashed" if trash_node? | ||
| 309 | |||
| 310 | ActiveRecord::Base.transaction do | ||
| 311 | path_before = unique_name | ||
| 312 | was_published = head_id.present? | ||
| 313 | final_published_at = head&.published_at | ||
| 314 | |||
| 315 | demoted = 0 | ||
| 316 | ([self] + descendants.to_a).each do |node| | ||
| 317 | next unless node.head_id | ||
| 318 | former = node.head | ||
| 319 | node.head = nil | ||
| 320 | node.draft_id = former.id if node.draft_id.nil? | ||
| 321 | node.save! | ||
| 322 | demoted += 1 | ||
| 323 | end | ||
| 324 | |||
| 325 | self.reload | ||
| 326 | move_to_child_of(Node.trash) | ||
| 327 | self.reload | ||
| 328 | update_unique_name | ||
| 329 | send(:update_unique_names_of_children) | ||
| 330 | unlock! | ||
| 331 | |||
| 332 | metadata = { :path => { "from" => path_before, "to" => unique_name } } | ||
| 333 | metadata[:was_published] = true if was_published | ||
| 334 | metadata[:final_published_at] = final_published_at.iso8601 if final_published_at | ||
| 335 | metadata[:demoted_heads] = demoted if demoted > 0 | ||
| 336 | |||
| 337 | NodeAction.record!(:node => self, :user => current_user, :action => "trash", **metadata) | ||
| 338 | self | ||
| 339 | end | ||
| 129 | end | 340 | end |
| 130 | 341 | ||
| 131 | # removes a draft and the lock if it is older than a day and still | 342 | # Returns the node to the living tree under a chosen parent. The |
| 132 | # identical to head | 343 | # subtree comes back exactly as it sits in the Trash: all drafts, |
| 133 | def wipe_draft! | 344 | # nothing published. Republication is a separate, witnessed act |
| 134 | unless self.draft | 345 | # per node. |
| 135 | self.unlock! | 346 | def restore_from_trash! new_parent, current_user = nil |
| 136 | return | 347 | return nil unless in_trash? |
| 348 | |||
| 349 | if new_parent.nil? || new_parent == self || descendants.include?(new_parent) || | ||
| 350 | new_parent.trash_node? || new_parent.in_trash? | ||
| 351 | raise ActiveRecord::RecordInvalid.new(self), "Restore target must be a living node" | ||
| 137 | end | 352 | end |
| 138 | return unless self.head | ||
| 139 | return unless self.draft.updated_at < 1.day.ago | ||
| 140 | return if self.head.has_changes_to? self.draft | ||
| 141 | 353 | ||
| 142 | self.draft.destroy | 354 | ActiveRecord::Base.transaction do |
| 143 | self.draft_id = nil | 355 | path_before = unique_name |
| 144 | self.unlock! | 356 | move_to_child_of(new_parent) |
| 145 | self.save! | 357 | self.reload |
| 146 | self.reload | 358 | update_unique_name |
| 359 | send(:update_unique_names_of_children) | ||
| 360 | |||
| 361 | NodeAction.record!(:node => self, :user => current_user, :action => "restore_from_trash", | ||
| 362 | :path => { "from" => path_before, "to" => unique_name }) | ||
| 363 | self | ||
| 364 | end | ||
| 147 | end | 365 | end |
| 148 | 366 | ||
| 149 | def restore_revision! revision | 367 | # Final deletion -- only from inside the Trash. Removes the whole |
| 150 | if page = self.pages.find_by_revision(revision) | 368 | # subtree, deepest first, each node through a real destroy! so every |
| 151 | self.head = page | 369 | # per-node cascade runs (the categorical difference from the old |
| 152 | self.save | 370 | # delete_all nuke). refuse_destroy_with_children on bare destroy is |
| 153 | else | 371 | # untouched |
| 154 | nil | 372 | # One log entry at the root, per the subtree rule, written before the |
| 373 | # rows die. | ||
| 374 | def destroy_from_trash! current_user = nil | ||
| 375 | raise ActiveRecord::RecordInvalid.new(self), "Nodes are only destroyed from the Trash" unless in_trash? | ||
| 376 | |||
| 377 | ActiveRecord::Base.transaction do | ||
| 378 | doomed = self_and_descendants_ordered_with_level | ||
| 379 | .sort_by { |_, level| -level } | ||
| 380 | .map(&:first) | ||
| 381 | |||
| 382 | metadata = { :path => unique_name } | ||
| 383 | metadata[:destroyed_descendants] = doomed.size - 1 if doomed.size > 1 | ||
| 384 | |||
| 385 | NodeAction.record!(:node => self, :user => current_user, :action => "destroy", **metadata) | ||
| 386 | doomed.each(&:destroy!) | ||
| 155 | end | 387 | end |
| 156 | end | 388 | end |
| 157 | 389 | ||
| 390 | # The most recent trash entry. Its path.from is the restore hint. | ||
| 391 | def last_trash_entry | ||
| 392 | node_actions.where(:action => "trash").order(:occurred_at => :desc, :id => :desc).first | ||
| 393 | end | ||
| 394 | |||
| 395 | # The node's pre-trash parent, if a living node still answers to | ||
| 396 | # that path. Nil when the parent was itself trashed (its unique_name | ||
| 397 | # changed) or deleted; a different node that has since taken over | ||
| 398 | # the path is a legitimate suggestion. | ||
| 399 | def suggested_restore_parent | ||
| 400 | from = last_trash_entry&.metadata&.dig("path", "from") | ||
| 401 | return nil unless from | ||
| 402 | |||
| 403 | parent_path = from.rpartition("/").first | ||
| 404 | return Node.root if parent_path.empty? | ||
| 405 | |||
| 406 | candidate = Node.find_by(:unique_name => parent_path) | ||
| 407 | candidate unless candidate.nil? || candidate.in_trash? || candidate.trash_node? | ||
| 408 | end | ||
| 409 | |||
| 158 | # returns an array with all parts of a unique_name rather than a string | 410 | # returns an array with all parts of a unique_name rather than a string |
| 159 | def unique_path | 411 | def unique_path |
| 160 | unique_name.to_s.split("/") | 412 | unique_name.to_s.split("/") |
| @@ -165,9 +417,12 @@ class Node < ApplicationRecord | |||
| 165 | parent.nil? ? [slug] : parent.path_to_root.push(slug) | 417 | parent.nil? ? [slug] : parent.path_to_root.push(slug) |
| 166 | end | 418 | end |
| 167 | 419 | ||
| 420 | def computed_unique_name | ||
| 421 | path_to_root[1..-1].join("/") # excluding root | ||
| 422 | end | ||
| 423 | |||
| 168 | def current_unique_name | 424 | def current_unique_name |
| 169 | path = path_to_root[1..-1] # excluding root | 425 | self.unique_name = computed_unique_name |
| 170 | self.unique_name = path.join("/") | ||
| 171 | end | 426 | end |
| 172 | 427 | ||
| 173 | def update_unique_name | 428 | def update_unique_name |
| @@ -203,6 +458,24 @@ class Node < ApplicationRecord | |||
| 203 | unique_path.length == 3 && unique_path[0] == "updates" | 458 | unique_path.length == 3 && unique_path[0] == "updates" |
| 204 | end | 459 | end |
| 205 | 460 | ||
| 461 | def editable_page | ||
| 462 | autosave || draft || head | ||
| 463 | end | ||
| 464 | |||
| 465 | def trash_node? | ||
| 466 | parent&.root? && slug == CccConventions::TRASH_SLUG | ||
| 467 | end | ||
| 468 | |||
| 469 | # Inside the Trash subtree. False for the Trash node itself. | ||
| 470 | def in_trash? | ||
| 471 | current = parent | ||
| 472 | while current | ||
| 473 | return true if current.trash_node? | ||
| 474 | current = current.parent | ||
| 475 | end | ||
| 476 | false | ||
| 477 | end | ||
| 478 | |||
| 206 | # Returns immutable node id for all new nodes so that the atom feed entry ids | 479 | # Returns immutable node id for all new nodes so that the atom feed entry ids |
| 207 | # stay the same eventhough the slug or positions changes. | 480 | # stay the same eventhough the slug or positions changes. |
| 208 | # Can be removed after a year or so ;) | 481 | # Can be removed after a year or so ;) |
| @@ -220,12 +493,68 @@ class Node < ApplicationRecord | |||
| 220 | .distinct | 493 | .distinct |
| 221 | end | 494 | end |
| 222 | 495 | ||
| 496 | # This one is for admin-only views, where finding a draft is the point. | ||
| 497 | # Substring match on whichever of head/draft is present. | ||
| 498 | def self.editor_search(term) | ||
| 499 | words = term.to_s.split(/\s+/).reject(&:blank?) | ||
| 500 | return none if words.empty? | ||
| 501 | |||
| 502 | conditions = [] | ||
| 503 | binds = {} | ||
| 504 | |||
| 505 | words.each_with_index do |word, i| | ||
| 506 | key = "term#{i}" | ||
| 507 | binds[key.to_sym] = "%#{sanitize_sql_like(word)}%" | ||
| 508 | conditions << "(head_translations.title ILIKE :#{key} OR head_translations.abstract ILIKE :#{key} " \ | ||
| 509 | "OR draft_translations.title ILIKE :#{key} OR draft_translations.abstract ILIKE :#{key})" | ||
| 510 | end | ||
| 511 | |||
| 512 | joins("LEFT JOIN page_translations head_translations ON head_translations.page_id = nodes.head_id") | ||
| 513 | .joins("LEFT JOIN page_translations draft_translations ON draft_translations.page_id = nodes.draft_id") | ||
| 514 | .where(conditions.join(" AND "), binds) | ||
| 515 | .distinct | ||
| 516 | end | ||
| 517 | |||
| 518 | def self.drafts_and_autosaves(current_user_id: nil) | ||
| 519 | scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").not_in_trash | ||
| 520 | return scope.order("updated_at DESC") unless current_user_id | ||
| 521 | |||
| 522 | scope.order( | ||
| 523 | Arel.sql(sanitize_sql_array(["CASE WHEN locking_user_id = ? THEN 0 ELSE 1 END, updated_at DESC", current_user_id])) | ||
| 524 | ) | ||
| 525 | end | ||
| 526 | |||
| 527 | # Nodes are never destroyed recursively | ||
| 528 | # Descendants must be removed or reparented individually first. | ||
| 529 | # The Trash feature will be the ordinary path to deletion. | ||
| 530 | def refuse_destroy_with_children | ||
| 531 | return unless children.exists? | ||
| 532 | errors.add(:base, "Cannot destroy a node that still has children") | ||
| 533 | throw :abort | ||
| 534 | end | ||
| 535 | |||
| 536 | def self.recently_changed | ||
| 537 | includes(:head).where( | ||
| 538 | "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", | ||
| 539 | Time.now, Time.now - 14.days | ||
| 540 | ).order("pages.updated_at desc").references(:head) | ||
| 541 | end | ||
| 542 | |||
| 223 | protected | 543 | protected |
| 224 | def lock_for! current_user | 544 | def lock_for! current_user |
| 225 | self.lock_owner = current_user | 545 | self.lock_owner = current_user |
| 226 | self.save | 546 | self.save |
| 227 | end | 547 | end |
| 228 | 548 | ||
| 549 | def assert_locked_by! current_user | ||
| 550 | return if self.lock_owner == current_user | ||
| 551 | raise( | ||
| 552 | LockedByAnotherUser, | ||
| 553 | "Page is locked by another user who is working on it! " \ | ||
| 554 | "Last modification: #{(self.autosave || self.draft || self.head).updated_at.to_fs(:db)}" | ||
| 555 | ) | ||
| 556 | end | ||
| 557 | |||
| 229 | # Creates an empty page and associates it to the given node. This means | 558 | # Creates an empty page and associates it to the given node. This means |
| 230 | # freshly created node has an empty draft. A user can create nodes as he | 559 | # freshly created node has an empty draft. A user can create nodes as he |
| 231 | # wants to which will not appear on the public page until the author edits | 560 | # wants to which will not appear on the public page until the author edits |
| @@ -246,10 +575,13 @@ class Node < ApplicationRecord | |||
| 246 | # Watch out recursion ahead! update_unique_name itself triggers this | 575 | # Watch out recursion ahead! update_unique_name itself triggers this |
| 247 | # after_save callback which invokes update_unique_name on its children. | 576 | # after_save callback which invokes update_unique_name on its children. |
| 248 | # Hopefully until no childrens occur | 577 | # Hopefully until no childrens occur |
| 578 | # | ||
| 579 | # Queries parent_id directly rather than the NestedTree#children | ||
| 580 | # association out of habit from the old awesome_nested_set-avoidance | ||
| 581 | # workaround - no longer strictly necessary now that children is | ||
| 582 | # equally safe, but left as-is since it already works correctly. | ||
| 249 | def update_unique_names_of_children | 583 | def update_unique_names_of_children |
| 250 | unless root? | 584 | unless root? |
| 251 | # Use parent_id-based traversal instead of lft/rgt descendants | ||
| 252 | # due to awesome_nested_set not refreshing parent lft/rgt in memory | ||
| 253 | Node.where(:parent_id => self.id).each do |child| | 585 | Node.where(:parent_id => self.id).each do |child| |
| 254 | child.reload | 586 | child.reload |
| 255 | child.update_unique_name | 587 | child.update_unique_name |
| @@ -258,9 +590,35 @@ class Node < ApplicationRecord | |||
| 258 | end | 590 | end |
| 259 | end | 591 | end |
| 260 | 592 | ||
| 261 | def borders | 593 | private |
| 262 | if lft && rgt && (lft > rgt) | 594 | |
| 263 | errors.add("Fuck!. lft should never be smaller than rgt") | 595 | def reserved_slug_stays_reserved |
| 596 | if parent&.root? && !trash_node_already_me? | ||
| 597 | errors.add(:slug, "is reserved for the Trash") if slug == CccConventions::TRASH_SLUG | ||
| 598 | errors.add(:staged_slug, "is reserved for the Trash") if staged_slug == CccConventions::TRASH_SLUG | ||
| 264 | end | 599 | end |
| 600 | |||
| 601 | if persisted? && slug_was == CccConventions::TRASH_SLUG && Node.find(id).trash_node? | ||
| 602 | errors.add(:slug, "of the Trash node cannot change") if slug_changed? | ||
| 603 | errors.add(:parent_id, "of the Trash node cannot change") if parent_id_changed? | ||
| 604 | errors.add(:staged_slug, "must stay empty on the Trash node") if staged_slug.present? | ||
| 605 | errors.add(:staged_parent_id, "must stay empty on the Trash node") if staged_parent_id.present? | ||
| 606 | end | ||
| 607 | end | ||
| 608 | |||
| 609 | def trash_node_already_me? | ||
| 610 | existing = Node.root&.children&.find_by(:slug => CccConventions::TRASH_SLUG) | ||
| 611 | existing.nil? || existing.id == id | ||
| 612 | end | ||
| 613 | |||
| 614 | def no_head_inside_trash | ||
| 615 | return unless head_id.present? | ||
| 616 | errors.add(:head_id, "cannot exist inside the Trash") if in_trash? || trash_node? | ||
| 617 | end | ||
| 618 | |||
| 619 | def refuse_destroying_trash_node | ||
| 620 | return unless trash_node? | ||
| 621 | errors.add(:base, "The Trash node cannot be destroyed") | ||
| 622 | throw :abort | ||
| 265 | end | 623 | end |
| 266 | end | 624 | end |
diff --git a/app/models/node_action.rb b/app/models/node_action.rb new file mode 100644 index 0000000..13bd5ba --- /dev/null +++ b/app/models/node_action.rb | |||
| @@ -0,0 +1,169 @@ | |||
| 1 | class NodeAction < ApplicationRecord | ||
| 2 | belongs_to :node, optional: true | ||
| 3 | belongs_to :page, optional: true | ||
| 4 | belongs_to :user, optional: true | ||
| 5 | |||
| 6 | validates :action, presence: true | ||
| 7 | validates :occurred_at, presence: true | ||
| 8 | |||
| 9 | # == Metadata contract == | ||
| 10 | # | ||
| 11 | # metadata is written once at creation, never updated. It is the | ||
| 12 | # single place anything that must survive deletion of the referenced | ||
| 13 | # rows lives; node_id/page_id/user_id are lookup and ordering only. | ||
| 14 | # All keys are strings. Pairs are always {"from" => x, "to" => y}. | ||
| 15 | # Optional pairs only when the sides differ; booleans only when | ||
| 16 | # true; required keys always present. Titles and names are read | ||
| 17 | # pinned to I18n.default_locale unless inside a locale-keyed block. | ||
| 18 | # | ||
| 19 | # Baseline, every entry (written here, not by call sites): | ||
| 20 | # "username" -- actor's login at write time | ||
| 21 | # "human_readable_node_name" -- node title, default locale | ||
| 22 | # | ||
| 23 | # "create": | ||
| 24 | # "title" -- initial title, flat string (NOT a pair) | ||
| 25 | # "path" -- unique path at creation, flat string. Historical | ||
| 26 | # value only; never a join key. | ||
| 27 | # | ||
| 28 | # "publish" (any promotion to head; diff computed BEFORE head is | ||
| 29 | # re-pointed, over the union of both pages' locales, by head_diff -- | ||
| 30 | # shared with the backfill): | ||
| 31 | # "via" -- "draft" | "revision" (rollback). Always written; | ||
| 32 | # absent means a pre-contract entry. | ||
| 33 | # "title" -- pair, always; "from" null on first publish | ||
| 34 | # "author" -- pair, when the byline changed (incl. first publish) | ||
| 35 | # "tags" -- pair of arrays, when changed | ||
| 36 | # "assets_changed", "template_changed", | ||
| 37 | # "abstract_changed", "body_changed" | ||
| 38 | # -- the last two for the default locale; page_id links | ||
| 39 | # to the revision for the real diff (never stored) | ||
| 40 | # "translation_diff" -- only when a non-default locale differs: | ||
| 41 | # { "<locale>" => { | ||
| 42 | # "status" -- "added" | "removed" | "changed" | ||
| 43 | # "title" -- pair, only when it differs; "from" null when | ||
| 44 | # added, "to" null when removed | ||
| 45 | # "abstract_changed", "body_changed" -- status "changed" only | ||
| 46 | # } } | ||
| 47 | # | ||
| 48 | # "move" (reparent and/or path change; one entry at the subtree | ||
| 49 | # root, descendants get none): | ||
| 50 | # "path" -- pair | ||
| 51 | # | ||
| 52 | # "trash" (subtree into the Trash; every head in the subtree is | ||
| 53 | # demoted first; one entry at the root; snapshots the | ||
| 54 | # leaving-public-view state, since Trash holds no heads and destroy | ||
| 55 | # can no longer know): | ||
| 56 | # "path" -- pair; "from" doubles as the restore hint | ||
| 57 | # "was_published" -- boolean | ||
| 58 | # "demoted_heads" -- integer count, only when positive | ||
| 59 | # "final_published_at" -- ISO8601 string, only when present | ||
| 60 | # | ||
| 61 | # "restore_from_trash" (reparent back to a living node; returns as | ||
| 62 | # drafts, republication is a separate witnessed act): | ||
| 63 | # "path" -- pair | ||
| 64 | # | ||
| 65 | # "destroy" (only from inside the Trash, never with children; the | ||
| 66 | # entry is written in the same transaction before the row dies): | ||
| 67 | # "path" -- final path, flat string (create-symmetric) | ||
| 68 | # "destroyed_descendants" -- integer, only when positive; one entry | ||
| 69 | # at the root, per the subtree rule. | ||
| 70 | # | ||
| 71 | # Reserved: "demote" (via "trash" | "depublish") for an explicit | ||
| 72 | # depublish workflow, if ever built. | ||
| 73 | # | ||
| 74 | # Backfilled entries mirror this vocabulary; diff content is | ||
| 75 | # computed, only actor (page.editor) and occurred_at are inferred, | ||
| 76 | # inferred_from names the heuristic ("from_node_created_at", | ||
| 77 | # "from_page_revision"). Null = witnessed live. | ||
| 78 | # | ||
| 79 | # The "locale" column is written by no verb; retained. | ||
| 80 | # | ||
| 81 | # This log records; it does not undo. No IP, session, or user | ||
| 82 | # agent, ever. Success only. | ||
| 83 | |||
| 84 | def self.record!(node:, action:, user: nil, page: nil, locale: nil, | ||
| 85 | occurred_at: nil, inferred_from: nil, **extra) | ||
| 86 | create!( | ||
| 87 | :node => node, | ||
| 88 | :page => page, | ||
| 89 | :user => user, | ||
| 90 | :action => action, | ||
| 91 | :locale => locale, | ||
| 92 | :occurred_at => occurred_at || Time.now, | ||
| 93 | :inferred_from => inferred_from, | ||
| 94 | :metadata => { | ||
| 95 | "username" => user&.login, | ||
| 96 | "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { | ||
| 97 | node&.head&.title || node&.draft&.title | ||
| 98 | }, | ||
| 99 | }.merge(extra.stringify_keys) | ||
| 100 | ) | ||
| 101 | end | ||
| 102 | |||
| 103 | # Computes the publish-entry diff between an outgoing head and the | ||
| 104 | # page replacing it, in the exact metadata shape the contract above | ||
| 105 | # specifies. Pure function of its two arguments -- shared verbatim by | ||
| 106 | # publish_draft!, restore_revision!, and the backfill task. Reads | ||
| 107 | # translation rows directly, never locale-dependent accessors, so a | ||
| 108 | # fallback value is never mistaken for real content. Returns | ||
| 109 | # symbol-keyed top level for splatting into record!; nested keys are | ||
| 110 | # strings and jsonb serialization stringifies the rest at write time. | ||
| 111 | def self.head_diff old_page, new_page | ||
| 112 | default = I18n.default_locale | ||
| 113 | |||
| 114 | title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title } | ||
| 115 | diff = { :title => { "from" => title_of.call(old_page), | ||
| 116 | "to" => title_of.call(new_page) } } | ||
| 117 | unless old_page | ||
| 118 | diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user | ||
| 119 | return diff | ||
| 120 | end | ||
| 121 | |||
| 122 | old_author, new_author = old_page.user&.login, new_page.user&.login | ||
| 123 | diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author | ||
| 124 | |||
| 125 | old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort | ||
| 126 | diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags | ||
| 127 | |||
| 128 | diff[:template_changed] = true if old_page.template_name != new_page.template_name | ||
| 129 | diff[:assets_changed] = true if old_page.assets.map(&:id) != new_page.assets.map(&:id) | ||
| 130 | |||
| 131 | old_t = old_page.translations.find_by(:locale => default) | ||
| 132 | new_t = new_page.translations.find_by(:locale => default) | ||
| 133 | diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract | ||
| 134 | diff[:body_changed] = true if old_t&.body != new_t&.body | ||
| 135 | |||
| 136 | locales = (old_page.translated_locales | new_page.translated_locales) - [default] | ||
| 137 | translation_diff = {} | ||
| 138 | |||
| 139 | locales.sort_by(&:to_s).each do |locale| | ||
| 140 | o = old_page.translations.find_by(:locale => locale) | ||
| 141 | n = new_page.translations.find_by(:locale => locale) | ||
| 142 | |||
| 143 | if o.nil? | ||
| 144 | translation_diff[locale.to_s] = { "status" => "added", | ||
| 145 | "title" => { "from" => nil, "to" => n.title } } | ||
| 146 | elsif n.nil? | ||
| 147 | translation_diff[locale.to_s] = { "status" => "removed", | ||
| 148 | "title" => { "from" => o.title, "to" => nil } } | ||
| 149 | elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body | ||
| 150 | entry = { "status" => "changed" } | ||
| 151 | entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title | ||
| 152 | entry["abstract_changed"] = true if o.abstract != n.abstract | ||
| 153 | entry["body_changed"] = true if o.body != n.body | ||
| 154 | translation_diff[locale.to_s] = entry | ||
| 155 | end | ||
| 156 | end | ||
| 157 | |||
| 158 | diff[:translation_diff] = translation_diff if translation_diff.any? | ||
| 159 | diff | ||
| 160 | end | ||
| 161 | |||
| 162 | def actor_name | ||
| 163 | metadata["username"] || "unknown" | ||
| 164 | end | ||
| 165 | |||
| 166 | def subject_name | ||
| 167 | metadata["human_readable_node_name"] || node&.unique_name || "deleted node" | ||
| 168 | end | ||
| 169 | end | ||
diff --git a/app/models/occurrence.rb b/app/models/occurrence.rb index 3baf447..c6a2380 100644 --- a/app/models/occurrence.rb +++ b/app/models/occurrence.rb | |||
| @@ -1,19 +1,19 @@ | |||
| 1 | # TODO Make a gem out of the c wrapper | ||
| 2 | require 'chaos_calendar' | 1 | require 'chaos_calendar' |
| 3 | 2 | ||
| 4 | class Occurrence < ApplicationRecord | 3 | class Occurrence < ApplicationRecord |
| 5 | 4 | ||
| 6 | # Associations | 5 | # Associations |
| 7 | 6 | ||
| 8 | belongs_to :node | 7 | belongs_to :node, optional: true |
| 9 | belongs_to :event | 8 | belongs_to :event |
| 10 | 9 | ||
| 11 | # Class Methods | 10 | # Class Methods |
| 12 | 11 | ||
| 13 | def self.find_in_range start_time, end_time | 12 | def self.find_in_range start_time, end_time |
| 14 | includes(:node) | 13 | joins(:event) |
| 15 | .where("start_time > ? AND end_time < ?", start_time, end_time) | 14 | .includes(:node) |
| 16 | .order("start_time") | 15 | .where("occurrences.start_time > ? AND occurrences.end_time < ?", start_time, end_time) |
| 16 | .order("occurrences.start_time") | ||
| 17 | end | 17 | end |
| 18 | 18 | ||
| 19 | def self.find_next | 19 | def self.find_next |
| @@ -27,6 +27,7 @@ class Occurrence < ApplicationRecord | |||
| 27 | # event are then calculated and created. | 27 | # event are then calculated and created. |
| 28 | def self.generate event | 28 | def self.generate event |
| 29 | self.where(:event_id => event.id).delete_all | 29 | self.where(:event_id => event.id).delete_all |
| 30 | return if event.start_time.nil? | ||
| 30 | 31 | ||
| 31 | node = event.node | 32 | node = event.node |
| 32 | duration = (event.end_time - event.start_time) | 33 | duration = (event.end_time - event.start_time) |
| @@ -36,7 +37,7 @@ class Occurrence < ApplicationRecord | |||
| 36 | self.create( | 37 | self.create( |
| 37 | :start_time => occurrence, | 38 | :start_time => occurrence, |
| 38 | :end_time => (occurrence + duration), | 39 | :end_time => (occurrence + duration), |
| 39 | :node_id => node.id, | 40 | :node_id => node&.id, |
| 40 | :event_id => event.id | 41 | :event_id => event.id |
| 41 | ) | 42 | ) |
| 42 | end | 43 | end |
| @@ -49,10 +50,11 @@ class Occurrence < ApplicationRecord | |||
| 49 | # Return value is always an array of Time objects. | 50 | # Return value is always an array of Time objects. |
| 50 | def self.generate_dates event | 51 | def self.generate_dates event |
| 51 | if event.rrule && !event.rrule.empty? | 52 | if event.rrule && !event.rrule.empty? |
| 52 | ChaosCalendar::occurrences( | 53 | ChaosCalendar::occurrences_for_timezone( |
| 53 | event.start_time, | 54 | event.start_time, |
| 54 | (Time.now + 5.years), | 55 | (Time.now + 5.years), |
| 55 | event.rrule | 56 | event.rrule, |
| 57 | Time.zone.tzinfo.identifier | ||
| 56 | ) | 58 | ) |
| 57 | else | 59 | else |
| 58 | [event.start_time] | 60 | [event.start_time] |
| @@ -64,7 +66,7 @@ class Occurrence < ApplicationRecord | |||
| 64 | # Instance Methods | 66 | # Instance Methods |
| 65 | 67 | ||
| 66 | def summary | 68 | def summary |
| 67 | node.head.title | 69 | node&.head&.title || event.display_title |
| 68 | end | 70 | end |
| 69 | 71 | ||
| 70 | end | 72 | end |
diff --git a/app/models/page.rb b/app/models/page.rb index e6baf20..c4d66fc 100644 --- a/app/models/page.rb +++ b/app/models/page.rb | |||
| @@ -11,11 +11,21 @@ class Page < ApplicationRecord | |||
| 11 | 11 | ||
| 12 | translates :title, :abstract, :body # Globalize2 | 12 | translates :title, :abstract, :body # Globalize2 |
| 13 | 13 | ||
| 14 | # Template names render as filesystem paths; only names actually | ||
| 15 | # present in the public template directory are acceptable. Validated | ||
| 16 | # only on change so legacy rows whose template file has since | ||
| 17 | # vanished stay saveable -- valid_template already falls back to | ||
| 18 | # standard_template for those at render time. | ||
| 19 | validates :template_name, | ||
| 20 | :inclusion => { :in => ->(_) { Page.custom_templates } }, | ||
| 21 | :allow_nil => true, | ||
| 22 | :if => :template_name_changed? | ||
| 23 | |||
| 14 | # Associations | 24 | # Associations |
| 15 | belongs_to :node, optional: true | 25 | belongs_to :node, optional: true |
| 16 | belongs_to :user, optional: true | 26 | belongs_to :user, optional: true |
| 17 | belongs_to :editor, :class_name => "User", optional: true | 27 | belongs_to :editor, :class_name => "User", optional: true |
| 18 | has_many :related_assets | 28 | has_many :related_assets, :dependent => :destroy |
| 19 | has_many :assets, -> { order("position ASC") }, :through => :related_assets | 29 | has_many :assets, -> { order("position ASC") }, :through => :related_assets |
| 20 | 30 | ||
| 21 | # Named scopes | 31 | # Named scopes |
| @@ -63,7 +73,24 @@ class Page < ApplicationRecord | |||
| 63 | end | 73 | end |
| 64 | end | 74 | end |
| 65 | 75 | ||
| 66 | scope.order("#{options[:order_by]} #{options[:order_direction]}") | 76 | if options[:node] && options[:children] == "direct" |
| 77 | scope = scope.where(nodes: { parent_id: options[:node].id }) | ||
| 78 | elsif options[:node] && options[:children] == "all" | ||
| 79 | scope = scope.where(nodes: { id: options[:node].descendants.pluck(:id) }) | ||
| 80 | end | ||
| 81 | |||
| 82 | direction = %w[ASC DESC].include?(options[:order_direction]&.upcase) ? options[:order_direction].upcase : "ASC" | ||
| 83 | |||
| 84 | if options[:order_by] == "title" | ||
| 85 | return scope | ||
| 86 | .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}")) | ||
| 87 | .paginate(:page => page, :per_page => options[:limit]) | ||
| 88 | end | ||
| 89 | |||
| 90 | column = options[:order_by].to_s.sub(/\Apages\./, "") | ||
| 91 | column = "id" unless %w[id published_at created_at updated_at].include?(column) | ||
| 92 | |||
| 93 | scope.order("pages.#{column} #{direction}") | ||
| 67 | .paginate(:page => page, :per_page => options[:limit]) | 94 | .paginate(:page => page, :per_page => options[:limit]) |
| 68 | end | 95 | end |
| 69 | 96 | ||
| @@ -73,6 +100,27 @@ class Page < ApplicationRecord | |||
| 73 | end | 100 | end |
| 74 | end | 101 | end |
| 75 | 102 | ||
| 103 | def self.non_default_locales | ||
| 104 | I18n.available_locales - [:root, I18n.default_locale] | ||
| 105 | end | ||
| 106 | |||
| 107 | # One row per non-default locale, read from the actual translation | ||
| 108 | # row -- never through the locale-dependent accessor, so a locale | ||
| 109 | # with no real translation yet reports as absent rather than quietly | ||
| 110 | # showing a fallback value borrowed from another locale. | ||
| 111 | def translation_summary | ||
| 112 | Page.non_default_locales.map do |locale| | ||
| 113 | translation = translations.find_by(:locale => locale) | ||
| 114 | { | ||
| 115 | :locale => locale, | ||
| 116 | :exists => translation.present?, | ||
| 117 | :title => translation&.title, | ||
| 118 | :updated_at => translation&.updated_at, | ||
| 119 | :outdated => translation.present? && outdated_translations?(:locale => locale) | ||
| 120 | } | ||
| 121 | end | ||
| 122 | end | ||
| 123 | |||
| 76 | def self.untranslated(options = {:locale => :de}) | 124 | def self.untranslated(options = {:locale => :de}) |
| 77 | PageTranslation.all.group_by(&:page_id).select do |k,v| | 125 | PageTranslation.all.group_by(&:page_id).select do |k,v| |
| 78 | v.size == 1 && v.map{|x| x.locale}.include?(options[:locale]) | 126 | v.size == 1 && v.map{|x| x.locale}.include?(options[:locale]) |
| @@ -128,22 +176,47 @@ class Page < ApplicationRecord | |||
| 128 | "/#{node.unique_name}" | 176 | "/#{node.unique_name}" |
| 129 | end | 177 | end |
| 130 | 178 | ||
| 179 | def ensure_preview_token! | ||
| 180 | update!(preview_token: SecureRandom.urlsafe_base64(24)) unless preview_token.present? | ||
| 181 | preview_token | ||
| 182 | end | ||
| 183 | |||
| 184 | def revoke_preview_token! | ||
| 185 | update!(preview_token: nil) | ||
| 186 | end | ||
| 187 | |||
| 131 | def clone_attributes_from page | 188 | def clone_attributes_from page |
| 132 | return nil unless page | 189 | return nil unless page |
| 133 | 190 | ||
| 134 | self.reload | 191 | self.reload |
| 192 | page.translations.reload | ||
| 135 | 193 | ||
| 136 | # Clone untranslated attributes | 194 | # Clone untranslated attributes |
| 137 | self.tag_list = page.tag_list | 195 | self.tag_list = page.tag_list |
| 138 | self.template_name ||= page.template_name | 196 | self.template_name ||= page.template_name |
| 139 | self.published_at = page.published_at | 197 | self.published_at = page.published_at |
| 140 | 198 | ||
| 141 | # Getting rid of the auto-generated empty translations | 199 | # Clone translated attributes -- update each locale in place rather |
| 142 | self.translations.delete_all | 200 | # than delete-and-recreate, so a locale whose content is genuinely |
| 201 | # unchanged keeps its real created_at/updated_at instead of looking | ||
| 202 | # freshly touched on every single save (which was silently defeating | ||
| 203 | # Page.find_with_outdated_translations' whole staleness comparison). | ||
| 204 | # search_vector is excluded deliberately: it's DB-trigger-maintained | ||
| 205 | # from title/abstract, not real content, and comparing a precomputed | ||
| 206 | # tsvector risked a false "changed" from representation noise alone. | ||
| 207 | source_locales = page.translations.map(&:locale) | ||
| 208 | self.translations.where.not(:locale => source_locales).destroy_all | ||
| 143 | 209 | ||
| 144 | # Clone translated attributes | 210 | page.translations.each do |source_translation| |
| 145 | page.translations.each do |translation| | 211 | attrs = source_translation.attributes.except("id", "page_id", "created_at", "updated_at", "search_vector") |
| 146 | self.translations.create!(translation.attributes.except("id", "page_id", "created_at", "updated_at")) | 212 | mine = self.translations.find_by(:locale => source_translation.locale) |
| 213 | |||
| 214 | if mine | ||
| 215 | changed_attrs = attrs.reject { |k, v| mine.public_send(k) == v } | ||
| 216 | mine.update!(changed_attrs) if changed_attrs.any? | ||
| 217 | else | ||
| 218 | self.translations.create!(attrs) | ||
| 219 | end | ||
| 147 | end | 220 | end |
| 148 | 221 | ||
| 149 | # Clone asset references | 222 | # Clone asset references |
| @@ -152,6 +225,54 @@ class Page < ApplicationRecord | |||
| 152 | self.save | 225 | self.save |
| 153 | end | 226 | end |
| 154 | 227 | ||
| 228 | def diff_against other, view: :inline, locale: nil | ||
| 229 | if locale | ||
| 230 | mine, theirs = translations.find_by(:locale => locale), other.translations.find_by(:locale => locale) | ||
| 231 | my_title, my_abstract, my_body = mine&.title, mine&.abstract, mine&.body | ||
| 232 | their_title, their_abstract, their_body = theirs&.title, theirs&.abstract, theirs&.body | ||
| 233 | else | ||
| 234 | my_title, my_abstract, my_body = title, abstract, body | ||
| 235 | their_title, their_abstract, their_body = other.title, other.abstract, other.body | ||
| 236 | end | ||
| 237 | |||
| 238 | text_diffs = | ||
| 239 | if view == :side_by_side | ||
| 240 | { | ||
| 241 | title: HtmlWordDiff.side_by_side(their_title.to_s, my_title.to_s), | ||
| 242 | abstract: HtmlWordDiff.side_by_side(their_abstract.to_s, my_abstract.to_s), | ||
| 243 | body: HtmlWordDiff.side_by_side(their_body.to_s, my_body.to_s) | ||
| 244 | } | ||
| 245 | else | ||
| 246 | { | ||
| 247 | title: HtmlWordDiff.inline(their_title.to_s, my_title.to_s), | ||
| 248 | abstract: HtmlWordDiff.inline(their_abstract.to_s, my_abstract.to_s), | ||
| 249 | body: HtmlWordDiff.inline(their_body.to_s, my_body.to_s) | ||
| 250 | } | ||
| 251 | end | ||
| 252 | |||
| 253 | text_diffs.merge( | ||
| 254 | tags: { added: tag_list.to_a - other.tag_list.to_a, removed: other.tag_list.to_a - tag_list.to_a }, | ||
| 255 | template_name: { from: other.template_name, to: template_name, changed: template_name != other.template_name }, | ||
| 256 | assets: { added: assets.to_a - other.assets.to_a, removed: other.assets.to_a - assets.to_a } | ||
| 257 | ) | ||
| 258 | end | ||
| 259 | |||
| 260 | def locale_diff_summary other | ||
| 261 | (translated_locales | other.translated_locales).sort_by(&:to_s).map do |locale| | ||
| 262 | mine = translations.find_by(:locale => locale) | ||
| 263 | theirs = other.translations.find_by(:locale => locale) | ||
| 264 | { | ||
| 265 | :locale => locale, | ||
| 266 | :exists_here => mine.present?, | ||
| 267 | :exists_there => theirs.present?, | ||
| 268 | :changed => mine.present? != theirs.present? || | ||
| 269 | mine&.title != theirs&.title || | ||
| 270 | mine&.abstract != theirs&.abstract || | ||
| 271 | mine&.body != theirs&.body | ||
| 272 | } | ||
| 273 | end | ||
| 274 | end | ||
| 275 | |||
| 155 | def public? | 276 | def public? |
| 156 | published_at.nil? ? true : published_at < Time.now | 277 | published_at.nil? ? true : published_at < Time.now |
| 157 | end | 278 | end |
| @@ -208,46 +329,60 @@ class Page < ApplicationRecord | |||
| 208 | 329 | ||
| 209 | end | 330 | end |
| 210 | 331 | ||
| 332 | # Installs (or re-installs) the trigger that keeps page_translations' | ||
| 333 | # search_vector in sync. Idempotent, safe to call on every boot. | ||
| 334 | # search_vector is populated by a raw Postgres trigger, not anything | ||
| 335 | # Rails' schema dumper can represent -- a database rebuilt from | ||
| 336 | # schema.rb rather than replayed migrations silently loses it. | ||
| 337 | def self.ensure_search_vector_trigger! | ||
| 338 | connection.execute(<<~SQL) | ||
| 339 | CREATE OR REPLACE FUNCTION page_translations_search_vector_update() | ||
| 340 | RETURNS trigger AS $$ | ||
| 341 | BEGIN | ||
| 342 | NEW.search_vector := to_tsvector( | ||
| 343 | 'simple', | ||
| 344 | coalesce(NEW.title, '') || ' ' || | ||
| 345 | coalesce(NEW.abstract, '') || ' ' || | ||
| 346 | coalesce(NEW.body, '') | ||
| 347 | ); | ||
| 348 | RETURN NEW; | ||
| 349 | END; | ||
| 350 | $$ LANGUAGE plpgsql; | ||
| 351 | |||
| 352 | CREATE OR REPLACE TRIGGER page_translations_search_vector_trigger | ||
| 353 | BEFORE INSERT OR UPDATE ON page_translations | ||
| 354 | FOR EACH ROW EXECUTE PROCEDURE page_translations_search_vector_update(); | ||
| 355 | SQL | ||
| 356 | end | ||
| 357 | |||
| 211 | private | 358 | private |
| 212 | 359 | ||
| 213 | def set_page_title | 360 | def set_page_title |
| 214 | if title.nil? | 361 | self.title = "Untitled" if title.nil? |
| 215 | title = "Untitled" | ||
| 216 | end | ||
| 217 | end | 362 | end |
| 218 | 363 | ||
| 219 | def set_template | 364 | def set_template |
| 220 | if node && node.update? | 365 | return if template_name.present? |
| 221 | self.template_name = "update" | 366 | self.template_name = node&.default_template_name || (node&.update? ? "update" : nil) |
| 222 | end | ||
| 223 | end | 367 | end |
| 224 | 368 | ||
| 225 | def rewrite_links_in_body | 369 | def rewrite_links_in_body |
| 226 | begin | 370 | return unless self.body |
| 227 | if self.body | ||
| 228 | tmp_body = "<div>#{self.body}</div>" | ||
| 229 | xml_string = XML::Parser.string( tmp_body ) | ||
| 230 | xml_doc = xml_string.parse | ||
| 231 | links = xml_doc.find("//a[not(starts-with(@href, 'http://'))]") | ||
| 232 | links = links.reject { |l| l[:href] =~ /system\/uploads/ } | ||
| 233 | locales = I18n.available_locales.reject {|l| l == :root} | ||
| 234 | 371 | ||
| 235 | links.each do |link| | 372 | doc = Nokogiri::HTML::DocumentFragment.parse(self.body) |
| 236 | unless locales.include? link[:href].slice(1,2).to_sym | 373 | locales = I18n.available_locales.reject { |l| l == :root } |
| 237 | unless link[:href] =~ /sytem\/uploads/ | ||
| 238 | link[:href] = link[:href].sub(/^\//, "/#{I18n.locale}/") | ||
| 239 | end | ||
| 240 | end | ||
| 241 | end | ||
| 242 | 374 | ||
| 243 | tmp_body = xml_doc.to_s.gsub(/(\n\<div\>|\<\/div\>\n)/, "") | 375 | doc.css('a').each do |link| |
| 244 | tmp_body.gsub!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>", "") | 376 | href = link['href'] |
| 377 | next unless href | ||
| 378 | next if href.start_with?('http://', 'https://') | ||
| 379 | next if href =~ /system\/uploads/ | ||
| 245 | 380 | ||
| 246 | self.body = tmp_body | 381 | unless locales.include?(href.slice(1, 2)&.to_sym) |
| 382 | link['href'] = href.sub(/^\//, "/#{I18n.locale}/") | ||
| 247 | end | 383 | end |
| 248 | rescue | ||
| 249 | nil | ||
| 250 | end | 384 | end |
| 251 | end | ||
| 252 | 385 | ||
| 386 | self.body = doc.to_html | ||
| 387 | end | ||
| 253 | end | 388 | end |
diff --git a/app/models/user.rb b/app/models/user.rb index 92ac33a..5e47ae7 100644 --- a/app/models/user.rb +++ b/app/models/user.rb | |||
| @@ -1,6 +1,10 @@ | |||
| 1 | require 'digest/sha1' | 1 | require 'digest/sha1' |
| 2 | 2 | ||
| 3 | class User < ApplicationRecord | 3 | class User < ApplicationRecord |
| 4 | has_secure_password(validations: false) | ||
| 5 | |||
| 6 | alias_method :authenticate_bcrypt, :authenticate | ||
| 7 | |||
| 4 | # Mixins and Plugins | 8 | # Mixins and Plugins |
| 5 | include Authentication | 9 | include Authentication |
| 6 | include Authentication::ByPassword | 10 | include Authentication::ByPassword |
| @@ -23,9 +27,30 @@ class User < ApplicationRecord | |||
| 23 | 27 | ||
| 24 | # Authenticates a user by their login name and unencrypted password. Returns the user or nil. | 28 | # Authenticates a user by their login name and unencrypted password. Returns the user or nil. |
| 25 | def self.authenticate(login, password) | 29 | def self.authenticate(login, password) |
| 26 | return nil if login.blank? || password.blank? | 30 | return if login.blank? || password.blank? |
| 27 | u = find_by_login(login) # need to get the salt | 31 | |
| 28 | u && u.authenticated?(password) ? u : nil | 32 | user = find_by(login: login) |
| 33 | return unless user | ||
| 34 | |||
| 35 | user&.authenticate(password) | ||
| 36 | end | ||
| 37 | |||
| 38 | def authenticate(password) | ||
| 39 | if password_digest.present? | ||
| 40 | return self if authenticate_bcrypt(password) | ||
| 41 | return nil | ||
| 42 | end | ||
| 43 | |||
| 44 | return nil unless authenticate_legacy(password) | ||
| 45 | |||
| 46 | transaction do | ||
| 47 | self.password = password | ||
| 48 | self.crypted_password = nil | ||
| 49 | self.salt = nil | ||
| 50 | save!(validate: false) | ||
| 51 | end | ||
| 52 | |||
| 53 | self | ||
| 29 | end | 54 | end |
| 30 | 55 | ||
| 31 | # TODO: Do we really want to have downcase logins only? | 56 | # TODO: Do we really want to have downcase logins only? |
