##// END OF EJS Templates
Merged r2168 to r2171 from trunk....
Jean-Philippe Lang -
r2170:733987fbb6e4
parent child
Show More
@@ -1,92 +1,92
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 AdminController < ApplicationController
19 19 before_filter :require_admin
20 20
21 21 helper :sort
22 22 include SortHelper
23 23
24 24 def index
25 25 @no_configuration_data = Redmine::DefaultData::Loader::no_data?
26 26 end
27 27
28 28 def projects
29 29 sort_init 'name', 'asc'
30 sort_update
30 sort_update %w(name is_public created_on)
31 31
32 32 @status = params[:status] ? params[:status].to_i : 1
33 33 c = ARCondition.new(@status == 0 ? "status <> 0" : ["status = ?", @status])
34 34
35 35 unless params[:name].blank?
36 36 name = "%#{params[:name].strip.downcase}%"
37 37 c << ["LOWER(identifier) LIKE ? OR LOWER(name) LIKE ?", name, name]
38 38 end
39 39
40 40 @project_count = Project.count(:conditions => c.conditions)
41 41 @project_pages = Paginator.new self, @project_count,
42 42 per_page_option,
43 43 params['page']
44 44 @projects = Project.find :all, :order => sort_clause,
45 45 :conditions => c.conditions,
46 46 :limit => @project_pages.items_per_page,
47 47 :offset => @project_pages.current.offset
48 48
49 49 render :action => "projects", :layout => false if request.xhr?
50 50 end
51 51
52 52 def plugins
53 53 @plugins = Redmine::Plugin.all
54 54 end
55 55
56 56 # Loads the default configuration
57 57 # (roles, trackers, statuses, workflow, enumerations)
58 58 def default_configuration
59 59 if request.post?
60 60 begin
61 61 Redmine::DefaultData::Loader::load(params[:lang])
62 62 flash[:notice] = l(:notice_default_data_loaded)
63 63 rescue Exception => e
64 64 flash[:error] = l(:error_can_t_load_default_data, e.message)
65 65 end
66 66 end
67 67 redirect_to :action => 'index'
68 68 end
69 69
70 70 def test_email
71 71 raise_delivery_errors = ActionMailer::Base.raise_delivery_errors
72 72 # Force ActionMailer to raise delivery errors so we can catch it
73 73 ActionMailer::Base.raise_delivery_errors = true
74 74 begin
75 75 @test = Mailer.deliver_test(User.current)
76 76 flash[:notice] = l(:notice_email_sent, User.current.mail)
77 77 rescue Exception => e
78 78 flash[:error] = l(:notice_email_error, e.message)
79 79 end
80 80 ActionMailer::Base.raise_delivery_errors = raise_delivery_errors
81 81 redirect_to :controller => 'settings', :action => 'edit', :tab => 'notifications'
82 82 end
83 83
84 84 def info
85 85 @db_adapter_name = ActiveRecord::Base.connection.adapter_name
86 86 @flags = {
87 87 :default_admin_changed => User.find(:first, :conditions => ["login=? and hashed_password=?", 'admin', User.hash_password('admin')]).nil?,
88 88 :file_repository_writable => File.writable?(Attachment.storage_path),
89 89 :rmagick_available => Object.const_defined?(:Magick)
90 90 }
91 91 end
92 92 end
@@ -1,85 +1,87
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 BoardsController < ApplicationController
19 19 before_filter :find_project, :authorize
20 20
21 21 helper :messages
22 22 include MessagesHelper
23 23 helper :sort
24 24 include SortHelper
25 25 helper :watchers
26 26 include WatchersHelper
27 27
28 28 def index
29 29 @boards = @project.boards
30 30 # show the board if there is only one
31 31 if @boards.size == 1
32 32 @board = @boards.first
33 33 show
34 34 end
35 35 end
36 36
37 37 def show
38 sort_init "#{Message.table_name}.updated_on", "desc"
39 sort_update
38 sort_init 'updated_on', 'desc'
39 sort_update 'created_on' => "#{Message.table_name}.created_on",
40 'replies' => "#{Message.table_name}.replies_count",
41 'updated_on' => "#{Message.table_name}.updated_on"
40 42
41 43 @topic_count = @board.topics.count
42 44 @topic_pages = Paginator.new self, @topic_count, per_page_option, params['page']
43 45 @topics = @board.topics.find :all, :order => "#{Message.table_name}.sticky DESC, #{sort_clause}",
44 46 :include => [:author, {:last_reply => :author}],
45 47 :limit => @topic_pages.items_per_page,
46 48 :offset => @topic_pages.current.offset
47 49 render :action => 'show', :layout => !request.xhr?
48 50 end
49 51
50 52 verify :method => :post, :only => [ :destroy ], :redirect_to => { :action => :index }
51 53
52 54 def new
53 55 @board = Board.new(params[:board])
54 56 @board.project = @project
55 57 if request.post? && @board.save
56 58 flash[:notice] = l(:notice_successful_create)
57 59 redirect_to :controller => 'projects', :action => 'settings', :id => @project, :tab => 'boards'
58 60 end
59 61 end
60 62
61 63 def edit
62 64 if request.post? && @board.update_attributes(params[:board])
63 65 case params[:position]
64 66 when 'highest'; @board.move_to_top
65 67 when 'higher'; @board.move_higher
66 68 when 'lower'; @board.move_lower
67 69 when 'lowest'; @board.move_to_bottom
68 70 end if params[:position]
69 71 redirect_to :controller => 'projects', :action => 'settings', :id => @project, :tab => 'boards'
70 72 end
71 73 end
72 74
73 75 def destroy
74 76 @board.destroy
75 77 redirect_to :controller => 'projects', :action => 'settings', :id => @project, :tab => 'boards'
76 78 end
77 79
78 80 private
79 81 def find_project
80 82 @project = Project.find(params[:project_id])
81 83 @board = @project.boards.find(params[:id]) if params[:id]
82 84 rescue ActiveRecord::RecordNotFound
83 85 render_404
84 86 end
85 87 end
@@ -1,493 +1,495
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2008 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 IssuesController < ApplicationController
19 19 menu_item :new_issue, :only => :new
20 20
21 21 before_filter :find_issue, :only => [:show, :edit, :reply, :destroy_attachment]
22 22 before_filter :find_issues, :only => [:bulk_edit, :move, :destroy]
23 23 before_filter :find_project, :only => [:new, :update_form, :preview]
24 24 before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :update_form, :context_menu]
25 25 before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar]
26 26 accept_key_auth :index, :changes
27 27
28 28 helper :journals
29 29 helper :projects
30 30 include ProjectsHelper
31 31 helper :custom_fields
32 32 include CustomFieldsHelper
33 33 helper :ifpdf
34 34 include IfpdfHelper
35 35 helper :issue_relations
36 36 include IssueRelationsHelper
37 37 helper :watchers
38 38 include WatchersHelper
39 39 helper :attachments
40 40 include AttachmentsHelper
41 41 helper :queries
42 42 helper :sort
43 43 include SortHelper
44 44 include IssuesHelper
45 45 helper :timelog
46 46
47 47 def index
48 sort_init "#{Issue.table_name}.id", "desc"
49 sort_update
50 48 retrieve_query
49 sort_init 'id', 'desc'
50 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
51
51 52 if @query.valid?
52 53 limit = per_page_option
53 54 respond_to do |format|
54 55 format.html { }
55 56 format.atom { }
56 57 format.csv { limit = Setting.issues_export_limit.to_i }
57 58 format.pdf { limit = Setting.issues_export_limit.to_i }
58 59 end
59 60 @issue_count = Issue.count(:include => [:status, :project], :conditions => @query.statement)
60 61 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
61 62 @issues = Issue.find :all, :order => sort_clause,
62 63 :include => [ :assigned_to, :status, :tracker, :project, :priority, :category, :fixed_version ],
63 64 :conditions => @query.statement,
64 65 :limit => limit,
65 66 :offset => @issue_pages.current.offset
66 67 respond_to do |format|
67 68 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
68 69 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
69 70 format.csv { send_data(issues_to_csv(@issues, @project).read, :type => 'text/csv; header=present', :filename => 'export.csv') }
70 71 format.pdf { send_data(render(:template => 'issues/index.rfpdf', :layout => false), :type => 'application/pdf', :filename => 'export.pdf') }
71 72 end
72 73 else
73 74 # Send html if the query is not valid
74 75 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
75 76 end
76 77 rescue ActiveRecord::RecordNotFound
77 78 render_404
78 79 end
79 80
80 81 def changes
81 sort_init "#{Issue.table_name}.id", "desc"
82 sort_update
83 82 retrieve_query
83 sort_init 'id', 'desc'
84 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
85
84 86 if @query.valid?
85 87 @journals = Journal.find :all, :include => [ :details, :user, {:issue => [:project, :author, :tracker, :status]} ],
86 88 :conditions => @query.statement,
87 89 :limit => 25,
88 90 :order => "#{Journal.table_name}.created_on DESC"
89 91 end
90 92 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
91 93 render :layout => false, :content_type => 'application/atom+xml'
92 94 rescue ActiveRecord::RecordNotFound
93 95 render_404
94 96 end
95 97
96 98 def show
97 99 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
98 100 @journals.each_with_index {|j,i| j.indice = i+1}
99 101 @journals.reverse! if User.current.wants_comments_in_reverse_order?
100 102 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
101 103 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
102 104 @priorities = Enumeration::get_values('IPRI')
103 105 @time_entry = TimeEntry.new
104 106 respond_to do |format|
105 107 format.html { render :template => 'issues/show.rhtml' }
106 108 format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
107 109 format.pdf { send_data(render(:template => 'issues/show.rfpdf', :layout => false), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
108 110 end
109 111 end
110 112
111 113 # Add a new issue
112 114 # The new issue will be created from an existing one if copy_from parameter is given
113 115 def new
114 116 @issue = Issue.new
115 117 @issue.copy_from(params[:copy_from]) if params[:copy_from]
116 118 @issue.project = @project
117 119 # Tracker must be set before custom field values
118 120 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
119 121 if @issue.tracker.nil?
120 122 flash.now[:error] = 'No tracker is associated to this project. Please check the Project settings.'
121 123 render :nothing => true, :layout => true
122 124 return
123 125 end
124 126 @issue.attributes = params[:issue]
125 127 @issue.author = User.current
126 128
127 129 default_status = IssueStatus.default
128 130 unless default_status
129 131 flash.now[:error] = 'No default issue status is defined. Please check your configuration (Go to "Administration -> Issue statuses").'
130 132 render :nothing => true, :layout => true
131 133 return
132 134 end
133 135 @issue.status = default_status
134 136 @allowed_statuses = ([default_status] + default_status.find_new_statuses_allowed_to(User.current.role_for_project(@project), @issue.tracker)).uniq
135 137
136 138 if request.get? || request.xhr?
137 139 @issue.start_date ||= Date.today
138 140 else
139 141 requested_status = IssueStatus.find_by_id(params[:issue][:status_id])
140 142 # Check that the user is allowed to apply the requested status
141 143 @issue.status = (@allowed_statuses.include? requested_status) ? requested_status : default_status
142 144 if @issue.save
143 145 attach_files(@issue, params[:attachments])
144 146 flash[:notice] = l(:notice_successful_create)
145 147 Mailer.deliver_issue_add(@issue) if Setting.notified_events.include?('issue_added')
146 148 redirect_to :controller => 'issues', :action => 'show', :id => @issue
147 149 return
148 150 end
149 151 end
150 152 @priorities = Enumeration::get_values('IPRI')
151 153 render :layout => !request.xhr?
152 154 end
153 155
154 156 # Attributes that can be updated on workflow transition (without :edit permission)
155 157 # TODO: make it configurable (at least per role)
156 158 UPDATABLE_ATTRS_ON_TRANSITION = %w(status_id assigned_to_id fixed_version_id done_ratio) unless const_defined?(:UPDATABLE_ATTRS_ON_TRANSITION)
157 159
158 160 def edit
159 161 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
160 162 @priorities = Enumeration::get_values('IPRI')
161 163 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
162 164 @time_entry = TimeEntry.new
163 165
164 166 @notes = params[:notes]
165 167 journal = @issue.init_journal(User.current, @notes)
166 168 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
167 169 if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
168 170 attrs = params[:issue].dup
169 171 attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
170 172 attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
171 173 @issue.attributes = attrs
172 174 end
173 175
174 176 if request.post?
175 177 @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
176 178 @time_entry.attributes = params[:time_entry]
177 179 attachments = attach_files(@issue, params[:attachments])
178 180 attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
179 181
180 182 call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
181 183
182 184 if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.save
183 185 # Log spend time
184 186 if current_role.allowed_to?(:log_time)
185 187 @time_entry.save
186 188 end
187 189 if !journal.new_record?
188 190 # Only send notification if something was actually changed
189 191 flash[:notice] = l(:notice_successful_update)
190 192 Mailer.deliver_issue_edit(journal) if Setting.notified_events.include?('issue_updated')
191 193 end
192 194 redirect_to(params[:back_to] || {:action => 'show', :id => @issue})
193 195 end
194 196 end
195 197 rescue ActiveRecord::StaleObjectError
196 198 # Optimistic locking exception
197 199 flash.now[:error] = l(:notice_locking_conflict)
198 200 end
199 201
200 202 def reply
201 203 journal = Journal.find(params[:journal_id]) if params[:journal_id]
202 204 if journal
203 205 user = journal.user
204 206 text = journal.notes
205 207 else
206 208 user = @issue.author
207 209 text = @issue.description
208 210 end
209 211 content = "#{ll(Setting.default_language, :text_user_wrote, user)}\\n> "
210 212 content << text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub('"', '\"').gsub(/(\r?\n|\r\n?)/, "\\n> ") + "\\n\\n"
211 213 render(:update) { |page|
212 214 page.<< "$('notes').value = \"#{content}\";"
213 215 page.show 'update'
214 216 page << "Form.Element.focus('notes');"
215 217 page << "Element.scrollTo('update');"
216 218 page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
217 219 }
218 220 end
219 221
220 222 # Bulk edit a set of issues
221 223 def bulk_edit
222 224 if request.post?
223 225 status = params[:status_id].blank? ? nil : IssueStatus.find_by_id(params[:status_id])
224 226 priority = params[:priority_id].blank? ? nil : Enumeration.find_by_id(params[:priority_id])
225 227 assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
226 228 category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
227 229 fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.versions.find_by_id(params[:fixed_version_id])
228 230
229 231 unsaved_issue_ids = []
230 232 @issues.each do |issue|
231 233 journal = issue.init_journal(User.current, params[:notes])
232 234 issue.priority = priority if priority
233 235 issue.assigned_to = assigned_to if assigned_to || params[:assigned_to_id] == 'none'
234 236 issue.category = category if category || params[:category_id] == 'none'
235 237 issue.fixed_version = fixed_version if fixed_version || params[:fixed_version_id] == 'none'
236 238 issue.start_date = params[:start_date] unless params[:start_date].blank?
237 239 issue.due_date = params[:due_date] unless params[:due_date].blank?
238 240 issue.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
239 241 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
240 242 # Don't save any change to the issue if the user is not authorized to apply the requested status
241 243 if (status.nil? || (issue.status.new_status_allowed_to?(status, current_role, issue.tracker) && issue.status = status)) && issue.save
242 244 # Send notification for each issue (if changed)
243 245 Mailer.deliver_issue_edit(journal) if journal.details.any? && Setting.notified_events.include?('issue_updated')
244 246 else
245 247 # Keep unsaved issue ids to display them in flash error
246 248 unsaved_issue_ids << issue.id
247 249 end
248 250 end
249 251 if unsaved_issue_ids.empty?
250 252 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
251 253 else
252 254 flash[:error] = l(:notice_failed_to_save_issues, unsaved_issue_ids.size, @issues.size, '#' + unsaved_issue_ids.join(', #'))
253 255 end
254 256 redirect_to(params[:back_to] || {:controller => 'issues', :action => 'index', :project_id => @project})
255 257 return
256 258 end
257 259 # Find potential statuses the user could be allowed to switch issues to
258 260 @available_statuses = Workflow.find(:all, :include => :new_status,
259 261 :conditions => {:role_id => current_role.id}).collect(&:new_status).compact.uniq.sort
260 262 end
261 263
262 264 def move
263 265 @allowed_projects = []
264 266 # find projects to which the user is allowed to move the issue
265 267 if User.current.admin?
266 268 # admin is allowed to move issues to any active (visible) project
267 269 @allowed_projects = Project.find(:all, :conditions => Project.visible_by(User.current), :order => 'name')
268 270 else
269 271 User.current.memberships.each {|m| @allowed_projects << m.project if m.role.allowed_to?(:move_issues)}
270 272 end
271 273 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:new_project_id]} if params[:new_project_id]
272 274 @target_project ||= @project
273 275 @trackers = @target_project.trackers
274 276 if request.post?
275 277 new_tracker = params[:new_tracker_id].blank? ? nil : @target_project.trackers.find_by_id(params[:new_tracker_id])
276 278 unsaved_issue_ids = []
277 279 @issues.each do |issue|
278 280 issue.init_journal(User.current)
279 281 unsaved_issue_ids << issue.id unless issue.move_to(@target_project, new_tracker)
280 282 end
281 283 if unsaved_issue_ids.empty?
282 284 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
283 285 else
284 286 flash[:error] = l(:notice_failed_to_save_issues, unsaved_issue_ids.size, @issues.size, '#' + unsaved_issue_ids.join(', #'))
285 287 end
286 288 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
287 289 return
288 290 end
289 291 render :layout => false if request.xhr?
290 292 end
291 293
292 294 def destroy
293 295 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
294 296 if @hours > 0
295 297 case params[:todo]
296 298 when 'destroy'
297 299 # nothing to do
298 300 when 'nullify'
299 301 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
300 302 when 'reassign'
301 303 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
302 304 if reassign_to.nil?
303 305 flash.now[:error] = l(:error_issue_not_found_in_project)
304 306 return
305 307 else
306 308 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
307 309 end
308 310 else
309 311 # display the destroy form
310 312 return
311 313 end
312 314 end
313 315 @issues.each(&:destroy)
314 316 redirect_to :action => 'index', :project_id => @project
315 317 end
316 318
317 319 def destroy_attachment
318 320 a = @issue.attachments.find(params[:attachment_id])
319 321 a.destroy
320 322 journal = @issue.init_journal(User.current)
321 323 journal.details << JournalDetail.new(:property => 'attachment',
322 324 :prop_key => a.id,
323 325 :old_value => a.filename)
324 326 journal.save
325 327 redirect_to :action => 'show', :id => @issue
326 328 end
327 329
328 330 def gantt
329 331 @gantt = Redmine::Helpers::Gantt.new(params)
330 332 retrieve_query
331 333 if @query.valid?
332 334 events = []
333 335 # Issues that have start and due dates
334 336 events += Issue.find(:all,
335 337 :order => "start_date, due_date",
336 338 :include => [:tracker, :status, :assigned_to, :priority, :project],
337 339 :conditions => ["(#{@query.statement}) AND (((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
338 340 )
339 341 # Issues that don't have a due date but that are assigned to a version with a date
340 342 events += Issue.find(:all,
341 343 :order => "start_date, effective_date",
342 344 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version],
343 345 :conditions => ["(#{@query.statement}) AND (((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
344 346 )
345 347 # Versions
346 348 events += Version.find(:all, :include => :project,
347 349 :conditions => ["(#{@query.project_statement}) AND effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
348 350
349 351 @gantt.events = events
350 352 end
351 353
352 354 respond_to do |format|
353 355 format.html { render :template => "issues/gantt.rhtml", :layout => !request.xhr? }
354 356 format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{@project.identifier}-gantt.png") } if @gantt.respond_to?('to_image')
355 357 format.pdf { send_data(render(:template => "issues/gantt.rfpdf", :layout => false), :type => 'application/pdf', :filename => "#{@project.nil? ? '' : "#{@project.identifier}-" }gantt.pdf") }
356 358 end
357 359 end
358 360
359 361 def calendar
360 362 if params[:year] and params[:year].to_i > 1900
361 363 @year = params[:year].to_i
362 364 if params[:month] and params[:month].to_i > 0 and params[:month].to_i < 13
363 365 @month = params[:month].to_i
364 366 end
365 367 end
366 368 @year ||= Date.today.year
367 369 @month ||= Date.today.month
368 370
369 371 @calendar = Redmine::Helpers::Calendar.new(Date.civil(@year, @month, 1), current_language, :month)
370 372 retrieve_query
371 373 if @query.valid?
372 374 events = []
373 375 events += Issue.find(:all,
374 376 :include => [:tracker, :status, :assigned_to, :priority, :project],
375 377 :conditions => ["(#{@query.statement}) AND ((start_date BETWEEN ? AND ?) OR (due_date BETWEEN ? AND ?))", @calendar.startdt, @calendar.enddt, @calendar.startdt, @calendar.enddt]
376 378 )
377 379 events += Version.find(:all, :include => :project,
378 380 :conditions => ["(#{@query.project_statement}) AND effective_date BETWEEN ? AND ?", @calendar.startdt, @calendar.enddt])
379 381
380 382 @calendar.events = events
381 383 end
382 384
383 385 render :layout => false if request.xhr?
384 386 end
385 387
386 388 def context_menu
387 389 @issues = Issue.find_all_by_id(params[:ids], :include => :project)
388 390 if (@issues.size == 1)
389 391 @issue = @issues.first
390 392 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
391 393 end
392 394 projects = @issues.collect(&:project).compact.uniq
393 395 @project = projects.first if projects.size == 1
394 396
395 397 @can = {:edit => (@project && User.current.allowed_to?(:edit_issues, @project)),
396 398 :log_time => (@project && User.current.allowed_to?(:log_time, @project)),
397 399 :update => (@project && (User.current.allowed_to?(:edit_issues, @project) || (User.current.allowed_to?(:change_status, @project) && @allowed_statuses && !@allowed_statuses.empty?))),
398 400 :move => (@project && User.current.allowed_to?(:move_issues, @project)),
399 401 :copy => (@issue && @project.trackers.include?(@issue.tracker) && User.current.allowed_to?(:add_issues, @project)),
400 402 :delete => (@project && User.current.allowed_to?(:delete_issues, @project))
401 403 }
402 404 if @project
403 405 @assignables = @project.assignable_users
404 406 @assignables << @issue.assigned_to if @issue && @issue.assigned_to && !@assignables.include?(@issue.assigned_to)
405 407 end
406 408
407 409 @priorities = Enumeration.get_values('IPRI').reverse
408 410 @statuses = IssueStatus.find(:all, :order => 'position')
409 411 @back = request.env['HTTP_REFERER']
410 412
411 413 render :layout => false
412 414 end
413 415
414 416 def update_form
415 417 @issue = Issue.new(params[:issue])
416 418 render :action => :new, :layout => false
417 419 end
418 420
419 421 def preview
420 422 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
421 423 @attachements = @issue.attachments if @issue
422 424 @text = params[:notes] || (params[:issue] ? params[:issue][:description] : nil)
423 425 render :partial => 'common/preview'
424 426 end
425 427
426 428 private
427 429 def find_issue
428 430 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
429 431 @project = @issue.project
430 432 rescue ActiveRecord::RecordNotFound
431 433 render_404
432 434 end
433 435
434 436 # Filter for bulk operations
435 437 def find_issues
436 438 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
437 439 raise ActiveRecord::RecordNotFound if @issues.empty?
438 440 projects = @issues.collect(&:project).compact.uniq
439 441 if projects.size == 1
440 442 @project = projects.first
441 443 else
442 444 # TODO: let users bulk edit/move/destroy issues from different projects
443 445 render_error 'Can not bulk edit/move/destroy issues from different projects' and return false
444 446 end
445 447 rescue ActiveRecord::RecordNotFound
446 448 render_404
447 449 end
448 450
449 451 def find_project
450 452 @project = Project.find(params[:project_id])
451 453 rescue ActiveRecord::RecordNotFound
452 454 render_404
453 455 end
454 456
455 457 def find_optional_project
456 458 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
457 459 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
458 460 allowed ? true : deny_access
459 461 rescue ActiveRecord::RecordNotFound
460 462 render_404
461 463 end
462 464
463 465 # Retrieve query from session or build a new query
464 466 def retrieve_query
465 467 if !params[:query_id].blank?
466 468 cond = "project_id IS NULL"
467 469 cond << " OR project_id = #{@project.id}" if @project
468 470 @query = Query.find(params[:query_id], :conditions => cond)
469 471 @query.project = @project
470 472 session[:query] = {:id => @query.id, :project_id => @query.project_id}
471 473 else
472 474 if params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
473 475 # Give it a name, required to be valid
474 476 @query = Query.new(:name => "_")
475 477 @query.project = @project
476 478 if params[:fields] and params[:fields].is_a? Array
477 479 params[:fields].each do |field|
478 480 @query.add_filter(field, params[:operators][field], params[:values][field])
479 481 end
480 482 else
481 483 @query.available_filters.keys.each do |field|
482 484 @query.add_short_filter(field, params[field]) if params[field]
483 485 end
484 486 end
485 487 session[:query] = {:project_id => @query.project_id, :filters => @query.filters}
486 488 else
487 489 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
488 490 @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters])
489 491 @query.project = @project
490 492 end
491 493 end
492 494 end
493 495 end
@@ -1,285 +1,289
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 ProjectsController < ApplicationController
19 19 menu_item :overview
20 20 menu_item :activity, :only => :activity
21 21 menu_item :roadmap, :only => :roadmap
22 22 menu_item :files, :only => [:list_files, :add_file]
23 23 menu_item :settings, :only => :settings
24 24 menu_item :issues, :only => [:changelog]
25 25
26 26 before_filter :find_project, :except => [ :index, :list, :add, :activity ]
27 27 before_filter :find_optional_project, :only => :activity
28 28 before_filter :authorize, :except => [ :index, :list, :add, :archive, :unarchive, :destroy, :activity ]
29 29 before_filter :require_admin, :only => [ :add, :archive, :unarchive, :destroy ]
30 30 accept_key_auth :activity
31 31
32 32 helper :sort
33 33 include SortHelper
34 34 helper :custom_fields
35 35 include CustomFieldsHelper
36 36 helper :ifpdf
37 37 include IfpdfHelper
38 38 helper :issues
39 39 helper IssuesHelper
40 40 helper :queries
41 41 include QueriesHelper
42 42 helper :repositories
43 43 include RepositoriesHelper
44 44 include ProjectsHelper
45 45
46 46 # Lists visible projects
47 47 def index
48 48 projects = Project.find :all,
49 49 :conditions => Project.visible_by(User.current),
50 50 :include => :parent
51 51 respond_to do |format|
52 52 format.html {
53 53 @project_tree = projects.group_by {|p| p.parent || p}
54 54 @project_tree.keys.each {|p| @project_tree[p] -= [p]}
55 55 }
56 56 format.atom {
57 57 render_feed(projects.sort_by(&:created_on).reverse.slice(0, Setting.feeds_limit.to_i),
58 58 :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
59 59 }
60 60 end
61 61 end
62 62
63 63 # Add a new project
64 64 def add
65 65 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
66 66 @trackers = Tracker.all
67 67 @root_projects = Project.find(:all,
68 68 :conditions => "parent_id IS NULL AND status = #{Project::STATUS_ACTIVE}",
69 69 :order => 'name')
70 70 @project = Project.new(params[:project])
71 71 if request.get?
72 72 @project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
73 73 @project.trackers = Tracker.all
74 74 @project.is_public = Setting.default_projects_public?
75 75 @project.enabled_module_names = Redmine::AccessControl.available_project_modules
76 76 else
77 77 @project.enabled_module_names = params[:enabled_modules]
78 78 if @project.save
79 79 flash[:notice] = l(:notice_successful_create)
80 80 redirect_to :controller => 'admin', :action => 'projects'
81 81 end
82 82 end
83 83 end
84 84
85 85 # Show @project
86 86 def show
87 87 @members_by_role = @project.members.find(:all, :include => [:user, :role], :order => 'position').group_by {|m| m.role}
88 88 @subprojects = @project.children.find(:all, :conditions => Project.visible_by(User.current))
89 89 @news = @project.news.find(:all, :limit => 5, :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
90 90 @trackers = @project.rolled_up_trackers
91 91
92 92 cond = @project.project_condition(Setting.display_subprojects_issues?)
93 93 Issue.visible_by(User.current) do
94 94 @open_issues_by_tracker = Issue.count(:group => :tracker,
95 95 :include => [:project, :status, :tracker],
96 96 :conditions => ["(#{cond}) AND #{IssueStatus.table_name}.is_closed=?", false])
97 97 @total_issues_by_tracker = Issue.count(:group => :tracker,
98 98 :include => [:project, :status, :tracker],
99 99 :conditions => cond)
100 100 end
101 101 TimeEntry.visible_by(User.current) do
102 102 @total_hours = TimeEntry.sum(:hours,
103 103 :include => :project,
104 104 :conditions => cond).to_f
105 105 end
106 106 @key = User.current.rss_key
107 107 end
108 108
109 109 def settings
110 110 @root_projects = Project.find(:all,
111 111 :conditions => ["parent_id IS NULL AND status = #{Project::STATUS_ACTIVE} AND id <> ?", @project.id],
112 112 :order => 'name')
113 113 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
114 114 @issue_category ||= IssueCategory.new
115 115 @member ||= @project.members.new
116 116 @trackers = Tracker.all
117 117 @repository ||= @project.repository
118 118 @wiki ||= @project.wiki
119 119 end
120 120
121 121 # Edit @project
122 122 def edit
123 123 if request.post?
124 124 @project.attributes = params[:project]
125 125 if @project.save
126 126 flash[:notice] = l(:notice_successful_update)
127 127 redirect_to :action => 'settings', :id => @project
128 128 else
129 129 settings
130 130 render :action => 'settings'
131 131 end
132 132 end
133 133 end
134 134
135 135 def modules
136 136 @project.enabled_module_names = params[:enabled_modules]
137 137 redirect_to :action => 'settings', :id => @project, :tab => 'modules'
138 138 end
139 139
140 140 def archive
141 141 @project.archive if request.post? && @project.active?
142 142 redirect_to :controller => 'admin', :action => 'projects'
143 143 end
144 144
145 145 def unarchive
146 146 @project.unarchive if request.post? && !@project.active?
147 147 redirect_to :controller => 'admin', :action => 'projects'
148 148 end
149 149
150 150 # Delete @project
151 151 def destroy
152 152 @project_to_destroy = @project
153 153 if request.post? and params[:confirm]
154 154 @project_to_destroy.destroy
155 155 redirect_to :controller => 'admin', :action => 'projects'
156 156 end
157 157 # hide project in layout
158 158 @project = nil
159 159 end
160 160
161 161 # Add a new issue category to @project
162 162 def add_issue_category
163 163 @category = @project.issue_categories.build(params[:category])
164 164 if request.post? and @category.save
165 165 respond_to do |format|
166 166 format.html do
167 167 flash[:notice] = l(:notice_successful_create)
168 168 redirect_to :action => 'settings', :tab => 'categories', :id => @project
169 169 end
170 170 format.js do
171 171 # IE doesn't support the replace_html rjs method for select box options
172 172 render(:update) {|page| page.replace "issue_category_id",
173 173 content_tag('select', '<option></option>' + options_from_collection_for_select(@project.issue_categories, 'id', 'name', @category.id), :id => 'issue_category_id', :name => 'issue[category_id]')
174 174 }
175 175 end
176 176 end
177 177 end
178 178 end
179 179
180 180 # Add a new version to @project
181 181 def add_version
182 182 @version = @project.versions.build(params[:version])
183 183 if request.post? and @version.save
184 184 flash[:notice] = l(:notice_successful_create)
185 185 redirect_to :action => 'settings', :tab => 'versions', :id => @project
186 186 end
187 187 end
188 188
189 189 def add_file
190 190 if request.post?
191 191 @version = @project.versions.find_by_id(params[:version_id])
192 192 attachments = attach_files(@version, params[:attachments])
193 193 Mailer.deliver_attachments_added(attachments) if !attachments.empty? && Setting.notified_events.include?('file_added')
194 194 redirect_to :controller => 'projects', :action => 'list_files', :id => @project
195 195 end
196 196 @versions = @project.versions.sort
197 197 end
198 198
199 199 def list_files
200 sort_init "#{Attachment.table_name}.filename", "asc"
201 sort_update
200 sort_init 'filename', 'asc'
201 sort_update 'filename' => "#{Attachment.table_name}.filename",
202 'created_on' => "#{Attachment.table_name}.created_on",
203 'size' => "#{Attachment.table_name}.filesize",
204 'downloads' => "#{Attachment.table_name}.downloads"
205
202 206 @versions = @project.versions.find(:all, :include => :attachments, :order => sort_clause).sort.reverse
203 207 render :layout => !request.xhr?
204 208 end
205 209
206 210 # Show changelog for @project
207 211 def changelog
208 212 @trackers = @project.trackers.find(:all, :conditions => ["is_in_chlog=?", true], :order => 'position')
209 213 retrieve_selected_tracker_ids(@trackers)
210 214 @versions = @project.versions.sort
211 215 end
212 216
213 217 def roadmap
214 218 @trackers = @project.trackers.find(:all, :conditions => ["is_in_roadmap=?", true])
215 219 retrieve_selected_tracker_ids(@trackers)
216 220 @versions = @project.versions.sort
217 221 @versions = @versions.select {|v| !v.completed? } unless params[:completed]
218 222 end
219 223
220 224 def activity
221 225 @days = Setting.activity_days_default.to_i
222 226
223 227 if params[:from]
224 228 begin; @date_to = params[:from].to_date + 1; rescue; end
225 229 end
226 230
227 231 @date_to ||= Date.today + 1
228 232 @date_from = @date_to - @days
229 233 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
230 234 @author = (params[:user_id].blank? ? nil : User.active.find(params[:user_id]))
231 235
232 236 @activity = Redmine::Activity::Fetcher.new(User.current, :project => @project,
233 237 :with_subprojects => @with_subprojects,
234 238 :author => @author)
235 239 @activity.scope_select {|t| !params["show_#{t}"].nil?}
236 240 @activity.scope = (@author.nil? ? :default : :all) if @activity.scope.empty?
237 241
238 242 events = @activity.events(@date_from, @date_to)
239 243
240 244 respond_to do |format|
241 245 format.html {
242 246 @events_by_day = events.group_by(&:event_date)
243 247 render :layout => false if request.xhr?
244 248 }
245 249 format.atom {
246 250 title = l(:label_activity)
247 251 if @author
248 252 title = @author.name
249 253 elsif @activity.scope.size == 1
250 254 title = l("label_#{@activity.scope.first.singularize}_plural")
251 255 end
252 256 render_feed(events, :title => "#{@project || Setting.app_title}: #{title}")
253 257 }
254 258 end
255 259
256 260 rescue ActiveRecord::RecordNotFound
257 261 render_404
258 262 end
259 263
260 264 private
261 265 # Find project of id params[:id]
262 266 # if not found, redirect to project list
263 267 # Used as a before_filter
264 268 def find_project
265 269 @project = Project.find(params[:id])
266 270 rescue ActiveRecord::RecordNotFound
267 271 render_404
268 272 end
269 273
270 274 def find_optional_project
271 275 return true unless params[:id]
272 276 @project = Project.find(params[:id])
273 277 authorize
274 278 rescue ActiveRecord::RecordNotFound
275 279 render_404
276 280 end
277 281
278 282 def retrieve_selected_tracker_ids(selectable_trackers)
279 283 if ids = params[:tracker_ids]
280 284 @selected_tracker_ids = (ids.is_a? Array) ? ids.collect { |id| id.to_i.to_s } : ids.split('/').collect { |id| id.to_i.to_s }
281 285 else
282 286 @selected_tracker_ids = selectable_trackers.collect {|t| t.id.to_s }
283 287 end
284 288 end
285 289 end
@@ -1,285 +1,290
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 TimelogController < ApplicationController
19 19 menu_item :issues
20 20 before_filter :find_project, :authorize, :only => [:edit, :destroy]
21 21 before_filter :find_optional_project, :only => [:report, :details]
22 22
23 23 verify :method => :post, :only => :destroy, :redirect_to => { :action => :details }
24 24
25 25 helper :sort
26 26 include SortHelper
27 27 helper :issues
28 28 include TimelogHelper
29 29 helper :custom_fields
30 30 include CustomFieldsHelper
31 31
32 32 def report
33 33 @available_criterias = { 'project' => {:sql => "#{TimeEntry.table_name}.project_id",
34 34 :klass => Project,
35 35 :label => :label_project},
36 36 'version' => {:sql => "#{Issue.table_name}.fixed_version_id",
37 37 :klass => Version,
38 38 :label => :label_version},
39 39 'category' => {:sql => "#{Issue.table_name}.category_id",
40 40 :klass => IssueCategory,
41 41 :label => :field_category},
42 42 'member' => {:sql => "#{TimeEntry.table_name}.user_id",
43 43 :klass => User,
44 44 :label => :label_member},
45 45 'tracker' => {:sql => "#{Issue.table_name}.tracker_id",
46 46 :klass => Tracker,
47 47 :label => :label_tracker},
48 48 'activity' => {:sql => "#{TimeEntry.table_name}.activity_id",
49 49 :klass => Enumeration,
50 50 :label => :label_activity},
51 51 'issue' => {:sql => "#{TimeEntry.table_name}.issue_id",
52 52 :klass => Issue,
53 53 :label => :label_issue}
54 54 }
55 55
56 56 # Add list and boolean custom fields as available criterias
57 57 custom_fields = (@project.nil? ? IssueCustomField.for_all : @project.all_issue_custom_fields)
58 58 custom_fields.select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
59 59 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'Issue' AND c.customized_id = #{Issue.table_name}.id)",
60 60 :format => cf.field_format,
61 61 :label => cf.name}
62 62 end if @project
63 63
64 64 # Add list and boolean time entry custom fields
65 65 TimeEntryCustomField.find(:all).select {|cf| %w(list bool).include? cf.field_format }.each do |cf|
66 66 @available_criterias["cf_#{cf.id}"] = {:sql => "(SELECT c.value FROM #{CustomValue.table_name} c WHERE c.custom_field_id = #{cf.id} AND c.customized_type = 'TimeEntry' AND c.customized_id = #{TimeEntry.table_name}.id)",
67 67 :format => cf.field_format,
68 68 :label => cf.name}
69 69 end
70 70
71 71 @criterias = params[:criterias] || []
72 72 @criterias = @criterias.select{|criteria| @available_criterias.has_key? criteria}
73 73 @criterias.uniq!
74 74 @criterias = @criterias[0,3]
75 75
76 76 @columns = (params[:columns] && %w(year month week day).include?(params[:columns])) ? params[:columns] : 'month'
77 77
78 78 retrieve_date_range
79 79
80 80 unless @criterias.empty?
81 81 sql_select = @criterias.collect{|criteria| @available_criterias[criteria][:sql] + " AS " + criteria}.join(', ')
82 82 sql_group_by = @criterias.collect{|criteria| @available_criterias[criteria][:sql]}.join(', ')
83 83
84 84 sql = "SELECT #{sql_select}, tyear, tmonth, tweek, spent_on, SUM(hours) AS hours"
85 85 sql << " FROM #{TimeEntry.table_name}"
86 86 sql << " LEFT JOIN #{Issue.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id"
87 87 sql << " LEFT JOIN #{Project.table_name} ON #{TimeEntry.table_name}.project_id = #{Project.table_name}.id"
88 88 sql << " WHERE"
89 89 sql << " (%s) AND" % @project.project_condition(Setting.display_subprojects_issues?) if @project
90 90 sql << " (%s) AND" % Project.allowed_to_condition(User.current, :view_time_entries)
91 91 sql << " (spent_on BETWEEN '%s' AND '%s')" % [ActiveRecord::Base.connection.quoted_date(@from.to_time), ActiveRecord::Base.connection.quoted_date(@to.to_time)]
92 92 sql << " GROUP BY #{sql_group_by}, tyear, tmonth, tweek, spent_on"
93 93
94 94 @hours = ActiveRecord::Base.connection.select_all(sql)
95 95
96 96 @hours.each do |row|
97 97 case @columns
98 98 when 'year'
99 99 row['year'] = row['tyear']
100 100 when 'month'
101 101 row['month'] = "#{row['tyear']}-#{row['tmonth']}"
102 102 when 'week'
103 103 row['week'] = "#{row['tyear']}-#{row['tweek']}"
104 104 when 'day'
105 105 row['day'] = "#{row['spent_on']}"
106 106 end
107 107 end
108 108
109 109 @total_hours = @hours.inject(0) {|s,k| s = s + k['hours'].to_f}
110 110
111 111 @periods = []
112 112 # Date#at_beginning_of_ not supported in Rails 1.2.x
113 113 date_from = @from.to_time
114 114 # 100 columns max
115 115 while date_from <= @to.to_time && @periods.length < 100
116 116 case @columns
117 117 when 'year'
118 118 @periods << "#{date_from.year}"
119 119 date_from = (date_from + 1.year).at_beginning_of_year
120 120 when 'month'
121 121 @periods << "#{date_from.year}-#{date_from.month}"
122 122 date_from = (date_from + 1.month).at_beginning_of_month
123 123 when 'week'
124 124 @periods << "#{date_from.year}-#{date_from.to_date.cweek}"
125 125 date_from = (date_from + 7.day).at_beginning_of_week
126 126 when 'day'
127 127 @periods << "#{date_from.to_date}"
128 128 date_from = date_from + 1.day
129 129 end
130 130 end
131 131 end
132 132
133 133 respond_to do |format|
134 134 format.html { render :layout => !request.xhr? }
135 135 format.csv { send_data(report_to_csv(@criterias, @periods, @hours).read, :type => 'text/csv; header=present', :filename => 'timelog.csv') }
136 136 end
137 137 end
138 138
139 139 def details
140 140 sort_init 'spent_on', 'desc'
141 sort_update
141 sort_update 'spent_on' => 'spent_on',
142 'user' => 'user_id',
143 'activity' => 'activity_id',
144 'project' => "#{Project.table_name}.name",
145 'issue' => 'issue_id',
146 'hours' => 'hours'
142 147
143 148 cond = ARCondition.new
144 149 if @project.nil?
145 150 cond << Project.allowed_to_condition(User.current, :view_time_entries)
146 151 elsif @issue.nil?
147 152 cond << @project.project_condition(Setting.display_subprojects_issues?)
148 153 else
149 154 cond << ["#{TimeEntry.table_name}.issue_id = ?", @issue.id]
150 155 end
151 156
152 157 retrieve_date_range
153 158 cond << ['spent_on BETWEEN ? AND ?', @from, @to]
154 159
155 160 TimeEntry.visible_by(User.current) do
156 161 respond_to do |format|
157 162 format.html {
158 163 # Paginate results
159 164 @entry_count = TimeEntry.count(:include => :project, :conditions => cond.conditions)
160 165 @entry_pages = Paginator.new self, @entry_count, per_page_option, params['page']
161 166 @entries = TimeEntry.find(:all,
162 167 :include => [:project, :activity, :user, {:issue => :tracker}],
163 168 :conditions => cond.conditions,
164 169 :order => sort_clause,
165 170 :limit => @entry_pages.items_per_page,
166 171 :offset => @entry_pages.current.offset)
167 172 @total_hours = TimeEntry.sum(:hours, :include => :project, :conditions => cond.conditions).to_f
168 173
169 174 render :layout => !request.xhr?
170 175 }
171 176 format.atom {
172 177 entries = TimeEntry.find(:all,
173 178 :include => [:project, :activity, :user, {:issue => :tracker}],
174 179 :conditions => cond.conditions,
175 180 :order => "#{TimeEntry.table_name}.created_on DESC",
176 181 :limit => Setting.feeds_limit.to_i)
177 182 render_feed(entries, :title => l(:label_spent_time))
178 183 }
179 184 format.csv {
180 185 # Export all entries
181 186 @entries = TimeEntry.find(:all,
182 187 :include => [:project, :activity, :user, {:issue => [:tracker, :assigned_to, :priority]}],
183 188 :conditions => cond.conditions,
184 189 :order => sort_clause)
185 190 send_data(entries_to_csv(@entries).read, :type => 'text/csv; header=present', :filename => 'timelog.csv')
186 191 }
187 192 end
188 193 end
189 194 end
190 195
191 196 def edit
192 197 render_403 and return if @time_entry && !@time_entry.editable_by?(User.current)
193 198 @time_entry ||= TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
194 199 @time_entry.attributes = params[:time_entry]
195 200 if request.post? and @time_entry.save
196 201 flash[:notice] = l(:notice_successful_update)
197 202 redirect_back_or_default :action => 'details', :project_id => @time_entry.project
198 203 return
199 204 end
200 205 end
201 206
202 207 def destroy
203 208 render_404 and return unless @time_entry
204 209 render_403 and return unless @time_entry.editable_by?(User.current)
205 210 @time_entry.destroy
206 211 flash[:notice] = l(:notice_successful_delete)
207 212 redirect_to :back
208 213 rescue ::ActionController::RedirectBackError
209 214 redirect_to :action => 'details', :project_id => @time_entry.project
210 215 end
211 216
212 217 private
213 218 def find_project
214 219 if params[:id]
215 220 @time_entry = TimeEntry.find(params[:id])
216 221 @project = @time_entry.project
217 222 elsif params[:issue_id]
218 223 @issue = Issue.find(params[:issue_id])
219 224 @project = @issue.project
220 225 elsif params[:project_id]
221 226 @project = Project.find(params[:project_id])
222 227 else
223 228 render_404
224 229 return false
225 230 end
226 231 rescue ActiveRecord::RecordNotFound
227 232 render_404
228 233 end
229 234
230 235 def find_optional_project
231 236 if !params[:issue_id].blank?
232 237 @issue = Issue.find(params[:issue_id])
233 238 @project = @issue.project
234 239 elsif !params[:project_id].blank?
235 240 @project = Project.find(params[:project_id])
236 241 end
237 242 deny_access unless User.current.allowed_to?(:view_time_entries, @project, :global => true)
238 243 end
239 244
240 245 # Retrieves the date range based on predefined ranges or specific from/to param dates
241 246 def retrieve_date_range
242 247 @free_period = false
243 248 @from, @to = nil, nil
244 249
245 250 if params[:period_type] == '1' || (params[:period_type].nil? && !params[:period].nil?)
246 251 case params[:period].to_s
247 252 when 'today'
248 253 @from = @to = Date.today
249 254 when 'yesterday'
250 255 @from = @to = Date.today - 1
251 256 when 'current_week'
252 257 @from = Date.today - (Date.today.cwday - 1)%7
253 258 @to = @from + 6
254 259 when 'last_week'
255 260 @from = Date.today - 7 - (Date.today.cwday - 1)%7
256 261 @to = @from + 6
257 262 when '7_days'
258 263 @from = Date.today - 7
259 264 @to = Date.today
260 265 when 'current_month'
261 266 @from = Date.civil(Date.today.year, Date.today.month, 1)
262 267 @to = (@from >> 1) - 1
263 268 when 'last_month'
264 269 @from = Date.civil(Date.today.year, Date.today.month, 1) << 1
265 270 @to = (@from >> 1) - 1
266 271 when '30_days'
267 272 @from = Date.today - 30
268 273 @to = Date.today
269 274 when 'current_year'
270 275 @from = Date.civil(Date.today.year, 1, 1)
271 276 @to = Date.civil(Date.today.year, 12, 31)
272 277 end
273 278 elsif params[:period_type] == '2' || (params[:period_type].nil? && (!params[:from].nil? || !params[:to].nil?))
274 279 begin; @from = params[:from].to_s.to_date unless params[:from].blank?; rescue; end
275 280 begin; @to = params[:to].to_s.to_date unless params[:to].blank?; rescue; end
276 281 @free_period = true
277 282 else
278 283 # default
279 284 end
280 285
281 286 @from, @to = @to, @from if @from && @to && @from > @to
282 287 @from ||= (TimeEntry.minimum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries)) || Date.today) - 1
283 288 @to ||= (TimeEntry.maximum(:spent_on, :include => :project, :conditions => Project.allowed_to_condition(User.current, :view_time_entries)) || Date.today)
284 289 end
285 290 end
@@ -1,104 +1,104
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 UsersController < ApplicationController
19 19 before_filter :require_admin
20 20
21 21 helper :sort
22 22 include SortHelper
23 23 helper :custom_fields
24 24 include CustomFieldsHelper
25 25
26 26 def index
27 27 list
28 28 render :action => 'list' unless request.xhr?
29 29 end
30 30
31 31 def list
32 32 sort_init 'login', 'asc'
33 sort_update
33 sort_update %w(login firstname lastname mail admin created_on last_login_on)
34 34
35 35 @status = params[:status] ? params[:status].to_i : 1
36 36 c = ARCondition.new(@status == 0 ? "status <> 0" : ["status = ?", @status])
37 37
38 38 unless params[:name].blank?
39 39 name = "%#{params[:name].strip.downcase}%"
40 40 c << ["LOWER(login) LIKE ? OR LOWER(firstname) LIKE ? OR LOWER(lastname) LIKE ?", name, name, name]
41 41 end
42 42
43 43 @user_count = User.count(:conditions => c.conditions)
44 44 @user_pages = Paginator.new self, @user_count,
45 45 per_page_option,
46 46 params['page']
47 47 @users = User.find :all,:order => sort_clause,
48 48 :conditions => c.conditions,
49 49 :limit => @user_pages.items_per_page,
50 50 :offset => @user_pages.current.offset
51 51
52 52 render :action => "list", :layout => false if request.xhr?
53 53 end
54 54
55 55 def add
56 56 if request.get?
57 57 @user = User.new(:language => Setting.default_language)
58 58 else
59 59 @user = User.new(params[:user])
60 60 @user.admin = params[:user][:admin] || false
61 61 @user.login = params[:user][:login]
62 62 @user.password, @user.password_confirmation = params[:password], params[:password_confirmation] unless @user.auth_source_id
63 63 if @user.save
64 64 Mailer.deliver_account_information(@user, params[:password]) if params[:send_information]
65 65 flash[:notice] = l(:notice_successful_create)
66 66 redirect_to :action => 'list'
67 67 end
68 68 end
69 69 @auth_sources = AuthSource.find(:all)
70 70 end
71 71
72 72 def edit
73 73 @user = User.find(params[:id])
74 74 if request.post?
75 75 @user.admin = params[:user][:admin] if params[:user][:admin]
76 76 @user.login = params[:user][:login] if params[:user][:login]
77 77 @user.password, @user.password_confirmation = params[:password], params[:password_confirmation] unless params[:password].nil? or params[:password].empty? or @user.auth_source_id
78 78 if @user.update_attributes(params[:user])
79 79 flash[:notice] = l(:notice_successful_update)
80 80 # Give a string to redirect_to otherwise it would use status param as the response code
81 81 redirect_to(url_for(:action => 'list', :status => params[:status], :page => params[:page]))
82 82 end
83 83 end
84 84 @auth_sources = AuthSource.find(:all)
85 85 @roles = Role.find_all_givable
86 86 @projects = Project.find(:all, :order => 'name', :conditions => "status=#{Project::STATUS_ACTIVE}") - @user.projects
87 87 @membership ||= Member.new
88 88 @memberships = @user.memberships
89 89 end
90 90
91 91 def edit_membership
92 92 @user = User.find(params[:id])
93 93 @membership = params[:membership_id] ? Member.find(params[:membership_id]) : Member.new(:user => @user)
94 94 @membership.attributes = params[:membership]
95 95 @membership.save if request.post?
96 96 redirect_to :action => 'edit', :id => @user, :tab => 'memberships'
97 97 end
98 98
99 99 def destroy_membership
100 100 @user = User.find(params[:id])
101 101 Member.find(params[:membership_id]).destroy if request.post?
102 102 redirect_to :action => 'edit', :id => @user, :tab => 'memberships'
103 103 end
104 104 end
@@ -1,55 +1,55
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 QueriesHelper
19 19
20 20 def operators_for_select(filter_type)
21 21 Query.operators_by_filter_type[filter_type].collect {|o| [l(Query.operators[o]), o]}
22 22 end
23 23
24 24 def column_header(column)
25 column.sortable ? sort_header_tag(column.sortable, :caption => column.caption,
26 :default_order => column.default_order) :
25 column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption,
26 :default_order => column.default_order) :
27 27 content_tag('th', column.caption)
28 28 end
29 29
30 30 def column_content(column, issue)
31 31 if column.is_a?(QueryCustomFieldColumn)
32 32 cv = issue.custom_values.detect {|v| v.custom_field_id == column.custom_field.id}
33 33 show_value(cv)
34 34 else
35 35 value = issue.send(column.name)
36 36 if value.is_a?(Date)
37 37 format_date(value)
38 38 elsif value.is_a?(Time)
39 39 format_time(value)
40 40 else
41 41 case column.name
42 42 when :subject
43 43 h((@project.nil? || @project != issue.project) ? "#{issue.project.name} - " : '') +
44 44 link_to(h(value), :controller => 'issues', :action => 'show', :id => issue)
45 45 when :done_ratio
46 46 progress_bar(value, :width => '80px')
47 47 when :fixed_version
48 48 link_to(h(value), { :controller => 'versions', :action => 'show', :id => issue.fixed_version_id })
49 49 else
50 50 h(value)
51 51 end
52 52 end
53 53 end
54 54 end
55 55 end
@@ -1,160 +1,168
1 1 # Helpers to sort tables using clickable column headers.
2 2 #
3 3 # Author: Stuart Rackham <srackham@methods.co.nz>, March 2005.
4 4 # License: This source code is released under the MIT license.
5 5 #
6 6 # - Consecutive clicks toggle the column's sort order.
7 7 # - Sort state is maintained by a session hash entry.
8 8 # - Icon image identifies sort column and state.
9 9 # - Typically used in conjunction with the Pagination module.
10 10 #
11 11 # Example code snippets:
12 12 #
13 13 # Controller:
14 14 #
15 15 # helper :sort
16 16 # include SortHelper
17 17 #
18 18 # def list
19 19 # sort_init 'last_name'
20 20 # sort_update
21 21 # @items = Contact.find_all nil, sort_clause
22 22 # end
23 23 #
24 24 # Controller (using Pagination module):
25 25 #
26 26 # helper :sort
27 27 # include SortHelper
28 28 #
29 29 # def list
30 30 # sort_init 'last_name'
31 31 # sort_update
32 32 # @contact_pages, @items = paginate :contacts,
33 33 # :order_by => sort_clause,
34 34 # :per_page => 10
35 35 # end
36 36 #
37 37 # View (table header in list.rhtml):
38 38 #
39 39 # <thead>
40 40 # <tr>
41 41 # <%= sort_header_tag('id', :title => 'Sort by contact ID') %>
42 42 # <%= sort_header_tag('last_name', :caption => 'Name') %>
43 43 # <%= sort_header_tag('phone') %>
44 44 # <%= sort_header_tag('address', :width => 200) %>
45 45 # </tr>
46 46 # </thead>
47 47 #
48 48 # - The ascending and descending sort icon images are sort_asc.png and
49 49 # sort_desc.png and reside in the application's images directory.
50 50 # - Introduces instance variables: @sort_name, @sort_default.
51 51 # - Introduces params :sort_key and :sort_order.
52 52 #
53 53 module SortHelper
54 54
55 55 # Initializes the default sort column (default_key) and sort order
56 56 # (default_order).
57 57 #
58 58 # - default_key is a column attribute name.
59 59 # - default_order is 'asc' or 'desc'.
60 60 # - name is the name of the session hash entry that stores the sort state,
61 61 # defaults to '<controller_name>_sort'.
62 62 #
63 63 def sort_init(default_key, default_order='asc', name=nil)
64 64 @sort_name = name || params[:controller] + params[:action] + '_sort'
65 65 @sort_default = {:key => default_key, :order => default_order}
66 66 end
67 67
68 68 # Updates the sort state. Call this in the controller prior to calling
69 69 # sort_clause.
70 #
71 def sort_update()
72 if params[:sort_key]
73 sort = {:key => params[:sort_key], :order => params[:sort_order]}
70 # sort_keys can be either an array or a hash of allowed keys
71 def sort_update(sort_keys)
72 sort_key = params[:sort_key]
73 sort_key = nil unless (sort_keys.is_a?(Array) ? sort_keys.include?(sort_key) : sort_keys[sort_key])
74
75 sort_order = (params[:sort_order] == 'desc' ? 'DESC' : 'ASC')
76
77 if sort_key
78 sort = {:key => sort_key, :order => sort_order}
74 79 elsif session[@sort_name]
75 80 sort = session[@sort_name] # Previous sort.
76 81 else
77 82 sort = @sort_default
78 83 end
79 84 session[@sort_name] = sort
85
86 sort_column = (sort_keys.is_a?(Hash) ? sort_keys[sort[:key]] : sort[:key])
87 @sort_clause = (sort_column.blank? ? '' : "#{sort_column} #{sort[:order]}")
80 88 end
81 89
82 90 # Returns an SQL sort clause corresponding to the current sort state.
83 91 # Use this to sort the controller's table items collection.
84 92 #
85 93 def sort_clause()
86 session[@sort_name][:key] + ' ' + (session[@sort_name][:order] || 'ASC')
94 @sort_clause || '' #session[@sort_name][:key] + ' ' + (session[@sort_name][:order] || 'ASC')
87 95 end
88 96
89 97 # Returns a link which sorts by the named column.
90 98 #
91 99 # - column is the name of an attribute in the sorted record collection.
92 100 # - The optional caption explicitly specifies the displayed link text.
93 101 # - A sort icon image is positioned to the right of the sort link.
94 102 #
95 103 def sort_link(column, caption, default_order)
96 104 key, order = session[@sort_name][:key], session[@sort_name][:order]
97 105 if key == column
98 106 if order.downcase == 'asc'
99 107 icon = 'sort_asc.png'
100 108 order = 'desc'
101 109 else
102 110 icon = 'sort_desc.png'
103 111 order = 'asc'
104 112 end
105 113 else
106 114 icon = nil
107 115 order = default_order
108 116 end
109 117 caption = titleize(Inflector::humanize(column)) unless caption
110 118
111 119 sort_options = { :sort_key => column, :sort_order => order }
112 120 # don't reuse params if filters are present
113 121 url_options = params.has_key?(:set_filter) ? sort_options : params.merge(sort_options)
114 122
115 123 link_to_remote(caption,
116 124 {:update => "content", :url => url_options},
117 125 {:href => url_for(url_options)}) +
118 126 (icon ? nbsp(2) + image_tag(icon) : '')
119 127 end
120 128
121 129 # Returns a table header <th> tag with a sort link for the named column
122 130 # attribute.
123 131 #
124 132 # Options:
125 133 # :caption The displayed link name (defaults to titleized column name).
126 134 # :title The tag's 'title' attribute (defaults to 'Sort by :caption').
127 135 #
128 136 # Other options hash entries generate additional table header tag attributes.
129 137 #
130 138 # Example:
131 139 #
132 140 # <%= sort_header_tag('id', :title => 'Sort by contact ID', :width => 40) %>
133 141 #
134 142 # Renders:
135 143 #
136 144 # <th title="Sort by contact ID" width="40">
137 145 # <a href="/contact/list?sort_order=desc&amp;sort_key=id">Id</a>
138 146 # &nbsp;&nbsp;<img alt="Sort_asc" src="/images/sort_asc.png" />
139 147 # </th>
140 148 #
141 149 def sort_header_tag(column, options = {})
142 150 caption = options.delete(:caption) || titleize(Inflector::humanize(column))
143 151 default_order = options.delete(:default_order) || 'asc'
144 152 options[:title]= l(:label_sort_by, "\"#{caption}\"") unless options[:title]
145 153 content_tag('th', sort_link(column, caption, default_order), options)
146 154 end
147 155
148 156 private
149 157
150 158 # Return n non-breaking spaces.
151 159 def nbsp(n)
152 160 '&nbsp;' * n
153 161 end
154 162
155 163 # Return capitalized title.
156 164 def titleize(title)
157 165 title.split.map {|w| w.capitalize }.join(' ')
158 166 end
159 167
160 168 end
@@ -1,247 +1,247
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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 Mailer < ActionMailer::Base
19 19 helper :application
20 20 helper :issues
21 21 helper :custom_fields
22 22
23 23 include ActionController::UrlWriter
24 24
25 25 def issue_add(issue)
26 26 redmine_headers 'Project' => issue.project.identifier,
27 27 'Issue-Id' => issue.id,
28 28 'Issue-Author' => issue.author.login
29 29 redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
30 30 recipients issue.recipients
31 31 subject "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] (#{issue.status.name}) #{issue.subject}"
32 32 body :issue => issue,
33 33 :issue_url => url_for(:controller => 'issues', :action => 'show', :id => issue)
34 34 end
35 35
36 36 def issue_edit(journal)
37 37 issue = journal.journalized
38 38 redmine_headers 'Project' => issue.project.identifier,
39 39 'Issue-Id' => issue.id,
40 40 'Issue-Author' => issue.author.login
41 41 redmine_headers 'Issue-Assignee' => issue.assigned_to.login if issue.assigned_to
42 42 recipients issue.recipients
43 43 # Watchers in cc
44 44 cc(issue.watcher_recipients - @recipients)
45 45 s = "[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}] "
46 46 s << "(#{issue.status.name}) " if journal.new_value_for('status_id')
47 47 s << issue.subject
48 48 subject s
49 49 body :issue => issue,
50 50 :journal => journal,
51 51 :issue_url => url_for(:controller => 'issues', :action => 'show', :id => issue)
52 52 end
53 53
54 54 def reminder(user, issues, days)
55 55 set_language_if_valid user.language
56 56 recipients user.mail
57 57 subject l(:mail_subject_reminder, issues.size)
58 58 body :issues => issues,
59 59 :days => days,
60 :issues_url => url_for(:controller => 'issues', :action => 'index', :set_filter => 1, :assigned_to_id => user.id, :sort_key => 'issues.due_date', :sort_order => 'asc')
60 :issues_url => url_for(:controller => 'issues', :action => 'index', :set_filter => 1, :assigned_to_id => user.id, :sort_key => 'due_date', :sort_order => 'asc')
61 61 end
62 62
63 63 def document_added(document)
64 64 redmine_headers 'Project' => document.project.identifier
65 65 recipients document.project.recipients
66 66 subject "[#{document.project.name}] #{l(:label_document_new)}: #{document.title}"
67 67 body :document => document,
68 68 :document_url => url_for(:controller => 'documents', :action => 'show', :id => document)
69 69 end
70 70
71 71 def attachments_added(attachments)
72 72 container = attachments.first.container
73 73 added_to = ''
74 74 added_to_url = ''
75 75 case container.class.name
76 76 when 'Version'
77 77 added_to_url = url_for(:controller => 'projects', :action => 'list_files', :id => container.project_id)
78 78 added_to = "#{l(:label_version)}: #{container.name}"
79 79 when 'Document'
80 80 added_to_url = url_for(:controller => 'documents', :action => 'show', :id => container.id)
81 81 added_to = "#{l(:label_document)}: #{container.title}"
82 82 end
83 83 redmine_headers 'Project' => container.project.identifier
84 84 recipients container.project.recipients
85 85 subject "[#{container.project.name}] #{l(:label_attachment_new)}"
86 86 body :attachments => attachments,
87 87 :added_to => added_to,
88 88 :added_to_url => added_to_url
89 89 end
90 90
91 91 def news_added(news)
92 92 redmine_headers 'Project' => news.project.identifier
93 93 recipients news.project.recipients
94 94 subject "[#{news.project.name}] #{l(:label_news)}: #{news.title}"
95 95 body :news => news,
96 96 :news_url => url_for(:controller => 'news', :action => 'show', :id => news)
97 97 end
98 98
99 99 def message_posted(message, recipients)
100 100 redmine_headers 'Project' => message.project.identifier,
101 101 'Topic-Id' => (message.parent_id || message.id)
102 102 recipients(recipients)
103 103 subject "[#{message.board.project.name} - #{message.board.name}] #{message.subject}"
104 104 body :message => message,
105 105 :message_url => url_for(:controller => 'messages', :action => 'show', :board_id => message.board_id, :id => message.root)
106 106 end
107 107
108 108 def account_information(user, password)
109 109 set_language_if_valid user.language
110 110 recipients user.mail
111 111 subject l(:mail_subject_register, Setting.app_title)
112 112 body :user => user,
113 113 :password => password,
114 114 :login_url => url_for(:controller => 'account', :action => 'login')
115 115 end
116 116
117 117 def account_activation_request(user)
118 118 # Send the email to all active administrators
119 119 recipients User.active.find(:all, :conditions => {:admin => true}).collect { |u| u.mail }.compact
120 120 subject l(:mail_subject_account_activation_request, Setting.app_title)
121 121 body :user => user,
122 122 :url => url_for(:controller => 'users', :action => 'index', :status => User::STATUS_REGISTERED, :sort_key => 'created_on', :sort_order => 'desc')
123 123 end
124 124
125 125 def lost_password(token)
126 126 set_language_if_valid(token.user.language)
127 127 recipients token.user.mail
128 128 subject l(:mail_subject_lost_password, Setting.app_title)
129 129 body :token => token,
130 130 :url => url_for(:controller => 'account', :action => 'lost_password', :token => token.value)
131 131 end
132 132
133 133 def register(token)
134 134 set_language_if_valid(token.user.language)
135 135 recipients token.user.mail
136 136 subject l(:mail_subject_register, Setting.app_title)
137 137 body :token => token,
138 138 :url => url_for(:controller => 'account', :action => 'activate', :token => token.value)
139 139 end
140 140
141 141 def test(user)
142 142 set_language_if_valid(user.language)
143 143 recipients user.mail
144 144 subject 'Redmine test'
145 145 body :url => url_for(:controller => 'welcome')
146 146 end
147 147
148 148 # Overrides default deliver! method to prevent from sending an email
149 149 # with no recipient, cc or bcc
150 150 def deliver!(mail = @mail)
151 151 return false if (recipients.nil? || recipients.empty?) &&
152 152 (cc.nil? || cc.empty?) &&
153 153 (bcc.nil? || bcc.empty?)
154 154 super
155 155 end
156 156
157 157 # Sends reminders to issue assignees
158 158 # Available options:
159 159 # * :days => how many days in the future to remind about (defaults to 7)
160 160 # * :tracker => id of tracker for filtering issues (defaults to all trackers)
161 161 # * :project => id or identifier of project to process (defaults to all projects)
162 162 def self.reminders(options={})
163 163 days = options[:days] || 7
164 164 project = options[:project] ? Project.find(options[:project]) : nil
165 165 tracker = options[:tracker] ? Tracker.find(options[:tracker]) : nil
166 166
167 167 s = ARCondition.new ["#{IssueStatus.table_name}.is_closed = ? AND #{Issue.table_name}.due_date <= ?", false, days.day.from_now.to_date]
168 168 s << "#{Issue.table_name}.assigned_to_id IS NOT NULL"
169 169 s << "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}"
170 170 s << "#{Issue.table_name}.project_id = #{project.id}" if project
171 171 s << "#{Issue.table_name}.tracker_id = #{tracker.id}" if tracker
172 172
173 173 issues_by_assignee = Issue.find(:all, :include => [:status, :assigned_to, :project, :tracker],
174 174 :conditions => s.conditions
175 175 ).group_by(&:assigned_to)
176 176 issues_by_assignee.each do |assignee, issues|
177 177 deliver_reminder(assignee, issues, days) unless assignee.nil?
178 178 end
179 179 end
180 180
181 181 private
182 182 def initialize_defaults(method_name)
183 183 super
184 184 set_language_if_valid Setting.default_language
185 185 from Setting.mail_from
186 186
187 187 # URL options
188 188 h = Setting.host_name
189 189 h = h.to_s.gsub(%r{\/.*$}, '') unless ActionController::AbstractRequest.relative_url_root.blank?
190 190 default_url_options[:host] = h
191 191 default_url_options[:protocol] = Setting.protocol
192 192
193 193 # Common headers
194 194 headers 'X-Mailer' => 'Redmine',
195 195 'X-Redmine-Host' => Setting.host_name,
196 196 'X-Redmine-Site' => Setting.app_title
197 197 end
198 198
199 199 # Appends a Redmine header field (name is prepended with 'X-Redmine-')
200 200 def redmine_headers(h)
201 201 h.each { |k,v| headers["X-Redmine-#{k}"] = v }
202 202 end
203 203
204 204 # Overrides the create_mail method
205 205 def create_mail
206 206 # Removes the current user from the recipients and cc
207 207 # if he doesn't want to receive notifications about what he does
208 208 if User.current.pref[:no_self_notified]
209 209 recipients.delete(User.current.mail) if recipients
210 210 cc.delete(User.current.mail) if cc
211 211 end
212 212 # Blind carbon copy recipients
213 213 if Setting.bcc_recipients?
214 214 bcc([recipients, cc].flatten.compact.uniq)
215 215 recipients []
216 216 cc []
217 217 end
218 218 super
219 219 end
220 220
221 221 # Renders a message with the corresponding layout
222 222 def render_message(method_name, body)
223 223 layout = method_name.match(%r{text\.html\.(rhtml|rxml)}) ? 'layout.text.html.rhtml' : 'layout.text.plain.rhtml'
224 224 body[:content_for_layout] = render(:file => method_name, :body => body)
225 225 ActionView::Base.new(template_root, body, self).render(:file => "mailer/#{layout}", :use_full_path => true)
226 226 end
227 227
228 228 # for the case of plain text only
229 229 def body(*params)
230 230 value = super(*params)
231 231 if Setting.plain_text_mail?
232 232 templates = Dir.glob("#{template_path}/#{@template}.text.plain.{rhtml,erb}")
233 233 unless String === @body or templates.empty?
234 234 template = File.basename(templates.first)
235 235 @body[:content_for_layout] = render(:file => template, :body => @body)
236 236 @body = ActionView::Base.new(template_root, @body, self).render(:file => "mailer/layout.text.plain.rhtml", :use_full_path => true)
237 237 return @body
238 238 end
239 239 end
240 240 return value
241 241 end
242 242
243 243 # Makes partial rendering work with Rails 1.2 (retro-compatibility)
244 244 def self.controller_path
245 245 ''
246 246 end unless respond_to?('controller_path')
247 247 end
@@ -1,62 +1,62
1 1 <%= breadcrumb link_to(l(:label_board_plural), {:controller => 'boards', :action => 'index', :project_id => @project}) %>
2 2
3 3 <div class="contextual">
4 4 <%= link_to_if_authorized l(:label_message_new),
5 5 {:controller => 'messages', :action => 'new', :board_id => @board},
6 6 :class => 'icon icon-add',
7 7 :onclick => 'Element.show("add-message"); return false;' %>
8 8 <%= watcher_tag(@board, User.current) %>
9 9 </div>
10 10
11 11 <div id="add-message" style="display:none;">
12 12 <h2><%= link_to h(@board.name), :controller => 'boards', :action => 'show', :project_id => @project, :id => @board %> &#187; <%= l(:label_message_new) %></h2>
13 13 <% form_for :message, @message, :url => {:controller => 'messages', :action => 'new', :board_id => @board}, :html => {:multipart => true, :id => 'message-form'} do |f| %>
14 14 <%= render :partial => 'messages/form', :locals => {:f => f} %>
15 15 <p><%= submit_tag l(:button_create) %>
16 16 <%= link_to_remote l(:label_preview),
17 17 { :url => { :controller => 'messages', :action => 'preview', :board_id => @board },
18 18 :method => 'post',
19 19 :update => 'preview',
20 20 :with => "Form.serialize('message-form')",
21 21 :complete => "Element.scrollTo('preview')"
22 22 }, :accesskey => accesskey(:preview) %> |
23 23 <%= link_to l(:button_cancel), "#", :onclick => 'Element.hide("add-message")' %></p>
24 24 <% end %>
25 25 <div id="preview" class="wiki"></div>
26 26 </div>
27 27
28 28 <h2><%=h @board.name %></h2>
29 29 <p class="subtitle"><%=h @board.description %></p>
30 30
31 31 <% if @topics.any? %>
32 32 <table class="list messages">
33 33 <thead><tr>
34 34 <th><%= l(:field_subject) %></th>
35 35 <th><%= l(:field_author) %></th>
36 <%= sort_header_tag("#{Message.table_name}.created_on", :caption => l(:field_created_on)) %>
37 <%= sort_header_tag("#{Message.table_name}.replies_count", :caption => l(:label_reply_plural)) %>
38 <%= sort_header_tag("#{Message.table_name}.updated_on", :caption => l(:label_message_last)) %>
36 <%= sort_header_tag('created_on', :caption => l(:field_created_on)) %>
37 <%= sort_header_tag('replies', :caption => l(:label_reply_plural)) %>
38 <%= sort_header_tag('updated_on', :caption => l(:label_message_last)) %>
39 39 </tr></thead>
40 40 <tbody>
41 41 <% @topics.each do |topic| %>
42 42 <tr class="message <%= cycle 'odd', 'even' %> <%= topic.sticky? ? 'sticky' : '' %> <%= topic.locked? ? 'locked' : '' %>">
43 43 <td class="subject"><%= link_to h(topic.subject), { :controller => 'messages', :action => 'show', :board_id => @board, :id => topic }, :class => 'icon' %></td>
44 44 <td class="author" align="center"><%= topic.author %></td>
45 45 <td class="created_on" align="center"><%= format_time(topic.created_on) %></td>
46 46 <td class="replies" align="center"><%= topic.replies_count %></td>
47 47 <td class="last_message">
48 48 <% if topic.last_reply %>
49 49 <%= authoring topic.last_reply.created_on, topic.last_reply.author %><br />
50 50 <%= link_to_message topic.last_reply %>
51 51 <% end %>
52 52 </td>
53 53 </tr>
54 54 <% end %>
55 55 </tbody>
56 56 </table>
57 57 <p class="pagination"><%= pagination_links_full @topic_pages, @topic_count %></p>
58 58 <% else %>
59 59 <p class="nodata"><%= l(:label_no_data) %></p>
60 60 <% end %>
61 61
62 62 <% html_title h(@board.name) %>
@@ -1,22 +1,22
1 1 <% form_tag({}) do -%>
2 2 <table class="list issues">
3 3 <thead><tr>
4 4 <th><%= link_to image_tag('toggle_check.png'), {}, :onclick => 'toggleIssuesSelection(Element.up(this, "form")); return false;',
5 5 :title => "#{l(:button_check_all)}/#{l(:button_uncheck_all)}" %>
6 6 </th>
7 <%= sort_header_tag("#{Issue.table_name}.id", :caption => '#', :default_order => 'desc') %>
7 <%= sort_header_tag('id', :caption => '#', :default_order => 'desc') %>
8 8 <% query.columns.each do |column| %>
9 9 <%= column_header(column) %>
10 10 <% end %>
11 11 </tr></thead>
12 12 <tbody>
13 13 <% issues.each do |issue| -%>
14 14 <tr id="issue-<%= issue.id %>" class="hascontextmenu <%= cycle('odd', 'even') %> <%= css_issue_classes(issue) %>">
15 15 <td class="checkbox"><%= check_box_tag("ids[]", issue.id, false, :id => nil) %></td>
16 16 <td><%= link_to issue.id, :controller => 'issues', :action => 'show', :id => issue %></td>
17 17 <% query.columns.each do |column| %><%= content_tag 'td', column_content(column, issue), :class => column.name %><% end %>
18 18 </tr>
19 19 <% end -%>
20 20 </tbody>
21 21 </table>
22 22 <% end -%>
@@ -1,26 +1,26
1 1 <h3><%= l(:label_issue_plural) %></h3>
2 2 <%= link_to l(:label_issue_view_all), { :controller => 'issues', :action => 'index', :project_id => @project, :set_filter => 1 } %><br />
3 3 <% if @project %>
4 4 <%= link_to l(:field_summary), :controller => 'reports', :action => 'issue_report', :id => @project %><br />
5 5 <%= link_to l(:label_change_log), :controller => 'projects', :action => 'changelog', :id => @project %><br />
6 6 <% end %>
7 7 <%= call_hook(:view_issues_sidebar_issues_bottom) %>
8 8
9 9 <% planning_links = []
10 10 planning_links << link_to(l(:label_calendar), :action => 'calendar', :project_id => @project) if User.current.allowed_to?(:view_calendar, @project, :global => true)
11 11 planning_links << link_to(l(:label_gantt), :action => 'gantt', :project_id => @project) if User.current.allowed_to?(:view_gantt, @project, :global => true)
12 12 %>
13 13 <% unless planning_links.empty? %>
14 14 <h3><%= l(:label_planning) %></h3>
15 15 <p><%= planning_links.join(' | ') %></p>
16 16 <%= call_hook(:view_issues_sidebar_planning_bottom) %>
17 17 <% end %>
18 18
19 19 <% unless sidebar_queries.empty? -%>
20 20 <h3><%= l(:label_query_plural) %></h3>
21 21
22 22 <% sidebar_queries.each do |query| -%>
23 <%= link_to query.name, :controller => 'issues', :action => 'index', :project_id => @project, :query_id => query %><br />
23 <%= link_to(h(query.name), :controller => 'issues', :action => 'index', :project_id => @project, :query_id => query) %><br />
24 24 <% end -%>
25 25 <%= call_hook(:view_issues_sidebar_queries_bottom) %>
26 26 <% end -%>
@@ -1,44 +1,44
1 1 <div class="contextual">
2 2 <%= link_to_if_authorized l(:label_attachment_new), {:controller => 'projects', :action => 'add_file', :id => @project}, :class => 'icon icon-add' %>
3 3 </div>
4 4
5 5 <h2><%=l(:label_attachment_plural)%></h2>
6 6
7 7 <% delete_allowed = authorize_for('versions', 'destroy_file') %>
8 8
9 9 <table class="list">
10 10 <thead><tr>
11 11 <th><%=l(:field_version)%></th>
12 <%= sort_header_tag("#{Attachment.table_name}.filename", :caption => l(:field_filename)) %>
13 <%= sort_header_tag("#{Attachment.table_name}.created_on", :caption => l(:label_date), :default_order => 'desc') %>
14 <%= sort_header_tag("#{Attachment.table_name}.filesize", :caption => l(:field_filesize), :default_order => 'desc') %>
15 <%= sort_header_tag("#{Attachment.table_name}.downloads", :caption => l(:label_downloads_abbr), :default_order => 'desc') %>
12 <%= sort_header_tag('filename', :caption => l(:field_filename)) %>
13 <%= sort_header_tag('created_on', :caption => l(:label_date), :default_order => 'desc') %>
14 <%= sort_header_tag('size', :caption => l(:field_filesize), :default_order => 'desc') %>
15 <%= sort_header_tag('downloads', :caption => l(:label_downloads_abbr), :default_order => 'desc') %>
16 16 <th>MD5</th>
17 17 <% if delete_allowed %><th></th><% end %>
18 18 </tr></thead>
19 19 <tbody>
20 20 <% for version in @versions %>
21 21 <% unless version.attachments.empty? %>
22 22 <tr><th colspan="7" align="left"><span class="icon icon-package"><b><%= version.name %></b></span></th></tr>
23 23 <% for file in version.attachments %>
24 24 <tr class="<%= cycle("odd", "even") %>">
25 25 <td></td>
26 26 <td><%= link_to_attachment file, :download => true, :title => file.description %></td>
27 27 <td align="center"><%= format_time(file.created_on) %></td>
28 28 <td align="center"><%= number_to_human_size(file.filesize) %></td>
29 29 <td align="center"><%= file.downloads %></td>
30 30 <td align="center"><small><%= file.digest %></small></td>
31 31 <% if delete_allowed %>
32 32 <td align="center">
33 33 <%= link_to_if_authorized image_tag('delete.png'), {:controller => 'versions', :action => 'destroy_file', :id => version, :attachment_id => file}, :confirm => l(:text_are_you_sure), :method => :post %>
34 34 </td>
35 35 <% end %>
36 36 </tr>
37 37 <% end
38 38 reset_cycle %>
39 39 <% end %>
40 40 <% end %>
41 41 </tbody>
42 42 </table>
43 43
44 44 <% html_title(l(:label_attachment_plural)) -%>
@@ -1,41 +1,41
1 1 <table class="list time-entries">
2 2 <thead>
3 3 <tr>
4 4 <%= sort_header_tag('spent_on', :caption => l(:label_date), :default_order => 'desc') %>
5 <%= sort_header_tag('user_id', :caption => l(:label_member)) %>
6 <%= sort_header_tag('activity_id', :caption => l(:label_activity)) %>
7 <%= sort_header_tag("#{Project.table_name}.name", :caption => l(:label_project)) %>
8 <%= sort_header_tag('issue_id', :caption => l(:label_issue), :default_order => 'desc') %>
5 <%= sort_header_tag('user', :caption => l(:label_member)) %>
6 <%= sort_header_tag('activity', :caption => l(:label_activity)) %>
7 <%= sort_header_tag('project', :caption => l(:label_project)) %>
8 <%= sort_header_tag('issue', :caption => l(:label_issue), :default_order => 'desc') %>
9 9 <th><%= l(:field_comments) %></th>
10 10 <%= sort_header_tag('hours', :caption => l(:field_hours)) %>
11 11 <th></th>
12 12 </tr>
13 13 </thead>
14 14 <tbody>
15 15 <% entries.each do |entry| -%>
16 16 <tr class="time-entry <%= cycle("odd", "even") %>">
17 17 <td class="spent_on"><%= format_date(entry.spent_on) %></td>
18 18 <td class="user"><%=h entry.user %></td>
19 19 <td class="activity"><%=h entry.activity %></td>
20 20 <td class="project"><%=h entry.project %></td>
21 21 <td class="subject">
22 22 <% if entry.issue -%>
23 23 <%= link_to_issue entry.issue %>: <%= h(truncate(entry.issue.subject, 50)) -%>
24 24 <% end -%>
25 25 </td>
26 26 <td class="comments"><%=h entry.comments %></td>
27 27 <td class="hours"><%= html_hours("%.2f" % entry.hours) %></td>
28 28 <td align="center">
29 29 <% if entry.editable_by?(User.current) -%>
30 30 <%= link_to image_tag('edit.png'), {:controller => 'timelog', :action => 'edit', :id => entry, :project_id => nil},
31 31 :title => l(:button_edit) %>
32 32 <%= link_to image_tag('delete.png'), {:controller => 'timelog', :action => 'destroy', :id => entry, :project_id => nil},
33 33 :confirm => l(:text_are_you_sure),
34 34 :method => :post,
35 35 :title => l(:button_delete) %>
36 36 <% end -%>
37 37 </td>
38 38 </tr>
39 39 <% end -%>
40 40 </tbody>
41 41 </table>
@@ -1,32 +1,32
1 1 <div class="contextual">
2 2 <%= link_to(l(:button_edit), {:action => 'edit', :page => @page.title}, :class => 'icon icon-edit') %>
3 3 <%= link_to(l(:label_history), {:action => 'history', :page => @page.title}, :class => 'icon icon-history') %>
4 4 </div>
5 5
6 6 <h2><%= @page.pretty_title %></h2>
7 7
8 8 <p>
9 9 <%= l(:label_version) %> <%= link_to @annotate.content.version, :action => 'index', :page => @page.title, :version => @annotate.content.version %>
10 10 <em>(<%= @annotate.content.author ? @annotate.content.author.name : "anonyme" %>, <%= format_time(@annotate.content.updated_on) %>)</em>
11 11 </p>
12 12
13 13 <% colors = Hash.new {|k,v| k[v] = (k.size % 12) } %>
14 14
15 15 <table class="filecontent annotate CodeRay ">
16 16 <tbody>
17 17 <% line_num = 1 %>
18 18 <% @annotate.lines.each do |line| -%>
19 19 <tr class="bloc-<%= colors[line[0]] %>">
20 20 <th class="line-num"><%= line_num %></th>
21 21 <td class="revision"><%= link_to line[0], :controller => 'wiki', :action => 'index', :id => @project, :page => @page.title, :version => line[0] %></td>
22 22 <td class="author"><%= h(line[1]) %></td>
23 <td class="line-code"><pre><%= line[2] %></pre></td>
23 <td class="line-code"><pre><%=h line[2] %></pre></td>
24 24 </tr>
25 25 <% line_num += 1 %>
26 26 <% end -%>
27 27 </tbody>
28 28 </table>
29 29
30 30 <% content_for :header_tags do %>
31 31 <%= stylesheet_link_tag 'scm' %>
32 32 <% end %>
@@ -1,1172 +1,1175
1 1 # vim:ts=4:sw=4:
2 2 # = RedCloth - Textile and Markdown Hybrid for Ruby
3 3 #
4 4 # Homepage:: http://whytheluckystiff.net/ruby/redcloth/
5 5 # Author:: why the lucky stiff (http://whytheluckystiff.net/)
6 6 # Copyright:: (cc) 2004 why the lucky stiff (and his puppet organizations.)
7 7 # License:: BSD
8 8 #
9 9 # (see http://hobix.com/textile/ for a Textile Reference.)
10 10 #
11 11 # Based on (and also inspired by) both:
12 12 #
13 13 # PyTextile: http://diveintomark.org/projects/textile/textile.py.txt
14 14 # Textism for PHP: http://www.textism.com/tools/textile/
15 15 #
16 16 #
17 17
18 18 # = RedCloth
19 19 #
20 20 # RedCloth is a Ruby library for converting Textile and/or Markdown
21 21 # into HTML. You can use either format, intermingled or separately.
22 22 # You can also extend RedCloth to honor your own custom text stylings.
23 23 #
24 24 # RedCloth users are encouraged to use Textile if they are generating
25 25 # HTML and to use Markdown if others will be viewing the plain text.
26 26 #
27 27 # == What is Textile?
28 28 #
29 29 # Textile is a simple formatting style for text
30 30 # documents, loosely based on some HTML conventions.
31 31 #
32 32 # == Sample Textile Text
33 33 #
34 34 # h2. This is a title
35 35 #
36 36 # h3. This is a subhead
37 37 #
38 38 # This is a bit of paragraph.
39 39 #
40 40 # bq. This is a blockquote.
41 41 #
42 42 # = Writing Textile
43 43 #
44 44 # A Textile document consists of paragraphs. Paragraphs
45 45 # can be specially formatted by adding a small instruction
46 46 # to the beginning of the paragraph.
47 47 #
48 48 # h[n]. Header of size [n].
49 49 # bq. Blockquote.
50 50 # # Numeric list.
51 51 # * Bulleted list.
52 52 #
53 53 # == Quick Phrase Modifiers
54 54 #
55 55 # Quick phrase modifiers are also included, to allow formatting
56 56 # of small portions of text within a paragraph.
57 57 #
58 58 # \_emphasis\_
59 59 # \_\_italicized\_\_
60 60 # \*strong\*
61 61 # \*\*bold\*\*
62 62 # ??citation??
63 63 # -deleted text-
64 64 # +inserted text+
65 65 # ^superscript^
66 66 # ~subscript~
67 67 # @code@
68 68 # %(classname)span%
69 69 #
70 70 # ==notextile== (leave text alone)
71 71 #
72 72 # == Links
73 73 #
74 74 # To make a hypertext link, put the link text in "quotation
75 75 # marks" followed immediately by a colon and the URL of the link.
76 76 #
77 77 # Optional: text in (parentheses) following the link text,
78 78 # but before the closing quotation mark, will become a Title
79 79 # attribute for the link, visible as a tool tip when a cursor is above it.
80 80 #
81 81 # Example:
82 82 #
83 83 # "This is a link (This is a title) ":http://www.textism.com
84 84 #
85 85 # Will become:
86 86 #
87 87 # <a href="http://www.textism.com" title="This is a title">This is a link</a>
88 88 #
89 89 # == Images
90 90 #
91 91 # To insert an image, put the URL for the image inside exclamation marks.
92 92 #
93 93 # Optional: text that immediately follows the URL in (parentheses) will
94 94 # be used as the Alt text for the image. Images on the web should always
95 95 # have descriptive Alt text for the benefit of readers using non-graphical
96 96 # browsers.
97 97 #
98 98 # Optional: place a colon followed by a URL immediately after the
99 99 # closing ! to make the image into a link.
100 100 #
101 101 # Example:
102 102 #
103 103 # !http://www.textism.com/common/textist.gif(Textist)!
104 104 #
105 105 # Will become:
106 106 #
107 107 # <img src="http://www.textism.com/common/textist.gif" alt="Textist" />
108 108 #
109 109 # With a link:
110 110 #
111 111 # !/common/textist.gif(Textist)!:http://textism.com
112 112 #
113 113 # Will become:
114 114 #
115 115 # <a href="http://textism.com"><img src="/common/textist.gif" alt="Textist" /></a>
116 116 #
117 117 # == Defining Acronyms
118 118 #
119 119 # HTML allows authors to define acronyms via the tag. The definition appears as a
120 120 # tool tip when a cursor hovers over the acronym. A crucial aid to clear writing,
121 121 # this should be used at least once for each acronym in documents where they appear.
122 122 #
123 123 # To quickly define an acronym in Textile, place the full text in (parentheses)
124 124 # immediately following the acronym.
125 125 #
126 126 # Example:
127 127 #
128 128 # ACLU(American Civil Liberties Union)
129 129 #
130 130 # Will become:
131 131 #
132 132 # <acronym title="American Civil Liberties Union">ACLU</acronym>
133 133 #
134 134 # == Adding Tables
135 135 #
136 136 # In Textile, simple tables can be added by seperating each column by
137 137 # a pipe.
138 138 #
139 139 # |a|simple|table|row|
140 140 # |And|Another|table|row|
141 141 #
142 142 # Attributes are defined by style definitions in parentheses.
143 143 #
144 144 # table(border:1px solid black).
145 145 # (background:#ddd;color:red). |{}| | | |
146 146 #
147 147 # == Using RedCloth
148 148 #
149 149 # RedCloth is simply an extension of the String class, which can handle
150 150 # Textile formatting. Use it like a String and output HTML with its
151 151 # RedCloth#to_html method.
152 152 #
153 153 # doc = RedCloth.new "
154 154 #
155 155 # h2. Test document
156 156 #
157 157 # Just a simple test."
158 158 #
159 159 # puts doc.to_html
160 160 #
161 161 # By default, RedCloth uses both Textile and Markdown formatting, with
162 162 # Textile formatting taking precedence. If you want to turn off Markdown
163 163 # formatting, to boost speed and limit the processor:
164 164 #
165 165 # class RedCloth::Textile.new( str )
166 166
167 167 class RedCloth3 < String
168 168
169 169 VERSION = '3.0.4'
170 170 DEFAULT_RULES = [:textile, :markdown]
171 171
172 172 #
173 173 # Two accessor for setting security restrictions.
174 174 #
175 175 # This is a nice thing if you're using RedCloth for
176 176 # formatting in public places (e.g. Wikis) where you
177 177 # don't want users to abuse HTML for bad things.
178 178 #
179 179 # If +:filter_html+ is set, HTML which wasn't
180 180 # created by the Textile processor will be escaped.
181 181 #
182 182 # If +:filter_styles+ is set, it will also disable
183 183 # the style markup specifier. ('{color: red}')
184 184 #
185 185 attr_accessor :filter_html, :filter_styles
186 186
187 187 #
188 188 # Accessor for toggling hard breaks.
189 189 #
190 190 # If +:hard_breaks+ is set, single newlines will
191 191 # be converted to HTML break tags. This is the
192 192 # default behavior for traditional RedCloth.
193 193 #
194 194 attr_accessor :hard_breaks
195 195
196 196 # Accessor for toggling lite mode.
197 197 #
198 198 # In lite mode, block-level rules are ignored. This means
199 199 # that tables, paragraphs, lists, and such aren't available.
200 200 # Only the inline markup for bold, italics, entities and so on.
201 201 #
202 202 # r = RedCloth.new( "And then? She *fell*!", [:lite_mode] )
203 203 # r.to_html
204 204 # #=> "And then? She <strong>fell</strong>!"
205 205 #
206 206 attr_accessor :lite_mode
207 207
208 208 #
209 209 # Accessor for toggling span caps.
210 210 #
211 211 # Textile places `span' tags around capitalized
212 212 # words by default, but this wreaks havoc on Wikis.
213 213 # If +:no_span_caps+ is set, this will be
214 214 # suppressed.
215 215 #
216 216 attr_accessor :no_span_caps
217 217
218 218 #
219 219 # Establishes the markup predence. Available rules include:
220 220 #
221 221 # == Textile Rules
222 222 #
223 223 # The following textile rules can be set individually. Or add the complete
224 224 # set of rules with the single :textile rule, which supplies the rule set in
225 225 # the following precedence:
226 226 #
227 227 # refs_textile:: Textile references (i.e. [hobix]http://hobix.com/)
228 228 # block_textile_table:: Textile table block structures
229 229 # block_textile_lists:: Textile list structures
230 230 # block_textile_prefix:: Textile blocks with prefixes (i.e. bq., h2., etc.)
231 231 # inline_textile_image:: Textile inline images
232 232 # inline_textile_link:: Textile inline links
233 233 # inline_textile_span:: Textile inline spans
234 234 # glyphs_textile:: Textile entities (such as em-dashes and smart quotes)
235 235 #
236 236 # == Markdown
237 237 #
238 238 # refs_markdown:: Markdown references (for example: [hobix]: http://hobix.com/)
239 239 # block_markdown_setext:: Markdown setext headers
240 240 # block_markdown_atx:: Markdown atx headers
241 241 # block_markdown_rule:: Markdown horizontal rules
242 242 # block_markdown_bq:: Markdown blockquotes
243 243 # block_markdown_lists:: Markdown lists
244 244 # inline_markdown_link:: Markdown links
245 245 attr_accessor :rules
246 246
247 247 # Returns a new RedCloth object, based on _string_ and
248 248 # enforcing all the included _restrictions_.
249 249 #
250 250 # r = RedCloth.new( "h1. A <b>bold</b> man", [:filter_html] )
251 251 # r.to_html
252 252 # #=>"<h1>A &lt;b&gt;bold&lt;/b&gt; man</h1>"
253 253 #
254 254 def initialize( string, restrictions = [] )
255 255 restrictions.each { |r| method( "#{ r }=" ).call( true ) }
256 256 super( string )
257 257 end
258 258
259 259 #
260 260 # Generates HTML from the Textile contents.
261 261 #
262 262 # r = RedCloth.new( "And then? She *fell*!" )
263 263 # r.to_html( true )
264 264 # #=>"And then? She <strong>fell</strong>!"
265 265 #
266 266 def to_html( *rules )
267 267 rules = DEFAULT_RULES if rules.empty?
268 268 # make our working copy
269 269 text = self.dup
270 270
271 271 @urlrefs = {}
272 272 @shelf = []
273 273 textile_rules = [:refs_textile, :block_textile_table, :block_textile_lists,
274 274 :block_textile_prefix, :inline_textile_image, :inline_textile_link,
275 275 :inline_textile_code, :inline_textile_span, :glyphs_textile]
276 276 markdown_rules = [:refs_markdown, :block_markdown_setext, :block_markdown_atx, :block_markdown_rule,
277 277 :block_markdown_bq, :block_markdown_lists,
278 278 :inline_markdown_reflink, :inline_markdown_link]
279 279 @rules = rules.collect do |rule|
280 280 case rule
281 281 when :markdown
282 282 markdown_rules
283 283 when :textile
284 284 textile_rules
285 285 else
286 286 rule
287 287 end
288 288 end.flatten
289 289
290 290 # standard clean up
291 291 incoming_entities text
292 292 clean_white_space text
293 293
294 294 # start processor
295 295 @pre_list = []
296 296 rip_offtags text
297 297 no_textile text
298 298 escape_html_tags text
299 299 hard_break text
300 300 unless @lite_mode
301 301 refs text
302 302 # need to do this before text is split by #blocks
303 303 block_textile_quotes text
304 304 blocks text
305 305 end
306 306 inline text
307 307 smooth_offtags text
308 308
309 309 retrieve text
310 310
311 311 text.gsub!( /<\/?notextile>/, '' )
312 312 text.gsub!( /x%x%/, '&#38;' )
313 313 clean_html text if filter_html
314 314 text.strip!
315 315 text
316 316
317 317 end
318 318
319 319 #######
320 320 private
321 321 #######
322 322 #
323 323 # Mapping of 8-bit ASCII codes to HTML numerical entity equivalents.
324 324 # (from PyTextile)
325 325 #
326 326 TEXTILE_TAGS =
327 327
328 328 [[128, 8364], [129, 0], [130, 8218], [131, 402], [132, 8222], [133, 8230],
329 329 [134, 8224], [135, 8225], [136, 710], [137, 8240], [138, 352], [139, 8249],
330 330 [140, 338], [141, 0], [142, 0], [143, 0], [144, 0], [145, 8216], [146, 8217],
331 331 [147, 8220], [148, 8221], [149, 8226], [150, 8211], [151, 8212], [152, 732],
332 332 [153, 8482], [154, 353], [155, 8250], [156, 339], [157, 0], [158, 0], [159, 376]].
333 333
334 334 collect! do |a, b|
335 335 [a.chr, ( b.zero? and "" or "&#{ b };" )]
336 336 end
337 337
338 338 #
339 339 # Regular expressions to convert to HTML.
340 340 #
341 341 A_HLGN = /(?:(?:<>|<|>|\=|[()]+)+)/
342 342 A_VLGN = /[\-^~]/
343 343 C_CLAS = '(?:\([^)]+\))'
344 344 C_LNGE = '(?:\[[^\[\]]+\])'
345 345 C_STYL = '(?:\{[^}]+\})'
346 346 S_CSPN = '(?:\\\\\d+)'
347 347 S_RSPN = '(?:/\d+)'
348 348 A = "(?:#{A_HLGN}?#{A_VLGN}?|#{A_VLGN}?#{A_HLGN}?)"
349 349 S = "(?:#{S_CSPN}?#{S_RSPN}|#{S_RSPN}?#{S_CSPN}?)"
350 350 C = "(?:#{C_CLAS}?#{C_STYL}?#{C_LNGE}?|#{C_STYL}?#{C_LNGE}?#{C_CLAS}?|#{C_LNGE}?#{C_STYL}?#{C_CLAS}?)"
351 351 # PUNCT = Regexp::quote( '!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~' )
352 352 PUNCT = Regexp::quote( '!"#$%&\'*+,-./:;=?@\\^_`|~' )
353 353 PUNCT_NOQ = Regexp::quote( '!"#$&\',./:;=?@\\`|' )
354 354 PUNCT_Q = Regexp::quote( '*-_+^~%' )
355 355 HYPERLINK = '(\S+?)([^\w\s/;=\?]*?)(?=\s|<|$)'
356 356
357 357 # Text markup tags, don't conflict with block tags
358 358 SIMPLE_HTML_TAGS = [
359 359 'tt', 'b', 'i', 'big', 'small', 'em', 'strong', 'dfn', 'code',
360 360 'samp', 'kbd', 'var', 'cite', 'abbr', 'acronym', 'a', 'img', 'br',
361 361 'br', 'map', 'q', 'sub', 'sup', 'span', 'bdo'
362 362 ]
363 363
364 364 QTAGS = [
365 365 ['**', 'b', :limit],
366 366 ['*', 'strong', :limit],
367 367 ['??', 'cite', :limit],
368 368 ['-', 'del', :limit],
369 369 ['__', 'i', :limit],
370 370 ['_', 'em', :limit],
371 371 ['%', 'span', :limit],
372 372 ['+', 'ins', :limit],
373 373 ['^', 'sup', :limit],
374 374 ['~', 'sub', :limit]
375 375 ]
376 376 QTAGS.collect! do |rc, ht, rtype|
377 377 rcq = Regexp::quote rc
378 378 re =
379 379 case rtype
380 380 when :limit
381 381 /(^|[>\s\(])
382 382 (#{rcq})
383 383 (#{C})
384 384 (?::(\S+?))?
385 385 ([^\s\-].*?[^\s\-]|\w)
386 386 #{rcq}
387 387 (?=[[:punct:]]|\s|\)|$)/x
388 388 else
389 389 /(#{rcq})
390 390 (#{C})
391 391 (?::(\S+))?
392 392 ([^\s\-].*?[^\s\-]|\w)
393 393 #{rcq}/xm
394 394 end
395 395 [rc, ht, re, rtype]
396 396 end
397 397
398 398 # Elements to handle
399 399 GLYPHS = [
400 400 # [ /([^\s\[{(>])?\'([dmst]\b|ll\b|ve\b|\s|:|$)/, '\1&#8217;\2' ], # single closing
401 401 # [ /([^\s\[{(>#{PUNCT_Q}][#{PUNCT_Q}]*)\'/, '\1&#8217;' ], # single closing
402 402 # [ /\'(?=[#{PUNCT_Q}]*(s\b|[\s#{PUNCT_NOQ}]))/, '&#8217;' ], # single closing
403 403 # [ /\'/, '&#8216;' ], # single opening
404 404 # [ /</, '&lt;' ], # less-than
405 405 # [ />/, '&gt;' ], # greater-than
406 406 # [ /([^\s\[{(])?"(\s|:|$)/, '\1&#8221;\2' ], # double closing
407 407 # [ /([^\s\[{(>#{PUNCT_Q}][#{PUNCT_Q}]*)"/, '\1&#8221;' ], # double closing
408 408 # [ /"(?=[#{PUNCT_Q}]*[\s#{PUNCT_NOQ}])/, '&#8221;' ], # double closing
409 409 # [ /"/, '&#8220;' ], # double opening
410 410 # [ /\b( )?\.{3}/, '\1&#8230;' ], # ellipsis
411 [ /\b([A-Z][A-Z0-9]{2,})\b(?:[(]([^)]*)[)])/, '<acronym title="\2">\1</acronym>' ], # 3+ uppercase acronym
411 # [ /\b([A-Z][A-Z0-9]{2,})\b(?:[(]([^)]*)[)])/, '<acronym title="\2">\1</acronym>' ], # 3+ uppercase acronym
412 412 # [ /(^|[^"][>\s])([A-Z][A-Z0-9 ]+[A-Z0-9])([^<A-Za-z0-9]|$)/, '\1<span class="caps">\2</span>\3', :no_span_caps ], # 3+ uppercase caps
413 413 # [ /(\.\s)?\s?--\s?/, '\1&#8212;' ], # em dash
414 414 # [ /\s->\s/, ' &rarr; ' ], # right arrow
415 415 # [ /\s-\s/, ' &#8211; ' ], # en dash
416 416 # [ /(\d+) ?x ?(\d+)/, '\1&#215;\2' ], # dimension sign
417 417 # [ /\b ?[(\[]TM[\])]/i, '&#8482;' ], # trademark
418 418 # [ /\b ?[(\[]R[\])]/i, '&#174;' ], # registered
419 419 # [ /\b ?[(\[]C[\])]/i, '&#169;' ] # copyright
420 420 ]
421 421
422 422 H_ALGN_VALS = {
423 423 '<' => 'left',
424 424 '=' => 'center',
425 425 '>' => 'right',
426 426 '<>' => 'justify'
427 427 }
428 428
429 429 V_ALGN_VALS = {
430 430 '^' => 'top',
431 431 '-' => 'middle',
432 432 '~' => 'bottom'
433 433 }
434 434
435 435 #
436 436 # Flexible HTML escaping
437 437 #
438 438 def htmlesc( str, mode=:Quotes )
439 439 if str
440 440 str.gsub!( '&', '&amp;' )
441 441 str.gsub!( '"', '&quot;' ) if mode != :NoQuotes
442 442 str.gsub!( "'", '&#039;' ) if mode == :Quotes
443 443 str.gsub!( '<', '&lt;')
444 444 str.gsub!( '>', '&gt;')
445 445 end
446 446 str
447 447 end
448 448
449 449 # Search and replace for Textile glyphs (quotes, dashes, other symbols)
450 450 def pgl( text )
451 GLYPHS.each do |re, resub, tog|
452 next if tog and method( tog ).call
453 text.gsub! re, resub
451 #GLYPHS.each do |re, resub, tog|
452 # next if tog and method( tog ).call
453 # text.gsub! re, resub
454 #end
455 text.gsub!(/\b([A-Z][A-Z0-9]{2,})\b(?:[(]([^)]*)[)])/) do |m|
456 "<acronym title=\"#{htmlesc $2}\">#{$1}</acronym>"
454 457 end
455 458 end
456 459
457 460 # Parses Textile attribute lists and builds an HTML attribute string
458 461 def pba( text_in, element = "" )
459 462
460 463 return '' unless text_in
461 464
462 465 style = []
463 466 text = text_in.dup
464 467 if element == 'td'
465 468 colspan = $1 if text =~ /\\(\d+)/
466 469 rowspan = $1 if text =~ /\/(\d+)/
467 470 style << "vertical-align:#{ v_align( $& ) };" if text =~ A_VLGN
468 471 end
469 472
470 style << "#{ $1 };" if not filter_styles and
473 style << "#{ htmlesc $1 };" if not filter_styles and
471 474 text.sub!( /\{([^}]*)\}/, '' )
472 475
473 476 lang = $1 if
474 477 text.sub!( /\[([^)]+?)\]/, '' )
475 478
476 479 cls = $1 if
477 480 text.sub!( /\(([^()]+?)\)/, '' )
478 481
479 482 style << "padding-left:#{ $1.length }em;" if
480 483 text.sub!( /([(]+)/, '' )
481 484
482 485 style << "padding-right:#{ $1.length }em;" if text.sub!( /([)]+)/, '' )
483 486
484 487 style << "text-align:#{ h_align( $& ) };" if text =~ A_HLGN
485 488
486 489 cls, id = $1, $2 if cls =~ /^(.*?)#(.*)$/
487 490
488 491 atts = ''
489 492 atts << " style=\"#{ style.join }\"" unless style.empty?
490 493 atts << " class=\"#{ cls }\"" unless cls.to_s.empty?
491 494 atts << " lang=\"#{ lang }\"" if lang
492 495 atts << " id=\"#{ id }\"" if id
493 496 atts << " colspan=\"#{ colspan }\"" if colspan
494 497 atts << " rowspan=\"#{ rowspan }\"" if rowspan
495 498
496 499 atts
497 500 end
498 501
499 502 TABLE_RE = /^(?:table(_?#{S}#{A}#{C})\. ?\n)?^(#{A}#{C}\.? ?\|.*?\|)(\n\n|\Z)/m
500 503
501 504 # Parses a Textile table block, building HTML from the result.
502 505 def block_textile_table( text )
503 506 text.gsub!( TABLE_RE ) do |matches|
504 507
505 508 tatts, fullrow = $~[1..2]
506 509 tatts = pba( tatts, 'table' )
507 510 tatts = shelve( tatts ) if tatts
508 511 rows = []
509 512
510 513 fullrow.each_line do |row|
511 514 ratts, row = pba( $1, 'tr' ), $2 if row =~ /^(#{A}#{C}\. )(.*)/m
512 515 cells = []
513 516 row.split( /(\|)(?![^\[\|]*\]\])/ )[1..-2].each do |cell|
514 517 next if cell == '|'
515 518 ctyp = 'd'
516 519 ctyp = 'h' if cell =~ /^_/
517 520
518 521 catts = ''
519 522 catts, cell = pba( $1, 'td' ), $2 if cell =~ /^(_?#{S}#{A}#{C}\. ?)(.*)/
520 523
521 524 catts = shelve( catts ) if catts
522 525 cells << "\t\t\t<t#{ ctyp }#{ catts }>#{ cell }</t#{ ctyp }>"
523 526 end
524 527 ratts = shelve( ratts ) if ratts
525 528 rows << "\t\t<tr#{ ratts }>\n#{ cells.join( "\n" ) }\n\t\t</tr>"
526 529 end
527 530 "\t<table#{ tatts }>\n#{ rows.join( "\n" ) }\n\t</table>\n\n"
528 531 end
529 532 end
530 533
531 534 LISTS_RE = /^([#*]+?#{C} .*?)$(?![^#*])/m
532 535 LISTS_CONTENT_RE = /^([#*]+)(#{A}#{C}) (.*)$/m
533 536
534 537 # Parses Textile lists and generates HTML
535 538 def block_textile_lists( text )
536 539 text.gsub!( LISTS_RE ) do |match|
537 540 lines = match.split( /\n/ )
538 541 last_line = -1
539 542 depth = []
540 543 lines.each_with_index do |line, line_id|
541 544 if line =~ LISTS_CONTENT_RE
542 545 tl,atts,content = $~[1..3]
543 546 if depth.last
544 547 if depth.last.length > tl.length
545 548 (depth.length - 1).downto(0) do |i|
546 549 break if depth[i].length == tl.length
547 550 lines[line_id - 1] << "</li>\n\t</#{ lT( depth[i] ) }l>\n\t"
548 551 depth.pop
549 552 end
550 553 end
551 554 if depth.last and depth.last.length == tl.length
552 555 lines[line_id - 1] << '</li>'
553 556 end
554 557 end
555 558 unless depth.last == tl
556 559 depth << tl
557 560 atts = pba( atts )
558 561 atts = shelve( atts ) if atts
559 562 lines[line_id] = "\t<#{ lT(tl) }l#{ atts }>\n\t<li>#{ content }"
560 563 else
561 564 lines[line_id] = "\t\t<li>#{ content }"
562 565 end
563 566 last_line = line_id
564 567
565 568 else
566 569 last_line = line_id
567 570 end
568 571 if line_id - last_line > 1 or line_id == lines.length - 1
569 572 depth.delete_if do |v|
570 573 lines[last_line] << "</li>\n\t</#{ lT( v ) }l>"
571 574 end
572 575 end
573 576 end
574 577 lines.join( "\n" )
575 578 end
576 579 end
577 580
578 581 QUOTES_RE = /(^>+([^\n]*?)\n?)+/m
579 582 QUOTES_CONTENT_RE = /^([> ]+)(.*)$/m
580 583
581 584 def block_textile_quotes( text )
582 585 text.gsub!( QUOTES_RE ) do |match|
583 586 lines = match.split( /\n/ )
584 587 quotes = ''
585 588 indent = 0
586 589 lines.each do |line|
587 590 line =~ QUOTES_CONTENT_RE
588 591 bq,content = $1, $2
589 592 l = bq.count('>')
590 593 if l != indent
591 594 quotes << ("\n\n" + (l>indent ? '<blockquote>' * (l-indent) : '</blockquote>' * (indent-l)) + "\n\n")
592 595 indent = l
593 596 end
594 597 quotes << (content + "\n")
595 598 end
596 599 quotes << ("\n" + '</blockquote>' * indent + "\n\n")
597 600 quotes
598 601 end
599 602 end
600 603
601 604 CODE_RE = /(\W)
602 605 @
603 606 (?:\|(\w+?)\|)?
604 607 (.+?)
605 608 @
606 609 (?=\W)/x
607 610
608 611 def inline_textile_code( text )
609 612 text.gsub!( CODE_RE ) do |m|
610 613 before,lang,code,after = $~[1..4]
611 614 lang = " lang=\"#{ lang }\"" if lang
612 615 rip_offtags( "#{ before }<code#{ lang }>#{ code }</code>#{ after }" )
613 616 end
614 617 end
615 618
616 619 def lT( text )
617 620 text =~ /\#$/ ? 'o' : 'u'
618 621 end
619 622
620 623 def hard_break( text )
621 624 text.gsub!( /(.)\n(?!\Z| *([#*=]+(\s|$)|[{|]))/, "\\1<br />" ) if hard_breaks
622 625 end
623 626
624 627 BLOCKS_GROUP_RE = /\n{2,}(?! )/m
625 628
626 629 def blocks( text, deep_code = false )
627 630 text.replace( text.split( BLOCKS_GROUP_RE ).collect do |blk|
628 631 plain = blk !~ /\A[#*> ]/
629 632
630 633 # skip blocks that are complex HTML
631 634 if blk =~ /^<\/?(\w+).*>/ and not SIMPLE_HTML_TAGS.include? $1
632 635 blk
633 636 else
634 637 # search for indentation levels
635 638 blk.strip!
636 639 if blk.empty?
637 640 blk
638 641 else
639 642 code_blk = nil
640 643 blk.gsub!( /((?:\n(?:\n^ +[^\n]*)+)+)/m ) do |iblk|
641 644 flush_left iblk
642 645 blocks iblk, plain
643 646 iblk.gsub( /^(\S)/, "\t\\1" )
644 647 if plain
645 648 code_blk = iblk; ""
646 649 else
647 650 iblk
648 651 end
649 652 end
650 653
651 654 block_applied = 0
652 655 @rules.each do |rule_name|
653 656 block_applied += 1 if ( rule_name.to_s.match /^block_/ and method( rule_name ).call( blk ) )
654 657 end
655 658 if block_applied.zero?
656 659 if deep_code
657 660 blk = "\t<pre><code>#{ blk }</code></pre>"
658 661 else
659 662 blk = "\t<p>#{ blk }</p>"
660 663 end
661 664 end
662 665 # hard_break blk
663 666 blk + "\n#{ code_blk }"
664 667 end
665 668 end
666 669
667 670 end.join( "\n\n" ) )
668 671 end
669 672
670 673 def textile_bq( tag, atts, cite, content )
671 674 cite, cite_title = check_refs( cite )
672 675 cite = " cite=\"#{ cite }\"" if cite
673 676 atts = shelve( atts ) if atts
674 677 "\t<blockquote#{ cite }>\n\t\t<p#{ atts }>#{ content }</p>\n\t</blockquote>"
675 678 end
676 679
677 680 def textile_p( tag, atts, cite, content )
678 681 atts = shelve( atts ) if atts
679 682 "\t<#{ tag }#{ atts }>#{ content }</#{ tag }>"
680 683 end
681 684
682 685 alias textile_h1 textile_p
683 686 alias textile_h2 textile_p
684 687 alias textile_h3 textile_p
685 688 alias textile_h4 textile_p
686 689 alias textile_h5 textile_p
687 690 alias textile_h6 textile_p
688 691
689 692 def textile_fn_( tag, num, atts, cite, content )
690 693 atts << " id=\"fn#{ num }\" class=\"footnote\""
691 694 content = "<sup>#{ num }</sup> #{ content }"
692 695 atts = shelve( atts ) if atts
693 696 "\t<p#{ atts }>#{ content }</p>"
694 697 end
695 698
696 699 BLOCK_RE = /^(([a-z]+)(\d*))(#{A}#{C})\.(?::(\S+))? (.*)$/m
697 700
698 701 def block_textile_prefix( text )
699 702 if text =~ BLOCK_RE
700 703 tag,tagpre,num,atts,cite,content = $~[1..6]
701 704 atts = pba( atts )
702 705
703 706 # pass to prefix handler
704 707 if respond_to? "textile_#{ tag }", true
705 708 text.gsub!( $&, method( "textile_#{ tag }" ).call( tag, atts, cite, content ) )
706 709 elsif respond_to? "textile_#{ tagpre }_", true
707 710 text.gsub!( $&, method( "textile_#{ tagpre }_" ).call( tagpre, num, atts, cite, content ) )
708 711 end
709 712 end
710 713 end
711 714
712 715 SETEXT_RE = /\A(.+?)\n([=-])[=-]* *$/m
713 716 def block_markdown_setext( text )
714 717 if text =~ SETEXT_RE
715 718 tag = if $2 == "="; "h1"; else; "h2"; end
716 719 blk, cont = "<#{ tag }>#{ $1 }</#{ tag }>", $'
717 720 blocks cont
718 721 text.replace( blk + cont )
719 722 end
720 723 end
721 724
722 725 ATX_RE = /\A(\#{1,6}) # $1 = string of #'s
723 726 [ ]*
724 727 (.+?) # $2 = Header text
725 728 [ ]*
726 729 \#* # optional closing #'s (not counted)
727 730 $/x
728 731 def block_markdown_atx( text )
729 732 if text =~ ATX_RE
730 733 tag = "h#{ $1.length }"
731 734 blk, cont = "<#{ tag }>#{ $2 }</#{ tag }>\n\n", $'
732 735 blocks cont
733 736 text.replace( blk + cont )
734 737 end
735 738 end
736 739
737 740 MARKDOWN_BQ_RE = /\A(^ *> ?.+$(.+\n)*\n*)+/m
738 741
739 742 def block_markdown_bq( text )
740 743 text.gsub!( MARKDOWN_BQ_RE ) do |blk|
741 744 blk.gsub!( /^ *> ?/, '' )
742 745 flush_left blk
743 746 blocks blk
744 747 blk.gsub!( /^(\S)/, "\t\\1" )
745 748 "<blockquote>\n#{ blk }\n</blockquote>\n\n"
746 749 end
747 750 end
748 751
749 752 MARKDOWN_RULE_RE = /^(#{
750 753 ['*', '-', '_'].collect { |ch| ' ?(' + Regexp::quote( ch ) + ' ?){3,}' }.join( '|' )
751 754 })$/
752 755
753 756 def block_markdown_rule( text )
754 757 text.gsub!( MARKDOWN_RULE_RE ) do |blk|
755 758 "<hr />"
756 759 end
757 760 end
758 761
759 762 # XXX TODO XXX
760 763 def block_markdown_lists( text )
761 764 end
762 765
763 766 def inline_textile_span( text )
764 767 QTAGS.each do |qtag_rc, ht, qtag_re, rtype|
765 768 text.gsub!( qtag_re ) do |m|
766 769
767 770 case rtype
768 771 when :limit
769 772 sta,qtag,atts,cite,content = $~[1..5]
770 773 else
771 774 qtag,atts,cite,content = $~[1..4]
772 775 sta = ''
773 776 end
774 777 atts = pba( atts )
775 778 atts << " cite=\"#{ cite }\"" if cite
776 779 atts = shelve( atts ) if atts
777 780
778 781 "#{ sta }<#{ ht }#{ atts }>#{ content }</#{ ht }>"
779 782
780 783 end
781 784 end
782 785 end
783 786
784 787 LINK_RE = /
785 788 ([\s\[{(]|[#{PUNCT}])? # $pre
786 789 " # start
787 790 (#{C}) # $atts
788 791 ([^"\n]+?) # $text
789 792 \s?
790 793 (?:\(([^)]+?)\)(?="))? # $title
791 794 ":
792 795 ([\w\/]\S+?) # $url
793 796 (\/)? # $slash
794 797 ([^\w\=\/;\(\)]*?) # $post
795 798 (?=<|\s|$)
796 799 /x
797 800 #"
798 801 def inline_textile_link( text )
799 802 text.gsub!( LINK_RE ) do |m|
800 803 pre,atts,text,title,url,slash,post = $~[1..7]
801 804
802 805 url, url_title = check_refs( url )
803 806 title ||= url_title
804 807
805 808 # Idea below : an URL with unbalanced parethesis and
806 809 # ending by ')' is put into external parenthesis
807 810 if ( url[-1]==?) and ((url.count("(") - url.count(")")) < 0 ) )
808 811 url=url[0..-2] # discard closing parenth from url
809 812 post = ")"+post # add closing parenth to post
810 813 end
811 814 atts = pba( atts )
812 815 atts = " href=\"#{ url }#{ slash }\"#{ atts }"
813 atts << " title=\"#{ title }\"" if title
816 atts << " title=\"#{ htmlesc title }\"" if title
814 817 atts = shelve( atts ) if atts
815 818
816 819 external = (url =~ /^https?:\/\//) ? ' class="external"' : ''
817 820
818 821 "#{ pre }<a#{ atts }#{ external }>#{ text }</a>#{ post }"
819 822 end
820 823 end
821 824
822 825 MARKDOWN_REFLINK_RE = /
823 826 \[([^\[\]]+)\] # $text
824 827 [ ]? # opt. space
825 828 (?:\n[ ]*)? # one optional newline followed by spaces
826 829 \[(.*?)\] # $id
827 830 /x
828 831
829 832 def inline_markdown_reflink( text )
830 833 text.gsub!( MARKDOWN_REFLINK_RE ) do |m|
831 834 text, id = $~[1..2]
832 835
833 836 if id.empty?
834 837 url, title = check_refs( text )
835 838 else
836 839 url, title = check_refs( id )
837 840 end
838 841
839 842 atts = " href=\"#{ url }\""
840 843 atts << " title=\"#{ title }\"" if title
841 844 atts = shelve( atts )
842 845
843 846 "<a#{ atts }>#{ text }</a>"
844 847 end
845 848 end
846 849
847 850 MARKDOWN_LINK_RE = /
848 851 \[([^\[\]]+)\] # $text
849 852 \( # open paren
850 853 [ \t]* # opt space
851 854 <?(.+?)>? # $href
852 855 [ \t]* # opt space
853 856 (?: # whole title
854 857 (['"]) # $quote
855 858 (.*?) # $title
856 859 \3 # matching quote
857 860 )? # title is optional
858 861 \)
859 862 /x
860 863
861 864 def inline_markdown_link( text )
862 865 text.gsub!( MARKDOWN_LINK_RE ) do |m|
863 866 text, url, quote, title = $~[1..4]
864 867
865 868 atts = " href=\"#{ url }\""
866 869 atts << " title=\"#{ title }\"" if title
867 870 atts = shelve( atts )
868 871
869 872 "<a#{ atts }>#{ text }</a>"
870 873 end
871 874 end
872 875
873 876 TEXTILE_REFS_RE = /(^ *)\[([^\[\n]+?)\](#{HYPERLINK})(?=\s|$)/
874 877 MARKDOWN_REFS_RE = /(^ *)\[([^\n]+?)\]:\s+<?(#{HYPERLINK})>?(?:\s+"((?:[^"]|\\")+)")?(?=\s|$)/m
875 878
876 879 def refs( text )
877 880 @rules.each do |rule_name|
878 881 method( rule_name ).call( text ) if rule_name.to_s.match /^refs_/
879 882 end
880 883 end
881 884
882 885 def refs_textile( text )
883 886 text.gsub!( TEXTILE_REFS_RE ) do |m|
884 887 flag, url = $~[2..3]
885 888 @urlrefs[flag.downcase] = [url, nil]
886 889 nil
887 890 end
888 891 end
889 892
890 893 def refs_markdown( text )
891 894 text.gsub!( MARKDOWN_REFS_RE ) do |m|
892 895 flag, url = $~[2..3]
893 896 title = $~[6]
894 897 @urlrefs[flag.downcase] = [url, title]
895 898 nil
896 899 end
897 900 end
898 901
899 902 def check_refs( text )
900 903 ret = @urlrefs[text.downcase] if text
901 904 ret || [text, nil]
902 905 end
903 906
904 907 IMAGE_RE = /
905 908 (<p>|.|^) # start of line?
906 909 \! # opening
907 910 (\<|\=|\>)? # optional alignment atts
908 911 (#{C}) # optional style,class atts
909 912 (?:\. )? # optional dot-space
910 913 ([^\s(!]+?) # presume this is the src
911 914 \s? # optional space
912 915 (?:\(((?:[^\(\)]|\([^\)]+\))+?)\))? # optional title
913 916 \! # closing
914 917 (?::#{ HYPERLINK })? # optional href
915 918 /x
916 919
917 920 def inline_textile_image( text )
918 921 text.gsub!( IMAGE_RE ) do |m|
919 922 stln,algn,atts,url,title,href,href_a1,href_a2 = $~[1..8]
920 923 htmlesc title
921 924 atts = pba( atts )
922 925 atts = " src=\"#{ url }\"#{ atts }"
923 926 atts << " title=\"#{ title }\"" if title
924 927 atts << " alt=\"#{ title }\""
925 928 # size = @getimagesize($url);
926 929 # if($size) $atts.= " $size[3]";
927 930
928 931 href, alt_title = check_refs( href ) if href
929 932 url, url_title = check_refs( url )
930 933
931 934 out = ''
932 935 out << "<a#{ shelve( " href=\"#{ href }\"" ) }>" if href
933 936 out << "<img#{ shelve( atts ) } />"
934 937 out << "</a>#{ href_a1 }#{ href_a2 }" if href
935 938
936 939 if algn
937 940 algn = h_align( algn )
938 941 if stln == "<p>"
939 942 out = "<p style=\"float:#{ algn }\">#{ out }"
940 943 else
941 944 out = "#{ stln }<div style=\"float:#{ algn }\">#{ out }</div>"
942 945 end
943 946 else
944 947 out = stln + out
945 948 end
946 949
947 950 out
948 951 end
949 952 end
950 953
951 954 def shelve( val )
952 955 @shelf << val
953 956 " :redsh##{ @shelf.length }:"
954 957 end
955 958
956 959 def retrieve( text )
957 960 @shelf.each_with_index do |r, i|
958 961 text.gsub!( " :redsh##{ i + 1 }:", r )
959 962 end
960 963 end
961 964
962 965 def incoming_entities( text )
963 966 ## turn any incoming ampersands into a dummy character for now.
964 967 ## This uses a negative lookahead for alphanumerics followed by a semicolon,
965 968 ## implying an incoming html entity, to be skipped
966 969
967 970 text.gsub!( /&(?![#a-z0-9]+;)/i, "x%x%" )
968 971 end
969 972
970 973 def no_textile( text )
971 974 text.gsub!( /(^|\s)==([^=]+.*?)==(\s|$)?/,
972 975 '\1<notextile>\2</notextile>\3' )
973 976 text.gsub!( /^ *==([^=]+.*?)==/m,
974 977 '\1<notextile>\2</notextile>\3' )
975 978 end
976 979
977 980 def clean_white_space( text )
978 981 # normalize line breaks
979 982 text.gsub!( /\r\n/, "\n" )
980 983 text.gsub!( /\r/, "\n" )
981 984 text.gsub!( /\t/, ' ' )
982 985 text.gsub!( /^ +$/, '' )
983 986 text.gsub!( /\n{3,}/, "\n\n" )
984 987 text.gsub!( /"$/, "\" " )
985 988
986 989 # if entire document is indented, flush
987 990 # to the left side
988 991 flush_left text
989 992 end
990 993
991 994 def flush_left( text )
992 995 indt = 0
993 996 if text =~ /^ /
994 997 while text !~ /^ {#{indt}}\S/
995 998 indt += 1
996 999 end unless text.empty?
997 1000 if indt.nonzero?
998 1001 text.gsub!( /^ {#{indt}}/, '' )
999 1002 end
1000 1003 end
1001 1004 end
1002 1005
1003 1006 def footnote_ref( text )
1004 1007 text.gsub!( /\b\[([0-9]+?)\](\s)?/,
1005 1008 '<sup><a href="#fn\1">\1</a></sup>\2' )
1006 1009 end
1007 1010
1008 1011 OFFTAGS = /(code|pre|kbd|notextile)/
1009 1012 OFFTAG_MATCH = /(?:(<\/#{ OFFTAGS }>)|(<#{ OFFTAGS }[^>]*>))(.*?)(?=<\/?#{ OFFTAGS }|\Z)/mi
1010 1013 OFFTAG_OPEN = /<#{ OFFTAGS }/
1011 1014 OFFTAG_CLOSE = /<\/?#{ OFFTAGS }/
1012 1015 HASTAG_MATCH = /(<\/?\w[^\n]*?>)/m
1013 1016 ALLTAG_MATCH = /(<\/?\w[^\n]*?>)|.*?(?=<\/?\w[^\n]*?>|$)/m
1014 1017
1015 1018 def glyphs_textile( text, level = 0 )
1016 1019 if text !~ HASTAG_MATCH
1017 1020 pgl text
1018 1021 footnote_ref text
1019 1022 else
1020 1023 codepre = 0
1021 1024 text.gsub!( ALLTAG_MATCH ) do |line|
1022 1025 ## matches are off if we're between <code>, <pre> etc.
1023 1026 if $1
1024 1027 if line =~ OFFTAG_OPEN
1025 1028 codepre += 1
1026 1029 elsif line =~ OFFTAG_CLOSE
1027 1030 codepre -= 1
1028 1031 codepre = 0 if codepre < 0
1029 1032 end
1030 1033 elsif codepre.zero?
1031 1034 glyphs_textile( line, level + 1 )
1032 1035 else
1033 1036 htmlesc( line, :NoQuotes )
1034 1037 end
1035 1038 # p [level, codepre, line]
1036 1039
1037 1040 line
1038 1041 end
1039 1042 end
1040 1043 end
1041 1044
1042 1045 def rip_offtags( text )
1043 1046 if text =~ /<.*>/
1044 1047 ## strip and encode <pre> content
1045 1048 codepre, used_offtags = 0, {}
1046 1049 text.gsub!( OFFTAG_MATCH ) do |line|
1047 1050 if $3
1048 1051 offtag, aftertag = $4, $5
1049 1052 codepre += 1
1050 1053 used_offtags[offtag] = true
1051 1054 if codepre - used_offtags.length > 0
1052 1055 htmlesc( line, :NoQuotes )
1053 1056 @pre_list.last << line
1054 1057 line = ""
1055 1058 else
1056 1059 htmlesc( aftertag, :NoQuotes ) if aftertag
1057 1060 line = "<redpre##{ @pre_list.length }>"
1058 1061 $3.match(/<#{ OFFTAGS }([^>]*)>/)
1059 1062 tag = $1
1060 1063 $2.to_s.match(/(class\=\S+)/i)
1061 1064 tag << " #{$1}" if $1
1062 1065 @pre_list << "<#{ tag }>#{ aftertag }"
1063 1066 end
1064 1067 elsif $1 and codepre > 0
1065 1068 if codepre - used_offtags.length > 0
1066 1069 htmlesc( line, :NoQuotes )
1067 1070 @pre_list.last << line
1068 1071 line = ""
1069 1072 end
1070 1073 codepre -= 1 unless codepre.zero?
1071 1074 used_offtags = {} if codepre.zero?
1072 1075 end
1073 1076 line
1074 1077 end
1075 1078 end
1076 1079 text
1077 1080 end
1078 1081
1079 1082 def smooth_offtags( text )
1080 1083 unless @pre_list.empty?
1081 1084 ## replace <pre> content
1082 1085 text.gsub!( /<redpre#(\d+)>/ ) { @pre_list[$1.to_i] }
1083 1086 end
1084 1087 end
1085 1088
1086 1089 def inline( text )
1087 1090 [/^inline_/, /^glyphs_/].each do |meth_re|
1088 1091 @rules.each do |rule_name|
1089 1092 method( rule_name ).call( text ) if rule_name.to_s.match( meth_re )
1090 1093 end
1091 1094 end
1092 1095 end
1093 1096
1094 1097 def h_align( text )
1095 1098 H_ALGN_VALS[text]
1096 1099 end
1097 1100
1098 1101 def v_align( text )
1099 1102 V_ALGN_VALS[text]
1100 1103 end
1101 1104
1102 1105 def textile_popup_help( name, windowW, windowH )
1103 1106 ' <a target="_blank" href="http://hobix.com/textile/#' + helpvar + '" onclick="window.open(this.href, \'popupwindow\', \'width=' + windowW + ',height=' + windowH + ',scrollbars,resizable\'); return false;">' + name + '</a><br />'
1104 1107 end
1105 1108
1106 1109 # HTML cleansing stuff
1107 1110 BASIC_TAGS = {
1108 1111 'a' => ['href', 'title'],
1109 1112 'img' => ['src', 'alt', 'title'],
1110 1113 'br' => [],
1111 1114 'i' => nil,
1112 1115 'u' => nil,
1113 1116 'b' => nil,
1114 1117 'pre' => nil,
1115 1118 'kbd' => nil,
1116 1119 'code' => ['lang'],
1117 1120 'cite' => nil,
1118 1121 'strong' => nil,
1119 1122 'em' => nil,
1120 1123 'ins' => nil,
1121 1124 'sup' => nil,
1122 1125 'sub' => nil,
1123 1126 'del' => nil,
1124 1127 'table' => nil,
1125 1128 'tr' => nil,
1126 1129 'td' => ['colspan', 'rowspan'],
1127 1130 'th' => nil,
1128 1131 'ol' => nil,
1129 1132 'ul' => nil,
1130 1133 'li' => nil,
1131 1134 'p' => nil,
1132 1135 'h1' => nil,
1133 1136 'h2' => nil,
1134 1137 'h3' => nil,
1135 1138 'h4' => nil,
1136 1139 'h5' => nil,
1137 1140 'h6' => nil,
1138 1141 'blockquote' => ['cite']
1139 1142 }
1140 1143
1141 1144 def clean_html( text, tags = BASIC_TAGS )
1142 1145 text.gsub!( /<!\[CDATA\[/, '' )
1143 1146 text.gsub!( /<(\/*)(\w+)([^>]*)>/ ) do
1144 1147 raw = $~
1145 1148 tag = raw[2].downcase
1146 1149 if tags.has_key? tag
1147 1150 pcs = [tag]
1148 1151 tags[tag].each do |prop|
1149 1152 ['"', "'", ''].each do |q|
1150 1153 q2 = ( q != '' ? q : '\s' )
1151 1154 if raw[3] =~ /#{prop}\s*=\s*#{q}([^#{q2}]+)#{q}/i
1152 1155 attrv = $1
1153 1156 next if prop == 'src' and attrv =~ %r{^(?!http)\w+:}
1154 1157 pcs << "#{prop}=\"#{$1.gsub('"', '\\"')}\""
1155 1158 break
1156 1159 end
1157 1160 end
1158 1161 end if tags[tag]
1159 1162 "<#{raw[1]}#{pcs.join " "}>"
1160 1163 else
1161 1164 " "
1162 1165 end
1163 1166 end
1164 1167 end
1165 1168
1166 1169 ALLOWED_TAGS = %w(redpre pre code notextile)
1167 1170
1168 1171 def escape_html_tags(text)
1169 1172 text.gsub!(%r{<(\/?([!\w]+)[^<>\n]*)(>?)}) {|m| ALLOWED_TAGS.include?($2) ? "<#{$1}#{$3}" : "&lt;#{$1}#{'&gt;' unless $3.blank?}" }
1170 1173 end
1171 1174 end
1172 1175
@@ -1,729 +1,739
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2008 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.dirname(__FILE__) + '/../test_helper'
19 19 require 'issues_controller'
20 20
21 21 # Re-raise errors caught by the controller.
22 22 class IssuesController; def rescue_action(e) raise e end; end
23 23
24 24 class IssuesControllerTest < Test::Unit::TestCase
25 25 fixtures :projects,
26 26 :users,
27 27 :roles,
28 28 :members,
29 29 :issues,
30 30 :issue_statuses,
31 31 :versions,
32 32 :trackers,
33 33 :projects_trackers,
34 34 :issue_categories,
35 35 :enabled_modules,
36 36 :enumerations,
37 37 :attachments,
38 38 :workflows,
39 39 :custom_fields,
40 40 :custom_values,
41 41 :custom_fields_trackers,
42 42 :time_entries,
43 43 :journals,
44 44 :journal_details
45 45
46 46 def setup
47 47 @controller = IssuesController.new
48 48 @request = ActionController::TestRequest.new
49 49 @response = ActionController::TestResponse.new
50 50 User.current = nil
51 51 end
52 52
53 53 def test_index
54 54 get :index
55 55 assert_response :success
56 56 assert_template 'index.rhtml'
57 57 assert_not_nil assigns(:issues)
58 58 assert_nil assigns(:project)
59 59 assert_tag :tag => 'a', :content => /Can't print recipes/
60 60 assert_tag :tag => 'a', :content => /Subproject issue/
61 61 # private projects hidden
62 62 assert_no_tag :tag => 'a', :content => /Issue of a private subproject/
63 63 assert_no_tag :tag => 'a', :content => /Issue on project 2/
64 64 end
65 65
66 66 def test_index_should_not_list_issues_when_module_disabled
67 67 EnabledModule.delete_all("name = 'issue_tracking' AND project_id = 1")
68 68 get :index
69 69 assert_response :success
70 70 assert_template 'index.rhtml'
71 71 assert_not_nil assigns(:issues)
72 72 assert_nil assigns(:project)
73 73 assert_no_tag :tag => 'a', :content => /Can't print recipes/
74 74 assert_tag :tag => 'a', :content => /Subproject issue/
75 75 end
76 76
77 77 def test_index_with_project
78 78 Setting.display_subprojects_issues = 0
79 79 get :index, :project_id => 1
80 80 assert_response :success
81 81 assert_template 'index.rhtml'
82 82 assert_not_nil assigns(:issues)
83 83 assert_tag :tag => 'a', :content => /Can't print recipes/
84 84 assert_no_tag :tag => 'a', :content => /Subproject issue/
85 85 end
86 86
87 87 def test_index_with_project_and_subprojects
88 88 Setting.display_subprojects_issues = 1
89 89 get :index, :project_id => 1
90 90 assert_response :success
91 91 assert_template 'index.rhtml'
92 92 assert_not_nil assigns(:issues)
93 93 assert_tag :tag => 'a', :content => /Can't print recipes/
94 94 assert_tag :tag => 'a', :content => /Subproject issue/
95 95 assert_no_tag :tag => 'a', :content => /Issue of a private subproject/
96 96 end
97 97
98 98 def test_index_with_project_and_subprojects_should_show_private_subprojects
99 99 @request.session[:user_id] = 2
100 100 Setting.display_subprojects_issues = 1
101 101 get :index, :project_id => 1
102 102 assert_response :success
103 103 assert_template 'index.rhtml'
104 104 assert_not_nil assigns(:issues)
105 105 assert_tag :tag => 'a', :content => /Can't print recipes/
106 106 assert_tag :tag => 'a', :content => /Subproject issue/
107 107 assert_tag :tag => 'a', :content => /Issue of a private subproject/
108 108 end
109 109
110 110 def test_index_with_project_and_filter
111 111 get :index, :project_id => 1, :set_filter => 1
112 112 assert_response :success
113 113 assert_template 'index.rhtml'
114 114 assert_not_nil assigns(:issues)
115 115 end
116 116
117 117 def test_index_csv_with_project
118 118 get :index, :format => 'csv'
119 119 assert_response :success
120 120 assert_not_nil assigns(:issues)
121 121 assert_equal 'text/csv', @response.content_type
122 122
123 123 get :index, :project_id => 1, :format => 'csv'
124 124 assert_response :success
125 125 assert_not_nil assigns(:issues)
126 126 assert_equal 'text/csv', @response.content_type
127 127 end
128 128
129 129 def test_index_pdf
130 130 get :index, :format => 'pdf'
131 131 assert_response :success
132 132 assert_not_nil assigns(:issues)
133 133 assert_equal 'application/pdf', @response.content_type
134 134
135 135 get :index, :project_id => 1, :format => 'pdf'
136 136 assert_response :success
137 137 assert_not_nil assigns(:issues)
138 138 assert_equal 'application/pdf', @response.content_type
139 139 end
140
141 def test_index_sort
142 get :index, :sort_key => 'tracker'
143 assert_response :success
144
145 sort_params = @request.session['issuesindex_sort']
146 assert sort_params.is_a?(Hash)
147 assert_equal 'tracker', sort_params[:key]
148 assert_equal 'ASC', sort_params[:order]
149 end
140 150
141 151 def test_gantt
142 152 get :gantt, :project_id => 1
143 153 assert_response :success
144 154 assert_template 'gantt.rhtml'
145 155 assert_not_nil assigns(:gantt)
146 156 events = assigns(:gantt).events
147 157 assert_not_nil events
148 158 # Issue with start and due dates
149 159 i = Issue.find(1)
150 160 assert_not_nil i.due_date
151 161 assert events.include?(Issue.find(1))
152 162 # Issue with without due date but targeted to a version with date
153 163 i = Issue.find(2)
154 164 assert_nil i.due_date
155 165 assert events.include?(i)
156 166 end
157 167
158 168 def test_cross_project_gantt
159 169 get :gantt
160 170 assert_response :success
161 171 assert_template 'gantt.rhtml'
162 172 assert_not_nil assigns(:gantt)
163 173 events = assigns(:gantt).events
164 174 assert_not_nil events
165 175 end
166 176
167 177 def test_gantt_export_to_pdf
168 178 get :gantt, :project_id => 1, :format => 'pdf'
169 179 assert_response :success
170 180 assert_template 'gantt.rfpdf'
171 181 assert_equal 'application/pdf', @response.content_type
172 182 assert_not_nil assigns(:gantt)
173 183 end
174 184
175 185 def test_cross_project_gantt_export_to_pdf
176 186 get :gantt, :format => 'pdf'
177 187 assert_response :success
178 188 assert_template 'gantt.rfpdf'
179 189 assert_equal 'application/pdf', @response.content_type
180 190 assert_not_nil assigns(:gantt)
181 191 end
182 192
183 193 if Object.const_defined?(:Magick)
184 194 def test_gantt_image
185 195 get :gantt, :project_id => 1, :format => 'png'
186 196 assert_response :success
187 197 assert_equal 'image/png', @response.content_type
188 198 end
189 199 else
190 200 puts "RMagick not installed. Skipping tests !!!"
191 201 end
192 202
193 203 def test_calendar
194 204 get :calendar, :project_id => 1
195 205 assert_response :success
196 206 assert_template 'calendar'
197 207 assert_not_nil assigns(:calendar)
198 208 end
199 209
200 210 def test_cross_project_calendar
201 211 get :calendar
202 212 assert_response :success
203 213 assert_template 'calendar'
204 214 assert_not_nil assigns(:calendar)
205 215 end
206 216
207 217 def test_changes
208 218 get :changes, :project_id => 1
209 219 assert_response :success
210 220 assert_not_nil assigns(:journals)
211 221 assert_equal 'application/atom+xml', @response.content_type
212 222 end
213 223
214 224 def test_show_by_anonymous
215 225 get :show, :id => 1
216 226 assert_response :success
217 227 assert_template 'show.rhtml'
218 228 assert_not_nil assigns(:issue)
219 229 assert_equal Issue.find(1), assigns(:issue)
220 230
221 231 # anonymous role is allowed to add a note
222 232 assert_tag :tag => 'form',
223 233 :descendant => { :tag => 'fieldset',
224 234 :child => { :tag => 'legend',
225 235 :content => /Notes/ } }
226 236 end
227 237
228 238 def test_show_by_manager
229 239 @request.session[:user_id] = 2
230 240 get :show, :id => 1
231 241 assert_response :success
232 242
233 243 assert_tag :tag => 'form',
234 244 :descendant => { :tag => 'fieldset',
235 245 :child => { :tag => 'legend',
236 246 :content => /Change properties/ } },
237 247 :descendant => { :tag => 'fieldset',
238 248 :child => { :tag => 'legend',
239 249 :content => /Log time/ } },
240 250 :descendant => { :tag => 'fieldset',
241 251 :child => { :tag => 'legend',
242 252 :content => /Notes/ } }
243 253 end
244 254
245 255 def test_get_new
246 256 @request.session[:user_id] = 2
247 257 get :new, :project_id => 1, :tracker_id => 1
248 258 assert_response :success
249 259 assert_template 'new'
250 260
251 261 assert_tag :tag => 'input', :attributes => { :name => 'issue[custom_field_values][2]',
252 262 :value => 'Default string' }
253 263 end
254 264
255 265 def test_get_new_without_tracker_id
256 266 @request.session[:user_id] = 2
257 267 get :new, :project_id => 1
258 268 assert_response :success
259 269 assert_template 'new'
260 270
261 271 issue = assigns(:issue)
262 272 assert_not_nil issue
263 273 assert_equal Project.find(1).trackers.first, issue.tracker
264 274 end
265 275
266 276 def test_update_new_form
267 277 @request.session[:user_id] = 2
268 278 xhr :post, :new, :project_id => 1,
269 279 :issue => {:tracker_id => 2,
270 280 :subject => 'This is the test_new issue',
271 281 :description => 'This is the description',
272 282 :priority_id => 5}
273 283 assert_response :success
274 284 assert_template 'new'
275 285 end
276 286
277 287 def test_post_new
278 288 @request.session[:user_id] = 2
279 289 post :new, :project_id => 1,
280 290 :issue => {:tracker_id => 3,
281 291 :subject => 'This is the test_new issue',
282 292 :description => 'This is the description',
283 293 :priority_id => 5,
284 294 :estimated_hours => '',
285 295 :custom_field_values => {'2' => 'Value for field 2'}}
286 296 assert_redirected_to 'issues/show'
287 297
288 298 issue = Issue.find_by_subject('This is the test_new issue')
289 299 assert_not_nil issue
290 300 assert_equal 2, issue.author_id
291 301 assert_equal 3, issue.tracker_id
292 302 assert_nil issue.estimated_hours
293 303 v = issue.custom_values.find(:first, :conditions => {:custom_field_id => 2})
294 304 assert_not_nil v
295 305 assert_equal 'Value for field 2', v.value
296 306 end
297 307
298 308 def test_post_new_without_custom_fields_param
299 309 @request.session[:user_id] = 2
300 310 post :new, :project_id => 1,
301 311 :issue => {:tracker_id => 1,
302 312 :subject => 'This is the test_new issue',
303 313 :description => 'This is the description',
304 314 :priority_id => 5}
305 315 assert_redirected_to 'issues/show'
306 316 end
307 317
308 318 def test_post_new_with_required_custom_field_and_without_custom_fields_param
309 319 field = IssueCustomField.find_by_name('Database')
310 320 field.update_attribute(:is_required, true)
311 321
312 322 @request.session[:user_id] = 2
313 323 post :new, :project_id => 1,
314 324 :issue => {:tracker_id => 1,
315 325 :subject => 'This is the test_new issue',
316 326 :description => 'This is the description',
317 327 :priority_id => 5}
318 328 assert_response :success
319 329 assert_template 'new'
320 330 issue = assigns(:issue)
321 331 assert_not_nil issue
322 332 assert_equal 'activerecord_error_invalid', issue.errors.on(:custom_values)
323 333 end
324 334
325 335 def test_post_should_preserve_fields_values_on_validation_failure
326 336 @request.session[:user_id] = 2
327 337 post :new, :project_id => 1,
328 338 :issue => {:tracker_id => 1,
329 339 :subject => 'This is the test_new issue',
330 340 # empty description
331 341 :description => '',
332 342 :priority_id => 6,
333 343 :custom_field_values => {'1' => 'Oracle', '2' => 'Value for field 2'}}
334 344 assert_response :success
335 345 assert_template 'new'
336 346
337 347 assert_tag :input, :attributes => { :name => 'issue[subject]',
338 348 :value => 'This is the test_new issue' }
339 349 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
340 350 :child => { :tag => 'option', :attributes => { :selected => 'selected',
341 351 :value => '6' },
342 352 :content => 'High' }
343 353 # Custom fields
344 354 assert_tag :select, :attributes => { :name => 'issue[custom_field_values][1]' },
345 355 :child => { :tag => 'option', :attributes => { :selected => 'selected',
346 356 :value => 'Oracle' },
347 357 :content => 'Oracle' }
348 358 assert_tag :input, :attributes => { :name => 'issue[custom_field_values][2]',
349 359 :value => 'Value for field 2'}
350 360 end
351 361
352 362 def test_copy_issue
353 363 @request.session[:user_id] = 2
354 364 get :new, :project_id => 1, :copy_from => 1
355 365 assert_template 'new'
356 366 assert_not_nil assigns(:issue)
357 367 orig = Issue.find(1)
358 368 assert_equal orig.subject, assigns(:issue).subject
359 369 end
360 370
361 371 def test_get_edit
362 372 @request.session[:user_id] = 2
363 373 get :edit, :id => 1
364 374 assert_response :success
365 375 assert_template 'edit'
366 376 assert_not_nil assigns(:issue)
367 377 assert_equal Issue.find(1), assigns(:issue)
368 378 end
369 379
370 380 def test_get_edit_with_params
371 381 @request.session[:user_id] = 2
372 382 get :edit, :id => 1, :issue => { :status_id => 5, :priority_id => 7 }
373 383 assert_response :success
374 384 assert_template 'edit'
375 385
376 386 issue = assigns(:issue)
377 387 assert_not_nil issue
378 388
379 389 assert_equal 5, issue.status_id
380 390 assert_tag :select, :attributes => { :name => 'issue[status_id]' },
381 391 :child => { :tag => 'option',
382 392 :content => 'Closed',
383 393 :attributes => { :selected => 'selected' } }
384 394
385 395 assert_equal 7, issue.priority_id
386 396 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
387 397 :child => { :tag => 'option',
388 398 :content => 'Urgent',
389 399 :attributes => { :selected => 'selected' } }
390 400 end
391 401
392 402 def test_reply_to_issue
393 403 @request.session[:user_id] = 2
394 404 get :reply, :id => 1
395 405 assert_response :success
396 406 assert_select_rjs :show, "update"
397 407 end
398 408
399 409 def test_reply_to_note
400 410 @request.session[:user_id] = 2
401 411 get :reply, :id => 1, :journal_id => 2
402 412 assert_response :success
403 413 assert_select_rjs :show, "update"
404 414 end
405 415
406 416 def test_post_edit_without_custom_fields_param
407 417 @request.session[:user_id] = 2
408 418 ActionMailer::Base.deliveries.clear
409 419
410 420 issue = Issue.find(1)
411 421 assert_equal '125', issue.custom_value_for(2).value
412 422 old_subject = issue.subject
413 423 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
414 424
415 425 assert_difference('Journal.count') do
416 426 assert_difference('JournalDetail.count', 2) do
417 427 post :edit, :id => 1, :issue => {:subject => new_subject,
418 428 :priority_id => '6',
419 429 :category_id => '1' # no change
420 430 }
421 431 end
422 432 end
423 433 assert_redirected_to 'issues/show/1'
424 434 issue.reload
425 435 assert_equal new_subject, issue.subject
426 436 # Make sure custom fields were not cleared
427 437 assert_equal '125', issue.custom_value_for(2).value
428 438
429 439 mail = ActionMailer::Base.deliveries.last
430 440 assert_kind_of TMail::Mail, mail
431 441 assert mail.subject.starts_with?("[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]")
432 442 assert mail.body.include?("Subject changed from #{old_subject} to #{new_subject}")
433 443 end
434 444
435 445 def test_post_edit_with_custom_field_change
436 446 @request.session[:user_id] = 2
437 447 issue = Issue.find(1)
438 448 assert_equal '125', issue.custom_value_for(2).value
439 449
440 450 assert_difference('Journal.count') do
441 451 assert_difference('JournalDetail.count', 3) do
442 452 post :edit, :id => 1, :issue => {:subject => 'Custom field change',
443 453 :priority_id => '6',
444 454 :category_id => '1', # no change
445 455 :custom_field_values => { '2' => 'New custom value' }
446 456 }
447 457 end
448 458 end
449 459 assert_redirected_to 'issues/show/1'
450 460 issue.reload
451 461 assert_equal 'New custom value', issue.custom_value_for(2).value
452 462
453 463 mail = ActionMailer::Base.deliveries.last
454 464 assert_kind_of TMail::Mail, mail
455 465 assert mail.body.include?("Searchable field changed from 125 to New custom value")
456 466 end
457 467
458 468 def test_post_edit_with_status_and_assignee_change
459 469 issue = Issue.find(1)
460 470 assert_equal 1, issue.status_id
461 471 @request.session[:user_id] = 2
462 472 assert_difference('TimeEntry.count', 0) do
463 473 post :edit,
464 474 :id => 1,
465 475 :issue => { :status_id => 2, :assigned_to_id => 3 },
466 476 :notes => 'Assigned to dlopper',
467 477 :time_entry => { :hours => '', :comments => '', :activity_id => Enumeration.get_values('ACTI').first }
468 478 end
469 479 assert_redirected_to 'issues/show/1'
470 480 issue.reload
471 481 assert_equal 2, issue.status_id
472 482 j = issue.journals.find(:first, :order => 'id DESC')
473 483 assert_equal 'Assigned to dlopper', j.notes
474 484 assert_equal 2, j.details.size
475 485
476 486 mail = ActionMailer::Base.deliveries.last
477 487 assert mail.body.include?("Status changed from New to Assigned")
478 488 end
479 489
480 490 def test_post_edit_with_note_only
481 491 notes = 'Note added by IssuesControllerTest#test_update_with_note_only'
482 492 # anonymous user
483 493 post :edit,
484 494 :id => 1,
485 495 :notes => notes
486 496 assert_redirected_to 'issues/show/1'
487 497 j = Issue.find(1).journals.find(:first, :order => 'id DESC')
488 498 assert_equal notes, j.notes
489 499 assert_equal 0, j.details.size
490 500 assert_equal User.anonymous, j.user
491 501
492 502 mail = ActionMailer::Base.deliveries.last
493 503 assert mail.body.include?(notes)
494 504 end
495 505
496 506 def test_post_edit_with_note_and_spent_time
497 507 @request.session[:user_id] = 2
498 508 spent_hours_before = Issue.find(1).spent_hours
499 509 assert_difference('TimeEntry.count') do
500 510 post :edit,
501 511 :id => 1,
502 512 :notes => '2.5 hours added',
503 513 :time_entry => { :hours => '2.5', :comments => '', :activity_id => Enumeration.get_values('ACTI').first }
504 514 end
505 515 assert_redirected_to 'issues/show/1'
506 516
507 517 issue = Issue.find(1)
508 518
509 519 j = issue.journals.find(:first, :order => 'id DESC')
510 520 assert_equal '2.5 hours added', j.notes
511 521 assert_equal 0, j.details.size
512 522
513 523 t = issue.time_entries.find(:first, :order => 'id DESC')
514 524 assert_not_nil t
515 525 assert_equal 2.5, t.hours
516 526 assert_equal spent_hours_before + 2.5, issue.spent_hours
517 527 end
518 528
519 529 def test_post_edit_with_attachment_only
520 530 set_tmp_attachments_directory
521 531
522 532 # Delete all fixtured journals, a race condition can occur causing the wrong
523 533 # journal to get fetched in the next find.
524 534 Journal.delete_all
525 535
526 536 # anonymous user
527 537 post :edit,
528 538 :id => 1,
529 539 :notes => '',
530 540 :attachments => {'1' => {'file' => test_uploaded_file('testfile.txt', 'text/plain')}}
531 541 assert_redirected_to 'issues/show/1'
532 542 j = Issue.find(1).journals.find(:first, :order => 'id DESC')
533 543 assert j.notes.blank?
534 544 assert_equal 1, j.details.size
535 545 assert_equal 'testfile.txt', j.details.first.value
536 546 assert_equal User.anonymous, j.user
537 547
538 548 mail = ActionMailer::Base.deliveries.last
539 549 assert mail.body.include?('testfile.txt')
540 550 end
541 551
542 552 def test_post_edit_with_no_change
543 553 issue = Issue.find(1)
544 554 issue.journals.clear
545 555 ActionMailer::Base.deliveries.clear
546 556
547 557 post :edit,
548 558 :id => 1,
549 559 :notes => ''
550 560 assert_redirected_to 'issues/show/1'
551 561
552 562 issue.reload
553 563 assert issue.journals.empty?
554 564 # No email should be sent
555 565 assert ActionMailer::Base.deliveries.empty?
556 566 end
557 567
558 568 def test_bulk_edit
559 569 @request.session[:user_id] = 2
560 570 # update issues priority
561 571 post :bulk_edit, :ids => [1, 2], :priority_id => 7, :notes => 'Bulk editing', :assigned_to_id => ''
562 572 assert_response 302
563 573 # check that the issues were updated
564 574 assert_equal [7, 7], Issue.find_all_by_id([1, 2]).collect {|i| i.priority.id}
565 575 assert_equal 'Bulk editing', Issue.find(1).journals.find(:first, :order => 'created_on DESC').notes
566 576 end
567 577
568 578 def test_bulk_unassign
569 579 assert_not_nil Issue.find(2).assigned_to
570 580 @request.session[:user_id] = 2
571 581 # unassign issues
572 582 post :bulk_edit, :ids => [1, 2], :notes => 'Bulk unassigning', :assigned_to_id => 'none'
573 583 assert_response 302
574 584 # check that the issues were updated
575 585 assert_nil Issue.find(2).assigned_to
576 586 end
577 587
578 588 def test_move_one_issue_to_another_project
579 589 @request.session[:user_id] = 1
580 590 post :move, :id => 1, :new_project_id => 2
581 591 assert_redirected_to 'projects/ecookbook/issues'
582 592 assert_equal 2, Issue.find(1).project_id
583 593 end
584 594
585 595 def test_bulk_move_to_another_project
586 596 @request.session[:user_id] = 1
587 597 post :move, :ids => [1, 2], :new_project_id => 2
588 598 assert_redirected_to 'projects/ecookbook/issues'
589 599 # Issues moved to project 2
590 600 assert_equal 2, Issue.find(1).project_id
591 601 assert_equal 2, Issue.find(2).project_id
592 602 # No tracker change
593 603 assert_equal 1, Issue.find(1).tracker_id
594 604 assert_equal 2, Issue.find(2).tracker_id
595 605 end
596 606
597 607 def test_bulk_move_to_another_tracker
598 608 @request.session[:user_id] = 1
599 609 post :move, :ids => [1, 2], :new_tracker_id => 2
600 610 assert_redirected_to 'projects/ecookbook/issues'
601 611 assert_equal 2, Issue.find(1).tracker_id
602 612 assert_equal 2, Issue.find(2).tracker_id
603 613 end
604 614
605 615 def test_context_menu_one_issue
606 616 @request.session[:user_id] = 2
607 617 get :context_menu, :ids => [1]
608 618 assert_response :success
609 619 assert_template 'context_menu'
610 620 assert_tag :tag => 'a', :content => 'Edit',
611 621 :attributes => { :href => '/issues/edit/1',
612 622 :class => 'icon-edit' }
613 623 assert_tag :tag => 'a', :content => 'Closed',
614 624 :attributes => { :href => '/issues/edit/1?issue%5Bstatus_id%5D=5',
615 625 :class => '' }
616 626 assert_tag :tag => 'a', :content => 'Immediate',
617 627 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;priority_id=8',
618 628 :class => '' }
619 629 assert_tag :tag => 'a', :content => 'Dave Lopper',
620 630 :attributes => { :href => '/issues/bulk_edit?assigned_to_id=3&amp;ids%5B%5D=1',
621 631 :class => '' }
622 632 assert_tag :tag => 'a', :content => 'Copy',
623 633 :attributes => { :href => '/projects/ecookbook/issues/new?copy_from=1',
624 634 :class => 'icon-copy' }
625 635 assert_tag :tag => 'a', :content => 'Move',
626 636 :attributes => { :href => '/issues/move?ids%5B%5D=1',
627 637 :class => 'icon-move' }
628 638 assert_tag :tag => 'a', :content => 'Delete',
629 639 :attributes => { :href => '/issues/destroy?ids%5B%5D=1',
630 640 :class => 'icon-del' }
631 641 end
632 642
633 643 def test_context_menu_one_issue_by_anonymous
634 644 get :context_menu, :ids => [1]
635 645 assert_response :success
636 646 assert_template 'context_menu'
637 647 assert_tag :tag => 'a', :content => 'Delete',
638 648 :attributes => { :href => '#',
639 649 :class => 'icon-del disabled' }
640 650 end
641 651
642 652 def test_context_menu_multiple_issues_of_same_project
643 653 @request.session[:user_id] = 2
644 654 get :context_menu, :ids => [1, 2]
645 655 assert_response :success
646 656 assert_template 'context_menu'
647 657 assert_tag :tag => 'a', :content => 'Edit',
648 658 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;ids%5B%5D=2',
649 659 :class => 'icon-edit' }
650 660 assert_tag :tag => 'a', :content => 'Immediate',
651 661 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;ids%5B%5D=2&amp;priority_id=8',
652 662 :class => '' }
653 663 assert_tag :tag => 'a', :content => 'Dave Lopper',
654 664 :attributes => { :href => '/issues/bulk_edit?assigned_to_id=3&amp;ids%5B%5D=1&amp;ids%5B%5D=2',
655 665 :class => '' }
656 666 assert_tag :tag => 'a', :content => 'Move',
657 667 :attributes => { :href => '/issues/move?ids%5B%5D=1&amp;ids%5B%5D=2',
658 668 :class => 'icon-move' }
659 669 assert_tag :tag => 'a', :content => 'Delete',
660 670 :attributes => { :href => '/issues/destroy?ids%5B%5D=1&amp;ids%5B%5D=2',
661 671 :class => 'icon-del' }
662 672 end
663 673
664 674 def test_context_menu_multiple_issues_of_different_project
665 675 @request.session[:user_id] = 2
666 676 get :context_menu, :ids => [1, 2, 4]
667 677 assert_response :success
668 678 assert_template 'context_menu'
669 679 assert_tag :tag => 'a', :content => 'Delete',
670 680 :attributes => { :href => '#',
671 681 :class => 'icon-del disabled' }
672 682 end
673 683
674 684 def test_destroy_issue_with_no_time_entries
675 685 assert_nil TimeEntry.find_by_issue_id(2)
676 686 @request.session[:user_id] = 2
677 687 post :destroy, :id => 2
678 688 assert_redirected_to 'projects/ecookbook/issues'
679 689 assert_nil Issue.find_by_id(2)
680 690 end
681 691
682 692 def test_destroy_issues_with_time_entries
683 693 @request.session[:user_id] = 2
684 694 post :destroy, :ids => [1, 3]
685 695 assert_response :success
686 696 assert_template 'destroy'
687 697 assert_not_nil assigns(:hours)
688 698 assert Issue.find_by_id(1) && Issue.find_by_id(3)
689 699 end
690 700
691 701 def test_destroy_issues_and_destroy_time_entries
692 702 @request.session[:user_id] = 2
693 703 post :destroy, :ids => [1, 3], :todo => 'destroy'
694 704 assert_redirected_to 'projects/ecookbook/issues'
695 705 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
696 706 assert_nil TimeEntry.find_by_id([1, 2])
697 707 end
698 708
699 709 def test_destroy_issues_and_assign_time_entries_to_project
700 710 @request.session[:user_id] = 2
701 711 post :destroy, :ids => [1, 3], :todo => 'nullify'
702 712 assert_redirected_to 'projects/ecookbook/issues'
703 713 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
704 714 assert_nil TimeEntry.find(1).issue_id
705 715 assert_nil TimeEntry.find(2).issue_id
706 716 end
707 717
708 718 def test_destroy_issues_and_reassign_time_entries_to_another_issue
709 719 @request.session[:user_id] = 2
710 720 post :destroy, :ids => [1, 3], :todo => 'reassign', :reassign_to_id => 2
711 721 assert_redirected_to 'projects/ecookbook/issues'
712 722 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
713 723 assert_equal 2, TimeEntry.find(1).issue_id
714 724 assert_equal 2, TimeEntry.find(2).issue_id
715 725 end
716 726
717 727 def test_destroy_attachment
718 728 issue = Issue.find(3)
719 729 a = issue.attachments.size
720 730 @request.session[:user_id] = 2
721 731 post :destroy_attachment, :id => 3, :attachment_id => 1
722 732 assert_redirected_to 'issues/show/3'
723 733 assert_nil Attachment.find_by_id(1)
724 734 issue.reload
725 735 assert_equal((a-1), issue.attachments.size)
726 736 j = issue.journals.find(:first, :order => 'created_on DESC')
727 737 assert_equal 'attachment', j.details.first.property
728 738 end
729 739 end
@@ -1,433 +1,443
1 1 # redMine - project management software
2 2 # Copyright (C) 2006-2007 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.dirname(__FILE__) + '/../../test_helper'
19 19
20 20 class ApplicationHelperTest < HelperTestCase
21 21 include ApplicationHelper
22 22 include ActionView::Helpers::TextHelper
23 23 fixtures :projects, :roles, :enabled_modules, :users,
24 24 :repositories, :changesets,
25 25 :trackers, :issue_statuses, :issues, :versions, :documents,
26 26 :wikis, :wiki_pages, :wiki_contents,
27 27 :boards, :messages,
28 28 :attachments
29 29
30 30 def setup
31 31 super
32 32 end
33 33
34 34 def test_auto_links
35 35 to_test = {
36 36 'http://foo.bar' => '<a class="external" href="http://foo.bar">http://foo.bar</a>',
37 37 'http://foo.bar/~user' => '<a class="external" href="http://foo.bar/~user">http://foo.bar/~user</a>',
38 38 'http://foo.bar.' => '<a class="external" href="http://foo.bar">http://foo.bar</a>.',
39 39 'https://foo.bar.' => '<a class="external" href="https://foo.bar">https://foo.bar</a>.',
40 40 'This is a link: http://foo.bar.' => 'This is a link: <a class="external" href="http://foo.bar">http://foo.bar</a>.',
41 41 'A link (eg. http://foo.bar).' => 'A link (eg. <a class="external" href="http://foo.bar">http://foo.bar</a>).',
42 42 'http://foo.bar/foo.bar#foo.bar.' => '<a class="external" href="http://foo.bar/foo.bar#foo.bar">http://foo.bar/foo.bar#foo.bar</a>.',
43 43 'http://www.foo.bar/Test_(foobar)' => '<a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>',
44 44 '(see inline link : http://www.foo.bar/Test_(foobar))' => '(see inline link : <a class="external" href="http://www.foo.bar/Test_(foobar)">http://www.foo.bar/Test_(foobar)</a>)',
45 45 '(see inline link : http://www.foo.bar/Test)' => '(see inline link : <a class="external" href="http://www.foo.bar/Test">http://www.foo.bar/Test</a>)',
46 46 '(see inline link : http://www.foo.bar/Test).' => '(see inline link : <a class="external" href="http://www.foo.bar/Test">http://www.foo.bar/Test</a>).',
47 47 '(see "inline link":http://www.foo.bar/Test_(foobar))' => '(see <a href="http://www.foo.bar/Test_(foobar)" class="external">inline link</a>)',
48 48 '(see "inline link":http://www.foo.bar/Test)' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>)',
49 49 '(see "inline link":http://www.foo.bar/Test).' => '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>).',
50 50 'www.foo.bar' => '<a class="external" href="http://www.foo.bar">www.foo.bar</a>',
51 51 'http://foo.bar/page?p=1&t=z&s=' => '<a class="external" href="http://foo.bar/page?p=1&#38;t=z&#38;s=">http://foo.bar/page?p=1&#38;t=z&#38;s=</a>',
52 52 'http://foo.bar/page#125' => '<a class="external" href="http://foo.bar/page#125">http://foo.bar/page#125</a>',
53 53 'http://foo@www.bar.com' => '<a class="external" href="http://foo@www.bar.com">http://foo@www.bar.com</a>',
54 54 'http://foo:bar@www.bar.com' => '<a class="external" href="http://foo:bar@www.bar.com">http://foo:bar@www.bar.com</a>',
55 55 'ftp://foo.bar' => '<a class="external" href="ftp://foo.bar">ftp://foo.bar</a>',
56 56 'ftps://foo.bar' => '<a class="external" href="ftps://foo.bar">ftps://foo.bar</a>',
57 57 'sftp://foo.bar' => '<a class="external" href="sftp://foo.bar">sftp://foo.bar</a>',
58 58 }
59 59 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
60 60 end
61 61
62 62 def test_auto_mailto
63 63 assert_equal '<p><a href="mailto:test@foo.bar" class="email">test@foo.bar</a></p>',
64 64 textilizable('test@foo.bar')
65 65 end
66 66
67 67 def test_inline_images
68 68 to_test = {
69 69 '!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="" />',
70 70 'floating !>http://foo.bar/image.jpg!' => 'floating <div style="float:right"><img src="http://foo.bar/image.jpg" alt="" /></div>',
71 71 'with class !(some-class)http://foo.bar/image.jpg!' => 'with class <img src="http://foo.bar/image.jpg" class="some-class" alt="" />',
72 72 'with style !{width:100px;height100px}http://foo.bar/image.jpg!' => 'with style <img src="http://foo.bar/image.jpg" style="width:100px;height100px;" alt="" />',
73 73 'with title !http://foo.bar/image.jpg(This is a title)!' => 'with title <img src="http://foo.bar/image.jpg" title="This is a title" alt="This is a title" />',
74 74 'with title !http://foo.bar/image.jpg(This is a double-quoted "title")!' => 'with title <img src="http://foo.bar/image.jpg" title="This is a double-quoted &quot;title&quot;" alt="This is a double-quoted &quot;title&quot;" />',
75 75 }
76 76 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
77 77 end
78 78
79 def test_acronyms
80 to_test = {
81 'this is an acronym: GPL(General Public License)' => 'this is an acronym: <acronym title="General Public License">GPL</acronym>',
82 'GPL(This is a double-quoted "title")' => '<acronym title="This is a double-quoted &quot;title&quot;">GPL</acronym>',
83 }
84 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
85
86 end
87
79 88 def test_attached_images
80 89 to_test = {
81 90 'Inline image: !logo.gif!' => 'Inline image: <img src="/attachments/download/3" title="This is a logo" alt="This is a logo" />',
82 91 'Inline image: !logo.GIF!' => 'Inline image: <img src="/attachments/download/3" title="This is a logo" alt="This is a logo" />'
83 92 }
84 93 attachments = Attachment.find(:all)
85 94 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text, :attachments => attachments) }
86 95 end
87 96
88 97 def test_textile_external_links
89 98 to_test = {
90 99 'This is a "link":http://foo.bar' => 'This is a <a href="http://foo.bar" class="external">link</a>',
91 100 'This is an intern "link":/foo/bar' => 'This is an intern <a href="/foo/bar">link</a>',
92 101 '"link (Link title)":http://foo.bar' => '<a href="http://foo.bar" title="Link title" class="external">link</a>',
102 '"link (Link title with "double-quotes")":http://foo.bar' => '<a href="http://foo.bar" title="Link title with &quot;double-quotes&quot;" class="external">link</a>',
93 103 "This is not a \"Link\":\n\nAnother paragraph" => "This is not a \"Link\":</p>\n\n\n\t<p>Another paragraph",
94 104 # no multiline link text
95 105 "This is a double quote \"on the first line\nand another on a second line\":test" => "This is a double quote \"on the first line<br />\nand another on a second line\":test"
96 106 }
97 107 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
98 108 end
99 109
100 110 def test_redmine_links
101 111 issue_link = link_to('#3', {:controller => 'issues', :action => 'show', :id => 3},
102 112 :class => 'issue', :title => 'Error 281 when updating a recipe (New)')
103 113
104 114 changeset_link = link_to('r1', {:controller => 'repositories', :action => 'revision', :id => 'ecookbook', :rev => 1},
105 115 :class => 'changeset', :title => 'My very first commit')
106 116
107 117 document_link = link_to('Test document', {:controller => 'documents', :action => 'show', :id => 1},
108 118 :class => 'document')
109 119
110 120 version_link = link_to('1.0', {:controller => 'versions', :action => 'show', :id => 2},
111 121 :class => 'version')
112 122
113 123 message_url = {:controller => 'messages', :action => 'show', :board_id => 1, :id => 4}
114 124
115 125 source_url = {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file']}
116 126 source_url_with_ext = {:controller => 'repositories', :action => 'entry', :id => 'ecookbook', :path => ['some', 'file.ext']}
117 127
118 128 to_test = {
119 129 # tickets
120 130 '#3, #3 and #3.' => "#{issue_link}, #{issue_link} and #{issue_link}.",
121 131 # changesets
122 132 'r1' => changeset_link,
123 133 # documents
124 134 'document#1' => document_link,
125 135 'document:"Test document"' => document_link,
126 136 # versions
127 137 'version#2' => version_link,
128 138 'version:1.0' => version_link,
129 139 'version:"1.0"' => version_link,
130 140 # source
131 141 'source:/some/file' => link_to('source:/some/file', source_url, :class => 'source'),
132 142 'source:/some/file.' => link_to('source:/some/file', source_url, :class => 'source') + ".",
133 143 'source:/some/file.ext.' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
134 144 'source:/some/file. ' => link_to('source:/some/file', source_url, :class => 'source') + ".",
135 145 'source:/some/file.ext. ' => link_to('source:/some/file.ext', source_url_with_ext, :class => 'source') + ".",
136 146 'source:/some/file, ' => link_to('source:/some/file', source_url, :class => 'source') + ",",
137 147 'source:/some/file@52' => link_to('source:/some/file@52', source_url.merge(:rev => 52), :class => 'source'),
138 148 'source:/some/file.ext@52' => link_to('source:/some/file.ext@52', source_url_with_ext.merge(:rev => 52), :class => 'source'),
139 149 'source:/some/file#L110' => link_to('source:/some/file#L110', source_url.merge(:anchor => 'L110'), :class => 'source'),
140 150 'source:/some/file.ext#L110' => link_to('source:/some/file.ext#L110', source_url_with_ext.merge(:anchor => 'L110'), :class => 'source'),
141 151 'source:/some/file@52#L110' => link_to('source:/some/file@52#L110', source_url.merge(:rev => 52, :anchor => 'L110'), :class => 'source'),
142 152 'export:/some/file' => link_to('export:/some/file', source_url.merge(:format => 'raw'), :class => 'source download'),
143 153 # message
144 154 'message#4' => link_to('Post 2', message_url, :class => 'message'),
145 155 'message#5' => link_to('RE: post 2', message_url.merge(:anchor => 'message-5'), :class => 'message'),
146 156 # escaping
147 157 '!#3.' => '#3.',
148 158 '!r1' => 'r1',
149 159 '!document#1' => 'document#1',
150 160 '!document:"Test document"' => 'document:"Test document"',
151 161 '!version#2' => 'version#2',
152 162 '!version:1.0' => 'version:1.0',
153 163 '!version:"1.0"' => 'version:"1.0"',
154 164 '!source:/some/file' => 'source:/some/file',
155 165 # invalid expressions
156 166 'source:' => 'source:',
157 167 # url hash
158 168 "http://foo.bar/FAQ#3" => '<a class="external" href="http://foo.bar/FAQ#3">http://foo.bar/FAQ#3</a>',
159 169 }
160 170 @project = Project.find(1)
161 171 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
162 172 end
163 173
164 174 def test_wiki_links
165 175 to_test = {
166 176 '[[CookBook documentation]]' => '<a href="/wiki/ecookbook/CookBook_documentation" class="wiki-page">CookBook documentation</a>',
167 177 '[[Another page|Page]]' => '<a href="/wiki/ecookbook/Another_page" class="wiki-page">Page</a>',
168 178 # link with anchor
169 179 '[[CookBook documentation#One-section]]' => '<a href="/wiki/ecookbook/CookBook_documentation#One-section" class="wiki-page">CookBook documentation</a>',
170 180 '[[Another page#anchor|Page]]' => '<a href="/wiki/ecookbook/Another_page#anchor" class="wiki-page">Page</a>',
171 181 # page that doesn't exist
172 182 '[[Unknown page]]' => '<a href="/wiki/ecookbook/Unknown_page" class="wiki-page new">Unknown page</a>',
173 183 '[[Unknown page|404]]' => '<a href="/wiki/ecookbook/Unknown_page" class="wiki-page new">404</a>',
174 184 # link to another project wiki
175 185 '[[onlinestore:]]' => '<a href="/wiki/onlinestore/" class="wiki-page">onlinestore</a>',
176 186 '[[onlinestore:|Wiki]]' => '<a href="/wiki/onlinestore/" class="wiki-page">Wiki</a>',
177 187 '[[onlinestore:Start page]]' => '<a href="/wiki/onlinestore/Start_page" class="wiki-page">Start page</a>',
178 188 '[[onlinestore:Start page|Text]]' => '<a href="/wiki/onlinestore/Start_page" class="wiki-page">Text</a>',
179 189 '[[onlinestore:Unknown page]]' => '<a href="/wiki/onlinestore/Unknown_page" class="wiki-page new">Unknown page</a>',
180 190 # striked through link
181 191 '-[[Another page|Page]]-' => '<del><a href="/wiki/ecookbook/Another_page" class="wiki-page">Page</a></del>',
182 192 '-[[Another page|Page]] link-' => '<del><a href="/wiki/ecookbook/Another_page" class="wiki-page">Page</a> link</del>',
183 193 # escaping
184 194 '![[Another page|Page]]' => '[[Another page|Page]]',
185 195 }
186 196 @project = Project.find(1)
187 197 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
188 198 end
189 199
190 200 def test_html_tags
191 201 to_test = {
192 202 "<div>content</div>" => "<p>&lt;div&gt;content&lt;/div&gt;</p>",
193 203 "<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
194 204 "<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</p>",
195 205 # do not escape pre/code tags
196 206 "<pre>\nline 1\nline2</pre>" => "<pre>\nline 1\nline2</pre>",
197 207 "<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
198 208 "<pre><div>content</div></pre>" => "<pre>&lt;div&gt;content&lt;/div&gt;</pre>",
199 209 "HTML comment: <!-- no comments -->" => "<p>HTML comment: &lt;!-- no comments --&gt;</p>",
200 210 "<!-- opening comment" => "<p>&lt;!-- opening comment</p>",
201 211 # remove attributes except class
202 212 "<pre class='foo'>some text</pre>" => "<pre class='foo'>some text</pre>",
203 213 "<pre onmouseover='alert(1)'>some text</pre>" => "<pre>some text</pre>",
204 214 }
205 215 to_test.each { |text, result| assert_equal result, textilizable(text) }
206 216 end
207 217
208 218 def test_allowed_html_tags
209 219 to_test = {
210 220 "<pre>preformatted text</pre>" => "<pre>preformatted text</pre>",
211 221 "<notextile>no *textile* formatting</notextile>" => "no *textile* formatting",
212 222 "<notextile>this is <tag>a tag</tag></notextile>" => "this is &lt;tag&gt;a tag&lt;/tag&gt;"
213 223 }
214 224 to_test.each { |text, result| assert_equal result, textilizable(text) }
215 225 end
216 226
217 227 def syntax_highlight
218 228 raw = <<-RAW
219 229 <pre><code class="ruby">
220 230 # Some ruby code here
221 231 </pre></code>
222 232 RAW
223 233
224 234 expected = <<-EXPECTED
225 235 <pre><code class="ruby CodeRay"><span class="no">1</span> <span class="c"># Some ruby code here</span>
226 236 </pre></code>
227 237 EXPECTED
228 238
229 239 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
230 240 end
231 241
232 242 def test_wiki_links_in_tables
233 243 to_test = {"|[[Page|Link title]]|[[Other Page|Other title]]|\n|Cell 21|[[Last page]]|" =>
234 244 '<tr><td><a href="/wiki/ecookbook/Page" class="wiki-page new">Link title</a></td>' +
235 245 '<td><a href="/wiki/ecookbook/Other_Page" class="wiki-page new">Other title</a></td>' +
236 246 '</tr><tr><td>Cell 21</td><td><a href="/wiki/ecookbook/Last_page" class="wiki-page new">Last page</a></td></tr>'
237 247 }
238 248 @project = Project.find(1)
239 249 to_test.each { |text, result| assert_equal "<table>#{result}</table>", textilizable(text).gsub(/[\t\n]/, '') }
240 250 end
241 251
242 252 def test_text_formatting
243 253 to_test = {'*_+bold, italic and underline+_*' => '<strong><em><ins>bold, italic and underline</ins></em></strong>',
244 254 '(_text within parentheses_)' => '(<em>text within parentheses</em>)'
245 255 }
246 256 to_test.each { |text, result| assert_equal "<p>#{result}</p>", textilizable(text) }
247 257 end
248 258
249 259 def test_wiki_horizontal_rule
250 260 assert_equal '<hr />', textilizable('---')
251 261 assert_equal '<p>Dashes: ---</p>', textilizable('Dashes: ---')
252 262 end
253 263
254 264 def test_acronym
255 265 assert_equal '<p>This is an acronym: <acronym title="American Civil Liberties Union">ACLU</acronym>.</p>',
256 266 textilizable('This is an acronym: ACLU(American Civil Liberties Union).')
257 267 end
258 268
259 269 def test_footnotes
260 270 raw = <<-RAW
261 271 This is some text[1].
262 272
263 273 fn1. This is the foot note
264 274 RAW
265 275
266 276 expected = <<-EXPECTED
267 277 <p>This is some text<sup><a href=\"#fn1\">1</a></sup>.</p>
268 278 <p id="fn1" class="footnote"><sup>1</sup> This is the foot note</p>
269 279 EXPECTED
270 280
271 281 assert_equal expected.gsub(%r{[\r\n\t]}, ''), textilizable(raw).gsub(%r{[\r\n\t]}, '')
272 282 end
273 283
274 284 def test_table_of_content
275 285 raw = <<-RAW
276 286 {{toc}}
277 287
278 288 h1. Title
279 289
280 290 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
281 291
282 292 h2. Subtitle
283 293
284 294 Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
285 295
286 296 h2. Subtitle with %{color:red}red text%
287 297
288 298 h1. Another title
289 299
290 300 RAW
291 301
292 302 expected = '<ul class="toc">' +
293 303 '<li class="heading1"><a href="#Title">Title</a></li>' +
294 304 '<li class="heading2"><a href="#Subtitle">Subtitle</a></li>' +
295 305 '<li class="heading2"><a href="#Subtitle-with-red-text">Subtitle with red text</a></li>' +
296 306 '<li class="heading1"><a href="#Another-title">Another title</a></li>' +
297 307 '</ul>'
298 308
299 309 assert textilizable(raw).gsub("\n", "").include?(expected)
300 310 end
301 311
302 312 def test_blockquote
303 313 # orig raw text
304 314 raw = <<-RAW
305 315 John said:
306 316 > Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
307 317 > Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
308 318 > * Donec odio lorem,
309 319 > * sagittis ac,
310 320 > * malesuada in,
311 321 > * adipiscing eu, dolor.
312 322 >
313 323 > >Nulla varius pulvinar diam. Proin id arcu id lorem scelerisque condimentum. Proin vehicula turpis vitae lacus.
314 324 > Proin a tellus. Nam vel neque.
315 325
316 326 He's right.
317 327 RAW
318 328
319 329 # expected html
320 330 expected = <<-EXPECTED
321 331 <p>John said:</p>
322 332 <blockquote>
323 333 Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.
324 334 Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
325 335 <ul>
326 336 <li>Donec odio lorem,</li>
327 337 <li>sagittis ac,</li>
328 338 <li>malesuada in,</li>
329 339 <li>adipiscing eu, dolor.</li>
330 340 </ul>
331 341 <blockquote>
332 342 <p>Nulla varius pulvinar diam. Proin id arcu id lorem scelerisque condimentum. Proin vehicula turpis vitae lacus.</p>
333 343 </blockquote>
334 344 <p>Proin a tellus. Nam vel neque.</p>
335 345 </blockquote>
336 346 <p>He's right.</p>
337 347 EXPECTED
338 348
339 349 assert_equal expected.gsub(%r{\s+}, ''), textilizable(raw).gsub(%r{\s+}, '')
340 350 end
341 351
342 352 def test_table
343 353 raw = <<-RAW
344 354 This is a table with empty cells:
345 355
346 356 |cell11|cell12||
347 357 |cell21||cell23|
348 358 |cell31|cell32|cell33|
349 359 RAW
350 360
351 361 expected = <<-EXPECTED
352 362 <p>This is a table with empty cells:</p>
353 363
354 364 <table>
355 365 <tr><td>cell11</td><td>cell12</td><td></td></tr>
356 366 <tr><td>cell21</td><td></td><td>cell23</td></tr>
357 367 <tr><td>cell31</td><td>cell32</td><td>cell33</td></tr>
358 368 </table>
359 369 EXPECTED
360 370
361 371 assert_equal expected.gsub(%r{\s+}, ''), textilizable(raw).gsub(%r{\s+}, '')
362 372 end
363 373
364 374 def test_default_formatter
365 375 Setting.text_formatting = 'unknown'
366 376 text = 'a *link*: http://www.example.net/'
367 377 assert_equal '<p>a *link*: <a href="http://www.example.net/">http://www.example.net/</a></p>', textilizable(text)
368 378 Setting.text_formatting = 'textile'
369 379 end
370 380
371 381 def test_date_format_default
372 382 today = Date.today
373 383 Setting.date_format = ''
374 384 assert_equal l_date(today), format_date(today)
375 385 end
376 386
377 387 def test_date_format
378 388 today = Date.today
379 389 Setting.date_format = '%d %m %Y'
380 390 assert_equal today.strftime('%d %m %Y'), format_date(today)
381 391 end
382 392
383 393 def test_time_format_default
384 394 now = Time.now
385 395 Setting.date_format = ''
386 396 Setting.time_format = ''
387 397 assert_equal l_datetime(now), format_time(now)
388 398 assert_equal l_time(now), format_time(now, false)
389 399 end
390 400
391 401 def test_time_format
392 402 now = Time.now
393 403 Setting.date_format = '%d %m %Y'
394 404 Setting.time_format = '%H %M'
395 405 assert_equal now.strftime('%d %m %Y %H %M'), format_time(now)
396 406 assert_equal now.strftime('%H %M'), format_time(now, false)
397 407 end
398 408
399 409 def test_utc_time_format
400 410 now = Time.now.utc
401 411 Setting.date_format = '%d %m %Y'
402 412 Setting.time_format = '%H %M'
403 413 assert_equal Time.now.strftime('%d %m %Y %H %M'), format_time(now)
404 414 assert_equal Time.now.strftime('%H %M'), format_time(now, false)
405 415 end
406 416
407 417 def test_due_date_distance_in_words
408 418 to_test = { Date.today => 'Due in 0 days',
409 419 Date.today + 1 => 'Due in 1 day',
410 420 Date.today + 100 => 'Due in 100 days',
411 421 Date.today + 20000 => 'Due in 20000 days',
412 422 Date.today - 1 => '1 day late',
413 423 Date.today - 100 => '100 days late',
414 424 Date.today - 20000 => '20000 days late',
415 425 }
416 426 to_test.each do |date, expected|
417 427 assert_equal expected, due_date_distance_in_words(date)
418 428 end
419 429 end
420 430
421 431 def test_avatar
422 432 # turn on avatars
423 433 Setting.gravatar_enabled = '1'
424 434 assert avatar(User.find_by_mail('jsmith@somenet.foo')).include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
425 435 assert avatar('jsmith <jsmith@somenet.foo>').include?(Digest::MD5.hexdigest('jsmith@somenet.foo'))
426 436 assert_nil avatar('jsmith')
427 437 assert_nil avatar(nil)
428 438
429 439 # turn off avatars
430 440 Setting.gravatar_enabled = '0'
431 441 assert_nil avatar(User.find_by_mail('jsmith@somenet.foo'))
432 442 end
433 443 end
General Comments 0
You need to be logged in to leave comments. Login now