diff options
Diffstat (limited to 'app')
24 files changed, 839 insertions, 156 deletions
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 37fd78b..8bd99ac 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb | |||
| @@ -6,7 +6,7 @@ class AdminController < ApplicationController | |||
| 6 | 6 | ||
| 7 | def index | 7 | def index |
| 8 | @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5) | 8 | @drafts = Node.drafts_and_autosaves(current_user_id: current_user.id).limit(5) |
| 9 | @recent_changes = Node.recently_changed.limit(5) | 9 | @actions = NodeAction.order(:occurred_at => :desc, :id => :desc).limit(5) |
| 10 | end | 10 | end |
| 11 | 11 | ||
| 12 | def conventions | 12 | def conventions |
diff --git a/app/controllers/csp_reports_controller.rb b/app/controllers/csp_reports_controller.rb new file mode 100644 index 0000000..08cbc98 --- /dev/null +++ b/app/controllers/csp_reports_controller.rb | |||
| @@ -0,0 +1,10 @@ | |||
| 1 | class CspReportsController < ApplicationController | ||
| 2 | # Browsers POST application/csp-report with no CSRF token, no session. | ||
| 3 | skip_before_action :verify_authenticity_token | ||
| 4 | |||
| 5 | def create | ||
| 6 | report = request.body.read(8192) | ||
| 7 | Rails.logger.warn("CSP violation: #{report}") if report.present? | ||
| 8 | head :no_content | ||
| 9 | end | ||
| 10 | end | ||
diff --git a/app/controllers/node_actions_controller.rb b/app/controllers/node_actions_controller.rb new file mode 100644 index 0000000..6e46719 --- /dev/null +++ b/app/controllers/node_actions_controller.rb | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | class NodeActionsController < ApplicationController | ||
| 2 | |||
| 3 | before_action :login_required | ||
| 4 | |||
| 5 | layout 'admin' | ||
| 6 | |||
| 7 | def index | ||
| 8 | @actions = NodeAction.order(:occurred_at => :desc, :id => :desc) | ||
| 9 | @actions = @actions.where(:node_id => params[:node_id]) if params[:node_id].present? | ||
| 10 | @actions = @actions.where(:user_id => params[:user_id]) if params[:user_id].present? | ||
| 11 | @actions = @actions.includes(:node, :user) | ||
| 12 | .paginate(:page => params[:page], :per_page => 50) | ||
| 13 | end | ||
| 14 | end | ||
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index b66ea49..6caa827 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb | |||
| @@ -13,7 +13,9 @@ class NodesController < ApplicationController | |||
| 13 | :publish, | 13 | :publish, |
| 14 | :unlock, | 14 | :unlock, |
| 15 | :autosave, | 15 | :autosave, |
| 16 | :revert | 16 | :revert, |
| 17 | :trash, | ||
| 18 | :restore_from_trash | ||
| 17 | ] | 19 | ] |
| 18 | 20 | ||
| 19 | around_action :pin_to_default_locale, :only => [:show, :edit, :update, :autosave] | 21 | around_action :pin_to_default_locale, :only => [:show, :edit, :update, :autosave] |
| @@ -27,12 +29,7 @@ class NodesController < ApplicationController | |||
| 27 | def new | 29 | def new |
| 28 | @node = Node.new node_params | 30 | @node = Node.new node_params |
| 29 | @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" | 31 | @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" |
| 30 | if params.has_key?(:parent_id) | 32 | @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id) |
| 31 | @parent_id = params[:parent_id] | ||
| 32 | parent = Node.find(@parent_id) | ||
| 33 | @parent_name = parent.title | ||
| 34 | @parent_unique_name = parent.current_unique_name | ||
| 35 | end | ||
| 36 | end | 33 | end |
| 37 | 34 | ||
| 38 | def create | 35 | def create |
| @@ -57,10 +54,7 @@ class NodesController < ApplicationController | |||
| 57 | redirect_to(edit_node_path(@node)) | 54 | redirect_to(edit_node_path(@node)) |
| 58 | else | 55 | else |
| 59 | @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" | 56 | @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" |
| 60 | if params[:parent_id].present? | 57 | @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id) |
| 61 | @parent_id = params[:parent_id] | ||
| 62 | @parent_name = Node.find(@parent_id).title | ||
| 63 | end | ||
| 64 | render :new | 58 | render :new |
| 65 | end | 59 | end |
| 66 | end | 60 | end |
| @@ -68,6 +62,9 @@ class NodesController < ApplicationController | |||
| 68 | def show | 62 | def show |
| 69 | @page = @node.draft || @node.head | 63 | @page = @node.draft || @node.head |
| 70 | @translations = @page.translation_summary | 64 | @translations = @page.translation_summary |
| 65 | @page_actions = NodeAction.where(:page_id => @node.pages.select(:id)) | ||
| 66 | .order(:occurred_at, :id) | ||
| 67 | .group_by(&:page_id) | ||
| 71 | end | 68 | end |
| 72 | 69 | ||
| 73 | def edit | 70 | def edit |
| @@ -147,8 +144,40 @@ class NodesController < ApplicationController | |||
| 147 | redirect_to node_path(@node) | 144 | redirect_to node_path(@node) |
| 148 | end | 145 | end |
| 149 | 146 | ||
| 147 | def trash | ||
| 148 | if @node.trash!(current_user) | ||
| 149 | flash[:notice] = "Page has been moved to the Trash" | ||
| 150 | redirect_to trashed_nodes_path | ||
| 151 | else | ||
| 152 | flash[:notice] = "Page is already in the Trash" | ||
| 153 | redirect_to node_path(@node) | ||
| 154 | end | ||
| 155 | rescue ActiveRecord::RecordInvalid, LockedByAnotherUser => e | ||
| 156 | flash[:error] = e.message | ||
| 157 | redirect_to node_path(@node) | ||
| 158 | end | ||
| 159 | |||
| 160 | def restore_from_trash | ||
| 161 | parent = Node.find(params[:parent_id]) | ||
| 162 | @node.restore_from_trash!(parent, current_user) | ||
| 163 | flash[:notice] = "Page has been restored from the Trash" | ||
| 164 | redirect_to node_path(@node) | ||
| 165 | rescue ActiveRecord::RecordNotFound | ||
| 166 | flash[:error] = "Restore target not found" | ||
| 167 | redirect_to node_path(@node) | ||
| 168 | rescue ActiveRecord::RecordInvalid => e | ||
| 169 | flash[:error] = e.message | ||
| 170 | redirect_to node_path(@node) | ||
| 171 | end | ||
| 172 | |||
| 150 | def destroy | 173 | def destroy |
| 151 | @node.destroy | 174 | @node.destroy_from_trash!(current_user) |
| 175 | flash[:notice] = "Page has been permanently deleted" | ||
| 176 | redirect_to trashed_nodes_path | ||
| 177 | |||
| 178 | rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e | ||
| 179 | flash[:error] = e.message | ||
| 180 | redirect_to node_path(@node) | ||
| 152 | end | 181 | end |
| 153 | 182 | ||
| 154 | def publish | 183 | def publish |
| @@ -222,6 +251,11 @@ class NodesController < ApplicationController | |||
| 222 | @sitemap_descendant_counts = descendant_counts_for(@sitemap) | 251 | @sitemap_descendant_counts = descendant_counts_for(@sitemap) |
| 223 | end | 252 | end |
| 224 | 253 | ||
| 254 | def trashed | ||
| 255 | @nodes = Node.trash.children.order(:slug) | ||
| 256 | .paginate(:page => params[:page], :per_page => 50) | ||
| 257 | end | ||
| 258 | |||
| 225 | private | 259 | private |
| 226 | 260 | ||
| 227 | def slug_for(title) | 261 | def slug_for(title) |
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb new file mode 100644 index 0000000..fd8cc36 --- /dev/null +++ b/app/helpers/node_actions_helper.rb | |||
| @@ -0,0 +1,183 @@ | |||
| 1 | module NodeActionsHelper | ||
| 2 | include ERB::Util | ||
| 3 | |||
| 4 | # One glyph per verb, rendered before the sentence in _action_row. | ||
| 5 | # Unknown verbs get the dashed circle -- the unknown-verb principle | ||
| 6 | # extended to iconography: the log outlives its vocabulary. | ||
| 7 | VERB_ICONS = { | ||
| 8 | "create" => "file-plus", | ||
| 9 | "publish" => "send", | ||
| 10 | "move" => "arrows-move", | ||
| 11 | "trash" => "trash", | ||
| 12 | "restore_from_trash" => "arrow-back-up", | ||
| 13 | "destroy" => "trash-x", | ||
| 14 | "discard_autosave" => "eraser", | ||
| 15 | "destroy_draft" => "eraser", | ||
| 16 | }.freeze | ||
| 17 | |||
| 18 | def verb_icon action | ||
| 19 | name = if action.action == "publish" && action.metadata["via"] == "revision" | ||
| 20 | "history" | ||
| 21 | else | ||
| 22 | VERB_ICONS.fetch(action.action, "circle-dashed") | ||
| 23 | end | ||
| 24 | content_tag(:span, icon(name, library: "tabler", "aria-hidden": true), | ||
| 25 | :class => "node_action_icon node_action_icon--#{name}", | ||
| 26 | :title => action.action) | ||
| 27 | end | ||
| 28 | |||
| 29 | # One sentence per entry, rendered from metadata alone, so entries | ||
| 30 | # stay renderable after the rows they reference are gone. Live | ||
| 31 | # associations only upgrade plain names to links. Every metadata | ||
| 32 | # value passes through h() here -- this helper is the escaping | ||
| 33 | # boundary; the locale sentences are trusted, the data never is. | ||
| 34 | def action_summary action | ||
| 35 | renderer = "summarize_#{action.action}" | ||
| 36 | return send(renderer, action) if respond_to?(renderer, true) | ||
| 37 | |||
| 38 | # Unknown-verb fallback: the log outlives the vocabulary. A renamed | ||
| 39 | # or future verb degrades to an ugly sentence, never to a 500. | ||
| 40 | t("node_actions.unknown", :actor => actor_ref(action), | ||
| 41 | :action => h(action.action), :subject => subject_ref(action)).html_safe | ||
| 42 | end | ||
| 43 | |||
| 44 | |||
| 45 | def action_details? action | ||
| 46 | m = action.metadata | ||
| 47 | return true if m["translation_diff"].present? | ||
| 48 | return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to") | ||
| 49 | %w[author tags template_changed assets_changed | ||
| 50 | abstract_changed body_changed].any? { |key| m[key].present? } | ||
| 51 | end | ||
| 52 | |||
| 53 | # Plain strings by design -- safe_join in the template escapes them. | ||
| 54 | def default_locale_changes action | ||
| 55 | m = action.metadata | ||
| 56 | items = [] | ||
| 57 | if m["title"].is_a?(Hash) && m.dig("title", "from") && m.dig("title", "from") != m.dig("title", "to") | ||
| 58 | items << t("node_actions.detail_title", | ||
| 59 | :from => m.dig("title", "from"), :to => m.dig("title", "to")) | ||
| 60 | end | ||
| 61 | items << t("node_actions.detail_author", | ||
| 62 | :from => m.dig("author", "from"), :to => m.dig("author", "to")) if m["author"] | ||
| 63 | items << t("node_actions.detail_tags", | ||
| 64 | :from => Array(m.dig("tags", "from")).join(", "), | ||
| 65 | :to => Array(m.dig("tags", "to")).join(", ")) if m["tags"] | ||
| 66 | items << t("node_actions.abstract_changed") if m["abstract_changed"] | ||
| 67 | items << t("node_actions.body_changed") if m["body_changed"] | ||
| 68 | items << t("node_actions.template_changed") if m["template_changed"] | ||
| 69 | items << t("node_actions.assets_changed") if m["assets_changed"] | ||
| 70 | items | ||
| 71 | end | ||
| 72 | |||
| 73 | def translation_changes diff | ||
| 74 | case diff["status"] | ||
| 75 | when "added" then [t("node_actions.locale_added", :title => diff.dig("title", "to"))] | ||
| 76 | when "removed" then [t("node_actions.locale_removed", :title => diff.dig("title", "from"))] | ||
| 77 | else | ||
| 78 | items = [] | ||
| 79 | items << t("node_actions.detail_title", | ||
| 80 | :from => diff.dig("title", "from"), :to => diff.dig("title", "to")) if diff["title"] | ||
| 81 | items << t("node_actions.abstract_changed") if diff["abstract_changed"] | ||
| 82 | items << t("node_actions.body_changed") if diff["body_changed"] | ||
| 83 | items | ||
| 84 | end | ||
| 85 | end | ||
| 86 | |||
| 87 | def revision_lifecycle_badges actions | ||
| 88 | return "" if actions.blank? | ||
| 89 | |||
| 90 | badges = actions.map do |action| | ||
| 91 | key = case action.action | ||
| 92 | when "create" then "node_actions.revision_created" | ||
| 93 | when "publish" then action.metadata["via"] == "revision" ? | ||
| 94 | "node_actions.revision_restored" : | ||
| 95 | "node_actions.revision_published" | ||
| 96 | end | ||
| 97 | next unless key | ||
| 98 | |||
| 99 | badge = h(t(key, :date => action.occurred_at.strftime("%Y-%m-%d"), | ||
| 100 | :actor => action.actor_name)) | ||
| 101 | if action.inferred_from | ||
| 102 | badge = safe_join([badge, content_tag(:span, t("node_actions.backfilled"), | ||
| 103 | :class => "node_action_inferred", | ||
| 104 | :title => action.inferred_from)], " ") | ||
| 105 | end | ||
| 106 | badge | ||
| 107 | end.compact | ||
| 108 | |||
| 109 | return "" if badges.empty? | ||
| 110 | safe_join(["— ", safe_join(badges, " · ")]) | ||
| 111 | end | ||
| 112 | |||
| 113 | private | ||
| 114 | |||
| 115 | def revision_ref action, key | ||
| 116 | label = t(key) | ||
| 117 | return label unless action.node && action.page | ||
| 118 | link_to(label, node_revision_path(action.node, action.page)) | ||
| 119 | end | ||
| 120 | |||
| 121 | def actor_ref action | ||
| 122 | action.user ? link_to(h(action.actor_name), admin_log_path(:user_id => action.user_id)) | ||
| 123 | : h(action.actor_name) | ||
| 124 | end | ||
| 125 | |||
| 126 | def subject_ref action | ||
| 127 | action.node ? link_to(h(action.subject_name), node_path(action.node)) | ||
| 128 | : h(action.subject_name) | ||
| 129 | end | ||
| 130 | |||
| 131 | def summarize_publish action | ||
| 132 | if action.metadata["via"] == "revision" | ||
| 133 | t("node_actions.publish_rollback", | ||
| 134 | :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 135 | :revision => revision_ref(action, "node_actions.revision_earlier")).html_safe | ||
| 136 | elsif action.metadata.dig("title", "from").nil? | ||
| 137 | author = action.metadata.dig("author", "to") | ||
| 138 | key = author ? "node_actions.publish_first_with_author" : "node_actions.publish_first" | ||
| 139 | t(key, :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 140 | :author => h(author)).html_safe | ||
| 141 | else | ||
| 142 | t("node_actions.publish", | ||
| 143 | :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 144 | :revision => revision_ref(action, "node_actions.revision_new")).html_safe | ||
| 145 | end | ||
| 146 | end | ||
| 147 | |||
| 148 | def summarize_move action | ||
| 149 | t("node_actions.move", :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 150 | :from => h(action.metadata.dig("path", "from")), | ||
| 151 | :to => h(action.metadata.dig("path", "to"))).html_safe | ||
| 152 | end | ||
| 153 | |||
| 154 | def summarize_create action | ||
| 155 | t("node_actions.create", :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 156 | :path => h(action.metadata["path"])).html_safe | ||
| 157 | end | ||
| 158 | |||
| 159 | def summarize_discard_autosave action | ||
| 160 | t("node_actions.discard_autosave", | ||
| 161 | :actor => actor_ref(action), :subject => subject_ref(action)).html_safe | ||
| 162 | end | ||
| 163 | |||
| 164 | def summarize_destroy_draft action | ||
| 165 | t("node_actions.destroy_draft", | ||
| 166 | :actor => actor_ref(action), :subject => subject_ref(action)).html_safe | ||
| 167 | end | ||
| 168 | |||
| 169 | def summarize_trash action | ||
| 170 | t("node_actions.trash", :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 171 | :from => h(action.metadata.dig("path", "from"))).html_safe | ||
| 172 | end | ||
| 173 | |||
| 174 | def summarize_restore_from_trash action | ||
| 175 | t("node_actions.restore_from_trash", :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 176 | :to => h(action.metadata.dig("path", "to"))).html_safe | ||
| 177 | end | ||
| 178 | |||
| 179 | def summarize_destroy action | ||
| 180 | t("node_actions.destroy", :actor => actor_ref(action), :subject => subject_ref(action), | ||
| 181 | :path => h(action.metadata["path"])).html_safe | ||
| 182 | end | ||
| 183 | end | ||
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb index 9a2eb0e..befcdb3 100644 --- a/app/models/concerns/nested_tree.rb +++ b/app/models/concerns/nested_tree.rb | |||
| @@ -9,8 +9,6 @@ module NestedTree | |||
| 9 | included do | 9 | included do |
| 10 | belongs_to :parent, class_name: name, foreign_key: :parent_id, optional: true, inverse_of: :children | 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 | 11 | has_many :children, -> { order(:id) }, class_name: name, foreign_key: :parent_id, inverse_of: :parent |
| 12 | |||
| 13 | before_destroy :delete_descendants | ||
| 14 | end | 12 | end |
| 15 | 13 | ||
| 16 | class_methods do | 14 | class_methods do |
| @@ -77,10 +75,4 @@ module NestedTree | |||
| 77 | def move_to_child_of(new_parent) | 75 | def move_to_child_of(new_parent) |
| 78 | update!(parent_id: new_parent.id) | 76 | update!(parent_id: new_parent.id) |
| 79 | end | 77 | end |
| 80 | |||
| 81 | private | ||
| 82 | |||
| 83 | def delete_descendants | ||
| 84 | self.class.where(id: descendants.pluck(:id)).delete_all | ||
| 85 | end | ||
| 86 | end | 78 | end |
diff --git a/app/models/concerns/rrule_humanizer.rb b/app/models/concerns/rrule_humanizer.rb index 8231de8..07141c1 100644 --- a/app/models/concerns/rrule_humanizer.rb +++ b/app/models/concerns/rrule_humanizer.rb | |||
| @@ -15,8 +15,8 @@ module RruleHumanizer | |||
| 15 | }.freeze | 15 | }.freeze |
| 16 | 16 | ||
| 17 | ORDINAL_NAMES = { | 17 | ORDINAL_NAMES = { |
| 18 | de: { 1=>"ersten", 2=>"zweiten", 3=>"dritten", 4=>"vierten", -1=>"letzten", -2=>"vorletzten" }, | 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", -1=>"last", -2=>"second-to-last" } | 19 | en: { 1=>"first", 2=>"second", 3=>"third", 4=>"fourth", 5=>"fifth", -1=>"last", -2=>"second-to-last" } |
| 20 | }.freeze | 20 | }.freeze |
| 21 | 21 | ||
| 22 | MONTH_NAMES = { | 22 | MONTH_NAMES = { |
| @@ -35,7 +35,8 @@ module RruleHumanizer | |||
| 35 | ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] | 35 | ordinals = ORDINAL_NAMES[loc] || ORDINAL_NAMES[:en] |
| 36 | months = MONTH_NAMES[loc] || MONTH_NAMES[:en] | 36 | months = MONTH_NAMES[loc] || MONTH_NAMES[:en] |
| 37 | 37 | ||
| 38 | days = byday&.split(",")&.map do |d| | 38 | byday_values = byday&.split(",") |
| 39 | days = byday_values&.map do |d| | ||
| 39 | if d =~ /^(-?\d+)([A-Z]{2})$/ | 40 | if d =~ /^(-?\d+)([A-Z]{2})$/ |
| 40 | "#{ordinals[$1.to_i]} #{weekdays[$2]}" | 41 | "#{ordinals[$1.to_i]} #{weekdays[$2]}" |
| 41 | else | 42 | else |
| @@ -43,6 +44,32 @@ module RruleHumanizer | |||
| 43 | end | 44 | end |
| 44 | end | 45 | end |
| 45 | 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 | |||
| 46 | base = | 73 | base = |
| 47 | case loc | 74 | case loc |
| 48 | when :de | 75 | when :de |
| @@ -59,14 +86,26 @@ module RruleHumanizer | |||
| 59 | interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" | 86 | interval == 2 ? "Alle zwei Wochen" : "Wöchentlich" |
| 60 | end | 87 | end |
| 61 | when "MONTHLY" | 88 | when "MONTHLY" |
| 62 | days ? "Jeden #{days.join(' und ')} im Monat" : "Monatlich" | 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 | ||
| 63 | end | 96 | end |
| 64 | else | 97 | else |
| 65 | case freq | 98 | case freq |
| 66 | when "WEEKLY" | 99 | when "WEEKLY" |
| 67 | days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") | 100 | days ? "#{interval == 2 ? 'Every other' : 'Every'} #{days.join(' and ')}" : (interval == 2 ? "Every other week" : "Weekly") |
| 68 | when "MONTHLY" | 101 | when "MONTHLY" |
| 69 | days ? "Every #{days.join(' and ')} of the month" : "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 | ||
| 70 | end | 109 | end |
| 71 | end | 110 | end |
| 72 | return nil unless base | 111 | return nil unless base |
diff --git a/app/models/node.rb b/app/models/node.rb index 6ca607d..a440c2f 100644 --- a/app/models/node.rb +++ b/app/models/node.rb | |||
| @@ -4,17 +4,23 @@ class Node < ApplicationRecord | |||
| 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. | ||
| 9 | belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true | 12 | belongs_to :autosave, :class_name => "Page", :foreign_key => :autosave_id, :dependent => :destroy, optional: true |
| 13 | |||
| 10 | has_many :permissions, :dependent => :destroy | 14 | has_many :permissions, :dependent => :destroy |
| 11 | has_many :events, :dependent => :destroy | 15 | has_many :events, :dependent => :destroy |
| 12 | 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 |
| 13 | 17 | ||
| 14 | # Callbacks | 18 | # Callbacks |
| 15 | after_create :initialize_empty_page | 19 | after_create :initialize_empty_page |
| 16 | before_save :check_for_changed_slug | 20 | before_save :check_for_changed_slug |
| 17 | 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 | ||
| 18 | 24 | ||
| 19 | # Validations | 25 | # Validations |
| 20 | validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } | 26 | validates_length_of :slug, :within => 1..255, :unless => -> { parent_id.nil? || slug.blank? } |
| @@ -22,6 +28,21 @@ class Node < ApplicationRecord | |||
| 22 | validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } | 28 | validates_uniqueness_of :slug, :scope => :parent_id, :unless => -> { parent_id.nil? } |
| 23 | validates_presence_of :parent_id, :unless => -> { Node.root.nil? } | 29 | validates_presence_of :parent_id, :unless => -> { Node.root.nil? } |
| 24 | 30 | ||
| 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_blank => true, | ||
| 36 | :if => :default_template_name_changed? | ||
| 37 | |||
| 38 | # Everything outside the Trash subtree, the Trash node included. | ||
| 39 | # Relies on unique_name being authoritative for tree position -- | ||
| 40 | # the same trust public routing places in it. | ||
| 41 | scope :not_in_trash, -> { | ||
| 42 | where.not(:unique_name => CccConventions::TRASH_SLUG) | ||
| 43 | .where("unique_name NOT LIKE ?", "#{CccConventions::TRASH_SLUG}/%") | ||
| 44 | } | ||
| 45 | |||
| 25 | # Class methods | 46 | # Class methods |
| 26 | 47 | ||
| 27 | # Returns a page for a given node. If no revision is supplied, it returns | 48 | # Returns a page for a given node. If no revision is supplied, it returns |
| @@ -48,6 +69,19 @@ class Node < ApplicationRecord | |||
| 48 | nil | 69 | nil |
| 49 | end | 70 | end |
| 50 | 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 | |||
| 51 | # Instance Methods | 85 | # Instance Methods |
| 52 | 86 | ||
| 53 | # Acquires (or reaffirms) the editing lock without creating a draft or | 87 | # Acquires (or reaffirms) the editing lock without creating a draft or |
| @@ -194,6 +228,10 @@ class Node < ApplicationRecord | |||
| 194 | # Return nil if nothing to publish and no staged changes | 228 | # Return nil if nothing to publish and no staged changes |
| 195 | return nil unless self.draft || staged_slug || staged_parent_id | 229 | return nil unless self.draft || staged_slug || staged_parent_id |
| 196 | 230 | ||
| 231 | if in_trash? || trash_node? | ||
| 232 | raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash" | ||
| 233 | end | ||
| 234 | |||
| 197 | path_before = self.unique_name | 235 | path_before = self.unique_name |
| 198 | 236 | ||
| 199 | ActiveRecord::Base.transaction do | 237 | ActiveRecord::Base.transaction do |
| @@ -258,6 +296,117 @@ class Node < ApplicationRecord | |||
| 258 | end | 296 | end |
| 259 | end | 297 | end |
| 260 | 298 | ||
| 299 | |||
| 300 | # Moves this node and its subtree into the Trash. Demotes every head | ||
| 301 | # in the subtree first (aggregators and search operate on heads | ||
| 302 | # regardless of tree position); where a node has no draft, the former | ||
| 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 | ||
| 340 | end | ||
| 341 | |||
| 342 | # Returns the node to the living tree under a chosen parent. The | ||
| 343 | # subtree comes back exactly as it sits in the Trash: all drafts, | ||
| 344 | # nothing published. Republication is a separate, witnessed act | ||
| 345 | # per node. | ||
| 346 | def restore_from_trash! new_parent, current_user = nil | ||
| 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" | ||
| 352 | end | ||
| 353 | |||
| 354 | ActiveRecord::Base.transaction do | ||
| 355 | path_before = unique_name | ||
| 356 | move_to_child_of(new_parent) | ||
| 357 | 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 | ||
| 365 | end | ||
| 366 | |||
| 367 | # Final deletion -- only from inside the Trash. Removes the whole | ||
| 368 | # subtree, deepest first, each node through a real destroy! so every | ||
| 369 | # per-node cascade runs (the categorical difference from the old | ||
| 370 | # delete_all nuke). refuse_destroy_with_children on bare destroy is | ||
| 371 | # untouched | ||
| 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!) | ||
| 387 | end | ||
| 388 | end | ||
| 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 | |||
| 261 | # 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 |
| 262 | def unique_path | 411 | def unique_path |
| 263 | unique_name.to_s.split("/") | 412 | unique_name.to_s.split("/") |
| @@ -268,9 +417,12 @@ class Node < ApplicationRecord | |||
| 268 | parent.nil? ? [slug] : parent.path_to_root.push(slug) | 417 | parent.nil? ? [slug] : parent.path_to_root.push(slug) |
| 269 | end | 418 | end |
| 270 | 419 | ||
| 420 | def computed_unique_name | ||
| 421 | path_to_root[1..-1].join("/") # excluding root | ||
| 422 | end | ||
| 423 | |||
| 271 | def current_unique_name | 424 | def current_unique_name |
| 272 | path = path_to_root[1..-1] # excluding root | 425 | self.unique_name = computed_unique_name |
| 273 | self.unique_name = path.join("/") | ||
| 274 | end | 426 | end |
| 275 | 427 | ||
| 276 | def update_unique_name | 428 | def update_unique_name |
| @@ -310,6 +462,20 @@ class Node < ApplicationRecord | |||
| 310 | autosave || draft || head | 462 | autosave || draft || head |
| 311 | end | 463 | end |
| 312 | 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 | |||
| 313 | # 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 |
| 314 | # stay the same eventhough the slug or positions changes. | 480 | # stay the same eventhough the slug or positions changes. |
| 315 | # Can be removed after a year or so ;) | 481 | # Can be removed after a year or so ;) |
| @@ -350,7 +516,7 @@ class Node < ApplicationRecord | |||
| 350 | end | 516 | end |
| 351 | 517 | ||
| 352 | def self.drafts_and_autosaves(current_user_id: nil) | 518 | def self.drafts_and_autosaves(current_user_id: nil) |
| 353 | scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL") | 519 | scope = where("draft_id IS NOT NULL OR autosave_id IS NOT NULL").not_in_trash |
| 354 | return scope.order("updated_at DESC") unless current_user_id | 520 | return scope.order("updated_at DESC") unless current_user_id |
| 355 | 521 | ||
| 356 | scope.order( | 522 | scope.order( |
| @@ -358,6 +524,15 @@ class Node < ApplicationRecord | |||
| 358 | ) | 524 | ) |
| 359 | end | 525 | end |
| 360 | 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 | |||
| 361 | def self.recently_changed | 536 | def self.recently_changed |
| 362 | includes(:head).where( | 537 | includes(:head).where( |
| 363 | "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", | 538 | "pages.updated_at < ? AND pages.updated_at > ? AND nodes.parent_id IS NOT NULL", |
| @@ -414,4 +589,36 @@ class Node < ApplicationRecord | |||
| 414 | end | 589 | end |
| 415 | end | 590 | end |
| 416 | end | 591 | end |
| 592 | |||
| 593 | private | ||
| 594 | |||
| 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 | ||
| 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 | ||
| 623 | end | ||
| 417 | end | 624 | end |
diff --git a/app/models/node_action.rb b/app/models/node_action.rb index 39935c7..13bd5ba 100644 --- a/app/models/node_action.rb +++ b/app/models/node_action.rb | |||
| @@ -8,95 +8,96 @@ class NodeAction < ApplicationRecord | |||
| 8 | 8 | ||
| 9 | # == Metadata contract == | 9 | # == Metadata contract == |
| 10 | # | 10 | # |
| 11 | # metadata is written once at creation and never updated. It is the | 11 | # metadata is written once at creation, never updated. It is the |
| 12 | # single place anything that must survive deletion of the referenced | 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. | 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} -- | 14 | # All keys are strings. Pairs are always {"from" => x, "to" => y}. |
| 15 | # never _old/_new suffixes. Optional pairs are written only when the | 15 | # Optional pairs only when the sides differ; booleans only when |
| 16 | # two sides differ; required keys are always present. All titles and | 16 | # true; required keys always present. Titles and names are read |
| 17 | # names are read pinned to I18n.default_locale unless inside a | 17 | # pinned to I18n.default_locale unless inside a locale-keyed block. |
| 18 | # locale-keyed block. | ||
| 19 | # | 18 | # |
| 20 | # Baseline, every entry (written here, not by call sites): | 19 | # Baseline, every entry (written here, not by call sites): |
| 21 | # "username" -- actor's login at write time | 20 | # "username" -- actor's login at write time |
| 22 | # "human_readable_node_name" -- node title, default locale | 21 | # "human_readable_node_name" -- node title, default locale |
| 23 | # | 22 | # |
| 24 | # "create": | 23 | # "create": |
| 25 | # "title" -- initial title, default locale | 24 | # "title" -- initial title, flat string (NOT a pair) |
| 26 | # "path" -- unique path at creation time. Historical value only: | 25 | # "path" -- unique path at creation, flat string. Historical |
| 27 | # later renames/moves do NOT update old entries. Never | 26 | # value only; never a join key. |
| 28 | # use as a join key; node_id is the join key while the | ||
| 29 | # node lives. | ||
| 30 | # | 27 | # |
| 31 | # "publish" (any promotion of a page to head; the diff against the | 28 | # "publish" (any promotion to head; diff computed BEFORE head is |
| 32 | # outgoing head is computed BEFORE head is re-pointed, over the | 29 | # re-pointed, over the union of both pages' locales, by head_diff -- |
| 33 | # union of both pages' locales, by the shared diff function also | 30 | # shared with the backfill): |
| 34 | # used by the backfill): | 31 | # "via" -- "draft" | "revision" (rollback). Always written; |
| 35 | # "via" -- "draft" (ordinary publish) | "revision" (rollback | 32 | # absent means a pre-contract entry. |
| 36 | # via restore_revision!). Always written; absent | 33 | # "title" -- pair, always; "from" null on first publish |
| 37 | # means a pre-contract entry. | 34 | # "author" -- pair, when the byline changed (incl. first publish) |
| 38 | # "title" -- pair, always present. "from" is null on a first | 35 | # "tags" -- pair of arrays, when changed |
| 39 | # publish (no outgoing head). | 36 | # "assets_changed", "template_changed", |
| 40 | # "author" -- pair, only when the byline (page.user) changed | ||
| 41 | # "tags" -- pair of arrays, only when changed | ||
| 42 | # "assets_changed", "template_changed" | ||
| 43 | # -- booleans, only when true; page_id links to the | ||
| 44 | # revision for the real diff (never stored here) | ||
| 45 | # "abstract_changed", "body_changed" | 37 | # "abstract_changed", "body_changed" |
| 46 | # -- booleans for the default locale, only when true | 38 | # -- the last two for the default locale; page_id links |
| 47 | # "translation_diff" -- only when any non-default locale differs: | 39 | # to the revision for the real diff (never stored) |
| 40 | # "translation_diff" -- only when a non-default locale differs: | ||
| 48 | # { "<locale>" => { | 41 | # { "<locale>" => { |
| 49 | # "status" -- "added" | "removed" | "changed" | 42 | # "status" -- "added" | "removed" | "changed" |
| 50 | # "title" -- pair; "from" null when added, | 43 | # "title" -- pair, only when it differs; "from" null when |
| 51 | # "to" null when removed | 44 | # added, "to" null when removed |
| 52 | # "abstract_changed", "body_changed" -- booleans, only when | 45 | # "abstract_changed", "body_changed" -- status "changed" only |
| 53 | # true, only for status "changed" | ||
| 54 | # } } | 46 | # } } |
| 55 | # | 47 | # |
| 56 | # "move" (reparenting and/or unique-path change; one entry at the | 48 | # "move" (reparent and/or path change; one entry at the subtree |
| 57 | # subtree root, descendants get none -- a descendant's own zoomed | 49 | # root, descendants get none): |
| 58 | # view will not show path history): | 50 | # "path" -- pair |
| 59 | # "path" -- pair | ||
| 60 | # | 51 | # |
| 61 | # Reserved for the Trash feature, final shape decided there: | 52 | # "trash" (subtree into the Trash; every head in the subtree is |
| 62 | # "demote" (via "trash" | "depublish"; carries the leaving-public- | 53 | # demoted first; one entry at the root; snapshots the |
| 63 | # view snapshot: head presence, final published_at), "trash", | 54 | # leaving-public-view state, since Trash holds no heads and destroy |
| 64 | # "restore_from_trash", "destroy" (final path only; publish-state | 55 | # can no longer know): |
| 65 | # snapshot lives on the entry that removed it from public view). | 56 | # "path" -- pair; "from" doubles as the restore hint |
| 66 | # Whether trash logs one entry or a trash+demote pair is decided | 57 | # "was_published" -- boolean |
| 67 | # with that feature. | 58 | # "demoted_heads" -- integer count, only when positive |
| 59 | # "final_published_at" -- ISO8601 string, only when present | ||
| 68 | # | 60 | # |
| 69 | # Backfilled entries mirror this vocabulary exactly. Their diff | 61 | # "restore_from_trash" (reparent back to a living node; returns as |
| 70 | # content is computed, not guessed (consecutive revisions still | 62 | # drafts, republication is a separate witnessed act): |
| 71 | # exist); only actor (from page.editor) and occurred_at are | 63 | # "path" -- pair |
| 72 | # inferred, and inferred_from names the heuristic per entry | ||
| 73 | # ("from_page_revision", "from_published_at_heuristic"). | ||
| 74 | # inferred_from null = witnessed live. | ||
| 75 | # | 64 | # |
| 76 | # The "locale" column is currently written by no verb (it belonged | 65 | # "destroy" (only from inside the Trash, never with children; the |
| 77 | # to the retired translation_destroy) and is retained for backfill | 66 | # entry is written in the same transaction before the row dies): |
| 78 | # or future draft-scoped verbs. | 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. | ||
| 79 | # | 70 | # |
| 80 | # This log records; it does not undo. Reversibility stays in | 71 | # Reserved: "demote" (via "trash" | "depublish") for an explicit |
| 81 | # restore_revision! and the revisions system. No IP, session, or | 72 | # depublish workflow, if ever built. |
| 82 | # user agent, ever. Success only -- rejected attempts are not | 73 | # |
| 83 | # logged. | 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. | ||
| 84 | 83 | ||
| 85 | def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra) | 84 | def self.record!(node:, action:, user: nil, page: nil, locale: nil, |
| 85 | occurred_at: nil, inferred_from: nil, **extra) | ||
| 86 | create!( | 86 | create!( |
| 87 | :node => node, | 87 | :node => node, |
| 88 | :page => page, | 88 | :page => page, |
| 89 | :user => user, | 89 | :user => user, |
| 90 | :action => action, | 90 | :action => action, |
| 91 | :locale => locale, | 91 | :locale => locale, |
| 92 | :occurred_at => Time.now, | 92 | :occurred_at => occurred_at || Time.now, |
| 93 | :metadata => { | 93 | :inferred_from => inferred_from, |
| 94 | :metadata => { | ||
| 94 | "username" => user&.login, | 95 | "username" => user&.login, |
| 95 | "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { | 96 | "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { |
| 96 | node&.head&.title || node&.draft&.title | 97 | node&.head&.title || node&.draft&.title |
| 97 | }, | 98 | }, |
| 98 | }.merge(extra.stringify_keys) | 99 | }.merge(extra.stringify_keys) |
| 99 | ) | 100 | ) |
| 100 | end | 101 | end |
| 101 | 102 | ||
| 102 | # Computes the publish-entry diff between an outgoing head and the | 103 | # Computes the publish-entry diff between an outgoing head and the |
| @@ -113,7 +114,10 @@ class NodeAction < ApplicationRecord | |||
| 113 | title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title } | 114 | title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title } |
| 114 | diff = { :title => { "from" => title_of.call(old_page), | 115 | diff = { :title => { "from" => title_of.call(old_page), |
| 115 | "to" => title_of.call(new_page) } } | 116 | "to" => title_of.call(new_page) } } |
| 116 | return diff unless old_page | 117 | unless old_page |
| 118 | diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user | ||
| 119 | return diff | ||
| 120 | end | ||
| 117 | 121 | ||
| 118 | old_author, new_author = old_page.user&.login, new_page.user&.login | 122 | old_author, new_author = old_page.user&.login, new_page.user&.login |
| 119 | diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author | 123 | diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author |
diff --git a/app/models/page.rb b/app/models/page.rb index e5a8d9d..f33d88d 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_blank => 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 |
| @@ -77,7 +87,10 @@ class Page < ApplicationRecord | |||
| 77 | .paginate(:page => page, :per_page => options[:limit]) | 87 | .paginate(:page => page, :per_page => options[:limit]) |
| 78 | end | 88 | end |
| 79 | 89 | ||
| 80 | scope.order("#{options[:order_by]} #{direction}") | 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}") | ||
| 81 | .paginate(:page => page, :per_page => options[:limit]) | 94 | .paginate(:page => page, :per_page => options[:limit]) |
| 82 | end | 95 | end |
| 83 | 96 | ||
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? |
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index a9c0512..319530d 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb | |||
| @@ -42,11 +42,12 @@ | |||
| 42 | 42 | ||
| 43 | <div class="dashboard_widget"> | 43 | <div class="dashboard_widget"> |
| 44 | <h3>Recent changes</h3> | 44 | <h3>Recent changes</h3> |
| 45 | <ul> | 45 | <table id="node_action_list"> |
| 46 | <%= render partial: "nodes/recent_change_item", collection: @recent_changes, as: :node %> | 46 | <%= render partial: "node_actions/action_row", collection: @actions, as: :action %> |
| 47 | </ul> | 47 | </table> |
| 48 | <%= link_to "See all recent changes →", recent_nodes_path %> | 48 | <%= link_to "See all recent changes →", admin_log_path %> |
| 49 | </div> | 49 | </div> |
| 50 | |||
| 50 | </div> | 51 | </div> |
| 51 | 52 | ||
| 52 | <div id="dashboard_housekeeping"> | 53 | <div id="dashboard_housekeeping"> |
| @@ -67,5 +68,9 @@ | |||
| 67 | <%= link_to menu_items_path, class: "action_button" do %> | 68 | <%= link_to menu_items_path, class: "action_button" do %> |
| 68 | <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation | 69 | <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation |
| 69 | <% end %> | 70 | <% end %> |
| 71 | <% trash_count = Node.trash.children.count %> | ||
| 72 | <%= link_to trashed_nodes_path, class: "action_button" do %> | ||
| 73 | <%= icon("trash", library: "tabler", "aria-hidden": true) %> Trash<%= " (#{trash_count})" if trash_count > 0 %> | ||
| 74 | <% end %> | ||
| 70 | </div> | 75 | </div> |
| 71 | </div> | 76 | </div> |
diff --git a/app/views/events/_rrule_builder.html.erb b/app/views/events/_rrule_builder.html.erb new file mode 100644 index 0000000..1ffec0a --- /dev/null +++ b/app/views/events/_rrule_builder.html.erb | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | <div id="rrule_builder"> | ||
| 2 | <p> | ||
| 3 | <label><%= radio_button_tag "rrule_freq", "weekly", true %> Weekly</label> | ||
| 4 | <label><%= radio_button_tag "rrule_freq", "monthly" %> Monthly</label> | ||
| 5 | </p> | ||
| 6 | <div id="rrule_weekly_options"> | ||
| 7 | <p><label><%= check_box_tag "rrule_biweekly" %> Every 2 weeks</label></p> | ||
| 8 | <p> | ||
| 9 | <% %w[MO TU WE TH FR SA SU].each do |code| %> | ||
| 10 | <label><%= check_box_tag "rrule_byday_#{code}" %> <%= RruleHumanizer::WEEKDAY_NAMES_ABBR[:de][code] %></label> | ||
| 11 | <% end %> | ||
| 12 | </p> | ||
| 13 | </div> | ||
| 14 | <div id="rrule_monthly_options" style="display: none;"> | ||
| 15 | <p><label><%= check_box_tag "rrule_monthly_ordinal" %> On a specific weekday each month</label></p> | ||
| 16 | <p id="rrule_ordinal_fields" style="display: none;"> | ||
| 17 | <%= select_tag "rrule_ordinal", options_for_select([["1.", 1], ["2.", 2], ["3.", 3], ["4.", 4], ["letzter", -1], ["vorletzter", -2], ["Selected weeks", "custom"]]) %> | ||
| 18 | <%= select_tag "rrule_ordinal_day", options_for_select(%w[MO TU WE TH FR SA SU].map { |c| [RruleHumanizer::WEEKDAY_NAMES[:de][c], c] }) %> | ||
| 19 | </p> | ||
| 20 | <p id="rrule_custom_ordinal_fields" style="display: none;"> | ||
| 21 | Weeks of the month: | ||
| 22 | <% (1..5).each do |ordinal| %> | ||
| 23 | <label><%= check_box_tag "rrule_custom_ordinal_#{ordinal}" %> <%= ordinal %>.</label> | ||
| 24 | <% end %> | ||
| 25 | </p> | ||
| 26 | </div> | ||
| 27 | <p> | ||
| 28 | <label><%= check_box_tag "rrule_exclude_month" %> Except in one month</label> | ||
| 29 | <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %> | ||
| 30 | </p> | ||
| 31 | <span class="field_hint">Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them.</span> | ||
| 32 | </div> | ||
| 33 | <%= f.text_field :rrule, id: "event_rrule" %> | ||
diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index b6564a4..4532ff2 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb | |||
| @@ -40,33 +40,7 @@ | |||
| 40 | 40 | ||
| 41 | <div class="node_description">Recurrence</div> | 41 | <div class="node_description">Recurrence</div> |
| 42 | <div class="node_content"> | 42 | <div class="node_content"> |
| 43 | <div id="rrule_builder"> | 43 | <%= render "rrule_builder", f: f %> |
| 44 | <p> | ||
| 45 | <label><%= radio_button_tag "rrule_freq", "weekly", true %> Weekly</label> | ||
| 46 | <label><%= radio_button_tag "rrule_freq", "monthly" %> Monthly</label> | ||
| 47 | </p> | ||
| 48 | <div id="rrule_weekly_options"> | ||
| 49 | <p><label><%= check_box_tag "rrule_biweekly" %> Every 2 weeks</label></p> | ||
| 50 | <p> | ||
| 51 | <% %w[MO TU WE TH FR SA SU].each do |code| %> | ||
| 52 | <label><%= check_box_tag "rrule_byday_#{code}" %> <%= RruleHumanizer::WEEKDAY_NAMES_ABBR[:de][code] %></label> | ||
| 53 | <% end %> | ||
| 54 | </p> | ||
| 55 | </div> | ||
| 56 | <div id="rrule_monthly_options" style="display: none;"> | ||
| 57 | <p><label><%= check_box_tag "rrule_monthly_ordinal" %> On a specific weekday each month</label></p> | ||
| 58 | <p id="rrule_ordinal_fields" style="display: none;"> | ||
| 59 | <%= select_tag "rrule_ordinal", options_for_select([["1.", 1], ["2.", 2], ["3.", 3], ["4.", 4], ["letzter", -1], ["vorletzter", -2]]) %> | ||
| 60 | <%= select_tag "rrule_ordinal_day", options_for_select(%w[MO TU WE TH FR SA SU].map { |c| [RruleHumanizer::WEEKDAY_NAMES[:de][c], c] }) %> | ||
| 61 | </p> | ||
| 62 | </div> | ||
| 63 | <p> | ||
| 64 | <label><%= check_box_tag "rrule_exclude_month" %> Except in one month</label> | ||
| 65 | <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %> | ||
| 66 | </p> | ||
| 67 | <span class="field_hint">Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them.</span> | ||
| 68 | </div> | ||
| 69 | <%= f.text_field :rrule, id: "event_rrule" %> | ||
| 70 | </div> | 44 | </div> |
| 71 | 45 | ||
| 72 | <div class="node_description">Title</div> | 46 | <div class="node_description">Title</div> |
diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb index 7a1ee7a..028fce7 100644 --- a/app/views/events/new.html.erb +++ b/app/views/events/new.html.erb | |||
| @@ -21,8 +21,8 @@ | |||
| 21 | <div class="node_description">End time</div> | 21 | <div class="node_description">End time</div> |
| 22 | <div class="node_content"><%= f.datetime_select :end_time %></div> | 22 | <div class="node_content"><%= f.datetime_select :end_time %></div> |
| 23 | 23 | ||
| 24 | <div class="node_description">Rrule</div> | 24 | <div class="node_description">Recurrence</div> |
| 25 | <div class="node_content"><%= f.text_field :rrule %></div> | 25 | <div class="node_content"><%= render "rrule_builder", f: f %></div> |
| 26 | 26 | ||
| 27 | <div class="node_description">Title</div> | 27 | <div class="node_description">Title</div> |
| 28 | <div class="node_content"> | 28 | <div class="node_content"> |
| @@ -53,4 +53,3 @@ | |||
| 53 | </div> | 53 | </div> |
| 54 | </div> | 54 | </div> |
| 55 | <% end %> | 55 | <% end %> |
| 56 | |||
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 0856a0f..e220beb 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb | |||
| @@ -7,19 +7,18 @@ | |||
| 7 | <%= csrf_meta_tags %> | 7 | <%= csrf_meta_tags %> |
| 8 | 8 | ||
| 9 | <title><%= "#{params[:controller]} | #{params[:action]}" %></title> | 9 | <title><%= "#{params[:controller]} | #{params[:action]}" %></title> |
| 10 | <%= javascript_tag "var AUTH_TOKEN = #{form_authenticity_token.inspect};" if protect_against_forgery? %> | ||
| 11 | <%= javascript_include_tag 'admin_bundle' %> | 10 | <%= javascript_include_tag 'admin_bundle' %> |
| 12 | <%= tinymce_assets %> | 11 | <%= tinymce_assets %> |
| 13 | <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/admin.css') %>"> | 12 | <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/admin.css') %>"> |
| 14 | <script src="<%= mtime_busted_path('/javascripts/admin_search.js') %>"></script> | 13 | <script src="<%= mtime_busted_path('/javascripts/admin_search.js') %>"></script> |
| 15 | <script src="<%= mtime_busted_path('/javascripts/admin_interface.js') %>"></script> | 14 | <script src="<%= mtime_busted_path('/javascripts/admin_interface.js') %>"></script> |
| 16 | <script src="<%= mtime_busted_path('/javascripts/related_assets.js') %>"></script> | 15 | <script src="<%= mtime_busted_path('/javascripts/related_assets.js') %>"></script> |
| 17 | <script> | 16 | <%= javascript_tag nonce: true do %> |
| 18 | var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; | 17 | var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; |
| 19 | var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; | 18 | var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; |
| 20 | var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; | 19 | var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; |
| 21 | var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; | 20 | var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; |
| 22 | </script> | 21 | <% end %> |
| 23 | </head> | 22 | </head> |
| 24 | 23 | ||
| 25 | <body> | 24 | <body> |
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index d7681af..ca867ab 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb | |||
| @@ -18,12 +18,12 @@ | |||
| 18 | <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %> | 18 | <%= auto_discovery_link_tag(:atom, '/rss/updates.xml', title: "ATOM") %> |
| 19 | <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %> | 19 | <%= auto_discovery_link_tag(:rss, '/rss/updates.rdf', title: "RSS") %> |
| 20 | 20 | ||
| 21 | <script> | 21 | <%= javascript_tag nonce: true do %> |
| 22 | (function() { document.addEventListener("DOMContentLoaded", function() { | 22 | document.addEventListener("DOMContentLoaded", function() { |
| 23 | if (localStorage.getItem('override-prefers-color-scheme', false)) | 23 | if (localStorage.getItem('override-prefers-color-scheme')) |
| 24 | document.getElementById("light-mode").checked = true; | 24 | document.getElementById("light-mode").checked = true; |
| 25 | }); })(); | 25 | }); |
| 26 | </script> | 26 | <% end %> |
| 27 | 27 | ||
| 28 | </head> | 28 | </head> |
| 29 | 29 | ||
diff --git a/app/views/node_actions/_action_row.html.erb b/app/views/node_actions/_action_row.html.erb new file mode 100644 index 0000000..c640f0d --- /dev/null +++ b/app/views/node_actions/_action_row.html.erb | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | <tr class="node_action node_action--<%= action.action %>"> | ||
| 2 | <td class="node_action_time"> | ||
| 3 | <span class="node_action_date"><%= action.occurred_at.strftime("%Y-%m-%d") %></span> | ||
| 4 | <span class="node_action_clock"><%= action.occurred_at.strftime("%H:%M") %></span> | ||
| 5 | </td> | ||
| 6 | <td class="node_action_body"> | ||
| 7 | <%= verb_icon(action) %> | ||
| 8 | <%= action_summary(action) %> | ||
| 9 | <% if action.node_id && params[:node_id].blank? %> | ||
| 10 | <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %> | ||
| 11 | <% end %> | ||
| 12 | <% if action.inferred_from %> | ||
| 13 | <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span> | ||
| 14 | <% end %> | ||
| 15 | <%= render "node_actions/change_details", :action_entry => action if action_details?(action) %> | ||
| 16 | </td> | ||
| 17 | </tr> | ||
| 18 | |||
diff --git a/app/views/node_actions/_change_details.html.erb b/app/views/node_actions/_change_details.html.erb new file mode 100644 index 0000000..2583e8b --- /dev/null +++ b/app/views/node_actions/_change_details.html.erb | |||
| @@ -0,0 +1,27 @@ | |||
| 1 | <details class="node_action_details"> | ||
| 2 | <summary><%= t("node_actions.show_changes") %></summary> | ||
| 3 | <table> | ||
| 4 | <% if (default_items = default_locale_changes(action_entry)).any? %> | ||
| 5 | <tr> | ||
| 6 | <th><%= I18n.default_locale.to_s.upcase %></th> | ||
| 7 | <td> | ||
| 8 | <%= safe_join(default_items, ", ") %> | ||
| 9 | <% if action_entry.page && action_entry.node %> | ||
| 10 | <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page) %> | ||
| 11 | <% end %> | ||
| 12 | </td> | ||
| 13 | </tr> | ||
| 14 | <% end %> | ||
| 15 | <% (action_entry.metadata["translation_diff"] || {}).each do |locale, diff| %> | ||
| 16 | <tr> | ||
| 17 | <th><%= locale.upcase %></th> | ||
| 18 | <td> | ||
| 19 | <%= safe_join(translation_changes(diff), ", ") %> | ||
| 20 | <% if action_entry.page && action_entry.node %> | ||
| 21 | <%= link_to t("node_actions.view_revision"), node_revision_path(action_entry.node, action_entry.page, :locale => locale) %> | ||
| 22 | <% end %> | ||
| 23 | </td> | ||
| 24 | </tr> | ||
| 25 | <% end %> | ||
| 26 | </table> | ||
| 27 | </details> | ||
diff --git a/app/views/node_actions/index.html.erb b/app/views/node_actions/index.html.erb new file mode 100644 index 0000000..20ef5b3 --- /dev/null +++ b/app/views/node_actions/index.html.erb | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | <h1><%= t("node_actions.heading") %></h1> | ||
| 2 | |||
| 3 | <% if params[:node_id].present? || params[:user_id].present? %> | ||
| 4 | <p><%= link_to t("node_actions.show_all"), admin_log_path %></p> | ||
| 5 | <% end %> | ||
| 6 | |||
| 7 | <%= will_paginate @actions %> | ||
| 8 | |||
| 9 | <table id="node_action_list"> | ||
| 10 | <%= render partial: "node_actions/action_row", collection: @actions, as: :action %> | ||
| 11 | </table> | ||
| 12 | |||
| 13 | <%= will_paginate @actions %> | ||
diff --git a/app/views/nodes/destroy.html.erb b/app/views/nodes/destroy.html.erb deleted file mode 100644 index 065cf1d..0000000 --- a/app/views/nodes/destroy.html.erb +++ /dev/null | |||
| @@ -1,2 +0,0 @@ | |||
| 1 | <h1>Nodes#destroy</h1> | ||
| 2 | <p>Find me in app/views/nodes/destroy.html.erb</p> | ||
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 49149d5..890d46e 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb | |||
| @@ -32,8 +32,8 @@ | |||
| 32 | <div id="parent_search_field" style="<%= @selected_kind == "generic" ? "" : "display: none;" %>"> | 32 | <div id="parent_search_field" style="<%= @selected_kind == "generic" ? "" : "display: none;" %>"> |
| 33 | <div class="node_description">Parent</div> | 33 | <div class="node_description">Parent</div> |
| 34 | <div class="node_content"> | 34 | <div class="node_content"> |
| 35 | <%= text_field_tag :parent_search_term, @parent_name %> | 35 | <%= text_field_tag :parent_search_term, @parent&.title %> |
| 36 | <%= hidden_field_tag :parent_id, @parent_id, data: { unique_name: @parent_unique_name } %> | 36 | <%= hidden_field_tag :parent_id, @parent&.id, data: { unique_name: @parent&.computed_unique_name } %> |
| 37 | <div id="parent_search_results" class="search_results"></div> | 37 | <div id="parent_search_results" class="search_results"></div> |
| 38 | </div> | 38 | </div> |
| 39 | </div> | 39 | </div> |
diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 66f04f9..af05778 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb | |||
| @@ -46,7 +46,7 @@ | |||
| 46 | <% end %> | 46 | <% end %> |
| 47 | 47 | ||
| 48 | <% unless locked_by_other %> | 48 | <% unless locked_by_other %> |
| 49 | <% if @node.draft && !@node.autosave %> | 49 | <% if @node.draft && !@node.autosave && !@node.in_trash? && !@node.trash_node? %> |
| 50 | <div class="node_info_item"> | 50 | <div class="node_info_item"> |
| 51 | <%= button_to 'Publish', publish_node_path(@node), method: :put, | 51 | <%= button_to 'Publish', publish_node_path(@node), method: :put, |
| 52 | form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> | 52 | form: { data: { confirm: "Publish this draft?" }, class: 'button_to state_changing' } %> |
| @@ -62,6 +62,15 @@ | |||
| 62 | <% end %> | 62 | <% end %> |
| 63 | </div> | 63 | </div> |
| 64 | <% end %> | 64 | <% end %> |
| 65 | <% unless @node.trash_node? || @node.in_trash? || @node.root? %> | ||
| 66 | <div class="node_info_item"> | ||
| 67 | <%= button_to trash_node_path(@node), method: :put, | ||
| 68 | form: { data: { confirm: "Move this page and everything beneath it to the Trash? All published content in it will go offline." }, class: 'button_to destructive' } do %> | ||
| 69 | <%= icon("trash", library: "tabler", "aria-hidden": true) %> | ||
| 70 | Move to Trash | ||
| 71 | <% end %> | ||
| 72 | </div> | ||
| 73 | <% end %> | ||
| 65 | <% end %> | 74 | <% end %> |
| 66 | </div> | 75 | </div> |
| 67 | 76 | ||
| @@ -70,6 +79,48 @@ | |||
| 70 | <% end %> | 79 | <% end %> |
| 71 | </div> | 80 | </div> |
| 72 | 81 | ||
| 82 | <% if @node.in_trash? %> | ||
| 83 | <div class="node_description">Trash</div> | ||
| 84 | <div class="node_content node_info_group"> | ||
| 85 | <div class="node_info_group_items"> | ||
| 86 | <% if (entry = @node.last_trash_entry) && entry.metadata.dig("path", "from") %> | ||
| 87 | <div class="node_info_item"> | ||
| 88 | <span class="node_info_label">Was at</span> | ||
| 89 | <%= entry.metadata.dig("path", "from") %> | ||
| 90 | </div> | ||
| 91 | <% end %> | ||
| 92 | <div class="node_info_item"> | ||
| 93 | <span class="node_info_label">Restore to</span> | ||
| 94 | <% suggestion = @node.suggested_restore_parent %> | ||
| 95 | <%= form_tag restore_from_trash_node_path(@node), :method => :put, :class => "aligned_action_row" do %> | ||
| 96 | <div class="restore_picker"> | ||
| 97 | <%= text_field_tag :restore_search_term, | ||
| 98 | suggestion && (suggestion.head&.title || suggestion.draft&.title || suggestion.slug), | ||
| 99 | :placeholder => "Search for a new parent…" %> | ||
| 100 | <div id="restore_search_results" class="search_results"></div> | ||
| 101 | </div> | ||
| 102 | <%= hidden_field_tag :parent_id, suggestion&.id %> | ||
| 103 | <%= submit_tag "Restore here", :class => "action_button action_button_no_margin_top" %> | ||
| 104 | <% end %> | ||
| 105 | <span class="field_hint">Restored pages come back unpublished. Republish each page deliberately.</span> | ||
| 106 | </div> | ||
| 107 | </div> | ||
| 108 | <div class="node_info_group_items"> | ||
| 109 | <div class="node_info_item"> | ||
| 110 | <% doomed_below = @node.descendants.count %> | ||
| 111 | <%= button_to node_path(@node), method: :delete, | ||
| 112 | form: { data: { confirm: doomed_below > 0 ? | ||
| 113 | "Delete this page and the #{doomed_below} pages beneath it permanently? This cannot be undone." : | ||
| 114 | "Delete this page permanently? This cannot be undone." }, | ||
| 115 | class: 'button_to destructive' } do %> | ||
| 116 | <%= icon("trash-x", library: "tabler", "aria-hidden": true) %> | ||
| 117 | Delete permanently<%= " (including #{doomed_below} beneath)" if doomed_below > 0 %> | ||
| 118 | <% end %> | ||
| 119 | </div> | ||
| 120 | </div> | ||
| 121 | </div> | ||
| 122 | <% end %> | ||
| 123 | |||
| 73 | <div class="node_description">Translations</div> | 124 | <div class="node_description">Translations</div> |
| 74 | <div class="node_content node_info_group"> | 125 | <div class="node_content node_info_group"> |
| 75 | <div class="node_info_group_items"> | 126 | <div class="node_info_group_items"> |
| @@ -170,19 +221,22 @@ | |||
| 170 | </div> | 221 | </div> |
| 171 | </div> | 222 | </div> |
| 172 | 223 | ||
| 173 | <div class="node_description">Revisions</div> | 224 | <div class="node_description">History</div> |
| 174 | <div class="node_content node_info_group"> | 225 | <div class="node_content node_info_group"> |
| 175 | <details> | 226 | <details> |
| 176 | <summary> | 227 | <summary> |
| 177 | <%= pluralize(@node.pages.count, 'revision', 'revisions') %> | 228 | <%= pluralize(@node.pages.count, 'published revision', 'published revisions') %> |
| 178 | </summary> | 229 | </summary> |
| 179 | <ul> | 230 | <ul> |
| 180 | <% @node.pages.order(:revision).each do |page| %> | 231 | <% @node.pages.order(:revision).each do |page| %> |
| 181 | <li><%= link_to "##{page.revision} — #{page.title} (#{page.editor.try(:login)}, #{page.updated_at})", node_revision_path(@node, page) %></li> | 232 | <li> |
| 233 | <%= link_to "##{page.revision} — #{page.title} (#{page.editor.try(:login)}, #{page.updated_at})", node_revision_path(@node, page) %> | ||
| 234 | <span class="revision_lifecycle"><%= revision_lifecycle_badges(@page_actions[page.id]) %></span> | ||
| 235 | </li> | ||
| 182 | <% end %> | 236 | <% end %> |
| 183 | </ul> | 237 | </ul> |
| 184 | </details> | 238 | </details> |
| 185 | <p class="revisions_full_history_link"><%= link_to 'Full history (diff / restore)', node_revisions_path(@node) %></p> | 239 | <p class="revisions_full_history_link"><%= link_to 'Revision history (diff / restore)', node_revisions_path(@node) %> | <%= link_to t("node_actions.heading"), admin_log_path(:node_id => @node.id) %></p> |
| 186 | </div> | 240 | </div> |
| 187 | 241 | ||
| 188 | 242 | ||
diff --git a/app/views/nodes/trashed.html.erb b/app/views/nodes/trashed.html.erb new file mode 100644 index 0000000..9a4808e --- /dev/null +++ b/app/views/nodes/trashed.html.erb | |||
| @@ -0,0 +1,42 @@ | |||
| 1 | <h1>Trash</h1> | ||
| 2 | |||
| 3 | <% if @nodes.empty? %> | ||
| 4 | <p class="field_hint">The Trash is empty.</p> | ||
| 5 | <% else %> | ||
| 6 | <%= will_paginate @nodes %> | ||
| 7 | <table id="trashed_node_list"> | ||
| 8 | <tr> | ||
| 9 | <th>Title</th> | ||
| 10 | <th>Was at</th> | ||
| 11 | <th>Trashed</th> | ||
| 12 | <th>Actions</th> | ||
| 13 | </tr> | ||
| 14 | <% @nodes.each do |node| %> | ||
| 15 | <% entry = node.last_trash_entry %> | ||
| 16 | <tr> | ||
| 17 | <% doomed_below = node.descendants.count %> | ||
| 18 | <td> | ||
| 19 | <%= link_to (node.draft&.title || node.slug), node_path(node) %><%= " (+ #{doomed_below} beneath)" if doomed_below > 0 %> | ||
| 20 | </td> | ||
| 21 | <td><%= entry&.metadata&.dig("path", "from") %></td> | ||
| 22 | <td> | ||
| 23 | <% if entry %> | ||
| 24 | <%= entry.occurred_at.strftime("%Y-%m-%d %H:%M") %> by <%= entry.actor_name %> | ||
| 25 | <% end %> | ||
| 26 | </td> | ||
| 27 | <td> | ||
| 28 | <%= button_to node_path(node), method: :delete, | ||
| 29 | form: { data: { confirm: doomed_below > 0 ? | ||
| 30 | "Delete \"#{node.draft&.title || node.slug}\" and the #{doomed_below} pages beneath it permanently? This cannot be undone." : | ||
| 31 | "Delete \"#{node.draft&.title || node.slug}\" permanently? This cannot be undone." }, | ||
| 32 | class: 'button_to destructive' } do %> | ||
| 33 | <%= icon("trash-x", library: "tabler", "aria-hidden": true) %> | ||
| 34 | Delete permanently | ||
| 35 | <% end %> | ||
| 36 | </td> | ||
| 37 | </tr> | ||
| 38 | <% end %> | ||
| 39 | </table> | ||
| 40 | <%= will_paginate @nodes %> | ||
| 41 | <p class="field_hint">To restore a page, open it and pick a new parent in its Trash section.</p> | ||
| 42 | <% end %> | ||
