From 7bdf53f49f442655eb9ffa53f256ada295904c32 Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Sun, 19 Apr 2026 16:54:39 +0900 Subject: [PATCH] Add ZIP export for all pages in a project's wiki --- app/controllers/wiki_controller.rb | 55 ++++++++++++++++++++ app/views/wiki/date_index.html.erb | 1 + app/views/wiki/index.html.erb | 1 + test/functional/wiki_controller_test.rb | 68 +++++++++++++++++++++++++ test/integration/routing/wiki_test.rb | 1 + 5 files changed, 126 insertions(+) diff --git a/app/controllers/wiki_controller.rb b/app/controllers/wiki_controller.rb index bcb3b0891..eaac9fc02 100644 --- a/app/controllers/wiki_controller.rb +++ b/app/controllers/wiki_controller.rb @@ -17,6 +17,8 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +require 'zip' + # The WikiController follows the Rails REST controller pattern but with # a few differences # @@ -320,6 +322,14 @@ class WikiController < ApplicationController format.pdf do send_file_headers! :type => 'application/pdf', :filename => "#{@project.identifier}.pdf" end + format.zip do + file_name = "#{@project.identifier}-wiki.zip" + send_data( + wiki_pages_to_zip(@pages), + :type => Redmine::MimeType.of(file_name), + :filename => filename_for_content_disposition(file_name) + ) + end end end @@ -400,4 +410,49 @@ class WikiController < ApplicationController includes(:parent). to_a end + + def wiki_pages_to_zip(pages) + Zip.unicode_names = true + archived_file_names = [] + buffer = Zip::OutputStream.write_buffer do |zos| + pages.each do |page| + filename = archived_wiki_page_filename(page, archived_file_names) + entry = Zip::Entry.new('', filename) + if page.updated_on.present? + local_time = User.current.convert_time_to_user_timezone(page.updated_on) + # DOS timestamp stores user's displayed local time + entry.time = Zip::DOSTime.new( + local_time.year, local_time.month, local_time.day, + local_time.hour, local_time.min, local_time.sec + ) + # UT extra field stores time in UTC + entry.extra[:universaltime].mtime = local_time.utc + end + zos.put_next_entry(entry) + zos << page.content.text.to_s + end + end + buffer.string + ensure + buffer&.close + end + + def archived_wiki_page_filename(page, archived_file_names) + extension = '.txt' + # Wiki titles are already titleized/validated to exclude spaces and the + # characters ',', '.', '/', '?', ';', '|', ':'. We also normalize + # '\', '*', '"', '<', '>' here so ZIP entries stay portable across + # unzip tools and filesystems, especially on Windows. + sanitized_title = page.title.gsub(/[\\*"<>]/, '_') + filename = "#{sanitized_title}#{extension}" + dup_count = 0 + + while archived_file_names.include?(filename) + dup_count += 1 + filename = "#{sanitized_title}(#{dup_count})#{extension}" + end + + archived_file_names << filename + filename + end end diff --git a/app/views/wiki/date_index.html.erb b/app/views/wiki/date_index.html.erb index c8acf933c..cb82bff16 100644 --- a/app/views/wiki/date_index.html.erb +++ b/app/views/wiki/date_index.html.erb @@ -32,6 +32,7 @@ <% if User.current.allowed_to?(:export_wiki_pages, @project) %> <%= f.link_to('PDF', :url => {:action => 'export', :format => 'pdf'}) %> <%= f.link_to('HTML', :url => {:action => 'export'}) %> + <%= f.link_to('ZIP', :url => {:action => 'export', :format => 'zip'}) %> <% end %> <%= f.link_to 'Atom', :url => {:controller => 'activities', :action => 'index', :id => @project, :show_wiki_edits => 1, :key => User.current.atom_key} %> <% end %> diff --git a/app/views/wiki/index.html.erb b/app/views/wiki/index.html.erb index a4afcb28e..d3fd300fd 100644 --- a/app/views/wiki/index.html.erb +++ b/app/views/wiki/index.html.erb @@ -25,6 +25,7 @@ <% if User.current.allowed_to?(:export_wiki_pages, @project) %> <%= f.link_to('PDF', :url => {:action => 'export', :format => 'pdf'}) %> <%= f.link_to('HTML', :url => {:action => 'export'}) %> + <%= f.link_to('ZIP', :url => {:action => 'export', :format => 'zip'}) %> <% end %> <%= f.link_to 'Atom', :url => {:controller => 'activities', :action => 'index', diff --git a/test/functional/wiki_controller_test.rb b/test/functional/wiki_controller_test.rb index d96fde4c8..59b8e94e9 100644 --- a/test/functional/wiki_controller_test.rb +++ b/test/functional/wiki_controller_test.rb @@ -1146,6 +1146,56 @@ class WikiControllerTest < Redmine::ControllerTest assert @response.body.starts_with?('%PDF') end + def test_export_to_zip + with_settings :text_formatting => 'textile' do + user = User.find(2) + user.pref.update!(:time_zone => 'Tokyo') + + @request.session[:user_id] = user.id + get :export, :params => {:project_id => 'ecookbook', :format => 'zip'} + + assert_response :success + assert_equal 'application/zip', @response.media_type + assert_equal "attachment; filename=\"ecookbook-wiki.zip\"; filename*=UTF-8''ecookbook-wiki.zip", + @response.headers['Content-Disposition'] + + pages = Project.find(1).wiki.pages.includes(:content).to_a.index_by(&:title) + zip_entries = zip_entries_from_response + + assert_equal pages.keys.sort.map {|title| "#{title}.txt"}, zip_entries.keys.sort + + zip_entries.each do |name, entry| + title = name.delete_suffix('.txt') + page = pages.fetch(title) + local_time = user.convert_time_to_user_timezone(page.updated_on) + + assert_equal page.content.text, entry[:content] + # DOS timestamp should match the user's displayed local time. + assert_equal [local_time.year, local_time.month, local_time.day, local_time.hour, local_time.min, local_time.sec], + [entry[:time].year, entry[:time].month, entry[:time].day, entry[:time].hour, entry[:time].min, entry[:time].sec] + # UT extra field should store the corresponding absolute UTC time. + assert_equal local_time.utc.to_i, entry[:utc_time].utc.to_i + end + end + end + + def test_export_to_zip_should_sanitize_non_portable_entry_name_characters + with_settings :text_formatting => 'textile' do + page = Project.find(1).wiki.pages.new(:title => 'Foo*') + page.content = WikiContent.new(:text => 'sanitized') + assert_save page + + @request.session[:user_id] = 2 + get :export, :params => {:project_id => 'ecookbook', :format => 'zip'} + + assert_response :success + + zip_entries = zip_entries_from_response + assert_equal 'sanitized', zip_entries['Foo_.txt'][:content] + assert_not_includes zip_entries.keys, 'Foo*.txt' + end + end + def test_export_without_permission_should_be_denied @request.session[:user_id] = 2 Role.find_by_name('Manager').remove_permission! :export_wiki_pages @@ -1333,4 +1383,22 @@ class WikiControllerTest < Redmine::ControllerTest assert_response :success assert_select 'head>meta[name="robots"]', false end + + private + + def zip_entries_from_response + entries = {} + + Zip::InputStream.open(StringIO.new(@response.body)) do |io| + while (entry = io.get_next_entry) + entries[entry.name] = { + :content => io.read, + :time => entry.time, + :utc_time => entry.extra[:universaltime]&.mtime + } + end + end + + entries.transform_keys {|name| name.dup.force_encoding('UTF-8')} + end end diff --git a/test/integration/routing/wiki_test.rb b/test/integration/routing/wiki_test.rb index 571c7a846..72c4c9445 100644 --- a/test/integration/routing/wiki_test.rb +++ b/test/integration/routing/wiki_test.rb @@ -26,6 +26,7 @@ class RoutingWikiTest < Redmine::RoutingTest should_route 'GET /projects/foo/wiki/date_index' => 'wiki#date_index', :project_id => 'foo' should_route 'GET /projects/foo/wiki/export' => 'wiki#export', :project_id => 'foo' should_route 'GET /projects/foo/wiki/export.pdf' => 'wiki#export', :project_id => 'foo', :format => 'pdf' + should_route 'GET /projects/foo/wiki/export.zip' => 'wiki#export', :project_id => 'foo', :format => 'zip' end def test_wiki_pages -- 2.50.1