alchemy_cms 8.2.2 → 8.2.4

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
@@ -14,6 +14,12 @@ export class RemoteSelect extends AlchemyHTMLElement {
14
14
  url: { default: "" }
15
15
  }
16
16
 
17
+ // Select2 manages its own DOM after initialization, so attribute changes
18
+ // must not trigger the default re-render which would destroy the widget.
19
+ static get observedAttributes() {
20
+ return []
21
+ }
22
+
17
23
  async connected() {
18
24
  await setupSelectLocale()
19
25
 
@@ -34,6 +40,11 @@ export class RemoteSelect extends AlchemyHTMLElement {
34
40
  * @param {Event} event
35
41
  */
36
42
  onChange(event) {
43
+ // Update selection attribute so re-attaching the select2 component to
44
+ // the same input (e.g. after dragndrop) does not reset the selection.
45
+ if (event.added) {
46
+ this.setAttribute("selection", JSON.stringify(event.added))
47
+ }
37
48
  this.dispatchCustomEvent("RemoteSelect.Change", {
38
49
  removed: event.removed,
39
50
  added: event.added
@@ -42,6 +42,25 @@ 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
+ # Extracts referenced attachment IDs from ingredient values via Ruby
53
+ # regex to stay database-agnostic.
54
+ scope :deletable, -> do
55
+ referenced_ids = Alchemy::Ingredient
56
+ .where("value LIKE '%/attachment/%/download%'")
57
+ .pluck(:value)
58
+ .flat_map { |v| v.scan(%r{/attachment/(\d+)/download}).flatten.map(&:to_i) }
59
+
60
+ scope = where("#{table_name}.id NOT IN (#{RelatableResource::RELATED_INGREDIENTS_SUBQUERY})", type: name)
61
+ referenced_ids.any? ? scope.where.not(id: referenced_ids) : scope
62
+ end
63
+
45
64
  # We need to define this method here to have it available in the validations below.
46
65
  class << self
47
66
  # 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
  related_pages.any? && related_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 ||= Alchemy.storage_adapter.file_basename(self).humanize
183
215
  end
@@ -615,7 +615,17 @@ module Alchemy
615
615
  end
616
616
 
617
617
  def check_descendants_for_menu_nodes
618
- pages_with_nodes = descendants.joins(:nodes).reorder("alchemy_pages.lft").distinct
618
+ # awesome_nested_set's before_destroy runs first and closes the gap left
619
+ # by this page, shifting the following siblings' lft/rgt leftward. For a
620
+ # leaf the next sibling then lands exactly on this page's lft, so the
621
+ # inclusive comparison the `descendants` helper uses (lft >= self.lft)
622
+ # would match it. Restrict to true descendants with strict bounds. Build
623
+ # on nested_set_scope so the acts_as_nested_set scope stays in sync.
624
+ pages_with_nodes = nested_set_scope
625
+ .where("alchemy_pages.lft > ? AND alchemy_pages.rgt < ?", lft, rgt)
626
+ .joins(:nodes)
627
+ .reorder("alchemy_pages.lft")
628
+ .distinct
619
629
  if pages_with_nodes.exists?
620
630
  errors.add(:descendants, :still_attached_to_nodes, page_names: pages_with_nodes.map(&:name).to_sentence)
621
631
  throw :abort
@@ -29,32 +29,6 @@ module Alchemy
29
29
 
30
30
  before_destroy :delete_elements
31
31
 
32
- # Determines if this version is public
33
- #
34
- # Takes the two timestamps +public_on+ and +public_until+
35
- # and returns true if the time given (+Time.current+ per default)
36
- # is in this timespan.
37
- #
38
- # @param time [DateTime] (Time.current)
39
- # @returns Boolean
40
- def public?(time = Current.preview_time)
41
- already_public_for?(time) && still_public_for?(time)
42
- end
43
-
44
- # Determines if this version is already public for given time
45
- # @param time [DateTime] (Current.preview_time)
46
- # @returns Boolean
47
- def already_public_for?(time = Current.preview_time)
48
- !public_on.nil? && public_on <= time
49
- end
50
-
51
- # Determines if this version is still public for given time
52
- # @param time [DateTime] (Current.preview_time)
53
- # @returns Boolean
54
- def still_public_for?(time = Current.preview_time)
55
- public_until.nil? || public_until >= time
56
- end
57
-
58
32
  def element_repository
59
33
  ElementsRepository.new(elements)
60
34
  end
@@ -46,7 +46,7 @@ module Alchemy
46
46
  #
47
47
  # @returns Boolean
48
48
  def publishable?
49
- !public_on.nil? && still_public_for?
49
+ !public_on.nil? && still_public_for?(at: Time.current)
50
50
  end
51
51
 
52
52
  # Determines if this record is already public for given time
@@ -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,
@@ -61,7 +61,7 @@ module Alchemy
61
61
 
62
62
  elements_by_id.each_value do |element|
63
63
  children = elements_by_parent[element.id] || []
64
- children = children.sort_by(&:position)
64
+ children = children.sort_by { |c| c.position.to_i }
65
65
 
66
66
  # Manually set the association target
67
67
  element.association(:all_nested_elements).target = children
@@ -70,6 +70,15 @@ alchemy-message {
70
70
  font-size: var(--font-size_medium);
71
71
  }
72
72
 
73
+ h1,
74
+ h2,
75
+ h3,
76
+ p {
77
+ &:last-child {
78
+ margin-bottom: 0;
79
+ }
80
+ }
81
+
73
82
  a[href] {
74
83
  text-decoration-color: inherit;
75
84
  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(
@@ -1,11 +1,18 @@
1
1
  class ConvertSelectValueForMultiple < ActiveRecord::Migration[7.1]
2
2
  def up
3
3
  say_with_time "Converting Alchemy::Ingredients::Select values to multiple" do
4
- update <<-SQL.squish
5
- UPDATE alchemy_ingredients
6
- SET value = '["' || value || '"]'
7
- WHERE type = 'Alchemy::Ingredients::Select' AND value NOT LIKE '["%"]';
8
- SQL
4
+ Alchemy::Ingredients::Select
5
+ .where.not("value LIKE ?", '["%')
6
+ .update_all(
7
+ Arel.sql(
8
+ case ActiveRecord::Base.connection.adapter_name
9
+ when /mysql|mariadb/i
10
+ "value = CONCAT('[\"', value, '\"]')"
11
+ else
12
+ "value = '[\"' || value || '\"]'"
13
+ end
14
+ )
15
+ )
9
16
  end
10
17
  end
11
18
  end
@@ -212,4 +212,41 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
212
212
  end
213
213
  end
214
214
  end
215
+
216
+ describe "#publishable?" do
217
+ context "when public_on is nil" do
218
+ let(:page_version) { build(factory_name, public_on: nil) }
219
+
220
+ it { expect(page_version.publishable?).to be(false) }
221
+ end
222
+
223
+ context "when public_on is set and public_until is nil" do
224
+ let(:page_version) { build(factory_name, public_on: Time.current) }
225
+
226
+ it { expect(page_version.publishable?).to be(true) }
227
+ end
228
+
229
+ context "when public_on is set and public_until is in the past" do
230
+ let(:page_version) do
231
+ build(factory_name,
232
+ public_on: Time.current - 2.days,
233
+ public_until: Time.current - 1.day)
234
+ end
235
+
236
+ it { expect(page_version.publishable?).to be(false) }
237
+ end
238
+
239
+ context "when Current.preview_time is set to a future time" do
240
+ let(:page_version) do
241
+ build(factory_name,
242
+ public_on: Time.current - 1.day,
243
+ public_until: Time.current + 1.day)
244
+ end
245
+
246
+ it "uses Time.current instead of the preview_time" do
247
+ Alchemy::Current.preview_time = Time.current + 1.week
248
+ expect(page_version.publishable?).to be(true)
249
+ end
250
+ end
251
+ end
215
252
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Alchemy
4
- VERSION = "8.2.2"
4
+ VERSION = "8.2.4"
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.2
4
+ version: 8.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas von Deyen
@@ -1490,7 +1490,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
1490
1490
  version: '0'
1491
1491
  requirements:
1492
1492
  - ImageMagick (libmagick), v6.6 or greater.
1493
- rubygems_version: 4.0.6
1493
+ rubygems_version: 4.0.10
1494
1494
  specification_version: 4
1495
1495
  summary: A powerful, userfriendly and flexible CMS for Rails
1496
1496
  test_files: []