##// END OF EJS Templates
move logic to use latest image file attachment to class method for common use (#3261)...
Toshi MARUYAMA -
r7788:564321b2d5b8
parent child
Show More
@@ -1,1055 +1,1055
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2011 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27
28 28 extend Forwardable
29 29 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
30 30
31 31 # Return true if user is authorized for controller/action, otherwise false
32 32 def authorize_for(controller, action)
33 33 User.current.allowed_to?({:controller => controller, :action => action}, @project)
34 34 end
35 35
36 36 # Display a link if user is authorized
37 37 #
38 38 # @param [String] name Anchor text (passed to link_to)
39 39 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
40 40 # @param [optional, Hash] html_options Options passed to link_to
41 41 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
42 42 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
43 43 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
44 44 end
45 45
46 46 # Display a link to remote if user is authorized
47 47 def link_to_remote_if_authorized(name, options = {}, html_options = nil)
48 48 url = options[:url] || {}
49 49 link_to_remote(name, options, html_options) if authorize_for(url[:controller] || params[:controller], url[:action])
50 50 end
51 51
52 52 # Displays a link to user's account page if active
53 53 def link_to_user(user, options={})
54 54 if user.is_a?(User)
55 55 name = h(user.name(options[:format]))
56 56 if user.active?
57 57 link_to name, :controller => 'users', :action => 'show', :id => user
58 58 else
59 59 name
60 60 end
61 61 else
62 62 h(user.to_s)
63 63 end
64 64 end
65 65
66 66 # Displays a link to +issue+ with its subject.
67 67 # Examples:
68 68 #
69 69 # link_to_issue(issue) # => Defect #6: This is the subject
70 70 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
71 71 # link_to_issue(issue, :subject => false) # => Defect #6
72 72 # link_to_issue(issue, :project => true) # => Foo - Defect #6
73 73 #
74 74 def link_to_issue(issue, options={})
75 75 title = nil
76 76 subject = nil
77 77 if options[:subject] == false
78 78 title = truncate(issue.subject, :length => 60)
79 79 else
80 80 subject = issue.subject
81 81 if options[:truncate]
82 82 subject = truncate(subject, :length => options[:truncate])
83 83 end
84 84 end
85 85 s = link_to "#{h(issue.tracker)} ##{issue.id}", {:controller => "issues", :action => "show", :id => issue},
86 86 :class => issue.css_classes,
87 87 :title => title
88 88 s << ": #{h subject}" if subject
89 89 s = "#{h issue.project} - " + s if options[:project]
90 90 s
91 91 end
92 92
93 93 # Generates a link to an attachment.
94 94 # Options:
95 95 # * :text - Link text (default to attachment filename)
96 96 # * :download - Force download (default: false)
97 97 def link_to_attachment(attachment, options={})
98 98 text = options.delete(:text) || attachment.filename
99 99 action = options.delete(:download) ? 'download' : 'show'
100 100 link_to(h(text),
101 101 {:controller => 'attachments', :action => action,
102 102 :id => attachment, :filename => attachment.filename },
103 103 options)
104 104 end
105 105
106 106 # Generates a link to a SCM revision
107 107 # Options:
108 108 # * :text - Link text (default to the formatted revision)
109 109 def link_to_revision(revision, project, options={})
110 110 text = options.delete(:text) || format_revision(revision)
111 111 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
112 112
113 113 link_to(h(text), {:controller => 'repositories', :action => 'revision', :id => project, :rev => rev},
114 114 :title => l(:label_revision_id, format_revision(revision)))
115 115 end
116 116
117 117 # Generates a link to a message
118 118 def link_to_message(message, options={}, html_options = nil)
119 119 link_to(
120 120 h(truncate(message.subject, :length => 60)),
121 121 { :controller => 'messages', :action => 'show',
122 122 :board_id => message.board_id,
123 123 :id => message.root,
124 124 :r => (message.parent_id && message.id),
125 125 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
126 126 }.merge(options),
127 127 html_options
128 128 )
129 129 end
130 130
131 131 # Generates a link to a project if active
132 132 # Examples:
133 133 #
134 134 # link_to_project(project) # => link to the specified project overview
135 135 # link_to_project(project, :action=>'settings') # => link to project settings
136 136 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
137 137 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
138 138 #
139 139 def link_to_project(project, options={}, html_options = nil)
140 140 if project.active?
141 141 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
142 142 link_to(h(project), url, html_options)
143 143 else
144 144 h(project)
145 145 end
146 146 end
147 147
148 148 def toggle_link(name, id, options={})
149 149 onclick = "Element.toggle('#{id}'); "
150 150 onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
151 151 onclick << "return false;"
152 152 link_to(name, "#", :onclick => onclick)
153 153 end
154 154
155 155 def image_to_function(name, function, html_options = {})
156 156 html_options.symbolize_keys!
157 157 tag(:input, html_options.merge({
158 158 :type => "image", :src => image_path(name),
159 159 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
160 160 }))
161 161 end
162 162
163 163 def prompt_to_remote(name, text, param, url, html_options = {})
164 164 html_options[:onclick] = "promptToRemote('#{text}', '#{param}', '#{url_for(url)}'); return false;"
165 165 link_to name, {}, html_options
166 166 end
167 167
168 168 def format_activity_title(text)
169 169 h(truncate_single_line(text, :length => 100))
170 170 end
171 171
172 172 def format_activity_day(date)
173 173 date == Date.today ? l(:label_today).titleize : format_date(date)
174 174 end
175 175
176 176 def format_activity_description(text)
177 177 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')).gsub(/[\r\n]+/, "<br />")
178 178 end
179 179
180 180 def format_version_name(version)
181 181 if version.project == @project
182 182 h(version)
183 183 else
184 184 h("#{version.project} - #{version}")
185 185 end
186 186 end
187 187
188 188 def due_date_distance_in_words(date)
189 189 if date
190 190 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
191 191 end
192 192 end
193 193
194 194 def render_page_hierarchy(pages, node=nil, options={})
195 195 content = ''
196 196 if pages[node]
197 197 content << "<ul class=\"pages-hierarchy\">\n"
198 198 pages[node].each do |page|
199 199 content << "<li>"
200 200 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title},
201 201 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
202 202 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
203 203 content << "</li>\n"
204 204 end
205 205 content << "</ul>\n"
206 206 end
207 207 content.html_safe
208 208 end
209 209
210 210 # Renders flash messages
211 211 def render_flash_messages
212 212 s = ''
213 213 flash.each do |k,v|
214 214 s << content_tag('div', v, :class => "flash #{k}")
215 215 end
216 216 s.html_safe
217 217 end
218 218
219 219 # Renders tabs and their content
220 220 def render_tabs(tabs)
221 221 if tabs.any?
222 222 render :partial => 'common/tabs', :locals => {:tabs => tabs}
223 223 else
224 224 content_tag 'p', l(:label_no_data), :class => "nodata"
225 225 end
226 226 end
227 227
228 228 # Renders the project quick-jump box
229 229 def render_project_jump_box
230 230 return unless User.current.logged?
231 231 projects = User.current.memberships.collect(&:project).compact.uniq
232 232 if projects.any?
233 233 s = '<select onchange="if (this.value != \'\') { window.location = this.value; }">' +
234 234 "<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
235 235 '<option value="" disabled="disabled">---</option>'
236 236 s << project_tree_options_for_select(projects, :selected => @project) do |p|
237 237 { :value => url_for(:controller => 'projects', :action => 'show', :id => p, :jump => current_menu_item) }
238 238 end
239 239 s << '</select>'
240 240 s.html_safe
241 241 end
242 242 end
243 243
244 244 def project_tree_options_for_select(projects, options = {})
245 245 s = ''
246 246 project_tree(projects) do |project, level|
247 247 name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ') : '')
248 248 tag_options = {:value => project.id}
249 249 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
250 250 tag_options[:selected] = 'selected'
251 251 else
252 252 tag_options[:selected] = nil
253 253 end
254 254 tag_options.merge!(yield(project)) if block_given?
255 255 s << content_tag('option', name_prefix + h(project), tag_options)
256 256 end
257 257 s.html_safe
258 258 end
259 259
260 260 # Yields the given block for each project with its level in the tree
261 261 #
262 262 # Wrapper for Project#project_tree
263 263 def project_tree(projects, &block)
264 264 Project.project_tree(projects, &block)
265 265 end
266 266
267 267 def project_nested_ul(projects, &block)
268 268 s = ''
269 269 if projects.any?
270 270 ancestors = []
271 271 projects.sort_by(&:lft).each do |project|
272 272 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
273 273 s << "<ul>\n"
274 274 else
275 275 ancestors.pop
276 276 s << "</li>"
277 277 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
278 278 ancestors.pop
279 279 s << "</ul></li>\n"
280 280 end
281 281 end
282 282 s << "<li>"
283 283 s << yield(project).to_s
284 284 ancestors << project
285 285 end
286 286 s << ("</li></ul>\n" * ancestors.size)
287 287 end
288 288 s.html_safe
289 289 end
290 290
291 291 def principals_check_box_tags(name, principals)
292 292 s = ''
293 293 principals.sort.each do |principal|
294 294 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
295 295 end
296 296 s.html_safe
297 297 end
298 298
299 299 # Returns a string for users/groups option tags
300 300 def principals_options_for_select(collection, selected=nil)
301 301 s = ''
302 302 groups = ''
303 303 collection.sort.each do |element|
304 304 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected)
305 305 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
306 306 end
307 307 unless groups.empty?
308 308 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
309 309 end
310 310 s
311 311 end
312 312
313 313 # Truncates and returns the string as a single line
314 314 def truncate_single_line(string, *args)
315 315 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
316 316 end
317 317
318 318 # Truncates at line break after 250 characters or options[:length]
319 319 def truncate_lines(string, options={})
320 320 length = options[:length] || 250
321 321 if string.to_s =~ /\A(.{#{length}}.*?)$/m
322 322 "#{$1}..."
323 323 else
324 324 string
325 325 end
326 326 end
327 327
328 328 def html_hours(text)
329 329 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
330 330 end
331 331
332 332 def authoring(created, author, options={})
333 333 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
334 334 end
335 335
336 336 def time_tag(time)
337 337 text = distance_of_time_in_words(Time.now, time)
338 338 if @project
339 339 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => time.to_date}, :title => format_time(time))
340 340 else
341 341 content_tag('acronym', text, :title => format_time(time))
342 342 end
343 343 end
344 344
345 345 def syntax_highlight(name, content)
346 346 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
347 347 end
348 348
349 349 def to_path_param(path)
350 350 path.to_s.split(%r{[/\\]}).select {|p| !p.blank?}
351 351 end
352 352
353 353 def pagination_links_full(paginator, count=nil, options={})
354 354 page_param = options.delete(:page_param) || :page
355 355 per_page_links = options.delete(:per_page_links)
356 356 url_param = params.dup
357 357
358 358 html = ''
359 359 if paginator.current.previous
360 360 # \xc2\xab(utf-8) = &#171;
361 361 html << link_to_content_update(
362 362 "\xc2\xab " + l(:label_previous),
363 363 url_param.merge(page_param => paginator.current.previous)) + ' '
364 364 end
365 365
366 366 html << (pagination_links_each(paginator, options) do |n|
367 367 link_to_content_update(n.to_s, url_param.merge(page_param => n))
368 368 end || '')
369 369
370 370 if paginator.current.next
371 371 # \xc2\xbb(utf-8) = &#187;
372 372 html << ' ' + link_to_content_update(
373 373 (l(:label_next) + " \xc2\xbb"),
374 374 url_param.merge(page_param => paginator.current.next))
375 375 end
376 376
377 377 unless count.nil?
378 378 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
379 379 if per_page_links != false && links = per_page_links(paginator.items_per_page)
380 380 html << " | #{links}"
381 381 end
382 382 end
383 383
384 384 html.html_safe
385 385 end
386 386
387 387 def per_page_links(selected=nil)
388 388 links = Setting.per_page_options_array.collect do |n|
389 389 n == selected ? n : link_to_content_update(n, params.merge(:per_page => n))
390 390 end
391 391 links.size > 1 ? l(:label_display_per_page, links.join(', ')) : nil
392 392 end
393 393
394 394 def reorder_links(name, url, method = :post)
395 395 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
396 396 url.merge({"#{name}[move_to]" => 'highest'}),
397 397 :method => method, :title => l(:label_sort_highest)) +
398 398 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
399 399 url.merge({"#{name}[move_to]" => 'higher'}),
400 400 :method => method, :title => l(:label_sort_higher)) +
401 401 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
402 402 url.merge({"#{name}[move_to]" => 'lower'}),
403 403 :method => method, :title => l(:label_sort_lower)) +
404 404 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
405 405 url.merge({"#{name}[move_to]" => 'lowest'}),
406 406 :method => method, :title => l(:label_sort_lowest))
407 407 end
408 408
409 409 def breadcrumb(*args)
410 410 elements = args.flatten
411 411 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
412 412 end
413 413
414 414 def other_formats_links(&block)
415 415 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
416 416 yield Redmine::Views::OtherFormatsBuilder.new(self)
417 417 concat('</p>'.html_safe)
418 418 end
419 419
420 420 def page_header_title
421 421 if @project.nil? || @project.new_record?
422 422 h(Setting.app_title)
423 423 else
424 424 b = []
425 425 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
426 426 if ancestors.any?
427 427 root = ancestors.shift
428 428 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
429 429 if ancestors.size > 2
430 430 b << "\xe2\x80\xa6"
431 431 ancestors = ancestors[-2, 2]
432 432 end
433 433 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
434 434 end
435 435 b << h(@project)
436 436 b.join(" \xc2\xbb ").html_safe
437 437 end
438 438 end
439 439
440 440 def html_title(*args)
441 441 if args.empty?
442 442 title = @html_title || []
443 443 title << @project.name if @project
444 444 title << Setting.app_title unless Setting.app_title == title.last
445 445 title.select {|t| !t.blank? }.join(' - ')
446 446 else
447 447 @html_title ||= []
448 448 @html_title += args
449 449 end
450 450 end
451 451
452 452 # Returns the theme, controller name, and action as css classes for the
453 453 # HTML body.
454 454 def body_css_classes
455 455 css = []
456 456 if theme = Redmine::Themes.theme(Setting.ui_theme)
457 457 css << 'theme-' + theme.name
458 458 end
459 459
460 460 css << 'controller-' + params[:controller]
461 461 css << 'action-' + params[:action]
462 462 css.join(' ')
463 463 end
464 464
465 465 def accesskey(s)
466 466 Redmine::AccessKeys.key_for s
467 467 end
468 468
469 469 # Formats text according to system settings.
470 470 # 2 ways to call this method:
471 471 # * with a String: textilizable(text, options)
472 472 # * with an object and one of its attribute: textilizable(issue, :description, options)
473 473 def textilizable(*args)
474 474 options = args.last.is_a?(Hash) ? args.pop : {}
475 475 case args.size
476 476 when 1
477 477 obj = options[:object]
478 478 text = args.shift
479 479 when 2
480 480 obj = args.shift
481 481 attr = args.shift
482 482 text = obj.send(attr).to_s
483 483 else
484 484 raise ArgumentError, 'invalid arguments to textilizable'
485 485 end
486 486 return '' if text.blank?
487 487 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
488 488 only_path = options.delete(:only_path) == false ? false : true
489 489
490 490 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
491 491
492 492 @parsed_headings = []
493 493 @current_section = 0 if options[:edit_section_links]
494 494 text = parse_non_pre_blocks(text) do |text|
495 495 [:parse_sections, :parse_inline_attachments, :parse_wiki_links, :parse_redmine_links, :parse_macros, :parse_headings].each do |method_name|
496 496 send method_name, text, project, obj, attr, only_path, options
497 497 end
498 498 end
499 499
500 500 if @parsed_headings.any?
501 501 replace_toc(text, @parsed_headings)
502 502 end
503 503
504 504 text
505 505 end
506 506
507 507 def parse_non_pre_blocks(text)
508 508 s = StringScanner.new(text)
509 509 tags = []
510 510 parsed = ''
511 511 while !s.eos?
512 512 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
513 513 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
514 514 if tags.empty?
515 515 yield text
516 516 end
517 517 parsed << text
518 518 if tag
519 519 if closing
520 520 if tags.last == tag.downcase
521 521 tags.pop
522 522 end
523 523 else
524 524 tags << tag.downcase
525 525 end
526 526 parsed << full_tag
527 527 end
528 528 end
529 529 # Close any non closing tags
530 530 while tag = tags.pop
531 531 parsed << "</#{tag}>"
532 532 end
533 533 parsed.html_safe
534 534 end
535 535
536 536 def parse_inline_attachments(text, project, obj, attr, only_path, options)
537 537 # when using an image link, try to use an attachment, if possible
538 538 if options[:attachments] || (obj && obj.respond_to?(:attachments))
539 attachments = nil
539 attachments = options[:attachments] || obj.attachments
540 540 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
541 541 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
542 attachments ||= (options[:attachments] || obj.attachments).sort_by(&:created_on).reverse
543 542 # search for the picture in attachments
544 if found = attachments.detect { |att| att.filename.downcase == filename }
545 image_url = url_for :only_path => only_path, :controller => 'attachments', :action => 'download', :id => found
543 if found = Attachment.latest_attach(attachments, filename)
544 image_url = url_for :only_path => only_path, :controller => 'attachments',
545 :action => 'download', :id => found
546 546 desc = found.description.to_s.gsub('"', '')
547 547 if !desc.blank? && alttext.blank?
548 548 alt = " title=\"#{desc}\" alt=\"#{desc}\""
549 549 end
550 550 "src=\"#{image_url}\"#{alt}".html_safe
551 551 else
552 552 m.html_safe
553 553 end
554 554 end
555 555 end
556 556 end
557 557
558 558 # Wiki links
559 559 #
560 560 # Examples:
561 561 # [[mypage]]
562 562 # [[mypage|mytext]]
563 563 # wiki links can refer other project wikis, using project name or identifier:
564 564 # [[project:]] -> wiki starting page
565 565 # [[project:|mytext]]
566 566 # [[project:mypage]]
567 567 # [[project:mypage|mytext]]
568 568 def parse_wiki_links(text, project, obj, attr, only_path, options)
569 569 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
570 570 link_project = project
571 571 esc, all, page, title = $1, $2, $3, $5
572 572 if esc.nil?
573 573 if page =~ /^([^\:]+)\:(.*)$/
574 574 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
575 575 page = $2
576 576 title ||= $1 if page.blank?
577 577 end
578 578
579 579 if link_project && link_project.wiki
580 580 # extract anchor
581 581 anchor = nil
582 582 if page =~ /^(.+?)\#(.+)$/
583 583 page, anchor = $1, $2
584 584 end
585 585 anchor = sanitize_anchor_name(anchor) if anchor.present?
586 586 # check if page exists
587 587 wiki_page = link_project.wiki.find_page(page)
588 588 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
589 589 "##{anchor}"
590 590 else
591 591 case options[:wiki_links]
592 592 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
593 593 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
594 594 else
595 595 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
596 596 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project, :id => wiki_page_id, :anchor => anchor)
597 597 end
598 598 end
599 599 link_to(title || h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
600 600 else
601 601 # project or wiki doesn't exist
602 602 all.html_safe
603 603 end
604 604 else
605 605 all.html_safe
606 606 end
607 607 end
608 608 end
609 609
610 610 # Redmine links
611 611 #
612 612 # Examples:
613 613 # Issues:
614 614 # #52 -> Link to issue #52
615 615 # Changesets:
616 616 # r52 -> Link to revision 52
617 617 # commit:a85130f -> Link to scmid starting with a85130f
618 618 # Documents:
619 619 # document#17 -> Link to document with id 17
620 620 # document:Greetings -> Link to the document with title "Greetings"
621 621 # document:"Some document" -> Link to the document with title "Some document"
622 622 # Versions:
623 623 # version#3 -> Link to version with id 3
624 624 # version:1.0.0 -> Link to version named "1.0.0"
625 625 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
626 626 # Attachments:
627 627 # attachment:file.zip -> Link to the attachment of the current object named file.zip
628 628 # Source files:
629 629 # source:some/file -> Link to the file located at /some/file in the project's repository
630 630 # source:some/file@52 -> Link to the file's revision 52
631 631 # source:some/file#L120 -> Link to line 120 of the file
632 632 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
633 633 # export:some/file -> Force the download of the file
634 634 # Forum messages:
635 635 # message#1218 -> Link to message with id 1218
636 636 #
637 637 # Links can refer other objects from other projects, using project identifier:
638 638 # identifier:r52
639 639 # identifier:document:"Some document"
640 640 # identifier:version:1.0.0
641 641 # identifier:source:some/file
642 642 def parse_redmine_links(text, project, obj, attr, only_path, options)
643 643 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-]+):)?(attachment|document|version|forum|news|commit|source|export|message|project)?((#|r)(\d+)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]]\W)|,|\s|\]|<|$)}) do |m|
644 644 leading, esc, project_prefix, project_identifier, prefix, sep, identifier = $1, $2, $3, $4, $5, $7 || $9, $8 || $10
645 645 link = nil
646 646 if project_identifier
647 647 project = Project.visible.find_by_identifier(project_identifier)
648 648 end
649 649 if esc.nil?
650 650 if prefix.nil? && sep == 'r'
651 651 # project.changesets.visible raises an SQL error because of a double join on repositories
652 652 if project && project.repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(project.repository.id, identifier))
653 653 link = link_to(h("#{project_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => changeset.revision},
654 654 :class => 'changeset',
655 655 :title => truncate_single_line(changeset.comments, :length => 100))
656 656 end
657 657 elsif sep == '#'
658 658 oid = identifier.to_i
659 659 case prefix
660 660 when nil
661 661 if issue = Issue.visible.find_by_id(oid, :include => :status)
662 662 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid},
663 663 :class => issue.css_classes,
664 664 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
665 665 end
666 666 when 'document'
667 667 if document = Document.visible.find_by_id(oid)
668 668 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
669 669 :class => 'document'
670 670 end
671 671 when 'version'
672 672 if version = Version.visible.find_by_id(oid)
673 673 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
674 674 :class => 'version'
675 675 end
676 676 when 'message'
677 677 if message = Message.visible.find_by_id(oid, :include => :parent)
678 678 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
679 679 end
680 680 when 'forum'
681 681 if board = Board.visible.find_by_id(oid)
682 682 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
683 683 :class => 'board'
684 684 end
685 685 when 'news'
686 686 if news = News.visible.find_by_id(oid)
687 687 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
688 688 :class => 'news'
689 689 end
690 690 when 'project'
691 691 if p = Project.visible.find_by_id(oid)
692 692 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
693 693 end
694 694 end
695 695 elsif sep == ':'
696 696 # removes the double quotes if any
697 697 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
698 698 case prefix
699 699 when 'document'
700 700 if project && document = project.documents.visible.find_by_title(name)
701 701 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
702 702 :class => 'document'
703 703 end
704 704 when 'version'
705 705 if project && version = project.versions.visible.find_by_name(name)
706 706 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
707 707 :class => 'version'
708 708 end
709 709 when 'forum'
710 710 if project && board = project.boards.visible.find_by_name(name)
711 711 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
712 712 :class => 'board'
713 713 end
714 714 when 'news'
715 715 if project && news = project.news.visible.find_by_title(name)
716 716 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
717 717 :class => 'news'
718 718 end
719 719 when 'commit'
720 720 if project && project.repository && (changeset = Changeset.visible.find(:first, :conditions => ["repository_id = ? AND scmid LIKE ?", project.repository.id, "#{name}%"]))
721 721 link = link_to h("#{project_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => changeset.identifier},
722 722 :class => 'changeset',
723 723 :title => truncate_single_line(h(changeset.comments), :length => 100)
724 724 end
725 725 when 'source', 'export'
726 726 if project && project.repository && User.current.allowed_to?(:browse_repository, project)
727 727 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
728 728 path, rev, anchor = $1, $3, $5
729 729 link = link_to h("#{project_prefix}#{prefix}:#{name}"), {:controller => 'repositories', :action => 'entry', :id => project,
730 730 :path => to_path_param(path),
731 731 :rev => rev,
732 732 :anchor => anchor,
733 733 :format => (prefix == 'export' ? 'raw' : nil)},
734 734 :class => (prefix == 'export' ? 'source download' : 'source')
735 735 end
736 736 when 'attachment'
737 737 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
738 738 if attachments && attachment = attachments.detect {|a| a.filename == name }
739 739 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
740 740 :class => 'attachment'
741 741 end
742 742 when 'project'
743 743 if p = Project.visible.find(:first, :conditions => ["identifier = :s OR LOWER(name) = :s", {:s => name.downcase}])
744 744 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
745 745 end
746 746 end
747 747 end
748 748 end
749 749 (leading + (link || "#{project_prefix}#{prefix}#{sep}#{identifier}")).html_safe
750 750 end
751 751 end
752 752
753 753 HEADING_RE = /(<h(1|2|3|4)( [^>]+)?>(.+?)<\/h(1|2|3|4)>)/i unless const_defined?(:HEADING_RE)
754 754
755 755 def parse_sections(text, project, obj, attr, only_path, options)
756 756 return unless options[:edit_section_links]
757 757 text.gsub!(HEADING_RE) do
758 758 @current_section += 1
759 759 if @current_section > 1
760 760 content_tag('div',
761 761 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
762 762 :class => 'contextual',
763 763 :title => l(:button_edit_section)) + $1
764 764 else
765 765 $1
766 766 end
767 767 end
768 768 end
769 769
770 770 # Headings and TOC
771 771 # Adds ids and links to headings unless options[:headings] is set to false
772 772 def parse_headings(text, project, obj, attr, only_path, options)
773 773 return if options[:headings] == false
774 774
775 775 text.gsub!(HEADING_RE) do
776 776 level, attrs, content = $2.to_i, $3, $4
777 777 item = strip_tags(content).strip
778 778 anchor = sanitize_anchor_name(item)
779 779 # used for single-file wiki export
780 780 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
781 781 @parsed_headings << [level, anchor, item]
782 782 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
783 783 end
784 784 end
785 785
786 786 MACROS_RE = /
787 787 (!)? # escaping
788 788 (
789 789 \{\{ # opening tag
790 790 ([\w]+) # macro name
791 791 (\(([^\}]*)\))? # optional arguments
792 792 \}\} # closing tag
793 793 )
794 794 /x unless const_defined?(:MACROS_RE)
795 795
796 796 # Macros substitution
797 797 def parse_macros(text, project, obj, attr, only_path, options)
798 798 text.gsub!(MACROS_RE) do
799 799 esc, all, macro = $1, $2, $3.downcase
800 800 args = ($5 || '').split(',').each(&:strip)
801 801 if esc.nil?
802 802 begin
803 803 exec_macro(macro, obj, args)
804 804 rescue => e
805 805 "<div class=\"flash error\">Error executing the <strong>#{macro}</strong> macro (#{e})</div>"
806 806 end || all
807 807 else
808 808 all
809 809 end
810 810 end
811 811 end
812 812
813 813 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
814 814
815 815 # Renders the TOC with given headings
816 816 def replace_toc(text, headings)
817 817 text.gsub!(TOC_RE) do
818 818 if headings.empty?
819 819 ''
820 820 else
821 821 div_class = 'toc'
822 822 div_class << ' right' if $1 == '>'
823 823 div_class << ' left' if $1 == '<'
824 824 out = "<ul class=\"#{div_class}\"><li>"
825 825 root = headings.map(&:first).min
826 826 current = root
827 827 started = false
828 828 headings.each do |level, anchor, item|
829 829 if level > current
830 830 out << '<ul><li>' * (level - current)
831 831 elsif level < current
832 832 out << "</li></ul>\n" * (current - level) + "</li><li>"
833 833 elsif started
834 834 out << '</li><li>'
835 835 end
836 836 out << "<a href=\"##{anchor}\">#{item}</a>"
837 837 current = level
838 838 started = true
839 839 end
840 840 out << '</li></ul>' * (current - root)
841 841 out << '</li></ul>'
842 842 end
843 843 end
844 844 end
845 845
846 846 # Same as Rails' simple_format helper without using paragraphs
847 847 def simple_format_without_paragraph(text)
848 848 text.to_s.
849 849 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
850 850 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
851 851 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
852 852 html_safe
853 853 end
854 854
855 855 def lang_options_for_select(blank=true)
856 856 (blank ? [["(auto)", ""]] : []) +
857 857 valid_languages.collect{|lang| [ ll(lang.to_s, :general_lang_name), lang.to_s]}.sort{|x,y| x.last <=> y.last }
858 858 end
859 859
860 860 def label_tag_for(name, option_tags = nil, options = {})
861 861 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
862 862 content_tag("label", label_text)
863 863 end
864 864
865 865 def labelled_tabular_form_for(*args, &proc)
866 866 args << {} unless args.last.is_a?(Hash)
867 867 options = args.last
868 868 options[:html] ||= {}
869 869 options[:html][:class] = 'tabular' unless options[:html].has_key?(:class)
870 870 options.merge!({:builder => TabularFormBuilder})
871 871 form_for(*args, &proc)
872 872 end
873 873
874 874 def back_url_hidden_field_tag
875 875 back_url = params[:back_url] || request.env['HTTP_REFERER']
876 876 back_url = CGI.unescape(back_url.to_s)
877 877 hidden_field_tag('back_url', CGI.escape(back_url)) unless back_url.blank?
878 878 end
879 879
880 880 def check_all_links(form_name)
881 881 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
882 882 " | ".html_safe +
883 883 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
884 884 end
885 885
886 886 def progress_bar(pcts, options={})
887 887 pcts = [pcts, pcts] unless pcts.is_a?(Array)
888 888 pcts = pcts.collect(&:round)
889 889 pcts[1] = pcts[1] - pcts[0]
890 890 pcts << (100 - pcts[1] - pcts[0])
891 891 width = options[:width] || '100px;'
892 892 legend = options[:legend] || ''
893 893 content_tag('table',
894 894 content_tag('tr',
895 895 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
896 896 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
897 897 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
898 898 ), :class => 'progress', :style => "width: #{width};").html_safe +
899 899 content_tag('p', legend, :class => 'pourcent').html_safe
900 900 end
901 901
902 902 def checked_image(checked=true)
903 903 if checked
904 904 image_tag 'toggle_check.png'
905 905 end
906 906 end
907 907
908 908 def context_menu(url)
909 909 unless @context_menu_included
910 910 content_for :header_tags do
911 911 javascript_include_tag('context_menu') +
912 912 stylesheet_link_tag('context_menu')
913 913 end
914 914 if l(:direction) == 'rtl'
915 915 content_for :header_tags do
916 916 stylesheet_link_tag('context_menu_rtl')
917 917 end
918 918 end
919 919 @context_menu_included = true
920 920 end
921 921 javascript_tag "new ContextMenu('#{ url_for(url) }')"
922 922 end
923 923
924 924 def context_menu_link(name, url, options={})
925 925 options[:class] ||= ''
926 926 if options.delete(:selected)
927 927 options[:class] << ' icon-checked disabled'
928 928 options[:disabled] = true
929 929 end
930 930 if options.delete(:disabled)
931 931 options.delete(:method)
932 932 options.delete(:confirm)
933 933 options.delete(:onclick)
934 934 options[:class] << ' disabled'
935 935 url = '#'
936 936 end
937 937 link_to h(name), url, options
938 938 end
939 939
940 940 def calendar_for(field_id)
941 941 include_calendar_headers_tags
942 942 image_tag("calendar.png", {:id => "#{field_id}_trigger",:class => "calendar-trigger"}) +
943 943 javascript_tag("Calendar.setup({inputField : '#{field_id}', ifFormat : '%Y-%m-%d', button : '#{field_id}_trigger' });")
944 944 end
945 945
946 946 def include_calendar_headers_tags
947 947 unless @calendar_headers_tags_included
948 948 @calendar_headers_tags_included = true
949 949 content_for :header_tags do
950 950 start_of_week = case Setting.start_of_week.to_i
951 951 when 1
952 952 'Calendar._FD = 1;' # Monday
953 953 when 7
954 954 'Calendar._FD = 0;' # Sunday
955 955 when 6
956 956 'Calendar._FD = 6;' # Saturday
957 957 else
958 958 '' # use language
959 959 end
960 960
961 961 javascript_include_tag('calendar/calendar') +
962 962 javascript_include_tag("calendar/lang/calendar-#{current_language.to_s.downcase}.js") +
963 963 javascript_tag(start_of_week) +
964 964 javascript_include_tag('calendar/calendar-setup') +
965 965 stylesheet_link_tag('calendar')
966 966 end
967 967 end
968 968 end
969 969
970 970 def content_for(name, content = nil, &block)
971 971 @has_content ||= {}
972 972 @has_content[name] = true
973 973 super(name, content, &block)
974 974 end
975 975
976 976 def has_content?(name)
977 977 (@has_content && @has_content[name]) || false
978 978 end
979 979
980 980 def email_delivery_enabled?
981 981 !!ActionMailer::Base.perform_deliveries
982 982 end
983 983
984 984 # Returns the avatar image tag for the given +user+ if avatars are enabled
985 985 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
986 986 def avatar(user, options = { })
987 987 if Setting.gravatar_enabled?
988 988 options.merge!({:ssl => (defined?(request) && request.ssl?), :default => Setting.gravatar_default})
989 989 email = nil
990 990 if user.respond_to?(:mail)
991 991 email = user.mail
992 992 elsif user.to_s =~ %r{<(.+?)>}
993 993 email = $1
994 994 end
995 995 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
996 996 else
997 997 ''
998 998 end
999 999 end
1000 1000
1001 1001 def sanitize_anchor_name(anchor)
1002 1002 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1003 1003 end
1004 1004
1005 1005 # Returns the javascript tags that are included in the html layout head
1006 1006 def javascript_heads
1007 1007 tags = javascript_include_tag(:defaults)
1008 1008 unless User.current.pref.warn_on_leaving_unsaved == '0'
1009 1009 tags << "\n".html_safe + javascript_tag("Event.observe(window, 'load', function(){ new WarnLeavingUnsaved('#{escape_javascript( l(:text_warn_on_leaving_unsaved) )}'); });")
1010 1010 end
1011 1011 tags
1012 1012 end
1013 1013
1014 1014 def favicon
1015 1015 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1016 1016 end
1017 1017
1018 1018 def robot_exclusion_tag
1019 1019 '<meta name="robots" content="noindex,follow,noarchive" />'
1020 1020 end
1021 1021
1022 1022 # Returns true if arg is expected in the API response
1023 1023 def include_in_api_response?(arg)
1024 1024 unless @included_in_api_response
1025 1025 param = params[:include]
1026 1026 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1027 1027 @included_in_api_response.collect!(&:strip)
1028 1028 end
1029 1029 @included_in_api_response.include?(arg.to_s)
1030 1030 end
1031 1031
1032 1032 # Returns options or nil if nometa param or X-Redmine-Nometa header
1033 1033 # was set in the request
1034 1034 def api_meta(options)
1035 1035 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1036 1036 # compatibility mode for activeresource clients that raise
1037 1037 # an error when unserializing an array with attributes
1038 1038 nil
1039 1039 else
1040 1040 options
1041 1041 end
1042 1042 end
1043 1043
1044 1044 private
1045 1045
1046 1046 def wiki_helper
1047 1047 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1048 1048 extend helper
1049 1049 return self
1050 1050 end
1051 1051
1052 1052 def link_to_content_update(text, url_params = {}, html_options = {})
1053 1053 link_to(text, url_params, html_options)
1054 1054 end
1055 1055 end
@@ -1,197 +1,203
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/md5"
19 19
20 20 class Attachment < ActiveRecord::Base
21 21 belongs_to :container, :polymorphic => true
22 22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23 23
24 24 validates_presence_of :container, :filename, :author
25 25 validates_length_of :filename, :maximum => 255
26 26 validates_length_of :disk_filename, :maximum => 255
27 27 validate :validate_max_file_size
28 28
29 29 acts_as_event :title => :filename,
30 30 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
31 31
32 32 acts_as_activity_provider :type => 'files',
33 33 :permission => :view_files,
34 34 :author_key => :author_id,
35 35 :find_options => {:select => "#{Attachment.table_name}.*",
36 36 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
37 37 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
38 38
39 39 acts_as_activity_provider :type => 'documents',
40 40 :permission => :view_documents,
41 41 :author_key => :author_id,
42 42 :find_options => {:select => "#{Attachment.table_name}.*",
43 43 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
44 44 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
45 45
46 46 cattr_accessor :storage_path
47 47 @@storage_path = Redmine::Configuration['attachments_storage_path'] || "#{Rails.root}/files"
48 48
49 49 before_save :files_to_final_location
50 50 after_destroy :delete_from_disk
51 51
52 52 def validate_max_file_size
53 53 if self.filesize > Setting.attachment_max_size.to_i.kilobytes
54 54 errors.add(:base, :too_long, :count => Setting.attachment_max_size.to_i.kilobytes)
55 55 end
56 56 end
57 57
58 58 def file=(incoming_file)
59 59 unless incoming_file.nil?
60 60 @temp_file = incoming_file
61 61 if @temp_file.size > 0
62 62 self.filename = sanitize_filename(@temp_file.original_filename)
63 63 self.disk_filename = Attachment.disk_filename(filename)
64 64 self.content_type = @temp_file.content_type.to_s.chomp
65 65 if content_type.blank?
66 66 self.content_type = Redmine::MimeType.of(filename)
67 67 end
68 68 self.filesize = @temp_file.size
69 69 end
70 70 end
71 71 end
72 72
73 73 def file
74 74 nil
75 75 end
76 76
77 77 # Copies the temporary file to its final location
78 78 # and computes its MD5 hash
79 79 def files_to_final_location
80 80 if @temp_file && (@temp_file.size > 0)
81 81 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
82 82 md5 = Digest::MD5.new
83 83 File.open(diskfile, "wb") do |f|
84 84 buffer = ""
85 85 while (buffer = @temp_file.read(8192))
86 86 f.write(buffer)
87 87 md5.update(buffer)
88 88 end
89 89 end
90 90 self.digest = md5.hexdigest
91 91 end
92 92 @temp_file = nil
93 93 # Don't save the content type if it's longer than the authorized length
94 94 if self.content_type && self.content_type.length > 255
95 95 self.content_type = nil
96 96 end
97 97 end
98 98
99 99 # Deletes file on the disk
100 100 def delete_from_disk
101 101 File.delete(diskfile) if !filename.blank? && File.exist?(diskfile)
102 102 end
103 103
104 104 # Returns file's location on disk
105 105 def diskfile
106 106 "#{@@storage_path}/#{self.disk_filename}"
107 107 end
108 108
109 109 def increment_download
110 110 increment!(:downloads)
111 111 end
112 112
113 113 def project
114 114 container.project
115 115 end
116 116
117 117 def visible?(user=User.current)
118 118 container.attachments_visible?(user)
119 119 end
120 120
121 121 def deletable?(user=User.current)
122 122 container.attachments_deletable?(user)
123 123 end
124 124
125 125 def image?
126 126 self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i
127 127 end
128 128
129 129 def is_text?
130 130 Redmine::MimeType.is_type?('text', filename)
131 131 end
132 132
133 133 def is_diff?
134 134 self.filename =~ /\.(patch|diff)$/i
135 135 end
136 136
137 137 # Returns true if the file is readable
138 138 def readable?
139 139 File.readable?(diskfile)
140 140 end
141 141
142 142 # Bulk attaches a set of files to an object
143 143 #
144 144 # Returns a Hash of the results:
145 145 # :files => array of the attached files
146 146 # :unsaved => array of the files that could not be attached
147 147 def self.attach_files(obj, attachments)
148 148 attached = []
149 149 if attachments && attachments.is_a?(Hash)
150 150 attachments.each_value do |attachment|
151 151 file = attachment['file']
152 152 next unless file && file.size > 0
153 153 a = Attachment.create(:container => obj,
154 154 :file => file,
155 155 :description => attachment['description'].to_s.strip,
156 156 :author => User.current)
157 157 obj.attachments << a
158 158
159 159 if a.new_record?
160 160 obj.unsaved_attachments ||= []
161 161 obj.unsaved_attachments << a
162 162 else
163 163 attached << a
164 164 end
165 165 end
166 166 end
167 167 {:files => attached, :unsaved => obj.unsaved_attachments}
168 168 end
169 169
170 def self.latest_attach(attachments, filename)
171 attachments.sort_by(&:created_on).reverse.detect {
172 |att| att.filename.downcase == filename.downcase
173 }
174 end
175
170 176 private
171 177 def sanitize_filename(value)
172 178 # get only the filename, not the whole path
173 179 just_filename = value.gsub(/^.*(\\|\/)/, '')
174 180 # NOTE: File.basename doesn't work right with Windows paths on Unix
175 181 # INCORRECT: just_filename = File.basename(value.gsub('\\\\', '/'))
176 182
177 183 # Finally, replace all non alphanumeric, hyphens or periods with underscore
178 184 @filename = just_filename.gsub(/[^\w\.\-]/,'_')
179 185 end
180 186
181 187 # Returns an ASCII or hashed filename
182 188 def self.disk_filename(filename)
183 189 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
184 190 ascii = ''
185 191 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
186 192 ascii = filename
187 193 else
188 194 ascii = Digest::MD5.hexdigest(filename)
189 195 # keep the extension if any
190 196 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
191 197 end
192 198 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
193 199 timestamp.succ!
194 200 end
195 201 "#{timestamp}_#{ascii}"
196 202 end
197 203 end
@@ -1,124 +1,144
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2011 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../test_helper', __FILE__)
21 21
22 22 class AttachmentTest < ActiveSupport::TestCase
23 23 fixtures :issues, :users
24 24
25 25 def setup
26 26 set_tmp_attachments_directory
27 27 end
28 28
29 29 def test_create
30 30 a = Attachment.new(:container => Issue.find(1),
31 31 :file => uploaded_test_file("testfile.txt", "text/plain"),
32 32 :author => User.find(1))
33 33 assert a.save
34 34 assert_equal 'testfile.txt', a.filename
35 35 assert_equal 59, a.filesize
36 36 assert_equal 'text/plain', a.content_type
37 37 assert_equal 0, a.downloads
38 38 assert_equal '1478adae0d4eb06d35897518540e25d6', a.digest
39 39 assert File.exist?(a.diskfile)
40 40 assert_equal 59, File.size(a.diskfile)
41 41 end
42 42
43 43 def test_destroy
44 44 a = Attachment.new(:container => Issue.find(1),
45 45 :file => uploaded_test_file("testfile.txt", "text/plain"),
46 46 :author => User.find(1))
47 47 assert a.save
48 48 assert_equal 'testfile.txt', a.filename
49 49 assert_equal 59, a.filesize
50 50 assert_equal 'text/plain', a.content_type
51 51 assert_equal 0, a.downloads
52 52 assert_equal '1478adae0d4eb06d35897518540e25d6', a.digest
53 53 diskfile = a.diskfile
54 54 assert File.exist?(diskfile)
55 55 assert_equal 59, File.size(a.diskfile)
56 56 assert a.destroy
57 57 assert !File.exist?(diskfile)
58 58 end
59 59
60 60 def test_create_should_auto_assign_content_type
61 61 a = Attachment.new(:container => Issue.find(1),
62 62 :file => uploaded_test_file("testfile.txt", ""),
63 63 :author => User.find(1))
64 64 assert a.save
65 65 assert_equal 'text/plain', a.content_type
66 66 end
67 67
68 68 def test_identical_attachments_at_the_same_time_should_not_overwrite
69 69 a1 = Attachment.create!(:container => Issue.find(1),
70 70 :file => uploaded_test_file("testfile.txt", ""),
71 71 :author => User.find(1))
72 72 a2 = Attachment.create!(:container => Issue.find(1),
73 73 :file => uploaded_test_file("testfile.txt", ""),
74 74 :author => User.find(1))
75 75 assert a1.disk_filename != a2.disk_filename
76 76 end
77 77
78 78 def test_diskfilename
79 79 assert Attachment.disk_filename("test_file.txt") =~ /^\d{12}_test_file.txt$/
80 80 assert_equal 'test_file.txt', Attachment.disk_filename("test_file.txt")[13..-1]
81 81 assert_equal '770c509475505f37c2b8fb6030434d6b.txt', Attachment.disk_filename("test_accentuΓ©.txt")[13..-1]
82 82 assert_equal 'f8139524ebb8f32e51976982cd20a85d', Attachment.disk_filename("test_accentuΓ©")[13..-1]
83 83 assert_equal 'cbb5b0f30978ba03731d61f9f6d10011', Attachment.disk_filename("test_accentuΓ©.Γ§a")[13..-1]
84 84 end
85 85
86 86 context "Attachmnet.attach_files" do
87 87 should "attach the file" do
88 88 issue = Issue.first
89 89 assert_difference 'Attachment.count' do
90 90 Attachment.attach_files(issue,
91 91 '1' => {
92 92 'file' => uploaded_test_file('testfile.txt', 'text/plain'),
93 93 'description' => 'test'
94 94 })
95 95 end
96 96
97 97 attachment = Attachment.first(:order => 'id DESC')
98 98 assert_equal issue, attachment.container
99 99 assert_equal 'testfile.txt', attachment.filename
100 100 assert_equal 59, attachment.filesize
101 101 assert_equal 'test', attachment.description
102 102 assert_equal 'text/plain', attachment.content_type
103 103 assert File.exists?(attachment.diskfile)
104 104 assert_equal 59, File.size(attachment.diskfile)
105 105 end
106 106
107 107 should "add unsaved files to the object as unsaved attachments" do
108 108 # Max size of 0 to force Attachment creation failures
109 109 with_settings(:attachment_max_size => 0) do
110 110 @project = Project.generate!
111 111 response = Attachment.attach_files(@project, {
112 112 '1' => {'file' => mock_file, 'description' => 'test'},
113 113 '2' => {'file' => mock_file, 'description' => 'test'}
114 114 })
115 115
116 116 assert response[:unsaved].present?
117 117 assert_equal 2, response[:unsaved].length
118 118 assert response[:unsaved].first.new_record?
119 119 assert response[:unsaved].second.new_record?
120 120 assert_equal response[:unsaved], @project.unsaved_attachments
121 121 end
122 122 end
123 123 end
124
125 def test_latest_attach
126 Attachment.storage_path = "#{Rails.root}/test/fixtures/files"
127 a1 = Attachment.find(16)
128 assert_equal "testfile.png", a1.filename
129 assert a1.readable?
130 assert (! a1.visible?(User.anonymous))
131 assert a1.visible?(User.find(2))
132 a2 = Attachment.find(17)
133 assert_equal "testfile.PNG", a2.filename
134 assert a2.readable?
135 assert (! a2.visible?(User.anonymous))
136 assert a2.visible?(User.find(2))
137 assert a1.created_on < a2.created_on
138
139 la1 = Attachment.latest_attach([a1, a2], "testfile.png")
140 assert_equal 17, la1.id
141 la2 = Attachment.latest_attach([a1, a2], "Testfile.PNG")
142 assert_equal 17, la2.id
143 end
124 144 end
General Comments 0
You need to be logged in to leave comments. Login now