application_helper.rb 51.1 KB
Newer Older
1 2
# encoding: utf-8
#
3
# Redmine - project management software
jplang's avatar
jplang committed
4
# Copyright (C) 2006-2016  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 201
    when 'Attachment'
      html ? link_to_attachment(object, :download => true) : 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 224
    link_to image_tag(thumbnail_path(attachment)),
      named_attachment_path(attachment, attachment.filename),
225 226 227
      :title => attachment.filename
  end

jplang's avatar
jplang committed
228
  def toggle_link(name, id, options={})
229 230
    onclick = "$('##{id}').toggle(); "
    onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
jplang's avatar
jplang committed
231 232 233
    onclick << "return false;"
    link_to(name, "#", :onclick => onclick)
  end
234

235
  # Used to format item titles on the activity view
236
  def format_activity_title(text)
237
    text
238
  end
239

240
  def format_activity_day(date)
241
    date == User.current.today ? l(:label_today).titleize : format_date(date)
242
  end
243

244
  def format_activity_description(text)
245
    h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
246
       ).gsub(/[\r\n]+/, "<br />").html_safe
247
  end
248

249
  def format_version_name(version)
250
    if version.project == @project
251
      h(version)
252 253 254 255
    else
      h("#{version.project} - #{version}")
    end
  end
256

257 258 259 260 261
  def format_changeset_comments(changeset, options={})
    method = options[:short] ? :short_comments : :comments
    textilizable changeset, method, :formatting => Setting.commit_logs_formatting?
  end

262 263
  def due_date_distance_in_words(date)
    if date
264
      l((date < User.current.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(User.current.today, date))
265 266
    end
  end
267

268 269 270
  # 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)
271
  def render_project_nested_lists(projects, &block)
272 273 274 275
    s = ''
    if projects.any?
      ancestors = []
      original_project = @project
jplang's avatar
jplang committed
276
      projects.sort_by(&:lft).each do |project|
277 278 279 280 281 282 283 284 285 286 287 288 289 290
        # 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}'>"
291
        s << h(block_given? ? capture(project, &block) : project.name)
292 293 294 295 296 297 298 299 300
        s << "</div>\n"
        ancestors << project
      end
      s << ("</li></ul>\n" * ancestors.size)
      @project = original_project
    end
    s.html_safe
  end

301
  def render_page_hierarchy(pages, node=nil, options={})
302 303 304 305 306
    content = ''
    if pages[node]
      content << "<ul class=\"pages-hierarchy\">\n"
      pages[node].each do |page|
        content << "<li>"
jplang's avatar
jplang committed
307
        content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
308 309
                           :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]
310 311 312 313
        content << "</li>\n"
      end
      content << "</ul>\n"
    end
314
    content.html_safe
315
  end
316

317 318 319 320
  # Renders flash messages
  def render_flash_messages
    s = ''
    flash.each do |k,v|
321
      s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
322
    end
323
    s.html_safe
324
  end
325

jplang's avatar
jplang committed
326
  # Renders tabs and their content
327
  def render_tabs(tabs, selected=params[:tab])
jplang's avatar
jplang committed
328
    if tabs.any?
329 330 331 332 333
      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
334 335 336 337
    else
      content_tag 'p', l(:label_no_data), :class => "nodata"
    end
  end
338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356
 
  # 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)
    s = ''.html_safe
    project_tree(projects) do |project, level|
      padding = level * 16
      text = content_tag('span', project.name, :style => "padding-left:#{padding}px;")
      s << link_to(text, project_path(project, :jump => current_menu_item), :title => project.name, :class => (project == selected ? 'selected' : nil))
    end
    s
  end
357

358 359
  # Renders the project quick-jump box
  def render_project_jump_box
360
    projects = projects_for_jump_box(User.current)
361
    if projects.any?
362 363 364 365 366 367 368 369
      text = @project.try(:name) || l(:label_jump_to_a_project)
      trigger = content_tag('span', text, :class => 'drdn-trigger')
      q = text_field_tag('q', '', :id => 'projects-quick-search', :class => 'autocomplete', :data => {:automcomplete_url => projects_path(:format => 'js')})
      content = content_tag('div',
            content_tag('div', q, :class => 'quick-search') + 
            content_tag('div', render_projects_for_jump_box(projects, @project), :class => 'drdn-items selection'),
          :class => 'drdn-content'
        )
emassip's avatar
emassip committed
370

371
      content_tag('span', trigger + content, :id => "project-jump", :class => "drdn")
372 373
    end
  end
374

375
  def project_tree_options_for_select(projects, options = {})
376
    s = ''.html_safe
377 378 379 380 381
    if blank_text = options[:include_blank]
      if blank_text == true
        blank_text = '&nbsp;'.html_safe
      end
      s << content_tag('option', blank_text, :value => '')
382
    end
383
    project_tree(projects) do |project, level|
emassip's avatar
emassip committed
384
      name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
385 386 387 388 389 390
      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
391 392 393
      tag_options.merge!(yield(project)) if block_given?
      s << content_tag('option', name_prefix + h(project), tag_options)
    end
394
    s.html_safe
395
  end
396

397
  # Yields the given block for each project with its level in the tree
398 399
  #
  # Wrapper for Project#project_tree
400 401
  def project_tree(projects, options={}, &block)
    Project.project_tree(projects, options, &block)
402
  end
403

jplang's avatar
jplang committed
404 405
  def principals_check_box_tags(name, principals)
    s = ''
406
    principals.each do |principal|
jplang's avatar
jplang committed
407
      s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
jplang's avatar
jplang committed
408
    end
409
    s.html_safe
jplang's avatar
jplang committed
410
  end
411

412 413 414
  # Returns a string for users/groups option tags
  def principals_options_for_select(collection, selected=nil)
    s = ''
415
    if collection.include?(User.current)
jplang's avatar
jplang committed
416
      s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
417
    end
418 419
    groups = ''
    collection.sort.each do |element|
420
      selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
421 422 423 424 425
      (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
426
    s.html_safe
427
  end
428

429 430 431 432
  def option_tag(name, text, value, selected=nil, options={})
    content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
  end

433
  def truncate_single_line_raw(string, length)
jplang's avatar
jplang committed
434
    string.to_s.truncate(length).gsub(%r{[\r\n]+}m, ' ')
435 436
  end

437 438 439 440 441 442 443 444 445
  # 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
446

jplang's avatar
jplang committed
447 448 449 450
  def anchor(text)
    text.to_s.gsub(' ', '_')
  end

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

jplang's avatar
jplang committed
455
  def authoring(created, author, options={})
456
    l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
457
  end
458

459 460 461
  def time_tag(time)
    text = distance_of_time_in_words(Time.now, time)
    if @project
jplang's avatar
jplang committed
462
      link_to(text, project_activity_path(@project, :from => User.current.time_to_date(time)), :title => format_time(time))
463
    else
464
      content_tag('abbr', text, :title => format_time(time))
465
    end
466 467
  end

468 469 470 471 472 473
  def syntax_highlight_lines(name, content)
    lines = []
    syntax_highlight(name, content).each_line { |line| lines << line }
    lines
  end

474
  def syntax_highlight(name, content)
475
    Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
476
  end
477

jplang's avatar
jplang committed
478
  def to_path_param(path)
479 480
    str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
    str.blank? ? nil : str
jplang's avatar
jplang committed
481 482
  end

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

487
    link_to(l(:label_sort_highest),
488 489
            url.merge({"#{name}[move_to]" => 'highest'}), :method => method,
            :title => l(:label_sort_highest), :class => 'icon-only icon-move-top') +
490
    link_to(l(:label_sort_higher),
491 492
            url.merge({"#{name}[move_to]" => 'higher'}), :method => method,
            :title => l(:label_sort_higher), :class => 'icon-only icon-move-up') +
493
    link_to(l(:label_sort_lower),
494 495
            url.merge({"#{name}[move_to]" => 'lower'}), :method => method,
            :title => l(:label_sort_lower), :class => 'icon-only icon-move-down') +
496
    link_to(l(:label_sort_lowest),
497 498
            url.merge({"#{name}[move_to]" => 'lowest'}), :method => method,
            :title => l(:label_sort_lowest), :class => 'icon-only icon-move-bottom')
jplang's avatar
jplang committed
499
  end
500

501 502 503 504 505 506
  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
507
      :class => "sort-handle",
508 509
      :data => data,
      :title => l(:button_sort))
510 511
  end

512
  def breadcrumb(*args)
513
    elements = args.flatten
514
    elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
515
  end
516

517
  def other_formats_links(&block)
jplang's avatar
jplang committed
518
    concat('<p class="other-formats">'.html_safe + l(:label_export_to))
519
    yield Redmine::Views::OtherFormatsBuilder.new(self)
jplang's avatar
jplang committed
520
    concat('</p>'.html_safe)
521
  end
522

523 524 525 526 527
  def page_header_title
    if @project.nil? || @project.new_record?
      h(Setting.app_title)
    else
      b = []
jplang's avatar
jplang committed
528
      ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
529 530
      if ancestors.any?
        root = ancestors.shift
531
        b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
532
        if ancestors.size > 2
533
          b << "\xe2\x80\xa6"
534 535
          ancestors = ancestors[-2, 2]
        end
536
        b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
537
      end
538 539 540 541 542 543 544
      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
545 546
    end
  end
547

548
  # Returns a h2 tag and sets the html title with the given arguments
549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566
  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'
567 568
  def html_title(*args)
    if args.empty?
569
      title = @html_title || []
tmaruyama's avatar
tmaruyama committed
570
      title << @project.name if @project
571
      title << Setting.app_title unless Setting.app_title == title.last
572
      title.reject(&:blank?).join(' - ')
573 574 575 576
    else
      @html_title ||= []
      @html_title += args
    end
577
  end
jplang's avatar
jplang committed
578

579 580 581 582 583 584 585 586
  # 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

587
    css << 'project-' + @project.identifier if @project && @project.identifier.present?
588 589
    css << 'controller-' + controller_name
    css << 'action-' + action_name
590 591 592
    if UserPreference::TEXTAREA_FONT_OPTIONS.include?(User.current.pref.textarea_font)
      css << "textarea-#{User.current.pref.textarea_font}"
    end
593 594 595
    css.join(' ')
  end

jplang's avatar
jplang committed
596
  def accesskey(s)
597 598 599 600 601
    @used_accesskeys ||= []
    key = Redmine::AccessKeys.key_for(s)
    return nil if @used_accesskeys.include?(key)
    @used_accesskeys << key
    key
jplang's avatar
jplang committed
602 603
  end

604 605 606 607 608 609 610 611
  # 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
612
      obj = options[:object]
jplang's avatar
jplang committed
613
      text = args.shift
614 615
    when 2
      obj = args.shift
616 617
      attr = args.shift
      text = obj.send(attr).to_s
618 619 620
    else
      raise ArgumentError, 'invalid arguments to textilizable'
    end
jplang's avatar
jplang committed
621
    return '' if text.blank?
622
    project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
623
    @only_path = only_path = options.delete(:only_path) == false ? false : true
624

625 626
    text = text.dup
    macros = catch_macros(text)
627 628 629 630 631 632 633

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

635
    @parsed_headings = []
636
    @heading_anchors = {}
637
    @current_section = 0 if options[:edit_section_links]
638 639

    parse_sections(text, project, obj, attr, only_path, options)
640 641
    text = parse_non_pre_blocks(text, obj, macros) do |text|
      [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
642 643
        send method_name, text, project, obj, attr, only_path, options
      end
644
    end
645
    parse_headings(text, project, obj, attr, only_path, options)
646

647 648 649
    if @parsed_headings.any?
      replace_toc(text, @parsed_headings)
    end
650

jplang's avatar
jplang committed
651
    text.html_safe
652
  end
653

654
  def parse_non_pre_blocks(text, obj, macros)
655 656 657 658 659 660 661 662
    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
663 664 665
        inject_macros(text, obj, macros) if macros.any?
      else
        inject_macros(text, obj, macros, false) if macros.any?
666 667 668 669
      end
      parsed << text
      if tag
        if closing
670
          if tags.last && tags.last.casecmp(tag) == 0
671 672 673 674 675 676 677 678
            tags.pop
          end
        else
          tags << tag.downcase
        end
        parsed << full_tag
      end
    end
679 680 681 682
    # Close any non closing tags
    while tag = tags.pop
      parsed << "</#{tag}>"
    end
683
    parsed
684
  end
685

686
  def parse_inline_attachments(text, project, obj, attr, only_path, options)
687 688
    return if options[:inline_attachments] == false

689
    # when using an image link, try to use an attachment, if possible
jplang's avatar
jplang committed
690 691 692
    attachments = options[:attachments] || []
    attachments += obj.attachments if obj.respond_to?(:attachments)
    if attachments.present?
693
      text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
694
        filename, ext, alt, alttext = $1.downcase, $2, $3, $4
695
        # search for the picture in attachments
696
        if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
jplang's avatar
jplang committed
697
          image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
698 699 700 701
          desc = found.description.to_s.gsub('"', '')
          if !desc.blank? && alttext.blank?
            alt = " title=\"#{desc}\" alt=\"#{desc}\""
          end
702
          "src=\"#{image_url}\"#{alt}"
703
        else
704
          m
705 706 707
        end
      end
    end
708
  end
709

710 711 712 713 714 715 716 717 718 719 720 721
  # 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|
722
      link_project = project
723 724 725
      esc, all, page, title = $1, $2, $3, $5
      if esc.nil?
        if page =~ /^([^\:]+)\:(.*)$/
726 727 728
          identifier, page = $1, $2
          link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
          title ||= identifier if page.blank?
729
        end
730

731
        if link_project && link_project.wiki
732 733 734 735 736
          # extract anchor
          anchor = nil
          if page =~ /^(.+?)\#(.+)$/
            page, anchor = $1, $2
          end
737
          anchor = sanitize_anchor_name(anchor) if anchor.present?
738 739
          # check if page exists
          wiki_page = link_project.wiki.find_page(page)
740 741 742 743
          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]
744
            when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
745
            when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
746
            else
747
              wiki_page_id = page.present? ? Wiki.titleize(page) : nil
748
              parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
749
              url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
jplang's avatar
jplang committed
750
               :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
751
            end
752
          end
emassip's avatar
emassip committed
753
          link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
754 755
        else
          # project or wiki doesn't exist
756
          all
757
        end
jplang's avatar
jplang committed
758
      else
759
        all
jplang's avatar
jplang committed
760
      end
761
    end
762
  end
763

764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787
  # 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
788
  #   Forum messages:
789
  #     message#1218 -> Link to message with id 1218
790 791 792
  #  Projects:
  #     project:someproject -> Link to project named "someproject"
  #     project#3 -> Link to project with id 3
793 794 795 796 797 798
  #
  #   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
799
  def parse_redmine_links(text, default_project, obj, attr, only_path, options)
800
    text.gsub!(%r{<a( [^>]+?)?>(.*?)</a>|([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-_]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
801
      tag_content, leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $2, $3, $4, $5, $6, $7, $12, $13, $10 || $14 || $20, $16 || $21, $17, $19
802 803 804 805 806 807 808 809 810 811
      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'
812 813
            if project
              repository = nil
814
              if repo_identifier
815 816 817 818
                repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
              else
                repository = project.repository
              end
819 820 821 822 823 824 825 826 827 828 829
              # 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))
830
              end
831
            end
832 833 834 835 836
          elsif sep == '#'
            oid = identifier.to_i
            case prefix
            when nil
              if oid.to_s == identifier &&
jplang's avatar
jplang committed
837
                issue = Issue.visible.find_by_id(oid)
838
                anchor = comment_id ? "note-#{comment_id}" : nil
jplang's avatar
jplang committed
839
                link = link_to("##{oid}#{comment_suffix}",
jplang's avatar
jplang committed
840
                               issue_url(issue, :only_path => only_path, :anchor => anchor),
841
                               :class => issue.css_classes,
842
                               :title => "#{issue.tracker.name}: #{issue.subject.truncate(100)} (#{issue.status.name})")
843 844 845
              end
            when 'document'
              if document = Document.visible.find_by_id(oid)
jplang's avatar
jplang committed
846
                link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
847 848 849
              end
            when 'version'
              if version = Version.visible.find_by_id(oid)
jplang's avatar
jplang committed
850
                link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
851 852
              end
            when 'message'
jplang's avatar
jplang committed
853
              if message = Message.visible.find_by_id(oid)
854 855 856 857
                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
858
                link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
859 860 861
              end
            when 'news'
              if news = News.visible.find_by_id(oid)
jplang's avatar
jplang committed
862
                link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
863 864 865 866 867
              end
            when 'project'
              if p = Project.visible.find_by_id(oid)
                link = link_to_project(p, {:only_path => only_path}, :class => 'project')
              end
868
            end
869 870 871 872 873 874 875
          elsif sep == ':'
            # removes the double quotes if any
            name = identifier.gsub(%r{^"(.*)"$}, "\\1")
            name = CGI.unescapeHTML(name)
            case prefix
            when 'document'
              if project && document = project.documents.visible.find_by_title(name)
jplang's avatar
jplang committed
876
                link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
877 878 879
              end
            when 'version'
              if project && version = project.versions.visible.find_by_name(name)
jplang's avatar
jplang committed
880
                link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
881 882 883
              end
            when 'forum'
              if project && board = project.boards.visible.find_by_name(name)
jplang's avatar
jplang committed
884
                link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
885 886 887
              end
            when 'news'
              if project && news = project.news.visible.find_by_title(name)
jplang's avatar
jplang committed
888
                link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927
              end
            when 'commit', 'source', 'export'
              if project
                repository = nil
                if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
                  repo_prefix, repo_identifier, name = $1, $2, $3
                  repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
                else
                  repository = project.repository
                end
                if prefix == 'commit'
                  if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
                    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},
                                                 :class => 'changeset',
                                                 :title => truncate_single_line_raw(changeset.comments, 100)
                  end
                else
                  if repository && User.current.allowed_to?(:browse_repository, project)
                    name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
                    path, rev, anchor = $1, $3, $5
                    link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
                                                            :path => to_path_param(path),
                                                            :rev => rev,
                                                            :anchor => anchor},
                                                           :class => (prefix == 'export' ? 'source download' : 'source')
                  end
                end
                repo_prefix = nil
              end
            when 'attachment'
              attachments = options[:attachments] || []
              attachments += obj.attachments if obj.respond_to?(:attachments)
              if attachments && attachment = Attachment.latest_attach(attachments, name)
                link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
              end
            when 'project'
              if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
                link = link_to_project(p, {:only_path => only_path}, :class => 'project')
              end
jplang's avatar
jplang committed
928
            end
929
          end
jplang's avatar
jplang committed
930
        end
931
        (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
932 933
      end
    end
934
  end
935

jplang's avatar
jplang committed
936
  HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
937 938 939 940

  def parse_sections(text, project, obj, attr, only_path, options)
    return unless options[:edit_section_links]
    text.gsub!(HEADING_RE) do
941
      heading, level = $1, $2
942 943
      @current_section += 1
      if @current_section > 1
944
        content_tag('div',
jplang's avatar