summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin_controller.rb9
-rw-r--r--app/controllers/content_controller.rb2
-rw-r--r--app/controllers/csp_reports_controller.rb22
-rw-r--r--app/controllers/node_actions_controller.rb14
-rw-r--r--app/controllers/nodes_controller.rb61
-rw-r--r--app/controllers/related_assets_controller.rb2
-rw-r--r--app/controllers/revisions_controller.rb2
-rw-r--r--app/helpers/admin_helper.rb6
-rw-r--r--app/helpers/application_helper.rb14
-rw-r--r--app/helpers/node_actions_helper.rb183
-rw-r--r--app/models/concerns/file_attachment.rb9
-rw-r--r--app/models/concerns/nested_tree.rb8
-rw-r--r--app/models/concerns/rrule_humanizer.rb49
-rw-r--r--app/models/node.rb348
-rw-r--r--app/models/node_action.rb169
-rw-r--r--app/models/page.rb17
-rw-r--r--app/models/user.rb31
-rw-r--r--app/views/admin/_recent_changes.html.erb24
-rw-r--r--app/views/admin/index.html.erb23
-rw-r--r--app/views/custom/page_templates/public/chapter_detail.html.erb1
-rw-r--r--app/views/custom/partials/_chapter.html.erb37
-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/layouts/admin.html.erb5
-rw-r--r--app/views/layouts/application.html.erb18
-rw-r--r--app/views/node_actions/_action_row.html.erb18
-rw-r--r--app/views/node_actions/_change_details.html.erb27
-rw-r--r--app/views/node_actions/index.html.erb13
-rw-r--r--app/views/nodes/_recent_change_item.html.erb9
-rw-r--r--app/views/nodes/destroy.html.erb2
-rw-r--r--app/views/nodes/edit.html.erb5
-rw-r--r--app/views/nodes/new.html.erb4
-rw-r--r--app/views/nodes/recent.html.erb6
-rw-r--r--app/views/nodes/show.html.erb64
-rw-r--r--app/views/nodes/trashed.html.erb42
-rw-r--r--app/views/page_translations/edit.html.erb9
37 files changed, 1086 insertions, 233 deletions
diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 37fd78b..d9cf1be 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
@@ -71,4 +71,11 @@ class AdminController < ApplicationController
71 end 71 end
72 end 72 end
73 end 73 end
74
75 # Deliberately raises, to verify the error-log tripwire end to end.
76 # Behind login_required like the rest of the controller; harmless --
77 # the visitor gets the ordinary 500 page.
78 def boom
79 raise "Deliberate test exception via admin/boom"
80 end
74end 81end
diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb
index 8d33105..8be4bfb 100644
--- a/app/controllers/content_controller.rb
+++ b/app/controllers/content_controller.rb
@@ -31,7 +31,7 @@ class ContentController < ApplicationController
31 def render_gallery 31 def render_gallery
32 unless @page.nil? 32 unless @page.nil?
33 @images = @page.assets.images 33 @images = @page.assets.images
34 render :file => "content/gallery" 34 render :template => 'content/gallery'
35 else 35 else
36 head :not_found 36 head :not_found
37 end 37 end
diff --git a/app/controllers/csp_reports_controller.rb b/app/controllers/csp_reports_controller.rb
new file mode 100644
index 0000000..a8f8edb
--- /dev/null
+++ b/app/controllers/csp_reports_controller.rb
@@ -0,0 +1,22 @@
1class 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 request.body.rewind if request.body.respond_to?(:rewind)
7 raw = request.body.read(8192)
8 raw = request.raw_post if raw.blank?
9
10 report = (JSON.parse(raw)["csp-report"] rescue nil)
11
12 if report
13 directive = report["effective-directive"] || report["violated-directive"]
14 at = (URI.parse(report["document-uri"]).path rescue "unparsed")
15 Rails.logger.warn("CSP violation: #{directive} blocked=#{report['blocked-uri']} at=#{at}")
16 else
17 Rails.logger.warn("CSP violation: unparseable report (#{raw.to_s.bytesize} bytes)")
18 end
19
20 head :no_content
21 end
22end
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 bff1733..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,10 +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_name = Node.find(@parent_id).title
33 end
34 end 33 end
35 34
36 def create 35 def create
@@ -48,14 +47,14 @@ class NodesController < ApplicationController
48 @node.draft.save! 47 @node.draft.save!
49 48
50 @node.update!(default_template_name: config[:template]) if config && config[:template] 49 @node.update!(default_template_name: config[:template]) if config && config[:template]
50 NodeAction.record!(:node => @node, :page => @node.draft, :user => current_user,
51 :action => "create",
52 :title => params[:title], :path => @node.unique_name)
51 53
52 redirect_to(edit_node_path(@node)) 54 redirect_to(edit_node_path(@node))
53 else 55 else
54 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" 56 @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic"
55 if params[:parent_id].present? 57 @parent = Node.find(params[:parent_id]) if params.has_key?(:parent_id)
56 @parent_id = params[:parent_id]
57 @parent_name = Node.find(@parent_id).title
58 end
59 render :new 58 render :new
60 end 59 end
61 end 60 end
@@ -63,6 +62,9 @@ class NodesController < ApplicationController
63 def show 62 def show
64 @page = @node.draft || @node.head 63 @page = @node.draft || @node.head
65 @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)
66 end 68 end
67 69
68 def edit 70 def edit
@@ -142,12 +144,44 @@ class NodesController < ApplicationController
142 redirect_to node_path(@node) 144 redirect_to node_path(@node)
143 end 145 end
144 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
145 def destroy 173 def destroy
146 @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)
147 end 181 end
148 182
149 def publish 183 def publish
150 @node.publish_draft! 184 @node.publish_draft!(current_user)
151 flash[:notice] = "Draft has been published" 185 flash[:notice] = "Draft has been published"
152 redirect_to node_path(@node) 186 redirect_to node_path(@node)
153 end 187 end
@@ -217,6 +251,11 @@ class NodesController < ApplicationController
217 @sitemap_descendant_counts = descendant_counts_for(@sitemap) 251 @sitemap_descendant_counts = descendant_counts_for(@sitemap)
218 end 252 end
219 253
254 def trashed
255 @nodes = Node.trash.children.order(:slug)
256 .paginate(:page => params[:page], :per_page => 50)
257 end
258
220 private 259 private
221 260
222 def slug_for(title) 261 def slug_for(title)
diff --git a/app/controllers/related_assets_controller.rb b/app/controllers/related_assets_controller.rb
index 479ebec..5af73fc 100644
--- a/app/controllers/related_assets_controller.rb
+++ b/app/controllers/related_assets_controller.rb
@@ -27,6 +27,8 @@ class RelatedAssetsController < ApplicationController
27 asset_id: asset.id, 27 asset_id: asset.id,
28 name: asset.name, 28 name: asset.name,
29 thumb_url: asset.upload.url(:thumb), 29 thumb_url: asset.upload.url(:thumb),
30 large_url: asset.upload.url(:large),
31 original_url: asset.upload.url,
30 url: node_related_asset_path(@node, related) 32 url: node_related_asset_path(@node, related)
31 } 33 }
32 end 34 end
diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb
index c5932a4..c1237a9 100644
--- a/app/controllers/revisions_controller.rb
+++ b/app/controllers/revisions_controller.rb
@@ -49,7 +49,7 @@ class RevisionsController < ApplicationController
49 49
50 def restore 50 def restore
51 page = Page.find(params[:id]) 51 page = Page.find(params[:id])
52 page.node.restore_revision! page.revision 52 page.node.restore_revision! page.revision, current_user
53 flash[:notice] = "Revision #{page.revision} restored" 53 flash[:notice] = "Revision #{page.revision} restored"
54 redirect_to node_path(page.node) 54 redirect_to node_path(page.node)
55 end 55 end
diff --git a/app/helpers/admin_helper.rb b/app/helpers/admin_helper.rb
index 6c3cbc6..2684fff 100644
--- a/app/helpers/admin_helper.rb
+++ b/app/helpers/admin_helper.rb
@@ -7,10 +7,4 @@ module AdminHelper
7 link_to raw('<span class="inactive">Deutsch</span>'), url_for(params.permit(:locale, :page_path).to_h.merge('locale' => 'de')) 7 link_to raw('<span class="inactive">Deutsch</span>'), url_for(params.permit(:locale, :page_path).to_h.merge('locale' => 'de'))
8 end 8 end
9 end 9 end
10
11 def mtime_busted_path(path)
12 file = Rails.public_path.join(path.sub(%r{\A/}, ""))
13 raise "Static asset not found for cache-busting: #{path} (looked for #{file})" unless File.exist?(file)
14 "#{path}?v=#{File.mtime(file).to_i}"
15 end
16end 10end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 87aa82c..0d28661 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -15,4 +15,18 @@ module ApplicationHelper
15 def resolve_kind_text(value) 15 def resolve_kind_text(value)
16 value.respond_to?(:call) ? value.call : value 16 value.respond_to?(:call) ? value.call : value
17 end 17 end
18
19 # Cache-busts a static file living directly under public/, outside the
20 # asset pipeline -- used by both the admin and public layouts, since
21 # neither loads its static CSS/JS through Sprockets (admin_bundle.js is
22 # the one exception; it already gets pipeline fingerprinting for free).
23 # Raises rather than silently omitting the version param: a missing
24 # file here means the page is already broken, and failing loudly beats
25 # a confusing "why is the browser still showing the old version" report
26 # days later.
27 def mtime_busted_path(path)
28 file = Rails.public_path.join(path.sub(%r{\A/}, ""))
29 raise "Static asset not found for cache-busting: #{path} (looked for #{file})" unless File.exist?(file)
30 "#{path}?v=#{File.mtime(file).to_i}"
31 end
18end 32end
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 @@
1module 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
183end
diff --git a/app/models/concerns/file_attachment.rb b/app/models/concerns/file_attachment.rb
index 0e99fa2..613a6f0 100644
--- a/app/models/concerns/file_attachment.rb
+++ b/app/models/concerns/file_attachment.rb
@@ -23,9 +23,10 @@ module FileAttachment
23 extend ActiveSupport::Concern 23 extend ActiveSupport::Concern
24 24
25 STYLES = { 25 STYLES = {
26 medium: { geometry: "300x300>", format: nil }, 26 medium: { args: ["-resize", "300x300>"] },
27 thumb: { geometry: "100x100>", format: nil }, 27 thumb: { args: ["-resize", "100x100>"] },
28 headline: { geometry: "460x250!", format: nil } 28 headline: { args: ["-resize", "460x250^", "-gravity", "center", "-extent", "460x250"] },
29 large: { args: ["-resize", "1600x1600>"] }
29 }.freeze 30 }.freeze
30 31
31 IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze 32 IMAGE_CONTENT_TYPES = %w[image/jpeg image/gif image/png image/webp].freeze
@@ -80,7 +81,7 @@ module FileAttachment
80 STYLES.each do |style, options| 81 STYLES.each do |style, options|
81 dest_path = file_path(style) 82 dest_path = file_path(style)
82 FileUtils.mkdir_p(File.dirname(dest_path)) 83 FileUtils.mkdir_p(File.dirname(dest_path))
83 system("magick", original_path, "-resize", options[:geometry], dest_path) 84 system("magick", original_path, *options[:args], dest_path)
84 end 85 end
85 end 86 end
86 87
diff --git a/app/models/concerns/nested_tree.rb b/app/models/concerns/nested_tree.rb
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
86end 78end
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 73eefd1..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
@@ -145,28 +179,6 @@ class Node < ApplicationRecord
145 pairs 179 pairs
146 end 180 end
147 181
148 def find_or_create_draft current_user
149 self.wipe_draft!
150 if draft && self.lock_owner == current_user
151 draft
152 elsif draft && self.lock_owner.nil?
153 lock_for! current_user
154 draft.user = current_user if draft.user.nil?
155 draft.editor = current_user
156 draft.save
157 draft
158 elsif draft && self.lock_owner != current_user
159 raise(
160 LockedByAnotherUser,
161 "Page is locked by another user who is working on it! " \
162 "Last modification: #{draft.updated_at.to_fs(:db)}"
163 )
164 else
165 lock_for! current_user
166 create_new_draft current_user
167 end
168 end
169
170 def create_new_draft user 182 def create_new_draft user
171 empty_page = self.pages.create! 183 empty_page = self.pages.create!
172 empty_page.user = (self.head ? self.head.user : user) 184 empty_page.user = (self.head ? self.head.user : user)
@@ -192,10 +204,12 @@ class Node < ApplicationRecord
192 self.autosave.destroy 204 self.autosave.destroy
193 self.autosave_id = nil 205 self.autosave_id = nil
194 self.save! 206 self.save!
207 NodeAction.record!(:node => self, :user => current_user, :action => "discard_autosave")
195 elsif self.draft && self.head 208 elsif self.draft && self.head
196 self.draft.destroy 209 self.draft.destroy
197 self.draft_id = nil 210 self.draft_id = nil
198 self.save! 211 self.save!
212 NodeAction.record!(:node => self, :user => current_user, :action => "destroy_draft")
199 end 213 end
200 214
201 self.unlock! unless self.draft 215 self.unlock! unless self.draft
@@ -210,81 +224,187 @@ class Node < ApplicationRecord
210 end 224 end
211 end 225 end
212 226
213 def publish_draft! 227 def publish_draft! current_user = nil
214 # Return nil if nothing to publish and no staged changes 228 # Return nil if nothing to publish and no staged changes
215 return nil unless self.draft || staged_slug || staged_parent_id 229 return nil unless self.draft || staged_slug || staged_parent_id
216 230
217 if self.draft 231 if in_trash? || trash_node?
218 self.head = self.draft 232 raise ActiveRecord::RecordInvalid.new(self), "Cannot publish a node in the Trash"
219 self.head.published_at ||= Time.now
220 self.head.save!
221 self.draft = nil
222 end 233 end
223 234
224 if staged_slug && (staged_slug != slug) 235 path_before = self.unique_name
225 self.slug = staged_slug
226 self.staged_slug = nil
227 end
228 236
229 if staged_parent_id && (staged_parent_id != parent_id) 237 ActiveRecord::Base.transaction do
230 new_parent = Node.find(staged_parent_id) 238 if self.draft
239 outgoing_head = self.head
240 self.head = self.draft
241 self.head.published_at ||= Time.now
242 self.head.save!
243 self.draft = nil
231 244
232 if new_parent == self || self.descendants.include?(new_parent) 245 NodeAction.record!(:node => self, :page => self.head, :user => current_user,
233 raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants" 246 :action => "publish", :via => "draft",
247 **NodeAction.head_diff(outgoing_head, self.head))
234 end 248 end
235 249
236 self.staged_parent_id = nil 250 if staged_slug && (staged_slug != slug)
237 self.save! 251 self.slug = staged_slug
238 self.move_to_child_of(new_parent) 252 self.staged_slug = nil
239 else
240 unless self.save
241 raise ActiveRecord::RecordInvalid.new(self)
242 end 253 end
243 end
244 254
245 self.reload 255 if staged_parent_id && (staged_parent_id != parent_id)
246 self.update_unique_name 256 new_parent = Node.find(staged_parent_id)
247 self.send(:update_unique_names_of_children)
248 self.unlock!
249 self
250 end
251 257
252 # Releases whatever's stale and abandoned; never anything actively in 258 if new_parent == self || self.descendants.include?(new_parent)
253 # use. Three independent cases share one rule -- nothing is touched 259 raise ActiveRecord::RecordInvalid.new(self), "Cannot move a node under itself or one of its own descendants"
254 # unless it's been sitting untouched for over a day: 260 end
255 # - a lock held with no draft or autosave at all (the editor opened
256 # the page and never actually wrote anything)
257 # - a fresh autosave older than a day, never promoted to a draft
258 # - a draft older than a day that's still identical to head
259 def wipe_draft!
260 return if self.autosave && self.autosave.updated_at > 1.day.ago
261 261
262 unless self.draft 262 self.staged_parent_id = nil
263 return if self.autosave.nil? && self.locked? && self.updated_at > 1.day.ago 263 self.save!
264 self.autosave&.destroy 264 self.move_to_child_of(new_parent)
265 self.autosave_id = nil 265 else
266 unless self.save
267 raise ActiveRecord::RecordInvalid.new(self)
268 end
269 end
270
271 self.reload
272 self.update_unique_name
273 self.send(:update_unique_names_of_children)
274 if self.unique_name != path_before
275 NodeAction.record!(:node => self, :user => current_user, :action => "move",
276 :path => { "from" => path_before, "to" => self.unique_name })
277 end
266 self.unlock! 278 self.unlock!
279 self
280 end
281 end
282
283 def restore_revision! revision, current_user = nil
284 page = self.pages.find_by_revision(revision)
285 return nil unless page
286
287 ActiveRecord::Base.transaction do
288 outgoing_head = self.head
289 self.head = page
267 self.save! 290 self.save!
268 return 291
292 NodeAction.record!(:node => self, :page => page, :user => current_user,
293 :action => "publish", :via => "revision",
294 **NodeAction.head_diff(outgoing_head, page))
295 self
269 end 296 end
270 return unless self.head 297 end
271 return unless self.draft.updated_at < 1.day.ago
272 return if self.head.has_changes_to? self.draft
273 298
274 self.draft.destroy 299
275 self.draft_id = nil 300 # Moves this node and its subtree into the Trash. Demotes every head
276 self.unlock! 301 # in the subtree first (aggregators and search operate on heads
277 self.save! 302 # regardless of tree position); where a node has no draft, the former
278 self.reload 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
279 end 340 end
280 341
281 def restore_revision! revision 342 # Returns the node to the living tree under a chosen parent. The
282 if page = self.pages.find_by_revision(revision) 343 # subtree comes back exactly as it sits in the Trash: all drafts,
283 self.head = page 344 # nothing published. Republication is a separate, witnessed act
284 self.save 345 # per node.
285 else 346 def restore_from_trash! new_parent, current_user = nil
286 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"
287 end 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?
288 end 408 end
289 409
290 # 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
@@ -297,9 +417,12 @@ class Node < ApplicationRecord
297 parent.nil? ? [slug] : parent.path_to_root.push(slug) 417 parent.nil? ? [slug] : parent.path_to_root.push(slug)
298 end 418 end
299 419
420 def computed_unique_name
421 path_to_root[1..-1].join("/") # excluding root
422 end
423
300 def current_unique_name 424 def current_unique_name
301 path = path_to_root[1..-1] # excluding root 425 self.unique_name = computed_unique_name
302 self.unique_name = path.join("/")
303 end 426 end
304 427
305 def update_unique_name 428 def update_unique_name
@@ -339,6 +462,20 @@ class Node < ApplicationRecord
339 autosave || draft || head 462 autosave || draft || head
340 end 463 end
341 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
342 # 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
343 # stay the same eventhough the slug or positions changes. 480 # stay the same eventhough the slug or positions changes.
344 # Can be removed after a year or so ;) 481 # Can be removed after a year or so ;)
@@ -379,7 +516,7 @@ class Node < ApplicationRecord
379 end 516 end
380 517
381 def self.drafts_and_autosaves(current_user_id: nil) 518 def self.drafts_and_autosaves(current_user_id: nil)
382 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
383 return scope.order("updated_at DESC") unless current_user_id 520 return scope.order("updated_at DESC") unless current_user_id
384 521
385 scope.order( 522 scope.order(
@@ -387,6 +524,15 @@ class Node < ApplicationRecord
387 ) 524 )
388 end 525 end
389 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
390 def self.recently_changed 536 def self.recently_changed
391 includes(:head).where( 537 includes(:head).where(
392 "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",
@@ -443,4 +589,36 @@ class Node < ApplicationRecord
443 end 589 end
444 end 590 end
445 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
446end 624end
diff --git a/app/models/node_action.rb b/app/models/node_action.rb
new file mode 100644
index 0000000..13bd5ba
--- /dev/null
+++ b/app/models/node_action.rb
@@ -0,0 +1,169 @@
1class NodeAction < ApplicationRecord
2 belongs_to :node, optional: true
3 belongs_to :page, optional: true
4 belongs_to :user, optional: true
5
6 validates :action, presence: true
7 validates :occurred_at, presence: true
8
9 # == Metadata contract ==
10 #
11 # metadata is written once at creation, never updated. It is the
12 # single place anything that must survive deletion of the referenced
13 # rows lives; node_id/page_id/user_id are lookup and ordering only.
14 # All keys are strings. Pairs are always {"from" => x, "to" => y}.
15 # Optional pairs only when the sides differ; booleans only when
16 # true; required keys always present. Titles and names are read
17 # pinned to I18n.default_locale unless inside a locale-keyed block.
18 #
19 # Baseline, every entry (written here, not by call sites):
20 # "username" -- actor's login at write time
21 # "human_readable_node_name" -- node title, default locale
22 #
23 # "create":
24 # "title" -- initial title, flat string (NOT a pair)
25 # "path" -- unique path at creation, flat string. Historical
26 # value only; never a join key.
27 #
28 # "publish" (any promotion to head; diff computed BEFORE head is
29 # re-pointed, over the union of both pages' locales, by head_diff --
30 # shared with the backfill):
31 # "via" -- "draft" | "revision" (rollback). Always written;
32 # absent means a pre-contract entry.
33 # "title" -- pair, always; "from" null on first publish
34 # "author" -- pair, when the byline changed (incl. first publish)
35 # "tags" -- pair of arrays, when changed
36 # "assets_changed", "template_changed",
37 # "abstract_changed", "body_changed"
38 # -- the last two for the default locale; page_id links
39 # to the revision for the real diff (never stored)
40 # "translation_diff" -- only when a non-default locale differs:
41 # { "<locale>" => {
42 # "status" -- "added" | "removed" | "changed"
43 # "title" -- pair, only when it differs; "from" null when
44 # added, "to" null when removed
45 # "abstract_changed", "body_changed" -- status "changed" only
46 # } }
47 #
48 # "move" (reparent and/or path change; one entry at the subtree
49 # root, descendants get none):
50 # "path" -- pair
51 #
52 # "trash" (subtree into the Trash; every head in the subtree is
53 # demoted first; one entry at the root; snapshots the
54 # leaving-public-view state, since Trash holds no heads and destroy
55 # can no longer know):
56 # "path" -- pair; "from" doubles as the restore hint
57 # "was_published" -- boolean
58 # "demoted_heads" -- integer count, only when positive
59 # "final_published_at" -- ISO8601 string, only when present
60 #
61 # "restore_from_trash" (reparent back to a living node; returns as
62 # drafts, republication is a separate witnessed act):
63 # "path" -- pair
64 #
65 # "destroy" (only from inside the Trash, never with children; the
66 # entry is written in the same transaction before the row dies):
67 # "path" -- final path, flat string (create-symmetric)
68 # "destroyed_descendants" -- integer, only when positive; one entry
69 # at the root, per the subtree rule.
70 #
71 # Reserved: "demote" (via "trash" | "depublish") for an explicit
72 # depublish workflow, if ever built.
73 #
74 # Backfilled entries mirror this vocabulary; diff content is
75 # computed, only actor (page.editor) and occurred_at are inferred,
76 # inferred_from names the heuristic ("from_node_created_at",
77 # "from_page_revision"). Null = witnessed live.
78 #
79 # The "locale" column is written by no verb; retained.
80 #
81 # This log records; it does not undo. No IP, session, or user
82 # agent, ever. Success only.
83
84 def self.record!(node:, action:, user: nil, page: nil, locale: nil,
85 occurred_at: nil, inferred_from: nil, **extra)
86 create!(
87 :node => node,
88 :page => page,
89 :user => user,
90 :action => action,
91 :locale => locale,
92 :occurred_at => occurred_at || Time.now,
93 :inferred_from => inferred_from,
94 :metadata => {
95 "username" => user&.login,
96 "human_readable_node_name" => Globalize.with_locale(I18n.default_locale) {
97 node&.head&.title || node&.draft&.title
98 },
99 }.merge(extra.stringify_keys)
100 )
101 end
102
103 # Computes the publish-entry diff between an outgoing head and the
104 # page replacing it, in the exact metadata shape the contract above
105 # specifies. Pure function of its two arguments -- shared verbatim by
106 # publish_draft!, restore_revision!, and the backfill task. Reads
107 # translation rows directly, never locale-dependent accessors, so a
108 # fallback value is never mistaken for real content. Returns
109 # symbol-keyed top level for splatting into record!; nested keys are
110 # strings and jsonb serialization stringifies the rest at write time.
111 def self.head_diff old_page, new_page
112 default = I18n.default_locale
113
114 title_of = ->(page) { page&.translations&.find_by(:locale => default)&.title }
115 diff = { :title => { "from" => title_of.call(old_page),
116 "to" => title_of.call(new_page) } }
117 unless old_page
118 diff[:author] = { "from" => nil, "to" => new_page.user&.login } if new_page.user
119 return diff
120 end
121
122 old_author, new_author = old_page.user&.login, new_page.user&.login
123 diff[:author] = { "from" => old_author, "to" => new_author } if old_author != new_author
124
125 old_tags, new_tags = old_page.tag_list.sort, new_page.tag_list.sort
126 diff[:tags] = { "from" => old_tags, "to" => new_tags } if old_tags != new_tags
127
128 diff[:template_changed] = true if old_page.template_name != new_page.template_name
129 diff[:assets_changed] = true if old_page.assets.map(&:id) != new_page.assets.map(&:id)
130
131 old_t = old_page.translations.find_by(:locale => default)
132 new_t = new_page.translations.find_by(:locale => default)
133 diff[:abstract_changed] = true if old_t&.abstract != new_t&.abstract
134 diff[:body_changed] = true if old_t&.body != new_t&.body
135
136 locales = (old_page.translated_locales | new_page.translated_locales) - [default]
137 translation_diff = {}
138
139 locales.sort_by(&:to_s).each do |locale|
140 o = old_page.translations.find_by(:locale => locale)
141 n = new_page.translations.find_by(:locale => locale)
142
143 if o.nil?
144 translation_diff[locale.to_s] = { "status" => "added",
145 "title" => { "from" => nil, "to" => n.title } }
146 elsif n.nil?
147 translation_diff[locale.to_s] = { "status" => "removed",
148 "title" => { "from" => o.title, "to" => nil } }
149 elsif o.title != n.title || o.abstract != n.abstract || o.body != n.body
150 entry = { "status" => "changed" }
151 entry["title"] = { "from" => o.title, "to" => n.title } if o.title != n.title
152 entry["abstract_changed"] = true if o.abstract != n.abstract
153 entry["body_changed"] = true if o.body != n.body
154 translation_diff[locale.to_s] = entry
155 end
156 end
157
158 diff[:translation_diff] = translation_diff if translation_diff.any?
159 diff
160 end
161
162 def actor_name
163 metadata["username"] || "unknown"
164 end
165
166 def subject_name
167 metadata["human_readable_node_name"] || node&.unique_name || "deleted node"
168 end
169end
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 @@
1require 'digest/sha1' 1require 'digest/sha1'
2 2
3class User < ApplicationRecord 3class 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/_recent_changes.html.erb b/app/views/admin/_recent_changes.html.erb
deleted file mode 100644
index 88b5e93..0000000
--- a/app/views/admin/_recent_changes.html.erb
+++ /dev/null
@@ -1,24 +0,0 @@
1<h2>Recent Changes</h2>
2<div id="draft_list">
3 <table>
4 <tr class="header">
5 <th>Title</th>
6 <th>path</th>
7 <th>user</th>
8 <th>date</th>
9 <th></th>
10 </tr>
11 <% @recent_changes.each do |node| %>
12 <tr>
13 <td><%= truncated_title_for_node node %></td>
14 <td><%= node.unique_name %></td>
15 <td><%= node.draft.user.login rescue "" %></td>
16 <td><%= node.updated_at.to_fs(:db) %></td>
17 <td class="actions">
18 <%= link_to 'Show', node_path(node) %>
19 <%= link_to "Revisions", revision_path(:id => node.id) %>
20 </td>
21 </tr>
22 <% end %>
23 </table>
24</div> \ No newline at end of file
diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb
index bdb555e..319530d 100644
--- a/app/views/admin/index.html.erb
+++ b/app/views/admin/index.html.erb
@@ -42,21 +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 <% @recent_changes.each do |node| %> 46 <%= render partial: "node_actions/action_row", collection: @actions, as: :action %>
47 <li> 47 </table>
48 <div> 48 <%= link_to "See all recent changes →", admin_log_path %>
49 <%= link_to title_for_node(node), node_path(node) %>
50 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
51 </div>
52 <span class="dashboard_widget_meta">
53 <%= (editor = node_head_editor(node)) ? t("published_by", editor: editor) : t("published") %>, <%= relative_time_phrase(node.head.updated_at) %>
54 </span>
55 </li>
56 <% end %>
57 </ul>
58 <%= link_to "See all recent changes →", recent_nodes_path %>
59 </div> 49 </div>
50
60</div> 51</div>
61 52
62<div id="dashboard_housekeeping"> 53<div id="dashboard_housekeeping">
@@ -77,5 +68,9 @@
77 <%= link_to menu_items_path, class: "action_button" do %> 68 <%= link_to menu_items_path, class: "action_button" do %>
78 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation 69 <%= icon("menu-2", library: "tabler", "aria-hidden": true) %> Navigation
79 <% 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 %>
80 </div> 75 </div>
81</div> 76</div>
diff --git a/app/views/custom/page_templates/public/chapter_detail.html.erb b/app/views/custom/page_templates/public/chapter_detail.html.erb
index f47353c..c4c2598 100644
--- a/app/views/custom/page_templates/public/chapter_detail.html.erb
+++ b/app/views/custom/page_templates/public/chapter_detail.html.erb
@@ -1,5 +1,6 @@
1<div class="article" lang="<%= @page.effective_lang %>"> 1<div class="article" lang="<%= @page.effective_lang %>">
2 <h2 class="headline"><%= @page.title %></h2> 2 <h2 class="headline"><%= @page.title %></h2>
3 <%= headline_image %>
3 <% if @page.node.external_url.present? %> 4 <% if @page.node.external_url.present? %>
4 <div class="chapter_url"><%= link_to @page.node.external_url, @page.node.external_url, target: '_blank', rel: 'noopener' %></div> 5 <div class="chapter_url"><%= link_to @page.node.external_url, @page.node.external_url, target: '_blank', rel: 'noopener' %></div>
5 <% end %> 6 <% end %>
diff --git a/app/views/custom/partials/_chapter.html.erb b/app/views/custom/partials/_chapter.html.erb
index 805559b..47f3950 100644
--- a/app/views/custom/partials/_chapter.html.erb
+++ b/app/views/custom/partials/_chapter.html.erb
@@ -1,19 +1,26 @@
1<div class="article_partial chapter_partial" lang="<%= page.effective_lang %>"> 1<div class="article_partial chapter_partial" lang="<%= page.effective_lang %>">
2 <h2 class="headline"><%= link_to_path page.title, page.node.unique_name %></h2> 2 <div class="chapter_partial_layout">
3 <% if page.abstract.present? %> 3 <% if page.assets.images.any? %>
4 <div class="chapter_location"><%= page.abstract %></div> 4 <%= link_to_path image_tag(page.assets.images.first.upload.url(:thumb), :alt => ""), page.node.unique_name, class: "chapter_thumbnail" %>
5 <% end %> 5 <% end %>
6 <% if page.node.external_url.present? %> 6 <div class="chapter_partial_content">
7 <div class="chapter_url"><%= link_to page.node.external_url, page.node.external_url, target: '_blank', rel: 'noopener' %></div> 7 <h2 class="headline"><%= link_to_path page.title, page.node.unique_name %></h2>
8 <% end %> 8 <% if page.abstract.present? %>
9 <% open_days = page.node.events.tagged_with('open-day').order(:start_time) %> 9 <div class="chapter_location"><%= page.abstract %></div>
10 <% if open_days.any? %>
11 <div class="chapter_open_days">
12 <span class="open_days_label"><%= t(:open_days_label) %>: </span>
13 <% open_days.each do |event| %>
14 <span class="event_schedule"><%= event_schedule_text(event) %></span>
15 <% end %> 10 <% end %>
11 <% if page.node.external_url.present? %>
12 <div class="chapter_url"><%= link_to page.node.external_url, page.node.external_url, target: '_blank', rel: 'noopener' %></div>
13 <% end %>
14 <% open_days = page.node.events.tagged_with('open-day').order(:start_time) %>
15 <% if open_days.any? %>
16 <div class="chapter_open_days">
17 <span class="open_days_label"><%= t(:open_days_label) %>: </span>
18 <% open_days.each do |event| %>
19 <span class="event_schedule"><%= event_schedule_text(event) %></span>
20 <% end %>
21 </div>
22 <% end %>
23 <p class="excerpt"><%= sanitize page.body %></p>
16 </div> 24 </div>
17 <% end %> 25 </div>
18 <p class="excerpt"><%= sanitize page.body %></p>
19</div> 26</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 57d12e6..ca867ab 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -10,20 +10,20 @@
10 <meta property="og:description" content="Der Chaos Computer Club ist eine galaktische Gemeinschaft von Lebewesen für Informationsfreiheit und Technikfolgenabschätzung." /> 10 <meta property="og:description" content="Der Chaos Computer Club ist eine galaktische Gemeinschaft von Lebewesen für Informationsfreiheit und Technikfolgenabschätzung." />
11 11
12 <title><%= page_title %></title> 12 <title><%= page_title %></title>
13 <link rel="stylesheet" href="/stylesheets/ccc.css"> 13 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/ccc.css') %>">
14 <link rel="stylesheet" href="/stylesheets/glightbox.min.css"> 14 <link rel="stylesheet" href="<%= mtime_busted_path('/stylesheets/glightbox.min.css') %>">
15 <script src="/javascripts/glightbox.min.js"></script> 15 <script src="<%= mtime_busted_path('/javascripts/glightbox.min.js') %>"></script>
16 <script src="/javascripts/public.js"></script> 16 <script src="<%= mtime_busted_path('/javascripts/public.js') %>"></script>
17 17
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..a9a3b8d
--- /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 <%= verb_icon(action) %>
5 <span class="node_action_clock"><%= action.occurred_at.strftime("%H:%M") %></span>
6 </td>
7 <td class="node_action_body">
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/_recent_change_item.html.erb b/app/views/nodes/_recent_change_item.html.erb
new file mode 100644
index 0000000..754a775
--- /dev/null
+++ b/app/views/nodes/_recent_change_item.html.erb
@@ -0,0 +1,9 @@
1<li>
2 <div>
3 <%= link_to title_for_node(node), node_path(node) %>
4 <span class="field_hint"><%= link_to_path("#{node.unique_name} ↗", node.unique_name) %></span>
5 </div>
6 <span class="dashboard_widget_meta">
7 <%= (editor = node_head_editor(node)) ? t("last_edited_by", editor: editor) : t("last_edited") %>, <%= relative_time_phrase(node.head.updated_at) %>
8 </span>
9</li>
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/edit.html.erb b/app/views/nodes/edit.html.erb
index 9506995..25f15c2 100644
--- a/app/views/nodes/edit.html.erb
+++ b/app/views/nodes/edit.html.erb
@@ -81,7 +81,10 @@
81 data-create-url="<%= node_related_assets_path(@node) %>"> 81 data-create-url="<%= node_related_assets_path(@node) %>">
82 <ul id="related_asset_list" class="thumbnail_list"> 82 <ul id="related_asset_list" class="thumbnail_list">
83 <% @page.related_assets.includes(:asset).each do |related| %> 83 <% @page.related_assets.includes(:asset).each do |related| %>
84 <li data-url="<%= node_related_asset_path(@node, related) %>"> 84 <li data-url="<%= node_related_asset_path(@node, related) %>"
85 data-large-url="<%= related.asset.upload.url(:large) %>"
86 data-original-url="<%= related.asset.upload.url %>"
87 data-name="<%= related.asset.name %>">
85 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span> 88 <span class="related_asset_handle"><%= icon("grip-vertical", library: "tabler", "aria-hidden": true) %></span>
86 <%= image_tag related.asset.upload.url(:thumb) %> 89 <%= image_tag related.asset.upload.url(:thumb) %>
87 <button type="button" class="related_asset_remove" aria-label="Remove image"> 90 <button type="button" class="related_asset_remove" aria-label="Remove image">
diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb
index b63a606..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 %> 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/recent.html.erb b/app/views/nodes/recent.html.erb
index 3602775..d256253 100644
--- a/app/views/nodes/recent.html.erb
+++ b/app/views/nodes/recent.html.erb
@@ -1,3 +1,7 @@
1<h1>Recently changed</h1> 1<h1>Recently changed</h1>
2 2
3<%= render 'node_list' %> 3<%= will_paginate @nodes %>
4<ul id="recent_changes_full_list">
5 <%= render partial: "nodes/recent_change_item", collection: @nodes, as: :node %>
6</ul>
7<%= will_paginate @nodes %>
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 %>
diff --git a/app/views/page_translations/edit.html.erb b/app/views/page_translations/edit.html.erb
index 0f64d40..00bf221 100644
--- a/app/views/page_translations/edit.html.erb
+++ b/app/views/page_translations/edit.html.erb
@@ -20,6 +20,15 @@
20</div> 20</div>
21 21
22<div id="page_editor"> 22<div id="page_editor">
23 <ul id="related_asset_list" style="display:none">
24 <% @page.related_assets.includes(:asset).each do |related| %>
25 <li data-large-url="<%= related.asset.upload.url(:large) %>"
26 data-original-url="<%= related.asset.upload.url %>"
27 data-name="<%= related.asset.name %>">
28 <%= image_tag related.asset.upload.url(:thumb) %>
29 </li>
30 <% end %>
31 </ul>
23 <%= form_with url: node_translation_path(@node, @locale), method: :patch, local: true, id: "translation_edit_form", 32 <%= form_with url: node_translation_path(@node, @locale), method: :patch, local: true, id: "translation_edit_form",
24 data: { autosave_url: autosave_node_translation_path(@node, @locale), show_url: node_path(@node) } do |f| %> 33 data: { autosave_url: autosave_node_translation_path(@node, @locale), show_url: node_path(@node) } do |f| %>
25 <div id="content"> 34 <div id="content">