##// END OF EJS Templates
Adds links to locked users when current user is admin....
Jean-Philippe Lang -
r10462:7729178d9d50
parent child
Show More
@@ -1,1275 +1,1275
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 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
28 28 extend Forwardable
29 29 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
30 30
31 31 # Return true if user is authorized for controller/action, otherwise false
32 32 def authorize_for(controller, action)
33 33 User.current.allowed_to?({:controller => controller, :action => action}, @project)
34 34 end
35 35
36 36 # Display a link if user is authorized
37 37 #
38 38 # @param [String] name Anchor text (passed to link_to)
39 39 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
40 40 # @param [optional, Hash] html_options Options passed to link_to
41 41 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
42 42 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
43 43 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
44 44 end
45 45
46 46 # Displays a link to user's account page if active
47 47 def link_to_user(user, options={})
48 48 if user.is_a?(User)
49 49 name = h(user.name(options[:format]))
50 if user.active?
51 link_to name, :controller => 'users', :action => 'show', :id => user
50 if user.active? || (User.current.admin? && user.logged?)
51 link_to name, {:controller => 'users', :action => 'show', :id => user}, :class => user.css_classes
52 52 else
53 53 name
54 54 end
55 55 else
56 56 h(user.to_s)
57 57 end
58 58 end
59 59
60 60 # Displays a link to +issue+ with its subject.
61 61 # Examples:
62 62 #
63 63 # link_to_issue(issue) # => Defect #6: This is the subject
64 64 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
65 65 # link_to_issue(issue, :subject => false) # => Defect #6
66 66 # link_to_issue(issue, :project => true) # => Foo - Defect #6
67 67 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
68 68 #
69 69 def link_to_issue(issue, options={})
70 70 title = nil
71 71 subject = nil
72 72 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
73 73 if options[:subject] == false
74 74 title = truncate(issue.subject, :length => 60)
75 75 else
76 76 subject = issue.subject
77 77 if options[:truncate]
78 78 subject = truncate(subject, :length => options[:truncate])
79 79 end
80 80 end
81 81 s = link_to text, {:controller => "issues", :action => "show", :id => issue},
82 82 :class => issue.css_classes,
83 83 :title => title
84 84 s << h(": #{subject}") if subject
85 85 s = h("#{issue.project} - ") + s if options[:project]
86 86 s
87 87 end
88 88
89 89 # Generates a link to an attachment.
90 90 # Options:
91 91 # * :text - Link text (default to attachment filename)
92 92 # * :download - Force download (default: false)
93 93 def link_to_attachment(attachment, options={})
94 94 text = options.delete(:text) || attachment.filename
95 95 action = options.delete(:download) ? 'download' : 'show'
96 96 opt_only_path = {}
97 97 opt_only_path[:only_path] = (options[:only_path] == false ? false : true)
98 98 options.delete(:only_path)
99 99 link_to(h(text),
100 100 {:controller => 'attachments', :action => action,
101 101 :id => attachment, :filename => attachment.filename}.merge(opt_only_path),
102 102 options)
103 103 end
104 104
105 105 # Generates a link to a SCM revision
106 106 # Options:
107 107 # * :text - Link text (default to the formatted revision)
108 108 def link_to_revision(revision, repository, options={})
109 109 if repository.is_a?(Project)
110 110 repository = repository.repository
111 111 end
112 112 text = options.delete(:text) || format_revision(revision)
113 113 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
114 114 link_to(
115 115 h(text),
116 116 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
117 117 :title => l(:label_revision_id, format_revision(revision))
118 118 )
119 119 end
120 120
121 121 # Generates a link to a message
122 122 def link_to_message(message, options={}, html_options = nil)
123 123 link_to(
124 124 h(truncate(message.subject, :length => 60)),
125 125 { :controller => 'messages', :action => 'show',
126 126 :board_id => message.board_id,
127 127 :id => (message.parent_id || message.id),
128 128 :r => (message.parent_id && message.id),
129 129 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
130 130 }.merge(options),
131 131 html_options
132 132 )
133 133 end
134 134
135 135 # Generates a link to a project if active
136 136 # Examples:
137 137 #
138 138 # link_to_project(project) # => link to the specified project overview
139 139 # link_to_project(project, :action=>'settings') # => link to project settings
140 140 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
141 141 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
142 142 #
143 143 def link_to_project(project, options={}, html_options = nil)
144 144 if project.archived?
145 145 h(project)
146 146 else
147 147 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
148 148 link_to(h(project), url, html_options)
149 149 end
150 150 end
151 151
152 152 def thumbnail_tag(attachment)
153 153 link_to image_tag(url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment)),
154 154 {:controller => 'attachments', :action => 'show', :id => attachment, :filename => attachment.filename},
155 155 :title => attachment.filename
156 156 end
157 157
158 158 def toggle_link(name, id, options={})
159 159 onclick = "$('##{id}').toggle(); "
160 160 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
161 161 onclick << "return false;"
162 162 link_to(name, "#", :onclick => onclick)
163 163 end
164 164
165 165 def image_to_function(name, function, html_options = {})
166 166 html_options.symbolize_keys!
167 167 tag(:input, html_options.merge({
168 168 :type => "image", :src => image_path(name),
169 169 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
170 170 }))
171 171 end
172 172
173 173 def format_activity_title(text)
174 174 h(truncate_single_line(text, :length => 100))
175 175 end
176 176
177 177 def format_activity_day(date)
178 178 date == User.current.today ? l(:label_today).titleize : format_date(date)
179 179 end
180 180
181 181 def format_activity_description(text)
182 182 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
183 183 ).gsub(/[\r\n]+/, "<br />").html_safe
184 184 end
185 185
186 186 def format_version_name(version)
187 187 if version.project == @project
188 188 h(version)
189 189 else
190 190 h("#{version.project} - #{version}")
191 191 end
192 192 end
193 193
194 194 def due_date_distance_in_words(date)
195 195 if date
196 196 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
197 197 end
198 198 end
199 199
200 200 # Renders a tree of projects as a nested set of unordered lists
201 201 # The given collection may be a subset of the whole project tree
202 202 # (eg. some intermediate nodes are private and can not be seen)
203 203 def render_project_nested_lists(projects)
204 204 s = ''
205 205 if projects.any?
206 206 ancestors = []
207 207 original_project = @project
208 208 projects.sort_by(&:lft).each do |project|
209 209 # set the project environment to please macros.
210 210 @project = project
211 211 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
212 212 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
213 213 else
214 214 ancestors.pop
215 215 s << "</li>"
216 216 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
217 217 ancestors.pop
218 218 s << "</ul></li>\n"
219 219 end
220 220 end
221 221 classes = (ancestors.empty? ? 'root' : 'child')
222 222 s << "<li class='#{classes}'><div class='#{classes}'>"
223 223 s << h(block_given? ? yield(project) : project.name)
224 224 s << "</div>\n"
225 225 ancestors << project
226 226 end
227 227 s << ("</li></ul>\n" * ancestors.size)
228 228 @project = original_project
229 229 end
230 230 s.html_safe
231 231 end
232 232
233 233 def render_page_hierarchy(pages, node=nil, options={})
234 234 content = ''
235 235 if pages[node]
236 236 content << "<ul class=\"pages-hierarchy\">\n"
237 237 pages[node].each do |page|
238 238 content << "<li>"
239 239 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title},
240 240 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
241 241 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
242 242 content << "</li>\n"
243 243 end
244 244 content << "</ul>\n"
245 245 end
246 246 content.html_safe
247 247 end
248 248
249 249 # Renders flash messages
250 250 def render_flash_messages
251 251 s = ''
252 252 flash.each do |k,v|
253 253 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
254 254 end
255 255 s.html_safe
256 256 end
257 257
258 258 # Renders tabs and their content
259 259 def render_tabs(tabs)
260 260 if tabs.any?
261 261 render :partial => 'common/tabs', :locals => {:tabs => tabs}
262 262 else
263 263 content_tag 'p', l(:label_no_data), :class => "nodata"
264 264 end
265 265 end
266 266
267 267 # Renders the project quick-jump box
268 268 def render_project_jump_box
269 269 return unless User.current.logged?
270 270 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
271 271 if projects.any?
272 272 options =
273 273 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
274 274 '<option value="" disabled="disabled">---</option>').html_safe
275 275
276 276 options << project_tree_options_for_select(projects, :selected => @project) do |p|
277 277 { :value => project_path(:id => p, :jump => current_menu_item) }
278 278 end
279 279
280 280 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
281 281 end
282 282 end
283 283
284 284 def project_tree_options_for_select(projects, options = {})
285 285 s = ''
286 286 project_tree(projects) do |project, level|
287 287 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
288 288 tag_options = {:value => project.id}
289 289 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
290 290 tag_options[:selected] = 'selected'
291 291 else
292 292 tag_options[:selected] = nil
293 293 end
294 294 tag_options.merge!(yield(project)) if block_given?
295 295 s << content_tag('option', name_prefix + h(project), tag_options)
296 296 end
297 297 s.html_safe
298 298 end
299 299
300 300 # Yields the given block for each project with its level in the tree
301 301 #
302 302 # Wrapper for Project#project_tree
303 303 def project_tree(projects, &block)
304 304 Project.project_tree(projects, &block)
305 305 end
306 306
307 307 def principals_check_box_tags(name, principals)
308 308 s = ''
309 309 principals.sort.each do |principal|
310 310 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
311 311 end
312 312 s.html_safe
313 313 end
314 314
315 315 # Returns a string for users/groups option tags
316 316 def principals_options_for_select(collection, selected=nil)
317 317 s = ''
318 318 if collection.include?(User.current)
319 319 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
320 320 end
321 321 groups = ''
322 322 collection.sort.each do |element|
323 323 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected)
324 324 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
325 325 end
326 326 unless groups.empty?
327 327 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
328 328 end
329 329 s.html_safe
330 330 end
331 331
332 332 # Truncates and returns the string as a single line
333 333 def truncate_single_line(string, *args)
334 334 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
335 335 end
336 336
337 337 # Truncates at line break after 250 characters or options[:length]
338 338 def truncate_lines(string, options={})
339 339 length = options[:length] || 250
340 340 if string.to_s =~ /\A(.{#{length}}.*?)$/m
341 341 "#{$1}..."
342 342 else
343 343 string
344 344 end
345 345 end
346 346
347 347 def anchor(text)
348 348 text.to_s.gsub(' ', '_')
349 349 end
350 350
351 351 def html_hours(text)
352 352 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
353 353 end
354 354
355 355 def authoring(created, author, options={})
356 356 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
357 357 end
358 358
359 359 def time_tag(time)
360 360 text = distance_of_time_in_words(Time.now, time)
361 361 if @project
362 362 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
363 363 else
364 364 content_tag('acronym', text, :title => format_time(time))
365 365 end
366 366 end
367 367
368 368 def syntax_highlight_lines(name, content)
369 369 lines = []
370 370 syntax_highlight(name, content).each_line { |line| lines << line }
371 371 lines
372 372 end
373 373
374 374 def syntax_highlight(name, content)
375 375 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
376 376 end
377 377
378 378 def to_path_param(path)
379 379 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
380 380 str.blank? ? nil : str
381 381 end
382 382
383 383 def pagination_links_full(paginator, count=nil, options={})
384 384 page_param = options.delete(:page_param) || :page
385 385 per_page_links = options.delete(:per_page_links)
386 386 url_param = params.dup
387 387
388 388 html = ''
389 389 if paginator.current.previous
390 390 # \xc2\xab(utf-8) = &#171;
391 391 html << link_to_content_update(
392 392 "\xc2\xab " + l(:label_previous),
393 393 url_param.merge(page_param => paginator.current.previous)) + ' '
394 394 end
395 395
396 396 html << (pagination_links_each(paginator, options) do |n|
397 397 link_to_content_update(n.to_s, url_param.merge(page_param => n))
398 398 end || '')
399 399
400 400 if paginator.current.next
401 401 # \xc2\xbb(utf-8) = &#187;
402 402 html << ' ' + link_to_content_update(
403 403 (l(:label_next) + " \xc2\xbb"),
404 404 url_param.merge(page_param => paginator.current.next))
405 405 end
406 406
407 407 unless count.nil?
408 408 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
409 409 if per_page_links != false && links = per_page_links(paginator.items_per_page, count)
410 410 html << " | #{links}"
411 411 end
412 412 end
413 413
414 414 html.html_safe
415 415 end
416 416
417 417 def per_page_links(selected=nil, item_count=nil)
418 418 values = Setting.per_page_options_array
419 419 if item_count && values.any?
420 420 if item_count > values.first
421 421 max = values.detect {|value| value >= item_count} || item_count
422 422 else
423 423 max = item_count
424 424 end
425 425 values = values.select {|value| value <= max || value == selected}
426 426 end
427 427 if values.empty? || (values.size == 1 && values.first == selected)
428 428 return nil
429 429 end
430 430 links = values.collect do |n|
431 431 n == selected ? n : link_to_content_update(n, params.merge(:per_page => n))
432 432 end
433 433 l(:label_display_per_page, links.join(', '))
434 434 end
435 435
436 436 def reorder_links(name, url, method = :post)
437 437 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
438 438 url.merge({"#{name}[move_to]" => 'highest'}),
439 439 :method => method, :title => l(:label_sort_highest)) +
440 440 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
441 441 url.merge({"#{name}[move_to]" => 'higher'}),
442 442 :method => method, :title => l(:label_sort_higher)) +
443 443 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
444 444 url.merge({"#{name}[move_to]" => 'lower'}),
445 445 :method => method, :title => l(:label_sort_lower)) +
446 446 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
447 447 url.merge({"#{name}[move_to]" => 'lowest'}),
448 448 :method => method, :title => l(:label_sort_lowest))
449 449 end
450 450
451 451 def breadcrumb(*args)
452 452 elements = args.flatten
453 453 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
454 454 end
455 455
456 456 def other_formats_links(&block)
457 457 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
458 458 yield Redmine::Views::OtherFormatsBuilder.new(self)
459 459 concat('</p>'.html_safe)
460 460 end
461 461
462 462 def page_header_title
463 463 if @project.nil? || @project.new_record?
464 464 h(Setting.app_title)
465 465 else
466 466 b = []
467 467 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
468 468 if ancestors.any?
469 469 root = ancestors.shift
470 470 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
471 471 if ancestors.size > 2
472 472 b << "\xe2\x80\xa6"
473 473 ancestors = ancestors[-2, 2]
474 474 end
475 475 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
476 476 end
477 477 b << h(@project)
478 478 b.join(" \xc2\xbb ").html_safe
479 479 end
480 480 end
481 481
482 482 def html_title(*args)
483 483 if args.empty?
484 484 title = @html_title || []
485 485 title << @project.name if @project
486 486 title << Setting.app_title unless Setting.app_title == title.last
487 487 title.select {|t| !t.blank? }.join(' - ')
488 488 else
489 489 @html_title ||= []
490 490 @html_title += args
491 491 end
492 492 end
493 493
494 494 # Returns the theme, controller name, and action as css classes for the
495 495 # HTML body.
496 496 def body_css_classes
497 497 css = []
498 498 if theme = Redmine::Themes.theme(Setting.ui_theme)
499 499 css << 'theme-' + theme.name
500 500 end
501 501
502 502 css << 'controller-' + controller_name
503 503 css << 'action-' + action_name
504 504 css.join(' ')
505 505 end
506 506
507 507 def accesskey(s)
508 508 Redmine::AccessKeys.key_for s
509 509 end
510 510
511 511 # Formats text according to system settings.
512 512 # 2 ways to call this method:
513 513 # * with a String: textilizable(text, options)
514 514 # * with an object and one of its attribute: textilizable(issue, :description, options)
515 515 def textilizable(*args)
516 516 options = args.last.is_a?(Hash) ? args.pop : {}
517 517 case args.size
518 518 when 1
519 519 obj = options[:object]
520 520 text = args.shift
521 521 when 2
522 522 obj = args.shift
523 523 attr = args.shift
524 524 text = obj.send(attr).to_s
525 525 else
526 526 raise ArgumentError, 'invalid arguments to textilizable'
527 527 end
528 528 return '' if text.blank?
529 529 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
530 530 only_path = options.delete(:only_path) == false ? false : true
531 531
532 532 text = text.dup
533 533 macros = catch_macros(text)
534 534 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
535 535
536 536 @parsed_headings = []
537 537 @heading_anchors = {}
538 538 @current_section = 0 if options[:edit_section_links]
539 539
540 540 parse_sections(text, project, obj, attr, only_path, options)
541 541 text = parse_non_pre_blocks(text, obj, macros) do |text|
542 542 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
543 543 send method_name, text, project, obj, attr, only_path, options
544 544 end
545 545 end
546 546 parse_headings(text, project, obj, attr, only_path, options)
547 547
548 548 if @parsed_headings.any?
549 549 replace_toc(text, @parsed_headings)
550 550 end
551 551
552 552 text.html_safe
553 553 end
554 554
555 555 def parse_non_pre_blocks(text, obj, macros)
556 556 s = StringScanner.new(text)
557 557 tags = []
558 558 parsed = ''
559 559 while !s.eos?
560 560 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
561 561 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
562 562 if tags.empty?
563 563 yield text
564 564 inject_macros(text, obj, macros) if macros.any?
565 565 else
566 566 inject_macros(text, obj, macros, false) if macros.any?
567 567 end
568 568 parsed << text
569 569 if tag
570 570 if closing
571 571 if tags.last == tag.downcase
572 572 tags.pop
573 573 end
574 574 else
575 575 tags << tag.downcase
576 576 end
577 577 parsed << full_tag
578 578 end
579 579 end
580 580 # Close any non closing tags
581 581 while tag = tags.pop
582 582 parsed << "</#{tag}>"
583 583 end
584 584 parsed
585 585 end
586 586
587 587 def parse_inline_attachments(text, project, obj, attr, only_path, options)
588 588 # when using an image link, try to use an attachment, if possible
589 589 if options[:attachments] || (obj && obj.respond_to?(:attachments))
590 590 attachments = options[:attachments] || obj.attachments
591 591 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
592 592 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
593 593 # search for the picture in attachments
594 594 if found = Attachment.latest_attach(attachments, filename)
595 595 image_url = url_for :only_path => only_path, :controller => 'attachments',
596 596 :action => 'download', :id => found
597 597 desc = found.description.to_s.gsub('"', '')
598 598 if !desc.blank? && alttext.blank?
599 599 alt = " title=\"#{desc}\" alt=\"#{desc}\""
600 600 end
601 601 "src=\"#{image_url}\"#{alt}"
602 602 else
603 603 m
604 604 end
605 605 end
606 606 end
607 607 end
608 608
609 609 # Wiki links
610 610 #
611 611 # Examples:
612 612 # [[mypage]]
613 613 # [[mypage|mytext]]
614 614 # wiki links can refer other project wikis, using project name or identifier:
615 615 # [[project:]] -> wiki starting page
616 616 # [[project:|mytext]]
617 617 # [[project:mypage]]
618 618 # [[project:mypage|mytext]]
619 619 def parse_wiki_links(text, project, obj, attr, only_path, options)
620 620 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
621 621 link_project = project
622 622 esc, all, page, title = $1, $2, $3, $5
623 623 if esc.nil?
624 624 if page =~ /^([^\:]+)\:(.*)$/
625 625 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
626 626 page = $2
627 627 title ||= $1 if page.blank?
628 628 end
629 629
630 630 if link_project && link_project.wiki
631 631 # extract anchor
632 632 anchor = nil
633 633 if page =~ /^(.+?)\#(.+)$/
634 634 page, anchor = $1, $2
635 635 end
636 636 anchor = sanitize_anchor_name(anchor) if anchor.present?
637 637 # check if page exists
638 638 wiki_page = link_project.wiki.find_page(page)
639 639 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
640 640 "##{anchor}"
641 641 else
642 642 case options[:wiki_links]
643 643 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
644 644 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
645 645 else
646 646 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
647 647 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
648 648 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
649 649 :id => wiki_page_id, :anchor => anchor, :parent => parent)
650 650 end
651 651 end
652 652 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
653 653 else
654 654 # project or wiki doesn't exist
655 655 all
656 656 end
657 657 else
658 658 all
659 659 end
660 660 end
661 661 end
662 662
663 663 # Redmine links
664 664 #
665 665 # Examples:
666 666 # Issues:
667 667 # #52 -> Link to issue #52
668 668 # Changesets:
669 669 # r52 -> Link to revision 52
670 670 # commit:a85130f -> Link to scmid starting with a85130f
671 671 # Documents:
672 672 # document#17 -> Link to document with id 17
673 673 # document:Greetings -> Link to the document with title "Greetings"
674 674 # document:"Some document" -> Link to the document with title "Some document"
675 675 # Versions:
676 676 # version#3 -> Link to version with id 3
677 677 # version:1.0.0 -> Link to version named "1.0.0"
678 678 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
679 679 # Attachments:
680 680 # attachment:file.zip -> Link to the attachment of the current object named file.zip
681 681 # Source files:
682 682 # source:some/file -> Link to the file located at /some/file in the project's repository
683 683 # source:some/file@52 -> Link to the file's revision 52
684 684 # source:some/file#L120 -> Link to line 120 of the file
685 685 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
686 686 # export:some/file -> Force the download of the file
687 687 # Forum messages:
688 688 # message#1218 -> Link to message with id 1218
689 689 #
690 690 # Links can refer other objects from other projects, using project identifier:
691 691 # identifier:r52
692 692 # identifier:document:"Some document"
693 693 # identifier:version:1.0.0
694 694 # identifier:source:some/file
695 695 def parse_redmine_links(text, project, obj, attr, only_path, options)
696 696 text.gsub!(%r{([\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|
697 697 leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
698 698 link = nil
699 699 if project_identifier
700 700 project = Project.visible.find_by_identifier(project_identifier)
701 701 end
702 702 if esc.nil?
703 703 if prefix.nil? && sep == 'r'
704 704 if project
705 705 repository = nil
706 706 if repo_identifier
707 707 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
708 708 else
709 709 repository = project.repository
710 710 end
711 711 # project.changesets.visible raises an SQL error because of a double join on repositories
712 712 if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
713 713 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
714 714 :class => 'changeset',
715 715 :title => truncate_single_line(changeset.comments, :length => 100))
716 716 end
717 717 end
718 718 elsif sep == '#'
719 719 oid = identifier.to_i
720 720 case prefix
721 721 when nil
722 722 if oid.to_s == identifier && issue = Issue.visible.find_by_id(oid, :include => :status)
723 723 anchor = comment_id ? "note-#{comment_id}" : nil
724 724 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid, :anchor => anchor},
725 725 :class => issue.css_classes,
726 726 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
727 727 end
728 728 when 'document'
729 729 if document = Document.visible.find_by_id(oid)
730 730 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
731 731 :class => 'document'
732 732 end
733 733 when 'version'
734 734 if version = Version.visible.find_by_id(oid)
735 735 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
736 736 :class => 'version'
737 737 end
738 738 when 'message'
739 739 if message = Message.visible.find_by_id(oid, :include => :parent)
740 740 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
741 741 end
742 742 when 'forum'
743 743 if board = Board.visible.find_by_id(oid)
744 744 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
745 745 :class => 'board'
746 746 end
747 747 when 'news'
748 748 if news = News.visible.find_by_id(oid)
749 749 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
750 750 :class => 'news'
751 751 end
752 752 when 'project'
753 753 if p = Project.visible.find_by_id(oid)
754 754 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
755 755 end
756 756 end
757 757 elsif sep == ':'
758 758 # removes the double quotes if any
759 759 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
760 760 case prefix
761 761 when 'document'
762 762 if project && document = project.documents.visible.find_by_title(name)
763 763 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
764 764 :class => 'document'
765 765 end
766 766 when 'version'
767 767 if project && version = project.versions.visible.find_by_name(name)
768 768 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
769 769 :class => 'version'
770 770 end
771 771 when 'forum'
772 772 if project && board = project.boards.visible.find_by_name(name)
773 773 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
774 774 :class => 'board'
775 775 end
776 776 when 'news'
777 777 if project && news = project.news.visible.find_by_title(name)
778 778 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
779 779 :class => 'news'
780 780 end
781 781 when 'commit', 'source', 'export'
782 782 if project
783 783 repository = nil
784 784 if name =~ %r{^(([a-z0-9\-]+)\|)(.+)$}
785 785 repo_prefix, repo_identifier, name = $1, $2, $3
786 786 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
787 787 else
788 788 repository = project.repository
789 789 end
790 790 if prefix == 'commit'
791 791 if repository && (changeset = Changeset.visible.find(:first, :conditions => ["repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%"]))
792 792 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},
793 793 :class => 'changeset',
794 794 :title => truncate_single_line(h(changeset.comments), :length => 100)
795 795 end
796 796 else
797 797 if repository && User.current.allowed_to?(:browse_repository, project)
798 798 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
799 799 path, rev, anchor = $1, $3, $5
800 800 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
801 801 :path => to_path_param(path),
802 802 :rev => rev,
803 803 :anchor => anchor},
804 804 :class => (prefix == 'export' ? 'source download' : 'source')
805 805 end
806 806 end
807 807 repo_prefix = nil
808 808 end
809 809 when 'attachment'
810 810 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
811 811 if attachments && attachment = attachments.detect {|a| a.filename == name }
812 812 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
813 813 :class => 'attachment'
814 814 end
815 815 when 'project'
816 816 if p = Project.visible.find(:first, :conditions => ["identifier = :s OR LOWER(name) = :s", {:s => name.downcase}])
817 817 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
818 818 end
819 819 end
820 820 end
821 821 end
822 822 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
823 823 end
824 824 end
825 825
826 826 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
827 827
828 828 def parse_sections(text, project, obj, attr, only_path, options)
829 829 return unless options[:edit_section_links]
830 830 text.gsub!(HEADING_RE) do
831 831 heading = $1
832 832 @current_section += 1
833 833 if @current_section > 1
834 834 content_tag('div',
835 835 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
836 836 :class => 'contextual',
837 837 :title => l(:button_edit_section)) + heading.html_safe
838 838 else
839 839 heading
840 840 end
841 841 end
842 842 end
843 843
844 844 # Headings and TOC
845 845 # Adds ids and links to headings unless options[:headings] is set to false
846 846 def parse_headings(text, project, obj, attr, only_path, options)
847 847 return if options[:headings] == false
848 848
849 849 text.gsub!(HEADING_RE) do
850 850 level, attrs, content = $2.to_i, $3, $4
851 851 item = strip_tags(content).strip
852 852 anchor = sanitize_anchor_name(item)
853 853 # used for single-file wiki export
854 854 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
855 855 @heading_anchors[anchor] ||= 0
856 856 idx = (@heading_anchors[anchor] += 1)
857 857 if idx > 1
858 858 anchor = "#{anchor}-#{idx}"
859 859 end
860 860 @parsed_headings << [level, anchor, item]
861 861 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
862 862 end
863 863 end
864 864
865 865 MACROS_RE = /(
866 866 (!)? # escaping
867 867 (
868 868 \{\{ # opening tag
869 869 ([\w]+) # macro name
870 870 (\(([^\n\r]*?)\))? # optional arguments
871 871 ([\n\r].*?[\n\r])? # optional block of text
872 872 \}\} # closing tag
873 873 )
874 874 )/mx unless const_defined?(:MACROS_RE)
875 875
876 876 MACRO_SUB_RE = /(
877 877 \{\{
878 878 macro\((\d+)\)
879 879 \}\}
880 880 )/x unless const_defined?(:MACRO_SUB_RE)
881 881
882 882 # Extracts macros from text
883 883 def catch_macros(text)
884 884 macros = {}
885 885 text.gsub!(MACROS_RE) do
886 886 all, macro = $1, $4.downcase
887 887 if macro_exists?(macro) || all =~ MACRO_SUB_RE
888 888 index = macros.size
889 889 macros[index] = all
890 890 "{{macro(#{index})}}"
891 891 else
892 892 all
893 893 end
894 894 end
895 895 macros
896 896 end
897 897
898 898 # Executes and replaces macros in text
899 899 def inject_macros(text, obj, macros, execute=true)
900 900 text.gsub!(MACRO_SUB_RE) do
901 901 all, index = $1, $2.to_i
902 902 orig = macros.delete(index)
903 903 if execute && orig && orig =~ MACROS_RE
904 904 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
905 905 if esc.nil?
906 906 h(exec_macro(macro, obj, args, block) || all)
907 907 else
908 908 h(all)
909 909 end
910 910 elsif orig
911 911 h(orig)
912 912 else
913 913 h(all)
914 914 end
915 915 end
916 916 end
917 917
918 918 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
919 919
920 920 # Renders the TOC with given headings
921 921 def replace_toc(text, headings)
922 922 text.gsub!(TOC_RE) do
923 923 # Keep only the 4 first levels
924 924 headings = headings.select{|level, anchor, item| level <= 4}
925 925 if headings.empty?
926 926 ''
927 927 else
928 928 div_class = 'toc'
929 929 div_class << ' right' if $1 == '>'
930 930 div_class << ' left' if $1 == '<'
931 931 out = "<ul class=\"#{div_class}\"><li>"
932 932 root = headings.map(&:first).min
933 933 current = root
934 934 started = false
935 935 headings.each do |level, anchor, item|
936 936 if level > current
937 937 out << '<ul><li>' * (level - current)
938 938 elsif level < current
939 939 out << "</li></ul>\n" * (current - level) + "</li><li>"
940 940 elsif started
941 941 out << '</li><li>'
942 942 end
943 943 out << "<a href=\"##{anchor}\">#{item}</a>"
944 944 current = level
945 945 started = true
946 946 end
947 947 out << '</li></ul>' * (current - root)
948 948 out << '</li></ul>'
949 949 end
950 950 end
951 951 end
952 952
953 953 # Same as Rails' simple_format helper without using paragraphs
954 954 def simple_format_without_paragraph(text)
955 955 text.to_s.
956 956 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
957 957 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
958 958 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
959 959 html_safe
960 960 end
961 961
962 962 def lang_options_for_select(blank=true)
963 963 (blank ? [["(auto)", ""]] : []) +
964 964 valid_languages.collect{|lang| [ ll(lang.to_s, :general_lang_name), lang.to_s]}.sort{|x,y| x.last <=> y.last }
965 965 end
966 966
967 967 def label_tag_for(name, option_tags = nil, options = {})
968 968 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
969 969 content_tag("label", label_text)
970 970 end
971 971
972 972 def labelled_form_for(*args, &proc)
973 973 args << {} unless args.last.is_a?(Hash)
974 974 options = args.last
975 975 if args.first.is_a?(Symbol)
976 976 options.merge!(:as => args.shift)
977 977 end
978 978 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
979 979 form_for(*args, &proc)
980 980 end
981 981
982 982 def labelled_fields_for(*args, &proc)
983 983 args << {} unless args.last.is_a?(Hash)
984 984 options = args.last
985 985 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
986 986 fields_for(*args, &proc)
987 987 end
988 988
989 989 def labelled_remote_form_for(*args, &proc)
990 990 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
991 991 args << {} unless args.last.is_a?(Hash)
992 992 options = args.last
993 993 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
994 994 form_for(*args, &proc)
995 995 end
996 996
997 997 def error_messages_for(*objects)
998 998 html = ""
999 999 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1000 1000 errors = objects.map {|o| o.errors.full_messages}.flatten
1001 1001 if errors.any?
1002 1002 html << "<div id='errorExplanation'><ul>\n"
1003 1003 errors.each do |error|
1004 1004 html << "<li>#{h error}</li>\n"
1005 1005 end
1006 1006 html << "</ul></div>\n"
1007 1007 end
1008 1008 html.html_safe
1009 1009 end
1010 1010
1011 1011 def delete_link(url, options={})
1012 1012 options = {
1013 1013 :method => :delete,
1014 1014 :data => {:confirm => l(:text_are_you_sure)},
1015 1015 :class => 'icon icon-del'
1016 1016 }.merge(options)
1017 1017
1018 1018 link_to l(:button_delete), url, options
1019 1019 end
1020 1020
1021 1021 def preview_link(url, form, target='preview', options={})
1022 1022 content_tag 'a', l(:label_preview), {
1023 1023 :href => "#",
1024 1024 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1025 1025 :accesskey => accesskey(:preview)
1026 1026 }.merge(options)
1027 1027 end
1028 1028
1029 1029 def link_to_function(name, function, html_options={})
1030 1030 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1031 1031 end
1032 1032
1033 1033 # Helper to render JSON in views
1034 1034 def raw_json(arg)
1035 1035 arg.to_json.to_s.gsub('/', '\/').html_safe
1036 1036 end
1037 1037
1038 1038 def back_url
1039 1039 url = params[:back_url]
1040 1040 if url.nil? && referer = request.env['HTTP_REFERER']
1041 1041 url = CGI.unescape(referer.to_s)
1042 1042 end
1043 1043 url
1044 1044 end
1045 1045
1046 1046 def back_url_hidden_field_tag
1047 1047 url = back_url
1048 1048 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1049 1049 end
1050 1050
1051 1051 def check_all_links(form_name)
1052 1052 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1053 1053 " | ".html_safe +
1054 1054 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1055 1055 end
1056 1056
1057 1057 def progress_bar(pcts, options={})
1058 1058 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1059 1059 pcts = pcts.collect(&:round)
1060 1060 pcts[1] = pcts[1] - pcts[0]
1061 1061 pcts << (100 - pcts[1] - pcts[0])
1062 1062 width = options[:width] || '100px;'
1063 1063 legend = options[:legend] || ''
1064 1064 content_tag('table',
1065 1065 content_tag('tr',
1066 1066 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1067 1067 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1068 1068 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1069 1069 ), :class => 'progress', :style => "width: #{width};").html_safe +
1070 1070 content_tag('p', legend, :class => 'pourcent').html_safe
1071 1071 end
1072 1072
1073 1073 def checked_image(checked=true)
1074 1074 if checked
1075 1075 image_tag 'toggle_check.png'
1076 1076 end
1077 1077 end
1078 1078
1079 1079 def context_menu(url)
1080 1080 unless @context_menu_included
1081 1081 content_for :header_tags do
1082 1082 javascript_include_tag('context_menu') +
1083 1083 stylesheet_link_tag('context_menu')
1084 1084 end
1085 1085 if l(:direction) == 'rtl'
1086 1086 content_for :header_tags do
1087 1087 stylesheet_link_tag('context_menu_rtl')
1088 1088 end
1089 1089 end
1090 1090 @context_menu_included = true
1091 1091 end
1092 1092 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1093 1093 end
1094 1094
1095 1095 def calendar_for(field_id)
1096 1096 include_calendar_headers_tags
1097 1097 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1098 1098 end
1099 1099
1100 1100 def include_calendar_headers_tags
1101 1101 unless @calendar_headers_tags_included
1102 1102 @calendar_headers_tags_included = true
1103 1103 content_for :header_tags do
1104 1104 start_of_week = Setting.start_of_week
1105 1105 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1106 1106 # Redmine uses 1..7 (monday..sunday) in settings and locales
1107 1107 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1108 1108 start_of_week = start_of_week.to_i % 7
1109 1109
1110 1110 tags = javascript_tag(
1111 1111 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1112 1112 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1113 1113 path_to_image('/images/calendar.png') +
1114 1114 "', showButtonPanel: true};")
1115 1115 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1116 1116 unless jquery_locale == 'en'
1117 1117 tags << javascript_include_tag("i18n/jquery.ui.datepicker-#{jquery_locale}.js")
1118 1118 end
1119 1119 tags
1120 1120 end
1121 1121 end
1122 1122 end
1123 1123
1124 1124 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1125 1125 # Examples:
1126 1126 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1127 1127 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1128 1128 #
1129 1129 def stylesheet_link_tag(*sources)
1130 1130 options = sources.last.is_a?(Hash) ? sources.pop : {}
1131 1131 plugin = options.delete(:plugin)
1132 1132 sources = sources.map do |source|
1133 1133 if plugin
1134 1134 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1135 1135 elsif current_theme && current_theme.stylesheets.include?(source)
1136 1136 current_theme.stylesheet_path(source)
1137 1137 else
1138 1138 source
1139 1139 end
1140 1140 end
1141 1141 super sources, options
1142 1142 end
1143 1143
1144 1144 # Overrides Rails' image_tag with themes and plugins support.
1145 1145 # Examples:
1146 1146 # image_tag('image.png') # => picks image.png from the current theme or defaults
1147 1147 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1148 1148 #
1149 1149 def image_tag(source, options={})
1150 1150 if plugin = options.delete(:plugin)
1151 1151 source = "/plugin_assets/#{plugin}/images/#{source}"
1152 1152 elsif current_theme && current_theme.images.include?(source)
1153 1153 source = current_theme.image_path(source)
1154 1154 end
1155 1155 super source, options
1156 1156 end
1157 1157
1158 1158 # Overrides Rails' javascript_include_tag with plugins support
1159 1159 # Examples:
1160 1160 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1161 1161 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1162 1162 #
1163 1163 def javascript_include_tag(*sources)
1164 1164 options = sources.last.is_a?(Hash) ? sources.pop : {}
1165 1165 if plugin = options.delete(:plugin)
1166 1166 sources = sources.map do |source|
1167 1167 if plugin
1168 1168 "/plugin_assets/#{plugin}/javascripts/#{source}"
1169 1169 else
1170 1170 source
1171 1171 end
1172 1172 end
1173 1173 end
1174 1174 super sources, options
1175 1175 end
1176 1176
1177 1177 def content_for(name, content = nil, &block)
1178 1178 @has_content ||= {}
1179 1179 @has_content[name] = true
1180 1180 super(name, content, &block)
1181 1181 end
1182 1182
1183 1183 def has_content?(name)
1184 1184 (@has_content && @has_content[name]) || false
1185 1185 end
1186 1186
1187 1187 def sidebar_content?
1188 1188 has_content?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1189 1189 end
1190 1190
1191 1191 def view_layouts_base_sidebar_hook_response
1192 1192 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1193 1193 end
1194 1194
1195 1195 def email_delivery_enabled?
1196 1196 !!ActionMailer::Base.perform_deliveries
1197 1197 end
1198 1198
1199 1199 # Returns the avatar image tag for the given +user+ if avatars are enabled
1200 1200 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1201 1201 def avatar(user, options = { })
1202 1202 if Setting.gravatar_enabled?
1203 1203 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1204 1204 email = nil
1205 1205 if user.respond_to?(:mail)
1206 1206 email = user.mail
1207 1207 elsif user.to_s =~ %r{<(.+?)>}
1208 1208 email = $1
1209 1209 end
1210 1210 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1211 1211 else
1212 1212 ''
1213 1213 end
1214 1214 end
1215 1215
1216 1216 def sanitize_anchor_name(anchor)
1217 1217 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1218 1218 anchor.gsub(%r{[^\p{Word}\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1219 1219 else
1220 1220 # TODO: remove when ruby1.8 is no longer supported
1221 1221 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1222 1222 end
1223 1223 end
1224 1224
1225 1225 # Returns the javascript tags that are included in the html layout head
1226 1226 def javascript_heads
1227 1227 tags = javascript_include_tag('jquery-1.7.2-ui-1.8.21-ujs-2.0.3', 'application')
1228 1228 unless User.current.pref.warn_on_leaving_unsaved == '0'
1229 1229 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1230 1230 end
1231 1231 tags
1232 1232 end
1233 1233
1234 1234 def favicon
1235 1235 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1236 1236 end
1237 1237
1238 1238 def robot_exclusion_tag
1239 1239 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1240 1240 end
1241 1241
1242 1242 # Returns true if arg is expected in the API response
1243 1243 def include_in_api_response?(arg)
1244 1244 unless @included_in_api_response
1245 1245 param = params[:include]
1246 1246 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1247 1247 @included_in_api_response.collect!(&:strip)
1248 1248 end
1249 1249 @included_in_api_response.include?(arg.to_s)
1250 1250 end
1251 1251
1252 1252 # Returns options or nil if nometa param or X-Redmine-Nometa header
1253 1253 # was set in the request
1254 1254 def api_meta(options)
1255 1255 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1256 1256 # compatibility mode for activeresource clients that raise
1257 1257 # an error when unserializing an array with attributes
1258 1258 nil
1259 1259 else
1260 1260 options
1261 1261 end
1262 1262 end
1263 1263
1264 1264 private
1265 1265
1266 1266 def wiki_helper
1267 1267 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1268 1268 extend helper
1269 1269 return self
1270 1270 end
1271 1271
1272 1272 def link_to_content_update(text, url_params = {}, html_options = {})
1273 1273 link_to(text, url_params, html_options)
1274 1274 end
1275 1275 end
@@ -1,696 +1,707
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 "digest/sha1"
19 19
20 20 class User < Principal
21 21 include Redmine::SafeAttributes
22 22
23 23 # Account statuses
24 24 STATUS_ANONYMOUS = 0
25 25 STATUS_ACTIVE = 1
26 26 STATUS_REGISTERED = 2
27 27 STATUS_LOCKED = 3
28 28
29 29 # Different ways of displaying/sorting users
30 30 USER_FORMATS = {
31 31 :firstname_lastname => {
32 32 :string => '#{firstname} #{lastname}',
33 33 :order => %w(firstname lastname id),
34 34 :setting_order => 1
35 35 },
36 36 :firstname => {
37 37 :string => '#{firstname}',
38 38 :order => %w(firstname id),
39 39 :setting_order => 2
40 40 },
41 41 :lastname_firstname => {
42 42 :string => '#{lastname} #{firstname}',
43 43 :order => %w(lastname firstname id),
44 44 :setting_order => 3
45 45 },
46 46 :lastname_coma_firstname => {
47 47 :string => '#{lastname}, #{firstname}',
48 48 :order => %w(lastname firstname id),
49 49 :setting_order => 4
50 50 },
51 51 :lastname => {
52 52 :string => '#{lastname}',
53 53 :order => %w(lastname id),
54 54 :setting_order => 5
55 55 },
56 56 :username => {
57 57 :string => '#{login}',
58 58 :order => %w(login id),
59 59 :setting_order => 6
60 60 },
61 61 }
62 62
63 63 MAIL_NOTIFICATION_OPTIONS = [
64 64 ['all', :label_user_mail_option_all],
65 65 ['selected', :label_user_mail_option_selected],
66 66 ['only_my_events', :label_user_mail_option_only_my_events],
67 67 ['only_assigned', :label_user_mail_option_only_assigned],
68 68 ['only_owner', :label_user_mail_option_only_owner],
69 69 ['none', :label_user_mail_option_none]
70 70 ]
71 71
72 72 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
73 73 :after_remove => Proc.new {|user, group| group.user_removed(user)}
74 74 has_many :changesets, :dependent => :nullify
75 75 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
76 76 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
77 77 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
78 78 belongs_to :auth_source
79 79
80 80 scope :logged, :conditions => "#{User.table_name}.status <> #{STATUS_ANONYMOUS}"
81 81 scope :status, lambda {|arg| arg.blank? ? {} : {:conditions => {:status => arg.to_i}} }
82 82
83 83 acts_as_customizable
84 84
85 85 attr_accessor :password, :password_confirmation
86 86 attr_accessor :last_before_login_on
87 87 # Prevents unauthorized assignments
88 88 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
89 89
90 90 LOGIN_LENGTH_LIMIT = 60
91 91 MAIL_LENGTH_LIMIT = 60
92 92
93 93 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
94 94 validates_uniqueness_of :login, :if => Proc.new { |user| user.login_changed? && user.login.present? }, :case_sensitive => false
95 95 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
96 96 # Login must contain lettres, numbers, underscores only
97 97 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
98 98 validates_length_of :login, :maximum => LOGIN_LENGTH_LIMIT
99 99 validates_length_of :firstname, :lastname, :maximum => 30
100 100 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_blank => true
101 101 validates_length_of :mail, :maximum => MAIL_LENGTH_LIMIT, :allow_nil => true
102 102 validates_confirmation_of :password, :allow_nil => true
103 103 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
104 104 validate :validate_password_length
105 105
106 106 before_create :set_mail_notification
107 107 before_save :update_hashed_password
108 108 before_destroy :remove_references_before_destroy
109 109
110 110 scope :in_group, lambda {|group|
111 111 group_id = group.is_a?(Group) ? group.id : group.to_i
112 112 { :conditions => ["#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
113 113 }
114 114 scope :not_in_group, lambda {|group|
115 115 group_id = group.is_a?(Group) ? group.id : group.to_i
116 116 { :conditions => ["#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
117 117 }
118 118
119 119 def set_mail_notification
120 120 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
121 121 true
122 122 end
123 123
124 124 def update_hashed_password
125 125 # update hashed_password if password was set
126 126 if self.password && self.auth_source_id.blank?
127 127 salt_password(password)
128 128 end
129 129 end
130 130
131 131 def reload(*args)
132 132 @name = nil
133 133 @projects_by_role = nil
134 134 super
135 135 end
136 136
137 137 def mail=(arg)
138 138 write_attribute(:mail, arg.to_s.strip)
139 139 end
140 140
141 141 def identity_url=(url)
142 142 if url.blank?
143 143 write_attribute(:identity_url, '')
144 144 else
145 145 begin
146 146 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
147 147 rescue OpenIdAuthentication::InvalidOpenId
148 148 # Invlaid url, don't save
149 149 end
150 150 end
151 151 self.read_attribute(:identity_url)
152 152 end
153 153
154 154 # Returns the user that matches provided login and password, or nil
155 155 def self.try_to_login(login, password)
156 156 login = login.to_s
157 157 password = password.to_s
158 158
159 159 # Make sure no one can sign in with an empty password
160 160 return nil if password.empty?
161 161 user = find_by_login(login)
162 162 if user
163 163 # user is already in local database
164 164 return nil if !user.active?
165 165 if user.auth_source
166 166 # user has an external authentication method
167 167 return nil unless user.auth_source.authenticate(login, password)
168 168 else
169 169 # authentication with local password
170 170 return nil unless user.check_password?(password)
171 171 end
172 172 else
173 173 # user is not yet registered, try to authenticate with available sources
174 174 attrs = AuthSource.authenticate(login, password)
175 175 if attrs
176 176 user = new(attrs)
177 177 user.login = login
178 178 user.language = Setting.default_language
179 179 if user.save
180 180 user.reload
181 181 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
182 182 end
183 183 end
184 184 end
185 185 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
186 186 user
187 187 rescue => text
188 188 raise text
189 189 end
190 190
191 191 # Returns the user who matches the given autologin +key+ or nil
192 192 def self.try_to_autologin(key)
193 193 tokens = Token.find_all_by_action_and_value('autologin', key.to_s)
194 194 # Make sure there's only 1 token that matches the key
195 195 if tokens.size == 1
196 196 token = tokens.first
197 197 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
198 198 token.user.update_attribute(:last_login_on, Time.now)
199 199 token.user
200 200 end
201 201 end
202 202 end
203 203
204 204 def self.name_formatter(formatter = nil)
205 205 USER_FORMATS[formatter || Setting.user_format] || USER_FORMATS[:firstname_lastname]
206 206 end
207 207
208 208 # Returns an array of fields names than can be used to make an order statement for users
209 209 # according to how user names are displayed
210 210 # Examples:
211 211 #
212 212 # User.fields_for_order_statement => ['users.login', 'users.id']
213 213 # User.fields_for_order_statement('authors') => ['authors.login', 'authors.id']
214 214 def self.fields_for_order_statement(table=nil)
215 215 table ||= table_name
216 216 name_formatter[:order].map {|field| "#{table}.#{field}"}
217 217 end
218 218
219 219 # Return user's full name for display
220 220 def name(formatter = nil)
221 221 f = self.class.name_formatter(formatter)
222 222 if formatter
223 223 eval('"' + f[:string] + '"')
224 224 else
225 225 @name ||= eval('"' + f[:string] + '"')
226 226 end
227 227 end
228 228
229 229 def active?
230 230 self.status == STATUS_ACTIVE
231 231 end
232 232
233 233 def registered?
234 234 self.status == STATUS_REGISTERED
235 235 end
236 236
237 237 def locked?
238 238 self.status == STATUS_LOCKED
239 239 end
240 240
241 241 def activate
242 242 self.status = STATUS_ACTIVE
243 243 end
244 244
245 245 def register
246 246 self.status = STATUS_REGISTERED
247 247 end
248 248
249 249 def lock
250 250 self.status = STATUS_LOCKED
251 251 end
252 252
253 253 def activate!
254 254 update_attribute(:status, STATUS_ACTIVE)
255 255 end
256 256
257 257 def register!
258 258 update_attribute(:status, STATUS_REGISTERED)
259 259 end
260 260
261 261 def lock!
262 262 update_attribute(:status, STATUS_LOCKED)
263 263 end
264 264
265 265 # Returns true if +clear_password+ is the correct user's password, otherwise false
266 266 def check_password?(clear_password)
267 267 if auth_source_id.present?
268 268 auth_source.authenticate(self.login, clear_password)
269 269 else
270 270 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
271 271 end
272 272 end
273 273
274 274 # Generates a random salt and computes hashed_password for +clear_password+
275 275 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
276 276 def salt_password(clear_password)
277 277 self.salt = User.generate_salt
278 278 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
279 279 end
280 280
281 281 # Does the backend storage allow this user to change their password?
282 282 def change_password_allowed?
283 283 return true if auth_source.nil?
284 284 return auth_source.allow_password_changes?
285 285 end
286 286
287 287 # Generate and set a random password. Useful for automated user creation
288 288 # Based on Token#generate_token_value
289 289 #
290 290 def random_password
291 291 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
292 292 password = ''
293 293 40.times { |i| password << chars[rand(chars.size-1)] }
294 294 self.password = password
295 295 self.password_confirmation = password
296 296 self
297 297 end
298 298
299 299 def pref
300 300 self.preference ||= UserPreference.new(:user => self)
301 301 end
302 302
303 303 def time_zone
304 304 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
305 305 end
306 306
307 307 def wants_comments_in_reverse_order?
308 308 self.pref[:comments_sorting] == 'desc'
309 309 end
310 310
311 311 # Return user's RSS key (a 40 chars long string), used to access feeds
312 312 def rss_key
313 313 if rss_token.nil?
314 314 create_rss_token(:action => 'feeds')
315 315 end
316 316 rss_token.value
317 317 end
318 318
319 319 # Return user's API key (a 40 chars long string), used to access the API
320 320 def api_key
321 321 if api_token.nil?
322 322 create_api_token(:action => 'api')
323 323 end
324 324 api_token.value
325 325 end
326 326
327 327 # Return an array of project ids for which the user has explicitly turned mail notifications on
328 328 def notified_projects_ids
329 329 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
330 330 end
331 331
332 332 def notified_project_ids=(ids)
333 333 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
334 334 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
335 335 @notified_projects_ids = nil
336 336 notified_projects_ids
337 337 end
338 338
339 339 def valid_notification_options
340 340 self.class.valid_notification_options(self)
341 341 end
342 342
343 343 # Only users that belong to more than 1 project can select projects for which they are notified
344 344 def self.valid_notification_options(user=nil)
345 345 # Note that @user.membership.size would fail since AR ignores
346 346 # :include association option when doing a count
347 347 if user.nil? || user.memberships.length < 1
348 348 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
349 349 else
350 350 MAIL_NOTIFICATION_OPTIONS
351 351 end
352 352 end
353 353
354 354 # Find a user account by matching the exact login and then a case-insensitive
355 355 # version. Exact matches will be given priority.
356 356 def self.find_by_login(login)
357 357 # First look for an exact match
358 358 user = all(:conditions => {:login => login}).detect {|u| u.login == login}
359 359 unless user
360 360 # Fail over to case-insensitive if none was found
361 361 user = first(:conditions => ["LOWER(login) = ?", login.to_s.downcase])
362 362 end
363 363 user
364 364 end
365 365
366 366 def self.find_by_rss_key(key)
367 367 token = Token.find_by_action_and_value('feeds', key.to_s)
368 368 token && token.user.active? ? token.user : nil
369 369 end
370 370
371 371 def self.find_by_api_key(key)
372 372 token = Token.find_by_action_and_value('api', key.to_s)
373 373 token && token.user.active? ? token.user : nil
374 374 end
375 375
376 376 # Makes find_by_mail case-insensitive
377 377 def self.find_by_mail(mail)
378 378 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
379 379 end
380 380
381 381 # Returns true if the default admin account can no longer be used
382 382 def self.default_admin_account_changed?
383 383 !User.active.find_by_login("admin").try(:check_password?, "admin")
384 384 end
385 385
386 386 def to_s
387 387 name
388 388 end
389 389
390 CSS_CLASS_BY_STATUS = {
391 STATUS_ANONYMOUS => 'anon',
392 STATUS_ACTIVE => 'active',
393 STATUS_REGISTERED => 'registered',
394 STATUS_LOCKED => 'locked'
395 }
396
397 def css_classes
398 "user #{CSS_CLASS_BY_STATUS[status]}"
399 end
400
390 401 # Returns the current day according to user's time zone
391 402 def today
392 403 if time_zone.nil?
393 404 Date.today
394 405 else
395 406 Time.now.in_time_zone(time_zone).to_date
396 407 end
397 408 end
398 409
399 410 # Returns the day of +time+ according to user's time zone
400 411 def time_to_date(time)
401 412 if time_zone.nil?
402 413 time.to_date
403 414 else
404 415 time.in_time_zone(time_zone).to_date
405 416 end
406 417 end
407 418
408 419 def logged?
409 420 true
410 421 end
411 422
412 423 def anonymous?
413 424 !logged?
414 425 end
415 426
416 427 # Return user's roles for project
417 428 def roles_for_project(project)
418 429 roles = []
419 430 # No role on archived projects
420 431 return roles if project.nil? || project.archived?
421 432 if logged?
422 433 # Find project membership
423 434 membership = memberships.detect {|m| m.project_id == project.id}
424 435 if membership
425 436 roles = membership.roles
426 437 else
427 438 @role_non_member ||= Role.non_member
428 439 roles << @role_non_member
429 440 end
430 441 else
431 442 @role_anonymous ||= Role.anonymous
432 443 roles << @role_anonymous
433 444 end
434 445 roles
435 446 end
436 447
437 448 # Return true if the user is a member of project
438 449 def member_of?(project)
439 450 !roles_for_project(project).detect {|role| role.member?}.nil?
440 451 end
441 452
442 453 # Returns a hash of user's projects grouped by roles
443 454 def projects_by_role
444 455 return @projects_by_role if @projects_by_role
445 456
446 457 @projects_by_role = Hash.new([])
447 458 memberships.each do |membership|
448 459 if membership.project
449 460 membership.roles.each do |role|
450 461 @projects_by_role[role] = [] unless @projects_by_role.key?(role)
451 462 @projects_by_role[role] << membership.project
452 463 end
453 464 end
454 465 end
455 466 @projects_by_role.each do |role, projects|
456 467 projects.uniq!
457 468 end
458 469
459 470 @projects_by_role
460 471 end
461 472
462 473 # Returns true if user is arg or belongs to arg
463 474 def is_or_belongs_to?(arg)
464 475 if arg.is_a?(User)
465 476 self == arg
466 477 elsif arg.is_a?(Group)
467 478 arg.users.include?(self)
468 479 else
469 480 false
470 481 end
471 482 end
472 483
473 484 # Return true if the user is allowed to do the specified action on a specific context
474 485 # Action can be:
475 486 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
476 487 # * a permission Symbol (eg. :edit_project)
477 488 # Context can be:
478 489 # * a project : returns true if user is allowed to do the specified action on this project
479 490 # * an array of projects : returns true if user is allowed on every project
480 491 # * nil with options[:global] set : check if user has at least one role allowed for this action,
481 492 # or falls back to Non Member / Anonymous permissions depending if the user is logged
482 493 def allowed_to?(action, context, options={}, &block)
483 494 if context && context.is_a?(Project)
484 495 return false unless context.allows_to?(action)
485 496 # Admin users are authorized for anything else
486 497 return true if admin?
487 498
488 499 roles = roles_for_project(context)
489 500 return false unless roles
490 501 roles.any? {|role|
491 502 (context.is_public? || role.member?) &&
492 503 role.allowed_to?(action) &&
493 504 (block_given? ? yield(role, self) : true)
494 505 }
495 506 elsif context && context.is_a?(Array)
496 507 if context.empty?
497 508 false
498 509 else
499 510 # Authorize if user is authorized on every element of the array
500 511 context.map {|project| allowed_to?(action, project, options, &block)}.reduce(:&)
501 512 end
502 513 elsif options[:global]
503 514 # Admin users are always authorized
504 515 return true if admin?
505 516
506 517 # authorize if user has at least one role that has this permission
507 518 roles = memberships.collect {|m| m.roles}.flatten.uniq
508 519 roles << (self.logged? ? Role.non_member : Role.anonymous)
509 520 roles.any? {|role|
510 521 role.allowed_to?(action) &&
511 522 (block_given? ? yield(role, self) : true)
512 523 }
513 524 else
514 525 false
515 526 end
516 527 end
517 528
518 529 # Is the user allowed to do the specified action on any project?
519 530 # See allowed_to? for the actions and valid options.
520 531 def allowed_to_globally?(action, options, &block)
521 532 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
522 533 end
523 534
524 535 # Returns true if the user is allowed to delete his own account
525 536 def own_account_deletable?
526 537 Setting.unsubscribe? &&
527 538 (!admin? || User.active.first(:conditions => ["admin = ? AND id <> ?", true, id]).present?)
528 539 end
529 540
530 541 safe_attributes 'login',
531 542 'firstname',
532 543 'lastname',
533 544 'mail',
534 545 'mail_notification',
535 546 'language',
536 547 'custom_field_values',
537 548 'custom_fields',
538 549 'identity_url'
539 550
540 551 safe_attributes 'status',
541 552 'auth_source_id',
542 553 :if => lambda {|user, current_user| current_user.admin?}
543 554
544 555 safe_attributes 'group_ids',
545 556 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
546 557
547 558 # Utility method to help check if a user should be notified about an
548 559 # event.
549 560 #
550 561 # TODO: only supports Issue events currently
551 562 def notify_about?(object)
552 563 case mail_notification
553 564 when 'all'
554 565 true
555 566 when 'selected'
556 567 # user receives notifications for created/assigned issues on unselected projects
557 568 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
558 569 true
559 570 else
560 571 false
561 572 end
562 573 when 'none'
563 574 false
564 575 when 'only_my_events'
565 576 if object.is_a?(Issue) && (object.author == self || is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
566 577 true
567 578 else
568 579 false
569 580 end
570 581 when 'only_assigned'
571 582 if object.is_a?(Issue) && (is_or_belongs_to?(object.assigned_to) || is_or_belongs_to?(object.assigned_to_was))
572 583 true
573 584 else
574 585 false
575 586 end
576 587 when 'only_owner'
577 588 if object.is_a?(Issue) && object.author == self
578 589 true
579 590 else
580 591 false
581 592 end
582 593 else
583 594 false
584 595 end
585 596 end
586 597
587 598 def self.current=(user)
588 599 @current_user = user
589 600 end
590 601
591 602 def self.current
592 603 @current_user ||= User.anonymous
593 604 end
594 605
595 606 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
596 607 # one anonymous user per database.
597 608 def self.anonymous
598 609 anonymous_user = AnonymousUser.find(:first)
599 610 if anonymous_user.nil?
600 611 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
601 612 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
602 613 end
603 614 anonymous_user
604 615 end
605 616
606 617 # Salts all existing unsalted passwords
607 618 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
608 619 # This method is used in the SaltPasswords migration and is to be kept as is
609 620 def self.salt_unsalted_passwords!
610 621 transaction do
611 622 User.find_each(:conditions => "salt IS NULL OR salt = ''") do |user|
612 623 next if user.hashed_password.blank?
613 624 salt = User.generate_salt
614 625 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
615 626 User.update_all("salt = '#{salt}', hashed_password = '#{hashed_password}'", ["id = ?", user.id] )
616 627 end
617 628 end
618 629 end
619 630
620 631 protected
621 632
622 633 def validate_password_length
623 634 # Password length validation based on setting
624 635 if !password.nil? && password.size < Setting.password_min_length.to_i
625 636 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
626 637 end
627 638 end
628 639
629 640 private
630 641
631 642 # Removes references that are not handled by associations
632 643 # Things that are not deleted are reassociated with the anonymous user
633 644 def remove_references_before_destroy
634 645 return if self.id.nil?
635 646
636 647 substitute = User.anonymous
637 648 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
638 649 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
639 650 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
640 651 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
641 652 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
642 653 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
643 654 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
644 655 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
645 656 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
646 657 # Remove private queries and keep public ones
647 658 ::Query.delete_all ['user_id = ? AND is_public = ?', id, false]
648 659 ::Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
649 660 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
650 661 Token.delete_all ['user_id = ?', id]
651 662 Watcher.delete_all ['user_id = ?', id]
652 663 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
653 664 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
654 665 end
655 666
656 667 # Return password digest
657 668 def self.hash_password(clear_password)
658 669 Digest::SHA1.hexdigest(clear_password || "")
659 670 end
660 671
661 672 # Returns a 128bits random salt as a hex string (32 chars long)
662 673 def self.generate_salt
663 674 Redmine::Utils.random_hex(16)
664 675 end
665 676
666 677 end
667 678
668 679 class AnonymousUser < User
669 680 validate :validate_anonymous_uniqueness, :on => :create
670 681
671 682 def validate_anonymous_uniqueness
672 683 # There should be only one AnonymousUser in the database
673 684 errors.add :base, 'An anonymous user already exists.' if AnonymousUser.find(:first)
674 685 end
675 686
676 687 def available_custom_fields
677 688 []
678 689 end
679 690
680 691 # Overrides a few properties
681 692 def logged?; false end
682 693 def admin; false end
683 694 def name(*args); I18n.t(:label_user_anonymous) end
684 695 def mail; nil end
685 696 def time_zone; nil end
686 697 def rss_key; nil end
687 698
688 699 def pref
689 700 UserPreference.new(:user => self)
690 701 end
691 702
692 703 # Anonymous user can not be destroyed
693 704 def destroy
694 705 false
695 706 end
696 707 end
@@ -1,58 +1,58
1 1 <div class="contextual">
2 2 <%= link_to l(:label_user_new), new_user_path, :class => 'icon icon-add' %>
3 3 </div>
4 4
5 5 <h2><%=l(:label_user_plural)%></h2>
6 6
7 7 <%= form_tag({}, :method => :get) do %>
8 8 <fieldset><legend><%= l(:label_filter_plural) %></legend>
9 9 <label for='status'><%= l(:field_status) %>:</label>
10 10 <%= select_tag 'status', users_status_options_for_select(@status), :class => "small", :onchange => "this.form.submit(); return false;" %>
11 11
12 12 <% if @groups.present? %>
13 13 <label for='group_id'><%= l(:label_group) %>:</label>
14 14 <%= select_tag 'group_id', content_tag('option') + options_from_collection_for_select(@groups, :id, :name, params[:group_id].to_i), :onchange => "this.form.submit(); return false;" %>
15 15 <% end %>
16 16
17 17 <label for='name'><%= l(:label_user) %>:</label>
18 18 <%= text_field_tag 'name', params[:name], :size => 30 %>
19 19 <%= submit_tag l(:button_apply), :class => "small", :name => nil %>
20 20 <%= link_to l(:button_clear), users_path, :class => 'icon icon-reload' %>
21 21 </fieldset>
22 22 <% end %>
23 23 &nbsp;
24 24
25 25 <div class="autoscroll">
26 26 <table class="list">
27 27 <thead><tr>
28 28 <%= sort_header_tag('login', :caption => l(:field_login)) %>
29 29 <%= sort_header_tag('firstname', :caption => l(:field_firstname)) %>
30 30 <%= sort_header_tag('lastname', :caption => l(:field_lastname)) %>
31 31 <%= sort_header_tag('mail', :caption => l(:field_mail)) %>
32 32 <%= sort_header_tag('admin', :caption => l(:field_admin), :default_order => 'desc') %>
33 33 <%= sort_header_tag('created_on', :caption => l(:field_created_on), :default_order => 'desc') %>
34 34 <%= sort_header_tag('last_login_on', :caption => l(:field_last_login_on), :default_order => 'desc') %>
35 35 <th></th>
36 36 </tr></thead>
37 37 <tbody>
38 38 <% for user in @users -%>
39 <tr class="user <%= cycle("odd", "even") %> <%= %w(anon active registered locked)[user.status] %>">
39 <tr class="<%= user.css_classes %> <%= cycle("odd", "even") %>">
40 40 <td class="username"><%= avatar(user, :size => "14") %><%= link_to h(user.login), edit_user_path(user) %></td>
41 41 <td class="firstname"><%= h(user.firstname) %></td>
42 42 <td class="lastname"><%= h(user.lastname) %></td>
43 43 <td class="email"><%= mail_to(h(user.mail)) %></td>
44 44 <td align="center"><%= checked_image user.admin? %></td>
45 45 <td class="created_on" align="center"><%= format_time(user.created_on) %></td>
46 46 <td class="last_login_on" align="center"><%= format_time(user.last_login_on) unless user.last_login_on.nil? %></td>
47 47 <td class="buttons">
48 48 <%= change_status_link(user) %>
49 49 <%= delete_link user_path(user, :back_url => users_path(params)) unless User.current == user %>
50 50 </td>
51 51 </tr>
52 52 <% end -%>
53 53 </tbody>
54 54 </table>
55 55 </div>
56 56 <p class="pagination"><%= pagination_links_full @user_pages, @user_count %></p>
57 57
58 58 <% html_title(l(:label_user_plural)) -%>
@@ -1,1132 +1,1133
1 1 html {overflow-y:scroll;}
2 2 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
3 3
4 4 h1, h2, h3, h4 {font-family: "Trebuchet MS", Verdana, sans-serif;padding: 2px 10px 1px 0px;margin: 0 0 10px 0;}
5 5 #content h1, h2, h3, h4 {color: #555;}
6 6 h2, .wiki h1 {font-size: 20px;}
7 7 h3, .wiki h2 {font-size: 16px;}
8 8 h4, .wiki h3 {font-size: 13px;}
9 9 h4 {border-bottom: 1px dotted #bbb;}
10 10
11 11 /***** Layout *****/
12 12 #wrapper {background: white;}
13 13
14 14 #top-menu {background: #3E5B76; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
15 15 #top-menu ul {margin: 0; padding: 0;}
16 16 #top-menu li {
17 17 float:left;
18 18 list-style-type:none;
19 19 margin: 0px 0px 0px 0px;
20 20 padding: 0px 0px 0px 0px;
21 21 white-space:nowrap;
22 22 }
23 23 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
24 24 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
25 25
26 26 #account {float:right;}
27 27
28 28 #header {height:5.3em;margin:0;background-color:#628DB6;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
29 29 #header a {color:#f8f8f8;}
30 30 #header h1 a.ancestor { font-size: 80%; }
31 31 #quick-search {float:right;}
32 32
33 33 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
34 34 #main-menu ul {margin: 0; padding: 0;}
35 35 #main-menu li {
36 36 float:left;
37 37 list-style-type:none;
38 38 margin: 0px 2px 0px 0px;
39 39 padding: 0px 0px 0px 0px;
40 40 white-space:nowrap;
41 41 }
42 42 #main-menu li a {
43 43 display: block;
44 44 color: #fff;
45 45 text-decoration: none;
46 46 font-weight: bold;
47 47 margin: 0;
48 48 padding: 4px 10px 4px 10px;
49 49 }
50 50 #main-menu li a:hover {background:#759FCF; color:#fff;}
51 51 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
52 52
53 53 #admin-menu ul {margin: 0; padding: 0;}
54 54 #admin-menu li {margin: 0; padding: 0 0 6px 0; list-style-type:none;}
55 55
56 56 #admin-menu a { background-position: 0% 40%; background-repeat: no-repeat; padding-left: 20px; padding-top: 2px; padding-bottom: 3px;}
57 57 #admin-menu a.projects { background-image: url(../images/projects.png); }
58 58 #admin-menu a.users { background-image: url(../images/user.png); }
59 59 #admin-menu a.groups { background-image: url(../images/group.png); }
60 60 #admin-menu a.roles { background-image: url(../images/database_key.png); }
61 61 #admin-menu a.trackers { background-image: url(../images/ticket.png); }
62 62 #admin-menu a.issue_statuses { background-image: url(../images/ticket_edit.png); }
63 63 #admin-menu a.workflows { background-image: url(../images/ticket_go.png); }
64 64 #admin-menu a.custom_fields { background-image: url(../images/textfield.png); }
65 65 #admin-menu a.enumerations { background-image: url(../images/text_list_bullets.png); }
66 66 #admin-menu a.settings { background-image: url(../images/changeset.png); }
67 67 #admin-menu a.plugins { background-image: url(../images/plugin.png); }
68 68 #admin-menu a.info { background-image: url(../images/help.png); }
69 69 #admin-menu a.server_authentication { background-image: url(../images/server_key.png); }
70 70
71 71 #main {background-color:#EEEEEE;}
72 72
73 73 #sidebar{ float: right; width: 22%; position: relative; z-index: 9; padding: 0; margin: 0;}
74 74 * html #sidebar{ width: 22%; }
75 75 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
76 76 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
77 77 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
78 78 #sidebar .contextual { margin-right: 1em; }
79 79
80 80 #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
81 81 * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
82 82 html>body #content { min-height: 600px; }
83 83 * html body #content { height: 600px; } /* IE */
84 84
85 85 #main.nosidebar #sidebar{ display: none; }
86 86 #main.nosidebar #content{ width: auto; border-right: 0; }
87 87
88 88 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
89 89
90 90 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
91 91 #login-form table td {padding: 6px;}
92 92 #login-form label {font-weight: bold;}
93 93 #login-form input#username, #login-form input#password { width: 300px; }
94 94
95 95 div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
96 96 div.modal h3.title {display:none;}
97 97 div.modal p.buttons {text-align:right; margin-bottom:0;}
98 98
99 99 input#openid_url { background: url(../images/openid-bg.gif) no-repeat; background-color: #fff; background-position: 0 50%; padding-left: 18px; }
100 100
101 101 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
102 102
103 103 /***** Links *****/
104 104 a, a:link, a:visited{ color: #169; text-decoration: none; }
105 105 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
106 106 a img{ border: 0; }
107 107
108 108 a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; }
109 109 a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }
110 a.user.locked, a.user.locked:link, a.user.locked:visited {color: #999;}
110 111
111 112 #sidebar a.selected {line-height:1.7em; padding:1px 3px 2px 2px; margin-left:-2px; background-color:#9DB9D5; color:#fff; border-radius:2px;}
112 113 #sidebar a.selected:hover {text-decoration:none;}
113 114 #admin-menu a {line-height:1.7em;}
114 115 #admin-menu a.selected {padding-left: 20px !important; background-position: 2px 40%;}
115 116
116 117 a.collapsible {padding-left: 12px; background: url(../images/arrow_expanded.png) no-repeat -3px 40%;}
117 118 a.collapsible.collapsed {background: url(../images/arrow_collapsed.png) no-repeat -5px 40%;}
118 119
119 120 a#toggle-completed-versions {color:#999;}
120 121 /***** Tables *****/
121 122 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
122 123 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
123 124 table.list td { vertical-align: top; padding-right:10px; }
124 125 table.list td.id { width: 2%; text-align: center;}
125 126 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
126 127 table.list td.checkbox input {padding:0px;}
127 128 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
128 129 table.list td.buttons a { padding-right: 0.6em; }
129 130 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
130 131
131 132 tr.project td.name a { white-space:nowrap; }
132 133 tr.project.closed, tr.project.archived { color: #aaa; }
133 134 tr.project.closed a, tr.project.archived a { color: #aaa; }
134 135
135 136 tr.project.idnt td.name span {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
136 137 tr.project.idnt-1 td.name {padding-left: 0.5em;}
137 138 tr.project.idnt-2 td.name {padding-left: 2em;}
138 139 tr.project.idnt-3 td.name {padding-left: 3.5em;}
139 140 tr.project.idnt-4 td.name {padding-left: 5em;}
140 141 tr.project.idnt-5 td.name {padding-left: 6.5em;}
141 142 tr.project.idnt-6 td.name {padding-left: 8em;}
142 143 tr.project.idnt-7 td.name {padding-left: 9.5em;}
143 144 tr.project.idnt-8 td.name {padding-left: 11em;}
144 145 tr.project.idnt-9 td.name {padding-left: 12.5em;}
145 146
146 147 tr.issue { text-align: center; white-space: nowrap; }
147 148 tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text, tr.issue td.relations { white-space: normal; }
148 149 tr.issue td.subject, tr.issue td.relations { text-align: left; }
149 150 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
150 151 tr.issue td.relations span {white-space: nowrap;}
151 152
152 153 tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
153 154 tr.issue.idnt-1 td.subject {padding-left: 0.5em;}
154 155 tr.issue.idnt-2 td.subject {padding-left: 2em;}
155 156 tr.issue.idnt-3 td.subject {padding-left: 3.5em;}
156 157 tr.issue.idnt-4 td.subject {padding-left: 5em;}
157 158 tr.issue.idnt-5 td.subject {padding-left: 6.5em;}
158 159 tr.issue.idnt-6 td.subject {padding-left: 8em;}
159 160 tr.issue.idnt-7 td.subject {padding-left: 9.5em;}
160 161 tr.issue.idnt-8 td.subject {padding-left: 11em;}
161 162 tr.issue.idnt-9 td.subject {padding-left: 12.5em;}
162 163
163 164 tr.entry { border: 1px solid #f8f8f8; }
164 165 tr.entry td { white-space: nowrap; }
165 166 tr.entry td.filename { width: 30%; }
166 167 tr.entry td.filename_no_report { width: 70%; }
167 168 tr.entry td.size { text-align: right; font-size: 90%; }
168 169 tr.entry td.revision, tr.entry td.author { text-align: center; }
169 170 tr.entry td.age { text-align: right; }
170 171 tr.entry.file td.filename a { margin-left: 16px; }
171 172 tr.entry.file td.filename_no_report a { margin-left: 16px; }
172 173
173 174 tr span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
174 175 tr.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
175 176
176 177 tr.changeset { height: 20px }
177 178 tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; }
178 179 tr.changeset td.revision_graph { width: 15%; background-color: #fffffb; }
179 180 tr.changeset td.author { text-align: center; width: 15%; white-space:nowrap;}
180 181 tr.changeset td.committed_on { text-align: center; width: 15%; white-space:nowrap;}
181 182
182 183 table.files tr.file td { text-align: center; }
183 184 table.files tr.file td.filename { text-align: left; padding-left: 24px; }
184 185 table.files tr.file td.digest { font-size: 80%; }
185 186
186 187 table.members td.roles, table.memberships td.roles { width: 45%; }
187 188
188 189 tr.message { height: 2.6em; }
189 190 tr.message td.subject { padding-left: 20px; }
190 191 tr.message td.created_on { white-space: nowrap; }
191 192 tr.message td.last_message { font-size: 80%; white-space: nowrap; }
192 193 tr.message.locked td.subject { background: url(../images/locked.png) no-repeat 0 1px; }
193 194 tr.message.sticky td.subject { background: url(../images/bullet_go.png) no-repeat 0 1px; font-weight: bold; }
194 195
195 196 tr.version.closed, tr.version.closed a { color: #999; }
196 197 tr.version td.name { padding-left: 20px; }
197 198 tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%; }
198 199 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
199 200
200 201 tr.user td { width:13%; }
201 202 tr.user td.email { width:18%; }
202 203 tr.user td { white-space: nowrap; }
203 204 tr.user.locked, tr.user.registered { color: #aaa; }
204 205 tr.user.locked a, tr.user.registered a { color: #aaa; }
205 206
206 207 table.permissions td.role {color:#999;font-size:90%;font-weight:normal !important;text-align:center;vertical-align:bottom;}
207 208
208 209 tr.wiki-page-version td.updated_on, tr.wiki-page-version td.author {text-align:center;}
209 210
210 211 tr.time-entry { text-align: center; white-space: nowrap; }
211 212 tr.time-entry td.subject, tr.time-entry td.comments { text-align: left; white-space: normal; }
212 213 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
213 214 td.hours .hours-dec { font-size: 0.9em; }
214 215
215 216 table.plugins td { vertical-align: middle; }
216 217 table.plugins td.configure { text-align: right; padding-right: 1em; }
217 218 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
218 219 table.plugins span.description { display: block; font-size: 0.9em; }
219 220 table.plugins span.url { display: block; font-size: 0.9em; }
220 221
221 222 table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; }
222 223 table.list tbody tr.group span.count {position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
223 224 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
224 225 tr.group:hover a.toggle-all { display:inline;}
225 226 a.toggle-all:hover {text-decoration:none;}
226 227
227 228 table.list tbody tr:hover { background-color:#ffffdd; }
228 229 table.list tbody tr.group:hover { background-color:inherit; }
229 230 table td {padding:2px;}
230 231 table p {margin:0;}
231 232 .odd {background-color:#f6f7f8;}
232 233 .even {background-color: #fff;}
233 234
234 235 a.sort { padding-right: 16px; background-position: 100% 50%; background-repeat: no-repeat; }
235 236 a.sort.asc { background-image: url(../images/sort_asc.png); }
236 237 a.sort.desc { background-image: url(../images/sort_desc.png); }
237 238
238 239 table.attributes { width: 100% }
239 240 table.attributes th { vertical-align: top; text-align: left; }
240 241 table.attributes td { vertical-align: top; }
241 242
242 243 table.boards a.board, h3.comments { background: url(../images/comment.png) no-repeat 0% 50%; padding-left: 20px; }
243 244 table.boards td.topic-count, table.boards td.message-count {text-align:center;}
244 245 table.boards td.last-message {font-size:80%;}
245 246
246 247 table.messages td.author, table.messages td.created_on, table.messages td.reply-count {text-align:center;}
247 248
248 249 table.query-columns {
249 250 border-collapse: collapse;
250 251 border: 0;
251 252 }
252 253
253 254 table.query-columns td.buttons {
254 255 vertical-align: middle;
255 256 text-align: center;
256 257 }
257 258
258 259 td.center {text-align:center;}
259 260
260 261 h3.version { background: url(../images/package.png) no-repeat 0% 50%; padding-left: 20px; }
261 262
262 263 div.issues h3 { background: url(../images/ticket.png) no-repeat 0% 50%; padding-left: 20px; }
263 264 div.members h3 { background: url(../images/group.png) no-repeat 0% 50%; padding-left: 20px; }
264 265 div.news h3 { background: url(../images/news.png) no-repeat 0% 50%; padding-left: 20px; }
265 266 div.projects h3 { background: url(../images/projects.png) no-repeat 0% 50%; padding-left: 20px; }
266 267
267 268 #watchers ul {margin: 0; padding: 0;}
268 269 #watchers li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
269 270 #watchers select {width: 95%; display: block;}
270 271 #watchers a.delete {opacity: 0.4;}
271 272 #watchers a.delete:hover {opacity: 1;}
272 273 #watchers img.gravatar {margin: 0 4px 2px 0;}
273 274
274 275 span#watchers_inputs {overflow:auto; display:block;}
275 276 span.search_for_watchers {display:block;}
276 277 span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
277 278 span.search_for_watchers a, span.add_attachment a {padding-left:16px; background: url(../images/bullet_add.png) no-repeat 0 50%; }
278 279
279 280
280 281 .highlight { background-color: #FCFD8D;}
281 282 .highlight.token-1 { background-color: #faa;}
282 283 .highlight.token-2 { background-color: #afa;}
283 284 .highlight.token-3 { background-color: #aaf;}
284 285
285 286 .box{
286 287 padding:6px;
287 288 margin-bottom: 10px;
288 289 background-color:#f6f6f6;
289 290 color:#505050;
290 291 line-height:1.5em;
291 292 border: 1px solid #e4e4e4;
292 293 }
293 294
294 295 div.square {
295 296 border: 1px solid #999;
296 297 float: left;
297 298 margin: .3em .4em 0 .4em;
298 299 overflow: hidden;
299 300 width: .6em; height: .6em;
300 301 }
301 302 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
302 303 .contextual input, .contextual select {font-size:0.9em;}
303 304 .message .contextual { margin-top: 0; }
304 305
305 306 .splitcontent {overflow:auto;}
306 307 .splitcontentleft{float:left; width:49%;}
307 308 .splitcontentright{float:right; width:49%;}
308 309 form {display: inline;}
309 310 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
310 311 fieldset {border: 1px solid #e4e4e4; margin:0;}
311 312 legend {color: #484848;}
312 313 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
313 314 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
314 315 blockquote blockquote { margin-left: 0;}
315 316 acronym { border-bottom: 1px dotted; cursor: help; }
316 317 textarea.wiki-edit { width: 99%; }
317 318 li p {margin-top: 0;}
318 319 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
319 320 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
320 321 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
321 322 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
322 323
323 324 div.issue div.subject div div { padding-left: 16px; }
324 325 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
325 326 div.issue div.subject>div>p { margin-top: 0.5em; }
326 327 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
327 328 div.issue span.private { position:relative; bottom: 2px; text-transform: uppercase; background: #d22; color: #fff; font-weight:bold; padding: 0px 2px 0px 2px; font-size: 60%; margin-right: 2px; border-radius: 2px;}
328 329 div.issue .next-prev-links {color:#999;}
329 330 div.issue table.attributes th {width:22%;}
330 331 div.issue table.attributes td {width:28%;}
331 332
332 333 #issue_tree table.issues, #relations table.issues { border: 0; }
333 334 #issue_tree td.checkbox, #relations td.checkbox {display:none;}
334 335 #relations td.buttons {padding:0;}
335 336
336 337 fieldset.collapsible { border-width: 1px 0 0 0; font-size: 0.9em; }
337 338 fieldset.collapsible legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
338 339 fieldset.collapsible.collapsed legend { background-image: url(../images/arrow_collapsed.png); }
339 340
340 341 fieldset#date-range p { margin: 2px 0 2px 0; }
341 342 fieldset#filters table { border-collapse: collapse; }
342 343 fieldset#filters table td { padding: 0; vertical-align: middle; }
343 344 fieldset#filters tr.filter { height: 2.1em; }
344 345 fieldset#filters td.field { width:230px; }
345 346 fieldset#filters td.operator { width:180px; }
346 347 fieldset#filters td.operator select {max-width:170px;}
347 348 fieldset#filters td.values { white-space:nowrap; }
348 349 fieldset#filters td.values select {min-width:130px;}
349 350 fieldset#filters td.values input {height:1em;}
350 351 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
351 352
352 353 .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;}
353 354 .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; }
354 355
355 356 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
356 357 div#issue-changesets div.changeset { padding: 4px;}
357 358 div#issue-changesets div.changeset { border-bottom: 1px solid #ddd; }
358 359 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
359 360
360 361 .journal ul.details img {margin:0 0 -3px 4px;}
361 362 div.journal {overflow:auto;}
362 363 div.journal.private-notes {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
363 364
364 365 div#activity dl, #search-results { margin-left: 2em; }
365 366 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
366 367 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
367 368 div#activity dt.me .time { border-bottom: 1px solid #999; }
368 369 div#activity dt .time { color: #777; font-size: 80%; }
369 370 div#activity dd .description, #search-results dd .description { font-style: italic; }
370 371 div#activity span.project:after, #search-results span.project:after { content: " -"; }
371 372 div#activity dd span.description, #search-results dd span.description { display:block; color: #808080; }
372 373
373 374 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
374 375
375 376 div#search-results-counts {float:right;}
376 377 div#search-results-counts ul { margin-top: 0.5em; }
377 378 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
378 379
379 380 dt.issue { background-image: url(../images/ticket.png); }
380 381 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
381 382 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
382 383 dt.issue-note { background-image: url(../images/ticket_note.png); }
383 384 dt.changeset { background-image: url(../images/changeset.png); }
384 385 dt.news { background-image: url(../images/news.png); }
385 386 dt.message { background-image: url(../images/message.png); }
386 387 dt.reply { background-image: url(../images/comments.png); }
387 388 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
388 389 dt.attachment { background-image: url(../images/attachment.png); }
389 390 dt.document { background-image: url(../images/document.png); }
390 391 dt.project { background-image: url(../images/projects.png); }
391 392 dt.time-entry { background-image: url(../images/time.png); }
392 393
393 394 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
394 395
395 396 div#roadmap .related-issues { margin-bottom: 1em; }
396 397 div#roadmap .related-issues td.checkbox { display: none; }
397 398 div#roadmap .wiki h1:first-child { display: none; }
398 399 div#roadmap .wiki h1 { font-size: 120%; }
399 400 div#roadmap .wiki h2 { font-size: 110%; }
400 401 body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
401 402
402 403 div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
403 404 div#version-summary fieldset { margin-bottom: 1em; }
404 405 div#version-summary fieldset.time-tracking table { width:100%; }
405 406 div#version-summary th, div#version-summary td.total-hours { text-align: right; }
406 407
407 408 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
408 409 table#time-report tbody tr.subtotal { font-style: italic; color:#777;}
409 410 table#time-report tbody tr.subtotal td.hours { color:#b0b0b0; }
410 411 table#time-report tbody tr.total { font-weight: bold; background-color:#EEEEEE; border-top:1px solid #e4e4e4;}
411 412 table#time-report .hours-dec { font-size: 0.9em; }
412 413
413 414 div.wiki-page .contextual a {opacity: 0.4}
414 415 div.wiki-page .contextual a:hover {opacity: 1}
415 416
416 417 form .attributes select { width: 60%; }
417 418 input#issue_subject { width: 99%; }
418 419 select#issue_done_ratio { width: 95px; }
419 420
420 421 ul.projects {margin:0; padding-left:1em;}
421 422 ul.projects ul {padding-left:1.6em;}
422 423 ul.projects.root {margin:0; padding:0;}
423 424 ul.projects li {list-style-type:none;}
424 425
425 426 #projects-index ul.projects ul.projects { border-left: 3px solid #e0e0e0; padding-left:1em;}
426 427 #projects-index ul.projects li.root {margin-bottom: 1em;}
427 428 #projects-index ul.projects li.child {margin-top: 1em;}
428 429 #projects-index ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
429 430 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
430 431
431 432 #notified-projects ul, #tracker_project_ids ul {max-height:250px; overflow-y:auto;}
432 433
433 434 #related-issues li img {vertical-align:middle;}
434 435
435 436 ul.properties {padding:0; font-size: 0.9em; color: #777;}
436 437 ul.properties li {list-style-type:none;}
437 438 ul.properties li span {font-style:italic;}
438 439
439 440 .total-hours { font-size: 110%; font-weight: bold; }
440 441 .total-hours span.hours-int { font-size: 120%; }
441 442
442 443 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
443 444 #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select, #user_identity_url { width: 90%; }
444 445
445 446 #workflow_copy_form select { width: 200px; }
446 447 table.transitions td.enabled {background: #bfb;}
447 448 table.fields_permissions select {font-size:90%}
448 449 table.fields_permissions td.readonly {background:#ddd;}
449 450 table.fields_permissions td.required {background:#d88;}
450 451
451 452 textarea#custom_field_possible_values {width: 99%}
452 453 input#content_comments {width: 99%}
453 454
454 455 .pagination {font-size: 90%}
455 456 p.pagination {margin-top:8px;}
456 457
457 458 /***** Tabular forms ******/
458 459 .tabular p{
459 460 margin: 0;
460 461 padding: 3px 0 3px 0;
461 462 padding-left: 180px; /* width of left column containing the label elements */
462 463 min-height: 1.8em;
463 464 clear:left;
464 465 }
465 466
466 467 html>body .tabular p {overflow:hidden;}
467 468
468 469 .tabular label{
469 470 font-weight: bold;
470 471 float: left;
471 472 text-align: right;
472 473 /* width of left column */
473 474 margin-left: -180px;
474 475 /* width of labels. Should be smaller than left column to create some right margin */
475 476 width: 175px;
476 477 }
477 478
478 479 .tabular label.floating{
479 480 font-weight: normal;
480 481 margin-left: 0px;
481 482 text-align: left;
482 483 width: 270px;
483 484 }
484 485
485 486 .tabular label.block{
486 487 font-weight: normal;
487 488 margin-left: 0px !important;
488 489 text-align: left;
489 490 float: none;
490 491 display: block;
491 492 width: auto;
492 493 }
493 494
494 495 .tabular label.inline{
495 496 float:none;
496 497 margin-left: 5px !important;
497 498 width: auto;
498 499 }
499 500
500 501 label.no-css {
501 502 font-weight: inherit;
502 503 float:none;
503 504 text-align:left;
504 505 margin-left:0px;
505 506 width:auto;
506 507 }
507 508 input#time_entry_comments { width: 90%;}
508 509
509 510 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
510 511
511 512 .tabular.settings p{ padding-left: 300px; }
512 513 .tabular.settings label{ margin-left: -300px; width: 295px; }
513 514 .tabular.settings textarea { width: 99%; }
514 515
515 516 .settings.enabled_scm table {width:100%}
516 517 .settings.enabled_scm td.scm_name{ font-weight: bold; }
517 518
518 519 fieldset.settings label { display: block; }
519 520 fieldset#notified_events .parent { padding-left: 20px; }
520 521
521 522 span.required {color: #bb0000;}
522 523 .summary {font-style: italic;}
523 524
524 525 #attachments_fields input.description {margin-left: 8px; width:340px;}
525 526 #attachments_fields span {display:block; white-space:nowrap;}
526 527 #attachments_fields img {vertical-align: middle;}
527 528
528 529 div.attachments { margin-top: 12px; }
529 530 div.attachments p { margin:4px 0 2px 0; }
530 531 div.attachments img { vertical-align: middle; }
531 532 div.attachments span.author { font-size: 0.9em; color: #888; }
532 533
533 534 div.thumbnails {margin-top:0.6em;}
534 535 div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
535 536 div.thumbnails img {margin: 3px;}
536 537
537 538 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
538 539 .other-formats span + span:before { content: "| "; }
539 540
540 541 a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
541 542
542 543 em.info {font-style:normal;font-size:90%;color:#888;display:block;}
543 544 em.info.error {padding-left:20px; background:url(../images/exclamation.png) no-repeat 0 50%;}
544 545
545 546 textarea.text_cf {width:90%;}
546 547
547 548 /* Project members tab */
548 549 div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft, div#tab-content-users .splitcontentleft { width: 64% }
549 550 div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright, div#tab-content-users .splitcontentright { width: 34% }
550 551 div#tab-content-members fieldset, div#tab-content-memberships fieldset, div#tab-content-users fieldset { padding:1em; margin-bottom: 1em; }
551 552 div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend, div#tab-content-users fieldset legend { font-weight: bold; }
552 553 div#tab-content-members fieldset label, div#tab-content-memberships fieldset label, div#tab-content-users fieldset label { display: block; }
553 554 div#tab-content-members fieldset div, div#tab-content-users fieldset div { max-height: 400px; overflow:auto; }
554 555
555 556 #users_for_watcher {height: 200px; overflow:auto;}
556 557 #users_for_watcher label {display: block;}
557 558
558 559 table.members td.group { padding-left: 20px; background: url(../images/group.png) no-repeat 0% 50%; }
559 560
560 561 input#principal_search, input#user_search {width:100%}
561 562 input#principal_search, input#user_search {
562 563 background: url(../images/magnifier.png) no-repeat 2px 50%; padding-left:20px;
563 564 border:1px solid #9EB1C2; border-radius:3px; height:1.5em; width:95%;
564 565 }
565 566 input#principal_search.ajax-loading, input#user_search.ajax-loading {
566 567 background-image: url(../images/loading.gif);
567 568 }
568 569
569 570 * html div#tab-content-members fieldset div { height: 450px; }
570 571
571 572 /***** Flash & error messages ****/
572 573 #errorExplanation, div.flash, .nodata, .warning, .conflict {
573 574 padding: 4px 4px 4px 30px;
574 575 margin-bottom: 12px;
575 576 font-size: 1.1em;
576 577 border: 2px solid;
577 578 }
578 579
579 580 div.flash {margin-top: 8px;}
580 581
581 582 div.flash.error, #errorExplanation {
582 583 background: url(../images/exclamation.png) 8px 50% no-repeat;
583 584 background-color: #ffe3e3;
584 585 border-color: #dd0000;
585 586 color: #880000;
586 587 }
587 588
588 589 div.flash.notice {
589 590 background: url(../images/true.png) 8px 5px no-repeat;
590 591 background-color: #dfffdf;
591 592 border-color: #9fcf9f;
592 593 color: #005f00;
593 594 }
594 595
595 596 div.flash.warning, .conflict {
596 597 background: url(../images/warning.png) 8px 5px no-repeat;
597 598 background-color: #FFEBC1;
598 599 border-color: #FDBF3B;
599 600 color: #A6750C;
600 601 text-align: left;
601 602 }
602 603
603 604 .nodata, .warning {
604 605 text-align: center;
605 606 background-color: #FFEBC1;
606 607 border-color: #FDBF3B;
607 608 color: #A6750C;
608 609 }
609 610
610 611 #errorExplanation ul { font-size: 0.9em;}
611 612 #errorExplanation h2, #errorExplanation p { display: none; }
612 613
613 614 .conflict-details {font-size:80%;}
614 615
615 616 /***** Ajax indicator ******/
616 617 #ajax-indicator {
617 618 position: absolute; /* fixed not supported by IE */
618 619 background-color:#eee;
619 620 border: 1px solid #bbb;
620 621 top:35%;
621 622 left:40%;
622 623 width:20%;
623 624 font-weight:bold;
624 625 text-align:center;
625 626 padding:0.6em;
626 627 z-index:100;
627 628 opacity: 0.5;
628 629 }
629 630
630 631 html>body #ajax-indicator { position: fixed; }
631 632
632 633 #ajax-indicator span {
633 634 background-position: 0% 40%;
634 635 background-repeat: no-repeat;
635 636 background-image: url(../images/loading.gif);
636 637 padding-left: 26px;
637 638 vertical-align: bottom;
638 639 }
639 640
640 641 /***** Calendar *****/
641 642 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
642 643 table.cal thead th {width: 14%; background-color:#EEEEEE; padding: 4px; }
643 644 table.cal thead th.week-number {width: auto;}
644 645 table.cal tbody tr {height: 100px;}
645 646 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
646 647 table.cal td.week-number { background-color:#EEEEEE; padding: 4px; border:none; font-size: 1em;}
647 648 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
648 649 table.cal td.odd p.day-num {color: #bbb;}
649 650 table.cal td.today {background:#ffffdd;}
650 651 table.cal td.today p.day-num {font-weight: bold;}
651 652 table.cal .starting a, p.cal.legend .starting {background: url(../images/bullet_go.png) no-repeat -1px -2px; padding-left:16px;}
652 653 table.cal .ending a, p.cal.legend .ending {background: url(../images/bullet_end.png) no-repeat -1px -2px; padding-left:16px;}
653 654 table.cal .starting.ending a, p.cal.legend .starting.ending {background: url(../images/bullet_diamond.png) no-repeat -1px -2px; padding-left:16px;}
654 655 p.cal.legend span {display:block;}
655 656
656 657 /***** Tooltips ******/
657 658 .tooltip{position:relative;z-index:24;}
658 659 .tooltip:hover{z-index:25;color:#000;}
659 660 .tooltip span.tip{display: none; text-align:left;}
660 661
661 662 div.tooltip:hover span.tip{
662 663 display:block;
663 664 position:absolute;
664 665 top:12px; left:24px; width:270px;
665 666 border:1px solid #555;
666 667 background-color:#fff;
667 668 padding: 4px;
668 669 font-size: 0.8em;
669 670 color:#505050;
670 671 }
671 672
672 673 img.ui-datepicker-trigger {
673 674 cursor: pointer;
674 675 vertical-align: middle;
675 676 margin-left: 4px;
676 677 }
677 678
678 679 /***** Progress bar *****/
679 680 table.progress {
680 681 border-collapse: collapse;
681 682 border-spacing: 0pt;
682 683 empty-cells: show;
683 684 text-align: center;
684 685 float:left;
685 686 margin: 1px 6px 1px 0px;
686 687 }
687 688
688 689 table.progress td { height: 1em; }
689 690 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
690 691 table.progress td.done { background: #D3EDD3 none repeat scroll 0%; }
691 692 table.progress td.todo { background: #eee none repeat scroll 0%; }
692 693 p.pourcent {font-size: 80%;}
693 694 p.progress-info {clear: left; font-size: 80%; margin-top:-4px; color:#777;}
694 695
695 696 #roadmap table.progress td { height: 1.2em; }
696 697 /***** Tabs *****/
697 698 #content .tabs {height: 2.6em; margin-bottom:1.2em; position:relative; overflow:hidden;}
698 699 #content .tabs ul {margin:0; position:absolute; bottom:0; padding-left:0.5em; width: 2000px; border-bottom: 1px solid #bbbbbb;}
699 700 #content .tabs ul li {
700 701 float:left;
701 702 list-style-type:none;
702 703 white-space:nowrap;
703 704 margin-right:4px;
704 705 background:#fff;
705 706 position:relative;
706 707 margin-bottom:-1px;
707 708 }
708 709 #content .tabs ul li a{
709 710 display:block;
710 711 font-size: 0.9em;
711 712 text-decoration:none;
712 713 line-height:1.3em;
713 714 padding:4px 6px 4px 6px;
714 715 border: 1px solid #ccc;
715 716 border-bottom: 1px solid #bbbbbb;
716 717 background-color: #f6f6f6;
717 718 color:#999;
718 719 font-weight:bold;
719 720 border-top-left-radius:3px;
720 721 border-top-right-radius:3px;
721 722 }
722 723
723 724 #content .tabs ul li a:hover {
724 725 background-color: #ffffdd;
725 726 text-decoration:none;
726 727 }
727 728
728 729 #content .tabs ul li a.selected {
729 730 background-color: #fff;
730 731 border: 1px solid #bbbbbb;
731 732 border-bottom: 1px solid #fff;
732 733 color:#444;
733 734 }
734 735
735 736 #content .tabs ul li a.selected:hover {background-color: #fff;}
736 737
737 738 div.tabs-buttons { position:absolute; right: 0; width: 48px; height: 24px; background: white; bottom: 0; border-bottom: 1px solid #bbbbbb; }
738 739
739 740 button.tab-left, button.tab-right {
740 741 font-size: 0.9em;
741 742 cursor: pointer;
742 743 height:24px;
743 744 border: 1px solid #ccc;
744 745 border-bottom: 1px solid #bbbbbb;
745 746 position:absolute;
746 747 padding:4px;
747 748 width: 20px;
748 749 bottom: -1px;
749 750 }
750 751
751 752 button.tab-left {
752 753 right: 20px;
753 754 background: #eeeeee url(../images/bullet_arrow_left.png) no-repeat 50% 50%;
754 755 border-top-left-radius:3px;
755 756 }
756 757
757 758 button.tab-right {
758 759 right: 0;
759 760 background: #eeeeee url(../images/bullet_arrow_right.png) no-repeat 50% 50%;
760 761 border-top-right-radius:3px;
761 762 }
762 763
763 764 /***** Diff *****/
764 765 .diff_out { background: #fcc; }
765 766 .diff_out span { background: #faa; }
766 767 .diff_in { background: #cfc; }
767 768 .diff_in span { background: #afa; }
768 769
769 770 .text-diff {
770 771 padding: 1em;
771 772 background-color:#f6f6f6;
772 773 color:#505050;
773 774 border: 1px solid #e4e4e4;
774 775 }
775 776
776 777 /***** Wiki *****/
777 778 div.wiki table {
778 779 border-collapse: collapse;
779 780 margin-bottom: 1em;
780 781 }
781 782
782 783 div.wiki table, div.wiki td, div.wiki th {
783 784 border: 1px solid #bbb;
784 785 padding: 4px;
785 786 }
786 787
787 788 div.wiki .noborder, div.wiki .noborder td, div.wiki .noborder th {border:0;}
788 789
789 790 div.wiki .external {
790 791 background-position: 0% 60%;
791 792 background-repeat: no-repeat;
792 793 padding-left: 12px;
793 794 background-image: url(../images/external.png);
794 795 }
795 796
796 797 div.wiki a.new {color: #b73535;}
797 798
798 799 div.wiki ul, div.wiki ol {margin-bottom:1em;}
799 800
800 801 div.wiki pre {
801 802 margin: 1em 1em 1em 1.6em;
802 803 padding: 8px;
803 804 background-color: #fafafa;
804 805 border: 1px solid #e2e2e2;
805 806 width:auto;
806 807 overflow-x: auto;
807 808 overflow-y: hidden;
808 809 }
809 810
810 811 div.wiki ul.toc {
811 812 background-color: #ffffdd;
812 813 border: 1px solid #e4e4e4;
813 814 padding: 4px;
814 815 line-height: 1.2em;
815 816 margin-bottom: 12px;
816 817 margin-right: 12px;
817 818 margin-left: 0;
818 819 display: table
819 820 }
820 821 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
821 822
822 823 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
823 824 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
824 825 div.wiki ul.toc ul { margin: 0; padding: 0; }
825 826 div.wiki ul.toc li {list-style-type:none; margin: 0; font-size:12px;}
826 827 div.wiki ul.toc li li {margin-left: 1.5em; font-size:10px;}
827 828 div.wiki ul.toc a {
828 829 font-size: 0.9em;
829 830 font-weight: normal;
830 831 text-decoration: none;
831 832 color: #606060;
832 833 }
833 834 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
834 835
835 836 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
836 837 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
837 838 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
838 839
839 840 div.wiki img { vertical-align: middle; }
840 841
841 842 /***** My page layout *****/
842 843 .block-receiver {
843 844 border:1px dashed #c0c0c0;
844 845 margin-bottom: 20px;
845 846 padding: 15px 0 15px 0;
846 847 }
847 848
848 849 .mypage-box {
849 850 margin:0 0 20px 0;
850 851 color:#505050;
851 852 line-height:1.5em;
852 853 }
853 854
854 855 .handle {cursor: move;}
855 856
856 857 a.close-icon {
857 858 display:block;
858 859 margin-top:3px;
859 860 overflow:hidden;
860 861 width:12px;
861 862 height:12px;
862 863 background-repeat: no-repeat;
863 864 cursor:pointer;
864 865 background-image:url('../images/close.png');
865 866 }
866 867 a.close-icon:hover {background-image:url('../images/close_hl.png');}
867 868
868 869 /***** Gantt chart *****/
869 870 .gantt_hdr {
870 871 position:absolute;
871 872 top:0;
872 873 height:16px;
873 874 border-top: 1px solid #c0c0c0;
874 875 border-bottom: 1px solid #c0c0c0;
875 876 border-right: 1px solid #c0c0c0;
876 877 text-align: center;
877 878 overflow: hidden;
878 879 }
879 880
880 881 .gantt_subjects { font-size: 0.8em; }
881 882 .gantt_subjects div { line-height:16px;height:16px;overflow:hidden;white-space:nowrap;text-overflow: ellipsis; }
882 883
883 884 .task {
884 885 position: absolute;
885 886 height:8px;
886 887 font-size:0.8em;
887 888 color:#888;
888 889 padding:0;
889 890 margin:0;
890 891 line-height:16px;
891 892 white-space:nowrap;
892 893 }
893 894
894 895 .task.label {width:100%;}
895 896 .task.label.project, .task.label.version { font-weight: bold; }
896 897
897 898 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
898 899 .task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
899 900 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
900 901
901 902 .task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
902 903 .task_late.parent, .task_done.parent { height: 3px;}
903 904 .task.parent.marker.starting { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; left: 0px; top: -1px;}
904 905 .task.parent.marker.ending { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;}
905 906
906 907 .version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
907 908 .version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
908 909 .version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
909 910 .version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
910 911
911 912 .project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
912 913 .project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
913 914 .project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
914 915 .project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
915 916
916 917 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
917 918 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
918 919
919 920 /***** Icons *****/
920 921 .icon {
921 922 background-position: 0% 50%;
922 923 background-repeat: no-repeat;
923 924 padding-left: 20px;
924 925 padding-top: 2px;
925 926 padding-bottom: 3px;
926 927 }
927 928
928 929 .icon-add { background-image: url(../images/add.png); }
929 930 .icon-edit { background-image: url(../images/edit.png); }
930 931 .icon-copy { background-image: url(../images/copy.png); }
931 932 .icon-duplicate { background-image: url(../images/duplicate.png); }
932 933 .icon-del { background-image: url(../images/delete.png); }
933 934 .icon-move { background-image: url(../images/move.png); }
934 935 .icon-save { background-image: url(../images/save.png); }
935 936 .icon-cancel { background-image: url(../images/cancel.png); }
936 937 .icon-multiple { background-image: url(../images/table_multiple.png); }
937 938 .icon-folder { background-image: url(../images/folder.png); }
938 939 .open .icon-folder { background-image: url(../images/folder_open.png); }
939 940 .icon-package { background-image: url(../images/package.png); }
940 941 .icon-user { background-image: url(../images/user.png); }
941 942 .icon-projects { background-image: url(../images/projects.png); }
942 943 .icon-help { background-image: url(../images/help.png); }
943 944 .icon-attachment { background-image: url(../images/attachment.png); }
944 945 .icon-history { background-image: url(../images/history.png); }
945 946 .icon-time { background-image: url(../images/time.png); }
946 947 .icon-time-add { background-image: url(../images/time_add.png); }
947 948 .icon-stats { background-image: url(../images/stats.png); }
948 949 .icon-warning { background-image: url(../images/warning.png); }
949 950 .icon-fav { background-image: url(../images/fav.png); }
950 951 .icon-fav-off { background-image: url(../images/fav_off.png); }
951 952 .icon-reload { background-image: url(../images/reload.png); }
952 953 .icon-lock { background-image: url(../images/locked.png); }
953 954 .icon-unlock { background-image: url(../images/unlock.png); }
954 955 .icon-checked { background-image: url(../images/true.png); }
955 956 .icon-details { background-image: url(../images/zoom_in.png); }
956 957 .icon-report { background-image: url(../images/report.png); }
957 958 .icon-comment { background-image: url(../images/comment.png); }
958 959 .icon-summary { background-image: url(../images/lightning.png); }
959 960 .icon-server-authentication { background-image: url(../images/server_key.png); }
960 961 .icon-issue { background-image: url(../images/ticket.png); }
961 962 .icon-zoom-in { background-image: url(../images/zoom_in.png); }
962 963 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
963 964 .icon-passwd { background-image: url(../images/textfield_key.png); }
964 965 .icon-test { background-image: url(../images/bullet_go.png); }
965 966
966 967 .icon-file { background-image: url(../images/files/default.png); }
967 968 .icon-file.text-plain { background-image: url(../images/files/text.png); }
968 969 .icon-file.text-x-c { background-image: url(../images/files/c.png); }
969 970 .icon-file.text-x-csharp { background-image: url(../images/files/csharp.png); }
970 971 .icon-file.text-x-java { background-image: url(../images/files/java.png); }
971 972 .icon-file.text-x-javascript { background-image: url(../images/files/js.png); }
972 973 .icon-file.text-x-php { background-image: url(../images/files/php.png); }
973 974 .icon-file.text-x-ruby { background-image: url(../images/files/ruby.png); }
974 975 .icon-file.text-xml { background-image: url(../images/files/xml.png); }
975 976 .icon-file.text-css { background-image: url(../images/files/css.png); }
976 977 .icon-file.text-html { background-image: url(../images/files/html.png); }
977 978 .icon-file.image-gif { background-image: url(../images/files/image.png); }
978 979 .icon-file.image-jpeg { background-image: url(../images/files/image.png); }
979 980 .icon-file.image-png { background-image: url(../images/files/image.png); }
980 981 .icon-file.image-tiff { background-image: url(../images/files/image.png); }
981 982 .icon-file.application-pdf { background-image: url(../images/files/pdf.png); }
982 983 .icon-file.application-zip { background-image: url(../images/files/zip.png); }
983 984 .icon-file.application-x-gzip { background-image: url(../images/files/zip.png); }
984 985
985 986 img.gravatar {
986 987 padding: 2px;
987 988 border: solid 1px #d5d5d5;
988 989 background: #fff;
989 990 vertical-align: middle;
990 991 }
991 992
992 993 div.issue img.gravatar {
993 994 float: left;
994 995 margin: 0 6px 0 0;
995 996 padding: 5px;
996 997 }
997 998
998 999 div.issue table img.gravatar {
999 1000 height: 14px;
1000 1001 width: 14px;
1001 1002 padding: 2px;
1002 1003 float: left;
1003 1004 margin: 0 0.5em 0 0;
1004 1005 }
1005 1006
1006 1007 h2 img.gravatar {margin: -2px 4px -4px 0;}
1007 1008 h3 img.gravatar {margin: -4px 4px -4px 0;}
1008 1009 h4 img.gravatar {margin: -6px 4px -4px 0;}
1009 1010 td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
1010 1011 #activity dt img.gravatar {float: left; margin: 0 1em 1em 0;}
1011 1012 /* Used on 12px Gravatar img tags without the icon background */
1012 1013 .icon-gravatar {float: left; margin-right: 4px;}
1013 1014
1014 1015 #activity dt, .journal {clear: left;}
1015 1016
1016 1017 .journal-link {float: right;}
1017 1018
1018 1019 h2 img { vertical-align:middle; }
1019 1020
1020 1021 .hascontextmenu { cursor: context-menu; }
1021 1022
1022 1023 /************* CodeRay styles *************/
1023 1024 .syntaxhl div {display: inline;}
1024 1025 .syntaxhl .line-numbers {padding: 2px 4px 2px 4px; background-color: #eee; margin:0px 5px 0px 0px;}
1025 1026 .syntaxhl .code pre { overflow: auto }
1026 1027 .syntaxhl .debug { color: white !important; background: blue !important; }
1027 1028
1028 1029 .syntaxhl .annotation { color:#007 }
1029 1030 .syntaxhl .attribute-name { color:#b48 }
1030 1031 .syntaxhl .attribute-value { color:#700 }
1031 1032 .syntaxhl .binary { color:#509 }
1032 1033 .syntaxhl .char .content { color:#D20 }
1033 1034 .syntaxhl .char .delimiter { color:#710 }
1034 1035 .syntaxhl .char { color:#D20 }
1035 1036 .syntaxhl .class { color:#258; font-weight:bold }
1036 1037 .syntaxhl .class-variable { color:#369 }
1037 1038 .syntaxhl .color { color:#0A0 }
1038 1039 .syntaxhl .comment { color:#385 }
1039 1040 .syntaxhl .comment .char { color:#385 }
1040 1041 .syntaxhl .comment .delimiter { color:#385 }
1041 1042 .syntaxhl .complex { color:#A08 }
1042 1043 .syntaxhl .constant { color:#258; font-weight:bold }
1043 1044 .syntaxhl .decorator { color:#B0B }
1044 1045 .syntaxhl .definition { color:#099; font-weight:bold }
1045 1046 .syntaxhl .delimiter { color:black }
1046 1047 .syntaxhl .directive { color:#088; font-weight:bold }
1047 1048 .syntaxhl .doc { color:#970 }
1048 1049 .syntaxhl .doc-string { color:#D42; font-weight:bold }
1049 1050 .syntaxhl .doctype { color:#34b }
1050 1051 .syntaxhl .entity { color:#800; font-weight:bold }
1051 1052 .syntaxhl .error { color:#F00; background-color:#FAA }
1052 1053 .syntaxhl .escape { color:#666 }
1053 1054 .syntaxhl .exception { color:#C00; font-weight:bold }
1054 1055 .syntaxhl .float { color:#06D }
1055 1056 .syntaxhl .function { color:#06B; font-weight:bold }
1056 1057 .syntaxhl .global-variable { color:#d70 }
1057 1058 .syntaxhl .hex { color:#02b }
1058 1059 .syntaxhl .imaginary { color:#f00 }
1059 1060 .syntaxhl .include { color:#B44; font-weight:bold }
1060 1061 .syntaxhl .inline { background-color: hsla(0,0%,0%,0.07); color: black }
1061 1062 .syntaxhl .inline-delimiter { font-weight: bold; color: #666 }
1062 1063 .syntaxhl .instance-variable { color:#33B }
1063 1064 .syntaxhl .integer { color:#06D }
1064 1065 .syntaxhl .key .char { color: #60f }
1065 1066 .syntaxhl .key .delimiter { color: #404 }
1066 1067 .syntaxhl .key { color: #606 }
1067 1068 .syntaxhl .keyword { color:#939; font-weight:bold }
1068 1069 .syntaxhl .label { color:#970; font-weight:bold }
1069 1070 .syntaxhl .local-variable { color:#963 }
1070 1071 .syntaxhl .namespace { color:#707; font-weight:bold }
1071 1072 .syntaxhl .octal { color:#40E }
1072 1073 .syntaxhl .operator { }
1073 1074 .syntaxhl .predefined { color:#369; font-weight:bold }
1074 1075 .syntaxhl .predefined-constant { color:#069 }
1075 1076 .syntaxhl .predefined-type { color:#0a5; font-weight:bold }
1076 1077 .syntaxhl .preprocessor { color:#579 }
1077 1078 .syntaxhl .pseudo-class { color:#00C; font-weight:bold }
1078 1079 .syntaxhl .regexp .content { color:#808 }
1079 1080 .syntaxhl .regexp .delimiter { color:#404 }
1080 1081 .syntaxhl .regexp .modifier { color:#C2C }
1081 1082 .syntaxhl .regexp { background-color:hsla(300,100%,50%,0.06); }
1082 1083 .syntaxhl .reserved { color:#080; font-weight:bold }
1083 1084 .syntaxhl .shell .content { color:#2B2 }
1084 1085 .syntaxhl .shell .delimiter { color:#161 }
1085 1086 .syntaxhl .shell { background-color:hsla(120,100%,50%,0.06); }
1086 1087 .syntaxhl .string .char { color: #46a }
1087 1088 .syntaxhl .string .content { color: #46a }
1088 1089 .syntaxhl .string .delimiter { color: #46a }
1089 1090 .syntaxhl .string .modifier { color: #46a }
1090 1091 .syntaxhl .symbol .content { color:#d33 }
1091 1092 .syntaxhl .symbol .delimiter { color:#d33 }
1092 1093 .syntaxhl .symbol { color:#d33 }
1093 1094 .syntaxhl .tag { color:#070 }
1094 1095 .syntaxhl .type { color:#339; font-weight:bold }
1095 1096 .syntaxhl .value { color: #088; }
1096 1097 .syntaxhl .variable { color:#037 }
1097 1098
1098 1099 .syntaxhl .insert { background: hsla(120,100%,50%,0.12) }
1099 1100 .syntaxhl .delete { background: hsla(0,100%,50%,0.12) }
1100 1101 .syntaxhl .change { color: #bbf; background: #007; }
1101 1102 .syntaxhl .head { color: #f8f; background: #505 }
1102 1103 .syntaxhl .head .filename { color: white; }
1103 1104
1104 1105 .syntaxhl .delete .eyecatcher { background-color: hsla(0,100%,50%,0.2); border: 1px solid hsla(0,100%,45%,0.5); margin: -1px; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; }
1105 1106 .syntaxhl .insert .eyecatcher { background-color: hsla(120,100%,50%,0.2); border: 1px solid hsla(120,100%,25%,0.5); margin: -1px; border-top: none; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; }
1106 1107
1107 1108 .syntaxhl .insert .insert { color: #0c0; background:transparent; font-weight:bold }
1108 1109 .syntaxhl .delete .delete { color: #c00; background:transparent; font-weight:bold }
1109 1110 .syntaxhl .change .change { color: #88f }
1110 1111 .syntaxhl .head .head { color: #f4f }
1111 1112
1112 1113 /***** Media print specific styles *****/
1113 1114 @media print {
1114 1115 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
1115 1116 #main { background: #fff; }
1116 1117 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
1117 1118 #wiki_add_attachment { display:none; }
1118 1119 .hide-when-print { display: none; }
1119 1120 .autoscroll {overflow-x: visible;}
1120 1121 table.list {margin-top:0.5em;}
1121 1122 table.list th, table.list td {border: 1px solid #aaa;}
1122 1123 }
1123 1124
1124 1125 /* Accessibility specific styles */
1125 1126 .hidden-for-sighted {
1126 1127 position:absolute;
1127 1128 left:-10000px;
1128 1129 top:auto;
1129 1130 width:1px;
1130 1131 height:1px;
1131 1132 overflow:hidden;
1132 1133 }
@@ -1,480 +1,489
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 'shoulda'
19 19 ENV["RAILS_ENV"] = "test"
20 20 require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
21 21 require 'rails/test_help'
22 22 require Rails.root.join('test', 'mocks', 'open_id_authentication_mock.rb').to_s
23 23
24 24 require File.expand_path(File.dirname(__FILE__) + '/object_helpers')
25 25 include ObjectHelpers
26 26
27 27 class ActiveSupport::TestCase
28 28 include ActionDispatch::TestProcess
29 29
30 30 # Transactional fixtures accelerate your tests by wrapping each test method
31 31 # in a transaction that's rolled back on completion. This ensures that the
32 32 # test database remains unchanged so your fixtures don't have to be reloaded
33 33 # between every test method. Fewer database queries means faster tests.
34 34 #
35 35 # Read Mike Clark's excellent walkthrough at
36 36 # http://clarkware.com/cgi/blosxom/2005/10/24#Rails10FastTesting
37 37 #
38 38 # Every Active Record database supports transactions except MyISAM tables
39 39 # in MySQL. Turn off transactional fixtures in this case; however, if you
40 40 # don't care one way or the other, switching from MyISAM to InnoDB tables
41 41 # is recommended.
42 42 self.use_transactional_fixtures = true
43 43
44 44 # Instantiated fixtures are slow, but give you @david where otherwise you
45 45 # would need people(:david). If you don't want to migrate your existing
46 46 # test cases which use the @david style and don't mind the speed hit (each
47 47 # instantiated fixtures translates to a database query per test method),
48 48 # then set this back to true.
49 49 self.use_instantiated_fixtures = false
50 50
51 51 # Add more helper methods to be used by all tests here...
52 52
53 53 def log_user(login, password)
54 54 User.anonymous
55 55 get "/login"
56 56 assert_equal nil, session[:user_id]
57 57 assert_response :success
58 58 assert_template "account/login"
59 59 post "/login", :username => login, :password => password
60 60 assert_equal login, User.find(session[:user_id]).login
61 61 end
62 62
63 63 def uploaded_test_file(name, mime)
64 64 fixture_file_upload("files/#{name}", mime, true)
65 65 end
66 66
67 67 def credentials(user, password=nil)
68 68 {'HTTP_AUTHORIZATION' => ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)}
69 69 end
70 70
71 71 # Mock out a file
72 72 def self.mock_file
73 73 file = 'a_file.png'
74 74 file.stubs(:size).returns(32)
75 75 file.stubs(:original_filename).returns('a_file.png')
76 76 file.stubs(:content_type).returns('image/png')
77 77 file.stubs(:read).returns(false)
78 78 file
79 79 end
80 80
81 81 def mock_file
82 82 self.class.mock_file
83 83 end
84 84
85 85 def mock_file_with_options(options={})
86 86 file = ''
87 87 file.stubs(:size).returns(32)
88 88 original_filename = options[:original_filename] || nil
89 89 file.stubs(:original_filename).returns(original_filename)
90 90 content_type = options[:content_type] || nil
91 91 file.stubs(:content_type).returns(content_type)
92 92 file.stubs(:read).returns(false)
93 93 file
94 94 end
95 95
96 96 # Use a temporary directory for attachment related tests
97 97 def set_tmp_attachments_directory
98 98 Dir.mkdir "#{Rails.root}/tmp/test" unless File.directory?("#{Rails.root}/tmp/test")
99 99 unless File.directory?("#{Rails.root}/tmp/test/attachments")
100 100 Dir.mkdir "#{Rails.root}/tmp/test/attachments"
101 101 end
102 102 Attachment.storage_path = "#{Rails.root}/tmp/test/attachments"
103 103 end
104 104
105 105 def set_fixtures_attachments_directory
106 106 Attachment.storage_path = "#{Rails.root}/test/fixtures/files"
107 107 end
108 108
109 109 def with_settings(options, &block)
110 110 saved_settings = options.keys.inject({}) {|h, k| h[k] = Setting[k].is_a?(Symbol) ? Setting[k] : Setting[k].dup; h}
111 111 options.each {|k, v| Setting[k] = v}
112 112 yield
113 113 ensure
114 114 saved_settings.each {|k, v| Setting[k] = v} if saved_settings
115 115 end
116 116
117 # Yields the block with user as the current user
118 def with_current_user(user, &block)
119 saved_user = User.current
120 User.current = user
121 yield
122 ensure
123 User.current = saved_user
124 end
125
117 126 def change_user_password(login, new_password)
118 127 user = User.first(:conditions => {:login => login})
119 128 user.password, user.password_confirmation = new_password, new_password
120 129 user.save!
121 130 end
122 131
123 132 def self.ldap_configured?
124 133 @test_ldap = Net::LDAP.new(:host => '127.0.0.1', :port => 389)
125 134 return @test_ldap.bind
126 135 rescue Exception => e
127 136 # LDAP is not listening
128 137 return nil
129 138 end
130 139
131 140 def self.convert_installed?
132 141 Redmine::Thumbnail.convert_available?
133 142 end
134 143
135 144 # Returns the path to the test +vendor+ repository
136 145 def self.repository_path(vendor)
137 146 Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s
138 147 end
139 148
140 149 # Returns the url of the subversion test repository
141 150 def self.subversion_repository_url
142 151 path = repository_path('subversion')
143 152 path = '/' + path unless path.starts_with?('/')
144 153 "file://#{path}"
145 154 end
146 155
147 156 # Returns true if the +vendor+ test repository is configured
148 157 def self.repository_configured?(vendor)
149 158 File.directory?(repository_path(vendor))
150 159 end
151 160
152 161 def repository_path_hash(arr)
153 162 hs = {}
154 163 hs[:path] = arr.join("/")
155 164 hs[:param] = arr.join("/")
156 165 hs
157 166 end
158 167
159 168 def assert_save(object)
160 169 saved = object.save
161 170 message = "#{object.class} could not be saved"
162 171 errors = object.errors.full_messages.map {|m| "- #{m}"}
163 172 message << ":\n#{errors.join("\n")}" if errors.any?
164 173 assert_equal true, saved, message
165 174 end
166 175
167 176 def assert_error_tag(options={})
168 177 assert_tag({:attributes => { :id => 'errorExplanation' }}.merge(options))
169 178 end
170 179
171 180 def assert_include(expected, s, message=nil)
172 181 assert s.include?(expected), (message || "\"#{expected}\" not found in \"#{s}\"")
173 182 end
174 183
175 184 def assert_not_include(expected, s)
176 185 assert !s.include?(expected), "\"#{expected}\" found in \"#{s}\""
177 186 end
178 187
179 188 def assert_mail_body_match(expected, mail)
180 189 if expected.is_a?(String)
181 190 assert_include expected, mail_body(mail)
182 191 else
183 192 assert_match expected, mail_body(mail)
184 193 end
185 194 end
186 195
187 196 def assert_mail_body_no_match(expected, mail)
188 197 if expected.is_a?(String)
189 198 assert_not_include expected, mail_body(mail)
190 199 else
191 200 assert_no_match expected, mail_body(mail)
192 201 end
193 202 end
194 203
195 204 def mail_body(mail)
196 205 mail.parts.first.body.encoded
197 206 end
198 207
199 208 # Shoulda macros
200 209 def self.should_render_404
201 210 should_respond_with :not_found
202 211 should_render_template 'common/error'
203 212 end
204 213
205 214 def self.should_have_before_filter(expected_method, options = {})
206 215 should_have_filter('before', expected_method, options)
207 216 end
208 217
209 218 def self.should_have_after_filter(expected_method, options = {})
210 219 should_have_filter('after', expected_method, options)
211 220 end
212 221
213 222 def self.should_have_filter(filter_type, expected_method, options)
214 223 description = "have #{filter_type}_filter :#{expected_method}"
215 224 description << " with #{options.inspect}" unless options.empty?
216 225
217 226 should description do
218 227 klass = "action_controller/filters/#{filter_type}_filter".classify.constantize
219 228 expected = klass.new(:filter, expected_method.to_sym, options)
220 229 assert_equal 1, @controller.class.filter_chain.select { |filter|
221 230 filter.method == expected.method && filter.kind == expected.kind &&
222 231 filter.options == expected.options && filter.class == expected.class
223 232 }.size
224 233 end
225 234 end
226 235
227 236 # Test that a request allows the three types of API authentication
228 237 #
229 238 # * HTTP Basic with username and password
230 239 # * HTTP Basic with an api key for the username
231 240 # * Key based with the key=X parameter
232 241 #
233 242 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
234 243 # @param [String] url the request url
235 244 # @param [optional, Hash] parameters additional request parameters
236 245 # @param [optional, Hash] options additional options
237 246 # @option options [Symbol] :success_code Successful response code (:success)
238 247 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
239 248 def self.should_allow_api_authentication(http_method, url, parameters={}, options={})
240 249 should_allow_http_basic_auth_with_username_and_password(http_method, url, parameters, options)
241 250 should_allow_http_basic_auth_with_key(http_method, url, parameters, options)
242 251 should_allow_key_based_auth(http_method, url, parameters, options)
243 252 end
244 253
245 254 # Test that a request allows the username and password for HTTP BASIC
246 255 #
247 256 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
248 257 # @param [String] url the request url
249 258 # @param [optional, Hash] parameters additional request parameters
250 259 # @param [optional, Hash] options additional options
251 260 # @option options [Symbol] :success_code Successful response code (:success)
252 261 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
253 262 def self.should_allow_http_basic_auth_with_username_and_password(http_method, url, parameters={}, options={})
254 263 success_code = options[:success_code] || :success
255 264 failure_code = options[:failure_code] || :unauthorized
256 265
257 266 context "should allow http basic auth using a username and password for #{http_method} #{url}" do
258 267 context "with a valid HTTP authentication" do
259 268 setup do
260 269 @user = User.generate! do |user|
261 270 user.admin = true
262 271 user.password = 'my_password'
263 272 end
264 273 send(http_method, url, parameters, credentials(@user.login, 'my_password'))
265 274 end
266 275
267 276 should_respond_with success_code
268 277 should_respond_with_content_type_based_on_url(url)
269 278 should "login as the user" do
270 279 assert_equal @user, User.current
271 280 end
272 281 end
273 282
274 283 context "with an invalid HTTP authentication" do
275 284 setup do
276 285 @user = User.generate!
277 286 send(http_method, url, parameters, credentials(@user.login, 'wrong_password'))
278 287 end
279 288
280 289 should_respond_with failure_code
281 290 should_respond_with_content_type_based_on_url(url)
282 291 should "not login as the user" do
283 292 assert_equal User.anonymous, User.current
284 293 end
285 294 end
286 295
287 296 context "without credentials" do
288 297 setup do
289 298 send(http_method, url, parameters)
290 299 end
291 300
292 301 should_respond_with failure_code
293 302 should_respond_with_content_type_based_on_url(url)
294 303 should "include_www_authenticate_header" do
295 304 assert @controller.response.headers.has_key?('WWW-Authenticate')
296 305 end
297 306 end
298 307 end
299 308 end
300 309
301 310 # Test that a request allows the API key with HTTP BASIC
302 311 #
303 312 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
304 313 # @param [String] url the request url
305 314 # @param [optional, Hash] parameters additional request parameters
306 315 # @param [optional, Hash] options additional options
307 316 # @option options [Symbol] :success_code Successful response code (:success)
308 317 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
309 318 def self.should_allow_http_basic_auth_with_key(http_method, url, parameters={}, options={})
310 319 success_code = options[:success_code] || :success
311 320 failure_code = options[:failure_code] || :unauthorized
312 321
313 322 context "should allow http basic auth with a key for #{http_method} #{url}" do
314 323 context "with a valid HTTP authentication using the API token" do
315 324 setup do
316 325 @user = User.generate! do |user|
317 326 user.admin = true
318 327 end
319 328 @token = Token.create!(:user => @user, :action => 'api')
320 329 send(http_method, url, parameters, credentials(@token.value, 'X'))
321 330 end
322 331 should_respond_with success_code
323 332 should_respond_with_content_type_based_on_url(url)
324 333 should_be_a_valid_response_string_based_on_url(url)
325 334 should "login as the user" do
326 335 assert_equal @user, User.current
327 336 end
328 337 end
329 338
330 339 context "with an invalid HTTP authentication" do
331 340 setup do
332 341 @user = User.generate!
333 342 @token = Token.create!(:user => @user, :action => 'feeds')
334 343 send(http_method, url, parameters, credentials(@token.value, 'X'))
335 344 end
336 345 should_respond_with failure_code
337 346 should_respond_with_content_type_based_on_url(url)
338 347 should "not login as the user" do
339 348 assert_equal User.anonymous, User.current
340 349 end
341 350 end
342 351 end
343 352 end
344 353
345 354 # Test that a request allows full key authentication
346 355 #
347 356 # @param [Symbol] http_method the HTTP method for request (:get, :post, :put, :delete)
348 357 # @param [String] url the request url, without the key=ZXY parameter
349 358 # @param [optional, Hash] parameters additional request parameters
350 359 # @param [optional, Hash] options additional options
351 360 # @option options [Symbol] :success_code Successful response code (:success)
352 361 # @option options [Symbol] :failure_code Failure response code (:unauthorized)
353 362 def self.should_allow_key_based_auth(http_method, url, parameters={}, options={})
354 363 success_code = options[:success_code] || :success
355 364 failure_code = options[:failure_code] || :unauthorized
356 365
357 366 context "should allow key based auth using key=X for #{http_method} #{url}" do
358 367 context "with a valid api token" do
359 368 setup do
360 369 @user = User.generate! do |user|
361 370 user.admin = true
362 371 end
363 372 @token = Token.create!(:user => @user, :action => 'api')
364 373 # Simple url parse to add on ?key= or &key=
365 374 request_url = if url.match(/\?/)
366 375 url + "&key=#{@token.value}"
367 376 else
368 377 url + "?key=#{@token.value}"
369 378 end
370 379 send(http_method, request_url, parameters)
371 380 end
372 381 should_respond_with success_code
373 382 should_respond_with_content_type_based_on_url(url)
374 383 should_be_a_valid_response_string_based_on_url(url)
375 384 should "login as the user" do
376 385 assert_equal @user, User.current
377 386 end
378 387 end
379 388
380 389 context "with an invalid api token" do
381 390 setup do
382 391 @user = User.generate! do |user|
383 392 user.admin = true
384 393 end
385 394 @token = Token.create!(:user => @user, :action => 'feeds')
386 395 # Simple url parse to add on ?key= or &key=
387 396 request_url = if url.match(/\?/)
388 397 url + "&key=#{@token.value}"
389 398 else
390 399 url + "?key=#{@token.value}"
391 400 end
392 401 send(http_method, request_url, parameters)
393 402 end
394 403 should_respond_with failure_code
395 404 should_respond_with_content_type_based_on_url(url)
396 405 should "not login as the user" do
397 406 assert_equal User.anonymous, User.current
398 407 end
399 408 end
400 409 end
401 410
402 411 context "should allow key based auth using X-Redmine-API-Key header for #{http_method} #{url}" do
403 412 setup do
404 413 @user = User.generate! do |user|
405 414 user.admin = true
406 415 end
407 416 @token = Token.create!(:user => @user, :action => 'api')
408 417 send(http_method, url, parameters, {'X-Redmine-API-Key' => @token.value.to_s})
409 418 end
410 419 should_respond_with success_code
411 420 should_respond_with_content_type_based_on_url(url)
412 421 should_be_a_valid_response_string_based_on_url(url)
413 422 should "login as the user" do
414 423 assert_equal @user, User.current
415 424 end
416 425 end
417 426 end
418 427
419 428 # Uses should_respond_with_content_type based on what's in the url:
420 429 #
421 430 # '/project/issues.xml' => should_respond_with_content_type :xml
422 431 # '/project/issues.json' => should_respond_with_content_type :json
423 432 #
424 433 # @param [String] url Request
425 434 def self.should_respond_with_content_type_based_on_url(url)
426 435 case
427 436 when url.match(/xml/i)
428 437 should "respond with XML" do
429 438 assert_equal 'application/xml', @response.content_type
430 439 end
431 440 when url.match(/json/i)
432 441 should "respond with JSON" do
433 442 assert_equal 'application/json', @response.content_type
434 443 end
435 444 else
436 445 raise "Unknown content type for should_respond_with_content_type_based_on_url: #{url}"
437 446 end
438 447 end
439 448
440 449 # Uses the url to assert which format the response should be in
441 450 #
442 451 # '/project/issues.xml' => should_be_a_valid_xml_string
443 452 # '/project/issues.json' => should_be_a_valid_json_string
444 453 #
445 454 # @param [String] url Request
446 455 def self.should_be_a_valid_response_string_based_on_url(url)
447 456 case
448 457 when url.match(/xml/i)
449 458 should_be_a_valid_xml_string
450 459 when url.match(/json/i)
451 460 should_be_a_valid_json_string
452 461 else
453 462 raise "Unknown content type for should_be_a_valid_response_based_on_url: #{url}"
454 463 end
455 464 end
456 465
457 466 # Checks that the response is a valid JSON string
458 467 def self.should_be_a_valid_json_string
459 468 should "be a valid JSON string (or empty)" do
460 469 assert(response.body.blank? || ActiveSupport::JSON.decode(response.body))
461 470 end
462 471 end
463 472
464 473 # Checks that the response is a valid XML string
465 474 def self.should_be_a_valid_xml_string
466 475 should "be a valid XML string" do
467 476 assert REXML::Document.new(response.body)
468 477 end
469 478 end
470 479
471 480 def self.should_respond_with(status)
472 481 should "respond with #{status}" do
473 482 assert_response status
474 483 end
475 484 end
476 485 end
477 486
478 487 # Simple module to "namespace" all of the API tests
479 488 module ApiTest
480 489 end
@@ -1,1133 +1,1141
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 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 ERB::Util
24 24
25 25 fixtures :projects, :roles, :enabled_modules, :users,
26 26 :repositories, :changesets,
27 27 :trackers, :issue_statuses, :issues, :versions, :documents,
28 28 :wikis, :wiki_pages, :wiki_contents,
29 29 :boards, :messages, :news,
30 30 :attachments, :enumerations
31 31
32 32 def setup
33 33 super
34 34 set_tmp_attachments_directory
35 35 end
36 36
37 37 context "#link_to_if_authorized" do
38 38 context "authorized user" do
39 39 should "be tested"
40 40 end
41 41
42 42 context "unauthorized user" do
43 43 should "be tested"
44 44 end
45 45
46 46 should "allow using the :controller and :action for the target link" do
47 47 User.current = User.find_by_login('admin')
48 48
49 49 @project = Issue.first.project # Used by helper
50 50 response = link_to_if_authorized("By controller/action",
51 51 {:controller => 'issues', :action => 'edit', :id => Issue.first.id})
52 52 assert_match /href/, response
53 53 end
54 54
55 55 end
56 56
57 57 def test_auto_links
58 58 to_test = {
59 59 'http://foo.bar' => '<a class="external" href="http://foo.bar">http://foo.bar</a>',
60 60 'http://foo.bar/~user' => '<a class="external" href="http://foo.bar/~user">http://foo.bar/~user</a>',
61 61 'http://foo.bar.' => '<a class="external" href="http://foo.bar">http://foo.bar</a>.',
62 62 'https://foo.bar.' => '<a class="external" href="https://foo.bar">https://foo.bar</a>.',
63 63 'This is a link: http://foo.bar.' => 'This is a link: <a class="external" href="http://foo.bar">http://foo.bar</a>.',
64 64 'A link (eg. http://foo.bar).' => 'A link (eg. <a class="external" href="http://foo.bar">http://foo.bar</a>).',
65 65 '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>.',
66 66 'http://www.foo.bar/Test_(foobar)' => '<a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>',
67 67 '(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>)',
68 68 '(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>)',
69 69 '(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>).',
70 70 '(see "inline link":http://www.foo.bar/Test_(foobar))' => '(see <a href="http://www.foo.bar/Test_(foobar)" class="external">inline link</a>)',
71 71 '(see "inline link":http://www.foo.bar/Test)' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>)',
72 72 '(see "inline link":http://www.foo.bar/Test).' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>).',
73 73 'www.foo.bar' => '<a class="external" href="http://www.foo.bar">www.foo.bar</a>',
74 74 '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>',
75 75 'http://foo.bar/page#125' => '<a class="external" href="http://foo.bar/page#125">http://foo.bar/page#125</a>',
76 76 'http://foo@www.bar.com' => '<a class="external" href="http://foo@www.bar.com">http://foo@www.bar.com</a>',
77 77 'http://foo:bar@www.bar.com' => '<a class="external" href="http://foo:bar@www.bar.com">http://foo:bar@www.bar.com</a>',
78 78 'ftp://foo.bar' => '<a class="external" href="ftp://foo.bar">ftp://foo.bar</a>',
79 79 'ftps://foo.bar' => '<a class="external" href="ftps://foo.bar">ftps://foo.bar</a>',
80 80 'sftp://foo.bar' => '<a class="external" href="sftp://foo.bar">sftp://foo.bar</a>',
81 81 # two exclamation marks
82 82 '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>',
83 83 # escaping
84 84 'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo&quot;bar</a>',
85 85 # wrap in angle brackets
86 86 '<http://foo.bar>' => '&lt;<a class="external" href="http://foo.bar">http://foo.bar</a>&gt;'
87 87 }
88 88 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
89 89 end
90 90
91 91 def test_auto_mailto
92 92 assert_equal '<p><a class="email" href="mailto:test@foo.bar">test@foo.bar</a></p>',
93 93 textilizable('test@foo.bar')
94 94 end
95 95
96 96 def test_inline_images
97 97 to_test = {
98 98 '!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="" />',
99 99 'floating !>http://foo.bar/image.jpg!' => 'floating <div style="float:right"><img src="http://foo.bar/image.jpg" alt="" /></div>',
100 100 'with class !(some-class)http://foo.bar/image.jpg!' => 'with class <img src="http://foo.bar/image.jpg" class="some-class" alt="" />',
101 101 '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="" />',
102 102 '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" />',
103 103 '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;" />',
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_inline_images_inside_tags
109 109 raw = <<-RAW
110 110 h1. !foo.png! Heading
111 111
112 112 Centered image:
113 113
114 114 p=. !bar.gif!
115 115 RAW
116 116
117 117 assert textilizable(raw).include?('<img src="foo.png" alt="" />')
118 118 assert textilizable(raw).include?('<img src="bar.gif" alt="" />')
119 119 end
120 120
121 121 def test_attached_images
122 122 to_test = {
123 123 'Inline image: !logo.gif!' => 'Inline image: <img src="/attachments/download/3" title="This is a logo" alt="This is a logo" />',
124 124 'Inline image: !logo.GIF!' => 'Inline image: <img src="/attachments/download/3" title="This is a logo" alt="This is a logo" />',
125 125 'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="" />',
126 126 'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="" />',
127 127 # link image
128 128 '!logo.gif!:http://foo.bar/' => '<a href="http://foo.bar/"><img src="/attachments/download/3" title="This is a logo" alt="This is a logo" /></a>',
129 129 }
130 130 attachments = Attachment.find(:all)
131 131 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
132 132 end
133 133
134 134 def test_attached_images_filename_extension
135 135 set_tmp_attachments_directory
136 136 a1 = Attachment.new(
137 137 :container => Issue.find(1),
138 138 :file => mock_file_with_options({:original_filename => "testtest.JPG"}),
139 139 :author => User.find(1))
140 140 assert a1.save
141 141 assert_equal "testtest.JPG", a1.filename
142 142 assert_equal "image/jpeg", a1.content_type
143 143 assert a1.image?
144 144
145 145 a2 = Attachment.new(
146 146 :container => Issue.find(1),
147 147 :file => mock_file_with_options({:original_filename => "testtest.jpeg"}),
148 148 :author => User.find(1))
149 149 assert a2.save
150 150 assert_equal "testtest.jpeg", a2.filename
151 151 assert_equal "image/jpeg", a2.content_type
152 152 assert a2.image?
153 153
154 154 a3 = Attachment.new(
155 155 :container => Issue.find(1),
156 156 :file => mock_file_with_options({:original_filename => "testtest.JPE"}),
157 157 :author => User.find(1))
158 158 assert a3.save
159 159 assert_equal "testtest.JPE", a3.filename
160 160 assert_equal "image/jpeg", a3.content_type
161 161 assert a3.image?
162 162
163 163 a4 = Attachment.new(
164 164 :container => Issue.find(1),
165 165 :file => mock_file_with_options({:original_filename => "Testtest.BMP"}),
166 166 :author => User.find(1))
167 167 assert a4.save
168 168 assert_equal "Testtest.BMP", a4.filename
169 169 assert_equal "image/x-ms-bmp", a4.content_type
170 170 assert a4.image?
171 171
172 172 to_test = {
173 173 'Inline image: !testtest.jpg!' =>
174 174 'Inline image: <img src="/attachments/download/' + a1.id.to_s + '" alt="" />',
175 175 'Inline image: !testtest.jpeg!' =>
176 176 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '" alt="" />',
177 177 'Inline image: !testtest.jpe!' =>
178 178 'Inline image: <img src="/attachments/download/' + a3.id.to_s + '" alt="" />',
179 179 'Inline image: !testtest.bmp!' =>
180 180 'Inline image: <img src="/attachments/download/' + a4.id.to_s + '" alt="" />',
181 181 }
182 182
183 183 attachments = [a1, a2, a3, a4]
184 184 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
185 185 end
186 186
187 187 def test_attached_images_should_read_later
188 188 set_fixtures_attachments_directory
189 189 a1 = Attachment.find(16)
190 190 assert_equal "testfile.png", a1.filename
191 191 assert a1.readable?
192 192 assert (! a1.visible?(User.anonymous))
193 193 assert a1.visible?(User.find(2))
194 194 a2 = Attachment.find(17)
195 195 assert_equal "testfile.PNG", a2.filename
196 196 assert a2.readable?
197 197 assert (! a2.visible?(User.anonymous))
198 198 assert a2.visible?(User.find(2))
199 199 assert a1.created_on < a2.created_on
200 200
201 201 to_test = {
202 202 'Inline image: !testfile.png!' =>
203 203 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '" alt="" />',
204 204 'Inline image: !Testfile.PNG!' =>
205 205 'Inline image: <img src="/attachments/download/' + a2.id.to_s + '" alt="" />',
206 206 }
207 207 attachments = [a1, a2]
208 208 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
209 209 set_tmp_attachments_directory
210 210 end
211 211
212 212 def test_textile_external_links
213 213 to_test = {
214 214 'This is a "link":http://foo.bar' => 'This is a <a href="http://foo.bar" class="external">link</a>',
215 215 'This is an intern "link":/foo/bar' => 'This is an intern <a href="/foo/bar">link</a>',
216 216 '"link (Link title)":http://foo.bar' => '<a href="http://foo.bar" title="Link title" class="external">link</a>',
217 217 '"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>',
218 218 "This is not a \"Link\":\n\nAnother paragraph" => "This is not a \"Link\":</p>\n\n\n\t<p>Another paragraph",
219 219 # no multiline link text
220 220 "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",
221 221 # mailto link
222 222 "\"system administrator\":mailto:sysadmin@example.com?subject=redmine%20permissions" => "<a href=\"mailto:sysadmin@example.com?subject=redmine%20permissions\">system administrator</a>",
223 223 # two exclamation marks
224 224 '"a link":http://example.net/path!602815048C7B5C20!302.html' => '<a href="http://example.net/path!602815048C7B5C20!302.html" class="external">a link</a>',
225 225 # escaping
226 226 '"test":http://foo"bar' => '<a href="http://foo&quot;bar" class="external">test</a>',
227 227 }
228 228 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
229 229 end
230 230
231 231 def test_redmine_links
232 232 issue_link = link_to('#3', {:controller => 'issues', :action => 'show', :id => 3},
233 233 :class => 'issue status-1 priority-4 overdue', :title => 'Error 281 when updating a recipe (New)')
234 234 note_link = link_to('#3', {:controller => 'issues', :action => 'show', :id => 3, :anchor => 'note-14'},
235 235 :class => 'issue status-1 priority-4 overdue', :title => 'Error 281 when updating a recipe (New)')
236 236
237 237 changeset_link = link_to('r1', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 1},
238 238 :class => 'changeset', :title => 'My very first commit')
239 239 changeset_link2 = link_to('r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
240 240 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
241 241
242 242 document_link = link_to('Test document', {:controller => 'documents', :action => 'show', :id => 1},
243 243 :class => 'document')
244 244
245 245 version_link = link_to('1.0', {:controller => 'versions', :action => 'show', :id => 2},
246 246 :class => 'version')
247 247
248 248 board_url = {:controller => 'boards', :action => 'show', :id => 2, :project_id => 'ecookbook'}
249 249
250 250 message_url = {:controller => 'messages', :action => 'show', :board_id => 1, :id => 4}
251 251
252 252 news_url = {:controller => 'news', :action => 'show', :id => 1}
253 253
254 254 project_url = {:controller => 'projects', :action => 'show', :id => 'subproject1'}
255 255
256 256 source_url = '/projects/ecookbook/repository/entry/some/file'
257 257 source_url_with_rev = '/projects/ecookbook/repository/revisions/52/entry/some/file'
258 258 source_url_with_ext = '/projects/ecookbook/repository/entry/some/file.ext'
259 259 source_url_with_rev_and_ext = '/projects/ecookbook/repository/revisions/52/entry/some/file.ext'
260 260
261 261 export_url = '/projects/ecookbook/repository/raw/some/file'
262 262 export_url_with_rev = '/projects/ecookbook/repository/revisions/52/raw/some/file'
263 263 export_url_with_ext = '/projects/ecookbook/repository/raw/some/file.ext'
264 264 export_url_with_rev_and_ext = '/projects/ecookbook/repository/revisions/52/raw/some/file.ext'
265 265
266 266 to_test = {
267 267 # tickets
268 268 '#3, [#3], (#3) and #3.' => "#{issue_link}, [#{issue_link}], (#{issue_link}) and #{issue_link}.",
269 269 # ticket notes
270 270 '#3-14' => note_link,
271 271 '#3#note-14' => note_link,
272 272 # should not ignore leading zero
273 273 '#03' => '#03',
274 274 # changesets
275 275 'r1' => changeset_link,
276 276 'r1.' => "#{changeset_link}.",
277 277 'r1, r2' => "#{changeset_link}, #{changeset_link2}",
278 278 'r1,r2' => "#{changeset_link},#{changeset_link2}",
279 279 # documents
280 280 'document#1' => document_link,
281 281 'document:"Test document"' => document_link,
282 282 # versions
283 283 'version#2' => version_link,
284 284 'version:1.0' => version_link,
285 285 'version:"1.0"' => version_link,
286 286 # source
287 287 'source:some/file' => link_to('source:some/file', source_url, :class => 'source'),
288 288 'source:/some/file' => link_to('source:/some/file', source_url, :class => 'source'),
289 289 'source:/some/file.' => link_to('source:/some/file', source_url, :class => 'source') + ".",
290 290 'source:/some/file.ext.' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
291 291 'source:/some/file. ' => link_to('source:/some/file', source_url, :class => 'source') + ".",
292 292 'source:/some/file.ext. ' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
293 293 'source:/some/file, ' => link_to('source:/some/file', source_url, :class => 'source') + ",",
294 294 'source:/some/file@52' => link_to('source:/some/file@52', source_url_with_rev, :class => 'source'),
295 295 'source:/some/file.ext@52' => link_to('source:/some/file.ext@52', source_url_with_rev_and_ext, :class => 'source'),
296 296 'source:/some/file#L110' => link_to('source:/some/file#L110', source_url + "#L110", :class => 'source'),
297 297 'source:/some/file.ext#L110' => link_to('source:/some/file.ext#L110', source_url_with_ext + "#L110", :class => 'source'),
298 298 'source:/some/file@52#L110' => link_to('source:/some/file@52#L110', source_url_with_rev + "#L110", :class => 'source'),
299 299 # export
300 300 'export:/some/file' => link_to('export:/some/file', export_url, :class => 'source download'),
301 301 'export:/some/file.ext' => link_to('export:/some/file.ext', export_url_with_ext, :class => 'source download'),
302 302 'export:/some/file@52' => link_to('export:/some/file@52', export_url_with_rev, :class => 'source download'),
303 303 'export:/some/file.ext@52' => link_to('export:/some/file.ext@52', export_url_with_rev_and_ext, :class => 'source download'),
304 304 # forum
305 305 'forum#2' => link_to('Discussion', board_url, :class => 'board'),
306 306 'forum:Discussion' => link_to('Discussion', board_url, :class => 'board'),
307 307 # message
308 308 'message#4' => link_to('Post 2', message_url, :class => 'message'),
309 309 'message#5' => link_to('RE: post 2', message_url.merge(:anchor => 'message-5', :r => 5), :class => 'message'),
310 310 # news
311 311 'news#1' => link_to('eCookbook first release !', news_url, :class => 'news'),
312 312 'news:"eCookbook first release !"' => link_to('eCookbook first release !', news_url, :class => 'news'),
313 313 # project
314 314 'project#3' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
315 315 'project:subproject1' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
316 316 'project:"eCookbook subProject 1"' => link_to('eCookbook Subproject 1', project_url, :class => 'project'),
317 317 # not found
318 318 '#0123456789' => '#0123456789',
319 319 # invalid expressions
320 320 'source:' => 'source:',
321 321 # url hash
322 322 "http://foo.bar/FAQ#3" => '<a class="external" href="http://foo.bar/FAQ#3">http://foo.bar/FAQ#3</a>',
323 323 }
324 324 @project = Project.find(1)
325 325 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
326 326 end
327 327
328 328 def test_escaped_redmine_links_should_not_be_parsed
329 329 to_test = [
330 330 '#3.',
331 331 '#3-14.',
332 332 '#3#-note14.',
333 333 'r1',
334 334 'document#1',
335 335 'document:"Test document"',
336 336 'version#2',
337 337 'version:1.0',
338 338 'version:"1.0"',
339 339 'source:/some/file'
340 340 ]
341 341 @project = Project.find(1)
342 342 to_test.each { |text| assert_equal "<p>#{text}</p>", textilizable("!" + text), "#{text} failed" }
343 343 end
344 344
345 345 def test_cross_project_redmine_links
346 346 source_link = link_to('ecookbook:source:/some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']},
347 347 :class => 'source')
348 348
349 349 changeset_link = link_to('ecookbook:r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
350 350 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
351 351
352 352 to_test = {
353 353 # documents
354 354 'document:"Test document"' => 'document:"Test document"',
355 355 'ecookbook:document:"Test document"' => '<a href="/documents/1" class="document">Test document</a>',
356 356 'invalid:document:"Test document"' => 'invalid:document:"Test document"',
357 357 # versions
358 358 'version:"1.0"' => 'version:"1.0"',
359 359 'ecookbook:version:"1.0"' => '<a href="/versions/2" class="version">1.0</a>',
360 360 'invalid:version:"1.0"' => 'invalid:version:"1.0"',
361 361 # changeset
362 362 'r2' => 'r2',
363 363 'ecookbook:r2' => changeset_link,
364 364 'invalid:r2' => 'invalid:r2',
365 365 # source
366 366 'source:/some/file' => 'source:/some/file',
367 367 'ecookbook:source:/some/file' => source_link,
368 368 'invalid:source:/some/file' => 'invalid:source:/some/file',
369 369 }
370 370 @project = Project.find(3)
371 371 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
372 372 end
373 373
374 374 def test_multiple_repositories_redmine_links
375 375 svn = Repository::Subversion.create!(:project_id => 1, :identifier => 'svn1', :url => 'file:///foo/hg')
376 376 Changeset.create!(:repository => svn, :committed_on => Time.now, :revision => '123')
377 377 hg = Repository::Mercurial.create!(:project_id => 1, :identifier => 'hg1', :url => '/foo/hg')
378 378 Changeset.create!(:repository => hg, :committed_on => Time.now, :revision => '123', :scmid => 'abcd')
379 379
380 380 changeset_link = link_to('r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
381 381 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
382 382 svn_changeset_link = link_to('svn1|r123', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'svn1', :rev => 123},
383 383 :class => 'changeset', :title => '')
384 384 hg_changeset_link = link_to('hg1|abcd', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'hg1', :rev => 'abcd'},
385 385 :class => 'changeset', :title => '')
386 386
387 387 source_link = link_to('source:some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}, :class => 'source')
388 388 hg_source_link = link_to('source:hg1|some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :repository_id => 'hg1', :path => ['some', 'file']}, :class => 'source')
389 389
390 390 to_test = {
391 391 'r2' => changeset_link,
392 392 'svn1|r123' => svn_changeset_link,
393 393 'invalid|r123' => 'invalid|r123',
394 394 'commit:hg1|abcd' => hg_changeset_link,
395 395 'commit:invalid|abcd' => 'commit:invalid|abcd',
396 396 # source
397 397 'source:some/file' => source_link,
398 398 'source:hg1|some/file' => hg_source_link,
399 399 'source:invalid|some/file' => 'source:invalid|some/file',
400 400 }
401 401
402 402 @project = Project.find(1)
403 403 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
404 404 end
405 405
406 406 def test_cross_project_multiple_repositories_redmine_links
407 407 svn = Repository::Subversion.create!(:project_id => 1, :identifier => 'svn1', :url => 'file:///foo/hg')
408 408 Changeset.create!(:repository => svn, :committed_on => Time.now, :revision => '123')
409 409 hg = Repository::Mercurial.create!(:project_id => 1, :identifier => 'hg1', :url => '/foo/hg')
410 410 Changeset.create!(:repository => hg, :committed_on => Time.now, :revision => '123', :scmid => 'abcd')
411 411
412 412 changeset_link = link_to('ecookbook:r2', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 2},
413 413 :class => 'changeset', :title => 'This commit fixes #1, #2 and references #1 & #3')
414 414 svn_changeset_link = link_to('ecookbook:svn1|r123', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'svn1', :rev => 123},
415 415 :class => 'changeset', :title => '')
416 416 hg_changeset_link = link_to('ecookbook:hg1|abcd', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :repository_id => 'hg1', :rev => 'abcd'},
417 417 :class => 'changeset', :title => '')
418 418
419 419 source_link = link_to('ecookbook:source:some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}, :class => 'source')
420 420 hg_source_link = link_to('ecookbook:source:hg1|some/file', {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :repository_id => 'hg1', :path => ['some', 'file']}, :class => 'source')
421 421
422 422 to_test = {
423 423 'ecookbook:r2' => changeset_link,
424 424 'ecookbook:svn1|r123' => svn_changeset_link,
425 425 'ecookbook:invalid|r123' => 'ecookbook:invalid|r123',
426 426 'ecookbook:commit:hg1|abcd' => hg_changeset_link,
427 427 'ecookbook:commit:invalid|abcd' => 'ecookbook:commit:invalid|abcd',
428 428 'invalid:commit:invalid|abcd' => 'invalid:commit:invalid|abcd',
429 429 # source
430 430 'ecookbook:source:some/file' => source_link,
431 431 'ecookbook:source:hg1|some/file' => hg_source_link,
432 432 'ecookbook:source:invalid|some/file' => 'ecookbook:source:invalid|some/file',
433 433 'invalid:source:invalid|some/file' => 'invalid:source:invalid|some/file',
434 434 }
435 435
436 436 @project = Project.find(3)
437 437 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text), "#{text} failed" }
438 438 end
439 439
440 440 def test_redmine_links_git_commit
441 441 changeset_link = link_to('abcd',
442 442 {
443 443 :controller => 'repositories',
444 444 :action => 'revision',
445 445 :id => 'subproject1',
446 446 :rev => 'abcd',
447 447 },
448 448 :class => 'changeset', :title => 'test commit')
449 449 to_test = {
450 450 'commit:abcd' => changeset_link,
451 451 }
452 452 @project = Project.find(3)
453 453 r = Repository::Git.create!(:project => @project, :url => '/tmp/test/git')
454 454 assert r
455 455 c = Changeset.new(:repository => r,
456 456 :committed_on => Time.now,
457 457 :revision => 'abcd',
458 458 :scmid => 'abcd',
459 459 :comments => 'test commit')
460 460 assert( c.save )
461 461 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
462 462 end
463 463
464 464 # TODO: Bazaar commit id contains mail address, so it contains '@' and '_'.
465 465 def test_redmine_links_darcs_commit
466 466 changeset_link = link_to('20080308225258-98289-abcd456efg.gz',
467 467 {
468 468 :controller => 'repositories',
469 469 :action => 'revision',
470 470 :id => 'subproject1',
471 471 :rev => '123',
472 472 },
473 473 :class => 'changeset', :title => 'test commit')
474 474 to_test = {
475 475 'commit:20080308225258-98289-abcd456efg.gz' => changeset_link,
476 476 }
477 477 @project = Project.find(3)
478 478 r = Repository::Darcs.create!(
479 479 :project => @project, :url => '/tmp/test/darcs',
480 480 :log_encoding => 'UTF-8')
481 481 assert r
482 482 c = Changeset.new(:repository => r,
483 483 :committed_on => Time.now,
484 484 :revision => '123',
485 485 :scmid => '20080308225258-98289-abcd456efg.gz',
486 486 :comments => 'test commit')
487 487 assert( c.save )
488 488 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
489 489 end
490 490
491 491 def test_redmine_links_mercurial_commit
492 492 changeset_link_rev = link_to('r123',
493 493 {
494 494 :controller => 'repositories',
495 495 :action => 'revision',
496 496 :id => 'subproject1',
497 497 :rev => '123' ,
498 498 },
499 499 :class => 'changeset', :title => 'test commit')
500 500 changeset_link_commit = link_to('abcd',
501 501 {
502 502 :controller => 'repositories',
503 503 :action => 'revision',
504 504 :id => 'subproject1',
505 505 :rev => 'abcd' ,
506 506 },
507 507 :class => 'changeset', :title => 'test commit')
508 508 to_test = {
509 509 'r123' => changeset_link_rev,
510 510 'commit:abcd' => changeset_link_commit,
511 511 }
512 512 @project = Project.find(3)
513 513 r = Repository::Mercurial.create!(:project => @project, :url => '/tmp/test')
514 514 assert r
515 515 c = Changeset.new(:repository => r,
516 516 :committed_on => Time.now,
517 517 :revision => '123',
518 518 :scmid => 'abcd',
519 519 :comments => 'test commit')
520 520 assert( c.save )
521 521 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
522 522 end
523 523
524 524 def test_attachment_links
525 525 attachment_link = link_to('error281.txt', {:controller => 'attachments', :action => 'download', :id => '1'}, :class => 'attachment')
526 526 to_test = {
527 527 'attachment:error281.txt' => attachment_link
528 528 }
529 529 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => Issue.find(3).attachments), "#{text} failed" }
530 530 end
531 531
532 532 def test_wiki_links
533 533 to_test = {
534 534 '[[CookBook documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a>',
535 535 '[[Another page|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a>',
536 536 # title content should be formatted
537 537 '[[Another page|With _styled_ *title*]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">With <em>styled</em> <strong>title</strong></a>',
538 538 '[[Another page|With title containing <strong>HTML entities &amp; markups</strong>]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">With title containing &lt;strong&gt;HTML entities &amp; markups&lt;/strong&gt;</a>',
539 539 # link with anchor
540 540 '[[CookBook documentation#One-section]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">CookBook documentation</a>',
541 541 '[[Another page#anchor|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page#anchor" class="wiki-page">Page</a>',
542 542 # UTF8 anchor
543 543 '[[Another_page#ВСст|ВСст]]' => %|<a href="/projects/ecookbook/wiki/Another_page##{CGI.escape 'ВСст'}" class="wiki-page">ВСст</a>|,
544 544 # page that doesn't exist
545 545 '[[Unknown page]]' => '<a href="/projects/ecookbook/wiki/Unknown_page" class="wiki-page new">Unknown page</a>',
546 546 '[[Unknown page|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page" class="wiki-page new">404</a>',
547 547 # link to another project wiki
548 548 '[[onlinestore:]]' => '<a href="/projects/onlinestore/wiki" class="wiki-page">onlinestore</a>',
549 549 '[[onlinestore:|Wiki]]' => '<a href="/projects/onlinestore/wiki" class="wiki-page">Wiki</a>',
550 550 '[[onlinestore:Start page]]' => '<a href="/projects/onlinestore/wiki/Start_page" class="wiki-page">Start page</a>',
551 551 '[[onlinestore:Start page|Text]]' => '<a href="/projects/onlinestore/wiki/Start_page" class="wiki-page">Text</a>',
552 552 '[[onlinestore:Unknown page]]' => '<a href="/projects/onlinestore/wiki/Unknown_page" class="wiki-page new">Unknown page</a>',
553 553 # striked through link
554 554 '-[[Another page|Page]]-' => '<del><a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a></del>',
555 555 '-[[Another page|Page]] link-' => '<del><a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a> link</del>',
556 556 # escaping
557 557 '![[Another page|Page]]' => '[[Another page|Page]]',
558 558 # project does not exist
559 559 '[[unknowproject:Start]]' => '[[unknowproject:Start]]',
560 560 '[[unknowproject:Start|Page title]]' => '[[unknowproject:Start|Page title]]',
561 561 }
562 562
563 563 @project = Project.find(1)
564 564 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
565 565 end
566 566
567 567 def test_wiki_links_within_local_file_generation_context
568 568
569 569 to_test = {
570 570 # link to a page
571 571 '[[CookBook documentation]]' => '<a href="CookBook_documentation.html" class="wiki-page">CookBook documentation</a>',
572 572 '[[CookBook documentation|documentation]]' => '<a href="CookBook_documentation.html" class="wiki-page">documentation</a>',
573 573 '[[CookBook documentation#One-section]]' => '<a href="CookBook_documentation.html#One-section" class="wiki-page">CookBook documentation</a>',
574 574 '[[CookBook documentation#One-section|documentation]]' => '<a href="CookBook_documentation.html#One-section" class="wiki-page">documentation</a>',
575 575 # page that doesn't exist
576 576 '[[Unknown page]]' => '<a href="Unknown_page.html" class="wiki-page new">Unknown page</a>',
577 577 '[[Unknown page|404]]' => '<a href="Unknown_page.html" class="wiki-page new">404</a>',
578 578 '[[Unknown page#anchor]]' => '<a href="Unknown_page.html#anchor" class="wiki-page new">Unknown page</a>',
579 579 '[[Unknown page#anchor|404]]' => '<a href="Unknown_page.html#anchor" class="wiki-page new">404</a>',
580 580 }
581 581
582 582 @project = Project.find(1)
583 583
584 584 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :wiki_links => :local) }
585 585 end
586 586
587 587 def test_wiki_links_within_wiki_page_context
588 588
589 589 page = WikiPage.find_by_title('Another_page' )
590 590
591 591 to_test = {
592 592 # link to another page
593 593 '[[CookBook documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a>',
594 594 '[[CookBook documentation|documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">documentation</a>',
595 595 '[[CookBook documentation#One-section]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">CookBook documentation</a>',
596 596 '[[CookBook documentation#One-section|documentation]]' => '<a href="/projects/ecookbook/wiki/CookBook_documentation#One-section" class="wiki-page">documentation</a>',
597 597 # link to the current page
598 598 '[[Another page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Another page</a>',
599 599 '[[Another page|Page]]' => '<a href="/projects/ecookbook/wiki/Another_page" class="wiki-page">Page</a>',
600 600 '[[Another page#anchor]]' => '<a href="#anchor" class="wiki-page">Another page</a>',
601 601 '[[Another page#anchor|Page]]' => '<a href="#anchor" class="wiki-page">Page</a>',
602 602 # page that doesn't exist
603 603 '[[Unknown page]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page" class="wiki-page new">Unknown page</a>',
604 604 '[[Unknown page|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page" class="wiki-page new">404</a>',
605 605 '[[Unknown page#anchor]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page#anchor" class="wiki-page new">Unknown page</a>',
606 606 '[[Unknown page#anchor|404]]' => '<a href="/projects/ecookbook/wiki/Unknown_page?parent=Another_page#anchor" class="wiki-page new">404</a>',
607 607 }
608 608
609 609 @project = Project.find(1)
610 610
611 611 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(WikiContent.new( :text => text, :page => page ), :text) }
612 612 end
613 613
614 614 def test_wiki_links_anchor_option_should_prepend_page_title_to_href
615 615
616 616 to_test = {
617 617 # link to a page
618 618 '[[CookBook documentation]]' => '<a href="#CookBook_documentation" class="wiki-page">CookBook documentation</a>',
619 619 '[[CookBook documentation|documentation]]' => '<a href="#CookBook_documentation" class="wiki-page">documentation</a>',
620 620 '[[CookBook documentation#One-section]]' => '<a href="#CookBook_documentation_One-section" class="wiki-page">CookBook documentation</a>',
621 621 '[[CookBook documentation#One-section|documentation]]' => '<a href="#CookBook_documentation_One-section" class="wiki-page">documentation</a>',
622 622 # page that doesn't exist
623 623 '[[Unknown page]]' => '<a href="#Unknown_page" class="wiki-page new">Unknown page</a>',
624 624 '[[Unknown page|404]]' => '<a href="#Unknown_page" class="wiki-page new">404</a>',
625 625 '[[Unknown page#anchor]]' => '<a href="#Unknown_page_anchor" class="wiki-page new">Unknown page</a>',
626 626 '[[Unknown page#anchor|404]]' => '<a href="#Unknown_page_anchor" class="wiki-page new">404</a>',
627 627 }
628 628
629 629 @project = Project.find(1)
630 630
631 631 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :wiki_links => :anchor) }
632 632 end
633 633
634 634 def test_html_tags
635 635 to_test = {
636 636 "<div>content</div>" => "<p>&lt;div&gt;content&lt;/div&gt;</p>",
637 637 "<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
638 638 "<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</p>",
639 639 # do not escape pre/code tags
640 640 "<pre>\nline 1\nline2</pre>" => "<pre>\nline 1\nline2</pre>",
641 641 "<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
642 642 "<pre><div>content</div></pre>" => "<pre>&lt;div&gt;content&lt;/div&gt;</pre>",
643 643 "HTML comment: <!-- no comments -->" => "<p>HTML comment: &lt;!-- no comments --&gt;</p>",
644 644 "<!-- opening comment" => "<p>&lt;!-- opening comment</p>",
645 645 # remove attributes except class
646 646 "<pre class='foo'>some text</pre>" => "<pre class='foo'>some text</pre>",
647 647 '<pre class="foo">some text</pre>' => '<pre class="foo">some text</pre>',
648 648 "<pre class='foo bar'>some text</pre>" => "<pre class='foo bar'>some text</pre>",
649 649 '<pre class="foo bar">some text</pre>' => '<pre class="foo bar">some text</pre>',
650 650 "<pre onmouseover='alert(1)'>some text</pre>" => "<pre>some text</pre>",
651 651 # xss
652 652 '<pre><code class=""onmouseover="alert(1)">text</code></pre>' => '<pre><code>text</code></pre>',
653 653 '<pre class=""onmouseover="alert(1)">text</pre>' => '<pre>text</pre>',
654 654 }
655 655 to_test.each { |text, result| assert_equal result, textilizable(text) }
656 656 end
657 657
658 658 def test_allowed_html_tags
659 659 to_test = {
660 660 "<pre>preformatted text</pre>" => "<pre>preformatted text</pre>",
661 661 "<notextile>no *textile* formatting</notextile>" => "no *textile* formatting",
662 662 "<notextile>this is <tag>a tag</tag></notextile>" => "this is &lt;tag&gt;a tag&lt;/tag&gt;"
663 663 }
664 664 to_test.each { |text, result| assert_equal result, textilizable(text) }
665 665 end
666 666
667 667 def test_pre_tags
668 668 raw = <<-RAW
669 669 Before
670 670
671 671 <pre>
672 672 <prepared-statement-cache-size>32</prepared-statement-cache-size>
673 673 </pre>
674 674
675 675 After
676 676 RAW
677 677
678 678 expected = <<-EXPECTED
679 679 <p>Before</p>
680 680 <pre>
681 681 &lt;prepared-statement-cache-size&gt;32&lt;/prepared-statement-cache-size&gt;
682 682 </pre>
683 683 <p>After</p>
684 684 EXPECTED
685 685
686 686 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
687 687 end
688 688
689 689 def test_pre_content_should_not_parse_wiki_and_redmine_links
690 690 raw = <<-RAW
691 691 [[CookBook documentation]]
692 692
693 693 #1
694 694
695 695 <pre>
696 696 [[CookBook documentation]]
697 697
698 698 #1
699 699 </pre>
700 700 RAW
701 701
702 702 expected = <<-EXPECTED
703 703 <p><a href="/projects/ecookbook/wiki/CookBook_documentation" class="wiki-page">CookBook documentation</a></p>
704 704 <p><a href="/issues/1" class="issue status-1 priority-4" title="Can&#x27;t print recipes (New)">#1</a></p>
705 705 <pre>
706 706 [[CookBook documentation]]
707 707
708 708 #1
709 709 </pre>
710 710 EXPECTED
711 711
712 712 @project = Project.find(1)
713 713 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
714 714 end
715 715
716 716 def test_non_closing_pre_blocks_should_be_closed
717 717 raw = <<-RAW
718 718 <pre><code>
719 719 RAW
720 720
721 721 expected = <<-EXPECTED
722 722 <pre><code>
723 723 </code></pre>
724 724 EXPECTED
725 725
726 726 @project = Project.find(1)
727 727 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
728 728 end
729 729
730 730 def test_syntax_highlight
731 731 raw = <<-RAW
732 732 <pre><code class="ruby">
733 733 # Some ruby code here
734 734 </code></pre>
735 735 RAW
736 736
737 737 expected = <<-EXPECTED
738 738 <pre><code class="ruby syntaxhl"><span class=\"CodeRay\"><span class="comment"># Some ruby code here</span></span>
739 739 </code></pre>
740 740 EXPECTED
741 741
742 742 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
743 743 end
744 744
745 745 def test_to_path_param
746 746 assert_equal 'test1/test2', to_path_param('test1/test2')
747 747 assert_equal 'test1/test2', to_path_param('/test1/test2/')
748 748 assert_equal 'test1/test2', to_path_param('//test1/test2/')
749 749 assert_equal nil, to_path_param('/')
750 750 end
751 751
752 752 def test_wiki_links_in_tables
753 753 to_test = {"|[[Page|Link title]]|[[Other Page|Other title]]|\n|Cell 21|[[Last page]]|" =>
754 754 '<tr><td><a href="/projects/ecookbook/wiki/Page" class="wiki-page new">Link title</a></td>' +
755 755 '<td><a href="/projects/ecookbook/wiki/Other_Page" class="wiki-page new">Other title</a></td>' +
756 756 '</tr><tr><td>Cell 21</td><td><a href="/projects/ecookbook/wiki/Last_page" class="wiki-page new">Last page</a></td></tr>'
757 757 }
758 758 @project = Project.find(1)
759 759 to_test.each { |text, result| assert_equal "<table>#{result}</table>", textilizable(text).gsub(/[\t\n]/, '') }
760 760 end
761 761
762 762 def test_text_formatting
763 763 to_test = {'*_+bold, italic and underline+_*' => '<strong><em><ins>bold, italic and underline</ins></em></strong>',
764 764 '(_text within parentheses_)' => '(<em>text within parentheses</em>)',
765 765 'a *Humane Web* Text Generator' => 'a <strong>Humane Web</strong> Text Generator',
766 766 '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>',
767 767 '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',
768 768 }
769 769 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
770 770 end
771 771
772 772 def test_wiki_horizontal_rule
773 773 assert_equal '<hr />', textilizable('---')
774 774 assert_equal '<p>Dashes: ---</p>', textilizable('Dashes: ---')
775 775 end
776 776
777 777 def test_footnotes
778 778 raw = <<-RAW
779 779 This is some text[1].
780 780
781 781 fn1. This is the foot note
782 782 RAW
783 783
784 784 expected = <<-EXPECTED
785 785 <p>This is some text<sup><a href=\"#fn1\">1</a></sup>.</p>
786 786 <p id="fn1" class="footnote"><sup>1</sup> This is the foot note</p>
787 787 EXPECTED
788 788
789 789 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
790 790 end
791 791
792 792 def test_headings
793 793 raw = 'h1. Some heading'
794 794 expected = %|<a name="Some-heading"></a>\n<h1 >Some heading<a href="#Some-heading" class="wiki-anchor">&para;</a></h1>|
795 795
796 796 assert_equal expected, textilizable(raw)
797 797 end
798 798
799 799 def test_headings_with_special_chars
800 800 # This test makes sure that the generated anchor names match the expected
801 801 # ones even if the heading text contains unconventional characters
802 802 raw = 'h1. Some heading related to version 0.5'
803 803 anchor = sanitize_anchor_name("Some-heading-related-to-version-0.5")
804 804 expected = %|<a name="#{anchor}"></a>\n<h1 >Some heading related to version 0.5<a href="##{anchor}" class="wiki-anchor">&para;</a></h1>|
805 805
806 806 assert_equal expected, textilizable(raw)
807 807 end
808 808
809 809 def test_headings_in_wiki_single_page_export_should_be_prepended_with_page_title
810 810 page = WikiPage.new( :title => 'Page Title', :wiki_id => 1 )
811 811 content = WikiContent.new( :text => 'h1. Some heading', :page => page )
812 812
813 813 expected = %|<a name="Page_Title_Some-heading"></a>\n<h1 >Some heading<a href="#Page_Title_Some-heading" class="wiki-anchor">&para;</a></h1>|
814 814
815 815 assert_equal expected, textilizable(content, :text, :wiki_links => :anchor )
816 816 end
817 817
818 818 def test_table_of_content
819 819 raw = <<-RAW
820 820 {{toc}}
821 821
822 822 h1. Title
823 823
824 824 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
825 825
826 826 h2. Subtitle with a [[Wiki]] link
827 827
828 828 Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
829 829
830 830 h2. Subtitle with [[Wiki|another Wiki]] link
831 831
832 832 h2. Subtitle with %{color:red}red text%
833 833
834 834 <pre>
835 835 some code
836 836 </pre>
837 837
838 838 h3. Subtitle with *some* _modifiers_
839 839
840 840 h3. Subtitle with @inline code@
841 841
842 842 h1. Another title
843 843
844 844 h3. An "Internet link":http://www.redmine.org/ inside subtitle
845 845
846 846 h2. "Project Name !/attachments/1234/logo_small.gif! !/attachments/5678/logo_2.png!":/projects/projectname/issues
847 847
848 848 RAW
849 849
850 850 expected = '<ul class="toc">' +
851 851 '<li><a href="#Title">Title</a>' +
852 852 '<ul>' +
853 853 '<li><a href="#Subtitle-with-a-Wiki-link">Subtitle with a Wiki link</a></li>' +
854 854 '<li><a href="#Subtitle-with-another-Wiki-link">Subtitle with another Wiki link</a></li>' +
855 855 '<li><a href="#Subtitle-with-red-text">Subtitle with red text</a>' +
856 856 '<ul>' +
857 857 '<li><a href="#Subtitle-with-some-modifiers">Subtitle with some modifiers</a></li>' +
858 858 '<li><a href="#Subtitle-with-inline-code">Subtitle with inline code</a></li>' +
859 859 '</ul>' +
860 860 '</li>' +
861 861 '</ul>' +
862 862 '</li>' +
863 863 '<li><a href="#Another-title">Another title</a>' +
864 864 '<ul>' +
865 865 '<li>' +
866 866 '<ul>' +
867 867 '<li><a href="#An-Internet-link-inside-subtitle">An Internet link inside subtitle</a></li>' +
868 868 '</ul>' +
869 869 '</li>' +
870 870 '<li><a href="#Project-Name">Project Name</a></li>' +
871 871 '</ul>' +
872 872 '</li>' +
873 873 '</ul>'
874 874
875 875 @project = Project.find(1)
876 876 assert textilizable(raw).gsub("\n", "").include?(expected)
877 877 end
878 878
879 879 def test_table_of_content_should_generate_unique_anchors
880 880 raw = <<-RAW
881 881 {{toc}}
882 882
883 883 h1. Title
884 884
885 885 h2. Subtitle
886 886
887 887 h2. Subtitle
888 888 RAW
889 889
890 890 expected = '<ul class="toc">' +
891 891 '<li><a href="#Title">Title</a>' +
892 892 '<ul>' +
893 893 '<li><a href="#Subtitle">Subtitle</a></li>' +
894 894 '<li><a href="#Subtitle-2">Subtitle</a></li>'
895 895 '</ul>'
896 896 '</li>' +
897 897 '</ul>'
898 898
899 899 @project = Project.find(1)
900 900 result = textilizable(raw).gsub("\n", "")
901 901 assert_include expected, result
902 902 assert_include '<a name="Subtitle">', result
903 903 assert_include '<a name="Subtitle-2">', result
904 904 end
905 905
906 906 def test_table_of_content_should_contain_included_page_headings
907 907 raw = <<-RAW
908 908 {{toc}}
909 909
910 910 h1. Included
911 911
912 912 {{include(Child_1)}}
913 913 RAW
914 914
915 915 expected = '<ul class="toc">' +
916 916 '<li><a href="#Included">Included</a></li>' +
917 917 '<li><a href="#Child-page-1">Child page 1</a></li>' +
918 918 '</ul>'
919 919
920 920 @project = Project.find(1)
921 921 assert textilizable(raw).gsub("\n", "").include?(expected)
922 922 end
923 923
924 924 def test_section_edit_links
925 925 raw = <<-RAW
926 926 h1. Title
927 927
928 928 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
929 929
930 930 h2. Subtitle with a [[Wiki]] link
931 931
932 932 h2. Subtitle with *some* _modifiers_
933 933
934 934 h2. Subtitle with @inline code@
935 935
936 936 <pre>
937 937 some code
938 938
939 939 h2. heading inside pre
940 940
941 941 <h2>html heading inside pre</h2>
942 942 </pre>
943 943
944 944 h2. Subtitle after pre tag
945 945 RAW
946 946
947 947 @project = Project.find(1)
948 948 set_language_if_valid 'en'
949 949 result = textilizable(raw, :edit_section_links => {:controller => 'wiki', :action => 'edit', :project_id => '1', :id => 'Test'}).gsub("\n", "")
950 950
951 951 # heading that contains inline code
952 952 assert_match Regexp.new('<div class="contextual" title="Edit this section">' +
953 953 '<a href="/projects/1/wiki/Test/edit\?section=4"><img alt="Edit" src="/images/edit.png(\?\d+)?" /></a></div>' +
954 954 '<a name="Subtitle-with-inline-code"></a>' +
955 955 '<h2 >Subtitle with <code>inline code</code><a href="#Subtitle-with-inline-code" class="wiki-anchor">&para;</a></h2>'),
956 956 result
957 957
958 958 # last heading
959 959 assert_match Regexp.new('<div class="contextual" title="Edit this section">' +
960 960 '<a href="/projects/1/wiki/Test/edit\?section=5"><img alt="Edit" src="/images/edit.png(\?\d+)?" /></a></div>' +
961 961 '<a name="Subtitle-after-pre-tag"></a>' +
962 962 '<h2 >Subtitle after pre tag<a href="#Subtitle-after-pre-tag" class="wiki-anchor">&para;</a></h2>'),
963 963 result
964 964 end
965 965
966 966 def test_default_formatter
967 967 with_settings :text_formatting => 'unknown' do
968 968 text = 'a *link*: http://www.example.net/'
969 969 assert_equal '<p>a *link*: <a class="external" href="http://www.example.net/">http://www.example.net/</a></p>', textilizable(text)
970 970 end
971 971 end
972 972
973 973 def test_due_date_distance_in_words
974 974 to_test = { Date.today => 'Due in 0 days',
975 975 Date.today + 1 => 'Due in 1 day',
976 976 Date.today + 100 => 'Due in about 3 months',
977 977 Date.today + 20000 => 'Due in over 54 years',
978 978 Date.today - 1 => '1 day late',
979 979 Date.today - 100 => 'about 3 months late',
980 980 Date.today - 20000 => 'over 54 years late',
981 981 }
982 982 ::I18n.locale = :en
983 983 to_test.each do |date, expected|
984 984 assert_equal expected, due_date_distance_in_words(date)
985 985 end
986 986 end
987 987
988 988 def test_avatar_enabled
989 989 with_settings :gravatar_enabled => '1' do
990 990 assert avatar(User.find_by_mail('jsmith@somenet.foo')).include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
991 991 assert avatar('jsmith <jsmith@somenet.foo>').include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
992 992 # Default size is 50
993 993 assert avatar('jsmith <jsmith@somenet.foo>').include?('size=50')
994 994 assert avatar('jsmith <jsmith@somenet.foo>', :size => 24).include?('size=24')
995 995 # Non-avatar options should be considered html options
996 996 assert avatar('jsmith <jsmith@somenet.foo>', :title => 'John Smith').include?('title="John Smith"')
997 997 # The default class of the img tag should be gravatar
998 998 assert avatar('jsmith <jsmith@somenet.foo>').include?('class="gravatar"')
999 999 assert !avatar('jsmith <jsmith@somenet.foo>', :class => 'picture').include?('class="gravatar"')
1000 1000 assert_nil avatar('jsmith')
1001 1001 assert_nil avatar(nil)
1002 1002 end
1003 1003 end
1004 1004
1005 1005 def test_avatar_disabled
1006 1006 with_settings :gravatar_enabled => '0' do
1007 1007 assert_equal '', avatar(User.find_by_mail('jsmith@somenet.foo'))
1008 1008 end
1009 1009 end
1010 1010
1011 1011 def test_link_to_user
1012 1012 user = User.find(2)
1013 t = link_to_user(user)
1014 assert_equal "<a href=\"/users/2\">#{ user.name }</a>", t
1013 assert_equal '<a href="/users/2" class="user active">John Smith</a>', link_to_user(user)
1015 1014 end
1016 1015
1017 1016 def test_link_to_user_should_not_link_to_locked_user
1018 user = User.find(5)
1019 assert user.locked?
1020 t = link_to_user(user)
1021 assert_equal user.name, t
1017 with_current_user nil do
1018 user = User.find(5)
1019 assert user.locked?
1020 assert_equal 'Dave2 Lopper2', link_to_user(user)
1021 end
1022 end
1023
1024 def test_link_to_user_should_link_to_locked_user_if_current_user_is_admin
1025 with_current_user User.find(1) do
1026 user = User.find(5)
1027 assert user.locked?
1028 assert_equal '<a href="/users/5" class="user locked">Dave2 Lopper2</a>', link_to_user(user)
1029 end
1022 1030 end
1023 1031
1024 1032 def test_link_to_user_should_not_link_to_anonymous
1025 1033 user = User.anonymous
1026 1034 assert user.anonymous?
1027 1035 t = link_to_user(user)
1028 1036 assert_equal ::I18n.t(:label_user_anonymous), t
1029 1037 end
1030 1038
1031 1039 def test_link_to_project
1032 1040 project = Project.find(1)
1033 1041 assert_equal %(<a href="/projects/ecookbook">eCookbook</a>),
1034 1042 link_to_project(project)
1035 1043 assert_equal %(<a href="/projects/ecookbook/settings">eCookbook</a>),
1036 1044 link_to_project(project, :action => 'settings')
1037 1045 assert_equal %(<a href="http://test.host/projects/ecookbook?jump=blah">eCookbook</a>),
1038 1046 link_to_project(project, {:only_path => false, :jump => 'blah'})
1039 1047 assert_equal %(<a href="/projects/ecookbook/settings" class="project">eCookbook</a>),
1040 1048 link_to_project(project, {:action => 'settings'}, :class => "project")
1041 1049 end
1042 1050
1043 1051 def test_link_to_legacy_project_with_numerical_identifier_should_use_id
1044 1052 # numeric identifier are no longer allowed
1045 1053 Project.update_all "identifier=25", "id=1"
1046 1054
1047 1055 assert_equal '<a href="/projects/1">eCookbook</a>',
1048 1056 link_to_project(Project.find(1))
1049 1057 end
1050 1058
1051 1059 def test_principals_options_for_select_with_users
1052 1060 User.current = nil
1053 1061 users = [User.find(2), User.find(4)]
1054 1062 assert_equal %(<option value="2">John Smith</option><option value="4">Robert Hill</option>),
1055 1063 principals_options_for_select(users)
1056 1064 end
1057 1065
1058 1066 def test_principals_options_for_select_with_selected
1059 1067 User.current = nil
1060 1068 users = [User.find(2), User.find(4)]
1061 1069 assert_equal %(<option value="2">John Smith</option><option value="4" selected="selected">Robert Hill</option>),
1062 1070 principals_options_for_select(users, User.find(4))
1063 1071 end
1064 1072
1065 1073 def test_principals_options_for_select_with_users_and_groups
1066 1074 User.current = nil
1067 1075 users = [User.find(2), Group.find(11), User.find(4), Group.find(10)]
1068 1076 assert_equal %(<option value="2">John Smith</option><option value="4">Robert Hill</option>) +
1069 1077 %(<optgroup label="Groups"><option value="10">A Team</option><option value="11">B Team</option></optgroup>),
1070 1078 principals_options_for_select(users)
1071 1079 end
1072 1080
1073 1081 def test_principals_options_for_select_with_empty_collection
1074 1082 assert_equal '', principals_options_for_select([])
1075 1083 end
1076 1084
1077 1085 def test_principals_options_for_select_should_include_me_option_when_current_user_is_in_collection
1078 1086 users = [User.find(2), User.find(4)]
1079 1087 User.current = User.find(4)
1080 1088 assert_include '<option value="4">&lt;&lt; me &gt;&gt;</option>', principals_options_for_select(users)
1081 1089 end
1082 1090
1083 1091 def test_stylesheet_link_tag_should_pick_the_default_stylesheet
1084 1092 assert_match 'href="/stylesheets/styles.css"', stylesheet_link_tag("styles")
1085 1093 end
1086 1094
1087 1095 def test_stylesheet_link_tag_for_plugin_should_pick_the_plugin_stylesheet
1088 1096 assert_match 'href="/plugin_assets/foo/stylesheets/styles.css"', stylesheet_link_tag("styles", :plugin => :foo)
1089 1097 end
1090 1098
1091 1099 def test_image_tag_should_pick_the_default_image
1092 1100 assert_match 'src="/images/image.png"', image_tag("image.png")
1093 1101 end
1094 1102
1095 1103 def test_image_tag_should_pick_the_theme_image_if_it_exists
1096 1104 theme = Redmine::Themes.themes.last
1097 1105 theme.images << 'image.png'
1098 1106
1099 1107 with_settings :ui_theme => theme.id do
1100 1108 assert_match %|src="/themes/#{theme.dir}/images/image.png"|, image_tag("image.png")
1101 1109 assert_match %|src="/images/other.png"|, image_tag("other.png")
1102 1110 end
1103 1111 ensure
1104 1112 theme.images.delete 'image.png'
1105 1113 end
1106 1114
1107 1115 def test_image_tag_sfor_plugin_should_pick_the_plugin_image
1108 1116 assert_match 'src="/plugin_assets/foo/images/image.png"', image_tag("image.png", :plugin => :foo)
1109 1117 end
1110 1118
1111 1119 def test_javascript_include_tag_should_pick_the_default_javascript
1112 1120 assert_match 'src="/javascripts/scripts.js"', javascript_include_tag("scripts")
1113 1121 end
1114 1122
1115 1123 def test_javascript_include_tag_for_plugin_should_pick_the_plugin_javascript
1116 1124 assert_match 'src="/plugin_assets/foo/javascripts/scripts.js"', javascript_include_tag("scripts", :plugin => :foo)
1117 1125 end
1118 1126
1119 1127 def test_per_page_links_should_show_usefull_values
1120 1128 set_language_if_valid 'en'
1121 1129 stubs(:link_to).returns("[link]")
1122 1130
1123 1131 with_settings :per_page_options => '10, 25, 50, 100' do
1124 1132 assert_nil per_page_links(10, 3)
1125 1133 assert_nil per_page_links(25, 3)
1126 1134 assert_equal "Per page: 10, [link]", per_page_links(10, 22)
1127 1135 assert_equal "Per page: [link], 25", per_page_links(25, 22)
1128 1136 assert_equal "Per page: [link], [link], 50", per_page_links(50, 22)
1129 1137 assert_equal "Per page: [link], 25, [link]", per_page_links(25, 26)
1130 1138 assert_equal "Per page: [link], 25, [link], [link]", per_page_links(25, 120)
1131 1139 end
1132 1140 end
1133 1141 end
General Comments 0
You need to be logged in to leave comments. Login now