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.
- checksums.yaml +4 -4
- data/app/assets/builds/alchemy/admin.css +1 -1
- data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
- data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
- data/app/controllers/alchemy/admin/attachments_controller.rb +9 -0
- data/app/controllers/alchemy/admin/pictures_controller.rb +23 -2
- data/app/javascript/alchemy_admin/components/remote_select.js +11 -0
- data/app/models/alchemy/attachment.rb +32 -0
- data/app/models/alchemy/page.rb +11 -1
- data/app/models/alchemy/page_version.rb +0 -26
- data/app/models/concerns/alchemy/publishable.rb +1 -1
- data/app/models/concerns/alchemy/relatable_resource.rb +14 -4
- data/app/services/alchemy/element_preloader.rb +1 -1
- 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/db/migrate/20251106150010_convert_select_value_for_multiple.rb +12 -5
- data/lib/alchemy/test_support/shared_publishable_examples.rb +37 -0
- data/lib/alchemy/version.rb +1 -1
- metadata +2 -2
|
@@ -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
|
data/app/models/alchemy/page.rb
CHANGED
|
@@ -615,7 +615,17 @@ module Alchemy
|
|
|
615
615
|
end
|
|
616
616
|
|
|
617
617
|
def check_descendants_for_menu_nodes
|
|
618
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
@@ -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(
|
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
data/lib/alchemy/version.rb
CHANGED
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.
|
|
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.
|
|
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: []
|