alchemy_cms 8.0.11 → 8.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/assets/builds/alchemy/admin.css +1 -1
- data/app/assets/builds/alchemy/preview.min.js +1 -1
- data/app/controllers/alchemy/admin/attachments_controller.rb +9 -0
- data/app/controllers/alchemy/admin/pictures_controller.rb +23 -2
- data/app/models/alchemy/attachment.rb +32 -0
- data/app/models/concerns/alchemy/relatable_resource.rb +14 -4
- data/app/stylesheets/alchemy/admin/notices.scss +9 -0
- data/app/views/alchemy/admin/attachments/_files_list.html.erb +1 -1
- data/app/views/alchemy/admin/pages/_table.html.erb +1 -1
- data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
- data/lib/alchemy/configuration.rb +2 -0
- data/lib/alchemy/version.rb +1 -1
- metadata +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
window.Alchemy=Alchemy||{},Object.assign(Alchemy,{ElementSelector:{styles:{reset:{outline:"","outline-offset":"",cursor:""},hover:{outline:"2px dashed #f0b437","outline-offset":"4px",cursor:"pointer"},selected:{outline:"2px dashed #90b9d0","outline-offset":"4px"}},init(){window.addEventListener("message",(e=>{switch(e.data.message){case"Alchemy.blurElements":this.blurElements();break;case"Alchemy.focusElement":this.focusElement(e.data);break;default:console.info("Received unknown message!",e.data)}})),this.elements=Array.from(document.querySelectorAll("[data-alchemy-element]")),this.elements.forEach((e=>{e.addEventListener("mouseover",(()=>{e.classList.contains("selected")||Object.assign(e.style,this.getStyle("hover"))})),e.addEventListener("mouseout",(()=>{e.classList.contains("selected")||Object.assign(e.style,this.getStyle("reset"))})),e.addEventListener("click",(t=>{t.stopPropagation(),t.preventDefault(),this.selectElement(e),this.focusElementEditor(e)}))}))},selectElement(e){this.blurElements(e),e.classList.add("selected"),Object.assign(e.style,this.getStyle("selected")),e.scrollIntoView({behavior:"smooth",block:"start"})},blurElements(e){this.elements.forEach((t=>{t!==e&&(t.classList.remove("selected"),Object.assign(t.style,this.getStyle("reset")))}))},focusElement(e){const t=this.getElement(e.element_id);return t?this.selectElement(t):console.warn("Could not focus element with id",e.element_id)},getElement(e){return this.elements.find((t=>t.dataset.alchemyElement===e.toString()))},focusElementEditor(e){const t=e.dataset.alchemyElement;window.parent.postMessage({message:"Alchemy.focusElementEditor",element_id:t},window.location.origin)},getStyle(e){return"reset"===e?this.styles.reset:this.styles[e]}}}),Alchemy.ElementSelector.init();
|
|
1
|
+
window.Alchemy=Alchemy||{},Object.assign(Alchemy,{ElementSelector:{styles:{reset:{outline:"","outline-offset":"",cursor:""},hover:{outline:"2px dashed #f0b437","outline-offset":"4px",cursor:"pointer"},selected:{outline:"2px dashed #90b9d0","outline-offset":"4px"}},init(){window.addEventListener("message",(e=>{switch(e.data.message){case"Alchemy.blurElements":this.blurElements();break;case"Alchemy.focusElement":this.focusElement(e.data);break;default:console.info("Received unknown message!",e.data)}})),this.elements=Array.from(document.querySelectorAll("[data-alchemy-element]")),this.elements.forEach((e=>{e.addEventListener("mouseover",(()=>{e.classList.contains("selected")||Object.assign(e.style,this.getStyle("hover"))})),e.addEventListener("mouseout",(()=>{e.classList.contains("selected")||Object.assign(e.style,this.getStyle("reset"))})),e.addEventListener("click",(t=>{t.stopPropagation(),t.preventDefault(),this.selectElement(e),this.focusElementEditor(e)}))}))},selectElement(e){this.blurElements(e),e.classList.add("selected"),Object.assign(e.style,this.getStyle("selected")),e.scrollIntoView({behavior:"smooth",block:"start"})},blurElements(e){this.elements.forEach((t=>{t!==e&&(t.classList.remove("selected"),Object.assign(t.style,this.getStyle("reset")))}))},focusElement(e){const t=this.getElement(e.element_id);return t?this.selectElement(t):console.warn("Could not focus element with id",e.element_id)},getElement(e){return this.elements.find((t=>t.dataset.alchemyElement===e.toString()))},focusElementEditor(e){const t=e.dataset.alchemyElement;window.parent.postMessage({message:"Alchemy.focusElementEditor",element_id:t},window.location.origin)},getStyle(e){return"reset"===e?this.styles.reset:this.styles[e]}}}),Alchemy.ElementSelector.init(),window.parent.postMessage({message:"Alchemy.previewReady"},window.location.origin);
|
|
@@ -41,6 +41,15 @@ module Alchemy
|
|
|
41
41
|
.page(params[:page] || 1)
|
|
42
42
|
.per(items_per_page)
|
|
43
43
|
|
|
44
|
+
# Preload deletable ids for the current page in a single query so the
|
|
45
|
+
# view can decide which delete buttons to enable without calling
|
|
46
|
+
# +deletable?+ (a two-query check) per row. We pass the already-loaded
|
|
47
|
+
# ids (via +map(&:id)+) rather than the relation itself, because passing
|
|
48
|
+
# a paginated relation produces +IN (SELECT ... LIMIT n)+, which older
|
|
49
|
+
# MariaDB versions reject.
|
|
50
|
+
@deletable_attachment_ids =
|
|
51
|
+
Attachment.where(id: @attachments.map(&:id)).deletable.pluck(:id).to_set
|
|
52
|
+
|
|
44
53
|
if in_overlay?
|
|
45
54
|
archive_overlay
|
|
46
55
|
end
|
|
@@ -10,6 +10,8 @@ module Alchemy
|
|
|
10
10
|
|
|
11
11
|
helper "alchemy/admin/tags"
|
|
12
12
|
|
|
13
|
+
before_action :load_pictures, only: :index
|
|
14
|
+
|
|
13
15
|
before_action :load_resource,
|
|
14
16
|
only: [:edit, :update, :url, :destroy]
|
|
15
17
|
|
|
@@ -21,6 +23,12 @@ module Alchemy
|
|
|
21
23
|
@picture = Picture.find(params[:id])
|
|
22
24
|
end
|
|
23
25
|
|
|
26
|
+
# Preload deletable ids for the current page (index) or the current
|
|
27
|
+
# resource (update, which re-renders the +_picture+ partial via a
|
|
28
|
+
# turbo stream). One query replaces the per-row +deletable?+ check
|
|
29
|
+
# that would otherwise fire for every picture the view renders.
|
|
30
|
+
before_action :load_deletable_picture_ids, only: [:index, :update]
|
|
31
|
+
|
|
24
32
|
add_alchemy_filter :by_file_format, type: :select, options: ->(query) do
|
|
25
33
|
Alchemy::Picture.file_formats(query.result)
|
|
26
34
|
end
|
|
@@ -32,8 +40,6 @@ module Alchemy
|
|
|
32
40
|
helper_method :picture_offset
|
|
33
41
|
|
|
34
42
|
def index
|
|
35
|
-
@pictures = filtered_pictures(page: params[:page])
|
|
36
|
-
|
|
37
43
|
if in_overlay?
|
|
38
44
|
archive_overlay
|
|
39
45
|
end
|
|
@@ -162,6 +168,21 @@ module Alchemy
|
|
|
162
168
|
|
|
163
169
|
private
|
|
164
170
|
|
|
171
|
+
def load_pictures
|
|
172
|
+
@pictures = filtered_pictures(page: params[:page])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Preload deletable ids in a single query so the view can decide which
|
|
176
|
+
# delete buttons to enable without calling +deletable?+ (a two-query
|
|
177
|
+
# check) per row. We pass already-loaded ids (via +map(&:id)+) rather
|
|
178
|
+
# than the relation itself, because passing a paginated relation
|
|
179
|
+
# produces +IN (SELECT ... LIMIT n)+, which older MariaDB versions
|
|
180
|
+
# reject.
|
|
181
|
+
def load_deletable_picture_ids
|
|
182
|
+
ids = @pictures ? @pictures.map(&:id) : [@picture.id]
|
|
183
|
+
@deletable_picture_ids = Picture.where(id: ids).deletable.pluck(:id).to_set
|
|
184
|
+
end
|
|
185
|
+
|
|
165
186
|
def picture_offset
|
|
166
187
|
((params[:page] || 1).to_i - 1) * items_per_page
|
|
167
188
|
end
|
|
@@ -40,6 +40,25 @@ module Alchemy
|
|
|
40
40
|
scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) }
|
|
41
41
|
scope :without_tag, -> { left_outer_joins(:taggings).where(gutentag_taggings: {id: nil}) }
|
|
42
42
|
|
|
43
|
+
# Override +Alchemy::RelatableResource#deletable+ to also exclude
|
|
44
|
+
# attachments referenced from +/attachment/:id/download+ URLs inside
|
|
45
|
+
# ingredient values (e.g. Richtext markup, Link ingredients, raw Html).
|
|
46
|
+
# Those URLs are written by the file tab of the link dialog and are
|
|
47
|
+
# not tracked via the polymorphic +related_object+ association, so the
|
|
48
|
+
# base scope cannot see them.
|
|
49
|
+
#
|
|
50
|
+
# Extracts referenced attachment IDs from ingredient values via Ruby
|
|
51
|
+
# regex to stay database-agnostic.
|
|
52
|
+
scope :deletable, -> do
|
|
53
|
+
referenced_ids = Alchemy::Ingredient
|
|
54
|
+
.where("value LIKE '%/attachment/%/download%'")
|
|
55
|
+
.pluck(:value)
|
|
56
|
+
.flat_map { |v| v.scan(%r{/attachment/(\d+)/download}).flatten.map(&:to_i) }
|
|
57
|
+
|
|
58
|
+
scope = where("#{table_name}.id NOT IN (#{RelatableResource::RELATED_INGREDIENTS_SUBQUERY})", type: name)
|
|
59
|
+
referenced_ids.any? ? scope.where.not(id: referenced_ids) : scope
|
|
60
|
+
end
|
|
61
|
+
|
|
43
62
|
# We need to define this method here to have it available in the validations below.
|
|
44
63
|
class << self
|
|
45
64
|
# The class used to generate URLs for attachments
|
|
@@ -112,6 +131,13 @@ module Alchemy
|
|
|
112
131
|
CGI.escape(file_name.gsub(/\.#{extension}$/, "").tr(".", " "))
|
|
113
132
|
end
|
|
114
133
|
|
|
134
|
+
# Override +Alchemy::RelatableResource#deletable?+ to also consider
|
|
135
|
+
# +/attachment/:id/download+ links inside ingredient values (e.g.
|
|
136
|
+
# Richtext markup, Link ingredients, raw Html).
|
|
137
|
+
def deletable?
|
|
138
|
+
super && !referenced_in_ingredient_value?
|
|
139
|
+
end
|
|
140
|
+
|
|
115
141
|
# Checks if the attachment is restricted, because it is attached on restricted pages only
|
|
116
142
|
def restricted?
|
|
117
143
|
pages.any? && pages.not_restricted.blank?
|
|
@@ -178,6 +204,12 @@ module Alchemy
|
|
|
178
204
|
end
|
|
179
205
|
end
|
|
180
206
|
|
|
207
|
+
def referenced_in_ingredient_value?
|
|
208
|
+
Alchemy::Ingredient
|
|
209
|
+
.where("value LIKE ?", "%/attachment/#{id}/download%")
|
|
210
|
+
.exists?
|
|
211
|
+
end
|
|
212
|
+
|
|
181
213
|
def set_name
|
|
182
214
|
self.name ||= convert_to_humanized_name(file_name, extension)
|
|
183
215
|
end
|
|
@@ -2,12 +2,22 @@ module Alchemy
|
|
|
2
2
|
module RelatableResource
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
# SQL subquery selecting +related_object_id+ values for ingredients that
|
|
6
|
+
# reference a given polymorphic type. Intended to be composed into a
|
|
7
|
+
# +NOT IN (...)+ clause by +deletable+ and any overrides in including
|
|
8
|
+
# classes. Takes one named bind :type - the polymorphic type name,
|
|
9
|
+
# typically the class name of the including model
|
|
10
|
+
# (e.g. +"Alchemy::Attachment"+).
|
|
11
|
+
RELATED_INGREDIENTS_SUBQUERY = <<~SQL.squish
|
|
12
|
+
SELECT related_object_id
|
|
13
|
+
FROM alchemy_ingredients
|
|
14
|
+
WHERE related_object_id IS NOT NULL
|
|
15
|
+
AND related_object_type = :type
|
|
16
|
+
SQL
|
|
17
|
+
|
|
5
18
|
included do
|
|
6
19
|
scope :deletable, -> do
|
|
7
|
-
where(
|
|
8
|
-
"#{table_name}.id NOT IN (SELECT related_object_id FROM alchemy_ingredients WHERE related_object_id IS NOT NULL AND related_object_type = ?)",
|
|
9
|
-
name
|
|
10
|
-
)
|
|
20
|
+
where("#{table_name}.id NOT IN (#{RELATED_INGREDIENTS_SUBQUERY})", type: name)
|
|
11
21
|
end
|
|
12
22
|
|
|
13
23
|
has_many :related_ingredients,
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
file_attribute: 'file' %>
|
|
62
62
|
<% end %>
|
|
63
63
|
<% table.with_action(:destroy) do |attachment| %>
|
|
64
|
-
<% if attachment.
|
|
64
|
+
<% if @deletable_attachment_ids.include?(attachment.id) %>
|
|
65
65
|
<sl-tooltip content="<%= Alchemy.t(:delete_file) %>">
|
|
66
66
|
<%= link_to_confirm_dialog render_icon(:minus),
|
|
67
67
|
Alchemy.t(:confirm_to_delete_file),
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<%= render_icon "file-edit", size: "xl" %>
|
|
7
7
|
</sl-tooltip>
|
|
8
8
|
<% else %>
|
|
9
|
-
<%= render_icon "file
|
|
9
|
+
<%= render_icon "file", size: "xl" %>
|
|
10
10
|
<% end %>
|
|
11
11
|
<% else %>
|
|
12
12
|
<sl-tooltip class="like-hint-tooltip" content="<%= Alchemy.t("Your user role does not allow you to edit this page") %>" placement="bottom-start">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
<span class="picture_tool select">
|
|
3
3
|
<%= check_box_tag "picture_ids[]", picture.id %>
|
|
4
4
|
</span>
|
|
5
|
-
<% if picture.
|
|
5
|
+
<% if @deletable_picture_ids.include?(picture.id) && can?(:destroy, picture) %>
|
|
6
6
|
<div class="picture_tool delete">
|
|
7
7
|
<sl-tooltip content="<%= Alchemy.t('Delete image') %>">
|
|
8
8
|
<%= link_to_confirm_dialog(
|
data/lib/alchemy/version.rb
CHANGED