application_helper.rb 54.5 KB
Newer Older
1 2
# encoding: utf-8
#
3
# Redmine - project management software
jplang's avatar
jplang committed
4
# Copyright (C) 2006-2017  Jean-Philippe Lang
5 6 7 8 9
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
10
#
11 12 13 14
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
15
#
16 17 18 19
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

20
require 'forwardable'
21
require 'cgi'
22

23
module ApplicationHelper
24
  include Redmine::WikiFormatting::Macros::Definitions
25
  include Redmine::I18n
26
  include GravatarHelper::PublicMethods
27
  include Redmine::Pagination::Helper
28
  include Redmine::SudoMode::Helper
29
  include Redmine::Themes::Helper
30
  include Redmine::Hook::Helper
31
  include Redmine::Helpers::URL
32

33 34 35
  extend Forwardable
  def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter

36
  # Return true if user is authorized for controller/action, otherwise false
jplang's avatar
jplang committed
37 38
  def authorize_for(controller, action)
    User.current.allowed_to?({:controller => controller, :action => action}, @project)
39 40 41
  end

  # Display a link if user is authorized
42 43
  #
  # @param [String] name Anchor text (passed to link_to)
edavis10's avatar
edavis10 committed
44
  # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
45 46
  # @param [optional, Hash] html_options Options passed to link_to
  # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
47
  def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
edavis10's avatar
edavis10 committed
48
    link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
49
  end
50

51
  # Displays a link to user's account page if active
52
  def link_to_user(user, options={})
jplang's avatar
jplang committed
53
    if user.is_a?(User)
54
      name = h(user.name(options[:format]))
55
      if user.active? || (User.current.admin? && user.logged?)
jplang's avatar
jplang committed
56
        link_to name, user_path(user), :class => user.css_classes
57 58 59
      else
        name
      end
jplang's avatar
jplang committed
60
    else
61
      h(user.to_s)
jplang's avatar
jplang committed
62
    end
63
  end
64

65 66
  # Displays a link to +issue+ with its subject.
  # Examples:
67
  #
68 69 70
  #   link_to_issue(issue)                        # => Defect #6: This is the subject
  #   link_to_issue(issue, :truncate => 6)        # => Defect #6: This i...
  #   link_to_issue(issue, :subject => false)     # => Defect #6
71
  #   link_to_issue(issue, :project => true)      # => Foo - Defect #6
72
  #   link_to_issue(issue, :subject => false, :tracker => false)     # => #6
73
  #
74
  def link_to_issue(issue, options={})
75 76
    title = nil
    subject = nil
77
    text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
78
    if options[:subject] == false
79
      title = issue.subject.truncate(60)
80 81
    else
      subject = issue.subject
82 83
      if truncate_length = options[:truncate]
        subject = subject.truncate(truncate_length)
84 85
      end
    end
86
    only_path = options[:only_path].nil? ? true : options[:only_path]
jplang's avatar
jplang committed
87
    s = link_to(text, issue_url(issue, :only_path => only_path),
88
                :class => issue.css_classes, :title => title)
jplang's avatar
jplang committed
89 90
    s << h(": #{subject}") if subject
    s = h("#{issue.project} - ") + s if options[:project]
91
    s
jplang's avatar
jplang committed
92
  end
93

94 95 96 97 98 99
  # Generates a link to an attachment.
  # Options:
  # * :text - Link text (default to attachment filename)
  # * :download - Force download (default: false)
  def link_to_attachment(attachment, options={})
    text = options.delete(:text) || attachment.filename
jplang's avatar
jplang committed
100
    route_method = options.delete(:download) ? :download_named_attachment_url : :named_attachment_url
101
    html_options = options.slice!(:only_path)
jplang's avatar
jplang committed
102
    options[:only_path] = true unless options.key?(:only_path)
103 104
    url = send(route_method, attachment, attachment.filename, options)
    link_to text, url, html_options
105
  end
106

107 108 109
  # Generates a link to a SCM revision
  # Options:
  # * :text - Link text (default to the formatted revision)
110 111 112 113
  def link_to_revision(revision, repository, options={})
    if repository.is_a?(Project)
      repository = repository.repository
    end
114
    text = options.delete(:text) || format_revision(revision)
115
    rev = revision.respond_to?(:identifier) ? revision.identifier : revision
116 117
    link_to(
        h(text),
118
        {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
119 120
        :title => l(:label_revision_id, format_revision(revision)),
        :accesskey => options[:accesskey]
121
      )
122
  end
123

124 125 126
  # Generates a link to a message
  def link_to_message(message, options={}, html_options = nil)
    link_to(
127
      message.subject.truncate(60),
jplang's avatar
jplang committed
128
      board_message_url(message.board_id, message.parent_id || message.id, {
129
        :r => (message.parent_id && message.id),
jplang's avatar
jplang committed
130 131
        :anchor => (message.parent_id ? "message-#{message.id}" : nil),
        :only_path => true
132
      }.merge(options)),
133 134 135
      html_options
    )
  end
136

137 138
  # Generates a link to a project if active
  # Examples:
139
  #
140 141 142 143 144
  #   link_to_project(project)                          # => link to the specified project overview
  #   link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
  #   link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
  #
  def link_to_project(project, options={}, html_options = nil)
145
    if project.archived?
146 147
      h(project.name)
    else
jplang's avatar
jplang committed
148 149 150
      link_to project.name,
        project_url(project, {:only_path => true}.merge(options)),
        html_options
151 152 153 154 155 156 157 158 159 160 161
    end
  end

  # Generates a link to a project settings if active
  def link_to_project_settings(project, options={}, html_options=nil)
    if project.active?
      link_to project.name, settings_project_path(project, options), html_options
    elsif project.archived?
      h(project.name)
    else
      link_to project.name, project_path(project, options), html_options
162 163 164
    end
  end

165 166 167 168 169 170 171
  # Generates a link to a version
  def link_to_version(version, options = {})
    return '' unless version && version.is_a?(Version)
    options = {:title => format_date(version.effective_date)}.merge(options)
    link_to_if version.visible?, format_version_name(version), version_path(version), options
  end

172
  # Helper that formats object for html or text rendering
173 174 175 176
  def format_object(object, html=true, &block)
    if block_given?
      object = yield object
    end
177
    case object.class.name
178 179
    when 'Array'
      object.map {|o| format_object(o, html)}.join(', ').html_safe
180 181 182 183 184 185 186 187 188 189 190 191 192
    when 'Time'
      format_time(object)
    when 'Date'
      format_date(object)
    when 'Fixnum'
      object.to_s
    when 'Float'
      sprintf "%.2f", object
    when 'User'
      html ? link_to_user(object) : object.to_s
    when 'Project'
      html ? link_to_project(object) : object.to_s
    when 'Version'
193
      html ? link_to_version(object) : object.to_s
194 195 196 197 198 199
    when 'TrueClass'
      l(:general_text_Yes)
    when 'FalseClass'
      l(:general_text_No)
    when 'Issue'
      object.visible? && html ? link_to_issue(object) : "##{object.id}"
200
    when 'Attachment'
201
      html ? link_to_attachment(object) : object.filename
202 203 204 205 206 207
    when 'CustomValue', 'CustomFieldValue'
      if object.custom_field
        f = object.custom_field.format.formatted_custom_value(self, object, html)
        if f.nil? || f.is_a?(String)
          f
        else
208
          format_object(f, html, &block)
209 210 211 212
        end
      else
        object.value.to_s
      end
213 214 215 216 217
    else
      html ? h(object) : object.to_s
    end
  end

218 219 220 221
  def wiki_page_path(page, options={})
    url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
  end

222
  def thumbnail_tag(attachment)
223
    thumbnail_size = Setting.thumbnails_size.to_i
224 225 226
    link_to(
      image_tag(
        thumbnail_path(attachment),
227 228
        :srcset => "#{thumbnail_path(attachment, :size => thumbnail_size * 2)} 2x",
        :style => "max-width: #{thumbnail_size}px; max-height: #{thumbnail_size}px;"
229 230 231 232 233
      ),
      named_attachment_path(
        attachment,
        attachment.filename
      ),
234
      :title => attachment.filename
235
    )
236 237
  end

jplang's avatar
jplang committed
238
  def toggle_link(name, id, options={})
239 240
    onclick = "$('##{id}').toggle(); "
    onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
241
    onclick << "$(window).scrollTop($('##{options[:focus]}').position().top); " if options[:scroll]
jplang's avatar
jplang committed
242 243 244
    onclick << "return false;"
    link_to(name, "#", :onclick => onclick)
  end
245

246
  # Used to format item titles on the activity view
247
  def format_activity_title(text)
248
    text
249
  end
250

251
  def format_activity_day(date)
252
    date == User.current.today ? l(:label_today).titleize : format_date(date)
253
  end
254

255
  def format_activity_description(text)
256
    h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
257
       ).gsub(/[\r\n]+/, "<br />").html_safe
258
  end
259

260
  def format_version_name(version)
261
    if version.project == @project
262
      h(version)
263 264 265 266
    else
      h("#{version.project} - #{version}")
    end
  end
267

268 269 270 271 272
  def format_changeset_comments(changeset, options={})
    method = options[:short] ? :short_comments : :comments
    textilizable changeset, method, :formatting => Setting.commit_logs_formatting?
  end

273 274
  def due_date_distance_in_words(date)
    if date
275
      l((date < User.current.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(User.current.today, date))
276 277
    end
  end
278

279 280 281
  # Renders a tree of projects as a nested set of unordered lists
  # The given collection may be a subset of the whole project tree
  # (eg. some intermediate nodes are private and can not be seen)
282
  def render_project_nested_lists(projects, &block)
283 284 285 286
    s = ''
    if projects.any?
      ancestors = []
      original_project = @project
jplang's avatar
jplang committed
287
      projects.sort_by(&:lft).each do |project|
288 289 290 291 292 293 294 295 296 297 298 299 300 301
        # set the project environment to please macros.
        @project = project
        if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
          s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
        else
          ancestors.pop
          s << "</li>"
          while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
            ancestors.pop
            s << "</ul></li>\n"
          end
        end
        classes = (ancestors.empty? ? 'root' : 'child')
        s << "<li class='#{classes}'><div class='#{classes}'>"
302
        s << h(block_given? ? capture(project, &block) : project.name)
303 304 305 306 307 308 309 310 311
        s << "</div>\n"
        ancestors << project
      end
      s << ("</li></ul>\n" * ancestors.size)
      @project = original_project
    end
    s.html_safe
  end

312
  def render_page_hierarchy(pages, node=nil, options={})
313 314 315 316 317
    content = ''
    if pages[node]
      content << "<ul class=\"pages-hierarchy\">\n"
      pages[node].each do |page|
        content << "<li>"
jplang's avatar
jplang committed
318
        content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
319 320
                           :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
        content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
321 322 323 324
        content << "</li>\n"
      end
      content << "</ul>\n"
    end
325
    content.html_safe
326
  end
327

328 329 330 331
  # Renders flash messages
  def render_flash_messages
    s = ''
    flash.each do |k,v|
332
      s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
333
    end
334
    s.html_safe
335
  end
336

jplang's avatar
jplang committed
337
  # Renders tabs and their content
338
  def render_tabs(tabs, selected=params[:tab])
jplang's avatar
jplang committed
339
    if tabs.any?
340 341 342 343 344
      unless tabs.detect {|tab| tab[:name] == selected}
        selected = nil
      end
      selected ||= tabs.first[:name]
      render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
jplang's avatar
jplang committed
345 346 347 348
    else
      content_tag 'p', l(:label_no_data), :class => "nodata"
    end
  end
349

350 351 352 353 354 355 356 357
  # Returns the default scope for the quick search form
  # Could be 'all', 'my_projects', 'subprojects' or nil (current project)
  def default_search_project_scope
    if @project && !@project.leaf?
      'subprojects'
    end
  end

358 359 360 361 362 363 364 365 366 367
  # Returns an array of projects that are displayed in the quick-jump box
  def projects_for_jump_box(user=User.current)
    if user.logged?
      user.projects.active.select(:id, :name, :identifier, :lft, :rgt).to_a
    else
      []
    end
  end

  def render_projects_for_jump_box(projects, selected=nil)
368
    jump = params[:jump].presence || current_menu_item
369 370 371 372
    s = ''.html_safe
    project_tree(projects) do |project, level|
      padding = level * 16
      text = content_tag('span', project.name, :style => "padding-left:#{padding}px;")
373
      s << link_to(text, project_path(project, :jump => jump), :title => project.name, :class => (project == selected ? 'selected' : nil))
374 375 376
    end
    s
  end
377

378 379
  # Renders the project quick-jump box
  def render_project_jump_box
380
    projects = projects_for_jump_box(User.current)
381 382 383 384
    if @project && @project.persisted?
      text = @project.name_was
    end
    text ||= l(:label_jump_to_a_project)
385 386
    url = autocomplete_projects_path(:format => 'js', :jump => current_menu_item)

387
    trigger = content_tag('span', text, :class => 'drdn-trigger')
388
    q = text_field_tag('q', '', :id => 'projects-quick-search', :class => 'autocomplete', :data => {:automcomplete_url => url}, :autocomplete => 'off')
389 390 391 392 393 394 395
    all = link_to(l(:label_project_all), projects_path(:jump => current_menu_item), :class => (@project.nil? && controller.class.main_menu ? 'selected' : nil))
    content = content_tag('div',
          content_tag('div', q, :class => 'quick-search') +
          content_tag('div', render_projects_for_jump_box(projects, @project), :class => 'drdn-items projects selection') +
          content_tag('div', all, :class => 'drdn-items all-projects selection'),
        :class => 'drdn-content'
      )
emassip's avatar
emassip committed
396

397
    content_tag('div', trigger + content, :id => "project-jump", :class => "drdn")
398
  end
399

400
  def project_tree_options_for_select(projects, options = {})
401
    s = ''.html_safe
402 403 404 405 406
    if blank_text = options[:include_blank]
      if blank_text == true
        blank_text = '&nbsp;'.html_safe
      end
      s << content_tag('option', blank_text, :value => '')
407
    end
408
    project_tree(projects) do |project, level|
emassip's avatar
emassip committed
409
      name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
410 411 412 413 414 415
      tag_options = {:value => project.id}
      if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
        tag_options[:selected] = 'selected'
      else
        tag_options[:selected] = nil
      end
416 417 418
      tag_options.merge!(yield(project)) if block_given?
      s << content_tag('option', name_prefix + h(project), tag_options)
    end
419
    s.html_safe
420
  end
421

422
  # Yields the given block for each project with its level in the tree
423 424
  #
  # Wrapper for Project#project_tree
425 426
  def project_tree(projects, options={}, &block)
    Project.project_tree(projects, options, &block)
427
  end
428

jplang's avatar
jplang committed
429 430
  def principals_check_box_tags(name, principals)
    s = ''
431
    principals.each do |principal|
jplang's avatar
jplang committed
432
      s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
jplang's avatar
jplang committed
433
    end
434
    s.html_safe
jplang's avatar
jplang committed
435
  end
436

437 438 439
  # Returns a string for users/groups option tags
  def principals_options_for_select(collection, selected=nil)
    s = ''
440
    if collection.include?(User.current)
jplang's avatar
jplang committed
441
      s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
442
    end
443 444
    groups = ''
    collection.sort.each do |element|
445
      selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
446 447 448 449 450
      (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
    end
    unless groups.empty?
      s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
    end
451
    s.html_safe
452
  end
453

454 455 456 457
  def option_tag(name, text, value, selected=nil, options={})
    content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
  end

458
  def truncate_single_line_raw(string, length)
jplang's avatar
jplang committed
459
    string.to_s.truncate(length).gsub(%r{[\r\n]+}m, ' ')
460 461
  end

462 463 464 465 466 467 468 469 470
  # Truncates at line break after 250 characters or options[:length]
  def truncate_lines(string, options={})
    length = options[:length] || 250
    if string.to_s =~ /\A(.{#{length}}.*?)$/m
      "#{$1}..."
    else
      string
    end
  end
471

jplang's avatar
jplang committed
472 473 474 475
  def anchor(text)
    text.to_s.gsub(' ', '_')
  end

476
  def html_hours(text)
477
    text.gsub(%r{(\d+)([\.:])(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">\2\3</span>').html_safe
478
  end
479

jplang's avatar
jplang committed
480
  def authoring(created, author, options={})
481
    l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
482
  end
483

484 485 486
  def time_tag(time)
    text = distance_of_time_in_words(Time.now, time)
    if @project
jplang's avatar
jplang committed
487
      link_to(text, project_activity_path(@project, :from => User.current.time_to_date(time)), :title => format_time(time))
488
    else
489
      content_tag('abbr', text, :title => format_time(time))
490
    end
491 492
  end

493
  def syntax_highlight_lines(name, content)
494
    syntax_highlight(name, content).each_line.to_a
495 496
  end

497
  def syntax_highlight(name, content)
498
    Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
499
  end
500

jplang's avatar
jplang committed
501
  def to_path_param(path)
502 503
    str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
    str.blank? ? nil : str
jplang's avatar
jplang committed
504 505
  end

jplang's avatar
jplang committed
506
  def reorder_links(name, url, method = :post)
jplang's avatar
jplang committed
507 508 509
    # TODO: remove associated styles from application.css too
    ActiveSupport::Deprecation.warn "Application#reorder_links will be removed in Redmine 4."

510
    link_to(l(:label_sort_highest),
511 512
            url.merge({"#{name}[move_to]" => 'highest'}), :method => method,
            :title => l(:label_sort_highest), :class => 'icon-only icon-move-top') +
513
    link_to(l(:label_sort_higher),
514 515
            url.merge({"#{name}[move_to]" => 'higher'}), :method => method,
            :title => l(:label_sort_higher), :class => 'icon-only icon-move-up') +
516
    link_to(l(:label_sort_lower),
517 518
            url.merge({"#{name}[move_to]" => 'lower'}), :method => method,
            :title => l(:label_sort_lower), :class => 'icon-only icon-move-down') +
519
    link_to(l(:label_sort_lowest),
520 521
            url.merge({"#{name}[move_to]" => 'lowest'}), :method => method,
            :title => l(:label_sort_lowest), :class => 'icon-only icon-move-bottom')
jplang's avatar
jplang committed
522
  end
523

524 525 526 527 528 529
  def reorder_handle(object, options={})
    data = {
      :reorder_url => options[:url] || url_for(object),
      :reorder_param => options[:param] || object.class.name.underscore
    }
    content_tag('span', '',
jplang's avatar
jplang committed
530
      :class => "sort-handle",
531 532
      :data => data,
      :title => l(:button_sort))
533 534
  end

535
  def breadcrumb(*args)
536
    elements = args.flatten
537
    elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
538
  end
539

540
  def other_formats_links(&block)
jplang's avatar
jplang committed
541
    concat('<p class="other-formats">'.html_safe + l(:label_export_to))
542
    yield Redmine::Views::OtherFormatsBuilder.new(self)
jplang's avatar
jplang committed
543
    concat('</p>'.html_safe)
544
  end
545

546 547 548 549 550
  def page_header_title
    if @project.nil? || @project.new_record?
      h(Setting.app_title)
    else
      b = []
jplang's avatar
jplang committed
551
      ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
552 553
      if ancestors.any?
        root = ancestors.shift
554
        b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
555
        if ancestors.size > 2
556
          b << "\xe2\x80\xa6"
557 558
          ancestors = ancestors[-2, 2]
        end
559
        b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
560
      end
561 562 563 564 565 566 567
      b << content_tag(:span, h(@project), class: 'current-project')
      if b.size > 1
        separator = content_tag(:span, ' &raquo; '.html_safe, class: 'separator')
        path = safe_join(b[0..-2], separator) + separator
        b = [content_tag(:span, path.html_safe, class: 'breadcrumbs'), b[-1]]
      end
      safe_join b
568 569
    end
  end
570

571
  # Returns a h2 tag and sets the html title with the given arguments
572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589
  def title(*args)
    strings = args.map do |arg|
      if arg.is_a?(Array) && arg.size >= 2
        link_to(*arg)
      else
        h(arg.to_s)
      end
    end
    html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
    content_tag('h2', strings.join(' &#187; ').html_safe)
  end

  # Sets the html title
  # Returns the html title when called without arguments
  # Current project name and app_title and automatically appended
  # Exemples:
  #   html_title 'Foo', 'Bar'
  #   html_title # => 'Foo - Bar - My Project - Redmine'
590 591
  def html_title(*args)
    if args.empty?
592
      title = @html_title || []
tmaruyama's avatar
tmaruyama committed
593
      title << @project.name if @project
594
      title << Setting.app_title unless Setting.app_title == title.last
595
      title.reject(&:blank?).join(' - ')
596 597 598 599
    else
      @html_title ||= []
      @html_title += args
    end
600
  end
jplang's avatar
jplang committed
601

602 603 604 605 606 607 608 609
  # Returns the theme, controller name, and action as css classes for the
  # HTML body.
  def body_css_classes
    css = []
    if theme = Redmine::Themes.theme(Setting.ui_theme)
      css << 'theme-' + theme.name
    end

610
    css << 'project-' + @project.identifier if @project && @project.identifier.present?
611 612
    css << 'controller-' + controller_name
    css << 'action-' + action_name
613 614 615
    if UserPreference::TEXTAREA_FONT_OPTIONS.include?(User.current.pref.textarea_font)
      css << "textarea-#{User.current.pref.textarea_font}"
    end
616 617 618
    css.join(' ')
  end

jplang's avatar
jplang committed
619
  def accesskey(s)
620 621 622 623 624
    @used_accesskeys ||= []
    key = Redmine::AccessKeys.key_for(s)
    return nil if @used_accesskeys.include?(key)
    @used_accesskeys << key
    key
jplang's avatar
jplang committed
625 626
  end

627 628 629 630 631 632 633 634
  # Formats text according to system settings.
  # 2 ways to call this method:
  # * with a String: textilizable(text, options)
  # * with an object and one of its attribute: textilizable(issue, :description, options)
  def textilizable(*args)
    options = args.last.is_a?(Hash) ? args.pop : {}
    case args.size
    when 1
635
      obj = options[:object]
jplang's avatar
jplang committed
636
      text = args.shift
637 638
    when 2
      obj = args.shift
639 640
      attr = args.shift
      text = obj.send(attr).to_s
641 642 643
    else
      raise ArgumentError, 'invalid arguments to textilizable'
    end
jplang's avatar
jplang committed
644
    return '' if text.blank?
645
    project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
646
    @only_path = only_path = options.delete(:only_path) == false ? false : true
647

648 649
    text = text.dup
    macros = catch_macros(text)
650 651 652 653

    if options[:formatting] == false
      text = h(text)
    else
654
      formatting = Setting.text_formatting
655 656
      text = Redmine::WikiFormatting.to_html(formatting, text, :object => obj, :attribute => attr)
    end
657

658
    @parsed_headings = []
659
    @heading_anchors = {}
660
    @current_section = 0 if options[:edit_section_links]
661 662

    parse_sections(text, project, obj, attr, only_path, options)
663
    text = parse_non_pre_blocks(text, obj, macros) do |text|
664
      [:parse_inline_attachments, :parse_hires_images, :parse_wiki_links, :parse_redmine_links].each do |method_name|
665 666
        send method_name, text, project, obj, attr, only_path, options
      end
667
    end
668
    parse_headings(text, project, obj, attr, only_path, options)
669

670 671 672
    if @parsed_headings.any?
      replace_toc(text, @parsed_headings)
    end
673

jplang's avatar
jplang committed
674
    text.html_safe
675
  end
676

677
  def parse_non_pre_blocks(text, obj, macros)
678 679 680 681 682 683 684 685
    s = StringScanner.new(text)
    tags = []
    parsed = ''
    while !s.eos?
      s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
      text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
      if tags.empty?
        yield text
686 687 688
        inject_macros(text, obj, macros) if macros.any?
      else
        inject_macros(text, obj, macros, false) if macros.any?
689 690 691 692
      end
      parsed << text
      if tag
        if closing
693
          if tags.last && tags.last.casecmp(tag) == 0
694 695 696 697 698 699 700 701
            tags.pop
          end
        else
          tags << tag.downcase
        end
        parsed << full_tag
      end
    end
702 703 704 705
    # Close any non closing tags
    while tag = tags.pop
      parsed << "</#{tag}>"
    end
706
    parsed
707
  end
708

709 710 711 712 713 714 715 716 717
  # add srcset attribute to img tags if filename includes @2x, @3x, etc.
  # to support hires displays
  def parse_hires_images(text, project, obj, attr, only_path, options)
    text.gsub!(/src="([^"]+@(\dx)\.(bmp|gif|jpg|jpe|jpeg|png))"/i) do |m|
      filename, dpr = $1, $2
      m + " srcset=\"#{filename} #{dpr}\""
    end
  end

718
  def parse_inline_attachments(text, project, obj, attr, only_path, options)
719 720
    return if options[:inline_attachments] == false

721
    # when using an image link, try to use an attachment, if possible
jplang's avatar
jplang committed
722 723 724
    attachments = options[:attachments] || []
    attachments += obj.attachments if obj.respond_to?(:attachments)
    if attachments.present?
725
      text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
726
        filename, ext, alt, alttext = $1.downcase, $2, $3, $4
727
        # search for the picture in attachments
728
        if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
jplang's avatar
jplang committed
729
          image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
730 731 732 733
          desc = found.description.to_s.gsub('"', '')
          if !desc.blank? && alttext.blank?
            alt = " title=\"#{desc}\" alt=\"#{desc}\""
          end
734
          "src=\"#{image_url}\"#{alt}"
735
        else
736
          m
737 738 739
        end
      end
    end
740
  end
741

742 743 744 745 746 747 748 749 750 751 752 753
  # Wiki links
  #
  # Examples:
  #   [[mypage]]
  #   [[mypage|mytext]]
  # wiki links can refer other project wikis, using project name or identifier:
  #   [[project:]] -> wiki starting page
  #   [[project:|mytext]]
  #   [[project:mypage]]
  #   [[project:mypage|mytext]]
  def parse_wiki_links(text, project, obj, attr, only_path, options)
    text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
754
      link_project = project
755 756 757
      esc, all, page, title = $1, $2, $3, $5
      if esc.nil?
        if page =~ /^([^\:]+)\:(.*)$/
758 759 760
          identifier, page = $1, $2
          link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
          title ||= identifier if page.blank?
761
        end
762

763
        if link_project && link_project.wiki && User.current.allowed_to?(:view_wiki_pages, link_project)
764 765 766 767 768
          # extract anchor
          anchor = nil
          if page =~ /^(.+?)\#(.+)$/
            page, anchor = $1, $2
          end
769
          anchor = sanitize_anchor_name(anchor) if anchor.present?
770 771
          # check if page exists
          wiki_page = link_project.wiki.find_page(page)
772 773 774 775
          url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
            "##{anchor}"
          else
            case options[:wiki_links]
776
            when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
777
            when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
778
            else
779
              wiki_page_id = page.present? ? Wiki.titleize(page) : nil
780
              parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
781
              url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
jplang's avatar
jplang committed
782
               :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
783
            end
784
          end
emassip's avatar
emassip committed
785
          link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
786 787
        else
          # project or wiki doesn't exist
788
          all
789
        end
jplang's avatar
jplang committed
790
      else
791
        all
jplang's avatar
jplang committed
792
      end
793
    end
794
  end
795

796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819
  # Redmine links
  #
  # Examples:
  #   Issues:
  #     #52 -> Link to issue #52
  #   Changesets:
  #     r52 -> Link to revision 52
  #     commit:a85130f -> Link to scmid starting with a85130f
  #   Documents:
  #     document#17 -> Link to document with id 17
  #     document:Greetings -> Link to the document with title "Greetings"
  #     document:"Some document" -> Link to the document with title "Some document"
  #   Versions:
  #     version#3 -> Link to version with id 3
  #     version:1.0.0 -> Link to version named "1.0.0"
  #     version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
  #   Attachments:
  #     attachment:file.zip -> Link to the attachment of the current object named file.zip
  #   Source files:
  #     source:some/file -> Link to the file located at /some/file in the project's repository
  #     source:some/file@52 -> Link to the file's revision 52
  #     source:some/file#L120 -> Link to line 120 of the file
  #     source:some/file@52#L120 -> Link to line 120 of the file's revision 52
  #     export:some/file -> Force the download of the file
820 821 822 823
  #   Forums:
  #     forum#1 -> Link to forum with id 1
  #     forum:Support -> Link to forum named "Support"
  #     forum:"Technical Support" -> Link to forum named "Technical Support"
824
  #   Forum messages:
825
  #     message#1218 -> Link to message with id 1218
jplang's avatar
jplang committed
826
  #   Projects:
827 828
  #     project:someproject -> Link to project named "someproject"
  #     project#3 -> Link to project with id 3
829 830 831 832
  #   News:
  #     news#2 -> Link to news item with id 1
  #     news:Greetings -> Link to news item named "Greetings"
  #     news:"First Release" -> Link to news item named "First Release"
jplang's avatar
jplang committed
833
  #   Users:
834 835
  #     user:jsmith -> Link to user with login jsmith
  #     @jsmith -> Link to user with login jsmith
jplang's avatar
jplang committed
836
  #     user#2 -> Link to user with id 2
837 838 839 840 841 842
  #
  #   Links can refer other objects from other projects, using project identifier:
  #     identifier:r52
  #     identifier:document:"Some document"
  #     identifier:version:1.0.0
  #     identifier:source:some/file
843
  def parse_redmine_links(text, default_project, obj, attr, only_path, options)
844 845 846 847 848 849 850 851 852
    text.gsub!(LINKS_RE) do |_|
      tag_content = $~[:tag_content]
      leading = $~[:leading]
      esc = $~[:esc]
      project_prefix = $~[:project_prefix]
      project_identifier = $~[:project_identifier]
      prefix = $~[:prefix]
      repo_prefix = $~[:repo_prefix]
      repo_identifier = $~[:repo_identifier]
853 854
      sep = $~[:sep1] || $~[:sep2] || $~[:sep3] || $~[:sep4]
      identifier = $~[:identifier1] || $~[:identifier2] || $~[:identifier3]
855 856 857
      comment_suffix = $~[:comment_suffix]
      comment_id = $~[:comment_id]

858 859 860 861 862 863 864 865 866 867
      if tag_content
        $&
      else
        link = nil
        project = default_project
        if project_identifier
          project = Project.visible.find_by_identifier(project_identifier)
        end
        if esc.nil?
          if prefix.nil? && sep == 'r'
868 869
            if project
              repository = nil
870
              if repo_identifier
871 872 873 874
                repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
              else
                repository = project.repository
              end
875 876 877 878 879 880 881 882 883 884 885
              # project.changesets.visible raises an SQL error because of a double join on repositories
              if repository &&
                   (changeset = Changeset.visible.
                                    find_by_repository_id_and_revision(repository.id, identifier))
                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},
                               :class => 'changeset',
                               :title => truncate_single_line_raw(changeset.comments, 100))
886
              end
887
            end
888 889 890 891 892
          elsif sep == '#'
            oid = identifier.to_i
            case prefix
            when nil
              if oid.to_s == identifier &&
jplang's avatar
jplang committed
893
                issue = Issue.visible.find_by_id(oid)
894
                anchor = comment_id ? "note-#{comment_id}" : nil
jplang's avatar
jplang committed
895
                link = link_to("##{oid}#{comment_suffix}",
jplang's avatar
jplang committed
896
                               issue_url(issue, :only_path => only_path, :anchor => anchor),
897
                               :class => issue.css_classes,
898
                               :title => "#{issue.tracker.name}: #{issue.subject.truncate(100)} (#{issue.status.name})")
899 900 901
              end
            when 'document'
              if document = Document.visible.find_by_id(oid)
jplang's avatar
jplang committed
902
                link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
903 904 905
              end
            when 'version'
              if version = Version.visible.find_by_id(oid)
jplang's avatar
jplang committed
906
                link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
907 908
              end
            when 'message'
jplang's avatar
jplang committed
909
              if message = Message.visible.find_by_id(oid)
910 911 912 913
                link = link_to_message(message, {:only_path => only_path}, :class => 'message')
              end
            when 'forum'
              if board = Board.visible.find_by_id(oid)
jplang's avatar
jplang committed
914
                link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
915 916 917
              end
            when 'news'
              if news = News.visible.find_by_id(oid)
jplang's avatar
jplang committed
918
                link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
919 920 921 922 923
              end
            when 'project'
              if p = Project.visible.find_by_id(oid)
                link = link_to_project(p, {:only_path => only_path}, :class => 'project')
              end
jplang's avatar
jplang committed
924 925 926
            when 'user'
              u = User.visible.where(:id => oid, :type => 'User').first
              link = link_to_user(u) if u
927
            end