##// END OF EJS Templates
Merged ajax_upload branch (#3957)....
Jean-Philippe Lang -
r10748:ef25210aca92
parent child
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -0,0 +1,1
1 $('#attachments_<%= j params[:attachment_id] %>').remove();
@@ -0,0 +1,9
1 var fileSpan = $('#attachments_<%= j params[:attachment_id] %>');
2 $('<input>', { type: 'hidden', name: 'attachments[<%= j params[:attachment_id] %>][token]' } ).val('<%= j @attachment.token %>').appendTo(fileSpan);
3 fileSpan.find('a.remove-upload')
4 .attr({
5 "data-remote": true,
6 "data-method": 'delete',
7 href: '<%= j attachment_path(@attachment, :attachment_id => params[:attachment_id], :format => 'js') %>'
8 })
9 .off('click');
1 NO CONTENT: new file 100644, binary diff hidden
NO CONTENT: new file 100644, binary diff hidden
@@ -0,0 +1,189
1 /* Redmine - project management software
2 Copyright (C) 2006-2012 Jean-Philippe Lang */
3
4 function addFile(inputEl, file, eagerUpload) {
5
6 if ($('#attachments_fields').children().length < 10) {
7
8 var attachmentId = addFile.nextAttachmentId++;
9
10 var fileSpan = $('<span>', { id: 'attachments_' + attachmentId });
11
12 fileSpan.append(
13 $('<input>', { type: 'text', 'class': 'filename readonly', name: 'attachments[' + attachmentId + '][filename]', readonly: 'readonly'} ).val(file.name),
14 $('<input>', { type: 'text', 'class': 'description', name: 'attachments[' + attachmentId + '][description]', maxlength: 255, placeholder: $(inputEl).data('description-placeholder') } ).toggle(!eagerUpload),
15 $('<a>&nbsp</a>').attr({ href: "#", 'class': 'remove-upload' }).click(removeFile).toggle(!eagerUpload)
16 ).appendTo('#attachments_fields');
17
18 if(eagerUpload) {
19 ajaxUpload(file, attachmentId, fileSpan, inputEl);
20 }
21
22 return attachmentId;
23 }
24 return null;
25 }
26
27 addFile.nextAttachmentId = 1;
28
29 function ajaxUpload(file, attachmentId, fileSpan, inputEl) {
30
31 function onLoadstart(e) {
32 fileSpan.removeClass('ajax-waiting');
33 fileSpan.addClass('ajax-loading');
34 $('input:submit', $(this).parents('form')).attr('disabled', 'disabled');
35 }
36
37 function onProgress(e) {
38 if(e.lengthComputable) {
39 this.progressbar( 'value', e.loaded * 100 / e.total );
40 }
41 }
42
43 function actualUpload(file, attachmentId, fileSpan, inputEl) {
44
45 ajaxUpload.uploading++;
46
47 uploadBlob(file, $(inputEl).data('upload-path'), attachmentId, {
48 loadstartEventHandler: onLoadstart.bind(progressSpan),
49 progressEventHandler: onProgress.bind(progressSpan)
50 })
51 .done(function(result) {
52 progressSpan.progressbar( 'value', 100 ).remove();
53 fileSpan.find('input.description, a').css('display', 'inline-block');
54 })
55 .fail(function(result) {
56 progressSpan.text(result.statusText);
57 }).always(function() {
58 ajaxUpload.uploading--;
59 fileSpan.removeClass('ajax-loading');
60 var form = fileSpan.parents('form');
61 if (form.queue('upload').length == 0 && ajaxUpload.uploading == 0) {
62 $('input:submit', form).removeAttr('disabled');
63 }
64 form.dequeue('upload');
65 });
66 }
67
68 var progressSpan = $('<div>').insertAfter(fileSpan.find('input.filename'));
69 progressSpan.progressbar();
70 fileSpan.addClass('ajax-waiting');
71
72 var maxSyncUpload = $(inputEl).data('max-concurrent-uploads');
73
74 if(maxSyncUpload == null || maxSyncUpload <= 0 || ajaxUpload.uploading < maxSyncUpload)
75 actualUpload(file, attachmentId, fileSpan, inputEl);
76 else
77 $(inputEl).parents('form').queue('upload', actualUpload.bind(this, file, attachmentId, fileSpan, inputEl));
78 }
79
80 ajaxUpload.uploading = 0;
81
82 function removeFile() {
83 $(this).parent('span').remove();
84 return false;
85 }
86
87 function uploadBlob(blob, uploadUrl, attachmentId, options) {
88
89 var actualOptions = $.extend({
90 loadstartEventHandler: $.noop,
91 progressEventHandler: $.noop
92 }, options);
93
94 uploadUrl = uploadUrl + '?attachment_id=' + attachmentId;
95 if (blob instanceof window.File) {
96 uploadUrl += '&filename=' + encodeURIComponent(blob.name);
97 }
98
99 return $.ajax(uploadUrl, {
100 type: 'POST',
101 contentType: 'application/octet-stream',
102 beforeSend: function(jqXhr) {
103 jqXhr.setRequestHeader('Accept', 'application/js');
104 },
105 xhr: function() {
106 var xhr = $.ajaxSettings.xhr();
107 xhr.upload.onloadstart = actualOptions.loadstartEventHandler;
108 xhr.upload.onprogress = actualOptions.progressEventHandler;
109 return xhr;
110 },
111 data: blob,
112 cache: false,
113 processData: false
114 });
115 }
116
117 function addInputFiles(inputEl) {
118 var clearedFileInput = $(inputEl).clone().val('');
119
120 if (inputEl.files) {
121 // upload files using ajax
122 uploadAndAttachFiles(inputEl.files, inputEl);
123 $(inputEl).remove();
124 } else {
125 // browser not supporting the file API, upload on form submission
126 var attachmentId;
127 var aFilename = inputEl.value.split(/\/|\\/);
128 attachmentId = addFile(inputEl, { name: aFilename[ aFilename.length - 1 ] }, false);
129 if (attachmentId) {
130 $(inputEl).attr({ name: 'attachments[' + attachmentId + '][file]', style: 'display:none;' }).appendTo('#attachments_' + attachmentId);
131 }
132 }
133
134 clearedFileInput.insertAfter('#attachments_fields');
135 }
136
137 function uploadAndAttachFiles(files, inputEl) {
138
139 var maxFileSize = $(inputEl).data('max-file-size');
140 var maxFileSizeExceeded = $(inputEl).data('max-file-size-message');
141
142 var sizeExceeded = false;
143 $.each(files, function() {
144 if (this.size && maxFileSize && this.size > parseInt(maxFileSize)) {sizeExceeded=true;}
145 });
146 if (sizeExceeded) {
147 window.alert(maxFileSizeExceeded);
148 } else {
149 $.each(files, function() {addFile(inputEl, this, true);});
150 }
151 }
152
153 function handleFileDropEvent(e) {
154
155 $(this).removeClass('fileover');
156 blockEventPropagation(e);
157
158 if ($.inArray('Files', e.dataTransfer.types) > -1) {
159
160 uploadAndAttachFiles(e.dataTransfer.files, $('input:file[name=attachments_files]'));
161 }
162 }
163
164 function dragOverHandler(e) {
165 $(this).addClass('fileover');
166 blockEventPropagation(e);
167 }
168
169 function dragOutHandler(e) {
170 $(this).removeClass('fileover');
171 blockEventPropagation(e);
172 }
173
174 function setupFileDrop() {
175 if (window.File && window.FileList && window.ProgressEvent && window.FormData) {
176
177 $.event.fixHooks.drop = { props: [ 'dataTransfer' ] };
178
179 $('form div.box').has('input:file').each(function() {
180 $(this).on({
181 dragover: dragOverHandler,
182 dragleave: dragOutHandler,
183 drop: handleFileDropEvent
184 });
185 });
186 }
187 }
188
189 $(document).ready(setupFileDrop);
@@ -0,0 +1,132
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.expand_path('../../test_helper', __FILE__)
19
20 class AttachmentsTest < ActionController::IntegrationTest
21 fixtures :projects, :enabled_modules,
22 :users, :roles, :members, :member_roles,
23 :trackers, :projects_trackers,
24 :issue_statuses, :enumerations
25
26 def test_upload_as_js_and_attach_to_an_issue
27 log_user('jsmith', 'jsmith')
28
29 token = ajax_upload('myupload.txt', 'File content')
30
31 assert_difference 'Issue.count' do
32 post '/projects/ecookbook/issues', {
33 :issue => {:tracker_id => 1, :subject => 'Issue with upload'},
34 :attachments => {'1' => {:filename => 'myupload.txt', :description => 'My uploaded file', :token => token}}
35 }
36 assert_response 302
37 end
38
39 issue = Issue.order('id DESC').first
40 assert_equal 'Issue with upload', issue.subject
41 assert_equal 1, issue.attachments.count
42
43 attachment = issue.attachments.first
44 assert_equal 'myupload.txt', attachment.filename
45 assert_equal 'My uploaded file', attachment.description
46 assert_equal 'File content'.length, attachment.filesize
47 end
48
49 def test_upload_as_js_and_preview_as_inline_attachment
50 log_user('jsmith', 'jsmith')
51
52 token = ajax_upload('myupload.jpg', 'JPEG content')
53
54 post '/issues/preview/new/ecookbook', {
55 :issue => {:tracker_id => 1, :description => 'Inline upload: !myupload.jpg!'},
56 :attachments => {'1' => {:filename => 'myupload.jpg', :description => 'My uploaded file', :token => token}}
57 }
58 assert_response :success
59
60 attachment_path = response.body.match(%r{<img src="(/attachments/download/\d+)"})[1]
61 assert_not_nil token, "No attachment path found in response:\n#{response.body}"
62
63 get attachment_path
64 assert_response :success
65 assert_equal 'JPEG content', response.body
66 end
67
68 def test_upload_and_resubmit_after_validation_failure
69 log_user('jsmith', 'jsmith')
70
71 token = ajax_upload('myupload.txt', 'File content')
72
73 assert_no_difference 'Issue.count' do
74 post '/projects/ecookbook/issues', {
75 :issue => {:tracker_id => 1, :subject => ''},
76 :attachments => {'1' => {:filename => 'myupload.txt', :description => 'My uploaded file', :token => token}}
77 }
78 assert_response :success
79 end
80 assert_select 'input[type=hidden][name=?][value=?]', 'attachments[p0][token]', token
81 assert_select 'input[name=?][value=?]', 'attachments[p0][filename]', 'myupload.txt'
82 assert_select 'input[name=?][value=?]', 'attachments[p0][description]', 'My uploaded file'
83
84 assert_difference 'Issue.count' do
85 post '/projects/ecookbook/issues', {
86 :issue => {:tracker_id => 1, :subject => 'Issue with upload'},
87 :attachments => {'p0' => {:filename => 'myupload.txt', :description => 'My uploaded file', :token => token}}
88 }
89 assert_response 302
90 end
91
92 issue = Issue.order('id DESC').first
93 assert_equal 'Issue with upload', issue.subject
94 assert_equal 1, issue.attachments.count
95
96 attachment = issue.attachments.first
97 assert_equal 'myupload.txt', attachment.filename
98 assert_equal 'My uploaded file', attachment.description
99 assert_equal 'File content'.length, attachment.filesize
100 end
101
102 def test_upload_as_js_and_destroy
103 log_user('jsmith', 'jsmith')
104
105 token = ajax_upload('myupload.txt', 'File content')
106
107 attachment = Attachment.order('id DESC').first
108 attachment_path = "/attachments/#{attachment.id}.js?attachment_id=1"
109 assert_include "href: '#{attachment_path}'", response.body, "Path to attachment: #{attachment_path} not found in response:\n#{response.body}"
110
111 assert_difference 'Attachment.count', -1 do
112 delete attachment_path
113 assert_response :success
114 end
115
116 assert_include "$('#attachments_1').remove();", response.body
117 end
118
119 private
120
121 def ajax_upload(filename, content, attachment_id=1)
122 assert_difference 'Attachment.count' do
123 post "/uploads.js?attachment_id=#{attachment_id}&filename=#{filename}", content, {"CONTENT_TYPE" => 'application/octet-stream'}
124 assert_response :success
125 assert_equal 'text/javascript', response.content_type
126 end
127
128 token = response.body.match(/\.val\('(\d+\.[0-9a-f]+)'\)/)[1]
129 assert_not_nil token, "No upload token found in response:\n#{response.body}"
130 token
131 end
132 end
@@ -1,590 +1,600
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'uri'
18 require 'uri'
19 require 'cgi'
19 require 'cgi'
20
20
21 class Unauthorized < Exception; end
21 class Unauthorized < Exception; end
22
22
23 class ApplicationController < ActionController::Base
23 class ApplicationController < ActionController::Base
24 include Redmine::I18n
24 include Redmine::I18n
25
25
26 class_attribute :accept_api_auth_actions
26 class_attribute :accept_api_auth_actions
27 class_attribute :accept_rss_auth_actions
27 class_attribute :accept_rss_auth_actions
28 class_attribute :model_object
28 class_attribute :model_object
29
29
30 layout 'base'
30 layout 'base'
31
31
32 protect_from_forgery
32 protect_from_forgery
33 def handle_unverified_request
33 def handle_unverified_request
34 super
34 super
35 cookies.delete(:autologin)
35 cookies.delete(:autologin)
36 end
36 end
37
37
38 before_filter :session_expiration, :user_setup, :check_if_login_required, :set_localization
38 before_filter :session_expiration, :user_setup, :check_if_login_required, :set_localization
39
39
40 rescue_from ActionController::InvalidAuthenticityToken, :with => :invalid_authenticity_token
40 rescue_from ActionController::InvalidAuthenticityToken, :with => :invalid_authenticity_token
41 rescue_from ::Unauthorized, :with => :deny_access
41 rescue_from ::Unauthorized, :with => :deny_access
42 rescue_from ::ActionView::MissingTemplate, :with => :missing_template
42 rescue_from ::ActionView::MissingTemplate, :with => :missing_template
43
43
44 include Redmine::Search::Controller
44 include Redmine::Search::Controller
45 include Redmine::MenuManager::MenuController
45 include Redmine::MenuManager::MenuController
46 helper Redmine::MenuManager::MenuHelper
46 helper Redmine::MenuManager::MenuHelper
47
47
48 def session_expiration
48 def session_expiration
49 if session[:user_id]
49 if session[:user_id]
50 if session_expired? && !try_to_autologin
50 if session_expired? && !try_to_autologin
51 reset_session
51 reset_session
52 flash[:error] = l(:error_session_expired)
52 flash[:error] = l(:error_session_expired)
53 redirect_to signin_url
53 redirect_to signin_url
54 else
54 else
55 session[:atime] = Time.now.utc.to_i
55 session[:atime] = Time.now.utc.to_i
56 end
56 end
57 end
57 end
58 end
58 end
59
59
60 def session_expired?
60 def session_expired?
61 if Setting.session_lifetime?
61 if Setting.session_lifetime?
62 unless session[:ctime] && (Time.now.utc.to_i - session[:ctime].to_i <= Setting.session_lifetime.to_i * 60)
62 unless session[:ctime] && (Time.now.utc.to_i - session[:ctime].to_i <= Setting.session_lifetime.to_i * 60)
63 return true
63 return true
64 end
64 end
65 end
65 end
66 if Setting.session_timeout?
66 if Setting.session_timeout?
67 unless session[:atime] && (Time.now.utc.to_i - session[:atime].to_i <= Setting.session_timeout.to_i * 60)
67 unless session[:atime] && (Time.now.utc.to_i - session[:atime].to_i <= Setting.session_timeout.to_i * 60)
68 return true
68 return true
69 end
69 end
70 end
70 end
71 false
71 false
72 end
72 end
73
73
74 def start_user_session(user)
74 def start_user_session(user)
75 session[:user_id] = user.id
75 session[:user_id] = user.id
76 session[:ctime] = Time.now.utc.to_i
76 session[:ctime] = Time.now.utc.to_i
77 session[:atime] = Time.now.utc.to_i
77 session[:atime] = Time.now.utc.to_i
78 end
78 end
79
79
80 def user_setup
80 def user_setup
81 # Check the settings cache for each request
81 # Check the settings cache for each request
82 Setting.check_cache
82 Setting.check_cache
83 # Find the current user
83 # Find the current user
84 User.current = find_current_user
84 User.current = find_current_user
85 logger.info(" Current user: " + (User.current.logged? ? "#{User.current.login} (id=#{User.current.id})" : "anonymous")) if logger
85 logger.info(" Current user: " + (User.current.logged? ? "#{User.current.login} (id=#{User.current.id})" : "anonymous")) if logger
86 end
86 end
87
87
88 # Returns the current user or nil if no user is logged in
88 # Returns the current user or nil if no user is logged in
89 # and starts a session if needed
89 # and starts a session if needed
90 def find_current_user
90 def find_current_user
91 user = nil
91 user = nil
92 unless api_request?
92 unless api_request?
93 if session[:user_id]
93 if session[:user_id]
94 # existing session
94 # existing session
95 user = (User.active.find(session[:user_id]) rescue nil)
95 user = (User.active.find(session[:user_id]) rescue nil)
96 elsif autologin_user = try_to_autologin
96 elsif autologin_user = try_to_autologin
97 user = autologin_user
97 user = autologin_user
98 elsif params[:format] == 'atom' && params[:key] && request.get? && accept_rss_auth?
98 elsif params[:format] == 'atom' && params[:key] && request.get? && accept_rss_auth?
99 # RSS key authentication does not start a session
99 # RSS key authentication does not start a session
100 user = User.find_by_rss_key(params[:key])
100 user = User.find_by_rss_key(params[:key])
101 end
101 end
102 end
102 end
103 if user.nil? && Setting.rest_api_enabled? && accept_api_auth?
103 if user.nil? && Setting.rest_api_enabled? && accept_api_auth?
104 if (key = api_key_from_request)
104 if (key = api_key_from_request)
105 # Use API key
105 # Use API key
106 user = User.find_by_api_key(key)
106 user = User.find_by_api_key(key)
107 else
107 else
108 # HTTP Basic, either username/password or API key/random
108 # HTTP Basic, either username/password or API key/random
109 authenticate_with_http_basic do |username, password|
109 authenticate_with_http_basic do |username, password|
110 user = User.try_to_login(username, password) || User.find_by_api_key(username)
110 user = User.try_to_login(username, password) || User.find_by_api_key(username)
111 end
111 end
112 end
112 end
113 # Switch user if requested by an admin user
113 # Switch user if requested by an admin user
114 if user && user.admin? && (username = api_switch_user_from_request)
114 if user && user.admin? && (username = api_switch_user_from_request)
115 su = User.find_by_login(username)
115 su = User.find_by_login(username)
116 if su && su.active?
116 if su && su.active?
117 logger.info(" User switched by: #{user.login} (id=#{user.id})") if logger
117 logger.info(" User switched by: #{user.login} (id=#{user.id})") if logger
118 user = su
118 user = su
119 else
119 else
120 render_error :message => 'Invalid X-Redmine-Switch-User header', :status => 412
120 render_error :message => 'Invalid X-Redmine-Switch-User header', :status => 412
121 end
121 end
122 end
122 end
123 end
123 end
124 user
124 user
125 end
125 end
126
126
127 def try_to_autologin
127 def try_to_autologin
128 if cookies[:autologin] && Setting.autologin?
128 if cookies[:autologin] && Setting.autologin?
129 # auto-login feature starts a new session
129 # auto-login feature starts a new session
130 user = User.try_to_autologin(cookies[:autologin])
130 user = User.try_to_autologin(cookies[:autologin])
131 if user
131 if user
132 reset_session
132 reset_session
133 start_user_session(user)
133 start_user_session(user)
134 end
134 end
135 user
135 user
136 end
136 end
137 end
137 end
138
138
139 # Sets the logged in user
139 # Sets the logged in user
140 def logged_user=(user)
140 def logged_user=(user)
141 reset_session
141 reset_session
142 if user && user.is_a?(User)
142 if user && user.is_a?(User)
143 User.current = user
143 User.current = user
144 start_user_session(user)
144 start_user_session(user)
145 else
145 else
146 User.current = User.anonymous
146 User.current = User.anonymous
147 end
147 end
148 end
148 end
149
149
150 # Logs out current user
150 # Logs out current user
151 def logout_user
151 def logout_user
152 if User.current.logged?
152 if User.current.logged?
153 cookies.delete :autologin
153 cookies.delete :autologin
154 Token.delete_all(["user_id = ? AND action = ?", User.current.id, 'autologin'])
154 Token.delete_all(["user_id = ? AND action = ?", User.current.id, 'autologin'])
155 self.logged_user = nil
155 self.logged_user = nil
156 end
156 end
157 end
157 end
158
158
159 # check if login is globally required to access the application
159 # check if login is globally required to access the application
160 def check_if_login_required
160 def check_if_login_required
161 # no check needed if user is already logged in
161 # no check needed if user is already logged in
162 return true if User.current.logged?
162 return true if User.current.logged?
163 require_login if Setting.login_required?
163 require_login if Setting.login_required?
164 end
164 end
165
165
166 def set_localization
166 def set_localization
167 lang = nil
167 lang = nil
168 if User.current.logged?
168 if User.current.logged?
169 lang = find_language(User.current.language)
169 lang = find_language(User.current.language)
170 end
170 end
171 if lang.nil? && request.env['HTTP_ACCEPT_LANGUAGE']
171 if lang.nil? && request.env['HTTP_ACCEPT_LANGUAGE']
172 accept_lang = parse_qvalues(request.env['HTTP_ACCEPT_LANGUAGE']).first
172 accept_lang = parse_qvalues(request.env['HTTP_ACCEPT_LANGUAGE']).first
173 if !accept_lang.blank?
173 if !accept_lang.blank?
174 accept_lang = accept_lang.downcase
174 accept_lang = accept_lang.downcase
175 lang = find_language(accept_lang) || find_language(accept_lang.split('-').first)
175 lang = find_language(accept_lang) || find_language(accept_lang.split('-').first)
176 end
176 end
177 end
177 end
178 lang ||= Setting.default_language
178 lang ||= Setting.default_language
179 set_language_if_valid(lang)
179 set_language_if_valid(lang)
180 end
180 end
181
181
182 def require_login
182 def require_login
183 if !User.current.logged?
183 if !User.current.logged?
184 # Extract only the basic url parameters on non-GET requests
184 # Extract only the basic url parameters on non-GET requests
185 if request.get?
185 if request.get?
186 url = url_for(params)
186 url = url_for(params)
187 else
187 else
188 url = url_for(:controller => params[:controller], :action => params[:action], :id => params[:id], :project_id => params[:project_id])
188 url = url_for(:controller => params[:controller], :action => params[:action], :id => params[:id], :project_id => params[:project_id])
189 end
189 end
190 respond_to do |format|
190 respond_to do |format|
191 format.html { redirect_to :controller => "account", :action => "login", :back_url => url }
191 format.html { redirect_to :controller => "account", :action => "login", :back_url => url }
192 format.atom { redirect_to :controller => "account", :action => "login", :back_url => url }
192 format.atom { redirect_to :controller => "account", :action => "login", :back_url => url }
193 format.xml { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
193 format.xml { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
194 format.js { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
194 format.js { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
195 format.json { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
195 format.json { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
196 end
196 end
197 return false
197 return false
198 end
198 end
199 true
199 true
200 end
200 end
201
201
202 def require_admin
202 def require_admin
203 return unless require_login
203 return unless require_login
204 if !User.current.admin?
204 if !User.current.admin?
205 render_403
205 render_403
206 return false
206 return false
207 end
207 end
208 true
208 true
209 end
209 end
210
210
211 def deny_access
211 def deny_access
212 User.current.logged? ? render_403 : require_login
212 User.current.logged? ? render_403 : require_login
213 end
213 end
214
214
215 # Authorize the user for the requested action
215 # Authorize the user for the requested action
216 def authorize(ctrl = params[:controller], action = params[:action], global = false)
216 def authorize(ctrl = params[:controller], action = params[:action], global = false)
217 allowed = User.current.allowed_to?({:controller => ctrl, :action => action}, @project || @projects, :global => global)
217 allowed = User.current.allowed_to?({:controller => ctrl, :action => action}, @project || @projects, :global => global)
218 if allowed
218 if allowed
219 true
219 true
220 else
220 else
221 if @project && @project.archived?
221 if @project && @project.archived?
222 render_403 :message => :notice_not_authorized_archived_project
222 render_403 :message => :notice_not_authorized_archived_project
223 else
223 else
224 deny_access
224 deny_access
225 end
225 end
226 end
226 end
227 end
227 end
228
228
229 # Authorize the user for the requested action outside a project
229 # Authorize the user for the requested action outside a project
230 def authorize_global(ctrl = params[:controller], action = params[:action], global = true)
230 def authorize_global(ctrl = params[:controller], action = params[:action], global = true)
231 authorize(ctrl, action, global)
231 authorize(ctrl, action, global)
232 end
232 end
233
233
234 # Find project of id params[:id]
234 # Find project of id params[:id]
235 def find_project
235 def find_project
236 @project = Project.find(params[:id])
236 @project = Project.find(params[:id])
237 rescue ActiveRecord::RecordNotFound
237 rescue ActiveRecord::RecordNotFound
238 render_404
238 render_404
239 end
239 end
240
240
241 # Find project of id params[:project_id]
241 # Find project of id params[:project_id]
242 def find_project_by_project_id
242 def find_project_by_project_id
243 @project = Project.find(params[:project_id])
243 @project = Project.find(params[:project_id])
244 rescue ActiveRecord::RecordNotFound
244 rescue ActiveRecord::RecordNotFound
245 render_404
245 render_404
246 end
246 end
247
247
248 # Find a project based on params[:project_id]
248 # Find a project based on params[:project_id]
249 # TODO: some subclasses override this, see about merging their logic
249 # TODO: some subclasses override this, see about merging their logic
250 def find_optional_project
250 def find_optional_project
251 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
251 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
252 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
252 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
253 allowed ? true : deny_access
253 allowed ? true : deny_access
254 rescue ActiveRecord::RecordNotFound
254 rescue ActiveRecord::RecordNotFound
255 render_404
255 render_404
256 end
256 end
257
257
258 # Finds and sets @project based on @object.project
258 # Finds and sets @project based on @object.project
259 def find_project_from_association
259 def find_project_from_association
260 render_404 unless @object.present?
260 render_404 unless @object.present?
261
261
262 @project = @object.project
262 @project = @object.project
263 end
263 end
264
264
265 def find_model_object
265 def find_model_object
266 model = self.class.model_object
266 model = self.class.model_object
267 if model
267 if model
268 @object = model.find(params[:id])
268 @object = model.find(params[:id])
269 self.instance_variable_set('@' + controller_name.singularize, @object) if @object
269 self.instance_variable_set('@' + controller_name.singularize, @object) if @object
270 end
270 end
271 rescue ActiveRecord::RecordNotFound
271 rescue ActiveRecord::RecordNotFound
272 render_404
272 render_404
273 end
273 end
274
274
275 def self.model_object(model)
275 def self.model_object(model)
276 self.model_object = model
276 self.model_object = model
277 end
277 end
278
278
279 # Find the issue whose id is the :id parameter
279 # Find the issue whose id is the :id parameter
280 # Raises a Unauthorized exception if the issue is not visible
280 # Raises a Unauthorized exception if the issue is not visible
281 def find_issue
281 def find_issue
282 # Issue.visible.find(...) can not be used to redirect user to the login form
282 # Issue.visible.find(...) can not be used to redirect user to the login form
283 # if the issue actually exists but requires authentication
283 # if the issue actually exists but requires authentication
284 @issue = Issue.find(params[:id])
284 @issue = Issue.find(params[:id])
285 raise Unauthorized unless @issue.visible?
285 raise Unauthorized unless @issue.visible?
286 @project = @issue.project
286 @project = @issue.project
287 rescue ActiveRecord::RecordNotFound
287 rescue ActiveRecord::RecordNotFound
288 render_404
288 render_404
289 end
289 end
290
290
291 # Find issues with a single :id param or :ids array param
291 # Find issues with a single :id param or :ids array param
292 # Raises a Unauthorized exception if one of the issues is not visible
292 # Raises a Unauthorized exception if one of the issues is not visible
293 def find_issues
293 def find_issues
294 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
294 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
295 raise ActiveRecord::RecordNotFound if @issues.empty?
295 raise ActiveRecord::RecordNotFound if @issues.empty?
296 raise Unauthorized unless @issues.all?(&:visible?)
296 raise Unauthorized unless @issues.all?(&:visible?)
297 @projects = @issues.collect(&:project).compact.uniq
297 @projects = @issues.collect(&:project).compact.uniq
298 @project = @projects.first if @projects.size == 1
298 @project = @projects.first if @projects.size == 1
299 rescue ActiveRecord::RecordNotFound
299 rescue ActiveRecord::RecordNotFound
300 render_404
300 render_404
301 end
301 end
302
302
303 def find_attachments
304 if (attachments = params[:attachments]).present?
305 att = attachments.values.collect do |attachment|
306 Attachment.find_by_token( attachment[:token] ) if attachment[:token].present?
307 end
308 att.compact!
309 end
310 @attachments = att || []
311 end
312
303 # make sure that the user is a member of the project (or admin) if project is private
313 # make sure that the user is a member of the project (or admin) if project is private
304 # used as a before_filter for actions that do not require any particular permission on the project
314 # used as a before_filter for actions that do not require any particular permission on the project
305 def check_project_privacy
315 def check_project_privacy
306 if @project && !@project.archived?
316 if @project && !@project.archived?
307 if @project.visible?
317 if @project.visible?
308 true
318 true
309 else
319 else
310 deny_access
320 deny_access
311 end
321 end
312 else
322 else
313 @project = nil
323 @project = nil
314 render_404
324 render_404
315 false
325 false
316 end
326 end
317 end
327 end
318
328
319 def back_url
329 def back_url
320 url = params[:back_url]
330 url = params[:back_url]
321 if url.nil? && referer = request.env['HTTP_REFERER']
331 if url.nil? && referer = request.env['HTTP_REFERER']
322 url = CGI.unescape(referer.to_s)
332 url = CGI.unescape(referer.to_s)
323 end
333 end
324 url
334 url
325 end
335 end
326
336
327 def redirect_back_or_default(default)
337 def redirect_back_or_default(default)
328 back_url = params[:back_url].to_s
338 back_url = params[:back_url].to_s
329 if back_url.present?
339 if back_url.present?
330 begin
340 begin
331 uri = URI.parse(back_url)
341 uri = URI.parse(back_url)
332 # do not redirect user to another host or to the login or register page
342 # do not redirect user to another host or to the login or register page
333 if (uri.relative? || (uri.host == request.host)) && !uri.path.match(%r{/(login|account/register)})
343 if (uri.relative? || (uri.host == request.host)) && !uri.path.match(%r{/(login|account/register)})
334 redirect_to(back_url)
344 redirect_to(back_url)
335 return
345 return
336 end
346 end
337 rescue URI::InvalidURIError
347 rescue URI::InvalidURIError
338 logger.warn("Could not redirect to invalid URL #{back_url}")
348 logger.warn("Could not redirect to invalid URL #{back_url}")
339 # redirect to default
349 # redirect to default
340 end
350 end
341 end
351 end
342 redirect_to default
352 redirect_to default
343 false
353 false
344 end
354 end
345
355
346 # Redirects to the request referer if present, redirects to args or call block otherwise.
356 # Redirects to the request referer if present, redirects to args or call block otherwise.
347 def redirect_to_referer_or(*args, &block)
357 def redirect_to_referer_or(*args, &block)
348 redirect_to :back
358 redirect_to :back
349 rescue ::ActionController::RedirectBackError
359 rescue ::ActionController::RedirectBackError
350 if args.any?
360 if args.any?
351 redirect_to *args
361 redirect_to *args
352 elsif block_given?
362 elsif block_given?
353 block.call
363 block.call
354 else
364 else
355 raise "#redirect_to_referer_or takes arguments or a block"
365 raise "#redirect_to_referer_or takes arguments or a block"
356 end
366 end
357 end
367 end
358
368
359 def render_403(options={})
369 def render_403(options={})
360 @project = nil
370 @project = nil
361 render_error({:message => :notice_not_authorized, :status => 403}.merge(options))
371 render_error({:message => :notice_not_authorized, :status => 403}.merge(options))
362 return false
372 return false
363 end
373 end
364
374
365 def render_404(options={})
375 def render_404(options={})
366 render_error({:message => :notice_file_not_found, :status => 404}.merge(options))
376 render_error({:message => :notice_file_not_found, :status => 404}.merge(options))
367 return false
377 return false
368 end
378 end
369
379
370 # Renders an error response
380 # Renders an error response
371 def render_error(arg)
381 def render_error(arg)
372 arg = {:message => arg} unless arg.is_a?(Hash)
382 arg = {:message => arg} unless arg.is_a?(Hash)
373
383
374 @message = arg[:message]
384 @message = arg[:message]
375 @message = l(@message) if @message.is_a?(Symbol)
385 @message = l(@message) if @message.is_a?(Symbol)
376 @status = arg[:status] || 500
386 @status = arg[:status] || 500
377
387
378 respond_to do |format|
388 respond_to do |format|
379 format.html {
389 format.html {
380 render :template => 'common/error', :layout => use_layout, :status => @status
390 render :template => 'common/error', :layout => use_layout, :status => @status
381 }
391 }
382 format.any { head @status }
392 format.any { head @status }
383 end
393 end
384 end
394 end
385
395
386 # Handler for ActionView::MissingTemplate exception
396 # Handler for ActionView::MissingTemplate exception
387 def missing_template
397 def missing_template
388 logger.warn "Missing template, responding with 404"
398 logger.warn "Missing template, responding with 404"
389 @project = nil
399 @project = nil
390 render_404
400 render_404
391 end
401 end
392
402
393 # Filter for actions that provide an API response
403 # Filter for actions that provide an API response
394 # but have no HTML representation for non admin users
404 # but have no HTML representation for non admin users
395 def require_admin_or_api_request
405 def require_admin_or_api_request
396 return true if api_request?
406 return true if api_request?
397 if User.current.admin?
407 if User.current.admin?
398 true
408 true
399 elsif User.current.logged?
409 elsif User.current.logged?
400 render_error(:status => 406)
410 render_error(:status => 406)
401 else
411 else
402 deny_access
412 deny_access
403 end
413 end
404 end
414 end
405
415
406 # Picks which layout to use based on the request
416 # Picks which layout to use based on the request
407 #
417 #
408 # @return [boolean, string] name of the layout to use or false for no layout
418 # @return [boolean, string] name of the layout to use or false for no layout
409 def use_layout
419 def use_layout
410 request.xhr? ? false : 'base'
420 request.xhr? ? false : 'base'
411 end
421 end
412
422
413 def invalid_authenticity_token
423 def invalid_authenticity_token
414 if api_request?
424 if api_request?
415 logger.error "Form authenticity token is missing or is invalid. API calls must include a proper Content-type header (text/xml or text/json)."
425 logger.error "Form authenticity token is missing or is invalid. API calls must include a proper Content-type header (text/xml or text/json)."
416 end
426 end
417 render_error "Invalid form authenticity token."
427 render_error "Invalid form authenticity token."
418 end
428 end
419
429
420 def render_feed(items, options={})
430 def render_feed(items, options={})
421 @items = items || []
431 @items = items || []
422 @items.sort! {|x,y| y.event_datetime <=> x.event_datetime }
432 @items.sort! {|x,y| y.event_datetime <=> x.event_datetime }
423 @items = @items.slice(0, Setting.feeds_limit.to_i)
433 @items = @items.slice(0, Setting.feeds_limit.to_i)
424 @title = options[:title] || Setting.app_title
434 @title = options[:title] || Setting.app_title
425 render :template => "common/feed", :formats => [:atom], :layout => false,
435 render :template => "common/feed", :formats => [:atom], :layout => false,
426 :content_type => 'application/atom+xml'
436 :content_type => 'application/atom+xml'
427 end
437 end
428
438
429 def self.accept_rss_auth(*actions)
439 def self.accept_rss_auth(*actions)
430 if actions.any?
440 if actions.any?
431 self.accept_rss_auth_actions = actions
441 self.accept_rss_auth_actions = actions
432 else
442 else
433 self.accept_rss_auth_actions || []
443 self.accept_rss_auth_actions || []
434 end
444 end
435 end
445 end
436
446
437 def accept_rss_auth?(action=action_name)
447 def accept_rss_auth?(action=action_name)
438 self.class.accept_rss_auth.include?(action.to_sym)
448 self.class.accept_rss_auth.include?(action.to_sym)
439 end
449 end
440
450
441 def self.accept_api_auth(*actions)
451 def self.accept_api_auth(*actions)
442 if actions.any?
452 if actions.any?
443 self.accept_api_auth_actions = actions
453 self.accept_api_auth_actions = actions
444 else
454 else
445 self.accept_api_auth_actions || []
455 self.accept_api_auth_actions || []
446 end
456 end
447 end
457 end
448
458
449 def accept_api_auth?(action=action_name)
459 def accept_api_auth?(action=action_name)
450 self.class.accept_api_auth.include?(action.to_sym)
460 self.class.accept_api_auth.include?(action.to_sym)
451 end
461 end
452
462
453 # Returns the number of objects that should be displayed
463 # Returns the number of objects that should be displayed
454 # on the paginated list
464 # on the paginated list
455 def per_page_option
465 def per_page_option
456 per_page = nil
466 per_page = nil
457 if params[:per_page] && Setting.per_page_options_array.include?(params[:per_page].to_s.to_i)
467 if params[:per_page] && Setting.per_page_options_array.include?(params[:per_page].to_s.to_i)
458 per_page = params[:per_page].to_s.to_i
468 per_page = params[:per_page].to_s.to_i
459 session[:per_page] = per_page
469 session[:per_page] = per_page
460 elsif session[:per_page]
470 elsif session[:per_page]
461 per_page = session[:per_page]
471 per_page = session[:per_page]
462 else
472 else
463 per_page = Setting.per_page_options_array.first || 25
473 per_page = Setting.per_page_options_array.first || 25
464 end
474 end
465 per_page
475 per_page
466 end
476 end
467
477
468 # Returns offset and limit used to retrieve objects
478 # Returns offset and limit used to retrieve objects
469 # for an API response based on offset, limit and page parameters
479 # for an API response based on offset, limit and page parameters
470 def api_offset_and_limit(options=params)
480 def api_offset_and_limit(options=params)
471 if options[:offset].present?
481 if options[:offset].present?
472 offset = options[:offset].to_i
482 offset = options[:offset].to_i
473 if offset < 0
483 if offset < 0
474 offset = 0
484 offset = 0
475 end
485 end
476 end
486 end
477 limit = options[:limit].to_i
487 limit = options[:limit].to_i
478 if limit < 1
488 if limit < 1
479 limit = 25
489 limit = 25
480 elsif limit > 100
490 elsif limit > 100
481 limit = 100
491 limit = 100
482 end
492 end
483 if offset.nil? && options[:page].present?
493 if offset.nil? && options[:page].present?
484 offset = (options[:page].to_i - 1) * limit
494 offset = (options[:page].to_i - 1) * limit
485 offset = 0 if offset < 0
495 offset = 0 if offset < 0
486 end
496 end
487 offset ||= 0
497 offset ||= 0
488
498
489 [offset, limit]
499 [offset, limit]
490 end
500 end
491
501
492 # qvalues http header parser
502 # qvalues http header parser
493 # code taken from webrick
503 # code taken from webrick
494 def parse_qvalues(value)
504 def parse_qvalues(value)
495 tmp = []
505 tmp = []
496 if value
506 if value
497 parts = value.split(/,\s*/)
507 parts = value.split(/,\s*/)
498 parts.each {|part|
508 parts.each {|part|
499 if m = %r{^([^\s,]+?)(?:;\s*q=(\d+(?:\.\d+)?))?$}.match(part)
509 if m = %r{^([^\s,]+?)(?:;\s*q=(\d+(?:\.\d+)?))?$}.match(part)
500 val = m[1]
510 val = m[1]
501 q = (m[2] or 1).to_f
511 q = (m[2] or 1).to_f
502 tmp.push([val, q])
512 tmp.push([val, q])
503 end
513 end
504 }
514 }
505 tmp = tmp.sort_by{|val, q| -q}
515 tmp = tmp.sort_by{|val, q| -q}
506 tmp.collect!{|val, q| val}
516 tmp.collect!{|val, q| val}
507 end
517 end
508 return tmp
518 return tmp
509 rescue
519 rescue
510 nil
520 nil
511 end
521 end
512
522
513 # Returns a string that can be used as filename value in Content-Disposition header
523 # Returns a string that can be used as filename value in Content-Disposition header
514 def filename_for_content_disposition(name)
524 def filename_for_content_disposition(name)
515 request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(name) : name
525 request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(name) : name
516 end
526 end
517
527
518 def api_request?
528 def api_request?
519 %w(xml json).include? params[:format]
529 %w(xml json).include? params[:format]
520 end
530 end
521
531
522 # Returns the API key present in the request
532 # Returns the API key present in the request
523 def api_key_from_request
533 def api_key_from_request
524 if params[:key].present?
534 if params[:key].present?
525 params[:key].to_s
535 params[:key].to_s
526 elsif request.headers["X-Redmine-API-Key"].present?
536 elsif request.headers["X-Redmine-API-Key"].present?
527 request.headers["X-Redmine-API-Key"].to_s
537 request.headers["X-Redmine-API-Key"].to_s
528 end
538 end
529 end
539 end
530
540
531 # Returns the API 'switch user' value if present
541 # Returns the API 'switch user' value if present
532 def api_switch_user_from_request
542 def api_switch_user_from_request
533 request.headers["X-Redmine-Switch-User"].to_s.presence
543 request.headers["X-Redmine-Switch-User"].to_s.presence
534 end
544 end
535
545
536 # Renders a warning flash if obj has unsaved attachments
546 # Renders a warning flash if obj has unsaved attachments
537 def render_attachment_warning_if_needed(obj)
547 def render_attachment_warning_if_needed(obj)
538 flash[:warning] = l(:warning_attachments_not_saved, obj.unsaved_attachments.size) if obj.unsaved_attachments.present?
548 flash[:warning] = l(:warning_attachments_not_saved, obj.unsaved_attachments.size) if obj.unsaved_attachments.present?
539 end
549 end
540
550
541 # Sets the `flash` notice or error based the number of issues that did not save
551 # Sets the `flash` notice or error based the number of issues that did not save
542 #
552 #
543 # @param [Array, Issue] issues all of the saved and unsaved Issues
553 # @param [Array, Issue] issues all of the saved and unsaved Issues
544 # @param [Array, Integer] unsaved_issue_ids the issue ids that were not saved
554 # @param [Array, Integer] unsaved_issue_ids the issue ids that were not saved
545 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
555 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
546 if unsaved_issue_ids.empty?
556 if unsaved_issue_ids.empty?
547 flash[:notice] = l(:notice_successful_update) unless issues.empty?
557 flash[:notice] = l(:notice_successful_update) unless issues.empty?
548 else
558 else
549 flash[:error] = l(:notice_failed_to_save_issues,
559 flash[:error] = l(:notice_failed_to_save_issues,
550 :count => unsaved_issue_ids.size,
560 :count => unsaved_issue_ids.size,
551 :total => issues.size,
561 :total => issues.size,
552 :ids => '#' + unsaved_issue_ids.join(', #'))
562 :ids => '#' + unsaved_issue_ids.join(', #'))
553 end
563 end
554 end
564 end
555
565
556 # Rescues an invalid query statement. Just in case...
566 # Rescues an invalid query statement. Just in case...
557 def query_statement_invalid(exception)
567 def query_statement_invalid(exception)
558 logger.error "Query::StatementInvalid: #{exception.message}" if logger
568 logger.error "Query::StatementInvalid: #{exception.message}" if logger
559 session.delete(:query)
569 session.delete(:query)
560 sort_clear if respond_to?(:sort_clear)
570 sort_clear if respond_to?(:sort_clear)
561 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
571 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
562 end
572 end
563
573
564 # Renders a 200 response for successfull updates or deletions via the API
574 # Renders a 200 response for successfull updates or deletions via the API
565 def render_api_ok
575 def render_api_ok
566 render_api_head :ok
576 render_api_head :ok
567 end
577 end
568
578
569 # Renders a head API response
579 # Renders a head API response
570 def render_api_head(status)
580 def render_api_head(status)
571 # #head would return a response body with one space
581 # #head would return a response body with one space
572 render :text => '', :status => status, :layout => nil
582 render :text => '', :status => status, :layout => nil
573 end
583 end
574
584
575 # Renders API response on validation failure
585 # Renders API response on validation failure
576 def render_validation_errors(objects)
586 def render_validation_errors(objects)
577 if objects.is_a?(Array)
587 if objects.is_a?(Array)
578 @error_messages = objects.map {|object| object.errors.full_messages}.flatten
588 @error_messages = objects.map {|object| object.errors.full_messages}.flatten
579 else
589 else
580 @error_messages = objects.errors.full_messages
590 @error_messages = objects.errors.full_messages
581 end
591 end
582 render :template => 'common/error_messages.api', :status => :unprocessable_entity, :layout => nil
592 render :template => 'common/error_messages.api', :status => :unprocessable_entity, :layout => nil
583 end
593 end
584
594
585 # Overrides #_include_layout? so that #render with no arguments
595 # Overrides #_include_layout? so that #render with no arguments
586 # doesn't use the layout for api requests
596 # doesn't use the layout for api requests
587 def _include_layout?(*args)
597 def _include_layout?(*args)
588 api_request? ? false : super
598 api_request? ? false : super
589 end
599 end
590 end
600 end
@@ -1,139 +1,149
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class AttachmentsController < ApplicationController
18 class AttachmentsController < ApplicationController
19 before_filter :find_project, :except => :upload
19 before_filter :find_project, :except => :upload
20 before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
20 before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
21 before_filter :delete_authorize, :only => :destroy
21 before_filter :delete_authorize, :only => :destroy
22 before_filter :authorize_global, :only => :upload
22 before_filter :authorize_global, :only => :upload
23
23
24 accept_api_auth :show, :download, :upload
24 accept_api_auth :show, :download, :upload
25
25
26 def show
26 def show
27 respond_to do |format|
27 respond_to do |format|
28 format.html {
28 format.html {
29 if @attachment.is_diff?
29 if @attachment.is_diff?
30 @diff = File.new(@attachment.diskfile, "rb").read
30 @diff = File.new(@attachment.diskfile, "rb").read
31 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
31 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
32 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
32 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
33 # Save diff type as user preference
33 # Save diff type as user preference
34 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
34 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
35 User.current.pref[:diff_type] = @diff_type
35 User.current.pref[:diff_type] = @diff_type
36 User.current.preference.save
36 User.current.preference.save
37 end
37 end
38 render :action => 'diff'
38 render :action => 'diff'
39 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
39 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
40 @content = File.new(@attachment.diskfile, "rb").read
40 @content = File.new(@attachment.diskfile, "rb").read
41 render :action => 'file'
41 render :action => 'file'
42 else
42 else
43 download
43 download
44 end
44 end
45 }
45 }
46 format.api
46 format.api
47 end
47 end
48 end
48 end
49
49
50 def download
50 def download
51 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
51 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
52 @attachment.increment_download
52 @attachment.increment_download
53 end
53 end
54
54
55 if stale?(:etag => @attachment.digest)
55 if stale?(:etag => @attachment.digest)
56 # images are sent inline
56 # images are sent inline
57 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
57 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
58 :type => detect_content_type(@attachment),
58 :type => detect_content_type(@attachment),
59 :disposition => (@attachment.image? ? 'inline' : 'attachment')
59 :disposition => (@attachment.image? ? 'inline' : 'attachment')
60 end
60 end
61 end
61 end
62
62
63 def thumbnail
63 def thumbnail
64 if @attachment.thumbnailable? && thumbnail = @attachment.thumbnail(:size => params[:size])
64 if @attachment.thumbnailable? && thumbnail = @attachment.thumbnail(:size => params[:size])
65 if stale?(:etag => thumbnail)
65 if stale?(:etag => thumbnail)
66 send_file thumbnail,
66 send_file thumbnail,
67 :filename => filename_for_content_disposition(@attachment.filename),
67 :filename => filename_for_content_disposition(@attachment.filename),
68 :type => detect_content_type(@attachment),
68 :type => detect_content_type(@attachment),
69 :disposition => 'inline'
69 :disposition => 'inline'
70 end
70 end
71 else
71 else
72 # No thumbnail for the attachment or thumbnail could not be created
72 # No thumbnail for the attachment or thumbnail could not be created
73 render :nothing => true, :status => 404
73 render :nothing => true, :status => 404
74 end
74 end
75 end
75 end
76
76
77 def upload
77 def upload
78 # Make sure that API users get used to set this content type
78 # Make sure that API users get used to set this content type
79 # as it won't trigger Rails' automatic parsing of the request body for parameters
79 # as it won't trigger Rails' automatic parsing of the request body for parameters
80 unless request.content_type == 'application/octet-stream'
80 unless request.content_type == 'application/octet-stream'
81 render :nothing => true, :status => 406
81 render :nothing => true, :status => 406
82 return
82 return
83 end
83 end
84
84
85 @attachment = Attachment.new(:file => request.raw_post)
85 @attachment = Attachment.new(:file => request.raw_post)
86 @attachment.author = User.current
86 @attachment.author = User.current
87 @attachment.filename = params[:filename].presence || Redmine::Utils.random_hex(16)
87 @attachment.filename = params[:filename].presence || Redmine::Utils.random_hex(16)
88 saved = @attachment.save
88
89
89 if @attachment.save
90 respond_to do |format|
90 respond_to do |format|
91 format.api { render :action => 'upload', :status => :created }
91 format.js
92 end
92 format.api {
93 if saved
94 render :action => 'upload', :status => :created
93 else
95 else
94 respond_to do |format|
96 render_validation_errors(@attachment)
95 format.api { render_validation_errors(@attachment) }
96 end
97 end
98 }
97 end
99 end
98 end
100 end
99
101
100 def destroy
102 def destroy
101 if @attachment.container.respond_to?(:init_journal)
103 if @attachment.container.respond_to?(:init_journal)
102 @attachment.container.init_journal(User.current)
104 @attachment.container.init_journal(User.current)
103 end
105 end
106 if @attachment.container
104 # Make sure association callbacks are called
107 # Make sure association callbacks are called
105 @attachment.container.attachments.delete(@attachment)
108 @attachment.container.attachments.delete(@attachment)
106 redirect_to_referer_or project_path(@project)
109 else
110 @attachment.destroy
111 end
112
113 respond_to do |format|
114 format.html { redirect_to_referer_or project_path(@project) }
115 format.js
116 end
107 end
117 end
108
118
109 private
119 private
110 def find_project
120 def find_project
111 @attachment = Attachment.find(params[:id])
121 @attachment = Attachment.find(params[:id])
112 # Show 404 if the filename in the url is wrong
122 # Show 404 if the filename in the url is wrong
113 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
123 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
114 @project = @attachment.project
124 @project = @attachment.project
115 rescue ActiveRecord::RecordNotFound
125 rescue ActiveRecord::RecordNotFound
116 render_404
126 render_404
117 end
127 end
118
128
119 # Checks that the file exists and is readable
129 # Checks that the file exists and is readable
120 def file_readable
130 def file_readable
121 @attachment.readable? ? true : render_404
131 @attachment.readable? ? true : render_404
122 end
132 end
123
133
124 def read_authorize
134 def read_authorize
125 @attachment.visible? ? true : deny_access
135 @attachment.visible? ? true : deny_access
126 end
136 end
127
137
128 def delete_authorize
138 def delete_authorize
129 @attachment.deletable? ? true : deny_access
139 @attachment.deletable? ? true : deny_access
130 end
140 end
131
141
132 def detect_content_type(attachment)
142 def detect_content_type(attachment)
133 content_type = attachment.content_type
143 content_type = attachment.content_type
134 if content_type.blank?
144 if content_type.blank?
135 content_type = Redmine::MimeType.of(attachment.filename)
145 content_type = Redmine::MimeType.of(attachment.filename)
136 end
146 end
137 content_type.to_s
147 content_type.to_s
138 end
148 end
139 end
149 end
@@ -1,141 +1,141
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class MessagesController < ApplicationController
18 class MessagesController < ApplicationController
19 menu_item :boards
19 menu_item :boards
20 default_search_scope :messages
20 default_search_scope :messages
21 before_filter :find_board, :only => [:new, :preview]
21 before_filter :find_board, :only => [:new, :preview]
22 before_filter :find_attachments, :only => [:preview]
22 before_filter :find_message, :except => [:new, :preview]
23 before_filter :find_message, :except => [:new, :preview]
23 before_filter :authorize, :except => [:preview, :edit, :destroy]
24 before_filter :authorize, :except => [:preview, :edit, :destroy]
24
25
25 helper :boards
26 helper :boards
26 helper :watchers
27 helper :watchers
27 helper :attachments
28 helper :attachments
28 include AttachmentsHelper
29 include AttachmentsHelper
29
30
30 REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE)
31 REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE)
31
32
32 # Show a topic and its replies
33 # Show a topic and its replies
33 def show
34 def show
34 page = params[:page]
35 page = params[:page]
35 # Find the page of the requested reply
36 # Find the page of the requested reply
36 if params[:r] && page.nil?
37 if params[:r] && page.nil?
37 offset = @topic.children.count(:conditions => ["#{Message.table_name}.id < ?", params[:r].to_i])
38 offset = @topic.children.count(:conditions => ["#{Message.table_name}.id < ?", params[:r].to_i])
38 page = 1 + offset / REPLIES_PER_PAGE
39 page = 1 + offset / REPLIES_PER_PAGE
39 end
40 end
40
41
41 @reply_count = @topic.children.count
42 @reply_count = @topic.children.count
42 @reply_pages = Paginator.new self, @reply_count, REPLIES_PER_PAGE, page
43 @reply_pages = Paginator.new self, @reply_count, REPLIES_PER_PAGE, page
43 @replies = @topic.children.
44 @replies = @topic.children.
44 includes(:author, :attachments, {:board => :project}).
45 includes(:author, :attachments, {:board => :project}).
45 reorder("#{Message.table_name}.created_on ASC").
46 reorder("#{Message.table_name}.created_on ASC").
46 limit(@reply_pages.items_per_page).
47 limit(@reply_pages.items_per_page).
47 offset(@reply_pages.current.offset).
48 offset(@reply_pages.current.offset).
48 all
49 all
49
50
50 @reply = Message.new(:subject => "RE: #{@message.subject}")
51 @reply = Message.new(:subject => "RE: #{@message.subject}")
51 render :action => "show", :layout => false if request.xhr?
52 render :action => "show", :layout => false if request.xhr?
52 end
53 end
53
54
54 # Create a new topic
55 # Create a new topic
55 def new
56 def new
56 @message = Message.new
57 @message = Message.new
57 @message.author = User.current
58 @message.author = User.current
58 @message.board = @board
59 @message.board = @board
59 @message.safe_attributes = params[:message]
60 @message.safe_attributes = params[:message]
60 if request.post?
61 if request.post?
61 @message.save_attachments(params[:attachments])
62 @message.save_attachments(params[:attachments])
62 if @message.save
63 if @message.save
63 call_hook(:controller_messages_new_after_save, { :params => params, :message => @message})
64 call_hook(:controller_messages_new_after_save, { :params => params, :message => @message})
64 render_attachment_warning_if_needed(@message)
65 render_attachment_warning_if_needed(@message)
65 redirect_to board_message_path(@board, @message)
66 redirect_to board_message_path(@board, @message)
66 end
67 end
67 end
68 end
68 end
69 end
69
70
70 # Reply to a topic
71 # Reply to a topic
71 def reply
72 def reply
72 @reply = Message.new
73 @reply = Message.new
73 @reply.author = User.current
74 @reply.author = User.current
74 @reply.board = @board
75 @reply.board = @board
75 @reply.safe_attributes = params[:reply]
76 @reply.safe_attributes = params[:reply]
76 @topic.children << @reply
77 @topic.children << @reply
77 if !@reply.new_record?
78 if !@reply.new_record?
78 call_hook(:controller_messages_reply_after_save, { :params => params, :message => @reply})
79 call_hook(:controller_messages_reply_after_save, { :params => params, :message => @reply})
79 attachments = Attachment.attach_files(@reply, params[:attachments])
80 attachments = Attachment.attach_files(@reply, params[:attachments])
80 render_attachment_warning_if_needed(@reply)
81 render_attachment_warning_if_needed(@reply)
81 end
82 end
82 redirect_to board_message_path(@board, @topic, :r => @reply)
83 redirect_to board_message_path(@board, @topic, :r => @reply)
83 end
84 end
84
85
85 # Edit a message
86 # Edit a message
86 def edit
87 def edit
87 (render_403; return false) unless @message.editable_by?(User.current)
88 (render_403; return false) unless @message.editable_by?(User.current)
88 @message.safe_attributes = params[:message]
89 @message.safe_attributes = params[:message]
89 if request.post? && @message.save
90 if request.post? && @message.save
90 attachments = Attachment.attach_files(@message, params[:attachments])
91 attachments = Attachment.attach_files(@message, params[:attachments])
91 render_attachment_warning_if_needed(@message)
92 render_attachment_warning_if_needed(@message)
92 flash[:notice] = l(:notice_successful_update)
93 flash[:notice] = l(:notice_successful_update)
93 @message.reload
94 @message.reload
94 redirect_to board_message_path(@message.board, @message.root, :r => (@message.parent_id && @message.id))
95 redirect_to board_message_path(@message.board, @message.root, :r => (@message.parent_id && @message.id))
95 end
96 end
96 end
97 end
97
98
98 # Delete a messages
99 # Delete a messages
99 def destroy
100 def destroy
100 (render_403; return false) unless @message.destroyable_by?(User.current)
101 (render_403; return false) unless @message.destroyable_by?(User.current)
101 r = @message.to_param
102 r = @message.to_param
102 @message.destroy
103 @message.destroy
103 if @message.parent
104 if @message.parent
104 redirect_to board_message_path(@board, @message.parent, :r => r)
105 redirect_to board_message_path(@board, @message.parent, :r => r)
105 else
106 else
106 redirect_to project_board_path(@project, @board)
107 redirect_to project_board_path(@project, @board)
107 end
108 end
108 end
109 end
109
110
110 def quote
111 def quote
111 @subject = @message.subject
112 @subject = @message.subject
112 @subject = "RE: #{@subject}" unless @subject.starts_with?('RE:')
113 @subject = "RE: #{@subject}" unless @subject.starts_with?('RE:')
113
114
114 @content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> "
115 @content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> "
115 @content << @message.content.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
116 @content << @message.content.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
116 end
117 end
117
118
118 def preview
119 def preview
119 message = @board.messages.find_by_id(params[:id])
120 message = @board.messages.find_by_id(params[:id])
120 @attachements = message.attachments if message
121 @text = (params[:message] || params[:reply])[:content]
121 @text = (params[:message] || params[:reply])[:content]
122 @previewed = message
122 @previewed = message
123 render :partial => 'common/preview'
123 render :partial => 'common/preview'
124 end
124 end
125
125
126 private
126 private
127 def find_message
127 def find_message
128 find_board
128 find_board
129 @message = @board.messages.find(params[:id], :include => :parent)
129 @message = @board.messages.find(params[:id], :include => :parent)
130 @topic = @message.root
130 @topic = @message.root
131 rescue ActiveRecord::RecordNotFound
131 rescue ActiveRecord::RecordNotFound
132 render_404
132 render_404
133 end
133 end
134
134
135 def find_board
135 def find_board
136 @board = Board.find(params[:board_id], :include => :project)
136 @board = Board.find(params[:board_id], :include => :project)
137 @project = @board.project
137 @project = @board.project
138 rescue ActiveRecord::RecordNotFound
138 rescue ActiveRecord::RecordNotFound
139 render_404
139 render_404
140 end
140 end
141 end
141 end
@@ -1,55 +1,53
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class PreviewsController < ApplicationController
18 class PreviewsController < ApplicationController
19 before_filter :find_project
19 before_filter :find_project, :find_attachments
20
20
21 def issue
21 def issue
22 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
22 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
23 if @issue
23 if @issue
24 @attachements = @issue.attachments
25 @description = params[:issue] && params[:issue][:description]
24 @description = params[:issue] && params[:issue][:description]
26 if @description && @description.gsub(/(\r?\n|\n\r?)/, "\n") == @issue.description.to_s.gsub(/(\r?\n|\n\r?)/, "\n")
25 if @description && @description.gsub(/(\r?\n|\n\r?)/, "\n") == @issue.description.to_s.gsub(/(\r?\n|\n\r?)/, "\n")
27 @description = nil
26 @description = nil
28 end
27 end
29 # params[:notes] is useful for preview of notes in issue history
28 # params[:notes] is useful for preview of notes in issue history
30 @notes = params[:notes] || (params[:issue] ? params[:issue][:notes] : nil)
29 @notes = params[:notes] || (params[:issue] ? params[:issue][:notes] : nil)
31 else
30 else
32 @description = (params[:issue] ? params[:issue][:description] : nil)
31 @description = (params[:issue] ? params[:issue][:description] : nil)
33 end
32 end
34 render :layout => false
33 render :layout => false
35 end
34 end
36
35
37 def news
36 def news
38 if params[:id].present? && news = News.visible.find_by_id(params[:id])
37 if params[:id].present? && news = News.visible.find_by_id(params[:id])
39 @previewed = news
38 @previewed = news
40 @attachments = news.attachments
41 end
39 end
42 @text = (params[:news] ? params[:news][:description] : nil)
40 @text = (params[:news] ? params[:news][:description] : nil)
43 render :partial => 'common/preview'
41 render :partial => 'common/preview'
44 end
42 end
45
43
46 private
44 private
47
45
48 def find_project
46 def find_project
49 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
47 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
50 @project = Project.find(project_id)
48 @project = Project.find(project_id)
51 rescue ActiveRecord::RecordNotFound
49 rescue ActiveRecord::RecordNotFound
52 render_404
50 render_404
53 end
51 end
54
52
55 end
53 end
@@ -1,355 +1,356
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'diff'
18 require 'diff'
19
19
20 # The WikiController follows the Rails REST controller pattern but with
20 # The WikiController follows the Rails REST controller pattern but with
21 # a few differences
21 # a few differences
22 #
22 #
23 # * index - shows a list of WikiPages grouped by page or date
23 # * index - shows a list of WikiPages grouped by page or date
24 # * new - not used
24 # * new - not used
25 # * create - not used
25 # * create - not used
26 # * show - will also show the form for creating a new wiki page
26 # * show - will also show the form for creating a new wiki page
27 # * edit - used to edit an existing or new page
27 # * edit - used to edit an existing or new page
28 # * update - used to save a wiki page update to the database, including new pages
28 # * update - used to save a wiki page update to the database, including new pages
29 # * destroy - normal
29 # * destroy - normal
30 #
30 #
31 # Other member and collection methods are also used
31 # Other member and collection methods are also used
32 #
32 #
33 # TODO: still being worked on
33 # TODO: still being worked on
34 class WikiController < ApplicationController
34 class WikiController < ApplicationController
35 default_search_scope :wiki_pages
35 default_search_scope :wiki_pages
36 before_filter :find_wiki, :authorize
36 before_filter :find_wiki, :authorize
37 before_filter :find_existing_or_new_page, :only => [:show, :edit, :update]
37 before_filter :find_existing_or_new_page, :only => [:show, :edit, :update]
38 before_filter :find_existing_page, :only => [:rename, :protect, :history, :diff, :annotate, :add_attachment, :destroy, :destroy_version]
38 before_filter :find_existing_page, :only => [:rename, :protect, :history, :diff, :annotate, :add_attachment, :destroy, :destroy_version]
39 accept_api_auth :index, :show, :update, :destroy
39 accept_api_auth :index, :show, :update, :destroy
40 before_filter :find_attachments, :only => [:preview]
40
41
41 helper :attachments
42 helper :attachments
42 include AttachmentsHelper
43 include AttachmentsHelper
43 helper :watchers
44 helper :watchers
44 include Redmine::Export::PDF
45 include Redmine::Export::PDF
45
46
46 # List of pages, sorted alphabetically and by parent (hierarchy)
47 # List of pages, sorted alphabetically and by parent (hierarchy)
47 def index
48 def index
48 load_pages_for_index
49 load_pages_for_index
49
50
50 respond_to do |format|
51 respond_to do |format|
51 format.html {
52 format.html {
52 @pages_by_parent_id = @pages.group_by(&:parent_id)
53 @pages_by_parent_id = @pages.group_by(&:parent_id)
53 }
54 }
54 format.api
55 format.api
55 end
56 end
56 end
57 end
57
58
58 # List of page, by last update
59 # List of page, by last update
59 def date_index
60 def date_index
60 load_pages_for_index
61 load_pages_for_index
61 @pages_by_date = @pages.group_by {|p| p.updated_on.to_date}
62 @pages_by_date = @pages.group_by {|p| p.updated_on.to_date}
62 end
63 end
63
64
64 # display a page (in editing mode if it doesn't exist)
65 # display a page (in editing mode if it doesn't exist)
65 def show
66 def show
66 if @page.new_record?
67 if @page.new_record?
67 if User.current.allowed_to?(:edit_wiki_pages, @project) && editable? && !api_request?
68 if User.current.allowed_to?(:edit_wiki_pages, @project) && editable? && !api_request?
68 edit
69 edit
69 render :action => 'edit'
70 render :action => 'edit'
70 else
71 else
71 render_404
72 render_404
72 end
73 end
73 return
74 return
74 end
75 end
75 if params[:version] && !User.current.allowed_to?(:view_wiki_edits, @project)
76 if params[:version] && !User.current.allowed_to?(:view_wiki_edits, @project)
76 deny_access
77 deny_access
77 return
78 return
78 end
79 end
79 @content = @page.content_for_version(params[:version])
80 @content = @page.content_for_version(params[:version])
80 if User.current.allowed_to?(:export_wiki_pages, @project)
81 if User.current.allowed_to?(:export_wiki_pages, @project)
81 if params[:format] == 'pdf'
82 if params[:format] == 'pdf'
82 send_data(wiki_page_to_pdf(@page, @project), :type => 'application/pdf', :filename => "#{@page.title}.pdf")
83 send_data(wiki_page_to_pdf(@page, @project), :type => 'application/pdf', :filename => "#{@page.title}.pdf")
83 return
84 return
84 elsif params[:format] == 'html'
85 elsif params[:format] == 'html'
85 export = render_to_string :action => 'export', :layout => false
86 export = render_to_string :action => 'export', :layout => false
86 send_data(export, :type => 'text/html', :filename => "#{@page.title}.html")
87 send_data(export, :type => 'text/html', :filename => "#{@page.title}.html")
87 return
88 return
88 elsif params[:format] == 'txt'
89 elsif params[:format] == 'txt'
89 send_data(@content.text, :type => 'text/plain', :filename => "#{@page.title}.txt")
90 send_data(@content.text, :type => 'text/plain', :filename => "#{@page.title}.txt")
90 return
91 return
91 end
92 end
92 end
93 end
93 @editable = editable?
94 @editable = editable?
94 @sections_editable = @editable && User.current.allowed_to?(:edit_wiki_pages, @page.project) &&
95 @sections_editable = @editable && User.current.allowed_to?(:edit_wiki_pages, @page.project) &&
95 @content.current_version? &&
96 @content.current_version? &&
96 Redmine::WikiFormatting.supports_section_edit?
97 Redmine::WikiFormatting.supports_section_edit?
97
98
98 respond_to do |format|
99 respond_to do |format|
99 format.html
100 format.html
100 format.api
101 format.api
101 end
102 end
102 end
103 end
103
104
104 # edit an existing page or a new one
105 # edit an existing page or a new one
105 def edit
106 def edit
106 return render_403 unless editable?
107 return render_403 unless editable?
107 if @page.new_record?
108 if @page.new_record?
108 @page.content = WikiContent.new(:page => @page)
109 @page.content = WikiContent.new(:page => @page)
109 if params[:parent].present?
110 if params[:parent].present?
110 @page.parent = @page.wiki.find_page(params[:parent].to_s)
111 @page.parent = @page.wiki.find_page(params[:parent].to_s)
111 end
112 end
112 end
113 end
113
114
114 @content = @page.content_for_version(params[:version])
115 @content = @page.content_for_version(params[:version])
115 @content.text = initial_page_content(@page) if @content.text.blank?
116 @content.text = initial_page_content(@page) if @content.text.blank?
116 # don't keep previous comment
117 # don't keep previous comment
117 @content.comments = nil
118 @content.comments = nil
118
119
119 # To prevent StaleObjectError exception when reverting to a previous version
120 # To prevent StaleObjectError exception when reverting to a previous version
120 @content.version = @page.content.version
121 @content.version = @page.content.version
121
122
122 @text = @content.text
123 @text = @content.text
123 if params[:section].present? && Redmine::WikiFormatting.supports_section_edit?
124 if params[:section].present? && Redmine::WikiFormatting.supports_section_edit?
124 @section = params[:section].to_i
125 @section = params[:section].to_i
125 @text, @section_hash = Redmine::WikiFormatting.formatter.new(@text).get_section(@section)
126 @text, @section_hash = Redmine::WikiFormatting.formatter.new(@text).get_section(@section)
126 render_404 if @text.blank?
127 render_404 if @text.blank?
127 end
128 end
128 end
129 end
129
130
130 # Creates a new page or updates an existing one
131 # Creates a new page or updates an existing one
131 def update
132 def update
132 return render_403 unless editable?
133 return render_403 unless editable?
133 was_new_page = @page.new_record?
134 was_new_page = @page.new_record?
134 @page.content = WikiContent.new(:page => @page) if @page.new_record?
135 @page.content = WikiContent.new(:page => @page) if @page.new_record?
135 @page.safe_attributes = params[:wiki_page]
136 @page.safe_attributes = params[:wiki_page]
136
137
137 @content = @page.content
138 @content = @page.content
138 content_params = params[:content]
139 content_params = params[:content]
139 if content_params.nil? && params[:wiki_page].is_a?(Hash)
140 if content_params.nil? && params[:wiki_page].is_a?(Hash)
140 content_params = params[:wiki_page].slice(:text, :comments, :version)
141 content_params = params[:wiki_page].slice(:text, :comments, :version)
141 end
142 end
142 content_params ||= {}
143 content_params ||= {}
143
144
144 @content.comments = content_params[:comments]
145 @content.comments = content_params[:comments]
145 @text = content_params[:text]
146 @text = content_params[:text]
146 if params[:section].present? && Redmine::WikiFormatting.supports_section_edit?
147 if params[:section].present? && Redmine::WikiFormatting.supports_section_edit?
147 @section = params[:section].to_i
148 @section = params[:section].to_i
148 @section_hash = params[:section_hash]
149 @section_hash = params[:section_hash]
149 @content.text = Redmine::WikiFormatting.formatter.new(@content.text).update_section(params[:section].to_i, @text, @section_hash)
150 @content.text = Redmine::WikiFormatting.formatter.new(@content.text).update_section(params[:section].to_i, @text, @section_hash)
150 else
151 else
151 @content.version = content_params[:version] if content_params[:version]
152 @content.version = content_params[:version] if content_params[:version]
152 @content.text = @text
153 @content.text = @text
153 end
154 end
154 @content.author = User.current
155 @content.author = User.current
155
156
156 if @page.save_with_content
157 if @page.save_with_content
157 attachments = Attachment.attach_files(@page, params[:attachments])
158 attachments = Attachment.attach_files(@page, params[:attachments])
158 render_attachment_warning_if_needed(@page)
159 render_attachment_warning_if_needed(@page)
159 call_hook(:controller_wiki_edit_after_save, { :params => params, :page => @page})
160 call_hook(:controller_wiki_edit_after_save, { :params => params, :page => @page})
160
161
161 respond_to do |format|
162 respond_to do |format|
162 format.html { redirect_to :action => 'show', :project_id => @project, :id => @page.title }
163 format.html { redirect_to :action => 'show', :project_id => @project, :id => @page.title }
163 format.api {
164 format.api {
164 if was_new_page
165 if was_new_page
165 render :action => 'show', :status => :created, :location => url_for(:controller => 'wiki', :action => 'show', :project_id => @project, :id => @page.title)
166 render :action => 'show', :status => :created, :location => url_for(:controller => 'wiki', :action => 'show', :project_id => @project, :id => @page.title)
166 else
167 else
167 render_api_ok
168 render_api_ok
168 end
169 end
169 }
170 }
170 end
171 end
171 else
172 else
172 respond_to do |format|
173 respond_to do |format|
173 format.html { render :action => 'edit' }
174 format.html { render :action => 'edit' }
174 format.api { render_validation_errors(@content) }
175 format.api { render_validation_errors(@content) }
175 end
176 end
176 end
177 end
177
178
178 rescue ActiveRecord::StaleObjectError, Redmine::WikiFormatting::StaleSectionError
179 rescue ActiveRecord::StaleObjectError, Redmine::WikiFormatting::StaleSectionError
179 # Optimistic locking exception
180 # Optimistic locking exception
180 respond_to do |format|
181 respond_to do |format|
181 format.html {
182 format.html {
182 flash.now[:error] = l(:notice_locking_conflict)
183 flash.now[:error] = l(:notice_locking_conflict)
183 render :action => 'edit'
184 render :action => 'edit'
184 }
185 }
185 format.api { render_api_head :conflict }
186 format.api { render_api_head :conflict }
186 end
187 end
187 rescue ActiveRecord::RecordNotSaved
188 rescue ActiveRecord::RecordNotSaved
188 respond_to do |format|
189 respond_to do |format|
189 format.html { render :action => 'edit' }
190 format.html { render :action => 'edit' }
190 format.api { render_validation_errors(@content) }
191 format.api { render_validation_errors(@content) }
191 end
192 end
192 end
193 end
193
194
194 # rename a page
195 # rename a page
195 def rename
196 def rename
196 return render_403 unless editable?
197 return render_403 unless editable?
197 @page.redirect_existing_links = true
198 @page.redirect_existing_links = true
198 # used to display the *original* title if some AR validation errors occur
199 # used to display the *original* title if some AR validation errors occur
199 @original_title = @page.pretty_title
200 @original_title = @page.pretty_title
200 if request.post? && @page.update_attributes(params[:wiki_page])
201 if request.post? && @page.update_attributes(params[:wiki_page])
201 flash[:notice] = l(:notice_successful_update)
202 flash[:notice] = l(:notice_successful_update)
202 redirect_to :action => 'show', :project_id => @project, :id => @page.title
203 redirect_to :action => 'show', :project_id => @project, :id => @page.title
203 end
204 end
204 end
205 end
205
206
206 def protect
207 def protect
207 @page.update_attribute :protected, params[:protected]
208 @page.update_attribute :protected, params[:protected]
208 redirect_to :action => 'show', :project_id => @project, :id => @page.title
209 redirect_to :action => 'show', :project_id => @project, :id => @page.title
209 end
210 end
210
211
211 # show page history
212 # show page history
212 def history
213 def history
213 @version_count = @page.content.versions.count
214 @version_count = @page.content.versions.count
214 @version_pages = Paginator.new self, @version_count, per_page_option, params['page']
215 @version_pages = Paginator.new self, @version_count, per_page_option, params['page']
215 # don't load text
216 # don't load text
216 @versions = @page.content.versions.
217 @versions = @page.content.versions.
217 select("id, author_id, comments, updated_on, version").
218 select("id, author_id, comments, updated_on, version").
218 reorder('version DESC').
219 reorder('version DESC').
219 limit(@version_pages.items_per_page + 1).
220 limit(@version_pages.items_per_page + 1).
220 offset(@version_pages.current.offset).
221 offset(@version_pages.current.offset).
221 all
222 all
222
223
223 render :layout => false if request.xhr?
224 render :layout => false if request.xhr?
224 end
225 end
225
226
226 def diff
227 def diff
227 @diff = @page.diff(params[:version], params[:version_from])
228 @diff = @page.diff(params[:version], params[:version_from])
228 render_404 unless @diff
229 render_404 unless @diff
229 end
230 end
230
231
231 def annotate
232 def annotate
232 @annotate = @page.annotate(params[:version])
233 @annotate = @page.annotate(params[:version])
233 render_404 unless @annotate
234 render_404 unless @annotate
234 end
235 end
235
236
236 # Removes a wiki page and its history
237 # Removes a wiki page and its history
237 # Children can be either set as root pages, removed or reassigned to another parent page
238 # Children can be either set as root pages, removed or reassigned to another parent page
238 def destroy
239 def destroy
239 return render_403 unless editable?
240 return render_403 unless editable?
240
241
241 @descendants_count = @page.descendants.size
242 @descendants_count = @page.descendants.size
242 if @descendants_count > 0
243 if @descendants_count > 0
243 case params[:todo]
244 case params[:todo]
244 when 'nullify'
245 when 'nullify'
245 # Nothing to do
246 # Nothing to do
246 when 'destroy'
247 when 'destroy'
247 # Removes all its descendants
248 # Removes all its descendants
248 @page.descendants.each(&:destroy)
249 @page.descendants.each(&:destroy)
249 when 'reassign'
250 when 'reassign'
250 # Reassign children to another parent page
251 # Reassign children to another parent page
251 reassign_to = @wiki.pages.find_by_id(params[:reassign_to_id].to_i)
252 reassign_to = @wiki.pages.find_by_id(params[:reassign_to_id].to_i)
252 return unless reassign_to
253 return unless reassign_to
253 @page.children.each do |child|
254 @page.children.each do |child|
254 child.update_attribute(:parent, reassign_to)
255 child.update_attribute(:parent, reassign_to)
255 end
256 end
256 else
257 else
257 @reassignable_to = @wiki.pages - @page.self_and_descendants
258 @reassignable_to = @wiki.pages - @page.self_and_descendants
258 # display the destroy form if it's a user request
259 # display the destroy form if it's a user request
259 return unless api_request?
260 return unless api_request?
260 end
261 end
261 end
262 end
262 @page.destroy
263 @page.destroy
263 respond_to do |format|
264 respond_to do |format|
264 format.html { redirect_to :action => 'index', :project_id => @project }
265 format.html { redirect_to :action => 'index', :project_id => @project }
265 format.api { render_api_ok }
266 format.api { render_api_ok }
266 end
267 end
267 end
268 end
268
269
269 def destroy_version
270 def destroy_version
270 return render_403 unless editable?
271 return render_403 unless editable?
271
272
272 @content = @page.content_for_version(params[:version])
273 @content = @page.content_for_version(params[:version])
273 @content.destroy
274 @content.destroy
274 redirect_to_referer_or :action => 'history', :id => @page.title, :project_id => @project
275 redirect_to_referer_or :action => 'history', :id => @page.title, :project_id => @project
275 end
276 end
276
277
277 # Export wiki to a single pdf or html file
278 # Export wiki to a single pdf or html file
278 def export
279 def export
279 @pages = @wiki.pages.all(:order => 'title', :include => [:content, {:attachments => :author}])
280 @pages = @wiki.pages.all(:order => 'title', :include => [:content, {:attachments => :author}])
280 respond_to do |format|
281 respond_to do |format|
281 format.html {
282 format.html {
282 export = render_to_string :action => 'export_multiple', :layout => false
283 export = render_to_string :action => 'export_multiple', :layout => false
283 send_data(export, :type => 'text/html', :filename => "wiki.html")
284 send_data(export, :type => 'text/html', :filename => "wiki.html")
284 }
285 }
285 format.pdf {
286 format.pdf {
286 send_data(wiki_pages_to_pdf(@pages, @project), :type => 'application/pdf', :filename => "#{@project.identifier}.pdf")
287 send_data(wiki_pages_to_pdf(@pages, @project), :type => 'application/pdf', :filename => "#{@project.identifier}.pdf")
287 }
288 }
288 end
289 end
289 end
290 end
290
291
291 def preview
292 def preview
292 page = @wiki.find_page(params[:id])
293 page = @wiki.find_page(params[:id])
293 # page is nil when previewing a new page
294 # page is nil when previewing a new page
294 return render_403 unless page.nil? || editable?(page)
295 return render_403 unless page.nil? || editable?(page)
295 if page
296 if page
296 @attachements = page.attachments
297 @attachments += page.attachments
297 @previewed = page.content
298 @previewed = page.content
298 end
299 end
299 @text = params[:content][:text]
300 @text = params[:content][:text]
300 render :partial => 'common/preview'
301 render :partial => 'common/preview'
301 end
302 end
302
303
303 def add_attachment
304 def add_attachment
304 return render_403 unless editable?
305 return render_403 unless editable?
305 attachments = Attachment.attach_files(@page, params[:attachments])
306 attachments = Attachment.attach_files(@page, params[:attachments])
306 render_attachment_warning_if_needed(@page)
307 render_attachment_warning_if_needed(@page)
307 redirect_to :action => 'show', :id => @page.title, :project_id => @project
308 redirect_to :action => 'show', :id => @page.title, :project_id => @project
308 end
309 end
309
310
310 private
311 private
311
312
312 def find_wiki
313 def find_wiki
313 @project = Project.find(params[:project_id])
314 @project = Project.find(params[:project_id])
314 @wiki = @project.wiki
315 @wiki = @project.wiki
315 render_404 unless @wiki
316 render_404 unless @wiki
316 rescue ActiveRecord::RecordNotFound
317 rescue ActiveRecord::RecordNotFound
317 render_404
318 render_404
318 end
319 end
319
320
320 # Finds the requested page or a new page if it doesn't exist
321 # Finds the requested page or a new page if it doesn't exist
321 def find_existing_or_new_page
322 def find_existing_or_new_page
322 @page = @wiki.find_or_new_page(params[:id])
323 @page = @wiki.find_or_new_page(params[:id])
323 if @wiki.page_found_with_redirect?
324 if @wiki.page_found_with_redirect?
324 redirect_to params.update(:id => @page.title)
325 redirect_to params.update(:id => @page.title)
325 end
326 end
326 end
327 end
327
328
328 # Finds the requested page and returns a 404 error if it doesn't exist
329 # Finds the requested page and returns a 404 error if it doesn't exist
329 def find_existing_page
330 def find_existing_page
330 @page = @wiki.find_page(params[:id])
331 @page = @wiki.find_page(params[:id])
331 if @page.nil?
332 if @page.nil?
332 render_404
333 render_404
333 return
334 return
334 end
335 end
335 if @wiki.page_found_with_redirect?
336 if @wiki.page_found_with_redirect?
336 redirect_to params.update(:id => @page.title)
337 redirect_to params.update(:id => @page.title)
337 end
338 end
338 end
339 end
339
340
340 # Returns true if the current user is allowed to edit the page, otherwise false
341 # Returns true if the current user is allowed to edit the page, otherwise false
341 def editable?(page = @page)
342 def editable?(page = @page)
342 page.editable_by?(User.current)
343 page.editable_by?(User.current)
343 end
344 end
344
345
345 # Returns the default content of a new wiki page
346 # Returns the default content of a new wiki page
346 def initial_page_content(page)
347 def initial_page_content(page)
347 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
348 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
348 extend helper unless self.instance_of?(helper)
349 extend helper unless self.instance_of?(helper)
349 helper.instance_method(:initial_page_content).bind(self).call(page)
350 helper.instance_method(:initial_page_content).bind(self).call(page)
350 end
351 end
351
352
352 def load_pages_for_index
353 def load_pages_for_index
353 @pages = @wiki.pages.with_updated_on.order("#{WikiPage.table_name}.title").includes(:wiki => :project).includes(:parent).all
354 @pages = @wiki.pages.with_updated_on.order("#{WikiPage.table_name}.title").includes(:wiki => :project).includes(:parent).all
354 end
355 end
355 end
356 end
@@ -1,1285 +1,1286
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2012 Jean-Philippe Lang
4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 #
5 #
6 # This program is free software; you can redistribute it and/or
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
9 # of the License, or (at your option) any later version.
10 #
10 #
11 # This program is distributed in the hope that it will be useful,
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
14 # GNU General Public License for more details.
15 #
15 #
16 # You should have received a copy of the GNU General Public License
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19
19
20 require 'forwardable'
20 require 'forwardable'
21 require 'cgi'
21 require 'cgi'
22
22
23 module ApplicationHelper
23 module ApplicationHelper
24 include Redmine::WikiFormatting::Macros::Definitions
24 include Redmine::WikiFormatting::Macros::Definitions
25 include Redmine::I18n
25 include Redmine::I18n
26 include GravatarHelper::PublicMethods
26 include GravatarHelper::PublicMethods
27
27
28 extend Forwardable
28 extend Forwardable
29 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
29 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
30
30
31 # Return true if user is authorized for controller/action, otherwise false
31 # Return true if user is authorized for controller/action, otherwise false
32 def authorize_for(controller, action)
32 def authorize_for(controller, action)
33 User.current.allowed_to?({:controller => controller, :action => action}, @project)
33 User.current.allowed_to?({:controller => controller, :action => action}, @project)
34 end
34 end
35
35
36 # Display a link if user is authorized
36 # Display a link if user is authorized
37 #
37 #
38 # @param [String] name Anchor text (passed to link_to)
38 # @param [String] name Anchor text (passed to link_to)
39 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
39 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
40 # @param [optional, Hash] html_options Options passed to link_to
40 # @param [optional, Hash] html_options Options passed to link_to
41 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
41 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
42 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
42 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
43 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
43 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
44 end
44 end
45
45
46 # Displays a link to user's account page if active
46 # Displays a link to user's account page if active
47 def link_to_user(user, options={})
47 def link_to_user(user, options={})
48 if user.is_a?(User)
48 if user.is_a?(User)
49 name = h(user.name(options[:format]))
49 name = h(user.name(options[:format]))
50 if user.active? || (User.current.admin? && user.logged?)
50 if user.active? || (User.current.admin? && user.logged?)
51 link_to name, user_path(user), :class => user.css_classes
51 link_to name, user_path(user), :class => user.css_classes
52 else
52 else
53 name
53 name
54 end
54 end
55 else
55 else
56 h(user.to_s)
56 h(user.to_s)
57 end
57 end
58 end
58 end
59
59
60 # Displays a link to +issue+ with its subject.
60 # Displays a link to +issue+ with its subject.
61 # Examples:
61 # Examples:
62 #
62 #
63 # link_to_issue(issue) # => Defect #6: This is the subject
63 # link_to_issue(issue) # => Defect #6: This is the subject
64 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
64 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
65 # link_to_issue(issue, :subject => false) # => Defect #6
65 # link_to_issue(issue, :subject => false) # => Defect #6
66 # link_to_issue(issue, :project => true) # => Foo - Defect #6
66 # link_to_issue(issue, :project => true) # => Foo - Defect #6
67 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
67 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
68 #
68 #
69 def link_to_issue(issue, options={})
69 def link_to_issue(issue, options={})
70 title = nil
70 title = nil
71 subject = nil
71 subject = nil
72 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
72 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
73 if options[:subject] == false
73 if options[:subject] == false
74 title = truncate(issue.subject, :length => 60)
74 title = truncate(issue.subject, :length => 60)
75 else
75 else
76 subject = issue.subject
76 subject = issue.subject
77 if options[:truncate]
77 if options[:truncate]
78 subject = truncate(subject, :length => options[:truncate])
78 subject = truncate(subject, :length => options[:truncate])
79 end
79 end
80 end
80 end
81 s = link_to text, issue_path(issue), :class => issue.css_classes, :title => title
81 s = link_to text, issue_path(issue), :class => issue.css_classes, :title => title
82 s << h(": #{subject}") if subject
82 s << h(": #{subject}") if subject
83 s = h("#{issue.project} - ") + s if options[:project]
83 s = h("#{issue.project} - ") + s if options[:project]
84 s
84 s
85 end
85 end
86
86
87 # Generates a link to an attachment.
87 # Generates a link to an attachment.
88 # Options:
88 # Options:
89 # * :text - Link text (default to attachment filename)
89 # * :text - Link text (default to attachment filename)
90 # * :download - Force download (default: false)
90 # * :download - Force download (default: false)
91 def link_to_attachment(attachment, options={})
91 def link_to_attachment(attachment, options={})
92 text = options.delete(:text) || attachment.filename
92 text = options.delete(:text) || attachment.filename
93 action = options.delete(:download) ? 'download' : 'show'
93 action = options.delete(:download) ? 'download' : 'show'
94 opt_only_path = {}
94 opt_only_path = {}
95 opt_only_path[:only_path] = (options[:only_path] == false ? false : true)
95 opt_only_path[:only_path] = (options[:only_path] == false ? false : true)
96 options.delete(:only_path)
96 options.delete(:only_path)
97 link_to(h(text),
97 link_to(h(text),
98 {:controller => 'attachments', :action => action,
98 {:controller => 'attachments', :action => action,
99 :id => attachment, :filename => attachment.filename}.merge(opt_only_path),
99 :id => attachment, :filename => attachment.filename}.merge(opt_only_path),
100 options)
100 options)
101 end
101 end
102
102
103 # Generates a link to a SCM revision
103 # Generates a link to a SCM revision
104 # Options:
104 # Options:
105 # * :text - Link text (default to the formatted revision)
105 # * :text - Link text (default to the formatted revision)
106 def link_to_revision(revision, repository, options={})
106 def link_to_revision(revision, repository, options={})
107 if repository.is_a?(Project)
107 if repository.is_a?(Project)
108 repository = repository.repository
108 repository = repository.repository
109 end
109 end
110 text = options.delete(:text) || format_revision(revision)
110 text = options.delete(:text) || format_revision(revision)
111 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
111 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
112 link_to(
112 link_to(
113 h(text),
113 h(text),
114 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
114 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
115 :title => l(:label_revision_id, format_revision(revision))
115 :title => l(:label_revision_id, format_revision(revision))
116 )
116 )
117 end
117 end
118
118
119 # Generates a link to a message
119 # Generates a link to a message
120 def link_to_message(message, options={}, html_options = nil)
120 def link_to_message(message, options={}, html_options = nil)
121 link_to(
121 link_to(
122 h(truncate(message.subject, :length => 60)),
122 h(truncate(message.subject, :length => 60)),
123 { :controller => 'messages', :action => 'show',
123 { :controller => 'messages', :action => 'show',
124 :board_id => message.board_id,
124 :board_id => message.board_id,
125 :id => (message.parent_id || message.id),
125 :id => (message.parent_id || message.id),
126 :r => (message.parent_id && message.id),
126 :r => (message.parent_id && message.id),
127 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
127 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
128 }.merge(options),
128 }.merge(options),
129 html_options
129 html_options
130 )
130 )
131 end
131 end
132
132
133 # Generates a link to a project if active
133 # Generates a link to a project if active
134 # Examples:
134 # Examples:
135 #
135 #
136 # link_to_project(project) # => link to the specified project overview
136 # link_to_project(project) # => link to the specified project overview
137 # link_to_project(project, :action=>'settings') # => link to project settings
137 # link_to_project(project, :action=>'settings') # => link to project settings
138 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
138 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
139 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
139 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
140 #
140 #
141 def link_to_project(project, options={}, html_options = nil)
141 def link_to_project(project, options={}, html_options = nil)
142 if project.archived?
142 if project.archived?
143 h(project)
143 h(project)
144 else
144 else
145 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
145 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
146 link_to(h(project), url, html_options)
146 link_to(h(project), url, html_options)
147 end
147 end
148 end
148 end
149
149
150 def wiki_page_path(page, options={})
150 def wiki_page_path(page, options={})
151 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
151 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
152 end
152 end
153
153
154 def thumbnail_tag(attachment)
154 def thumbnail_tag(attachment)
155 link_to image_tag(url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment)),
155 link_to image_tag(url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment)),
156 {:controller => 'attachments', :action => 'show', :id => attachment, :filename => attachment.filename},
156 {:controller => 'attachments', :action => 'show', :id => attachment, :filename => attachment.filename},
157 :title => attachment.filename
157 :title => attachment.filename
158 end
158 end
159
159
160 def toggle_link(name, id, options={})
160 def toggle_link(name, id, options={})
161 onclick = "$('##{id}').toggle(); "
161 onclick = "$('##{id}').toggle(); "
162 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
162 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
163 onclick << "return false;"
163 onclick << "return false;"
164 link_to(name, "#", :onclick => onclick)
164 link_to(name, "#", :onclick => onclick)
165 end
165 end
166
166
167 def image_to_function(name, function, html_options = {})
167 def image_to_function(name, function, html_options = {})
168 html_options.symbolize_keys!
168 html_options.symbolize_keys!
169 tag(:input, html_options.merge({
169 tag(:input, html_options.merge({
170 :type => "image", :src => image_path(name),
170 :type => "image", :src => image_path(name),
171 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
171 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
172 }))
172 }))
173 end
173 end
174
174
175 def format_activity_title(text)
175 def format_activity_title(text)
176 h(truncate_single_line(text, :length => 100))
176 h(truncate_single_line(text, :length => 100))
177 end
177 end
178
178
179 def format_activity_day(date)
179 def format_activity_day(date)
180 date == User.current.today ? l(:label_today).titleize : format_date(date)
180 date == User.current.today ? l(:label_today).titleize : format_date(date)
181 end
181 end
182
182
183 def format_activity_description(text)
183 def format_activity_description(text)
184 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
184 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
185 ).gsub(/[\r\n]+/, "<br />").html_safe
185 ).gsub(/[\r\n]+/, "<br />").html_safe
186 end
186 end
187
187
188 def format_version_name(version)
188 def format_version_name(version)
189 if version.project == @project
189 if version.project == @project
190 h(version)
190 h(version)
191 else
191 else
192 h("#{version.project} - #{version}")
192 h("#{version.project} - #{version}")
193 end
193 end
194 end
194 end
195
195
196 def due_date_distance_in_words(date)
196 def due_date_distance_in_words(date)
197 if date
197 if date
198 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
198 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
199 end
199 end
200 end
200 end
201
201
202 # Renders a tree of projects as a nested set of unordered lists
202 # Renders a tree of projects as a nested set of unordered lists
203 # The given collection may be a subset of the whole project tree
203 # The given collection may be a subset of the whole project tree
204 # (eg. some intermediate nodes are private and can not be seen)
204 # (eg. some intermediate nodes are private and can not be seen)
205 def render_project_nested_lists(projects)
205 def render_project_nested_lists(projects)
206 s = ''
206 s = ''
207 if projects.any?
207 if projects.any?
208 ancestors = []
208 ancestors = []
209 original_project = @project
209 original_project = @project
210 projects.sort_by(&:lft).each do |project|
210 projects.sort_by(&:lft).each do |project|
211 # set the project environment to please macros.
211 # set the project environment to please macros.
212 @project = project
212 @project = project
213 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
213 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
214 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
214 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
215 else
215 else
216 ancestors.pop
216 ancestors.pop
217 s << "</li>"
217 s << "</li>"
218 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
218 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
219 ancestors.pop
219 ancestors.pop
220 s << "</ul></li>\n"
220 s << "</ul></li>\n"
221 end
221 end
222 end
222 end
223 classes = (ancestors.empty? ? 'root' : 'child')
223 classes = (ancestors.empty? ? 'root' : 'child')
224 s << "<li class='#{classes}'><div class='#{classes}'>"
224 s << "<li class='#{classes}'><div class='#{classes}'>"
225 s << h(block_given? ? yield(project) : project.name)
225 s << h(block_given? ? yield(project) : project.name)
226 s << "</div>\n"
226 s << "</div>\n"
227 ancestors << project
227 ancestors << project
228 end
228 end
229 s << ("</li></ul>\n" * ancestors.size)
229 s << ("</li></ul>\n" * ancestors.size)
230 @project = original_project
230 @project = original_project
231 end
231 end
232 s.html_safe
232 s.html_safe
233 end
233 end
234
234
235 def render_page_hierarchy(pages, node=nil, options={})
235 def render_page_hierarchy(pages, node=nil, options={})
236 content = ''
236 content = ''
237 if pages[node]
237 if pages[node]
238 content << "<ul class=\"pages-hierarchy\">\n"
238 content << "<ul class=\"pages-hierarchy\">\n"
239 pages[node].each do |page|
239 pages[node].each do |page|
240 content << "<li>"
240 content << "<li>"
241 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
241 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
242 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
242 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
243 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
243 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
244 content << "</li>\n"
244 content << "</li>\n"
245 end
245 end
246 content << "</ul>\n"
246 content << "</ul>\n"
247 end
247 end
248 content.html_safe
248 content.html_safe
249 end
249 end
250
250
251 # Renders flash messages
251 # Renders flash messages
252 def render_flash_messages
252 def render_flash_messages
253 s = ''
253 s = ''
254 flash.each do |k,v|
254 flash.each do |k,v|
255 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
255 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
256 end
256 end
257 s.html_safe
257 s.html_safe
258 end
258 end
259
259
260 # Renders tabs and their content
260 # Renders tabs and their content
261 def render_tabs(tabs)
261 def render_tabs(tabs)
262 if tabs.any?
262 if tabs.any?
263 render :partial => 'common/tabs', :locals => {:tabs => tabs}
263 render :partial => 'common/tabs', :locals => {:tabs => tabs}
264 else
264 else
265 content_tag 'p', l(:label_no_data), :class => "nodata"
265 content_tag 'p', l(:label_no_data), :class => "nodata"
266 end
266 end
267 end
267 end
268
268
269 # Renders the project quick-jump box
269 # Renders the project quick-jump box
270 def render_project_jump_box
270 def render_project_jump_box
271 return unless User.current.logged?
271 return unless User.current.logged?
272 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
272 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
273 if projects.any?
273 if projects.any?
274 options =
274 options =
275 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
275 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
276 '<option value="" disabled="disabled">---</option>').html_safe
276 '<option value="" disabled="disabled">---</option>').html_safe
277
277
278 options << project_tree_options_for_select(projects, :selected => @project) do |p|
278 options << project_tree_options_for_select(projects, :selected => @project) do |p|
279 { :value => project_path(:id => p, :jump => current_menu_item) }
279 { :value => project_path(:id => p, :jump => current_menu_item) }
280 end
280 end
281
281
282 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
282 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
283 end
283 end
284 end
284 end
285
285
286 def project_tree_options_for_select(projects, options = {})
286 def project_tree_options_for_select(projects, options = {})
287 s = ''
287 s = ''
288 project_tree(projects) do |project, level|
288 project_tree(projects) do |project, level|
289 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
289 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
290 tag_options = {:value => project.id}
290 tag_options = {:value => project.id}
291 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
291 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
292 tag_options[:selected] = 'selected'
292 tag_options[:selected] = 'selected'
293 else
293 else
294 tag_options[:selected] = nil
294 tag_options[:selected] = nil
295 end
295 end
296 tag_options.merge!(yield(project)) if block_given?
296 tag_options.merge!(yield(project)) if block_given?
297 s << content_tag('option', name_prefix + h(project), tag_options)
297 s << content_tag('option', name_prefix + h(project), tag_options)
298 end
298 end
299 s.html_safe
299 s.html_safe
300 end
300 end
301
301
302 # Yields the given block for each project with its level in the tree
302 # Yields the given block for each project with its level in the tree
303 #
303 #
304 # Wrapper for Project#project_tree
304 # Wrapper for Project#project_tree
305 def project_tree(projects, &block)
305 def project_tree(projects, &block)
306 Project.project_tree(projects, &block)
306 Project.project_tree(projects, &block)
307 end
307 end
308
308
309 def principals_check_box_tags(name, principals)
309 def principals_check_box_tags(name, principals)
310 s = ''
310 s = ''
311 principals.sort.each do |principal|
311 principals.sort.each do |principal|
312 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
312 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
313 end
313 end
314 s.html_safe
314 s.html_safe
315 end
315 end
316
316
317 # Returns a string for users/groups option tags
317 # Returns a string for users/groups option tags
318 def principals_options_for_select(collection, selected=nil)
318 def principals_options_for_select(collection, selected=nil)
319 s = ''
319 s = ''
320 if collection.include?(User.current)
320 if collection.include?(User.current)
321 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
321 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
322 end
322 end
323 groups = ''
323 groups = ''
324 collection.sort.each do |element|
324 collection.sort.each do |element|
325 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected)
325 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected)
326 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
326 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
327 end
327 end
328 unless groups.empty?
328 unless groups.empty?
329 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
329 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
330 end
330 end
331 s.html_safe
331 s.html_safe
332 end
332 end
333
333
334 # Options for the new membership projects combo-box
334 # Options for the new membership projects combo-box
335 def options_for_membership_project_select(principal, projects)
335 def options_for_membership_project_select(principal, projects)
336 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
336 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
337 options << project_tree_options_for_select(projects) do |p|
337 options << project_tree_options_for_select(projects) do |p|
338 {:disabled => principal.projects.include?(p)}
338 {:disabled => principal.projects.include?(p)}
339 end
339 end
340 options
340 options
341 end
341 end
342
342
343 # Truncates and returns the string as a single line
343 # Truncates and returns the string as a single line
344 def truncate_single_line(string, *args)
344 def truncate_single_line(string, *args)
345 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
345 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
346 end
346 end
347
347
348 # Truncates at line break after 250 characters or options[:length]
348 # Truncates at line break after 250 characters or options[:length]
349 def truncate_lines(string, options={})
349 def truncate_lines(string, options={})
350 length = options[:length] || 250
350 length = options[:length] || 250
351 if string.to_s =~ /\A(.{#{length}}.*?)$/m
351 if string.to_s =~ /\A(.{#{length}}.*?)$/m
352 "#{$1}..."
352 "#{$1}..."
353 else
353 else
354 string
354 string
355 end
355 end
356 end
356 end
357
357
358 def anchor(text)
358 def anchor(text)
359 text.to_s.gsub(' ', '_')
359 text.to_s.gsub(' ', '_')
360 end
360 end
361
361
362 def html_hours(text)
362 def html_hours(text)
363 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
363 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
364 end
364 end
365
365
366 def authoring(created, author, options={})
366 def authoring(created, author, options={})
367 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
367 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
368 end
368 end
369
369
370 def time_tag(time)
370 def time_tag(time)
371 text = distance_of_time_in_words(Time.now, time)
371 text = distance_of_time_in_words(Time.now, time)
372 if @project
372 if @project
373 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
373 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
374 else
374 else
375 content_tag('acronym', text, :title => format_time(time))
375 content_tag('acronym', text, :title => format_time(time))
376 end
376 end
377 end
377 end
378
378
379 def syntax_highlight_lines(name, content)
379 def syntax_highlight_lines(name, content)
380 lines = []
380 lines = []
381 syntax_highlight(name, content).each_line { |line| lines << line }
381 syntax_highlight(name, content).each_line { |line| lines << line }
382 lines
382 lines
383 end
383 end
384
384
385 def syntax_highlight(name, content)
385 def syntax_highlight(name, content)
386 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
386 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
387 end
387 end
388
388
389 def to_path_param(path)
389 def to_path_param(path)
390 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
390 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
391 str.blank? ? nil : str
391 str.blank? ? nil : str
392 end
392 end
393
393
394 def pagination_links_full(paginator, count=nil, options={})
394 def pagination_links_full(paginator, count=nil, options={})
395 page_param = options.delete(:page_param) || :page
395 page_param = options.delete(:page_param) || :page
396 per_page_links = options.delete(:per_page_links)
396 per_page_links = options.delete(:per_page_links)
397 url_param = params.dup
397 url_param = params.dup
398
398
399 html = ''
399 html = ''
400 if paginator.current.previous
400 if paginator.current.previous
401 # \xc2\xab(utf-8) = &#171;
401 # \xc2\xab(utf-8) = &#171;
402 html << link_to_content_update(
402 html << link_to_content_update(
403 "\xc2\xab " + l(:label_previous),
403 "\xc2\xab " + l(:label_previous),
404 url_param.merge(page_param => paginator.current.previous)) + ' '
404 url_param.merge(page_param => paginator.current.previous)) + ' '
405 end
405 end
406
406
407 html << (pagination_links_each(paginator, options) do |n|
407 html << (pagination_links_each(paginator, options) do |n|
408 link_to_content_update(n.to_s, url_param.merge(page_param => n))
408 link_to_content_update(n.to_s, url_param.merge(page_param => n))
409 end || '')
409 end || '')
410
410
411 if paginator.current.next
411 if paginator.current.next
412 # \xc2\xbb(utf-8) = &#187;
412 # \xc2\xbb(utf-8) = &#187;
413 html << ' ' + link_to_content_update(
413 html << ' ' + link_to_content_update(
414 (l(:label_next) + " \xc2\xbb"),
414 (l(:label_next) + " \xc2\xbb"),
415 url_param.merge(page_param => paginator.current.next))
415 url_param.merge(page_param => paginator.current.next))
416 end
416 end
417
417
418 unless count.nil?
418 unless count.nil?
419 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
419 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
420 if per_page_links != false && links = per_page_links(paginator.items_per_page, count)
420 if per_page_links != false && links = per_page_links(paginator.items_per_page, count)
421 html << " | #{links}"
421 html << " | #{links}"
422 end
422 end
423 end
423 end
424
424
425 html.html_safe
425 html.html_safe
426 end
426 end
427
427
428 def per_page_links(selected=nil, item_count=nil)
428 def per_page_links(selected=nil, item_count=nil)
429 values = Setting.per_page_options_array
429 values = Setting.per_page_options_array
430 if item_count && values.any?
430 if item_count && values.any?
431 if item_count > values.first
431 if item_count > values.first
432 max = values.detect {|value| value >= item_count} || item_count
432 max = values.detect {|value| value >= item_count} || item_count
433 else
433 else
434 max = item_count
434 max = item_count
435 end
435 end
436 values = values.select {|value| value <= max || value == selected}
436 values = values.select {|value| value <= max || value == selected}
437 end
437 end
438 if values.empty? || (values.size == 1 && values.first == selected)
438 if values.empty? || (values.size == 1 && values.first == selected)
439 return nil
439 return nil
440 end
440 end
441 links = values.collect do |n|
441 links = values.collect do |n|
442 n == selected ? n : link_to_content_update(n, params.merge(:per_page => n))
442 n == selected ? n : link_to_content_update(n, params.merge(:per_page => n))
443 end
443 end
444 l(:label_display_per_page, links.join(', '))
444 l(:label_display_per_page, links.join(', '))
445 end
445 end
446
446
447 def reorder_links(name, url, method = :post)
447 def reorder_links(name, url, method = :post)
448 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
448 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
449 url.merge({"#{name}[move_to]" => 'highest'}),
449 url.merge({"#{name}[move_to]" => 'highest'}),
450 :method => method, :title => l(:label_sort_highest)) +
450 :method => method, :title => l(:label_sort_highest)) +
451 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
451 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
452 url.merge({"#{name}[move_to]" => 'higher'}),
452 url.merge({"#{name}[move_to]" => 'higher'}),
453 :method => method, :title => l(:label_sort_higher)) +
453 :method => method, :title => l(:label_sort_higher)) +
454 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
454 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
455 url.merge({"#{name}[move_to]" => 'lower'}),
455 url.merge({"#{name}[move_to]" => 'lower'}),
456 :method => method, :title => l(:label_sort_lower)) +
456 :method => method, :title => l(:label_sort_lower)) +
457 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
457 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
458 url.merge({"#{name}[move_to]" => 'lowest'}),
458 url.merge({"#{name}[move_to]" => 'lowest'}),
459 :method => method, :title => l(:label_sort_lowest))
459 :method => method, :title => l(:label_sort_lowest))
460 end
460 end
461
461
462 def breadcrumb(*args)
462 def breadcrumb(*args)
463 elements = args.flatten
463 elements = args.flatten
464 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
464 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
465 end
465 end
466
466
467 def other_formats_links(&block)
467 def other_formats_links(&block)
468 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
468 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
469 yield Redmine::Views::OtherFormatsBuilder.new(self)
469 yield Redmine::Views::OtherFormatsBuilder.new(self)
470 concat('</p>'.html_safe)
470 concat('</p>'.html_safe)
471 end
471 end
472
472
473 def page_header_title
473 def page_header_title
474 if @project.nil? || @project.new_record?
474 if @project.nil? || @project.new_record?
475 h(Setting.app_title)
475 h(Setting.app_title)
476 else
476 else
477 b = []
477 b = []
478 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
478 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
479 if ancestors.any?
479 if ancestors.any?
480 root = ancestors.shift
480 root = ancestors.shift
481 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
481 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
482 if ancestors.size > 2
482 if ancestors.size > 2
483 b << "\xe2\x80\xa6"
483 b << "\xe2\x80\xa6"
484 ancestors = ancestors[-2, 2]
484 ancestors = ancestors[-2, 2]
485 end
485 end
486 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
486 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
487 end
487 end
488 b << h(@project)
488 b << h(@project)
489 b.join(" \xc2\xbb ").html_safe
489 b.join(" \xc2\xbb ").html_safe
490 end
490 end
491 end
491 end
492
492
493 def html_title(*args)
493 def html_title(*args)
494 if args.empty?
494 if args.empty?
495 title = @html_title || []
495 title = @html_title || []
496 title << @project.name if @project
496 title << @project.name if @project
497 title << Setting.app_title unless Setting.app_title == title.last
497 title << Setting.app_title unless Setting.app_title == title.last
498 title.select {|t| !t.blank? }.join(' - ')
498 title.select {|t| !t.blank? }.join(' - ')
499 else
499 else
500 @html_title ||= []
500 @html_title ||= []
501 @html_title += args
501 @html_title += args
502 end
502 end
503 end
503 end
504
504
505 # Returns the theme, controller name, and action as css classes for the
505 # Returns the theme, controller name, and action as css classes for the
506 # HTML body.
506 # HTML body.
507 def body_css_classes
507 def body_css_classes
508 css = []
508 css = []
509 if theme = Redmine::Themes.theme(Setting.ui_theme)
509 if theme = Redmine::Themes.theme(Setting.ui_theme)
510 css << 'theme-' + theme.name
510 css << 'theme-' + theme.name
511 end
511 end
512
512
513 css << 'controller-' + controller_name
513 css << 'controller-' + controller_name
514 css << 'action-' + action_name
514 css << 'action-' + action_name
515 css.join(' ')
515 css.join(' ')
516 end
516 end
517
517
518 def accesskey(s)
518 def accesskey(s)
519 Redmine::AccessKeys.key_for s
519 Redmine::AccessKeys.key_for s
520 end
520 end
521
521
522 # Formats text according to system settings.
522 # Formats text according to system settings.
523 # 2 ways to call this method:
523 # 2 ways to call this method:
524 # * with a String: textilizable(text, options)
524 # * with a String: textilizable(text, options)
525 # * with an object and one of its attribute: textilizable(issue, :description, options)
525 # * with an object and one of its attribute: textilizable(issue, :description, options)
526 def textilizable(*args)
526 def textilizable(*args)
527 options = args.last.is_a?(Hash) ? args.pop : {}
527 options = args.last.is_a?(Hash) ? args.pop : {}
528 case args.size
528 case args.size
529 when 1
529 when 1
530 obj = options[:object]
530 obj = options[:object]
531 text = args.shift
531 text = args.shift
532 when 2
532 when 2
533 obj = args.shift
533 obj = args.shift
534 attr = args.shift
534 attr = args.shift
535 text = obj.send(attr).to_s
535 text = obj.send(attr).to_s
536 else
536 else
537 raise ArgumentError, 'invalid arguments to textilizable'
537 raise ArgumentError, 'invalid arguments to textilizable'
538 end
538 end
539 return '' if text.blank?
539 return '' if text.blank?
540 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
540 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
541 only_path = options.delete(:only_path) == false ? false : true
541 only_path = options.delete(:only_path) == false ? false : true
542
542
543 text = text.dup
543 text = text.dup
544 macros = catch_macros(text)
544 macros = catch_macros(text)
545 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
545 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
546
546
547 @parsed_headings = []
547 @parsed_headings = []
548 @heading_anchors = {}
548 @heading_anchors = {}
549 @current_section = 0 if options[:edit_section_links]
549 @current_section = 0 if options[:edit_section_links]
550
550
551 parse_sections(text, project, obj, attr, only_path, options)
551 parse_sections(text, project, obj, attr, only_path, options)
552 text = parse_non_pre_blocks(text, obj, macros) do |text|
552 text = parse_non_pre_blocks(text, obj, macros) do |text|
553 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
553 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
554 send method_name, text, project, obj, attr, only_path, options
554 send method_name, text, project, obj, attr, only_path, options
555 end
555 end
556 end
556 end
557 parse_headings(text, project, obj, attr, only_path, options)
557 parse_headings(text, project, obj, attr, only_path, options)
558
558
559 if @parsed_headings.any?
559 if @parsed_headings.any?
560 replace_toc(text, @parsed_headings)
560 replace_toc(text, @parsed_headings)
561 end
561 end
562
562
563 text.html_safe
563 text.html_safe
564 end
564 end
565
565
566 def parse_non_pre_blocks(text, obj, macros)
566 def parse_non_pre_blocks(text, obj, macros)
567 s = StringScanner.new(text)
567 s = StringScanner.new(text)
568 tags = []
568 tags = []
569 parsed = ''
569 parsed = ''
570 while !s.eos?
570 while !s.eos?
571 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
571 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
572 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
572 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
573 if tags.empty?
573 if tags.empty?
574 yield text
574 yield text
575 inject_macros(text, obj, macros) if macros.any?
575 inject_macros(text, obj, macros) if macros.any?
576 else
576 else
577 inject_macros(text, obj, macros, false) if macros.any?
577 inject_macros(text, obj, macros, false) if macros.any?
578 end
578 end
579 parsed << text
579 parsed << text
580 if tag
580 if tag
581 if closing
581 if closing
582 if tags.last == tag.downcase
582 if tags.last == tag.downcase
583 tags.pop
583 tags.pop
584 end
584 end
585 else
585 else
586 tags << tag.downcase
586 tags << tag.downcase
587 end
587 end
588 parsed << full_tag
588 parsed << full_tag
589 end
589 end
590 end
590 end
591 # Close any non closing tags
591 # Close any non closing tags
592 while tag = tags.pop
592 while tag = tags.pop
593 parsed << "</#{tag}>"
593 parsed << "</#{tag}>"
594 end
594 end
595 parsed
595 parsed
596 end
596 end
597
597
598 def parse_inline_attachments(text, project, obj, attr, only_path, options)
598 def parse_inline_attachments(text, project, obj, attr, only_path, options)
599 # when using an image link, try to use an attachment, if possible
599 # when using an image link, try to use an attachment, if possible
600 if options[:attachments] || (obj && obj.respond_to?(:attachments))
600 if options[:attachments].present? || (obj && obj.respond_to?(:attachments))
601 attachments = options[:attachments] || obj.attachments
601 attachments = options[:attachments] || []
602 attachments += obj.attachments if obj
602 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
603 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
603 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
604 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
604 # search for the picture in attachments
605 # search for the picture in attachments
605 if found = Attachment.latest_attach(attachments, filename)
606 if found = Attachment.latest_attach(attachments, filename)
606 image_url = url_for :only_path => only_path, :controller => 'attachments',
607 image_url = url_for :only_path => only_path, :controller => 'attachments',
607 :action => 'download', :id => found
608 :action => 'download', :id => found
608 desc = found.description.to_s.gsub('"', '')
609 desc = found.description.to_s.gsub('"', '')
609 if !desc.blank? && alttext.blank?
610 if !desc.blank? && alttext.blank?
610 alt = " title=\"#{desc}\" alt=\"#{desc}\""
611 alt = " title=\"#{desc}\" alt=\"#{desc}\""
611 end
612 end
612 "src=\"#{image_url}\"#{alt}"
613 "src=\"#{image_url}\"#{alt}"
613 else
614 else
614 m
615 m
615 end
616 end
616 end
617 end
617 end
618 end
618 end
619 end
619
620
620 # Wiki links
621 # Wiki links
621 #
622 #
622 # Examples:
623 # Examples:
623 # [[mypage]]
624 # [[mypage]]
624 # [[mypage|mytext]]
625 # [[mypage|mytext]]
625 # wiki links can refer other project wikis, using project name or identifier:
626 # wiki links can refer other project wikis, using project name or identifier:
626 # [[project:]] -> wiki starting page
627 # [[project:]] -> wiki starting page
627 # [[project:|mytext]]
628 # [[project:|mytext]]
628 # [[project:mypage]]
629 # [[project:mypage]]
629 # [[project:mypage|mytext]]
630 # [[project:mypage|mytext]]
630 def parse_wiki_links(text, project, obj, attr, only_path, options)
631 def parse_wiki_links(text, project, obj, attr, only_path, options)
631 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
632 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
632 link_project = project
633 link_project = project
633 esc, all, page, title = $1, $2, $3, $5
634 esc, all, page, title = $1, $2, $3, $5
634 if esc.nil?
635 if esc.nil?
635 if page =~ /^([^\:]+)\:(.*)$/
636 if page =~ /^([^\:]+)\:(.*)$/
636 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
637 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
637 page = $2
638 page = $2
638 title ||= $1 if page.blank?
639 title ||= $1 if page.blank?
639 end
640 end
640
641
641 if link_project && link_project.wiki
642 if link_project && link_project.wiki
642 # extract anchor
643 # extract anchor
643 anchor = nil
644 anchor = nil
644 if page =~ /^(.+?)\#(.+)$/
645 if page =~ /^(.+?)\#(.+)$/
645 page, anchor = $1, $2
646 page, anchor = $1, $2
646 end
647 end
647 anchor = sanitize_anchor_name(anchor) if anchor.present?
648 anchor = sanitize_anchor_name(anchor) if anchor.present?
648 # check if page exists
649 # check if page exists
649 wiki_page = link_project.wiki.find_page(page)
650 wiki_page = link_project.wiki.find_page(page)
650 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
651 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
651 "##{anchor}"
652 "##{anchor}"
652 else
653 else
653 case options[:wiki_links]
654 case options[:wiki_links]
654 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
655 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
655 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
656 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
656 else
657 else
657 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
658 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
658 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
659 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
659 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
660 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
660 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
661 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
661 end
662 end
662 end
663 end
663 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
664 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
664 else
665 else
665 # project or wiki doesn't exist
666 # project or wiki doesn't exist
666 all
667 all
667 end
668 end
668 else
669 else
669 all
670 all
670 end
671 end
671 end
672 end
672 end
673 end
673
674
674 # Redmine links
675 # Redmine links
675 #
676 #
676 # Examples:
677 # Examples:
677 # Issues:
678 # Issues:
678 # #52 -> Link to issue #52
679 # #52 -> Link to issue #52
679 # Changesets:
680 # Changesets:
680 # r52 -> Link to revision 52
681 # r52 -> Link to revision 52
681 # commit:a85130f -> Link to scmid starting with a85130f
682 # commit:a85130f -> Link to scmid starting with a85130f
682 # Documents:
683 # Documents:
683 # document#17 -> Link to document with id 17
684 # document#17 -> Link to document with id 17
684 # document:Greetings -> Link to the document with title "Greetings"
685 # document:Greetings -> Link to the document with title "Greetings"
685 # document:"Some document" -> Link to the document with title "Some document"
686 # document:"Some document" -> Link to the document with title "Some document"
686 # Versions:
687 # Versions:
687 # version#3 -> Link to version with id 3
688 # version#3 -> Link to version with id 3
688 # version:1.0.0 -> Link to version named "1.0.0"
689 # version:1.0.0 -> Link to version named "1.0.0"
689 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
690 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
690 # Attachments:
691 # Attachments:
691 # attachment:file.zip -> Link to the attachment of the current object named file.zip
692 # attachment:file.zip -> Link to the attachment of the current object named file.zip
692 # Source files:
693 # Source files:
693 # source:some/file -> Link to the file located at /some/file in the project's repository
694 # source:some/file -> Link to the file located at /some/file in the project's repository
694 # source:some/file@52 -> Link to the file's revision 52
695 # source:some/file@52 -> Link to the file's revision 52
695 # source:some/file#L120 -> Link to line 120 of the file
696 # source:some/file#L120 -> Link to line 120 of the file
696 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
697 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
697 # export:some/file -> Force the download of the file
698 # export:some/file -> Force the download of the file
698 # Forum messages:
699 # Forum messages:
699 # message#1218 -> Link to message with id 1218
700 # message#1218 -> Link to message with id 1218
700 #
701 #
701 # Links can refer other objects from other projects, using project identifier:
702 # Links can refer other objects from other projects, using project identifier:
702 # identifier:r52
703 # identifier:r52
703 # identifier:document:"Some document"
704 # identifier:document:"Some document"
704 # identifier:version:1.0.0
705 # identifier:version:1.0.0
705 # identifier:source:some/file
706 # identifier:source:some/file
706 def parse_redmine_links(text, project, obj, attr, only_path, options)
707 def parse_redmine_links(text, project, obj, attr, only_path, options)
707 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
708 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
708 leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
709 leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
709 link = nil
710 link = nil
710 if project_identifier
711 if project_identifier
711 project = Project.visible.find_by_identifier(project_identifier)
712 project = Project.visible.find_by_identifier(project_identifier)
712 end
713 end
713 if esc.nil?
714 if esc.nil?
714 if prefix.nil? && sep == 'r'
715 if prefix.nil? && sep == 'r'
715 if project
716 if project
716 repository = nil
717 repository = nil
717 if repo_identifier
718 if repo_identifier
718 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
719 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
719 else
720 else
720 repository = project.repository
721 repository = project.repository
721 end
722 end
722 # project.changesets.visible raises an SQL error because of a double join on repositories
723 # project.changesets.visible raises an SQL error because of a double join on repositories
723 if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
724 if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
724 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
725 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
725 :class => 'changeset',
726 :class => 'changeset',
726 :title => truncate_single_line(changeset.comments, :length => 100))
727 :title => truncate_single_line(changeset.comments, :length => 100))
727 end
728 end
728 end
729 end
729 elsif sep == '#'
730 elsif sep == '#'
730 oid = identifier.to_i
731 oid = identifier.to_i
731 case prefix
732 case prefix
732 when nil
733 when nil
733 if oid.to_s == identifier && issue = Issue.visible.find_by_id(oid, :include => :status)
734 if oid.to_s == identifier && issue = Issue.visible.find_by_id(oid, :include => :status)
734 anchor = comment_id ? "note-#{comment_id}" : nil
735 anchor = comment_id ? "note-#{comment_id}" : nil
735 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid, :anchor => anchor},
736 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid, :anchor => anchor},
736 :class => issue.css_classes,
737 :class => issue.css_classes,
737 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
738 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
738 end
739 end
739 when 'document'
740 when 'document'
740 if document = Document.visible.find_by_id(oid)
741 if document = Document.visible.find_by_id(oid)
741 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
742 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
742 :class => 'document'
743 :class => 'document'
743 end
744 end
744 when 'version'
745 when 'version'
745 if version = Version.visible.find_by_id(oid)
746 if version = Version.visible.find_by_id(oid)
746 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
747 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
747 :class => 'version'
748 :class => 'version'
748 end
749 end
749 when 'message'
750 when 'message'
750 if message = Message.visible.find_by_id(oid, :include => :parent)
751 if message = Message.visible.find_by_id(oid, :include => :parent)
751 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
752 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
752 end
753 end
753 when 'forum'
754 when 'forum'
754 if board = Board.visible.find_by_id(oid)
755 if board = Board.visible.find_by_id(oid)
755 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
756 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
756 :class => 'board'
757 :class => 'board'
757 end
758 end
758 when 'news'
759 when 'news'
759 if news = News.visible.find_by_id(oid)
760 if news = News.visible.find_by_id(oid)
760 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
761 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
761 :class => 'news'
762 :class => 'news'
762 end
763 end
763 when 'project'
764 when 'project'
764 if p = Project.visible.find_by_id(oid)
765 if p = Project.visible.find_by_id(oid)
765 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
766 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
766 end
767 end
767 end
768 end
768 elsif sep == ':'
769 elsif sep == ':'
769 # removes the double quotes if any
770 # removes the double quotes if any
770 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
771 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
771 case prefix
772 case prefix
772 when 'document'
773 when 'document'
773 if project && document = project.documents.visible.find_by_title(name)
774 if project && document = project.documents.visible.find_by_title(name)
774 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
775 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
775 :class => 'document'
776 :class => 'document'
776 end
777 end
777 when 'version'
778 when 'version'
778 if project && version = project.versions.visible.find_by_name(name)
779 if project && version = project.versions.visible.find_by_name(name)
779 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
780 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
780 :class => 'version'
781 :class => 'version'
781 end
782 end
782 when 'forum'
783 when 'forum'
783 if project && board = project.boards.visible.find_by_name(name)
784 if project && board = project.boards.visible.find_by_name(name)
784 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
785 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
785 :class => 'board'
786 :class => 'board'
786 end
787 end
787 when 'news'
788 when 'news'
788 if project && news = project.news.visible.find_by_title(name)
789 if project && news = project.news.visible.find_by_title(name)
789 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
790 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
790 :class => 'news'
791 :class => 'news'
791 end
792 end
792 when 'commit', 'source', 'export'
793 when 'commit', 'source', 'export'
793 if project
794 if project
794 repository = nil
795 repository = nil
795 if name =~ %r{^(([a-z0-9\-]+)\|)(.+)$}
796 if name =~ %r{^(([a-z0-9\-]+)\|)(.+)$}
796 repo_prefix, repo_identifier, name = $1, $2, $3
797 repo_prefix, repo_identifier, name = $1, $2, $3
797 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
798 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
798 else
799 else
799 repository = project.repository
800 repository = project.repository
800 end
801 end
801 if prefix == 'commit'
802 if prefix == 'commit'
802 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
803 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
803 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
804 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
804 :class => 'changeset',
805 :class => 'changeset',
805 :title => truncate_single_line(h(changeset.comments), :length => 100)
806 :title => truncate_single_line(h(changeset.comments), :length => 100)
806 end
807 end
807 else
808 else
808 if repository && User.current.allowed_to?(:browse_repository, project)
809 if repository && User.current.allowed_to?(:browse_repository, project)
809 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
810 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
810 path, rev, anchor = $1, $3, $5
811 path, rev, anchor = $1, $3, $5
811 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
812 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
812 :path => to_path_param(path),
813 :path => to_path_param(path),
813 :rev => rev,
814 :rev => rev,
814 :anchor => anchor},
815 :anchor => anchor},
815 :class => (prefix == 'export' ? 'source download' : 'source')
816 :class => (prefix == 'export' ? 'source download' : 'source')
816 end
817 end
817 end
818 end
818 repo_prefix = nil
819 repo_prefix = nil
819 end
820 end
820 when 'attachment'
821 when 'attachment'
821 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
822 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
822 if attachments && attachment = attachments.detect {|a| a.filename == name }
823 if attachments && attachment = attachments.detect {|a| a.filename == name }
823 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
824 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
824 :class => 'attachment'
825 :class => 'attachment'
825 end
826 end
826 when 'project'
827 when 'project'
827 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
828 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
828 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
829 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
829 end
830 end
830 end
831 end
831 end
832 end
832 end
833 end
833 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
834 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
834 end
835 end
835 end
836 end
836
837
837 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
838 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
838
839
839 def parse_sections(text, project, obj, attr, only_path, options)
840 def parse_sections(text, project, obj, attr, only_path, options)
840 return unless options[:edit_section_links]
841 return unless options[:edit_section_links]
841 text.gsub!(HEADING_RE) do
842 text.gsub!(HEADING_RE) do
842 heading = $1
843 heading = $1
843 @current_section += 1
844 @current_section += 1
844 if @current_section > 1
845 if @current_section > 1
845 content_tag('div',
846 content_tag('div',
846 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
847 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
847 :class => 'contextual',
848 :class => 'contextual',
848 :title => l(:button_edit_section)) + heading.html_safe
849 :title => l(:button_edit_section)) + heading.html_safe
849 else
850 else
850 heading
851 heading
851 end
852 end
852 end
853 end
853 end
854 end
854
855
855 # Headings and TOC
856 # Headings and TOC
856 # Adds ids and links to headings unless options[:headings] is set to false
857 # Adds ids and links to headings unless options[:headings] is set to false
857 def parse_headings(text, project, obj, attr, only_path, options)
858 def parse_headings(text, project, obj, attr, only_path, options)
858 return if options[:headings] == false
859 return if options[:headings] == false
859
860
860 text.gsub!(HEADING_RE) do
861 text.gsub!(HEADING_RE) do
861 level, attrs, content = $2.to_i, $3, $4
862 level, attrs, content = $2.to_i, $3, $4
862 item = strip_tags(content).strip
863 item = strip_tags(content).strip
863 anchor = sanitize_anchor_name(item)
864 anchor = sanitize_anchor_name(item)
864 # used for single-file wiki export
865 # used for single-file wiki export
865 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
866 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
866 @heading_anchors[anchor] ||= 0
867 @heading_anchors[anchor] ||= 0
867 idx = (@heading_anchors[anchor] += 1)
868 idx = (@heading_anchors[anchor] += 1)
868 if idx > 1
869 if idx > 1
869 anchor = "#{anchor}-#{idx}"
870 anchor = "#{anchor}-#{idx}"
870 end
871 end
871 @parsed_headings << [level, anchor, item]
872 @parsed_headings << [level, anchor, item]
872 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
873 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
873 end
874 end
874 end
875 end
875
876
876 MACROS_RE = /(
877 MACROS_RE = /(
877 (!)? # escaping
878 (!)? # escaping
878 (
879 (
879 \{\{ # opening tag
880 \{\{ # opening tag
880 ([\w]+) # macro name
881 ([\w]+) # macro name
881 (\(([^\n\r]*?)\))? # optional arguments
882 (\(([^\n\r]*?)\))? # optional arguments
882 ([\n\r].*?[\n\r])? # optional block of text
883 ([\n\r].*?[\n\r])? # optional block of text
883 \}\} # closing tag
884 \}\} # closing tag
884 )
885 )
885 )/mx unless const_defined?(:MACROS_RE)
886 )/mx unless const_defined?(:MACROS_RE)
886
887
887 MACRO_SUB_RE = /(
888 MACRO_SUB_RE = /(
888 \{\{
889 \{\{
889 macro\((\d+)\)
890 macro\((\d+)\)
890 \}\}
891 \}\}
891 )/x unless const_defined?(:MACRO_SUB_RE)
892 )/x unless const_defined?(:MACRO_SUB_RE)
892
893
893 # Extracts macros from text
894 # Extracts macros from text
894 def catch_macros(text)
895 def catch_macros(text)
895 macros = {}
896 macros = {}
896 text.gsub!(MACROS_RE) do
897 text.gsub!(MACROS_RE) do
897 all, macro = $1, $4.downcase
898 all, macro = $1, $4.downcase
898 if macro_exists?(macro) || all =~ MACRO_SUB_RE
899 if macro_exists?(macro) || all =~ MACRO_SUB_RE
899 index = macros.size
900 index = macros.size
900 macros[index] = all
901 macros[index] = all
901 "{{macro(#{index})}}"
902 "{{macro(#{index})}}"
902 else
903 else
903 all
904 all
904 end
905 end
905 end
906 end
906 macros
907 macros
907 end
908 end
908
909
909 # Executes and replaces macros in text
910 # Executes and replaces macros in text
910 def inject_macros(text, obj, macros, execute=true)
911 def inject_macros(text, obj, macros, execute=true)
911 text.gsub!(MACRO_SUB_RE) do
912 text.gsub!(MACRO_SUB_RE) do
912 all, index = $1, $2.to_i
913 all, index = $1, $2.to_i
913 orig = macros.delete(index)
914 orig = macros.delete(index)
914 if execute && orig && orig =~ MACROS_RE
915 if execute && orig && orig =~ MACROS_RE
915 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
916 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
916 if esc.nil?
917 if esc.nil?
917 h(exec_macro(macro, obj, args, block) || all)
918 h(exec_macro(macro, obj, args, block) || all)
918 else
919 else
919 h(all)
920 h(all)
920 end
921 end
921 elsif orig
922 elsif orig
922 h(orig)
923 h(orig)
923 else
924 else
924 h(all)
925 h(all)
925 end
926 end
926 end
927 end
927 end
928 end
928
929
929 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
930 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
930
931
931 # Renders the TOC with given headings
932 # Renders the TOC with given headings
932 def replace_toc(text, headings)
933 def replace_toc(text, headings)
933 text.gsub!(TOC_RE) do
934 text.gsub!(TOC_RE) do
934 # Keep only the 4 first levels
935 # Keep only the 4 first levels
935 headings = headings.select{|level, anchor, item| level <= 4}
936 headings = headings.select{|level, anchor, item| level <= 4}
936 if headings.empty?
937 if headings.empty?
937 ''
938 ''
938 else
939 else
939 div_class = 'toc'
940 div_class = 'toc'
940 div_class << ' right' if $1 == '>'
941 div_class << ' right' if $1 == '>'
941 div_class << ' left' if $1 == '<'
942 div_class << ' left' if $1 == '<'
942 out = "<ul class=\"#{div_class}\"><li>"
943 out = "<ul class=\"#{div_class}\"><li>"
943 root = headings.map(&:first).min
944 root = headings.map(&:first).min
944 current = root
945 current = root
945 started = false
946 started = false
946 headings.each do |level, anchor, item|
947 headings.each do |level, anchor, item|
947 if level > current
948 if level > current
948 out << '<ul><li>' * (level - current)
949 out << '<ul><li>' * (level - current)
949 elsif level < current
950 elsif level < current
950 out << "</li></ul>\n" * (current - level) + "</li><li>"
951 out << "</li></ul>\n" * (current - level) + "</li><li>"
951 elsif started
952 elsif started
952 out << '</li><li>'
953 out << '</li><li>'
953 end
954 end
954 out << "<a href=\"##{anchor}\">#{item}</a>"
955 out << "<a href=\"##{anchor}\">#{item}</a>"
955 current = level
956 current = level
956 started = true
957 started = true
957 end
958 end
958 out << '</li></ul>' * (current - root)
959 out << '</li></ul>' * (current - root)
959 out << '</li></ul>'
960 out << '</li></ul>'
960 end
961 end
961 end
962 end
962 end
963 end
963
964
964 # Same as Rails' simple_format helper without using paragraphs
965 # Same as Rails' simple_format helper without using paragraphs
965 def simple_format_without_paragraph(text)
966 def simple_format_without_paragraph(text)
966 text.to_s.
967 text.to_s.
967 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
968 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
968 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
969 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
969 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
970 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
970 html_safe
971 html_safe
971 end
972 end
972
973
973 def lang_options_for_select(blank=true)
974 def lang_options_for_select(blank=true)
974 (blank ? [["(auto)", ""]] : []) + languages_options
975 (blank ? [["(auto)", ""]] : []) + languages_options
975 end
976 end
976
977
977 def label_tag_for(name, option_tags = nil, options = {})
978 def label_tag_for(name, option_tags = nil, options = {})
978 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
979 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
979 content_tag("label", label_text)
980 content_tag("label", label_text)
980 end
981 end
981
982
982 def labelled_form_for(*args, &proc)
983 def labelled_form_for(*args, &proc)
983 args << {} unless args.last.is_a?(Hash)
984 args << {} unless args.last.is_a?(Hash)
984 options = args.last
985 options = args.last
985 if args.first.is_a?(Symbol)
986 if args.first.is_a?(Symbol)
986 options.merge!(:as => args.shift)
987 options.merge!(:as => args.shift)
987 end
988 end
988 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
989 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
989 form_for(*args, &proc)
990 form_for(*args, &proc)
990 end
991 end
991
992
992 def labelled_fields_for(*args, &proc)
993 def labelled_fields_for(*args, &proc)
993 args << {} unless args.last.is_a?(Hash)
994 args << {} unless args.last.is_a?(Hash)
994 options = args.last
995 options = args.last
995 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
996 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
996 fields_for(*args, &proc)
997 fields_for(*args, &proc)
997 end
998 end
998
999
999 def labelled_remote_form_for(*args, &proc)
1000 def labelled_remote_form_for(*args, &proc)
1000 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
1001 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
1001 args << {} unless args.last.is_a?(Hash)
1002 args << {} unless args.last.is_a?(Hash)
1002 options = args.last
1003 options = args.last
1003 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
1004 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
1004 form_for(*args, &proc)
1005 form_for(*args, &proc)
1005 end
1006 end
1006
1007
1007 def error_messages_for(*objects)
1008 def error_messages_for(*objects)
1008 html = ""
1009 html = ""
1009 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1010 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1010 errors = objects.map {|o| o.errors.full_messages}.flatten
1011 errors = objects.map {|o| o.errors.full_messages}.flatten
1011 if errors.any?
1012 if errors.any?
1012 html << "<div id='errorExplanation'><ul>\n"
1013 html << "<div id='errorExplanation'><ul>\n"
1013 errors.each do |error|
1014 errors.each do |error|
1014 html << "<li>#{h error}</li>\n"
1015 html << "<li>#{h error}</li>\n"
1015 end
1016 end
1016 html << "</ul></div>\n"
1017 html << "</ul></div>\n"
1017 end
1018 end
1018 html.html_safe
1019 html.html_safe
1019 end
1020 end
1020
1021
1021 def delete_link(url, options={})
1022 def delete_link(url, options={})
1022 options = {
1023 options = {
1023 :method => :delete,
1024 :method => :delete,
1024 :data => {:confirm => l(:text_are_you_sure)},
1025 :data => {:confirm => l(:text_are_you_sure)},
1025 :class => 'icon icon-del'
1026 :class => 'icon icon-del'
1026 }.merge(options)
1027 }.merge(options)
1027
1028
1028 link_to l(:button_delete), url, options
1029 link_to l(:button_delete), url, options
1029 end
1030 end
1030
1031
1031 def preview_link(url, form, target='preview', options={})
1032 def preview_link(url, form, target='preview', options={})
1032 content_tag 'a', l(:label_preview), {
1033 content_tag 'a', l(:label_preview), {
1033 :href => "#",
1034 :href => "#",
1034 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1035 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1035 :accesskey => accesskey(:preview)
1036 :accesskey => accesskey(:preview)
1036 }.merge(options)
1037 }.merge(options)
1037 end
1038 end
1038
1039
1039 def link_to_function(name, function, html_options={})
1040 def link_to_function(name, function, html_options={})
1040 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1041 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1041 end
1042 end
1042
1043
1043 # Helper to render JSON in views
1044 # Helper to render JSON in views
1044 def raw_json(arg)
1045 def raw_json(arg)
1045 arg.to_json.to_s.gsub('/', '\/').html_safe
1046 arg.to_json.to_s.gsub('/', '\/').html_safe
1046 end
1047 end
1047
1048
1048 def back_url
1049 def back_url
1049 url = params[:back_url]
1050 url = params[:back_url]
1050 if url.nil? && referer = request.env['HTTP_REFERER']
1051 if url.nil? && referer = request.env['HTTP_REFERER']
1051 url = CGI.unescape(referer.to_s)
1052 url = CGI.unescape(referer.to_s)
1052 end
1053 end
1053 url
1054 url
1054 end
1055 end
1055
1056
1056 def back_url_hidden_field_tag
1057 def back_url_hidden_field_tag
1057 url = back_url
1058 url = back_url
1058 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1059 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1059 end
1060 end
1060
1061
1061 def check_all_links(form_name)
1062 def check_all_links(form_name)
1062 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1063 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1063 " | ".html_safe +
1064 " | ".html_safe +
1064 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1065 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1065 end
1066 end
1066
1067
1067 def progress_bar(pcts, options={})
1068 def progress_bar(pcts, options={})
1068 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1069 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1069 pcts = pcts.collect(&:round)
1070 pcts = pcts.collect(&:round)
1070 pcts[1] = pcts[1] - pcts[0]
1071 pcts[1] = pcts[1] - pcts[0]
1071 pcts << (100 - pcts[1] - pcts[0])
1072 pcts << (100 - pcts[1] - pcts[0])
1072 width = options[:width] || '100px;'
1073 width = options[:width] || '100px;'
1073 legend = options[:legend] || ''
1074 legend = options[:legend] || ''
1074 content_tag('table',
1075 content_tag('table',
1075 content_tag('tr',
1076 content_tag('tr',
1076 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1077 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1077 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1078 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1078 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1079 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1079 ), :class => 'progress', :style => "width: #{width};").html_safe +
1080 ), :class => 'progress', :style => "width: #{width};").html_safe +
1080 content_tag('p', legend, :class => 'pourcent').html_safe
1081 content_tag('p', legend, :class => 'pourcent').html_safe
1081 end
1082 end
1082
1083
1083 def checked_image(checked=true)
1084 def checked_image(checked=true)
1084 if checked
1085 if checked
1085 image_tag 'toggle_check.png'
1086 image_tag 'toggle_check.png'
1086 end
1087 end
1087 end
1088 end
1088
1089
1089 def context_menu(url)
1090 def context_menu(url)
1090 unless @context_menu_included
1091 unless @context_menu_included
1091 content_for :header_tags do
1092 content_for :header_tags do
1092 javascript_include_tag('context_menu') +
1093 javascript_include_tag('context_menu') +
1093 stylesheet_link_tag('context_menu')
1094 stylesheet_link_tag('context_menu')
1094 end
1095 end
1095 if l(:direction) == 'rtl'
1096 if l(:direction) == 'rtl'
1096 content_for :header_tags do
1097 content_for :header_tags do
1097 stylesheet_link_tag('context_menu_rtl')
1098 stylesheet_link_tag('context_menu_rtl')
1098 end
1099 end
1099 end
1100 end
1100 @context_menu_included = true
1101 @context_menu_included = true
1101 end
1102 end
1102 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1103 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1103 end
1104 end
1104
1105
1105 def calendar_for(field_id)
1106 def calendar_for(field_id)
1106 include_calendar_headers_tags
1107 include_calendar_headers_tags
1107 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1108 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1108 end
1109 end
1109
1110
1110 def include_calendar_headers_tags
1111 def include_calendar_headers_tags
1111 unless @calendar_headers_tags_included
1112 unless @calendar_headers_tags_included
1112 @calendar_headers_tags_included = true
1113 @calendar_headers_tags_included = true
1113 content_for :header_tags do
1114 content_for :header_tags do
1114 start_of_week = Setting.start_of_week
1115 start_of_week = Setting.start_of_week
1115 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1116 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1116 # Redmine uses 1..7 (monday..sunday) in settings and locales
1117 # Redmine uses 1..7 (monday..sunday) in settings and locales
1117 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1118 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1118 start_of_week = start_of_week.to_i % 7
1119 start_of_week = start_of_week.to_i % 7
1119
1120
1120 tags = javascript_tag(
1121 tags = javascript_tag(
1121 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1122 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1122 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1123 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1123 path_to_image('/images/calendar.png') +
1124 path_to_image('/images/calendar.png') +
1124 "', showButtonPanel: true};")
1125 "', showButtonPanel: true};")
1125 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1126 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1126 unless jquery_locale == 'en'
1127 unless jquery_locale == 'en'
1127 tags << javascript_include_tag("i18n/jquery.ui.datepicker-#{jquery_locale}.js")
1128 tags << javascript_include_tag("i18n/jquery.ui.datepicker-#{jquery_locale}.js")
1128 end
1129 end
1129 tags
1130 tags
1130 end
1131 end
1131 end
1132 end
1132 end
1133 end
1133
1134
1134 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1135 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1135 # Examples:
1136 # Examples:
1136 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1137 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1137 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1138 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1138 #
1139 #
1139 def stylesheet_link_tag(*sources)
1140 def stylesheet_link_tag(*sources)
1140 options = sources.last.is_a?(Hash) ? sources.pop : {}
1141 options = sources.last.is_a?(Hash) ? sources.pop : {}
1141 plugin = options.delete(:plugin)
1142 plugin = options.delete(:plugin)
1142 sources = sources.map do |source|
1143 sources = sources.map do |source|
1143 if plugin
1144 if plugin
1144 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1145 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1145 elsif current_theme && current_theme.stylesheets.include?(source)
1146 elsif current_theme && current_theme.stylesheets.include?(source)
1146 current_theme.stylesheet_path(source)
1147 current_theme.stylesheet_path(source)
1147 else
1148 else
1148 source
1149 source
1149 end
1150 end
1150 end
1151 end
1151 super sources, options
1152 super sources, options
1152 end
1153 end
1153
1154
1154 # Overrides Rails' image_tag with themes and plugins support.
1155 # Overrides Rails' image_tag with themes and plugins support.
1155 # Examples:
1156 # Examples:
1156 # image_tag('image.png') # => picks image.png from the current theme or defaults
1157 # image_tag('image.png') # => picks image.png from the current theme or defaults
1157 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1158 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1158 #
1159 #
1159 def image_tag(source, options={})
1160 def image_tag(source, options={})
1160 if plugin = options.delete(:plugin)
1161 if plugin = options.delete(:plugin)
1161 source = "/plugin_assets/#{plugin}/images/#{source}"
1162 source = "/plugin_assets/#{plugin}/images/#{source}"
1162 elsif current_theme && current_theme.images.include?(source)
1163 elsif current_theme && current_theme.images.include?(source)
1163 source = current_theme.image_path(source)
1164 source = current_theme.image_path(source)
1164 end
1165 end
1165 super source, options
1166 super source, options
1166 end
1167 end
1167
1168
1168 # Overrides Rails' javascript_include_tag with plugins support
1169 # Overrides Rails' javascript_include_tag with plugins support
1169 # Examples:
1170 # Examples:
1170 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1171 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1171 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1172 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1172 #
1173 #
1173 def javascript_include_tag(*sources)
1174 def javascript_include_tag(*sources)
1174 options = sources.last.is_a?(Hash) ? sources.pop : {}
1175 options = sources.last.is_a?(Hash) ? sources.pop : {}
1175 if plugin = options.delete(:plugin)
1176 if plugin = options.delete(:plugin)
1176 sources = sources.map do |source|
1177 sources = sources.map do |source|
1177 if plugin
1178 if plugin
1178 "/plugin_assets/#{plugin}/javascripts/#{source}"
1179 "/plugin_assets/#{plugin}/javascripts/#{source}"
1179 else
1180 else
1180 source
1181 source
1181 end
1182 end
1182 end
1183 end
1183 end
1184 end
1184 super sources, options
1185 super sources, options
1185 end
1186 end
1186
1187
1187 def content_for(name, content = nil, &block)
1188 def content_for(name, content = nil, &block)
1188 @has_content ||= {}
1189 @has_content ||= {}
1189 @has_content[name] = true
1190 @has_content[name] = true
1190 super(name, content, &block)
1191 super(name, content, &block)
1191 end
1192 end
1192
1193
1193 def has_content?(name)
1194 def has_content?(name)
1194 (@has_content && @has_content[name]) || false
1195 (@has_content && @has_content[name]) || false
1195 end
1196 end
1196
1197
1197 def sidebar_content?
1198 def sidebar_content?
1198 has_content?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1199 has_content?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1199 end
1200 end
1200
1201
1201 def view_layouts_base_sidebar_hook_response
1202 def view_layouts_base_sidebar_hook_response
1202 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1203 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1203 end
1204 end
1204
1205
1205 def email_delivery_enabled?
1206 def email_delivery_enabled?
1206 !!ActionMailer::Base.perform_deliveries
1207 !!ActionMailer::Base.perform_deliveries
1207 end
1208 end
1208
1209
1209 # Returns the avatar image tag for the given +user+ if avatars are enabled
1210 # Returns the avatar image tag for the given +user+ if avatars are enabled
1210 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1211 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1211 def avatar(user, options = { })
1212 def avatar(user, options = { })
1212 if Setting.gravatar_enabled?
1213 if Setting.gravatar_enabled?
1213 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1214 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1214 email = nil
1215 email = nil
1215 if user.respond_to?(:mail)
1216 if user.respond_to?(:mail)
1216 email = user.mail
1217 email = user.mail
1217 elsif user.to_s =~ %r{<(.+?)>}
1218 elsif user.to_s =~ %r{<(.+?)>}
1218 email = $1
1219 email = $1
1219 end
1220 end
1220 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1221 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1221 else
1222 else
1222 ''
1223 ''
1223 end
1224 end
1224 end
1225 end
1225
1226
1226 def sanitize_anchor_name(anchor)
1227 def sanitize_anchor_name(anchor)
1227 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1228 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1228 anchor.gsub(%r{[^\p{Word}\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1229 anchor.gsub(%r{[^\p{Word}\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1229 else
1230 else
1230 # TODO: remove when ruby1.8 is no longer supported
1231 # TODO: remove when ruby1.8 is no longer supported
1231 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1232 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1232 end
1233 end
1233 end
1234 end
1234
1235
1235 # Returns the javascript tags that are included in the html layout head
1236 # Returns the javascript tags that are included in the html layout head
1236 def javascript_heads
1237 def javascript_heads
1237 tags = javascript_include_tag('jquery-1.7.2-ui-1.8.21-ujs-2.0.3', 'application')
1238 tags = javascript_include_tag('jquery-1.7.2-ui-1.8.21-ujs-2.0.3', 'application')
1238 unless User.current.pref.warn_on_leaving_unsaved == '0'
1239 unless User.current.pref.warn_on_leaving_unsaved == '0'
1239 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1240 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1240 end
1241 end
1241 tags
1242 tags
1242 end
1243 end
1243
1244
1244 def favicon
1245 def favicon
1245 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1246 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1246 end
1247 end
1247
1248
1248 def robot_exclusion_tag
1249 def robot_exclusion_tag
1249 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1250 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1250 end
1251 end
1251
1252
1252 # Returns true if arg is expected in the API response
1253 # Returns true if arg is expected in the API response
1253 def include_in_api_response?(arg)
1254 def include_in_api_response?(arg)
1254 unless @included_in_api_response
1255 unless @included_in_api_response
1255 param = params[:include]
1256 param = params[:include]
1256 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1257 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1257 @included_in_api_response.collect!(&:strip)
1258 @included_in_api_response.collect!(&:strip)
1258 end
1259 end
1259 @included_in_api_response.include?(arg.to_s)
1260 @included_in_api_response.include?(arg.to_s)
1260 end
1261 end
1261
1262
1262 # Returns options or nil if nometa param or X-Redmine-Nometa header
1263 # Returns options or nil if nometa param or X-Redmine-Nometa header
1263 # was set in the request
1264 # was set in the request
1264 def api_meta(options)
1265 def api_meta(options)
1265 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1266 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1266 # compatibility mode for activeresource clients that raise
1267 # compatibility mode for activeresource clients that raise
1267 # an error when unserializing an array with attributes
1268 # an error when unserializing an array with attributes
1268 nil
1269 nil
1269 else
1270 else
1270 options
1271 options
1271 end
1272 end
1272 end
1273 end
1273
1274
1274 private
1275 private
1275
1276
1276 def wiki_helper
1277 def wiki_helper
1277 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1278 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1278 extend helper
1279 extend helper
1279 return self
1280 return self
1280 end
1281 end
1281
1282
1282 def link_to_content_update(text, url_params = {}, html_options = {})
1283 def link_to_content_update(text, url_params = {}, html_options = {})
1283 link_to(text, url_params, html_options)
1284 link_to(text, url_params, html_options)
1284 end
1285 end
1285 end
1286 end
@@ -1,287 +1,295
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/md5"
18 require "digest/md5"
19
19
20 class Attachment < ActiveRecord::Base
20 class Attachment < ActiveRecord::Base
21 belongs_to :container, :polymorphic => true
21 belongs_to :container, :polymorphic => true
22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23
23
24 validates_presence_of :filename, :author
24 validates_presence_of :filename, :author
25 validates_length_of :filename, :maximum => 255
25 validates_length_of :filename, :maximum => 255
26 validates_length_of :disk_filename, :maximum => 255
26 validates_length_of :disk_filename, :maximum => 255
27 validates_length_of :description, :maximum => 255
27 validates_length_of :description, :maximum => 255
28 validate :validate_max_file_size
28 validate :validate_max_file_size
29
29
30 acts_as_event :title => :filename,
30 acts_as_event :title => :filename,
31 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
31 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
32
32
33 acts_as_activity_provider :type => 'files',
33 acts_as_activity_provider :type => 'files',
34 :permission => :view_files,
34 :permission => :view_files,
35 :author_key => :author_id,
35 :author_key => :author_id,
36 :find_options => {:select => "#{Attachment.table_name}.*",
36 :find_options => {:select => "#{Attachment.table_name}.*",
37 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
37 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
38 "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 "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 )"}
39
39
40 acts_as_activity_provider :type => 'documents',
40 acts_as_activity_provider :type => 'documents',
41 :permission => :view_documents,
41 :permission => :view_documents,
42 :author_key => :author_id,
42 :author_key => :author_id,
43 :find_options => {:select => "#{Attachment.table_name}.*",
43 :find_options => {:select => "#{Attachment.table_name}.*",
44 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
44 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
45 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
45 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
46
46
47 cattr_accessor :storage_path
47 cattr_accessor :storage_path
48 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
48 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
49
49
50 cattr_accessor :thumbnails_storage_path
50 cattr_accessor :thumbnails_storage_path
51 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
51 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
52
52
53 before_save :files_to_final_location
53 before_save :files_to_final_location
54 after_destroy :delete_from_disk
54 after_destroy :delete_from_disk
55
55
56 # Returns an unsaved copy of the attachment
56 # Returns an unsaved copy of the attachment
57 def copy(attributes=nil)
57 def copy(attributes=nil)
58 copy = self.class.new
58 copy = self.class.new
59 copy.attributes = self.attributes.dup.except("id", "downloads")
59 copy.attributes = self.attributes.dup.except("id", "downloads")
60 copy.attributes = attributes if attributes
60 copy.attributes = attributes if attributes
61 copy
61 copy
62 end
62 end
63
63
64 def validate_max_file_size
64 def validate_max_file_size
65 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
65 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
66 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
66 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
67 end
67 end
68 end
68 end
69
69
70 def file=(incoming_file)
70 def file=(incoming_file)
71 unless incoming_file.nil?
71 unless incoming_file.nil?
72 @temp_file = incoming_file
72 @temp_file = incoming_file
73 if @temp_file.size > 0
73 if @temp_file.size > 0
74 if @temp_file.respond_to?(:original_filename)
74 if @temp_file.respond_to?(:original_filename)
75 self.filename = @temp_file.original_filename
75 self.filename = @temp_file.original_filename
76 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
76 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
77 end
77 end
78 if @temp_file.respond_to?(:content_type)
78 if @temp_file.respond_to?(:content_type)
79 self.content_type = @temp_file.content_type.to_s.chomp
79 self.content_type = @temp_file.content_type.to_s.chomp
80 end
80 end
81 if content_type.blank? && filename.present?
81 if content_type.blank? && filename.present?
82 self.content_type = Redmine::MimeType.of(filename)
82 self.content_type = Redmine::MimeType.of(filename)
83 end
83 end
84 self.filesize = @temp_file.size
84 self.filesize = @temp_file.size
85 end
85 end
86 end
86 end
87 end
87 end
88
88
89 def file
89 def file
90 nil
90 nil
91 end
91 end
92
92
93 def filename=(arg)
93 def filename=(arg)
94 write_attribute :filename, sanitize_filename(arg.to_s)
94 write_attribute :filename, sanitize_filename(arg.to_s)
95 if new_record? && disk_filename.blank?
95 if new_record? && disk_filename.blank?
96 self.disk_filename = Attachment.disk_filename(filename)
96 self.disk_filename = Attachment.disk_filename(filename)
97 end
97 end
98 filename
98 filename
99 end
99 end
100
100
101 # Copies the temporary file to its final location
101 # Copies the temporary file to its final location
102 # and computes its MD5 hash
102 # and computes its MD5 hash
103 def files_to_final_location
103 def files_to_final_location
104 if @temp_file && (@temp_file.size > 0)
104 if @temp_file && (@temp_file.size > 0)
105 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
105 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
106 md5 = Digest::MD5.new
106 md5 = Digest::MD5.new
107 File.open(diskfile, "wb") do |f|
107 File.open(diskfile, "wb") do |f|
108 if @temp_file.respond_to?(:read)
108 if @temp_file.respond_to?(:read)
109 buffer = ""
109 buffer = ""
110 while (buffer = @temp_file.read(8192))
110 while (buffer = @temp_file.read(8192))
111 f.write(buffer)
111 f.write(buffer)
112 md5.update(buffer)
112 md5.update(buffer)
113 end
113 end
114 else
114 else
115 f.write(@temp_file)
115 f.write(@temp_file)
116 md5.update(@temp_file)
116 md5.update(@temp_file)
117 end
117 end
118 end
118 end
119 self.digest = md5.hexdigest
119 self.digest = md5.hexdigest
120 end
120 end
121 @temp_file = nil
121 @temp_file = nil
122 # Don't save the content type if it's longer than the authorized length
122 # Don't save the content type if it's longer than the authorized length
123 if self.content_type && self.content_type.length > 255
123 if self.content_type && self.content_type.length > 255
124 self.content_type = nil
124 self.content_type = nil
125 end
125 end
126 end
126 end
127
127
128 # Deletes the file from the file system if it's not referenced by other attachments
128 # Deletes the file from the file system if it's not referenced by other attachments
129 def delete_from_disk
129 def delete_from_disk
130 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
130 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
131 delete_from_disk!
131 delete_from_disk!
132 end
132 end
133 end
133 end
134
134
135 # Returns file's location on disk
135 # Returns file's location on disk
136 def diskfile
136 def diskfile
137 File.join(self.class.storage_path, disk_filename.to_s)
137 File.join(self.class.storage_path, disk_filename.to_s)
138 end
138 end
139
139
140 def title
140 def title
141 title = filename.to_s
141 title = filename.to_s
142 if description.present?
142 if description.present?
143 title << " (#{description})"
143 title << " (#{description})"
144 end
144 end
145 title
145 title
146 end
146 end
147
147
148 def increment_download
148 def increment_download
149 increment!(:downloads)
149 increment!(:downloads)
150 end
150 end
151
151
152 def project
152 def project
153 container.try(:project)
153 container.try(:project)
154 end
154 end
155
155
156 def visible?(user=User.current)
156 def visible?(user=User.current)
157 if container_id
157 container && container.attachments_visible?(user)
158 container && container.attachments_visible?(user)
159 else
160 author == user
161 end
158 end
162 end
159
163
160 def deletable?(user=User.current)
164 def deletable?(user=User.current)
165 if container_id
161 container && container.attachments_deletable?(user)
166 container && container.attachments_deletable?(user)
167 else
168 author == user
169 end
162 end
170 end
163
171
164 def image?
172 def image?
165 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
173 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
166 end
174 end
167
175
168 def thumbnailable?
176 def thumbnailable?
169 image?
177 image?
170 end
178 end
171
179
172 # Returns the full path the attachment thumbnail, or nil
180 # Returns the full path the attachment thumbnail, or nil
173 # if the thumbnail cannot be generated.
181 # if the thumbnail cannot be generated.
174 def thumbnail(options={})
182 def thumbnail(options={})
175 if thumbnailable? && readable?
183 if thumbnailable? && readable?
176 size = options[:size].to_i
184 size = options[:size].to_i
177 if size > 0
185 if size > 0
178 # Limit the number of thumbnails per image
186 # Limit the number of thumbnails per image
179 size = (size / 50) * 50
187 size = (size / 50) * 50
180 # Maximum thumbnail size
188 # Maximum thumbnail size
181 size = 800 if size > 800
189 size = 800 if size > 800
182 else
190 else
183 size = Setting.thumbnails_size.to_i
191 size = Setting.thumbnails_size.to_i
184 end
192 end
185 size = 100 unless size > 0
193 size = 100 unless size > 0
186 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
194 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
187
195
188 begin
196 begin
189 Redmine::Thumbnail.generate(self.diskfile, target, size)
197 Redmine::Thumbnail.generate(self.diskfile, target, size)
190 rescue => e
198 rescue => e
191 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
199 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
192 return nil
200 return nil
193 end
201 end
194 end
202 end
195 end
203 end
196
204
197 # Deletes all thumbnails
205 # Deletes all thumbnails
198 def self.clear_thumbnails
206 def self.clear_thumbnails
199 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
207 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
200 File.delete file
208 File.delete file
201 end
209 end
202 end
210 end
203
211
204 def is_text?
212 def is_text?
205 Redmine::MimeType.is_type?('text', filename)
213 Redmine::MimeType.is_type?('text', filename)
206 end
214 end
207
215
208 def is_diff?
216 def is_diff?
209 self.filename =~ /\.(patch|diff)$/i
217 self.filename =~ /\.(patch|diff)$/i
210 end
218 end
211
219
212 # Returns true if the file is readable
220 # Returns true if the file is readable
213 def readable?
221 def readable?
214 File.readable?(diskfile)
222 File.readable?(diskfile)
215 end
223 end
216
224
217 # Returns the attachment token
225 # Returns the attachment token
218 def token
226 def token
219 "#{id}.#{digest}"
227 "#{id}.#{digest}"
220 end
228 end
221
229
222 # Finds an attachment that matches the given token and that has no container
230 # Finds an attachment that matches the given token and that has no container
223 def self.find_by_token(token)
231 def self.find_by_token(token)
224 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
232 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
225 attachment_id, attachment_digest = $1, $2
233 attachment_id, attachment_digest = $1, $2
226 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
234 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
227 if attachment && attachment.container.nil?
235 if attachment && attachment.container.nil?
228 attachment
236 attachment
229 end
237 end
230 end
238 end
231 end
239 end
232
240
233 # Bulk attaches a set of files to an object
241 # Bulk attaches a set of files to an object
234 #
242 #
235 # Returns a Hash of the results:
243 # Returns a Hash of the results:
236 # :files => array of the attached files
244 # :files => array of the attached files
237 # :unsaved => array of the files that could not be attached
245 # :unsaved => array of the files that could not be attached
238 def self.attach_files(obj, attachments)
246 def self.attach_files(obj, attachments)
239 result = obj.save_attachments(attachments, User.current)
247 result = obj.save_attachments(attachments, User.current)
240 obj.attach_saved_attachments
248 obj.attach_saved_attachments
241 result
249 result
242 end
250 end
243
251
244 def self.latest_attach(attachments, filename)
252 def self.latest_attach(attachments, filename)
245 attachments.sort_by(&:created_on).reverse.detect {
253 attachments.sort_by(&:created_on).reverse.detect {
246 |att| att.filename.downcase == filename.downcase
254 |att| att.filename.downcase == filename.downcase
247 }
255 }
248 end
256 end
249
257
250 def self.prune(age=1.day)
258 def self.prune(age=1.day)
251 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
259 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
252 end
260 end
253
261
254 private
262 private
255
263
256 # Physically deletes the file from the file system
264 # Physically deletes the file from the file system
257 def delete_from_disk!
265 def delete_from_disk!
258 if disk_filename.present? && File.exist?(diskfile)
266 if disk_filename.present? && File.exist?(diskfile)
259 File.delete(diskfile)
267 File.delete(diskfile)
260 end
268 end
261 end
269 end
262
270
263 def sanitize_filename(value)
271 def sanitize_filename(value)
264 # get only the filename, not the whole path
272 # get only the filename, not the whole path
265 just_filename = value.gsub(/^.*(\\|\/)/, '')
273 just_filename = value.gsub(/^.*(\\|\/)/, '')
266
274
267 # Finally, replace invalid characters with underscore
275 # Finally, replace invalid characters with underscore
268 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
276 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
269 end
277 end
270
278
271 # Returns an ASCII or hashed filename
279 # Returns an ASCII or hashed filename
272 def self.disk_filename(filename)
280 def self.disk_filename(filename)
273 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
281 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
274 ascii = ''
282 ascii = ''
275 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
283 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
276 ascii = filename
284 ascii = filename
277 else
285 else
278 ascii = Digest::MD5.hexdigest(filename)
286 ascii = Digest::MD5.hexdigest(filename)
279 # keep the extension if any
287 # keep the extension if any
280 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
288 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
281 end
289 end
282 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
290 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
283 timestamp.succ!
291 timestamp.succ!
284 end
292 end
285 "#{timestamp}_#{ascii}"
293 "#{timestamp}_#{ascii}"
286 end
294 end
287 end
295 end
@@ -1,18 +1,28
1 <span id="attachments_fields">
1 <% if defined?(container) && container && container.saved_attachments %>
2 <% if defined?(container) && container && container.saved_attachments %>
2 <% container.saved_attachments.each_with_index do |attachment, i| %>
3 <% container.saved_attachments.each_with_index do |attachment, i| %>
3 <span class="icon icon-attachment" style="display:block; line-height:1.5em;">
4 <span id="attachments_p<%= i %>">
4 <%= h(attachment.filename) %> (<%= number_to_human_size(attachment.filesize) %>)
5 <%= text_field_tag("attachments[p#{i}][filename]", attachment.filename, :class => 'filename') +
5 <%= hidden_field_tag "attachments[p#{i}][token]", "#{attachment.id}.#{attachment.digest}" %>
6 text_field_tag("attachments[p#{i}][description]", attachment.description, :maxlength => 255, :placeholder => l(:label_optional_description), :class => 'description') +
7 link_to('&nbsp;'.html_safe, attachment_path(attachment, :attachment_id => "p#{i}", :format => 'js'), :method => 'delete', :remote => true, :class => 'remove-upload') %>
8 <%= hidden_field_tag "attachments[p#{i}][token]", "#{attachment.token}" %>
6 </span>
9 </span>
7 <% end %>
10 <% end %>
8 <% end %>
11 <% end %>
9 <span id="attachments_fields">
10 <span>
11 <%= file_field_tag 'attachments[1][file]', :id => nil, :class => 'file',
12 :onchange => "checkFileSize(this, #{Setting.attachment_max_size.to_i.kilobytes}, '#{escape_javascript(l(:error_attachment_too_big, :max_size => number_to_human_size(Setting.attachment_max_size.to_i.kilobytes)))}');" -%>
13 <%= text_field_tag 'attachments[1][description]', '', :id => nil, :class => 'description', :maxlength => 255, :placeholder => l(:label_optional_description) %>
14 <%= link_to_function(image_tag('delete.png'), 'removeFileField(this)', :title => (l(:button_delete))) %>
15 </span>
12 </span>
13 <span class="add_attachment">
14 <%= file_field_tag 'attachments_files',
15 :id => nil,
16 :multiple => true,
17 :onchange => 'addInputFiles(this);',
18 :data => {
19 :max_file_size => Setting.attachment_max_size.to_i.kilobytes,
20 :max_file_size_message => l(:error_attachment_too_big, :max_size => number_to_human_size(Setting.attachment_max_size.to_i.kilobytes)),
21 :max_concurrent_uploads => Redmine::Configuration['max_concurrent_ajax_uploads'].to_i,
22 :upload_path => uploads_path(:format => 'js'),
23 :description_placeholder => l(:label_optional_description)
24 } %>
25 (<%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %>)
16 </span>
26 </span>
17 <span class="add_attachment"><%= link_to l(:label_add_another_file), '#', :onclick => 'addFileField(); return false;', :class => 'add_attachment' %>
27
18 (<%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %>)</span>
28 <%= javascript_include_tag 'attachments' %>
@@ -1,3 +1,3
1 <fieldset class="preview"><legend><%= l(:label_preview) %></legend>
1 <fieldset class="preview"><legend><%= l(:label_preview) %></legend>
2 <%= textilizable @text, :attachments => @attachements, :object => @previewed %>
2 <%= textilizable @text, :attachments => @attachments, :object => @previewed %>
3 </fieldset>
3 </fieldset>
@@ -1,11 +1,11
1 <% if @notes %>
1 <% if @notes %>
2 <fieldset class="preview"><legend><%= l(:field_notes) %></legend>
2 <fieldset class="preview"><legend><%= l(:field_notes) %></legend>
3 <%= textilizable @notes, :attachments => @attachements, :object => @issue %>
3 <%= textilizable @notes, :attachments => @attachments, :object => @issue %>
4 </fieldset>
4 </fieldset>
5 <% end %>
5 <% end %>
6
6
7 <% if @description %>
7 <% if @description %>
8 <fieldset class="preview"><legend><%= l(:field_description) %></legend>
8 <fieldset class="preview"><legend><%= l(:field_description) %></legend>
9 <%= textilizable @description, :attachments => @attachements, :object => @issue %>
9 <%= textilizable @description, :attachments => @attachments, :object => @issue %>
10 </fieldset>
10 </fieldset>
11 <% end %>
11 <% end %>
@@ -1,197 +1,200
1 # = Redmine configuration file
1 # = Redmine configuration file
2 #
2 #
3 # Each environment has it's own configuration options. If you are only
3 # Each environment has it's own configuration options. If you are only
4 # running in production, only the production block needs to be configured.
4 # running in production, only the production block needs to be configured.
5 # Environment specific configuration options override the default ones.
5 # Environment specific configuration options override the default ones.
6 #
6 #
7 # Note that this file needs to be a valid YAML file.
7 # Note that this file needs to be a valid YAML file.
8 # DO NOT USE TABS! Use 2 spaces instead of tabs for identation.
8 # DO NOT USE TABS! Use 2 spaces instead of tabs for identation.
9 #
9 #
10 # == Outgoing email settings (email_delivery setting)
10 # == Outgoing email settings (email_delivery setting)
11 #
11 #
12 # === Common configurations
12 # === Common configurations
13 #
13 #
14 # ==== Sendmail command
14 # ==== Sendmail command
15 #
15 #
16 # production:
16 # production:
17 # email_delivery:
17 # email_delivery:
18 # delivery_method: :sendmail
18 # delivery_method: :sendmail
19 #
19 #
20 # ==== Simple SMTP server at localhost
20 # ==== Simple SMTP server at localhost
21 #
21 #
22 # production:
22 # production:
23 # email_delivery:
23 # email_delivery:
24 # delivery_method: :smtp
24 # delivery_method: :smtp
25 # smtp_settings:
25 # smtp_settings:
26 # address: "localhost"
26 # address: "localhost"
27 # port: 25
27 # port: 25
28 #
28 #
29 # ==== SMTP server at example.com using LOGIN authentication and checking HELO for foo.com
29 # ==== SMTP server at example.com using LOGIN authentication and checking HELO for foo.com
30 #
30 #
31 # production:
31 # production:
32 # email_delivery:
32 # email_delivery:
33 # delivery_method: :smtp
33 # delivery_method: :smtp
34 # smtp_settings:
34 # smtp_settings:
35 # address: "example.com"
35 # address: "example.com"
36 # port: 25
36 # port: 25
37 # authentication: :login
37 # authentication: :login
38 # domain: 'foo.com'
38 # domain: 'foo.com'
39 # user_name: 'myaccount'
39 # user_name: 'myaccount'
40 # password: 'password'
40 # password: 'password'
41 #
41 #
42 # ==== SMTP server at example.com using PLAIN authentication
42 # ==== SMTP server at example.com using PLAIN authentication
43 #
43 #
44 # production:
44 # production:
45 # email_delivery:
45 # email_delivery:
46 # delivery_method: :smtp
46 # delivery_method: :smtp
47 # smtp_settings:
47 # smtp_settings:
48 # address: "example.com"
48 # address: "example.com"
49 # port: 25
49 # port: 25
50 # authentication: :plain
50 # authentication: :plain
51 # domain: 'example.com'
51 # domain: 'example.com'
52 # user_name: 'myaccount'
52 # user_name: 'myaccount'
53 # password: 'password'
53 # password: 'password'
54 #
54 #
55 # ==== SMTP server at using TLS (GMail)
55 # ==== SMTP server at using TLS (GMail)
56 #
56 #
57 # This might require some additional configuration. See the guides at:
57 # This might require some additional configuration. See the guides at:
58 # http://www.redmine.org/projects/redmine/wiki/EmailConfiguration
58 # http://www.redmine.org/projects/redmine/wiki/EmailConfiguration
59 #
59 #
60 # production:
60 # production:
61 # email_delivery:
61 # email_delivery:
62 # delivery_method: :smtp
62 # delivery_method: :smtp
63 # smtp_settings:
63 # smtp_settings:
64 # enable_starttls_auto: true
64 # enable_starttls_auto: true
65 # address: "smtp.gmail.com"
65 # address: "smtp.gmail.com"
66 # port: 587
66 # port: 587
67 # domain: "smtp.gmail.com" # 'your.domain.com' for GoogleApps
67 # domain: "smtp.gmail.com" # 'your.domain.com' for GoogleApps
68 # authentication: :plain
68 # authentication: :plain
69 # user_name: "your_email@gmail.com"
69 # user_name: "your_email@gmail.com"
70 # password: "your_password"
70 # password: "your_password"
71 #
71 #
72 #
72 #
73 # === More configuration options
73 # === More configuration options
74 #
74 #
75 # See the "Configuration options" at the following website for a list of the
75 # See the "Configuration options" at the following website for a list of the
76 # full options allowed:
76 # full options allowed:
77 #
77 #
78 # http://wiki.rubyonrails.org/rails/pages/HowToSendEmailsWithActionMailer
78 # http://wiki.rubyonrails.org/rails/pages/HowToSendEmailsWithActionMailer
79
79
80
80
81 # default configuration options for all environments
81 # default configuration options for all environments
82 default:
82 default:
83 # Outgoing emails configuration (see examples above)
83 # Outgoing emails configuration (see examples above)
84 email_delivery:
84 email_delivery:
85 delivery_method: :smtp
85 delivery_method: :smtp
86 smtp_settings:
86 smtp_settings:
87 address: smtp.example.net
87 address: smtp.example.net
88 port: 25
88 port: 25
89 domain: example.net
89 domain: example.net
90 authentication: :login
90 authentication: :login
91 user_name: "redmine@example.net"
91 user_name: "redmine@example.net"
92 password: "redmine"
92 password: "redmine"
93
93
94 # Absolute path to the directory where attachments are stored.
94 # Absolute path to the directory where attachments are stored.
95 # The default is the 'files' directory in your Redmine instance.
95 # The default is the 'files' directory in your Redmine instance.
96 # Your Redmine instance needs to have write permission on this
96 # Your Redmine instance needs to have write permission on this
97 # directory.
97 # directory.
98 # Examples:
98 # Examples:
99 # attachments_storage_path: /var/redmine/files
99 # attachments_storage_path: /var/redmine/files
100 # attachments_storage_path: D:/redmine/files
100 # attachments_storage_path: D:/redmine/files
101 attachments_storage_path:
101 attachments_storage_path:
102
102
103 # Configuration of the autologin cookie.
103 # Configuration of the autologin cookie.
104 # autologin_cookie_name: the name of the cookie (default: autologin)
104 # autologin_cookie_name: the name of the cookie (default: autologin)
105 # autologin_cookie_path: the cookie path (default: /)
105 # autologin_cookie_path: the cookie path (default: /)
106 # autologin_cookie_secure: true sets the cookie secure flag (default: false)
106 # autologin_cookie_secure: true sets the cookie secure flag (default: false)
107 autologin_cookie_name:
107 autologin_cookie_name:
108 autologin_cookie_path:
108 autologin_cookie_path:
109 autologin_cookie_secure:
109 autologin_cookie_secure:
110
110
111 # Configuration of SCM executable command.
111 # Configuration of SCM executable command.
112 #
112 #
113 # Absolute path (e.g. /usr/local/bin/hg) or command name (e.g. hg.exe, bzr.exe)
113 # Absolute path (e.g. /usr/local/bin/hg) or command name (e.g. hg.exe, bzr.exe)
114 # On Windows + CRuby, *.cmd, *.bat (e.g. hg.cmd, bzr.bat) does not work.
114 # On Windows + CRuby, *.cmd, *.bat (e.g. hg.cmd, bzr.bat) does not work.
115 #
115 #
116 # On Windows + JRuby 1.6.2, path which contains spaces does not work.
116 # On Windows + JRuby 1.6.2, path which contains spaces does not work.
117 # For example, "C:\Program Files\TortoiseHg\hg.exe".
117 # For example, "C:\Program Files\TortoiseHg\hg.exe".
118 # If you want to this feature, you need to install to the path which does not contains spaces.
118 # If you want to this feature, you need to install to the path which does not contains spaces.
119 # For example, "C:\TortoiseHg\hg.exe".
119 # For example, "C:\TortoiseHg\hg.exe".
120 #
120 #
121 # Examples:
121 # Examples:
122 # scm_subversion_command: svn # (default: svn)
122 # scm_subversion_command: svn # (default: svn)
123 # scm_mercurial_command: C:\Program Files\TortoiseHg\hg.exe # (default: hg)
123 # scm_mercurial_command: C:\Program Files\TortoiseHg\hg.exe # (default: hg)
124 # scm_git_command: /usr/local/bin/git # (default: git)
124 # scm_git_command: /usr/local/bin/git # (default: git)
125 # scm_cvs_command: cvs # (default: cvs)
125 # scm_cvs_command: cvs # (default: cvs)
126 # scm_bazaar_command: bzr.exe # (default: bzr)
126 # scm_bazaar_command: bzr.exe # (default: bzr)
127 # scm_darcs_command: darcs-1.0.9-i386-linux # (default: darcs)
127 # scm_darcs_command: darcs-1.0.9-i386-linux # (default: darcs)
128 #
128 #
129 scm_subversion_command:
129 scm_subversion_command:
130 scm_mercurial_command:
130 scm_mercurial_command:
131 scm_git_command:
131 scm_git_command:
132 scm_cvs_command:
132 scm_cvs_command:
133 scm_bazaar_command:
133 scm_bazaar_command:
134 scm_darcs_command:
134 scm_darcs_command:
135
135
136 # Key used to encrypt sensitive data in the database (SCM and LDAP passwords).
136 # Key used to encrypt sensitive data in the database (SCM and LDAP passwords).
137 # If you don't want to enable data encryption, just leave it blank.
137 # If you don't want to enable data encryption, just leave it blank.
138 # WARNING: losing/changing this key will make encrypted data unreadable.
138 # WARNING: losing/changing this key will make encrypted data unreadable.
139 #
139 #
140 # If you want to encrypt existing passwords in your database:
140 # If you want to encrypt existing passwords in your database:
141 # * set the cipher key here in your configuration file
141 # * set the cipher key here in your configuration file
142 # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
142 # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
143 #
143 #
144 # If you have encrypted data and want to change this key, you have to:
144 # If you have encrypted data and want to change this key, you have to:
145 # * decrypt data using 'rake db:decrypt RAILS_ENV=production' first
145 # * decrypt data using 'rake db:decrypt RAILS_ENV=production' first
146 # * change the cipher key here in your configuration file
146 # * change the cipher key here in your configuration file
147 # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
147 # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
148 database_cipher_key:
148 database_cipher_key:
149
149
150 # Set this to false to disable plugins' assets mirroring on startup.
150 # Set this to false to disable plugins' assets mirroring on startup.
151 # You can use `rake redmine:plugins:assets` to manually mirror assets
151 # You can use `rake redmine:plugins:assets` to manually mirror assets
152 # to public/plugin_assets when you install/upgrade a Redmine plugin.
152 # to public/plugin_assets when you install/upgrade a Redmine plugin.
153 #
153 #
154 #mirror_plugins_assets_on_startup: false
154 #mirror_plugins_assets_on_startup: false
155
155
156 # Your secret key for verifying cookie session data integrity. If you
156 # Your secret key for verifying cookie session data integrity. If you
157 # change this key, all old sessions will become invalid! Make sure the
157 # change this key, all old sessions will become invalid! Make sure the
158 # secret is at least 30 characters and all random, no regular words or
158 # secret is at least 30 characters and all random, no regular words or
159 # you'll be exposed to dictionary attacks.
159 # you'll be exposed to dictionary attacks.
160 #
160 #
161 # If you have a load-balancing Redmine cluster, you have to use the
161 # If you have a load-balancing Redmine cluster, you have to use the
162 # same secret token on each machine.
162 # same secret token on each machine.
163 #secret_token: 'change it to a long random string'
163 #secret_token: 'change it to a long random string'
164
164
165 # Absolute path (e.g. /usr/bin/convert, c:/im/convert.exe) to
165 # Absolute path (e.g. /usr/bin/convert, c:/im/convert.exe) to
166 # the ImageMagick's `convert` binary. Used to generate attachment thumbnails.
166 # the ImageMagick's `convert` binary. Used to generate attachment thumbnails.
167 #imagemagick_convert_command:
167 #imagemagick_convert_command:
168
168
169 # Configuration of RMagcik font.
169 # Configuration of RMagcik font.
170 #
170 #
171 # Redmine uses RMagcik in order to export gantt png.
171 # Redmine uses RMagcik in order to export gantt png.
172 # You don't need this setting if you don't install RMagcik.
172 # You don't need this setting if you don't install RMagcik.
173 #
173 #
174 # In CJK (Chinese, Japanese and Korean),
174 # In CJK (Chinese, Japanese and Korean),
175 # in order to show CJK characters correctly,
175 # in order to show CJK characters correctly,
176 # you need to set this configuration.
176 # you need to set this configuration.
177 #
177 #
178 # Because there is no standard font across platforms in CJK,
178 # Because there is no standard font across platforms in CJK,
179 # you need to set a font installed in your server.
179 # you need to set a font installed in your server.
180 #
180 #
181 # This setting is not necessary in non CJK.
181 # This setting is not necessary in non CJK.
182 #
182 #
183 # Examples for Japanese:
183 # Examples for Japanese:
184 # Windows:
184 # Windows:
185 # rmagick_font_path: C:\windows\fonts\msgothic.ttc
185 # rmagick_font_path: C:\windows\fonts\msgothic.ttc
186 # Linux:
186 # Linux:
187 # rmagick_font_path: /usr/share/fonts/ipa-mincho/ipam.ttf
187 # rmagick_font_path: /usr/share/fonts/ipa-mincho/ipam.ttf
188 #
188 #
189 rmagick_font_path:
189 rmagick_font_path:
190
190
191 # Maximum number of simultaneous AJAX uploads
192 #max_concurrent_ajax_uploads: 2
193
191 # specific configuration options for production environment
194 # specific configuration options for production environment
192 # that overrides the default ones
195 # that overrides the default ones
193 production:
196 production:
194
197
195 # specific configuration options for development environment
198 # specific configuration options for development environment
196 # that overrides the default ones
199 # that overrides the default ones
197 development:
200 development:
@@ -1,113 +1,114
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine
18 module Redmine
19 module Configuration
19 module Configuration
20
20
21 # Configuration default values
21 # Configuration default values
22 @defaults = {
22 @defaults = {
23 'email_delivery' => nil
23 'email_delivery' => nil,
24 'max_concurrent_ajax_uploads' => 2
24 }
25 }
25
26
26 @config = nil
27 @config = nil
27
28
28 class << self
29 class << self
29 # Loads the Redmine configuration file
30 # Loads the Redmine configuration file
30 # Valid options:
31 # Valid options:
31 # * <tt>:file</tt>: the configuration file to load (default: config/configuration.yml)
32 # * <tt>:file</tt>: the configuration file to load (default: config/configuration.yml)
32 # * <tt>:env</tt>: the environment to load the configuration for (default: Rails.env)
33 # * <tt>:env</tt>: the environment to load the configuration for (default: Rails.env)
33 def load(options={})
34 def load(options={})
34 filename = options[:file] || File.join(Rails.root, 'config', 'configuration.yml')
35 filename = options[:file] || File.join(Rails.root, 'config', 'configuration.yml')
35 env = options[:env] || Rails.env
36 env = options[:env] || Rails.env
36
37
37 @config = @defaults.dup
38 @config = @defaults.dup
38
39
39 load_deprecated_email_configuration(env)
40 load_deprecated_email_configuration(env)
40 if File.file?(filename)
41 if File.file?(filename)
41 @config.merge!(load_from_yaml(filename, env))
42 @config.merge!(load_from_yaml(filename, env))
42 end
43 end
43
44
44 # Compatibility mode for those who copy email.yml over configuration.yml
45 # Compatibility mode for those who copy email.yml over configuration.yml
45 %w(delivery_method smtp_settings sendmail_settings).each do |key|
46 %w(delivery_method smtp_settings sendmail_settings).each do |key|
46 if value = @config.delete(key)
47 if value = @config.delete(key)
47 @config['email_delivery'] ||= {}
48 @config['email_delivery'] ||= {}
48 @config['email_delivery'][key] = value
49 @config['email_delivery'][key] = value
49 end
50 end
50 end
51 end
51
52
52 if @config['email_delivery']
53 if @config['email_delivery']
53 ActionMailer::Base.perform_deliveries = true
54 ActionMailer::Base.perform_deliveries = true
54 @config['email_delivery'].each do |k, v|
55 @config['email_delivery'].each do |k, v|
55 v.symbolize_keys! if v.respond_to?(:symbolize_keys!)
56 v.symbolize_keys! if v.respond_to?(:symbolize_keys!)
56 ActionMailer::Base.send("#{k}=", v)
57 ActionMailer::Base.send("#{k}=", v)
57 end
58 end
58 end
59 end
59
60
60 @config
61 @config
61 end
62 end
62
63
63 # Returns a configuration setting
64 # Returns a configuration setting
64 def [](name)
65 def [](name)
65 load unless @config
66 load unless @config
66 @config[name]
67 @config[name]
67 end
68 end
68
69
69 # Yields a block with the specified hash configuration settings
70 # Yields a block with the specified hash configuration settings
70 def with(settings)
71 def with(settings)
71 settings.stringify_keys!
72 settings.stringify_keys!
72 load unless @config
73 load unless @config
73 was = settings.keys.inject({}) {|h,v| h[v] = @config[v]; h}
74 was = settings.keys.inject({}) {|h,v| h[v] = @config[v]; h}
74 @config.merge! settings
75 @config.merge! settings
75 yield if block_given?
76 yield if block_given?
76 @config.merge! was
77 @config.merge! was
77 end
78 end
78
79
79 private
80 private
80
81
81 def load_from_yaml(filename, env)
82 def load_from_yaml(filename, env)
82 yaml = nil
83 yaml = nil
83 begin
84 begin
84 yaml = YAML::load_file(filename)
85 yaml = YAML::load_file(filename)
85 rescue ArgumentError
86 rescue ArgumentError
86 $stderr.puts "Your Redmine configuration file located at #{filename} is not a valid YAML file and could not be loaded."
87 $stderr.puts "Your Redmine configuration file located at #{filename} is not a valid YAML file and could not be loaded."
87 exit 1
88 exit 1
88 end
89 end
89 conf = {}
90 conf = {}
90 if yaml.is_a?(Hash)
91 if yaml.is_a?(Hash)
91 if yaml['default']
92 if yaml['default']
92 conf.merge!(yaml['default'])
93 conf.merge!(yaml['default'])
93 end
94 end
94 if yaml[env]
95 if yaml[env]
95 conf.merge!(yaml[env])
96 conf.merge!(yaml[env])
96 end
97 end
97 else
98 else
98 $stderr.puts "Your Redmine configuration file located at #{filename} is not a valid Redmine configuration file."
99 $stderr.puts "Your Redmine configuration file located at #{filename} is not a valid Redmine configuration file."
99 exit 1
100 exit 1
100 end
101 end
101 conf
102 conf
102 end
103 end
103
104
104 def load_deprecated_email_configuration(env)
105 def load_deprecated_email_configuration(env)
105 deprecated_email_conf = File.join(Rails.root, 'config', 'email.yml')
106 deprecated_email_conf = File.join(Rails.root, 'config', 'email.yml')
106 if File.file?(deprecated_email_conf)
107 if File.file?(deprecated_email_conf)
107 warn "Storing outgoing emails configuration in config/email.yml is deprecated. You should now store it in config/configuration.yml using the email_delivery setting."
108 warn "Storing outgoing emails configuration in config/email.yml is deprecated. You should now store it in config/configuration.yml using the email_delivery setting."
108 @config.merge!({'email_delivery' => load_from_yaml(deprecated_email_conf, env)})
109 @config.merge!({'email_delivery' => load_from_yaml(deprecated_email_conf, env)})
109 end
110 end
110 end
111 end
111 end
112 end
112 end
113 end
113 end
114 end
@@ -1,611 +1,582
1 /* Redmine - project management software
1 /* Redmine - project management software
2 Copyright (C) 2006-2012 Jean-Philippe Lang */
2 Copyright (C) 2006-2012 Jean-Philippe Lang */
3
3
4 function checkAll(id, checked) {
4 function checkAll(id, checked) {
5 if (checked) {
5 if (checked) {
6 $('#'+id).find('input[type=checkbox]').attr('checked', true);
6 $('#'+id).find('input[type=checkbox]').attr('checked', true);
7 } else {
7 } else {
8 $('#'+id).find('input[type=checkbox]').removeAttr('checked');
8 $('#'+id).find('input[type=checkbox]').removeAttr('checked');
9 }
9 }
10 }
10 }
11
11
12 function toggleCheckboxesBySelector(selector) {
12 function toggleCheckboxesBySelector(selector) {
13 var all_checked = true;
13 var all_checked = true;
14 $(selector).each(function(index) {
14 $(selector).each(function(index) {
15 if (!$(this).is(':checked')) { all_checked = false; }
15 if (!$(this).is(':checked')) { all_checked = false; }
16 });
16 });
17 $(selector).attr('checked', !all_checked)
17 $(selector).attr('checked', !all_checked)
18 }
18 }
19
19
20 function showAndScrollTo(id, focus) {
20 function showAndScrollTo(id, focus) {
21 $('#'+id).show();
21 $('#'+id).show();
22 if (focus!=null) {
22 if (focus!=null) {
23 $('#'+focus).focus();
23 $('#'+focus).focus();
24 }
24 }
25 $('html, body').animate({scrollTop: $('#'+id).offset().top}, 100);
25 $('html, body').animate({scrollTop: $('#'+id).offset().top}, 100);
26 }
26 }
27
27
28 function toggleRowGroup(el) {
28 function toggleRowGroup(el) {
29 var tr = $(el).parents('tr').first();
29 var tr = $(el).parents('tr').first();
30 var n = tr.next();
30 var n = tr.next();
31 tr.toggleClass('open');
31 tr.toggleClass('open');
32 while (n.length && !n.hasClass('group')) {
32 while (n.length && !n.hasClass('group')) {
33 n.toggle();
33 n.toggle();
34 n = n.next('tr');
34 n = n.next('tr');
35 }
35 }
36 }
36 }
37
37
38 function collapseAllRowGroups(el) {
38 function collapseAllRowGroups(el) {
39 var tbody = $(el).parents('tbody').first();
39 var tbody = $(el).parents('tbody').first();
40 tbody.children('tr').each(function(index) {
40 tbody.children('tr').each(function(index) {
41 if ($(this).hasClass('group')) {
41 if ($(this).hasClass('group')) {
42 $(this).removeClass('open');
42 $(this).removeClass('open');
43 } else {
43 } else {
44 $(this).hide();
44 $(this).hide();
45 }
45 }
46 });
46 });
47 }
47 }
48
48
49 function expandAllRowGroups(el) {
49 function expandAllRowGroups(el) {
50 var tbody = $(el).parents('tbody').first();
50 var tbody = $(el).parents('tbody').first();
51 tbody.children('tr').each(function(index) {
51 tbody.children('tr').each(function(index) {
52 if ($(this).hasClass('group')) {
52 if ($(this).hasClass('group')) {
53 $(this).addClass('open');
53 $(this).addClass('open');
54 } else {
54 } else {
55 $(this).show();
55 $(this).show();
56 }
56 }
57 });
57 });
58 }
58 }
59
59
60 function toggleAllRowGroups(el) {
60 function toggleAllRowGroups(el) {
61 var tr = $(el).parents('tr').first();
61 var tr = $(el).parents('tr').first();
62 if (tr.hasClass('open')) {
62 if (tr.hasClass('open')) {
63 collapseAllRowGroups(el);
63 collapseAllRowGroups(el);
64 } else {
64 } else {
65 expandAllRowGroups(el);
65 expandAllRowGroups(el);
66 }
66 }
67 }
67 }
68
68
69 function toggleFieldset(el) {
69 function toggleFieldset(el) {
70 var fieldset = $(el).parents('fieldset').first();
70 var fieldset = $(el).parents('fieldset').first();
71 fieldset.toggleClass('collapsed');
71 fieldset.toggleClass('collapsed');
72 fieldset.children('div').toggle();
72 fieldset.children('div').toggle();
73 }
73 }
74
74
75 function hideFieldset(el) {
75 function hideFieldset(el) {
76 var fieldset = $(el).parents('fieldset').first();
76 var fieldset = $(el).parents('fieldset').first();
77 fieldset.toggleClass('collapsed');
77 fieldset.toggleClass('collapsed');
78 fieldset.children('div').hide();
78 fieldset.children('div').hide();
79 }
79 }
80
80
81 function initFilters(){
81 function initFilters(){
82 $('#add_filter_select').change(function(){
82 $('#add_filter_select').change(function(){
83 addFilter($(this).val(), '', []);
83 addFilter($(this).val(), '', []);
84 });
84 });
85 $('#filters-table td.field input[type=checkbox]').each(function(){
85 $('#filters-table td.field input[type=checkbox]').each(function(){
86 toggleFilter($(this).val());
86 toggleFilter($(this).val());
87 });
87 });
88 $('#filters-table td.field input[type=checkbox]').live('click',function(){
88 $('#filters-table td.field input[type=checkbox]').live('click',function(){
89 toggleFilter($(this).val());
89 toggleFilter($(this).val());
90 });
90 });
91 $('#filters-table .toggle-multiselect').live('click',function(){
91 $('#filters-table .toggle-multiselect').live('click',function(){
92 toggleMultiSelect($(this).siblings('select'));
92 toggleMultiSelect($(this).siblings('select'));
93 });
93 });
94 $('#filters-table input[type=text]').live('keypress', function(e){
94 $('#filters-table input[type=text]').live('keypress', function(e){
95 if (e.keyCode == 13) submit_query_form("query_form");
95 if (e.keyCode == 13) submit_query_form("query_form");
96 });
96 });
97 }
97 }
98
98
99 function addFilter(field, operator, values) {
99 function addFilter(field, operator, values) {
100 var fieldId = field.replace('.', '_');
100 var fieldId = field.replace('.', '_');
101 var tr = $('#tr_'+fieldId);
101 var tr = $('#tr_'+fieldId);
102 if (tr.length > 0) {
102 if (tr.length > 0) {
103 tr.show();
103 tr.show();
104 } else {
104 } else {
105 buildFilterRow(field, operator, values);
105 buildFilterRow(field, operator, values);
106 }
106 }
107 $('#cb_'+fieldId).attr('checked', true);
107 $('#cb_'+fieldId).attr('checked', true);
108 toggleFilter(field);
108 toggleFilter(field);
109 $('#add_filter_select').val('').children('option').each(function(){
109 $('#add_filter_select').val('').children('option').each(function(){
110 if ($(this).attr('value') == field) {
110 if ($(this).attr('value') == field) {
111 $(this).attr('disabled', true);
111 $(this).attr('disabled', true);
112 }
112 }
113 });
113 });
114 }
114 }
115
115
116 function buildFilterRow(field, operator, values) {
116 function buildFilterRow(field, operator, values) {
117 var fieldId = field.replace('.', '_');
117 var fieldId = field.replace('.', '_');
118 var filterTable = $("#filters-table");
118 var filterTable = $("#filters-table");
119 var filterOptions = availableFilters[field];
119 var filterOptions = availableFilters[field];
120 var operators = operatorByType[filterOptions['type']];
120 var operators = operatorByType[filterOptions['type']];
121 var filterValues = filterOptions['values'];
121 var filterValues = filterOptions['values'];
122 var i, select;
122 var i, select;
123
123
124 var tr = $('<tr class="filter">').attr('id', 'tr_'+fieldId).html(
124 var tr = $('<tr class="filter">').attr('id', 'tr_'+fieldId).html(
125 '<td class="field"><input checked="checked" id="cb_'+fieldId+'" name="f[]" value="'+field+'" type="checkbox"><label for="cb_'+fieldId+'"> '+filterOptions['name']+'</label></td>' +
125 '<td class="field"><input checked="checked" id="cb_'+fieldId+'" name="f[]" value="'+field+'" type="checkbox"><label for="cb_'+fieldId+'"> '+filterOptions['name']+'</label></td>' +
126 '<td class="operator"><select id="operators_'+fieldId+'" name="op['+field+']"></td>' +
126 '<td class="operator"><select id="operators_'+fieldId+'" name="op['+field+']"></td>' +
127 '<td class="values"></td>'
127 '<td class="values"></td>'
128 );
128 );
129 filterTable.append(tr);
129 filterTable.append(tr);
130
130
131 select = tr.find('td.operator select');
131 select = tr.find('td.operator select');
132 for (i=0;i<operators.length;i++){
132 for (i=0;i<operators.length;i++){
133 var option = $('<option>').val(operators[i]).text(operatorLabels[operators[i]]);
133 var option = $('<option>').val(operators[i]).text(operatorLabels[operators[i]]);
134 if (operators[i] == operator) {option.attr('selected', true)};
134 if (operators[i] == operator) {option.attr('selected', true)};
135 select.append(option);
135 select.append(option);
136 }
136 }
137 select.change(function(){toggleOperator(field)});
137 select.change(function(){toggleOperator(field)});
138
138
139 switch (filterOptions['type']){
139 switch (filterOptions['type']){
140 case "list":
140 case "list":
141 case "list_optional":
141 case "list_optional":
142 case "list_status":
142 case "list_status":
143 case "list_subprojects":
143 case "list_subprojects":
144 tr.find('td.values').append(
144 tr.find('td.values').append(
145 '<span style="display:none;"><select class="value" id="values_'+fieldId+'_1" name="v['+field+'][]"></select>' +
145 '<span style="display:none;"><select class="value" id="values_'+fieldId+'_1" name="v['+field+'][]"></select>' +
146 ' <span class="toggle-multiselect">&nbsp;</span></span>'
146 ' <span class="toggle-multiselect">&nbsp;</span></span>'
147 );
147 );
148 select = tr.find('td.values select');
148 select = tr.find('td.values select');
149 if (values.length > 1) {select.attr('multiple', true)};
149 if (values.length > 1) {select.attr('multiple', true)};
150 for (i=0;i<filterValues.length;i++){
150 for (i=0;i<filterValues.length;i++){
151 var filterValue = filterValues[i];
151 var filterValue = filterValues[i];
152 var option = $('<option>');
152 var option = $('<option>');
153 if ($.isArray(filterValue)) {
153 if ($.isArray(filterValue)) {
154 option.val(filterValue[1]).text(filterValue[0]);
154 option.val(filterValue[1]).text(filterValue[0]);
155 if ($.inArray(filterValue[1], values) > -1) {option.attr('selected', true);}
155 if ($.inArray(filterValue[1], values) > -1) {option.attr('selected', true);}
156 } else {
156 } else {
157 option.val(filterValue).text(filterValue);
157 option.val(filterValue).text(filterValue);
158 if ($.inArray(filterValue, values) > -1) {option.attr('selected', true);}
158 if ($.inArray(filterValue, values) > -1) {option.attr('selected', true);}
159 }
159 }
160 select.append(option);
160 select.append(option);
161 }
161 }
162 break;
162 break;
163 case "date":
163 case "date":
164 case "date_past":
164 case "date_past":
165 tr.find('td.values').append(
165 tr.find('td.values').append(
166 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="10" class="value date_value" /></span>' +
166 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="10" class="value date_value" /></span>' +
167 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="10" class="value date_value" /></span>' +
167 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="10" class="value date_value" /></span>' +
168 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="3" class="value" /> '+labelDayPlural+'</span>'
168 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="3" class="value" /> '+labelDayPlural+'</span>'
169 );
169 );
170 $('#values_'+fieldId+'_1').val(values[0]).datepicker(datepickerOptions);
170 $('#values_'+fieldId+'_1').val(values[0]).datepicker(datepickerOptions);
171 $('#values_'+fieldId+'_2').val(values[1]).datepicker(datepickerOptions);
171 $('#values_'+fieldId+'_2').val(values[1]).datepicker(datepickerOptions);
172 $('#values_'+fieldId).val(values[0]);
172 $('#values_'+fieldId).val(values[0]);
173 break;
173 break;
174 case "string":
174 case "string":
175 case "text":
175 case "text":
176 tr.find('td.values').append(
176 tr.find('td.values').append(
177 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="30" class="value" /></span>'
177 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="30" class="value" /></span>'
178 );
178 );
179 $('#values_'+fieldId).val(values[0]);
179 $('#values_'+fieldId).val(values[0]);
180 break;
180 break;
181 case "relation":
181 case "relation":
182 tr.find('td.values').append(
182 tr.find('td.values').append(
183 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="6" class="value" /></span>' +
183 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="6" class="value" /></span>' +
184 '<span style="display:none;"><select class="value" name="v['+field+'][]" id="values_'+fieldId+'_1"></select></span>'
184 '<span style="display:none;"><select class="value" name="v['+field+'][]" id="values_'+fieldId+'_1"></select></span>'
185 );
185 );
186 $('#values_'+fieldId).val(values[0]);
186 $('#values_'+fieldId).val(values[0]);
187 select = tr.find('td.values select');
187 select = tr.find('td.values select');
188 for (i=0;i<allProjects.length;i++){
188 for (i=0;i<allProjects.length;i++){
189 var filterValue = allProjects[i];
189 var filterValue = allProjects[i];
190 var option = $('<option>');
190 var option = $('<option>');
191 option.val(filterValue[1]).text(filterValue[0]);
191 option.val(filterValue[1]).text(filterValue[0]);
192 if (values[0] == filterValue[1]) {option.attr('selected', true)};
192 if (values[0] == filterValue[1]) {option.attr('selected', true)};
193 select.append(option);
193 select.append(option);
194 }
194 }
195 case "integer":
195 case "integer":
196 case "float":
196 case "float":
197 tr.find('td.values').append(
197 tr.find('td.values').append(
198 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="6" class="value" /></span>' +
198 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="6" class="value" /></span>' +
199 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="6" class="value" /></span>'
199 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="6" class="value" /></span>'
200 );
200 );
201 $('#values_'+fieldId+'_1').val(values[0]);
201 $('#values_'+fieldId+'_1').val(values[0]);
202 $('#values_'+fieldId+'_2').val(values[1]);
202 $('#values_'+fieldId+'_2').val(values[1]);
203 break;
203 break;
204 }
204 }
205 }
205 }
206
206
207 function toggleFilter(field) {
207 function toggleFilter(field) {
208 var fieldId = field.replace('.', '_');
208 var fieldId = field.replace('.', '_');
209 if ($('#cb_' + fieldId).is(':checked')) {
209 if ($('#cb_' + fieldId).is(':checked')) {
210 $("#operators_" + fieldId).show().removeAttr('disabled');
210 $("#operators_" + fieldId).show().removeAttr('disabled');
211 toggleOperator(field);
211 toggleOperator(field);
212 } else {
212 } else {
213 $("#operators_" + fieldId).hide().attr('disabled', true);
213 $("#operators_" + fieldId).hide().attr('disabled', true);
214 enableValues(field, []);
214 enableValues(field, []);
215 }
215 }
216 }
216 }
217
217
218 function enableValues(field, indexes) {
218 function enableValues(field, indexes) {
219 var fieldId = field.replace('.', '_');
219 var fieldId = field.replace('.', '_');
220 $('#tr_'+fieldId+' td.values .value').each(function(index) {
220 $('#tr_'+fieldId+' td.values .value').each(function(index) {
221 if ($.inArray(index, indexes) >= 0) {
221 if ($.inArray(index, indexes) >= 0) {
222 $(this).removeAttr('disabled');
222 $(this).removeAttr('disabled');
223 $(this).parents('span').first().show();
223 $(this).parents('span').first().show();
224 } else {
224 } else {
225 $(this).val('');
225 $(this).val('');
226 $(this).attr('disabled', true);
226 $(this).attr('disabled', true);
227 $(this).parents('span').first().hide();
227 $(this).parents('span').first().hide();
228 }
228 }
229
229
230 if ($(this).hasClass('group')) {
230 if ($(this).hasClass('group')) {
231 $(this).addClass('open');
231 $(this).addClass('open');
232 } else {
232 } else {
233 $(this).show();
233 $(this).show();
234 }
234 }
235 });
235 });
236 }
236 }
237
237
238 function toggleOperator(field) {
238 function toggleOperator(field) {
239 var fieldId = field.replace('.', '_');
239 var fieldId = field.replace('.', '_');
240 var operator = $("#operators_" + fieldId);
240 var operator = $("#operators_" + fieldId);
241 switch (operator.val()) {
241 switch (operator.val()) {
242 case "!*":
242 case "!*":
243 case "*":
243 case "*":
244 case "t":
244 case "t":
245 case "ld":
245 case "ld":
246 case "w":
246 case "w":
247 case "lw":
247 case "lw":
248 case "l2w":
248 case "l2w":
249 case "m":
249 case "m":
250 case "lm":
250 case "lm":
251 case "y":
251 case "y":
252 case "o":
252 case "o":
253 case "c":
253 case "c":
254 enableValues(field, []);
254 enableValues(field, []);
255 break;
255 break;
256 case "><":
256 case "><":
257 enableValues(field, [0,1]);
257 enableValues(field, [0,1]);
258 break;
258 break;
259 case "<t+":
259 case "<t+":
260 case ">t+":
260 case ">t+":
261 case "><t+":
261 case "><t+":
262 case "t+":
262 case "t+":
263 case ">t-":
263 case ">t-":
264 case "<t-":
264 case "<t-":
265 case "><t-":
265 case "><t-":
266 case "t-":
266 case "t-":
267 enableValues(field, [2]);
267 enableValues(field, [2]);
268 break;
268 break;
269 case "=p":
269 case "=p":
270 case "=!p":
270 case "=!p":
271 case "!p":
271 case "!p":
272 enableValues(field, [1]);
272 enableValues(field, [1]);
273 break;
273 break;
274 default:
274 default:
275 enableValues(field, [0]);
275 enableValues(field, [0]);
276 break;
276 break;
277 }
277 }
278 }
278 }
279
279
280 function toggleMultiSelect(el) {
280 function toggleMultiSelect(el) {
281 if (el.attr('multiple')) {
281 if (el.attr('multiple')) {
282 el.removeAttr('multiple');
282 el.removeAttr('multiple');
283 } else {
283 } else {
284 el.attr('multiple', true);
284 el.attr('multiple', true);
285 }
285 }
286 }
286 }
287
287
288 function submit_query_form(id) {
288 function submit_query_form(id) {
289 selectAllOptions("selected_columns");
289 selectAllOptions("selected_columns");
290 $('#'+id).submit();
290 $('#'+id).submit();
291 }
291 }
292
292
293 var fileFieldCount = 1;
294 function addFileField() {
295 var fields = $('#attachments_fields');
296 if (fields.children().length >= 10) return false;
297 fileFieldCount++;
298 var s = fields.children('span').first().clone();
299 s.children('input.file').attr('name', "attachments[" + fileFieldCount + "][file]").val('');
300 s.children('input.description').attr('name', "attachments[" + fileFieldCount + "][description]").val('');
301 fields.append(s);
302 }
303
304 function removeFileField(el) {
305 var fields = $('#attachments_fields');
306 var s = $(el).parents('span').first();
307 if (fields.children().length > 1) {
308 s.remove();
309 } else {
310 s.children('input.file').val('');
311 s.children('input.description').val('');
312 }
313 }
314
315 function checkFileSize(el, maxSize, message) {
316 var files = el.files;
317 if (files) {
318 for (var i=0; i<files.length; i++) {
319 if (files[i].size > maxSize) {
320 alert(message);
321 el.value = "";
322 }
323 }
324 }
325 }
326
327 function showTab(name) {
293 function showTab(name) {
328 $('div#content .tab-content').hide();
294 $('div#content .tab-content').hide();
329 $('div.tabs a').removeClass('selected');
295 $('div.tabs a').removeClass('selected');
330 $('#tab-content-' + name).show();
296 $('#tab-content-' + name).show();
331 $('#tab-' + name).addClass('selected');
297 $('#tab-' + name).addClass('selected');
332 return false;
298 return false;
333 }
299 }
334
300
335 function moveTabRight(el) {
301 function moveTabRight(el) {
336 var lis = $(el).parents('div.tabs').first().find('ul').children();
302 var lis = $(el).parents('div.tabs').first().find('ul').children();
337 var tabsWidth = 0;
303 var tabsWidth = 0;
338 var i = 0;
304 var i = 0;
339 lis.each(function(){
305 lis.each(function(){
340 if ($(this).is(':visible')) {
306 if ($(this).is(':visible')) {
341 tabsWidth += $(this).width() + 6;
307 tabsWidth += $(this).width() + 6;
342 }
308 }
343 });
309 });
344 if (tabsWidth < $(el).parents('div.tabs').first().width() - 60) { return; }
310 if (tabsWidth < $(el).parents('div.tabs').first().width() - 60) { return; }
345 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
311 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
346 lis.eq(i).hide();
312 lis.eq(i).hide();
347 }
313 }
348
314
349 function moveTabLeft(el) {
315 function moveTabLeft(el) {
350 var lis = $(el).parents('div.tabs').first().find('ul').children();
316 var lis = $(el).parents('div.tabs').first().find('ul').children();
351 var i = 0;
317 var i = 0;
352 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
318 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
353 if (i>0) {
319 if (i>0) {
354 lis.eq(i-1).show();
320 lis.eq(i-1).show();
355 }
321 }
356 }
322 }
357
323
358 function displayTabsButtons() {
324 function displayTabsButtons() {
359 var lis;
325 var lis;
360 var tabsWidth = 0;
326 var tabsWidth = 0;
361 var el;
327 var el;
362 $('div.tabs').each(function() {
328 $('div.tabs').each(function() {
363 el = $(this);
329 el = $(this);
364 lis = el.find('ul').children();
330 lis = el.find('ul').children();
365 lis.each(function(){
331 lis.each(function(){
366 if ($(this).is(':visible')) {
332 if ($(this).is(':visible')) {
367 tabsWidth += $(this).width() + 6;
333 tabsWidth += $(this).width() + 6;
368 }
334 }
369 });
335 });
370 if ((tabsWidth < el.width() - 60) && (lis.first().is(':visible'))) {
336 if ((tabsWidth < el.width() - 60) && (lis.first().is(':visible'))) {
371 el.find('div.tabs-buttons').hide();
337 el.find('div.tabs-buttons').hide();
372 } else {
338 } else {
373 el.find('div.tabs-buttons').show();
339 el.find('div.tabs-buttons').show();
374 }
340 }
375 });
341 });
376 }
342 }
377
343
378 function setPredecessorFieldsVisibility() {
344 function setPredecessorFieldsVisibility() {
379 var relationType = $('#relation_relation_type');
345 var relationType = $('#relation_relation_type');
380 if (relationType.val() == "precedes" || relationType.val() == "follows") {
346 if (relationType.val() == "precedes" || relationType.val() == "follows") {
381 $('#predecessor_fields').show();
347 $('#predecessor_fields').show();
382 } else {
348 } else {
383 $('#predecessor_fields').hide();
349 $('#predecessor_fields').hide();
384 }
350 }
385 }
351 }
386
352
387 function showModal(id, width) {
353 function showModal(id, width) {
388 var el = $('#'+id).first();
354 var el = $('#'+id).first();
389 if (el.length == 0 || el.is(':visible')) {return;}
355 if (el.length == 0 || el.is(':visible')) {return;}
390 var title = el.find('h3.title').text();
356 var title = el.find('h3.title').text();
391 el.dialog({
357 el.dialog({
392 width: width,
358 width: width,
393 modal: true,
359 modal: true,
394 resizable: false,
360 resizable: false,
395 dialogClass: 'modal',
361 dialogClass: 'modal',
396 title: title
362 title: title
397 });
363 });
398 el.find("input[type=text], input[type=submit]").first().focus();
364 el.find("input[type=text], input[type=submit]").first().focus();
399 }
365 }
400
366
401 function hideModal(el) {
367 function hideModal(el) {
402 var modal;
368 var modal;
403 if (el) {
369 if (el) {
404 modal = $(el).parents('.ui-dialog-content');
370 modal = $(el).parents('.ui-dialog-content');
405 } else {
371 } else {
406 modal = $('#ajax-modal');
372 modal = $('#ajax-modal');
407 }
373 }
408 modal.dialog("close");
374 modal.dialog("close");
409 }
375 }
410
376
411 function submitPreview(url, form, target) {
377 function submitPreview(url, form, target) {
412 $.ajax({
378 $.ajax({
413 url: url,
379 url: url,
414 type: 'post',
380 type: 'post',
415 data: $('#'+form).serialize(),
381 data: $('#'+form).serialize(),
416 success: function(data){
382 success: function(data){
417 $('#'+target).html(data);
383 $('#'+target).html(data);
418 }
384 }
419 });
385 });
420 }
386 }
421
387
422 function collapseScmEntry(id) {
388 function collapseScmEntry(id) {
423 $('.'+id).each(function() {
389 $('.'+id).each(function() {
424 if ($(this).hasClass('open')) {
390 if ($(this).hasClass('open')) {
425 collapseScmEntry($(this).attr('id'));
391 collapseScmEntry($(this).attr('id'));
426 }
392 }
427 $(this).hide();
393 $(this).hide();
428 });
394 });
429 $('#'+id).removeClass('open');
395 $('#'+id).removeClass('open');
430 }
396 }
431
397
432 function expandScmEntry(id) {
398 function expandScmEntry(id) {
433 $('.'+id).each(function() {
399 $('.'+id).each(function() {
434 $(this).show();
400 $(this).show();
435 if ($(this).hasClass('loaded') && !$(this).hasClass('collapsed')) {
401 if ($(this).hasClass('loaded') && !$(this).hasClass('collapsed')) {
436 expandScmEntry($(this).attr('id'));
402 expandScmEntry($(this).attr('id'));
437 }
403 }
438 });
404 });
439 $('#'+id).addClass('open');
405 $('#'+id).addClass('open');
440 }
406 }
441
407
442 function scmEntryClick(id, url) {
408 function scmEntryClick(id, url) {
443 el = $('#'+id);
409 el = $('#'+id);
444 if (el.hasClass('open')) {
410 if (el.hasClass('open')) {
445 collapseScmEntry(id);
411 collapseScmEntry(id);
446 el.addClass('collapsed');
412 el.addClass('collapsed');
447 return false;
413 return false;
448 } else if (el.hasClass('loaded')) {
414 } else if (el.hasClass('loaded')) {
449 expandScmEntry(id);
415 expandScmEntry(id);
450 el.removeClass('collapsed');
416 el.removeClass('collapsed');
451 return false;
417 return false;
452 }
418 }
453 if (el.hasClass('loading')) {
419 if (el.hasClass('loading')) {
454 return false;
420 return false;
455 }
421 }
456 el.addClass('loading');
422 el.addClass('loading');
457 $.ajax({
423 $.ajax({
458 url: url,
424 url: url,
459 success: function(data){
425 success: function(data){
460 el.after(data);
426 el.after(data);
461 el.addClass('open').addClass('loaded').removeClass('loading');
427 el.addClass('open').addClass('loaded').removeClass('loading');
462 }
428 }
463 });
429 });
464 return true;
430 return true;
465 }
431 }
466
432
467 function randomKey(size) {
433 function randomKey(size) {
468 var chars = new Array('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z');
434 var chars = new Array('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z');
469 var key = '';
435 var key = '';
470 for (i = 0; i < size; i++) {
436 for (i = 0; i < size; i++) {
471 key += chars[Math.floor(Math.random() * chars.length)];
437 key += chars[Math.floor(Math.random() * chars.length)];
472 }
438 }
473 return key;
439 return key;
474 }
440 }
475
441
476 // Can't use Rails' remote select because we need the form data
442 // Can't use Rails' remote select because we need the form data
477 function updateIssueFrom(url) {
443 function updateIssueFrom(url) {
478 $.ajax({
444 $.ajax({
479 url: url,
445 url: url,
480 type: 'post',
446 type: 'post',
481 data: $('#issue-form').serialize()
447 data: $('#issue-form').serialize()
482 });
448 });
483 }
449 }
484
450
485 function updateBulkEditFrom(url) {
451 function updateBulkEditFrom(url) {
486 $.ajax({
452 $.ajax({
487 url: url,
453 url: url,
488 type: 'post',
454 type: 'post',
489 data: $('#bulk_edit_form').serialize()
455 data: $('#bulk_edit_form').serialize()
490 });
456 });
491 }
457 }
492
458
493 function observeAutocompleteField(fieldId, url) {
459 function observeAutocompleteField(fieldId, url) {
494 $(document).ready(function() {
460 $(document).ready(function() {
495 $('#'+fieldId).autocomplete({
461 $('#'+fieldId).autocomplete({
496 source: url,
462 source: url,
497 minLength: 2
463 minLength: 2
498 });
464 });
499 });
465 });
500 }
466 }
501
467
502 function observeSearchfield(fieldId, targetId, url) {
468 function observeSearchfield(fieldId, targetId, url) {
503 $('#'+fieldId).each(function() {
469 $('#'+fieldId).each(function() {
504 var $this = $(this);
470 var $this = $(this);
505 $this.attr('data-value-was', $this.val());
471 $this.attr('data-value-was', $this.val());
506 var check = function() {
472 var check = function() {
507 var val = $this.val();
473 var val = $this.val();
508 if ($this.attr('data-value-was') != val){
474 if ($this.attr('data-value-was') != val){
509 $this.attr('data-value-was', val);
475 $this.attr('data-value-was', val);
510 $.ajax({
476 $.ajax({
511 url: url,
477 url: url,
512 type: 'get',
478 type: 'get',
513 data: {q: $this.val()},
479 data: {q: $this.val()},
514 success: function(data){ $('#'+targetId).html(data); },
480 success: function(data){ $('#'+targetId).html(data); },
515 beforeSend: function(){ $this.addClass('ajax-loading'); },
481 beforeSend: function(){ $this.addClass('ajax-loading'); },
516 complete: function(){ $this.removeClass('ajax-loading'); }
482 complete: function(){ $this.removeClass('ajax-loading'); }
517 });
483 });
518 }
484 }
519 };
485 };
520 var reset = function() {
486 var reset = function() {
521 if (timer) {
487 if (timer) {
522 clearInterval(timer);
488 clearInterval(timer);
523 timer = setInterval(check, 300);
489 timer = setInterval(check, 300);
524 }
490 }
525 };
491 };
526 var timer = setInterval(check, 300);
492 var timer = setInterval(check, 300);
527 $this.bind('keyup click mousemove', reset);
493 $this.bind('keyup click mousemove', reset);
528 });
494 });
529 }
495 }
530
496
531 function observeProjectModules() {
497 function observeProjectModules() {
532 var f = function() {
498 var f = function() {
533 /* Hides trackers and issues custom fields on the new project form when issue_tracking module is disabled */
499 /* Hides trackers and issues custom fields on the new project form when issue_tracking module is disabled */
534 if ($('#project_enabled_module_names_issue_tracking').attr('checked')) {
500 if ($('#project_enabled_module_names_issue_tracking').attr('checked')) {
535 $('#project_trackers').show();
501 $('#project_trackers').show();
536 }else{
502 }else{
537 $('#project_trackers').hide();
503 $('#project_trackers').hide();
538 }
504 }
539 };
505 };
540
506
541 $(window).load(f);
507 $(window).load(f);
542 $('#project_enabled_module_names_issue_tracking').change(f);
508 $('#project_enabled_module_names_issue_tracking').change(f);
543 }
509 }
544
510
545 function initMyPageSortable(list, url) {
511 function initMyPageSortable(list, url) {
546 $('#list-'+list).sortable({
512 $('#list-'+list).sortable({
547 connectWith: '.block-receiver',
513 connectWith: '.block-receiver',
548 tolerance: 'pointer',
514 tolerance: 'pointer',
549 update: function(){
515 update: function(){
550 $.ajax({
516 $.ajax({
551 url: url,
517 url: url,
552 type: 'post',
518 type: 'post',
553 data: {'blocks': $.map($('#list-'+list).children(), function(el){return $(el).attr('id');})}
519 data: {'blocks': $.map($('#list-'+list).children(), function(el){return $(el).attr('id');})}
554 });
520 });
555 }
521 }
556 });
522 });
557 $("#list-top, #list-left, #list-right").disableSelection();
523 $("#list-top, #list-left, #list-right").disableSelection();
558 }
524 }
559
525
560 var warnLeavingUnsavedMessage;
526 var warnLeavingUnsavedMessage;
561 function warnLeavingUnsaved(message) {
527 function warnLeavingUnsaved(message) {
562 warnLeavingUnsavedMessage = message;
528 warnLeavingUnsavedMessage = message;
563
529
564 $('form').submit(function(){
530 $('form').submit(function(){
565 $('textarea').removeData('changed');
531 $('textarea').removeData('changed');
566 });
532 });
567 $('textarea').change(function(){
533 $('textarea').change(function(){
568 $(this).data('changed', 'changed');
534 $(this).data('changed', 'changed');
569 });
535 });
570 window.onbeforeunload = function(){
536 window.onbeforeunload = function(){
571 var warn = false;
537 var warn = false;
572 $('textarea').blur().each(function(){
538 $('textarea').blur().each(function(){
573 if ($(this).data('changed')) {
539 if ($(this).data('changed')) {
574 warn = true;
540 warn = true;
575 }
541 }
576 });
542 });
577 if (warn) {return warnLeavingUnsavedMessage;}
543 if (warn) {return warnLeavingUnsavedMessage;}
578 };
544 };
579 };
545 };
580
546
581 $(document).ready(function(){
547 $(document).ready(function(){
582 $('#ajax-indicator').bind('ajaxSend', function(){
548 $('#ajax-indicator').bind('ajaxSend', function(event, xhr, settings){
583 if ($('.ajax-loading').length == 0) {
549 if ($('.ajax-loading').length == 0 && settings.contentType != 'application/octet-stream') {
584 $('#ajax-indicator').show();
550 $('#ajax-indicator').show();
585 }
551 }
586 });
552 });
587 $('#ajax-indicator').bind('ajaxStop', function(){
553 $('#ajax-indicator').bind('ajaxStop', function(){
588 $('#ajax-indicator').hide();
554 $('#ajax-indicator').hide();
589 });
555 });
590 });
556 });
591
557
592 function hideOnLoad() {
558 function hideOnLoad() {
593 $('.hol').hide();
559 $('.hol').hide();
594 }
560 }
595
561
596 function addFormObserversForDoubleSubmit() {
562 function addFormObserversForDoubleSubmit() {
597 $('form[method=post]').each(function() {
563 $('form[method=post]').each(function() {
598 if (!$(this).hasClass('multiple-submit')) {
564 if (!$(this).hasClass('multiple-submit')) {
599 $(this).submit(function(form_submission) {
565 $(this).submit(function(form_submission) {
600 if ($(form_submission.target).attr('data-submitted')) {
566 if ($(form_submission.target).attr('data-submitted')) {
601 form_submission.preventDefault();
567 form_submission.preventDefault();
602 } else {
568 } else {
603 $(form_submission.target).attr('data-submitted', true);
569 $(form_submission.target).attr('data-submitted', true);
604 }
570 }
605 });
571 });
606 }
572 }
607 });
573 });
608 }
574 }
609
575
576 function blockEventPropagation(event) {
577 event.stopPropagation();
578 event.preventDefault();
579 }
580
610 $(document).ready(hideOnLoad);
581 $(document).ready(hideOnLoad);
611 $(document).ready(addFormObserversForDoubleSubmit);
582 $(document).ready(addFormObserversForDoubleSubmit);
@@ -1,1140 +1,1147
1 html {overflow-y:scroll;}
1 html {overflow-y:scroll;}
2 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
2 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
3
3
4 h1, h2, h3, h4 {font-family: "Trebuchet MS", Verdana, sans-serif;padding: 2px 10px 1px 0px;margin: 0 0 10px 0;}
4 h1, h2, h3, h4 {font-family: "Trebuchet MS", Verdana, sans-serif;padding: 2px 10px 1px 0px;margin: 0 0 10px 0;}
5 #content h1, h2, h3, h4 {color: #555;}
5 #content h1, h2, h3, h4 {color: #555;}
6 h2, .wiki h1 {font-size: 20px;}
6 h2, .wiki h1 {font-size: 20px;}
7 h3, .wiki h2 {font-size: 16px;}
7 h3, .wiki h2 {font-size: 16px;}
8 h4, .wiki h3 {font-size: 13px;}
8 h4, .wiki h3 {font-size: 13px;}
9 h4 {border-bottom: 1px dotted #bbb;}
9 h4 {border-bottom: 1px dotted #bbb;}
10
10
11 /***** Layout *****/
11 /***** Layout *****/
12 #wrapper {background: white;}
12 #wrapper {background: white;}
13
13
14 #top-menu {background: #3E5B76; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
14 #top-menu {background: #3E5B76; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
15 #top-menu ul {margin: 0; padding: 0;}
15 #top-menu ul {margin: 0; padding: 0;}
16 #top-menu li {
16 #top-menu li {
17 float:left;
17 float:left;
18 list-style-type:none;
18 list-style-type:none;
19 margin: 0px 0px 0px 0px;
19 margin: 0px 0px 0px 0px;
20 padding: 0px 0px 0px 0px;
20 padding: 0px 0px 0px 0px;
21 white-space:nowrap;
21 white-space:nowrap;
22 }
22 }
23 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
23 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
24 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
24 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
25
25
26 #account {float:right;}
26 #account {float:right;}
27
27
28 #header {height:5.3em;margin:0;background-color:#628DB6;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
28 #header {height:5.3em;margin:0;background-color:#628DB6;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
29 #header a {color:#f8f8f8;}
29 #header a {color:#f8f8f8;}
30 #header h1 a.ancestor { font-size: 80%; }
30 #header h1 a.ancestor { font-size: 80%; }
31 #quick-search {float:right;}
31 #quick-search {float:right;}
32
32
33 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
33 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
34 #main-menu ul {margin: 0; padding: 0;}
34 #main-menu ul {margin: 0; padding: 0;}
35 #main-menu li {
35 #main-menu li {
36 float:left;
36 float:left;
37 list-style-type:none;
37 list-style-type:none;
38 margin: 0px 2px 0px 0px;
38 margin: 0px 2px 0px 0px;
39 padding: 0px 0px 0px 0px;
39 padding: 0px 0px 0px 0px;
40 white-space:nowrap;
40 white-space:nowrap;
41 }
41 }
42 #main-menu li a {
42 #main-menu li a {
43 display: block;
43 display: block;
44 color: #fff;
44 color: #fff;
45 text-decoration: none;
45 text-decoration: none;
46 font-weight: bold;
46 font-weight: bold;
47 margin: 0;
47 margin: 0;
48 padding: 4px 10px 4px 10px;
48 padding: 4px 10px 4px 10px;
49 }
49 }
50 #main-menu li a:hover {background:#759FCF; color:#fff;}
50 #main-menu li a:hover {background:#759FCF; color:#fff;}
51 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
51 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
52
52
53 #admin-menu ul {margin: 0; padding: 0;}
53 #admin-menu ul {margin: 0; padding: 0;}
54 #admin-menu li {margin: 0; padding: 0 0 6px 0; list-style-type:none;}
54 #admin-menu li {margin: 0; padding: 0 0 6px 0; list-style-type:none;}
55
55
56 #admin-menu a { background-position: 0% 40%; background-repeat: no-repeat; padding-left: 20px; padding-top: 2px; padding-bottom: 3px;}
56 #admin-menu a { background-position: 0% 40%; background-repeat: no-repeat; padding-left: 20px; padding-top: 2px; padding-bottom: 3px;}
57 #admin-menu a.projects { background-image: url(../images/projects.png); }
57 #admin-menu a.projects { background-image: url(../images/projects.png); }
58 #admin-menu a.users { background-image: url(../images/user.png); }
58 #admin-menu a.users { background-image: url(../images/user.png); }
59 #admin-menu a.groups { background-image: url(../images/group.png); }
59 #admin-menu a.groups { background-image: url(../images/group.png); }
60 #admin-menu a.roles { background-image: url(../images/database_key.png); }
60 #admin-menu a.roles { background-image: url(../images/database_key.png); }
61 #admin-menu a.trackers { background-image: url(../images/ticket.png); }
61 #admin-menu a.trackers { background-image: url(../images/ticket.png); }
62 #admin-menu a.issue_statuses { background-image: url(../images/ticket_edit.png); }
62 #admin-menu a.issue_statuses { background-image: url(../images/ticket_edit.png); }
63 #admin-menu a.workflows { background-image: url(../images/ticket_go.png); }
63 #admin-menu a.workflows { background-image: url(../images/ticket_go.png); }
64 #admin-menu a.custom_fields { background-image: url(../images/textfield.png); }
64 #admin-menu a.custom_fields { background-image: url(../images/textfield.png); }
65 #admin-menu a.enumerations { background-image: url(../images/text_list_bullets.png); }
65 #admin-menu a.enumerations { background-image: url(../images/text_list_bullets.png); }
66 #admin-menu a.settings { background-image: url(../images/changeset.png); }
66 #admin-menu a.settings { background-image: url(../images/changeset.png); }
67 #admin-menu a.plugins { background-image: url(../images/plugin.png); }
67 #admin-menu a.plugins { background-image: url(../images/plugin.png); }
68 #admin-menu a.info { background-image: url(../images/help.png); }
68 #admin-menu a.info { background-image: url(../images/help.png); }
69 #admin-menu a.server_authentication { background-image: url(../images/server_key.png); }
69 #admin-menu a.server_authentication { background-image: url(../images/server_key.png); }
70
70
71 #main {background-color:#EEEEEE;}
71 #main {background-color:#EEEEEE;}
72
72
73 #sidebar{ float: right; width: 22%; position: relative; z-index: 9; padding: 0; margin: 0;}
73 #sidebar{ float: right; width: 22%; position: relative; z-index: 9; padding: 0; margin: 0;}
74 * html #sidebar{ width: 22%; }
74 * html #sidebar{ width: 22%; }
75 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
75 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
76 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
76 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
77 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
77 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
78 #sidebar .contextual { margin-right: 1em; }
78 #sidebar .contextual { margin-right: 1em; }
79
79
80 #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
80 #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
81 * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
81 * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
82 html>body #content { min-height: 600px; }
82 html>body #content { min-height: 600px; }
83 * html body #content { height: 600px; } /* IE */
83 * html body #content { height: 600px; } /* IE */
84
84
85 #main.nosidebar #sidebar{ display: none; }
85 #main.nosidebar #sidebar{ display: none; }
86 #main.nosidebar #content{ width: auto; border-right: 0; }
86 #main.nosidebar #content{ width: auto; border-right: 0; }
87
87
88 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
88 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
89
89
90 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
90 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
91 #login-form table td {padding: 6px;}
91 #login-form table td {padding: 6px;}
92 #login-form label {font-weight: bold;}
92 #login-form label {font-weight: bold;}
93 #login-form input#username, #login-form input#password { width: 300px; }
93 #login-form input#username, #login-form input#password { width: 300px; }
94
94
95 div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
95 div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
96 div.modal h3.title {display:none;}
96 div.modal h3.title {display:none;}
97 div.modal p.buttons {text-align:right; margin-bottom:0;}
97 div.modal p.buttons {text-align:right; margin-bottom:0;}
98
98
99 input#openid_url { background: url(../images/openid-bg.gif) no-repeat; background-color: #fff; background-position: 0 50%; padding-left: 18px; }
99 input#openid_url { background: url(../images/openid-bg.gif) no-repeat; background-color: #fff; background-position: 0 50%; padding-left: 18px; }
100
100
101 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
101 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
102
102
103 /***** Links *****/
103 /***** Links *****/
104 a, a:link, a:visited{ color: #169; text-decoration: none; }
104 a, a:link, a:visited{ color: #169; text-decoration: none; }
105 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
105 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
106 a img{ border: 0; }
106 a img{ border: 0; }
107
107
108 a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; }
108 a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; }
109 a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }
109 a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }
110 a.user.locked, a.user.locked:link, a.user.locked:visited {color: #999;}
110 a.user.locked, a.user.locked:link, a.user.locked:visited {color: #999;}
111
111
112 #sidebar a.selected {line-height:1.7em; padding:1px 3px 2px 2px; margin-left:-2px; background-color:#9DB9D5; color:#fff; border-radius:2px;}
112 #sidebar a.selected {line-height:1.7em; padding:1px 3px 2px 2px; margin-left:-2px; background-color:#9DB9D5; color:#fff; border-radius:2px;}
113 #sidebar a.selected:hover {text-decoration:none;}
113 #sidebar a.selected:hover {text-decoration:none;}
114 #admin-menu a {line-height:1.7em;}
114 #admin-menu a {line-height:1.7em;}
115 #admin-menu a.selected {padding-left: 20px !important; background-position: 2px 40%;}
115 #admin-menu a.selected {padding-left: 20px !important; background-position: 2px 40%;}
116
116
117 a.collapsible {padding-left: 12px; background: url(../images/arrow_expanded.png) no-repeat -3px 40%;}
117 a.collapsible {padding-left: 12px; background: url(../images/arrow_expanded.png) no-repeat -3px 40%;}
118 a.collapsible.collapsed {background: url(../images/arrow_collapsed.png) no-repeat -5px 40%;}
118 a.collapsible.collapsed {background: url(../images/arrow_collapsed.png) no-repeat -5px 40%;}
119
119
120 a#toggle-completed-versions {color:#999;}
120 a#toggle-completed-versions {color:#999;}
121 /***** Tables *****/
121 /***** Tables *****/
122 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
122 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
123 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
123 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
124 table.list td { vertical-align: top; padding-right:10px; }
124 table.list td { vertical-align: top; padding-right:10px; }
125 table.list td.id { width: 2%; text-align: center;}
125 table.list td.id { width: 2%; text-align: center;}
126 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
126 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
127 table.list td.checkbox input {padding:0px;}
127 table.list td.checkbox input {padding:0px;}
128 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
128 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
129 table.list td.buttons a { padding-right: 0.6em; }
129 table.list td.buttons a { padding-right: 0.6em; }
130 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
130 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
131
131
132 tr.project td.name a { white-space:nowrap; }
132 tr.project td.name a { white-space:nowrap; }
133 tr.project.closed, tr.project.archived { color: #aaa; }
133 tr.project.closed, tr.project.archived { color: #aaa; }
134 tr.project.closed a, tr.project.archived a { color: #aaa; }
134 tr.project.closed a, tr.project.archived a { color: #aaa; }
135
135
136 tr.project.idnt td.name span {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
136 tr.project.idnt td.name span {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
137 tr.project.idnt-1 td.name {padding-left: 0.5em;}
137 tr.project.idnt-1 td.name {padding-left: 0.5em;}
138 tr.project.idnt-2 td.name {padding-left: 2em;}
138 tr.project.idnt-2 td.name {padding-left: 2em;}
139 tr.project.idnt-3 td.name {padding-left: 3.5em;}
139 tr.project.idnt-3 td.name {padding-left: 3.5em;}
140 tr.project.idnt-4 td.name {padding-left: 5em;}
140 tr.project.idnt-4 td.name {padding-left: 5em;}
141 tr.project.idnt-5 td.name {padding-left: 6.5em;}
141 tr.project.idnt-5 td.name {padding-left: 6.5em;}
142 tr.project.idnt-6 td.name {padding-left: 8em;}
142 tr.project.idnt-6 td.name {padding-left: 8em;}
143 tr.project.idnt-7 td.name {padding-left: 9.5em;}
143 tr.project.idnt-7 td.name {padding-left: 9.5em;}
144 tr.project.idnt-8 td.name {padding-left: 11em;}
144 tr.project.idnt-8 td.name {padding-left: 11em;}
145 tr.project.idnt-9 td.name {padding-left: 12.5em;}
145 tr.project.idnt-9 td.name {padding-left: 12.5em;}
146
146
147 tr.issue { text-align: center; white-space: nowrap; }
147 tr.issue { text-align: center; white-space: nowrap; }
148 tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text, tr.issue td.relations { white-space: normal; }
148 tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text, tr.issue td.relations { white-space: normal; }
149 tr.issue td.subject, tr.issue td.relations { text-align: left; }
149 tr.issue td.subject, tr.issue td.relations { text-align: left; }
150 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
150 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
151 tr.issue td.relations span {white-space: nowrap;}
151 tr.issue td.relations span {white-space: nowrap;}
152 table.issues td.description {color:#777; font-size:90%; padding:4px 4px 4px 24px; text-align:left; white-space:normal;}
152 table.issues td.description {color:#777; font-size:90%; padding:4px 4px 4px 24px; text-align:left; white-space:normal;}
153 table.issues td.description pre {white-space:normal;}
153 table.issues td.description pre {white-space:normal;}
154
154
155 tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
155 tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
156 tr.issue.idnt-1 td.subject {padding-left: 0.5em;}
156 tr.issue.idnt-1 td.subject {padding-left: 0.5em;}
157 tr.issue.idnt-2 td.subject {padding-left: 2em;}
157 tr.issue.idnt-2 td.subject {padding-left: 2em;}
158 tr.issue.idnt-3 td.subject {padding-left: 3.5em;}
158 tr.issue.idnt-3 td.subject {padding-left: 3.5em;}
159 tr.issue.idnt-4 td.subject {padding-left: 5em;}
159 tr.issue.idnt-4 td.subject {padding-left: 5em;}
160 tr.issue.idnt-5 td.subject {padding-left: 6.5em;}
160 tr.issue.idnt-5 td.subject {padding-left: 6.5em;}
161 tr.issue.idnt-6 td.subject {padding-left: 8em;}
161 tr.issue.idnt-6 td.subject {padding-left: 8em;}
162 tr.issue.idnt-7 td.subject {padding-left: 9.5em;}
162 tr.issue.idnt-7 td.subject {padding-left: 9.5em;}
163 tr.issue.idnt-8 td.subject {padding-left: 11em;}
163 tr.issue.idnt-8 td.subject {padding-left: 11em;}
164 tr.issue.idnt-9 td.subject {padding-left: 12.5em;}
164 tr.issue.idnt-9 td.subject {padding-left: 12.5em;}
165
165
166 tr.entry { border: 1px solid #f8f8f8; }
166 tr.entry { border: 1px solid #f8f8f8; }
167 tr.entry td { white-space: nowrap; }
167 tr.entry td { white-space: nowrap; }
168 tr.entry td.filename { width: 30%; }
168 tr.entry td.filename { width: 30%; }
169 tr.entry td.filename_no_report { width: 70%; }
169 tr.entry td.filename_no_report { width: 70%; }
170 tr.entry td.size { text-align: right; font-size: 90%; }
170 tr.entry td.size { text-align: right; font-size: 90%; }
171 tr.entry td.revision, tr.entry td.author { text-align: center; }
171 tr.entry td.revision, tr.entry td.author { text-align: center; }
172 tr.entry td.age { text-align: right; }
172 tr.entry td.age { text-align: right; }
173 tr.entry.file td.filename a { margin-left: 16px; }
173 tr.entry.file td.filename a { margin-left: 16px; }
174 tr.entry.file td.filename_no_report a { margin-left: 16px; }
174 tr.entry.file td.filename_no_report a { margin-left: 16px; }
175
175
176 tr span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
176 tr span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
177 tr.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
177 tr.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
178
178
179 tr.changeset { height: 20px }
179 tr.changeset { height: 20px }
180 tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; }
180 tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; }
181 tr.changeset td.revision_graph { width: 15%; background-color: #fffffb; }
181 tr.changeset td.revision_graph { width: 15%; background-color: #fffffb; }
182 tr.changeset td.author { text-align: center; width: 15%; white-space:nowrap;}
182 tr.changeset td.author { text-align: center; width: 15%; white-space:nowrap;}
183 tr.changeset td.committed_on { text-align: center; width: 15%; white-space:nowrap;}
183 tr.changeset td.committed_on { text-align: center; width: 15%; white-space:nowrap;}
184
184
185 table.files tr.file td { text-align: center; }
185 table.files tr.file td { text-align: center; }
186 table.files tr.file td.filename { text-align: left; padding-left: 24px; }
186 table.files tr.file td.filename { text-align: left; padding-left: 24px; }
187 table.files tr.file td.digest { font-size: 80%; }
187 table.files tr.file td.digest { font-size: 80%; }
188
188
189 table.members td.roles, table.memberships td.roles { width: 45%; }
189 table.members td.roles, table.memberships td.roles { width: 45%; }
190
190
191 tr.message { height: 2.6em; }
191 tr.message { height: 2.6em; }
192 tr.message td.subject { padding-left: 20px; }
192 tr.message td.subject { padding-left: 20px; }
193 tr.message td.created_on { white-space: nowrap; }
193 tr.message td.created_on { white-space: nowrap; }
194 tr.message td.last_message { font-size: 80%; white-space: nowrap; }
194 tr.message td.last_message { font-size: 80%; white-space: nowrap; }
195 tr.message.locked td.subject { background: url(../images/locked.png) no-repeat 0 1px; }
195 tr.message.locked td.subject { background: url(../images/locked.png) no-repeat 0 1px; }
196 tr.message.sticky td.subject { background: url(../images/bullet_go.png) no-repeat 0 1px; font-weight: bold; }
196 tr.message.sticky td.subject { background: url(../images/bullet_go.png) no-repeat 0 1px; font-weight: bold; }
197
197
198 tr.version.closed, tr.version.closed a { color: #999; }
198 tr.version.closed, tr.version.closed a { color: #999; }
199 tr.version td.name { padding-left: 20px; }
199 tr.version td.name { padding-left: 20px; }
200 tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%; }
200 tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%; }
201 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
201 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
202
202
203 tr.user td { width:13%; }
203 tr.user td { width:13%; }
204 tr.user td.email { width:18%; }
204 tr.user td.email { width:18%; }
205 tr.user td { white-space: nowrap; }
205 tr.user td { white-space: nowrap; }
206 tr.user.locked, tr.user.registered { color: #aaa; }
206 tr.user.locked, tr.user.registered { color: #aaa; }
207 tr.user.locked a, tr.user.registered a { color: #aaa; }
207 tr.user.locked a, tr.user.registered a { color: #aaa; }
208
208
209 table.permissions td.role {color:#999;font-size:90%;font-weight:normal !important;text-align:center;vertical-align:bottom;}
209 table.permissions td.role {color:#999;font-size:90%;font-weight:normal !important;text-align:center;vertical-align:bottom;}
210
210
211 tr.wiki-page-version td.updated_on, tr.wiki-page-version td.author {text-align:center;}
211 tr.wiki-page-version td.updated_on, tr.wiki-page-version td.author {text-align:center;}
212
212
213 tr.time-entry { text-align: center; white-space: nowrap; }
213 tr.time-entry { text-align: center; white-space: nowrap; }
214 tr.time-entry td.issue, tr.time-entry td.comments { text-align: left; white-space: normal; }
214 tr.time-entry td.issue, tr.time-entry td.comments { text-align: left; white-space: normal; }
215 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
215 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
216 td.hours .hours-dec { font-size: 0.9em; }
216 td.hours .hours-dec { font-size: 0.9em; }
217
217
218 table.plugins td { vertical-align: middle; }
218 table.plugins td { vertical-align: middle; }
219 table.plugins td.configure { text-align: right; padding-right: 1em; }
219 table.plugins td.configure { text-align: right; padding-right: 1em; }
220 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
220 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
221 table.plugins span.description { display: block; font-size: 0.9em; }
221 table.plugins span.description { display: block; font-size: 0.9em; }
222 table.plugins span.url { display: block; font-size: 0.9em; }
222 table.plugins span.url { display: block; font-size: 0.9em; }
223
223
224 table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; }
224 table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; }
225 table.list tbody tr.group span.count {position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
225 table.list tbody tr.group span.count {position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
226 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
226 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
227 tr.group:hover a.toggle-all { display:inline;}
227 tr.group:hover a.toggle-all { display:inline;}
228 a.toggle-all:hover {text-decoration:none;}
228 a.toggle-all:hover {text-decoration:none;}
229
229
230 table.list tbody tr:hover { background-color:#ffffdd; }
230 table.list tbody tr:hover { background-color:#ffffdd; }
231 table.list tbody tr.group:hover { background-color:inherit; }
231 table.list tbody tr.group:hover { background-color:inherit; }
232 table td {padding:2px;}
232 table td {padding:2px;}
233 table p {margin:0;}
233 table p {margin:0;}
234 .odd {background-color:#f6f7f8;}
234 .odd {background-color:#f6f7f8;}
235 .even {background-color: #fff;}
235 .even {background-color: #fff;}
236
236
237 a.sort { padding-right: 16px; background-position: 100% 50%; background-repeat: no-repeat; }
237 a.sort { padding-right: 16px; background-position: 100% 50%; background-repeat: no-repeat; }
238 a.sort.asc { background-image: url(../images/sort_asc.png); }
238 a.sort.asc { background-image: url(../images/sort_asc.png); }
239 a.sort.desc { background-image: url(../images/sort_desc.png); }
239 a.sort.desc { background-image: url(../images/sort_desc.png); }
240
240
241 table.attributes { width: 100% }
241 table.attributes { width: 100% }
242 table.attributes th { vertical-align: top; text-align: left; }
242 table.attributes th { vertical-align: top; text-align: left; }
243 table.attributes td { vertical-align: top; }
243 table.attributes td { vertical-align: top; }
244
244
245 table.boards a.board, h3.comments { background: url(../images/comment.png) no-repeat 0% 50%; padding-left: 20px; }
245 table.boards a.board, h3.comments { background: url(../images/comment.png) no-repeat 0% 50%; padding-left: 20px; }
246 table.boards td.topic-count, table.boards td.message-count {text-align:center;}
246 table.boards td.topic-count, table.boards td.message-count {text-align:center;}
247 table.boards td.last-message {font-size:80%;}
247 table.boards td.last-message {font-size:80%;}
248
248
249 table.messages td.author, table.messages td.created_on, table.messages td.reply-count {text-align:center;}
249 table.messages td.author, table.messages td.created_on, table.messages td.reply-count {text-align:center;}
250
250
251 table.query-columns {
251 table.query-columns {
252 border-collapse: collapse;
252 border-collapse: collapse;
253 border: 0;
253 border: 0;
254 }
254 }
255
255
256 table.query-columns td.buttons {
256 table.query-columns td.buttons {
257 vertical-align: middle;
257 vertical-align: middle;
258 text-align: center;
258 text-align: center;
259 }
259 }
260
260
261 td.center {text-align:center;}
261 td.center {text-align:center;}
262
262
263 h3.version { background: url(../images/package.png) no-repeat 0% 50%; padding-left: 20px; }
263 h3.version { background: url(../images/package.png) no-repeat 0% 50%; padding-left: 20px; }
264
264
265 div.issues h3 { background: url(../images/ticket.png) no-repeat 0% 50%; padding-left: 20px; }
265 div.issues h3 { background: url(../images/ticket.png) no-repeat 0% 50%; padding-left: 20px; }
266 div.members h3 { background: url(../images/group.png) no-repeat 0% 50%; padding-left: 20px; }
266 div.members h3 { background: url(../images/group.png) no-repeat 0% 50%; padding-left: 20px; }
267 div.news h3 { background: url(../images/news.png) no-repeat 0% 50%; padding-left: 20px; }
267 div.news h3 { background: url(../images/news.png) no-repeat 0% 50%; padding-left: 20px; }
268 div.projects h3 { background: url(../images/projects.png) no-repeat 0% 50%; padding-left: 20px; }
268 div.projects h3 { background: url(../images/projects.png) no-repeat 0% 50%; padding-left: 20px; }
269
269
270 #watchers ul {margin: 0; padding: 0;}
270 #watchers ul {margin: 0; padding: 0;}
271 #watchers li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
271 #watchers li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
272 #watchers select {width: 95%; display: block;}
272 #watchers select {width: 95%; display: block;}
273 #watchers a.delete {opacity: 0.4;}
273 #watchers a.delete {opacity: 0.4;}
274 #watchers a.delete:hover {opacity: 1;}
274 #watchers a.delete:hover {opacity: 1;}
275 #watchers img.gravatar {margin: 0 4px 2px 0;}
275 #watchers img.gravatar {margin: 0 4px 2px 0;}
276
276
277 span#watchers_inputs {overflow:auto; display:block;}
277 span#watchers_inputs {overflow:auto; display:block;}
278 span.search_for_watchers {display:block;}
278 span.search_for_watchers {display:block;}
279 span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
279 span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
280 span.search_for_watchers a, span.add_attachment a {padding-left:16px; background: url(../images/bullet_add.png) no-repeat 0 50%; }
280 span.search_for_watchers a, span.add_attachment a {padding-left:16px; background: url(../images/bullet_add.png) no-repeat 0 50%; }
281
281
282
282
283 .highlight { background-color: #FCFD8D;}
283 .highlight { background-color: #FCFD8D;}
284 .highlight.token-1 { background-color: #faa;}
284 .highlight.token-1 { background-color: #faa;}
285 .highlight.token-2 { background-color: #afa;}
285 .highlight.token-2 { background-color: #afa;}
286 .highlight.token-3 { background-color: #aaf;}
286 .highlight.token-3 { background-color: #aaf;}
287
287
288 .box{
288 .box{
289 padding:6px;
289 padding:6px;
290 margin-bottom: 10px;
290 margin-bottom: 10px;
291 background-color:#f6f6f6;
291 background-color:#f6f6f6;
292 color:#505050;
292 color:#505050;
293 line-height:1.5em;
293 line-height:1.5em;
294 border: 1px solid #e4e4e4;
294 border: 1px solid #e4e4e4;
295 }
295 }
296
296
297 div.square {
297 div.square {
298 border: 1px solid #999;
298 border: 1px solid #999;
299 float: left;
299 float: left;
300 margin: .3em .4em 0 .4em;
300 margin: .3em .4em 0 .4em;
301 overflow: hidden;
301 overflow: hidden;
302 width: .6em; height: .6em;
302 width: .6em; height: .6em;
303 }
303 }
304 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
304 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
305 .contextual input, .contextual select {font-size:0.9em;}
305 .contextual input, .contextual select {font-size:0.9em;}
306 .message .contextual { margin-top: 0; }
306 .message .contextual { margin-top: 0; }
307
307
308 .splitcontent {overflow:auto;}
308 .splitcontent {overflow:auto;}
309 .splitcontentleft{float:left; width:49%;}
309 .splitcontentleft{float:left; width:49%;}
310 .splitcontentright{float:right; width:49%;}
310 .splitcontentright{float:right; width:49%;}
311 form {display: inline;}
311 form {display: inline;}
312 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
312 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
313 fieldset {border: 1px solid #e4e4e4; margin:0;}
313 fieldset {border: 1px solid #e4e4e4; margin:0;}
314 legend {color: #484848;}
314 legend {color: #484848;}
315 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
315 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
316 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
316 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
317 blockquote blockquote { margin-left: 0;}
317 blockquote blockquote { margin-left: 0;}
318 acronym { border-bottom: 1px dotted; cursor: help; }
318 acronym { border-bottom: 1px dotted; cursor: help; }
319 textarea.wiki-edit {width:99%; resize:vertical;}
319 textarea.wiki-edit {width:99%; resize:vertical;}
320 li p {margin-top: 0;}
320 li p {margin-top: 0;}
321 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
321 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
322 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
322 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
323 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
323 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
324 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
324 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
325
325
326 div.issue div.subject div div { padding-left: 16px; }
326 div.issue div.subject div div { padding-left: 16px; }
327 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
327 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
328 div.issue div.subject>div>p { margin-top: 0.5em; }
328 div.issue div.subject>div>p { margin-top: 0.5em; }
329 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
329 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
330 div.issue span.private { position:relative; bottom: 2px; text-transform: uppercase; background: #d22; color: #fff; font-weight:bold; padding: 0px 2px 0px 2px; font-size: 60%; margin-right: 2px; border-radius: 2px;}
330 div.issue span.private { position:relative; bottom: 2px; text-transform: uppercase; background: #d22; color: #fff; font-weight:bold; padding: 0px 2px 0px 2px; font-size: 60%; margin-right: 2px; border-radius: 2px;}
331 div.issue .next-prev-links {color:#999;}
331 div.issue .next-prev-links {color:#999;}
332 div.issue table.attributes th {width:22%;}
332 div.issue table.attributes th {width:22%;}
333 div.issue table.attributes td {width:28%;}
333 div.issue table.attributes td {width:28%;}
334
334
335 #issue_tree table.issues, #relations table.issues { border: 0; }
335 #issue_tree table.issues, #relations table.issues { border: 0; }
336 #issue_tree td.checkbox, #relations td.checkbox {display:none;}
336 #issue_tree td.checkbox, #relations td.checkbox {display:none;}
337 #relations td.buttons {padding:0;}
337 #relations td.buttons {padding:0;}
338
338
339 fieldset.collapsible { border-width: 1px 0 0 0; font-size: 0.9em; }
339 fieldset.collapsible { border-width: 1px 0 0 0; font-size: 0.9em; }
340 fieldset.collapsible legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
340 fieldset.collapsible legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
341 fieldset.collapsible.collapsed legend { background-image: url(../images/arrow_collapsed.png); }
341 fieldset.collapsible.collapsed legend { background-image: url(../images/arrow_collapsed.png); }
342
342
343 fieldset#date-range p { margin: 2px 0 2px 0; }
343 fieldset#date-range p { margin: 2px 0 2px 0; }
344 fieldset#filters table { border-collapse: collapse; }
344 fieldset#filters table { border-collapse: collapse; }
345 fieldset#filters table td { padding: 0; vertical-align: middle; }
345 fieldset#filters table td { padding: 0; vertical-align: middle; }
346 fieldset#filters tr.filter { height: 2.1em; }
346 fieldset#filters tr.filter { height: 2.1em; }
347 fieldset#filters td.field { width:230px; }
347 fieldset#filters td.field { width:230px; }
348 fieldset#filters td.operator { width:180px; }
348 fieldset#filters td.operator { width:180px; }
349 fieldset#filters td.operator select {max-width:170px;}
349 fieldset#filters td.operator select {max-width:170px;}
350 fieldset#filters td.values { white-space:nowrap; }
350 fieldset#filters td.values { white-space:nowrap; }
351 fieldset#filters td.values select {min-width:130px;}
351 fieldset#filters td.values select {min-width:130px;}
352 fieldset#filters td.values input {height:1em;}
352 fieldset#filters td.values input {height:1em;}
353 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
353 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
354
354
355 .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;}
355 .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;}
356 .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; }
356 .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; }
357
357
358 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
358 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
359 div#issue-changesets div.changeset { padding: 4px;}
359 div#issue-changesets div.changeset { padding: 4px;}
360 div#issue-changesets div.changeset { border-bottom: 1px solid #ddd; }
360 div#issue-changesets div.changeset { border-bottom: 1px solid #ddd; }
361 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
361 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
362
362
363 .journal ul.details img {margin:0 0 -3px 4px;}
363 .journal ul.details img {margin:0 0 -3px 4px;}
364 div.journal {overflow:auto;}
364 div.journal {overflow:auto;}
365 div.journal.private-notes {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
365 div.journal.private-notes {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
366
366
367 div#activity dl, #search-results { margin-left: 2em; }
367 div#activity dl, #search-results { margin-left: 2em; }
368 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
368 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
369 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
369 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
370 div#activity dt.me .time { border-bottom: 1px solid #999; }
370 div#activity dt.me .time { border-bottom: 1px solid #999; }
371 div#activity dt .time { color: #777; font-size: 80%; }
371 div#activity dt .time { color: #777; font-size: 80%; }
372 div#activity dd .description, #search-results dd .description { font-style: italic; }
372 div#activity dd .description, #search-results dd .description { font-style: italic; }
373 div#activity span.project:after, #search-results span.project:after { content: " -"; }
373 div#activity span.project:after, #search-results span.project:after { content: " -"; }
374 div#activity dd span.description, #search-results dd span.description { display:block; color: #808080; }
374 div#activity dd span.description, #search-results dd span.description { display:block; color: #808080; }
375 div#activity dt.grouped {margin-left:5em;}
375 div#activity dt.grouped {margin-left:5em;}
376 div#activity dd.grouped {margin-left:9em;}
376 div#activity dd.grouped {margin-left:9em;}
377
377
378 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
378 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
379
379
380 div#search-results-counts {float:right;}
380 div#search-results-counts {float:right;}
381 div#search-results-counts ul { margin-top: 0.5em; }
381 div#search-results-counts ul { margin-top: 0.5em; }
382 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
382 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
383
383
384 dt.issue { background-image: url(../images/ticket.png); }
384 dt.issue { background-image: url(../images/ticket.png); }
385 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
385 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
386 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
386 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
387 dt.issue-note { background-image: url(../images/ticket_note.png); }
387 dt.issue-note { background-image: url(../images/ticket_note.png); }
388 dt.changeset { background-image: url(../images/changeset.png); }
388 dt.changeset { background-image: url(../images/changeset.png); }
389 dt.news { background-image: url(../images/news.png); }
389 dt.news { background-image: url(../images/news.png); }
390 dt.message { background-image: url(../images/message.png); }
390 dt.message { background-image: url(../images/message.png); }
391 dt.reply { background-image: url(../images/comments.png); }
391 dt.reply { background-image: url(../images/comments.png); }
392 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
392 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
393 dt.attachment { background-image: url(../images/attachment.png); }
393 dt.attachment { background-image: url(../images/attachment.png); }
394 dt.document { background-image: url(../images/document.png); }
394 dt.document { background-image: url(../images/document.png); }
395 dt.project { background-image: url(../images/projects.png); }
395 dt.project { background-image: url(../images/projects.png); }
396 dt.time-entry { background-image: url(../images/time.png); }
396 dt.time-entry { background-image: url(../images/time.png); }
397
397
398 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
398 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
399
399
400 div#roadmap .related-issues { margin-bottom: 1em; }
400 div#roadmap .related-issues { margin-bottom: 1em; }
401 div#roadmap .related-issues td.checkbox { display: none; }
401 div#roadmap .related-issues td.checkbox { display: none; }
402 div#roadmap .wiki h1:first-child { display: none; }
402 div#roadmap .wiki h1:first-child { display: none; }
403 div#roadmap .wiki h1 { font-size: 120%; }
403 div#roadmap .wiki h1 { font-size: 120%; }
404 div#roadmap .wiki h2 { font-size: 110%; }
404 div#roadmap .wiki h2 { font-size: 110%; }
405 body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
405 body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
406
406
407 div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
407 div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
408 div#version-summary fieldset { margin-bottom: 1em; }
408 div#version-summary fieldset { margin-bottom: 1em; }
409 div#version-summary fieldset.time-tracking table { width:100%; }
409 div#version-summary fieldset.time-tracking table { width:100%; }
410 div#version-summary th, div#version-summary td.total-hours { text-align: right; }
410 div#version-summary th, div#version-summary td.total-hours { text-align: right; }
411
411
412 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
412 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
413 table#time-report tbody tr.subtotal { font-style: italic; color:#777;}
413 table#time-report tbody tr.subtotal { font-style: italic; color:#777;}
414 table#time-report tbody tr.subtotal td.hours { color:#b0b0b0; }
414 table#time-report tbody tr.subtotal td.hours { color:#b0b0b0; }
415 table#time-report tbody tr.total { font-weight: bold; background-color:#EEEEEE; border-top:1px solid #e4e4e4;}
415 table#time-report tbody tr.total { font-weight: bold; background-color:#EEEEEE; border-top:1px solid #e4e4e4;}
416 table#time-report .hours-dec { font-size: 0.9em; }
416 table#time-report .hours-dec { font-size: 0.9em; }
417
417
418 div.wiki-page .contextual a {opacity: 0.4}
418 div.wiki-page .contextual a {opacity: 0.4}
419 div.wiki-page .contextual a:hover {opacity: 1}
419 div.wiki-page .contextual a:hover {opacity: 1}
420
420
421 form .attributes select { width: 60%; }
421 form .attributes select { width: 60%; }
422 input#issue_subject { width: 99%; }
422 input#issue_subject { width: 99%; }
423 select#issue_done_ratio { width: 95px; }
423 select#issue_done_ratio { width: 95px; }
424
424
425 ul.projects {margin:0; padding-left:1em;}
425 ul.projects {margin:0; padding-left:1em;}
426 ul.projects ul {padding-left:1.6em;}
426 ul.projects ul {padding-left:1.6em;}
427 ul.projects.root {margin:0; padding:0;}
427 ul.projects.root {margin:0; padding:0;}
428 ul.projects li {list-style-type:none;}
428 ul.projects li {list-style-type:none;}
429
429
430 #projects-index ul.projects ul.projects { border-left: 3px solid #e0e0e0; padding-left:1em;}
430 #projects-index ul.projects ul.projects { border-left: 3px solid #e0e0e0; padding-left:1em;}
431 #projects-index ul.projects li.root {margin-bottom: 1em;}
431 #projects-index ul.projects li.root {margin-bottom: 1em;}
432 #projects-index ul.projects li.child {margin-top: 1em;}
432 #projects-index ul.projects li.child {margin-top: 1em;}
433 #projects-index ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
433 #projects-index ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
434 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
434 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
435
435
436 #notified-projects ul, #tracker_project_ids ul {max-height:250px; overflow-y:auto;}
436 #notified-projects ul, #tracker_project_ids ul {max-height:250px; overflow-y:auto;}
437
437
438 #related-issues li img {vertical-align:middle;}
438 #related-issues li img {vertical-align:middle;}
439
439
440 ul.properties {padding:0; font-size: 0.9em; color: #777;}
440 ul.properties {padding:0; font-size: 0.9em; color: #777;}
441 ul.properties li {list-style-type:none;}
441 ul.properties li {list-style-type:none;}
442 ul.properties li span {font-style:italic;}
442 ul.properties li span {font-style:italic;}
443
443
444 .total-hours { font-size: 110%; font-weight: bold; }
444 .total-hours { font-size: 110%; font-weight: bold; }
445 .total-hours span.hours-int { font-size: 120%; }
445 .total-hours span.hours-int { font-size: 120%; }
446
446
447 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
447 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
448 #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select, #user_identity_url { width: 90%; }
448 #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select, #user_identity_url { width: 90%; }
449
449
450 #workflow_copy_form select { width: 200px; }
450 #workflow_copy_form select { width: 200px; }
451 table.transitions td.enabled {background: #bfb;}
451 table.transitions td.enabled {background: #bfb;}
452 table.fields_permissions select {font-size:90%}
452 table.fields_permissions select {font-size:90%}
453 table.fields_permissions td.readonly {background:#ddd;}
453 table.fields_permissions td.readonly {background:#ddd;}
454 table.fields_permissions td.required {background:#d88;}
454 table.fields_permissions td.required {background:#d88;}
455
455
456 textarea#custom_field_possible_values {width: 99%}
456 textarea#custom_field_possible_values {width: 99%}
457 input#content_comments {width: 99%}
457 input#content_comments {width: 99%}
458
458
459 .pagination {font-size: 90%}
459 .pagination {font-size: 90%}
460 p.pagination {margin-top:8px;}
460 p.pagination {margin-top:8px;}
461
461
462 /***** Tabular forms ******/
462 /***** Tabular forms ******/
463 .tabular p{
463 .tabular p{
464 margin: 0;
464 margin: 0;
465 padding: 3px 0 3px 0;
465 padding: 3px 0 3px 0;
466 padding-left: 180px; /* width of left column containing the label elements */
466 padding-left: 180px; /* width of left column containing the label elements */
467 min-height: 1.8em;
467 min-height: 1.8em;
468 clear:left;
468 clear:left;
469 }
469 }
470
470
471 html>body .tabular p {overflow:hidden;}
471 html>body .tabular p {overflow:hidden;}
472
472
473 .tabular label{
473 .tabular label{
474 font-weight: bold;
474 font-weight: bold;
475 float: left;
475 float: left;
476 text-align: right;
476 text-align: right;
477 /* width of left column */
477 /* width of left column */
478 margin-left: -180px;
478 margin-left: -180px;
479 /* width of labels. Should be smaller than left column to create some right margin */
479 /* width of labels. Should be smaller than left column to create some right margin */
480 width: 175px;
480 width: 175px;
481 }
481 }
482
482
483 .tabular label.floating{
483 .tabular label.floating{
484 font-weight: normal;
484 font-weight: normal;
485 margin-left: 0px;
485 margin-left: 0px;
486 text-align: left;
486 text-align: left;
487 width: 270px;
487 width: 270px;
488 }
488 }
489
489
490 .tabular label.block{
490 .tabular label.block{
491 font-weight: normal;
491 font-weight: normal;
492 margin-left: 0px !important;
492 margin-left: 0px !important;
493 text-align: left;
493 text-align: left;
494 float: none;
494 float: none;
495 display: block;
495 display: block;
496 width: auto;
496 width: auto;
497 }
497 }
498
498
499 .tabular label.inline{
499 .tabular label.inline{
500 font-weight: normal;
500 font-weight: normal;
501 float:none;
501 float:none;
502 margin-left: 5px !important;
502 margin-left: 5px !important;
503 width: auto;
503 width: auto;
504 }
504 }
505
505
506 label.no-css {
506 label.no-css {
507 font-weight: inherit;
507 font-weight: inherit;
508 float:none;
508 float:none;
509 text-align:left;
509 text-align:left;
510 margin-left:0px;
510 margin-left:0px;
511 width:auto;
511 width:auto;
512 }
512 }
513 input#time_entry_comments { width: 90%;}
513 input#time_entry_comments { width: 90%;}
514
514
515 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
515 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
516
516
517 .tabular.settings p{ padding-left: 300px; }
517 .tabular.settings p{ padding-left: 300px; }
518 .tabular.settings label{ margin-left: -300px; width: 295px; }
518 .tabular.settings label{ margin-left: -300px; width: 295px; }
519 .tabular.settings textarea { width: 99%; }
519 .tabular.settings textarea { width: 99%; }
520
520
521 .settings.enabled_scm table {width:100%}
521 .settings.enabled_scm table {width:100%}
522 .settings.enabled_scm td.scm_name{ font-weight: bold; }
522 .settings.enabled_scm td.scm_name{ font-weight: bold; }
523
523
524 fieldset.settings label { display: block; }
524 fieldset.settings label { display: block; }
525 fieldset#notified_events .parent { padding-left: 20px; }
525 fieldset#notified_events .parent { padding-left: 20px; }
526
526
527 span.required {color: #bb0000;}
527 span.required {color: #bb0000;}
528 .summary {font-style: italic;}
528 .summary {font-style: italic;}
529
529
530 #attachments_fields input.description {margin-left: 8px; width:340px;}
530 #attachments_fields input.description {margin-left:4px; width:340px;}
531 #attachments_fields span {display:block; white-space:nowrap;}
531 #attachments_fields span {display:block; white-space:nowrap;}
532 #attachments_fields img {vertical-align: middle;}
532 #attachments_fields input.filename {border:0; height:1.8em; width:250px; color:#555; background-color:inherit; background:url(../images/attachment.png) no-repeat 1px 50%; padding-left:18px;}
533 #attachments_fields .ajax-waiting input.filename {background:url(../images/hourglass.png) no-repeat 0px 50%;}
534 #attachments_fields .ajax-loading input.filename {background:url(../images/loading.gif) no-repeat 0px 50%;}
535 #attachments_fields div.ui-progressbar { width: 100px; height:14px; margin: 2px 0 -5px 8px; display: inline-block; }
536 a.remove-upload {background: url(../images/delete.png) no-repeat 1px 50%; width:1px; display:inline-block; padding-left:16px;}
537 a.remove-upload:hover {text-decoration:none !important;}
538
539 div.fileover { background-color: lavender; }
533
540
534 div.attachments { margin-top: 12px; }
541 div.attachments { margin-top: 12px; }
535 div.attachments p { margin:4px 0 2px 0; }
542 div.attachments p { margin:4px 0 2px 0; }
536 div.attachments img { vertical-align: middle; }
543 div.attachments img { vertical-align: middle; }
537 div.attachments span.author { font-size: 0.9em; color: #888; }
544 div.attachments span.author { font-size: 0.9em; color: #888; }
538
545
539 div.thumbnails {margin-top:0.6em;}
546 div.thumbnails {margin-top:0.6em;}
540 div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
547 div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
541 div.thumbnails img {margin: 3px;}
548 div.thumbnails img {margin: 3px;}
542
549
543 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
550 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
544 .other-formats span + span:before { content: "| "; }
551 .other-formats span + span:before { content: "| "; }
545
552
546 a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
553 a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
547
554
548 em.info {font-style:normal;font-size:90%;color:#888;display:block;}
555 em.info {font-style:normal;font-size:90%;color:#888;display:block;}
549 em.info.error {padding-left:20px; background:url(../images/exclamation.png) no-repeat 0 50%;}
556 em.info.error {padding-left:20px; background:url(../images/exclamation.png) no-repeat 0 50%;}
550
557
551 textarea.text_cf {width:90%;}
558 textarea.text_cf {width:90%;}
552
559
553 /* Project members tab */
560 /* Project members tab */
554 div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft, div#tab-content-users .splitcontentleft { width: 64% }
561 div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft, div#tab-content-users .splitcontentleft { width: 64% }
555 div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright, div#tab-content-users .splitcontentright { width: 34% }
562 div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright, div#tab-content-users .splitcontentright { width: 34% }
556 div#tab-content-members fieldset, div#tab-content-memberships fieldset, div#tab-content-users fieldset { padding:1em; margin-bottom: 1em; }
563 div#tab-content-members fieldset, div#tab-content-memberships fieldset, div#tab-content-users fieldset { padding:1em; margin-bottom: 1em; }
557 div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend, div#tab-content-users fieldset legend { font-weight: bold; }
564 div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend, div#tab-content-users fieldset legend { font-weight: bold; }
558 div#tab-content-members fieldset label, div#tab-content-memberships fieldset label, div#tab-content-users fieldset label { display: block; }
565 div#tab-content-members fieldset label, div#tab-content-memberships fieldset label, div#tab-content-users fieldset label { display: block; }
559 div#tab-content-members fieldset div, div#tab-content-users fieldset div { max-height: 400px; overflow:auto; }
566 div#tab-content-members fieldset div, div#tab-content-users fieldset div { max-height: 400px; overflow:auto; }
560
567
561 #users_for_watcher {height: 200px; overflow:auto;}
568 #users_for_watcher {height: 200px; overflow:auto;}
562 #users_for_watcher label {display: block;}
569 #users_for_watcher label {display: block;}
563
570
564 table.members td.group { padding-left: 20px; background: url(../images/group.png) no-repeat 0% 50%; }
571 table.members td.group { padding-left: 20px; background: url(../images/group.png) no-repeat 0% 50%; }
565
572
566 input#principal_search, input#user_search {width:100%}
573 input#principal_search, input#user_search {width:100%}
567 input#principal_search, input#user_search {
574 input#principal_search, input#user_search {
568 background: url(../images/magnifier.png) no-repeat 2px 50%; padding-left:20px;
575 background: url(../images/magnifier.png) no-repeat 2px 50%; padding-left:20px;
569 border:1px solid #9EB1C2; border-radius:3px; height:1.5em; width:95%;
576 border:1px solid #9EB1C2; border-radius:3px; height:1.5em; width:95%;
570 }
577 }
571 input#principal_search.ajax-loading, input#user_search.ajax-loading {
578 input#principal_search.ajax-loading, input#user_search.ajax-loading {
572 background-image: url(../images/loading.gif);
579 background-image: url(../images/loading.gif);
573 }
580 }
574
581
575 * html div#tab-content-members fieldset div { height: 450px; }
582 * html div#tab-content-members fieldset div { height: 450px; }
576
583
577 /***** Flash & error messages ****/
584 /***** Flash & error messages ****/
578 #errorExplanation, div.flash, .nodata, .warning, .conflict {
585 #errorExplanation, div.flash, .nodata, .warning, .conflict {
579 padding: 4px 4px 4px 30px;
586 padding: 4px 4px 4px 30px;
580 margin-bottom: 12px;
587 margin-bottom: 12px;
581 font-size: 1.1em;
588 font-size: 1.1em;
582 border: 2px solid;
589 border: 2px solid;
583 }
590 }
584
591
585 div.flash {margin-top: 8px;}
592 div.flash {margin-top: 8px;}
586
593
587 div.flash.error, #errorExplanation {
594 div.flash.error, #errorExplanation {
588 background: url(../images/exclamation.png) 8px 50% no-repeat;
595 background: url(../images/exclamation.png) 8px 50% no-repeat;
589 background-color: #ffe3e3;
596 background-color: #ffe3e3;
590 border-color: #dd0000;
597 border-color: #dd0000;
591 color: #880000;
598 color: #880000;
592 }
599 }
593
600
594 div.flash.notice {
601 div.flash.notice {
595 background: url(../images/true.png) 8px 5px no-repeat;
602 background: url(../images/true.png) 8px 5px no-repeat;
596 background-color: #dfffdf;
603 background-color: #dfffdf;
597 border-color: #9fcf9f;
604 border-color: #9fcf9f;
598 color: #005f00;
605 color: #005f00;
599 }
606 }
600
607
601 div.flash.warning, .conflict {
608 div.flash.warning, .conflict {
602 background: url(../images/warning.png) 8px 5px no-repeat;
609 background: url(../images/warning.png) 8px 5px no-repeat;
603 background-color: #FFEBC1;
610 background-color: #FFEBC1;
604 border-color: #FDBF3B;
611 border-color: #FDBF3B;
605 color: #A6750C;
612 color: #A6750C;
606 text-align: left;
613 text-align: left;
607 }
614 }
608
615
609 .nodata, .warning {
616 .nodata, .warning {
610 text-align: center;
617 text-align: center;
611 background-color: #FFEBC1;
618 background-color: #FFEBC1;
612 border-color: #FDBF3B;
619 border-color: #FDBF3B;
613 color: #A6750C;
620 color: #A6750C;
614 }
621 }
615
622
616 #errorExplanation ul { font-size: 0.9em;}
623 #errorExplanation ul { font-size: 0.9em;}
617 #errorExplanation h2, #errorExplanation p { display: none; }
624 #errorExplanation h2, #errorExplanation p { display: none; }
618
625
619 .conflict-details {font-size:80%;}
626 .conflict-details {font-size:80%;}
620
627
621 /***** Ajax indicator ******/
628 /***** Ajax indicator ******/
622 #ajax-indicator {
629 #ajax-indicator {
623 position: absolute; /* fixed not supported by IE */
630 position: absolute; /* fixed not supported by IE */
624 background-color:#eee;
631 background-color:#eee;
625 border: 1px solid #bbb;
632 border: 1px solid #bbb;
626 top:35%;
633 top:35%;
627 left:40%;
634 left:40%;
628 width:20%;
635 width:20%;
629 font-weight:bold;
636 font-weight:bold;
630 text-align:center;
637 text-align:center;
631 padding:0.6em;
638 padding:0.6em;
632 z-index:100;
639 z-index:100;
633 opacity: 0.5;
640 opacity: 0.5;
634 }
641 }
635
642
636 html>body #ajax-indicator { position: fixed; }
643 html>body #ajax-indicator { position: fixed; }
637
644
638 #ajax-indicator span {
645 #ajax-indicator span {
639 background-position: 0% 40%;
646 background-position: 0% 40%;
640 background-repeat: no-repeat;
647 background-repeat: no-repeat;
641 background-image: url(../images/loading.gif);
648 background-image: url(../images/loading.gif);
642 padding-left: 26px;
649 padding-left: 26px;
643 vertical-align: bottom;
650 vertical-align: bottom;
644 }
651 }
645
652
646 /***** Calendar *****/
653 /***** Calendar *****/
647 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
654 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
648 table.cal thead th {width: 14%; background-color:#EEEEEE; padding: 4px; }
655 table.cal thead th {width: 14%; background-color:#EEEEEE; padding: 4px; }
649 table.cal thead th.week-number {width: auto;}
656 table.cal thead th.week-number {width: auto;}
650 table.cal tbody tr {height: 100px;}
657 table.cal tbody tr {height: 100px;}
651 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
658 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
652 table.cal td.week-number { background-color:#EEEEEE; padding: 4px; border:none; font-size: 1em;}
659 table.cal td.week-number { background-color:#EEEEEE; padding: 4px; border:none; font-size: 1em;}
653 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
660 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
654 table.cal td.odd p.day-num {color: #bbb;}
661 table.cal td.odd p.day-num {color: #bbb;}
655 table.cal td.today {background:#ffffdd;}
662 table.cal td.today {background:#ffffdd;}
656 table.cal td.today p.day-num {font-weight: bold;}
663 table.cal td.today p.day-num {font-weight: bold;}
657 table.cal .starting a, p.cal.legend .starting {background: url(../images/bullet_go.png) no-repeat -1px -2px; padding-left:16px;}
664 table.cal .starting a, p.cal.legend .starting {background: url(../images/bullet_go.png) no-repeat -1px -2px; padding-left:16px;}
658 table.cal .ending a, p.cal.legend .ending {background: url(../images/bullet_end.png) no-repeat -1px -2px; padding-left:16px;}
665 table.cal .ending a, p.cal.legend .ending {background: url(../images/bullet_end.png) no-repeat -1px -2px; padding-left:16px;}
659 table.cal .starting.ending a, p.cal.legend .starting.ending {background: url(../images/bullet_diamond.png) no-repeat -1px -2px; padding-left:16px;}
666 table.cal .starting.ending a, p.cal.legend .starting.ending {background: url(../images/bullet_diamond.png) no-repeat -1px -2px; padding-left:16px;}
660 p.cal.legend span {display:block;}
667 p.cal.legend span {display:block;}
661
668
662 /***** Tooltips ******/
669 /***** Tooltips ******/
663 .tooltip{position:relative;z-index:24;}
670 .tooltip{position:relative;z-index:24;}
664 .tooltip:hover{z-index:25;color:#000;}
671 .tooltip:hover{z-index:25;color:#000;}
665 .tooltip span.tip{display: none; text-align:left;}
672 .tooltip span.tip{display: none; text-align:left;}
666
673
667 div.tooltip:hover span.tip{
674 div.tooltip:hover span.tip{
668 display:block;
675 display:block;
669 position:absolute;
676 position:absolute;
670 top:12px; left:24px; width:270px;
677 top:12px; left:24px; width:270px;
671 border:1px solid #555;
678 border:1px solid #555;
672 background-color:#fff;
679 background-color:#fff;
673 padding: 4px;
680 padding: 4px;
674 font-size: 0.8em;
681 font-size: 0.8em;
675 color:#505050;
682 color:#505050;
676 }
683 }
677
684
678 img.ui-datepicker-trigger {
685 img.ui-datepicker-trigger {
679 cursor: pointer;
686 cursor: pointer;
680 vertical-align: middle;
687 vertical-align: middle;
681 margin-left: 4px;
688 margin-left: 4px;
682 }
689 }
683
690
684 /***** Progress bar *****/
691 /***** Progress bar *****/
685 table.progress {
692 table.progress {
686 border-collapse: collapse;
693 border-collapse: collapse;
687 border-spacing: 0pt;
694 border-spacing: 0pt;
688 empty-cells: show;
695 empty-cells: show;
689 text-align: center;
696 text-align: center;
690 float:left;
697 float:left;
691 margin: 1px 6px 1px 0px;
698 margin: 1px 6px 1px 0px;
692 }
699 }
693
700
694 table.progress td { height: 1em; }
701 table.progress td { height: 1em; }
695 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
702 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
696 table.progress td.done { background: #D3EDD3 none repeat scroll 0%; }
703 table.progress td.done { background: #D3EDD3 none repeat scroll 0%; }
697 table.progress td.todo { background: #eee none repeat scroll 0%; }
704 table.progress td.todo { background: #eee none repeat scroll 0%; }
698 p.pourcent {font-size: 80%;}
705 p.pourcent {font-size: 80%;}
699 p.progress-info {clear: left; font-size: 80%; margin-top:-4px; color:#777;}
706 p.progress-info {clear: left; font-size: 80%; margin-top:-4px; color:#777;}
700
707
701 #roadmap table.progress td { height: 1.2em; }
708 #roadmap table.progress td { height: 1.2em; }
702 /***** Tabs *****/
709 /***** Tabs *****/
703 #content .tabs {height: 2.6em; margin-bottom:1.2em; position:relative; overflow:hidden;}
710 #content .tabs {height: 2.6em; margin-bottom:1.2em; position:relative; overflow:hidden;}
704 #content .tabs ul {margin:0; position:absolute; bottom:0; padding-left:0.5em; width: 2000px; border-bottom: 1px solid #bbbbbb;}
711 #content .tabs ul {margin:0; position:absolute; bottom:0; padding-left:0.5em; width: 2000px; border-bottom: 1px solid #bbbbbb;}
705 #content .tabs ul li {
712 #content .tabs ul li {
706 float:left;
713 float:left;
707 list-style-type:none;
714 list-style-type:none;
708 white-space:nowrap;
715 white-space:nowrap;
709 margin-right:4px;
716 margin-right:4px;
710 background:#fff;
717 background:#fff;
711 position:relative;
718 position:relative;
712 margin-bottom:-1px;
719 margin-bottom:-1px;
713 }
720 }
714 #content .tabs ul li a{
721 #content .tabs ul li a{
715 display:block;
722 display:block;
716 font-size: 0.9em;
723 font-size: 0.9em;
717 text-decoration:none;
724 text-decoration:none;
718 line-height:1.3em;
725 line-height:1.3em;
719 padding:4px 6px 4px 6px;
726 padding:4px 6px 4px 6px;
720 border: 1px solid #ccc;
727 border: 1px solid #ccc;
721 border-bottom: 1px solid #bbbbbb;
728 border-bottom: 1px solid #bbbbbb;
722 background-color: #f6f6f6;
729 background-color: #f6f6f6;
723 color:#999;
730 color:#999;
724 font-weight:bold;
731 font-weight:bold;
725 border-top-left-radius:3px;
732 border-top-left-radius:3px;
726 border-top-right-radius:3px;
733 border-top-right-radius:3px;
727 }
734 }
728
735
729 #content .tabs ul li a:hover {
736 #content .tabs ul li a:hover {
730 background-color: #ffffdd;
737 background-color: #ffffdd;
731 text-decoration:none;
738 text-decoration:none;
732 }
739 }
733
740
734 #content .tabs ul li a.selected {
741 #content .tabs ul li a.selected {
735 background-color: #fff;
742 background-color: #fff;
736 border: 1px solid #bbbbbb;
743 border: 1px solid #bbbbbb;
737 border-bottom: 1px solid #fff;
744 border-bottom: 1px solid #fff;
738 color:#444;
745 color:#444;
739 }
746 }
740
747
741 #content .tabs ul li a.selected:hover {background-color: #fff;}
748 #content .tabs ul li a.selected:hover {background-color: #fff;}
742
749
743 div.tabs-buttons { position:absolute; right: 0; width: 48px; height: 24px; background: white; bottom: 0; border-bottom: 1px solid #bbbbbb; }
750 div.tabs-buttons { position:absolute; right: 0; width: 48px; height: 24px; background: white; bottom: 0; border-bottom: 1px solid #bbbbbb; }
744
751
745 button.tab-left, button.tab-right {
752 button.tab-left, button.tab-right {
746 font-size: 0.9em;
753 font-size: 0.9em;
747 cursor: pointer;
754 cursor: pointer;
748 height:24px;
755 height:24px;
749 border: 1px solid #ccc;
756 border: 1px solid #ccc;
750 border-bottom: 1px solid #bbbbbb;
757 border-bottom: 1px solid #bbbbbb;
751 position:absolute;
758 position:absolute;
752 padding:4px;
759 padding:4px;
753 width: 20px;
760 width: 20px;
754 bottom: -1px;
761 bottom: -1px;
755 }
762 }
756
763
757 button.tab-left {
764 button.tab-left {
758 right: 20px;
765 right: 20px;
759 background: #eeeeee url(../images/bullet_arrow_left.png) no-repeat 50% 50%;
766 background: #eeeeee url(../images/bullet_arrow_left.png) no-repeat 50% 50%;
760 border-top-left-radius:3px;
767 border-top-left-radius:3px;
761 }
768 }
762
769
763 button.tab-right {
770 button.tab-right {
764 right: 0;
771 right: 0;
765 background: #eeeeee url(../images/bullet_arrow_right.png) no-repeat 50% 50%;
772 background: #eeeeee url(../images/bullet_arrow_right.png) no-repeat 50% 50%;
766 border-top-right-radius:3px;
773 border-top-right-radius:3px;
767 }
774 }
768
775
769 /***** Diff *****/
776 /***** Diff *****/
770 .diff_out { background: #fcc; }
777 .diff_out { background: #fcc; }
771 .diff_out span { background: #faa; }
778 .diff_out span { background: #faa; }
772 .diff_in { background: #cfc; }
779 .diff_in { background: #cfc; }
773 .diff_in span { background: #afa; }
780 .diff_in span { background: #afa; }
774
781
775 .text-diff {
782 .text-diff {
776 padding: 1em;
783 padding: 1em;
777 background-color:#f6f6f6;
784 background-color:#f6f6f6;
778 color:#505050;
785 color:#505050;
779 border: 1px solid #e4e4e4;
786 border: 1px solid #e4e4e4;
780 }
787 }
781
788
782 /***** Wiki *****/
789 /***** Wiki *****/
783 div.wiki table {
790 div.wiki table {
784 border-collapse: collapse;
791 border-collapse: collapse;
785 margin-bottom: 1em;
792 margin-bottom: 1em;
786 }
793 }
787
794
788 div.wiki table, div.wiki td, div.wiki th {
795 div.wiki table, div.wiki td, div.wiki th {
789 border: 1px solid #bbb;
796 border: 1px solid #bbb;
790 padding: 4px;
797 padding: 4px;
791 }
798 }
792
799
793 div.wiki .noborder, div.wiki .noborder td, div.wiki .noborder th {border:0;}
800 div.wiki .noborder, div.wiki .noborder td, div.wiki .noborder th {border:0;}
794
801
795 div.wiki .external {
802 div.wiki .external {
796 background-position: 0% 60%;
803 background-position: 0% 60%;
797 background-repeat: no-repeat;
804 background-repeat: no-repeat;
798 padding-left: 12px;
805 padding-left: 12px;
799 background-image: url(../images/external.png);
806 background-image: url(../images/external.png);
800 }
807 }
801
808
802 div.wiki a.new {color: #b73535;}
809 div.wiki a.new {color: #b73535;}
803
810
804 div.wiki ul, div.wiki ol {margin-bottom:1em;}
811 div.wiki ul, div.wiki ol {margin-bottom:1em;}
805
812
806 div.wiki pre {
813 div.wiki pre {
807 margin: 1em 1em 1em 1.6em;
814 margin: 1em 1em 1em 1.6em;
808 padding: 8px;
815 padding: 8px;
809 background-color: #fafafa;
816 background-color: #fafafa;
810 border: 1px solid #e2e2e2;
817 border: 1px solid #e2e2e2;
811 width:auto;
818 width:auto;
812 overflow-x: auto;
819 overflow-x: auto;
813 overflow-y: hidden;
820 overflow-y: hidden;
814 }
821 }
815
822
816 div.wiki ul.toc {
823 div.wiki ul.toc {
817 background-color: #ffffdd;
824 background-color: #ffffdd;
818 border: 1px solid #e4e4e4;
825 border: 1px solid #e4e4e4;
819 padding: 4px;
826 padding: 4px;
820 line-height: 1.2em;
827 line-height: 1.2em;
821 margin-bottom: 12px;
828 margin-bottom: 12px;
822 margin-right: 12px;
829 margin-right: 12px;
823 margin-left: 0;
830 margin-left: 0;
824 display: table
831 display: table
825 }
832 }
826 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
833 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
827
834
828 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
835 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
829 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
836 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
830 div.wiki ul.toc ul { margin: 0; padding: 0; }
837 div.wiki ul.toc ul { margin: 0; padding: 0; }
831 div.wiki ul.toc li {list-style-type:none; margin: 0; font-size:12px;}
838 div.wiki ul.toc li {list-style-type:none; margin: 0; font-size:12px;}
832 div.wiki ul.toc li li {margin-left: 1.5em; font-size:10px;}
839 div.wiki ul.toc li li {margin-left: 1.5em; font-size:10px;}
833 div.wiki ul.toc a {
840 div.wiki ul.toc a {
834 font-size: 0.9em;
841 font-size: 0.9em;
835 font-weight: normal;
842 font-weight: normal;
836 text-decoration: none;
843 text-decoration: none;
837 color: #606060;
844 color: #606060;
838 }
845 }
839 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
846 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
840
847
841 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
848 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
842 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
849 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
843 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
850 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
844
851
845 div.wiki img { vertical-align: middle; }
852 div.wiki img { vertical-align: middle; }
846
853
847 /***** My page layout *****/
854 /***** My page layout *****/
848 .block-receiver {
855 .block-receiver {
849 border:1px dashed #c0c0c0;
856 border:1px dashed #c0c0c0;
850 margin-bottom: 20px;
857 margin-bottom: 20px;
851 padding: 15px 0 15px 0;
858 padding: 15px 0 15px 0;
852 }
859 }
853
860
854 .mypage-box {
861 .mypage-box {
855 margin:0 0 20px 0;
862 margin:0 0 20px 0;
856 color:#505050;
863 color:#505050;
857 line-height:1.5em;
864 line-height:1.5em;
858 }
865 }
859
866
860 .handle {cursor: move;}
867 .handle {cursor: move;}
861
868
862 a.close-icon {
869 a.close-icon {
863 display:block;
870 display:block;
864 margin-top:3px;
871 margin-top:3px;
865 overflow:hidden;
872 overflow:hidden;
866 width:12px;
873 width:12px;
867 height:12px;
874 height:12px;
868 background-repeat: no-repeat;
875 background-repeat: no-repeat;
869 cursor:pointer;
876 cursor:pointer;
870 background-image:url('../images/close.png');
877 background-image:url('../images/close.png');
871 }
878 }
872 a.close-icon:hover {background-image:url('../images/close_hl.png');}
879 a.close-icon:hover {background-image:url('../images/close_hl.png');}
873
880
874 /***** Gantt chart *****/
881 /***** Gantt chart *****/
875 .gantt_hdr {
882 .gantt_hdr {
876 position:absolute;
883 position:absolute;
877 top:0;
884 top:0;
878 height:16px;
885 height:16px;
879 border-top: 1px solid #c0c0c0;
886 border-top: 1px solid #c0c0c0;
880 border-bottom: 1px solid #c0c0c0;
887 border-bottom: 1px solid #c0c0c0;
881 border-right: 1px solid #c0c0c0;
888 border-right: 1px solid #c0c0c0;
882 text-align: center;
889 text-align: center;
883 overflow: hidden;
890 overflow: hidden;
884 }
891 }
885
892
886 .gantt_hdr.nwday {background-color:#f1f1f1;}
893 .gantt_hdr.nwday {background-color:#f1f1f1;}
887
894
888 .gantt_subjects { font-size: 0.8em; }
895 .gantt_subjects { font-size: 0.8em; }
889 .gantt_subjects div { line-height:16px;height:16px;overflow:hidden;white-space:nowrap;text-overflow: ellipsis; }
896 .gantt_subjects div { line-height:16px;height:16px;overflow:hidden;white-space:nowrap;text-overflow: ellipsis; }
890
897
891 .task {
898 .task {
892 position: absolute;
899 position: absolute;
893 height:8px;
900 height:8px;
894 font-size:0.8em;
901 font-size:0.8em;
895 color:#888;
902 color:#888;
896 padding:0;
903 padding:0;
897 margin:0;
904 margin:0;
898 line-height:16px;
905 line-height:16px;
899 white-space:nowrap;
906 white-space:nowrap;
900 }
907 }
901
908
902 .task.label {width:100%;}
909 .task.label {width:100%;}
903 .task.label.project, .task.label.version { font-weight: bold; }
910 .task.label.project, .task.label.version { font-weight: bold; }
904
911
905 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
912 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
906 .task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
913 .task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
907 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
914 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
908
915
909 .task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
916 .task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
910 .task_late.parent, .task_done.parent { height: 3px;}
917 .task_late.parent, .task_done.parent { height: 3px;}
911 .task.parent.marker.starting { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; left: 0px; top: -1px;}
918 .task.parent.marker.starting { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; left: 0px; top: -1px;}
912 .task.parent.marker.ending { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;}
919 .task.parent.marker.ending { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;}
913
920
914 .version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
921 .version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
915 .version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
922 .version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
916 .version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
923 .version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
917 .version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
924 .version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
918
925
919 .project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
926 .project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
920 .project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
927 .project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
921 .project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
928 .project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
922 .project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
929 .project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
923
930
924 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
931 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
925 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
932 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
926
933
927 /***** Icons *****/
934 /***** Icons *****/
928 .icon {
935 .icon {
929 background-position: 0% 50%;
936 background-position: 0% 50%;
930 background-repeat: no-repeat;
937 background-repeat: no-repeat;
931 padding-left: 20px;
938 padding-left: 20px;
932 padding-top: 2px;
939 padding-top: 2px;
933 padding-bottom: 3px;
940 padding-bottom: 3px;
934 }
941 }
935
942
936 .icon-add { background-image: url(../images/add.png); }
943 .icon-add { background-image: url(../images/add.png); }
937 .icon-edit { background-image: url(../images/edit.png); }
944 .icon-edit { background-image: url(../images/edit.png); }
938 .icon-copy { background-image: url(../images/copy.png); }
945 .icon-copy { background-image: url(../images/copy.png); }
939 .icon-duplicate { background-image: url(../images/duplicate.png); }
946 .icon-duplicate { background-image: url(../images/duplicate.png); }
940 .icon-del { background-image: url(../images/delete.png); }
947 .icon-del { background-image: url(../images/delete.png); }
941 .icon-move { background-image: url(../images/move.png); }
948 .icon-move { background-image: url(../images/move.png); }
942 .icon-save { background-image: url(../images/save.png); }
949 .icon-save { background-image: url(../images/save.png); }
943 .icon-cancel { background-image: url(../images/cancel.png); }
950 .icon-cancel { background-image: url(../images/cancel.png); }
944 .icon-multiple { background-image: url(../images/table_multiple.png); }
951 .icon-multiple { background-image: url(../images/table_multiple.png); }
945 .icon-folder { background-image: url(../images/folder.png); }
952 .icon-folder { background-image: url(../images/folder.png); }
946 .open .icon-folder { background-image: url(../images/folder_open.png); }
953 .open .icon-folder { background-image: url(../images/folder_open.png); }
947 .icon-package { background-image: url(../images/package.png); }
954 .icon-package { background-image: url(../images/package.png); }
948 .icon-user { background-image: url(../images/user.png); }
955 .icon-user { background-image: url(../images/user.png); }
949 .icon-projects { background-image: url(../images/projects.png); }
956 .icon-projects { background-image: url(../images/projects.png); }
950 .icon-help { background-image: url(../images/help.png); }
957 .icon-help { background-image: url(../images/help.png); }
951 .icon-attachment { background-image: url(../images/attachment.png); }
958 .icon-attachment { background-image: url(../images/attachment.png); }
952 .icon-history { background-image: url(../images/history.png); }
959 .icon-history { background-image: url(../images/history.png); }
953 .icon-time { background-image: url(../images/time.png); }
960 .icon-time { background-image: url(../images/time.png); }
954 .icon-time-add { background-image: url(../images/time_add.png); }
961 .icon-time-add { background-image: url(../images/time_add.png); }
955 .icon-stats { background-image: url(../images/stats.png); }
962 .icon-stats { background-image: url(../images/stats.png); }
956 .icon-warning { background-image: url(../images/warning.png); }
963 .icon-warning { background-image: url(../images/warning.png); }
957 .icon-fav { background-image: url(../images/fav.png); }
964 .icon-fav { background-image: url(../images/fav.png); }
958 .icon-fav-off { background-image: url(../images/fav_off.png); }
965 .icon-fav-off { background-image: url(../images/fav_off.png); }
959 .icon-reload { background-image: url(../images/reload.png); }
966 .icon-reload { background-image: url(../images/reload.png); }
960 .icon-lock { background-image: url(../images/locked.png); }
967 .icon-lock { background-image: url(../images/locked.png); }
961 .icon-unlock { background-image: url(../images/unlock.png); }
968 .icon-unlock { background-image: url(../images/unlock.png); }
962 .icon-checked { background-image: url(../images/true.png); }
969 .icon-checked { background-image: url(../images/true.png); }
963 .icon-details { background-image: url(../images/zoom_in.png); }
970 .icon-details { background-image: url(../images/zoom_in.png); }
964 .icon-report { background-image: url(../images/report.png); }
971 .icon-report { background-image: url(../images/report.png); }
965 .icon-comment { background-image: url(../images/comment.png); }
972 .icon-comment { background-image: url(../images/comment.png); }
966 .icon-summary { background-image: url(../images/lightning.png); }
973 .icon-summary { background-image: url(../images/lightning.png); }
967 .icon-server-authentication { background-image: url(../images/server_key.png); }
974 .icon-server-authentication { background-image: url(../images/server_key.png); }
968 .icon-issue { background-image: url(../images/ticket.png); }
975 .icon-issue { background-image: url(../images/ticket.png); }
969 .icon-zoom-in { background-image: url(../images/zoom_in.png); }
976 .icon-zoom-in { background-image: url(../images/zoom_in.png); }
970 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
977 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
971 .icon-passwd { background-image: url(../images/textfield_key.png); }
978 .icon-passwd { background-image: url(../images/textfield_key.png); }
972 .icon-test { background-image: url(../images/bullet_go.png); }
979 .icon-test { background-image: url(../images/bullet_go.png); }
973
980
974 .icon-file { background-image: url(../images/files/default.png); }
981 .icon-file { background-image: url(../images/files/default.png); }
975 .icon-file.text-plain { background-image: url(../images/files/text.png); }
982 .icon-file.text-plain { background-image: url(../images/files/text.png); }
976 .icon-file.text-x-c { background-image: url(../images/files/c.png); }
983 .icon-file.text-x-c { background-image: url(../images/files/c.png); }
977 .icon-file.text-x-csharp { background-image: url(../images/files/csharp.png); }
984 .icon-file.text-x-csharp { background-image: url(../images/files/csharp.png); }
978 .icon-file.text-x-java { background-image: url(../images/files/java.png); }
985 .icon-file.text-x-java { background-image: url(../images/files/java.png); }
979 .icon-file.text-x-javascript { background-image: url(../images/files/js.png); }
986 .icon-file.text-x-javascript { background-image: url(../images/files/js.png); }
980 .icon-file.text-x-php { background-image: url(../images/files/php.png); }
987 .icon-file.text-x-php { background-image: url(../images/files/php.png); }
981 .icon-file.text-x-ruby { background-image: url(../images/files/ruby.png); }
988 .icon-file.text-x-ruby { background-image: url(../images/files/ruby.png); }
982 .icon-file.text-xml { background-image: url(../images/files/xml.png); }
989 .icon-file.text-xml { background-image: url(../images/files/xml.png); }
983 .icon-file.text-css { background-image: url(../images/files/css.png); }
990 .icon-file.text-css { background-image: url(../images/files/css.png); }
984 .icon-file.text-html { background-image: url(../images/files/html.png); }
991 .icon-file.text-html { background-image: url(../images/files/html.png); }
985 .icon-file.image-gif { background-image: url(../images/files/image.png); }
992 .icon-file.image-gif { background-image: url(../images/files/image.png); }
986 .icon-file.image-jpeg { background-image: url(../images/files/image.png); }
993 .icon-file.image-jpeg { background-image: url(../images/files/image.png); }
987 .icon-file.image-png { background-image: url(../images/files/image.png); }
994 .icon-file.image-png { background-image: url(../images/files/image.png); }
988 .icon-file.image-tiff { background-image: url(../images/files/image.png); }
995 .icon-file.image-tiff { background-image: url(../images/files/image.png); }
989 .icon-file.application-pdf { background-image: url(../images/files/pdf.png); }
996 .icon-file.application-pdf { background-image: url(../images/files/pdf.png); }
990 .icon-file.application-zip { background-image: url(../images/files/zip.png); }
997 .icon-file.application-zip { background-image: url(../images/files/zip.png); }
991 .icon-file.application-x-gzip { background-image: url(../images/files/zip.png); }
998 .icon-file.application-x-gzip { background-image: url(../images/files/zip.png); }
992
999
993 img.gravatar {
1000 img.gravatar {
994 padding: 2px;
1001 padding: 2px;
995 border: solid 1px #d5d5d5;
1002 border: solid 1px #d5d5d5;
996 background: #fff;
1003 background: #fff;
997 vertical-align: middle;
1004 vertical-align: middle;
998 }
1005 }
999
1006
1000 div.issue img.gravatar {
1007 div.issue img.gravatar {
1001 float: left;
1008 float: left;
1002 margin: 0 6px 0 0;
1009 margin: 0 6px 0 0;
1003 padding: 5px;
1010 padding: 5px;
1004 }
1011 }
1005
1012
1006 div.issue table img.gravatar {
1013 div.issue table img.gravatar {
1007 height: 14px;
1014 height: 14px;
1008 width: 14px;
1015 width: 14px;
1009 padding: 2px;
1016 padding: 2px;
1010 float: left;
1017 float: left;
1011 margin: 0 0.5em 0 0;
1018 margin: 0 0.5em 0 0;
1012 }
1019 }
1013
1020
1014 h2 img.gravatar {margin: -2px 4px -4px 0;}
1021 h2 img.gravatar {margin: -2px 4px -4px 0;}
1015 h3 img.gravatar {margin: -4px 4px -4px 0;}
1022 h3 img.gravatar {margin: -4px 4px -4px 0;}
1016 h4 img.gravatar {margin: -6px 4px -4px 0;}
1023 h4 img.gravatar {margin: -6px 4px -4px 0;}
1017 td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
1024 td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
1018 #activity dt img.gravatar {float: left; margin: 0 1em 1em 0;}
1025 #activity dt img.gravatar {float: left; margin: 0 1em 1em 0;}
1019 /* Used on 12px Gravatar img tags without the icon background */
1026 /* Used on 12px Gravatar img tags without the icon background */
1020 .icon-gravatar {float: left; margin-right: 4px;}
1027 .icon-gravatar {float: left; margin-right: 4px;}
1021
1028
1022 #activity dt, .journal {clear: left;}
1029 #activity dt, .journal {clear: left;}
1023
1030
1024 .journal-link {float: right;}
1031 .journal-link {float: right;}
1025
1032
1026 h2 img { vertical-align:middle; }
1033 h2 img { vertical-align:middle; }
1027
1034
1028 .hascontextmenu { cursor: context-menu; }
1035 .hascontextmenu { cursor: context-menu; }
1029
1036
1030 /************* CodeRay styles *************/
1037 /************* CodeRay styles *************/
1031 .syntaxhl div {display: inline;}
1038 .syntaxhl div {display: inline;}
1032 .syntaxhl .line-numbers {padding: 2px 4px 2px 4px; background-color: #eee; margin:0px 5px 0px 0px;}
1039 .syntaxhl .line-numbers {padding: 2px 4px 2px 4px; background-color: #eee; margin:0px 5px 0px 0px;}
1033 .syntaxhl .code pre { overflow: auto }
1040 .syntaxhl .code pre { overflow: auto }
1034 .syntaxhl .debug { color: white !important; background: blue !important; }
1041 .syntaxhl .debug { color: white !important; background: blue !important; }
1035
1042
1036 .syntaxhl .annotation { color:#007 }
1043 .syntaxhl .annotation { color:#007 }
1037 .syntaxhl .attribute-name { color:#b48 }
1044 .syntaxhl .attribute-name { color:#b48 }
1038 .syntaxhl .attribute-value { color:#700 }
1045 .syntaxhl .attribute-value { color:#700 }
1039 .syntaxhl .binary { color:#509 }
1046 .syntaxhl .binary { color:#509 }
1040 .syntaxhl .char .content { color:#D20 }
1047 .syntaxhl .char .content { color:#D20 }
1041 .syntaxhl .char .delimiter { color:#710 }
1048 .syntaxhl .char .delimiter { color:#710 }
1042 .syntaxhl .char { color:#D20 }
1049 .syntaxhl .char { color:#D20 }
1043 .syntaxhl .class { color:#258; font-weight:bold }
1050 .syntaxhl .class { color:#258; font-weight:bold }
1044 .syntaxhl .class-variable { color:#369 }
1051 .syntaxhl .class-variable { color:#369 }
1045 .syntaxhl .color { color:#0A0 }
1052 .syntaxhl .color { color:#0A0 }
1046 .syntaxhl .comment { color:#385 }
1053 .syntaxhl .comment { color:#385 }
1047 .syntaxhl .comment .char { color:#385 }
1054 .syntaxhl .comment .char { color:#385 }
1048 .syntaxhl .comment .delimiter { color:#385 }
1055 .syntaxhl .comment .delimiter { color:#385 }
1049 .syntaxhl .complex { color:#A08 }
1056 .syntaxhl .complex { color:#A08 }
1050 .syntaxhl .constant { color:#258; font-weight:bold }
1057 .syntaxhl .constant { color:#258; font-weight:bold }
1051 .syntaxhl .decorator { color:#B0B }
1058 .syntaxhl .decorator { color:#B0B }
1052 .syntaxhl .definition { color:#099; font-weight:bold }
1059 .syntaxhl .definition { color:#099; font-weight:bold }
1053 .syntaxhl .delimiter { color:black }
1060 .syntaxhl .delimiter { color:black }
1054 .syntaxhl .directive { color:#088; font-weight:bold }
1061 .syntaxhl .directive { color:#088; font-weight:bold }
1055 .syntaxhl .doc { color:#970 }
1062 .syntaxhl .doc { color:#970 }
1056 .syntaxhl .doc-string { color:#D42; font-weight:bold }
1063 .syntaxhl .doc-string { color:#D42; font-weight:bold }
1057 .syntaxhl .doctype { color:#34b }
1064 .syntaxhl .doctype { color:#34b }
1058 .syntaxhl .entity { color:#800; font-weight:bold }
1065 .syntaxhl .entity { color:#800; font-weight:bold }
1059 .syntaxhl .error { color:#F00; background-color:#FAA }
1066 .syntaxhl .error { color:#F00; background-color:#FAA }
1060 .syntaxhl .escape { color:#666 }
1067 .syntaxhl .escape { color:#666 }
1061 .syntaxhl .exception { color:#C00; font-weight:bold }
1068 .syntaxhl .exception { color:#C00; font-weight:bold }
1062 .syntaxhl .float { color:#06D }
1069 .syntaxhl .float { color:#06D }
1063 .syntaxhl .function { color:#06B; font-weight:bold }
1070 .syntaxhl .function { color:#06B; font-weight:bold }
1064 .syntaxhl .global-variable { color:#d70 }
1071 .syntaxhl .global-variable { color:#d70 }
1065 .syntaxhl .hex { color:#02b }
1072 .syntaxhl .hex { color:#02b }
1066 .syntaxhl .imaginary { color:#f00 }
1073 .syntaxhl .imaginary { color:#f00 }
1067 .syntaxhl .include { color:#B44; font-weight:bold }
1074 .syntaxhl .include { color:#B44; font-weight:bold }
1068 .syntaxhl .inline { background-color: hsla(0,0%,0%,0.07); color: black }
1075 .syntaxhl .inline { background-color: hsla(0,0%,0%,0.07); color: black }
1069 .syntaxhl .inline-delimiter { font-weight: bold; color: #666 }
1076 .syntaxhl .inline-delimiter { font-weight: bold; color: #666 }
1070 .syntaxhl .instance-variable { color:#33B }
1077 .syntaxhl .instance-variable { color:#33B }
1071 .syntaxhl .integer { color:#06D }
1078 .syntaxhl .integer { color:#06D }
1072 .syntaxhl .key .char { color: #60f }
1079 .syntaxhl .key .char { color: #60f }
1073 .syntaxhl .key .delimiter { color: #404 }
1080 .syntaxhl .key .delimiter { color: #404 }
1074 .syntaxhl .key { color: #606 }
1081 .syntaxhl .key { color: #606 }
1075 .syntaxhl .keyword { color:#939; font-weight:bold }
1082 .syntaxhl .keyword { color:#939; font-weight:bold }
1076 .syntaxhl .label { color:#970; font-weight:bold }
1083 .syntaxhl .label { color:#970; font-weight:bold }
1077 .syntaxhl .local-variable { color:#963 }
1084 .syntaxhl .local-variable { color:#963 }
1078 .syntaxhl .namespace { color:#707; font-weight:bold }
1085 .syntaxhl .namespace { color:#707; font-weight:bold }
1079 .syntaxhl .octal { color:#40E }
1086 .syntaxhl .octal { color:#40E }
1080 .syntaxhl .operator { }
1087 .syntaxhl .operator { }
1081 .syntaxhl .predefined { color:#369; font-weight:bold }
1088 .syntaxhl .predefined { color:#369; font-weight:bold }
1082 .syntaxhl .predefined-constant { color:#069 }
1089 .syntaxhl .predefined-constant { color:#069 }
1083 .syntaxhl .predefined-type { color:#0a5; font-weight:bold }
1090 .syntaxhl .predefined-type { color:#0a5; font-weight:bold }
1084 .syntaxhl .preprocessor { color:#579 }
1091 .syntaxhl .preprocessor { color:#579 }
1085 .syntaxhl .pseudo-class { color:#00C; font-weight:bold }
1092 .syntaxhl .pseudo-class { color:#00C; font-weight:bold }
1086 .syntaxhl .regexp .content { color:#808 }
1093 .syntaxhl .regexp .content { color:#808 }
1087 .syntaxhl .regexp .delimiter { color:#404 }
1094 .syntaxhl .regexp .delimiter { color:#404 }
1088 .syntaxhl .regexp .modifier { color:#C2C }
1095 .syntaxhl .regexp .modifier { color:#C2C }
1089 .syntaxhl .regexp { background-color:hsla(300,100%,50%,0.06); }
1096 .syntaxhl .regexp { background-color:hsla(300,100%,50%,0.06); }
1090 .syntaxhl .reserved { color:#080; font-weight:bold }
1097 .syntaxhl .reserved { color:#080; font-weight:bold }
1091 .syntaxhl .shell .content { color:#2B2 }
1098 .syntaxhl .shell .content { color:#2B2 }
1092 .syntaxhl .shell .delimiter { color:#161 }
1099 .syntaxhl .shell .delimiter { color:#161 }
1093 .syntaxhl .shell { background-color:hsla(120,100%,50%,0.06); }
1100 .syntaxhl .shell { background-color:hsla(120,100%,50%,0.06); }
1094 .syntaxhl .string .char { color: #46a }
1101 .syntaxhl .string .char { color: #46a }
1095 .syntaxhl .string .content { color: #46a }
1102 .syntaxhl .string .content { color: #46a }
1096 .syntaxhl .string .delimiter { color: #46a }
1103 .syntaxhl .string .delimiter { color: #46a }
1097 .syntaxhl .string .modifier { color: #46a }
1104 .syntaxhl .string .modifier { color: #46a }
1098 .syntaxhl .symbol .content { color:#d33 }
1105 .syntaxhl .symbol .content { color:#d33 }
1099 .syntaxhl .symbol .delimiter { color:#d33 }
1106 .syntaxhl .symbol .delimiter { color:#d33 }
1100 .syntaxhl .symbol { color:#d33 }
1107 .syntaxhl .symbol { color:#d33 }
1101 .syntaxhl .tag { color:#070 }
1108 .syntaxhl .tag { color:#070 }
1102 .syntaxhl .type { color:#339; font-weight:bold }
1109 .syntaxhl .type { color:#339; font-weight:bold }
1103 .syntaxhl .value { color: #088; }
1110 .syntaxhl .value { color: #088; }
1104 .syntaxhl .variable { color:#037 }
1111 .syntaxhl .variable { color:#037 }
1105
1112
1106 .syntaxhl .insert { background: hsla(120,100%,50%,0.12) }
1113 .syntaxhl .insert { background: hsla(120,100%,50%,0.12) }
1107 .syntaxhl .delete { background: hsla(0,100%,50%,0.12) }
1114 .syntaxhl .delete { background: hsla(0,100%,50%,0.12) }
1108 .syntaxhl .change { color: #bbf; background: #007; }
1115 .syntaxhl .change { color: #bbf; background: #007; }
1109 .syntaxhl .head { color: #f8f; background: #505 }
1116 .syntaxhl .head { color: #f8f; background: #505 }
1110 .syntaxhl .head .filename { color: white; }
1117 .syntaxhl .head .filename { color: white; }
1111
1118
1112 .syntaxhl .delete .eyecatcher { background-color: hsla(0,100%,50%,0.2); border: 1px solid hsla(0,100%,45%,0.5); margin: -1px; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; }
1119 .syntaxhl .delete .eyecatcher { background-color: hsla(0,100%,50%,0.2); border: 1px solid hsla(0,100%,45%,0.5); margin: -1px; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; }
1113 .syntaxhl .insert .eyecatcher { background-color: hsla(120,100%,50%,0.2); border: 1px solid hsla(120,100%,25%,0.5); margin: -1px; border-top: none; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; }
1120 .syntaxhl .insert .eyecatcher { background-color: hsla(120,100%,50%,0.2); border: 1px solid hsla(120,100%,25%,0.5); margin: -1px; border-top: none; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; }
1114
1121
1115 .syntaxhl .insert .insert { color: #0c0; background:transparent; font-weight:bold }
1122 .syntaxhl .insert .insert { color: #0c0; background:transparent; font-weight:bold }
1116 .syntaxhl .delete .delete { color: #c00; background:transparent; font-weight:bold }
1123 .syntaxhl .delete .delete { color: #c00; background:transparent; font-weight:bold }
1117 .syntaxhl .change .change { color: #88f }
1124 .syntaxhl .change .change { color: #88f }
1118 .syntaxhl .head .head { color: #f4f }
1125 .syntaxhl .head .head { color: #f4f }
1119
1126
1120 /***** Media print specific styles *****/
1127 /***** Media print specific styles *****/
1121 @media print {
1128 @media print {
1122 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
1129 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
1123 #main { background: #fff; }
1130 #main { background: #fff; }
1124 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
1131 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
1125 #wiki_add_attachment { display:none; }
1132 #wiki_add_attachment { display:none; }
1126 .hide-when-print { display: none; }
1133 .hide-when-print { display: none; }
1127 .autoscroll {overflow-x: visible;}
1134 .autoscroll {overflow-x: visible;}
1128 table.list {margin-top:0.5em;}
1135 table.list {margin-top:0.5em;}
1129 table.list th, table.list td {border: 1px solid #aaa;}
1136 table.list th, table.list td {border: 1px solid #aaa;}
1130 }
1137 }
1131
1138
1132 /* Accessibility specific styles */
1139 /* Accessibility specific styles */
1133 .hidden-for-sighted {
1140 .hidden-for-sighted {
1134 position:absolute;
1141 position:absolute;
1135 left:-10000px;
1142 left:-10000px;
1136 top:auto;
1143 top:auto;
1137 width:1px;
1144 width:1px;
1138 height:1px;
1145 height:1px;
1139 overflow:hidden;
1146 overflow:hidden;
1140 }
1147 }
@@ -1,376 +1,385
1 # encoding: utf-8
1 # encoding: utf-8
2 #
2 #
3 # Redmine - project management software
3 # Redmine - project management software
4 # Copyright (C) 2006-2012 Jean-Philippe Lang
4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 #
5 #
6 # This program is free software; you can redistribute it and/or
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
9 # of the License, or (at your option) any later version.
10 #
10 #
11 # This program is distributed in the hope that it will be useful,
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
14 # GNU General Public License for more details.
15 #
15 #
16 # You should have received a copy of the GNU General Public License
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19
19
20 require File.expand_path('../../test_helper', __FILE__)
20 require File.expand_path('../../test_helper', __FILE__)
21
21
22 class AttachmentsControllerTest < ActionController::TestCase
22 class AttachmentsControllerTest < ActionController::TestCase
23 fixtures :users, :projects, :roles, :members, :member_roles,
23 fixtures :users, :projects, :roles, :members, :member_roles,
24 :enabled_modules, :issues, :trackers, :attachments,
24 :enabled_modules, :issues, :trackers, :attachments,
25 :versions, :wiki_pages, :wikis, :documents
25 :versions, :wiki_pages, :wikis, :documents
26
26
27 def setup
27 def setup
28 User.current = nil
28 User.current = nil
29 set_fixtures_attachments_directory
29 set_fixtures_attachments_directory
30 end
30 end
31
31
32 def teardown
32 def teardown
33 set_tmp_attachments_directory
33 set_tmp_attachments_directory
34 end
34 end
35
35
36 def test_show_diff
36 def test_show_diff
37 ['inline', 'sbs'].each do |dt|
37 ['inline', 'sbs'].each do |dt|
38 # 060719210727_changeset_utf8.diff
38 # 060719210727_changeset_utf8.diff
39 get :show, :id => 14, :type => dt
39 get :show, :id => 14, :type => dt
40 assert_response :success
40 assert_response :success
41 assert_template 'diff'
41 assert_template 'diff'
42 assert_equal 'text/html', @response.content_type
42 assert_equal 'text/html', @response.content_type
43 assert_tag 'th',
43 assert_tag 'th',
44 :attributes => {:class => /filename/},
44 :attributes => {:class => /filename/},
45 :content => /issues_controller.rb\t\(rΓ©vision 1484\)/
45 :content => /issues_controller.rb\t\(rΓ©vision 1484\)/
46 assert_tag 'td',
46 assert_tag 'td',
47 :attributes => {:class => /line-code/},
47 :attributes => {:class => /line-code/},
48 :content => /Demande créée avec succès/
48 :content => /Demande créée avec succès/
49 end
49 end
50 set_tmp_attachments_directory
50 set_tmp_attachments_directory
51 end
51 end
52
52
53 def test_show_diff_replcace_cannot_convert_content
53 def test_show_diff_replcace_cannot_convert_content
54 with_settings :repositories_encodings => 'UTF-8' do
54 with_settings :repositories_encodings => 'UTF-8' do
55 ['inline', 'sbs'].each do |dt|
55 ['inline', 'sbs'].each do |dt|
56 # 060719210727_changeset_iso8859-1.diff
56 # 060719210727_changeset_iso8859-1.diff
57 get :show, :id => 5, :type => dt
57 get :show, :id => 5, :type => dt
58 assert_response :success
58 assert_response :success
59 assert_template 'diff'
59 assert_template 'diff'
60 assert_equal 'text/html', @response.content_type
60 assert_equal 'text/html', @response.content_type
61 assert_tag 'th',
61 assert_tag 'th',
62 :attributes => {:class => "filename"},
62 :attributes => {:class => "filename"},
63 :content => /issues_controller.rb\t\(r\?vision 1484\)/
63 :content => /issues_controller.rb\t\(r\?vision 1484\)/
64 assert_tag 'td',
64 assert_tag 'td',
65 :attributes => {:class => /line-code/},
65 :attributes => {:class => /line-code/},
66 :content => /Demande cr\?\?e avec succ\?s/
66 :content => /Demande cr\?\?e avec succ\?s/
67 end
67 end
68 end
68 end
69 set_tmp_attachments_directory
69 set_tmp_attachments_directory
70 end
70 end
71
71
72 def test_show_diff_latin_1
72 def test_show_diff_latin_1
73 with_settings :repositories_encodings => 'UTF-8,ISO-8859-1' do
73 with_settings :repositories_encodings => 'UTF-8,ISO-8859-1' do
74 ['inline', 'sbs'].each do |dt|
74 ['inline', 'sbs'].each do |dt|
75 # 060719210727_changeset_iso8859-1.diff
75 # 060719210727_changeset_iso8859-1.diff
76 get :show, :id => 5, :type => dt
76 get :show, :id => 5, :type => dt
77 assert_response :success
77 assert_response :success
78 assert_template 'diff'
78 assert_template 'diff'
79 assert_equal 'text/html', @response.content_type
79 assert_equal 'text/html', @response.content_type
80 assert_tag 'th',
80 assert_tag 'th',
81 :attributes => {:class => "filename"},
81 :attributes => {:class => "filename"},
82 :content => /issues_controller.rb\t\(rΓ©vision 1484\)/
82 :content => /issues_controller.rb\t\(rΓ©vision 1484\)/
83 assert_tag 'td',
83 assert_tag 'td',
84 :attributes => {:class => /line-code/},
84 :attributes => {:class => /line-code/},
85 :content => /Demande créée avec succès/
85 :content => /Demande créée avec succès/
86 end
86 end
87 end
87 end
88 set_tmp_attachments_directory
88 set_tmp_attachments_directory
89 end
89 end
90
90
91 def test_save_diff_type
91 def test_save_diff_type
92 user1 = User.find(1)
92 user1 = User.find(1)
93 user1.pref[:diff_type] = nil
93 user1.pref[:diff_type] = nil
94 user1.preference.save
94 user1.preference.save
95 user = User.find(1)
95 user = User.find(1)
96 assert_nil user.pref[:diff_type]
96 assert_nil user.pref[:diff_type]
97
97
98 @request.session[:user_id] = 1 # admin
98 @request.session[:user_id] = 1 # admin
99 get :show, :id => 5
99 get :show, :id => 5
100 assert_response :success
100 assert_response :success
101 assert_template 'diff'
101 assert_template 'diff'
102 user.reload
102 user.reload
103 assert_equal "inline", user.pref[:diff_type]
103 assert_equal "inline", user.pref[:diff_type]
104 get :show, :id => 5, :type => 'sbs'
104 get :show, :id => 5, :type => 'sbs'
105 assert_response :success
105 assert_response :success
106 assert_template 'diff'
106 assert_template 'diff'
107 user.reload
107 user.reload
108 assert_equal "sbs", user.pref[:diff_type]
108 assert_equal "sbs", user.pref[:diff_type]
109 end
109 end
110
110
111 def test_diff_show_filename_in_mercurial_export
111 def test_diff_show_filename_in_mercurial_export
112 set_tmp_attachments_directory
112 set_tmp_attachments_directory
113 a = Attachment.new(:container => Issue.find(1),
113 a = Attachment.new(:container => Issue.find(1),
114 :file => uploaded_test_file("hg-export.diff", "text/plain"),
114 :file => uploaded_test_file("hg-export.diff", "text/plain"),
115 :author => User.find(1))
115 :author => User.find(1))
116 assert a.save
116 assert a.save
117 assert_equal 'hg-export.diff', a.filename
117 assert_equal 'hg-export.diff', a.filename
118
118
119 get :show, :id => a.id, :type => 'inline'
119 get :show, :id => a.id, :type => 'inline'
120 assert_response :success
120 assert_response :success
121 assert_template 'diff'
121 assert_template 'diff'
122 assert_equal 'text/html', @response.content_type
122 assert_equal 'text/html', @response.content_type
123 assert_select 'th.filename', :text => 'test1.txt'
123 assert_select 'th.filename', :text => 'test1.txt'
124 end
124 end
125
125
126 def test_show_text_file
126 def test_show_text_file
127 get :show, :id => 4
127 get :show, :id => 4
128 assert_response :success
128 assert_response :success
129 assert_template 'file'
129 assert_template 'file'
130 assert_equal 'text/html', @response.content_type
130 assert_equal 'text/html', @response.content_type
131 set_tmp_attachments_directory
131 set_tmp_attachments_directory
132 end
132 end
133
133
134 def test_show_text_file_utf_8
134 def test_show_text_file_utf_8
135 set_tmp_attachments_directory
135 set_tmp_attachments_directory
136 a = Attachment.new(:container => Issue.find(1),
136 a = Attachment.new(:container => Issue.find(1),
137 :file => uploaded_test_file("japanese-utf-8.txt", "text/plain"),
137 :file => uploaded_test_file("japanese-utf-8.txt", "text/plain"),
138 :author => User.find(1))
138 :author => User.find(1))
139 assert a.save
139 assert a.save
140 assert_equal 'japanese-utf-8.txt', a.filename
140 assert_equal 'japanese-utf-8.txt', a.filename
141
141
142 str_japanese = "\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e"
142 str_japanese = "\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e"
143 str_japanese.force_encoding('UTF-8') if str_japanese.respond_to?(:force_encoding)
143 str_japanese.force_encoding('UTF-8') if str_japanese.respond_to?(:force_encoding)
144
144
145 get :show, :id => a.id
145 get :show, :id => a.id
146 assert_response :success
146 assert_response :success
147 assert_template 'file'
147 assert_template 'file'
148 assert_equal 'text/html', @response.content_type
148 assert_equal 'text/html', @response.content_type
149 assert_tag :tag => 'th',
149 assert_tag :tag => 'th',
150 :content => '1',
150 :content => '1',
151 :attributes => { :class => 'line-num' },
151 :attributes => { :class => 'line-num' },
152 :sibling => { :tag => 'td', :content => /#{str_japanese}/ }
152 :sibling => { :tag => 'td', :content => /#{str_japanese}/ }
153 end
153 end
154
154
155 def test_show_text_file_replcace_cannot_convert_content
155 def test_show_text_file_replcace_cannot_convert_content
156 set_tmp_attachments_directory
156 set_tmp_attachments_directory
157 with_settings :repositories_encodings => 'UTF-8' do
157 with_settings :repositories_encodings => 'UTF-8' do
158 a = Attachment.new(:container => Issue.find(1),
158 a = Attachment.new(:container => Issue.find(1),
159 :file => uploaded_test_file("iso8859-1.txt", "text/plain"),
159 :file => uploaded_test_file("iso8859-1.txt", "text/plain"),
160 :author => User.find(1))
160 :author => User.find(1))
161 assert a.save
161 assert a.save
162 assert_equal 'iso8859-1.txt', a.filename
162 assert_equal 'iso8859-1.txt', a.filename
163
163
164 get :show, :id => a.id
164 get :show, :id => a.id
165 assert_response :success
165 assert_response :success
166 assert_template 'file'
166 assert_template 'file'
167 assert_equal 'text/html', @response.content_type
167 assert_equal 'text/html', @response.content_type
168 assert_tag :tag => 'th',
168 assert_tag :tag => 'th',
169 :content => '7',
169 :content => '7',
170 :attributes => { :class => 'line-num' },
170 :attributes => { :class => 'line-num' },
171 :sibling => { :tag => 'td', :content => /Demande cr\?\?e avec succ\?s/ }
171 :sibling => { :tag => 'td', :content => /Demande cr\?\?e avec succ\?s/ }
172 end
172 end
173 end
173 end
174
174
175 def test_show_text_file_latin_1
175 def test_show_text_file_latin_1
176 set_tmp_attachments_directory
176 set_tmp_attachments_directory
177 with_settings :repositories_encodings => 'UTF-8,ISO-8859-1' do
177 with_settings :repositories_encodings => 'UTF-8,ISO-8859-1' do
178 a = Attachment.new(:container => Issue.find(1),
178 a = Attachment.new(:container => Issue.find(1),
179 :file => uploaded_test_file("iso8859-1.txt", "text/plain"),
179 :file => uploaded_test_file("iso8859-1.txt", "text/plain"),
180 :author => User.find(1))
180 :author => User.find(1))
181 assert a.save
181 assert a.save
182 assert_equal 'iso8859-1.txt', a.filename
182 assert_equal 'iso8859-1.txt', a.filename
183
183
184 get :show, :id => a.id
184 get :show, :id => a.id
185 assert_response :success
185 assert_response :success
186 assert_template 'file'
186 assert_template 'file'
187 assert_equal 'text/html', @response.content_type
187 assert_equal 'text/html', @response.content_type
188 assert_tag :tag => 'th',
188 assert_tag :tag => 'th',
189 :content => '7',
189 :content => '7',
190 :attributes => { :class => 'line-num' },
190 :attributes => { :class => 'line-num' },
191 :sibling => { :tag => 'td', :content => /Demande créée avec succès/ }
191 :sibling => { :tag => 'td', :content => /Demande créée avec succès/ }
192 end
192 end
193 end
193 end
194
194
195 def test_show_text_file_should_send_if_too_big
195 def test_show_text_file_should_send_if_too_big
196 Setting.file_max_size_displayed = 512
196 Setting.file_max_size_displayed = 512
197 Attachment.find(4).update_attribute :filesize, 754.kilobyte
197 Attachment.find(4).update_attribute :filesize, 754.kilobyte
198
198
199 get :show, :id => 4
199 get :show, :id => 4
200 assert_response :success
200 assert_response :success
201 assert_equal 'application/x-ruby', @response.content_type
201 assert_equal 'application/x-ruby', @response.content_type
202 set_tmp_attachments_directory
202 set_tmp_attachments_directory
203 end
203 end
204
204
205 def test_show_other
205 def test_show_other
206 get :show, :id => 6
206 get :show, :id => 6
207 assert_response :success
207 assert_response :success
208 assert_equal 'application/octet-stream', @response.content_type
208 assert_equal 'application/octet-stream', @response.content_type
209 set_tmp_attachments_directory
209 set_tmp_attachments_directory
210 end
210 end
211
211
212 def test_show_file_from_private_issue_without_permission
212 def test_show_file_from_private_issue_without_permission
213 get :show, :id => 15
213 get :show, :id => 15
214 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2F15'
214 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2F15'
215 set_tmp_attachments_directory
215 set_tmp_attachments_directory
216 end
216 end
217
217
218 def test_show_file_from_private_issue_with_permission
218 def test_show_file_from_private_issue_with_permission
219 @request.session[:user_id] = 2
219 @request.session[:user_id] = 2
220 get :show, :id => 15
220 get :show, :id => 15
221 assert_response :success
221 assert_response :success
222 assert_tag 'h2', :content => /private.diff/
222 assert_tag 'h2', :content => /private.diff/
223 set_tmp_attachments_directory
223 set_tmp_attachments_directory
224 end
224 end
225
225
226 def test_show_file_without_container_should_be_denied
226 def test_show_file_without_container_should_be_allowed_to_author
227 set_tmp_attachments_directory
227 set_tmp_attachments_directory
228 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
228 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
229
229
230 @request.session[:user_id] = 2
230 @request.session[:user_id] = 2
231 get :show, :id => attachment.id
231 get :show, :id => attachment.id
232 assert_response 200
233 end
234
235 def test_show_file_without_container_should_be_allowed_to_author
236 set_tmp_attachments_directory
237 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
238
239 @request.session[:user_id] = 3
240 get :show, :id => attachment.id
232 assert_response 403
241 assert_response 403
233 end
242 end
234
243
235 def test_show_invalid_should_respond_with_404
244 def test_show_invalid_should_respond_with_404
236 get :show, :id => 999
245 get :show, :id => 999
237 assert_response 404
246 assert_response 404
238 end
247 end
239
248
240 def test_download_text_file
249 def test_download_text_file
241 get :download, :id => 4
250 get :download, :id => 4
242 assert_response :success
251 assert_response :success
243 assert_equal 'application/x-ruby', @response.content_type
252 assert_equal 'application/x-ruby', @response.content_type
244 set_tmp_attachments_directory
253 set_tmp_attachments_directory
245 end
254 end
246
255
247 def test_download_version_file_with_issue_tracking_disabled
256 def test_download_version_file_with_issue_tracking_disabled
248 Project.find(1).disable_module! :issue_tracking
257 Project.find(1).disable_module! :issue_tracking
249 get :download, :id => 9
258 get :download, :id => 9
250 assert_response :success
259 assert_response :success
251 end
260 end
252
261
253 def test_download_should_assign_content_type_if_blank
262 def test_download_should_assign_content_type_if_blank
254 Attachment.find(4).update_attribute(:content_type, '')
263 Attachment.find(4).update_attribute(:content_type, '')
255
264
256 get :download, :id => 4
265 get :download, :id => 4
257 assert_response :success
266 assert_response :success
258 assert_equal 'text/x-ruby', @response.content_type
267 assert_equal 'text/x-ruby', @response.content_type
259 set_tmp_attachments_directory
268 set_tmp_attachments_directory
260 end
269 end
261
270
262 def test_download_missing_file
271 def test_download_missing_file
263 get :download, :id => 2
272 get :download, :id => 2
264 assert_response 404
273 assert_response 404
265 set_tmp_attachments_directory
274 set_tmp_attachments_directory
266 end
275 end
267
276
268 def test_download_should_be_denied_without_permission
277 def test_download_should_be_denied_without_permission
269 get :download, :id => 7
278 get :download, :id => 7
270 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdownload%2F7'
279 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdownload%2F7'
271 set_tmp_attachments_directory
280 set_tmp_attachments_directory
272 end
281 end
273
282
274 if convert_installed?
283 if convert_installed?
275 def test_thumbnail
284 def test_thumbnail
276 Attachment.clear_thumbnails
285 Attachment.clear_thumbnails
277 @request.session[:user_id] = 2
286 @request.session[:user_id] = 2
278
287
279 get :thumbnail, :id => 16
288 get :thumbnail, :id => 16
280 assert_response :success
289 assert_response :success
281 assert_equal 'image/png', response.content_type
290 assert_equal 'image/png', response.content_type
282 end
291 end
283
292
284 def test_thumbnail_should_not_exceed_maximum_size
293 def test_thumbnail_should_not_exceed_maximum_size
285 Redmine::Thumbnail.expects(:generate).with {|source, target, size| size == 800}
294 Redmine::Thumbnail.expects(:generate).with {|source, target, size| size == 800}
286
295
287 @request.session[:user_id] = 2
296 @request.session[:user_id] = 2
288 get :thumbnail, :id => 16, :size => 2000
297 get :thumbnail, :id => 16, :size => 2000
289 end
298 end
290
299
291 def test_thumbnail_should_round_size
300 def test_thumbnail_should_round_size
292 Redmine::Thumbnail.expects(:generate).with {|source, target, size| size == 250}
301 Redmine::Thumbnail.expects(:generate).with {|source, target, size| size == 250}
293
302
294 @request.session[:user_id] = 2
303 @request.session[:user_id] = 2
295 get :thumbnail, :id => 16, :size => 260
304 get :thumbnail, :id => 16, :size => 260
296 end
305 end
297
306
298 def test_thumbnail_should_return_404_for_non_image_attachment
307 def test_thumbnail_should_return_404_for_non_image_attachment
299 @request.session[:user_id] = 2
308 @request.session[:user_id] = 2
300
309
301 get :thumbnail, :id => 15
310 get :thumbnail, :id => 15
302 assert_response 404
311 assert_response 404
303 end
312 end
304
313
305 def test_thumbnail_should_return_404_if_thumbnail_generation_failed
314 def test_thumbnail_should_return_404_if_thumbnail_generation_failed
306 Attachment.any_instance.stubs(:thumbnail).returns(nil)
315 Attachment.any_instance.stubs(:thumbnail).returns(nil)
307 @request.session[:user_id] = 2
316 @request.session[:user_id] = 2
308
317
309 get :thumbnail, :id => 16
318 get :thumbnail, :id => 16
310 assert_response 404
319 assert_response 404
311 end
320 end
312
321
313 def test_thumbnail_should_be_denied_without_permission
322 def test_thumbnail_should_be_denied_without_permission
314 get :thumbnail, :id => 16
323 get :thumbnail, :id => 16
315 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fthumbnail%2F16'
324 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fthumbnail%2F16'
316 end
325 end
317 else
326 else
318 puts '(ImageMagick convert not available)'
327 puts '(ImageMagick convert not available)'
319 end
328 end
320
329
321 def test_destroy_issue_attachment
330 def test_destroy_issue_attachment
322 set_tmp_attachments_directory
331 set_tmp_attachments_directory
323 issue = Issue.find(3)
332 issue = Issue.find(3)
324 @request.session[:user_id] = 2
333 @request.session[:user_id] = 2
325
334
326 assert_difference 'issue.attachments.count', -1 do
335 assert_difference 'issue.attachments.count', -1 do
327 assert_difference 'Journal.count' do
336 assert_difference 'Journal.count' do
328 delete :destroy, :id => 1
337 delete :destroy, :id => 1
329 assert_redirected_to '/projects/ecookbook'
338 assert_redirected_to '/projects/ecookbook'
330 end
339 end
331 end
340 end
332 assert_nil Attachment.find_by_id(1)
341 assert_nil Attachment.find_by_id(1)
333 j = Journal.first(:order => 'id DESC')
342 j = Journal.first(:order => 'id DESC')
334 assert_equal issue, j.journalized
343 assert_equal issue, j.journalized
335 assert_equal 'attachment', j.details.first.property
344 assert_equal 'attachment', j.details.first.property
336 assert_equal '1', j.details.first.prop_key
345 assert_equal '1', j.details.first.prop_key
337 assert_equal 'error281.txt', j.details.first.old_value
346 assert_equal 'error281.txt', j.details.first.old_value
338 assert_equal User.find(2), j.user
347 assert_equal User.find(2), j.user
339 end
348 end
340
349
341 def test_destroy_wiki_page_attachment
350 def test_destroy_wiki_page_attachment
342 set_tmp_attachments_directory
351 set_tmp_attachments_directory
343 @request.session[:user_id] = 2
352 @request.session[:user_id] = 2
344 assert_difference 'Attachment.count', -1 do
353 assert_difference 'Attachment.count', -1 do
345 delete :destroy, :id => 3
354 delete :destroy, :id => 3
346 assert_response 302
355 assert_response 302
347 end
356 end
348 end
357 end
349
358
350 def test_destroy_project_attachment
359 def test_destroy_project_attachment
351 set_tmp_attachments_directory
360 set_tmp_attachments_directory
352 @request.session[:user_id] = 2
361 @request.session[:user_id] = 2
353 assert_difference 'Attachment.count', -1 do
362 assert_difference 'Attachment.count', -1 do
354 delete :destroy, :id => 8
363 delete :destroy, :id => 8
355 assert_response 302
364 assert_response 302
356 end
365 end
357 end
366 end
358
367
359 def test_destroy_version_attachment
368 def test_destroy_version_attachment
360 set_tmp_attachments_directory
369 set_tmp_attachments_directory
361 @request.session[:user_id] = 2
370 @request.session[:user_id] = 2
362 assert_difference 'Attachment.count', -1 do
371 assert_difference 'Attachment.count', -1 do
363 delete :destroy, :id => 9
372 delete :destroy, :id => 9
364 assert_response 302
373 assert_response 302
365 end
374 end
366 end
375 end
367
376
368 def test_destroy_without_permission
377 def test_destroy_without_permission
369 set_tmp_attachments_directory
378 set_tmp_attachments_directory
370 assert_no_difference 'Attachment.count' do
379 assert_no_difference 'Attachment.count' do
371 delete :destroy, :id => 3
380 delete :destroy, :id => 3
372 end
381 end
373 assert_response 302
382 assert_response 302
374 assert Attachment.find_by_id(3)
383 assert Attachment.find_by_id(3)
375 end
384 end
376 end
385 end
1 NO CONTENT: modified file
NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,263 +1,263
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19 require 'issues_controller'
19 require 'issues_controller'
20
20
21 class IssuesControllerTransactionTest < ActionController::TestCase
21 class IssuesControllerTransactionTest < ActionController::TestCase
22 tests IssuesController
22 tests IssuesController
23 fixtures :projects,
23 fixtures :projects,
24 :users,
24 :users,
25 :roles,
25 :roles,
26 :members,
26 :members,
27 :member_roles,
27 :member_roles,
28 :issues,
28 :issues,
29 :issue_statuses,
29 :issue_statuses,
30 :versions,
30 :versions,
31 :trackers,
31 :trackers,
32 :projects_trackers,
32 :projects_trackers,
33 :issue_categories,
33 :issue_categories,
34 :enabled_modules,
34 :enabled_modules,
35 :enumerations,
35 :enumerations,
36 :attachments,
36 :attachments,
37 :workflows,
37 :workflows,
38 :custom_fields,
38 :custom_fields,
39 :custom_values,
39 :custom_values,
40 :custom_fields_projects,
40 :custom_fields_projects,
41 :custom_fields_trackers,
41 :custom_fields_trackers,
42 :time_entries,
42 :time_entries,
43 :journals,
43 :journals,
44 :journal_details,
44 :journal_details,
45 :queries
45 :queries
46
46
47 self.use_transactional_fixtures = false
47 self.use_transactional_fixtures = false
48
48
49 def setup
49 def setup
50 User.current = nil
50 User.current = nil
51 end
51 end
52
52
53 def test_update_stale_issue_should_not_update_the_issue
53 def test_update_stale_issue_should_not_update_the_issue
54 issue = Issue.find(2)
54 issue = Issue.find(2)
55 @request.session[:user_id] = 2
55 @request.session[:user_id] = 2
56
56
57 assert_no_difference 'Journal.count' do
57 assert_no_difference 'Journal.count' do
58 assert_no_difference 'TimeEntry.count' do
58 assert_no_difference 'TimeEntry.count' do
59 put :update,
59 put :update,
60 :id => issue.id,
60 :id => issue.id,
61 :issue => {
61 :issue => {
62 :fixed_version_id => 4,
62 :fixed_version_id => 4,
63 :notes => 'My notes',
63 :notes => 'My notes',
64 :lock_version => (issue.lock_version - 1)
64 :lock_version => (issue.lock_version - 1)
65 },
65 },
66 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }
66 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }
67 end
67 end
68 end
68 end
69
69
70 assert_response :success
70 assert_response :success
71 assert_template 'edit'
71 assert_template 'edit'
72
72
73 assert_select 'div.conflict'
73 assert_select 'div.conflict'
74 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'overwrite'
74 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'overwrite'
75 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'add_notes'
75 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'add_notes'
76 assert_select 'label' do
76 assert_select 'label' do
77 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'cancel'
77 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'cancel'
78 assert_select 'a[href=/issues/2]'
78 assert_select 'a[href=/issues/2]'
79 end
79 end
80 end
80 end
81
81
82 def test_update_stale_issue_should_save_attachments
82 def test_update_stale_issue_should_save_attachments
83 set_tmp_attachments_directory
83 set_tmp_attachments_directory
84 issue = Issue.find(2)
84 issue = Issue.find(2)
85 @request.session[:user_id] = 2
85 @request.session[:user_id] = 2
86
86
87 assert_no_difference 'Journal.count' do
87 assert_no_difference 'Journal.count' do
88 assert_no_difference 'TimeEntry.count' do
88 assert_no_difference 'TimeEntry.count' do
89 assert_difference 'Attachment.count' do
89 assert_difference 'Attachment.count' do
90 put :update,
90 put :update,
91 :id => issue.id,
91 :id => issue.id,
92 :issue => {
92 :issue => {
93 :fixed_version_id => 4,
93 :fixed_version_id => 4,
94 :notes => 'My notes',
94 :notes => 'My notes',
95 :lock_version => (issue.lock_version - 1)
95 :lock_version => (issue.lock_version - 1)
96 },
96 },
97 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}},
97 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}},
98 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }
98 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }
99 end
99 end
100 end
100 end
101 end
101 end
102
102
103 assert_response :success
103 assert_response :success
104 assert_template 'edit'
104 assert_template 'edit'
105 attachment = Attachment.first(:order => 'id DESC')
105 attachment = Attachment.first(:order => 'id DESC')
106 assert_tag 'input', :attributes => {:name => 'attachments[p0][token]', :value => attachment.token}
106 assert_tag 'input', :attributes => {:name => 'attachments[p0][token]', :value => attachment.token}
107 assert_tag 'span', :content => /testfile.txt/
107 assert_tag 'input', :attributes => {:name => 'attachments[p0][filename]', :value => 'testfile.txt'}
108 end
108 end
109
109
110 def test_update_stale_issue_without_notes_should_not_show_add_notes_option
110 def test_update_stale_issue_without_notes_should_not_show_add_notes_option
111 issue = Issue.find(2)
111 issue = Issue.find(2)
112 @request.session[:user_id] = 2
112 @request.session[:user_id] = 2
113
113
114 put :update, :id => issue.id,
114 put :update, :id => issue.id,
115 :issue => {
115 :issue => {
116 :fixed_version_id => 4,
116 :fixed_version_id => 4,
117 :notes => '',
117 :notes => '',
118 :lock_version => (issue.lock_version - 1)
118 :lock_version => (issue.lock_version - 1)
119 }
119 }
120
120
121 assert_tag 'div', :attributes => {:class => 'conflict'}
121 assert_tag 'div', :attributes => {:class => 'conflict'}
122 assert_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'overwrite'}
122 assert_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'overwrite'}
123 assert_no_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'add_notes'}
123 assert_no_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'add_notes'}
124 assert_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'cancel'}
124 assert_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'cancel'}
125 end
125 end
126
126
127 def test_update_stale_issue_should_show_conflicting_journals
127 def test_update_stale_issue_should_show_conflicting_journals
128 @request.session[:user_id] = 2
128 @request.session[:user_id] = 2
129
129
130 put :update, :id => 1,
130 put :update, :id => 1,
131 :issue => {
131 :issue => {
132 :fixed_version_id => 4,
132 :fixed_version_id => 4,
133 :notes => '',
133 :notes => '',
134 :lock_version => 2
134 :lock_version => 2
135 },
135 },
136 :last_journal_id => 1
136 :last_journal_id => 1
137
137
138 assert_not_nil assigns(:conflict_journals)
138 assert_not_nil assigns(:conflict_journals)
139 assert_equal 1, assigns(:conflict_journals).size
139 assert_equal 1, assigns(:conflict_journals).size
140 assert_equal 2, assigns(:conflict_journals).first.id
140 assert_equal 2, assigns(:conflict_journals).first.id
141 assert_tag 'div', :attributes => {:class => 'conflict'},
141 assert_tag 'div', :attributes => {:class => 'conflict'},
142 :descendant => {:content => /Some notes with Redmine links/}
142 :descendant => {:content => /Some notes with Redmine links/}
143 end
143 end
144
144
145 def test_update_stale_issue_without_previous_journal_should_show_all_journals
145 def test_update_stale_issue_without_previous_journal_should_show_all_journals
146 @request.session[:user_id] = 2
146 @request.session[:user_id] = 2
147
147
148 put :update, :id => 1,
148 put :update, :id => 1,
149 :issue => {
149 :issue => {
150 :fixed_version_id => 4,
150 :fixed_version_id => 4,
151 :notes => '',
151 :notes => '',
152 :lock_version => 2
152 :lock_version => 2
153 },
153 },
154 :last_journal_id => ''
154 :last_journal_id => ''
155
155
156 assert_not_nil assigns(:conflict_journals)
156 assert_not_nil assigns(:conflict_journals)
157 assert_equal 2, assigns(:conflict_journals).size
157 assert_equal 2, assigns(:conflict_journals).size
158 assert_tag 'div', :attributes => {:class => 'conflict'},
158 assert_tag 'div', :attributes => {:class => 'conflict'},
159 :descendant => {:content => /Some notes with Redmine links/}
159 :descendant => {:content => /Some notes with Redmine links/}
160 assert_tag 'div', :attributes => {:class => 'conflict'},
160 assert_tag 'div', :attributes => {:class => 'conflict'},
161 :descendant => {:content => /Journal notes/}
161 :descendant => {:content => /Journal notes/}
162 end
162 end
163
163
164 def test_update_stale_issue_should_show_private_journals_with_permission_only
164 def test_update_stale_issue_should_show_private_journals_with_permission_only
165 journal = Journal.create!(:journalized => Issue.find(1), :notes => 'Privates notes', :private_notes => true, :user_id => 1)
165 journal = Journal.create!(:journalized => Issue.find(1), :notes => 'Privates notes', :private_notes => true, :user_id => 1)
166
166
167 @request.session[:user_id] = 2
167 @request.session[:user_id] = 2
168 put :update, :id => 1, :issue => {:fixed_version_id => 4, :lock_version => 2}, :last_journal_id => ''
168 put :update, :id => 1, :issue => {:fixed_version_id => 4, :lock_version => 2}, :last_journal_id => ''
169 assert_include journal, assigns(:conflict_journals)
169 assert_include journal, assigns(:conflict_journals)
170
170
171 Role.find(1).remove_permission! :view_private_notes
171 Role.find(1).remove_permission! :view_private_notes
172 put :update, :id => 1, :issue => {:fixed_version_id => 4, :lock_version => 2}, :last_journal_id => ''
172 put :update, :id => 1, :issue => {:fixed_version_id => 4, :lock_version => 2}, :last_journal_id => ''
173 assert_not_include journal, assigns(:conflict_journals)
173 assert_not_include journal, assigns(:conflict_journals)
174 end
174 end
175
175
176 def test_update_stale_issue_with_overwrite_conflict_resolution_should_update
176 def test_update_stale_issue_with_overwrite_conflict_resolution_should_update
177 @request.session[:user_id] = 2
177 @request.session[:user_id] = 2
178
178
179 assert_difference 'Journal.count' do
179 assert_difference 'Journal.count' do
180 put :update, :id => 1,
180 put :update, :id => 1,
181 :issue => {
181 :issue => {
182 :fixed_version_id => 4,
182 :fixed_version_id => 4,
183 :notes => 'overwrite_conflict_resolution',
183 :notes => 'overwrite_conflict_resolution',
184 :lock_version => 2
184 :lock_version => 2
185 },
185 },
186 :conflict_resolution => 'overwrite'
186 :conflict_resolution => 'overwrite'
187 end
187 end
188
188
189 assert_response 302
189 assert_response 302
190 issue = Issue.find(1)
190 issue = Issue.find(1)
191 assert_equal 4, issue.fixed_version_id
191 assert_equal 4, issue.fixed_version_id
192 journal = Journal.first(:order => 'id DESC')
192 journal = Journal.first(:order => 'id DESC')
193 assert_equal 'overwrite_conflict_resolution', journal.notes
193 assert_equal 'overwrite_conflict_resolution', journal.notes
194 assert journal.details.any?
194 assert journal.details.any?
195 end
195 end
196
196
197 def test_update_stale_issue_with_add_notes_conflict_resolution_should_update
197 def test_update_stale_issue_with_add_notes_conflict_resolution_should_update
198 @request.session[:user_id] = 2
198 @request.session[:user_id] = 2
199
199
200 assert_difference 'Journal.count' do
200 assert_difference 'Journal.count' do
201 put :update, :id => 1,
201 put :update, :id => 1,
202 :issue => {
202 :issue => {
203 :fixed_version_id => 4,
203 :fixed_version_id => 4,
204 :notes => 'add_notes_conflict_resolution',
204 :notes => 'add_notes_conflict_resolution',
205 :lock_version => 2
205 :lock_version => 2
206 },
206 },
207 :conflict_resolution => 'add_notes'
207 :conflict_resolution => 'add_notes'
208 end
208 end
209
209
210 assert_response 302
210 assert_response 302
211 issue = Issue.find(1)
211 issue = Issue.find(1)
212 assert_nil issue.fixed_version_id
212 assert_nil issue.fixed_version_id
213 journal = Journal.first(:order => 'id DESC')
213 journal = Journal.first(:order => 'id DESC')
214 assert_equal 'add_notes_conflict_resolution', journal.notes
214 assert_equal 'add_notes_conflict_resolution', journal.notes
215 assert journal.details.empty?
215 assert journal.details.empty?
216 end
216 end
217
217
218 def test_update_stale_issue_with_cancel_conflict_resolution_should_redirect_without_updating
218 def test_update_stale_issue_with_cancel_conflict_resolution_should_redirect_without_updating
219 @request.session[:user_id] = 2
219 @request.session[:user_id] = 2
220
220
221 assert_no_difference 'Journal.count' do
221 assert_no_difference 'Journal.count' do
222 put :update, :id => 1,
222 put :update, :id => 1,
223 :issue => {
223 :issue => {
224 :fixed_version_id => 4,
224 :fixed_version_id => 4,
225 :notes => 'add_notes_conflict_resolution',
225 :notes => 'add_notes_conflict_resolution',
226 :lock_version => 2
226 :lock_version => 2
227 },
227 },
228 :conflict_resolution => 'cancel'
228 :conflict_resolution => 'cancel'
229 end
229 end
230
230
231 assert_redirected_to '/issues/1'
231 assert_redirected_to '/issues/1'
232 issue = Issue.find(1)
232 issue = Issue.find(1)
233 assert_nil issue.fixed_version_id
233 assert_nil issue.fixed_version_id
234 end
234 end
235
235
236 def test_put_update_with_spent_time_and_failure_should_not_add_spent_time
236 def test_put_update_with_spent_time_and_failure_should_not_add_spent_time
237 @request.session[:user_id] = 2
237 @request.session[:user_id] = 2
238
238
239 assert_no_difference('TimeEntry.count') do
239 assert_no_difference('TimeEntry.count') do
240 put :update,
240 put :update,
241 :id => 1,
241 :id => 1,
242 :issue => { :subject => '' },
242 :issue => { :subject => '' },
243 :time_entry => { :hours => '2.5', :comments => 'should not be added', :activity_id => TimeEntryActivity.first.id }
243 :time_entry => { :hours => '2.5', :comments => 'should not be added', :activity_id => TimeEntryActivity.first.id }
244 assert_response :success
244 assert_response :success
245 end
245 end
246
246
247 assert_select 'input[name=?][value=?]', 'time_entry[hours]', '2.5'
247 assert_select 'input[name=?][value=?]', 'time_entry[hours]', '2.5'
248 assert_select 'input[name=?][value=?]', 'time_entry[comments]', 'should not be added'
248 assert_select 'input[name=?][value=?]', 'time_entry[comments]', 'should not be added'
249 assert_select 'select[name=?]', 'time_entry[activity_id]' do
249 assert_select 'select[name=?]', 'time_entry[activity_id]' do
250 assert_select 'option[value=?][selected=selected]', TimeEntryActivity.first.id
250 assert_select 'option[value=?][selected=selected]', TimeEntryActivity.first.id
251 end
251 end
252 end
252 end
253
253
254 def test_index_should_rescue_invalid_sql_query
254 def test_index_should_rescue_invalid_sql_query
255 IssueQuery.any_instance.stubs(:statement).returns("INVALID STATEMENT")
255 IssueQuery.any_instance.stubs(:statement).returns("INVALID STATEMENT")
256
256
257 get :index
257 get :index
258 assert_response 500
258 assert_response 500
259 assert_tag 'p', :content => /An error occurred/
259 assert_tag 'p', :content => /An error occurred/
260 assert_nil session[:query]
260 assert_nil session[:query]
261 assert_nil session[:issues_index_sort]
261 assert_nil session[:issues_index_sort]
262 end
262 end
263 end
263 end
General Comments 0
You need to be logged in to leave comments. Login now