##// 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
@@ -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 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'uri'
19 19 require 'cgi'
20 20
21 21 class Unauthorized < Exception; end
22 22
23 23 class ApplicationController < ActionController::Base
24 24 include Redmine::I18n
25 25
26 26 class_attribute :accept_api_auth_actions
27 27 class_attribute :accept_rss_auth_actions
28 28 class_attribute :model_object
29 29
30 30 layout 'base'
31 31
32 32 protect_from_forgery
33 33 def handle_unverified_request
34 34 super
35 35 cookies.delete(:autologin)
36 36 end
37 37
38 38 before_filter :session_expiration, :user_setup, :check_if_login_required, :set_localization
39 39
40 40 rescue_from ActionController::InvalidAuthenticityToken, :with => :invalid_authenticity_token
41 41 rescue_from ::Unauthorized, :with => :deny_access
42 42 rescue_from ::ActionView::MissingTemplate, :with => :missing_template
43 43
44 44 include Redmine::Search::Controller
45 45 include Redmine::MenuManager::MenuController
46 46 helper Redmine::MenuManager::MenuHelper
47 47
48 48 def session_expiration
49 49 if session[:user_id]
50 50 if session_expired? && !try_to_autologin
51 51 reset_session
52 52 flash[:error] = l(:error_session_expired)
53 53 redirect_to signin_url
54 54 else
55 55 session[:atime] = Time.now.utc.to_i
56 56 end
57 57 end
58 58 end
59 59
60 60 def session_expired?
61 61 if Setting.session_lifetime?
62 62 unless session[:ctime] && (Time.now.utc.to_i - session[:ctime].to_i <= Setting.session_lifetime.to_i * 60)
63 63 return true
64 64 end
65 65 end
66 66 if Setting.session_timeout?
67 67 unless session[:atime] && (Time.now.utc.to_i - session[:atime].to_i <= Setting.session_timeout.to_i * 60)
68 68 return true
69 69 end
70 70 end
71 71 false
72 72 end
73 73
74 74 def start_user_session(user)
75 75 session[:user_id] = user.id
76 76 session[:ctime] = Time.now.utc.to_i
77 77 session[:atime] = Time.now.utc.to_i
78 78 end
79 79
80 80 def user_setup
81 81 # Check the settings cache for each request
82 82 Setting.check_cache
83 83 # Find the current user
84 84 User.current = find_current_user
85 85 logger.info(" Current user: " + (User.current.logged? ? "#{User.current.login} (id=#{User.current.id})" : "anonymous")) if logger
86 86 end
87 87
88 88 # Returns the current user or nil if no user is logged in
89 89 # and starts a session if needed
90 90 def find_current_user
91 91 user = nil
92 92 unless api_request?
93 93 if session[:user_id]
94 94 # existing session
95 95 user = (User.active.find(session[:user_id]) rescue nil)
96 96 elsif autologin_user = try_to_autologin
97 97 user = autologin_user
98 98 elsif params[:format] == 'atom' && params[:key] && request.get? && accept_rss_auth?
99 99 # RSS key authentication does not start a session
100 100 user = User.find_by_rss_key(params[:key])
101 101 end
102 102 end
103 103 if user.nil? && Setting.rest_api_enabled? && accept_api_auth?
104 104 if (key = api_key_from_request)
105 105 # Use API key
106 106 user = User.find_by_api_key(key)
107 107 else
108 108 # HTTP Basic, either username/password or API key/random
109 109 authenticate_with_http_basic do |username, password|
110 110 user = User.try_to_login(username, password) || User.find_by_api_key(username)
111 111 end
112 112 end
113 113 # Switch user if requested by an admin user
114 114 if user && user.admin? && (username = api_switch_user_from_request)
115 115 su = User.find_by_login(username)
116 116 if su && su.active?
117 117 logger.info(" User switched by: #{user.login} (id=#{user.id})") if logger
118 118 user = su
119 119 else
120 120 render_error :message => 'Invalid X-Redmine-Switch-User header', :status => 412
121 121 end
122 122 end
123 123 end
124 124 user
125 125 end
126 126
127 127 def try_to_autologin
128 128 if cookies[:autologin] && Setting.autologin?
129 129 # auto-login feature starts a new session
130 130 user = User.try_to_autologin(cookies[:autologin])
131 131 if user
132 132 reset_session
133 133 start_user_session(user)
134 134 end
135 135 user
136 136 end
137 137 end
138 138
139 139 # Sets the logged in user
140 140 def logged_user=(user)
141 141 reset_session
142 142 if user && user.is_a?(User)
143 143 User.current = user
144 144 start_user_session(user)
145 145 else
146 146 User.current = User.anonymous
147 147 end
148 148 end
149 149
150 150 # Logs out current user
151 151 def logout_user
152 152 if User.current.logged?
153 153 cookies.delete :autologin
154 154 Token.delete_all(["user_id = ? AND action = ?", User.current.id, 'autologin'])
155 155 self.logged_user = nil
156 156 end
157 157 end
158 158
159 159 # check if login is globally required to access the application
160 160 def check_if_login_required
161 161 # no check needed if user is already logged in
162 162 return true if User.current.logged?
163 163 require_login if Setting.login_required?
164 164 end
165 165
166 166 def set_localization
167 167 lang = nil
168 168 if User.current.logged?
169 169 lang = find_language(User.current.language)
170 170 end
171 171 if lang.nil? && request.env['HTTP_ACCEPT_LANGUAGE']
172 172 accept_lang = parse_qvalues(request.env['HTTP_ACCEPT_LANGUAGE']).first
173 173 if !accept_lang.blank?
174 174 accept_lang = accept_lang.downcase
175 175 lang = find_language(accept_lang) || find_language(accept_lang.split('-').first)
176 176 end
177 177 end
178 178 lang ||= Setting.default_language
179 179 set_language_if_valid(lang)
180 180 end
181 181
182 182 def require_login
183 183 if !User.current.logged?
184 184 # Extract only the basic url parameters on non-GET requests
185 185 if request.get?
186 186 url = url_for(params)
187 187 else
188 188 url = url_for(:controller => params[:controller], :action => params[:action], :id => params[:id], :project_id => params[:project_id])
189 189 end
190 190 respond_to do |format|
191 191 format.html { redirect_to :controller => "account", :action => "login", :back_url => url }
192 192 format.atom { redirect_to :controller => "account", :action => "login", :back_url => url }
193 193 format.xml { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
194 194 format.js { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
195 195 format.json { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
196 196 end
197 197 return false
198 198 end
199 199 true
200 200 end
201 201
202 202 def require_admin
203 203 return unless require_login
204 204 if !User.current.admin?
205 205 render_403
206 206 return false
207 207 end
208 208 true
209 209 end
210 210
211 211 def deny_access
212 212 User.current.logged? ? render_403 : require_login
213 213 end
214 214
215 215 # Authorize the user for the requested action
216 216 def authorize(ctrl = params[:controller], action = params[:action], global = false)
217 217 allowed = User.current.allowed_to?({:controller => ctrl, :action => action}, @project || @projects, :global => global)
218 218 if allowed
219 219 true
220 220 else
221 221 if @project && @project.archived?
222 222 render_403 :message => :notice_not_authorized_archived_project
223 223 else
224 224 deny_access
225 225 end
226 226 end
227 227 end
228 228
229 229 # Authorize the user for the requested action outside a project
230 230 def authorize_global(ctrl = params[:controller], action = params[:action], global = true)
231 231 authorize(ctrl, action, global)
232 232 end
233 233
234 234 # Find project of id params[:id]
235 235 def find_project
236 236 @project = Project.find(params[:id])
237 237 rescue ActiveRecord::RecordNotFound
238 238 render_404
239 239 end
240 240
241 241 # Find project of id params[:project_id]
242 242 def find_project_by_project_id
243 243 @project = Project.find(params[:project_id])
244 244 rescue ActiveRecord::RecordNotFound
245 245 render_404
246 246 end
247 247
248 248 # Find a project based on params[:project_id]
249 249 # TODO: some subclasses override this, see about merging their logic
250 250 def find_optional_project
251 251 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
252 252 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
253 253 allowed ? true : deny_access
254 254 rescue ActiveRecord::RecordNotFound
255 255 render_404
256 256 end
257 257
258 258 # Finds and sets @project based on @object.project
259 259 def find_project_from_association
260 260 render_404 unless @object.present?
261 261
262 262 @project = @object.project
263 263 end
264 264
265 265 def find_model_object
266 266 model = self.class.model_object
267 267 if model
268 268 @object = model.find(params[:id])
269 269 self.instance_variable_set('@' + controller_name.singularize, @object) if @object
270 270 end
271 271 rescue ActiveRecord::RecordNotFound
272 272 render_404
273 273 end
274 274
275 275 def self.model_object(model)
276 276 self.model_object = model
277 277 end
278 278
279 279 # Find the issue whose id is the :id parameter
280 280 # Raises a Unauthorized exception if the issue is not visible
281 281 def find_issue
282 282 # Issue.visible.find(...) can not be used to redirect user to the login form
283 283 # if the issue actually exists but requires authentication
284 284 @issue = Issue.find(params[:id])
285 285 raise Unauthorized unless @issue.visible?
286 286 @project = @issue.project
287 287 rescue ActiveRecord::RecordNotFound
288 288 render_404
289 289 end
290 290
291 291 # Find issues with a single :id param or :ids array param
292 292 # Raises a Unauthorized exception if one of the issues is not visible
293 293 def find_issues
294 294 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
295 295 raise ActiveRecord::RecordNotFound if @issues.empty?
296 296 raise Unauthorized unless @issues.all?(&:visible?)
297 297 @projects = @issues.collect(&:project).compact.uniq
298 298 @project = @projects.first if @projects.size == 1
299 299 rescue ActiveRecord::RecordNotFound
300 300 render_404
301 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 313 # make sure that the user is a member of the project (or admin) if project is private
304 314 # used as a before_filter for actions that do not require any particular permission on the project
305 315 def check_project_privacy
306 316 if @project && !@project.archived?
307 317 if @project.visible?
308 318 true
309 319 else
310 320 deny_access
311 321 end
312 322 else
313 323 @project = nil
314 324 render_404
315 325 false
316 326 end
317 327 end
318 328
319 329 def back_url
320 330 url = params[:back_url]
321 331 if url.nil? && referer = request.env['HTTP_REFERER']
322 332 url = CGI.unescape(referer.to_s)
323 333 end
324 334 url
325 335 end
326 336
327 337 def redirect_back_or_default(default)
328 338 back_url = params[:back_url].to_s
329 339 if back_url.present?
330 340 begin
331 341 uri = URI.parse(back_url)
332 342 # do not redirect user to another host or to the login or register page
333 343 if (uri.relative? || (uri.host == request.host)) && !uri.path.match(%r{/(login|account/register)})
334 344 redirect_to(back_url)
335 345 return
336 346 end
337 347 rescue URI::InvalidURIError
338 348 logger.warn("Could not redirect to invalid URL #{back_url}")
339 349 # redirect to default
340 350 end
341 351 end
342 352 redirect_to default
343 353 false
344 354 end
345 355
346 356 # Redirects to the request referer if present, redirects to args or call block otherwise.
347 357 def redirect_to_referer_or(*args, &block)
348 358 redirect_to :back
349 359 rescue ::ActionController::RedirectBackError
350 360 if args.any?
351 361 redirect_to *args
352 362 elsif block_given?
353 363 block.call
354 364 else
355 365 raise "#redirect_to_referer_or takes arguments or a block"
356 366 end
357 367 end
358 368
359 369 def render_403(options={})
360 370 @project = nil
361 371 render_error({:message => :notice_not_authorized, :status => 403}.merge(options))
362 372 return false
363 373 end
364 374
365 375 def render_404(options={})
366 376 render_error({:message => :notice_file_not_found, :status => 404}.merge(options))
367 377 return false
368 378 end
369 379
370 380 # Renders an error response
371 381 def render_error(arg)
372 382 arg = {:message => arg} unless arg.is_a?(Hash)
373 383
374 384 @message = arg[:message]
375 385 @message = l(@message) if @message.is_a?(Symbol)
376 386 @status = arg[:status] || 500
377 387
378 388 respond_to do |format|
379 389 format.html {
380 390 render :template => 'common/error', :layout => use_layout, :status => @status
381 391 }
382 392 format.any { head @status }
383 393 end
384 394 end
385 395
386 396 # Handler for ActionView::MissingTemplate exception
387 397 def missing_template
388 398 logger.warn "Missing template, responding with 404"
389 399 @project = nil
390 400 render_404
391 401 end
392 402
393 403 # Filter for actions that provide an API response
394 404 # but have no HTML representation for non admin users
395 405 def require_admin_or_api_request
396 406 return true if api_request?
397 407 if User.current.admin?
398 408 true
399 409 elsif User.current.logged?
400 410 render_error(:status => 406)
401 411 else
402 412 deny_access
403 413 end
404 414 end
405 415
406 416 # Picks which layout to use based on the request
407 417 #
408 418 # @return [boolean, string] name of the layout to use or false for no layout
409 419 def use_layout
410 420 request.xhr? ? false : 'base'
411 421 end
412 422
413 423 def invalid_authenticity_token
414 424 if api_request?
415 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 426 end
417 427 render_error "Invalid form authenticity token."
418 428 end
419 429
420 430 def render_feed(items, options={})
421 431 @items = items || []
422 432 @items.sort! {|x,y| y.event_datetime <=> x.event_datetime }
423 433 @items = @items.slice(0, Setting.feeds_limit.to_i)
424 434 @title = options[:title] || Setting.app_title
425 435 render :template => "common/feed", :formats => [:atom], :layout => false,
426 436 :content_type => 'application/atom+xml'
427 437 end
428 438
429 439 def self.accept_rss_auth(*actions)
430 440 if actions.any?
431 441 self.accept_rss_auth_actions = actions
432 442 else
433 443 self.accept_rss_auth_actions || []
434 444 end
435 445 end
436 446
437 447 def accept_rss_auth?(action=action_name)
438 448 self.class.accept_rss_auth.include?(action.to_sym)
439 449 end
440 450
441 451 def self.accept_api_auth(*actions)
442 452 if actions.any?
443 453 self.accept_api_auth_actions = actions
444 454 else
445 455 self.accept_api_auth_actions || []
446 456 end
447 457 end
448 458
449 459 def accept_api_auth?(action=action_name)
450 460 self.class.accept_api_auth.include?(action.to_sym)
451 461 end
452 462
453 463 # Returns the number of objects that should be displayed
454 464 # on the paginated list
455 465 def per_page_option
456 466 per_page = nil
457 467 if params[:per_page] && Setting.per_page_options_array.include?(params[:per_page].to_s.to_i)
458 468 per_page = params[:per_page].to_s.to_i
459 469 session[:per_page] = per_page
460 470 elsif session[:per_page]
461 471 per_page = session[:per_page]
462 472 else
463 473 per_page = Setting.per_page_options_array.first || 25
464 474 end
465 475 per_page
466 476 end
467 477
468 478 # Returns offset and limit used to retrieve objects
469 479 # for an API response based on offset, limit and page parameters
470 480 def api_offset_and_limit(options=params)
471 481 if options[:offset].present?
472 482 offset = options[:offset].to_i
473 483 if offset < 0
474 484 offset = 0
475 485 end
476 486 end
477 487 limit = options[:limit].to_i
478 488 if limit < 1
479 489 limit = 25
480 490 elsif limit > 100
481 491 limit = 100
482 492 end
483 493 if offset.nil? && options[:page].present?
484 494 offset = (options[:page].to_i - 1) * limit
485 495 offset = 0 if offset < 0
486 496 end
487 497 offset ||= 0
488 498
489 499 [offset, limit]
490 500 end
491 501
492 502 # qvalues http header parser
493 503 # code taken from webrick
494 504 def parse_qvalues(value)
495 505 tmp = []
496 506 if value
497 507 parts = value.split(/,\s*/)
498 508 parts.each {|part|
499 509 if m = %r{^([^\s,]+?)(?:;\s*q=(\d+(?:\.\d+)?))?$}.match(part)
500 510 val = m[1]
501 511 q = (m[2] or 1).to_f
502 512 tmp.push([val, q])
503 513 end
504 514 }
505 515 tmp = tmp.sort_by{|val, q| -q}
506 516 tmp.collect!{|val, q| val}
507 517 end
508 518 return tmp
509 519 rescue
510 520 nil
511 521 end
512 522
513 523 # Returns a string that can be used as filename value in Content-Disposition header
514 524 def filename_for_content_disposition(name)
515 525 request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(name) : name
516 526 end
517 527
518 528 def api_request?
519 529 %w(xml json).include? params[:format]
520 530 end
521 531
522 532 # Returns the API key present in the request
523 533 def api_key_from_request
524 534 if params[:key].present?
525 535 params[:key].to_s
526 536 elsif request.headers["X-Redmine-API-Key"].present?
527 537 request.headers["X-Redmine-API-Key"].to_s
528 538 end
529 539 end
530 540
531 541 # Returns the API 'switch user' value if present
532 542 def api_switch_user_from_request
533 543 request.headers["X-Redmine-Switch-User"].to_s.presence
534 544 end
535 545
536 546 # Renders a warning flash if obj has unsaved attachments
537 547 def render_attachment_warning_if_needed(obj)
538 548 flash[:warning] = l(:warning_attachments_not_saved, obj.unsaved_attachments.size) if obj.unsaved_attachments.present?
539 549 end
540 550
541 551 # Sets the `flash` notice or error based the number of issues that did not save
542 552 #
543 553 # @param [Array, Issue] issues all of the saved and unsaved Issues
544 554 # @param [Array, Integer] unsaved_issue_ids the issue ids that were not saved
545 555 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
546 556 if unsaved_issue_ids.empty?
547 557 flash[:notice] = l(:notice_successful_update) unless issues.empty?
548 558 else
549 559 flash[:error] = l(:notice_failed_to_save_issues,
550 560 :count => unsaved_issue_ids.size,
551 561 :total => issues.size,
552 562 :ids => '#' + unsaved_issue_ids.join(', #'))
553 563 end
554 564 end
555 565
556 566 # Rescues an invalid query statement. Just in case...
557 567 def query_statement_invalid(exception)
558 568 logger.error "Query::StatementInvalid: #{exception.message}" if logger
559 569 session.delete(:query)
560 570 sort_clear if respond_to?(:sort_clear)
561 571 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
562 572 end
563 573
564 574 # Renders a 200 response for successfull updates or deletions via the API
565 575 def render_api_ok
566 576 render_api_head :ok
567 577 end
568 578
569 579 # Renders a head API response
570 580 def render_api_head(status)
571 581 # #head would return a response body with one space
572 582 render :text => '', :status => status, :layout => nil
573 583 end
574 584
575 585 # Renders API response on validation failure
576 586 def render_validation_errors(objects)
577 587 if objects.is_a?(Array)
578 588 @error_messages = objects.map {|object| object.errors.full_messages}.flatten
579 589 else
580 590 @error_messages = objects.errors.full_messages
581 591 end
582 592 render :template => 'common/error_messages.api', :status => :unprocessable_entity, :layout => nil
583 593 end
584 594
585 595 # Overrides #_include_layout? so that #render with no arguments
586 596 # doesn't use the layout for api requests
587 597 def _include_layout?(*args)
588 598 api_request? ? false : super
589 599 end
590 600 end
@@ -1,139 +1,149
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class AttachmentsController < ApplicationController
19 19 before_filter :find_project, :except => :upload
20 20 before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
21 21 before_filter :delete_authorize, :only => :destroy
22 22 before_filter :authorize_global, :only => :upload
23 23
24 24 accept_api_auth :show, :download, :upload
25 25
26 26 def show
27 27 respond_to do |format|
28 28 format.html {
29 29 if @attachment.is_diff?
30 30 @diff = File.new(@attachment.diskfile, "rb").read
31 31 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
32 32 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
33 33 # Save diff type as user preference
34 34 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
35 35 User.current.pref[:diff_type] = @diff_type
36 36 User.current.preference.save
37 37 end
38 38 render :action => 'diff'
39 39 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
40 40 @content = File.new(@attachment.diskfile, "rb").read
41 41 render :action => 'file'
42 42 else
43 43 download
44 44 end
45 45 }
46 46 format.api
47 47 end
48 48 end
49 49
50 50 def download
51 51 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
52 52 @attachment.increment_download
53 53 end
54 54
55 55 if stale?(:etag => @attachment.digest)
56 56 # images are sent inline
57 57 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
58 58 :type => detect_content_type(@attachment),
59 59 :disposition => (@attachment.image? ? 'inline' : 'attachment')
60 60 end
61 61 end
62 62
63 63 def thumbnail
64 64 if @attachment.thumbnailable? && thumbnail = @attachment.thumbnail(:size => params[:size])
65 65 if stale?(:etag => thumbnail)
66 66 send_file thumbnail,
67 67 :filename => filename_for_content_disposition(@attachment.filename),
68 68 :type => detect_content_type(@attachment),
69 69 :disposition => 'inline'
70 70 end
71 71 else
72 72 # No thumbnail for the attachment or thumbnail could not be created
73 73 render :nothing => true, :status => 404
74 74 end
75 75 end
76 76
77 77 def upload
78 78 # Make sure that API users get used to set this content type
79 79 # as it won't trigger Rails' automatic parsing of the request body for parameters
80 80 unless request.content_type == 'application/octet-stream'
81 81 render :nothing => true, :status => 406
82 82 return
83 83 end
84 84
85 85 @attachment = Attachment.new(:file => request.raw_post)
86 86 @attachment.author = User.current
87 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|
91 format.api { render :action => 'upload', :status => :created }
92 end
93 else
94 respond_to do |format|
95 format.api { render_validation_errors(@attachment) }
96 end
90 respond_to do |format|
91 format.js
92 format.api {
93 if saved
94 render :action => 'upload', :status => :created
95 else
96 render_validation_errors(@attachment)
97 end
98 }
97 99 end
98 100 end
99 101
100 102 def destroy
101 103 if @attachment.container.respond_to?(:init_journal)
102 104 @attachment.container.init_journal(User.current)
103 105 end
104 # Make sure association callbacks are called
105 @attachment.container.attachments.delete(@attachment)
106 redirect_to_referer_or project_path(@project)
106 if @attachment.container
107 # Make sure association callbacks are called
108 @attachment.container.attachments.delete(@attachment)
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 117 end
108 118
109 119 private
110 120 def find_project
111 121 @attachment = Attachment.find(params[:id])
112 122 # Show 404 if the filename in the url is wrong
113 123 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
114 124 @project = @attachment.project
115 125 rescue ActiveRecord::RecordNotFound
116 126 render_404
117 127 end
118 128
119 129 # Checks that the file exists and is readable
120 130 def file_readable
121 131 @attachment.readable? ? true : render_404
122 132 end
123 133
124 134 def read_authorize
125 135 @attachment.visible? ? true : deny_access
126 136 end
127 137
128 138 def delete_authorize
129 139 @attachment.deletable? ? true : deny_access
130 140 end
131 141
132 142 def detect_content_type(attachment)
133 143 content_type = attachment.content_type
134 144 if content_type.blank?
135 145 content_type = Redmine::MimeType.of(attachment.filename)
136 146 end
137 147 content_type.to_s
138 148 end
139 149 end
@@ -1,141 +1,141
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class MessagesController < ApplicationController
19 19 menu_item :boards
20 20 default_search_scope :messages
21 21 before_filter :find_board, :only => [:new, :preview]
22 before_filter :find_attachments, :only => [:preview]
22 23 before_filter :find_message, :except => [:new, :preview]
23 24 before_filter :authorize, :except => [:preview, :edit, :destroy]
24 25
25 26 helper :boards
26 27 helper :watchers
27 28 helper :attachments
28 29 include AttachmentsHelper
29 30
30 31 REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE)
31 32
32 33 # Show a topic and its replies
33 34 def show
34 35 page = params[:page]
35 36 # Find the page of the requested reply
36 37 if params[:r] && page.nil?
37 38 offset = @topic.children.count(:conditions => ["#{Message.table_name}.id < ?", params[:r].to_i])
38 39 page = 1 + offset / REPLIES_PER_PAGE
39 40 end
40 41
41 42 @reply_count = @topic.children.count
42 43 @reply_pages = Paginator.new self, @reply_count, REPLIES_PER_PAGE, page
43 44 @replies = @topic.children.
44 45 includes(:author, :attachments, {:board => :project}).
45 46 reorder("#{Message.table_name}.created_on ASC").
46 47 limit(@reply_pages.items_per_page).
47 48 offset(@reply_pages.current.offset).
48 49 all
49 50
50 51 @reply = Message.new(:subject => "RE: #{@message.subject}")
51 52 render :action => "show", :layout => false if request.xhr?
52 53 end
53 54
54 55 # Create a new topic
55 56 def new
56 57 @message = Message.new
57 58 @message.author = User.current
58 59 @message.board = @board
59 60 @message.safe_attributes = params[:message]
60 61 if request.post?
61 62 @message.save_attachments(params[:attachments])
62 63 if @message.save
63 64 call_hook(:controller_messages_new_after_save, { :params => params, :message => @message})
64 65 render_attachment_warning_if_needed(@message)
65 66 redirect_to board_message_path(@board, @message)
66 67 end
67 68 end
68 69 end
69 70
70 71 # Reply to a topic
71 72 def reply
72 73 @reply = Message.new
73 74 @reply.author = User.current
74 75 @reply.board = @board
75 76 @reply.safe_attributes = params[:reply]
76 77 @topic.children << @reply
77 78 if !@reply.new_record?
78 79 call_hook(:controller_messages_reply_after_save, { :params => params, :message => @reply})
79 80 attachments = Attachment.attach_files(@reply, params[:attachments])
80 81 render_attachment_warning_if_needed(@reply)
81 82 end
82 83 redirect_to board_message_path(@board, @topic, :r => @reply)
83 84 end
84 85
85 86 # Edit a message
86 87 def edit
87 88 (render_403; return false) unless @message.editable_by?(User.current)
88 89 @message.safe_attributes = params[:message]
89 90 if request.post? && @message.save
90 91 attachments = Attachment.attach_files(@message, params[:attachments])
91 92 render_attachment_warning_if_needed(@message)
92 93 flash[:notice] = l(:notice_successful_update)
93 94 @message.reload
94 95 redirect_to board_message_path(@message.board, @message.root, :r => (@message.parent_id && @message.id))
95 96 end
96 97 end
97 98
98 99 # Delete a messages
99 100 def destroy
100 101 (render_403; return false) unless @message.destroyable_by?(User.current)
101 102 r = @message.to_param
102 103 @message.destroy
103 104 if @message.parent
104 105 redirect_to board_message_path(@board, @message.parent, :r => r)
105 106 else
106 107 redirect_to project_board_path(@project, @board)
107 108 end
108 109 end
109 110
110 111 def quote
111 112 @subject = @message.subject
112 113 @subject = "RE: #{@subject}" unless @subject.starts_with?('RE:')
113 114
114 115 @content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> "
115 116 @content << @message.content.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
116 117 end
117 118
118 119 def preview
119 120 message = @board.messages.find_by_id(params[:id])
120 @attachements = message.attachments if message
121 121 @text = (params[:message] || params[:reply])[:content]
122 122 @previewed = message
123 123 render :partial => 'common/preview'
124 124 end
125 125
126 126 private
127 127 def find_message
128 128 find_board
129 129 @message = @board.messages.find(params[:id], :include => :parent)
130 130 @topic = @message.root
131 131 rescue ActiveRecord::RecordNotFound
132 132 render_404
133 133 end
134 134
135 135 def find_board
136 136 @board = Board.find(params[:board_id], :include => :project)
137 137 @project = @board.project
138 138 rescue ActiveRecord::RecordNotFound
139 139 render_404
140 140 end
141 141 end
@@ -1,55 +1,53
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class PreviewsController < ApplicationController
19 before_filter :find_project
19 before_filter :find_project, :find_attachments
20 20
21 21 def issue
22 22 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
23 23 if @issue
24 @attachements = @issue.attachments
25 24 @description = params[:issue] && params[:issue][:description]
26 25 if @description && @description.gsub(/(\r?\n|\n\r?)/, "\n") == @issue.description.to_s.gsub(/(\r?\n|\n\r?)/, "\n")
27 26 @description = nil
28 27 end
29 28 # params[:notes] is useful for preview of notes in issue history
30 29 @notes = params[:notes] || (params[:issue] ? params[:issue][:notes] : nil)
31 30 else
32 31 @description = (params[:issue] ? params[:issue][:description] : nil)
33 32 end
34 33 render :layout => false
35 34 end
36 35
37 36 def news
38 37 if params[:id].present? && news = News.visible.find_by_id(params[:id])
39 38 @previewed = news
40 @attachments = news.attachments
41 39 end
42 40 @text = (params[:news] ? params[:news][:description] : nil)
43 41 render :partial => 'common/preview'
44 42 end
45 43
46 44 private
47 45
48 46 def find_project
49 47 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
50 48 @project = Project.find(project_id)
51 49 rescue ActiveRecord::RecordNotFound
52 50 render_404
53 51 end
54 52
55 53 end
@@ -1,355 +1,356
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require 'diff'
19 19
20 20 # The WikiController follows the Rails REST controller pattern but with
21 21 # a few differences
22 22 #
23 23 # * index - shows a list of WikiPages grouped by page or date
24 24 # * new - not used
25 25 # * create - not used
26 26 # * show - will also show the form for creating a new wiki page
27 27 # * edit - used to edit an existing or new page
28 28 # * update - used to save a wiki page update to the database, including new pages
29 29 # * destroy - normal
30 30 #
31 31 # Other member and collection methods are also used
32 32 #
33 33 # TODO: still being worked on
34 34 class WikiController < ApplicationController
35 35 default_search_scope :wiki_pages
36 36 before_filter :find_wiki, :authorize
37 37 before_filter :find_existing_or_new_page, :only => [:show, :edit, :update]
38 38 before_filter :find_existing_page, :only => [:rename, :protect, :history, :diff, :annotate, :add_attachment, :destroy, :destroy_version]
39 39 accept_api_auth :index, :show, :update, :destroy
40 before_filter :find_attachments, :only => [:preview]
40 41
41 42 helper :attachments
42 43 include AttachmentsHelper
43 44 helper :watchers
44 45 include Redmine::Export::PDF
45 46
46 47 # List of pages, sorted alphabetically and by parent (hierarchy)
47 48 def index
48 49 load_pages_for_index
49 50
50 51 respond_to do |format|
51 52 format.html {
52 53 @pages_by_parent_id = @pages.group_by(&:parent_id)
53 54 }
54 55 format.api
55 56 end
56 57 end
57 58
58 59 # List of page, by last update
59 60 def date_index
60 61 load_pages_for_index
61 62 @pages_by_date = @pages.group_by {|p| p.updated_on.to_date}
62 63 end
63 64
64 65 # display a page (in editing mode if it doesn't exist)
65 66 def show
66 67 if @page.new_record?
67 68 if User.current.allowed_to?(:edit_wiki_pages, @project) && editable? && !api_request?
68 69 edit
69 70 render :action => 'edit'
70 71 else
71 72 render_404
72 73 end
73 74 return
74 75 end
75 76 if params[:version] && !User.current.allowed_to?(:view_wiki_edits, @project)
76 77 deny_access
77 78 return
78 79 end
79 80 @content = @page.content_for_version(params[:version])
80 81 if User.current.allowed_to?(:export_wiki_pages, @project)
81 82 if params[:format] == 'pdf'
82 83 send_data(wiki_page_to_pdf(@page, @project), :type => 'application/pdf', :filename => "#{@page.title}.pdf")
83 84 return
84 85 elsif params[:format] == 'html'
85 86 export = render_to_string :action => 'export', :layout => false
86 87 send_data(export, :type => 'text/html', :filename => "#{@page.title}.html")
87 88 return
88 89 elsif params[:format] == 'txt'
89 90 send_data(@content.text, :type => 'text/plain', :filename => "#{@page.title}.txt")
90 91 return
91 92 end
92 93 end
93 94 @editable = editable?
94 95 @sections_editable = @editable && User.current.allowed_to?(:edit_wiki_pages, @page.project) &&
95 96 @content.current_version? &&
96 97 Redmine::WikiFormatting.supports_section_edit?
97 98
98 99 respond_to do |format|
99 100 format.html
100 101 format.api
101 102 end
102 103 end
103 104
104 105 # edit an existing page or a new one
105 106 def edit
106 107 return render_403 unless editable?
107 108 if @page.new_record?
108 109 @page.content = WikiContent.new(:page => @page)
109 110 if params[:parent].present?
110 111 @page.parent = @page.wiki.find_page(params[:parent].to_s)
111 112 end
112 113 end
113 114
114 115 @content = @page.content_for_version(params[:version])
115 116 @content.text = initial_page_content(@page) if @content.text.blank?
116 117 # don't keep previous comment
117 118 @content.comments = nil
118 119
119 120 # To prevent StaleObjectError exception when reverting to a previous version
120 121 @content.version = @page.content.version
121 122
122 123 @text = @content.text
123 124 if params[:section].present? && Redmine::WikiFormatting.supports_section_edit?
124 125 @section = params[:section].to_i
125 126 @text, @section_hash = Redmine::WikiFormatting.formatter.new(@text).get_section(@section)
126 127 render_404 if @text.blank?
127 128 end
128 129 end
129 130
130 131 # Creates a new page or updates an existing one
131 132 def update
132 133 return render_403 unless editable?
133 134 was_new_page = @page.new_record?
134 135 @page.content = WikiContent.new(:page => @page) if @page.new_record?
135 136 @page.safe_attributes = params[:wiki_page]
136 137
137 138 @content = @page.content
138 139 content_params = params[:content]
139 140 if content_params.nil? && params[:wiki_page].is_a?(Hash)
140 141 content_params = params[:wiki_page].slice(:text, :comments, :version)
141 142 end
142 143 content_params ||= {}
143 144
144 145 @content.comments = content_params[:comments]
145 146 @text = content_params[:text]
146 147 if params[:section].present? && Redmine::WikiFormatting.supports_section_edit?
147 148 @section = params[:section].to_i
148 149 @section_hash = params[:section_hash]
149 150 @content.text = Redmine::WikiFormatting.formatter.new(@content.text).update_section(params[:section].to_i, @text, @section_hash)
150 151 else
151 152 @content.version = content_params[:version] if content_params[:version]
152 153 @content.text = @text
153 154 end
154 155 @content.author = User.current
155 156
156 157 if @page.save_with_content
157 158 attachments = Attachment.attach_files(@page, params[:attachments])
158 159 render_attachment_warning_if_needed(@page)
159 160 call_hook(:controller_wiki_edit_after_save, { :params => params, :page => @page})
160 161
161 162 respond_to do |format|
162 163 format.html { redirect_to :action => 'show', :project_id => @project, :id => @page.title }
163 164 format.api {
164 165 if was_new_page
165 166 render :action => 'show', :status => :created, :location => url_for(:controller => 'wiki', :action => 'show', :project_id => @project, :id => @page.title)
166 167 else
167 168 render_api_ok
168 169 end
169 170 }
170 171 end
171 172 else
172 173 respond_to do |format|
173 174 format.html { render :action => 'edit' }
174 175 format.api { render_validation_errors(@content) }
175 176 end
176 177 end
177 178
178 179 rescue ActiveRecord::StaleObjectError, Redmine::WikiFormatting::StaleSectionError
179 180 # Optimistic locking exception
180 181 respond_to do |format|
181 182 format.html {
182 183 flash.now[:error] = l(:notice_locking_conflict)
183 184 render :action => 'edit'
184 185 }
185 186 format.api { render_api_head :conflict }
186 187 end
187 188 rescue ActiveRecord::RecordNotSaved
188 189 respond_to do |format|
189 190 format.html { render :action => 'edit' }
190 191 format.api { render_validation_errors(@content) }
191 192 end
192 193 end
193 194
194 195 # rename a page
195 196 def rename
196 197 return render_403 unless editable?
197 198 @page.redirect_existing_links = true
198 199 # used to display the *original* title if some AR validation errors occur
199 200 @original_title = @page.pretty_title
200 201 if request.post? && @page.update_attributes(params[:wiki_page])
201 202 flash[:notice] = l(:notice_successful_update)
202 203 redirect_to :action => 'show', :project_id => @project, :id => @page.title
203 204 end
204 205 end
205 206
206 207 def protect
207 208 @page.update_attribute :protected, params[:protected]
208 209 redirect_to :action => 'show', :project_id => @project, :id => @page.title
209 210 end
210 211
211 212 # show page history
212 213 def history
213 214 @version_count = @page.content.versions.count
214 215 @version_pages = Paginator.new self, @version_count, per_page_option, params['page']
215 216 # don't load text
216 217 @versions = @page.content.versions.
217 218 select("id, author_id, comments, updated_on, version").
218 219 reorder('version DESC').
219 220 limit(@version_pages.items_per_page + 1).
220 221 offset(@version_pages.current.offset).
221 222 all
222 223
223 224 render :layout => false if request.xhr?
224 225 end
225 226
226 227 def diff
227 228 @diff = @page.diff(params[:version], params[:version_from])
228 229 render_404 unless @diff
229 230 end
230 231
231 232 def annotate
232 233 @annotate = @page.annotate(params[:version])
233 234 render_404 unless @annotate
234 235 end
235 236
236 237 # Removes a wiki page and its history
237 238 # Children can be either set as root pages, removed or reassigned to another parent page
238 239 def destroy
239 240 return render_403 unless editable?
240 241
241 242 @descendants_count = @page.descendants.size
242 243 if @descendants_count > 0
243 244 case params[:todo]
244 245 when 'nullify'
245 246 # Nothing to do
246 247 when 'destroy'
247 248 # Removes all its descendants
248 249 @page.descendants.each(&:destroy)
249 250 when 'reassign'
250 251 # Reassign children to another parent page
251 252 reassign_to = @wiki.pages.find_by_id(params[:reassign_to_id].to_i)
252 253 return unless reassign_to
253 254 @page.children.each do |child|
254 255 child.update_attribute(:parent, reassign_to)
255 256 end
256 257 else
257 258 @reassignable_to = @wiki.pages - @page.self_and_descendants
258 259 # display the destroy form if it's a user request
259 260 return unless api_request?
260 261 end
261 262 end
262 263 @page.destroy
263 264 respond_to do |format|
264 265 format.html { redirect_to :action => 'index', :project_id => @project }
265 266 format.api { render_api_ok }
266 267 end
267 268 end
268 269
269 270 def destroy_version
270 271 return render_403 unless editable?
271 272
272 273 @content = @page.content_for_version(params[:version])
273 274 @content.destroy
274 275 redirect_to_referer_or :action => 'history', :id => @page.title, :project_id => @project
275 276 end
276 277
277 278 # Export wiki to a single pdf or html file
278 279 def export
279 280 @pages = @wiki.pages.all(:order => 'title', :include => [:content, {:attachments => :author}])
280 281 respond_to do |format|
281 282 format.html {
282 283 export = render_to_string :action => 'export_multiple', :layout => false
283 284 send_data(export, :type => 'text/html', :filename => "wiki.html")
284 285 }
285 286 format.pdf {
286 287 send_data(wiki_pages_to_pdf(@pages, @project), :type => 'application/pdf', :filename => "#{@project.identifier}.pdf")
287 288 }
288 289 end
289 290 end
290 291
291 292 def preview
292 293 page = @wiki.find_page(params[:id])
293 294 # page is nil when previewing a new page
294 295 return render_403 unless page.nil? || editable?(page)
295 296 if page
296 @attachements = page.attachments
297 @attachments += page.attachments
297 298 @previewed = page.content
298 299 end
299 300 @text = params[:content][:text]
300 301 render :partial => 'common/preview'
301 302 end
302 303
303 304 def add_attachment
304 305 return render_403 unless editable?
305 306 attachments = Attachment.attach_files(@page, params[:attachments])
306 307 render_attachment_warning_if_needed(@page)
307 308 redirect_to :action => 'show', :id => @page.title, :project_id => @project
308 309 end
309 310
310 311 private
311 312
312 313 def find_wiki
313 314 @project = Project.find(params[:project_id])
314 315 @wiki = @project.wiki
315 316 render_404 unless @wiki
316 317 rescue ActiveRecord::RecordNotFound
317 318 render_404
318 319 end
319 320
320 321 # Finds the requested page or a new page if it doesn't exist
321 322 def find_existing_or_new_page
322 323 @page = @wiki.find_or_new_page(params[:id])
323 324 if @wiki.page_found_with_redirect?
324 325 redirect_to params.update(:id => @page.title)
325 326 end
326 327 end
327 328
328 329 # Finds the requested page and returns a 404 error if it doesn't exist
329 330 def find_existing_page
330 331 @page = @wiki.find_page(params[:id])
331 332 if @page.nil?
332 333 render_404
333 334 return
334 335 end
335 336 if @wiki.page_found_with_redirect?
336 337 redirect_to params.update(:id => @page.title)
337 338 end
338 339 end
339 340
340 341 # Returns true if the current user is allowed to edit the page, otherwise false
341 342 def editable?(page = @page)
342 343 page.editable_by?(User.current)
343 344 end
344 345
345 346 # Returns the default content of a new wiki page
346 347 def initial_page_content(page)
347 348 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
348 349 extend helper unless self.instance_of?(helper)
349 350 helper.instance_method(:initial_page_content).bind(self).call(page)
350 351 end
351 352
352 353 def load_pages_for_index
353 354 @pages = @wiki.pages.with_updated_on.order("#{WikiPage.table_name}.title").includes(:wiki => :project).includes(:parent).all
354 355 end
355 356 end
@@ -1,1285 +1,1286
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27
28 28 extend Forwardable
29 29 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
30 30
31 31 # Return true if user is authorized for controller/action, otherwise false
32 32 def authorize_for(controller, action)
33 33 User.current.allowed_to?({:controller => controller, :action => action}, @project)
34 34 end
35 35
36 36 # Display a link if user is authorized
37 37 #
38 38 # @param [String] name Anchor text (passed to link_to)
39 39 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
40 40 # @param [optional, Hash] html_options Options passed to link_to
41 41 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
42 42 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
43 43 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
44 44 end
45 45
46 46 # Displays a link to user's account page if active
47 47 def link_to_user(user, options={})
48 48 if user.is_a?(User)
49 49 name = h(user.name(options[:format]))
50 50 if user.active? || (User.current.admin? && user.logged?)
51 51 link_to name, user_path(user), :class => user.css_classes
52 52 else
53 53 name
54 54 end
55 55 else
56 56 h(user.to_s)
57 57 end
58 58 end
59 59
60 60 # Displays a link to +issue+ with its subject.
61 61 # Examples:
62 62 #
63 63 # link_to_issue(issue) # => Defect #6: This is the subject
64 64 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
65 65 # link_to_issue(issue, :subject => false) # => Defect #6
66 66 # link_to_issue(issue, :project => true) # => Foo - Defect #6
67 67 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
68 68 #
69 69 def link_to_issue(issue, options={})
70 70 title = nil
71 71 subject = nil
72 72 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
73 73 if options[:subject] == false
74 74 title = truncate(issue.subject, :length => 60)
75 75 else
76 76 subject = issue.subject
77 77 if options[:truncate]
78 78 subject = truncate(subject, :length => options[:truncate])
79 79 end
80 80 end
81 81 s = link_to text, issue_path(issue), :class => issue.css_classes, :title => title
82 82 s << h(": #{subject}") if subject
83 83 s = h("#{issue.project} - ") + s if options[:project]
84 84 s
85 85 end
86 86
87 87 # Generates a link to an attachment.
88 88 # Options:
89 89 # * :text - Link text (default to attachment filename)
90 90 # * :download - Force download (default: false)
91 91 def link_to_attachment(attachment, options={})
92 92 text = options.delete(:text) || attachment.filename
93 93 action = options.delete(:download) ? 'download' : 'show'
94 94 opt_only_path = {}
95 95 opt_only_path[:only_path] = (options[:only_path] == false ? false : true)
96 96 options.delete(:only_path)
97 97 link_to(h(text),
98 98 {:controller => 'attachments', :action => action,
99 99 :id => attachment, :filename => attachment.filename}.merge(opt_only_path),
100 100 options)
101 101 end
102 102
103 103 # Generates a link to a SCM revision
104 104 # Options:
105 105 # * :text - Link text (default to the formatted revision)
106 106 def link_to_revision(revision, repository, options={})
107 107 if repository.is_a?(Project)
108 108 repository = repository.repository
109 109 end
110 110 text = options.delete(:text) || format_revision(revision)
111 111 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
112 112 link_to(
113 113 h(text),
114 114 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
115 115 :title => l(:label_revision_id, format_revision(revision))
116 116 )
117 117 end
118 118
119 119 # Generates a link to a message
120 120 def link_to_message(message, options={}, html_options = nil)
121 121 link_to(
122 122 h(truncate(message.subject, :length => 60)),
123 123 { :controller => 'messages', :action => 'show',
124 124 :board_id => message.board_id,
125 125 :id => (message.parent_id || message.id),
126 126 :r => (message.parent_id && message.id),
127 127 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
128 128 }.merge(options),
129 129 html_options
130 130 )
131 131 end
132 132
133 133 # Generates a link to a project if active
134 134 # Examples:
135 135 #
136 136 # link_to_project(project) # => link to the specified project overview
137 137 # link_to_project(project, :action=>'settings') # => link to project settings
138 138 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
139 139 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
140 140 #
141 141 def link_to_project(project, options={}, html_options = nil)
142 142 if project.archived?
143 143 h(project)
144 144 else
145 145 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
146 146 link_to(h(project), url, html_options)
147 147 end
148 148 end
149 149
150 150 def wiki_page_path(page, options={})
151 151 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
152 152 end
153 153
154 154 def thumbnail_tag(attachment)
155 155 link_to image_tag(url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment)),
156 156 {:controller => 'attachments', :action => 'show', :id => attachment, :filename => attachment.filename},
157 157 :title => attachment.filename
158 158 end
159 159
160 160 def toggle_link(name, id, options={})
161 161 onclick = "$('##{id}').toggle(); "
162 162 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
163 163 onclick << "return false;"
164 164 link_to(name, "#", :onclick => onclick)
165 165 end
166 166
167 167 def image_to_function(name, function, html_options = {})
168 168 html_options.symbolize_keys!
169 169 tag(:input, html_options.merge({
170 170 :type => "image", :src => image_path(name),
171 171 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
172 172 }))
173 173 end
174 174
175 175 def format_activity_title(text)
176 176 h(truncate_single_line(text, :length => 100))
177 177 end
178 178
179 179 def format_activity_day(date)
180 180 date == User.current.today ? l(:label_today).titleize : format_date(date)
181 181 end
182 182
183 183 def format_activity_description(text)
184 184 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
185 185 ).gsub(/[\r\n]+/, "<br />").html_safe
186 186 end
187 187
188 188 def format_version_name(version)
189 189 if version.project == @project
190 190 h(version)
191 191 else
192 192 h("#{version.project} - #{version}")
193 193 end
194 194 end
195 195
196 196 def due_date_distance_in_words(date)
197 197 if date
198 198 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
199 199 end
200 200 end
201 201
202 202 # Renders a tree of projects as a nested set of unordered lists
203 203 # The given collection may be a subset of the whole project tree
204 204 # (eg. some intermediate nodes are private and can not be seen)
205 205 def render_project_nested_lists(projects)
206 206 s = ''
207 207 if projects.any?
208 208 ancestors = []
209 209 original_project = @project
210 210 projects.sort_by(&:lft).each do |project|
211 211 # set the project environment to please macros.
212 212 @project = project
213 213 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
214 214 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
215 215 else
216 216 ancestors.pop
217 217 s << "</li>"
218 218 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
219 219 ancestors.pop
220 220 s << "</ul></li>\n"
221 221 end
222 222 end
223 223 classes = (ancestors.empty? ? 'root' : 'child')
224 224 s << "<li class='#{classes}'><div class='#{classes}'>"
225 225 s << h(block_given? ? yield(project) : project.name)
226 226 s << "</div>\n"
227 227 ancestors << project
228 228 end
229 229 s << ("</li></ul>\n" * ancestors.size)
230 230 @project = original_project
231 231 end
232 232 s.html_safe
233 233 end
234 234
235 235 def render_page_hierarchy(pages, node=nil, options={})
236 236 content = ''
237 237 if pages[node]
238 238 content << "<ul class=\"pages-hierarchy\">\n"
239 239 pages[node].each do |page|
240 240 content << "<li>"
241 241 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
242 242 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
243 243 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
244 244 content << "</li>\n"
245 245 end
246 246 content << "</ul>\n"
247 247 end
248 248 content.html_safe
249 249 end
250 250
251 251 # Renders flash messages
252 252 def render_flash_messages
253 253 s = ''
254 254 flash.each do |k,v|
255 255 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
256 256 end
257 257 s.html_safe
258 258 end
259 259
260 260 # Renders tabs and their content
261 261 def render_tabs(tabs)
262 262 if tabs.any?
263 263 render :partial => 'common/tabs', :locals => {:tabs => tabs}
264 264 else
265 265 content_tag 'p', l(:label_no_data), :class => "nodata"
266 266 end
267 267 end
268 268
269 269 # Renders the project quick-jump box
270 270 def render_project_jump_box
271 271 return unless User.current.logged?
272 272 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
273 273 if projects.any?
274 274 options =
275 275 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
276 276 '<option value="" disabled="disabled">---</option>').html_safe
277 277
278 278 options << project_tree_options_for_select(projects, :selected => @project) do |p|
279 279 { :value => project_path(:id => p, :jump => current_menu_item) }
280 280 end
281 281
282 282 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
283 283 end
284 284 end
285 285
286 286 def project_tree_options_for_select(projects, options = {})
287 287 s = ''
288 288 project_tree(projects) do |project, level|
289 289 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
290 290 tag_options = {:value => project.id}
291 291 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
292 292 tag_options[:selected] = 'selected'
293 293 else
294 294 tag_options[:selected] = nil
295 295 end
296 296 tag_options.merge!(yield(project)) if block_given?
297 297 s << content_tag('option', name_prefix + h(project), tag_options)
298 298 end
299 299 s.html_safe
300 300 end
301 301
302 302 # Yields the given block for each project with its level in the tree
303 303 #
304 304 # Wrapper for Project#project_tree
305 305 def project_tree(projects, &block)
306 306 Project.project_tree(projects, &block)
307 307 end
308 308
309 309 def principals_check_box_tags(name, principals)
310 310 s = ''
311 311 principals.sort.each do |principal|
312 312 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
313 313 end
314 314 s.html_safe
315 315 end
316 316
317 317 # Returns a string for users/groups option tags
318 318 def principals_options_for_select(collection, selected=nil)
319 319 s = ''
320 320 if collection.include?(User.current)
321 321 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
322 322 end
323 323 groups = ''
324 324 collection.sort.each do |element|
325 325 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected)
326 326 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
327 327 end
328 328 unless groups.empty?
329 329 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
330 330 end
331 331 s.html_safe
332 332 end
333 333
334 334 # Options for the new membership projects combo-box
335 335 def options_for_membership_project_select(principal, projects)
336 336 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
337 337 options << project_tree_options_for_select(projects) do |p|
338 338 {:disabled => principal.projects.include?(p)}
339 339 end
340 340 options
341 341 end
342 342
343 343 # Truncates and returns the string as a single line
344 344 def truncate_single_line(string, *args)
345 345 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
346 346 end
347 347
348 348 # Truncates at line break after 250 characters or options[:length]
349 349 def truncate_lines(string, options={})
350 350 length = options[:length] || 250
351 351 if string.to_s =~ /\A(.{#{length}}.*?)$/m
352 352 "#{$1}..."
353 353 else
354 354 string
355 355 end
356 356 end
357 357
358 358 def anchor(text)
359 359 text.to_s.gsub(' ', '_')
360 360 end
361 361
362 362 def html_hours(text)
363 363 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
364 364 end
365 365
366 366 def authoring(created, author, options={})
367 367 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
368 368 end
369 369
370 370 def time_tag(time)
371 371 text = distance_of_time_in_words(Time.now, time)
372 372 if @project
373 373 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
374 374 else
375 375 content_tag('acronym', text, :title => format_time(time))
376 376 end
377 377 end
378 378
379 379 def syntax_highlight_lines(name, content)
380 380 lines = []
381 381 syntax_highlight(name, content).each_line { |line| lines << line }
382 382 lines
383 383 end
384 384
385 385 def syntax_highlight(name, content)
386 386 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
387 387 end
388 388
389 389 def to_path_param(path)
390 390 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
391 391 str.blank? ? nil : str
392 392 end
393 393
394 394 def pagination_links_full(paginator, count=nil, options={})
395 395 page_param = options.delete(:page_param) || :page
396 396 per_page_links = options.delete(:per_page_links)
397 397 url_param = params.dup
398 398
399 399 html = ''
400 400 if paginator.current.previous
401 401 # \xc2\xab(utf-8) = &#171;
402 402 html << link_to_content_update(
403 403 "\xc2\xab " + l(:label_previous),
404 404 url_param.merge(page_param => paginator.current.previous)) + ' '
405 405 end
406 406
407 407 html << (pagination_links_each(paginator, options) do |n|
408 408 link_to_content_update(n.to_s, url_param.merge(page_param => n))
409 409 end || '')
410 410
411 411 if paginator.current.next
412 412 # \xc2\xbb(utf-8) = &#187;
413 413 html << ' ' + link_to_content_update(
414 414 (l(:label_next) + " \xc2\xbb"),
415 415 url_param.merge(page_param => paginator.current.next))
416 416 end
417 417
418 418 unless count.nil?
419 419 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
420 420 if per_page_links != false && links = per_page_links(paginator.items_per_page, count)
421 421 html << " | #{links}"
422 422 end
423 423 end
424 424
425 425 html.html_safe
426 426 end
427 427
428 428 def per_page_links(selected=nil, item_count=nil)
429 429 values = Setting.per_page_options_array
430 430 if item_count && values.any?
431 431 if item_count > values.first
432 432 max = values.detect {|value| value >= item_count} || item_count
433 433 else
434 434 max = item_count
435 435 end
436 436 values = values.select {|value| value <= max || value == selected}
437 437 end
438 438 if values.empty? || (values.size == 1 && values.first == selected)
439 439 return nil
440 440 end
441 441 links = values.collect do |n|
442 442 n == selected ? n : link_to_content_update(n, params.merge(:per_page => n))
443 443 end
444 444 l(:label_display_per_page, links.join(', '))
445 445 end
446 446
447 447 def reorder_links(name, url, method = :post)
448 448 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
449 449 url.merge({"#{name}[move_to]" => 'highest'}),
450 450 :method => method, :title => l(:label_sort_highest)) +
451 451 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
452 452 url.merge({"#{name}[move_to]" => 'higher'}),
453 453 :method => method, :title => l(:label_sort_higher)) +
454 454 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
455 455 url.merge({"#{name}[move_to]" => 'lower'}),
456 456 :method => method, :title => l(:label_sort_lower)) +
457 457 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
458 458 url.merge({"#{name}[move_to]" => 'lowest'}),
459 459 :method => method, :title => l(:label_sort_lowest))
460 460 end
461 461
462 462 def breadcrumb(*args)
463 463 elements = args.flatten
464 464 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
465 465 end
466 466
467 467 def other_formats_links(&block)
468 468 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
469 469 yield Redmine::Views::OtherFormatsBuilder.new(self)
470 470 concat('</p>'.html_safe)
471 471 end
472 472
473 473 def page_header_title
474 474 if @project.nil? || @project.new_record?
475 475 h(Setting.app_title)
476 476 else
477 477 b = []
478 478 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
479 479 if ancestors.any?
480 480 root = ancestors.shift
481 481 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
482 482 if ancestors.size > 2
483 483 b << "\xe2\x80\xa6"
484 484 ancestors = ancestors[-2, 2]
485 485 end
486 486 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
487 487 end
488 488 b << h(@project)
489 489 b.join(" \xc2\xbb ").html_safe
490 490 end
491 491 end
492 492
493 493 def html_title(*args)
494 494 if args.empty?
495 495 title = @html_title || []
496 496 title << @project.name if @project
497 497 title << Setting.app_title unless Setting.app_title == title.last
498 498 title.select {|t| !t.blank? }.join(' - ')
499 499 else
500 500 @html_title ||= []
501 501 @html_title += args
502 502 end
503 503 end
504 504
505 505 # Returns the theme, controller name, and action as css classes for the
506 506 # HTML body.
507 507 def body_css_classes
508 508 css = []
509 509 if theme = Redmine::Themes.theme(Setting.ui_theme)
510 510 css << 'theme-' + theme.name
511 511 end
512 512
513 513 css << 'controller-' + controller_name
514 514 css << 'action-' + action_name
515 515 css.join(' ')
516 516 end
517 517
518 518 def accesskey(s)
519 519 Redmine::AccessKeys.key_for s
520 520 end
521 521
522 522 # Formats text according to system settings.
523 523 # 2 ways to call this method:
524 524 # * with a String: textilizable(text, options)
525 525 # * with an object and one of its attribute: textilizable(issue, :description, options)
526 526 def textilizable(*args)
527 527 options = args.last.is_a?(Hash) ? args.pop : {}
528 528 case args.size
529 529 when 1
530 530 obj = options[:object]
531 531 text = args.shift
532 532 when 2
533 533 obj = args.shift
534 534 attr = args.shift
535 535 text = obj.send(attr).to_s
536 536 else
537 537 raise ArgumentError, 'invalid arguments to textilizable'
538 538 end
539 539 return '' if text.blank?
540 540 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
541 541 only_path = options.delete(:only_path) == false ? false : true
542 542
543 543 text = text.dup
544 544 macros = catch_macros(text)
545 545 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
546 546
547 547 @parsed_headings = []
548 548 @heading_anchors = {}
549 549 @current_section = 0 if options[:edit_section_links]
550 550
551 551 parse_sections(text, project, obj, attr, only_path, options)
552 552 text = parse_non_pre_blocks(text, obj, macros) do |text|
553 553 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
554 554 send method_name, text, project, obj, attr, only_path, options
555 555 end
556 556 end
557 557 parse_headings(text, project, obj, attr, only_path, options)
558 558
559 559 if @parsed_headings.any?
560 560 replace_toc(text, @parsed_headings)
561 561 end
562 562
563 563 text.html_safe
564 564 end
565 565
566 566 def parse_non_pre_blocks(text, obj, macros)
567 567 s = StringScanner.new(text)
568 568 tags = []
569 569 parsed = ''
570 570 while !s.eos?
571 571 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
572 572 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
573 573 if tags.empty?
574 574 yield text
575 575 inject_macros(text, obj, macros) if macros.any?
576 576 else
577 577 inject_macros(text, obj, macros, false) if macros.any?
578 578 end
579 579 parsed << text
580 580 if tag
581 581 if closing
582 582 if tags.last == tag.downcase
583 583 tags.pop
584 584 end
585 585 else
586 586 tags << tag.downcase
587 587 end
588 588 parsed << full_tag
589 589 end
590 590 end
591 591 # Close any non closing tags
592 592 while tag = tags.pop
593 593 parsed << "</#{tag}>"
594 594 end
595 595 parsed
596 596 end
597 597
598 598 def parse_inline_attachments(text, project, obj, attr, only_path, options)
599 599 # when using an image link, try to use an attachment, if possible
600 if options[:attachments] || (obj && obj.respond_to?(:attachments))
601 attachments = options[:attachments] || obj.attachments
600 if options[:attachments].present? || (obj && obj.respond_to?(:attachments))
601 attachments = options[:attachments] || []
602 attachments += obj.attachments if obj
602 603 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
603 604 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
604 605 # search for the picture in attachments
605 606 if found = Attachment.latest_attach(attachments, filename)
606 607 image_url = url_for :only_path => only_path, :controller => 'attachments',
607 608 :action => 'download', :id => found
608 609 desc = found.description.to_s.gsub('"', '')
609 610 if !desc.blank? && alttext.blank?
610 611 alt = " title=\"#{desc}\" alt=\"#{desc}\""
611 612 end
612 613 "src=\"#{image_url}\"#{alt}"
613 614 else
614 615 m
615 616 end
616 617 end
617 618 end
618 619 end
619 620
620 621 # Wiki links
621 622 #
622 623 # Examples:
623 624 # [[mypage]]
624 625 # [[mypage|mytext]]
625 626 # wiki links can refer other project wikis, using project name or identifier:
626 627 # [[project:]] -> wiki starting page
627 628 # [[project:|mytext]]
628 629 # [[project:mypage]]
629 630 # [[project:mypage|mytext]]
630 631 def parse_wiki_links(text, project, obj, attr, only_path, options)
631 632 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
632 633 link_project = project
633 634 esc, all, page, title = $1, $2, $3, $5
634 635 if esc.nil?
635 636 if page =~ /^([^\:]+)\:(.*)$/
636 637 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
637 638 page = $2
638 639 title ||= $1 if page.blank?
639 640 end
640 641
641 642 if link_project && link_project.wiki
642 643 # extract anchor
643 644 anchor = nil
644 645 if page =~ /^(.+?)\#(.+)$/
645 646 page, anchor = $1, $2
646 647 end
647 648 anchor = sanitize_anchor_name(anchor) if anchor.present?
648 649 # check if page exists
649 650 wiki_page = link_project.wiki.find_page(page)
650 651 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
651 652 "##{anchor}"
652 653 else
653 654 case options[:wiki_links]
654 655 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
655 656 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
656 657 else
657 658 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
658 659 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
659 660 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
660 661 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
661 662 end
662 663 end
663 664 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
664 665 else
665 666 # project or wiki doesn't exist
666 667 all
667 668 end
668 669 else
669 670 all
670 671 end
671 672 end
672 673 end
673 674
674 675 # Redmine links
675 676 #
676 677 # Examples:
677 678 # Issues:
678 679 # #52 -> Link to issue #52
679 680 # Changesets:
680 681 # r52 -> Link to revision 52
681 682 # commit:a85130f -> Link to scmid starting with a85130f
682 683 # Documents:
683 684 # document#17 -> Link to document with id 17
684 685 # document:Greetings -> Link to the document with title "Greetings"
685 686 # document:"Some document" -> Link to the document with title "Some document"
686 687 # Versions:
687 688 # version#3 -> Link to version with id 3
688 689 # version:1.0.0 -> Link to version named "1.0.0"
689 690 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
690 691 # Attachments:
691 692 # attachment:file.zip -> Link to the attachment of the current object named file.zip
692 693 # Source files:
693 694 # source:some/file -> Link to the file located at /some/file in the project's repository
694 695 # source:some/file@52 -> Link to the file's revision 52
695 696 # source:some/file#L120 -> Link to line 120 of the file
696 697 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
697 698 # export:some/file -> Force the download of the file
698 699 # Forum messages:
699 700 # message#1218 -> Link to message with id 1218
700 701 #
701 702 # Links can refer other objects from other projects, using project identifier:
702 703 # identifier:r52
703 704 # identifier:document:"Some document"
704 705 # identifier:version:1.0.0
705 706 # identifier:source:some/file
706 707 def parse_redmine_links(text, project, obj, attr, only_path, options)
707 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 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 710 link = nil
710 711 if project_identifier
711 712 project = Project.visible.find_by_identifier(project_identifier)
712 713 end
713 714 if esc.nil?
714 715 if prefix.nil? && sep == 'r'
715 716 if project
716 717 repository = nil
717 718 if repo_identifier
718 719 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
719 720 else
720 721 repository = project.repository
721 722 end
722 723 # project.changesets.visible raises an SQL error because of a double join on repositories
723 724 if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
724 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 726 :class => 'changeset',
726 727 :title => truncate_single_line(changeset.comments, :length => 100))
727 728 end
728 729 end
729 730 elsif sep == '#'
730 731 oid = identifier.to_i
731 732 case prefix
732 733 when nil
733 734 if oid.to_s == identifier && issue = Issue.visible.find_by_id(oid, :include => :status)
734 735 anchor = comment_id ? "note-#{comment_id}" : nil
735 736 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid, :anchor => anchor},
736 737 :class => issue.css_classes,
737 738 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
738 739 end
739 740 when 'document'
740 741 if document = Document.visible.find_by_id(oid)
741 742 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
742 743 :class => 'document'
743 744 end
744 745 when 'version'
745 746 if version = Version.visible.find_by_id(oid)
746 747 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
747 748 :class => 'version'
748 749 end
749 750 when 'message'
750 751 if message = Message.visible.find_by_id(oid, :include => :parent)
751 752 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
752 753 end
753 754 when 'forum'
754 755 if board = Board.visible.find_by_id(oid)
755 756 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
756 757 :class => 'board'
757 758 end
758 759 when 'news'
759 760 if news = News.visible.find_by_id(oid)
760 761 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
761 762 :class => 'news'
762 763 end
763 764 when 'project'
764 765 if p = Project.visible.find_by_id(oid)
765 766 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
766 767 end
767 768 end
768 769 elsif sep == ':'
769 770 # removes the double quotes if any
770 771 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
771 772 case prefix
772 773 when 'document'
773 774 if project && document = project.documents.visible.find_by_title(name)
774 775 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
775 776 :class => 'document'
776 777 end
777 778 when 'version'
778 779 if project && version = project.versions.visible.find_by_name(name)
779 780 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
780 781 :class => 'version'
781 782 end
782 783 when 'forum'
783 784 if project && board = project.boards.visible.find_by_name(name)
784 785 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
785 786 :class => 'board'
786 787 end
787 788 when 'news'
788 789 if project && news = project.news.visible.find_by_title(name)
789 790 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
790 791 :class => 'news'
791 792 end
792 793 when 'commit', 'source', 'export'
793 794 if project
794 795 repository = nil
795 796 if name =~ %r{^(([a-z0-9\-]+)\|)(.+)$}
796 797 repo_prefix, repo_identifier, name = $1, $2, $3
797 798 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
798 799 else
799 800 repository = project.repository
800 801 end
801 802 if prefix == 'commit'
802 803 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
803 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 805 :class => 'changeset',
805 806 :title => truncate_single_line(h(changeset.comments), :length => 100)
806 807 end
807 808 else
808 809 if repository && User.current.allowed_to?(:browse_repository, project)
809 810 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
810 811 path, rev, anchor = $1, $3, $5
811 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 813 :path => to_path_param(path),
813 814 :rev => rev,
814 815 :anchor => anchor},
815 816 :class => (prefix == 'export' ? 'source download' : 'source')
816 817 end
817 818 end
818 819 repo_prefix = nil
819 820 end
820 821 when 'attachment'
821 822 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
822 823 if attachments && attachment = attachments.detect {|a| a.filename == name }
823 824 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
824 825 :class => 'attachment'
825 826 end
826 827 when 'project'
827 828 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
828 829 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
829 830 end
830 831 end
831 832 end
832 833 end
833 834 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
834 835 end
835 836 end
836 837
837 838 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
838 839
839 840 def parse_sections(text, project, obj, attr, only_path, options)
840 841 return unless options[:edit_section_links]
841 842 text.gsub!(HEADING_RE) do
842 843 heading = $1
843 844 @current_section += 1
844 845 if @current_section > 1
845 846 content_tag('div',
846 847 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
847 848 :class => 'contextual',
848 849 :title => l(:button_edit_section)) + heading.html_safe
849 850 else
850 851 heading
851 852 end
852 853 end
853 854 end
854 855
855 856 # Headings and TOC
856 857 # Adds ids and links to headings unless options[:headings] is set to false
857 858 def parse_headings(text, project, obj, attr, only_path, options)
858 859 return if options[:headings] == false
859 860
860 861 text.gsub!(HEADING_RE) do
861 862 level, attrs, content = $2.to_i, $3, $4
862 863 item = strip_tags(content).strip
863 864 anchor = sanitize_anchor_name(item)
864 865 # used for single-file wiki export
865 866 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
866 867 @heading_anchors[anchor] ||= 0
867 868 idx = (@heading_anchors[anchor] += 1)
868 869 if idx > 1
869 870 anchor = "#{anchor}-#{idx}"
870 871 end
871 872 @parsed_headings << [level, anchor, item]
872 873 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
873 874 end
874 875 end
875 876
876 877 MACROS_RE = /(
877 878 (!)? # escaping
878 879 (
879 880 \{\{ # opening tag
880 881 ([\w]+) # macro name
881 882 (\(([^\n\r]*?)\))? # optional arguments
882 883 ([\n\r].*?[\n\r])? # optional block of text
883 884 \}\} # closing tag
884 885 )
885 886 )/mx unless const_defined?(:MACROS_RE)
886 887
887 888 MACRO_SUB_RE = /(
888 889 \{\{
889 890 macro\((\d+)\)
890 891 \}\}
891 892 )/x unless const_defined?(:MACRO_SUB_RE)
892 893
893 894 # Extracts macros from text
894 895 def catch_macros(text)
895 896 macros = {}
896 897 text.gsub!(MACROS_RE) do
897 898 all, macro = $1, $4.downcase
898 899 if macro_exists?(macro) || all =~ MACRO_SUB_RE
899 900 index = macros.size
900 901 macros[index] = all
901 902 "{{macro(#{index})}}"
902 903 else
903 904 all
904 905 end
905 906 end
906 907 macros
907 908 end
908 909
909 910 # Executes and replaces macros in text
910 911 def inject_macros(text, obj, macros, execute=true)
911 912 text.gsub!(MACRO_SUB_RE) do
912 913 all, index = $1, $2.to_i
913 914 orig = macros.delete(index)
914 915 if execute && orig && orig =~ MACROS_RE
915 916 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
916 917 if esc.nil?
917 918 h(exec_macro(macro, obj, args, block) || all)
918 919 else
919 920 h(all)
920 921 end
921 922 elsif orig
922 923 h(orig)
923 924 else
924 925 h(all)
925 926 end
926 927 end
927 928 end
928 929
929 930 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
930 931
931 932 # Renders the TOC with given headings
932 933 def replace_toc(text, headings)
933 934 text.gsub!(TOC_RE) do
934 935 # Keep only the 4 first levels
935 936 headings = headings.select{|level, anchor, item| level <= 4}
936 937 if headings.empty?
937 938 ''
938 939 else
939 940 div_class = 'toc'
940 941 div_class << ' right' if $1 == '>'
941 942 div_class << ' left' if $1 == '<'
942 943 out = "<ul class=\"#{div_class}\"><li>"
943 944 root = headings.map(&:first).min
944 945 current = root
945 946 started = false
946 947 headings.each do |level, anchor, item|
947 948 if level > current
948 949 out << '<ul><li>' * (level - current)
949 950 elsif level < current
950 951 out << "</li></ul>\n" * (current - level) + "</li><li>"
951 952 elsif started
952 953 out << '</li><li>'
953 954 end
954 955 out << "<a href=\"##{anchor}\">#{item}</a>"
955 956 current = level
956 957 started = true
957 958 end
958 959 out << '</li></ul>' * (current - root)
959 960 out << '</li></ul>'
960 961 end
961 962 end
962 963 end
963 964
964 965 # Same as Rails' simple_format helper without using paragraphs
965 966 def simple_format_without_paragraph(text)
966 967 text.to_s.
967 968 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
968 969 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
969 970 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
970 971 html_safe
971 972 end
972 973
973 974 def lang_options_for_select(blank=true)
974 975 (blank ? [["(auto)", ""]] : []) + languages_options
975 976 end
976 977
977 978 def label_tag_for(name, option_tags = nil, options = {})
978 979 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
979 980 content_tag("label", label_text)
980 981 end
981 982
982 983 def labelled_form_for(*args, &proc)
983 984 args << {} unless args.last.is_a?(Hash)
984 985 options = args.last
985 986 if args.first.is_a?(Symbol)
986 987 options.merge!(:as => args.shift)
987 988 end
988 989 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
989 990 form_for(*args, &proc)
990 991 end
991 992
992 993 def labelled_fields_for(*args, &proc)
993 994 args << {} unless args.last.is_a?(Hash)
994 995 options = args.last
995 996 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
996 997 fields_for(*args, &proc)
997 998 end
998 999
999 1000 def labelled_remote_form_for(*args, &proc)
1000 1001 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
1001 1002 args << {} unless args.last.is_a?(Hash)
1002 1003 options = args.last
1003 1004 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
1004 1005 form_for(*args, &proc)
1005 1006 end
1006 1007
1007 1008 def error_messages_for(*objects)
1008 1009 html = ""
1009 1010 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1010 1011 errors = objects.map {|o| o.errors.full_messages}.flatten
1011 1012 if errors.any?
1012 1013 html << "<div id='errorExplanation'><ul>\n"
1013 1014 errors.each do |error|
1014 1015 html << "<li>#{h error}</li>\n"
1015 1016 end
1016 1017 html << "</ul></div>\n"
1017 1018 end
1018 1019 html.html_safe
1019 1020 end
1020 1021
1021 1022 def delete_link(url, options={})
1022 1023 options = {
1023 1024 :method => :delete,
1024 1025 :data => {:confirm => l(:text_are_you_sure)},
1025 1026 :class => 'icon icon-del'
1026 1027 }.merge(options)
1027 1028
1028 1029 link_to l(:button_delete), url, options
1029 1030 end
1030 1031
1031 1032 def preview_link(url, form, target='preview', options={})
1032 1033 content_tag 'a', l(:label_preview), {
1033 1034 :href => "#",
1034 1035 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1035 1036 :accesskey => accesskey(:preview)
1036 1037 }.merge(options)
1037 1038 end
1038 1039
1039 1040 def link_to_function(name, function, html_options={})
1040 1041 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1041 1042 end
1042 1043
1043 1044 # Helper to render JSON in views
1044 1045 def raw_json(arg)
1045 1046 arg.to_json.to_s.gsub('/', '\/').html_safe
1046 1047 end
1047 1048
1048 1049 def back_url
1049 1050 url = params[:back_url]
1050 1051 if url.nil? && referer = request.env['HTTP_REFERER']
1051 1052 url = CGI.unescape(referer.to_s)
1052 1053 end
1053 1054 url
1054 1055 end
1055 1056
1056 1057 def back_url_hidden_field_tag
1057 1058 url = back_url
1058 1059 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1059 1060 end
1060 1061
1061 1062 def check_all_links(form_name)
1062 1063 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1063 1064 " | ".html_safe +
1064 1065 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1065 1066 end
1066 1067
1067 1068 def progress_bar(pcts, options={})
1068 1069 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1069 1070 pcts = pcts.collect(&:round)
1070 1071 pcts[1] = pcts[1] - pcts[0]
1071 1072 pcts << (100 - pcts[1] - pcts[0])
1072 1073 width = options[:width] || '100px;'
1073 1074 legend = options[:legend] || ''
1074 1075 content_tag('table',
1075 1076 content_tag('tr',
1076 1077 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1077 1078 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1078 1079 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1079 1080 ), :class => 'progress', :style => "width: #{width};").html_safe +
1080 1081 content_tag('p', legend, :class => 'pourcent').html_safe
1081 1082 end
1082 1083
1083 1084 def checked_image(checked=true)
1084 1085 if checked
1085 1086 image_tag 'toggle_check.png'
1086 1087 end
1087 1088 end
1088 1089
1089 1090 def context_menu(url)
1090 1091 unless @context_menu_included
1091 1092 content_for :header_tags do
1092 1093 javascript_include_tag('context_menu') +
1093 1094 stylesheet_link_tag('context_menu')
1094 1095 end
1095 1096 if l(:direction) == 'rtl'
1096 1097 content_for :header_tags do
1097 1098 stylesheet_link_tag('context_menu_rtl')
1098 1099 end
1099 1100 end
1100 1101 @context_menu_included = true
1101 1102 end
1102 1103 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1103 1104 end
1104 1105
1105 1106 def calendar_for(field_id)
1106 1107 include_calendar_headers_tags
1107 1108 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1108 1109 end
1109 1110
1110 1111 def include_calendar_headers_tags
1111 1112 unless @calendar_headers_tags_included
1112 1113 @calendar_headers_tags_included = true
1113 1114 content_for :header_tags do
1114 1115 start_of_week = Setting.start_of_week
1115 1116 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1116 1117 # Redmine uses 1..7 (monday..sunday) in settings and locales
1117 1118 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1118 1119 start_of_week = start_of_week.to_i % 7
1119 1120
1120 1121 tags = javascript_tag(
1121 1122 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1122 1123 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1123 1124 path_to_image('/images/calendar.png') +
1124 1125 "', showButtonPanel: true};")
1125 1126 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1126 1127 unless jquery_locale == 'en'
1127 1128 tags << javascript_include_tag("i18n/jquery.ui.datepicker-#{jquery_locale}.js")
1128 1129 end
1129 1130 tags
1130 1131 end
1131 1132 end
1132 1133 end
1133 1134
1134 1135 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1135 1136 # Examples:
1136 1137 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1137 1138 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1138 1139 #
1139 1140 def stylesheet_link_tag(*sources)
1140 1141 options = sources.last.is_a?(Hash) ? sources.pop : {}
1141 1142 plugin = options.delete(:plugin)
1142 1143 sources = sources.map do |source|
1143 1144 if plugin
1144 1145 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1145 1146 elsif current_theme && current_theme.stylesheets.include?(source)
1146 1147 current_theme.stylesheet_path(source)
1147 1148 else
1148 1149 source
1149 1150 end
1150 1151 end
1151 1152 super sources, options
1152 1153 end
1153 1154
1154 1155 # Overrides Rails' image_tag with themes and plugins support.
1155 1156 # Examples:
1156 1157 # image_tag('image.png') # => picks image.png from the current theme or defaults
1157 1158 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1158 1159 #
1159 1160 def image_tag(source, options={})
1160 1161 if plugin = options.delete(:plugin)
1161 1162 source = "/plugin_assets/#{plugin}/images/#{source}"
1162 1163 elsif current_theme && current_theme.images.include?(source)
1163 1164 source = current_theme.image_path(source)
1164 1165 end
1165 1166 super source, options
1166 1167 end
1167 1168
1168 1169 # Overrides Rails' javascript_include_tag with plugins support
1169 1170 # Examples:
1170 1171 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1171 1172 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1172 1173 #
1173 1174 def javascript_include_tag(*sources)
1174 1175 options = sources.last.is_a?(Hash) ? sources.pop : {}
1175 1176 if plugin = options.delete(:plugin)
1176 1177 sources = sources.map do |source|
1177 1178 if plugin
1178 1179 "/plugin_assets/#{plugin}/javascripts/#{source}"
1179 1180 else
1180 1181 source
1181 1182 end
1182 1183 end
1183 1184 end
1184 1185 super sources, options
1185 1186 end
1186 1187
1187 1188 def content_for(name, content = nil, &block)
1188 1189 @has_content ||= {}
1189 1190 @has_content[name] = true
1190 1191 super(name, content, &block)
1191 1192 end
1192 1193
1193 1194 def has_content?(name)
1194 1195 (@has_content && @has_content[name]) || false
1195 1196 end
1196 1197
1197 1198 def sidebar_content?
1198 1199 has_content?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1199 1200 end
1200 1201
1201 1202 def view_layouts_base_sidebar_hook_response
1202 1203 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1203 1204 end
1204 1205
1205 1206 def email_delivery_enabled?
1206 1207 !!ActionMailer::Base.perform_deliveries
1207 1208 end
1208 1209
1209 1210 # Returns the avatar image tag for the given +user+ if avatars are enabled
1210 1211 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1211 1212 def avatar(user, options = { })
1212 1213 if Setting.gravatar_enabled?
1213 1214 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1214 1215 email = nil
1215 1216 if user.respond_to?(:mail)
1216 1217 email = user.mail
1217 1218 elsif user.to_s =~ %r{<(.+?)>}
1218 1219 email = $1
1219 1220 end
1220 1221 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1221 1222 else
1222 1223 ''
1223 1224 end
1224 1225 end
1225 1226
1226 1227 def sanitize_anchor_name(anchor)
1227 1228 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1228 1229 anchor.gsub(%r{[^\p{Word}\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1229 1230 else
1230 1231 # TODO: remove when ruby1.8 is no longer supported
1231 1232 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1232 1233 end
1233 1234 end
1234 1235
1235 1236 # Returns the javascript tags that are included in the html layout head
1236 1237 def javascript_heads
1237 1238 tags = javascript_include_tag('jquery-1.7.2-ui-1.8.21-ujs-2.0.3', 'application')
1238 1239 unless User.current.pref.warn_on_leaving_unsaved == '0'
1239 1240 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1240 1241 end
1241 1242 tags
1242 1243 end
1243 1244
1244 1245 def favicon
1245 1246 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1246 1247 end
1247 1248
1248 1249 def robot_exclusion_tag
1249 1250 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1250 1251 end
1251 1252
1252 1253 # Returns true if arg is expected in the API response
1253 1254 def include_in_api_response?(arg)
1254 1255 unless @included_in_api_response
1255 1256 param = params[:include]
1256 1257 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1257 1258 @included_in_api_response.collect!(&:strip)
1258 1259 end
1259 1260 @included_in_api_response.include?(arg.to_s)
1260 1261 end
1261 1262
1262 1263 # Returns options or nil if nometa param or X-Redmine-Nometa header
1263 1264 # was set in the request
1264 1265 def api_meta(options)
1265 1266 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1266 1267 # compatibility mode for activeresource clients that raise
1267 1268 # an error when unserializing an array with attributes
1268 1269 nil
1269 1270 else
1270 1271 options
1271 1272 end
1272 1273 end
1273 1274
1274 1275 private
1275 1276
1276 1277 def wiki_helper
1277 1278 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1278 1279 extend helper
1279 1280 return self
1280 1281 end
1281 1282
1282 1283 def link_to_content_update(text, url_params = {}, html_options = {})
1283 1284 link_to(text, url_params, html_options)
1284 1285 end
1285 1286 end
@@ -1,287 +1,295
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require "digest/md5"
19 19
20 20 class Attachment < ActiveRecord::Base
21 21 belongs_to :container, :polymorphic => true
22 22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23 23
24 24 validates_presence_of :filename, :author
25 25 validates_length_of :filename, :maximum => 255
26 26 validates_length_of :disk_filename, :maximum => 255
27 27 validates_length_of :description, :maximum => 255
28 28 validate :validate_max_file_size
29 29
30 30 acts_as_event :title => :filename,
31 31 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
32 32
33 33 acts_as_activity_provider :type => 'files',
34 34 :permission => :view_files,
35 35 :author_key => :author_id,
36 36 :find_options => {:select => "#{Attachment.table_name}.*",
37 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 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 40 acts_as_activity_provider :type => 'documents',
41 41 :permission => :view_documents,
42 42 :author_key => :author_id,
43 43 :find_options => {:select => "#{Attachment.table_name}.*",
44 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 45 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
46 46
47 47 cattr_accessor :storage_path
48 48 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
49 49
50 50 cattr_accessor :thumbnails_storage_path
51 51 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
52 52
53 53 before_save :files_to_final_location
54 54 after_destroy :delete_from_disk
55 55
56 56 # Returns an unsaved copy of the attachment
57 57 def copy(attributes=nil)
58 58 copy = self.class.new
59 59 copy.attributes = self.attributes.dup.except("id", "downloads")
60 60 copy.attributes = attributes if attributes
61 61 copy
62 62 end
63 63
64 64 def validate_max_file_size
65 65 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
66 66 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
67 67 end
68 68 end
69 69
70 70 def file=(incoming_file)
71 71 unless incoming_file.nil?
72 72 @temp_file = incoming_file
73 73 if @temp_file.size > 0
74 74 if @temp_file.respond_to?(:original_filename)
75 75 self.filename = @temp_file.original_filename
76 76 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
77 77 end
78 78 if @temp_file.respond_to?(:content_type)
79 79 self.content_type = @temp_file.content_type.to_s.chomp
80 80 end
81 81 if content_type.blank? && filename.present?
82 82 self.content_type = Redmine::MimeType.of(filename)
83 83 end
84 84 self.filesize = @temp_file.size
85 85 end
86 86 end
87 87 end
88 88
89 89 def file
90 90 nil
91 91 end
92 92
93 93 def filename=(arg)
94 94 write_attribute :filename, sanitize_filename(arg.to_s)
95 95 if new_record? && disk_filename.blank?
96 96 self.disk_filename = Attachment.disk_filename(filename)
97 97 end
98 98 filename
99 99 end
100 100
101 101 # Copies the temporary file to its final location
102 102 # and computes its MD5 hash
103 103 def files_to_final_location
104 104 if @temp_file && (@temp_file.size > 0)
105 105 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
106 106 md5 = Digest::MD5.new
107 107 File.open(diskfile, "wb") do |f|
108 108 if @temp_file.respond_to?(:read)
109 109 buffer = ""
110 110 while (buffer = @temp_file.read(8192))
111 111 f.write(buffer)
112 112 md5.update(buffer)
113 113 end
114 114 else
115 115 f.write(@temp_file)
116 116 md5.update(@temp_file)
117 117 end
118 118 end
119 119 self.digest = md5.hexdigest
120 120 end
121 121 @temp_file = nil
122 122 # Don't save the content type if it's longer than the authorized length
123 123 if self.content_type && self.content_type.length > 255
124 124 self.content_type = nil
125 125 end
126 126 end
127 127
128 128 # Deletes the file from the file system if it's not referenced by other attachments
129 129 def delete_from_disk
130 130 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
131 131 delete_from_disk!
132 132 end
133 133 end
134 134
135 135 # Returns file's location on disk
136 136 def diskfile
137 137 File.join(self.class.storage_path, disk_filename.to_s)
138 138 end
139 139
140 140 def title
141 141 title = filename.to_s
142 142 if description.present?
143 143 title << " (#{description})"
144 144 end
145 145 title
146 146 end
147 147
148 148 def increment_download
149 149 increment!(:downloads)
150 150 end
151 151
152 152 def project
153 153 container.try(:project)
154 154 end
155 155
156 156 def visible?(user=User.current)
157 container && container.attachments_visible?(user)
157 if container_id
158 container && container.attachments_visible?(user)
159 else
160 author == user
161 end
158 162 end
159 163
160 164 def deletable?(user=User.current)
161 container && container.attachments_deletable?(user)
165 if container_id
166 container && container.attachments_deletable?(user)
167 else
168 author == user
169 end
162 170 end
163 171
164 172 def image?
165 173 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
166 174 end
167 175
168 176 def thumbnailable?
169 177 image?
170 178 end
171 179
172 180 # Returns the full path the attachment thumbnail, or nil
173 181 # if the thumbnail cannot be generated.
174 182 def thumbnail(options={})
175 183 if thumbnailable? && readable?
176 184 size = options[:size].to_i
177 185 if size > 0
178 186 # Limit the number of thumbnails per image
179 187 size = (size / 50) * 50
180 188 # Maximum thumbnail size
181 189 size = 800 if size > 800
182 190 else
183 191 size = Setting.thumbnails_size.to_i
184 192 end
185 193 size = 100 unless size > 0
186 194 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
187 195
188 196 begin
189 197 Redmine::Thumbnail.generate(self.diskfile, target, size)
190 198 rescue => e
191 199 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
192 200 return nil
193 201 end
194 202 end
195 203 end
196 204
197 205 # Deletes all thumbnails
198 206 def self.clear_thumbnails
199 207 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
200 208 File.delete file
201 209 end
202 210 end
203 211
204 212 def is_text?
205 213 Redmine::MimeType.is_type?('text', filename)
206 214 end
207 215
208 216 def is_diff?
209 217 self.filename =~ /\.(patch|diff)$/i
210 218 end
211 219
212 220 # Returns true if the file is readable
213 221 def readable?
214 222 File.readable?(diskfile)
215 223 end
216 224
217 225 # Returns the attachment token
218 226 def token
219 227 "#{id}.#{digest}"
220 228 end
221 229
222 230 # Finds an attachment that matches the given token and that has no container
223 231 def self.find_by_token(token)
224 232 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
225 233 attachment_id, attachment_digest = $1, $2
226 234 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
227 235 if attachment && attachment.container.nil?
228 236 attachment
229 237 end
230 238 end
231 239 end
232 240
233 241 # Bulk attaches a set of files to an object
234 242 #
235 243 # Returns a Hash of the results:
236 244 # :files => array of the attached files
237 245 # :unsaved => array of the files that could not be attached
238 246 def self.attach_files(obj, attachments)
239 247 result = obj.save_attachments(attachments, User.current)
240 248 obj.attach_saved_attachments
241 249 result
242 250 end
243 251
244 252 def self.latest_attach(attachments, filename)
245 253 attachments.sort_by(&:created_on).reverse.detect {
246 254 |att| att.filename.downcase == filename.downcase
247 255 }
248 256 end
249 257
250 258 def self.prune(age=1.day)
251 259 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
252 260 end
253 261
254 262 private
255 263
256 264 # Physically deletes the file from the file system
257 265 def delete_from_disk!
258 266 if disk_filename.present? && File.exist?(diskfile)
259 267 File.delete(diskfile)
260 268 end
261 269 end
262 270
263 271 def sanitize_filename(value)
264 272 # get only the filename, not the whole path
265 273 just_filename = value.gsub(/^.*(\\|\/)/, '')
266 274
267 275 # Finally, replace invalid characters with underscore
268 276 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
269 277 end
270 278
271 279 # Returns an ASCII or hashed filename
272 280 def self.disk_filename(filename)
273 281 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
274 282 ascii = ''
275 283 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
276 284 ascii = filename
277 285 else
278 286 ascii = Digest::MD5.hexdigest(filename)
279 287 # keep the extension if any
280 288 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
281 289 end
282 290 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
283 291 timestamp.succ!
284 292 end
285 293 "#{timestamp}_#{ascii}"
286 294 end
287 295 end
@@ -1,18 +1,28
1 <span id="attachments_fields">
1 2 <% if defined?(container) && container && container.saved_attachments %>
2 3 <% container.saved_attachments.each_with_index do |attachment, i| %>
3 <span class="icon icon-attachment" style="display:block; line-height:1.5em;">
4 <%= h(attachment.filename) %> (<%= number_to_human_size(attachment.filesize) %>)
5 <%= hidden_field_tag "attachments[p#{i}][token]", "#{attachment.id}.#{attachment.digest}" %>
4 <span id="attachments_p<%= i %>">
5 <%= text_field_tag("attachments[p#{i}][filename]", attachment.filename, :class => 'filename') +
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 9 </span>
7 10 <% end %>
8 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>
16 12 </span>
17 <span class="add_attachment"><%= link_to l(:label_add_another_file), '#', :onclick => 'addFileField(); return false;', :class => 'add_attachment' %>
18 (<%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %>)</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) %>)
26 </span>
27
28 <%= javascript_include_tag 'attachments' %>
@@ -1,3 +1,3
1 1 <fieldset class="preview"><legend><%= l(:label_preview) %></legend>
2 <%= textilizable @text, :attachments => @attachements, :object => @previewed %>
2 <%= textilizable @text, :attachments => @attachments, :object => @previewed %>
3 3 </fieldset>
@@ -1,11 +1,11
1 1 <% if @notes %>
2 2 <fieldset class="preview"><legend><%= l(:field_notes) %></legend>
3 <%= textilizable @notes, :attachments => @attachements, :object => @issue %>
3 <%= textilizable @notes, :attachments => @attachments, :object => @issue %>
4 4 </fieldset>
5 5 <% end %>
6 6
7 7 <% if @description %>
8 8 <fieldset class="preview"><legend><%= l(:field_description) %></legend>
9 <%= textilizable @description, :attachments => @attachements, :object => @issue %>
9 <%= textilizable @description, :attachments => @attachments, :object => @issue %>
10 10 </fieldset>
11 11 <% end %>
@@ -1,197 +1,200
1 1 # = Redmine configuration file
2 2 #
3 3 # Each environment has it's own configuration options. If you are only
4 4 # running in production, only the production block needs to be configured.
5 5 # Environment specific configuration options override the default ones.
6 6 #
7 7 # Note that this file needs to be a valid YAML file.
8 8 # DO NOT USE TABS! Use 2 spaces instead of tabs for identation.
9 9 #
10 10 # == Outgoing email settings (email_delivery setting)
11 11 #
12 12 # === Common configurations
13 13 #
14 14 # ==== Sendmail command
15 15 #
16 16 # production:
17 17 # email_delivery:
18 18 # delivery_method: :sendmail
19 19 #
20 20 # ==== Simple SMTP server at localhost
21 21 #
22 22 # production:
23 23 # email_delivery:
24 24 # delivery_method: :smtp
25 25 # smtp_settings:
26 26 # address: "localhost"
27 27 # port: 25
28 28 #
29 29 # ==== SMTP server at example.com using LOGIN authentication and checking HELO for foo.com
30 30 #
31 31 # production:
32 32 # email_delivery:
33 33 # delivery_method: :smtp
34 34 # smtp_settings:
35 35 # address: "example.com"
36 36 # port: 25
37 37 # authentication: :login
38 38 # domain: 'foo.com'
39 39 # user_name: 'myaccount'
40 40 # password: 'password'
41 41 #
42 42 # ==== SMTP server at example.com using PLAIN authentication
43 43 #
44 44 # production:
45 45 # email_delivery:
46 46 # delivery_method: :smtp
47 47 # smtp_settings:
48 48 # address: "example.com"
49 49 # port: 25
50 50 # authentication: :plain
51 51 # domain: 'example.com'
52 52 # user_name: 'myaccount'
53 53 # password: 'password'
54 54 #
55 55 # ==== SMTP server at using TLS (GMail)
56 56 #
57 57 # This might require some additional configuration. See the guides at:
58 58 # http://www.redmine.org/projects/redmine/wiki/EmailConfiguration
59 59 #
60 60 # production:
61 61 # email_delivery:
62 62 # delivery_method: :smtp
63 63 # smtp_settings:
64 64 # enable_starttls_auto: true
65 65 # address: "smtp.gmail.com"
66 66 # port: 587
67 67 # domain: "smtp.gmail.com" # 'your.domain.com' for GoogleApps
68 68 # authentication: :plain
69 69 # user_name: "your_email@gmail.com"
70 70 # password: "your_password"
71 71 #
72 72 #
73 73 # === More configuration options
74 74 #
75 75 # See the "Configuration options" at the following website for a list of the
76 76 # full options allowed:
77 77 #
78 78 # http://wiki.rubyonrails.org/rails/pages/HowToSendEmailsWithActionMailer
79 79
80 80
81 81 # default configuration options for all environments
82 82 default:
83 83 # Outgoing emails configuration (see examples above)
84 84 email_delivery:
85 85 delivery_method: :smtp
86 86 smtp_settings:
87 87 address: smtp.example.net
88 88 port: 25
89 89 domain: example.net
90 90 authentication: :login
91 91 user_name: "redmine@example.net"
92 92 password: "redmine"
93 93
94 94 # Absolute path to the directory where attachments are stored.
95 95 # The default is the 'files' directory in your Redmine instance.
96 96 # Your Redmine instance needs to have write permission on this
97 97 # directory.
98 98 # Examples:
99 99 # attachments_storage_path: /var/redmine/files
100 100 # attachments_storage_path: D:/redmine/files
101 101 attachments_storage_path:
102 102
103 103 # Configuration of the autologin cookie.
104 104 # autologin_cookie_name: the name of the cookie (default: autologin)
105 105 # autologin_cookie_path: the cookie path (default: /)
106 106 # autologin_cookie_secure: true sets the cookie secure flag (default: false)
107 107 autologin_cookie_name:
108 108 autologin_cookie_path:
109 109 autologin_cookie_secure:
110 110
111 111 # Configuration of SCM executable command.
112 112 #
113 113 # Absolute path (e.g. /usr/local/bin/hg) or command name (e.g. hg.exe, bzr.exe)
114 114 # On Windows + CRuby, *.cmd, *.bat (e.g. hg.cmd, bzr.bat) does not work.
115 115 #
116 116 # On Windows + JRuby 1.6.2, path which contains spaces does not work.
117 117 # For example, "C:\Program Files\TortoiseHg\hg.exe".
118 118 # If you want to this feature, you need to install to the path which does not contains spaces.
119 119 # For example, "C:\TortoiseHg\hg.exe".
120 120 #
121 121 # Examples:
122 122 # scm_subversion_command: svn # (default: svn)
123 123 # scm_mercurial_command: C:\Program Files\TortoiseHg\hg.exe # (default: hg)
124 124 # scm_git_command: /usr/local/bin/git # (default: git)
125 125 # scm_cvs_command: cvs # (default: cvs)
126 126 # scm_bazaar_command: bzr.exe # (default: bzr)
127 127 # scm_darcs_command: darcs-1.0.9-i386-linux # (default: darcs)
128 128 #
129 129 scm_subversion_command:
130 130 scm_mercurial_command:
131 131 scm_git_command:
132 132 scm_cvs_command:
133 133 scm_bazaar_command:
134 134 scm_darcs_command:
135 135
136 136 # Key used to encrypt sensitive data in the database (SCM and LDAP passwords).
137 137 # If you don't want to enable data encryption, just leave it blank.
138 138 # WARNING: losing/changing this key will make encrypted data unreadable.
139 139 #
140 140 # If you want to encrypt existing passwords in your database:
141 141 # * set the cipher key here in your configuration file
142 142 # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
143 143 #
144 144 # If you have encrypted data and want to change this key, you have to:
145 145 # * decrypt data using 'rake db:decrypt RAILS_ENV=production' first
146 146 # * change the cipher key here in your configuration file
147 147 # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
148 148 database_cipher_key:
149 149
150 150 # Set this to false to disable plugins' assets mirroring on startup.
151 151 # You can use `rake redmine:plugins:assets` to manually mirror assets
152 152 # to public/plugin_assets when you install/upgrade a Redmine plugin.
153 153 #
154 154 #mirror_plugins_assets_on_startup: false
155 155
156 156 # Your secret key for verifying cookie session data integrity. If you
157 157 # change this key, all old sessions will become invalid! Make sure the
158 158 # secret is at least 30 characters and all random, no regular words or
159 159 # you'll be exposed to dictionary attacks.
160 160 #
161 161 # If you have a load-balancing Redmine cluster, you have to use the
162 162 # same secret token on each machine.
163 163 #secret_token: 'change it to a long random string'
164 164
165 165 # Absolute path (e.g. /usr/bin/convert, c:/im/convert.exe) to
166 166 # the ImageMagick's `convert` binary. Used to generate attachment thumbnails.
167 167 #imagemagick_convert_command:
168 168
169 169 # Configuration of RMagcik font.
170 170 #
171 171 # Redmine uses RMagcik in order to export gantt png.
172 172 # You don't need this setting if you don't install RMagcik.
173 173 #
174 174 # In CJK (Chinese, Japanese and Korean),
175 175 # in order to show CJK characters correctly,
176 176 # you need to set this configuration.
177 177 #
178 178 # Because there is no standard font across platforms in CJK,
179 179 # you need to set a font installed in your server.
180 180 #
181 181 # This setting is not necessary in non CJK.
182 182 #
183 183 # Examples for Japanese:
184 184 # Windows:
185 185 # rmagick_font_path: C:\windows\fonts\msgothic.ttc
186 186 # Linux:
187 187 # rmagick_font_path: /usr/share/fonts/ipa-mincho/ipam.ttf
188 188 #
189 189 rmagick_font_path:
190 190
191 # Maximum number of simultaneous AJAX uploads
192 #max_concurrent_ajax_uploads: 2
193
191 194 # specific configuration options for production environment
192 195 # that overrides the default ones
193 196 production:
194 197
195 198 # specific configuration options for development environment
196 199 # that overrides the default ones
197 200 development:
@@ -1,113 +1,114
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 module Redmine
19 19 module Configuration
20 20
21 21 # Configuration default values
22 22 @defaults = {
23 'email_delivery' => nil
23 'email_delivery' => nil,
24 'max_concurrent_ajax_uploads' => 2
24 25 }
25 26
26 27 @config = nil
27 28
28 29 class << self
29 30 # Loads the Redmine configuration file
30 31 # Valid options:
31 32 # * <tt>:file</tt>: the configuration file to load (default: config/configuration.yml)
32 33 # * <tt>:env</tt>: the environment to load the configuration for (default: Rails.env)
33 34 def load(options={})
34 35 filename = options[:file] || File.join(Rails.root, 'config', 'configuration.yml')
35 36 env = options[:env] || Rails.env
36 37
37 38 @config = @defaults.dup
38 39
39 40 load_deprecated_email_configuration(env)
40 41 if File.file?(filename)
41 42 @config.merge!(load_from_yaml(filename, env))
42 43 end
43 44
44 45 # Compatibility mode for those who copy email.yml over configuration.yml
45 46 %w(delivery_method smtp_settings sendmail_settings).each do |key|
46 47 if value = @config.delete(key)
47 48 @config['email_delivery'] ||= {}
48 49 @config['email_delivery'][key] = value
49 50 end
50 51 end
51 52
52 53 if @config['email_delivery']
53 54 ActionMailer::Base.perform_deliveries = true
54 55 @config['email_delivery'].each do |k, v|
55 56 v.symbolize_keys! if v.respond_to?(:symbolize_keys!)
56 57 ActionMailer::Base.send("#{k}=", v)
57 58 end
58 59 end
59 60
60 61 @config
61 62 end
62 63
63 64 # Returns a configuration setting
64 65 def [](name)
65 66 load unless @config
66 67 @config[name]
67 68 end
68 69
69 70 # Yields a block with the specified hash configuration settings
70 71 def with(settings)
71 72 settings.stringify_keys!
72 73 load unless @config
73 74 was = settings.keys.inject({}) {|h,v| h[v] = @config[v]; h}
74 75 @config.merge! settings
75 76 yield if block_given?
76 77 @config.merge! was
77 78 end
78 79
79 80 private
80 81
81 82 def load_from_yaml(filename, env)
82 83 yaml = nil
83 84 begin
84 85 yaml = YAML::load_file(filename)
85 86 rescue ArgumentError
86 87 $stderr.puts "Your Redmine configuration file located at #{filename} is not a valid YAML file and could not be loaded."
87 88 exit 1
88 89 end
89 90 conf = {}
90 91 if yaml.is_a?(Hash)
91 92 if yaml['default']
92 93 conf.merge!(yaml['default'])
93 94 end
94 95 if yaml[env]
95 96 conf.merge!(yaml[env])
96 97 end
97 98 else
98 99 $stderr.puts "Your Redmine configuration file located at #{filename} is not a valid Redmine configuration file."
99 100 exit 1
100 101 end
101 102 conf
102 103 end
103 104
104 105 def load_deprecated_email_configuration(env)
105 106 deprecated_email_conf = File.join(Rails.root, 'config', 'email.yml')
106 107 if File.file?(deprecated_email_conf)
107 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 109 @config.merge!({'email_delivery' => load_from_yaml(deprecated_email_conf, env)})
109 110 end
110 111 end
111 112 end
112 113 end
113 114 end
@@ -1,611 +1,582
1 1 /* Redmine - project management software
2 2 Copyright (C) 2006-2012 Jean-Philippe Lang */
3 3
4 4 function checkAll(id, checked) {
5 5 if (checked) {
6 6 $('#'+id).find('input[type=checkbox]').attr('checked', true);
7 7 } else {
8 8 $('#'+id).find('input[type=checkbox]').removeAttr('checked');
9 9 }
10 10 }
11 11
12 12 function toggleCheckboxesBySelector(selector) {
13 13 var all_checked = true;
14 14 $(selector).each(function(index) {
15 15 if (!$(this).is(':checked')) { all_checked = false; }
16 16 });
17 17 $(selector).attr('checked', !all_checked)
18 18 }
19 19
20 20 function showAndScrollTo(id, focus) {
21 21 $('#'+id).show();
22 22 if (focus!=null) {
23 23 $('#'+focus).focus();
24 24 }
25 25 $('html, body').animate({scrollTop: $('#'+id).offset().top}, 100);
26 26 }
27 27
28 28 function toggleRowGroup(el) {
29 29 var tr = $(el).parents('tr').first();
30 30 var n = tr.next();
31 31 tr.toggleClass('open');
32 32 while (n.length && !n.hasClass('group')) {
33 33 n.toggle();
34 34 n = n.next('tr');
35 35 }
36 36 }
37 37
38 38 function collapseAllRowGroups(el) {
39 39 var tbody = $(el).parents('tbody').first();
40 40 tbody.children('tr').each(function(index) {
41 41 if ($(this).hasClass('group')) {
42 42 $(this).removeClass('open');
43 43 } else {
44 44 $(this).hide();
45 45 }
46 46 });
47 47 }
48 48
49 49 function expandAllRowGroups(el) {
50 50 var tbody = $(el).parents('tbody').first();
51 51 tbody.children('tr').each(function(index) {
52 52 if ($(this).hasClass('group')) {
53 53 $(this).addClass('open');
54 54 } else {
55 55 $(this).show();
56 56 }
57 57 });
58 58 }
59 59
60 60 function toggleAllRowGroups(el) {
61 61 var tr = $(el).parents('tr').first();
62 62 if (tr.hasClass('open')) {
63 63 collapseAllRowGroups(el);
64 64 } else {
65 65 expandAllRowGroups(el);
66 66 }
67 67 }
68 68
69 69 function toggleFieldset(el) {
70 70 var fieldset = $(el).parents('fieldset').first();
71 71 fieldset.toggleClass('collapsed');
72 72 fieldset.children('div').toggle();
73 73 }
74 74
75 75 function hideFieldset(el) {
76 76 var fieldset = $(el).parents('fieldset').first();
77 77 fieldset.toggleClass('collapsed');
78 78 fieldset.children('div').hide();
79 79 }
80 80
81 81 function initFilters(){
82 82 $('#add_filter_select').change(function(){
83 83 addFilter($(this).val(), '', []);
84 84 });
85 85 $('#filters-table td.field input[type=checkbox]').each(function(){
86 86 toggleFilter($(this).val());
87 87 });
88 88 $('#filters-table td.field input[type=checkbox]').live('click',function(){
89 89 toggleFilter($(this).val());
90 90 });
91 91 $('#filters-table .toggle-multiselect').live('click',function(){
92 92 toggleMultiSelect($(this).siblings('select'));
93 93 });
94 94 $('#filters-table input[type=text]').live('keypress', function(e){
95 95 if (e.keyCode == 13) submit_query_form("query_form");
96 96 });
97 97 }
98 98
99 99 function addFilter(field, operator, values) {
100 100 var fieldId = field.replace('.', '_');
101 101 var tr = $('#tr_'+fieldId);
102 102 if (tr.length > 0) {
103 103 tr.show();
104 104 } else {
105 105 buildFilterRow(field, operator, values);
106 106 }
107 107 $('#cb_'+fieldId).attr('checked', true);
108 108 toggleFilter(field);
109 109 $('#add_filter_select').val('').children('option').each(function(){
110 110 if ($(this).attr('value') == field) {
111 111 $(this).attr('disabled', true);
112 112 }
113 113 });
114 114 }
115 115
116 116 function buildFilterRow(field, operator, values) {
117 117 var fieldId = field.replace('.', '_');
118 118 var filterTable = $("#filters-table");
119 119 var filterOptions = availableFilters[field];
120 120 var operators = operatorByType[filterOptions['type']];
121 121 var filterValues = filterOptions['values'];
122 122 var i, select;
123 123
124 124 var tr = $('<tr class="filter">').attr('id', 'tr_'+fieldId).html(
125 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 126 '<td class="operator"><select id="operators_'+fieldId+'" name="op['+field+']"></td>' +
127 127 '<td class="values"></td>'
128 128 );
129 129 filterTable.append(tr);
130 130
131 131 select = tr.find('td.operator select');
132 132 for (i=0;i<operators.length;i++){
133 133 var option = $('<option>').val(operators[i]).text(operatorLabels[operators[i]]);
134 134 if (operators[i] == operator) {option.attr('selected', true)};
135 135 select.append(option);
136 136 }
137 137 select.change(function(){toggleOperator(field)});
138 138
139 139 switch (filterOptions['type']){
140 140 case "list":
141 141 case "list_optional":
142 142 case "list_status":
143 143 case "list_subprojects":
144 144 tr.find('td.values').append(
145 145 '<span style="display:none;"><select class="value" id="values_'+fieldId+'_1" name="v['+field+'][]"></select>' +
146 146 ' <span class="toggle-multiselect">&nbsp;</span></span>'
147 147 );
148 148 select = tr.find('td.values select');
149 149 if (values.length > 1) {select.attr('multiple', true)};
150 150 for (i=0;i<filterValues.length;i++){
151 151 var filterValue = filterValues[i];
152 152 var option = $('<option>');
153 153 if ($.isArray(filterValue)) {
154 154 option.val(filterValue[1]).text(filterValue[0]);
155 155 if ($.inArray(filterValue[1], values) > -1) {option.attr('selected', true);}
156 156 } else {
157 157 option.val(filterValue).text(filterValue);
158 158 if ($.inArray(filterValue, values) > -1) {option.attr('selected', true);}
159 159 }
160 160 select.append(option);
161 161 }
162 162 break;
163 163 case "date":
164 164 case "date_past":
165 165 tr.find('td.values').append(
166 166 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="10" class="value date_value" /></span>' +
167 167 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="10" class="value date_value" /></span>' +
168 168 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="3" class="value" /> '+labelDayPlural+'</span>'
169 169 );
170 170 $('#values_'+fieldId+'_1').val(values[0]).datepicker(datepickerOptions);
171 171 $('#values_'+fieldId+'_2').val(values[1]).datepicker(datepickerOptions);
172 172 $('#values_'+fieldId).val(values[0]);
173 173 break;
174 174 case "string":
175 175 case "text":
176 176 tr.find('td.values').append(
177 177 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="30" class="value" /></span>'
178 178 );
179 179 $('#values_'+fieldId).val(values[0]);
180 180 break;
181 181 case "relation":
182 182 tr.find('td.values').append(
183 183 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="6" class="value" /></span>' +
184 184 '<span style="display:none;"><select class="value" name="v['+field+'][]" id="values_'+fieldId+'_1"></select></span>'
185 185 );
186 186 $('#values_'+fieldId).val(values[0]);
187 187 select = tr.find('td.values select');
188 188 for (i=0;i<allProjects.length;i++){
189 189 var filterValue = allProjects[i];
190 190 var option = $('<option>');
191 191 option.val(filterValue[1]).text(filterValue[0]);
192 192 if (values[0] == filterValue[1]) {option.attr('selected', true)};
193 193 select.append(option);
194 194 }
195 195 case "integer":
196 196 case "float":
197 197 tr.find('td.values').append(
198 198 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="6" class="value" /></span>' +
199 199 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="6" class="value" /></span>'
200 200 );
201 201 $('#values_'+fieldId+'_1').val(values[0]);
202 202 $('#values_'+fieldId+'_2').val(values[1]);
203 203 break;
204 204 }
205 205 }
206 206
207 207 function toggleFilter(field) {
208 208 var fieldId = field.replace('.', '_');
209 209 if ($('#cb_' + fieldId).is(':checked')) {
210 210 $("#operators_" + fieldId).show().removeAttr('disabled');
211 211 toggleOperator(field);
212 212 } else {
213 213 $("#operators_" + fieldId).hide().attr('disabled', true);
214 214 enableValues(field, []);
215 215 }
216 216 }
217 217
218 218 function enableValues(field, indexes) {
219 219 var fieldId = field.replace('.', '_');
220 220 $('#tr_'+fieldId+' td.values .value').each(function(index) {
221 221 if ($.inArray(index, indexes) >= 0) {
222 222 $(this).removeAttr('disabled');
223 223 $(this).parents('span').first().show();
224 224 } else {
225 225 $(this).val('');
226 226 $(this).attr('disabled', true);
227 227 $(this).parents('span').first().hide();
228 228 }
229 229
230 230 if ($(this).hasClass('group')) {
231 231 $(this).addClass('open');
232 232 } else {
233 233 $(this).show();
234 234 }
235 235 });
236 236 }
237 237
238 238 function toggleOperator(field) {
239 239 var fieldId = field.replace('.', '_');
240 240 var operator = $("#operators_" + fieldId);
241 241 switch (operator.val()) {
242 242 case "!*":
243 243 case "*":
244 244 case "t":
245 245 case "ld":
246 246 case "w":
247 247 case "lw":
248 248 case "l2w":
249 249 case "m":
250 250 case "lm":
251 251 case "y":
252 252 case "o":
253 253 case "c":
254 254 enableValues(field, []);
255 255 break;
256 256 case "><":
257 257 enableValues(field, [0,1]);
258 258 break;
259 259 case "<t+":
260 260 case ">t+":
261 261 case "><t+":
262 262 case "t+":
263 263 case ">t-":
264 264 case "<t-":
265 265 case "><t-":
266 266 case "t-":
267 267 enableValues(field, [2]);
268 268 break;
269 269 case "=p":
270 270 case "=!p":
271 271 case "!p":
272 272 enableValues(field, [1]);
273 273 break;
274 274 default:
275 275 enableValues(field, [0]);
276 276 break;
277 277 }
278 278 }
279 279
280 280 function toggleMultiSelect(el) {
281 281 if (el.attr('multiple')) {
282 282 el.removeAttr('multiple');
283 283 } else {
284 284 el.attr('multiple', true);
285 285 }
286 286 }
287 287
288 288 function submit_query_form(id) {
289 289 selectAllOptions("selected_columns");
290 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 293 function showTab(name) {
328 294 $('div#content .tab-content').hide();
329 295 $('div.tabs a').removeClass('selected');
330 296 $('#tab-content-' + name).show();
331 297 $('#tab-' + name).addClass('selected');
332 298 return false;
333 299 }
334 300
335 301 function moveTabRight(el) {
336 302 var lis = $(el).parents('div.tabs').first().find('ul').children();
337 303 var tabsWidth = 0;
338 304 var i = 0;
339 305 lis.each(function(){
340 306 if ($(this).is(':visible')) {
341 307 tabsWidth += $(this).width() + 6;
342 308 }
343 309 });
344 310 if (tabsWidth < $(el).parents('div.tabs').first().width() - 60) { return; }
345 311 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
346 312 lis.eq(i).hide();
347 313 }
348 314
349 315 function moveTabLeft(el) {
350 316 var lis = $(el).parents('div.tabs').first().find('ul').children();
351 317 var i = 0;
352 318 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
353 319 if (i>0) {
354 320 lis.eq(i-1).show();
355 321 }
356 322 }
357 323
358 324 function displayTabsButtons() {
359 325 var lis;
360 326 var tabsWidth = 0;
361 327 var el;
362 328 $('div.tabs').each(function() {
363 329 el = $(this);
364 330 lis = el.find('ul').children();
365 331 lis.each(function(){
366 332 if ($(this).is(':visible')) {
367 333 tabsWidth += $(this).width() + 6;
368 334 }
369 335 });
370 336 if ((tabsWidth < el.width() - 60) && (lis.first().is(':visible'))) {
371 337 el.find('div.tabs-buttons').hide();
372 338 } else {
373 339 el.find('div.tabs-buttons').show();
374 340 }
375 341 });
376 342 }
377 343
378 344 function setPredecessorFieldsVisibility() {
379 345 var relationType = $('#relation_relation_type');
380 346 if (relationType.val() == "precedes" || relationType.val() == "follows") {
381 347 $('#predecessor_fields').show();
382 348 } else {
383 349 $('#predecessor_fields').hide();
384 350 }
385 351 }
386 352
387 353 function showModal(id, width) {
388 354 var el = $('#'+id).first();
389 355 if (el.length == 0 || el.is(':visible')) {return;}
390 356 var title = el.find('h3.title').text();
391 357 el.dialog({
392 358 width: width,
393 359 modal: true,
394 360 resizable: false,
395 361 dialogClass: 'modal',
396 362 title: title
397 363 });
398 364 el.find("input[type=text], input[type=submit]").first().focus();
399 365 }
400 366
401 367 function hideModal(el) {
402 368 var modal;
403 369 if (el) {
404 370 modal = $(el).parents('.ui-dialog-content');
405 371 } else {
406 372 modal = $('#ajax-modal');
407 373 }
408 374 modal.dialog("close");
409 375 }
410 376
411 377 function submitPreview(url, form, target) {
412 378 $.ajax({
413 379 url: url,
414 380 type: 'post',
415 381 data: $('#'+form).serialize(),
416 382 success: function(data){
417 383 $('#'+target).html(data);
418 384 }
419 385 });
420 386 }
421 387
422 388 function collapseScmEntry(id) {
423 389 $('.'+id).each(function() {
424 390 if ($(this).hasClass('open')) {
425 391 collapseScmEntry($(this).attr('id'));
426 392 }
427 393 $(this).hide();
428 394 });
429 395 $('#'+id).removeClass('open');
430 396 }
431 397
432 398 function expandScmEntry(id) {
433 399 $('.'+id).each(function() {
434 400 $(this).show();
435 401 if ($(this).hasClass('loaded') && !$(this).hasClass('collapsed')) {
436 402 expandScmEntry($(this).attr('id'));
437 403 }
438 404 });
439 405 $('#'+id).addClass('open');
440 406 }
441 407
442 408 function scmEntryClick(id, url) {
443 409 el = $('#'+id);
444 410 if (el.hasClass('open')) {
445 411 collapseScmEntry(id);
446 412 el.addClass('collapsed');
447 413 return false;
448 414 } else if (el.hasClass('loaded')) {
449 415 expandScmEntry(id);
450 416 el.removeClass('collapsed');
451 417 return false;
452 418 }
453 419 if (el.hasClass('loading')) {
454 420 return false;
455 421 }
456 422 el.addClass('loading');
457 423 $.ajax({
458 424 url: url,
459 425 success: function(data){
460 426 el.after(data);
461 427 el.addClass('open').addClass('loaded').removeClass('loading');
462 428 }
463 429 });
464 430 return true;
465 431 }
466 432
467 433 function randomKey(size) {
468 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 435 var key = '';
470 436 for (i = 0; i < size; i++) {
471 437 key += chars[Math.floor(Math.random() * chars.length)];
472 438 }
473 439 return key;
474 440 }
475 441
476 442 // Can't use Rails' remote select because we need the form data
477 443 function updateIssueFrom(url) {
478 444 $.ajax({
479 445 url: url,
480 446 type: 'post',
481 447 data: $('#issue-form').serialize()
482 448 });
483 449 }
484 450
485 451 function updateBulkEditFrom(url) {
486 452 $.ajax({
487 453 url: url,
488 454 type: 'post',
489 455 data: $('#bulk_edit_form').serialize()
490 456 });
491 457 }
492 458
493 459 function observeAutocompleteField(fieldId, url) {
494 460 $(document).ready(function() {
495 461 $('#'+fieldId).autocomplete({
496 462 source: url,
497 463 minLength: 2
498 464 });
499 465 });
500 466 }
501 467
502 468 function observeSearchfield(fieldId, targetId, url) {
503 469 $('#'+fieldId).each(function() {
504 470 var $this = $(this);
505 471 $this.attr('data-value-was', $this.val());
506 472 var check = function() {
507 473 var val = $this.val();
508 474 if ($this.attr('data-value-was') != val){
509 475 $this.attr('data-value-was', val);
510 476 $.ajax({
511 477 url: url,
512 478 type: 'get',
513 479 data: {q: $this.val()},
514 480 success: function(data){ $('#'+targetId).html(data); },
515 481 beforeSend: function(){ $this.addClass('ajax-loading'); },
516 482 complete: function(){ $this.removeClass('ajax-loading'); }
517 483 });
518 484 }
519 485 };
520 486 var reset = function() {
521 487 if (timer) {
522 488 clearInterval(timer);
523 489 timer = setInterval(check, 300);
524 490 }
525 491 };
526 492 var timer = setInterval(check, 300);
527 493 $this.bind('keyup click mousemove', reset);
528 494 });
529 495 }
530 496
531 497 function observeProjectModules() {
532 498 var f = function() {
533 499 /* Hides trackers and issues custom fields on the new project form when issue_tracking module is disabled */
534 500 if ($('#project_enabled_module_names_issue_tracking').attr('checked')) {
535 501 $('#project_trackers').show();
536 502 }else{
537 503 $('#project_trackers').hide();
538 504 }
539 505 };
540 506
541 507 $(window).load(f);
542 508 $('#project_enabled_module_names_issue_tracking').change(f);
543 509 }
544 510
545 511 function initMyPageSortable(list, url) {
546 512 $('#list-'+list).sortable({
547 513 connectWith: '.block-receiver',
548 514 tolerance: 'pointer',
549 515 update: function(){
550 516 $.ajax({
551 517 url: url,
552 518 type: 'post',
553 519 data: {'blocks': $.map($('#list-'+list).children(), function(el){return $(el).attr('id');})}
554 520 });
555 521 }
556 522 });
557 523 $("#list-top, #list-left, #list-right").disableSelection();
558 524 }
559 525
560 526 var warnLeavingUnsavedMessage;
561 527 function warnLeavingUnsaved(message) {
562 528 warnLeavingUnsavedMessage = message;
563 529
564 530 $('form').submit(function(){
565 531 $('textarea').removeData('changed');
566 532 });
567 533 $('textarea').change(function(){
568 534 $(this).data('changed', 'changed');
569 535 });
570 536 window.onbeforeunload = function(){
571 537 var warn = false;
572 538 $('textarea').blur().each(function(){
573 539 if ($(this).data('changed')) {
574 540 warn = true;
575 541 }
576 542 });
577 543 if (warn) {return warnLeavingUnsavedMessage;}
578 544 };
579 545 };
580 546
581 547 $(document).ready(function(){
582 $('#ajax-indicator').bind('ajaxSend', function(){
583 if ($('.ajax-loading').length == 0) {
548 $('#ajax-indicator').bind('ajaxSend', function(event, xhr, settings){
549 if ($('.ajax-loading').length == 0 && settings.contentType != 'application/octet-stream') {
584 550 $('#ajax-indicator').show();
585 551 }
586 552 });
587 553 $('#ajax-indicator').bind('ajaxStop', function(){
588 554 $('#ajax-indicator').hide();
589 555 });
590 556 });
591 557
592 558 function hideOnLoad() {
593 559 $('.hol').hide();
594 560 }
595 561
596 562 function addFormObserversForDoubleSubmit() {
597 563 $('form[method=post]').each(function() {
598 564 if (!$(this).hasClass('multiple-submit')) {
599 565 $(this).submit(function(form_submission) {
600 566 if ($(form_submission.target).attr('data-submitted')) {
601 567 form_submission.preventDefault();
602 568 } else {
603 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 581 $(document).ready(hideOnLoad);
611 582 $(document).ready(addFormObserversForDoubleSubmit);
@@ -1,1140 +1,1147
1 1 html {overflow-y:scroll;}
2 2 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
3 3
4 4 h1, h2, h3, h4 {font-family: "Trebuchet MS", Verdana, sans-serif;padding: 2px 10px 1px 0px;margin: 0 0 10px 0;}
5 5 #content h1, h2, h3, h4 {color: #555;}
6 6 h2, .wiki h1 {font-size: 20px;}
7 7 h3, .wiki h2 {font-size: 16px;}
8 8 h4, .wiki h3 {font-size: 13px;}
9 9 h4 {border-bottom: 1px dotted #bbb;}
10 10
11 11 /***** Layout *****/
12 12 #wrapper {background: white;}
13 13
14 14 #top-menu {background: #3E5B76; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
15 15 #top-menu ul {margin: 0; padding: 0;}
16 16 #top-menu li {
17 17 float:left;
18 18 list-style-type:none;
19 19 margin: 0px 0px 0px 0px;
20 20 padding: 0px 0px 0px 0px;
21 21 white-space:nowrap;
22 22 }
23 23 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
24 24 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
25 25
26 26 #account {float:right;}
27 27
28 28 #header {height:5.3em;margin:0;background-color:#628DB6;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
29 29 #header a {color:#f8f8f8;}
30 30 #header h1 a.ancestor { font-size: 80%; }
31 31 #quick-search {float:right;}
32 32
33 33 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
34 34 #main-menu ul {margin: 0; padding: 0;}
35 35 #main-menu li {
36 36 float:left;
37 37 list-style-type:none;
38 38 margin: 0px 2px 0px 0px;
39 39 padding: 0px 0px 0px 0px;
40 40 white-space:nowrap;
41 41 }
42 42 #main-menu li a {
43 43 display: block;
44 44 color: #fff;
45 45 text-decoration: none;
46 46 font-weight: bold;
47 47 margin: 0;
48 48 padding: 4px 10px 4px 10px;
49 49 }
50 50 #main-menu li a:hover {background:#759FCF; color:#fff;}
51 51 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
52 52
53 53 #admin-menu ul {margin: 0; padding: 0;}
54 54 #admin-menu li {margin: 0; padding: 0 0 6px 0; list-style-type:none;}
55 55
56 56 #admin-menu a { background-position: 0% 40%; background-repeat: no-repeat; padding-left: 20px; padding-top: 2px; padding-bottom: 3px;}
57 57 #admin-menu a.projects { background-image: url(../images/projects.png); }
58 58 #admin-menu a.users { background-image: url(../images/user.png); }
59 59 #admin-menu a.groups { background-image: url(../images/group.png); }
60 60 #admin-menu a.roles { background-image: url(../images/database_key.png); }
61 61 #admin-menu a.trackers { background-image: url(../images/ticket.png); }
62 62 #admin-menu a.issue_statuses { background-image: url(../images/ticket_edit.png); }
63 63 #admin-menu a.workflows { background-image: url(../images/ticket_go.png); }
64 64 #admin-menu a.custom_fields { background-image: url(../images/textfield.png); }
65 65 #admin-menu a.enumerations { background-image: url(../images/text_list_bullets.png); }
66 66 #admin-menu a.settings { background-image: url(../images/changeset.png); }
67 67 #admin-menu a.plugins { background-image: url(../images/plugin.png); }
68 68 #admin-menu a.info { background-image: url(../images/help.png); }
69 69 #admin-menu a.server_authentication { background-image: url(../images/server_key.png); }
70 70
71 71 #main {background-color:#EEEEEE;}
72 72
73 73 #sidebar{ float: right; width: 22%; position: relative; z-index: 9; padding: 0; margin: 0;}
74 74 * html #sidebar{ width: 22%; }
75 75 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
76 76 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
77 77 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
78 78 #sidebar .contextual { margin-right: 1em; }
79 79
80 80 #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
81 81 * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
82 82 html>body #content { min-height: 600px; }
83 83 * html body #content { height: 600px; } /* IE */
84 84
85 85 #main.nosidebar #sidebar{ display: none; }
86 86 #main.nosidebar #content{ width: auto; border-right: 0; }
87 87
88 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 90 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
91 91 #login-form table td {padding: 6px;}
92 92 #login-form label {font-weight: bold;}
93 93 #login-form input#username, #login-form input#password { width: 300px; }
94 94
95 95 div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
96 96 div.modal h3.title {display:none;}
97 97 div.modal p.buttons {text-align:right; margin-bottom:0;}
98 98
99 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 101 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
102 102
103 103 /***** Links *****/
104 104 a, a:link, a:visited{ color: #169; text-decoration: none; }
105 105 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
106 106 a img{ border: 0; }
107 107
108 108 a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; }
109 109 a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }
110 110 a.user.locked, a.user.locked:link, a.user.locked:visited {color: #999;}
111 111
112 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 113 #sidebar a.selected:hover {text-decoration:none;}
114 114 #admin-menu a {line-height:1.7em;}
115 115 #admin-menu a.selected {padding-left: 20px !important; background-position: 2px 40%;}
116 116
117 117 a.collapsible {padding-left: 12px; background: url(../images/arrow_expanded.png) no-repeat -3px 40%;}
118 118 a.collapsible.collapsed {background: url(../images/arrow_collapsed.png) no-repeat -5px 40%;}
119 119
120 120 a#toggle-completed-versions {color:#999;}
121 121 /***** Tables *****/
122 122 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
123 123 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
124 124 table.list td { vertical-align: top; padding-right:10px; }
125 125 table.list td.id { width: 2%; text-align: center;}
126 126 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
127 127 table.list td.checkbox input {padding:0px;}
128 128 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
129 129 table.list td.buttons a { padding-right: 0.6em; }
130 130 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
131 131
132 132 tr.project td.name a { white-space:nowrap; }
133 133 tr.project.closed, tr.project.archived { color: #aaa; }
134 134 tr.project.closed a, tr.project.archived a { color: #aaa; }
135 135
136 136 tr.project.idnt td.name span {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
137 137 tr.project.idnt-1 td.name {padding-left: 0.5em;}
138 138 tr.project.idnt-2 td.name {padding-left: 2em;}
139 139 tr.project.idnt-3 td.name {padding-left: 3.5em;}
140 140 tr.project.idnt-4 td.name {padding-left: 5em;}
141 141 tr.project.idnt-5 td.name {padding-left: 6.5em;}
142 142 tr.project.idnt-6 td.name {padding-left: 8em;}
143 143 tr.project.idnt-7 td.name {padding-left: 9.5em;}
144 144 tr.project.idnt-8 td.name {padding-left: 11em;}
145 145 tr.project.idnt-9 td.name {padding-left: 12.5em;}
146 146
147 147 tr.issue { text-align: center; white-space: nowrap; }
148 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 149 tr.issue td.subject, tr.issue td.relations { text-align: left; }
150 150 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
151 151 tr.issue td.relations span {white-space: nowrap;}
152 152 table.issues td.description {color:#777; font-size:90%; padding:4px 4px 4px 24px; text-align:left; white-space:normal;}
153 153 table.issues td.description pre {white-space:normal;}
154 154
155 155 tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
156 156 tr.issue.idnt-1 td.subject {padding-left: 0.5em;}
157 157 tr.issue.idnt-2 td.subject {padding-left: 2em;}
158 158 tr.issue.idnt-3 td.subject {padding-left: 3.5em;}
159 159 tr.issue.idnt-4 td.subject {padding-left: 5em;}
160 160 tr.issue.idnt-5 td.subject {padding-left: 6.5em;}
161 161 tr.issue.idnt-6 td.subject {padding-left: 8em;}
162 162 tr.issue.idnt-7 td.subject {padding-left: 9.5em;}
163 163 tr.issue.idnt-8 td.subject {padding-left: 11em;}
164 164 tr.issue.idnt-9 td.subject {padding-left: 12.5em;}
165 165
166 166 tr.entry { border: 1px solid #f8f8f8; }
167 167 tr.entry td { white-space: nowrap; }
168 168 tr.entry td.filename { width: 30%; }
169 169 tr.entry td.filename_no_report { width: 70%; }
170 170 tr.entry td.size { text-align: right; font-size: 90%; }
171 171 tr.entry td.revision, tr.entry td.author { text-align: center; }
172 172 tr.entry td.age { text-align: right; }
173 173 tr.entry.file td.filename a { margin-left: 16px; }
174 174 tr.entry.file td.filename_no_report a { margin-left: 16px; }
175 175
176 176 tr span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
177 177 tr.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
178 178
179 179 tr.changeset { height: 20px }
180 180 tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; }
181 181 tr.changeset td.revision_graph { width: 15%; background-color: #fffffb; }
182 182 tr.changeset td.author { text-align: center; width: 15%; white-space:nowrap;}
183 183 tr.changeset td.committed_on { text-align: center; width: 15%; white-space:nowrap;}
184 184
185 185 table.files tr.file td { text-align: center; }
186 186 table.files tr.file td.filename { text-align: left; padding-left: 24px; }
187 187 table.files tr.file td.digest { font-size: 80%; }
188 188
189 189 table.members td.roles, table.memberships td.roles { width: 45%; }
190 190
191 191 tr.message { height: 2.6em; }
192 192 tr.message td.subject { padding-left: 20px; }
193 193 tr.message td.created_on { white-space: nowrap; }
194 194 tr.message td.last_message { font-size: 80%; white-space: nowrap; }
195 195 tr.message.locked td.subject { background: url(../images/locked.png) no-repeat 0 1px; }
196 196 tr.message.sticky td.subject { background: url(../images/bullet_go.png) no-repeat 0 1px; font-weight: bold; }
197 197
198 198 tr.version.closed, tr.version.closed a { color: #999; }
199 199 tr.version td.name { padding-left: 20px; }
200 200 tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%; }
201 201 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
202 202
203 203 tr.user td { width:13%; }
204 204 tr.user td.email { width:18%; }
205 205 tr.user td { white-space: nowrap; }
206 206 tr.user.locked, tr.user.registered { color: #aaa; }
207 207 tr.user.locked a, tr.user.registered a { color: #aaa; }
208 208
209 209 table.permissions td.role {color:#999;font-size:90%;font-weight:normal !important;text-align:center;vertical-align:bottom;}
210 210
211 211 tr.wiki-page-version td.updated_on, tr.wiki-page-version td.author {text-align:center;}
212 212
213 213 tr.time-entry { text-align: center; white-space: nowrap; }
214 214 tr.time-entry td.issue, tr.time-entry td.comments { text-align: left; white-space: normal; }
215 215 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
216 216 td.hours .hours-dec { font-size: 0.9em; }
217 217
218 218 table.plugins td { vertical-align: middle; }
219 219 table.plugins td.configure { text-align: right; padding-right: 1em; }
220 220 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
221 221 table.plugins span.description { display: block; font-size: 0.9em; }
222 222 table.plugins span.url { display: block; font-size: 0.9em; }
223 223
224 224 table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; }
225 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 226 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
227 227 tr.group:hover a.toggle-all { display:inline;}
228 228 a.toggle-all:hover {text-decoration:none;}
229 229
230 230 table.list tbody tr:hover { background-color:#ffffdd; }
231 231 table.list tbody tr.group:hover { background-color:inherit; }
232 232 table td {padding:2px;}
233 233 table p {margin:0;}
234 234 .odd {background-color:#f6f7f8;}
235 235 .even {background-color: #fff;}
236 236
237 237 a.sort { padding-right: 16px; background-position: 100% 50%; background-repeat: no-repeat; }
238 238 a.sort.asc { background-image: url(../images/sort_asc.png); }
239 239 a.sort.desc { background-image: url(../images/sort_desc.png); }
240 240
241 241 table.attributes { width: 100% }
242 242 table.attributes th { vertical-align: top; text-align: left; }
243 243 table.attributes td { vertical-align: top; }
244 244
245 245 table.boards a.board, h3.comments { background: url(../images/comment.png) no-repeat 0% 50%; padding-left: 20px; }
246 246 table.boards td.topic-count, table.boards td.message-count {text-align:center;}
247 247 table.boards td.last-message {font-size:80%;}
248 248
249 249 table.messages td.author, table.messages td.created_on, table.messages td.reply-count {text-align:center;}
250 250
251 251 table.query-columns {
252 252 border-collapse: collapse;
253 253 border: 0;
254 254 }
255 255
256 256 table.query-columns td.buttons {
257 257 vertical-align: middle;
258 258 text-align: center;
259 259 }
260 260
261 261 td.center {text-align:center;}
262 262
263 263 h3.version { background: url(../images/package.png) no-repeat 0% 50%; padding-left: 20px; }
264 264
265 265 div.issues h3 { background: url(../images/ticket.png) no-repeat 0% 50%; padding-left: 20px; }
266 266 div.members h3 { background: url(../images/group.png) no-repeat 0% 50%; padding-left: 20px; }
267 267 div.news h3 { background: url(../images/news.png) no-repeat 0% 50%; padding-left: 20px; }
268 268 div.projects h3 { background: url(../images/projects.png) no-repeat 0% 50%; padding-left: 20px; }
269 269
270 270 #watchers ul {margin: 0; padding: 0;}
271 271 #watchers li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
272 272 #watchers select {width: 95%; display: block;}
273 273 #watchers a.delete {opacity: 0.4;}
274 274 #watchers a.delete:hover {opacity: 1;}
275 275 #watchers img.gravatar {margin: 0 4px 2px 0;}
276 276
277 277 span#watchers_inputs {overflow:auto; display:block;}
278 278 span.search_for_watchers {display:block;}
279 279 span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
280 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 283 .highlight { background-color: #FCFD8D;}
284 284 .highlight.token-1 { background-color: #faa;}
285 285 .highlight.token-2 { background-color: #afa;}
286 286 .highlight.token-3 { background-color: #aaf;}
287 287
288 288 .box{
289 289 padding:6px;
290 290 margin-bottom: 10px;
291 291 background-color:#f6f6f6;
292 292 color:#505050;
293 293 line-height:1.5em;
294 294 border: 1px solid #e4e4e4;
295 295 }
296 296
297 297 div.square {
298 298 border: 1px solid #999;
299 299 float: left;
300 300 margin: .3em .4em 0 .4em;
301 301 overflow: hidden;
302 302 width: .6em; height: .6em;
303 303 }
304 304 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
305 305 .contextual input, .contextual select {font-size:0.9em;}
306 306 .message .contextual { margin-top: 0; }
307 307
308 308 .splitcontent {overflow:auto;}
309 309 .splitcontentleft{float:left; width:49%;}
310 310 .splitcontentright{float:right; width:49%;}
311 311 form {display: inline;}
312 312 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
313 313 fieldset {border: 1px solid #e4e4e4; margin:0;}
314 314 legend {color: #484848;}
315 315 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
316 316 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
317 317 blockquote blockquote { margin-left: 0;}
318 318 acronym { border-bottom: 1px dotted; cursor: help; }
319 319 textarea.wiki-edit {width:99%; resize:vertical;}
320 320 li p {margin-top: 0;}
321 321 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
322 322 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
323 323 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
324 324 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
325 325
326 326 div.issue div.subject div div { padding-left: 16px; }
327 327 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
328 328 div.issue div.subject>div>p { margin-top: 0.5em; }
329 329 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
330 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 331 div.issue .next-prev-links {color:#999;}
332 332 div.issue table.attributes th {width:22%;}
333 333 div.issue table.attributes td {width:28%;}
334 334
335 335 #issue_tree table.issues, #relations table.issues { border: 0; }
336 336 #issue_tree td.checkbox, #relations td.checkbox {display:none;}
337 337 #relations td.buttons {padding:0;}
338 338
339 339 fieldset.collapsible { border-width: 1px 0 0 0; font-size: 0.9em; }
340 340 fieldset.collapsible legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
341 341 fieldset.collapsible.collapsed legend { background-image: url(../images/arrow_collapsed.png); }
342 342
343 343 fieldset#date-range p { margin: 2px 0 2px 0; }
344 344 fieldset#filters table { border-collapse: collapse; }
345 345 fieldset#filters table td { padding: 0; vertical-align: middle; }
346 346 fieldset#filters tr.filter { height: 2.1em; }
347 347 fieldset#filters td.field { width:230px; }
348 348 fieldset#filters td.operator { width:180px; }
349 349 fieldset#filters td.operator select {max-width:170px;}
350 350 fieldset#filters td.values { white-space:nowrap; }
351 351 fieldset#filters td.values select {min-width:130px;}
352 352 fieldset#filters td.values input {height:1em;}
353 353 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
354 354
355 355 .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;}
356 356 .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; }
357 357
358 358 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
359 359 div#issue-changesets div.changeset { padding: 4px;}
360 360 div#issue-changesets div.changeset { border-bottom: 1px solid #ddd; }
361 361 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
362 362
363 363 .journal ul.details img {margin:0 0 -3px 4px;}
364 364 div.journal {overflow:auto;}
365 365 div.journal.private-notes {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
366 366
367 367 div#activity dl, #search-results { margin-left: 2em; }
368 368 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
369 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 370 div#activity dt.me .time { border-bottom: 1px solid #999; }
371 371 div#activity dt .time { color: #777; font-size: 80%; }
372 372 div#activity dd .description, #search-results dd .description { font-style: italic; }
373 373 div#activity span.project:after, #search-results span.project:after { content: " -"; }
374 374 div#activity dd span.description, #search-results dd span.description { display:block; color: #808080; }
375 375 div#activity dt.grouped {margin-left:5em;}
376 376 div#activity dd.grouped {margin-left:9em;}
377 377
378 378 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
379 379
380 380 div#search-results-counts {float:right;}
381 381 div#search-results-counts ul { margin-top: 0.5em; }
382 382 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
383 383
384 384 dt.issue { background-image: url(../images/ticket.png); }
385 385 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
386 386 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
387 387 dt.issue-note { background-image: url(../images/ticket_note.png); }
388 388 dt.changeset { background-image: url(../images/changeset.png); }
389 389 dt.news { background-image: url(../images/news.png); }
390 390 dt.message { background-image: url(../images/message.png); }
391 391 dt.reply { background-image: url(../images/comments.png); }
392 392 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
393 393 dt.attachment { background-image: url(../images/attachment.png); }
394 394 dt.document { background-image: url(../images/document.png); }
395 395 dt.project { background-image: url(../images/projects.png); }
396 396 dt.time-entry { background-image: url(../images/time.png); }
397 397
398 398 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
399 399
400 400 div#roadmap .related-issues { margin-bottom: 1em; }
401 401 div#roadmap .related-issues td.checkbox { display: none; }
402 402 div#roadmap .wiki h1:first-child { display: none; }
403 403 div#roadmap .wiki h1 { font-size: 120%; }
404 404 div#roadmap .wiki h2 { font-size: 110%; }
405 405 body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
406 406
407 407 div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
408 408 div#version-summary fieldset { margin-bottom: 1em; }
409 409 div#version-summary fieldset.time-tracking table { width:100%; }
410 410 div#version-summary th, div#version-summary td.total-hours { text-align: right; }
411 411
412 412 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
413 413 table#time-report tbody tr.subtotal { font-style: italic; color:#777;}
414 414 table#time-report tbody tr.subtotal td.hours { color:#b0b0b0; }
415 415 table#time-report tbody tr.total { font-weight: bold; background-color:#EEEEEE; border-top:1px solid #e4e4e4;}
416 416 table#time-report .hours-dec { font-size: 0.9em; }
417 417
418 418 div.wiki-page .contextual a {opacity: 0.4}
419 419 div.wiki-page .contextual a:hover {opacity: 1}
420 420
421 421 form .attributes select { width: 60%; }
422 422 input#issue_subject { width: 99%; }
423 423 select#issue_done_ratio { width: 95px; }
424 424
425 425 ul.projects {margin:0; padding-left:1em;}
426 426 ul.projects ul {padding-left:1.6em;}
427 427 ul.projects.root {margin:0; padding:0;}
428 428 ul.projects li {list-style-type:none;}
429 429
430 430 #projects-index ul.projects ul.projects { border-left: 3px solid #e0e0e0; padding-left:1em;}
431 431 #projects-index ul.projects li.root {margin-bottom: 1em;}
432 432 #projects-index ul.projects li.child {margin-top: 1em;}
433 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 434 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
435 435
436 436 #notified-projects ul, #tracker_project_ids ul {max-height:250px; overflow-y:auto;}
437 437
438 438 #related-issues li img {vertical-align:middle;}
439 439
440 440 ul.properties {padding:0; font-size: 0.9em; color: #777;}
441 441 ul.properties li {list-style-type:none;}
442 442 ul.properties li span {font-style:italic;}
443 443
444 444 .total-hours { font-size: 110%; font-weight: bold; }
445 445 .total-hours span.hours-int { font-size: 120%; }
446 446
447 447 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
448 448 #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select, #user_identity_url { width: 90%; }
449 449
450 450 #workflow_copy_form select { width: 200px; }
451 451 table.transitions td.enabled {background: #bfb;}
452 452 table.fields_permissions select {font-size:90%}
453 453 table.fields_permissions td.readonly {background:#ddd;}
454 454 table.fields_permissions td.required {background:#d88;}
455 455
456 456 textarea#custom_field_possible_values {width: 99%}
457 457 input#content_comments {width: 99%}
458 458
459 459 .pagination {font-size: 90%}
460 460 p.pagination {margin-top:8px;}
461 461
462 462 /***** Tabular forms ******/
463 463 .tabular p{
464 464 margin: 0;
465 465 padding: 3px 0 3px 0;
466 466 padding-left: 180px; /* width of left column containing the label elements */
467 467 min-height: 1.8em;
468 468 clear:left;
469 469 }
470 470
471 471 html>body .tabular p {overflow:hidden;}
472 472
473 473 .tabular label{
474 474 font-weight: bold;
475 475 float: left;
476 476 text-align: right;
477 477 /* width of left column */
478 478 margin-left: -180px;
479 479 /* width of labels. Should be smaller than left column to create some right margin */
480 480 width: 175px;
481 481 }
482 482
483 483 .tabular label.floating{
484 484 font-weight: normal;
485 485 margin-left: 0px;
486 486 text-align: left;
487 487 width: 270px;
488 488 }
489 489
490 490 .tabular label.block{
491 491 font-weight: normal;
492 492 margin-left: 0px !important;
493 493 text-align: left;
494 494 float: none;
495 495 display: block;
496 496 width: auto;
497 497 }
498 498
499 499 .tabular label.inline{
500 500 font-weight: normal;
501 501 float:none;
502 502 margin-left: 5px !important;
503 503 width: auto;
504 504 }
505 505
506 506 label.no-css {
507 507 font-weight: inherit;
508 508 float:none;
509 509 text-align:left;
510 510 margin-left:0px;
511 511 width:auto;
512 512 }
513 513 input#time_entry_comments { width: 90%;}
514 514
515 515 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
516 516
517 517 .tabular.settings p{ padding-left: 300px; }
518 518 .tabular.settings label{ margin-left: -300px; width: 295px; }
519 519 .tabular.settings textarea { width: 99%; }
520 520
521 521 .settings.enabled_scm table {width:100%}
522 522 .settings.enabled_scm td.scm_name{ font-weight: bold; }
523 523
524 524 fieldset.settings label { display: block; }
525 525 fieldset#notified_events .parent { padding-left: 20px; }
526 526
527 527 span.required {color: #bb0000;}
528 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 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 541 div.attachments { margin-top: 12px; }
535 542 div.attachments p { margin:4px 0 2px 0; }
536 543 div.attachments img { vertical-align: middle; }
537 544 div.attachments span.author { font-size: 0.9em; color: #888; }
538 545
539 546 div.thumbnails {margin-top:0.6em;}
540 547 div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
541 548 div.thumbnails img {margin: 3px;}
542 549
543 550 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
544 551 .other-formats span + span:before { content: "| "; }
545 552
546 553 a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
547 554
548 555 em.info {font-style:normal;font-size:90%;color:#888;display:block;}
549 556 em.info.error {padding-left:20px; background:url(../images/exclamation.png) no-repeat 0 50%;}
550 557
551 558 textarea.text_cf {width:90%;}
552 559
553 560 /* Project members tab */
554 561 div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft, div#tab-content-users .splitcontentleft { width: 64% }
555 562 div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright, div#tab-content-users .splitcontentright { width: 34% }
556 563 div#tab-content-members fieldset, div#tab-content-memberships fieldset, div#tab-content-users fieldset { padding:1em; margin-bottom: 1em; }
557 564 div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend, div#tab-content-users fieldset legend { font-weight: bold; }
558 565 div#tab-content-members fieldset label, div#tab-content-memberships fieldset label, div#tab-content-users fieldset label { display: block; }
559 566 div#tab-content-members fieldset div, div#tab-content-users fieldset div { max-height: 400px; overflow:auto; }
560 567
561 568 #users_for_watcher {height: 200px; overflow:auto;}
562 569 #users_for_watcher label {display: block;}
563 570
564 571 table.members td.group { padding-left: 20px; background: url(../images/group.png) no-repeat 0% 50%; }
565 572
566 573 input#principal_search, input#user_search {width:100%}
567 574 input#principal_search, input#user_search {
568 575 background: url(../images/magnifier.png) no-repeat 2px 50%; padding-left:20px;
569 576 border:1px solid #9EB1C2; border-radius:3px; height:1.5em; width:95%;
570 577 }
571 578 input#principal_search.ajax-loading, input#user_search.ajax-loading {
572 579 background-image: url(../images/loading.gif);
573 580 }
574 581
575 582 * html div#tab-content-members fieldset div { height: 450px; }
576 583
577 584 /***** Flash & error messages ****/
578 585 #errorExplanation, div.flash, .nodata, .warning, .conflict {
579 586 padding: 4px 4px 4px 30px;
580 587 margin-bottom: 12px;
581 588 font-size: 1.1em;
582 589 border: 2px solid;
583 590 }
584 591
585 592 div.flash {margin-top: 8px;}
586 593
587 594 div.flash.error, #errorExplanation {
588 595 background: url(../images/exclamation.png) 8px 50% no-repeat;
589 596 background-color: #ffe3e3;
590 597 border-color: #dd0000;
591 598 color: #880000;
592 599 }
593 600
594 601 div.flash.notice {
595 602 background: url(../images/true.png) 8px 5px no-repeat;
596 603 background-color: #dfffdf;
597 604 border-color: #9fcf9f;
598 605 color: #005f00;
599 606 }
600 607
601 608 div.flash.warning, .conflict {
602 609 background: url(../images/warning.png) 8px 5px no-repeat;
603 610 background-color: #FFEBC1;
604 611 border-color: #FDBF3B;
605 612 color: #A6750C;
606 613 text-align: left;
607 614 }
608 615
609 616 .nodata, .warning {
610 617 text-align: center;
611 618 background-color: #FFEBC1;
612 619 border-color: #FDBF3B;
613 620 color: #A6750C;
614 621 }
615 622
616 623 #errorExplanation ul { font-size: 0.9em;}
617 624 #errorExplanation h2, #errorExplanation p { display: none; }
618 625
619 626 .conflict-details {font-size:80%;}
620 627
621 628 /***** Ajax indicator ******/
622 629 #ajax-indicator {
623 630 position: absolute; /* fixed not supported by IE */
624 631 background-color:#eee;
625 632 border: 1px solid #bbb;
626 633 top:35%;
627 634 left:40%;
628 635 width:20%;
629 636 font-weight:bold;
630 637 text-align:center;
631 638 padding:0.6em;
632 639 z-index:100;
633 640 opacity: 0.5;
634 641 }
635 642
636 643 html>body #ajax-indicator { position: fixed; }
637 644
638 645 #ajax-indicator span {
639 646 background-position: 0% 40%;
640 647 background-repeat: no-repeat;
641 648 background-image: url(../images/loading.gif);
642 649 padding-left: 26px;
643 650 vertical-align: bottom;
644 651 }
645 652
646 653 /***** Calendar *****/
647 654 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
648 655 table.cal thead th {width: 14%; background-color:#EEEEEE; padding: 4px; }
649 656 table.cal thead th.week-number {width: auto;}
650 657 table.cal tbody tr {height: 100px;}
651 658 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
652 659 table.cal td.week-number { background-color:#EEEEEE; padding: 4px; border:none; font-size: 1em;}
653 660 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
654 661 table.cal td.odd p.day-num {color: #bbb;}
655 662 table.cal td.today {background:#ffffdd;}
656 663 table.cal td.today p.day-num {font-weight: bold;}
657 664 table.cal .starting a, p.cal.legend .starting {background: url(../images/bullet_go.png) no-repeat -1px -2px; padding-left:16px;}
658 665 table.cal .ending a, p.cal.legend .ending {background: url(../images/bullet_end.png) no-repeat -1px -2px; padding-left:16px;}
659 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 667 p.cal.legend span {display:block;}
661 668
662 669 /***** Tooltips ******/
663 670 .tooltip{position:relative;z-index:24;}
664 671 .tooltip:hover{z-index:25;color:#000;}
665 672 .tooltip span.tip{display: none; text-align:left;}
666 673
667 674 div.tooltip:hover span.tip{
668 675 display:block;
669 676 position:absolute;
670 677 top:12px; left:24px; width:270px;
671 678 border:1px solid #555;
672 679 background-color:#fff;
673 680 padding: 4px;
674 681 font-size: 0.8em;
675 682 color:#505050;
676 683 }
677 684
678 685 img.ui-datepicker-trigger {
679 686 cursor: pointer;
680 687 vertical-align: middle;
681 688 margin-left: 4px;
682 689 }
683 690
684 691 /***** Progress bar *****/
685 692 table.progress {
686 693 border-collapse: collapse;
687 694 border-spacing: 0pt;
688 695 empty-cells: show;
689 696 text-align: center;
690 697 float:left;
691 698 margin: 1px 6px 1px 0px;
692 699 }
693 700
694 701 table.progress td { height: 1em; }
695 702 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
696 703 table.progress td.done { background: #D3EDD3 none repeat scroll 0%; }
697 704 table.progress td.todo { background: #eee none repeat scroll 0%; }
698 705 p.pourcent {font-size: 80%;}
699 706 p.progress-info {clear: left; font-size: 80%; margin-top:-4px; color:#777;}
700 707
701 708 #roadmap table.progress td { height: 1.2em; }
702 709 /***** Tabs *****/
703 710 #content .tabs {height: 2.6em; margin-bottom:1.2em; position:relative; overflow:hidden;}
704 711 #content .tabs ul {margin:0; position:absolute; bottom:0; padding-left:0.5em; width: 2000px; border-bottom: 1px solid #bbbbbb;}
705 712 #content .tabs ul li {
706 713 float:left;
707 714 list-style-type:none;
708 715 white-space:nowrap;
709 716 margin-right:4px;
710 717 background:#fff;
711 718 position:relative;
712 719 margin-bottom:-1px;
713 720 }
714 721 #content .tabs ul li a{
715 722 display:block;
716 723 font-size: 0.9em;
717 724 text-decoration:none;
718 725 line-height:1.3em;
719 726 padding:4px 6px 4px 6px;
720 727 border: 1px solid #ccc;
721 728 border-bottom: 1px solid #bbbbbb;
722 729 background-color: #f6f6f6;
723 730 color:#999;
724 731 font-weight:bold;
725 732 border-top-left-radius:3px;
726 733 border-top-right-radius:3px;
727 734 }
728 735
729 736 #content .tabs ul li a:hover {
730 737 background-color: #ffffdd;
731 738 text-decoration:none;
732 739 }
733 740
734 741 #content .tabs ul li a.selected {
735 742 background-color: #fff;
736 743 border: 1px solid #bbbbbb;
737 744 border-bottom: 1px solid #fff;
738 745 color:#444;
739 746 }
740 747
741 748 #content .tabs ul li a.selected:hover {background-color: #fff;}
742 749
743 750 div.tabs-buttons { position:absolute; right: 0; width: 48px; height: 24px; background: white; bottom: 0; border-bottom: 1px solid #bbbbbb; }
744 751
745 752 button.tab-left, button.tab-right {
746 753 font-size: 0.9em;
747 754 cursor: pointer;
748 755 height:24px;
749 756 border: 1px solid #ccc;
750 757 border-bottom: 1px solid #bbbbbb;
751 758 position:absolute;
752 759 padding:4px;
753 760 width: 20px;
754 761 bottom: -1px;
755 762 }
756 763
757 764 button.tab-left {
758 765 right: 20px;
759 766 background: #eeeeee url(../images/bullet_arrow_left.png) no-repeat 50% 50%;
760 767 border-top-left-radius:3px;
761 768 }
762 769
763 770 button.tab-right {
764 771 right: 0;
765 772 background: #eeeeee url(../images/bullet_arrow_right.png) no-repeat 50% 50%;
766 773 border-top-right-radius:3px;
767 774 }
768 775
769 776 /***** Diff *****/
770 777 .diff_out { background: #fcc; }
771 778 .diff_out span { background: #faa; }
772 779 .diff_in { background: #cfc; }
773 780 .diff_in span { background: #afa; }
774 781
775 782 .text-diff {
776 783 padding: 1em;
777 784 background-color:#f6f6f6;
778 785 color:#505050;
779 786 border: 1px solid #e4e4e4;
780 787 }
781 788
782 789 /***** Wiki *****/
783 790 div.wiki table {
784 791 border-collapse: collapse;
785 792 margin-bottom: 1em;
786 793 }
787 794
788 795 div.wiki table, div.wiki td, div.wiki th {
789 796 border: 1px solid #bbb;
790 797 padding: 4px;
791 798 }
792 799
793 800 div.wiki .noborder, div.wiki .noborder td, div.wiki .noborder th {border:0;}
794 801
795 802 div.wiki .external {
796 803 background-position: 0% 60%;
797 804 background-repeat: no-repeat;
798 805 padding-left: 12px;
799 806 background-image: url(../images/external.png);
800 807 }
801 808
802 809 div.wiki a.new {color: #b73535;}
803 810
804 811 div.wiki ul, div.wiki ol {margin-bottom:1em;}
805 812
806 813 div.wiki pre {
807 814 margin: 1em 1em 1em 1.6em;
808 815 padding: 8px;
809 816 background-color: #fafafa;
810 817 border: 1px solid #e2e2e2;
811 818 width:auto;
812 819 overflow-x: auto;
813 820 overflow-y: hidden;
814 821 }
815 822
816 823 div.wiki ul.toc {
817 824 background-color: #ffffdd;
818 825 border: 1px solid #e4e4e4;
819 826 padding: 4px;
820 827 line-height: 1.2em;
821 828 margin-bottom: 12px;
822 829 margin-right: 12px;
823 830 margin-left: 0;
824 831 display: table
825 832 }
826 833 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
827 834
828 835 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
829 836 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
830 837 div.wiki ul.toc ul { margin: 0; padding: 0; }
831 838 div.wiki ul.toc li {list-style-type:none; margin: 0; font-size:12px;}
832 839 div.wiki ul.toc li li {margin-left: 1.5em; font-size:10px;}
833 840 div.wiki ul.toc a {
834 841 font-size: 0.9em;
835 842 font-weight: normal;
836 843 text-decoration: none;
837 844 color: #606060;
838 845 }
839 846 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
840 847
841 848 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
842 849 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
843 850 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
844 851
845 852 div.wiki img { vertical-align: middle; }
846 853
847 854 /***** My page layout *****/
848 855 .block-receiver {
849 856 border:1px dashed #c0c0c0;
850 857 margin-bottom: 20px;
851 858 padding: 15px 0 15px 0;
852 859 }
853 860
854 861 .mypage-box {
855 862 margin:0 0 20px 0;
856 863 color:#505050;
857 864 line-height:1.5em;
858 865 }
859 866
860 867 .handle {cursor: move;}
861 868
862 869 a.close-icon {
863 870 display:block;
864 871 margin-top:3px;
865 872 overflow:hidden;
866 873 width:12px;
867 874 height:12px;
868 875 background-repeat: no-repeat;
869 876 cursor:pointer;
870 877 background-image:url('../images/close.png');
871 878 }
872 879 a.close-icon:hover {background-image:url('../images/close_hl.png');}
873 880
874 881 /***** Gantt chart *****/
875 882 .gantt_hdr {
876 883 position:absolute;
877 884 top:0;
878 885 height:16px;
879 886 border-top: 1px solid #c0c0c0;
880 887 border-bottom: 1px solid #c0c0c0;
881 888 border-right: 1px solid #c0c0c0;
882 889 text-align: center;
883 890 overflow: hidden;
884 891 }
885 892
886 893 .gantt_hdr.nwday {background-color:#f1f1f1;}
887 894
888 895 .gantt_subjects { font-size: 0.8em; }
889 896 .gantt_subjects div { line-height:16px;height:16px;overflow:hidden;white-space:nowrap;text-overflow: ellipsis; }
890 897
891 898 .task {
892 899 position: absolute;
893 900 height:8px;
894 901 font-size:0.8em;
895 902 color:#888;
896 903 padding:0;
897 904 margin:0;
898 905 line-height:16px;
899 906 white-space:nowrap;
900 907 }
901 908
902 909 .task.label {width:100%;}
903 910 .task.label.project, .task.label.version { font-weight: bold; }
904 911
905 912 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
906 913 .task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
907 914 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
908 915
909 916 .task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
910 917 .task_late.parent, .task_done.parent { height: 3px;}
911 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 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 921 .version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
915 922 .version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
916 923 .version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
917 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 926 .project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
920 927 .project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
921 928 .project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
922 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 931 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
925 932 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
926 933
927 934 /***** Icons *****/
928 935 .icon {
929 936 background-position: 0% 50%;
930 937 background-repeat: no-repeat;
931 938 padding-left: 20px;
932 939 padding-top: 2px;
933 940 padding-bottom: 3px;
934 941 }
935 942
936 943 .icon-add { background-image: url(../images/add.png); }
937 944 .icon-edit { background-image: url(../images/edit.png); }
938 945 .icon-copy { background-image: url(../images/copy.png); }
939 946 .icon-duplicate { background-image: url(../images/duplicate.png); }
940 947 .icon-del { background-image: url(../images/delete.png); }
941 948 .icon-move { background-image: url(../images/move.png); }
942 949 .icon-save { background-image: url(../images/save.png); }
943 950 .icon-cancel { background-image: url(../images/cancel.png); }
944 951 .icon-multiple { background-image: url(../images/table_multiple.png); }
945 952 .icon-folder { background-image: url(../images/folder.png); }
946 953 .open .icon-folder { background-image: url(../images/folder_open.png); }
947 954 .icon-package { background-image: url(../images/package.png); }
948 955 .icon-user { background-image: url(../images/user.png); }
949 956 .icon-projects { background-image: url(../images/projects.png); }
950 957 .icon-help { background-image: url(../images/help.png); }
951 958 .icon-attachment { background-image: url(../images/attachment.png); }
952 959 .icon-history { background-image: url(../images/history.png); }
953 960 .icon-time { background-image: url(../images/time.png); }
954 961 .icon-time-add { background-image: url(../images/time_add.png); }
955 962 .icon-stats { background-image: url(../images/stats.png); }
956 963 .icon-warning { background-image: url(../images/warning.png); }
957 964 .icon-fav { background-image: url(../images/fav.png); }
958 965 .icon-fav-off { background-image: url(../images/fav_off.png); }
959 966 .icon-reload { background-image: url(../images/reload.png); }
960 967 .icon-lock { background-image: url(../images/locked.png); }
961 968 .icon-unlock { background-image: url(../images/unlock.png); }
962 969 .icon-checked { background-image: url(../images/true.png); }
963 970 .icon-details { background-image: url(../images/zoom_in.png); }
964 971 .icon-report { background-image: url(../images/report.png); }
965 972 .icon-comment { background-image: url(../images/comment.png); }
966 973 .icon-summary { background-image: url(../images/lightning.png); }
967 974 .icon-server-authentication { background-image: url(../images/server_key.png); }
968 975 .icon-issue { background-image: url(../images/ticket.png); }
969 976 .icon-zoom-in { background-image: url(../images/zoom_in.png); }
970 977 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
971 978 .icon-passwd { background-image: url(../images/textfield_key.png); }
972 979 .icon-test { background-image: url(../images/bullet_go.png); }
973 980
974 981 .icon-file { background-image: url(../images/files/default.png); }
975 982 .icon-file.text-plain { background-image: url(../images/files/text.png); }
976 983 .icon-file.text-x-c { background-image: url(../images/files/c.png); }
977 984 .icon-file.text-x-csharp { background-image: url(../images/files/csharp.png); }
978 985 .icon-file.text-x-java { background-image: url(../images/files/java.png); }
979 986 .icon-file.text-x-javascript { background-image: url(../images/files/js.png); }
980 987 .icon-file.text-x-php { background-image: url(../images/files/php.png); }
981 988 .icon-file.text-x-ruby { background-image: url(../images/files/ruby.png); }
982 989 .icon-file.text-xml { background-image: url(../images/files/xml.png); }
983 990 .icon-file.text-css { background-image: url(../images/files/css.png); }
984 991 .icon-file.text-html { background-image: url(../images/files/html.png); }
985 992 .icon-file.image-gif { background-image: url(../images/files/image.png); }
986 993 .icon-file.image-jpeg { background-image: url(../images/files/image.png); }
987 994 .icon-file.image-png { background-image: url(../images/files/image.png); }
988 995 .icon-file.image-tiff { background-image: url(../images/files/image.png); }
989 996 .icon-file.application-pdf { background-image: url(../images/files/pdf.png); }
990 997 .icon-file.application-zip { background-image: url(../images/files/zip.png); }
991 998 .icon-file.application-x-gzip { background-image: url(../images/files/zip.png); }
992 999
993 1000 img.gravatar {
994 1001 padding: 2px;
995 1002 border: solid 1px #d5d5d5;
996 1003 background: #fff;
997 1004 vertical-align: middle;
998 1005 }
999 1006
1000 1007 div.issue img.gravatar {
1001 1008 float: left;
1002 1009 margin: 0 6px 0 0;
1003 1010 padding: 5px;
1004 1011 }
1005 1012
1006 1013 div.issue table img.gravatar {
1007 1014 height: 14px;
1008 1015 width: 14px;
1009 1016 padding: 2px;
1010 1017 float: left;
1011 1018 margin: 0 0.5em 0 0;
1012 1019 }
1013 1020
1014 1021 h2 img.gravatar {margin: -2px 4px -4px 0;}
1015 1022 h3 img.gravatar {margin: -4px 4px -4px 0;}
1016 1023 h4 img.gravatar {margin: -6px 4px -4px 0;}
1017 1024 td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
1018 1025 #activity dt img.gravatar {float: left; margin: 0 1em 1em 0;}
1019 1026 /* Used on 12px Gravatar img tags without the icon background */
1020 1027 .icon-gravatar {float: left; margin-right: 4px;}
1021 1028
1022 1029 #activity dt, .journal {clear: left;}
1023 1030
1024 1031 .journal-link {float: right;}
1025 1032
1026 1033 h2 img { vertical-align:middle; }
1027 1034
1028 1035 .hascontextmenu { cursor: context-menu; }
1029 1036
1030 1037 /************* CodeRay styles *************/
1031 1038 .syntaxhl div {display: inline;}
1032 1039 .syntaxhl .line-numbers {padding: 2px 4px 2px 4px; background-color: #eee; margin:0px 5px 0px 0px;}
1033 1040 .syntaxhl .code pre { overflow: auto }
1034 1041 .syntaxhl .debug { color: white !important; background: blue !important; }
1035 1042
1036 1043 .syntaxhl .annotation { color:#007 }
1037 1044 .syntaxhl .attribute-name { color:#b48 }
1038 1045 .syntaxhl .attribute-value { color:#700 }
1039 1046 .syntaxhl .binary { color:#509 }
1040 1047 .syntaxhl .char .content { color:#D20 }
1041 1048 .syntaxhl .char .delimiter { color:#710 }
1042 1049 .syntaxhl .char { color:#D20 }
1043 1050 .syntaxhl .class { color:#258; font-weight:bold }
1044 1051 .syntaxhl .class-variable { color:#369 }
1045 1052 .syntaxhl .color { color:#0A0 }
1046 1053 .syntaxhl .comment { color:#385 }
1047 1054 .syntaxhl .comment .char { color:#385 }
1048 1055 .syntaxhl .comment .delimiter { color:#385 }
1049 1056 .syntaxhl .complex { color:#A08 }
1050 1057 .syntaxhl .constant { color:#258; font-weight:bold }
1051 1058 .syntaxhl .decorator { color:#B0B }
1052 1059 .syntaxhl .definition { color:#099; font-weight:bold }
1053 1060 .syntaxhl .delimiter { color:black }
1054 1061 .syntaxhl .directive { color:#088; font-weight:bold }
1055 1062 .syntaxhl .doc { color:#970 }
1056 1063 .syntaxhl .doc-string { color:#D42; font-weight:bold }
1057 1064 .syntaxhl .doctype { color:#34b }
1058 1065 .syntaxhl .entity { color:#800; font-weight:bold }
1059 1066 .syntaxhl .error { color:#F00; background-color:#FAA }
1060 1067 .syntaxhl .escape { color:#666 }
1061 1068 .syntaxhl .exception { color:#C00; font-weight:bold }
1062 1069 .syntaxhl .float { color:#06D }
1063 1070 .syntaxhl .function { color:#06B; font-weight:bold }
1064 1071 .syntaxhl .global-variable { color:#d70 }
1065 1072 .syntaxhl .hex { color:#02b }
1066 1073 .syntaxhl .imaginary { color:#f00 }
1067 1074 .syntaxhl .include { color:#B44; font-weight:bold }
1068 1075 .syntaxhl .inline { background-color: hsla(0,0%,0%,0.07); color: black }
1069 1076 .syntaxhl .inline-delimiter { font-weight: bold; color: #666 }
1070 1077 .syntaxhl .instance-variable { color:#33B }
1071 1078 .syntaxhl .integer { color:#06D }
1072 1079 .syntaxhl .key .char { color: #60f }
1073 1080 .syntaxhl .key .delimiter { color: #404 }
1074 1081 .syntaxhl .key { color: #606 }
1075 1082 .syntaxhl .keyword { color:#939; font-weight:bold }
1076 1083 .syntaxhl .label { color:#970; font-weight:bold }
1077 1084 .syntaxhl .local-variable { color:#963 }
1078 1085 .syntaxhl .namespace { color:#707; font-weight:bold }
1079 1086 .syntaxhl .octal { color:#40E }
1080 1087 .syntaxhl .operator { }
1081 1088 .syntaxhl .predefined { color:#369; font-weight:bold }
1082 1089 .syntaxhl .predefined-constant { color:#069 }
1083 1090 .syntaxhl .predefined-type { color:#0a5; font-weight:bold }
1084 1091 .syntaxhl .preprocessor { color:#579 }
1085 1092 .syntaxhl .pseudo-class { color:#00C; font-weight:bold }
1086 1093 .syntaxhl .regexp .content { color:#808 }
1087 1094 .syntaxhl .regexp .delimiter { color:#404 }
1088 1095 .syntaxhl .regexp .modifier { color:#C2C }
1089 1096 .syntaxhl .regexp { background-color:hsla(300,100%,50%,0.06); }
1090 1097 .syntaxhl .reserved { color:#080; font-weight:bold }
1091 1098 .syntaxhl .shell .content { color:#2B2 }
1092 1099 .syntaxhl .shell .delimiter { color:#161 }
1093 1100 .syntaxhl .shell { background-color:hsla(120,100%,50%,0.06); }
1094 1101 .syntaxhl .string .char { color: #46a }
1095 1102 .syntaxhl .string .content { color: #46a }
1096 1103 .syntaxhl .string .delimiter { color: #46a }
1097 1104 .syntaxhl .string .modifier { color: #46a }
1098 1105 .syntaxhl .symbol .content { color:#d33 }
1099 1106 .syntaxhl .symbol .delimiter { color:#d33 }
1100 1107 .syntaxhl .symbol { color:#d33 }
1101 1108 .syntaxhl .tag { color:#070 }
1102 1109 .syntaxhl .type { color:#339; font-weight:bold }
1103 1110 .syntaxhl .value { color: #088; }
1104 1111 .syntaxhl .variable { color:#037 }
1105 1112
1106 1113 .syntaxhl .insert { background: hsla(120,100%,50%,0.12) }
1107 1114 .syntaxhl .delete { background: hsla(0,100%,50%,0.12) }
1108 1115 .syntaxhl .change { color: #bbf; background: #007; }
1109 1116 .syntaxhl .head { color: #f8f; background: #505 }
1110 1117 .syntaxhl .head .filename { color: white; }
1111 1118
1112 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 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 1122 .syntaxhl .insert .insert { color: #0c0; background:transparent; font-weight:bold }
1116 1123 .syntaxhl .delete .delete { color: #c00; background:transparent; font-weight:bold }
1117 1124 .syntaxhl .change .change { color: #88f }
1118 1125 .syntaxhl .head .head { color: #f4f }
1119 1126
1120 1127 /***** Media print specific styles *****/
1121 1128 @media print {
1122 1129 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
1123 1130 #main { background: #fff; }
1124 1131 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
1125 1132 #wiki_add_attachment { display:none; }
1126 1133 .hide-when-print { display: none; }
1127 1134 .autoscroll {overflow-x: visible;}
1128 1135 table.list {margin-top:0.5em;}
1129 1136 table.list th, table.list td {border: 1px solid #aaa;}
1130 1137 }
1131 1138
1132 1139 /* Accessibility specific styles */
1133 1140 .hidden-for-sighted {
1134 1141 position:absolute;
1135 1142 left:-10000px;
1136 1143 top:auto;
1137 1144 width:1px;
1138 1145 height:1px;
1139 1146 overflow:hidden;
1140 1147 }
@@ -1,376 +1,385
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../test_helper', __FILE__)
21 21
22 22 class AttachmentsControllerTest < ActionController::TestCase
23 23 fixtures :users, :projects, :roles, :members, :member_roles,
24 24 :enabled_modules, :issues, :trackers, :attachments,
25 25 :versions, :wiki_pages, :wikis, :documents
26 26
27 27 def setup
28 28 User.current = nil
29 29 set_fixtures_attachments_directory
30 30 end
31 31
32 32 def teardown
33 33 set_tmp_attachments_directory
34 34 end
35 35
36 36 def test_show_diff
37 37 ['inline', 'sbs'].each do |dt|
38 38 # 060719210727_changeset_utf8.diff
39 39 get :show, :id => 14, :type => dt
40 40 assert_response :success
41 41 assert_template 'diff'
42 42 assert_equal 'text/html', @response.content_type
43 43 assert_tag 'th',
44 44 :attributes => {:class => /filename/},
45 45 :content => /issues_controller.rb\t\(rΓ©vision 1484\)/
46 46 assert_tag 'td',
47 47 :attributes => {:class => /line-code/},
48 48 :content => /Demande créée avec succès/
49 49 end
50 50 set_tmp_attachments_directory
51 51 end
52 52
53 53 def test_show_diff_replcace_cannot_convert_content
54 54 with_settings :repositories_encodings => 'UTF-8' do
55 55 ['inline', 'sbs'].each do |dt|
56 56 # 060719210727_changeset_iso8859-1.diff
57 57 get :show, :id => 5, :type => dt
58 58 assert_response :success
59 59 assert_template 'diff'
60 60 assert_equal 'text/html', @response.content_type
61 61 assert_tag 'th',
62 62 :attributes => {:class => "filename"},
63 63 :content => /issues_controller.rb\t\(r\?vision 1484\)/
64 64 assert_tag 'td',
65 65 :attributes => {:class => /line-code/},
66 66 :content => /Demande cr\?\?e avec succ\?s/
67 67 end
68 68 end
69 69 set_tmp_attachments_directory
70 70 end
71 71
72 72 def test_show_diff_latin_1
73 73 with_settings :repositories_encodings => 'UTF-8,ISO-8859-1' do
74 74 ['inline', 'sbs'].each do |dt|
75 75 # 060719210727_changeset_iso8859-1.diff
76 76 get :show, :id => 5, :type => dt
77 77 assert_response :success
78 78 assert_template 'diff'
79 79 assert_equal 'text/html', @response.content_type
80 80 assert_tag 'th',
81 81 :attributes => {:class => "filename"},
82 82 :content => /issues_controller.rb\t\(rΓ©vision 1484\)/
83 83 assert_tag 'td',
84 84 :attributes => {:class => /line-code/},
85 85 :content => /Demande créée avec succès/
86 86 end
87 87 end
88 88 set_tmp_attachments_directory
89 89 end
90 90
91 91 def test_save_diff_type
92 92 user1 = User.find(1)
93 93 user1.pref[:diff_type] = nil
94 94 user1.preference.save
95 95 user = User.find(1)
96 96 assert_nil user.pref[:diff_type]
97 97
98 98 @request.session[:user_id] = 1 # admin
99 99 get :show, :id => 5
100 100 assert_response :success
101 101 assert_template 'diff'
102 102 user.reload
103 103 assert_equal "inline", user.pref[:diff_type]
104 104 get :show, :id => 5, :type => 'sbs'
105 105 assert_response :success
106 106 assert_template 'diff'
107 107 user.reload
108 108 assert_equal "sbs", user.pref[:diff_type]
109 109 end
110 110
111 111 def test_diff_show_filename_in_mercurial_export
112 112 set_tmp_attachments_directory
113 113 a = Attachment.new(:container => Issue.find(1),
114 114 :file => uploaded_test_file("hg-export.diff", "text/plain"),
115 115 :author => User.find(1))
116 116 assert a.save
117 117 assert_equal 'hg-export.diff', a.filename
118 118
119 119 get :show, :id => a.id, :type => 'inline'
120 120 assert_response :success
121 121 assert_template 'diff'
122 122 assert_equal 'text/html', @response.content_type
123 123 assert_select 'th.filename', :text => 'test1.txt'
124 124 end
125 125
126 126 def test_show_text_file
127 127 get :show, :id => 4
128 128 assert_response :success
129 129 assert_template 'file'
130 130 assert_equal 'text/html', @response.content_type
131 131 set_tmp_attachments_directory
132 132 end
133 133
134 134 def test_show_text_file_utf_8
135 135 set_tmp_attachments_directory
136 136 a = Attachment.new(:container => Issue.find(1),
137 137 :file => uploaded_test_file("japanese-utf-8.txt", "text/plain"),
138 138 :author => User.find(1))
139 139 assert a.save
140 140 assert_equal 'japanese-utf-8.txt', a.filename
141 141
142 142 str_japanese = "\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e"
143 143 str_japanese.force_encoding('UTF-8') if str_japanese.respond_to?(:force_encoding)
144 144
145 145 get :show, :id => a.id
146 146 assert_response :success
147 147 assert_template 'file'
148 148 assert_equal 'text/html', @response.content_type
149 149 assert_tag :tag => 'th',
150 150 :content => '1',
151 151 :attributes => { :class => 'line-num' },
152 152 :sibling => { :tag => 'td', :content => /#{str_japanese}/ }
153 153 end
154 154
155 155 def test_show_text_file_replcace_cannot_convert_content
156 156 set_tmp_attachments_directory
157 157 with_settings :repositories_encodings => 'UTF-8' do
158 158 a = Attachment.new(:container => Issue.find(1),
159 159 :file => uploaded_test_file("iso8859-1.txt", "text/plain"),
160 160 :author => User.find(1))
161 161 assert a.save
162 162 assert_equal 'iso8859-1.txt', a.filename
163 163
164 164 get :show, :id => a.id
165 165 assert_response :success
166 166 assert_template 'file'
167 167 assert_equal 'text/html', @response.content_type
168 168 assert_tag :tag => 'th',
169 169 :content => '7',
170 170 :attributes => { :class => 'line-num' },
171 171 :sibling => { :tag => 'td', :content => /Demande cr\?\?e avec succ\?s/ }
172 172 end
173 173 end
174 174
175 175 def test_show_text_file_latin_1
176 176 set_tmp_attachments_directory
177 177 with_settings :repositories_encodings => 'UTF-8,ISO-8859-1' do
178 178 a = Attachment.new(:container => Issue.find(1),
179 179 :file => uploaded_test_file("iso8859-1.txt", "text/plain"),
180 180 :author => User.find(1))
181 181 assert a.save
182 182 assert_equal 'iso8859-1.txt', a.filename
183 183
184 184 get :show, :id => a.id
185 185 assert_response :success
186 186 assert_template 'file'
187 187 assert_equal 'text/html', @response.content_type
188 188 assert_tag :tag => 'th',
189 189 :content => '7',
190 190 :attributes => { :class => 'line-num' },
191 191 :sibling => { :tag => 'td', :content => /Demande créée avec succès/ }
192 192 end
193 193 end
194 194
195 195 def test_show_text_file_should_send_if_too_big
196 196 Setting.file_max_size_displayed = 512
197 197 Attachment.find(4).update_attribute :filesize, 754.kilobyte
198 198
199 199 get :show, :id => 4
200 200 assert_response :success
201 201 assert_equal 'application/x-ruby', @response.content_type
202 202 set_tmp_attachments_directory
203 203 end
204 204
205 205 def test_show_other
206 206 get :show, :id => 6
207 207 assert_response :success
208 208 assert_equal 'application/octet-stream', @response.content_type
209 209 set_tmp_attachments_directory
210 210 end
211 211
212 212 def test_show_file_from_private_issue_without_permission
213 213 get :show, :id => 15
214 214 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2F15'
215 215 set_tmp_attachments_directory
216 216 end
217 217
218 218 def test_show_file_from_private_issue_with_permission
219 219 @request.session[:user_id] = 2
220 220 get :show, :id => 15
221 221 assert_response :success
222 222 assert_tag 'h2', :content => /private.diff/
223 223 set_tmp_attachments_directory
224 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 227 set_tmp_attachments_directory
228 228 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
229 229
230 230 @request.session[:user_id] = 2
231 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 241 assert_response 403
233 242 end
234 243
235 244 def test_show_invalid_should_respond_with_404
236 245 get :show, :id => 999
237 246 assert_response 404
238 247 end
239 248
240 249 def test_download_text_file
241 250 get :download, :id => 4
242 251 assert_response :success
243 252 assert_equal 'application/x-ruby', @response.content_type
244 253 set_tmp_attachments_directory
245 254 end
246 255
247 256 def test_download_version_file_with_issue_tracking_disabled
248 257 Project.find(1).disable_module! :issue_tracking
249 258 get :download, :id => 9
250 259 assert_response :success
251 260 end
252 261
253 262 def test_download_should_assign_content_type_if_blank
254 263 Attachment.find(4).update_attribute(:content_type, '')
255 264
256 265 get :download, :id => 4
257 266 assert_response :success
258 267 assert_equal 'text/x-ruby', @response.content_type
259 268 set_tmp_attachments_directory
260 269 end
261 270
262 271 def test_download_missing_file
263 272 get :download, :id => 2
264 273 assert_response 404
265 274 set_tmp_attachments_directory
266 275 end
267 276
268 277 def test_download_should_be_denied_without_permission
269 278 get :download, :id => 7
270 279 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdownload%2F7'
271 280 set_tmp_attachments_directory
272 281 end
273 282
274 283 if convert_installed?
275 284 def test_thumbnail
276 285 Attachment.clear_thumbnails
277 286 @request.session[:user_id] = 2
278 287
279 288 get :thumbnail, :id => 16
280 289 assert_response :success
281 290 assert_equal 'image/png', response.content_type
282 291 end
283 292
284 293 def test_thumbnail_should_not_exceed_maximum_size
285 294 Redmine::Thumbnail.expects(:generate).with {|source, target, size| size == 800}
286 295
287 296 @request.session[:user_id] = 2
288 297 get :thumbnail, :id => 16, :size => 2000
289 298 end
290 299
291 300 def test_thumbnail_should_round_size
292 301 Redmine::Thumbnail.expects(:generate).with {|source, target, size| size == 250}
293 302
294 303 @request.session[:user_id] = 2
295 304 get :thumbnail, :id => 16, :size => 260
296 305 end
297 306
298 307 def test_thumbnail_should_return_404_for_non_image_attachment
299 308 @request.session[:user_id] = 2
300 309
301 310 get :thumbnail, :id => 15
302 311 assert_response 404
303 312 end
304 313
305 314 def test_thumbnail_should_return_404_if_thumbnail_generation_failed
306 315 Attachment.any_instance.stubs(:thumbnail).returns(nil)
307 316 @request.session[:user_id] = 2
308 317
309 318 get :thumbnail, :id => 16
310 319 assert_response 404
311 320 end
312 321
313 322 def test_thumbnail_should_be_denied_without_permission
314 323 get :thumbnail, :id => 16
315 324 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fthumbnail%2F16'
316 325 end
317 326 else
318 327 puts '(ImageMagick convert not available)'
319 328 end
320 329
321 330 def test_destroy_issue_attachment
322 331 set_tmp_attachments_directory
323 332 issue = Issue.find(3)
324 333 @request.session[:user_id] = 2
325 334
326 335 assert_difference 'issue.attachments.count', -1 do
327 336 assert_difference 'Journal.count' do
328 337 delete :destroy, :id => 1
329 338 assert_redirected_to '/projects/ecookbook'
330 339 end
331 340 end
332 341 assert_nil Attachment.find_by_id(1)
333 342 j = Journal.first(:order => 'id DESC')
334 343 assert_equal issue, j.journalized
335 344 assert_equal 'attachment', j.details.first.property
336 345 assert_equal '1', j.details.first.prop_key
337 346 assert_equal 'error281.txt', j.details.first.old_value
338 347 assert_equal User.find(2), j.user
339 348 end
340 349
341 350 def test_destroy_wiki_page_attachment
342 351 set_tmp_attachments_directory
343 352 @request.session[:user_id] = 2
344 353 assert_difference 'Attachment.count', -1 do
345 354 delete :destroy, :id => 3
346 355 assert_response 302
347 356 end
348 357 end
349 358
350 359 def test_destroy_project_attachment
351 360 set_tmp_attachments_directory
352 361 @request.session[:user_id] = 2
353 362 assert_difference 'Attachment.count', -1 do
354 363 delete :destroy, :id => 8
355 364 assert_response 302
356 365 end
357 366 end
358 367
359 368 def test_destroy_version_attachment
360 369 set_tmp_attachments_directory
361 370 @request.session[:user_id] = 2
362 371 assert_difference 'Attachment.count', -1 do
363 372 delete :destroy, :id => 9
364 373 assert_response 302
365 374 end
366 375 end
367 376
368 377 def test_destroy_without_permission
369 378 set_tmp_attachments_directory
370 379 assert_no_difference 'Attachment.count' do
371 380 delete :destroy, :id => 3
372 381 end
373 382 assert_response 302
374 383 assert Attachment.find_by_id(3)
375 384 end
376 385 end
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,263 +1,263
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19 require 'issues_controller'
20 20
21 21 class IssuesControllerTransactionTest < ActionController::TestCase
22 22 tests IssuesController
23 23 fixtures :projects,
24 24 :users,
25 25 :roles,
26 26 :members,
27 27 :member_roles,
28 28 :issues,
29 29 :issue_statuses,
30 30 :versions,
31 31 :trackers,
32 32 :projects_trackers,
33 33 :issue_categories,
34 34 :enabled_modules,
35 35 :enumerations,
36 36 :attachments,
37 37 :workflows,
38 38 :custom_fields,
39 39 :custom_values,
40 40 :custom_fields_projects,
41 41 :custom_fields_trackers,
42 42 :time_entries,
43 43 :journals,
44 44 :journal_details,
45 45 :queries
46 46
47 47 self.use_transactional_fixtures = false
48 48
49 49 def setup
50 50 User.current = nil
51 51 end
52 52
53 53 def test_update_stale_issue_should_not_update_the_issue
54 54 issue = Issue.find(2)
55 55 @request.session[:user_id] = 2
56 56
57 57 assert_no_difference 'Journal.count' do
58 58 assert_no_difference 'TimeEntry.count' do
59 59 put :update,
60 60 :id => issue.id,
61 61 :issue => {
62 62 :fixed_version_id => 4,
63 63 :notes => 'My notes',
64 64 :lock_version => (issue.lock_version - 1)
65 65 },
66 66 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }
67 67 end
68 68 end
69 69
70 70 assert_response :success
71 71 assert_template 'edit'
72 72
73 73 assert_select 'div.conflict'
74 74 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'overwrite'
75 75 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'add_notes'
76 76 assert_select 'label' do
77 77 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'cancel'
78 78 assert_select 'a[href=/issues/2]'
79 79 end
80 80 end
81 81
82 82 def test_update_stale_issue_should_save_attachments
83 83 set_tmp_attachments_directory
84 84 issue = Issue.find(2)
85 85 @request.session[:user_id] = 2
86 86
87 87 assert_no_difference 'Journal.count' do
88 88 assert_no_difference 'TimeEntry.count' do
89 89 assert_difference 'Attachment.count' do
90 90 put :update,
91 91 :id => issue.id,
92 92 :issue => {
93 93 :fixed_version_id => 4,
94 94 :notes => 'My notes',
95 95 :lock_version => (issue.lock_version - 1)
96 96 },
97 97 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}},
98 98 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }
99 99 end
100 100 end
101 101 end
102 102
103 103 assert_response :success
104 104 assert_template 'edit'
105 105 attachment = Attachment.first(:order => 'id DESC')
106 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 108 end
109 109
110 110 def test_update_stale_issue_without_notes_should_not_show_add_notes_option
111 111 issue = Issue.find(2)
112 112 @request.session[:user_id] = 2
113 113
114 114 put :update, :id => issue.id,
115 115 :issue => {
116 116 :fixed_version_id => 4,
117 117 :notes => '',
118 118 :lock_version => (issue.lock_version - 1)
119 119 }
120 120
121 121 assert_tag 'div', :attributes => {:class => 'conflict'}
122 122 assert_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'overwrite'}
123 123 assert_no_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'add_notes'}
124 124 assert_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'cancel'}
125 125 end
126 126
127 127 def test_update_stale_issue_should_show_conflicting_journals
128 128 @request.session[:user_id] = 2
129 129
130 130 put :update, :id => 1,
131 131 :issue => {
132 132 :fixed_version_id => 4,
133 133 :notes => '',
134 134 :lock_version => 2
135 135 },
136 136 :last_journal_id => 1
137 137
138 138 assert_not_nil assigns(:conflict_journals)
139 139 assert_equal 1, assigns(:conflict_journals).size
140 140 assert_equal 2, assigns(:conflict_journals).first.id
141 141 assert_tag 'div', :attributes => {:class => 'conflict'},
142 142 :descendant => {:content => /Some notes with Redmine links/}
143 143 end
144 144
145 145 def test_update_stale_issue_without_previous_journal_should_show_all_journals
146 146 @request.session[:user_id] = 2
147 147
148 148 put :update, :id => 1,
149 149 :issue => {
150 150 :fixed_version_id => 4,
151 151 :notes => '',
152 152 :lock_version => 2
153 153 },
154 154 :last_journal_id => ''
155 155
156 156 assert_not_nil assigns(:conflict_journals)
157 157 assert_equal 2, assigns(:conflict_journals).size
158 158 assert_tag 'div', :attributes => {:class => 'conflict'},
159 159 :descendant => {:content => /Some notes with Redmine links/}
160 160 assert_tag 'div', :attributes => {:class => 'conflict'},
161 161 :descendant => {:content => /Journal notes/}
162 162 end
163 163
164 164 def test_update_stale_issue_should_show_private_journals_with_permission_only
165 165 journal = Journal.create!(:journalized => Issue.find(1), :notes => 'Privates notes', :private_notes => true, :user_id => 1)
166 166
167 167 @request.session[:user_id] = 2
168 168 put :update, :id => 1, :issue => {:fixed_version_id => 4, :lock_version => 2}, :last_journal_id => ''
169 169 assert_include journal, assigns(:conflict_journals)
170 170
171 171 Role.find(1).remove_permission! :view_private_notes
172 172 put :update, :id => 1, :issue => {:fixed_version_id => 4, :lock_version => 2}, :last_journal_id => ''
173 173 assert_not_include journal, assigns(:conflict_journals)
174 174 end
175 175
176 176 def test_update_stale_issue_with_overwrite_conflict_resolution_should_update
177 177 @request.session[:user_id] = 2
178 178
179 179 assert_difference 'Journal.count' do
180 180 put :update, :id => 1,
181 181 :issue => {
182 182 :fixed_version_id => 4,
183 183 :notes => 'overwrite_conflict_resolution',
184 184 :lock_version => 2
185 185 },
186 186 :conflict_resolution => 'overwrite'
187 187 end
188 188
189 189 assert_response 302
190 190 issue = Issue.find(1)
191 191 assert_equal 4, issue.fixed_version_id
192 192 journal = Journal.first(:order => 'id DESC')
193 193 assert_equal 'overwrite_conflict_resolution', journal.notes
194 194 assert journal.details.any?
195 195 end
196 196
197 197 def test_update_stale_issue_with_add_notes_conflict_resolution_should_update
198 198 @request.session[:user_id] = 2
199 199
200 200 assert_difference 'Journal.count' do
201 201 put :update, :id => 1,
202 202 :issue => {
203 203 :fixed_version_id => 4,
204 204 :notes => 'add_notes_conflict_resolution',
205 205 :lock_version => 2
206 206 },
207 207 :conflict_resolution => 'add_notes'
208 208 end
209 209
210 210 assert_response 302
211 211 issue = Issue.find(1)
212 212 assert_nil issue.fixed_version_id
213 213 journal = Journal.first(:order => 'id DESC')
214 214 assert_equal 'add_notes_conflict_resolution', journal.notes
215 215 assert journal.details.empty?
216 216 end
217 217
218 218 def test_update_stale_issue_with_cancel_conflict_resolution_should_redirect_without_updating
219 219 @request.session[:user_id] = 2
220 220
221 221 assert_no_difference 'Journal.count' do
222 222 put :update, :id => 1,
223 223 :issue => {
224 224 :fixed_version_id => 4,
225 225 :notes => 'add_notes_conflict_resolution',
226 226 :lock_version => 2
227 227 },
228 228 :conflict_resolution => 'cancel'
229 229 end
230 230
231 231 assert_redirected_to '/issues/1'
232 232 issue = Issue.find(1)
233 233 assert_nil issue.fixed_version_id
234 234 end
235 235
236 236 def test_put_update_with_spent_time_and_failure_should_not_add_spent_time
237 237 @request.session[:user_id] = 2
238 238
239 239 assert_no_difference('TimeEntry.count') do
240 240 put :update,
241 241 :id => 1,
242 242 :issue => { :subject => '' },
243 243 :time_entry => { :hours => '2.5', :comments => 'should not be added', :activity_id => TimeEntryActivity.first.id }
244 244 assert_response :success
245 245 end
246 246
247 247 assert_select 'input[name=?][value=?]', 'time_entry[hours]', '2.5'
248 248 assert_select 'input[name=?][value=?]', 'time_entry[comments]', 'should not be added'
249 249 assert_select 'select[name=?]', 'time_entry[activity_id]' do
250 250 assert_select 'option[value=?][selected=selected]', TimeEntryActivity.first.id
251 251 end
252 252 end
253 253
254 254 def test_index_should_rescue_invalid_sql_query
255 255 IssueQuery.any_instance.stubs(:statement).returns("INVALID STATEMENT")
256 256
257 257 get :index
258 258 assert_response 500
259 259 assert_tag 'p', :content => /An error occurred/
260 260 assert_nil session[:query]
261 261 assert_nil session[:issues_index_sort]
262 262 end
263 263 end
General Comments 0
You need to be logged in to leave comments. Login now