##// END OF EJS Templates
Merged r15431 to r15435 (#22924, #22925, #22926)....
Jean-Philippe Lang -
r15059:ee408687c61d
parent child
Show More
@@ -0,0 +1,35
1 # Redmine - project management software
2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require 'uri'
19
20 module Redmine
21 module Helpers
22 module URL
23 def uri_with_safe_scheme?(uri, schemes = ['http', 'https', 'ftp', 'mailto', nil])
24 # URLs relative to the current document or document root (without a protocol
25 # separator, should be harmless
26 return true unless uri.include? ":"
27
28 # Other URLs need to be parsed
29 schemes.include? URI.parse(uri).scheme
30 rescue URI::InvalidURIError
31 false
32 end
33 end
34 end
35 end
@@ -1,1337 +1,1338
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27 include Redmine::Pagination::Helper
28 28 include Redmine::SudoMode::Helper
29 29 include Redmine::Themes::Helper
30 30 include Redmine::Hook::Helper
31 include Redmine::Helpers::URL
31 32
32 33 extend Forwardable
33 34 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
34 35
35 36 # Return true if user is authorized for controller/action, otherwise false
36 37 def authorize_for(controller, action)
37 38 User.current.allowed_to?({:controller => controller, :action => action}, @project)
38 39 end
39 40
40 41 # Display a link if user is authorized
41 42 #
42 43 # @param [String] name Anchor text (passed to link_to)
43 44 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
44 45 # @param [optional, Hash] html_options Options passed to link_to
45 46 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
46 47 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
47 48 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
48 49 end
49 50
50 51 # Displays a link to user's account page if active
51 52 def link_to_user(user, options={})
52 53 if user.is_a?(User)
53 54 name = h(user.name(options[:format]))
54 55 if user.active? || (User.current.admin? && user.logged?)
55 56 link_to name, user_path(user), :class => user.css_classes
56 57 else
57 58 name
58 59 end
59 60 else
60 61 h(user.to_s)
61 62 end
62 63 end
63 64
64 65 # Displays a link to +issue+ with its subject.
65 66 # Examples:
66 67 #
67 68 # link_to_issue(issue) # => Defect #6: This is the subject
68 69 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
69 70 # link_to_issue(issue, :subject => false) # => Defect #6
70 71 # link_to_issue(issue, :project => true) # => Foo - Defect #6
71 72 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
72 73 #
73 74 def link_to_issue(issue, options={})
74 75 title = nil
75 76 subject = nil
76 77 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
77 78 if options[:subject] == false
78 79 title = issue.subject.truncate(60)
79 80 else
80 81 subject = issue.subject
81 82 if truncate_length = options[:truncate]
82 83 subject = subject.truncate(truncate_length)
83 84 end
84 85 end
85 86 only_path = options[:only_path].nil? ? true : options[:only_path]
86 87 s = link_to(text, issue_url(issue, :only_path => only_path),
87 88 :class => issue.css_classes, :title => title)
88 89 s << h(": #{subject}") if subject
89 90 s = h("#{issue.project} - ") + s if options[:project]
90 91 s
91 92 end
92 93
93 94 # Generates a link to an attachment.
94 95 # Options:
95 96 # * :text - Link text (default to attachment filename)
96 97 # * :download - Force download (default: false)
97 98 def link_to_attachment(attachment, options={})
98 99 text = options.delete(:text) || attachment.filename
99 100 route_method = options.delete(:download) ? :download_named_attachment_url : :named_attachment_url
100 101 html_options = options.slice!(:only_path)
101 102 options[:only_path] = true unless options.key?(:only_path)
102 103 url = send(route_method, attachment, attachment.filename, options)
103 104 link_to text, url, html_options
104 105 end
105 106
106 107 # Generates a link to a SCM revision
107 108 # Options:
108 109 # * :text - Link text (default to the formatted revision)
109 110 def link_to_revision(revision, repository, options={})
110 111 if repository.is_a?(Project)
111 112 repository = repository.repository
112 113 end
113 114 text = options.delete(:text) || format_revision(revision)
114 115 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
115 116 link_to(
116 117 h(text),
117 118 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
118 119 :title => l(:label_revision_id, format_revision(revision)),
119 120 :accesskey => options[:accesskey]
120 121 )
121 122 end
122 123
123 124 # Generates a link to a message
124 125 def link_to_message(message, options={}, html_options = nil)
125 126 link_to(
126 127 message.subject.truncate(60),
127 128 board_message_url(message.board_id, message.parent_id || message.id, {
128 129 :r => (message.parent_id && message.id),
129 130 :anchor => (message.parent_id ? "message-#{message.id}" : nil),
130 131 :only_path => true
131 132 }.merge(options)),
132 133 html_options
133 134 )
134 135 end
135 136
136 137 # Generates a link to a project if active
137 138 # Examples:
138 139 #
139 140 # link_to_project(project) # => link to the specified project overview
140 141 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
141 142 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
142 143 #
143 144 def link_to_project(project, options={}, html_options = nil)
144 145 if project.archived?
145 146 h(project.name)
146 147 else
147 148 link_to project.name,
148 149 project_url(project, {:only_path => true}.merge(options)),
149 150 html_options
150 151 end
151 152 end
152 153
153 154 # Generates a link to a project settings if active
154 155 def link_to_project_settings(project, options={}, html_options=nil)
155 156 if project.active?
156 157 link_to project.name, settings_project_path(project, options), html_options
157 158 elsif project.archived?
158 159 h(project.name)
159 160 else
160 161 link_to project.name, project_path(project, options), html_options
161 162 end
162 163 end
163 164
164 165 # Generates a link to a version
165 166 def link_to_version(version, options = {})
166 167 return '' unless version && version.is_a?(Version)
167 168 options = {:title => format_date(version.effective_date)}.merge(options)
168 169 link_to_if version.visible?, format_version_name(version), version_path(version), options
169 170 end
170 171
171 172 # Helper that formats object for html or text rendering
172 173 def format_object(object, html=true, &block)
173 174 if block_given?
174 175 object = yield object
175 176 end
176 177 case object.class.name
177 178 when 'Array'
178 179 object.map {|o| format_object(o, html)}.join(', ').html_safe
179 180 when 'Time'
180 181 format_time(object)
181 182 when 'Date'
182 183 format_date(object)
183 184 when 'Fixnum'
184 185 object.to_s
185 186 when 'Float'
186 187 sprintf "%.2f", object
187 188 when 'User'
188 189 html ? link_to_user(object) : object.to_s
189 190 when 'Project'
190 191 html ? link_to_project(object) : object.to_s
191 192 when 'Version'
192 193 html ? link_to_version(object) : object.to_s
193 194 when 'TrueClass'
194 195 l(:general_text_Yes)
195 196 when 'FalseClass'
196 197 l(:general_text_No)
197 198 when 'Issue'
198 199 object.visible? && html ? link_to_issue(object) : "##{object.id}"
199 200 when 'CustomValue', 'CustomFieldValue'
200 201 if object.custom_field
201 202 f = object.custom_field.format.formatted_custom_value(self, object, html)
202 203 if f.nil? || f.is_a?(String)
203 204 f
204 205 else
205 206 format_object(f, html, &block)
206 207 end
207 208 else
208 209 object.value.to_s
209 210 end
210 211 else
211 212 html ? h(object) : object.to_s
212 213 end
213 214 end
214 215
215 216 def wiki_page_path(page, options={})
216 217 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
217 218 end
218 219
219 220 def thumbnail_tag(attachment)
220 221 link_to image_tag(thumbnail_path(attachment)),
221 222 named_attachment_path(attachment, attachment.filename),
222 223 :title => attachment.filename
223 224 end
224 225
225 226 def toggle_link(name, id, options={})
226 227 onclick = "$('##{id}').toggle(); "
227 228 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
228 229 onclick << "return false;"
229 230 link_to(name, "#", :onclick => onclick)
230 231 end
231 232
232 233 def format_activity_title(text)
233 234 h(truncate_single_line_raw(text, 100))
234 235 end
235 236
236 237 def format_activity_day(date)
237 238 date == User.current.today ? l(:label_today).titleize : format_date(date)
238 239 end
239 240
240 241 def format_activity_description(text)
241 242 h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
242 243 ).gsub(/[\r\n]+/, "<br />").html_safe
243 244 end
244 245
245 246 def format_version_name(version)
246 247 if version.project == @project
247 248 h(version)
248 249 else
249 250 h("#{version.project} - #{version}")
250 251 end
251 252 end
252 253
253 254 def due_date_distance_in_words(date)
254 255 if date
255 256 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
256 257 end
257 258 end
258 259
259 260 # Renders a tree of projects as a nested set of unordered lists
260 261 # The given collection may be a subset of the whole project tree
261 262 # (eg. some intermediate nodes are private and can not be seen)
262 263 def render_project_nested_lists(projects, &block)
263 264 s = ''
264 265 if projects.any?
265 266 ancestors = []
266 267 original_project = @project
267 268 projects.sort_by(&:lft).each do |project|
268 269 # set the project environment to please macros.
269 270 @project = project
270 271 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
271 272 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
272 273 else
273 274 ancestors.pop
274 275 s << "</li>"
275 276 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
276 277 ancestors.pop
277 278 s << "</ul></li>\n"
278 279 end
279 280 end
280 281 classes = (ancestors.empty? ? 'root' : 'child')
281 282 s << "<li class='#{classes}'><div class='#{classes}'>"
282 283 s << h(block_given? ? capture(project, &block) : project.name)
283 284 s << "</div>\n"
284 285 ancestors << project
285 286 end
286 287 s << ("</li></ul>\n" * ancestors.size)
287 288 @project = original_project
288 289 end
289 290 s.html_safe
290 291 end
291 292
292 293 def render_page_hierarchy(pages, node=nil, options={})
293 294 content = ''
294 295 if pages[node]
295 296 content << "<ul class=\"pages-hierarchy\">\n"
296 297 pages[node].each do |page|
297 298 content << "<li>"
298 299 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
299 300 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
300 301 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
301 302 content << "</li>\n"
302 303 end
303 304 content << "</ul>\n"
304 305 end
305 306 content.html_safe
306 307 end
307 308
308 309 # Renders flash messages
309 310 def render_flash_messages
310 311 s = ''
311 312 flash.each do |k,v|
312 313 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
313 314 end
314 315 s.html_safe
315 316 end
316 317
317 318 # Renders tabs and their content
318 319 def render_tabs(tabs, selected=params[:tab])
319 320 if tabs.any?
320 321 unless tabs.detect {|tab| tab[:name] == selected}
321 322 selected = nil
322 323 end
323 324 selected ||= tabs.first[:name]
324 325 render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
325 326 else
326 327 content_tag 'p', l(:label_no_data), :class => "nodata"
327 328 end
328 329 end
329 330
330 331 # Renders the project quick-jump box
331 332 def render_project_jump_box
332 333 return unless User.current.logged?
333 334 projects = User.current.projects.active.select(:id, :name, :identifier, :lft, :rgt).to_a
334 335 if projects.any?
335 336 options =
336 337 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
337 338 '<option value="" disabled="disabled">---</option>').html_safe
338 339
339 340 options << project_tree_options_for_select(projects, :selected => @project) do |p|
340 341 { :value => project_path(:id => p, :jump => current_menu_item) }
341 342 end
342 343
343 344 content_tag( :span, nil, :class => 'jump-box-arrow') +
344 345 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
345 346 end
346 347 end
347 348
348 349 def project_tree_options_for_select(projects, options = {})
349 350 s = ''.html_safe
350 351 if blank_text = options[:include_blank]
351 352 if blank_text == true
352 353 blank_text = '&nbsp;'.html_safe
353 354 end
354 355 s << content_tag('option', blank_text, :value => '')
355 356 end
356 357 project_tree(projects) do |project, level|
357 358 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
358 359 tag_options = {:value => project.id}
359 360 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
360 361 tag_options[:selected] = 'selected'
361 362 else
362 363 tag_options[:selected] = nil
363 364 end
364 365 tag_options.merge!(yield(project)) if block_given?
365 366 s << content_tag('option', name_prefix + h(project), tag_options)
366 367 end
367 368 s.html_safe
368 369 end
369 370
370 371 # Yields the given block for each project with its level in the tree
371 372 #
372 373 # Wrapper for Project#project_tree
373 374 def project_tree(projects, &block)
374 375 Project.project_tree(projects, &block)
375 376 end
376 377
377 378 def principals_check_box_tags(name, principals)
378 379 s = ''
379 380 principals.each do |principal|
380 381 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
381 382 end
382 383 s.html_safe
383 384 end
384 385
385 386 # Returns a string for users/groups option tags
386 387 def principals_options_for_select(collection, selected=nil)
387 388 s = ''
388 389 if collection.include?(User.current)
389 390 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
390 391 end
391 392 groups = ''
392 393 collection.sort.each do |element|
393 394 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
394 395 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
395 396 end
396 397 unless groups.empty?
397 398 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
398 399 end
399 400 s.html_safe
400 401 end
401 402
402 403 def option_tag(name, text, value, selected=nil, options={})
403 404 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
404 405 end
405 406
406 407 def truncate_single_line_raw(string, length)
407 408 string.to_s.truncate(length).gsub(%r{[\r\n]+}m, ' ')
408 409 end
409 410
410 411 # Truncates at line break after 250 characters or options[:length]
411 412 def truncate_lines(string, options={})
412 413 length = options[:length] || 250
413 414 if string.to_s =~ /\A(.{#{length}}.*?)$/m
414 415 "#{$1}..."
415 416 else
416 417 string
417 418 end
418 419 end
419 420
420 421 def anchor(text)
421 422 text.to_s.gsub(' ', '_')
422 423 end
423 424
424 425 def html_hours(text)
425 426 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
426 427 end
427 428
428 429 def authoring(created, author, options={})
429 430 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
430 431 end
431 432
432 433 def time_tag(time)
433 434 text = distance_of_time_in_words(Time.now, time)
434 435 if @project
435 436 link_to(text, project_activity_path(@project, :from => User.current.time_to_date(time)), :title => format_time(time))
436 437 else
437 438 content_tag('abbr', text, :title => format_time(time))
438 439 end
439 440 end
440 441
441 442 def syntax_highlight_lines(name, content)
442 443 lines = []
443 444 syntax_highlight(name, content).each_line { |line| lines << line }
444 445 lines
445 446 end
446 447
447 448 def syntax_highlight(name, content)
448 449 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
449 450 end
450 451
451 452 def to_path_param(path)
452 453 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
453 454 str.blank? ? nil : str
454 455 end
455 456
456 457 def reorder_links(name, url, method = :post)
457 458 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
458 459 url.merge({"#{name}[move_to]" => 'highest'}),
459 460 :method => method, :title => l(:label_sort_highest)) +
460 461 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
461 462 url.merge({"#{name}[move_to]" => 'higher'}),
462 463 :method => method, :title => l(:label_sort_higher)) +
463 464 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
464 465 url.merge({"#{name}[move_to]" => 'lower'}),
465 466 :method => method, :title => l(:label_sort_lower)) +
466 467 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
467 468 url.merge({"#{name}[move_to]" => 'lowest'}),
468 469 :method => method, :title => l(:label_sort_lowest))
469 470 end
470 471
471 472 def breadcrumb(*args)
472 473 elements = args.flatten
473 474 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
474 475 end
475 476
476 477 def other_formats_links(&block)
477 478 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
478 479 yield Redmine::Views::OtherFormatsBuilder.new(self)
479 480 concat('</p>'.html_safe)
480 481 end
481 482
482 483 def page_header_title
483 484 if @project.nil? || @project.new_record?
484 485 h(Setting.app_title)
485 486 else
486 487 b = []
487 488 ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
488 489 if ancestors.any?
489 490 root = ancestors.shift
490 491 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
491 492 if ancestors.size > 2
492 493 b << "\xe2\x80\xa6"
493 494 ancestors = ancestors[-2, 2]
494 495 end
495 496 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
496 497 end
497 498 b << h(@project)
498 499 b.join(" \xc2\xbb ").html_safe
499 500 end
500 501 end
501 502
502 503 # Returns a h2 tag and sets the html title with the given arguments
503 504 def title(*args)
504 505 strings = args.map do |arg|
505 506 if arg.is_a?(Array) && arg.size >= 2
506 507 link_to(*arg)
507 508 else
508 509 h(arg.to_s)
509 510 end
510 511 end
511 512 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
512 513 content_tag('h2', strings.join(' &#187; ').html_safe)
513 514 end
514 515
515 516 # Sets the html title
516 517 # Returns the html title when called without arguments
517 518 # Current project name and app_title and automatically appended
518 519 # Exemples:
519 520 # html_title 'Foo', 'Bar'
520 521 # html_title # => 'Foo - Bar - My Project - Redmine'
521 522 def html_title(*args)
522 523 if args.empty?
523 524 title = @html_title || []
524 525 title << @project.name if @project
525 526 title << Setting.app_title unless Setting.app_title == title.last
526 527 title.reject(&:blank?).join(' - ')
527 528 else
528 529 @html_title ||= []
529 530 @html_title += args
530 531 end
531 532 end
532 533
533 534 # Returns the theme, controller name, and action as css classes for the
534 535 # HTML body.
535 536 def body_css_classes
536 537 css = []
537 538 if theme = Redmine::Themes.theme(Setting.ui_theme)
538 539 css << 'theme-' + theme.name
539 540 end
540 541
541 542 css << 'project-' + @project.identifier if @project && @project.identifier.present?
542 543 css << 'controller-' + controller_name
543 544 css << 'action-' + action_name
544 545 css.join(' ')
545 546 end
546 547
547 548 def accesskey(s)
548 549 @used_accesskeys ||= []
549 550 key = Redmine::AccessKeys.key_for(s)
550 551 return nil if @used_accesskeys.include?(key)
551 552 @used_accesskeys << key
552 553 key
553 554 end
554 555
555 556 # Formats text according to system settings.
556 557 # 2 ways to call this method:
557 558 # * with a String: textilizable(text, options)
558 559 # * with an object and one of its attribute: textilizable(issue, :description, options)
559 560 def textilizable(*args)
560 561 options = args.last.is_a?(Hash) ? args.pop : {}
561 562 case args.size
562 563 when 1
563 564 obj = options[:object]
564 565 text = args.shift
565 566 when 2
566 567 obj = args.shift
567 568 attr = args.shift
568 569 text = obj.send(attr).to_s
569 570 else
570 571 raise ArgumentError, 'invalid arguments to textilizable'
571 572 end
572 573 return '' if text.blank?
573 574 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
574 575 @only_path = only_path = options.delete(:only_path) == false ? false : true
575 576
576 577 text = text.dup
577 578 macros = catch_macros(text)
578 579 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
579 580
580 581 @parsed_headings = []
581 582 @heading_anchors = {}
582 583 @current_section = 0 if options[:edit_section_links]
583 584
584 585 parse_sections(text, project, obj, attr, only_path, options)
585 586 text = parse_non_pre_blocks(text, obj, macros) do |text|
586 587 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
587 588 send method_name, text, project, obj, attr, only_path, options
588 589 end
589 590 end
590 591 parse_headings(text, project, obj, attr, only_path, options)
591 592
592 593 if @parsed_headings.any?
593 594 replace_toc(text, @parsed_headings)
594 595 end
595 596
596 597 text.html_safe
597 598 end
598 599
599 600 def parse_non_pre_blocks(text, obj, macros)
600 601 s = StringScanner.new(text)
601 602 tags = []
602 603 parsed = ''
603 604 while !s.eos?
604 605 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
605 606 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
606 607 if tags.empty?
607 608 yield text
608 609 inject_macros(text, obj, macros) if macros.any?
609 610 else
610 611 inject_macros(text, obj, macros, false) if macros.any?
611 612 end
612 613 parsed << text
613 614 if tag
614 615 if closing
615 616 if tags.last && tags.last.casecmp(tag) == 0
616 617 tags.pop
617 618 end
618 619 else
619 620 tags << tag.downcase
620 621 end
621 622 parsed << full_tag
622 623 end
623 624 end
624 625 # Close any non closing tags
625 626 while tag = tags.pop
626 627 parsed << "</#{tag}>"
627 628 end
628 629 parsed
629 630 end
630 631
631 632 def parse_inline_attachments(text, project, obj, attr, only_path, options)
632 633 return if options[:inline_attachments] == false
633 634
634 635 # when using an image link, try to use an attachment, if possible
635 636 attachments = options[:attachments] || []
636 637 attachments += obj.attachments if obj.respond_to?(:attachments)
637 638 if attachments.present?
638 639 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
639 640 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
640 641 # search for the picture in attachments
641 642 if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
642 643 image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
643 644 desc = found.description.to_s.gsub('"', '')
644 645 if !desc.blank? && alttext.blank?
645 646 alt = " title=\"#{desc}\" alt=\"#{desc}\""
646 647 end
647 648 "src=\"#{image_url}\"#{alt}"
648 649 else
649 650 m
650 651 end
651 652 end
652 653 end
653 654 end
654 655
655 656 # Wiki links
656 657 #
657 658 # Examples:
658 659 # [[mypage]]
659 660 # [[mypage|mytext]]
660 661 # wiki links can refer other project wikis, using project name or identifier:
661 662 # [[project:]] -> wiki starting page
662 663 # [[project:|mytext]]
663 664 # [[project:mypage]]
664 665 # [[project:mypage|mytext]]
665 666 def parse_wiki_links(text, project, obj, attr, only_path, options)
666 667 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
667 668 link_project = project
668 669 esc, all, page, title = $1, $2, $3, $5
669 670 if esc.nil?
670 671 if page =~ /^([^\:]+)\:(.*)$/
671 672 identifier, page = $1, $2
672 673 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
673 674 title ||= identifier if page.blank?
674 675 end
675 676
676 677 if link_project && link_project.wiki
677 678 # extract anchor
678 679 anchor = nil
679 680 if page =~ /^(.+?)\#(.+)$/
680 681 page, anchor = $1, $2
681 682 end
682 683 anchor = sanitize_anchor_name(anchor) if anchor.present?
683 684 # check if page exists
684 685 wiki_page = link_project.wiki.find_page(page)
685 686 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
686 687 "##{anchor}"
687 688 else
688 689 case options[:wiki_links]
689 690 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
690 691 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
691 692 else
692 693 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
693 694 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
694 695 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
695 696 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
696 697 end
697 698 end
698 699 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
699 700 else
700 701 # project or wiki doesn't exist
701 702 all
702 703 end
703 704 else
704 705 all
705 706 end
706 707 end
707 708 end
708 709
709 710 # Redmine links
710 711 #
711 712 # Examples:
712 713 # Issues:
713 714 # #52 -> Link to issue #52
714 715 # Changesets:
715 716 # r52 -> Link to revision 52
716 717 # commit:a85130f -> Link to scmid starting with a85130f
717 718 # Documents:
718 719 # document#17 -> Link to document with id 17
719 720 # document:Greetings -> Link to the document with title "Greetings"
720 721 # document:"Some document" -> Link to the document with title "Some document"
721 722 # Versions:
722 723 # version#3 -> Link to version with id 3
723 724 # version:1.0.0 -> Link to version named "1.0.0"
724 725 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
725 726 # Attachments:
726 727 # attachment:file.zip -> Link to the attachment of the current object named file.zip
727 728 # Source files:
728 729 # source:some/file -> Link to the file located at /some/file in the project's repository
729 730 # source:some/file@52 -> Link to the file's revision 52
730 731 # source:some/file#L120 -> Link to line 120 of the file
731 732 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
732 733 # export:some/file -> Force the download of the file
733 734 # Forum messages:
734 735 # message#1218 -> Link to message with id 1218
735 736 # Projects:
736 737 # project:someproject -> Link to project named "someproject"
737 738 # project#3 -> Link to project with id 3
738 739 #
739 740 # Links can refer other objects from other projects, using project identifier:
740 741 # identifier:r52
741 742 # identifier:document:"Some document"
742 743 # identifier:version:1.0.0
743 744 # identifier:source:some/file
744 745 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
745 746 text.gsub!(%r{<a( [^>]+?)?>(.*?)</a>|([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-_]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
746 747 tag_content, leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $2, $3, $4, $5, $6, $7, $12, $13, $10 || $14 || $20, $16 || $21, $17, $19
747 748 if tag_content
748 749 $&
749 750 else
750 751 link = nil
751 752 project = default_project
752 753 if project_identifier
753 754 project = Project.visible.find_by_identifier(project_identifier)
754 755 end
755 756 if esc.nil?
756 757 if prefix.nil? && sep == 'r'
757 758 if project
758 759 repository = nil
759 760 if repo_identifier
760 761 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
761 762 else
762 763 repository = project.repository
763 764 end
764 765 # project.changesets.visible raises an SQL error because of a double join on repositories
765 766 if repository &&
766 767 (changeset = Changeset.visible.
767 768 find_by_repository_id_and_revision(repository.id, identifier))
768 769 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
769 770 {:only_path => only_path, :controller => 'repositories',
770 771 :action => 'revision', :id => project,
771 772 :repository_id => repository.identifier_param,
772 773 :rev => changeset.revision},
773 774 :class => 'changeset',
774 775 :title => truncate_single_line_raw(changeset.comments, 100))
775 776 end
776 777 end
777 778 elsif sep == '#'
778 779 oid = identifier.to_i
779 780 case prefix
780 781 when nil
781 782 if oid.to_s == identifier &&
782 783 issue = Issue.visible.find_by_id(oid)
783 784 anchor = comment_id ? "note-#{comment_id}" : nil
784 785 link = link_to("##{oid}#{comment_suffix}",
785 786 issue_url(issue, :only_path => only_path, :anchor => anchor),
786 787 :class => issue.css_classes,
787 788 :title => "#{issue.tracker.name}: #{issue.subject.truncate(100)} (#{issue.status.name})")
788 789 end
789 790 when 'document'
790 791 if document = Document.visible.find_by_id(oid)
791 792 link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
792 793 end
793 794 when 'version'
794 795 if version = Version.visible.find_by_id(oid)
795 796 link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
796 797 end
797 798 when 'message'
798 799 if message = Message.visible.find_by_id(oid)
799 800 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
800 801 end
801 802 when 'forum'
802 803 if board = Board.visible.find_by_id(oid)
803 804 link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
804 805 end
805 806 when 'news'
806 807 if news = News.visible.find_by_id(oid)
807 808 link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
808 809 end
809 810 when 'project'
810 811 if p = Project.visible.find_by_id(oid)
811 812 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
812 813 end
813 814 end
814 815 elsif sep == ':'
815 816 # removes the double quotes if any
816 817 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
817 818 name = CGI.unescapeHTML(name)
818 819 case prefix
819 820 when 'document'
820 821 if project && document = project.documents.visible.find_by_title(name)
821 822 link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
822 823 end
823 824 when 'version'
824 825 if project && version = project.versions.visible.find_by_name(name)
825 826 link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
826 827 end
827 828 when 'forum'
828 829 if project && board = project.boards.visible.find_by_name(name)
829 830 link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
830 831 end
831 832 when 'news'
832 833 if project && news = project.news.visible.find_by_title(name)
833 834 link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
834 835 end
835 836 when 'commit', 'source', 'export'
836 837 if project
837 838 repository = nil
838 839 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
839 840 repo_prefix, repo_identifier, name = $1, $2, $3
840 841 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
841 842 else
842 843 repository = project.repository
843 844 end
844 845 if prefix == 'commit'
845 846 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
846 847 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
847 848 :class => 'changeset',
848 849 :title => truncate_single_line_raw(changeset.comments, 100)
849 850 end
850 851 else
851 852 if repository && User.current.allowed_to?(:browse_repository, project)
852 853 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
853 854 path, rev, anchor = $1, $3, $5
854 855 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
855 856 :path => to_path_param(path),
856 857 :rev => rev,
857 858 :anchor => anchor},
858 859 :class => (prefix == 'export' ? 'source download' : 'source')
859 860 end
860 861 end
861 862 repo_prefix = nil
862 863 end
863 864 when 'attachment'
864 865 attachments = options[:attachments] || []
865 866 attachments += obj.attachments if obj.respond_to?(:attachments)
866 867 if attachments && attachment = Attachment.latest_attach(attachments, name)
867 868 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
868 869 end
869 870 when 'project'
870 871 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
871 872 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
872 873 end
873 874 end
874 875 end
875 876 end
876 877 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
877 878 end
878 879 end
879 880 end
880 881
881 882 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
882 883
883 884 def parse_sections(text, project, obj, attr, only_path, options)
884 885 return unless options[:edit_section_links]
885 886 text.gsub!(HEADING_RE) do
886 887 heading, level = $1, $2
887 888 @current_section += 1
888 889 if @current_section > 1
889 890 content_tag('div',
890 891 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
891 892 :class => "contextual heading-#{level}",
892 893 :title => l(:button_edit_section),
893 894 :id => "section-#{@current_section}") + heading.html_safe
894 895 else
895 896 heading
896 897 end
897 898 end
898 899 end
899 900
900 901 # Headings and TOC
901 902 # Adds ids and links to headings unless options[:headings] is set to false
902 903 def parse_headings(text, project, obj, attr, only_path, options)
903 904 return if options[:headings] == false
904 905
905 906 text.gsub!(HEADING_RE) do
906 907 level, attrs, content = $2.to_i, $3, $4
907 908 item = strip_tags(content).strip
908 909 anchor = sanitize_anchor_name(item)
909 910 # used for single-file wiki export
910 911 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
911 912 @heading_anchors[anchor] ||= 0
912 913 idx = (@heading_anchors[anchor] += 1)
913 914 if idx > 1
914 915 anchor = "#{anchor}-#{idx}"
915 916 end
916 917 @parsed_headings << [level, anchor, item]
917 918 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
918 919 end
919 920 end
920 921
921 922 MACROS_RE = /(
922 923 (!)? # escaping
923 924 (
924 925 \{\{ # opening tag
925 926 ([\w]+) # macro name
926 927 (\(([^\n\r]*?)\))? # optional arguments
927 928 ([\n\r].*?[\n\r])? # optional block of text
928 929 \}\} # closing tag
929 930 )
930 931 )/mx unless const_defined?(:MACROS_RE)
931 932
932 933 MACRO_SUB_RE = /(
933 934 \{\{
934 935 macro\((\d+)\)
935 936 \}\}
936 937 )/x unless const_defined?(:MACRO_SUB_RE)
937 938
938 939 # Extracts macros from text
939 940 def catch_macros(text)
940 941 macros = {}
941 942 text.gsub!(MACROS_RE) do
942 943 all, macro = $1, $4.downcase
943 944 if macro_exists?(macro) || all =~ MACRO_SUB_RE
944 945 index = macros.size
945 946 macros[index] = all
946 947 "{{macro(#{index})}}"
947 948 else
948 949 all
949 950 end
950 951 end
951 952 macros
952 953 end
953 954
954 955 # Executes and replaces macros in text
955 956 def inject_macros(text, obj, macros, execute=true)
956 957 text.gsub!(MACRO_SUB_RE) do
957 958 all, index = $1, $2.to_i
958 959 orig = macros.delete(index)
959 960 if execute && orig && orig =~ MACROS_RE
960 961 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
961 962 if esc.nil?
962 963 h(exec_macro(macro, obj, args, block) || all)
963 964 else
964 965 h(all)
965 966 end
966 967 elsif orig
967 968 h(orig)
968 969 else
969 970 h(all)
970 971 end
971 972 end
972 973 end
973 974
974 975 TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
975 976
976 977 # Renders the TOC with given headings
977 978 def replace_toc(text, headings)
978 979 text.gsub!(TOC_RE) do
979 980 left_align, right_align = $2, $3
980 981 # Keep only the 4 first levels
981 982 headings = headings.select{|level, anchor, item| level <= 4}
982 983 if headings.empty?
983 984 ''
984 985 else
985 986 div_class = 'toc'
986 987 div_class << ' right' if right_align
987 988 div_class << ' left' if left_align
988 989 out = "<ul class=\"#{div_class}\"><li>"
989 990 root = headings.map(&:first).min
990 991 current = root
991 992 started = false
992 993 headings.each do |level, anchor, item|
993 994 if level > current
994 995 out << '<ul><li>' * (level - current)
995 996 elsif level < current
996 997 out << "</li></ul>\n" * (current - level) + "</li><li>"
997 998 elsif started
998 999 out << '</li><li>'
999 1000 end
1000 1001 out << "<a href=\"##{anchor}\">#{item}</a>"
1001 1002 current = level
1002 1003 started = true
1003 1004 end
1004 1005 out << '</li></ul>' * (current - root)
1005 1006 out << '</li></ul>'
1006 1007 end
1007 1008 end
1008 1009 end
1009 1010
1010 1011 # Same as Rails' simple_format helper without using paragraphs
1011 1012 def simple_format_without_paragraph(text)
1012 1013 text.to_s.
1013 1014 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
1014 1015 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
1015 1016 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
1016 1017 html_safe
1017 1018 end
1018 1019
1019 1020 def lang_options_for_select(blank=true)
1020 1021 (blank ? [["(auto)", ""]] : []) + languages_options
1021 1022 end
1022 1023
1023 1024 def labelled_form_for(*args, &proc)
1024 1025 args << {} unless args.last.is_a?(Hash)
1025 1026 options = args.last
1026 1027 if args.first.is_a?(Symbol)
1027 1028 options.merge!(:as => args.shift)
1028 1029 end
1029 1030 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1030 1031 form_for(*args, &proc)
1031 1032 end
1032 1033
1033 1034 def labelled_fields_for(*args, &proc)
1034 1035 args << {} unless args.last.is_a?(Hash)
1035 1036 options = args.last
1036 1037 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1037 1038 fields_for(*args, &proc)
1038 1039 end
1039 1040
1040 1041 def error_messages_for(*objects)
1041 1042 html = ""
1042 1043 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1043 1044 errors = objects.map {|o| o.errors.full_messages}.flatten
1044 1045 if errors.any?
1045 1046 html << "<div id='errorExplanation'><ul>\n"
1046 1047 errors.each do |error|
1047 1048 html << "<li>#{h error}</li>\n"
1048 1049 end
1049 1050 html << "</ul></div>\n"
1050 1051 end
1051 1052 html.html_safe
1052 1053 end
1053 1054
1054 1055 def delete_link(url, options={})
1055 1056 options = {
1056 1057 :method => :delete,
1057 1058 :data => {:confirm => l(:text_are_you_sure)},
1058 1059 :class => 'icon icon-del'
1059 1060 }.merge(options)
1060 1061
1061 1062 link_to l(:button_delete), url, options
1062 1063 end
1063 1064
1064 1065 def preview_link(url, form, target='preview', options={})
1065 1066 content_tag 'a', l(:label_preview), {
1066 1067 :href => "#",
1067 1068 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1068 1069 :accesskey => accesskey(:preview)
1069 1070 }.merge(options)
1070 1071 end
1071 1072
1072 1073 def link_to_function(name, function, html_options={})
1073 1074 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1074 1075 end
1075 1076
1076 1077 # Helper to render JSON in views
1077 1078 def raw_json(arg)
1078 1079 arg.to_json.to_s.gsub('/', '\/').html_safe
1079 1080 end
1080 1081
1081 1082 def back_url
1082 1083 url = params[:back_url]
1083 1084 if url.nil? && referer = request.env['HTTP_REFERER']
1084 1085 url = CGI.unescape(referer.to_s)
1085 1086 end
1086 1087 url
1087 1088 end
1088 1089
1089 1090 def back_url_hidden_field_tag
1090 1091 url = back_url
1091 1092 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1092 1093 end
1093 1094
1094 1095 def check_all_links(form_name)
1095 1096 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1096 1097 " | ".html_safe +
1097 1098 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1098 1099 end
1099 1100
1100 1101 def toggle_checkboxes_link(selector)
1101 1102 link_to_function image_tag('toggle_check.png'),
1102 1103 "toggleCheckboxesBySelector('#{selector}')",
1103 1104 :title => "#{l(:button_check_all)} / #{l(:button_uncheck_all)}"
1104 1105 end
1105 1106
1106 1107 def progress_bar(pcts, options={})
1107 1108 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1108 1109 pcts = pcts.collect(&:round)
1109 1110 pcts[1] = pcts[1] - pcts[0]
1110 1111 pcts << (100 - pcts[1] - pcts[0])
1111 1112 legend = options[:legend] || ''
1112 1113 content_tag('table',
1113 1114 content_tag('tr',
1114 1115 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1115 1116 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1116 1117 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1117 1118 ), :class => "progress progress-#{pcts[0]}").html_safe +
1118 1119 content_tag('p', legend, :class => 'percent').html_safe
1119 1120 end
1120 1121
1121 1122 def checked_image(checked=true)
1122 1123 if checked
1123 1124 @checked_image_tag ||= image_tag('toggle_check.png')
1124 1125 end
1125 1126 end
1126 1127
1127 1128 def context_menu(url)
1128 1129 unless @context_menu_included
1129 1130 content_for :header_tags do
1130 1131 javascript_include_tag('context_menu') +
1131 1132 stylesheet_link_tag('context_menu')
1132 1133 end
1133 1134 if l(:direction) == 'rtl'
1134 1135 content_for :header_tags do
1135 1136 stylesheet_link_tag('context_menu_rtl')
1136 1137 end
1137 1138 end
1138 1139 @context_menu_included = true
1139 1140 end
1140 1141 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1141 1142 end
1142 1143
1143 1144 def calendar_for(field_id)
1144 1145 include_calendar_headers_tags
1145 1146 javascript_tag("$(function() { $('##{field_id}').addClass('date').datepicker(datepickerOptions); });")
1146 1147 end
1147 1148
1148 1149 def include_calendar_headers_tags
1149 1150 unless @calendar_headers_tags_included
1150 1151 tags = ''.html_safe
1151 1152 @calendar_headers_tags_included = true
1152 1153 content_for :header_tags do
1153 1154 start_of_week = Setting.start_of_week
1154 1155 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1155 1156 # Redmine uses 1..7 (monday..sunday) in settings and locales
1156 1157 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1157 1158 start_of_week = start_of_week.to_i % 7
1158 1159 tags << javascript_tag(
1159 1160 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1160 1161 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1161 1162 path_to_image('/images/calendar.png') +
1162 1163 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1163 1164 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1164 1165 "beforeShow: beforeShowDatePicker};")
1165 1166 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1166 1167 unless jquery_locale == 'en'
1167 1168 tags << javascript_include_tag("i18n/datepicker-#{jquery_locale}.js")
1168 1169 end
1169 1170 tags
1170 1171 end
1171 1172 end
1172 1173 end
1173 1174
1174 1175 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1175 1176 # Examples:
1176 1177 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1177 1178 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1178 1179 #
1179 1180 def stylesheet_link_tag(*sources)
1180 1181 options = sources.last.is_a?(Hash) ? sources.pop : {}
1181 1182 plugin = options.delete(:plugin)
1182 1183 sources = sources.map do |source|
1183 1184 if plugin
1184 1185 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1185 1186 elsif current_theme && current_theme.stylesheets.include?(source)
1186 1187 current_theme.stylesheet_path(source)
1187 1188 else
1188 1189 source
1189 1190 end
1190 1191 end
1191 1192 super *sources, options
1192 1193 end
1193 1194
1194 1195 # Overrides Rails' image_tag with themes and plugins support.
1195 1196 # Examples:
1196 1197 # image_tag('image.png') # => picks image.png from the current theme or defaults
1197 1198 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1198 1199 #
1199 1200 def image_tag(source, options={})
1200 1201 if plugin = options.delete(:plugin)
1201 1202 source = "/plugin_assets/#{plugin}/images/#{source}"
1202 1203 elsif current_theme && current_theme.images.include?(source)
1203 1204 source = current_theme.image_path(source)
1204 1205 end
1205 1206 super source, options
1206 1207 end
1207 1208
1208 1209 # Overrides Rails' javascript_include_tag with plugins support
1209 1210 # Examples:
1210 1211 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1211 1212 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1212 1213 #
1213 1214 def javascript_include_tag(*sources)
1214 1215 options = sources.last.is_a?(Hash) ? sources.pop : {}
1215 1216 if plugin = options.delete(:plugin)
1216 1217 sources = sources.map do |source|
1217 1218 if plugin
1218 1219 "/plugin_assets/#{plugin}/javascripts/#{source}"
1219 1220 else
1220 1221 source
1221 1222 end
1222 1223 end
1223 1224 end
1224 1225 super *sources, options
1225 1226 end
1226 1227
1227 1228 def sidebar_content?
1228 1229 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1229 1230 end
1230 1231
1231 1232 def view_layouts_base_sidebar_hook_response
1232 1233 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1233 1234 end
1234 1235
1235 1236 def email_delivery_enabled?
1236 1237 !!ActionMailer::Base.perform_deliveries
1237 1238 end
1238 1239
1239 1240 # Returns the avatar image tag for the given +user+ if avatars are enabled
1240 1241 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1241 1242 def avatar(user, options = { })
1242 1243 if Setting.gravatar_enabled?
1243 1244 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1244 1245 email = nil
1245 1246 if user.respond_to?(:mail)
1246 1247 email = user.mail
1247 1248 elsif user.to_s =~ %r{<(.+?)>}
1248 1249 email = $1
1249 1250 end
1250 1251 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1251 1252 else
1252 1253 ''
1253 1254 end
1254 1255 end
1255 1256
1256 1257 # Returns a link to edit user's avatar if avatars are enabled
1257 1258 def avatar_edit_link(user, options={})
1258 1259 if Setting.gravatar_enabled?
1259 1260 url = "https://gravatar.com"
1260 1261 link_to avatar(user, {:title => l(:button_edit)}.merge(options)), url, :target => '_blank'
1261 1262 end
1262 1263 end
1263 1264
1264 1265 def sanitize_anchor_name(anchor)
1265 1266 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1266 1267 end
1267 1268
1268 1269 # Returns the javascript tags that are included in the html layout head
1269 1270 def javascript_heads
1270 1271 tags = javascript_include_tag('jquery-1.11.1-ui-1.11.0-ujs-3.1.4', 'application', 'responsive')
1271 1272 unless User.current.pref.warn_on_leaving_unsaved == '0'
1272 1273 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1273 1274 end
1274 1275 tags
1275 1276 end
1276 1277
1277 1278 def favicon
1278 1279 "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
1279 1280 end
1280 1281
1281 1282 # Returns the path to the favicon
1282 1283 def favicon_path
1283 1284 icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
1284 1285 image_path(icon)
1285 1286 end
1286 1287
1287 1288 # Returns the full URL to the favicon
1288 1289 def favicon_url
1289 1290 # TODO: use #image_url introduced in Rails4
1290 1291 path = favicon_path
1291 1292 base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
1292 1293 base.sub(%r{/+$},'') + '/' + path.sub(%r{^/+},'')
1293 1294 end
1294 1295
1295 1296 def robot_exclusion_tag
1296 1297 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1297 1298 end
1298 1299
1299 1300 # Returns true if arg is expected in the API response
1300 1301 def include_in_api_response?(arg)
1301 1302 unless @included_in_api_response
1302 1303 param = params[:include]
1303 1304 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1304 1305 @included_in_api_response.collect!(&:strip)
1305 1306 end
1306 1307 @included_in_api_response.include?(arg.to_s)
1307 1308 end
1308 1309
1309 1310 # Returns options or nil if nometa param or X-Redmine-Nometa header
1310 1311 # was set in the request
1311 1312 def api_meta(options)
1312 1313 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1313 1314 # compatibility mode for activeresource clients that raise
1314 1315 # an error when deserializing an array with attributes
1315 1316 nil
1316 1317 else
1317 1318 options
1318 1319 end
1319 1320 end
1320 1321
1321 1322 def generate_csv(&block)
1322 1323 decimal_separator = l(:general_csv_decimal_separator)
1323 1324 encoding = l(:general_csv_encoding)
1324 1325 end
1325 1326
1326 1327 private
1327 1328
1328 1329 def wiki_helper
1329 1330 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1330 1331 extend helper
1331 1332 return self
1332 1333 end
1333 1334
1334 1335 def link_to_content_update(text, url_params = {}, html_options = {})
1335 1336 link_to(text, url_params, html_options)
1336 1337 end
1337 1338 end
@@ -1,284 +1,292
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class CustomField < ActiveRecord::Base
19 19 include Redmine::SubclassFactory
20 20
21 21 has_many :enumerations,
22 22 lambda { order(:position) },
23 23 :class_name => 'CustomFieldEnumeration',
24 24 :dependent => :delete_all
25 25 has_many :custom_values, :dependent => :delete_all
26 26 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id"
27 27 acts_as_list :scope => 'type = \'#{self.class}\''
28 28 serialize :possible_values
29 29 store :format_store
30 30
31 31 validates_presence_of :name, :field_format
32 32 validates_uniqueness_of :name, :scope => :type
33 33 validates_length_of :name, :maximum => 30
34 34 validates_inclusion_of :field_format, :in => Proc.new { Redmine::FieldFormat.available_formats }
35 35 validate :validate_custom_field
36 36 attr_protected :id
37 37
38 38 before_validation :set_searchable
39 39 before_save do |field|
40 40 field.format.before_custom_field_save(field)
41 41 end
42 42 after_save :handle_multiplicity_change
43 43 after_save do |field|
44 44 if field.visible_changed? && field.visible
45 45 field.roles.clear
46 46 end
47 47 end
48 48
49 49 scope :sorted, lambda { order(:position) }
50 50 scope :visible, lambda {|*args|
51 51 user = args.shift || User.current
52 52 if user.admin?
53 53 # nop
54 54 elsif user.memberships.any?
55 55 where("#{table_name}.visible = ? OR #{table_name}.id IN (SELECT DISTINCT cfr.custom_field_id FROM #{Member.table_name} m" +
56 56 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
57 57 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
58 58 " WHERE m.user_id = ?)",
59 59 true, user.id)
60 60 else
61 61 where(:visible => true)
62 62 end
63 63 }
64 64
65 65 def visible_by?(project, user=User.current)
66 66 visible? || user.admin?
67 67 end
68 68
69 69 def format
70 70 @format ||= Redmine::FieldFormat.find(field_format)
71 71 end
72 72
73 73 def field_format=(arg)
74 74 # cannot change format of a saved custom field
75 75 if new_record?
76 76 @format = nil
77 77 super
78 78 end
79 79 end
80 80
81 81 def set_searchable
82 82 # make sure these fields are not searchable
83 83 self.searchable = false unless format.class.searchable_supported
84 84 # make sure only these fields can have multiple values
85 85 self.multiple = false unless format.class.multiple_supported
86 86 true
87 87 end
88 88
89 89 def validate_custom_field
90 90 format.validate_custom_field(self).each do |attribute, message|
91 91 errors.add attribute, message
92 92 end
93 93
94 94 if regexp.present?
95 95 begin
96 96 Regexp.new(regexp)
97 97 rescue
98 98 errors.add(:regexp, :invalid)
99 99 end
100 100 end
101 101
102 102 if default_value.present?
103 103 validate_field_value(default_value).each do |message|
104 104 errors.add :default_value, message
105 105 end
106 106 end
107 107 end
108 108
109 109 def possible_custom_value_options(custom_value)
110 110 format.possible_custom_value_options(custom_value)
111 111 end
112 112
113 113 def possible_values_options(object=nil)
114 114 if object.is_a?(Array)
115 115 object.map {|o| format.possible_values_options(self, o)}.reduce(:&) || []
116 116 else
117 117 format.possible_values_options(self, object) || []
118 118 end
119 119 end
120 120
121 121 def possible_values
122 122 values = read_attribute(:possible_values)
123 123 if values.is_a?(Array)
124 124 values.each do |value|
125 125 value.to_s.force_encoding('UTF-8')
126 126 end
127 127 values
128 128 else
129 129 []
130 130 end
131 131 end
132 132
133 133 # Makes possible_values accept a multiline string
134 134 def possible_values=(arg)
135 135 if arg.is_a?(Array)
136 136 values = arg.compact.map {|a| a.to_s.strip}.reject(&:blank?)
137 137 write_attribute(:possible_values, values)
138 138 else
139 139 self.possible_values = arg.to_s.split(/[\n\r]+/)
140 140 end
141 141 end
142 142
143 143 def cast_value(value)
144 144 format.cast_value(self, value)
145 145 end
146 146
147 147 def value_from_keyword(keyword, customized)
148 148 format.value_from_keyword(self, keyword, customized)
149 149 end
150 150
151 151 # Returns the options hash used to build a query filter for the field
152 152 def query_filter_options(query)
153 153 format.query_filter_options(self, query)
154 154 end
155 155
156 156 def totalable?
157 157 format.totalable_supported
158 158 end
159 159
160 160 # Returns a ORDER BY clause that can used to sort customized
161 161 # objects by their value of the custom field.
162 162 # Returns nil if the custom field can not be used for sorting.
163 163 def order_statement
164 164 return nil if multiple?
165 165 format.order_statement(self)
166 166 end
167 167
168 168 # Returns a GROUP BY clause that can used to group by custom value
169 169 # Returns nil if the custom field can not be used for grouping.
170 170 def group_statement
171 171 return nil if multiple?
172 172 format.group_statement(self)
173 173 end
174 174
175 175 def join_for_order_statement
176 176 format.join_for_order_statement(self)
177 177 end
178 178
179 179 def visibility_by_project_condition(project_key=nil, user=User.current, id_column=nil)
180 180 if visible? || user.admin?
181 181 "1=1"
182 182 elsif user.anonymous?
183 183 "1=0"
184 184 else
185 185 project_key ||= "#{self.class.customized_class.table_name}.project_id"
186 186 id_column ||= id
187 187 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
188 188 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
189 189 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
190 190 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id_column})"
191 191 end
192 192 end
193 193
194 194 def self.visibility_condition
195 195 if user.admin?
196 196 "1=1"
197 197 elsif user.anonymous?
198 198 "#{table_name}.visible"
199 199 else
200 200 "#{project_key} IN (SELECT DISTINCT m.project_id FROM #{Member.table_name} m" +
201 201 " INNER JOIN #{MemberRole.table_name} mr ON mr.member_id = m.id" +
202 202 " INNER JOIN #{table_name_prefix}custom_fields_roles#{table_name_suffix} cfr ON cfr.role_id = mr.role_id" +
203 203 " WHERE m.user_id = #{user.id} AND cfr.custom_field_id = #{id})"
204 204 end
205 205 end
206 206
207 207 def <=>(field)
208 208 position <=> field.position
209 209 end
210 210
211 211 # Returns the class that values represent
212 212 def value_class
213 213 format.target_class if format.respond_to?(:target_class)
214 214 end
215 215
216 216 def self.customized_class
217 217 self.name =~ /^(.+)CustomField$/
218 218 $1.constantize rescue nil
219 219 end
220 220
221 221 # to move in project_custom_field
222 222 def self.for_all
223 223 where(:is_for_all => true).order('position').to_a
224 224 end
225 225
226 226 def type_name
227 227 nil
228 228 end
229 229
230 230 # Returns the error messages for the given value
231 231 # or an empty array if value is a valid value for the custom field
232 232 def validate_custom_value(custom_value)
233 233 value = custom_value.value
234 234 errs = []
235 235 if value.is_a?(Array)
236 236 if !multiple?
237 237 errs << ::I18n.t('activerecord.errors.messages.invalid')
238 238 end
239 239 if is_required? && value.detect(&:present?).nil?
240 240 errs << ::I18n.t('activerecord.errors.messages.blank')
241 241 end
242 242 else
243 243 if is_required? && value.blank?
244 244 errs << ::I18n.t('activerecord.errors.messages.blank')
245 245 end
246 246 end
247 247 errs += format.validate_custom_value(custom_value)
248 248 errs
249 249 end
250 250
251 251 # Returns the error messages for the default custom field value
252 252 def validate_field_value(value)
253 253 validate_custom_value(CustomFieldValue.new(:custom_field => self, :value => value))
254 254 end
255 255
256 256 # Returns true if value is a valid value for the custom field
257 257 def valid_field_value?(value)
258 258 validate_field_value(value).empty?
259 259 end
260 260
261 261 def format_in?(*args)
262 262 args.include?(field_format)
263 263 end
264 264
265 def self.human_attribute_name(attribute_key_name, *args)
266 attr_name = attribute_key_name.to_s
267 if attr_name == 'url_pattern'
268 attr_name = "url"
269 end
270 super(attr_name, *args)
271 end
272
265 273 protected
266 274
267 275 # Removes multiple values for the custom field after setting the multiple attribute to false
268 276 # We kepp the value with the highest id for each customized object
269 277 def handle_multiplicity_change
270 278 if !new_record? && multiple_was && !multiple
271 279 ids = custom_values.
272 280 where("EXISTS(SELECT 1 FROM #{CustomValue.table_name} cve WHERE cve.custom_field_id = #{CustomValue.table_name}.custom_field_id" +
273 281 " AND cve.customized_type = #{CustomValue.table_name}.customized_type AND cve.customized_id = #{CustomValue.table_name}.customized_id" +
274 282 " AND cve.id > #{CustomValue.table_name}.id)").
275 283 pluck(:id)
276 284
277 285 if ids.any?
278 286 custom_values.where(:id => ids).delete_all
279 287 end
280 288 end
281 289 end
282 290 end
283 291
284 292 require_dependency 'redmine/field_format'
@@ -1,109 +1,109
1 1 <div class="contextual">
2 2 <% if User.current.allowed_to?(:add_subprojects, @project) %>
3 3 <%= link_to l(:label_subproject_new), new_project_path(:parent_id => @project), :class => 'icon icon-add' %>
4 4 <% end %>
5 5 <% if User.current.allowed_to?(:close_project, @project) %>
6 6 <% if @project.active? %>
7 7 <%= link_to l(:button_close), close_project_path(@project), :data => {:confirm => l(:text_are_you_sure)}, :method => :post, :class => 'icon icon-lock' %>
8 8 <% else %>
9 9 <%= link_to l(:button_reopen), reopen_project_path(@project), :data => {:confirm => l(:text_are_you_sure)}, :method => :post, :class => 'icon icon-unlock' %>
10 10 <% end %>
11 11 <% end %>
12 12 </div>
13 13
14 14 <h2><%=l(:label_overview)%></h2>
15 15
16 16 <% unless @project.active? %>
17 17 <p class="warning"><span class="icon icon-lock"><%= l(:text_project_closed) %></span></p>
18 18 <% end %>
19 19
20 20 <div class="splitcontentleft">
21 21 <% if @project.description.present? %>
22 22 <div class="wiki">
23 23 <%= textilizable @project.description %>
24 24 </div>
25 25 <% end %>
26 26 <% if @project.homepage.present? || @subprojects.any? || @project.visible_custom_field_values.any?(&:present?) %>
27 27 <ul>
28 28 <% unless @project.homepage.blank? %>
29 <li><span class="label"><%=l(:field_homepage)%>:</span> <%= link_to @project.homepage, @project.homepage %></li>
29 <li><span class="label"><%=l(:field_homepage)%>:</span> <%= link_to_if uri_with_safe_scheme?(@project.homepage), @project.homepage, @project.homepage %></li>
30 30 <% end %>
31 31 <% if @subprojects.any? %>
32 32 <li><span class="label"><%=l(:label_subproject_plural)%>:</span>
33 33 <%= @subprojects.collect{|p| link_to p, project_path(p)}.join(", ").html_safe %></li>
34 34 <% end %>
35 35 <% render_custom_field_values(@project) do |custom_field, formatted| %>
36 36 <li><span class="label"><%= custom_field.name %>:</span> <%= formatted %></li>
37 37 <% end %>
38 38 </ul>
39 39 <% end %>
40 40
41 41 <% if User.current.allowed_to?(:view_issues, @project) %>
42 42 <div class="issues box">
43 43 <h3><%=l(:label_issue_tracking)%></h3>
44 44 <% if @trackers.present? %>
45 45 <table class="list issue-report">
46 46 <thead>
47 47 <tr>
48 48 <th></th>
49 49 <th><%=l(:label_open_issues_plural)%></th>
50 50 <th><%=l(:label_closed_issues_plural)%></th>
51 51 <th><%=l(:label_total)%></th>
52 52 </tr>
53 53 </thead>
54 54 <tbody>
55 55 <% @trackers.each do |tracker| %>
56 56 <tr class="<%= cycle("odd", "even") %>">
57 57 <td class="name">
58 58 <%= link_to tracker.name, project_issues_path(@project, :set_filter => 1, :tracker_id => tracker.id) %>
59 59 </td>
60 60 <td>
61 61 <%= link_to @open_issues_by_tracker[tracker].to_i, project_issues_path(@project, :set_filter => 1, :tracker_id => tracker.id) %>
62 62 </td>
63 63 <td>
64 64 <%= link_to (@total_issues_by_tracker[tracker].to_i - @open_issues_by_tracker[tracker].to_i), project_issues_path(@project, :set_filter => 1, :tracker_id => tracker.id, :status_id => 'c') %>
65 65 </td>
66 66 <td>
67 67 <%= link_to @total_issues_by_tracker[tracker].to_i, project_issues_path(@project, :set_filter => 1, :tracker_id => tracker.id, :status_id => '*') %>
68 68 </td>
69 69 </tr>
70 70 <% end %>
71 71 </tbody>
72 72 </table>
73 73 <% end %>
74 74 <p>
75 75 <%= link_to l(:label_issue_view_all), project_issues_path(@project, :set_filter => 1) %>
76 76 <% if User.current.allowed_to?(:view_calendar, @project, :global => true) %>
77 77 | <%= link_to l(:label_calendar), project_calendar_path(@project) %>
78 78 <% end %>
79 79 <% if User.current.allowed_to?(:view_gantt, @project, :global => true) %>
80 80 | <%= link_to l(:label_gantt), project_gantt_path(@project) %>
81 81 <% end %>
82 82 </p>
83 83 </div>
84 84 <% end %>
85 85 <%= call_hook(:view_projects_show_left, :project => @project) %>
86 86 </div>
87 87
88 88 <div class="splitcontentright">
89 89 <%= render :partial => 'members_box' %>
90 90
91 91 <% if @news.any? && authorize_for('news', 'index') %>
92 92 <div class="news box">
93 93 <h3><%=l(:label_news_latest)%></h3>
94 94 <%= render :partial => 'news/news', :collection => @news %>
95 95 <p><%= link_to l(:label_news_view_all), project_news_index_path(@project) %></p>
96 96 </div>
97 97 <% end %>
98 98 <%= call_hook(:view_projects_show_right, :project => @project) %>
99 99 </div>
100 100
101 101 <% content_for :sidebar do %>
102 102 <%= render :partial => 'projects/sidebar' %>
103 103 <% end %>
104 104
105 105 <% content_for :header_tags do %>
106 106 <%= auto_discovery_link_tag(:atom, {:controller => 'activities', :action => 'index', :id => @project, :format => 'atom', :key => User.current.rss_key}) %>
107 107 <% end %>
108 108
109 109 <% html_title(l(:label_overview)) -%>
@@ -1,1208 +1,1211
1 1 # vim:ts=4:sw=4:
2 2 # = RedCloth - Textile and Markdown Hybrid for Ruby
3 3 #
4 4 # Homepage:: http://whytheluckystiff.net/ruby/redcloth/
5 5 # Author:: why the lucky stiff (http://whytheluckystiff.net/)
6 6 # Copyright:: (cc) 2004 why the lucky stiff (and his puppet organizations.)
7 7 # License:: BSD
8 8 #
9 9 # (see http://hobix.com/textile/ for a Textile Reference.)
10 10 #
11 11 # Based on (and also inspired by) both:
12 12 #
13 13 # PyTextile: http://diveintomark.org/projects/textile/textile.py.txt
14 14 # Textism for PHP: http://www.textism.com/tools/textile/
15 15 #
16 16 #
17 17
18 18 # = RedCloth
19 19 #
20 20 # RedCloth is a Ruby library for converting Textile and/or Markdown
21 21 # into HTML. You can use either format, intermingled or separately.
22 22 # You can also extend RedCloth to honor your own custom text stylings.
23 23 #
24 24 # RedCloth users are encouraged to use Textile if they are generating
25 25 # HTML and to use Markdown if others will be viewing the plain text.
26 26 #
27 27 # == What is Textile?
28 28 #
29 29 # Textile is a simple formatting style for text
30 30 # documents, loosely based on some HTML conventions.
31 31 #
32 32 # == Sample Textile Text
33 33 #
34 34 # h2. This is a title
35 35 #
36 36 # h3. This is a subhead
37 37 #
38 38 # This is a bit of paragraph.
39 39 #
40 40 # bq. This is a blockquote.
41 41 #
42 42 # = Writing Textile
43 43 #
44 44 # A Textile document consists of paragraphs. Paragraphs
45 45 # can be specially formatted by adding a small instruction
46 46 # to the beginning of the paragraph.
47 47 #
48 48 # h[n]. Header of size [n].
49 49 # bq. Blockquote.
50 50 # # Numeric list.
51 51 # * Bulleted list.
52 52 #
53 53 # == Quick Phrase Modifiers
54 54 #
55 55 # Quick phrase modifiers are also included, to allow formatting
56 56 # of small portions of text within a paragraph.
57 57 #
58 58 # \_emphasis\_
59 59 # \_\_italicized\_\_
60 60 # \*strong\*
61 61 # \*\*bold\*\*
62 62 # ??citation??
63 63 # -deleted text-
64 64 # +inserted text+
65 65 # ^superscript^
66 66 # ~subscript~
67 67 # @code@
68 68 # %(classname)span%
69 69 #
70 70 # ==notextile== (leave text alone)
71 71 #
72 72 # == Links
73 73 #
74 74 # To make a hypertext link, put the link text in "quotation
75 75 # marks" followed immediately by a colon and the URL of the link.
76 76 #
77 77 # Optional: text in (parentheses) following the link text,
78 78 # but before the closing quotation mark, will become a Title
79 79 # attribute for the link, visible as a tool tip when a cursor is above it.
80 80 #
81 81 # Example:
82 82 #
83 83 # "This is a link (This is a title) ":http://www.textism.com
84 84 #
85 85 # Will become:
86 86 #
87 87 # <a href="http://www.textism.com" title="This is a title">This is a link</a>
88 88 #
89 89 # == Images
90 90 #
91 91 # To insert an image, put the URL for the image inside exclamation marks.
92 92 #
93 93 # Optional: text that immediately follows the URL in (parentheses) will
94 94 # be used as the Alt text for the image. Images on the web should always
95 95 # have descriptive Alt text for the benefit of readers using non-graphical
96 96 # browsers.
97 97 #
98 98 # Optional: place a colon followed by a URL immediately after the
99 99 # closing ! to make the image into a link.
100 100 #
101 101 # Example:
102 102 #
103 103 # !http://www.textism.com/common/textist.gif(Textist)!
104 104 #
105 105 # Will become:
106 106 #
107 107 # <img src="http://www.textism.com/common/textist.gif" alt="Textist" />
108 108 #
109 109 # With a link:
110 110 #
111 111 # !/common/textist.gif(Textist)!:http://textism.com
112 112 #
113 113 # Will become:
114 114 #
115 115 # <a href="http://textism.com"><img src="/common/textist.gif" alt="Textist" /></a>
116 116 #
117 117 # == Defining Acronyms
118 118 #
119 119 # HTML allows authors to define acronyms via the tag. The definition appears as a
120 120 # tool tip when a cursor hovers over the acronym. A crucial aid to clear writing,
121 121 # this should be used at least once for each acronym in documents where they appear.
122 122 #
123 123 # To quickly define an acronym in Textile, place the full text in (parentheses)
124 124 # immediately following the acronym.
125 125 #
126 126 # Example:
127 127 #
128 128 # ACLU(American Civil Liberties Union)
129 129 #
130 130 # Will become:
131 131 #
132 132 # <abbr title="American Civil Liberties Union">ACLU</abbr>
133 133 #
134 134 # == Adding Tables
135 135 #
136 136 # In Textile, simple tables can be added by separating each column by
137 137 # a pipe.
138 138 #
139 139 # |a|simple|table|row|
140 140 # |And|Another|table|row|
141 141 #
142 142 # Attributes are defined by style definitions in parentheses.
143 143 #
144 144 # table(border:1px solid black).
145 145 # (background:#ddd;color:red). |{}| | | |
146 146 #
147 147 # == Using RedCloth
148 148 #
149 149 # RedCloth is simply an extension of the String class, which can handle
150 150 # Textile formatting. Use it like a String and output HTML with its
151 151 # RedCloth#to_html method.
152 152 #
153 153 # doc = RedCloth.new "
154 154 #
155 155 # h2. Test document
156 156 #
157 157 # Just a simple test."
158 158 #
159 159 # puts doc.to_html
160 160 #
161 161 # By default, RedCloth uses both Textile and Markdown formatting, with
162 162 # Textile formatting taking precedence. If you want to turn off Markdown
163 163 # formatting, to boost speed and limit the processor:
164 164 #
165 165 # class RedCloth::Textile.new( str )
166 166
167 167 class RedCloth3 < String
168 include Redmine::Helpers::URL
168 169
169 170 VERSION = '3.0.4'
170 171 DEFAULT_RULES = [:textile, :markdown]
171 172
172 173 #
173 174 # Two accessor for setting security restrictions.
174 175 #
175 176 # This is a nice thing if you're using RedCloth for
176 177 # formatting in public places (e.g. Wikis) where you
177 178 # don't want users to abuse HTML for bad things.
178 179 #
179 180 # If +:filter_html+ is set, HTML which wasn't
180 181 # created by the Textile processor will be escaped.
181 182 #
182 183 # If +:filter_styles+ is set, it will also disable
183 184 # the style markup specifier. ('{color: red}')
184 185 #
185 186 attr_accessor :filter_html, :filter_styles
186 187
187 188 #
188 189 # Accessor for toggling hard breaks.
189 190 #
190 191 # If +:hard_breaks+ is set, single newlines will
191 192 # be converted to HTML break tags. This is the
192 193 # default behavior for traditional RedCloth.
193 194 #
194 195 attr_accessor :hard_breaks
195 196
196 197 # Accessor for toggling lite mode.
197 198 #
198 199 # In lite mode, block-level rules are ignored. This means
199 200 # that tables, paragraphs, lists, and such aren't available.
200 201 # Only the inline markup for bold, italics, entities and so on.
201 202 #
202 203 # r = RedCloth.new( "And then? She *fell*!", [:lite_mode] )
203 204 # r.to_html
204 205 # #=> "And then? She <strong>fell</strong>!"
205 206 #
206 207 attr_accessor :lite_mode
207 208
208 209 #
209 210 # Accessor for toggling span caps.
210 211 #
211 212 # Textile places `span' tags around capitalized
212 213 # words by default, but this wreaks havoc on Wikis.
213 214 # If +:no_span_caps+ is set, this will be
214 215 # suppressed.
215 216 #
216 217 attr_accessor :no_span_caps
217 218
218 219 #
219 220 # Establishes the markup predence. Available rules include:
220 221 #
221 222 # == Textile Rules
222 223 #
223 224 # The following textile rules can be set individually. Or add the complete
224 225 # set of rules with the single :textile rule, which supplies the rule set in
225 226 # the following precedence:
226 227 #
227 228 # refs_textile:: Textile references (i.e. [hobix]http://hobix.com/)
228 229 # block_textile_table:: Textile table block structures
229 230 # block_textile_lists:: Textile list structures
230 231 # block_textile_prefix:: Textile blocks with prefixes (i.e. bq., h2., etc.)
231 232 # inline_textile_image:: Textile inline images
232 233 # inline_textile_link:: Textile inline links
233 234 # inline_textile_span:: Textile inline spans
234 235 # glyphs_textile:: Textile entities (such as em-dashes and smart quotes)
235 236 #
236 237 # == Markdown
237 238 #
238 239 # refs_markdown:: Markdown references (for example: [hobix]: http://hobix.com/)
239 240 # block_markdown_setext:: Markdown setext headers
240 241 # block_markdown_atx:: Markdown atx headers
241 242 # block_markdown_rule:: Markdown horizontal rules
242 243 # block_markdown_bq:: Markdown blockquotes
243 244 # block_markdown_lists:: Markdown lists
244 245 # inline_markdown_link:: Markdown links
245 246 attr_accessor :rules
246 247
247 248 # Returns a new RedCloth object, based on _string_ and
248 249 # enforcing all the included _restrictions_.
249 250 #
250 251 # r = RedCloth.new( "h1. A <b>bold</b> man", [:filter_html] )
251 252 # r.to_html
252 253 # #=>"<h1>A &lt;b&gt;bold&lt;/b&gt; man</h1>"
253 254 #
254 255 def initialize( string, restrictions = [] )
255 256 restrictions.each { |r| method( "#{ r }=" ).call( true ) }
256 257 super( string )
257 258 end
258 259
259 260 #
260 261 # Generates HTML from the Textile contents.
261 262 #
262 263 # r = RedCloth.new( "And then? She *fell*!" )
263 264 # r.to_html( true )
264 265 # #=>"And then? She <strong>fell</strong>!"
265 266 #
266 267 def to_html( *rules )
267 268 rules = DEFAULT_RULES if rules.empty?
268 269 # make our working copy
269 270 text = self.dup
270 271
271 272 @urlrefs = {}
272 273 @shelf = []
273 274 textile_rules = [:block_textile_table, :block_textile_lists,
274 275 :block_textile_prefix, :inline_textile_image, :inline_textile_link,
275 276 :inline_textile_code, :inline_textile_span, :glyphs_textile]
276 277 markdown_rules = [:refs_markdown, :block_markdown_setext, :block_markdown_atx, :block_markdown_rule,
277 278 :block_markdown_bq, :block_markdown_lists,
278 279 :inline_markdown_reflink, :inline_markdown_link]
279 280 @rules = rules.collect do |rule|
280 281 case rule
281 282 when :markdown
282 283 markdown_rules
283 284 when :textile
284 285 textile_rules
285 286 else
286 287 rule
287 288 end
288 289 end.flatten
289 290
290 291 # standard clean up
291 292 incoming_entities text
292 293 clean_white_space text
293 294
294 295 # start processor
295 296 @pre_list = []
296 297 rip_offtags text
297 298 no_textile text
298 299 escape_html_tags text
299 300 # need to do this before #hard_break and #blocks
300 301 block_textile_quotes text unless @lite_mode
301 302 hard_break text
302 303 unless @lite_mode
303 304 refs text
304 305 blocks text
305 306 end
306 307 inline text
307 308 smooth_offtags text
308 309
309 310 retrieve text
310 311
311 312 text.gsub!( /<\/?notextile>/, '' )
312 313 text.gsub!( /x%x%/, '&#38;' )
313 314 clean_html text if filter_html
314 315 text.strip!
315 316 text
316 317
317 318 end
318 319
319 320 #######
320 321 private
321 322 #######
322 323 #
323 324 # Mapping of 8-bit ASCII codes to HTML numerical entity equivalents.
324 325 # (from PyTextile)
325 326 #
326 327 TEXTILE_TAGS =
327 328
328 329 [[128, 8364], [129, 0], [130, 8218], [131, 402], [132, 8222], [133, 8230],
329 330 [134, 8224], [135, 8225], [136, 710], [137, 8240], [138, 352], [139, 8249],
330 331 [140, 338], [141, 0], [142, 0], [143, 0], [144, 0], [145, 8216], [146, 8217],
331 332 [147, 8220], [148, 8221], [149, 8226], [150, 8211], [151, 8212], [152, 732],
332 333 [153, 8482], [154, 353], [155, 8250], [156, 339], [157, 0], [158, 0], [159, 376]].
333 334
334 335 collect! do |a, b|
335 336 [a.chr, ( b.zero? and "" or "&#{ b };" )]
336 337 end
337 338
338 339 #
339 340 # Regular expressions to convert to HTML.
340 341 #
341 342 A_HLGN = /(?:(?:<>|<|>|\=|[()]+)+)/
342 343 A_VLGN = /[\-^~]/
343 344 C_CLAS = '(?:\([^")]+\))'
344 345 C_LNGE = '(?:\[[a-z\-_]+\])'
345 346 C_STYL = '(?:\{[^"}]+\})'
346 347 S_CSPN = '(?:\\\\\d+)'
347 348 S_RSPN = '(?:/\d+)'
348 349 A = "(?:#{A_HLGN}?#{A_VLGN}?|#{A_VLGN}?#{A_HLGN}?)"
349 350 S = "(?:#{S_CSPN}?#{S_RSPN}|#{S_RSPN}?#{S_CSPN}?)"
350 351 C = "(?:#{C_CLAS}?#{C_STYL}?#{C_LNGE}?|#{C_STYL}?#{C_LNGE}?#{C_CLAS}?|#{C_LNGE}?#{C_STYL}?#{C_CLAS}?)"
351 352 # PUNCT = Regexp::quote( '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' )
352 353 PUNCT = Regexp::quote( '!"#$%&\'*+,-./:;=?@\\^_`|~' )
353 354 PUNCT_NOQ = Regexp::quote( '!"#$&\',./:;=?@\\`|' )
354 355 PUNCT_Q = Regexp::quote( '*-_+^~%' )
355 356 HYPERLINK = '(\S+?)([^\w\s/;=\?]*?)(?=\s|<|$)'
356 357
357 358 # Text markup tags, don't conflict with block tags
358 359 SIMPLE_HTML_TAGS = [
359 360 'tt', 'b', 'i', 'big', 'small', 'em', 'strong', 'dfn', 'code',
360 361 'samp', 'kbd', 'var', 'cite', 'abbr', 'acronym', 'a', 'img', 'br',
361 362 'br', 'map', 'q', 'sub', 'sup', 'span', 'bdo'
362 363 ]
363 364
364 365 QTAGS = [
365 366 ['**', 'b', :limit],
366 367 ['*', 'strong', :limit],
367 368 ['??', 'cite', :limit],
368 369 ['-', 'del', :limit],
369 370 ['__', 'i', :limit],
370 371 ['_', 'em', :limit],
371 372 ['%', 'span', :limit],
372 373 ['+', 'ins', :limit],
373 374 ['^', 'sup', :limit],
374 375 ['~', 'sub', :limit]
375 376 ]
376 377 QTAGS_JOIN = QTAGS.map {|rc, ht, rtype| Regexp::quote rc}.join('|')
377 378
378 379 QTAGS.collect! do |rc, ht, rtype|
379 380 rcq = Regexp::quote rc
380 381 re =
381 382 case rtype
382 383 when :limit
383 384 /(^|[>\s\(]) # sta
384 385 (?!\-\-)
385 386 (#{QTAGS_JOIN}|) # oqs
386 387 (#{rcq}) # qtag
387 388 ([[:word:]]|[^\s].*?[^\s]) # content
388 389 (?!\-\-)
389 390 #{rcq}
390 391 (#{QTAGS_JOIN}|) # oqa
391 392 (?=[[:punct:]]|<|\s|\)|$)/x
392 393 else
393 394 /(#{rcq})
394 395 (#{C})
395 396 (?::(\S+))?
396 397 ([[:word:]]|[^\s\-].*?[^\s\-])
397 398 #{rcq}/xm
398 399 end
399 400 [rc, ht, re, rtype]
400 401 end
401 402
402 403 # Elements to handle
403 404 GLYPHS = [
404 405 # [ /([^\s\[{(>])?\'([dmst]\b|ll\b|ve\b|\s|:|$)/, '\1&#8217;\2' ], # single closing
405 406 # [ /([^\s\[{(>#{PUNCT_Q}][#{PUNCT_Q}]*)\'/, '\1&#8217;' ], # single closing
406 407 # [ /\'(?=[#{PUNCT_Q}]*(s\b|[\s#{PUNCT_NOQ}]))/, '&#8217;' ], # single closing
407 408 # [ /\'/, '&#8216;' ], # single opening
408 409 # [ /</, '&lt;' ], # less-than
409 410 # [ />/, '&gt;' ], # greater-than
410 411 # [ /([^\s\[{(])?"(\s|:|$)/, '\1&#8221;\2' ], # double closing
411 412 # [ /([^\s\[{(>#{PUNCT_Q}][#{PUNCT_Q}]*)"/, '\1&#8221;' ], # double closing
412 413 # [ /"(?=[#{PUNCT_Q}]*[\s#{PUNCT_NOQ}])/, '&#8221;' ], # double closing
413 414 # [ /"/, '&#8220;' ], # double opening
414 415 # [ /\b( )?\.{3}/, '\1&#8230;' ], # ellipsis
415 416 # [ /\b([A-Z][A-Z0-9]{2,})\b(?:[(]([^)]*)[)])/, '<acronym title="\2">\1</acronym>' ], # 3+ uppercase acronym
416 417 # [ /(^|[^"][>\s])([A-Z][A-Z0-9 ]+[A-Z0-9])([^<A-Za-z0-9]|$)/, '\1<span class="caps">\2</span>\3', :no_span_caps ], # 3+ uppercase caps
417 418 # [ /(\.\s)?\s?--\s?/, '\1&#8212;' ], # em dash
418 419 # [ /\s->\s/, ' &rarr; ' ], # right arrow
419 420 # [ /\s-\s/, ' &#8211; ' ], # en dash
420 421 # [ /(\d+) ?x ?(\d+)/, '\1&#215;\2' ], # dimension sign
421 422 # [ /\b ?[(\[]TM[\])]/i, '&#8482;' ], # trademark
422 423 # [ /\b ?[(\[]R[\])]/i, '&#174;' ], # registered
423 424 # [ /\b ?[(\[]C[\])]/i, '&#169;' ] # copyright
424 425 ]
425 426
426 427 H_ALGN_VALS = {
427 428 '<' => 'left',
428 429 '=' => 'center',
429 430 '>' => 'right',
430 431 '<>' => 'justify'
431 432 }
432 433
433 434 V_ALGN_VALS = {
434 435 '^' => 'top',
435 436 '-' => 'middle',
436 437 '~' => 'bottom'
437 438 }
438 439
439 440 #
440 441 # Flexible HTML escaping
441 442 #
442 443 def htmlesc( str, mode=:Quotes )
443 444 if str
444 445 str.gsub!( '&', '&amp;' )
445 446 str.gsub!( '"', '&quot;' ) if mode != :NoQuotes
446 447 str.gsub!( "'", '&#039;' ) if mode == :Quotes
447 448 str.gsub!( '<', '&lt;')
448 449 str.gsub!( '>', '&gt;')
449 450 end
450 451 str
451 452 end
452 453
453 454 # Search and replace for Textile glyphs (quotes, dashes, other symbols)
454 455 def pgl( text )
455 456 #GLYPHS.each do |re, resub, tog|
456 457 # next if tog and method( tog ).call
457 458 # text.gsub! re, resub
458 459 #end
459 460 text.gsub!(/\b([A-Z][A-Z0-9]{1,})\b(?:[(]([^)]*)[)])/) do |m|
460 461 "<abbr title=\"#{htmlesc $2}\">#{$1}</abbr>"
461 462 end
462 463 end
463 464
464 465 # Parses Textile attribute lists and builds an HTML attribute string
465 466 def pba( text_in, element = "" )
466 467
467 468 return '' unless text_in
468 469
469 470 style = []
470 471 text = text_in.dup
471 472 if element == 'td'
472 473 colspan = $1 if text =~ /\\(\d+)/
473 474 rowspan = $1 if text =~ /\/(\d+)/
474 475 style << "vertical-align:#{ v_align( $& ) };" if text =~ A_VLGN
475 476 end
476 477
477 478 if text.sub!( /\{([^"}]*)\}/, '' ) && !filter_styles
478 479 sanitized = sanitize_styles($1)
479 480 style << "#{ sanitized };" unless sanitized.blank?
480 481 end
481 482
482 483 lang = $1 if
483 484 text.sub!( /\[([a-z\-_]+?)\]/, '' )
484 485
485 486 cls = $1 if
486 487 text.sub!( /\(([^()]+?)\)/, '' )
487 488
488 489 style << "padding-left:#{ $1.length }em;" if
489 490 text.sub!( /([(]+)/, '' )
490 491
491 492 style << "padding-right:#{ $1.length }em;" if text.sub!( /([)]+)/, '' )
492 493
493 494 style << "text-align:#{ h_align( $& ) };" if text =~ A_HLGN
494 495
495 496 cls, id = $1, $2 if cls =~ /^(.*?)#(.*)$/
496 497
497 498 atts = ''
498 499 atts << " style=\"#{ style.join }\"" unless style.empty?
499 500 atts << " class=\"#{ cls }\"" unless cls.to_s.empty?
500 501 atts << " lang=\"#{ lang }\"" if lang
501 502 atts << " id=\"#{ id }\"" if id
502 503 atts << " colspan=\"#{ colspan }\"" if colspan
503 504 atts << " rowspan=\"#{ rowspan }\"" if rowspan
504 505
505 506 atts
506 507 end
507 508
508 509 STYLES_RE = /^(color|width|height|border|background|padding|margin|font|text|float)(-[a-z]+)*:\s*((\d+%?|\d+px|\d+(\.\d+)?em|#[0-9a-f]+|[a-z]+)\s*)+$/i
509 510
510 511 def sanitize_styles(str)
511 512 styles = str.split(";").map(&:strip)
512 513 styles.reject! do |style|
513 514 !style.match(STYLES_RE)
514 515 end
515 516 styles.join(";")
516 517 end
517 518
518 519 TABLE_RE = /^(?:table(_?#{S}#{A}#{C})\. ?\n)?^(#{A}#{C}\.? ?\|.*?\|)(\n\n|\Z)/m
519 520
520 521 # Parses a Textile table block, building HTML from the result.
521 522 def block_textile_table( text )
522 523 text.gsub!( TABLE_RE ) do |matches|
523 524
524 525 tatts, fullrow = $~[1..2]
525 526 tatts = pba( tatts, 'table' )
526 527 tatts = shelve( tatts ) if tatts
527 528 rows = []
528 529 fullrow.gsub!(/([^|\s])\s*\n/, "\\1<br />")
529 530 fullrow.each_line do |row|
530 531 ratts, row = pba( $1, 'tr' ), $2 if row =~ /^(#{A}#{C}\. )(.*)/m
531 532 cells = []
532 533 # the regexp prevents wiki links with a | from being cut as cells
533 534 row.scan(/\|(_?#{S}#{A}#{C}\. ?)?((\[\[[^|\]]*\|[^|\]]*\]\]|[^|])*?)(?=\|)/) do |modifiers, cell|
534 535 ctyp = 'd'
535 536 ctyp = 'h' if modifiers && modifiers =~ /^_/
536 537
537 538 catts = nil
538 539 catts = pba( modifiers, 'td' ) if modifiers
539 540
540 541 catts = shelve( catts ) if catts
541 542 cells << "\t\t\t<t#{ ctyp }#{ catts }>#{ cell }</t#{ ctyp }>"
542 543 end
543 544 ratts = shelve( ratts ) if ratts
544 545 rows << "\t\t<tr#{ ratts }>\n#{ cells.join( "\n" ) }\n\t\t</tr>"
545 546 end
546 547 "\t<table#{ tatts }>\n#{ rows.join( "\n" ) }\n\t</table>\n\n"
547 548 end
548 549 end
549 550
550 551 LISTS_RE = /^([#*]+?#{C} .*?)$(?![^#*])/m
551 552 LISTS_CONTENT_RE = /^([#*]+)(#{A}#{C}) (.*)$/m
552 553
553 554 # Parses Textile lists and generates HTML
554 555 def block_textile_lists( text )
555 556 text.gsub!( LISTS_RE ) do |match|
556 557 lines = match.split( /\n/ )
557 558 last_line = -1
558 559 depth = []
559 560 lines.each_with_index do |line, line_id|
560 561 if line =~ LISTS_CONTENT_RE
561 562 tl,atts,content = $~[1..3]
562 563 if depth.last
563 564 if depth.last.length > tl.length
564 565 (depth.length - 1).downto(0) do |i|
565 566 break if depth[i].length == tl.length
566 567 lines[line_id - 1] << "</li>\n\t</#{ lT( depth[i] ) }l>\n\t"
567 568 depth.pop
568 569 end
569 570 end
570 571 if depth.last and depth.last.length == tl.length
571 572 lines[line_id - 1] << '</li>'
572 573 end
573 574 end
574 575 unless depth.last == tl
575 576 depth << tl
576 577 atts = pba( atts )
577 578 atts = shelve( atts ) if atts
578 579 lines[line_id] = "\t<#{ lT(tl) }l#{ atts }>\n\t<li>#{ content }"
579 580 else
580 581 lines[line_id] = "\t\t<li>#{ content }"
581 582 end
582 583 last_line = line_id
583 584
584 585 else
585 586 last_line = line_id
586 587 end
587 588 if line_id - last_line > 1 or line_id == lines.length - 1
588 589 while v = depth.pop
589 590 lines[last_line] << "</li>\n\t</#{ lT( v ) }l>"
590 591 end
591 592 end
592 593 end
593 594 lines.join( "\n" )
594 595 end
595 596 end
596 597
597 598 QUOTES_RE = /(^>+([^\n]*?)(\n|$))+/m
598 599 QUOTES_CONTENT_RE = /^([> ]+)(.*)$/m
599 600
600 601 def block_textile_quotes( text )
601 602 text.gsub!( QUOTES_RE ) do |match|
602 603 lines = match.split( /\n/ )
603 604 quotes = ''
604 605 indent = 0
605 606 lines.each do |line|
606 607 line =~ QUOTES_CONTENT_RE
607 608 bq,content = $1, $2
608 609 l = bq.count('>')
609 610 if l != indent
610 611 quotes << ("\n\n" + (l>indent ? '<blockquote>' * (l-indent) : '</blockquote>' * (indent-l)) + "\n\n")
611 612 indent = l
612 613 end
613 614 quotes << (content + "\n")
614 615 end
615 616 quotes << ("\n" + '</blockquote>' * indent + "\n\n")
616 617 quotes
617 618 end
618 619 end
619 620
620 621 CODE_RE = /(\W)
621 622 @
622 623 (?:\|(\w+?)\|)?
623 624 (.+?)
624 625 @
625 626 (?=\W)/x
626 627
627 628 def inline_textile_code( text )
628 629 text.gsub!( CODE_RE ) do |m|
629 630 before,lang,code,after = $~[1..4]
630 631 lang = " lang=\"#{ lang }\"" if lang
631 632 rip_offtags( "#{ before }<code#{ lang }>#{ code }</code>#{ after }", false )
632 633 end
633 634 end
634 635
635 636 def lT( text )
636 637 text =~ /\#$/ ? 'o' : 'u'
637 638 end
638 639
639 640 def hard_break( text )
640 641 text.gsub!( /(.)\n(?!\Z| *([#*=]+(\s|$)|[{|]))/, "\\1<br />" ) if hard_breaks
641 642 end
642 643
643 644 BLOCKS_GROUP_RE = /\n{2,}(?! )/m
644 645
645 646 def blocks( text, deep_code = false )
646 647 text.replace( text.split( BLOCKS_GROUP_RE ).collect do |blk|
647 648 plain = blk !~ /\A[#*> ]/
648 649
649 650 # skip blocks that are complex HTML
650 651 if blk =~ /^<\/?(\w+).*>/ and not SIMPLE_HTML_TAGS.include? $1
651 652 blk
652 653 else
653 654 # search for indentation levels
654 655 blk.strip!
655 656 if blk.empty?
656 657 blk
657 658 else
658 659 code_blk = nil
659 660 blk.gsub!( /((?:\n(?:\n^ +[^\n]*)+)+)/m ) do |iblk|
660 661 flush_left iblk
661 662 blocks iblk, plain
662 663 iblk.gsub( /^(\S)/, "\t\\1" )
663 664 if plain
664 665 code_blk = iblk; ""
665 666 else
666 667 iblk
667 668 end
668 669 end
669 670
670 671 block_applied = 0
671 672 @rules.each do |rule_name|
672 673 block_applied += 1 if ( rule_name.to_s.match /^block_/ and method( rule_name ).call( blk ) )
673 674 end
674 675 if block_applied.zero?
675 676 if deep_code
676 677 blk = "\t<pre><code>#{ blk }</code></pre>"
677 678 else
678 679 blk = "\t<p>#{ blk }</p>"
679 680 end
680 681 end
681 682 # hard_break blk
682 683 blk + "\n#{ code_blk }"
683 684 end
684 685 end
685 686
686 687 end.join( "\n\n" ) )
687 688 end
688 689
689 690 def textile_bq( tag, atts, cite, content )
690 691 cite, cite_title = check_refs( cite )
691 692 cite = " cite=\"#{ cite }\"" if cite
692 693 atts = shelve( atts ) if atts
693 694 "\t<blockquote#{ cite }>\n\t\t<p#{ atts }>#{ content }</p>\n\t</blockquote>"
694 695 end
695 696
696 697 def textile_p( tag, atts, cite, content )
697 698 atts = shelve( atts ) if atts
698 699 "\t<#{ tag }#{ atts }>#{ content }</#{ tag }>"
699 700 end
700 701
701 702 alias textile_h1 textile_p
702 703 alias textile_h2 textile_p
703 704 alias textile_h3 textile_p
704 705 alias textile_h4 textile_p
705 706 alias textile_h5 textile_p
706 707 alias textile_h6 textile_p
707 708
708 709 def textile_fn_( tag, num, atts, cite, content )
709 710 atts << " id=\"fn#{ num }\" class=\"footnote\""
710 711 content = "<sup>#{ num }</sup> #{ content }"
711 712 atts = shelve( atts ) if atts
712 713 "\t<p#{ atts }>#{ content }</p>"
713 714 end
714 715
715 716 BLOCK_RE = /^(([a-z]+)(\d*))(#{A}#{C})\.(?::(\S+))? (.*)$/m
716 717
717 718 def block_textile_prefix( text )
718 719 if text =~ BLOCK_RE
719 720 tag,tagpre,num,atts,cite,content = $~[1..6]
720 721 atts = pba( atts )
721 722
722 723 # pass to prefix handler
723 724 replacement = nil
724 725 if respond_to? "textile_#{ tag }", true
725 726 replacement = method( "textile_#{ tag }" ).call( tag, atts, cite, content )
726 727 elsif respond_to? "textile_#{ tagpre }_", true
727 728 replacement = method( "textile_#{ tagpre }_" ).call( tagpre, num, atts, cite, content )
728 729 end
729 730 text.gsub!( $& ) { replacement } if replacement
730 731 end
731 732 end
732 733
733 734 SETEXT_RE = /\A(.+?)\n([=-])[=-]* *$/m
734 735 def block_markdown_setext( text )
735 736 if text =~ SETEXT_RE
736 737 tag = if $2 == "="; "h1"; else; "h2"; end
737 738 blk, cont = "<#{ tag }>#{ $1 }</#{ tag }>", $'
738 739 blocks cont
739 740 text.replace( blk + cont )
740 741 end
741 742 end
742 743
743 744 ATX_RE = /\A(\#{1,6}) # $1 = string of #'s
744 745 [ ]*
745 746 (.+?) # $2 = Header text
746 747 [ ]*
747 748 \#* # optional closing #'s (not counted)
748 749 $/x
749 750 def block_markdown_atx( text )
750 751 if text =~ ATX_RE
751 752 tag = "h#{ $1.length }"
752 753 blk, cont = "<#{ tag }>#{ $2 }</#{ tag }>\n\n", $'
753 754 blocks cont
754 755 text.replace( blk + cont )
755 756 end
756 757 end
757 758
758 759 MARKDOWN_BQ_RE = /\A(^ *> ?.+$(.+\n)*\n*)+/m
759 760
760 761 def block_markdown_bq( text )
761 762 text.gsub!( MARKDOWN_BQ_RE ) do |blk|
762 763 blk.gsub!( /^ *> ?/, '' )
763 764 flush_left blk
764 765 blocks blk
765 766 blk.gsub!( /^(\S)/, "\t\\1" )
766 767 "<blockquote>\n#{ blk }\n</blockquote>\n\n"
767 768 end
768 769 end
769 770
770 771 MARKDOWN_RULE_RE = /^(#{
771 772 ['*', '-', '_'].collect { |ch| ' ?(' + Regexp::quote( ch ) + ' ?){3,}' }.join( '|' )
772 773 })$/
773 774
774 775 def block_markdown_rule( text )
775 776 text.gsub!( MARKDOWN_RULE_RE ) do |blk|
776 777 "<hr />"
777 778 end
778 779 end
779 780
780 781 # XXX TODO XXX
781 782 def block_markdown_lists( text )
782 783 end
783 784
784 785 def inline_textile_span( text )
785 786 QTAGS.each do |qtag_rc, ht, qtag_re, rtype|
786 787 text.gsub!( qtag_re ) do |m|
787 788
788 789 case rtype
789 790 when :limit
790 791 sta,oqs,qtag,content,oqa = $~[1..6]
791 792 atts = nil
792 793 if content =~ /^(#{C})(.+)$/
793 794 atts, content = $~[1..2]
794 795 end
795 796 else
796 797 qtag,atts,cite,content = $~[1..4]
797 798 sta = ''
798 799 end
799 800 atts = pba( atts )
800 801 atts = shelve( atts ) if atts
801 802
802 803 "#{ sta }#{ oqs }<#{ ht }#{ atts }>#{ content }</#{ ht }>#{ oqa }"
803 804
804 805 end
805 806 end
806 807 end
807 808
808 809 LINK_RE = /
809 810 (
810 811 ([\s\[{(]|[#{PUNCT}])? # $pre
811 812 " # start
812 813 (#{C}) # $atts
813 814 ([^"\n]+?) # $text
814 815 \s?
815 816 (?:\(([^)]+?)\)(?="))? # $title
816 817 ":
817 818 ( # $url
818 819 (\/|[a-zA-Z]+:\/\/|www\.|mailto:) # $proto
819 820 [[:alnum:]_\/]\S+?
820 821 )
821 822 (\/)? # $slash
822 823 ([^[:alnum:]_\=\/;\(\)]*?) # $post
823 824 )
824 825 (?=<|\s|$)
825 826 /x
826 827 #"
827 828 def inline_textile_link( text )
828 829 text.gsub!( LINK_RE ) do |m|
829 830 all,pre,atts,text,title,url,proto,slash,post = $~[1..9]
830 831 if text.include?('<br />')
831 832 all
832 833 else
833 834 url, url_title = check_refs( url )
834 835 title ||= url_title
835 836
836 837 # Idea below : an URL with unbalanced parethesis and
837 838 # ending by ')' is put into external parenthesis
838 839 if ( url[-1]==?) and ((url.count("(") - url.count(")")) < 0 ) )
839 840 url=url[0..-2] # discard closing parenth from url
840 841 post = ")"+post # add closing parenth to post
841 842 end
842 843 atts = pba( atts )
843 844 atts = " href=\"#{ htmlesc url }#{ slash }\"#{ atts }"
844 845 atts << " title=\"#{ htmlesc title }\"" if title
845 846 atts = shelve( atts ) if atts
846 847
847 848 external = (url =~ /^https?:\/\//) ? ' class="external"' : ''
848 849
849 850 "#{ pre }<a#{ atts }#{ external }>#{ text }</a>#{ post }"
850 851 end
851 852 end
852 853 end
853 854
854 855 MARKDOWN_REFLINK_RE = /
855 856 \[([^\[\]]+)\] # $text
856 857 [ ]? # opt. space
857 858 (?:\n[ ]*)? # one optional newline followed by spaces
858 859 \[(.*?)\] # $id
859 860 /x
860 861
861 862 def inline_markdown_reflink( text )
862 863 text.gsub!( MARKDOWN_REFLINK_RE ) do |m|
863 864 text, id = $~[1..2]
864 865
865 866 if id.empty?
866 867 url, title = check_refs( text )
867 868 else
868 869 url, title = check_refs( id )
869 870 end
870 871
871 872 atts = " href=\"#{ url }\""
872 873 atts << " title=\"#{ title }\"" if title
873 874 atts = shelve( atts )
874 875
875 876 "<a#{ atts }>#{ text }</a>"
876 877 end
877 878 end
878 879
879 880 MARKDOWN_LINK_RE = /
880 881 \[([^\[\]]+)\] # $text
881 882 \( # open paren
882 883 [ \t]* # opt space
883 884 <?(.+?)>? # $href
884 885 [ \t]* # opt space
885 886 (?: # whole title
886 887 (['"]) # $quote
887 888 (.*?) # $title
888 889 \3 # matching quote
889 890 )? # title is optional
890 891 \)
891 892 /x
892 893
893 894 def inline_markdown_link( text )
894 895 text.gsub!( MARKDOWN_LINK_RE ) do |m|
895 896 text, url, quote, title = $~[1..4]
896 897
897 898 atts = " href=\"#{ url }\""
898 899 atts << " title=\"#{ title }\"" if title
899 900 atts = shelve( atts )
900 901
901 902 "<a#{ atts }>#{ text }</a>"
902 903 end
903 904 end
904 905
905 906 TEXTILE_REFS_RE = /(^ *)\[([^\[\n]+?)\](#{HYPERLINK})(?=\s|$)/
906 907 MARKDOWN_REFS_RE = /(^ *)\[([^\n]+?)\]:\s+<?(#{HYPERLINK})>?(?:\s+"((?:[^"]|\\")+)")?(?=\s|$)/m
907 908
908 909 def refs( text )
909 910 @rules.each do |rule_name|
910 911 method( rule_name ).call( text ) if rule_name.to_s.match /^refs_/
911 912 end
912 913 end
913 914
914 915 def refs_textile( text )
915 916 text.gsub!( TEXTILE_REFS_RE ) do |m|
916 917 flag, url = $~[2..3]
917 918 @urlrefs[flag.downcase] = [url, nil]
918 919 nil
919 920 end
920 921 end
921 922
922 923 def refs_markdown( text )
923 924 text.gsub!( MARKDOWN_REFS_RE ) do |m|
924 925 flag, url = $~[2..3]
925 926 title = $~[6]
926 927 @urlrefs[flag.downcase] = [url, title]
927 928 nil
928 929 end
929 930 end
930 931
931 932 def check_refs( text )
932 933 ret = @urlrefs[text.downcase] if text
933 934 ret || [text, nil]
934 935 end
935 936
936 937 IMAGE_RE = /
937 938 (>|\s|^) # start of line?
938 939 \! # opening
939 940 (\<|\=|\>)? # optional alignment atts
940 941 (#{C}) # optional style,class atts
941 942 (?:\. )? # optional dot-space
942 943 ([^\s(!]+?) # presume this is the src
943 944 \s? # optional space
944 945 (?:\(((?:[^\(\)]|\([^\)]+\))+?)\))? # optional title
945 946 \! # closing
946 947 (?::#{ HYPERLINK })? # optional href
947 948 /x
948 949
949 950 def inline_textile_image( text )
950 951 text.gsub!( IMAGE_RE ) do |m|
951 952 stln,algn,atts,url,title,href,href_a1,href_a2 = $~[1..8]
952 953 htmlesc title
953 954 atts = pba( atts )
954 955 atts = " src=\"#{ htmlesc url.dup }\"#{ atts }"
955 956 atts << " title=\"#{ title }\"" if title
956 957 atts << " alt=\"#{ title }\""
957 958 # size = @getimagesize($url);
958 959 # if($size) $atts.= " $size[3]";
959 960
960 961 href, alt_title = check_refs( href ) if href
961 962 url, url_title = check_refs( url )
962 963
964 return m unless uri_with_safe_scheme?(url)
965
963 966 out = ''
964 967 out << "<a#{ shelve( " href=\"#{ href }\"" ) }>" if href
965 968 out << "<img#{ shelve( atts ) } />"
966 969 out << "</a>#{ href_a1 }#{ href_a2 }" if href
967 970
968 971 if algn
969 972 algn = h_align( algn )
970 973 if stln == "<p>"
971 974 out = "<p style=\"float:#{ algn }\">#{ out }"
972 975 else
973 976 out = "#{ stln }<div style=\"float:#{ algn }\">#{ out }</div>"
974 977 end
975 978 else
976 979 out = stln + out
977 980 end
978 981
979 982 out
980 983 end
981 984 end
982 985
983 986 def shelve( val )
984 987 @shelf << val
985 988 " :redsh##{ @shelf.length }:"
986 989 end
987 990
988 991 def retrieve( text )
989 992 text.gsub!(/ :redsh#(\d+):/) do
990 993 @shelf[$1.to_i - 1] || $&
991 994 end
992 995 end
993 996
994 997 def incoming_entities( text )
995 998 ## turn any incoming ampersands into a dummy character for now.
996 999 ## This uses a negative lookahead for alphanumerics followed by a semicolon,
997 1000 ## implying an incoming html entity, to be skipped
998 1001
999 1002 text.gsub!( /&(?![#a-z0-9]+;)/i, "x%x%" )
1000 1003 end
1001 1004
1002 1005 def no_textile( text )
1003 1006 text.gsub!( /(^|\s)==([^=]+.*?)==(\s|$)?/,
1004 1007 '\1<notextile>\2</notextile>\3' )
1005 1008 text.gsub!( /^ *==([^=]+.*?)==/m,
1006 1009 '\1<notextile>\2</notextile>\3' )
1007 1010 end
1008 1011
1009 1012 def clean_white_space( text )
1010 1013 # normalize line breaks
1011 1014 text.gsub!( /\r\n/, "\n" )
1012 1015 text.gsub!( /\r/, "\n" )
1013 1016 text.gsub!( /\t/, ' ' )
1014 1017 text.gsub!( /^ +$/, '' )
1015 1018 text.gsub!( /\n{3,}/, "\n\n" )
1016 1019 text.gsub!( /"$/, "\" " )
1017 1020
1018 1021 # if entire document is indented, flush
1019 1022 # to the left side
1020 1023 flush_left text
1021 1024 end
1022 1025
1023 1026 def flush_left( text )
1024 1027 indt = 0
1025 1028 if text =~ /^ /
1026 1029 while text !~ /^ {#{indt}}\S/
1027 1030 indt += 1
1028 1031 end unless text.empty?
1029 1032 if indt.nonzero?
1030 1033 text.gsub!( /^ {#{indt}}/, '' )
1031 1034 end
1032 1035 end
1033 1036 end
1034 1037
1035 1038 def footnote_ref( text )
1036 1039 text.gsub!( /\b\[([0-9]+?)\](\s)?/,
1037 1040 '<sup><a href="#fn\1">\1</a></sup>\2' )
1038 1041 end
1039 1042
1040 1043 OFFTAGS = /(code|pre|kbd|notextile)/
1041 1044 OFFTAG_MATCH = /(?:(<\/#{ OFFTAGS }>)|(<#{ OFFTAGS }[^>]*>))(.*?)(?=<\/?#{ OFFTAGS }\W|\Z)/mi
1042 1045 OFFTAG_OPEN = /<#{ OFFTAGS }/
1043 1046 OFFTAG_CLOSE = /<\/?#{ OFFTAGS }/
1044 1047 HASTAG_MATCH = /(<\/?\w[^\n]*?>)/m
1045 1048 ALLTAG_MATCH = /(<\/?\w[^\n]*?>)|.*?(?=<\/?\w[^\n]*?>|$)/m
1046 1049
1047 1050 def glyphs_textile( text, level = 0 )
1048 1051 if text !~ HASTAG_MATCH
1049 1052 pgl text
1050 1053 footnote_ref text
1051 1054 else
1052 1055 codepre = 0
1053 1056 text.gsub!( ALLTAG_MATCH ) do |line|
1054 1057 ## matches are off if we're between <code>, <pre> etc.
1055 1058 if $1
1056 1059 if line =~ OFFTAG_OPEN
1057 1060 codepre += 1
1058 1061 elsif line =~ OFFTAG_CLOSE
1059 1062 codepre -= 1
1060 1063 codepre = 0 if codepre < 0
1061 1064 end
1062 1065 elsif codepre.zero?
1063 1066 glyphs_textile( line, level + 1 )
1064 1067 else
1065 1068 htmlesc( line, :NoQuotes )
1066 1069 end
1067 1070 # p [level, codepre, line]
1068 1071
1069 1072 line
1070 1073 end
1071 1074 end
1072 1075 end
1073 1076
1074 1077 def rip_offtags( text, escape_aftertag=true, escape_line=true )
1075 1078 if text =~ /<.*>/
1076 1079 ## strip and encode <pre> content
1077 1080 codepre, used_offtags = 0, {}
1078 1081 text.gsub!( OFFTAG_MATCH ) do |line|
1079 1082 if $3
1080 1083 first, offtag, aftertag = $3, $4, $5
1081 1084 codepre += 1
1082 1085 used_offtags[offtag] = true
1083 1086 if codepre - used_offtags.length > 0
1084 1087 htmlesc( line, :NoQuotes ) if escape_line
1085 1088 @pre_list.last << line
1086 1089 line = ""
1087 1090 else
1088 1091 ### htmlesc is disabled between CODE tags which will be parsed with highlighter
1089 1092 ### Regexp in formatter.rb is : /<code\s+class="(\w+)">\s?(.+)/m
1090 1093 ### NB: some changes were made not to use $N variables, because we use "match"
1091 1094 ### and it breaks following lines
1092 1095 htmlesc( aftertag, :NoQuotes ) if aftertag && escape_aftertag && !first.match(/<code\s+class="(\w+)">/)
1093 1096 line = "<redpre##{ @pre_list.length }>"
1094 1097 first.match(/<#{ OFFTAGS }([^>]*)>/)
1095 1098 tag = $1
1096 1099 $2.to_s.match(/(class\=("[^"]+"|'[^']+'))/i)
1097 1100 tag << " #{$1}" if $1
1098 1101 @pre_list << "<#{ tag }>#{ aftertag }"
1099 1102 end
1100 1103 elsif $1 and codepre > 0
1101 1104 if codepre - used_offtags.length > 0
1102 1105 htmlesc( line, :NoQuotes ) if escape_line
1103 1106 @pre_list.last << line
1104 1107 line = ""
1105 1108 end
1106 1109 codepre -= 1 unless codepre.zero?
1107 1110 used_offtags = {} if codepre.zero?
1108 1111 end
1109 1112 line
1110 1113 end
1111 1114 end
1112 1115 text
1113 1116 end
1114 1117
1115 1118 def smooth_offtags( text )
1116 1119 unless @pre_list.empty?
1117 1120 ## replace <pre> content
1118 1121 text.gsub!( /<redpre#(\d+)>/ ) { @pre_list[$1.to_i] }
1119 1122 end
1120 1123 end
1121 1124
1122 1125 def inline( text )
1123 1126 [/^inline_/, /^glyphs_/].each do |meth_re|
1124 1127 @rules.each do |rule_name|
1125 1128 method( rule_name ).call( text ) if rule_name.to_s.match( meth_re )
1126 1129 end
1127 1130 end
1128 1131 end
1129 1132
1130 1133 def h_align( text )
1131 1134 H_ALGN_VALS[text]
1132 1135 end
1133 1136
1134 1137 def v_align( text )
1135 1138 V_ALGN_VALS[text]
1136 1139 end
1137 1140
1138 1141 def textile_popup_help( name, windowW, windowH )
1139 1142 ' <a target="_blank" href="http://hobix.com/textile/#' + helpvar + '" onclick="window.open(this.href, \'popupwindow\', \'width=' + windowW + ',height=' + windowH + ',scrollbars,resizable\'); return false;">' + name + '</a><br />'
1140 1143 end
1141 1144
1142 1145 # HTML cleansing stuff
1143 1146 BASIC_TAGS = {
1144 1147 'a' => ['href', 'title'],
1145 1148 'img' => ['src', 'alt', 'title'],
1146 1149 'br' => [],
1147 1150 'i' => nil,
1148 1151 'u' => nil,
1149 1152 'b' => nil,
1150 1153 'pre' => nil,
1151 1154 'kbd' => nil,
1152 1155 'code' => ['lang'],
1153 1156 'cite' => nil,
1154 1157 'strong' => nil,
1155 1158 'em' => nil,
1156 1159 'ins' => nil,
1157 1160 'sup' => nil,
1158 1161 'sub' => nil,
1159 1162 'del' => nil,
1160 1163 'table' => nil,
1161 1164 'tr' => nil,
1162 1165 'td' => ['colspan', 'rowspan'],
1163 1166 'th' => nil,
1164 1167 'ol' => nil,
1165 1168 'ul' => nil,
1166 1169 'li' => nil,
1167 1170 'p' => nil,
1168 1171 'h1' => nil,
1169 1172 'h2' => nil,
1170 1173 'h3' => nil,
1171 1174 'h4' => nil,
1172 1175 'h5' => nil,
1173 1176 'h6' => nil,
1174 1177 'blockquote' => ['cite']
1175 1178 }
1176 1179
1177 1180 def clean_html( text, tags = BASIC_TAGS )
1178 1181 text.gsub!( /<!\[CDATA\[/, '' )
1179 1182 text.gsub!( /<(\/*)(\w+)([^>]*)>/ ) do
1180 1183 raw = $~
1181 1184 tag = raw[2].downcase
1182 1185 if tags.has_key? tag
1183 1186 pcs = [tag]
1184 1187 tags[tag].each do |prop|
1185 1188 ['"', "'", ''].each do |q|
1186 1189 q2 = ( q != '' ? q : '\s' )
1187 1190 if raw[3] =~ /#{prop}\s*=\s*#{q}([^#{q2}]+)#{q}/i
1188 1191 attrv = $1
1189 1192 next if prop == 'src' and attrv =~ %r{^(?!http)\w+:}
1190 1193 pcs << "#{prop}=\"#{$1.gsub('"', '\\"')}\""
1191 1194 break
1192 1195 end
1193 1196 end
1194 1197 end if tags[tag]
1195 1198 "<#{raw[1]}#{pcs.join " "}>"
1196 1199 else
1197 1200 " "
1198 1201 end
1199 1202 end
1200 1203 end
1201 1204
1202 1205 ALLOWED_TAGS = %w(redpre pre code notextile)
1203 1206
1204 1207 def escape_html_tags(text)
1205 1208 text.gsub!(%r{<(\/?([!\w]+)[^<>\n]*)(>?)}) {|m| ALLOWED_TAGS.include?($2) ? "<#{$1}#{$3}" : "&lt;#{$1}#{'&gt;' unless $3.blank?}" }
1206 1209 end
1207 1210 end
1208 1211
@@ -1,805 +1,818
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module Redmine
19 19 module FieldFormat
20 20 def self.add(name, klass)
21 21 all[name.to_s] = klass.instance
22 22 end
23 23
24 24 def self.delete(name)
25 25 all.delete(name.to_s)
26 26 end
27 27
28 28 def self.all
29 29 @formats ||= Hash.new(Base.instance)
30 30 end
31 31
32 32 def self.available_formats
33 33 all.keys
34 34 end
35 35
36 36 def self.find(name)
37 37 all[name.to_s]
38 38 end
39 39
40 40 # Return an array of custom field formats which can be used in select_tag
41 41 def self.as_select(class_name=nil)
42 42 formats = all.values.select do |format|
43 43 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(class_name)
44 44 end
45 45 formats.map {|format| [::I18n.t(format.label), format.name] }.sort_by(&:first)
46 46 end
47 47
48 48 class Base
49 49 include Singleton
50 50 include Redmine::I18n
51 include Redmine::Helpers::URL
51 52 include ERB::Util
52 53
53 54 class_attribute :format_name
54 55 self.format_name = nil
55 56
56 57 # Set this to true if the format supports multiple values
57 58 class_attribute :multiple_supported
58 59 self.multiple_supported = false
59 60
60 61 # Set this to true if the format supports textual search on custom values
61 62 class_attribute :searchable_supported
62 63 self.searchable_supported = false
63 64
64 65 # Set this to true if field values can be summed up
65 66 class_attribute :totalable_supported
66 67 self.totalable_supported = false
67 68
68 69 # Restricts the classes that the custom field can be added to
69 70 # Set to nil for no restrictions
70 71 class_attribute :customized_class_names
71 72 self.customized_class_names = nil
72 73
73 74 # Name of the partial for editing the custom field
74 75 class_attribute :form_partial
75 76 self.form_partial = nil
76 77
77 78 class_attribute :change_as_diff
78 79 self.change_as_diff = false
79 80
80 81 def self.add(name)
81 82 self.format_name = name
82 83 Redmine::FieldFormat.add(name, self)
83 84 end
84 85 private_class_method :add
85 86
86 87 def self.field_attributes(*args)
87 88 CustomField.store_accessor :format_store, *args
88 89 end
89 90
90 91 field_attributes :url_pattern
91 92
92 93 def name
93 94 self.class.format_name
94 95 end
95 96
96 97 def label
97 98 "label_#{name}"
98 99 end
99 100
100 101 def cast_custom_value(custom_value)
101 102 cast_value(custom_value.custom_field, custom_value.value, custom_value.customized)
102 103 end
103 104
104 105 def cast_value(custom_field, value, customized=nil)
105 106 if value.blank?
106 107 nil
107 108 elsif value.is_a?(Array)
108 109 casted = value.map do |v|
109 110 cast_single_value(custom_field, v, customized)
110 111 end
111 112 casted.compact.sort
112 113 else
113 114 cast_single_value(custom_field, value, customized)
114 115 end
115 116 end
116 117
117 118 def cast_single_value(custom_field, value, customized=nil)
118 119 value.to_s
119 120 end
120 121
121 122 def target_class
122 123 nil
123 124 end
124 125
125 126 def possible_custom_value_options(custom_value)
126 127 possible_values_options(custom_value.custom_field, custom_value.customized)
127 128 end
128 129
129 130 def possible_values_options(custom_field, object=nil)
130 131 []
131 132 end
132 133
133 134 def value_from_keyword(custom_field, keyword, object)
134 135 possible_values_options = possible_values_options(custom_field, object)
135 136 if possible_values_options.present?
136 137 keyword = keyword.to_s
137 138 if v = possible_values_options.detect {|text, id| keyword.casecmp(text) == 0}
138 139 if v.is_a?(Array)
139 140 v.last
140 141 else
141 142 v
142 143 end
143 144 end
144 145 else
145 146 keyword
146 147 end
147 148 end
148 149
149 150 # Returns the validation errors for custom_field
150 151 # Should return an empty array if custom_field is valid
151 152 def validate_custom_field(custom_field)
152 []
153 errors = []
154 pattern = custom_field.url_pattern
155 if pattern.present? && !uri_with_safe_scheme?(url_pattern_without_tokens(pattern))
156 errors << [:url_pattern, :invalid]
157 end
158 errors
153 159 end
154 160
155 161 # Returns the validation error messages for custom_value
156 162 # Should return an empty array if custom_value is valid
157 163 def validate_custom_value(custom_value)
158 164 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
159 165 errors = values.map do |value|
160 166 validate_single_value(custom_value.custom_field, value, custom_value.customized)
161 167 end
162 168 errors.flatten.uniq
163 169 end
164 170
165 171 def validate_single_value(custom_field, value, customized=nil)
166 172 []
167 173 end
168 174
169 175 def formatted_custom_value(view, custom_value, html=false)
170 176 formatted_value(view, custom_value.custom_field, custom_value.value, custom_value.customized, html)
171 177 end
172 178
173 179 def formatted_value(view, custom_field, value, customized=nil, html=false)
174 180 casted = cast_value(custom_field, value, customized)
175 181 if html && custom_field.url_pattern.present?
176 182 texts_and_urls = Array.wrap(casted).map do |single_value|
177 183 text = view.format_object(single_value, false).to_s
178 184 url = url_from_pattern(custom_field, single_value, customized)
179 185 [text, url]
180 186 end
181 links = texts_and_urls.sort_by(&:first).map {|text, url| view.link_to text, url}
187 links = texts_and_urls.sort_by(&:first).map {|text, url| view.link_to_if uri_with_safe_scheme?(url), text, url}
182 188 links.join(', ').html_safe
183 189 else
184 190 casted
185 191 end
186 192 end
187 193
188 194 # Returns an URL generated with the custom field URL pattern
189 195 # and variables substitution:
190 196 # %value% => the custom field value
191 197 # %id% => id of the customized object
192 198 # %project_id% => id of the project of the customized object if defined
193 199 # %project_identifier% => identifier of the project of the customized object if defined
194 200 # %m1%, %m2%... => capture groups matches of the custom field regexp if defined
195 201 def url_from_pattern(custom_field, value, customized)
196 202 url = custom_field.url_pattern.to_s.dup
197 203 url.gsub!('%value%') {value.to_s}
198 204 url.gsub!('%id%') {customized.id.to_s}
199 205 url.gsub!('%project_id%') {(customized.respond_to?(:project) ? customized.project.try(:id) : nil).to_s}
200 206 url.gsub!('%project_identifier%') {(customized.respond_to?(:project) ? customized.project.try(:identifier) : nil).to_s}
201 207 if custom_field.regexp.present?
202 208 url.gsub!(%r{%m(\d+)%}) do
203 209 m = $1.to_i
204 210 if matches ||= value.to_s.match(Regexp.new(custom_field.regexp))
205 211 matches[m].to_s
206 212 end
207 213 end
208 214 end
209 215 url
210 216 end
211 217 protected :url_from_pattern
212 218
219 # Returns the URL pattern with substitution tokens removed,
220 # for validation purpose
221 def url_pattern_without_tokens(url_pattern)
222 url_pattern.to_s.gsub(/%(value|id|project_id|project_identifier|m\d+)%/, '')
223 end
224 protected :url_pattern_without_tokens
225
213 226 def edit_tag(view, tag_id, tag_name, custom_value, options={})
214 227 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id))
215 228 end
216 229
217 230 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
218 231 view.text_field_tag(tag_name, value, options.merge(:id => tag_id)) +
219 232 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
220 233 end
221 234
222 235 def bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
223 236 if custom_field.is_required?
224 237 ''.html_safe
225 238 else
226 239 view.content_tag('label',
227 240 view.check_box_tag(tag_name, '__none__', (value == '__none__'), :id => nil, :data => {:disables => "##{tag_id}"}) + l(:button_clear),
228 241 :class => 'inline'
229 242 )
230 243 end
231 244 end
232 245 protected :bulk_clear_tag
233 246
234 247 def query_filter_options(custom_field, query)
235 248 {:type => :string}
236 249 end
237 250
238 251 def before_custom_field_save(custom_field)
239 252 end
240 253
241 254 # Returns a ORDER BY clause that can used to sort customized
242 255 # objects by their value of the custom field.
243 256 # Returns nil if the custom field can not be used for sorting.
244 257 def order_statement(custom_field)
245 258 # COALESCE is here to make sure that blank and NULL values are sorted equally
246 259 "COALESCE(#{join_alias custom_field}.value, '')"
247 260 end
248 261
249 262 # Returns a GROUP BY clause that can used to group by custom value
250 263 # Returns nil if the custom field can not be used for grouping.
251 264 def group_statement(custom_field)
252 265 nil
253 266 end
254 267
255 268 # Returns a JOIN clause that is added to the query when sorting by custom values
256 269 def join_for_order_statement(custom_field)
257 270 alias_name = join_alias(custom_field)
258 271
259 272 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
260 273 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
261 274 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
262 275 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
263 276 " AND (#{custom_field.visibility_by_project_condition})" +
264 277 " AND #{alias_name}.value <> ''" +
265 278 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
266 279 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
267 280 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
268 281 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)"
269 282 end
270 283
271 284 def join_alias(custom_field)
272 285 "cf_#{custom_field.id}"
273 286 end
274 287 protected :join_alias
275 288 end
276 289
277 290 class Unbounded < Base
278 291 def validate_single_value(custom_field, value, customized=nil)
279 292 errs = super
280 293 value = value.to_s
281 294 unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
282 295 errs << ::I18n.t('activerecord.errors.messages.invalid')
283 296 end
284 297 if custom_field.min_length && value.length < custom_field.min_length
285 298 errs << ::I18n.t('activerecord.errors.messages.too_short', :count => custom_field.min_length)
286 299 end
287 300 if custom_field.max_length && custom_field.max_length > 0 && value.length > custom_field.max_length
288 301 errs << ::I18n.t('activerecord.errors.messages.too_long', :count => custom_field.max_length)
289 302 end
290 303 errs
291 304 end
292 305 end
293 306
294 307 class StringFormat < Unbounded
295 308 add 'string'
296 309 self.searchable_supported = true
297 310 self.form_partial = 'custom_fields/formats/string'
298 311 field_attributes :text_formatting
299 312
300 313 def formatted_value(view, custom_field, value, customized=nil, html=false)
301 314 if html
302 315 if custom_field.url_pattern.present?
303 316 super
304 317 elsif custom_field.text_formatting == 'full'
305 318 view.textilizable(value, :object => customized)
306 319 else
307 320 value.to_s
308 321 end
309 322 else
310 323 value.to_s
311 324 end
312 325 end
313 326 end
314 327
315 328 class TextFormat < Unbounded
316 329 add 'text'
317 330 self.searchable_supported = true
318 331 self.form_partial = 'custom_fields/formats/text'
319 332 self.change_as_diff = true
320 333
321 334 def formatted_value(view, custom_field, value, customized=nil, html=false)
322 335 if html
323 336 if value.present?
324 337 if custom_field.text_formatting == 'full'
325 338 view.textilizable(value, :object => customized)
326 339 else
327 340 view.simple_format(html_escape(value))
328 341 end
329 342 else
330 343 ''
331 344 end
332 345 else
333 346 value.to_s
334 347 end
335 348 end
336 349
337 350 def edit_tag(view, tag_id, tag_name, custom_value, options={})
338 351 view.text_area_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :rows => 3))
339 352 end
340 353
341 354 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
342 355 view.text_area_tag(tag_name, value, options.merge(:id => tag_id, :rows => 3)) +
343 356 '<br />'.html_safe +
344 357 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
345 358 end
346 359
347 360 def query_filter_options(custom_field, query)
348 361 {:type => :text}
349 362 end
350 363 end
351 364
352 365 class LinkFormat < StringFormat
353 366 add 'link'
354 367 self.searchable_supported = false
355 368 self.form_partial = 'custom_fields/formats/link'
356 369
357 370 def formatted_value(view, custom_field, value, customized=nil, html=false)
358 371 if html
359 372 if custom_field.url_pattern.present?
360 373 url = url_from_pattern(custom_field, value, customized)
361 374 else
362 375 url = value.to_s
363 376 unless url =~ %r{\A[a-z]+://}i
364 377 # no protocol found, use http by default
365 378 url = "http://" + url
366 379 end
367 380 end
368 381 view.link_to value.to_s.truncate(40), url
369 382 else
370 383 value.to_s
371 384 end
372 385 end
373 386 end
374 387
375 388 class Numeric < Unbounded
376 389 self.form_partial = 'custom_fields/formats/numeric'
377 390 self.totalable_supported = true
378 391
379 392 def order_statement(custom_field)
380 393 # Make the database cast values into numeric
381 394 # Postgresql will raise an error if a value can not be casted!
382 395 # CustomValue validations should ensure that it doesn't occur
383 396 "CAST(CASE #{join_alias custom_field}.value WHEN '' THEN '0' ELSE #{join_alias custom_field}.value END AS decimal(30,3))"
384 397 end
385 398
386 399 # Returns totals for the given scope
387 400 def total_for_scope(custom_field, scope)
388 401 scope.joins(:custom_values).
389 402 where(:custom_values => {:custom_field_id => custom_field.id}).
390 403 where.not(:custom_values => {:value => ''}).
391 404 sum("CAST(#{CustomValue.table_name}.value AS decimal(30,3))")
392 405 end
393 406
394 407 def cast_total_value(custom_field, value)
395 408 cast_single_value(custom_field, value)
396 409 end
397 410 end
398 411
399 412 class IntFormat < Numeric
400 413 add 'int'
401 414
402 415 def label
403 416 "label_integer"
404 417 end
405 418
406 419 def cast_single_value(custom_field, value, customized=nil)
407 420 value.to_i
408 421 end
409 422
410 423 def validate_single_value(custom_field, value, customized=nil)
411 424 errs = super
412 425 errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless value.to_s =~ /^[+-]?\d+$/
413 426 errs
414 427 end
415 428
416 429 def query_filter_options(custom_field, query)
417 430 {:type => :integer}
418 431 end
419 432
420 433 def group_statement(custom_field)
421 434 order_statement(custom_field)
422 435 end
423 436 end
424 437
425 438 class FloatFormat < Numeric
426 439 add 'float'
427 440
428 441 def cast_single_value(custom_field, value, customized=nil)
429 442 value.to_f
430 443 end
431 444
432 445 def cast_total_value(custom_field, value)
433 446 value.to_f.round(2)
434 447 end
435 448
436 449 def validate_single_value(custom_field, value, customized=nil)
437 450 errs = super
438 451 errs << ::I18n.t('activerecord.errors.messages.invalid') unless (Kernel.Float(value) rescue nil)
439 452 errs
440 453 end
441 454
442 455 def query_filter_options(custom_field, query)
443 456 {:type => :float}
444 457 end
445 458 end
446 459
447 460 class DateFormat < Unbounded
448 461 add 'date'
449 462 self.form_partial = 'custom_fields/formats/date'
450 463
451 464 def cast_single_value(custom_field, value, customized=nil)
452 465 value.to_date rescue nil
453 466 end
454 467
455 468 def validate_single_value(custom_field, value, customized=nil)
456 469 if value =~ /^\d{4}-\d{2}-\d{2}$/ && (value.to_date rescue false)
457 470 []
458 471 else
459 472 [::I18n.t('activerecord.errors.messages.not_a_date')]
460 473 end
461 474 end
462 475
463 476 def edit_tag(view, tag_id, tag_name, custom_value, options={})
464 477 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :size => 10)) +
465 478 view.calendar_for(tag_id)
466 479 end
467 480
468 481 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
469 482 view.text_field_tag(tag_name, value, options.merge(:id => tag_id, :size => 10)) +
470 483 view.calendar_for(tag_id) +
471 484 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
472 485 end
473 486
474 487 def query_filter_options(custom_field, query)
475 488 {:type => :date}
476 489 end
477 490
478 491 def group_statement(custom_field)
479 492 order_statement(custom_field)
480 493 end
481 494 end
482 495
483 496 class List < Base
484 497 self.multiple_supported = true
485 498 field_attributes :edit_tag_style
486 499
487 500 def edit_tag(view, tag_id, tag_name, custom_value, options={})
488 501 if custom_value.custom_field.edit_tag_style == 'check_box'
489 502 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
490 503 else
491 504 select_edit_tag(view, tag_id, tag_name, custom_value, options)
492 505 end
493 506 end
494 507
495 508 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
496 509 opts = []
497 510 opts << [l(:label_no_change_option), ''] unless custom_field.multiple?
498 511 opts << [l(:label_none), '__none__'] unless custom_field.is_required?
499 512 opts += possible_values_options(custom_field, objects)
500 513 view.select_tag(tag_name, view.options_for_select(opts, value), options.merge(:multiple => custom_field.multiple?))
501 514 end
502 515
503 516 def query_filter_options(custom_field, query)
504 517 {:type => :list_optional, :values => query_filter_values(custom_field, query)}
505 518 end
506 519
507 520 protected
508 521
509 522 # Returns the values that are available in the field filter
510 523 def query_filter_values(custom_field, query)
511 524 possible_values_options(custom_field, query.project)
512 525 end
513 526
514 527 # Renders the edit tag as a select tag
515 528 def select_edit_tag(view, tag_id, tag_name, custom_value, options={})
516 529 blank_option = ''.html_safe
517 530 unless custom_value.custom_field.multiple?
518 531 if custom_value.custom_field.is_required?
519 532 unless custom_value.custom_field.default_value.present?
520 533 blank_option = view.content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '')
521 534 end
522 535 else
523 536 blank_option = view.content_tag('option', '&nbsp;'.html_safe, :value => '')
524 537 end
525 538 end
526 539 options_tags = blank_option + view.options_for_select(possible_custom_value_options(custom_value), custom_value.value)
527 540 s = view.select_tag(tag_name, options_tags, options.merge(:id => tag_id, :multiple => custom_value.custom_field.multiple?))
528 541 if custom_value.custom_field.multiple?
529 542 s << view.hidden_field_tag(tag_name, '')
530 543 end
531 544 s
532 545 end
533 546
534 547 # Renders the edit tag as check box or radio tags
535 548 def check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
536 549 opts = []
537 550 unless custom_value.custom_field.multiple? || custom_value.custom_field.is_required?
538 551 opts << ["(#{l(:label_none)})", '']
539 552 end
540 553 opts += possible_custom_value_options(custom_value)
541 554 s = ''.html_safe
542 555 tag_method = custom_value.custom_field.multiple? ? :check_box_tag : :radio_button_tag
543 556 opts.each do |label, value|
544 557 value ||= label
545 558 checked = (custom_value.value.is_a?(Array) && custom_value.value.include?(value)) || custom_value.value.to_s == value
546 559 tag = view.send(tag_method, tag_name, value, checked, :id => tag_id)
547 560 # set the id on the first tag only
548 561 tag_id = nil
549 562 s << view.content_tag('label', tag + ' ' + label)
550 563 end
551 564 if custom_value.custom_field.multiple?
552 565 s << view.hidden_field_tag(tag_name, '')
553 566 end
554 567 css = "#{options[:class]} check_box_group"
555 568 view.content_tag('span', s, options.merge(:class => css))
556 569 end
557 570 end
558 571
559 572 class ListFormat < List
560 573 add 'list'
561 574 self.searchable_supported = true
562 575 self.form_partial = 'custom_fields/formats/list'
563 576
564 577 def possible_custom_value_options(custom_value)
565 578 options = possible_values_options(custom_value.custom_field)
566 579 missing = [custom_value.value].flatten.reject(&:blank?) - options
567 580 if missing.any?
568 581 options += missing
569 582 end
570 583 options
571 584 end
572 585
573 586 def possible_values_options(custom_field, object=nil)
574 587 custom_field.possible_values
575 588 end
576 589
577 590 def validate_custom_field(custom_field)
578 591 errors = []
579 592 errors << [:possible_values, :blank] if custom_field.possible_values.blank?
580 593 errors << [:possible_values, :invalid] unless custom_field.possible_values.is_a? Array
581 594 errors
582 595 end
583 596
584 597 def validate_custom_value(custom_value)
585 598 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
586 599 invalid_values = values - Array.wrap(custom_value.value_was) - custom_value.custom_field.possible_values
587 600 if invalid_values.any?
588 601 [::I18n.t('activerecord.errors.messages.inclusion')]
589 602 else
590 603 []
591 604 end
592 605 end
593 606
594 607 def group_statement(custom_field)
595 608 order_statement(custom_field)
596 609 end
597 610 end
598 611
599 612 class BoolFormat < List
600 613 add 'bool'
601 614 self.multiple_supported = false
602 615 self.form_partial = 'custom_fields/formats/bool'
603 616
604 617 def label
605 618 "label_boolean"
606 619 end
607 620
608 621 def cast_single_value(custom_field, value, customized=nil)
609 622 value == '1' ? true : false
610 623 end
611 624
612 625 def possible_values_options(custom_field, object=nil)
613 626 [[::I18n.t(:general_text_Yes), '1'], [::I18n.t(:general_text_No), '0']]
614 627 end
615 628
616 629 def group_statement(custom_field)
617 630 order_statement(custom_field)
618 631 end
619 632
620 633 def edit_tag(view, tag_id, tag_name, custom_value, options={})
621 634 case custom_value.custom_field.edit_tag_style
622 635 when 'check_box'
623 636 single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
624 637 when 'radio'
625 638 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
626 639 else
627 640 select_edit_tag(view, tag_id, tag_name, custom_value, options)
628 641 end
629 642 end
630 643
631 644 # Renders the edit tag as a simple check box
632 645 def single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
633 646 s = ''.html_safe
634 647 s << view.hidden_field_tag(tag_name, '0', :id => nil)
635 648 s << view.check_box_tag(tag_name, '1', custom_value.value.to_s == '1', :id => tag_id)
636 649 view.content_tag('span', s, options)
637 650 end
638 651 end
639 652
640 653 class RecordList < List
641 654 self.customized_class_names = %w(Issue TimeEntry Version Document Project)
642 655
643 656 def cast_single_value(custom_field, value, customized=nil)
644 657 target_class.find_by_id(value.to_i) if value.present?
645 658 end
646 659
647 660 def target_class
648 661 @target_class ||= self.class.name[/^(.*::)?(.+)Format$/, 2].constantize rescue nil
649 662 end
650 663
651 664 def reset_target_class
652 665 @target_class = nil
653 666 end
654 667
655 668 def possible_custom_value_options(custom_value)
656 669 options = possible_values_options(custom_value.custom_field, custom_value.customized)
657 670 missing = [custom_value.value_was].flatten.reject(&:blank?) - options.map(&:last)
658 671 if missing.any?
659 672 options += target_class.where(:id => missing.map(&:to_i)).map {|o| [o.to_s, o.id.to_s]}
660 673 end
661 674 options
662 675 end
663 676
664 677 def order_statement(custom_field)
665 678 if target_class.respond_to?(:fields_for_order_statement)
666 679 target_class.fields_for_order_statement(value_join_alias(custom_field))
667 680 end
668 681 end
669 682
670 683 def group_statement(custom_field)
671 684 "COALESCE(#{join_alias custom_field}.value, '')"
672 685 end
673 686
674 687 def join_for_order_statement(custom_field)
675 688 alias_name = join_alias(custom_field)
676 689
677 690 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
678 691 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
679 692 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
680 693 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
681 694 " AND (#{custom_field.visibility_by_project_condition})" +
682 695 " AND #{alias_name}.value <> ''" +
683 696 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
684 697 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
685 698 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
686 699 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)" +
687 700 " LEFT OUTER JOIN #{target_class.table_name} #{value_join_alias custom_field}" +
688 701 " ON CAST(CASE #{alias_name}.value WHEN '' THEN '0' ELSE #{alias_name}.value END AS decimal(30,0)) = #{value_join_alias custom_field}.id"
689 702 end
690 703
691 704 def value_join_alias(custom_field)
692 705 join_alias(custom_field) + "_" + custom_field.field_format
693 706 end
694 707 protected :value_join_alias
695 708 end
696 709
697 710 class EnumerationFormat < RecordList
698 711 add 'enumeration'
699 712 self.form_partial = 'custom_fields/formats/enumeration'
700 713
701 714 def label
702 715 "label_field_format_enumeration"
703 716 end
704 717
705 718 def target_class
706 719 @target_class ||= CustomFieldEnumeration
707 720 end
708 721
709 722 def possible_values_options(custom_field, object=nil)
710 723 possible_values_records(custom_field, object).map {|u| [u.name, u.id.to_s]}
711 724 end
712 725
713 726 def possible_values_records(custom_field, object=nil)
714 727 custom_field.enumerations.active
715 728 end
716 729
717 730 def value_from_keyword(custom_field, keyword, object)
718 731 value = custom_field.enumerations.where("LOWER(name) LIKE LOWER(?)", keyword).first
719 732 value ? value.id : nil
720 733 end
721 734 end
722 735
723 736 class UserFormat < RecordList
724 737 add 'user'
725 738 self.form_partial = 'custom_fields/formats/user'
726 739 field_attributes :user_role
727 740
728 741 def possible_values_options(custom_field, object=nil)
729 742 possible_values_records(custom_field, object).map {|u| [u.name, u.id.to_s]}
730 743 end
731 744
732 745 def possible_values_records(custom_field, object=nil)
733 746 if object.is_a?(Array)
734 747 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
735 748 projects.map {|project| possible_values_records(custom_field, project)}.reduce(:&) || []
736 749 elsif object.respond_to?(:project) && object.project
737 750 scope = object.project.users
738 751 if custom_field.user_role.is_a?(Array)
739 752 role_ids = custom_field.user_role.map(&:to_s).reject(&:blank?).map(&:to_i)
740 753 if role_ids.any?
741 754 scope = scope.where("#{Member.table_name}.id IN (SELECT DISTINCT member_id FROM #{MemberRole.table_name} WHERE role_id IN (?))", role_ids)
742 755 end
743 756 end
744 757 scope.sorted
745 758 else
746 759 []
747 760 end
748 761 end
749 762
750 763 def value_from_keyword(custom_field, keyword, object)
751 764 users = possible_values_records(custom_field, object).to_a
752 765 user = Principal.detect_by_keyword(users, keyword)
753 766 user ? user.id : nil
754 767 end
755 768
756 769 def before_custom_field_save(custom_field)
757 770 super
758 771 if custom_field.user_role.is_a?(Array)
759 772 custom_field.user_role.map!(&:to_s).reject!(&:blank?)
760 773 end
761 774 end
762 775 end
763 776
764 777 class VersionFormat < RecordList
765 778 add 'version'
766 779 self.form_partial = 'custom_fields/formats/version'
767 780 field_attributes :version_status
768 781
769 782 def possible_values_options(custom_field, object=nil)
770 783 versions_options(custom_field, object)
771 784 end
772 785
773 786 def before_custom_field_save(custom_field)
774 787 super
775 788 if custom_field.version_status.is_a?(Array)
776 789 custom_field.version_status.map!(&:to_s).reject!(&:blank?)
777 790 end
778 791 end
779 792
780 793 protected
781 794
782 795 def query_filter_values(custom_field, query)
783 796 versions_options(custom_field, query.project, true)
784 797 end
785 798
786 799 def versions_options(custom_field, object, all_statuses=false)
787 800 if object.is_a?(Array)
788 801 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
789 802 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
790 803 elsif object.respond_to?(:project) && object.project
791 804 scope = object.project.shared_versions
792 805 if !all_statuses && custom_field.version_status.is_a?(Array)
793 806 statuses = custom_field.version_status.map(&:to_s).reject(&:blank?)
794 807 if statuses.any?
795 808 scope = scope.where(:status => statuses.map(&:to_s))
796 809 end
797 810 end
798 811 scope.sort.collect {|u| [u.to_s, u.id.to_s]}
799 812 else
800 813 []
801 814 end
802 815 end
803 816 end
804 817 end
805 818 end
@@ -1,138 +1,147
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'cgi'
19 19
20 20 module Redmine
21 21 module WikiFormatting
22 22 module Markdown
23 23 class HTML < Redcarpet::Render::HTML
24 24 include ActionView::Helpers::TagHelper
25 include Redmine::Helpers::URL
25 26
26 27 def link(link, title, content)
28 return nil unless uri_with_safe_scheme?(link)
29
27 30 css = nil
28 31 unless link && link.starts_with?('/')
29 32 css = 'external'
30 33 end
31 34 content_tag('a', content.to_s.html_safe, :href => link, :title => title, :class => css)
32 35 end
33 36
34 37 def block_code(code, language)
35 38 if language.present?
36 39 "<pre><code class=\"#{CGI.escapeHTML language} syntaxhl\">" +
37 40 Redmine::SyntaxHighlighting.highlight_by_language(code, language) +
38 41 "</code></pre>"
39 42 else
40 43 "<pre>" + CGI.escapeHTML(code) + "</pre>"
41 44 end
42 45 end
46
47 def image(link, title, alt_text)
48 return unless uri_with_safe_scheme?(link)
49
50 tag('img', :src => link, :alt => alt_text || "", :title => title)
51 end
43 52 end
44 53
45 54 class Formatter
46 55 def initialize(text)
47 56 @text = text
48 57 end
49 58
50 59 def to_html(*args)
51 60 html = formatter.render(@text)
52 61 # restore wiki links eg. [[Foo]]
53 62 html.gsub!(%r{\[<a href="(.*?)">(.*?)</a>\]}) do
54 63 "[[#{$2}]]"
55 64 end
56 65 # restore Redmine links with double-quotes, eg. version:"1.0"
57 66 html.gsub!(/(\w):&quot;(.+?)&quot;/) do
58 67 "#{$1}:\"#{$2}\""
59 68 end
60 69 html
61 70 end
62 71
63 72 def get_section(index)
64 73 section = extract_sections(index)[1]
65 74 hash = Digest::MD5.hexdigest(section)
66 75 return section, hash
67 76 end
68 77
69 78 def update_section(index, update, hash=nil)
70 79 t = extract_sections(index)
71 80 if hash.present? && hash != Digest::MD5.hexdigest(t[1])
72 81 raise Redmine::WikiFormatting::StaleSectionError
73 82 end
74 83 t[1] = update unless t[1].blank?
75 84 t.reject(&:blank?).join "\n\n"
76 85 end
77 86
78 87 def extract_sections(index)
79 88 sections = ['', '', '']
80 89 offset = 0
81 90 i = 0
82 91 l = 1
83 92 inside_pre = false
84 93 @text.split(/(^(?:.+\r?\n\r?(?:\=+|\-+)|#+.+|~~~.*)\s*$)/).each do |part|
85 94 level = nil
86 95 if part =~ /\A~{3,}(\S+)?\s*$/
87 96 if $1
88 97 if !inside_pre
89 98 inside_pre = true
90 99 end
91 100 else
92 101 inside_pre = !inside_pre
93 102 end
94 103 elsif inside_pre
95 104 # nop
96 105 elsif part =~ /\A(#+).+/
97 106 level = $1.size
98 107 elsif part =~ /\A.+\r?\n\r?(\=+|\-+)\s*$/
99 108 level = $1.include?('=') ? 1 : 2
100 109 end
101 110 if level
102 111 i += 1
103 112 if offset == 0 && i == index
104 113 # entering the requested section
105 114 offset = 1
106 115 l = level
107 116 elsif offset == 1 && i > index && level <= l
108 117 # leaving the requested section
109 118 offset = 2
110 119 end
111 120 end
112 121 sections[offset] << part
113 122 end
114 123 sections.map(&:strip)
115 124 end
116 125
117 126 private
118 127
119 128 def formatter
120 129 @@formatter ||= Redcarpet::Markdown.new(
121 130 Redmine::WikiFormatting::Markdown::HTML.new(
122 131 :filter_html => true,
123 132 :hard_wrap => true
124 133 ),
125 134 :autolink => true,
126 135 :fenced_code_blocks => true,
127 136 :space_after_headers => true,
128 137 :tables => true,
129 138 :strikethrough => true,
130 139 :superscript => true,
131 140 :no_intra_emphasis => true,
132 141 :footnotes => true
133 142 )
134 143 end
135 144 end
136 145 end
137 146 end
138 147 end
@@ -1,1541 +1,1541
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../../test_helper', __FILE__)
21 21
22 22 class ApplicationHelperTest < ActionView::TestCase
23 23 include Redmine::I18n
24 24 include ERB::Util
25 25 include Rails.application.routes.url_helpers
26 26
27 27 fixtures :projects, :roles, :enabled_modules, :users,
28 28 :email_addresses,
29 29 :repositories, :changesets,
30 30 :projects_trackers,
31 31 :trackers, :issue_statuses, :issues, :versions, :documents,
32 32 :wikis, :wiki_pages, :wiki_contents,
33 33 :boards, :messages, :news,
34 34 :attachments, :enumerations
35 35
36 36 def setup
37 37 super
38 38 set_tmp_attachments_directory
39 39 @russian_test = "\xd1\x82\xd0\xb5\xd1\x81\xd1\x82".force_encoding('UTF-8')
40 40 end
41 41
42 42 test "#link_to_if_authorized for authorized user should allow using the :controller and :action for the target link" do
43 43 User.current = User.find_by_login('admin')
44 44
45 45 @project = Issue.first.project # Used by helper
46 46 response = link_to_if_authorized('By controller/actionr',
47 47 {:controller => 'issues', :action => 'edit', :id => Issue.first.id})
48 48 assert_match /href/, response
49 49 end
50 50
51 51 test "#link_to_if_authorized for unauthorized user should display nothing if user isn't authorized" do
52 52 User.current = User.find_by_login('dlopper')
53 53 @project = Project.find('private-child')
54 54 issue = @project.issues.first
55 55 assert !issue.visible?
56 56
57 57 response = link_to_if_authorized('Never displayed',
58 58 {:controller => 'issues', :action => 'show', :id => issue})
59 59 assert_nil response
60 60 end
61 61
62 62 def test_auto_links
63 63 to_test = {
64 64 'http://foo.bar' => '<a class="external" href="http://foo.bar">http://foo.bar</a>',
65 65 'http://foo.bar/~user' => '<a class="external" href="http://foo.bar/~user">http://foo.bar/~user</a>',
66 66 'http://foo.bar.' => '<a class="external" href="http://foo.bar">http://foo.bar</a>.',
67 67 'https://foo.bar.' => '<a class="external" href="https://foo.bar">https://foo.bar</a>.',
68 68 'This is a link: http://foo.bar.' => 'This is a link: <a class="external" href="http://foo.bar">http://foo.bar</a>.',
69 69 'A link (eg. http://foo.bar).' => 'A link (eg. <a class="external" href="http://foo.bar">http://foo.bar</a>).',
70 70 'http://foo.bar/foo.bar#foo.bar.' => '<a class="external" href="http://foo.bar/foo.bar#foo.bar">http://foo.bar/foo.bar#foo.bar</a>.',
71 71 'http://www.foo.bar/Test_(foobar)' => '<a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>',
72 72 '(see inline link : http://www.foo.bar/Test_(foobar))' => '(see inline link : <a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>)',
73 73 '(see inline link : http://www.foo.bar/Test)' => '(see inline link : <a class="external" href="http://www.foo.bar/Test">http://www.foo.bar/Test</a>)',
74 74 '(see inline link : http://www.foo.bar/Test).' => '(see inline link : <a class="external" href="http://www.foo.bar/Test">http://www.foo.bar/Test</a>).',
75 75 '(see "inline link":http://www.foo.bar/Test_(foobar))' => '(see <a href="http://www.foo.bar/Test_(foobar)" class="external">inline link</a>)',
76 76 '(see "inline link":http://www.foo.bar/Test)' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>)',
77 77 '(see "inline link":http://www.foo.bar/Test).' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>).',
78 78 'www.foo.bar' => '<a class="external" href="http://www.foo.bar">www.foo.bar</a>',
79 79 'http://foo.bar/page?p=1&t=z&s=' => '<a class="external" href="http://foo.bar/page?p=1&#38;t=z&#38;s=">http://foo.bar/page?p=1&#38;t=z&#38;s=</a>',
80 80 'http://foo.bar/page#125' => '<a class="external" href="http://foo.bar/page#125">http://foo.bar/page#125</a>',
81 81 'http://foo@www.bar.com' => '<a class="external" href="http://foo@www.bar.com">http://foo@www.bar.com</a>',
82 82 'http://foo:bar@www.bar.com' => '<a class="external" href="http://foo:bar@www.bar.com">http://foo:bar@www.bar.com</a>',
83 83 'ftp://foo.bar' => '<a class="external" href="ftp://foo.bar">ftp://foo.bar</a>',
84 84 'ftps://foo.bar' => '<a class="external" href="ftps://foo.bar">ftps://foo.bar</a>',
85 85 'sftp://foo.bar' => '<a class="external" href="sftp://foo.bar">sftp://foo.bar</a>',
86 86 # two exclamation marks
87 87 'http://example.net/path!602815048C7B5C20!302.html' => '<a class="external" href="http://example.net/path!602815048C7B5C20!302.html">http://example.net/path!602815048C7B5C20!302.html</a>',
88 88 # escaping
89 89 'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo&quot;bar</a>',
90 90 # wrap in angle brackets
91 91 '<http://foo.bar>' => '&lt;<a class="external" href="http://foo.bar">http://foo.bar</a>&gt;',
92 92 # invalid urls
93 93 'http://' => 'http://',
94 94 'www.' => 'www.',
95 95 'test-www.bar.com' => 'test-www.bar.com',
96 96 }
97 97 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
98 98 end
99 99
100 100 def test_auto_links_with_non_ascii_characters
101 101 to_test = {
102 102 "http://foo.bar/#{@russian_test}" =>
103 103 %|<a class="external" href="http://foo.bar/#{@russian_test}">http://foo.bar/#{@russian_test}</a>|
104 104 }
105 105 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
106 106 end
107 107
108 108 def test_auto_mailto
109 109 to_test = {
110 110 'test@foo.bar' => '<a class="email" href="mailto:test@foo.bar">test@foo.bar</a>',
111 111 'test@www.foo.bar' => '<a class="email" href="mailto:test@www.foo.bar">test@www.foo.bar</a>',
112 112 }
113 113 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
114 114 end
115 115
116 116 def test_inline_images
117 117 to_test = {
118 118 '!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="" />',
119 119 'floating !>http://foo.bar/image.jpg!' => 'floating <div style="float:right"><img src="http://foo.bar/image.jpg" alt="" /></div>',
120 120 'with class !(some-class)http://foo.bar/image.jpg!' => 'with class <img src="http://foo.bar/image.jpg" class="some-class" alt="" />',
121 121 'with style !{width:100px;height:100px}http://foo.bar/image.jpg!' => 'with style <img src="http://foo.bar/image.jpg" style="width:100px;height:100px;" alt="" />',
122 122 'with title !http://foo.bar/image.jpg(This is a title)!' => 'with title <img src="http://foo.bar/image.jpg" title="This is a title" alt="This is a title" />',
123 123 'with title !http://foo.bar/image.jpg(This is a double-quoted "title")!' => 'with title <img src="http://foo.bar/image.jpg" title="This is a double-quoted &quot;title&quot;" alt="This is a double-quoted &quot;title&quot;" />',
124 124 }
125 125 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
126 126 end
127 127
128 128 def test_inline_images_inside_tags
129 129 raw = <<-RAW
130 130 h1. !foo.png! Heading
131 131
132 132 Centered image:
133 133
134 134 p=. !bar.gif!
135 135 RAW
136 136
137 137 assert textilizable(raw).include?('<img src="foo.png" alt="" />')
138 138 assert textilizable(raw).include?('<img src="bar.gif" alt="" />')
139 139 end
140 140
141 141 def test_attached_images
142 142 to_test = {
143 143 'Inline image: !logo.gif!' => 'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" />',
144 144 'Inline image: !logo.GIF!' => 'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" />',
145 145 'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="" />',
146 146 'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="" />',
147 147 # link image
148 148 '!logo.gif!:http://foo.bar/' => '<a href="http://foo.bar/"><img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" /></a>',
149 149 }
150 150 attachments = Attachment.all
151 151 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
152 152 end
153 153
154 154 def test_attached_images_with_textile_and_non_ascii_filename
155 155 attachment = Attachment.generate!(:filename => 'cafΓ©.jpg')
156 156 with_settings :text_formatting => 'textile' do
157 157 assert_include %(<img src="/attachments/download/#{attachment.id}/caf%C3%A9.jpg" alt="" />),
158 158 textilizable("!cafΓ©.jpg!)", :attachments => [attachment])
159 159 end
160 160 end
161 161
162 162 def test_attached_images_with_markdown_and_non_ascii_filename
163 163 skip unless Object.const_defined?(:Redcarpet)
164 164
165 165 attachment = Attachment.generate!(:filename => 'cafΓ©.jpg')
166 166 with_settings :text_formatting => 'markdown' do
167 assert_include %(<img src="/attachments/download/#{attachment.id}/caf%C3%A9.jpg" alt="">),
167 assert_include %(<img src="/attachments/download/#{attachment.id}/caf%C3%A9.jpg" alt="" />),
168 168 textilizable("![](cafΓ©.jpg)", :attachments => [attachment])
169 169 end
170 170 end
171 171
172 172 def test_attached_images_filename_extension
173 173 set_tmp_attachments_directory
174 174 a1 = Attachment.new(
175 175 :container => Issue.find(1),
176 176 :file => mock_file_with_options({:original_filename => "testtest.JPG"}),
177 177 :author => User.find(1))
178 178 assert a1.save
179 179 assert_equal "testtest.JPG", a1.filename
180 180 assert_equal "image/jpeg", a1.content_type
181 181 assert a1.image?
182 182
183 183 a2 = Attachment.new(
184 184 :container => Issue.find(1),
185 185 :file => mock_file_with_options({:original_filename => "testtest.jpeg"}),
186 186 :author => User.find(1))
187 187 assert a2.save
188 188 assert_equal "testtest.jpeg", a2.filename
189 189 assert_equal "image/jpeg", a2.content_type
190 190 assert a2.image?
191 191
192 192 a3 = Attachment.new(
193 193 :container => Issue.find(1),
194 194 :file => mock_file_with_options({:original_filename => "testtest.JPE"}),
195 195 :author => User.find(1))
196 196 assert a3.save
197 197 assert_equal "testtest.JPE", a3.filename
198 198 assert_equal "image/jpeg", a3.content_type
199 199 assert a3.image?
200 200
201 201 a4 = Attachment.new(
202 202 :container => Issue.find(1),
203 203 :file => mock_file_with_options({:original_filename => "Testtest.BMP"}),
204 204 :author => User.find(1))
205 205 assert a4.save
206 206 assert_equal "Testtest.BMP", a4.filename
207 207 assert_equal "image/x-ms-bmp", a4.content_type
208 208 assert a4.image?
209 209
210 210 to_test = {
211 211 'Inline image: !testtest.jpg!' =>
212 212 'Inline image: <img src="/attachments/download/' + a1.id.to_s + '/testtest.JPG" alt="" />',
213 213 'Inline image: !testtest.jpeg!' =>
214 214 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testtest.jpeg" alt="" />',
215 215 'Inline image: !testtest.jpe!' =>
216 216 'Inline image: <img src="/attachments/download/' + a3.id.to_s + '/testtest.JPE" alt="" />',
217 217 'Inline image: !testtest.bmp!' =>
218 218 'Inline image: <img src="/attachments/download/' + a4.id.to_s + '/Testtest.BMP" alt="" />',
219 219 }
220 220
221 221 attachments = [a1, a2, a3, a4]
222 222 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
223 223 end
224 224
225 225 def test_attached_images_should_read_later
226 226 set_fixtures_attachments_directory
227 227 a1 = Attachment.find(16)
228 228 assert_equal "testfile.png", a1.filename
229 229 assert a1.readable?
230 230 assert (! a1.visible?(User.anonymous))
231 231 assert a1.visible?(User.find(2))
232 232 a2 = Attachment.find(17)
233 233 assert_equal "testfile.PNG", a2.filename
234 234 assert a2.readable?
235 235 assert (! a2.visible?(User.anonymous))
236 236 assert a2.visible?(User.find(2))
237 237 assert a1.created_on < a2.created_on
238 238
239 239 to_test = {
240 240 'Inline image: !testfile.png!' =>
241 241 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" />',
242 242 'Inline image: !Testfile.PNG!' =>
243 243 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" />',
244 244 }
245 245 attachments = [a1, a2]
246 246 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
247 247 set_tmp_attachments_directory
248 248 end
249 249
250 250 def test_textile_external_links
251 251 to_test = {
252 252 'This is a "link":http://foo.bar' => 'This is a <a href="http://foo.bar" class="external">link</a>',
253 253 'This is an intern "link":/foo/bar' => 'This is an intern <a href="/foo/bar">link</a>',
254 254 '"link (Link title)":http://foo.bar' => '<a href="http://foo.bar" title="Link title" class="external">link</a>',
255 255 '"link (Link title with "double-quotes")":http://foo.bar' => '<a href="http://foo.bar" title="Link title with &quot;double-quotes&quot;" class="external">link</a>',
256 256 "This is not a \"Link\":\n\nAnother paragraph" => "This is not a \"Link\":</p>\n\n\n\t<p>Another paragraph",
257 257 # no multiline link text
258 258 "This is a double quote \"on the first line\nand another on a second line\":test" => "This is a double quote \"on the first line<br />and another on a second line\":test",
259 259 # mailto link
260 260 "\"system administrator\":mailto:sysadmin@example.com?subject=redmine%20permissions" => "<a href=\"mailto:sysadmin@example.com?subject=redmine%20permissions\">system administrator</a>",
261 261 # two exclamation marks
262 262 '"a link":http://example.net/path!602815048C7B5C20!302.html' => '<a href="http://example.net/path!602815048C7B5C20!302.html" class="external">a link</a>',
263 263 # escaping
264 264 '"test":http://foo"bar' => '<a href="http://foo&quot;bar" class="external">test</a>',
265 265 }
266 266 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
267 267 end
268 268
269 269 def test_textile_external_links_with_non_ascii_characters
270 270 to_test = {
271 271 %|This is a "link":http://foo.bar/#{@russian_test}| =>
272 272 %|This is a <a href="http://foo.bar/#{@russian_test}" class="external">link</a>|
273 273 }
274 274 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
275 275 end
276 276
277 277 def test_redmine_links
278 278 issue_link = link_to('#3', {:controller => 'issues', :action => 'show', :id => 3},
279 279 :class => Issue.find(3).css_classes, :title => 'Bug: Error 281 when updating a recipe (New)')
280 280 note_link = link_to('#3-14', {:controller => 'issues', :action => 'show', :id => 3, :anchor => 'note-14'},
281 281 :class => Issue.find(3).css_classes, :title => 'Bug: Error 281 when updating a recipe (New)')
282 282 note_link2 = link_to('#3#note-14', {:controller => 'issues', :action => 'show', :id => 3, :anchor => 'note-14'},
283 283 :class => Issue.find(3).css_classes, :title => 'Bug: Error 281 when updating a recipe (New)')
284 284
285 285 revision_link = link_to('r1', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 1},
286 286 :class => 'changeset', :title => 'My very first commit do not escaping #<>&')
287 287 revision_link2 = link_to('r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
288 288 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
289 289
290 290 changeset_link2 = link_to('691322a8eb01e11fd7',
291 291 {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 1},
292 292 :class => 'changeset', :title => 'My very first commit do not escaping #<>&')
293 293
294 294 document_link = link_to('Test document', {:controller => 'documents', :action => 'show', :id => 1},
295 295 :class => 'document')
296 296
297 297 version_link = link_to('1.0', {:controller => 'versions', :action => 'show', :id => 2},
298 298 :class => 'version')
299 299
300 300 board_url = {:controller => 'boards', :action => 'show', :id => 2, :project_id => 'ecookbook'}
301 301
302 302 message_url = {:controller => 'messages', :action => 'show', :board_id => 1, :id => 4}
303 303
304 304 news_url = {:controller => 'news', :action => 'show', :id => 1}
305 305
306 306 project_url = {:controller => 'projects', :action => 'show', :id => 'subproject1'}
307 307
308 308 source_url = '/projects/ecookbook/repository/entry/some/file'
309 309 source_url_with_rev = '/projects/ecookbook/repository/revisions/52/entry/some/file'
310 310 source_url_with_ext = '/projects/ecookbook/repository/entry/some/file.ext'
311 311 source_url_with_rev_and_ext = '/projects/ecookbook/repository/revisions/52/entry/some/file.ext'
312 312 source_url_with_branch = '/projects/ecookbook/repository/revisions/branch/entry/some/file'
313 313
314 314 export_url = '/projects/ecookbook/repository/raw/some/file'
315 315 export_url_with_rev = '/projects/ecookbook/repository/revisions/52/raw/some/file'
316 316 export_url_with_ext = '/projects/ecookbook/repository/raw/some/file.ext'
317 317 export_url_with_rev_and_ext = '/projects/ecookbook/repository/revisions/52/raw/some/file.ext'
318 318 export_url_with_branch = '/projects/ecookbook/repository/revisions/branch/raw/some/file'
319 319
320 320 to_test = {
321 321 # tickets
322 322 '#3, [#3], (#3) and #3.' => "#{issue_link}, [#{issue_link}], (#{issue_link}) and #{issue_link}.",
323 323 # ticket notes
324 324 '#3-14' => note_link,
325 325 '#3#note-14' => note_link2,
326 326 # should not ignore leading zero
327 327 '#03' => '#03',
328 328 # changesets
329 329 'r1' => revision_link,
330 330 'r1.' => "#{revision_link}.",
331 331 'r1, r2' => "#{revision_link}, #{revision_link2}",
332 332 'r1,r2' => "#{revision_link},#{revision_link2}",
333 333 'commit:691322a8eb01e11fd7' => changeset_link2,
334 334 # documents
335 335 'document#1' => document_link,
336 336 'document:"Test document"' => document_link,
337 337 # versions
338 338 'version#2' => version_link,
339 339 'version:1.0' => version_link,
340 340 'version:"1.0"' => version_link,
341 341 # source
342 342 'source:some/file' => link_to('source:some/file', source_url, :class => 'source'),
343 343 'source:/some/file' => link_to('source:/some/file', source_url, :class => 'source'),
344 344 'source:/some/file.' => link_to('source:/some/file', source_url, :class => 'source') + ".",
345 345 'source:/some/file.ext.' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
346 346 'source:/some/file. ' => link_to('source:/some/file', source_url, :class => 'source') + ".",
347 347 'source:/some/file.ext. ' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
348 348 'source:/some/file, ' => link_to('source:/some/file', source_url, :class => 'source') + ",",
349 349 'source:/some/file@52' => link_to('source:/some/file@52', source_url_with_rev, :class => 'source'),
350 350 'source:/some/file@branch' => link_to('source:/some/file@branch', source_url_with_branch, :class => 'source'),
351 351 'source:/some/file.ext@52' => link_to('source:/some/file.ext@52', source_url_with_rev_and_ext, :class => 'source'),
352 352 'source:/some/file#L110' => link_to('source:/some/file#L110', source_url + "#L110", :class => 'source'),
353 353 'source:/some/file.ext#L110' => link_to('source:/some/file.ext#L110', source_url_with_ext + "#L110", :class => 'source'),
354 354 'source:/some/file@52#L110' => link_to('source:/some/file@52#L110', source_url_with_rev + "#L110", :class => 'source'),
355 355 # export
356 356 'export:/some/file' => link_to('export:/some/file', export_url, :class => 'source download'),
357 357 'export:/some/file.ext' => link_to('export:/some/file.ext', export_url_with_ext, :class => 'source download'),
358 358 'export:/some/file@52' => link_to('export:/some/file@52', export_url_with_rev, :class => 'source download'),
359 359 'export:/some/file.ext@52' => link_to('export:/some/file.ext@52', export_url_with_rev_and_ext, :class => 'source download'),
360 360 'export:/some/file@branch' => link_to('export:/some/file@branch', export_url_with_branch, :class => 'source download'),
361 361 # forum
362 362 'forum#2' => link_to('Discussion', board_url, :class => 'board'),
363 363 'forum:Discussion' => link_to('Discussion', board_url, :class => 'board'),
364 364 # message
365 365 'message#4' => link_to('Post 2', message_url, :class => 'message'),
366 366 'message#5' => link_to('RE: post 2', message_url.merge(:anchor => 'message-5', :r => 5), :class => 'message'),
367 367 # news
368 368 'news#1' => link_to('eCookbook first release !', news_url, :class => 'news'),
369 369 'news:"eCookbook first release !"' => link_to('eCookbook first release !', news_url, :class => 'news'),
370 370 # project
371 371 'project#3' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
372 372 'project:subproject1' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
373 373 'project:"eCookbook subProject 1"' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
374 374 # not found
375 375 '#0123456789' => '#0123456789',
376 376 # invalid expressions
377 377 'source:' => 'source:',
378 378 # url hash
379 379 "http://foo.bar/FAQ#3" => '<a class="external" href="http://foo.bar/FAQ#3">http://foo.bar/FAQ#3</a>',
380 380 }
381 381 @project = Project.find(1)
382 382 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
383 383 end
384 384
385 385 def test_should_not_parse_redmine_links_inside_link
386 386 raw = "r1 should not be parsed in http://example.com/url-r1/"
387 387 assert_match %r{<p><a class="changeset".*>r1</a> should not be parsed in <a class="external" href="http://example.com/url-r1/">http://example.com/url-r1/</a></p>},
388 388 textilizable(raw, :project => Project.find(1))
389 389 end
390 390
391 391 def test_redmine_links_with_a_different_project_before_current_project
392 392 vp1 = Version.generate!(:project_id => 1, :name => '1.4.4')
393 393 vp3 = Version.generate!(:project_id => 3, :name => '1.4.4')
394 394 @project = Project.find(3)
395 395 result1 = link_to("1.4.4", "/versions/#{vp1.id}", :class => "version")
396 396 result2 = link_to("1.4.4", "/versions/#{vp3.id}", :class => "version")
397 397 assert_equal "<p>#{result1} #{result2}</p>",
398 398 textilizable("ecookbook:version:1.4.4 version:1.4.4")
399 399 end
400 400
401 401 def test_escaped_redmine_links_should_not_be_parsed
402 402 to_test = [
403 403 '#3.',
404 404 '#3-14.',
405 405 '#3#-note14.',
406 406 'r1',
407 407 'document#1',
408 408 'document:"Test document"',
409 409 'version#2',
410 410 'version:1.0',
411 411 'version:"1.0"',
412 412 'source:/some/file'
413 413 ]
414 414 @project = Project.find(1)
415 415 to_test.each { |text| assert_equal "<p>#{text}</p>", textilizable("!" + text), "#{text} failed" }
416 416 end
417 417
418 418 def test_cross_project_redmine_links
419 419 source_link = link_to('ecookbook:source:/some/file',
420 420 {:controller => 'repositories', :action => 'entry',
421 421 :id => 'ecookbook', :path => ['some', 'file']},
422 422 :class => 'source')
423 423 changeset_link = link_to('ecookbook:r2',
424 424 {:controller => 'repositories', :action => 'revision',
425 425 :id => 'ecookbook', :rev => 2},
426 426 :class => 'changeset',
427 427 :title => 'This commit fixes #1, #2 and references #1 & #3')
428 428 to_test = {
429 429 # documents
430 430 'document:"Test document"' => 'document:"Test document"',
431 431 'ecookbook:document:"Test document"' =>
432 432 link_to("Test document", "/documents/1", :class => "document"),
433 433 'invalid:document:"Test document"' => 'invalid:document:"Test document"',
434 434 # versions
435 435 'version:"1.0"' => 'version:"1.0"',
436 436 'ecookbook:version:"1.0"' =>
437 437 link_to("1.0", "/versions/2", :class => "version"),
438 438 'invalid:version:"1.0"' => 'invalid:version:"1.0"',
439 439 # changeset
440 440 'r2' => 'r2',
441 441 'ecookbook:r2' => changeset_link,
442 442 'invalid:r2' => 'invalid:r2',
443 443 # source
444 444 'source:/some/file' => 'source:/some/file',
445 445 'ecookbook:source:/some/file' => source_link,
446 446 'invalid:source:/some/file' => 'invalid:source:/some/file',
447 447 }
448 448 @project = Project.find(3)
449 449 to_test.each do |text, result|
450 450 assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed"
451 451 end
452 452 end
453 453
454 454 def test_redmine_links_by_name_should_work_with_html_escaped_characters
455 455 v = Version.generate!(:name => "Test & Show.txt", :project_id => 1)
456 456 link = link_to("Test & Show.txt", "/versions/#{v.id}", :class => "version")
457 457
458 458 @project = v.project
459 459 assert_equal "<p>#{link}</p>", textilizable('version:"Test & Show.txt"')
460 460 end
461 461
462 462 def test_link_to_issue_subject
463 463 issue = Issue.generate!(:subject => "01234567890123456789")
464 464 str = link_to_issue(issue, :truncate => 10)
465 465 result = link_to("Bug ##{issue.id}", "/issues/#{issue.id}", :class => issue.css_classes)
466 466 assert_equal "#{result}: 0123456...", str
467 467
468 468 issue = Issue.generate!(:subject => "<&>")
469 469 str = link_to_issue(issue)
470 470 result = link_to("Bug ##{issue.id}", "/issues/#{issue.id}", :class => issue.css_classes)
471 471 assert_equal "#{result}: &lt;&amp;&gt;", str
472 472
473 473 issue = Issue.generate!(:subject => "<&>0123456789012345")
474 474 str = link_to_issue(issue, :truncate => 10)
475 475 result = link_to("Bug ##{issue.id}", "/issues/#{issue.id}", :class => issue.css_classes)
476 476 assert_equal "#{result}: &lt;&amp;&gt;0123...", str
477 477 end
478 478
479 479 def test_link_to_issue_title
480 480 long_str = "0123456789" * 5
481 481
482 482 issue = Issue.generate!(:subject => "#{long_str}01234567890123456789")
483 483 str = link_to_issue(issue, :subject => false)
484 484 result = link_to("Bug ##{issue.id}", "/issues/#{issue.id}",
485 485 :class => issue.css_classes,
486 486 :title => "#{long_str}0123456...")
487 487 assert_equal result, str
488 488
489 489 issue = Issue.generate!(:subject => "<&>#{long_str}01234567890123456789")
490 490 str = link_to_issue(issue, :subject => false)
491 491 result = link_to("Bug ##{issue.id}", "/issues/#{issue.id}",
492 492 :class => issue.css_classes,
493 493 :title => "<&>#{long_str}0123...")
494 494 assert_equal result, str
495 495 end
496 496
497 497 def test_multiple_repositories_redmine_links
498 498 svn = Repository::Subversion.create!(:project_id => 1, :identifier => 'svn_repo-1', :url => 'file:///foo/hg')
499 499 Changeset.create!(:repository => svn, :committed_on => Time.now, :revision => '123')
500 500 hg = Repository::Mercurial.create!(:project_id => 1, :identifier => 'hg1', :url => '/foo/hg')
501 501 Changeset.create!(:repository => hg, :committed_on => Time.now, :revision => '123', :scmid => 'abcd')
502 502
503 503 changeset_link = link_to('r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
504 504 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
505 505 svn_changeset_link = link_to('svn_repo-1|r123', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'svn_repo-1', :rev => 123},
506 506 :class => 'changeset', :title => '')
507 507 hg_changeset_link = link_to('hg1|abcd', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'hg1', :rev => 'abcd'},
508 508 :class => 'changeset', :title => '')
509 509
510 510 source_link = link_to('source:some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}, :class => 'source')
511 511 hg_source_link = link_to('source:hg1|some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :repository_id => 'hg1', :path => ['some', 'file']}, :class => 'source')
512 512
513 513 to_test = {
514 514 'r2' => changeset_link,
515 515 'svn_repo-1|r123' => svn_changeset_link,
516 516 'invalid|r123' => 'invalid|r123',
517 517 'commit:hg1|abcd' => hg_changeset_link,
518 518 'commit:invalid|abcd' => 'commit:invalid|abcd',
519 519 # source
520 520 'source:some/file' => source_link,
521 521 'source:hg1|some/file' => hg_source_link,
522 522 'source:invalid|some/file' => 'source:invalid|some/file',
523 523 }
524 524
525 525 @project = Project.find(1)
526 526 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
527 527 end
528 528
529 529 def test_cross_project_multiple_repositories_redmine_links
530 530 svn = Repository::Subversion.create!(:project_id => 1, :identifier => 'svn1', :url => 'file:///foo/hg')
531 531 Changeset.create!(:repository => svn, :committed_on => Time.now, :revision => '123')
532 532 hg = Repository::Mercurial.create!(:project_id => 1, :identifier => 'hg1', :url => '/foo/hg')
533 533 Changeset.create!(:repository => hg, :committed_on => Time.now, :revision => '123', :scmid => 'abcd')
534 534
535 535 changeset_link = link_to('ecookbook:r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
536 536 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
537 537 svn_changeset_link = link_to('ecookbook:svn1|r123', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'svn1', :rev => 123},
538 538 :class => 'changeset', :title => '')
539 539 hg_changeset_link = link_to('ecookbook:hg1|abcd', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'hg1', :rev => 'abcd'},
540 540 :class => 'changeset', :title => '')
541 541
542 542 source_link = link_to('ecookbook:source:some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}, :class => 'source')
543 543 hg_source_link = link_to('ecookbook:source:hg1|some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :repository_id => 'hg1', :path => ['some', 'file']}, :class => 'source')
544 544
545 545 to_test = {
546 546 'ecookbook:r2' => changeset_link,
547 547 'ecookbook:svn1|r123' => svn_changeset_link,
548 548 'ecookbook:invalid|r123' => 'ecookbook:invalid|r123',
549 549 'ecookbook:commit:hg1|abcd' => hg_changeset_link,
550 550 'ecookbook:commit:invalid|abcd' => 'ecookbook:commit:invalid|abcd',
551 551 'invalid:commit:invalid|abcd' => 'invalid:commit:invalid|abcd',
552 552 # source
553 553 'ecookbook:source:some/file' => source_link,
554 554 'ecookbook:source:hg1|some/file' => hg_source_link,
555 555 'ecookbook:source:invalid|some/file' => 'ecookbook:source:invalid|some/file',
556 556 'invalid:source:invalid|some/file' => 'invalid:source:invalid|some/file',
557 557 }
558 558
559 559 @project = Project.find(3)
560 560 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
561 561 end
562 562
563 563 def test_redmine_links_git_commit
564 564 changeset_link = link_to('abcd',
565 565 {
566 566 :controller => 'repositories',
567 567 :action => 'revision',
568 568 :id => 'subproject1',
569 569 :rev => 'abcd',
570 570 },
571 571 :class => 'changeset', :title => 'test commit')
572 572 to_test = {
573 573 'commit:abcd' => changeset_link,
574 574 }
575 575 @project = Project.find(3)
576 576 r = Repository::Git.create!(:project => @project, :url => '/tmp/test/git')
577 577 assert r
578 578 c = Changeset.new(:repository => r,
579 579 :committed_on => Time.now,
580 580 :revision => 'abcd',
581 581 :scmid => 'abcd',
582 582 :comments => 'test commit')
583 583 assert( c.save )
584 584 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
585 585 end
586 586
587 587 # TODO: Bazaar commit id contains mail address, so it contains '@' and '_'.
588 588 def test_redmine_links_darcs_commit
589 589 changeset_link = link_to('20080308225258-98289-abcd456efg.gz',
590 590 {
591 591 :controller => 'repositories',
592 592 :action => 'revision',
593 593 :id => 'subproject1',
594 594 :rev => '123',
595 595 },
596 596 :class => 'changeset', :title => 'test commit')
597 597 to_test = {
598 598 'commit:20080308225258-98289-abcd456efg.gz' => changeset_link,
599 599 }
600 600 @project = Project.find(3)
601 601 r = Repository::Darcs.create!(
602 602 :project => @project, :url => '/tmp/test/darcs',
603 603 :log_encoding => 'UTF-8')
604 604 assert r
605 605 c = Changeset.new(:repository => r,
606 606 :committed_on => Time.now,
607 607 :revision => '123',
608 608 :scmid => '20080308225258-98289-abcd456efg.gz',
609 609 :comments => 'test commit')
610 610 assert( c.save )
611 611 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
612 612 end
613 613
614 614 def test_redmine_links_mercurial_commit
615 615 changeset_link_rev = link_to('r123',
616 616 {
617 617 :controller => 'repositories',
618 618 :action => 'revision',
619 619 :id => 'subproject1',
620 620 :rev => '123' ,
621 621 },
622 622 :class => 'changeset', :title => 'test commit')
623 623 changeset_link_commit = link_to('abcd',
624 624 {
625 625 :controller => 'repositories',
626 626 :action => 'revision',
627 627 :id => 'subproject1',
628 628 :rev => 'abcd' ,
629 629 },
630 630 :class => 'changeset', :title => 'test commit')
631 631 to_test = {
632 632 'r123' => changeset_link_rev,
633 633 'commit:abcd' => changeset_link_commit,
634 634 }
635 635 @project = Project.find(3)
636 636 r = Repository::Mercurial.create!(:project => @project, :url => '/tmp/test')
637 637 assert r
638 638 c = Changeset.new(:repository => r,
639 639 :committed_on => Time.now,
640 640 :revision => '123',
641 641 :scmid => 'abcd',
642 642 :comments => 'test commit')
643 643 assert( c.save )
644 644 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
645 645 end
646 646
647 647 def test_attachment_links
648 648 text = 'attachment:error281.txt'
649 649 result = link_to("error281.txt", "/attachments/download/1/error281.txt",
650 650 :class => "attachment")
651 651 assert_equal "<p>#{result}</p>",
652 652 textilizable(text,
653 653 :attachments => Issue.find(3).attachments),
654 654 "#{text} failed"
655 655 end
656 656
657 657 def test_attachment_link_should_link_to_latest_attachment
658 658 set_tmp_attachments_directory
659 659 a1 = Attachment.generate!(:filename => "test.txt", :created_on => 1.hour.ago)
660 660 a2 = Attachment.generate!(:filename => "test.txt")
661 661 result = link_to("test.txt", "/attachments/download/#{a2.id}/test.txt",
662 662 :class => "attachment")
663 663 assert_equal "<p>#{result}</p>",
664 664 textilizable('attachment:test.txt', :attachments => [a1, a2])
665 665 end
666 666
667 667 def test_wiki_links
668 668 russian_eacape = CGI.escape(@russian_test)
669 669 to_test = {
670 670 '[[CookBook documentation]]' =>
671 671 link_to("CookBook documentation",
672 672 "/projects/ecookbook/wiki/CookBook_documentation",
673 673 :class => "wiki-page"),
674 674 '[[Another page|Page]]' =>
675 675 link_to("Page",
676 676 "/projects/ecookbook/wiki/Another_page",
677 677 :class => "wiki-page"),
678 678 # title content should be formatted
679 679 '[[Another page|With _styled_ *title*]]' =>
680 680 link_to("With <em>styled</em> <strong>title</strong>".html_safe,
681 681 "/projects/ecookbook/wiki/Another_page",
682 682 :class => "wiki-page"),
683 683 '[[Another page|With title containing <strong>HTML entities &amp; markups</strong>]]' =>
684 684 link_to("With title containing &lt;strong&gt;HTML entities &amp; markups&lt;/strong&gt;".html_safe,
685 685 "/projects/ecookbook/wiki/Another_page",
686 686 :class => "wiki-page"),
687 687 # link with anchor
688 688 '[[CookBook documentation#One-section]]' =>
689 689 link_to("CookBook documentation",
690 690 "/projects/ecookbook/wiki/CookBook_documentation#One-section",
691 691 :class => "wiki-page"),
692 692 '[[Another page#anchor|Page]]' =>
693 693 link_to("Page",
694 694 "/projects/ecookbook/wiki/Another_page#anchor",
695 695 :class => "wiki-page"),
696 696 # UTF8 anchor
697 697 "[[Another_page##{@russian_test}|#{@russian_test}]]" =>
698 698 link_to(@russian_test,
699 699 "/projects/ecookbook/wiki/Another_page##{russian_eacape}",
700 700 :class => "wiki-page"),
701 701 # page that doesn't exist
702 702 '[[Unknown page]]' =>
703 703 link_to("Unknown page",
704 704 "/projects/ecookbook/wiki/Unknown_page",
705 705 :class => "wiki-page new"),
706 706 '[[Unknown page|404]]' =>
707 707 link_to("404",
708 708 "/projects/ecookbook/wiki/Unknown_page",
709 709 :class => "wiki-page new"),
710 710 # link to another project wiki
711 711 '[[onlinestore:]]' =>
712 712 link_to("onlinestore",
713 713 "/projects/onlinestore/wiki",
714 714 :class => "wiki-page"),
715 715 '[[onlinestore:|Wiki]]' =>
716 716 link_to("Wiki",
717 717 "/projects/onlinestore/wiki",
718 718 :class => "wiki-page"),
719 719 '[[onlinestore:Start page]]' =>
720 720 link_to("Start page",
721 721 "/projects/onlinestore/wiki/Start_page",
722 722 :class => "wiki-page"),
723 723 '[[onlinestore:Start page|Text]]' =>
724 724 link_to("Text",
725 725 "/projects/onlinestore/wiki/Start_page",
726 726 :class => "wiki-page"),
727 727 '[[onlinestore:Unknown page]]' =>
728 728 link_to("Unknown page",
729 729 "/projects/onlinestore/wiki/Unknown_page",
730 730 :class => "wiki-page new"),
731 731 # struck through link
732 732 '-[[Another page|Page]]-' =>
733 733 "<del>".html_safe +
734 734 link_to("Page",
735 735 "/projects/ecookbook/wiki/Another_page",
736 736 :class => "wiki-page").html_safe +
737 737 "</del>".html_safe,
738 738 '-[[Another page|Page]] link-' =>
739 739 "<del>".html_safe +
740 740 link_to("Page",
741 741 "/projects/ecookbook/wiki/Another_page",
742 742 :class => "wiki-page").html_safe +
743 743 " link</del>".html_safe,
744 744 # escaping
745 745 '![[Another page|Page]]' => '[[Another page|Page]]',
746 746 # project does not exist
747 747 '[[unknowproject:Start]]' => '[[unknowproject:Start]]',
748 748 '[[unknowproject:Start|Page title]]' => '[[unknowproject:Start|Page title]]',
749 749 }
750 750 @project = Project.find(1)
751 751 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
752 752 end
753 753
754 754 def test_wiki_links_within_local_file_generation_context
755 755 to_test = {
756 756 # link to a page
757 757 '[[CookBook documentation]]' =>
758 758 link_to("CookBook documentation", "CookBook_documentation.html",
759 759 :class => "wiki-page"),
760 760 '[[CookBook documentation|documentation]]' =>
761 761 link_to("documentation", "CookBook_documentation.html",
762 762 :class => "wiki-page"),
763 763 '[[CookBook documentation#One-section]]' =>
764 764 link_to("CookBook documentation", "CookBook_documentation.html#One-section",
765 765 :class => "wiki-page"),
766 766 '[[CookBook documentation#One-section|documentation]]' =>
767 767 link_to("documentation", "CookBook_documentation.html#One-section",
768 768 :class => "wiki-page"),
769 769 # page that doesn't exist
770 770 '[[Unknown page]]' =>
771 771 link_to("Unknown page", "Unknown_page.html",
772 772 :class => "wiki-page new"),
773 773 '[[Unknown page|404]]' =>
774 774 link_to("404", "Unknown_page.html",
775 775 :class => "wiki-page new"),
776 776 '[[Unknown page#anchor]]' =>
777 777 link_to("Unknown page", "Unknown_page.html#anchor",
778 778 :class => "wiki-page new"),
779 779 '[[Unknown page#anchor|404]]' =>
780 780 link_to("404", "Unknown_page.html#anchor",
781 781 :class => "wiki-page new"),
782 782 }
783 783 @project = Project.find(1)
784 784 to_test.each do |text, result|
785 785 assert_equal "<p>#{result}</p>", textilizable(text, :wiki_links => :local)
786 786 end
787 787 end
788 788
789 789 def test_wiki_links_within_wiki_page_context
790 790 page = WikiPage.find_by_title('Another_page' )
791 791 to_test = {
792 792 '[[CookBook documentation]]' =>
793 793 link_to("CookBook documentation",
794 794 "/projects/ecookbook/wiki/CookBook_documentation",
795 795 :class => "wiki-page"),
796 796 '[[CookBook documentation|documentation]]' =>
797 797 link_to("documentation",
798 798 "/projects/ecookbook/wiki/CookBook_documentation",
799 799 :class => "wiki-page"),
800 800 '[[CookBook documentation#One-section]]' =>
801 801 link_to("CookBook documentation",
802 802 "/projects/ecookbook/wiki/CookBook_documentation#One-section",
803 803 :class => "wiki-page"),
804 804 '[[CookBook documentation#One-section|documentation]]' =>
805 805 link_to("documentation",
806 806 "/projects/ecookbook/wiki/CookBook_documentation#One-section",
807 807 :class => "wiki-page"),
808 808 # link to the current page
809 809 '[[Another page]]' =>
810 810 link_to("Another page",
811 811 "/projects/ecookbook/wiki/Another_page",
812 812 :class => "wiki-page"),
813 813 '[[Another page|Page]]' =>
814 814 link_to("Page",
815 815 "/projects/ecookbook/wiki/Another_page",
816 816 :class => "wiki-page"),
817 817 '[[Another page#anchor]]' =>
818 818 link_to("Another page",
819 819 "#anchor",
820 820 :class => "wiki-page"),
821 821 '[[Another page#anchor|Page]]' =>
822 822 link_to("Page",
823 823 "#anchor",
824 824 :class => "wiki-page"),
825 825 # page that doesn't exist
826 826 '[[Unknown page]]' =>
827 827 link_to("Unknown page",
828 828 "/projects/ecookbook/wiki/Unknown_page?parent=Another_page",
829 829 :class => "wiki-page new"),
830 830 '[[Unknown page|404]]' =>
831 831 link_to("404",
832 832 "/projects/ecookbook/wiki/Unknown_page?parent=Another_page",
833 833 :class => "wiki-page new"),
834 834 '[[Unknown page#anchor]]' =>
835 835 link_to("Unknown page",
836 836 "/projects/ecookbook/wiki/Unknown_page?parent=Another_page#anchor",
837 837 :class => "wiki-page new"),
838 838 '[[Unknown page#anchor|404]]' =>
839 839 link_to("404",
840 840 "/projects/ecookbook/wiki/Unknown_page?parent=Another_page#anchor",
841 841 :class => "wiki-page new"),
842 842 }
843 843 @project = Project.find(1)
844 844 to_test.each do |text, result|
845 845 assert_equal "<p>#{result}</p>",
846 846 textilizable(WikiContent.new( :text => text, :page => page ), :text)
847 847 end
848 848 end
849 849
850 850 def test_wiki_links_anchor_option_should_prepend_page_title_to_href
851 851 to_test = {
852 852 # link to a page
853 853 '[[CookBook documentation]]' =>
854 854 link_to("CookBook documentation",
855 855 "#CookBook_documentation",
856 856 :class => "wiki-page"),
857 857 '[[CookBook documentation|documentation]]' =>
858 858 link_to("documentation",
859 859 "#CookBook_documentation",
860 860 :class => "wiki-page"),
861 861 '[[CookBook documentation#One-section]]' =>
862 862 link_to("CookBook documentation",
863 863 "#CookBook_documentation_One-section",
864 864 :class => "wiki-page"),
865 865 '[[CookBook documentation#One-section|documentation]]' =>
866 866 link_to("documentation",
867 867 "#CookBook_documentation_One-section",
868 868 :class => "wiki-page"),
869 869 # page that doesn't exist
870 870 '[[Unknown page]]' =>
871 871 link_to("Unknown page",
872 872 "#Unknown_page",
873 873 :class => "wiki-page new"),
874 874 '[[Unknown page|404]]' =>
875 875 link_to("404",
876 876 "#Unknown_page",
877 877 :class => "wiki-page new"),
878 878 '[[Unknown page#anchor]]' =>
879 879 link_to("Unknown page",
880 880 "#Unknown_page_anchor",
881 881 :class => "wiki-page new"),
882 882 '[[Unknown page#anchor|404]]' =>
883 883 link_to("404",
884 884 "#Unknown_page_anchor",
885 885 :class => "wiki-page new"),
886 886 }
887 887 @project = Project.find(1)
888 888 to_test.each do |text, result|
889 889 assert_equal "<p>#{result}</p>", textilizable(text, :wiki_links => :anchor)
890 890 end
891 891 end
892 892
893 893 def test_html_tags
894 894 to_test = {
895 895 "<div>content</div>" => "<p>&lt;div&gt;content&lt;/div&gt;</p>",
896 896 "<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
897 897 "<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</p>",
898 898 # do not escape pre/code tags
899 899 "<pre>\nline 1\nline2</pre>" => "<pre>\nline 1\nline2</pre>",
900 900 "<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
901 901 "<pre><div>content</div></pre>" => "<pre>&lt;div&gt;content&lt;/div&gt;</pre>",
902 902 "HTML comment: <!-- no comments -->" => "<p>HTML comment: &lt;!-- no comments --&gt;</p>",
903 903 "<!-- opening comment" => "<p>&lt;!-- opening comment</p>",
904 904 # remove attributes except class
905 905 "<pre class='foo'>some text</pre>" => "<pre class='foo'>some text</pre>",
906 906 '<pre class="foo">some text</pre>' => '<pre class="foo">some text</pre>',
907 907 "<pre class='foo bar'>some text</pre>" => "<pre class='foo bar'>some text</pre>",
908 908 '<pre class="foo bar">some text</pre>' => '<pre class="foo bar">some text</pre>',
909 909 "<pre onmouseover='alert(1)'>some text</pre>" => "<pre>some text</pre>",
910 910 # xss
911 911 '<pre><code class=""onmouseover="alert(1)">text</code></pre>' => '<pre><code>text</code></pre>',
912 912 '<pre class=""onmouseover="alert(1)">text</pre>' => '<pre>text</pre>',
913 913 }
914 914 to_test.each { |text, result| assert_equal result, textilizable(text) }
915 915 end
916 916
917 917 def test_allowed_html_tags
918 918 to_test = {
919 919 "<pre>preformatted text</pre>" => "<pre>preformatted text</pre>",
920 920 "<notextile>no *textile* formatting</notextile>" => "no *textile* formatting",
921 921 "<notextile>this is <tag>a tag</tag></notextile>" => "this is &lt;tag&gt;a tag&lt;/tag&gt;"
922 922 }
923 923 to_test.each { |text, result| assert_equal result, textilizable(text) }
924 924 end
925 925
926 926 def test_pre_tags
927 927 raw = <<-RAW
928 928 Before
929 929
930 930 <pre>
931 931 <prepared-statement-cache-size>32</prepared-statement-cache-size>
932 932 </pre>
933 933
934 934 After
935 935 RAW
936 936
937 937 expected = <<-EXPECTED
938 938 <p>Before</p>
939 939 <pre>
940 940 &lt;prepared-statement-cache-size&gt;32&lt;/prepared-statement-cache-size&gt;
941 941 </pre>
942 942 <p>After</p>
943 943 EXPECTED
944 944
945 945 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
946 946 end
947 947
948 948 def test_pre_content_should_not_parse_wiki_and_redmine_links
949 949 raw = <<-RAW
950 950 [[CookBook documentation]]
951 951
952 952 #1
953 953
954 954 <pre>
955 955 [[CookBook documentation]]
956 956
957 957 #1
958 958 </pre>
959 959 RAW
960 960
961 961 result1 = link_to("CookBook documentation",
962 962 "/projects/ecookbook/wiki/CookBook_documentation",
963 963 :class => "wiki-page")
964 964 result2 = link_to('#1',
965 965 "/issues/1",
966 966 :class => Issue.find(1).css_classes,
967 967 :title => "Bug: Cannot print recipes (New)")
968 968
969 969 expected = <<-EXPECTED
970 970 <p>#{result1}</p>
971 971 <p>#{result2}</p>
972 972 <pre>
973 973 [[CookBook documentation]]
974 974
975 975 #1
976 976 </pre>
977 977 EXPECTED
978 978
979 979 @project = Project.find(1)
980 980 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
981 981 end
982 982
983 983 def test_non_closing_pre_blocks_should_be_closed
984 984 raw = <<-RAW
985 985 <pre><code>
986 986 RAW
987 987
988 988 expected = <<-EXPECTED
989 989 <pre><code>
990 990 </code></pre>
991 991 EXPECTED
992 992
993 993 @project = Project.find(1)
994 994 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
995 995 end
996 996
997 997 def test_unbalanced_closing_pre_tag_should_not_error
998 998 assert_nothing_raised do
999 999 textilizable("unbalanced</pre>")
1000 1000 end
1001 1001 end
1002 1002
1003 1003 def test_syntax_highlight
1004 1004 raw = <<-RAW
1005 1005 <pre><code class="ruby">
1006 1006 # Some ruby code here
1007 1007 </code></pre>
1008 1008 RAW
1009 1009
1010 1010 expected = <<-EXPECTED
1011 1011 <pre><code class="ruby syntaxhl"><span class=\"CodeRay\"><span class="comment"># Some ruby code here</span></span>
1012 1012 </code></pre>
1013 1013 EXPECTED
1014 1014
1015 1015 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
1016 1016 end
1017 1017
1018 1018 def test_to_path_param
1019 1019 assert_equal 'test1/test2', to_path_param('test1/test2')
1020 1020 assert_equal 'test1/test2', to_path_param('/test1/test2/')
1021 1021 assert_equal 'test1/test2', to_path_param('//test1/test2/')
1022 1022 assert_equal nil, to_path_param('/')
1023 1023 end
1024 1024
1025 1025 def test_wiki_links_in_tables
1026 1026 text = "|[[Page|Link title]]|[[Other Page|Other title]]|\n|Cell 21|[[Last page]]|"
1027 1027 link1 = link_to("Link title", "/projects/ecookbook/wiki/Page", :class => "wiki-page new")
1028 1028 link2 = link_to("Other title", "/projects/ecookbook/wiki/Other_Page", :class => "wiki-page new")
1029 1029 link3 = link_to("Last page", "/projects/ecookbook/wiki/Last_page", :class => "wiki-page new")
1030 1030 result = "<tr><td>#{link1}</td>" +
1031 1031 "<td>#{link2}</td>" +
1032 1032 "</tr><tr><td>Cell 21</td><td>#{link3}</td></tr>"
1033 1033 @project = Project.find(1)
1034 1034 assert_equal "<table>#{result}</table>", textilizable(text).gsub(/[\t\n]/, '')
1035 1035 end
1036 1036
1037 1037 def test_text_formatting
1038 1038 to_test = {'*_+bold, italic and underline+_*' => '<strong><em><ins>bold, italic and underline</ins></em></strong>',
1039 1039 '(_text within parentheses_)' => '(<em>text within parentheses</em>)',
1040 1040 'a *Humane Web* Text Generator' => 'a <strong>Humane Web</strong> Text Generator',
1041 1041 'a H *umane* W *eb* T *ext* G *enerator*' => 'a H <strong>umane</strong> W <strong>eb</strong> T <strong>ext</strong> G <strong>enerator</strong>',
1042 1042 'a *H* umane *W* eb *T* ext *G* enerator' => 'a <strong>H</strong> umane <strong>W</strong> eb <strong>T</strong> ext <strong>G</strong> enerator',
1043 1043 }
1044 1044 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
1045 1045 end
1046 1046
1047 1047 def test_wiki_horizontal_rule
1048 1048 assert_equal '<hr />', textilizable('---')
1049 1049 assert_equal '<p>Dashes: ---</p>', textilizable('Dashes: ---')
1050 1050 end
1051 1051
1052 1052 def test_footnotes
1053 1053 raw = <<-RAW
1054 1054 This is some text[1].
1055 1055
1056 1056 fn1. This is the foot note
1057 1057 RAW
1058 1058
1059 1059 expected = <<-EXPECTED
1060 1060 <p>This is some text<sup><a href=\"#fn1\">1</a></sup>.</p>
1061 1061 <p id="fn1" class="footnote"><sup>1</sup> This is the foot note</p>
1062 1062 EXPECTED
1063 1063
1064 1064 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
1065 1065 end
1066 1066
1067 1067 def test_headings
1068 1068 raw = 'h1. Some heading'
1069 1069 expected = %|<a name="Some-heading"></a>\n<h1 >Some heading<a href="#Some-heading" class="wiki-anchor">&para;</a></h1>|
1070 1070
1071 1071 assert_equal expected, textilizable(raw)
1072 1072 end
1073 1073
1074 1074 def test_headings_with_special_chars
1075 1075 # This test makes sure that the generated anchor names match the expected
1076 1076 # ones even if the heading text contains unconventional characters
1077 1077 raw = 'h1. Some heading related to version 0.5'
1078 1078 anchor = sanitize_anchor_name("Some-heading-related-to-version-0.5")
1079 1079 expected = %|<a name="#{anchor}"></a>\n<h1 >Some heading related to version 0.5<a href="##{anchor}" class="wiki-anchor">&para;</a></h1>|
1080 1080
1081 1081 assert_equal expected, textilizable(raw)
1082 1082 end
1083 1083
1084 1084 def test_headings_in_wiki_single_page_export_should_be_prepended_with_page_title
1085 1085 page = WikiPage.new( :title => 'Page Title', :wiki_id => 1 )
1086 1086 content = WikiContent.new( :text => 'h1. Some heading', :page => page )
1087 1087
1088 1088 expected = %|<a name="Page_Title_Some-heading"></a>\n<h1 >Some heading<a href="#Page_Title_Some-heading" class="wiki-anchor">&para;</a></h1>|
1089 1089
1090 1090 assert_equal expected, textilizable(content, :text, :wiki_links => :anchor )
1091 1091 end
1092 1092
1093 1093 def test_table_of_content
1094 1094 raw = <<-RAW
1095 1095 {{toc}}
1096 1096
1097 1097 h1. Title
1098 1098
1099 1099 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
1100 1100
1101 1101 h2. Subtitle with a [[Wiki]] link
1102 1102
1103 1103 Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
1104 1104
1105 1105 h2. Subtitle with [[Wiki|another Wiki]] link
1106 1106
1107 1107 h2. Subtitle with %{color:red}red text%
1108 1108
1109 1109 <pre>
1110 1110 some code
1111 1111 </pre>
1112 1112
1113 1113 h3. Subtitle with *some* _modifiers_
1114 1114
1115 1115 h3. Subtitle with @inline code@
1116 1116
1117 1117 h1. Another title
1118 1118
1119 1119 h3. An "Internet link":http://www.redmine.org/ inside subtitle
1120 1120
1121 1121 h2. "Project Name !/attachments/1234/logo_small.gif! !/attachments/5678/logo_2.png!":/projects/projectname/issues
1122 1122
1123 1123 RAW
1124 1124
1125 1125 expected = '<ul class="toc">' +
1126 1126 '<li><a href="#Title">Title</a>' +
1127 1127 '<ul>' +
1128 1128 '<li><a href="#Subtitle-with-a-Wiki-link">Subtitle with a Wiki link</a></li>' +
1129 1129 '<li><a href="#Subtitle-with-another-Wiki-link">Subtitle with another Wiki link</a></li>' +
1130 1130 '<li><a href="#Subtitle-with-red-text">Subtitle with red text</a>' +
1131 1131 '<ul>' +
1132 1132 '<li><a href="#Subtitle-with-some-modifiers">Subtitle with some modifiers</a></li>' +
1133 1133 '<li><a href="#Subtitle-with-inline-code">Subtitle with inline code</a></li>' +
1134 1134 '</ul>' +
1135 1135 '</li>' +
1136 1136 '</ul>' +
1137 1137 '</li>' +
1138 1138 '<li><a href="#Another-title">Another title</a>' +
1139 1139 '<ul>' +
1140 1140 '<li>' +
1141 1141 '<ul>' +
1142 1142 '<li><a href="#An-Internet-link-inside-subtitle">An Internet link inside subtitle</a></li>' +
1143 1143 '</ul>' +
1144 1144 '</li>' +
1145 1145 '<li><a href="#Project-Name">Project Name</a></li>' +
1146 1146 '</ul>' +
1147 1147 '</li>' +
1148 1148 '</ul>'
1149 1149
1150 1150 @project = Project.find(1)
1151 1151 assert textilizable(raw).gsub("\n", "").include?(expected)
1152 1152 end
1153 1153
1154 1154 def test_table_of_content_should_generate_unique_anchors
1155 1155 raw = <<-RAW
1156 1156 {{toc}}
1157 1157
1158 1158 h1. Title
1159 1159
1160 1160 h2. Subtitle
1161 1161
1162 1162 h2. Subtitle
1163 1163 RAW
1164 1164
1165 1165 expected = '<ul class="toc">' +
1166 1166 '<li><a href="#Title">Title</a>' +
1167 1167 '<ul>' +
1168 1168 '<li><a href="#Subtitle">Subtitle</a></li>' +
1169 1169 '<li><a href="#Subtitle-2">Subtitle</a></li>'
1170 1170 '</ul>'
1171 1171 '</li>' +
1172 1172 '</ul>'
1173 1173
1174 1174 @project = Project.find(1)
1175 1175 result = textilizable(raw).gsub("\n", "")
1176 1176 assert_include expected, result
1177 1177 assert_include '<a name="Subtitle">', result
1178 1178 assert_include '<a name="Subtitle-2">', result
1179 1179 end
1180 1180
1181 1181 def test_table_of_content_should_contain_included_page_headings
1182 1182 raw = <<-RAW
1183 1183 {{toc}}
1184 1184
1185 1185 h1. Included
1186 1186
1187 1187 {{include(Child_1)}}
1188 1188 RAW
1189 1189
1190 1190 expected = '<ul class="toc">' +
1191 1191 '<li><a href="#Included">Included</a></li>' +
1192 1192 '<li><a href="#Child-page-1">Child page 1</a></li>' +
1193 1193 '</ul>'
1194 1194
1195 1195 @project = Project.find(1)
1196 1196 assert textilizable(raw).gsub("\n", "").include?(expected)
1197 1197 end
1198 1198
1199 1199 def test_toc_with_textile_formatting_should_be_parsed
1200 1200 with_settings :text_formatting => 'textile' do
1201 1201 assert_select_in textilizable("{{toc}}\n\nh1. Heading"), 'ul.toc li', :text => 'Heading'
1202 1202 assert_select_in textilizable("{{<toc}}\n\nh1. Heading"), 'ul.toc.left li', :text => 'Heading'
1203 1203 assert_select_in textilizable("{{>toc}}\n\nh1. Heading"), 'ul.toc.right li', :text => 'Heading'
1204 1204 end
1205 1205 end
1206 1206
1207 1207 if Object.const_defined?(:Redcarpet)
1208 1208 def test_toc_with_markdown_formatting_should_be_parsed
1209 1209 with_settings :text_formatting => 'markdown' do
1210 1210 assert_select_in textilizable("{{toc}}\n\n# Heading"), 'ul.toc li', :text => 'Heading'
1211 1211 assert_select_in textilizable("{{<toc}}\n\n# Heading"), 'ul.toc.left li', :text => 'Heading'
1212 1212 assert_select_in textilizable("{{>toc}}\n\n# Heading"), 'ul.toc.right li', :text => 'Heading'
1213 1213 end
1214 1214 end
1215 1215 end
1216 1216
1217 1217 def test_section_edit_links
1218 1218 raw = <<-RAW
1219 1219 h1. Title
1220 1220
1221 1221 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
1222 1222
1223 1223 h2. Subtitle with a [[Wiki]] link
1224 1224
1225 1225 h2. Subtitle with *some* _modifiers_
1226 1226
1227 1227 h2. Subtitle with @inline code@
1228 1228
1229 1229 <pre>
1230 1230 some code
1231 1231
1232 1232 h2. heading inside pre
1233 1233
1234 1234 <h2>html heading inside pre</h2>
1235 1235 </pre>
1236 1236
1237 1237 h2. Subtitle after pre tag
1238 1238 RAW
1239 1239
1240 1240 @project = Project.find(1)
1241 1241 set_language_if_valid 'en'
1242 1242 result = textilizable(raw, :edit_section_links => {:controller => 'wiki', :action => 'edit', :project_id => '1', :id => 'Test'}).gsub("\n", "")
1243 1243
1244 1244 # heading that contains inline code
1245 1245 assert_match Regexp.new('<div class="contextual heading-2" title="Edit this section" id="section-4">' +
1246 1246 '<a href="/projects/1/wiki/Test/edit\?section=4"><img src="/images/edit.png(\?\d+)?" alt="Edit" /></a></div>' +
1247 1247 '<a name="Subtitle-with-inline-code"></a>' +
1248 1248 '<h2 >Subtitle with <code>inline code</code><a href="#Subtitle-with-inline-code" class="wiki-anchor">&para;</a></h2>'),
1249 1249 result
1250 1250
1251 1251 # last heading
1252 1252 assert_match Regexp.new('<div class="contextual heading-2" title="Edit this section" id="section-5">' +
1253 1253 '<a href="/projects/1/wiki/Test/edit\?section=5"><img src="/images/edit.png(\?\d+)?" alt="Edit" /></a></div>' +
1254 1254 '<a name="Subtitle-after-pre-tag"></a>' +
1255 1255 '<h2 >Subtitle after pre tag<a href="#Subtitle-after-pre-tag" class="wiki-anchor">&para;</a></h2>'),
1256 1256 result
1257 1257 end
1258 1258
1259 1259 def test_default_formatter
1260 1260 with_settings :text_formatting => 'unknown' do
1261 1261 text = 'a *link*: http://www.example.net/'
1262 1262 assert_equal '<p>a *link*: <a class="external" href="http://www.example.net/">http://www.example.net/</a></p>', textilizable(text)
1263 1263 end
1264 1264 end
1265 1265
1266 1266 def test_parse_redmine_links_should_handle_a_tag_without_attributes
1267 1267 text = '<a>http://example.com</a>'
1268 1268 expected = text.dup
1269 1269 parse_redmine_links(text, nil, nil, nil, true, {})
1270 1270 assert_equal expected, text
1271 1271 end
1272 1272
1273 1273 def test_due_date_distance_in_words
1274 1274 to_test = { Date.today => 'Due in 0 days',
1275 1275 Date.today + 1 => 'Due in 1 day',
1276 1276 Date.today + 100 => 'Due in about 3 months',
1277 1277 Date.today + 20000 => 'Due in over 54 years',
1278 1278 Date.today - 1 => '1 day late',
1279 1279 Date.today - 100 => 'about 3 months late',
1280 1280 Date.today - 20000 => 'over 54 years late',
1281 1281 }
1282 1282 ::I18n.locale = :en
1283 1283 to_test.each do |date, expected|
1284 1284 assert_equal expected, due_date_distance_in_words(date)
1285 1285 end
1286 1286 end
1287 1287
1288 1288 def test_avatar_enabled
1289 1289 with_settings :gravatar_enabled => '1' do
1290 1290 assert avatar(User.find_by_mail('jsmith@somenet.foo')).include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
1291 1291 assert avatar('jsmith <jsmith@somenet.foo>').include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
1292 1292 # Default size is 50
1293 1293 assert avatar('jsmith <jsmith@somenet.foo>').include?('size=50')
1294 1294 assert avatar('jsmith <jsmith@somenet.foo>', :size => 24).include?('size=24')
1295 1295 # Non-avatar options should be considered html options
1296 1296 assert avatar('jsmith <jsmith@somenet.foo>', :title => 'John Smith').include?('title="John Smith"')
1297 1297 # The default class of the img tag should be gravatar
1298 1298 assert avatar('jsmith <jsmith@somenet.foo>').include?('class="gravatar"')
1299 1299 assert !avatar('jsmith <jsmith@somenet.foo>', :class => 'picture').include?('class="gravatar"')
1300 1300 assert_nil avatar('jsmith')
1301 1301 assert_nil avatar(nil)
1302 1302 end
1303 1303 end
1304 1304
1305 1305 def test_avatar_disabled
1306 1306 with_settings :gravatar_enabled => '0' do
1307 1307 assert_equal '', avatar(User.find_by_mail('jsmith@somenet.foo'))
1308 1308 end
1309 1309 end
1310 1310
1311 1311 def test_link_to_user
1312 1312 user = User.find(2)
1313 1313 result = link_to("John Smith", "/users/2", :class => "user active")
1314 1314 assert_equal result, link_to_user(user)
1315 1315 end
1316 1316
1317 1317 def test_link_to_user_should_not_link_to_locked_user
1318 1318 with_current_user nil do
1319 1319 user = User.find(5)
1320 1320 assert user.locked?
1321 1321 assert_equal 'Dave2 Lopper2', link_to_user(user)
1322 1322 end
1323 1323 end
1324 1324
1325 1325 def test_link_to_user_should_link_to_locked_user_if_current_user_is_admin
1326 1326 with_current_user User.find(1) do
1327 1327 user = User.find(5)
1328 1328 assert user.locked?
1329 1329 result = link_to("Dave2 Lopper2", "/users/5", :class => "user locked")
1330 1330 assert_equal result, link_to_user(user)
1331 1331 end
1332 1332 end
1333 1333
1334 1334 def test_link_to_user_should_not_link_to_anonymous
1335 1335 user = User.anonymous
1336 1336 assert user.anonymous?
1337 1337 t = link_to_user(user)
1338 1338 assert_equal ::I18n.t(:label_user_anonymous), t
1339 1339 end
1340 1340
1341 1341 def test_link_to_attachment
1342 1342 a = Attachment.find(3)
1343 1343 assert_equal '<a href="/attachments/3/logo.gif">logo.gif</a>',
1344 1344 link_to_attachment(a)
1345 1345 assert_equal '<a href="/attachments/3/logo.gif">Text</a>',
1346 1346 link_to_attachment(a, :text => 'Text')
1347 1347 result = link_to("logo.gif", "/attachments/3/logo.gif", :class => "foo")
1348 1348 assert_equal result,
1349 1349 link_to_attachment(a, :class => 'foo')
1350 1350 assert_equal '<a href="/attachments/download/3/logo.gif">logo.gif</a>',
1351 1351 link_to_attachment(a, :download => true)
1352 1352 assert_equal '<a href="http://test.host/attachments/3/logo.gif">logo.gif</a>',
1353 1353 link_to_attachment(a, :only_path => false)
1354 1354 end
1355 1355
1356 1356 def test_thumbnail_tag
1357 1357 a = Attachment.find(3)
1358 1358 assert_select_in thumbnail_tag(a),
1359 1359 'a[href=?][title=?] img[alt="3"][src=?]',
1360 1360 "/attachments/3/logo.gif", "logo.gif", "/attachments/thumbnail/3"
1361 1361 end
1362 1362
1363 1363 def test_link_to_project
1364 1364 project = Project.find(1)
1365 1365 assert_equal %(<a href="/projects/ecookbook">eCookbook</a>),
1366 1366 link_to_project(project)
1367 1367 assert_equal %(<a href="http://test.host/projects/ecookbook?jump=blah">eCookbook</a>),
1368 1368 link_to_project(project, {:only_path => false, :jump => 'blah'})
1369 1369 end
1370 1370
1371 1371 def test_link_to_project_settings
1372 1372 project = Project.find(1)
1373 1373 assert_equal '<a href="/projects/ecookbook/settings">eCookbook</a>', link_to_project_settings(project)
1374 1374
1375 1375 project.status = Project::STATUS_CLOSED
1376 1376 assert_equal '<a href="/projects/ecookbook">eCookbook</a>', link_to_project_settings(project)
1377 1377
1378 1378 project.status = Project::STATUS_ARCHIVED
1379 1379 assert_equal 'eCookbook', link_to_project_settings(project)
1380 1380 end
1381 1381
1382 1382 def test_link_to_legacy_project_with_numerical_identifier_should_use_id
1383 1383 # numeric identifier are no longer allowed
1384 1384 Project.where(:id => 1).update_all(:identifier => 25)
1385 1385 assert_equal '<a href="/projects/1">eCookbook</a>',
1386 1386 link_to_project(Project.find(1))
1387 1387 end
1388 1388
1389 1389 def test_principals_options_for_select_with_users
1390 1390 User.current = nil
1391 1391 users = [User.find(2), User.find(4)]
1392 1392 assert_equal %(<option value="2">John Smith</option><option value="4">Robert Hill</option>),
1393 1393 principals_options_for_select(users)
1394 1394 end
1395 1395
1396 1396 def test_principals_options_for_select_with_selected
1397 1397 User.current = nil
1398 1398 users = [User.find(2), User.find(4)]
1399 1399 assert_equal %(<option value="2">John Smith</option><option value="4" selected="selected">Robert Hill</option>),
1400 1400 principals_options_for_select(users, User.find(4))
1401 1401 end
1402 1402
1403 1403 def test_principals_options_for_select_with_users_and_groups
1404 1404 User.current = nil
1405 1405 set_language_if_valid 'en'
1406 1406 users = [User.find(2), Group.find(11), User.find(4), Group.find(10)]
1407 1407 assert_equal %(<option value="2">John Smith</option><option value="4">Robert Hill</option>) +
1408 1408 %(<optgroup label="Groups"><option value="10">A Team</option><option value="11">B Team</option></optgroup>),
1409 1409 principals_options_for_select(users)
1410 1410 end
1411 1411
1412 1412 def test_principals_options_for_select_with_empty_collection
1413 1413 assert_equal '', principals_options_for_select([])
1414 1414 end
1415 1415
1416 1416 def test_principals_options_for_select_should_include_me_option_when_current_user_is_in_collection
1417 1417 set_language_if_valid 'en'
1418 1418 users = [User.find(2), User.find(4)]
1419 1419 User.current = User.find(4)
1420 1420 assert_include '<option value="4">&lt;&lt; me &gt;&gt;</option>', principals_options_for_select(users)
1421 1421 end
1422 1422
1423 1423 def test_stylesheet_link_tag_should_pick_the_default_stylesheet
1424 1424 assert_match 'href="/stylesheets/styles.css"', stylesheet_link_tag("styles")
1425 1425 end
1426 1426
1427 1427 def test_stylesheet_link_tag_for_plugin_should_pick_the_plugin_stylesheet
1428 1428 assert_match 'href="/plugin_assets/foo/stylesheets/styles.css"', stylesheet_link_tag("styles", :plugin => :foo)
1429 1429 end
1430 1430
1431 1431 def test_image_tag_should_pick_the_default_image
1432 1432 assert_match 'src="/images/image.png"', image_tag("image.png")
1433 1433 end
1434 1434
1435 1435 def test_image_tag_should_pick_the_theme_image_if_it_exists
1436 1436 theme = Redmine::Themes.themes.last
1437 1437 theme.images << 'image.png'
1438 1438
1439 1439 with_settings :ui_theme => theme.id do
1440 1440 assert_match %|src="/themes/#{theme.dir}/images/image.png"|, image_tag("image.png")
1441 1441 assert_match %|src="/images/other.png"|, image_tag("other.png")
1442 1442 end
1443 1443 ensure
1444 1444 theme.images.delete 'image.png'
1445 1445 end
1446 1446
1447 1447 def test_image_tag_sfor_plugin_should_pick_the_plugin_image
1448 1448 assert_match 'src="/plugin_assets/foo/images/image.png"', image_tag("image.png", :plugin => :foo)
1449 1449 end
1450 1450
1451 1451 def test_javascript_include_tag_should_pick_the_default_javascript
1452 1452 assert_match 'src="/javascripts/scripts.js"', javascript_include_tag("scripts")
1453 1453 end
1454 1454
1455 1455 def test_javascript_include_tag_for_plugin_should_pick_the_plugin_javascript
1456 1456 assert_match 'src="/plugin_assets/foo/javascripts/scripts.js"', javascript_include_tag("scripts", :plugin => :foo)
1457 1457 end
1458 1458
1459 1459 def test_raw_json_should_escape_closing_tags
1460 1460 s = raw_json(["<foo>bar</foo>"])
1461 1461 assert_include '\/foo', s
1462 1462 end
1463 1463
1464 1464 def test_raw_json_should_be_html_safe
1465 1465 s = raw_json(["foo"])
1466 1466 assert s.html_safe?
1467 1467 end
1468 1468
1469 1469 def test_html_title_should_app_title_if_not_set
1470 1470 assert_equal 'Redmine', html_title
1471 1471 end
1472 1472
1473 1473 def test_html_title_should_join_items
1474 1474 html_title 'Foo', 'Bar'
1475 1475 assert_equal 'Foo - Bar - Redmine', html_title
1476 1476 end
1477 1477
1478 1478 def test_html_title_should_append_current_project_name
1479 1479 @project = Project.find(1)
1480 1480 html_title 'Foo', 'Bar'
1481 1481 assert_equal 'Foo - Bar - eCookbook - Redmine', html_title
1482 1482 end
1483 1483
1484 1484 def test_title_should_return_a_h2_tag
1485 1485 assert_equal '<h2>Foo</h2>', title('Foo')
1486 1486 end
1487 1487
1488 1488 def test_title_should_set_html_title
1489 1489 title('Foo')
1490 1490 assert_equal 'Foo - Redmine', html_title
1491 1491 end
1492 1492
1493 1493 def test_title_should_turn_arrays_into_links
1494 1494 assert_equal '<h2><a href="/foo">Foo</a></h2>', title(['Foo', '/foo'])
1495 1495 assert_equal 'Foo - Redmine', html_title
1496 1496 end
1497 1497
1498 1498 def test_title_should_join_items
1499 1499 assert_equal '<h2>Foo &#187; Bar</h2>', title('Foo', 'Bar')
1500 1500 assert_equal 'Bar - Foo - Redmine', html_title
1501 1501 end
1502 1502
1503 1503 def test_favicon_path
1504 1504 assert_match %r{^/favicon\.ico}, favicon_path
1505 1505 end
1506 1506
1507 1507 def test_favicon_path_with_suburi
1508 1508 Redmine::Utils.relative_url_root = '/foo'
1509 1509 assert_match %r{^/foo/favicon\.ico}, favicon_path
1510 1510 ensure
1511 1511 Redmine::Utils.relative_url_root = ''
1512 1512 end
1513 1513
1514 1514 def test_favicon_url
1515 1515 assert_match %r{^http://test\.host/favicon\.ico}, favicon_url
1516 1516 end
1517 1517
1518 1518 def test_favicon_url_with_suburi
1519 1519 Redmine::Utils.relative_url_root = '/foo'
1520 1520 assert_match %r{^http://test\.host/foo/favicon\.ico}, favicon_url
1521 1521 ensure
1522 1522 Redmine::Utils.relative_url_root = ''
1523 1523 end
1524 1524
1525 1525 def test_truncate_single_line
1526 1526 str = "01234"
1527 1527 result = truncate_single_line_raw("#{str}\n#{str}", 10)
1528 1528 assert_equal "01234 0...", result
1529 1529 assert !result.html_safe?
1530 1530 result = truncate_single_line_raw("#{str}<&#>\n#{str}#{str}", 16)
1531 1531 assert_equal "01234<&#> 012...", result
1532 1532 assert !result.html_safe?
1533 1533 end
1534 1534
1535 1535 def test_truncate_single_line_non_ascii
1536 1536 ja = "\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e".force_encoding('UTF-8')
1537 1537 result = truncate_single_line_raw("#{ja}\n#{ja}\n#{ja}", 10)
1538 1538 assert_equal "#{ja} #{ja}...", result
1539 1539 assert !result.html_safe?
1540 1540 end
1541 1541 end
@@ -1,62 +1,77
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../../../../test_helper', __FILE__)
19 19
20 20 class Redmine::FieldFormatTest < ActionView::TestCase
21 21 include ApplicationHelper
22 22
23 def setup
24 set_language_if_valid 'en'
25 end
26
23 27 def test_string_field_with_text_formatting_disabled_should_not_format_text
24 28 field = IssueCustomField.new(:field_format => 'string')
25 29 custom_value = CustomValue.new(:custom_field => field, :customized => Issue.new, :value => "*foo*")
26 30
27 31 assert_equal "*foo*", field.format.formatted_custom_value(self, custom_value, false)
28 32 assert_equal "*foo*", field.format.formatted_custom_value(self, custom_value, true)
29 33 end
30 34
31 35 def test_string_field_with_text_formatting_enabled_should_format_text
32 36 field = IssueCustomField.new(:field_format => 'string', :text_formatting => 'full')
33 37 custom_value = CustomValue.new(:custom_field => field, :customized => Issue.new, :value => "*foo*")
34 38
35 39 assert_equal "*foo*", field.format.formatted_custom_value(self, custom_value, false)
36 40 assert_include "<strong>foo</strong>", field.format.formatted_custom_value(self, custom_value, true)
37 41 end
38 42
39 43 def test_text_field_with_text_formatting_disabled_should_not_format_text
40 44 field = IssueCustomField.new(:field_format => 'text')
41 45 custom_value = CustomValue.new(:custom_field => field, :customized => Issue.new, :value => "*foo*\nbar")
42 46
43 47 assert_equal "*foo*\nbar", field.format.formatted_custom_value(self, custom_value, false)
44 48 assert_include "*foo*\n<br />bar", field.format.formatted_custom_value(self, custom_value, true)
45 49 end
46 50
47 51 def test_text_field_with_text_formatting_enabled_should_format_text
48 52 field = IssueCustomField.new(:field_format => 'text', :text_formatting => 'full')
49 53 custom_value = CustomValue.new(:custom_field => field, :customized => Issue.new, :value => "*foo*\nbar")
50 54
51 55 assert_equal "*foo*\nbar", field.format.formatted_custom_value(self, custom_value, false)
52 56 assert_include "<strong>foo</strong>", field.format.formatted_custom_value(self, custom_value, true)
53 57 end
54 58
59 def test_should_validate_url_pattern_with_safe_scheme
60 field = IssueCustomField.new(:field_format => 'string', :name => 'URL', :url_pattern => 'http://foo/%value%')
61 assert_save field
62 end
63
64 def test_should_not_validate_url_pattern_with_unsafe_scheme
65 field = IssueCustomField.new(:field_format => 'string', :name => 'URL', :url_pattern => 'foo://foo/%value%')
66 assert !field.save
67 assert_include "URL is invalid", field.errors.full_messages
68 end
69
55 70 def test_text_field_with_url_pattern_should_format_as_link
56 71 field = IssueCustomField.new(:field_format => 'string', :url_pattern => 'http://foo/%value%')
57 72 custom_value = CustomValue.new(:custom_field => field, :customized => Issue.new, :value => "bar")
58 73
59 74 assert_equal "bar", field.format.formatted_custom_value(self, custom_value, false)
60 75 assert_equal '<a href="http://foo/bar">bar</a>', field.format.formatted_custom_value(self, custom_value, true)
61 76 end
62 77 end
General Comments 0
You need to be logged in to leave comments. Login now