Feature #22923 » 0001-Add-ODT-export-for-wiki-pages.patch
Gemfile | ||
---|---|---|
15 | 15 |
gem "actionpack-xml_parser" |
16 | 16 |
gem "roadie-rails" |
17 | 17 |
gem "mimemagic" |
18 |
gem "html2odt", "~> 0.3.3" |
|
18 | 19 | |
19 | 20 |
# Request at least nokogiri 1.6.7.2 because of security advisories |
20 | 21 |
gem "nokogiri", ">= 1.6.7.2" |
21 | 22 | |
22 |
# Request at least rails-html-sanitizer 1.0.3 because of security advisories
|
|
23 |
# Request at least rails-html-sanitizer 1.0.3 because of security advisories |
|
23 | 24 |
gem "rails-html-sanitizer", ">= 1.0.3" |
24 | 25 | |
25 | 26 |
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem |
app/controllers/wiki_controller.rb | ||
---|---|---|
101 | 101 |
export = render_to_string :action => 'export', :layout => false |
102 | 102 |
send_data(export, :type => 'text/html', :filename => "#{@page.title}.html") |
103 | 103 |
return |
104 |
elsif params[:format] == 'odt' |
|
105 |
send_file_headers! :type => 'application/vnd.oasis.opendocument.text', |
|
106 |
:filename => "#{@page.title}.odt" |
|
107 |
render :inline => "<%= raw wiki_page_to_odt(@page, @project) %>" |
|
108 |
return |
|
104 | 109 |
elsif params[:format] == 'txt' |
105 | 110 |
send_data(@content.text, :type => 'text/plain', :filename => "#{@page.title}.txt") |
106 | 111 |
return |
... | ... | |
305 | 310 |
format.pdf { |
306 | 311 |
send_file_headers! :type => 'application/pdf', :filename => "#{@project.identifier}.pdf" |
307 | 312 |
} |
313 |
format.odt { |
|
314 |
send_file_headers! :type => 'application/vnd.oasis.opendocument.text', |
|
315 |
:filename => "#{@project.identifier}.odt" |
|
316 |
render :inline => "<%= raw wiki_pages_to_odt(@pages, @project) %>" |
|
317 |
} |
|
308 | 318 |
end |
309 | 319 |
end |
310 | 320 |
app/helpers/wiki_helper.rb | ||
---|---|---|
19 | 19 | |
20 | 20 |
module WikiHelper |
21 | 21 |
include Redmine::Export::PDF::WikiPdfHelper |
22 |
include Redmine::Export::ODT::WikiOdtHelper |
|
22 | 23 | |
23 | 24 |
def wiki_page_options_for_select(pages, selected = nil, parent = nil, level = 0) |
24 | 25 |
pages = pages.group_by(&:parent) unless pages.is_a?(Hash) |
app/views/wiki/index.html.erb | ||
---|---|---|
26 | 26 |
<% if User.current.allowed_to?(:export_wiki_pages, @project) %> |
27 | 27 |
<%= f.link_to('PDF', :url => {:action => 'export', :format => 'pdf'}) %> |
28 | 28 |
<%= f.link_to('HTML', :url => {:action => 'export'}) %> |
29 |
<%= f.link_to('ODT', :url => {:action => 'export', :format => 'odt'}) %> |
|
29 | 30 |
<% end %> |
30 | 31 |
<% end %> |
31 | 32 |
<% end %> |
app/views/wiki/show.html.erb | ||
---|---|---|
31 | 31 |
<%= "#{l(:label_version)} #{@content.version}/#{@page.content.version}" %> |
32 | 32 |
<%= '('.html_safe + link_to(l(:label_diff), :controller => 'wiki', :action => 'diff', |
33 | 33 |
:id => @page.title, :project_id => @page.project, |
34 |
:version => @content.version) + ')'.html_safe if @content.previous %> -
|
|
34 |
:version => @content.version) + ')'.html_safe if @content.previous %> - |
|
35 | 35 |
<%= link_to((l(:label_next) + " \xc2\xbb"), :action => 'show', |
36 | 36 |
:id => @page.title, :project_id => @page.project, |
37 | 37 |
:version => @content.next.version) + " - " if @content.next %> |
... | ... | |
68 | 68 |
<% other_formats_links do |f| %> |
69 | 69 |
<%= f.link_to 'PDF', :url => {:id => @page.title, :version => params[:version]} %> |
70 | 70 |
<%= f.link_to 'HTML', :url => {:id => @page.title, :version => params[:version]} %> |
71 |
<%= f.link_to 'ODT', :url => {:id => @page.title, :version => params[:version]} %> |
|
71 | 72 |
<%= f.link_to 'TXT', :url => {:id => @page.title, :version => params[:version]} %> |
72 | 73 |
<% end if User.current.allowed_to?(:export_wiki_pages, @project) %> |
73 | 74 |
config/initializers/20-mime_types.rb | ||
---|---|---|
2 | 2 | |
3 | 3 |
Mime::SET << Mime::CSV unless Mime::SET.include?(Mime::CSV) |
4 | 4 | |
5 |
Mime::Type.register "application/vnd.oasis.opendocument.text", :odt |
lib/redmine/export/odt/wiki_odt_helper.rb | ||
---|---|---|
1 |
module Redmine |
|
2 |
module Export |
|
3 |
module ODT |
|
4 |
module WikiOdtHelper |
|
5 |
def wiki_pages_to_odt(pages, project) |
|
6 |
doc = Html2Odt::Document.new |
|
7 | ||
8 |
doc.image_location_mapping = image_location_mapping_proc(pages.map(&:attachments).flatten) |
|
9 | ||
10 |
doc.html = cleanup_html(html_for_page_hierarchy(pages.group_by(&:parent_id))) |
|
11 |
doc.base_uri = project_wiki_index_url(project) |
|
12 | ||
13 |
doc.title = project.name |
|
14 |
doc.author = User.current.name |
|
15 | ||
16 |
doc.data |
|
17 |
end |
|
18 | ||
19 |
def wiki_page_to_odt(page, project) |
|
20 |
doc = Html2Odt::Document.new |
|
21 | ||
22 |
doc.image_location_mapping = image_location_mapping_proc(page.attachments) |
|
23 |
doc.base_uri = project_wiki_page_url(project, page) |
|
24 | ||
25 |
doc.author = User.current.name |
|
26 |
doc.title = "#{project.name} - #{page.title}" |
|
27 | ||
28 |
doc.html = cleanup_html(html_for_page_hierarchy(nil => [page])) |
|
29 | ||
30 |
doc.data |
|
31 |
end |
|
32 | ||
33 |
protected |
|
34 | ||
35 |
def html_for_page_hierarchy(pages, node = nil, level = 0) |
|
36 |
return "" if pages[node].blank? |
|
37 | ||
38 |
html = "" |
|
39 | ||
40 |
pages[node].each do |page| |
|
41 |
html += "<hr/>\n" unless level == 0 && page == pages[node].first |
|
42 | ||
43 |
html += textilizable(page.content, :text, |
|
44 |
:only_path => false, |
|
45 |
:edit_section_links => false, |
|
46 |
:headings => false) |
|
47 | ||
48 |
html += html_for_page_hierarchy(pages, page.id, level + 1) |
|
49 |
end |
|
50 | ||
51 |
html |
|
52 |
end |
|
53 | ||
54 |
def cleanup_html(html) |
|
55 |
# Strip {{toc}} tags |
|
56 |
# |
|
57 |
# The links generated within the toc-pseudo-macro will not work inside |
|
58 |
# the ODT, so let's remove it.. |
|
59 |
html = html.gsub(/<p>\{\{([<>]?)toc.*?\}\}<\/p>/i, '') |
|
60 | ||
61 | ||
62 |
# Cleanup {{collapse}} macro output |
|
63 |
# |
|
64 |
# The collapse macro is generating the following (simplified) markup: |
|
65 |
# |
|
66 |
# <p> |
|
67 |
# <a class="collapsible collapsed">Open link</a> |
|
68 |
# <a class="collapsible">Close link</a> |
|
69 |
# <div class="collapsed-text">Content</div> |
|
70 |
# </p> |
|
71 |
# |
|
72 |
# An HTML parser (like Nokogiri or any browser) will create the |
|
73 |
# following DOM |
|
74 |
# |
|
75 |
# <p> |
|
76 |
# <a class="collapsible collapsed">Open link</a> |
|
77 |
# <a class="collapsible">Close link</a> |
|
78 |
# </p> |
|
79 |
# <div class="collapsed-text">Content</div> |
|
80 |
# <p/> |
|
81 |
# |
|
82 |
# So we're trying to remove the first p, containing the links, and |
|
83 |
# we're replacing the div.collapsed-text with its content. The |
|
84 |
# remaining p is difficult to target (or does not seem to be created |
|
85 |
# in Nokogiri), so we're leaving that one alone. |
|
86 |
# |
|
87 |
# In a previous version, we were only removing the links themselves, |
|
88 |
# but this lead to errors in html2odt's own HTML cleanup (elements, |
|
89 |
# that needed fixing were not found). |
|
90 |
doc = Nokogiri::HTML::DocumentFragment.parse(html) |
|
91 | ||
92 |
doc.css(".collapsible.collapsed").each do |collapsed_links| |
|
93 |
collapsed_links.parent.remove |
|
94 |
end |
|
95 | ||
96 |
doc.css(".collapsed-text").each do |collapsed_text| |
|
97 |
collapsed_text.replace collapsed_text.children |
|
98 |
end |
|
99 | ||
100 |
html = doc.to_xml(:save_with => Nokogiri::XML::Node::SaveOptions::AS_XML) |
|
101 | ||
102 |
html |
|
103 |
end |
|
104 | ||
105 |
def image_location_mapping_proc(attachments) |
|
106 |
lambda do |src| |
|
107 |
# See if src maps to local URL which happens to be an attachment of |
|
108 |
# this wiki page |
|
109 | ||
110 |
if src =~ /\Ahttps?:\/\// |
|
111 |
# Ignore URLs pointing to other hosts |
|
112 | ||
113 |
src_uri = URI.parse(src) rescue nil |
|
114 | ||
115 |
next unless src_uri |
|
116 | ||
117 |
src_base_url = "#{src_uri.scheme}://#{src_uri.host}" |
|
118 |
if src_uri.default_port != src_uri.port |
|
119 |
src_base_url += ":#{src_uri.port}" |
|
120 |
end |
|
121 | ||
122 |
next if request.base_url != src_base_url |
|
123 |
else |
|
124 |
# Ignore URLs with protocols != http(s) |
|
125 |
next if src.include? "://" |
|
126 |
end |
|
127 | ||
128 |
# Include public images |
|
129 |
# |
|
130 |
public_path = File.join(Rails.public_path, src) |
|
131 | ||
132 |
# but make sure, that we're not vulnerable to directory traversal attacks |
|
133 |
# |
|
134 |
# File.realpath accesses file system and raises Errno::ENOENT if file |
|
135 |
# does not exist |
|
136 |
valid_path = File.realpath(public_path).starts_with?(Rails.public_path.to_s) rescue false |
|
137 |
next public_path if valid_path and File.readable?(public_path) |
|
138 | ||
139 | ||
140 |
# Include attached images |
|
141 |
# |
|
142 |
path = Rails.application.routes.recognize_path(src) rescue nil |
|
143 | ||
144 |
next if path.blank? |
|
145 |
next if path[:controller] != "attachments" |
|
146 |
next if path[:id].blank? |
|
147 | ||
148 |
attachment = attachments.find { |a| a.to_param == path[:id] } |
|
149 | ||
150 |
if path[:action] == "thumbnail" and path[:size].present? |
|
151 |
return attachment.thumbnail(size: path[:size]) |
|
152 |
end |
|
153 | ||
154 |
if path[:action] == "download" |
|
155 |
return attachment.diskfile |
|
156 |
end |
|
157 |
end |
|
158 |
end |
|
159 |
end |
|
160 |
end |
|
161 |
end |
|
162 |
end |
- « Previous
- 1
- …
- 3
- 4
- 5
- Next »