From c72a016dcfdb36dbd5926f5977684b755bf1c510 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 27 Jan 2026 18:03:32 +0100 Subject: [PATCH 1/4] fix: Use Ruby/Rails internals to create human readable file names Use Ruby's `File.basename` and Rails' `humanize` to create the first initial human readable `name` of `Attachment`s and `Picture`s. Before we where using our own brittle Regexp, that expected the file suffix to be known from it's mime type. This is not the case for unknown mimetypes. We also move the name conversion before filename sanitization in order to get a nice human readable name and not escape characters. Since the name is not used to load the file and all output is escaped in Rails by default, this is fine. Signed-off-by: Thomas von Deyen --- .../alchemy/admin/pictures_controller.rb | 1 - app/models/alchemy/attachment.rb | 6 ++-- app/models/alchemy/picture.rb | 16 ++++----- app/models/alchemy/storage_adapter.rb | 2 ++ .../alchemy/storage_adapter/active_storage.rb | 14 ++++++++ .../alchemy/storage_adapter/dragonfly.rb | 12 +++++++ lib/alchemy/name_conversions.rb | 6 ---- .../alchemy/admin/pictures_controller_spec.rb | 17 +++++----- spec/fixtures/files/cute_kitten.png | Bin 0 -> 70 bytes .../fixtures/files/image with spaces.jpgi.png | Bin 0 -> 73 bytes spec/models/alchemy/attachment_spec.rb | 2 +- spec/models/alchemy/picture_spec.rb | 31 ++++++++++-------- 12 files changed, 66 insertions(+), 41 deletions(-) create mode 100644 spec/fixtures/files/cute_kitten.png create mode 100644 spec/fixtures/files/image with spaces.jpgi.png diff --git a/app/controllers/alchemy/admin/pictures_controller.rb b/app/controllers/alchemy/admin/pictures_controller.rb index 0459457418..72af9a33ac 100644 --- a/app/controllers/alchemy/admin/pictures_controller.rb +++ b/app/controllers/alchemy/admin/pictures_controller.rb @@ -64,7 +64,6 @@ def url def create @picture = Picture.new(picture_params) - @picture.name = @picture.humanized_name if @picture.save render successful_uploader_response(file: @picture) else diff --git a/app/models/alchemy/attachment.rb b/app/models/alchemy/attachment.rb index 06e9aa0d1d..150e26b252 100644 --- a/app/models/alchemy/attachment.rb +++ b/app/models/alchemy/attachment.rb @@ -25,6 +25,8 @@ class Attachment < BaseRecord include Alchemy::TouchElements include Alchemy::RelatableResource + before_save :set_name, if: -> { Alchemy.storage_adapter.set_attachment_name?(self) } + include Alchemy.storage_adapter.attachment_class_methods stampable stamper_class_name: Alchemy.config.user_class_name @@ -97,8 +99,6 @@ def ransackable_scopes(_auth_object = nil) validate :file_type_allowed, unless: -> { self.class.allowed_filetypes.include?("*") } - before_save :set_name, if: -> { Alchemy.storage_adapter.set_attachment_name?(self) } - scope :with_file_type, ->(file_type) { where(file_mime_type: file_type) } # Instance methods @@ -179,7 +179,7 @@ def file_type_allowed end def set_name - self.name ||= convert_to_humanized_name(file_name, extension) + self.name ||= Alchemy.storage_adapter.file_basename(self).humanize end end end diff --git a/app/models/alchemy/picture.rb b/app/models/alchemy/picture.rb index b9e602169f..800e9b0d7c 100644 --- a/app/models/alchemy/picture.rb +++ b/app/models/alchemy/picture.rb @@ -62,6 +62,8 @@ def self.preprocessor_class=(klass) @_preprocessor_class = klass end + before_create :set_name, if: :image_file_name + include Alchemy.storage_adapter.picture_class_methods # We need to define this method here to have it available in the validations below. @@ -195,14 +197,6 @@ def urlname end end - # Returns a humanized, readable name from image filename. - # - def humanized_name - return "" if image_file_name.blank? - - convert_to_humanized_name(image_file_name, image_file_extension) - end - # Returns the format the image should be rendered with # # Only returns a format differing from original if an +image_output_format+ @@ -287,6 +281,12 @@ def image_file_dimensions private + # Returns a humanized, readable name from image filename. + # + def set_name + self.name ||= Alchemy.storage_adapter.image_file_basename(self).humanize + end + def image_file_type_allowed unless image_file_extension&.in?(self.class.allowed_filetypes) errors.add(:image_file, Alchemy.t("not a valid image")) diff --git a/app/models/alchemy/storage_adapter.rb b/app/models/alchemy/storage_adapter.rb index 7aeb41c4f3..c2a4fb96d1 100644 --- a/app/models/alchemy/storage_adapter.rb +++ b/app/models/alchemy/storage_adapter.rb @@ -9,12 +9,14 @@ class UnknownAdapterError < StandardError; end :by_file_format_scope, :by_file_type_scope, :not_file_type_scope, + :file_basename, :file_extension, :file_formats, :file_mime_type, :file_name, :file_size, :has_convertible_format?, + :image_file_basename, :image_file_extension, :image_file_format, :image_file_height, diff --git a/app/models/alchemy/storage_adapter/active_storage.rb b/app/models/alchemy/storage_adapter/active_storage.rb index 13fa967bc5..1866137af6 100644 --- a/app/models/alchemy/storage_adapter/active_storage.rb +++ b/app/models/alchemy/storage_adapter/active_storage.rb @@ -96,6 +96,13 @@ def not_file_type_scope(file_type) Attachment.with_attached_file.joins(:file_blob).where.not(active_storage_blobs: {content_type: file_type}) end + # @param [Alchemy::Attachment] + # @return [String] + def file_basename(attachment) + filename = attachment.file&.filename.to_s + File.basename(filename, File.extname(filename)) + end + # @param [Alchemy::Attachment] # @return [String] def file_name(attachment) @@ -126,6 +133,13 @@ def has_convertible_format?(picture) picture.image_file&.variable? end + # @param [Alchemy::Picture] + # @return [String] + def image_file_basename(picture) + filename = picture.image_file&.filename.to_s + File.basename(filename, File.extname(filename)) + end + # @param [Alchemy::Picture] # @return [String] def image_file_name(picture) diff --git a/app/models/alchemy/storage_adapter/dragonfly.rb b/app/models/alchemy/storage_adapter/dragonfly.rb index 87561271f9..ace494f43b 100644 --- a/app/models/alchemy/storage_adapter/dragonfly.rb +++ b/app/models/alchemy/storage_adapter/dragonfly.rb @@ -127,6 +127,12 @@ def file_name(attachment) attachment.read_attribute(:file_name) end + # @param [Alchemy::Attachment] + # @return [String] + def file_basename(attachment) + attachment.file&.basename&.to_s + end + # @param [Alchemy::Attachment] # @return [Integer] def file_size(attachment) @@ -152,6 +158,12 @@ def has_convertible_format?(picture) image_file_extension(picture).in?(CONVERTIBLE_FILE_FORMATS) end + # @param [Alchemy::Picture] + # @return [String] + def image_file_basename(picture) + picture.image_file&.basename&.to_s + end + # @param [Alchemy::Picture] # @return [String] def image_file_name(picture) diff --git a/lib/alchemy/name_conversions.rb b/lib/alchemy/name_conversions.rb index 7caffa7d49..b71910150b 100644 --- a/lib/alchemy/name_conversions.rb +++ b/lib/alchemy/name_conversions.rb @@ -17,12 +17,6 @@ def convert_to_urlname(name) .parameterize end - # Converts a filename and suffix into a human readable name. - # - def convert_to_humanized_name(name, suffix) - name.gsub(/\.#{::Regexp.quote(suffix)}$/i, "").tr("_", " ").strip - end - # Sanitizes a given filename by removing directory traversal attempts and HTML entities. def sanitized_filename(file_name) file_name = File.basename(file_name) diff --git a/spec/controllers/alchemy/admin/pictures_controller_spec.rb b/spec/controllers/alchemy/admin/pictures_controller_spec.rb index a8427fd0d8..18b9a7f4e7 100644 --- a/spec/controllers/alchemy/admin/pictures_controller_spec.rb +++ b/spec/controllers/alchemy/admin/pictures_controller_spec.rb @@ -141,15 +141,14 @@ module Alchemy describe "#create" do subject { post :create, params: params } - let(:params) { {picture: {name: ""}} } - let(:picture) { mock_model("Picture", humanized_name: "Cute kittens") } - context "with passing validations" do - before do - expect(Picture).to receive(:new).and_return(picture) - expect(picture).to receive(:name=).and_return("Cute kittens") - expect(picture).to receive(:name).and_return("Cute kittens") - expect(picture).to receive(:save).and_return(true) + let(:params) do + {picture: {image_file: fixture_file_upload("my FileNämü.png")}} + end + + it "creates a new picture with human friendly name" do + expect { subject }.to change { Picture.count }.by(1) + expect(Picture.last.name).to eq("My filenämü") end it "renders json response with success message" do @@ -162,6 +161,8 @@ module Alchemy end context "with failing validations" do + let(:params) { {picture: {name: ""}} } + it_behaves_like "having a json uploader error message" end end diff --git a/spec/fixtures/files/cute_kitten.png b/spec/fixtures/files/cute_kitten.png new file mode 100644 index 0000000000000000000000000000000000000000..42ca8d1f4c0b978eca9e71bd413c781fb11772c8 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9YGfZh}s$-31 V68E@ql^ZC>;OXk;vd$@?2>|I-5On|m literal 0 HcmV?d00001 diff --git a/spec/models/alchemy/attachment_spec.rb b/spec/models/alchemy/attachment_spec.rb index 56757f7ccf..c975bf40e6 100644 --- a/spec/models/alchemy/attachment_spec.rb +++ b/spec/models/alchemy/attachment_spec.rb @@ -57,7 +57,7 @@ module Alchemy it "should have a humanized name" do save - expect(attachment.name).to eq("image with spaces") + expect(attachment.name).to eq("Image with spaces") end context "when file_name has not changed" do diff --git a/spec/models/alchemy/picture_spec.rb b/spec/models/alchemy/picture_spec.rb index d336d7de0d..6a6f394732 100644 --- a/spec/models/alchemy/picture_spec.rb +++ b/spec/models/alchemy/picture_spec.rb @@ -55,24 +55,27 @@ module Alchemy end end - describe "#humanized_name" do - it "should return a humanized version of original filename" do - allow(picture).to receive(:image_file_name).and_return("cute_kitten.JPG") - allow(picture).to receive(:image_file_extension).and_return("jpg") - expect(picture.humanized_name).to eq("cute kitten") + describe "after create" do + let(:image_file) { fixture_file_upload("image with spaces.png") } + let(:picture) { create(:alchemy_picture, name: nil, image_file: image_file) } + + it "should have a humanized version of original filename" do + expect(picture.name).to eq("Image with spaces") end - it "should not remove incidents of suffix from filename" do - allow(picture).to receive(:image_file_name).and_return("cute_kitten_mo.jpgi.JPG") - allow(picture).to receive(:image_file_extension).and_return("jpg") - expect(picture.humanized_name).to eq("cute kitten mo.jpgi") + context "image has suffix-like in filename" do + let(:image_file) { fixture_file_upload("image with spaces.jpgi.png") } + + it "should not have incidents of suffix from filename" do + expect(picture.name).to eq("Image with spaces.jpgi") + end end - context "image has no suffix" do - it "should return humanized name" do - allow(picture).to receive(:image_file_name).and_return("cute_kitten") - allow(picture).to receive(:image_file_extension).and_return("") - expect(picture.humanized_name).to eq("cute kitten") + context "image has underscores" do + let(:image_file) { fixture_file_upload("cute_kitten.png") } + + it "should have humanized name" do + expect(picture.name).to eq("Cute kitten") end end end From f7ca7f6e7316d9f90f8eb124fe4390f392b26920 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 27 Jan 2026 15:06:56 +0100 Subject: [PATCH 2/4] refactor: Move from_extensions conversion into storage adapters Both Attachment.file_types and Picture.file_formats now delegate from_extensions handling to the storage adapter's file_formats method. Each adapter converts extensions to the format it uses internally: Dragonfly keeps bare extensions for pictures and converts to MIME types for attachments, while Active Storage always converts to MIME types via Marcel. This removes the Marcel conversion that lived in Attachment.file_types and centralizes all format-aware logic in the adapters where it belongs. --- app/models/alchemy/attachment.rb | 7 +--- app/models/alchemy/picture.rb | 4 +-- .../alchemy/storage_adapter/active_storage.rb | 12 ++++++- .../alchemy/storage_adapter/dragonfly.rb | 12 ++++++- spec/models/alchemy/attachment_spec.rb | 35 +++++++------------ spec/models/alchemy/picture_spec.rb | 15 +++++++- .../storage_adapter/active_storage_spec.rb | 20 +++++++++++ .../alchemy/storage_adapter/dragonfly_spec.rb | 20 +++++++++++ 8 files changed, 91 insertions(+), 34 deletions(-) diff --git a/app/models/alchemy/attachment.rb b/app/models/alchemy/attachment.rb index 150e26b252..c74dfc4e78 100644 --- a/app/models/alchemy/attachment.rb +++ b/app/models/alchemy/attachment.rb @@ -77,12 +77,7 @@ def ransackable_associations(_auth_object = nil) end def file_types(scope = all, from_extensions: nil) - if from_extensions.present? - scope = by_file_type( - Array(from_extensions).map { |extension| Marcel::MimeType.for(extension:) } - ) - end - Alchemy.storage_adapter.file_formats(name, scope:) + Alchemy.storage_adapter.file_formats(name, scope:, from_extensions:) end def allowed_filetypes diff --git a/app/models/alchemy/picture.rb b/app/models/alchemy/picture.rb index 800e9b0d7c..a9197b117c 100644 --- a/app/models/alchemy/picture.rb +++ b/app/models/alchemy/picture.rb @@ -130,8 +130,8 @@ def ransackable_scopes(_auth_object = nil) [:by_file_format, :recent, :last_upload, :without_tag, :deletable] end - def file_formats(scope = all) - Alchemy.storage_adapter.file_formats(name, scope:) + def file_formats(scope = all, from_extensions: nil) + Alchemy.storage_adapter.file_formats(name, scope:, from_extensions:) end end diff --git a/app/models/alchemy/storage_adapter/active_storage.rb b/app/models/alchemy/storage_adapter/active_storage.rb index 1866137af6..736676527e 100644 --- a/app/models/alchemy/storage_adapter/active_storage.rb +++ b/app/models/alchemy/storage_adapter/active_storage.rb @@ -37,7 +37,17 @@ def picture_url_class PictureUrl end - def file_formats(class_name, scope:) + def file_formats(class_name, scope:, from_extensions: nil) + if from_extensions.present? + mime_types = Array(from_extensions).map { |extension| Marcel::MimeType.for(extension:) } + case class_name + when "Alchemy::Picture" + scope = scope.by_file_format(mime_types) + when "Alchemy::Attachment" + scope = scope.by_file_type(mime_types) + end + end + attachment_scope = case class_name when "Alchemy::Attachment" then scope.with_attached_file when "Alchemy::Picture" then scope.with_attached_image_file diff --git a/app/models/alchemy/storage_adapter/dragonfly.rb b/app/models/alchemy/storage_adapter/dragonfly.rb index ace494f43b..154eafc7d6 100644 --- a/app/models/alchemy/storage_adapter/dragonfly.rb +++ b/app/models/alchemy/storage_adapter/dragonfly.rb @@ -72,7 +72,17 @@ def picture_url_class @_picture_url_class ||= PictureUrl end - def file_formats(class_name, scope:) + def file_formats(class_name, scope:, from_extensions: nil) + if from_extensions.present? + extensions = Array(from_extensions) + case class_name + when "Alchemy::Picture" + scope = scope.by_file_format(extensions.map(&:to_s)) + when "Alchemy::Attachment" + scope = scope.by_file_type(extensions.map { |extension| Marcel::MimeType.for(extension:) }) + end + end + mime_type_column = case class_name when "Alchemy::Attachment" then :file_mime_type when "Alchemy::Picture" then :image_file_format diff --git a/spec/models/alchemy/attachment_spec.rb b/spec/models/alchemy/attachment_spec.rb index c975bf40e6..6578581ef4 100644 --- a/spec/models/alchemy/attachment_spec.rb +++ b/spec/models/alchemy/attachment_spec.rb @@ -108,33 +108,22 @@ module Alchemy end describe ".file_types" do - before do - allow(Alchemy.storage_adapter).to receive(:file_formats) - end - - context "without extensions" do - let(:scope) { double("all") } - - it "calls file_formats on storage adapter with all scope" do - expect(described_class).to receive(:all) { scope } - described_class.file_types - end - end - - context "with extensions" do - let(:scope) { double("by_file_type") } - - it "calls file_formats on storage adapter with by_file_type scope" do - expect(described_class).to receive(:by_file_type).with(%w[image/png image/jpeg]) { scope } - described_class.file_types(from_extensions: %w[png jpg]) - end + it "delegates to storage adapter" do + expect(Alchemy.storage_adapter).to receive(:file_formats).with( + described_class.name, + scope: described_class.all, + from_extensions: nil + ) + described_class.file_types end - after do - expect(Alchemy.storage_adapter).to have_received(:file_formats).with( + it "passes from_extensions to storage adapter" do + expect(Alchemy.storage_adapter).to receive(:file_formats).with( described_class.name, - scope: + scope: described_class.all, + from_extensions: %w[png jpg] ) + described_class.file_types(from_extensions: %w[png jpg]) end end diff --git a/spec/models/alchemy/picture_spec.rb b/spec/models/alchemy/picture_spec.rb index 6a6f394732..04642dad97 100644 --- a/spec/models/alchemy/picture_spec.rb +++ b/spec/models/alchemy/picture_spec.rb @@ -142,9 +142,22 @@ module Alchemy describe ".file_formats" do it "deligates to storage adapter" do - expect(Alchemy.storage_adapter).to receive(:file_formats).with(described_class.name, scope: described_class.all) + expect(Alchemy.storage_adapter).to receive(:file_formats).with( + described_class.name, + scope: described_class.all, + from_extensions: nil + ) described_class.file_formats end + + it "passes from_extensions to storage adapter" do + expect(Alchemy.storage_adapter).to receive(:file_formats).with( + described_class.name, + scope: described_class.all, + from_extensions: %w[png jpg] + ) + described_class.file_formats(from_extensions: %w[png jpg]) + end end describe "#destroy" do diff --git a/spec/models/alchemy/storage_adapter/active_storage_spec.rb b/spec/models/alchemy/storage_adapter/active_storage_spec.rb index 00ced9616d..21eeb42e1a 100644 --- a/spec/models/alchemy/storage_adapter/active_storage_spec.rb +++ b/spec/models/alchemy/storage_adapter/active_storage_spec.rb @@ -24,6 +24,16 @@ is_expected.to eq(["image/jpeg"]) end end + + context "with from_extensions" do + subject(:file_formats) do + described_class.file_formats(class_name, scope: scope, from_extensions: ["jpeg"]) + end + + it "should return only matching picture file formats" do + is_expected.to eq(["image/jpeg"]) + end + end end context "for a Alchemy::Attachment" do @@ -52,6 +62,16 @@ is_expected.to eq ["application/pdf"] end end + + context "with from_extensions" do + subject(:file_formats) do + described_class.file_formats(class_name, scope: scope, from_extensions: ["pdf"]) + end + + it "should return only matching attachment file formats" do + is_expected.to eq(["application/pdf"]) + end + end end end diff --git a/spec/models/alchemy/storage_adapter/dragonfly_spec.rb b/spec/models/alchemy/storage_adapter/dragonfly_spec.rb index 02dfbf51ac..a60c502a49 100644 --- a/spec/models/alchemy/storage_adapter/dragonfly_spec.rb +++ b/spec/models/alchemy/storage_adapter/dragonfly_spec.rb @@ -134,6 +134,16 @@ is_expected.to eq(["jpeg"]) end end + + context "with from_extensions" do + subject(:file_formats) do + described_class.file_formats(class_name, scope: scope, from_extensions: ["jpeg"]) + end + + it "should return only matching picture file formats" do + is_expected.to eq(["jpeg"]) + end + end end context "for a Alchemy::Attachment" do @@ -162,6 +172,16 @@ is_expected.to eq ["application/pdf"] end end + + context "with from_extensions" do + subject(:file_formats) do + described_class.file_formats(class_name, scope: scope, from_extensions: ["pdf"]) + end + + it "should return only matching attachment file formats" do + is_expected.to eq(["application/pdf"]) + end + end end end From 6c6afa4d9d62ac8373b46a11536ee6eb59c95af8 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 27 Jan 2026 15:10:07 +0100 Subject: [PATCH 3/4] feat(Picture): Add only and except setting support The Picture ingredient now supports `only` and `except` settings to constrain which image formats are available in the picture library overlay, matching the existing Attachment/File ingredient behavior. Signed-off-by: Thomas von Deyen --- .../alchemy/admin/pictures_controller.rb | 43 +++++++++++--- app/models/alchemy/ingredients/picture.rb | 2 + app/models/alchemy/picture.rb | 3 +- .../shared/_picture_tools.html.erb | 4 +- .../ingredients/picture_editor_spec.rb | 16 +++++ .../alchemy/admin/pictures_controller_spec.rb | 58 +++++++++++++++++++ spec/dummy/config/alchemy/elements.yml | 2 + .../admin/picture_library_integration_spec.rb | 41 +++++++++++++ spec/models/alchemy/picture_spec.rb | 6 ++ 9 files changed, 165 insertions(+), 10 deletions(-) diff --git a/app/controllers/alchemy/admin/pictures_controller.rb b/app/controllers/alchemy/admin/pictures_controller.rb index 72af9a33ac..6504638e99 100644 --- a/app/controllers/alchemy/admin/pictures_controller.rb +++ b/app/controllers/alchemy/admin/pictures_controller.rb @@ -19,8 +19,15 @@ class PicturesController < Alchemy::Admin::ResourcesController @picture = Picture.find(params[:id]) end - add_alchemy_filter :by_file_format, type: :select, options: ->(query) do - Alchemy::Picture.file_formats(query.result) + add_alchemy_filter :by_file_format, type: :select, options: ->(_query, params) do + case params&.to_h + in {except:} + Alchemy::Picture.file_formats - Alchemy::Picture.file_formats(from_extensions: except) + in {only:} + Alchemy::Picture.file_formats(from_extensions: only) + else + Alchemy::Picture.file_formats + end end add_alchemy_filter :recent, type: :checkbox add_alchemy_filter :last_upload, type: :checkbox @@ -182,12 +189,32 @@ def redirect_to_index end def search_filter_params - @_search_filter_params ||= params.except(*COMMON_SEARCH_FILTER_EXCLUDES + [:picture_ids]).permit( - *common_search_filter_includes + [ - :size, - :form_field_id - ] - ) + @_search_filter_params ||= begin + params[:q] ||= ActionController::Parameters.new + + if params[:only].present? + params[:q][:by_file_format] ||= Picture.file_formats(from_extensions: params[:only]) + end + + if params[:except].present? + params[:q][:by_file_format] ||= Picture.file_formats - Picture.file_formats(from_extensions: params[:except]) + end + + params.except(*COMMON_SEARCH_FILTER_EXCLUDES + [:picture_ids]).permit( + *common_search_filter_includes + [ + :size, + :form_field_id, + {only: []}, + {except: []} + ] + ) + end + end + + def permitted_ransack_search_fields + super + [ + {by_file_format: []} + ] end def picture_params diff --git a/app/models/alchemy/ingredients/picture.rb b/app/models/alchemy/ingredients/picture.rb index f36c08e1c7..237b6bb897 100644 --- a/app/models/alchemy/ingredients/picture.rb +++ b/app/models/alchemy/ingredients/picture.rb @@ -30,8 +30,10 @@ class Picture < Alchemy::Ingredient allow_settings %i[ crop css_classes + except fixed_ratio linkable + only size sizes srcset diff --git a/app/models/alchemy/picture.rb b/app/models/alchemy/picture.rb index a9197b117c..dc78be2ee6 100644 --- a/app/models/alchemy/picture.rb +++ b/app/models/alchemy/picture.rb @@ -82,7 +82,8 @@ def allowed_filetypes scope :named, ->(name) { where("#{table_name}.name LIKE ?", "%#{name}%") } scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) } scope :without_tag, -> { left_outer_joins(:taggings).where(gutentag_taggings: {id: nil}) } - scope :by_file_format, ->(file_format) do + + scope :by_file_format, ->(*file_format) do Alchemy.storage_adapter.by_file_format_scope(file_format) end diff --git a/app/views/alchemy/ingredients/shared/_picture_tools.html.erb b/app/views/alchemy/ingredients/shared/_picture_tools.html.erb index 7b6bed3e6a..8e78d889d8 100644 --- a/app/views/alchemy/ingredients/shared/_picture_tools.html.erb +++ b/app/views/alchemy/ingredients/shared/_picture_tools.html.erb @@ -22,7 +22,9 @@ <%= content_tag "sl-tooltip", content: picture_editor.picture ? Alchemy.t(:swap_image) : Alchemy.t(:insert_image) do %> <%= link_to_dialog render_icon("image-add"), alchemy.admin_pictures_path( - form_field_id: picture_editor.form_field_id(:picture_id) + form_field_id: picture_editor.form_field_id(:picture_id), + only: Array(picture_editor.settings[:only]), + except: Array(picture_editor.settings[:except]) ), { title: Alchemy.t(:choose_image), diff --git a/spec/components/alchemy/ingredients/picture_editor_spec.rb b/spec/components/alchemy/ingredients/picture_editor_spec.rb index 1ac4bae3b5..b5d8861be6 100644 --- a/spec/components/alchemy/ingredients/picture_editor_spec.rb +++ b/spec/components/alchemy/ingredients/picture_editor_spec.rb @@ -105,6 +105,22 @@ end end + context "with settings `only`" do + let(:settings) { {only: "jpeg"} } + + it "renders a link to open the picture library overlay with only jpegs" do + is_expected.to have_selector("a[href*='only%5B%5D=jpeg']") + end + end + + context "with settings `except`" do + let(:settings) { {except: "gif"} } + + it "renders a link to open the picture library overlay without gifs" do + is_expected.to have_selector("a[href*='except%5B%5D=gif']") + end + end + describe "#ingredient_label" do context "with another column given" do it "has for attribute set to ingredient form field id for that column" do diff --git a/spec/controllers/alchemy/admin/pictures_controller_spec.rb b/spec/controllers/alchemy/admin/pictures_controller_spec.rb index 18b9a7f4e7..933b17d891 100644 --- a/spec/controllers/alchemy/admin/pictures_controller_spec.rb +++ b/spec/controllers/alchemy/admin/pictures_controller_spec.rb @@ -121,6 +121,64 @@ module Alchemy end end + describe "by_file_format filter" do + let!(:png) { create(:alchemy_picture, image_file: fixture_file_upload("image.png")) } + + let!(:jpeg) do + create(:alchemy_picture, image_file: fixture_file_upload("image3.jpeg")) + end + + let(:jpeg_format) do + Alchemy.storage_adapter.dragonfly? ? "jpeg" : "image/jpeg" + end + + it "loads only pictures with matching format" do + get :index, params: {q: {by_file_format: jpeg_format}} + expect(assigns(:pictures).to_a).to eq([jpeg]) + expect(assigns(:pictures).to_a).to_not eq([png]) + end + + context "with multiple formats" do + it "loads only pictures with matching format" do + get :index, params: {q: {by_file_format: [jpeg_format]}} + expect(assigns(:pictures).to_a).to eq([jpeg]) + expect(assigns(:pictures).to_a).to_not eq([png]) + end + end + + context "with only param" do + it "populates by_file_format query" do + get :index, params: {only: ["jpeg"]} + expect(assigns(:pictures).to_a).to eq([jpeg]) + expect(assigns(:pictures).to_a).to_not eq([png]) + end + end + + context "with only param and by_file_format query" do + it "uses by_file_format query" do + get :index, params: {only: ["png"], q: {by_file_format: jpeg_format}} + expect(assigns(:pictures).to_a).to eq([jpeg]) + expect(assigns(:pictures).to_a).to_not eq([png]) + end + end + + context "with except param" do + it "populates by_file_format query" do + get :index, params: {except: ["jpeg"]} + expect(assigns(:pictures).to_a).to_not eq([jpeg]) + expect(assigns(:pictures).to_a).to eq([png]) + end + end + + context "with except param and by_file_format query" do + it "uses by_file_format query" do + get :index, params: {except: ["png"], q: {by_file_format: jpeg_format}} + expect(assigns(:pictures).to_a).to eq([jpeg]) + expect(assigns(:pictures).to_a).to_not eq([png]) + end + end + end + context "when params[:form_field_id]" do context "is set" do it "it renders the archive_overlay partial" do diff --git a/spec/dummy/config/alchemy/elements.yml b/spec/dummy/config/alchemy/elements.yml index 60b8cfee8d..ebda4d4287 100644 --- a/spec/dummy/config/alchemy/elements.yml +++ b/spec/dummy/config/alchemy/elements.yml @@ -6,6 +6,7 @@ type: Picture settings: linkable: false + except: gif - name: headline ingredients: @@ -123,6 +124,7 @@ settings: size: 1200x480 crop: true + only: [jpeg] css_classes: - "align-right" - "align-left" diff --git a/spec/features/admin/picture_library_integration_spec.rb b/spec/features/admin/picture_library_integration_spec.rb index 9c30411d70..025a946300 100644 --- a/spec/features/admin/picture_library_integration_spec.rb +++ b/spec/features/admin/picture_library_integration_spec.rb @@ -167,6 +167,47 @@ end end + describe "Filter by only and except params" do + let!(:picture1) { create(:alchemy_picture, name: "Ping", image_file: fixture_file_upload("image.png")) } + let!(:picture2) { create(:alchemy_picture, name: "Jay Peg", image_file: fixture_file_upload("image3.jpeg")) } + + scenario "the list of pictures can be constrained by passing the `only` param" do + visit alchemy.admin_pictures_path(only: ["png"]) + + within "#pictures" do + expect(page).to have_content("Ping") + expect(page).to_not have_content("Jay Peg") + end + end + + scenario "the list of pictures can be constrained by passing the `except` param" do + visit alchemy.admin_pictures_path(except: ["png"]) + + within "#pictures" do + expect(page).to_not have_content("Ping") + expect(page).to have_content("Jay Peg") + end + end + + scenario "the list of file formats can be constrained by passing the `only` param" do + visit alchemy.admin_pictures_path(only: ["png"]) + + within "#library_sidebar" do + expect(page).to have_css("option", text: "PNG") + expect(page).to_not have_css("option", text: "JPG") + end + end + + scenario "the list of file formats can be constrained by passing the `except` param" do + visit alchemy.admin_pictures_path(except: ["png"]) + + within "#library_sidebar" do + expect(page).to_not have_css("option", text: "PNG") + expect(page).to have_css("option", text: "JPG") + end + end + end + describe "Filter by format" do let!(:picture1) { create(:alchemy_picture, name: "Ping", image_file: fixture_file_upload("image.png")) } let!(:picture2) { create(:alchemy_picture, name: "Jay Peg", image_file: fixture_file_upload("image3.jpeg")) } diff --git a/spec/models/alchemy/picture_spec.rb b/spec/models/alchemy/picture_spec.rb index 04642dad97..7911b90627 100644 --- a/spec/models/alchemy/picture_spec.rb +++ b/spec/models/alchemy/picture_spec.rb @@ -101,6 +101,12 @@ module Alchemy end end + describe ".ransackable_scopes" do + it do + expect(described_class.ransackable_scopes).to eq %i[by_file_format recent last_upload without_tag deletable] + end + end + describe ".preprocessor_class" do it "delegates to storage adapter" do expect(Alchemy.storage_adapter).to receive(:preprocessor_class) From 1712cc0130d9fcc45238f401472f6d2244f0bed9 Mon Sep 17 00:00:00 2001 From: Thomas von Deyen Date: Tue, 27 Jan 2026 15:50:28 +0100 Subject: [PATCH 4/4] refactor(AttachmentsController): Only use Attachment.by_file_type scope We never used the not_file_type scope, but used the by_file_type scope with a inverted collection instead. Also use Attachment.file_types(from_extensions:) instead of inline Marcel::MimeType.for conversion. --- .../alchemy/admin/attachments_controller.rb | 12 +++------- app/models/alchemy/attachment.rb | 6 +---- .../admin/attachments_controller_spec.rb | 23 ------------------- spec/models/alchemy/attachment_spec.rb | 4 ++-- 4 files changed, 6 insertions(+), 39 deletions(-) diff --git a/app/controllers/alchemy/admin/attachments_controller.rb b/app/controllers/alchemy/admin/attachments_controller.rb index cd5b4088d8..ac9a90f291 100644 --- a/app/controllers/alchemy/admin/attachments_controller.rb +++ b/app/controllers/alchemy/admin/attachments_controller.rb @@ -85,15 +85,11 @@ def search_filter_params params[:q] ||= ActionController::Parameters.new if params[:only].present? - params[:q][:by_file_type] ||= Array(params[:only]).map do |extension| - Marcel::MimeType.for(extension:) - end + params[:q][:by_file_type] ||= Attachment.file_types(from_extensions: params[:only]) end if params[:except].present? - params[:q][:by_file_type] ||= Attachment.file_types - params[:except].map do |extension| - Marcel::MimeType.for(extension:) - end + params[:q][:by_file_type] ||= Attachment.file_types - Attachment.file_types(from_extensions: params[:except]) end params.except(*COMMON_SEARCH_FILTER_EXCLUDES + [:attachment]).permit( @@ -108,9 +104,7 @@ def search_filter_params def permitted_ransack_search_fields super + [ - {by_file_type: []}, - :not_file_type, - {not_file_type: []} + {by_file_type: []} ] end diff --git a/app/models/alchemy/attachment.rb b/app/models/alchemy/attachment.rb index c74dfc4e78..97a6c09f2c 100644 --- a/app/models/alchemy/attachment.rb +++ b/app/models/alchemy/attachment.rb @@ -35,10 +35,6 @@ class Attachment < BaseRecord Alchemy.storage_adapter.by_file_type_scope(file_type) end - scope :not_file_type, ->(*file_type) do - Alchemy.storage_adapter.not_file_type_scope(file_type) - end - scope :recent, -> { where("#{table_name}.created_at > ?", Time.current - 24.hours).order(:created_at) } scope :without_tag, -> { left_outer_joins(:taggings).where(gutentag_taggings: {id: nil}) } @@ -85,7 +81,7 @@ def allowed_filetypes end def ransackable_scopes(_auth_object = nil) - %i[by_file_type not_file_type recent last_upload without_tag deletable] + %i[by_file_type recent last_upload without_tag deletable] end end diff --git a/spec/controllers/alchemy/admin/attachments_controller_spec.rb b/spec/controllers/alchemy/admin/attachments_controller_spec.rb index 98289e8202..2598705f5a 100644 --- a/spec/controllers/alchemy/admin/attachments_controller_spec.rb +++ b/spec/controllers/alchemy/admin/attachments_controller_spec.rb @@ -107,29 +107,6 @@ module Alchemy expect(assigns(:attachments).to_a).to_not eq([png]) end end - end - - describe "not_file_type filter" do - let!(:png) { create(:alchemy_attachment) } - - let!(:jpg) do - create :alchemy_attachment, - file: fixture_file_upload("image3.jpeg") - end - - it "loads all but attachments with matching content type" do - get :index, params: {q: {not_file_type: "image/jpeg"}} - expect(assigns(:attachments).to_a).to_not eq([jpg]) - expect(assigns(:attachments).to_a).to eq([png]) - end - - context "with multiple content types" do - it "loads all but attachments with matching content type" do - get :index, params: {q: {not_file_type: ["image/jpeg"]}} - expect(assigns(:attachments).to_a).to_not eq([jpg]) - expect(assigns(:attachments).to_a).to eq([png]) - end - end context "with except param" do it "populates by_file_type query" do diff --git a/spec/models/alchemy/attachment_spec.rb b/spec/models/alchemy/attachment_spec.rb index 6578581ef4..8f9d77aeda 100644 --- a/spec/models/alchemy/attachment_spec.rb +++ b/spec/models/alchemy/attachment_spec.rb @@ -47,8 +47,8 @@ module Alchemy end describe ".ransackable_scopes" do - it "delegates to storage adapter" do - expect(described_class.ransackable_scopes).to eq %i[by_file_type not_file_type recent last_upload without_tag deletable] + it do + expect(described_class.ransackable_scopes).to eq %i[by_file_type recent last_upload without_tag deletable] end end