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.
@@ -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,
@@ -74,6 +74,15 @@ alchemy-message {
74
74
  margin-top: 0;
75
75
  }
76
76
 
77
+ h1,
78
+ h2,
79
+ h3,
80
+ p {
81
+ &:last-child {
82
+ margin-bottom: 0;
83
+ }
84
+ }
85
+
77
86
  a[href] {
78
87
  text-decoration-color: inherit;
79
88
  text-decoration-thickness: 1px;
@@ -61,7 +61,7 @@
61
61
  file_attribute: 'file' %>
62
62
  <% end %>
63
63
  <% table.with_action(:destroy) do |attachment| %>
64
- <% if attachment.deletable? %>
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-edit", size: "xl" %>
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.deletable? && can?(:destroy, 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(
@@ -67,6 +67,8 @@ module Alchemy
67
67
  ).to_h
68
68
  end
69
69
 
70
+ delegate :to_json, to: :to_h
71
+
70
72
  class << self
71
73
  def defined_configurations = []
72
74
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Alchemy
4
- VERSION = "8.0.11"
4
+ VERSION = "8.0.13"
5
5
 
6
6
  def self.version
7
7
  VERSION
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: alchemy_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.11
4
+ version: 8.0.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas von Deyen