application_helper.rb 57.7 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?)
56 57
        only_path = options[:only_path].nil? ? true : options[:only_path]
        link_to name, user_url(user, :only_path => only_path), :class => user.css_classes
58 59 60
      else
        name
      end
jplang's avatar
jplang committed
61
    else
62
      h(user.to_s)
jplang's avatar
jplang committed
63
    end
64
  end
65

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

95 96 97 98 99 100
  # 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
101 102 103 104 105 106 107 108 109
    if options.delete(:download)
      route_method = :download_named_attachment_url
      options[:filename] = attachment.filename
    else
      route_method = :attachment_url
      # make sure we don't have an extraneous :filename in the options
      options.delete(:filename)
    end
    html_options = options.slice!(:only_path, :filename)
jplang's avatar
jplang committed
110
    options[:only_path] = true unless options.key?(:only_path)
111
    url = send(route_method, attachment, options)
112
    link_to text, url, html_options
113
  end
114

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

132 133 134
  # Generates a link to a message
  def link_to_message(message, options={}, html_options = nil)
    link_to(
135
      message.subject.truncate(60),
jplang's avatar
jplang committed
136
      board_message_url(message.board_id, message.parent_id || message.id, {
137
        :r => (message.parent_id && message.id),
jplang's avatar
jplang committed
138 139
        :anchor => (message.parent_id ? "message-#{message.id}" : nil),
        :only_path => true
140
      }.merge(options)),
141 142 143
      html_options
    )
  end
144

145 146
  # Generates a link to a project if active
  # Examples:
147
  #
148 149 150 151 152
  #   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)
153
    if project.archived?
154 155
      h(project.name)
    else
jplang's avatar
jplang committed
156 157 158
      link_to project.name,
        project_url(project, {:only_path => true}.merge(options)),
        html_options
159 160 161 162 163 164 165 166 167 168 169
    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
170 171 172
    end
  end

173 174 175 176 177 178 179
  # 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

180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213
  RECORD_LINK = {
    'CustomValue'  => -> (custom_value) { link_to_record(custom_value.customized) },
    'Document'     => -> (document)     { link_to(document.title, document_path(document)) },
    'Group'        => -> (group)        { link_to(group.name, group_path(group)) },
    'Issue'        => -> (issue)        { link_to_issue(issue, :subject => false) },
    'Message'      => -> (message)      { link_to_message(message) },
    'News'         => -> (news)         { link_to(news.title, news_path(news)) },
    'Project'      => -> (project)      { link_to_project(project) },
    'User'         => -> (user)         { link_to_user(user) },
    'Version'      => -> (version)      { link_to_version(version) },
    'WikiPage'     => -> (wiki_page)    { link_to(wiki_page.pretty_title, project_wiki_page_path(wiki_page.project, wiki_page.title)) }
  }

  def link_to_record(record)
    if link = RECORD_LINK[record.class.name]
      self.instance_exec(record, &link)
    end
  end

  ATTACHMENT_CONTAINER_LINK = {
    # Custom list, since project/version attachments are listed in the files
    # view and not in the project/milestone view
    'Project'      => -> (project)      { link_to(l(:project_module_files), project_files_path(project)) },
    'Version'      => -> (version)      { link_to(l(:project_module_files), project_files_path(version.project)) },
  }

  def link_to_attachment_container(attachment_container)
    if link = ATTACHMENT_CONTAINER_LINK[attachment_container.class.name] ||
              RECORD_LINK[attachment_container.class.name]
      self.instance_exec(attachment_container, &link)
    end
  end


214
  # Helper that formats object for html or text rendering
215 216 217 218
  def format_object(object, html=true, &block)
    if block_given?
      object = yield object
    end
219
    case object.class.name
220
    when 'Array'
221 222
      formatted_objects = object.map {|o| format_object(o, html)}
      html ? safe_join(formatted_objects, ', ') : formatted_objects.join(', ')
223 224 225 226 227 228 229 230 231 232 233 234 235
    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'
236
      html ? link_to_version(object) : object.to_s
237 238 239 240 241 242
    when 'TrueClass'
      l(:general_text_Yes)
    when 'FalseClass'
      l(:general_text_No)
    when 'Issue'
      object.visible? && html ? link_to_issue(object) : "##{object.id}"
243
    when 'Attachment'
244
      html ? link_to_attachment(object) : object.filename
245 246 247 248 249 250
    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
251
          format_object(f, html, &block)
252 253 254 255
        end
      else
        object.value.to_s
      end
256 257 258 259 260
    else
      html ? h(object) : object.to_s
    end
  end

261 262 263 264
  def wiki_page_path(page, options={})
    url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
  end

265
  def thumbnail_tag(attachment)
266
    thumbnail_size = Setting.thumbnails_size.to_i
267 268 269
    link_to(
      image_tag(
        thumbnail_path(attachment),
270 271
        :srcset => "#{thumbnail_path(attachment, :size => thumbnail_size * 2)} 2x",
        :style => "max-width: #{thumbnail_size}px; max-height: #{thumbnail_size}px;"
272
      ),
273 274
      attachment_path(
        attachment
275
      ),
276
      :title => attachment.filename
277
    )
278 279
  end

jplang's avatar
jplang committed
280
  def toggle_link(name, id, options={})
281 282
    onclick = "$('##{id}').toggle(); "
    onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
283
    onclick << "$(window).scrollTop($('##{options[:focus]}').position().top); " if options[:scroll]
jplang's avatar
jplang committed
284 285 286
    onclick << "return false;"
    link_to(name, "#", :onclick => onclick)
  end
287

288
  # Used to format item titles on the activity view
289
  def format_activity_title(text)
290
    text
291
  end
292

293
  def format_activity_day(date)
294
    date == User.current.today ? l(:label_today).titleize : format_date(date)
295
  end
296

297
  def format_activity_description(text)
298
    h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
299
       ).gsub(/[\r\n]+/, "<br />").html_safe
300
  end
301

302
  def format_version_name(version)
303
    if version.project == @project
304
      h(version)
305 306 307 308
    else
      h("#{version.project} - #{version}")
    end
  end
309

310 311 312 313 314
  def format_changeset_comments(changeset, options={})
    method = options[:short] ? :short_comments : :comments
    textilizable changeset, method, :formatting => Setting.commit_logs_formatting?
  end

315 316
  def due_date_distance_in_words(date)
    if date
317
      l((date < User.current.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(User.current.today, date))
318 319
    end
  end
320

321 322 323
  # 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)
324
  def render_project_nested_lists(projects, &block)
325 326 327 328
    s = ''
    if projects.any?
      ancestors = []
      original_project = @project
jplang's avatar
jplang committed
329
      projects.sort_by(&:lft).each do |project|
330 331 332 333 334 335 336 337 338 339 340 341 342 343
        # 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}'>"
344
        s << h(block_given? ? capture(project, &block) : project.name)
345 346 347 348 349 350 351 352 353
        s << "</div>\n"
        ancestors << project
      end
      s << ("</li></ul>\n" * ancestors.size)
      @project = original_project
    end
    s.html_safe
  end

354
  def render_page_hierarchy(pages, node=nil, options={})
355 356 357 358 359
    content = ''
    if pages[node]
      content << "<ul class=\"pages-hierarchy\">\n"
      pages[node].each do |page|
        content << "<li>"
jplang's avatar
jplang committed
360
        content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
361 362
                           :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]
363 364 365 366
        content << "</li>\n"
      end
      content << "</ul>\n"
    end
367
    content.html_safe
368
  end
369

370 371 372 373
  # Renders flash messages
  def render_flash_messages
    s = ''
    flash.each do |k,v|
374
      s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
375
    end
376
    s.html_safe
377
  end
378

jplang's avatar
jplang committed
379
  # Renders tabs and their content
380
  def render_tabs(tabs, selected=params[:tab])
jplang's avatar
jplang committed
381
    if tabs.any?
382 383 384 385 386
      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
387 388 389 390
    else
      content_tag 'p', l(:label_no_data), :class => "nodata"
    end
  end
391

392 393 394 395 396 397 398 399
  # 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

400 401 402 403 404 405 406 407 408 409
  # 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)
410
    jump = params[:jump].presence || current_menu_item
411 412 413 414
    s = ''.html_safe
    project_tree(projects) do |project, level|
      padding = level * 16
      text = content_tag('span', project.name, :style => "padding-left:#{padding}px;")
415
      s << link_to(text, project_path(project, :jump => jump), :title => project.name, :class => (project == selected ? 'selected' : nil))
416 417 418
    end
    s
  end
419

420 421
  # Renders the project quick-jump box
  def render_project_jump_box
422
    projects = projects_for_jump_box(User.current)
423 424 425 426
    if @project && @project.persisted?
      text = @project.name_was
    end
    text ||= l(:label_jump_to_a_project)
427 428
    url = autocomplete_projects_path(:format => 'js', :jump => current_menu_item)

429
    trigger = content_tag('span', text, :class => 'drdn-trigger')
430
    q = text_field_tag('q', '', :id => 'projects-quick-search', :class => 'autocomplete', :data => {:automcomplete_url => url}, :autocomplete => 'off')
431 432 433 434 435 436 437
    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
438

439
    content_tag('div', trigger + content, :id => "project-jump", :class => "drdn")
440
  end
441

442
  def project_tree_options_for_select(projects, options = {})
443
    s = ''.html_safe
444 445 446 447 448
    if blank_text = options[:include_blank]
      if blank_text == true
        blank_text = '&nbsp;'.html_safe
      end
      s << content_tag('option', blank_text, :value => '')
449
    end
450
    project_tree(projects) do |project, level|
emassip's avatar
emassip committed
451
      name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
452 453 454 455 456 457
      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
458 459 460
      tag_options.merge!(yield(project)) if block_given?
      s << content_tag('option', name_prefix + h(project), tag_options)
    end
461
    s.html_safe
462
  end
463

464
  # Yields the given block for each project with its level in the tree
465 466
  #
  # Wrapper for Project#project_tree
467 468
  def project_tree(projects, options={}, &block)
    Project.project_tree(projects, options, &block)
469
  end
470

jplang's avatar
jplang committed
471 472
  def principals_check_box_tags(name, principals)
    s = ''
473
    principals.each do |principal|
jplang's avatar
jplang committed
474
      s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
jplang's avatar
jplang committed
475
    end
476
    s.html_safe
jplang's avatar
jplang committed
477
  end
478

479 480 481
  # Returns a string for users/groups option tags
  def principals_options_for_select(collection, selected=nil)
    s = ''
482
    if collection.include?(User.current)
jplang's avatar
jplang committed
483
      s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
484
    end
485 486
    groups = ''
    collection.sort.each do |element|
487
      selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
488 489 490 491 492
      (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
493
    s.html_safe
494
  end
495

496 497 498 499
  def option_tag(name, text, value, selected=nil, options={})
    content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
  end

500
  def truncate_single_line_raw(string, length)
jplang's avatar
jplang committed
501
    string.to_s.truncate(length).gsub(%r{[\r\n]+}m, ' ')
502 503
  end

504 505 506 507 508 509 510 511 512
  # 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
513

jplang's avatar
jplang committed
514
  def anchor(text)
515
    text.to_s.tr(' ', '_')
jplang's avatar
jplang committed
516 517
  end

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

jplang's avatar
jplang committed
522
  def authoring(created, author, options={})
523
    l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
524
  end
525

526 527 528
  def time_tag(time)
    text = distance_of_time_in_words(Time.now, time)
    if @project
jplang's avatar
jplang committed
529
      link_to(text, project_activity_path(@project, :from => User.current.time_to_date(time)), :title => format_time(time))
530
    else
531
      content_tag('abbr', text, :title => format_time(time))
532
    end
533 534
  end

535
  def syntax_highlight_lines(name, content)
536
    syntax_highlight(name, content).each_line.to_a
537 538
  end

539
  def syntax_highlight(name, content)
540
    Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
541
  end
542

jplang's avatar
jplang committed
543
  def to_path_param(path)
544 545
    str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
    str.blank? ? nil : str
jplang's avatar
jplang committed
546 547
  end

548 549 550 551 552 553
  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
554
      :class => "sort-handle",
555 556
      :data => data,
      :title => l(:button_sort))
557 558
  end

559
  def breadcrumb(*args)
560
    elements = args.flatten
561
    elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
562
  end
563

564
  def other_formats_links(&block)
jplang's avatar
jplang committed
565
    concat('<p class="other-formats">'.html_safe + l(:label_export_to))
566
    yield Redmine::Views::OtherFormatsBuilder.new(self)
jplang's avatar
jplang committed
567
    concat('</p>'.html_safe)
568
  end
569

570 571 572 573 574
  def page_header_title
    if @project.nil? || @project.new_record?
      h(Setting.app_title)
    else
      b = []
jplang's avatar
jplang committed
575
      ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
576 577
      if ancestors.any?
        root = ancestors.shift
578
        b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
579
        if ancestors.size > 2
580
          b << "\xe2\x80\xa6"
581 582
          ancestors = ancestors[-2, 2]
        end
583
        b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
584
      end
585 586 587 588 589 590 591
      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
592 593
    end
  end
594

595
  # Returns a h2 tag and sets the html title with the given arguments
596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613
  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'
614 615
  def html_title(*args)
    if args.empty?
616
      title = @html_title || []
tmaruyama's avatar
tmaruyama committed
617
      title << @project.name if @project
618
      title << Setting.app_title unless Setting.app_title == title.last
619
      title.reject(&:blank?).join(' - ')
620 621 622 623
    else
      @html_title ||= []
      @html_title += args
    end
624
  end
jplang's avatar
jplang committed
625

626 627 628 629 630 631 632 633
  # 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

634
    css << 'project-' + @project.identifier if @project && @project.identifier.present?
635
    css << 'has-main-menu' if display_main_menu?(@project)
636 637
    css << 'controller-' + controller_name
    css << 'action-' + action_name
638
    css << 'avatars-' + (Setting.gravatar_enabled? ? 'on' : 'off')
639 640 641
    if UserPreference::TEXTAREA_FONT_OPTIONS.include?(User.current.pref.textarea_font)
      css << "textarea-#{User.current.pref.textarea_font}"
    end
642 643 644
    css.join(' ')
  end

jplang's avatar
jplang committed
645
  def accesskey(s)
646 647 648 649 650
    @used_accesskeys ||= []
    key = Redmine::AccessKeys.key_for(s)
    return nil if @used_accesskeys.include?(key)
    @used_accesskeys << key
    key
jplang's avatar
jplang committed
651 652
  end

653 654 655 656 657 658 659 660
  # 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
661
      obj = options[:object]
jplang's avatar
jplang committed
662
      text = args.shift
663 664
    when 2
      obj = args.shift
665 666
      attr = args.shift
      text = obj.send(attr).to_s
667 668 669
    else
      raise ArgumentError, 'invalid arguments to textilizable'
    end
jplang's avatar
jplang committed
670
    return '' if text.blank?
671
    project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
672
    @only_path = only_path = options.delete(:only_path) == false ? false : true
673

674 675
    text = text.dup
    macros = catch_macros(text)
676 677 678 679

    if options[:formatting] == false
      text = h(text)
    else
680
      formatting = Setting.text_formatting
681 682
      text = Redmine::WikiFormatting.to_html(formatting, text, :object => obj, :attribute => attr)
    end
683

684
    @parsed_headings = []
685
    @heading_anchors = {}
686
    @current_section = 0 if options[:edit_section_links]
687 688

    parse_sections(text, project, obj, attr, only_path, options)
689
    text = parse_non_pre_blocks(text, obj, macros) do |text|
690
      [:parse_inline_attachments, :parse_hires_images, :parse_wiki_links, :parse_redmine_links].each do |method_name|
691 692
        send method_name, text, project, obj, attr, only_path, options
      end
693
    end
694
    parse_headings(text, project, obj, attr, only_path, options)
695

696 697 698
    if @parsed_headings.any?
      replace_toc(text, @parsed_headings)
    end
699

jplang's avatar
jplang committed
700
    text.html_safe
701
  end
702

703
  def parse_non_pre_blocks(text, obj, macros)
704 705 706 707 708 709 710 711
    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
712 713 714
        inject_macros(text, obj, macros) if macros.any?
      else
        inject_macros(text, obj, macros, false) if macros.any?
715 716 717 718
      end
      parsed << text
      if tag
        if closing
719
          if tags.last && tags.last.casecmp(tag) == 0
720 721 722 723 724 725 726 727
            tags.pop
          end
        else
          tags << tag.downcase
        end
        parsed << full_tag
      end
    end
728 729 730 731
    # Close any non closing tags
    while tag = tags.pop
      parsed << "</#{tag}>"
    end
732
    parsed
733
  end
734

735 736 737 738 739 740 741 742 743
  # 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

744
  def parse_inline_attachments(text, project, obj, attr, only_path, options)
745 746
    return if options[:inline_attachments] == false

747
    # when using an image link, try to use an attachment, if possible
jplang's avatar
jplang committed
748 749 750
    attachments = options[:attachments] || []
    attachments += obj.attachments if obj.respond_to?(:attachments)
    if attachments.present?
751
      text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
752
        filename, ext, alt, alttext = $1.downcase, $2, $3, $4
753
        # search for the picture in attachments
754
        if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
jplang's avatar
jplang committed
755
          image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
756 757 758 759
          desc = found.description.to_s.gsub('"', '')
          if !desc.blank? && alttext.blank?
            alt = " title=\"#{desc}\" alt=\"#{desc}\""
          end
760
          "src=\"#{image_url}\"#{alt}"
761
        else
762
          m
763 764 765
        end
      end
    end
766
  end
767

768 769 770 771 772 773 774 775 776 777 778 779
  # 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|
780
      link_project = project
781 782
      esc, all, page, title = $1, $2, $3, $5
      if esc.nil?
783
        page = CGI.unescapeHTML(page)
784 785 786 787 788 789
        if page =~ /^\#(.+)$/
          anchor = sanitize_anchor_name($1)
          url = "##{anchor}"
          next link_to(title.present? ? title.html_safe : h(page), url, :class => 'wiki-page')
        end

790
        if page =~ /^([^\:]+)\:(.*)$/
791 792 793
          identifier, page = $1, $2
          link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
          title ||= identifier if page.blank?
794
        end
795

796
        if link_project && link_project.wiki && User.current.allowed_to?(:view_wiki_pages, link_project)
797 798 799 800 801
          # extract anchor
          anchor = nil
          if page =~ /^(.+?)\#(.+)$/
            page, anchor = $1, $2
          end
802
          anchor = sanitize_anchor_name(anchor) if anchor.present?
803 804
          # check if page exists
          wiki_page = link_project.wiki.find_page(page)
805 806 807 808
          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]
809
            when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
810
            when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
811
            else
812
              wiki_page_id = page.present? ? Wiki.titleize(page) : nil
813
              parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
814
              url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
jplang's avatar
jplang committed
815
               :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
816
            end
817
          end
emassip's avatar
emassip committed
818
          link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
819 820
        else
          # project or wiki doesn't exist
821
          all
822
        end
jplang's avatar
jplang committed
823
      else
824
        all
jplang's avatar
jplang committed
825
      end
826
    end
827
  end
828

829 830 831 832 833
  # Redmine links
  #
  # Examples:
  #   Issues:
  #     #52 -> Link to issue #52
834
  #     ##52 -> Link to issue #52, including the issue's subject
835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853
  #   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
854 855 856 857
  #   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"
858
  #   Forum messages:
859
  #     message#1218 -> Link to message with id 1218
jplang's avatar
jplang committed
860
  #   Projects:
861 862
  #     project:someproject -> Link to project named "someproject"
  #     project#3 -> Link to project with id 3
863 864 865 866
  #   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
867
  #   Users:
868 869
  #     user:jsmith -> Link to user with login jsmith
  #     @jsmith -> Link to user with login jsmith
jplang's avatar
jplang committed
870
  #     user#2 -> Link to user with id 2
871 872 873 874 875 876
  #
  #   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
877
  def parse_redmine_links(text, default_project, obj, attr, only_path, options)
878 879 880 881 882 883 884 885 886
    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]
887 888
      sep = $~[:sep1] || $~[:sep2] || $~[:sep3] || $~[:sep4]
      identifier = $~[:identifier1] || $~[:identifier2] || $~[:identifier3]
889 890 891
      comment_suffix = $~[:comment_suffix]
      comment_id = $~[:comment_id]

892 893 894 895 896 897 898 899 900 901
      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'
902 903
            if project
              repository = nil
904
              if repo_identifier
905 906 907 908
                repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
              else
                repository = project.repository
              end
909 910 911 912 913 914 915 916 917 918 919
              # 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))
920
              end
921
            end
922
          elsif sep == '#' || sep == '##'
923 924 925 926
            oid = identifier.to_i
            case prefix
            when nil
              if oid.to_s == identifier &&
jplang's avatar
jplang committed
927
                issue = Issue.visible.find_by_id(oid)
928
                anchor = comment_id ? "note-#{comment_id}" : nil
929 930 931 932 933 934 935 936 937 938 939 940
                url = issue_url(issue, :only_path => only_path, :anchor => anchor)
                link = if sep == '##'
                  link_to("#{issue.tracker.name} ##{oid}#{comment_suffix}",
                          url,
                          :class => issue.css_classes,
                          :title => "#{issue.tracker.name}: #{issue.subject.truncate(100)} (#{issue.status.name})") + ": #{issue.subject}"
                else
                  link_to("##{oid}#{comment_suffix}",
                          url,
                          :class => issue.css_classes,
                          :title => "#{issue.tracker.name}: #{issue.subject.truncate(100)} (#{issue.status.name})")
                end
941 942 943
              end
            when 'document'
              if document = Document.visible.find_by_id(oid)
jplang's avatar
jplang committed
944
                link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
945 946 947
              end
            when 'version'
              if version = Version.visible.find_by_id(oid)
jplang's avatar
jplang committed
948
                link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
949 950
              end
            when 'message'
jplang's avatar
jplang committed
951
              if message = Message.visible.find_by_id(oid)
952 953 954 955
                link = link_to_message(message, {:only_path => only_path}, :class => 'message')
              end
            when 'forum'
              if board = Board.visible.find_by_id(oid)