From 187c65248ff1f85bf282efe671bfc7a0d25ade9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karel=20Pi=C4=8Dman?= Date: Fri, 19 Dec 2025 13:44:04 +0100 Subject: [PATCH] #9 Active Storage - thumbnails --- Gemfile | 1 + app/controllers/dmsf_files_controller.rb | 14 --- app/helpers/dmsf_helper.rb | 2 +- app/models/dmsf_file.rb | 25 +---- app/views/dmsf_files/_thumbnails.html.erb | 16 +++- lib/redmine_dmsf/macros.rb | 34 +++---- .../unit/lib/redmine_dmsf/dmsf_macros_test.rb | 94 +++++++++++-------- 7 files changed, 87 insertions(+), 99 deletions(-) diff --git a/Gemfile b/Gemfile index 90735174..19c81d1e 100644 --- a/Gemfile +++ b/Gemfile @@ -20,6 +20,7 @@ source 'https://rubygems.org' do gem 'active_record_union' gem 'activestorage' + gem 'image_processing', '~> 1.2' gem 'ox' # Dav4Rack gem 'rake' unless Dir.exist?(File.expand_path('../../redmine_dashboard', __FILE__)) gem 'simple_enum' diff --git a/app/controllers/dmsf_files_controller.rb b/app/controllers/dmsf_files_controller.rb index d75689ac..2d72c16d 100644 --- a/app/controllers/dmsf_files_controller.rb +++ b/app/controllers/dmsf_files_controller.rb @@ -322,20 +322,6 @@ class DmsfFilesController < ApplicationController redirect_to trash_dmsf_path(@project) end - def thumbnail - tbnail = @file.thumbnail(size: params[:size]) - if tbnail - if stale?(etag: tbnail) - send_file tbnail, - filename: filename_for_content_disposition(@file.name), - type: @file.last_revision.content_type, - disposition: 'inline' - end - else - head :not_found - end - end - private def find_file diff --git a/app/helpers/dmsf_helper.rb b/app/helpers/dmsf_helper.rb index bd306c62..ce5d0070 100644 --- a/app/helpers/dmsf_helper.rb +++ b/app/helpers/dmsf_helper.rb @@ -34,7 +34,7 @@ module DmsfHelper def self.sanitize_filename(filename) # Get only the filename, not the whole path just_filename = File.basename(filename.gsub('\\\\', '/')) - # Replace all non alphanumeric, hyphens or periods with underscore + # Replace all non-alphanumeric, hyphens or periods with underscore just_filename.gsub!(/[^\w.\-]/, '_') # Keep the extension if any if !/^[a-zA-Z0-9_.\-]*$/.match?(just_filename) && just_filename =~ /(.[a-zA-Z0-9]+)$/ diff --git a/app/models/dmsf_file.rb b/app/models/dmsf_file.rb index 28fedc85..e5ccd629 100644 --- a/app/models/dmsf_file.rb +++ b/app/models/dmsf_file.rb @@ -546,7 +546,7 @@ class DmsfFile < ApplicationRecord end def thumbnailable? - Redmine::Thumbnail.convert_available? && (image? || (pdf? && Redmine::Thumbnail.gs_available?)) + last_revision.file&.variable? end def previewable? @@ -632,29 +632,6 @@ class DmsfFile < ApplicationRecord nil end - def thumbnail(options = {}) - size = options[:size].to_i - if size.positive? - # Limit the number of thumbnails per image - size = (size / 50) * 50 - # Maximum thumbnail size - size = 800 if size > 800 - else - size = Setting.thumbnails_size.to_i - end - size = 100 unless size.positive? - target = File.join(Attachment.thumbnails_storage_path, "#{id}_#{last_revision.digest}_#{size}.thumb") - begin - Redmine::Thumbnail.generate last_revision.file.download, target, size, pdf? - rescue StandardError => e - Rails.logger.error do - %(An error occured while generating thumbnail for #{last_revision.file&.blob&.filename} to #{target}\n - Exception was: #{e.message}) - end - nil - end - end - def locked_title if locked_for_user? return l(:title_locked_by_user, user: lock.reverse[0].user) if lock.reverse[0].user diff --git a/app/views/dmsf_files/_thumbnails.html.erb b/app/views/dmsf_files/_thumbnails.html.erb index 3ffb90c9..d8aa7a58 100644 --- a/app/views/dmsf_files/_thumbnails.html.erb +++ b/app/views/dmsf_files/_thumbnails.html.erb @@ -25,12 +25,20 @@
<% end %> <% images.each do |file| %> -
+
<% if link_to # Redmine classic %> - <%= link_to image_tag(dmsf_thumbnail_path(file), alt: file.title), view_dmsf_file_url(file) %> + <% size = Setting.thumbnails_size.to_i %> + <%= link_to image_tag(file.last_revision&.file&.variant(resize_to_limit: [size, size]), + alt: file.title, + style: "max-width: #{size}px; max-height: #{size}px;", + loading: 'lazy'), + view_dmsf_file_url(file) %> <% else # jQuery gallery %> - <%= image_tag(dmsf_thumbnail_path(file), - { :'data-fullsrc' => view_dmsf_file_url(file), alt: file.title }) %> + <%= image_tag(file.last_revision&.file&.variant(resize_to_limit: [size, size]), + :'data-fullsrc' => view_dmsf_file_url(file), + alt: file.title, + style: "max-width: #{size}px; max-height: #{size}px;", + loading: 'lazy') %> <% end %>
<% end %> diff --git a/lib/redmine_dmsf/macros.rb b/lib/redmine_dmsf/macros.rb index 391713d4..7b0ce1ca 100644 --- a/lib/redmine_dmsf/macros.rb +++ b/lib/redmine_dmsf/macros.rb @@ -241,16 +241,22 @@ module RedmineDmsf {{dmsftn(file_id)}} -- with default height 200 (auto width) {{dmsftn(file_id1 file_id2 file_id3)}} -- multiple thumbnails {{dmsftn(file_id, size=300)}} -- with size 300x300 - {{dmsftn(file_id, height=300)}} -- with height (auto width) - {{dmsftn(file_id, width=300)}} -- with width (auto height) + {{dmsftn(file_id, height=300)}} -- with height (default width) + {{dmsftn(file_id, width=300)}} -- with width (default height) {{dmsftn(file_id, size=640x480)}} -- with size 640x480} macro :dmsftn do |_obj, args| raise ArgumentError if args.empty? # Requires file id args, options = extract_macro_options(args, :size, :width, :height, :title) - size = options[:size] - width = options[:width] - height = options[:height] + + if options[:size].present? + width, height = options[:size].split('x') + height = width if height.blank? + else + width = options[:width].presence || Setting.thumbnails_size.to_i + height = options[:height].presence || Setting.thumbnails_size.to_i + end + ids = args[0].split html = [] ids.each do |id| @@ -260,21 +266,17 @@ module RedmineDmsf next end raise ::I18n.t(:notice_not_authorized) unless User.current&.allowed_to?(:view_dmsf_files, file.project) - raise ::I18n.t(:error_not_supported_image_format) unless file.image? + raise ::I18n.t(:error_not_supported_image_format) unless file&.thumbnailable? member = Member.find_by(user_id: User.current.id, project_id: file.project.id) filename = file.last_revision.formatted_name(member) url = static_dmsf_file_url(file, filename: filename) - img = if size - image_tag(url, alt: filename, title: file.title, size: size) - elsif height - image_tag(url, alt: filename, title: file.title, width: 'auto', height: height) - elsif width - image_tag(url, alt: filename, title: file.title, width: width, height: 'auto') - else - image_tag(url, alt: filename, title: file.title, width: 'auto', height: 200) - end - html << link_to(img, url, + img = image_tag(file.last_revision&.file&.variant(resize_to_limit: [width, height]), + alt: filename, + style: "max-width: #{width}px; max-height: #{height}px;", + loading: 'lazy') + html << link_to(img, + url, target: '_blank', rel: 'noopener', title: h(file.last_revision.try(:tooltip)), diff --git a/test/unit/lib/redmine_dmsf/dmsf_macros_test.rb b/test/unit/lib/redmine_dmsf/dmsf_macros_test.rb index 63fed933..aa89d51a 100644 --- a/test/unit/lib/redmine_dmsf/dmsf_macros_test.rb +++ b/test/unit/lib/redmine_dmsf/dmsf_macros_test.rb @@ -254,21 +254,23 @@ class DmsfMacrosTest < RedmineDmsf::Test::HelperTest size = '50%' url = static_dmsf_file_url(@file7, @file7.last_revision.name) text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}") - assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: size, height: size)), text - # TODO: arguments src and with and height are swapped + assert_equal "

#{image_tag(url, alt: @file7.name, title: @file7.title, width: size, height: size)}

", text + # TODO: Swaped parameters src and size # size = '300' # text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}") - # assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: size, height: size)), text - # TODO: arguments src and with and height are swapped + # assert_equal "

#{image_tag(url, alt: @file7.name, title: @file7.title, width: size, height: size)}

", text # size = '640x480' # text = textilizable("{{dmsf_image(#{@file7.id}, size=#{size})}}") - # assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: '640', height: '480')), text + # assert_equal "

#{image_tag(url, alt: @file7.name, title: @file7.title, width: '640', height: '480')}

", + # text height = '480' text = textilizable("{{dmsf_image(#{@file7.id}, height=#{height})}}") - assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: height)), text + assert_equal "

#{image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: height)}

", + text width = '480' text = textilizable("{{dmsf_image(#{@file7.id}, width=#{height})}}") - assert text.include?(image_tag(url, alt: @file7.name, title: @file7.title, width: width, height: 'auto')), text + assert_equal "

#{image_tag(url, alt: @file7.name, title: @file7.title, width: width, height: 'auto')}

", + text end def test_macro_dmsf_image_no_permissions @@ -344,74 +346,86 @@ class DmsfMacrosTest < RedmineDmsf::Test::HelperTest def test_macro_dmsftn text = textilizable("{{dmsftn(#{@file7.id})}}") url = static_dmsf_file_url(@file7, @file7.last_revision.name) - img = image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: 200) + size = Setting.thumbnails_size.to_i + img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [size, size]), + alt: @file7.name, + style: "max-width: #{size}px; max-height: #{size}px;", + loading: 'lazy') link = link_to(img, url, target: '_blank', rel: 'noopener', title: h(@file7.last_revision.try(:tooltip)), 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}") - assert text.include?(link), text + assert_equal "

#{link}

", text end # {{dmsftn(file_id file_id)}} def test_macro_dmsftn_multiple text = textilizable("{{dmsftn(#{@file7.id} #{@file7.id})}}") url = static_dmsf_file_url(@file7, @file7.last_revision.name) - img = image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: 200) + img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [100, 100]), + alt: @file7.name, + style: 'max-width: 100px; max-height: 100px;', + loading: 'lazy') link = link_to(img, url, target: '_blank', rel: 'noopener', title: h(@file7.last_revision.try(:tooltip)), 'data-downloadurl': 'image/gif:test.gif:http://www.example.com/dmsf/files/7/test.gif') - assert text.include?(link + link), text + assert_equal "

#{link}#{link}

", text end # {{dmsftn(file_id size=300)}} def test_macro_dmsftn_size url = static_dmsf_file_url(@file7, @file7.last_revision.name) - size = '300' - text = textilizable("{{dmsftn(#{@file7.id}, size=#{size})}}") - img = image_tag(url, alt: @file7.name, title: @file7.title, size: size) + size = Setting.thumbnails_size.to_i + + # Size + text = textilizable("{{dmsftn(#{@file7.id}, size=300)}}") + img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [300, 300]), + alt: @file7.name, + style: 'max-width: 300px; max-height: 300px;', + loading: 'lazy') link = link_to(img, url, target: '_blank', rel: 'noopener', title: h(@file7.last_revision.try(:tooltip)), 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}") - assert text.include?(link), text - # TODO: arguments src and with and height are swapped - # size = '640x480' - # text = textilizable("{{dmsftn(#{@file7.id}, size=#{size})}}") - # img = image_tag(url, alt: @file7.name, title: @file7.title, width: 640, height: 480) - # link = link_to(img, - # url, - # target: '_blank', - # rel: 'noopener', - # title: h(@file7.last_revision.try(:tooltip)), - # 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}") - # assert text.include?(link), text - height = '480' - text = textilizable("{{dmsftn(#{@file7.id}, height=#{height})}}") - img = image_tag(url, alt: @file7.name, title: @file7.title, width: 'auto', height: 480) - link = link_to(img, - url, - target: '_blank', - rel: 'noopener', - title: h(@file7.last_revision.try(:tooltip)), - 'data-downloadurl': 'image/gif:test.gif:http://www.example.com/dmsf/files/7/test.gif') - assert text.include?(link), text - width = '640' - text = textilizable("{{dmsftn(#{@file7.id}, width=#{width})}}") - img = image_tag(url, alt: @file7.name, title: @file7.title, width: 640, height: 'auto') + assert_equal "

#{link.gsub(/redirect\/.*\/#{@file7.name}/, '...')}

", + text.gsub(/redirect\/.*\/#{@file7.name}/, '...') + + # Height + text = textilizable("{{dmsftn(#{@file7.id}, height=480)}}") + img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [size, 480]), + alt: @file7.name, + style: "max-width: #{size}px; max-height: 480px;", + loading: 'lazy') link = link_to(img, url, target: '_blank', rel: 'noopener', title: h(@file7.last_revision.try(:tooltip)), 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}") - assert text.include?(link), text + assert_equal "

#{link.gsub(/redirect\/.*\/#{@file7.name}/, '...')}

", + text.gsub(/redirect\/.*\/#{@file7.name}/, '...') + + # Width + text = textilizable("{{dmsftn(#{@file7.id}, width=640)}}") + img = image_tag(@file7.last_revision&.file&.variant(resize_to_limit: [640, size]), + alt: @file7.name, + style: "max-width: 640px; max-height: #{size}px;", + loading: 'lazy') + link = link_to(img, + url, + target: '_blank', + rel: 'noopener', + title: h(@file7.last_revision.try(:tooltip)), + 'data-downloadurl' => "#{@file7.last_revision.content_type}:#{h(@file7.name)}:#{url}") + assert_equal "

#{link.gsub(/redirect\/.*\/#{@file7.name}/, '...')}

", + text.gsub(/redirect\/.*\/#{@file7.name}/, '...') end def test_macro_dmsftn_no_permissions