You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

application_helper.rb 63KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843
  1. # frozen_string_literal: true
  2. # Redmine - project management software
  3. # Copyright (C) 2006-2023 Jean-Philippe Lang
  4. #
  5. # This program is free software; you can redistribute it and/or
  6. # modify it under the terms of the GNU General Public License
  7. # as published by the Free Software Foundation; either version 2
  8. # of the License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with this program; if not, write to the Free Software
  17. # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  18. require 'forwardable'
  19. require 'cgi'
  20. module ApplicationHelper
  21. include Redmine::WikiFormatting::Macros::Definitions
  22. include Redmine::I18n
  23. include Redmine::Pagination::Helper
  24. include Redmine::SudoMode::Helper
  25. include Redmine::Themes::Helper
  26. include Redmine::Hook::Helper
  27. include Redmine::Helpers::URL
  28. extend Forwardable
  29. def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
  30. # Return true if user is authorized for controller/action, otherwise false
  31. def authorize_for(controller, action)
  32. User.current.allowed_to?({:controller => controller, :action => action}, @project)
  33. end
  34. # Display a link if user is authorized
  35. #
  36. # @param [String] name Anchor text (passed to link_to)
  37. # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
  38. # @param [optional, Hash] html_options Options passed to link_to
  39. # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
  40. def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
  41. if authorize_for(options[:controller] || params[:controller], options[:action])
  42. link_to(name, options, html_options, *parameters_for_method_reference)
  43. end
  44. end
  45. # Displays a link to user's account page if active
  46. def link_to_user(user, options={})
  47. if user.is_a?(User)
  48. name = h(user.name(options[:format]))
  49. if user.active? || (User.current.admin? && user.logged?)
  50. only_path = options[:only_path].nil? ? true : options[:only_path]
  51. link_to name, user_url(user, :only_path => only_path), :class => user.css_classes
  52. else
  53. name
  54. end
  55. else
  56. h(user.to_s)
  57. end
  58. end
  59. # Displays a link to edit group page if current user is admin
  60. # Otherwise display only the group name
  61. def link_to_group(group, options={})
  62. if group.is_a?(Group)
  63. name = h(group.name)
  64. if User.current.admin?
  65. only_path = options[:only_path].nil? ? true : options[:only_path]
  66. link_to name, edit_group_path(group, :only_path => only_path)
  67. else
  68. name
  69. end
  70. end
  71. end
  72. # Displays a link to +issue+ with its subject.
  73. # Examples:
  74. #
  75. # link_to_issue(issue) # => Defect #6: This is the subject
  76. # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
  77. # link_to_issue(issue, :subject => false) # => Defect #6
  78. # link_to_issue(issue, :project => true) # => Foo - Defect #6
  79. # link_to_issue(issue, :subject => false, :tracker => false) # => #6
  80. #
  81. def link_to_issue(issue, options={})
  82. title = nil
  83. subject = nil
  84. text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
  85. if options[:subject] == false
  86. title = issue.subject.truncate(60)
  87. else
  88. subject = issue.subject
  89. if truncate_length = options[:truncate]
  90. subject = subject.truncate(truncate_length)
  91. end
  92. end
  93. only_path = options[:only_path].nil? ? true : options[:only_path]
  94. s = link_to(text, issue_url(issue, :only_path => only_path),
  95. :class => issue.css_classes, :title => title)
  96. s << h(": #{subject}") if subject
  97. s = h("#{issue.project} - ") + s if options[:project]
  98. s
  99. end
  100. # Generates a link to an attachment.
  101. # Options:
  102. # * :text - Link text (default to attachment filename)
  103. # * :download - Force download (default: false)
  104. def link_to_attachment(attachment, options={})
  105. text = options.delete(:text) || attachment.filename
  106. if options.delete(:download)
  107. route_method = :download_named_attachment_url
  108. options[:filename] = attachment.filename
  109. else
  110. route_method = :attachment_url
  111. # make sure we don't have an extraneous :filename in the options
  112. options.delete(:filename)
  113. end
  114. html_options = options.slice!(:only_path, :filename)
  115. options[:only_path] = true unless options.key?(:only_path)
  116. url = send(route_method, attachment, options)
  117. link_to text, url, html_options
  118. end
  119. # Generates a link to a SCM revision
  120. # Options:
  121. # * :text - Link text (default to the formatted revision)
  122. def link_to_revision(revision, repository, options={})
  123. if repository.is_a?(Project)
  124. repository = repository.repository
  125. end
  126. text = options.delete(:text) || format_revision(revision)
  127. rev = revision.respond_to?(:identifier) ? revision.identifier : revision
  128. link_to(
  129. h(text),
  130. {:controller => 'repositories', :action => 'revision',
  131. :id => repository.project,
  132. :repository_id => repository.identifier_param, :rev => rev},
  133. :title => l(:label_revision_id, format_revision(revision)),
  134. :accesskey => options[:accesskey]
  135. )
  136. end
  137. # Generates a link to a message
  138. def link_to_message(message, options={}, html_options = nil)
  139. link_to(
  140. message.subject.truncate(60),
  141. board_message_url(message.board_id, message.parent_id || message.id, {
  142. :r => (message.parent_id && message.id),
  143. :anchor => (message.parent_id ? "message-#{message.id}" : nil),
  144. :only_path => true
  145. }.merge(options)),
  146. html_options
  147. )
  148. end
  149. # Generates a link to a project if active
  150. # Examples:
  151. #
  152. # link_to_project(project) # => link to the specified project overview
  153. # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
  154. # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
  155. #
  156. def link_to_project(project, options={}, html_options = nil)
  157. if project.archived?
  158. h(project.name)
  159. else
  160. link_to project.name,
  161. project_url(project, {:only_path => true}.merge(options)),
  162. html_options
  163. end
  164. end
  165. # Generates a link to a project settings if active
  166. def link_to_project_settings(project, options={}, html_options=nil)
  167. if project.active?
  168. link_to project.name, settings_project_path(project, options), html_options
  169. elsif project.archived?
  170. h(project.name)
  171. else
  172. link_to project.name, project_path(project, options), html_options
  173. end
  174. end
  175. # Generates a link to a version
  176. def link_to_version(version, options = {})
  177. return '' unless version && version.is_a?(Version)
  178. options = {:title => format_date(version.effective_date)}.merge(options)
  179. link_to_if version.visible?, format_version_name(version), version_path(version), options
  180. end
  181. RECORD_LINK = {
  182. 'CustomValue' => lambda {|custom_value| link_to_record(custom_value.customized)},
  183. 'Document' => lambda {|document| link_to(document.title, document_path(document))},
  184. 'Group' => lambda {|group| link_to(group.name, group_path(group))},
  185. 'Issue' => lambda {|issue| link_to_issue(issue, :subject => false)},
  186. 'Message' => lambda {|message| link_to_message(message)},
  187. 'News' => lambda {|news| link_to(news.title, news_path(news))},
  188. 'Project' => lambda {|project| link_to_project(project)},
  189. 'User' => lambda {|user| link_to_user(user)},
  190. 'Version' => lambda {|version| link_to_version(version)},
  191. 'WikiPage' =>
  192. lambda do |wiki_page|
  193. link_to(
  194. wiki_page.pretty_title,
  195. project_wiki_page_path(wiki_page.project, wiki_page.title)
  196. )
  197. end
  198. }
  199. def link_to_record(record)
  200. if link = RECORD_LINK[record.class.name]
  201. self.instance_exec(record, &link)
  202. end
  203. end
  204. ATTACHMENT_CONTAINER_LINK = {
  205. # Custom list, since project/version attachments are listed in the files
  206. # view and not in the project/milestone view
  207. 'Project' =>
  208. lambda {|project| link_to(l(:project_module_files), project_files_path(project))},
  209. 'Version' =>
  210. lambda {|version| link_to(l(:project_module_files), project_files_path(version.project))},
  211. }
  212. def link_to_attachment_container(attachment_container)
  213. if link = ATTACHMENT_CONTAINER_LINK[attachment_container.class.name] ||
  214. RECORD_LINK[attachment_container.class.name]
  215. self.instance_exec(attachment_container, &link)
  216. end
  217. end
  218. # Helper that formats object for html or text rendering
  219. def format_object(object, html=true, &block)
  220. if block_given?
  221. object = yield object
  222. end
  223. case object.class.name
  224. when 'Array'
  225. formatted_objects = object.map {|o| format_object(o, html)}
  226. html ? safe_join(formatted_objects, ', ') : formatted_objects.join(', ')
  227. when 'Time'
  228. format_time(object)
  229. when 'Date'
  230. format_date(object)
  231. when 'Fixnum'
  232. object.to_s
  233. when 'Float'
  234. sprintf "%.2f", object
  235. when 'User'
  236. html ? link_to_user(object) : object.to_s
  237. when 'Project'
  238. html ? link_to_project(object) : object.to_s
  239. when 'Version'
  240. html ? link_to_version(object) : object.to_s
  241. when 'TrueClass'
  242. l(:general_text_Yes)
  243. when 'FalseClass'
  244. l(:general_text_No)
  245. when 'Issue'
  246. object.visible? && html ? link_to_issue(object) : "##{object.id}"
  247. when 'Attachment'
  248. if html
  249. content_tag(
  250. :span,
  251. link_to_attachment(object) +
  252. link_to_attachment(
  253. object,
  254. :class => ['icon-only', 'icon-download'],
  255. :title => l(:button_download),
  256. :download => true
  257. )
  258. )
  259. else
  260. object.filename
  261. end
  262. when 'CustomValue', 'CustomFieldValue'
  263. return "" unless object.customized&.visible?
  264. if object.custom_field
  265. f = object.custom_field.format.formatted_custom_value(self, object, html)
  266. if f.nil? || f.is_a?(String)
  267. f
  268. else
  269. format_object(f, html, &block)
  270. end
  271. else
  272. object.value.to_s
  273. end
  274. else
  275. html ? h(object) : object.to_s
  276. end
  277. end
  278. def wiki_page_path(page, options={})
  279. url_for({:controller => 'wiki', :action => 'show', :project_id => page.project,
  280. :id => page.title}.merge(options))
  281. end
  282. def thumbnail_tag(attachment)
  283. thumbnail_size = Setting.thumbnails_size.to_i
  284. link_to(
  285. image_tag(
  286. thumbnail_path(attachment),
  287. :srcset => "#{thumbnail_path(attachment, :size => thumbnail_size * 2)} 2x",
  288. :style => "max-width: #{thumbnail_size}px; max-height: #{thumbnail_size}px;"
  289. ),
  290. attachment_path(
  291. attachment
  292. ),
  293. :title => attachment.filename
  294. )
  295. end
  296. def toggle_link(name, id, options={})
  297. onclick = +"$('##{id}').toggle(); "
  298. onclick << (options[:focus] ? "$('##{options[:focus]}:visible').focus(); " : "this.blur(); ")
  299. onclick << "$(window).scrollTop($('##{options[:focus]}').position().top); " if options[:scroll]
  300. onclick << "return false;"
  301. link_to(name, "#", :onclick => onclick)
  302. end
  303. def link_to_previous_month(year, month, options={})
  304. target_year, target_month = if month == 1
  305. [year - 1, 12]
  306. else
  307. [year, month - 1]
  308. end
  309. name = if target_month == 12
  310. "#{month_name(target_month)} #{target_year}"
  311. else
  312. month_name(target_month)
  313. end
  314. link_to_month(("« " + name), target_year, target_month, options)
  315. end
  316. def link_to_next_month(year, month, options={})
  317. target_year, target_month = if month == 12
  318. [year + 1, 1]
  319. else
  320. [year, month + 1]
  321. end
  322. name = if target_month == 1
  323. "#{month_name(target_month)} #{target_year}"
  324. else
  325. month_name(target_month)
  326. end
  327. link_to_month((name + " »"), target_year, target_month, options)
  328. end
  329. def link_to_month(link_name, year, month, options={})
  330. link_to(link_name, {:params => request.query_parameters.merge(:year => year, :month => month)}, options)
  331. end
  332. # Used to format item titles on the activity view
  333. def format_activity_title(text)
  334. text
  335. end
  336. def format_activity_day(date)
  337. date == User.current.today ? l(:label_today).titleize : format_date(date)
  338. end
  339. def format_activity_description(text)
  340. h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')).
  341. gsub(/[\r\n]+/, "<br />").html_safe
  342. end
  343. def format_version_name(version)
  344. if version.project == @project
  345. h(version)
  346. else
  347. h("#{version.project} - #{version}")
  348. end
  349. end
  350. def format_changeset_comments(changeset, options={})
  351. method = options[:short] ? :short_comments : :comments
  352. textilizable changeset, method, :formatting => Setting.commit_logs_formatting?
  353. end
  354. def due_date_distance_in_words(date)
  355. if date
  356. l((if date < User.current.today
  357. :label_roadmap_overdue
  358. else
  359. :label_roadmap_due_in
  360. end),
  361. distance_of_date_in_words(User.current.today, date))
  362. end
  363. end
  364. # Renders a tree of projects as a nested set of unordered lists
  365. # The given collection may be a subset of the whole project tree
  366. # (eg. some intermediate nodes are private and can not be seen)
  367. def render_project_nested_lists(projects, &block)
  368. s = +''
  369. if projects.any?
  370. ancestors = []
  371. original_project = @project
  372. projects.sort_by(&:lft).each do |project|
  373. # set the project environment to please macros.
  374. @project = project
  375. if ancestors.empty? || project.is_descendant_of?(ancestors.last)
  376. s << "<ul class='projects #{ancestors.empty? ? 'root' : nil}'>\n"
  377. else
  378. ancestors.pop
  379. s << "</li>"
  380. while ancestors.any? && !project.is_descendant_of?(ancestors.last)
  381. ancestors.pop
  382. s << "</ul></li>\n"
  383. end
  384. end
  385. classes = (ancestors.empty? ? 'root' : 'child')
  386. classes += ' archived' if project.archived?
  387. s << "<li class='#{classes}'><div class='#{classes}'>"
  388. s << h(block_given? ? capture(project, &block) : project.name)
  389. s << "</div>\n"
  390. ancestors << project
  391. end
  392. s << ("</li></ul>\n" * ancestors.size)
  393. @project = original_project
  394. end
  395. s.html_safe
  396. end
  397. def render_page_hierarchy(pages, node=nil, options={})
  398. content = +''
  399. if pages[node]
  400. content << "<ul class=\"pages-hierarchy\">\n"
  401. pages[node].each do |page|
  402. content << "<li>"
  403. if controller.controller_name == 'wiki' && controller.action_name == 'export'
  404. href = "##{page.title}"
  405. else
  406. href = {:controller => 'wiki', :action => 'show',
  407. :project_id => page.project, :id => page.title, :version => nil}
  408. end
  409. content <<
  410. link_to(
  411. h(page.pretty_title),
  412. href,
  413. :title => (if options[:timestamp] && page.updated_on
  414. l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on))
  415. else
  416. nil
  417. end)
  418. )
  419. content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
  420. content << "</li>\n"
  421. end
  422. content << "</ul>\n"
  423. end
  424. content.html_safe
  425. end
  426. # Renders flash messages
  427. def render_flash_messages
  428. s = +''
  429. flash.each do |k, v|
  430. s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
  431. end
  432. s.html_safe
  433. end
  434. # Renders tabs and their content
  435. def render_tabs(tabs, selected=params[:tab])
  436. if tabs.any?
  437. unless tabs.detect {|tab| tab[:name] == selected}
  438. selected = nil
  439. end
  440. selected ||= tabs.first[:name]
  441. render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
  442. else
  443. content_tag 'p', l(:label_no_data), :class => "nodata"
  444. end
  445. end
  446. # Returns the tab action depending on the tab properties
  447. def get_tab_action(tab)
  448. if tab[:onclick]
  449. return tab[:onclick]
  450. elsif tab[:partial]
  451. return "showTab('#{tab[:name]}', this.href)"
  452. else
  453. return nil
  454. end
  455. end
  456. # Returns the default scope for the quick search form
  457. # Could be 'all', 'my_projects', 'subprojects' or nil (current project)
  458. def default_search_project_scope
  459. if @project && !@project.leaf?
  460. 'subprojects'
  461. end
  462. end
  463. # Returns an array of projects that are displayed in the quick-jump box
  464. def projects_for_jump_box(user=User.current)
  465. if user.logged?
  466. user.projects.active.select(:id, :name, :identifier, :lft, :rgt).to_a
  467. else
  468. []
  469. end
  470. end
  471. def render_projects_for_jump_box(projects, selected: nil, query: nil)
  472. if query.blank?
  473. jump_box = Redmine::ProjectJumpBox.new User.current
  474. bookmarked = jump_box.bookmarked_projects
  475. recents = jump_box.recently_used_projects
  476. projects_label = :label_project_all
  477. else
  478. projects_label = :label_result_plural
  479. end
  480. jump = params[:jump].presence || current_menu_item
  481. s = (+'').html_safe
  482. build_project_link = lambda do |project, level = 0|
  483. padding = level * 16
  484. text = content_tag('span', project.name, :style => "padding-left:#{padding}px;")
  485. s << link_to(text, project_path(project, :jump => jump),
  486. :title => project.name,
  487. :class => (project == selected ? 'selected' : nil))
  488. end
  489. [
  490. [bookmarked, :label_optgroup_bookmarks, true],
  491. [recents, :label_optgroup_recents, false],
  492. [projects, projects_label, true]
  493. ].each do |projects, label, is_tree|
  494. next if projects.blank?
  495. s << content_tag(:strong, l(label))
  496. if is_tree
  497. project_tree(projects, &build_project_link)
  498. else
  499. # we do not want to render recently used projects as a tree, but in the
  500. # order they were used (most recent first)
  501. projects.each(&build_project_link)
  502. end
  503. end
  504. s
  505. end
  506. # Renders the project quick-jump box
  507. def render_project_jump_box
  508. projects = projects_for_jump_box(User.current)
  509. if @project && @project.persisted?
  510. text = @project.name_was
  511. end
  512. text ||= l(:label_jump_to_a_project)
  513. url = autocomplete_projects_path(:format => 'js', :jump => current_menu_item)
  514. trigger = content_tag('span', text, :class => 'drdn-trigger')
  515. q = text_field_tag('q', '', :id => 'projects-quick-search',
  516. :class => 'autocomplete',
  517. :data => {:automcomplete_url => url},
  518. :autocomplete => 'off')
  519. all = link_to(l(:label_project_all), projects_path(:jump => current_menu_item),
  520. :class => (@project.nil? && controller.class.main_menu ? 'selected' : nil))
  521. content =
  522. content_tag('div',
  523. content_tag('div', q, :class => 'quick-search') +
  524. content_tag('div', render_projects_for_jump_box(projects, selected: @project),
  525. :class => 'drdn-items projects selection') +
  526. content_tag('div', all, :class => 'drdn-items all-projects selection'),
  527. :class => 'drdn-content')
  528. content_tag('div', trigger + content, :id => "project-jump", :class => "drdn")
  529. end
  530. def project_tree_options_for_select(projects, options = {})
  531. s = ''.html_safe
  532. if blank_text = options[:include_blank]
  533. if blank_text == true
  534. blank_text = '&nbsp;'.html_safe
  535. end
  536. s << content_tag('option', blank_text, :value => '')
  537. end
  538. project_tree(projects) do |project, level|
  539. name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
  540. tag_options = {:value => project.id}
  541. if project == options[:selected] || (options[:selected].respond_to?(:include?) &&
  542. options[:selected].include?(project))
  543. tag_options[:selected] = 'selected'
  544. else
  545. tag_options[:selected] = nil
  546. end
  547. tag_options.merge!(yield(project)) if block_given?
  548. s << content_tag('option', name_prefix + h(project), tag_options)
  549. end
  550. s.html_safe
  551. end
  552. # Yields the given block for each project with its level in the tree
  553. #
  554. # Wrapper for Project#project_tree
  555. def project_tree(projects, options={}, &block)
  556. Project.project_tree(projects, options, &block)
  557. end
  558. def principals_check_box_tags(name, principals)
  559. s = +''
  560. principals.each do |principal|
  561. s <<
  562. content_tag(
  563. 'label',
  564. check_box_tag(name, principal.id, false, :id => nil) +
  565. (avatar(principal, :size => 16).presence ||
  566. content_tag(
  567. 'span', nil,
  568. :class => "name icon icon-#{principal.class.name.downcase}"
  569. )
  570. ) + principal
  571. )
  572. end
  573. s.html_safe
  574. end
  575. # Returns a string for users/groups option tags
  576. def principals_options_for_select(collection, selected=nil)
  577. s = +''
  578. if collection.include?(User.current)
  579. s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
  580. end
  581. groups = +''
  582. collection.sort.each do |element|
  583. if option_value_selected?(element, selected) || element.id.to_s == selected
  584. selected_attribute = ' selected="selected"'
  585. end
  586. (element.is_a?(Group) ? groups : s) <<
  587. %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
  588. end
  589. unless groups.empty?
  590. s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
  591. end
  592. s.html_safe
  593. end
  594. def option_tag(name, text, value, selected=nil, options={})
  595. content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
  596. end
  597. def truncate_single_line_raw(string, length)
  598. string.to_s.truncate(length).gsub(%r{[\r\n]+}m, ' ')
  599. end
  600. # Truncates at line break after 250 characters or options[:length]
  601. def truncate_lines(string, options={})
  602. length = options[:length] || 250
  603. if string.to_s =~ /\A(.{#{length}}.*?)$/m
  604. "#{$1}..."
  605. else
  606. string
  607. end
  608. end
  609. def anchor(text)
  610. text.to_s.tr(' ', '_')
  611. end
  612. def html_hours(text)
  613. text.gsub(
  614. %r{(\d+)([\.:])(\d+)},
  615. '<span class="hours hours-int">\1</span><span class="hours hours-dec">\2\3</span>'
  616. ).html_safe
  617. end
  618. def authoring(created, author, options={})
  619. l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
  620. end
  621. def time_tag(time)
  622. text = distance_of_time_in_words(Time.now, time)
  623. if @project
  624. link_to(text,
  625. project_activity_path(@project, :from => User.current.time_to_date(time)),
  626. :title => format_time(time))
  627. else
  628. content_tag('abbr', text, :title => format_time(time))
  629. end
  630. end
  631. def syntax_highlight_lines(name, content)
  632. syntax_highlight(name, content).each_line.to_a
  633. end
  634. def syntax_highlight(name, content)
  635. Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
  636. end
  637. def to_path_param(path)
  638. str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
  639. str.blank? ? nil : str
  640. end
  641. def reorder_handle(object, options={})
  642. data = {
  643. :reorder_url => options[:url] || url_for(object),
  644. :reorder_param => options[:param] || object.class.name.underscore
  645. }
  646. content_tag('span', '',
  647. :class => "icon-only icon-sort-handle sort-handle",
  648. :data => data,
  649. :title => l(:button_sort))
  650. end
  651. def breadcrumb(*args)
  652. elements = args.flatten
  653. elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
  654. end
  655. def other_formats_links(&block)
  656. concat('<p class="other-formats">'.html_safe + l(:label_export_to))
  657. yield Redmine::Views::OtherFormatsBuilder.new(self)
  658. concat('</p>'.html_safe)
  659. end
  660. def page_header_title
  661. if @project.nil? || @project.new_record?
  662. h(Setting.app_title)
  663. else
  664. b = []
  665. ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
  666. if ancestors.any?
  667. root = ancestors.shift
  668. b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
  669. if ancestors.size > 2
  670. b << "\xe2\x80\xa6"
  671. ancestors = ancestors[-2, 2]
  672. end
  673. b +=
  674. ancestors.collect do |p|
  675. link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor')
  676. end
  677. end
  678. b << content_tag(:span, h(@project), class: 'current-project')
  679. if b.size > 1
  680. separator = content_tag(:span, ' &raquo; '.html_safe, class: 'separator')
  681. path = safe_join(b[0..-2], separator) + separator
  682. b = [content_tag(:span, path.html_safe, class: 'breadcrumbs'), b[-1]]
  683. end
  684. safe_join b
  685. end
  686. end
  687. # Returns a h2 tag and sets the html title with the given arguments
  688. def title(*args)
  689. strings = args.map do |arg|
  690. if arg.is_a?(Array) && arg.size >= 2
  691. link_to(*arg)
  692. else
  693. h(arg.to_s)
  694. end
  695. end
  696. html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
  697. content_tag('h2', strings.join(' &#187; ').html_safe)
  698. end
  699. # Sets the html title
  700. # Returns the html title when called without arguments
  701. # Current project name and app_title and automatically appended
  702. # Exemples:
  703. # html_title 'Foo', 'Bar'
  704. # html_title # => 'Foo - Bar - My Project - Redmine'
  705. def html_title(*args)
  706. if args.empty?
  707. title = @html_title || []
  708. title << @project.name if @project
  709. title << Setting.app_title unless Setting.app_title == title.last
  710. title.reject(&:blank?).join(' - ')
  711. else
  712. @html_title ||= []
  713. @html_title += args
  714. end
  715. end
  716. def actions_dropdown(&block)
  717. content = capture(&block)
  718. if content.present?
  719. trigger =
  720. content_tag('span', l(:button_actions), :class => 'icon-only icon-actions',
  721. :title => l(:button_actions))
  722. trigger = content_tag('span', trigger, :class => 'drdn-trigger')
  723. content = content_tag('div', content, :class => 'drdn-items')
  724. content = content_tag('div', content, :class => 'drdn-content')
  725. content_tag('span', trigger + content, :class => 'drdn')
  726. end
  727. end
  728. # Returns the theme, controller name, and action as css classes for the
  729. # HTML body.
  730. def body_css_classes
  731. css = []
  732. if theme = Redmine::Themes.theme(Setting.ui_theme)
  733. css << 'theme-' + theme.name
  734. end
  735. css << 'project-' + @project.identifier if @project && @project.identifier.present?
  736. css << 'has-main-menu' if display_main_menu?(@project)
  737. css << 'controller-' + controller_name
  738. css << 'action-' + action_name
  739. css << 'avatars-' + (Setting.gravatar_enabled? ? 'on' : 'off')
  740. if UserPreference::TEXTAREA_FONT_OPTIONS.include?(User.current.pref.textarea_font)
  741. css << "textarea-#{User.current.pref.textarea_font}"
  742. end
  743. css.join(' ')
  744. end
  745. def accesskey(s)
  746. @used_accesskeys ||= []
  747. key = Redmine::AccessKeys.key_for(s)
  748. return nil if @used_accesskeys.include?(key)
  749. @used_accesskeys << key
  750. key
  751. end
  752. # Formats text according to system settings.
  753. # 2 ways to call this method:
  754. # * with a String: textilizable(text, options)
  755. # * with an object and one of its attribute: textilizable(issue, :description, options)
  756. def textilizable(*args)
  757. options = args.last.is_a?(Hash) ? args.pop : {}
  758. case args.size
  759. when 1
  760. obj = options[:object]
  761. text = args.shift
  762. when 2
  763. obj = args.shift
  764. attr = args.shift
  765. text = obj.send(attr).to_s
  766. else
  767. raise ArgumentError, 'invalid arguments to textilizable'
  768. end
  769. return '' if text.blank?
  770. project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
  771. @only_path = only_path = options.delete(:only_path) == false ? false : true
  772. text = text.dup
  773. macros = catch_macros(text)
  774. if options[:formatting] == false
  775. text = h(text)
  776. else
  777. formatting = Setting.text_formatting
  778. text = Redmine::WikiFormatting.to_html(formatting, text, :object => obj, :attribute => attr)
  779. end
  780. @parsed_headings = []
  781. @heading_anchors = {}
  782. @current_section = 0 if options[:edit_section_links]
  783. parse_sections(text, project, obj, attr, only_path, options)
  784. text = parse_non_pre_blocks(text, obj, macros, options) do |text|
  785. [:parse_inline_attachments, :parse_hires_images, :parse_wiki_links, :parse_redmine_links].each do |method_name|
  786. send method_name, text, project, obj, attr, only_path, options
  787. end
  788. end
  789. parse_headings(text, project, obj, attr, only_path, options)
  790. if @parsed_headings.any?
  791. replace_toc(text, @parsed_headings)
  792. end
  793. text.html_safe
  794. end
  795. def parse_non_pre_blocks(text, obj, macros, options={})
  796. s = StringScanner.new(text)
  797. tags = []
  798. parsed = +''
  799. while !s.eos?
  800. s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
  801. text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
  802. if tags.empty?
  803. yield text
  804. inject_macros(text, obj, macros, true, options) if macros.any?
  805. else
  806. inject_macros(text, obj, macros, false, options) if macros.any?
  807. end
  808. parsed << text
  809. if tag
  810. if closing
  811. if tags.last && tags.last.casecmp(tag) == 0
  812. tags.pop
  813. end
  814. else
  815. tags << tag.downcase
  816. end
  817. parsed << full_tag
  818. end
  819. end
  820. # Close any non closing tags
  821. while tag = tags.pop
  822. parsed << "</#{tag}>"
  823. end
  824. parsed
  825. end
  826. # add srcset attribute to img tags if filename includes @2x, @3x, etc.
  827. # to support hires displays
  828. def parse_hires_images(text, project, obj, attr, only_path, options)
  829. text.gsub!(/src="([^"]+@(\dx)\.(bmp|gif|jpg|jpe|jpeg|png))"/i) do |m|
  830. filename, dpr = $1, $2
  831. m + " srcset=\"#{filename} #{dpr}\""
  832. end
  833. end
  834. def parse_inline_attachments(text, project, obj, attr, only_path, options)
  835. return if options[:inline_attachments] == false
  836. # when using an image link, try to use an attachment, if possible
  837. attachments = options[:attachments] || []
  838. attachments += obj.attachments if obj.respond_to?(:attachments)
  839. if attachments.present?
  840. text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
  841. filename, ext, alt, alttext = $1, $2, $3, $4
  842. # search for the picture in attachments
  843. if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
  844. image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
  845. desc = found.description.to_s.delete('"')
  846. if !desc.blank? && alttext.blank?
  847. alt = " title=\"#{desc}\" alt=\"#{desc}\""
  848. end
  849. "src=\"#{image_url}\"#{alt}"
  850. else
  851. m
  852. end
  853. end
  854. end
  855. end
  856. # Wiki links
  857. #
  858. # Examples:
  859. # [[mypage]]
  860. # [[mypage|mytext]]
  861. # wiki links can refer other project wikis, using project name or identifier:
  862. # [[project:]] -> wiki starting page
  863. # [[project:|mytext]]
  864. # [[project:mypage]]
  865. # [[project:mypage|mytext]]
  866. def parse_wiki_links(text, project, obj, attr, only_path, options)
  867. text.gsub!(/(!)?(\[\[([^\n\|]+?)(\|([^\n\|]+?))?\]\])/) do |m|
  868. link_project = project
  869. esc, all, page, title = $1, $2, $3, $5
  870. if esc.nil?
  871. page = CGI.unescapeHTML(page)
  872. if page =~ /^\#(.+)$/
  873. anchor = sanitize_anchor_name($1)
  874. url = "##{anchor}"
  875. next link_to(title.present? ? title.html_safe : h(page), url, :class => 'wiki-page')
  876. end
  877. if page =~ /^([^\:]+)\:(.*)$/
  878. identifier, page = $1, $2
  879. link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
  880. title ||= identifier if page.blank?
  881. end
  882. if link_project && link_project.wiki && User.current.allowed_to?(:view_wiki_pages, link_project)
  883. # extract anchor
  884. anchor = nil
  885. if page =~ /^(.+?)\#(.+)$/
  886. page, anchor = $1, $2
  887. end
  888. anchor = sanitize_anchor_name(anchor) if anchor.present?
  889. # check if page exists
  890. wiki_page = link_project.wiki.find_page(page)
  891. url =
  892. if anchor.present? && wiki_page.present? &&
  893. (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) &&
  894. obj.page == wiki_page
  895. "##{anchor}"
  896. else
  897. case options[:wiki_links]
  898. when :local
  899. "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
  900. when :anchor
  901. # used for single-file wiki export
  902. "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '')
  903. else
  904. wiki_page_id = page.present? ? Wiki.titleize(page) : nil
  905. parent =
  906. if wiki_page.nil? && obj.is_a?(WikiContent) &&
  907. obj.page && project == link_project
  908. obj.page.title
  909. else
  910. nil
  911. end
  912. url_for(:only_path => only_path, :controller => 'wiki',
  913. :action => 'show', :project_id => link_project,
  914. :id => wiki_page_id, :version => nil, :anchor => anchor,
  915. :parent => parent)
  916. end
  917. end
  918. link_to(title.present? ? title.html_safe : h(page),
  919. url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
  920. else
  921. # project or wiki doesn't exist
  922. all
  923. end
  924. else
  925. all
  926. end
  927. end
  928. end
  929. # Redmine links
  930. #
  931. # Examples:
  932. # Issues:
  933. # #52 -> Link to issue #52
  934. # ##52 -> Link to issue #52, including the issue's subject
  935. # Changesets:
  936. # r52 -> Link to revision 52
  937. # commit:a85130f -> Link to scmid starting with a85130f
  938. # Documents:
  939. # document#17 -> Link to document with id 17
  940. # document:Greetings -> Link to the document with title "Greetings"
  941. # document:"Some document" -> Link to the document with title "Some document"
  942. # Versions:
  943. # version#3 -> Link to version with id 3
  944. # version:1.0.0 -> Link to version named "1.0.0"
  945. # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
  946. # Attachments:
  947. # attachment:file.zip -> Link to the attachment of the current object named file.zip
  948. # Source files:
  949. # source:some/file -> Link to the file located at /some/file in the project's repository
  950. # source:some/file@52 -> Link to the file's revision 52
  951. # source:some/file#L120 -> Link to line 120 of the file
  952. # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
  953. # export:some/file -> Force the download of the file
  954. # Forums:
  955. # forum#1 -> Link to forum with id 1
  956. # forum:Support -> Link to forum named "Support"
  957. # forum:"Technical Support" -> Link to forum named "Technical Support"
  958. # Forum messages:
  959. # message#1218 -> Link to message with id 1218
  960. # Projects:
  961. # project:someproject -> Link to project named "someproject"
  962. # project#3 -> Link to project with id 3
  963. # News:
  964. # news#2 -> Link to news item with id 1
  965. # news:Greetings -> Link to news item named "Greetings"
  966. # news:"First Release" -> Link to news item named "First Release"
  967. # Users:
  968. # user:jsmith -> Link to user with login jsmith
  969. # @jsmith -> Link to user with login jsmith
  970. # user#2 -> Link to user with id 2
  971. #
  972. # Links can refer other objects from other projects, using project identifier:
  973. # identifier:r52
  974. # identifier:document:"Some document"
  975. # identifier:version:1.0.0
  976. # identifier:source:some/file
  977. def parse_redmine_links(text, default_project, obj, attr, only_path, options)
  978. text.gsub!(LINKS_RE) do |_|
  979. tag_content = $~[:tag_content]
  980. leading = $~[:leading]
  981. esc = $~[:esc]
  982. project_prefix = $~[:project_prefix]
  983. project_identifier = $~[:project_identifier]
  984. prefix = $~[:prefix]
  985. repo_prefix = $~[:repo_prefix]
  986. repo_identifier = $~[:repo_identifier]
  987. sep = $~[:sep1] || $~[:sep2] || $~[:sep3] || $~[:sep4]
  988. identifier = $~[:identifier1] || $~[:identifier2] || $~[:identifier3]
  989. comment_suffix = $~[:comment_suffix]
  990. comment_id = $~[:comment_id]
  991. if tag_content
  992. $&
  993. else
  994. link = nil
  995. project = default_project
  996. if project_identifier
  997. project = Project.visible.find_by_identifier(project_identifier)
  998. end
  999. if esc.nil?
  1000. if prefix.nil? && sep == 'r'
  1001. if project
  1002. repository = nil
  1003. if repo_identifier
  1004. repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
  1005. else
  1006. repository = project.repository
  1007. end
  1008. # project.changesets.visible raises an SQL error because of a double join on repositories
  1009. if repository &&
  1010. (changeset = Changeset.visible.
  1011. find_by_repository_id_and_revision(repository.id, identifier))
  1012. link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
  1013. {:only_path => only_path, :controller => 'repositories',
  1014. :action => 'revision', :id => project,
  1015. :repository_id => repository.identifier_param,
  1016. :rev => changeset.revision},
  1017. :class => 'changeset',
  1018. :title => truncate_single_line_raw(changeset.comments, 100))
  1019. end
  1020. end
  1021. elsif sep == '#' || sep == '##'
  1022. oid = identifier.to_i
  1023. case prefix
  1024. when nil
  1025. if oid.to_s == identifier &&
  1026. issue = Issue.visible.find_by_id(oid)
  1027. anchor = comment_id ? "note-#{comment_id}" : nil
  1028. url = issue_url(issue, :only_path => only_path, :anchor => anchor)
  1029. link =
  1030. if sep == '##'
  1031. link_to("#{issue.tracker.name} ##{oid}#{comment_suffix}: #{issue.subject}",
  1032. url,
  1033. :class => issue.css_classes,
  1034. :title => "#{l(:field_status)}: #{issue.status.name}")
  1035. else
  1036. link_to("##{oid}#{comment_suffix}",
  1037. url,
  1038. :class => issue.css_classes,
  1039. :title => "#{issue.tracker.name}: #{issue.subject.truncate(100)} (#{issue.status.name})")
  1040. end
  1041. elsif identifier == 'note'
  1042. link = link_to("#note-#{comment_id}", "#note-#{comment_id}")
  1043. end
  1044. when 'document'
  1045. if document = Document.visible.find_by_id(oid)
  1046. link = link_to(document.title,
  1047. document_url(document, :only_path => only_path),
  1048. :class => 'document')
  1049. end
  1050. when 'version'
  1051. if version = Version.visible.find_by_id(oid)
  1052. link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
  1053. end
  1054. when 'message'
  1055. if message = Message.visible.find_by_id(oid)
  1056. link = link_to_message(message, {:only_path => only_path}, :class => 'message')
  1057. end
  1058. when 'forum'
  1059. if board = Board.visible.find_by_id(oid)
  1060. link = link_to(board.name,
  1061. project_board_url(board.project, board, :only_path => only_path),
  1062. :class => 'board')
  1063. end
  1064. when 'news'
  1065. if news = News.visible.find_by_id(oid)
  1066. link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
  1067. end
  1068. when 'project'
  1069. if p = Project.visible.find_by_id(oid)
  1070. link = link_to_project(p, {:only_path => only_path}, :class => 'project')
  1071. end
  1072. when 'user'
  1073. u = User.visible.find_by(:id => oid, :type => 'User')
  1074. link = link_to_user(u, :only_path => only_path) if u
  1075. end
  1076. elsif sep == ':'
  1077. name = remove_double_quotes(identifier)
  1078. case prefix
  1079. when 'document'
  1080. if project && document = project.documents.visible.find_by_title(name)
  1081. link = link_to(document.title,
  1082. document_url(document, :only_path => only_path),
  1083. :class => 'document')
  1084. end
  1085. when 'version'
  1086. if project && version = project.versions.visible.find_by_name(name)
  1087. link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
  1088. end
  1089. when 'forum'
  1090. if project && board = project.boards.visible.find_by_name(name)
  1091. link = link_to(board.name,
  1092. project_board_url(board.project, board, :only_path => only_path),
  1093. :class => 'board')
  1094. end
  1095. when 'news'
  1096. if project && news = project.news.visible.find_by_title(name)
  1097. link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
  1098. end
  1099. when 'commit', 'source', 'export'
  1100. if project
  1101. repository = nil
  1102. if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
  1103. repo_prefix, repo_identifier, name = $1, $2, $3
  1104. repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
  1105. else
  1106. repository = project.repository
  1107. end
  1108. if prefix == 'commit'
  1109. if repository &&
  1110. (changeset =
  1111. Changeset.visible.
  1112. where(
  1113. "repository_id = ? AND scmid LIKE ?",
  1114. repository.id, "#{name}%"
  1115. ).first)
  1116. link =
  1117. link_to(
  1118. h("#{project_prefix}#{repo_prefix}#{name}"),
  1119. {:only_path => only_path, :controller => 'repositories',
  1120. :action => 'revision', :id => project,
  1121. :repository_id => repository.identifier_param,
  1122. :rev => changeset.identifier},
  1123. :class => 'changeset',
  1124. :title => truncate_single_line_raw(changeset.comments, 100)
  1125. )
  1126. end
  1127. else
  1128. if repository && User.current.allowed_to?(:browse_repository, project)
  1129. name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
  1130. path, rev, anchor = $1, $3, $5
  1131. link =
  1132. link_to(
  1133. h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"),
  1134. {:only_path => only_path, :controller => 'repositories',
  1135. :action => (prefix == 'export' ? 'raw' : 'entry'),
  1136. :id => project, :repository_id => repository.identifier_param,
  1137. :path => to_path_param(path),
  1138. :rev => rev,
  1139. :anchor => anchor},
  1140. :class => (prefix == 'export' ? 'source download' : 'source'))
  1141. end
  1142. end
  1143. repo_prefix = nil
  1144. end
  1145. when 'attachment'
  1146. attachments = options[:attachments] || []
  1147. attachments += obj.attachments if obj.respond_to?(:attachments)
  1148. if attachments && attachment = Attachment.latest_attach(attachments, name)
  1149. link = link_to_attachment(attachment, :only_path => only_path, :class => 'attachment')
  1150. end
  1151. when 'project'
  1152. if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
  1153. link = link_to_project(p, {:only_path => only_path}, :class => 'project')
  1154. end
  1155. when 'user'
  1156. u = User.visible.find_by("LOWER(login) = :s AND type = 'User'", :s => name.downcase)
  1157. link = link_to_user(u, :only_path => only_path) if u
  1158. end
  1159. elsif sep == "@"
  1160. name = remove_double_quotes(identifier)
  1161. u = User.visible.find_by("LOWER(login) = :s AND type = 'User'", :s => name.downcase)
  1162. link = link_to_user(u, :only_path => only_path) if u
  1163. end
  1164. end
  1165. (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
  1166. end
  1167. end
  1168. end
  1169. LINKS_RE =
  1170. %r{
  1171. <a( [^>]+?)?>(?<tag_content>.*?)</a>|
  1172. (?<leading>[\s\(,\-\[\>]|^)
  1173. (?<esc>!)?
  1174. (?<project_prefix>(?<project_identifier>[a-z0-9\-_]+):)?
  1175. (?<prefix>attachment|document|version|forum|news|message|project|commit|source|export|user)?
  1176. (
  1177. (
  1178. (?<sep1>\#\#?)|
  1179. (
  1180. (?<repo_prefix>(?<repo_identifier>[a-z0-9\-_]+)\|)?
  1181. (?<sep2>r)
  1182. )
  1183. )
  1184. (
  1185. (?<identifier1>((\d)+|(note)))
  1186. (?<comment_suffix>
  1187. (\#note)?
  1188. -(?<comment_id>\d+)
  1189. )?
  1190. )|
  1191. (
  1192. (?<sep3>:)
  1193. (?<identifier2>[^"\s<>][^\s<>]*?|"[^"]+?")
  1194. )|
  1195. (
  1196. (?<sep4>@)
  1197. (?<identifier3>[A-Za-z0-9_\-@\.]*?)
  1198. )
  1199. )
  1200. (?=
  1201. (?=[[:punct:]][^A-Za-z0-9_/])|
  1202. ,|
  1203. \s|
  1204. \]|
  1205. <|
  1206. $)
  1207. }x
  1208. HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
  1209. def parse_sections(text, project, obj, attr, only_path, options)
  1210. return unless options[:edit_section_links]
  1211. text.gsub!(HEADING_RE) do
  1212. heading, level = $1, $2
  1213. @current_section += 1
  1214. if @current_section > 1
  1215. content_tag(
  1216. 'div',
  1217. link_to(
  1218. l(:button_edit_section),
  1219. options[:edit_section_links].merge(
  1220. :section => @current_section),
  1221. :class => 'icon-only icon-edit'),
  1222. :class => "contextual heading-#{level}",
  1223. :title => l(:button_edit_section),
  1224. :id => "section-#{@current_section}") + heading.html_safe
  1225. else
  1226. heading
  1227. end
  1228. end
  1229. end
  1230. # Headings and TOC
  1231. # Adds ids and links to headings unless options[:headings] is set to false
  1232. def parse_headings(text, project, obj, attr, only_path, options)
  1233. return if options[:headings] == false
  1234. text.gsub!(HEADING_RE) do
  1235. level, attrs, content = $2.to_i, $3, $4
  1236. item = strip_tags(content).strip
  1237. anchor = sanitize_anchor_name(item)
  1238. # used for single-file wiki export
  1239. if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) ||
  1240. obj.is_a?(WikiContent::Version))
  1241. anchor = "#{obj.page.title}_#{anchor}"
  1242. end
  1243. @heading_anchors[anchor] ||= 0
  1244. idx = (@heading_anchors[anchor] += 1)
  1245. if idx > 1
  1246. anchor = "#{anchor}-#{idx}"
  1247. end
  1248. @parsed_headings << [level, anchor, item]
  1249. "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}" \
  1250. "<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
  1251. end
  1252. end
  1253. unless const_defined?(:MACROS_RE)
  1254. MACROS_RE = /(
  1255. (!)? # escaping
  1256. (
  1257. \{\{ # opening tag
  1258. ([\w]+) # macro name
  1259. (\(([^\n\r]*?)\))? # optional arguments
  1260. ([\n\r].*?[\n\r])? # optional block of text
  1261. \}\} # closing tag
  1262. )
  1263. )/mx
  1264. end
  1265. unless const_defined?(:MACRO_SUB_RE)
  1266. MACRO_SUB_RE = /(
  1267. \{\{
  1268. macro\((\d+)\)
  1269. \}\}
  1270. )/x
  1271. end
  1272. # Extracts macros from text
  1273. def catch_macros(text)
  1274. macros = {}
  1275. text.gsub!(MACROS_RE) do
  1276. all, macro = $1, $4.downcase
  1277. if macro_exists?(macro) || all =~ MACRO_SUB_RE
  1278. index = macros.size
  1279. macros[index] = all
  1280. "{{macro(#{index})}}"
  1281. else
  1282. all
  1283. end
  1284. end
  1285. macros
  1286. end
  1287. # Executes and replaces macros in text
  1288. def inject_macros(text, obj, macros, execute=true, options={})
  1289. text.gsub!(MACRO_SUB_RE) do
  1290. all, index = $1, $2.to_i
  1291. orig = macros.delete(index)
  1292. if execute && orig && orig =~ MACROS_RE
  1293. esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
  1294. if esc.nil?
  1295. h(exec_macro(macro, obj, args, block, options) || all)
  1296. else
  1297. h(all)
  1298. end
  1299. elsif orig
  1300. h(orig)
  1301. else
  1302. h(all)
  1303. end
  1304. end
  1305. end
  1306. TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
  1307. # Renders the TOC with given headings
  1308. def replace_toc(text, headings)
  1309. text.gsub!(TOC_RE) do
  1310. left_align, right_align = $2, $3
  1311. # Keep only the 4 first levels
  1312. headings = headings.select{|level, anchor, item| level <= 4}
  1313. if headings.empty?
  1314. ''
  1315. else
  1316. div_class = +'toc'
  1317. div_class << ' right' if right_align
  1318. div_class << ' left' if left_align
  1319. out = +"<ul class=\"#{div_class}\"><li><strong>#{l :label_table_of_contents}</strong></li><li>"
  1320. root = headings.map(&:first).min
  1321. current = root
  1322. started = false
  1323. headings.each do |level, anchor, item|
  1324. if level > current
  1325. out << '<ul><li>' * (level - current)
  1326. elsif level < current
  1327. out << "</li></ul>\n" * (current - level) + "</li><li>"
  1328. elsif started
  1329. out << '</li><li>'
  1330. end
  1331. out << "<a href=\"##{anchor}\">#{item}</a>"
  1332. current = level
  1333. started = true
  1334. end
  1335. out << '</li></ul>' * (current - root)
  1336. out << '</li></ul>'
  1337. end
  1338. end
  1339. end
  1340. # Same as Rails' simple_format helper without using paragraphs
  1341. def simple_format_without_paragraph(text)
  1342. text.to_s.
  1343. gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
  1344. gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
  1345. gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
  1346. html_safe
  1347. end
  1348. def lang_options_for_select(blank=true)
  1349. (blank ? [["(auto)", ""]] : []) + languages_options
  1350. end
  1351. def labelled_form_for(*args, &proc)
  1352. args << {} unless args.last.is_a?(Hash)
  1353. options = args.last
  1354. if args.first.is_a?(Symbol)
  1355. options[:as] = args.shift
  1356. end
  1357. options[:builder] = Redmine::Views::LabelledFormBuilder
  1358. form_for(*args, &proc)
  1359. end
  1360. def labelled_fields_for(*args, &proc)
  1361. args << {} unless args.last.is_a?(Hash)
  1362. options = args.last
  1363. options[:builder] = Redmine::Views::LabelledFormBuilder
  1364. fields_for(*args, &proc)
  1365. end
  1366. def form_tag_html(html_options)
  1367. # Set a randomized name attribute on all form fields by default
  1368. # as a workaround to https://bugzilla.mozilla.org/show_bug.cgi?id=1279253
  1369. html_options['name'] ||= "#{html_options['id'] || 'form'}-#{SecureRandom.hex(4)}"
  1370. super
  1371. end
  1372. # Render the error messages for the given objects
  1373. def error_messages_for(*objects)
  1374. objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
  1375. errors = objects.map {|o| o.errors.full_messages}.flatten
  1376. render_error_messages(errors)
  1377. end
  1378. # Renders a list of error messages
  1379. def render_error_messages(errors)
  1380. html = +""
  1381. if errors.present?
  1382. html << "<div id='errorExplanation'><ul>\n"
  1383. errors.each do |error|
  1384. html << "<li>#{h error}</li>\n"
  1385. end
  1386. html << "</ul></div>\n"
  1387. end
  1388. html.html_safe
  1389. end
  1390. def delete_link(url, options={})
  1391. options = {
  1392. :method => :delete,
  1393. :data => {:confirm => l(:text_are_you_sure)},
  1394. :class => 'icon icon-del'
  1395. }.merge(options)
  1396. link_to l(:button_delete), url, options
  1397. end
  1398. def link_to_function(name, function, html_options={})
  1399. content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
  1400. end
  1401. def link_to_context_menu
  1402. link_to l(:button_actions), '#', title: l(:button_actions), class: 'icon-only icon-actions js-contextmenu'
  1403. end
  1404. # Helper to render JSON in views
  1405. def raw_json(arg)
  1406. arg.to_json.to_s.gsub('/', '\/').html_safe
  1407. end
  1408. def back_url_hidden_field_tag
  1409. url = validate_back_url(back_url)
  1410. hidden_field_tag('back_url', url, :id => nil) unless url.blank?
  1411. end
  1412. def cancel_button_tag(fallback_url)
  1413. url = validate_back_url(back_url) || fallback_url
  1414. link_to l(:button_cancel), url
  1415. end
  1416. def check_all_links(form_name)
  1417. link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
  1418. " | ".html_safe +
  1419. link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
  1420. end
  1421. def toggle_checkboxes_link(selector)
  1422. link_to_function '',
  1423. "toggleCheckboxesBySelector('#{selector}')",
  1424. :title => "#{l(:button_check_all)} / #{l(:button_uncheck_all)}",
  1425. :class => 'icon icon-checked'
  1426. end
  1427. def progress_bar(pcts, options={})
  1428. pcts = [pcts, pcts] unless pcts.is_a?(Array)
  1429. pcts = pcts.collect(&:floor)
  1430. pcts[1] = pcts[1] - pcts[0]
  1431. pcts << (100 - pcts[1] - pcts[0])
  1432. titles = options[:titles].to_a
  1433. titles[0] = "#{pcts[0]}%" if titles[0].blank?
  1434. legend = options[:legend] || ''
  1435. content_tag(
  1436. 'table',
  1437. content_tag(
  1438. 'tr',
  1439. (if pcts[0] > 0
  1440. content_tag('td', '', :style => "width: #{pcts[0]}%;",
  1441. :class => 'closed', :title => titles[0])
  1442. else
  1443. ''.html_safe
  1444. end) +
  1445. (if pcts[1] > 0
  1446. content_tag('td', '', :style => "width: #{pcts[1]}%;",
  1447. :class => 'done', :title => titles[1])
  1448. else
  1449. ''.html_safe
  1450. end) +
  1451. (if pcts[2] > 0
  1452. content_tag('td', '', :style => "width: #{pcts[2]}%;",
  1453. :class => 'todo', :title => titles[2])
  1454. else
  1455. ''.html_safe
  1456. end)
  1457. ), :class => "progress progress-#{pcts[0]}").html_safe +
  1458. content_tag('p', legend, :class => 'percent').html_safe
  1459. end
  1460. def checked_image(checked=true)
  1461. if checked
  1462. @checked_image_tag ||= content_tag(:span, nil, :class => 'icon-only icon-checked')
  1463. end
  1464. end
  1465. def context_menu
  1466. unless @context_menu_included
  1467. content_for :header_tags do
  1468. javascript_include_tag('context_menu') +
  1469. stylesheet_link_tag('context_menu')
  1470. end
  1471. if l(:direction) == 'rtl'
  1472. content_for :header_tags do
  1473. stylesheet_link_tag('context_menu_rtl')
  1474. end
  1475. end
  1476. @context_menu_included = true
  1477. end
  1478. nil
  1479. end
  1480. def calendar_for(field_id)
  1481. include_calendar_headers_tags
  1482. javascript_tag(
  1483. "$(function() { $('##{field_id}').addClass('date').datepickerFallback(datepickerOptions); });"
  1484. )
  1485. end
  1486. def include_calendar_headers_tags
  1487. unless @calendar_headers_tags_included
  1488. tags = ''.html_safe
  1489. @calendar_headers_tags_included = true
  1490. content_for :header_tags do
  1491. start_of_week = Setting.start_of_week
  1492. start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
  1493. # Redmine uses 1..7 (monday..sunday) in settings and locales
  1494. # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
  1495. start_of_week = start_of_week.to_i % 7
  1496. tags <<
  1497. javascript_tag(
  1498. "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " \
  1499. "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
  1500. path_to_image('/images/calendar.png') +
  1501. "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " \
  1502. "selectOtherMonths: true, changeMonth: true, changeYear: true, " \
  1503. "beforeShow: beforeShowDatePicker};"
  1504. )
  1505. jquery_locale = l('jquery.locale', :default => current_language.to_s)
  1506. unless jquery_locale == 'en'
  1507. tags << javascript_include_tag("i18n/datepicker-#{jquery_locale}.js")
  1508. end
  1509. tags
  1510. end
  1511. end
  1512. end
  1513. # Overrides Rails' stylesheet_link_tag with themes and plugins support.
  1514. # Examples:
  1515. # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
  1516. # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
  1517. #
  1518. def stylesheet_link_tag(*sources)
  1519. options = sources.last.is_a?(Hash) ? sources.pop : {}
  1520. plugin = options.delete(:plugin)
  1521. sources = sources.map do |source|
  1522. if plugin
  1523. "/plugin_assets/#{plugin}/stylesheets/#{source}"
  1524. elsif current_theme && current_theme.stylesheets.include?(source)
  1525. current_theme.stylesheet_path(source)
  1526. else
  1527. source
  1528. end
  1529. end
  1530. super *sources, options
  1531. end
  1532. # Overrides Rails' image_tag with themes and plugins support.
  1533. # Examples:
  1534. # image_tag('image.png') # => picks image.png from the current theme or defaults
  1535. # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
  1536. #
  1537. def image_tag(source, options={})
  1538. if plugin = options.delete(:plugin)
  1539. source = "/plugin_assets/#{plugin}/images/#{source}"
  1540. elsif current_theme && current_theme.images.include?(source)
  1541. source = current_theme.image_path(source)
  1542. end
  1543. super source, options
  1544. end
  1545. # Overrides Rails' javascript_include_tag with plugins support
  1546. # Examples:
  1547. # javascript_include_tag('scripts') # => picks scripts.js from defaults
  1548. # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
  1549. #
  1550. def javascript_include_tag(*sources)
  1551. options = sources.last.is_a?(Hash) ? sources.pop : {}
  1552. if plugin = options.delete(:plugin)
  1553. sources = sources.map do |source|
  1554. if plugin
  1555. "/plugin_assets/#{plugin}/javascripts/#{source}"
  1556. else
  1557. source
  1558. end
  1559. end
  1560. end
  1561. super *sources, options
  1562. end
  1563. def sidebar_content?
  1564. content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
  1565. end
  1566. def view_layouts_base_sidebar_hook_response
  1567. @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
  1568. end
  1569. def email_delivery_enabled?
  1570. !!ActionMailer::Base.perform_deliveries
  1571. end
  1572. def sanitize_anchor_name(anchor)
  1573. anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
  1574. end
  1575. # Returns the javascript tags that are included in the html layout head
  1576. def javascript_heads
  1577. tags = javascript_include_tag(
  1578. 'jquery-3.6.1-ui-1.13.2-ujs-5.2.8.1',
  1579. 'jquery-migrate-3.3.2.min.js',
  1580. 'tribute-5.1.3.min',
  1581. 'tablesort-5.2.1.min.js',
  1582. 'tablesort-5.2.1.number.min.js',
  1583. 'application',
  1584. 'responsive')
  1585. unless User.current.pref.warn_on_leaving_unsaved == '0'
  1586. warn_text = escape_javascript(l(:text_warn_on_leaving_unsaved))
  1587. tags <<
  1588. "\n".html_safe +
  1589. javascript_tag(
  1590. "$(window).on('load', function(){ warnLeavingUnsaved('#{warn_text}'); });"
  1591. )
  1592. end
  1593. tags
  1594. end
  1595. def favicon
  1596. "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
  1597. end
  1598. # Returns the path to the favicon
  1599. def favicon_path
  1600. icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
  1601. image_path(icon)
  1602. end
  1603. # Returns the full URL to the favicon
  1604. def favicon_url
  1605. # TODO: use #image_url introduced in Rails4
  1606. path = favicon_path
  1607. base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
  1608. base.sub(%r{/+$}, '') + '/' + path.sub(%r{^/+}, '')
  1609. end
  1610. def robot_exclusion_tag
  1611. '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
  1612. end
  1613. # Returns true if arg is expected in the API response
  1614. def include_in_api_response?(arg)
  1615. unless @included_in_api_response
  1616. param = params[:include]
  1617. @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
  1618. @included_in_api_response.collect!(&:strip)
  1619. end
  1620. @included_in_api_response.include?(arg.to_s)
  1621. end
  1622. # Returns options or nil if nometa param or X-Redmine-Nometa header
  1623. # was set in the request
  1624. def api_meta(options)
  1625. if params[:nometa].present? || request.headers['X-Redmine-Nometa']
  1626. # compatibility mode for activeresource clients that raise
  1627. # an error when deserializing an array with attributes
  1628. nil
  1629. else
  1630. options
  1631. end
  1632. end
  1633. def export_csv_encoding_select_tag
  1634. return if l(:general_csv_encoding).casecmp('UTF-8') == 0
  1635. options = [l(:general_csv_encoding), 'UTF-8']
  1636. content_tag(:p) do
  1637. concat(
  1638. content_tag(:label) do
  1639. concat l(:label_encoding) + ' '
  1640. concat select_tag('encoding', options_for_select(options, l(:general_csv_encoding)))
  1641. end
  1642. )
  1643. end
  1644. end
  1645. # Returns an array of error messages for bulk edited items (issues, time entries)
  1646. def bulk_edit_error_messages(items)
  1647. messages = {}
  1648. items.each do |item|
  1649. item.errors.full_messages.each do |message|
  1650. messages[message] ||= []
  1651. messages[message] << item
  1652. end
  1653. end
  1654. messages.map do |message, items|
  1655. "#{message}: " + items.map {|i| "##{i.id}"}.join(', ')
  1656. end
  1657. end
  1658. def render_if_exist(options = {}, locals = {}, &block)
  1659. if options[:partial]
  1660. if lookup_context.exists?(options[:partial], lookup_context.prefixes, true)
  1661. render(options, locals, &block)
  1662. end
  1663. else
  1664. render(options, locals, &block)
  1665. end
  1666. end
  1667. def autocomplete_data_sources(project)
  1668. {
  1669. issues: auto_complete_issues_path(:project_id => project, :q => ''),
  1670. wiki_pages: auto_complete_wiki_pages_path(:project_id => project, :q => '')
  1671. }
  1672. end
  1673. def heads_for_auto_complete(project)
  1674. data_sources = autocomplete_data_sources(project)
  1675. javascript_tag(
  1676. "rm = window.rm || {};" \
  1677. "rm.AutoComplete = rm.AutoComplete || {};" \
  1678. "rm.AutoComplete.dataSources = '#{data_sources.to_json}';"
  1679. )
  1680. end
  1681. def copy_object_url_link(url)
  1682. link_to_function(
  1683. l(:button_copy_link), 'copyTextToClipboard(this);',
  1684. class: 'icon icon-copy-link',
  1685. data: {'clipboard-text' => url}
  1686. )
  1687. end
  1688. private
  1689. def wiki_helper
  1690. helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
  1691. extend helper
  1692. return self
  1693. end
  1694. # remove double quotes if any
  1695. def remove_double_quotes(identifier)
  1696. name = identifier.gsub(%r{^"(.*)"$}, "\\1")
  1697. return CGI.unescapeHTML(name)
  1698. end
  1699. end