summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/node_actions_controller.rb14
-rw-r--r--app/controllers/nodes_controller.rb12
-rw-r--r--app/helpers/node_actions_helper.rb117
-rw-r--r--app/models/concerns/rrule_humanizer.rb49
-rw-r--r--app/models/node.rb7
-rw-r--r--app/models/node_action.rb25
-rw-r--r--app/views/events/_rrule_builder.html.erb33
-rw-r--r--app/views/events/edit.html.erb28
-rw-r--r--app/views/events/new.html.erb5
-rw-r--r--app/views/node_actions/_change_details.html.erb27
-rw-r--r--app/views/node_actions/index.html.erb27
-rw-r--r--app/views/nodes/new.html.erb4
-rw-r--r--app/views/nodes/show.html.erb6
-rw-r--r--config/locales/de.yml28
-rw-r--r--config/locales/en.yml28
-rw-r--r--config/routes.rb1
-rw-r--r--public/javascripts/admin_interface.js46
-rw-r--r--public/stylesheets/admin.css34
-rw-r--r--test/controllers/node_actions_controller_test.rb51
-rw-r--r--test/models/concerns/rrule_humanizer_test.rb18
-rw-r--r--test/models/helpers/node_actions_helper_test.rb107
-rw-r--r--test/models/node_action_test.rb16
22 files changed, 619 insertions, 64 deletions
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 @@
1class 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
14end
diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb
index b66ea49..9834a17 100644
--- a/app/controllers/nodes_controller.rb
+++ b/app/controllers/nodes_controller.rb
@@ -27,12 +27,7 @@ class NodesController < ApplicationController
27 def new 27 def new
28 @node = Node.new node_params 28 @node = Node.new node_params
29 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 29 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
30 if params.has_key?(:parent_id) 30 @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 31 end
37 32
38 def create 33 def create
@@ -57,10 +52,7 @@ class NodesController < ApplicationController
57 redirect_to(edit_node_path(@node)) 52 redirect_to(edit_node_path(@node))
58 else 53 else
59 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 54 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
60 if params[:parent_id].present? 55 @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 56 render :new
65 end 57 end
66 end 58 end
diff --git a/app/helpers/node_actions_helper.rb b/app/helpers/node_actions_helper.rb
new file mode 100644
index 0000000..996f98d
--- /dev/null
+++ b/app/helpers/node_actions_helper.rb
@@ -0,0 +1,117 @@
1module NodeActionsHelper
2 include ERB::Util
3
4 # One sentence per entry, rendered from metadata alone, so entries
5 # stay renderable after the rows they reference are gone. Live
6 # associations only upgrade plain names to links. Every metadata
7 # value passes through h() here -- this helper is the escaping
8 # boundary; the locale sentences are trusted, the data never is.
9 def action_summary action
10 renderer = "summarize_#{action.action}"
11 return send(renderer, action) if respond_to?(renderer, true)
12
13 # Unknown-verb fallback: the log outlives the vocabulary. A renamed
14 # or future verb degrades to an ugly sentence, never to a 500.
15 t("node_actions.unknown", :actor => actor_ref(action),
16 :action => h(action.action), :subject => subject_ref(action)).html_safe
17 end
18
19
20 def action_details? action
21 m = action.metadata
22 return true if m["translation_diff"].present?
23 return true if m["title"].is_a?(Hash) && m.dig("title", "from") != m.dig("title", "to")
24 %w[author tags template_changed assets_changed
25 abstract_changed body_changed].any? { |key| m[key].present? }
26 end
27
28 # Plain strings by design -- safe_join in the template escapes them.
29 def default_locale_changes action
30 m = action.metadata
31 items = []
32 if m["title"].is_a?(Hash) && m.dig("title", "from") && m.dig("title", "from") != m.dig("title", "to")
33 items << t("node_actions.detail_title",
34 :from => m.dig("title", "from"), :to => m.dig("title", "to"))
35 end
36 items << t("node_actions.detail_author",
37 :from => m.dig("author", "from"), :to => m.dig("author", "to")) if m["author"]
38 items << t("node_actions.detail_tags",
39 :from => Array(m.dig("tags", "from")).join(", "),
40 :to => Array(m.dig("tags", "to")).join(", ")) if m["tags"]
41 items << t("node_actions.abstract_changed") if m["abstract_changed"]
42 items << t("node_actions.body_changed") if m["body_changed"]
43 items << t("node_actions.template_changed") if m["template_changed"]
44 items << t("node_actions.assets_changed") if m["assets_changed"]
45 items
46 end
47
48 def translation_changes diff
49 case diff["status"]
50 when "added" then [t("node_actions.locale_added", :title => diff.dig("title", "to"))]
51 when "removed" then [t("node_actions.locale_removed", :title => diff.dig("title", "from"))]
52 else
53 items = []
54 items << t("node_actions.detail_title",
55 :from => diff.dig("title", "from"), :to => diff.dig("title", "to")) if diff["title"]
56 items << t("node_actions.abstract_changed") if diff["abstract_changed"]
57 items << t("node_actions.body_changed") if diff["body_changed"]
58 items
59 end
60 end
61
62 private
63
64 def revision_ref action, key
65 label = t(key)
66 return label unless action.node && action.page
67 link_to(label, node_revision_path(action.node, action.page))
68 end
69
70 def actor_ref action
71 action.user ? link_to(h(action.actor_name), admin_log_path(:user_id => action.user_id))
72 : h(action.actor_name)
73 end
74
75 def subject_ref action
76 action.node ? link_to(h(action.subject_name), node_path(action.node))
77 : h(action.subject_name)
78 end
79
80 def summarize_publish action
81 if action.metadata["via"] == "revision"
82 t("node_actions.publish_rollback",
83 :actor => actor_ref(action), :subject => subject_ref(action),
84 :revision => revision_ref(action, "node_actions.revision_earlier")).html_safe
85 elsif action.metadata.dig("title", "from").nil?
86 author = action.metadata.dig("author", "to")
87 key = author ? "node_actions.publish_first_with_author" : "node_actions.publish_first"
88 t(key, :actor => actor_ref(action), :subject => subject_ref(action),
89 :author => h(author)).html_safe
90 else
91 t("node_actions.publish",
92 :actor => actor_ref(action), :subject => subject_ref(action),
93 :revision => revision_ref(action, "node_actions.revision_new")).html_safe
94 end
95 end
96
97 def summarize_move action
98 t("node_actions.move", :actor => actor_ref(action), :subject => subject_ref(action),
99 :from => h(action.metadata.dig("path", "from")),
100 :to => h(action.metadata.dig("path", "to"))).html_safe
101 end
102
103 def summarize_create action
104 t("node_actions.create", :actor => actor_ref(action), :subject => subject_ref(action),
105 :path => h(action.metadata["path"])).html_safe
106 end
107
108 def summarize_discard_autosave action
109 t("node_actions.discard_autosave",
110 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
111 end
112
113 def summarize_destroy_draft action
114 t("node_actions.destroy_draft",
115 :actor => actor_ref(action), :subject => subject_ref(action)).html_safe
116 end
117end
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..717f5b4 100644
--- a/app/models/node.rb
+++ b/app/models/node.rb
@@ -268,9 +268,12 @@ class Node < ApplicationRecord
268 parent.nil? ? [slug] : parent.path_to_root.push(slug) 268 parent.nil? ? [slug] : parent.path_to_root.push(slug)
269 end 269 end
270 270
271 def computed_unique_name
272 path = path_to_root[1..-1].join("/") # excluding root
273 end
274
271 def current_unique_name 275 def current_unique_name
272 path = path_to_root[1..-1] # excluding root 276 self.unique_name = computed_unique_name
273 self.unique_name = path.join("/")
274 end 277 end
275 278
276 def update_unique_name 279 def update_unique_name
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
index 39935c7..792ae1e 100644
--- a/app/models/node_action.rb
+++ b/app/models/node_action.rb
@@ -82,21 +82,23 @@ class NodeAction < ApplicationRecord
82 # user agent, ever. Success only -- rejected attempts are not 82 # user agent, ever. Success only -- rejected attempts are not
83 # logged. 83 # logged.
84 84
85 def self.record!(node:, action:, user: nil, page: nil, locale: nil, **extra) 85 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
86 occurred_at: nil, inferred_from: nil, **extra)
86 create!( 87 create!(
87 :node => node, 88 :node => node,
88 :page => page, 89 :page => page,
89 :user => user, 90 :user => user,
90 :action => action, 91 :action => action,
91 :locale => locale, 92 :locale => locale,
92 :occurred_at => Time.now, 93 :occurred_at => occurred_at || Time.now,
93 :metadata => { 94 :inferred_from => inferred_from,
95 :metadata => {
94 "username" => user&.login, 96 "username" => user&.login,
95 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) { 97 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
96 node&.head&.title || node&.draft&.title 98 node&.head&.title || node&.draft&.title
97 }, 99 },
98 }.merge(extra.stringify_keys) 100 }.merge(extra.stringify_keys)
99 ) 101 )
100 end 102 end
101 103
102 # Computes the publish-entry diff between an outgoing head and the 104 # Computes the publish-entry diff between an outgoing head and the
@@ -113,7 +115,10 @@ class NodeAction < ApplicationRecord
113 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title } 115 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
114 diff = { :title => { "from" => title_of.call(old_page), 116 diff = { :title => { "from" => title_of.call(old_page),
115 "to" => title_of.call(new_page) } } 117 "to" => title_of.call(new_page) } }
116 return diff unless old_page 118 unless old_page
119 diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
120 return diff
121 end
117 122
118 old_author, new_author = old_page.user&.login, new_page.user&.login 123 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 124 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
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/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..ee06213
--- /dev/null
+++ b/app/views/node_actions/index.html.erb
@@ -0,0 +1,27 @@
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 <% @actions.each do |action| %>
11 <tr class="node_action node_action--<%= action.action %>">
12 <td class="node_action_time"><%= action.occurred_at.strftime("%Y-%m-%d %H:%M") %></td>
13 <td class="node_action_body">
14 <%= action_summary(action) %>
15 <% if action.node_id && params[:node_id].blank? %>
16 <%= link_to t("node_actions.node_history"), admin_log_path(:node_id => action.node_id), :class => "node_action_zoom" %>
17 <% end %>
18 <% if action.inferred_from %>
19 <span class="node_action_inferred" title="<%= action.inferred_from %>"><%= t("node_actions.backfilled") %></span>
20 <% end %>
21 <%= render "change_details", :action_entry => action if action_details?(action) %>
22 </td>
23 </tr>
24 <% end %>
25</table>
26
27<%= will_paginate @actions %>
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb
index 49149d5..38eac84 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&.current_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..ae25571 100644
--- a/app/views/nodes/show.html.erb
+++ b/app/views/nodes/show.html.erb
@@ -170,11 +170,11 @@
170 </div> 170 </div>
171 </div> 171 </div>
172 172
173 <div class="node_description">Revisions</div> 173 <div class="node_description">History</div>
174 <div class="node_content node_info_group"> 174 <div class="node_content node_info_group">
175 <details> 175 <details>
176 <summary> 176 <summary>
177 <%= pluralize(@node.pages.count, 'revision', 'revisions') %> 177 <%= pluralize(@node.pages.count, 'published revision', 'published revisions') %>
178 </summary> 178 </summary>
179 <ul> 179 <ul>
180 <% @node.pages.order(:revision).each do |page| %> 180 <% @node.pages.order(:revision).each do |page| %>
@@ -182,7 +182,7 @@
182 <% end %> 182 <% end %>
183 </ul> 183 </ul>
184 </details> 184 </details>
185 <p class="revisions_full_history_link"><%= link_to 'Full history (diff / restore)', node_revisions_path(@node) %></p> 185 <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> 186 </div>
187 187
188 188
diff --git a/config/locales/de.yml b/config/locales/de.yml
index 74cd183..9de6aac 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -82,3 +82,31 @@ de:
82 page_gap: "&hellip;" 82 page_gap: "&hellip;"
83 container_aria_label: "Seitennavigation" 83 container_aria_label: "Seitennavigation"
84 page_aria_label: "Seite %{page}" 84 page_aria_label: "Seite %{page}"
85
86 node_actions:
87 heading: "Letzte Änderungen"
88 show_all: "Alle Einträge zeigen"
89 backfilled: "rekonstruiert"
90 show_changes: "Änderungen an Übersetzungen"
91 view_revision: "Diese Revision ansehen"
92 unknown: "%{actor} hat %{action} auf %{subject} aus"
93 publish: "%{actor} hat %{revision} von %{subject} veröffentlicht"
94 publish_rollback: "%{actor} hat %{subject} auf %{revision} zurückgesetzt"
95 publish_first: "%{actor} hat %{subject} zum ersten Mal veröffentlicht"
96 publish_first_with_author: "%{actor} hat %{subject} zum ersten Mal veröffentlicht (Autor: %{author})"
97 create: "%{actor} hat %{subject} unter %{path} angelegt"
98 move: "%{actor} hat %{subject} von %{from} nach %{to} verschoben"
99 discard_autosave: "%{actor} hat ungespeicherte Änderungen an %{subject} verschoben"
100 destroy_draft: "%{actor} hat den Entwurf von %{subject} verworfen"
101 locale_added: "Übersetzung angelegt, Titel \"%{title}\""
102 locale_removed: "Übersetzung entfernt, letzter Titel \"%{title}\""
103 abstract_changed: "Abstract geändert"
104 body_changed: "Text geändert"
105 revision_new: "eine neue Revision"
106 revision_earlier: "eine frühere Revision"
107 node_history: "Chronik"
108 detail_title: "Titel „%{from}“ → „%{to}“"
109 detail_author: "Autor %{from} → %{to}"
110 detail_tags: "Tags %{from} → %{to}"
111 template_changed: "Template geändert"
112 assets_changed: "Bildliste geändert"
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c52280d..f1856c1 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -34,3 +34,31 @@ en:
34 page_gap: "&hellip;" 34 page_gap: "&hellip;"
35 container_aria_label: "Pagination" 35 container_aria_label: "Pagination"
36 page_aria_label: "Page %{page}" 36 page_aria_label: "Page %{page}"
37
38 node_actions:
39 heading: "Action log"
40 show_all: "Show all entries"
41 backfilled: "backfilled"
42 show_changes: "translation changes"
43 view_revision: "view this revision"
44 unknown: "%{actor} did %{action} on %{subject}"
45 publish: "%{actor} published %{revision} of %{subject}"
46 publish_rollback: "%{actor} rolled %{subject} back to %{revision}"
47 publish_first: "%{actor} published %{subject} for the first time"
48 publish_first_with_author: "%{actor} published %{subject} for the first time (author: %{author})"
49 create: "%{actor} created %{subject} at %{path}"
50 move: "%{actor} moved %{subject} from %{from} to %{to}"
51 discard_autosave: "%{actor} discarded unsaved changes on %{subject}"
52 destroy_draft: "%{actor} discarded the draft of %{subject}"
53 locale_added: "translation added, titled \"%{title}\""
54 locale_removed: "translation removed, last titled \"%{title}\""
55 abstract_changed: "abstract changed"
56 body_changed: "body changed"
57 revision_new: "a new revision"
58 revision_earlier: "an earlier revision"
59 node_history: "node history"
60 detail_title: "title \"%{from}\" → \"%{to}\""
61 detail_author: "author %{from} → %{to}"
62 detail_tags: "tags %{from} → %{to}"
63 template_changed: "template changed"
64 assets_changed: "attached images changed"
diff --git a/config/routes.rb b/config/routes.rb
index 8e5ff23..c2b9590 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -86,6 +86,7 @@ Cccms::Application.routes.draw do
86 match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get 86 match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get
87 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get 87 match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get
88 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get 88 match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get
89 match 'log' => 'node_actions#index', :as => :admin_log, :via => :get
89 end 90 end
90 91
91 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete 92 match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js
index 9ce0b9e..3988016 100644
--- a/public/javascripts/admin_interface.js
+++ b/public/javascripts/admin_interface.js
@@ -415,8 +415,10 @@ rrule_builder = {
415 }); 415 });
416 $("#rrule_monthly_ordinal").bind("change", function() { 416 $("#rrule_monthly_ordinal").bind("change", function() {
417 $("#rrule_ordinal_fields").toggle($(this).is(":checked")); 417 $("#rrule_ordinal_fields").toggle($(this).is(":checked"));
418 rrule_builder.toggle_custom_ordinal_fields();
418 rrule_builder.sync(); 419 rrule_builder.sync();
419 }); 420 });
421 $("#rrule_ordinal").bind("change", rrule_builder.toggle_custom_ordinal_fields);
420 $("#rrule_exclude_month").bind("change", function() { 422 $("#rrule_exclude_month").bind("change", function() {
421 $("#rrule_excluded_month").toggle($(this).is(":checked")); 423 $("#rrule_excluded_month").toggle($(this).is(":checked"));
422 rrule_builder.sync(); 424 rrule_builder.sync();
@@ -433,6 +435,12 @@ rrule_builder = {
433 $("#rrule_monthly_options").show(); 435 $("#rrule_monthly_options").show();
434 }, 436 },
435 437
438 toggle_custom_ordinal_fields : function() {
439 $("#rrule_custom_ordinal_fields").toggle(
440 $("#rrule_monthly_ordinal").is(":checked") && $("#rrule_ordinal").val() === "custom"
441 );
442 },
443
436 sync : function() { 444 sync : function() {
437 var freq = $("input[name='rrule_freq']:checked").val(); 445 var freq = $("input[name='rrule_freq']:checked").val();
438 var parts = []; 446 var parts = [];
@@ -448,7 +456,16 @@ rrule_builder = {
448 } else { 456 } else {
449 parts.push("FREQ=MONTHLY"); 457 parts.push("FREQ=MONTHLY");
450 if ($("#rrule_monthly_ordinal").is(":checked")) { 458 if ($("#rrule_monthly_ordinal").is(":checked")) {
451 parts.push("BYDAY=" + $("#rrule_ordinal").val() + $("#rrule_ordinal_day").val()); 459 var ordinal = $("#rrule_ordinal").val();
460 if (ordinal === "custom") {
461 var customDays = [];
462 $("input[id^='rrule_custom_ordinal_']:checked").each(function() {
463 customDays.push($(this).attr("id").replace("rrule_custom_ordinal_", "") + $("#rrule_ordinal_day").val());
464 });
465 if (customDays.length > 0) parts.push("BYDAY=" + customDays.join(","));
466 } else {
467 parts.push("BYDAY=" + ordinal + $("#rrule_ordinal_day").val());
468 }
452 } 469 }
453 } 470 }
454 471
@@ -483,8 +500,33 @@ rrule_builder = {
483 } else if (parts.FREQ === "MONTHLY") { 500 } else if (parts.FREQ === "MONTHLY") {
484 $("input[name='rrule_freq'][value='monthly']").prop("checked", true); 501 $("input[name='rrule_freq'][value='monthly']").prop("checked", true);
485 rrule_builder.show_monthly_options(); 502 rrule_builder.show_monthly_options();
503 var ordinalDays = parts.BYDAY && parts.BYDAY.split(",");
504 var customOrdinalDay = null;
505 var customOrdinals = [];
506 var customOrdinalsValid = ordinalDays && ordinalDays.length > 0;
507 if (customOrdinalsValid) {
508 ordinalDays.forEach(function(value) {
509 var customMatch = value.match(/^([1-5])([A-Z]{2})$/);
510 if (!customMatch || (customOrdinalDay && customOrdinalDay !== customMatch[2])) {
511 customOrdinalsValid = false;
512 return;
513 }
514 customOrdinalDay = customMatch[2];
515 customOrdinals.push(customMatch[1]);
516 });
517 }
518
486 var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); 519 var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/);
487 if (match) { 520 if (customOrdinalsValid && (customOrdinals.length > 1 || customOrdinals[0] === "5")) {
521 $("#rrule_monthly_ordinal").prop("checked", true);
522 $("#rrule_ordinal_fields").show();
523 $("#rrule_ordinal").val("custom");
524 $("#rrule_ordinal_day").val(customOrdinalDay);
525 customOrdinals.forEach(function(ordinal) {
526 $("#rrule_custom_ordinal_" + ordinal).prop("checked", true);
527 });
528 rrule_builder.toggle_custom_ordinal_fields();
529 } else if (match) {
488 $("#rrule_monthly_ordinal").prop("checked", true); 530 $("#rrule_monthly_ordinal").prop("checked", true);
489 $("#rrule_ordinal_fields").show(); 531 $("#rrule_ordinal_fields").show();
490 $("#rrule_ordinal").val(match[1]); 532 $("#rrule_ordinal").val(match[1]);
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 5f0b165..c1273c6 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -1494,3 +1494,37 @@ div#image_browser ul li {
1494 gap: 8px; 1494 gap: 8px;
1495 flex-wrap: wrap; 1495 flex-wrap: wrap;
1496} 1496}
1497
1498/* ============================================================
1499 Action log
1500 ============================================================ */
1501
1502#node_action_list td.node_action_time {
1503 white-space: nowrap;
1504 vertical-align: top;
1505 font-family: monospace;
1506 font-size: 0.8em;
1507 color: #777;
1508 padding-right: 1em;
1509}
1510
1511#node_action_list td.node_action_body {
1512 vertical-align: top;
1513}
1514
1515.node_action_inferred {
1516 opacity: 0.5;
1517 font-size: smaller;
1518 margin-left: 0.5em;
1519}
1520
1521.node_action_zoom {
1522 font-size: smaller;
1523 margin-left: 0.5em;
1524}
1525
1526.node_action_details summary {
1527 cursor: pointer;
1528 font-size: smaller;
1529 color: #777;
1530}
diff --git a/test/controllers/node_actions_controller_test.rb b/test/controllers/node_actions_controller_test.rb
new file mode 100644
index 0000000..8bc68ec
--- /dev/null
+++ b/test/controllers/node_actions_controller_test.rb
@@ -0,0 +1,51 @@
1require 'test_helper'
2
3class NodeActionsControllerTest < ActionController::TestCase
4
5 def setup
6 login_as :quentin
7 end
8
9 def logged_node slug, title
10 node = Node.root.children.create!(:slug => slug)
11 Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => title) }
12 node
13 end
14
15 test "index renders" do
16 get :index
17 assert_response :success
18 end
19
20 test "index filters by node" do
21 node_a = logged_node("log_filter_a", "Alpha Subject")
22 node_b = logged_node("log_filter_b", "Beta Subject")
23 NodeAction.record!(:node => node_a, :action => "create", :user => users(:quentin))
24 NodeAction.record!(:node => node_b, :action => "create", :user => users(:quentin))
25
26 get :index, params: { :node_id => node_a.id }
27
28 assert_response :success
29 assert_includes @response.body, "Alpha Subject"
30 assert_not_includes @response.body, "Beta Subject"
31 end
32
33 test "index filters by user" do
34 node = logged_node("log_filter_user", "Gamma Subject")
35 NodeAction.record!(:node => node, :action => "create", :user => users(:quentin))
36
37 get :index, params: { :user_id => users(:quentin).id }
38 assert_includes @response.body, "Gamma Subject"
39
40 other = User.where.not(:id => users(:quentin).id).first
41 get :index, params: { :user_id => other.id }
42 assert_not_includes @response.body, "Gamma Subject"
43 end
44
45 test "index requires login" do
46 session[:user_id] = nil
47 @controller.instance_variable_set(:@current_user, nil)
48 get :index
49 assert_response :redirect
50 end
51end
diff --git a/test/models/concerns/rrule_humanizer_test.rb b/test/models/concerns/rrule_humanizer_test.rb
index 279ff73..54c4c22 100644
--- a/test/models/concerns/rrule_humanizer_test.rb
+++ b/test/models/concerns/rrule_humanizer_test.rb
@@ -47,6 +47,24 @@ class RruleHumanizerTest < ActiveSupport::TestCase
47 assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en) 47 assert_equal "Every second-to-last Thursday of the month", humanize("FREQ=MONTHLY;BYDAY=-2TH", :en)
48 end 48 end
49 49
50 test "monthly selected weeks" do
51 assert_equal "Jeden ersten, zweiten und fünften Dienstag im Monat",
52 humanize("FREQ=MONTHLY;BYDAY=1TU,2TU,5TU")
53 assert_equal "Every first, second and fifth Tuesday of the month",
54 humanize("FREQ=MONTHLY;BYDAY=1TU,2TU,5TU", :en)
55 end
56
57 test "monthly fifth weekday" do
58 assert_equal "Jeden fünften Dienstag im Monat", humanize("FREQ=MONTHLY;BYDAY=5TU")
59 assert_equal "Every fifth Tuesday of the month", humanize("FREQ=MONTHLY;BYDAY=5TU", :en)
60 end
61
62 test "monthly weekday excluding one ordinal" do
63 rrule = "FREQ=MONTHLY;BYDAY=1TU,3TU,4TU,5TU"
64 assert_equal "Jeden Dienstag im Monat, außer dem zweiten Dienstag", humanize(rrule)
65 assert_equal "Every Tuesday of the month, except the second Tuesday", humanize(rrule, :en)
66 end
67
50 test "monthly no byday" do 68 test "monthly no byday" do
51 assert_equal "Monatlich", humanize("FREQ=MONTHLY") 69 assert_equal "Monatlich", humanize("FREQ=MONTHLY")
52 assert_equal "Monthly", humanize("FREQ=MONTHLY", :en) 70 assert_equal "Monthly", humanize("FREQ=MONTHLY", :en)
diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb
new file mode 100644
index 0000000..5244c19
--- /dev/null
+++ b/test/models/helpers/node_actions_helper_test.rb
@@ -0,0 +1,107 @@
1require 'test_helper'
2
3class NodeActionsHelperTest < ActionView::TestCase
4 def setup
5 @original_locale = I18n.locale
6 I18n.locale = :en
7 end
8
9 def default_url_options
10 { :locale => nil }
11 end
12
13 def teardown
14 I18n.locale = @original_locale
15 end
16
17 def entry action, metadata = {}, node: nil, user: nil, page: nil
18 NodeAction.create!(:node => node, :user => user, :page => page,
19 :action => action, :occurred_at => Time.now,
20 :metadata => { "username" => "quentin",
21 "human_readable_node_name" => "Subject" }.merge(metadata))
22 end
23
24 test "publish renders the revision sentence, not the titles" do
25 out = action_summary(entry("publish",
26 { "via" => "draft", "title" => { "from" => "Old", "to" => "New" } }))
27
28 assert_includes out, "quentin"
29 assert_includes out, "new revision"
30 assert_not_includes out, "Old"
31 end
32
33 test "first publish uses its own sentence" do
34 out = action_summary(entry("publish",
35 { "via" => "draft", "title" => { "from" => nil, "to" => "New" } }))
36
37 assert_includes out, "for the first time"
38 end
39
40 test "rollback publishes get the rollback sentence" do
41 out = action_summary(entry("publish",
42 { "via" => "revision", "title" => { "from" => "Now", "to" => "Then" } }))
43
44 assert_includes out, "rolled"
45 end
46
47 test "move renders the path pair" do
48 out = action_summary(entry("move",
49 { "path" => { "from" => "a/b", "to" => "a/c" } }))
50
51 assert_includes out, "a/b"
52 assert_includes out, "a/c"
53 end
54
55 test "unknown verbs degrade to a generic sentence, never an error" do
56 out = action_summary(entry("frobnicate"))
57
58 assert_includes out, "frobnicate"
59 assert_includes out, "quentin"
60 end
61
62 test "dead references render as plain names from metadata, no links" do
63 out = action_summary(entry("publish",
64 { "title" => { "from" => "Old", "to" => "New" } }))
65
66 assert_not_includes out, "<a "
67 assert_includes out, "Subject"
68 end
69
70 test "metadata values are escaped" do
71 out = action_summary(entry("publish",
72 { "human_readable_node_name" => "<b>bold</b>" }))
73
74 assert_not_includes out, "<b>"
75 end
76
77 test "live associations upgrade names to links" do
78 out = action_summary(entry("publish",
79 { "title" => { "from" => "Old", "to" => "New" } },
80 :user => users(:quentin)))
81
82 assert_includes out, "<a "
83 end
84
85 test "details are guarded off when nothing but an unchanged title is present" do
86 unchanged = entry("publish", { "title" => { "from" => "Same", "to" => "Same" } })
87 changed = entry("publish", { "title" => { "from" => "Old", "to" => "New" } })
88
89 assert_not action_details?(unchanged)
90 assert action_details?(changed)
91 end
92
93 test "first publish with a byline names the author" do
94 out = action_summary(entry("publish",
95 { "title" => { "from" => nil, "to" => "New" },
96 "author" => { "from" => nil, "to" => "quentin" } }))
97
98 assert_includes out, "author"
99 end
100
101 test "create entries with their flat title render and are guarded off details" do
102 e = entry("create", { "title" => "Initial", "path" => "a/b" })
103
104 assert_includes action_summary(e), "a/b"
105 assert_not action_details?(e)
106 end
107end
diff --git a/test/models/node_action_test.rb b/test/models/node_action_test.rb
index 24d8245..b177cca 100644
--- a/test/models/node_action_test.rb
+++ b/test/models/node_action_test.rb
@@ -18,6 +18,12 @@ class NodeActionTest < ActiveSupport::TestCase
18 assert_equal({ :title => { "from" => nil, "to" => "Erstausgabe" } }, diff) 18 assert_equal({ :title => { "from" => nil, "to" => "Erstausgabe" } }, diff)
19 end 19 end
20 20
21 test "first publish carries the byline when the page has one" do
22 diff = NodeAction.head_diff(nil, build_page(:user => users(:quentin)))
23
24 assert_equal({ "from" => nil, "to" => users(:quentin).login }, diff[:author])
25 end
26
21 test "title pair is always present, even when unchanged" do 27 test "title pair is always present, even when unchanged" do
22 diff = NodeAction.head_diff(build_page, build_page) 28 diff = NodeAction.head_diff(build_page, build_page)
23 29
@@ -109,4 +115,14 @@ class NodeActionTest < ActiveSupport::TestCase
109 115
110 assert_nil NodeAction.head_diff(old_page, new_page)[:translation_diff] 116 assert_nil NodeAction.head_diff(old_page, new_page)[:translation_diff]
111 end 117 end
118
119 test "record! passes provenance and historical timestamps through" do
120 long_ago = 2.years.ago
121 action = NodeAction.record!(:node => nil, :action => "publish",
122 :occurred_at => long_ago,
123 :inferred_from => "from_page_revision")
124
125 assert_equal "from_page_revision", action.inferred_from
126 assert_in_delta long_ago.to_f, action.occurred_at.to_f, 1.0
127 end
112end 128end