alchemy_cms 8.2.1 → 8.2.3

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.
@@ -39,6 +39,15 @@ module Alchemy
39
39
  .page(params[:page] || 1)
40
40
  .per(items_per_page)
41
41
 
42
+ # Preload deletable ids for the current page in a single query so the
43
+ # view can decide which delete buttons to enable without calling
44
+ # +deletable?+ (a two-query check) per row. We pass the already-loaded
45
+ # ids (via +map(&:id)+) rather than the relation itself, because passing
46
+ # a paginated relation produces +IN (SELECT ... LIMIT n)+, which older
47
+ # MariaDB versions reject.
48
+ @deletable_attachment_ids =
49
+ Attachment.where(id: @attachments.map(&:id)).deletable.pluck(:id).to_set
50
+
42
51
  if in_overlay?
43
52
  archive_overlay
44
53
  end
@@ -8,6 +8,8 @@ module Alchemy
8
8
  include CurrentLanguage
9
9
  include PictureDescriptionsFormHelper
10
10
 
11
+ before_action :load_pictures, only: :index
12
+
11
13
  before_action :load_resource,
12
14
  only: [:edit, :update, :url, :destroy]
13
15
 
@@ -19,6 +21,12 @@ module Alchemy
19
21
  @picture = Picture.find(params[:id])
20
22
  end
21
23
 
24
+ # Preload deletable ids for the current page (index) or the current
25
+ # resource (update, which re-renders the +_picture+ partial via a
26
+ # turbo stream). One query replaces the per-row +deletable?+ check
27
+ # that would otherwise fire for every picture the view renders.
28
+ before_action :load_deletable_picture_ids, only: [:index, :update]
29
+
22
30
  add_alchemy_filter :by_file_format, type: :select, options: ->(query) do
23
31
  Alchemy::Picture.file_formats(query.result)
24
32
  end
@@ -30,8 +38,6 @@ module Alchemy
30
38
  helper_method :picture_offset
31
39
 
32
40
  def index
33
- @pictures = filtered_pictures(page: params[:page])
34
-
35
41
  if in_overlay?
36
42
  archive_overlay
37
43
  end
@@ -159,6 +165,21 @@ module Alchemy
159
165
 
160
166
  private
161
167
 
168
+ def load_pictures
169
+ @pictures = filtered_pictures(page: params[:page])
170
+ end
171
+
172
+ # Preload deletable ids in a single query so the view can decide which
173
+ # delete buttons to enable without calling +deletable?+ (a two-query
174
+ # check) per row. We pass already-loaded ids (via +map(&:id)+) rather
175
+ # than the relation itself, because passing a paginated relation
176
+ # produces +IN (SELECT ... LIMIT n)+, which older MariaDB versions
177
+ # reject.
178
+ def load_deletable_picture_ids
179
+ ids = @pictures ? @pictures.map(&:id) : [@picture.id]
180
+ @deletable_picture_ids = Picture.where(id: ids).deletable.pluck(:id).to_set
181
+ end
182
+
162
183
  def picture_offset
163
184
  ((params[:page] || 1).to_i - 1) * items_per_page
164
185
  end
@@ -44,7 +44,6 @@ export default class PictureThumbnail extends HTMLElement {
44
44
  if (alt) {
45
45
  this.image.alt = alt
46
46
  }
47
- this.image.loading = "lazy"
48
47
  }
49
48
 
50
49
  start(src) {
@@ -42,6 +42,33 @@ module Alchemy
42
42
  scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) }
43
43
  scope :without_tag, -> { left_outer_joins(:taggings).where(gutentag_taggings: {id: nil}) }
44
44
 
45
+ # Override +Alchemy::RelatableResource#deletable+ to also exclude
46
+ # attachments referenced from +/attachment/:id/download+ URLs inside
47
+ # ingredient values (e.g. Richtext markup, Link ingredients, raw Html).
48
+ # Those URLs are written by the file tab of the link dialog and are
49
+ # not tracked via the polymorphic +related_object+ association, so the
50
+ # base scope cannot see them.
51
+ #
52
+ # Uses a correlated +NOT EXISTS+ subquery that builds the per-row LIKE
53
+ # pattern with +Arel::Nodes::Concat+, which compiles to +||+ on
54
+ # SQLite/PostgreSQL and +CONCAT()+ on MySQL.
55
+ scope :deletable, -> do
56
+ ingredients = Alchemy::Ingredient.arel_table
57
+ pattern = Arel::Nodes::Concat.new(
58
+ Arel::Nodes::Concat.new(
59
+ Arel::Nodes.build_quoted("%/attachment/"),
60
+ arel_table[:id]
61
+ ),
62
+ Arel::Nodes.build_quoted("/download%")
63
+ )
64
+ referenced = ingredients
65
+ .project(1)
66
+ .where(ingredients[:value].matches(pattern))
67
+
68
+ where("#{table_name}.id NOT IN (#{RelatableResource::RELATED_INGREDIENTS_SUBQUERY})", type: name)
69
+ .where.not(referenced.exists)
70
+ end
71
+
45
72
  # We need to define this method here to have it available in the validations below.
46
73
  class << self
47
74
  # The class used to generate URLs for attachments
@@ -112,6 +139,13 @@ module Alchemy
112
139
  CGI.escape(file_name.gsub(/\.#{extension}$/, "").tr(".", " "))
113
140
  end
114
141
 
142
+ # Override +Alchemy::RelatableResource#deletable?+ to also consider
143
+ # +/attachment/:id/download+ links inside ingredient values (e.g.
144
+ # Richtext markup, Link ingredients, raw Html).
145
+ def deletable?
146
+ super && !referenced_in_ingredient_value?
147
+ end
148
+
115
149
  # Checks if the attachment is restricted, because it is attached on restricted pages only
116
150
  def restricted?
117
151
  related_pages.any? && related_pages.not_restricted.blank?
@@ -178,6 +212,12 @@ module Alchemy
178
212
  end
179
213
  end
180
214
 
215
+ def referenced_in_ingredient_value?
216
+ Alchemy::Ingredient
217
+ .where("value LIKE ?", "%/attachment/#{id}/download%")
218
+ .exists?
219
+ end
220
+
181
221
  def set_name
182
222
  self.name ||= Alchemy.storage_adapter.file_basename(self).humanize
183
223
  end
@@ -2,6 +2,19 @@ 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
  class_methods do
6
19
  # Preload associations for element editor display
7
20
  #
@@ -18,10 +31,7 @@ module Alchemy
18
31
 
19
32
  included do
20
33
  scope :deletable, -> do
21
- where(
22
- "#{table_name}.id NOT IN (SELECT related_object_id FROM alchemy_ingredients WHERE related_object_id IS NOT NULL AND related_object_type = ?)",
23
- name
24
- )
34
+ where("#{table_name}.id NOT IN (#{RELATED_INGREDIENTS_SUBQUERY})", type: name)
25
35
  end
26
36
 
27
37
  has_many :related_ingredients,
@@ -75,8 +75,8 @@ alchemy-uploader {
75
75
  }
76
76
 
77
77
  img {
78
- max-width: 100%;
79
- max-height: 100%;
78
+ max-width: var(--picture-width);
79
+ max-height: var(--picture-height);
80
80
 
81
81
  &:not([src*="alchemy/missing-image"]) {
82
82
  background: var(--thumbnail-background);
@@ -1,11 +1,11 @@
1
1
  alchemy-picture-thumbnail {
2
2
  &[loading] {
3
- img {
3
+ > img {
4
4
  opacity: 0;
5
5
  }
6
6
  }
7
7
 
8
- img {
8
+ > img {
9
9
  opacity: 1;
10
10
  transition: opacity var(--transition-duration);
11
11
  }
@@ -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),
@@ -22,7 +22,7 @@
22
22
  search_filter_params.merge(
23
23
  size: "small",
24
24
  form_field_id: @form_field_id
25
- )
25
+ ).to_h
26
26
  ),
27
27
  class: "icon_button"
28
28
  ) %>
@@ -34,7 +34,7 @@
34
34
  search_filter_params.merge(
35
35
  size: "medium",
36
36
  form_field_id: @form_field_id
37
- )
37
+ ).to_h
38
38
  ),
39
39
  class: "icon_button"
40
40
  ) %>
@@ -46,7 +46,7 @@
46
46
  search_filter_params.merge(
47
47
  size: "large",
48
48
  form_field_id: @form_field_id
49
- )
49
+ ).to_h
50
50
  ),
51
51
  class: "icon_button"
52
52
  ) %>
@@ -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(
@@ -10,7 +10,7 @@
10
10
  onclick: '$(self).attr("href", "#").off("click"); return false',
11
11
  method: 'put',
12
12
  ) do %>
13
- <%= render Alchemy::Admin::PictureThumbnail.new(picture_to_assign) %>
13
+ <%= render Alchemy::Admin::PictureThumbnail.new(picture_to_assign, size: size) %>
14
14
  <% end %>
15
15
  </sl-tooltip>
16
16
  <% else %>
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Alchemy
4
- VERSION = "8.2.1"
4
+ VERSION = "8.2.3"
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.2.1
4
+ version: 8.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas von Deyen