ChiliProject is not maintained anymore. Please be advised that there will be no more updates.
We do not recommend that you setup new ChiliProject instances and we urge all existing users to migrate their data to a maintained system, e.g. Redmine. We will provide a migration script later. In the meantime, you can use the instructions by Christian Daehn.
migrate_from_trac12.rake
1 | # redMine - project management software
|
---|---|
2 | # Copyright (C) 2006-2007 Jean-Philippe Lang
|
3 | #
|
4 | # This program is free software; you can redistribute it and/or
|
5 | # modify it under the terms of the GNU General Public License
|
6 | # as published by the Free Software Foundation; either version 2
|
7 | # of the License, or (at your option) any later version.
|
8 | #
|
9 | # This program is distributed in the hope that it will be useful,
|
10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of
|
11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
12 | # GNU General Public License for more details.
|
13 | #
|
14 | # You should have received a copy of the GNU General Public License
|
15 | # along with this program; if not, write to the Free Software
|
16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
17 | |
18 | require 'active_record'
|
19 | require 'iconv'
|
20 | require 'pp'
|
21 | |
22 | namespace :redmine do |
23 | desc 'Trac migration script'
|
24 | task :migrate_from_trac12 => :environment do |
25 | |
26 | module TracMigrate |
27 | TICKET_MAP = []
|
28 | |
29 | DEFAULT_STATUS = IssueStatus.default |
30 | assigned_status = IssueStatus.find_by_position(2) |
31 | resolved_status = IssueStatus.find_by_position(3) |
32 | feedback_status = IssueStatus.find_by_position(4) |
33 | closed_status = IssueStatus.find :first, :conditions => { :is_closed => true } |
34 | STATUS_MAPPING = {'new' => DEFAULT_STATUS, |
35 | 'reopened' => feedback_status,
|
36 | 'assigned' => assigned_status,
|
37 | 'closed' => closed_status
|
38 | } |
39 | |
40 | priorities = IssuePriority.all
|
41 | DEFAULT_PRIORITY = priorities[0] |
42 | PRIORITY_MAPPING = {'lowest' => priorities[0], |
43 | 'low' => priorities[0], |
44 | 'normal' => priorities[1], |
45 | 'high' => priorities[2], |
46 | 'highest' => priorities[3], |
47 | # ---
|
48 | 'trivial' => priorities[0], |
49 | 'minor' => priorities[1], |
50 | 'major' => priorities[2], |
51 | 'critical' => priorities[3], |
52 | 'blocker' => priorities[4] |
53 | } |
54 | |
55 | TRACKER_BUG = Tracker.find_by_position(1) |
56 | TRACKER_FEATURE = Tracker.find_by_position(2) |
57 | # Add a fourth issue type for tasks as we use them heavily
|
58 | t = Tracker.find_by_name('Task') |
59 | if !t
|
60 | t = Tracker.create(:name => 'Task', :is_in_chlog => true, :is_in_roadmap => false, :position => 4) |
61 | t.workflows.copy(Tracker.find(1)) |
62 | end
|
63 | TRACKER_TASK = t
|
64 | DEFAULT_TRACKER = TRACKER_BUG |
65 | TRACKER_MAPPING = {'defect' => TRACKER_BUG, |
66 | 'enhancement' => TRACKER_FEATURE, |
67 | 'task' => TRACKER_TASK, |
68 | 'patch' =>TRACKER_FEATURE |
69 | } |
70 | |
71 | roles = Role.find(:all, :conditions => {:builtin => 0}, :order => 'position ASC') |
72 | manager_role = roles[0]
|
73 | developer_role = roles[1]
|
74 | DEFAULT_ROLE = roles.last
|
75 | ROLE_MAPPING = {'admin' => manager_role, |
76 | 'developer' => developer_role
|
77 | } |
78 | # Add an Hash Table for comments' updatable fields
|
79 | PROP_MAPPING = {'status' => 'status_id', |
80 | 'owner' => 'assigned_to_id', |
81 | 'component' => 'category_id', |
82 | 'milestone' => 'fixed_version_id', |
83 | 'priority' => 'priority_id', |
84 | 'summary' => 'subject', |
85 | 'type' => 'tracker_id'} |
86 | |
87 | # Hash table to map completion ratio
|
88 | RATIO_MAPPING = {'' => 0, |
89 | 'fixed' => 100, |
90 | 'invalid' => 0, |
91 | 'wontfix' => 0, |
92 | 'duplicate' => 100, |
93 | 'worksforme' => 0} |
94 | |
95 | class ::Time |
96 | class << self |
97 | alias :real_now :now |
98 | alias :old_at_method :at |
99 | def now |
100 | real_now - @fake_diff.to_i
|
101 | end
|
102 | def fake(time) |
103 | @fake_diff = real_now - time
|
104 | res = yield
|
105 | @fake_diff = 0 |
106 | res |
107 | end
|
108 | def at(t) |
109 | old_at_method(t>1e6? t*1e-6 : t) |
110 | end
|
111 | end
|
112 | end
|
113 | |
114 | class TracComponent < ActiveRecord::Base |
115 | set_table_name :component
|
116 | end
|
117 | |
118 | class TracMilestone < ActiveRecord::Base |
119 | set_table_name :milestone
|
120 | # If this attribute is set a milestone has a defined target timepoint
|
121 | def due |
122 | if read_attribute(:due) && read_attribute(:due) > 0 |
123 | Time.at(read_attribute(:due)).to_date |
124 | else
|
125 | nil
|
126 | end
|
127 | end
|
128 | # This is the real timepoint at which the milestone has finished.
|
129 | def completed |
130 | if read_attribute(:completed) && read_attribute(:completed) > 0 |
131 | Time.at(read_attribute(:completed)).to_date |
132 | else
|
133 | nil
|
134 | end
|
135 | end
|
136 | |
137 | def description |
138 | # Attribute is named descr in Trac v0.8.x
|
139 | has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description) |
140 | end
|
141 | end
|
142 | |
143 | class TracTicketCustom < ActiveRecord::Base |
144 | set_table_name :ticket_custom
|
145 | end
|
146 | |
147 | class TracAttachment < ActiveRecord::Base |
148 | set_table_name :attachment
|
149 | set_inheritance_column :none
|
150 | |
151 | def time; Time.at(read_attribute(:time)) end |
152 | |
153 | def original_filename |
154 | filename |
155 | end
|
156 | |
157 | def content_type |
158 | ''
|
159 | end
|
160 | |
161 | def exist? |
162 | File.file? trac_fullpath
|
163 | end
|
164 | |
165 | def open |
166 | File.open("#{trac_fullpath}", 'rb') {|f| |
167 | @file = f
|
168 | yield self |
169 | } |
170 | end
|
171 | |
172 | def read(*args) |
173 | @file.read(*args)
|
174 | end
|
175 | |
176 | def description |
177 | read_attribute(:description).to_s.slice(0,255) |
178 | end
|
179 | |
180 | private |
181 | def trac_fullpath |
182 | attachment_type = read_attribute(:type)
|
183 | trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*]/n ) {|x| sprintf('%%%02X', x[0]) } |
184 | trac_dir = id.gsub( /[^a-zA-Z0-9\-_\.!~*\\\/]/n ) {|x| sprintf('%%%02X', x[0]) } |
185 | "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{trac_dir}/#{trac_file}"
|
186 | end
|
187 | end
|
188 | |
189 | class TracTicket < ActiveRecord::Base |
190 | set_table_name :ticket
|
191 | set_inheritance_column :none
|
192 | |
193 | # ticket changes: only migrate status changes and comments
|
194 | has_many :changes, :class_name => "TracTicketChange", :foreign_key => :ticket |
195 | has_many :attachments, :class_name => "TracAttachment", |
196 | :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" + |
197 | " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'ticket'" +
|
198 | ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{TracMigrate::TracAttachment.connection.quote_string(id.to_s)}\''
|
199 | has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket |
200 | |
201 | def ticket_type |
202 | read_attribute(:type)
|
203 | end
|
204 | |
205 | def summary |
206 | read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary) |
207 | end
|
208 | |
209 | def description |
210 | read_attribute(:description).blank? ? summary : read_attribute(:description) |
211 | end
|
212 | |
213 | def time; Time.at(read_attribute(:time)) end |
214 | def changetime; Time.at(read_attribute(:changetime)) end |
215 | end
|
216 | |
217 | class TracTicketChange < ActiveRecord::Base |
218 | set_table_name :ticket_change
|
219 | |
220 | def time; Time.at(read_attribute(:time)) end |
221 | end
|
222 | |
223 | TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup \ |
224 | TracBrowser TracCgi TracChangeset TracInstallPlatforms TracMultipleProjects TracModWSGI \ |
225 | TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \ |
226 | TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \ |
227 | TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \ |
228 | TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \ |
229 | WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \ |
230 | CamelCase TitleIndex TracNavigation TracFineGrainedPermissions TracWorkflow TimingAndEstimationPluginUserManual \ |
231 | PageTemplates BadContent TracRepositoryAdmin) |
232 | class TracWikiPage < ActiveRecord::Base |
233 | set_table_name :wiki
|
234 | set_primary_key :name
|
235 | |
236 | has_many :attachments, :class_name => "TracAttachment", |
237 | :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" + |
238 | " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'wiki'" +
|
239 | ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{TracMigrate::TracAttachment.connection.quote_string(id.to_s)}\''
|
240 | |
241 | def self.columns |
242 | # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
|
243 | super.select {|column| column.name.to_s != 'readonly'} |
244 | end
|
245 | |
246 | def time; Time.at(read_attribute(:time)) end |
247 | end
|
248 | |
249 | class TracPermission < ActiveRecord::Base |
250 | set_table_name :permission
|
251 | end
|
252 | |
253 | class TracSessionAttribute < ActiveRecord::Base |
254 | set_table_name :session_attribute
|
255 | end
|
256 | |
257 | def self.find_or_create_user(username, project_member = false) |
258 | return User.anonymous if username.blank? |
259 | |
260 | u = User.find_by_login(username)
|
261 | if !u
|
262 | # Create a new user if not found
|
263 | mail = username[0,limit_for(User, 'mail')] |
264 | if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email') |
265 | mail = mail_attr.value |
266 | end
|
267 | mail = "#{mail}@foo.bar" unless mail.include?("@") |
268 | |
269 | name = username |
270 | if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name') |
271 | name = name_attr.value |
272 | end
|
273 | name =~ (/(.+?)(?:[\ \t]+(.+)?|[\ \t]+|)$/)
|
274 | fn = $1.strip
|
275 | # Add a dash for lastname or the user is not saved (bugfix)
|
276 | ln = ($2 || '-').strip |
277 | |
278 | u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'), |
279 | :firstname => fn[0, limit_for(User, 'firstname')], |
280 | :lastname => ln[0, limit_for(User, 'lastname')] |
281 | |
282 | u.login = username[0,limit_for(User, 'login')].gsub(/[^a-z0-9_\-@\.]/i, '-') |
283 | u.password = 'trac'
|
284 | u.admin = true if TracPermission.find_by_username_and_action(username, 'admin') |
285 | # finally, a default user is used if the new user is not valid
|
286 | u = User.find(:first) unless u.save |
287 | end
|
288 | # Make sure he is a member of the project
|
289 | if project_member && !u.member_of?(@target_project) |
290 | role = DEFAULT_ROLE
|
291 | if u.admin
|
292 | role = ROLE_MAPPING['admin'] |
293 | elsif TracPermission.find_by_username_and_action(username, 'developer') |
294 | role = ROLE_MAPPING['developer'] |
295 | end
|
296 | Member.create(:user => u, :project => @target_project, :roles => [role]) |
297 | u.reload |
298 | end
|
299 | u |
300 | end
|
301 | |
302 | # Basic wiki syntax conversion
|
303 | def self.convert_wiki_text(text) |
304 | convert_wiki_text_mapping(text, TICKET_MAP)
|
305 | end
|
306 | |
307 | def self.migrate |
308 | establish_connection |
309 | |
310 | # Quick database test
|
311 | TracComponent.count
|
312 | |
313 | migrated_components = 0
|
314 | migrated_milestones = 0
|
315 | migrated_tickets = 0
|
316 | migrated_custom_values = 0
|
317 | migrated_ticket_attachments = 0
|
318 | migrated_wiki_edits = 0
|
319 | migrated_wiki_attachments = 0
|
320 | |
321 | # Wiki system initializing...
|
322 | @target_project.wiki.destroy if @target_project.wiki |
323 | @target_project.reload
|
324 | wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart') |
325 | wiki_edit_count = 0
|
326 | |
327 | # Components
|
328 | who = "Migrating components"
|
329 | issues_category_map = {} |
330 | components_total = TracComponent.count
|
331 | TracComponent.find(:all).each do |component| |
332 | c = IssueCategory.new :project => @target_project, |
333 | :name => encode(component.name[0, limit_for(IssueCategory, 'name')]) |
334 | # Owner
|
335 | unless component.owner.blank?
|
336 | c.assigned_to = find_or_create_user(component.owner, true)
|
337 | end
|
338 | next unless c.save |
339 | issues_category_map[component.name] = c |
340 | migrated_components += 1
|
341 | simplebar(who, migrated_components, components_total) |
342 | end
|
343 | puts if migrated_components < components_total
|
344 | |
345 | # Milestones
|
346 | who = "Migrating milestones"
|
347 | version_map = {} |
348 | milestone_wiki = Array.new
|
349 | milestones_total = TracMilestone.count
|
350 | TracMilestone.find(:all).each do |milestone| |
351 | # First we try to find the wiki page...
|
352 | p = wiki.find_or_new_page(milestone.name.to_s) |
353 | p.content = WikiContent.new(:page => p) if p.new_record? |
354 | p.content.text = milestone.description.to_s |
355 | p.content.author = find_or_create_user('trac')
|
356 | p.content.comments = 'Milestone'
|
357 | p.save |
358 | |
359 | v = Version.new :project => @target_project, |
360 | :name => encode(milestone.name[0, limit_for(Version, 'name')]), |
361 | :description => nil, |
362 | :wiki_page_title => milestone.name.to_s,
|
363 | :effective_date => milestone.completed
|
364 | |
365 | next unless v.save |
366 | version_map[milestone.name] = v |
367 | milestone_wiki.push(milestone.name); |
368 | migrated_milestones += 1
|
369 | simplebar(who, migrated_milestones, milestones_total) |
370 | end
|
371 | puts if migrated_milestones < milestones_total
|
372 | |
373 | # Custom fields
|
374 | # TODO: read trac.ini instead
|
375 | #print "Migrating custom fields"
|
376 | custom_field_map = {} |
377 | TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field| |
378 | #print '.' # Maybe not needed this out?
|
379 | #STDOUT.flush
|
380 | # Redmine custom field name
|
381 | field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize |
382 | # Find if the custom already exists in Redmine
|
383 | f = IssueCustomField.find_by_name(field_name)
|
384 | # Ugly hack to handle billable checkbox. Would require to read the ini file to be cleaner
|
385 | if field_name == 'Billable' |
386 | format = 'bool'
|
387 | else
|
388 | format = 'string'
|
389 | end
|
390 | # Or create a new one
|
391 | f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize, |
392 | :field_format => format)
|
393 | |
394 | next if f.new_record? |
395 | f.trackers = Tracker.find(:all) |
396 | f.projects << @target_project
|
397 | custom_field_map[field.name] = f |
398 | end
|
399 | #puts
|
400 | |
401 | # Trac 'resolution' field as a Redmine custom field
|
402 | r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" }) |
403 | r = IssueCustomField.new(:name => 'Resolution', |
404 | :field_format => 'list', |
405 | :default_value => '', |
406 | :is_filter => true) if r.nil? |
407 | r.trackers = Tracker.find(:all) |
408 | r.projects << @target_project
|
409 | r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
|
410 | r.save! |
411 | custom_field_map['resolution'] = r
|
412 | |
413 | # Trac 'keywords' field as a Redmine custom field
|
414 | k = IssueCustomField.find(:first, :conditions => { :name => "Keywords" }) |
415 | k = IssueCustomField.new(:name => 'Keywords', |
416 | :field_format => 'string', |
417 | :is_filter => true) if k.nil? |
418 | k.trackers = Tracker.find(:all) |
419 | k.projects << @target_project
|
420 | k.save! |
421 | custom_field_map['keywords'] = k
|
422 | |
423 | # Trac ticket id as a Redmine custom field
|
424 | tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" }) |
425 | tid = IssueCustomField.new(:name => 'TracID', |
426 | :field_format => 'string', |
427 | :is_filter => true) if tid.nil? |
428 | tid.trackers = Tracker.find(:all) |
429 | tid.projects << @target_project
|
430 | tid.save! |
431 | custom_field_map['tracid'] = tid
|
432 | |
433 | # Tickets
|
434 | who = "Migrating tickets"
|
435 | tickets_total = TracTicket.count
|
436 | TracTicket.find_each(:batch_size => 200) do |ticket| |
437 | i = Issue.new :project => @target_project, |
438 | :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]), |
439 | :description => encode(ticket.description),
|
440 | :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY, |
441 | :created_on => ticket.time
|
442 | # Add the ticket's author to project's reporter list (bugfix)
|
443 | i.author = find_or_create_user(ticket.reporter,true)
|
444 | # Extrapolate done_ratio from ticket's resolution
|
445 | i.done_ratio = RATIO_MAPPING[ticket.resolution] || 0 |
446 | i.category = issues_category_map[ticket.component] unless ticket.component.blank?
|
447 | i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
|
448 | i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS |
449 | i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER |
450 | i.id = ticket.id unless Issue.exists?(ticket.id) |
451 | next unless Time.fake(ticket.changetime) { i.save } |
452 | TICKET_MAP[ticket.id] = i.id
|
453 | migrated_tickets += 1
|
454 | simplebar(who, migrated_tickets, tickets_total) |
455 | # Owner
|
456 | unless ticket.owner.blank?
|
457 | i.assigned_to = find_or_create_user(ticket.owner, true)
|
458 | Time.fake(ticket.changetime) { i.save }
|
459 | end
|
460 | # Handle CC field
|
461 | ticket.cc.split(',').each do |email| |
462 | w = Watcher.new :watchable_type => 'Issue', |
463 | :watchable_id => i.id,
|
464 | :user_id => find_or_create_user(email.strip).id
|
465 | w.save |
466 | end
|
467 | |
468 | # Necessary to handle direct link to note from timelogs and putting the right start time in issue
|
469 | noteid = 1
|
470 | # Comments and status/resolution/keywords changes
|
471 | ticket.changes.group_by(&:time).each do |time, changeset| |
472 | status_change = changeset.select {|change| change.field == 'status'}.first
|
473 | resolution_change = changeset.select {|change| change.field == 'resolution'}.first
|
474 | keywords_change = changeset.select {|change| change.field == 'keywords'}.first
|
475 | comment_change = changeset.select {|change| change.field == 'comment'}.first
|
476 | # Handle more ticket changes (owner, component, milestone, priority, summary, type, done_ratio and hours)
|
477 | assigned_change = changeset.select {|change| change.field == 'owner'}.first
|
478 | category_change = changeset.select {|change| change.field == 'component'}.first
|
479 | version_change = changeset.select {|change| change.field == 'milestone'}.first
|
480 | priority_change = changeset.select {|change| change.field == 'priority'}.first
|
481 | subject_change = changeset.select {|change| change.field == 'summary'}.first
|
482 | tracker_change = changeset.select {|change| change.field == 'type'}.first
|
483 | time_change = changeset.select {|change| change.field == 'hours'}.first
|
484 | |
485 | # If it's the first note then we set the start working time to handle calendar and gantts
|
486 | if noteid == 1 |
487 | i.start_date = time |
488 | end
|
489 | |
490 | n = Journal.new :notes => (comment_change ? encode(comment_change.newvalue) : ''), |
491 | :created_on => time
|
492 | n.user = find_or_create_user(changeset.first.author) |
493 | n.journalized = i |
494 | if status_change &&
|
495 | STATUS_MAPPING[status_change.oldvalue] &&
|
496 | STATUS_MAPPING[status_change.newvalue] &&
|
497 | (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue]) |
498 | n.details << JournalDetail.new(:property => 'attr', |
499 | :prop_key => PROP_MAPPING['status'], |
500 | :old_value => STATUS_MAPPING[status_change.oldvalue].id, |
501 | :value => STATUS_MAPPING[status_change.newvalue].id) |
502 | end
|
503 | if resolution_change
|
504 | n.details << JournalDetail.new(:property => 'cf', |
505 | :prop_key => custom_field_map['resolution'].id, |
506 | :old_value => resolution_change.oldvalue,
|
507 | :value => resolution_change.newvalue)
|
508 | # Add a change for the done_ratio
|
509 | n.details << JournalDetail.new(:property => 'attr', |
510 | :prop_key => 'done_ratio', |
511 | :old_value => RATIO_MAPPING[resolution_change.oldvalue], |
512 | :value => RATIO_MAPPING[resolution_change.newvalue]) |
513 | # Arbitrary set the due time to the day the ticket was resolved for calendar and gantts
|
514 | case RATIO_MAPPING[resolution_change.newvalue] |
515 | when 0 |
516 | i.due_date = nil
|
517 | when 100 |
518 | i.due_date = time |
519 | end
|
520 | end
|
521 | if keywords_change
|
522 | n.details << JournalDetail.new(:property => 'cf', |
523 | :prop_key => custom_field_map['keywords'].id, |
524 | :old_value => keywords_change.oldvalue,
|
525 | :value => keywords_change.newvalue)
|
526 | end
|
527 | # Handle assignement/owner changes
|
528 | if assigned_change
|
529 | n.details << JournalDetail.new(:property => 'attr', |
530 | :prop_key => PROP_MAPPING['owner'], |
531 | :old_value => find_or_create_user(assigned_change.oldvalue, true), |
532 | :value => find_or_create_user(assigned_change.newvalue, true)) |
533 | end
|
534 | # Handle component/category changes
|
535 | if category_change
|
536 | n.details << JournalDetail.new(:property => 'attr', |
537 | :prop_key => PROP_MAPPING['component'], |
538 | :old_value => issues_category_map[category_change.oldvalue],
|
539 | :value => issues_category_map[category_change.newvalue])
|
540 | end
|
541 | # Handle version/mileston changes
|
542 | if version_change
|
543 | n.details << JournalDetail.new(:property => 'attr', |
544 | :prop_key => PROP_MAPPING['milestone'], |
545 | :old_value => version_map[version_change.oldvalue],
|
546 | :value => version_map[version_change.newvalue])
|
547 | end
|
548 | # Handle priority changes
|
549 | if priority_change
|
550 | n.details << JournalDetail.new(:property => 'attr', |
551 | :prop_key => PROP_MAPPING['priority'], |
552 | :old_value => PRIORITY_MAPPING[priority_change.oldvalue], |
553 | :value => PRIORITY_MAPPING[priority_change.newvalue]) |
554 | end
|
555 | # Handle subject/summary changes
|
556 | if subject_change
|
557 | n.details << JournalDetail.new(:property => 'attr', |
558 | :prop_key => PROP_MAPPING['summary'], |
559 | :old_value => encode(subject_change.oldvalue[0, limit_for(Issue, 'subject')]), |
560 | :value => encode(subject_change.newvalue[0, limit_for(Issue, 'subject')])) |
561 | end
|
562 | # Handle tracker/type (bug, feature) changes
|
563 | if tracker_change
|
564 | n.details << JournalDetail.new(:property => 'attr', |
565 | :prop_key => PROP_MAPPING['type'], |
566 | :old_value => TRACKER_MAPPING[tracker_change.oldvalue] || DEFAULT_TRACKER, |
567 | :value => TRACKER_MAPPING[tracker_change.newvalue] || DEFAULT_TRACKER) |
568 | end
|
569 | # Add timelog entries for each time changes (from timeandestimation plugin)
|
570 | if time_change && time_change.newvalue != '0' && time_change.newvalue != '' |
571 | t = TimeEntry.new(:project => @target_project, |
572 | :issue => i,
|
573 | :user => n.user,
|
574 | :spent_on => time,
|
575 | :hours => time_change.newvalue,
|
576 | :created_on => time,
|
577 | :updated_on => time,
|
578 | :activity_id => TimeEntryActivity.find_by_position(2).id, |
579 | :comments => "#{convert_wiki_text(n.notes.each_line.first.chomp)[0,100] unless !n.notes.each_line.first}... \"more\":/issues/#{i.id}#note-#{noteid}") |
580 | t.save |
581 | t.errors.each_full{|msg| puts msg } |
582 | end
|
583 | # Set correct changetime of the issue
|
584 | next unless Time.fake(ticket.changetime) { i.save } |
585 | n.save unless n.details.empty? && n.notes.blank?
|
586 | noteid += 1
|
587 | end
|
588 | |
589 | # Attachments
|
590 | ticket.attachments.each do |attachment|
|
591 | next unless attachment.exist? |
592 | attachment.open { |
593 | a = Attachment.new :created_on => attachment.time |
594 | a.file = attachment |
595 | a.author = find_or_create_user(attachment.author) |
596 | a.container = i |
597 | a.description = attachment.description |
598 | migrated_ticket_attachments += 1 if a.save |
599 | } |
600 | end
|
601 | |
602 | # Custom fields
|
603 | custom_values = ticket.customs.inject({}) do |h, custom|
|
604 | if custom_field = custom_field_map[custom.name]
|
605 | h[custom_field.id] = custom.value |
606 | migrated_custom_values += 1
|
607 | end
|
608 | h |
609 | end
|
610 | if custom_field_map['resolution'] && !ticket.resolution.blank? |
611 | custom_values[custom_field_map['resolution'].id] = ticket.resolution
|
612 | end
|
613 | if custom_field_map['keywords'] && !ticket.keywords.blank? |
614 | custom_values[custom_field_map['keywords'].id] = ticket.keywords
|
615 | end
|
616 | if custom_field_map['tracid'] |
617 | custom_values[custom_field_map['tracid'].id] = ticket.id
|
618 | end
|
619 | i.custom_field_values = custom_values |
620 | i.save_custom_field_values |
621 | end
|
622 | |
623 | # update issue id sequence if needed (postgresql)
|
624 | Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!') |
625 | puts if migrated_tickets < tickets_total
|
626 | |
627 | # Wiki
|
628 | who = "Migrating wiki"
|
629 | if wiki.save
|
630 | wiki_edits_total = TracWikiPage.count
|
631 | TracWikiPage.find(:all, :order => 'name, version').each do |page| |
632 | # Do not migrate Trac manual wiki pages
|
633 | if TRAC_WIKI_PAGES.include?(page.name) then |
634 | wiki_edits_total -= 1
|
635 | next
|
636 | end
|
637 | p = wiki.find_or_new_page(page.name) |
638 | p.content = WikiContent.new(:page => p) if p.new_record? |
639 | p.content.text = page.text |
640 | p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac' |
641 | p.content.comments = page.comment |
642 | Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
|
643 | migrated_wiki_edits += 1
|
644 | simplebar(who, migrated_wiki_edits, wiki_edits_total) |
645 | |
646 | next if p.content.new_record? |
647 | |
648 | # Attachments
|
649 | page.attachments.each do |attachment|
|
650 | next unless attachment.exist? |
651 | next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page |
652 | attachment.open { |
653 | a = Attachment.new :created_on => attachment.time |
654 | a.file = attachment |
655 | a.author = find_or_create_user(attachment.author) |
656 | a.description = attachment.description |
657 | a.container = p |
658 | migrated_wiki_attachments += 1 if a.save |
659 | } |
660 | end
|
661 | end
|
662 | |
663 | end
|
664 | puts if migrated_wiki_edits < wiki_edits_total
|
665 | |
666 | # Now load each wiki page and transform its content into textile format
|
667 | puts "\nTransform texts to textile format:"
|
668 | |
669 | wiki_pages_count = 0
|
670 | issues_count = 0
|
671 | milestone_wiki_count = 0
|
672 | |
673 | who = " in Wiki pages"
|
674 | wiki.reload |
675 | wiki_pages_total = wiki.pages.count |
676 | wiki.pages.each do |page|
|
677 | page.content.text = convert_wiki_text(page.content.text) |
678 | Time.fake(page.content.updated_on) { page.content.save }
|
679 | wiki_pages_count += 1
|
680 | simplebar(who, wiki_pages_count, wiki_pages_total) |
681 | end
|
682 | puts if wiki_pages_count < wiki_pages_total
|
683 | |
684 | who = " in Issues"
|
685 | issues_total = TICKET_MAP.count
|
686 | TICKET_MAP.each do |newId| |
687 | issues_count += 1
|
688 | simplebar(who, issues_count, issues_total) |
689 | next if newId.nil? |
690 | issue = findIssue(newId) |
691 | next if issue.nil? |
692 | # convert issue description
|
693 | issue.description = convert_wiki_text(issue.description) |
694 | # Converted issue comments had their last updated time set to the day of the migration (bugfix)
|
695 | next unless Time.fake(issue.updated_on) { issue.save } |
696 | # convert issue journals
|
697 | issue.journals.find(:all).each do |journal| |
698 | journal.notes = convert_wiki_text(journal.notes) |
699 | journal.save |
700 | end
|
701 | end
|
702 | puts if issues_count < issues_total
|
703 | |
704 | who = " in Milestone descriptions"
|
705 | milestone_wiki_total = milestone_wiki.count |
706 | milestone_wiki.each do |name|
|
707 | milestone_wiki_count += 1
|
708 | simplebar(who, milestone_wiki_count, milestone_wiki_total) |
709 | p = wiki.find_page(name) |
710 | next if p.nil? |
711 | p.content.text = convert_wiki_text(p.content.text) |
712 | p.content.save |
713 | end
|
714 | puts if milestone_wiki_count < milestone_wiki_total
|
715 | |
716 | puts |
717 | puts "Components: #{migrated_components}/#{components_total}"
|
718 | puts "Milestones: #{migrated_milestones}/#{milestones_total}"
|
719 | puts "Tickets: #{migrated_tickets}/#{tickets_total}"
|
720 | puts "Ticket files: #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s |
721 | puts "Custom values: #{migrated_custom_values}/#{TracTicketCustom.count}"
|
722 | puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edits_total}"
|
723 | puts "Wiki files: #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s |
724 | end
|
725 | |
726 | def self.findIssue(id) |
727 | return Issue.find(id) |
728 | rescue ActiveRecord::RecordNotFound |
729 | puts "[#{id}] not found"
|
730 | nil
|
731 | end
|
732 | |
733 | def self.limit_for(klass, attribute) |
734 | klass.columns_hash[attribute.to_s].limit |
735 | end
|
736 | |
737 | def self.encoding(charset) |
738 | @ic = Iconv.new('UTF-8', charset) |
739 | rescue Iconv::InvalidEncoding |
740 | puts "Invalid encoding!"
|
741 | return false |
742 | end
|
743 | |
744 | def self.set_trac_directory(path) |
745 | @@trac_directory = path
|
746 | raise "This directory doesn't exist!" unless File.directory?(path) |
747 | raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory) |
748 | @@trac_directory
|
749 | rescue Exception => e |
750 | puts e |
751 | return false |
752 | end
|
753 | |
754 | def self.trac_directory |
755 | @@trac_directory
|
756 | end
|
757 | |
758 | def self.set_trac_adapter(adapter) |
759 | return false if adapter.blank? |
760 | raise "Unknown adapter: #{adapter}!" unless %w(sqlite sqlite3 mysql postgresql).include?(adapter) |
761 | # If adapter is sqlite or sqlite3, make sure that trac.db exists
|
762 | raise "#{trac_db_path} doesn't exist!" if %w(sqlite sqlite3).include?(adapter) && !File.exist?(trac_db_path) |
763 | @@trac_adapter = adapter
|
764 | rescue Exception => e |
765 | puts e |
766 | return false |
767 | end
|
768 | |
769 | def self.set_trac_db_host(host) |
770 | return nil if host.blank? |
771 | @@trac_db_host = host
|
772 | end
|
773 | |
774 | def self.set_trac_db_port(port) |
775 | return nil if port.to_i == 0 |
776 | @@trac_db_port = port.to_i
|
777 | end
|
778 | |
779 | def self.set_trac_db_name(name) |
780 | return nil if name.blank? |
781 | @@trac_db_name = name
|
782 | end
|
783 | |
784 | def self.set_trac_db_username(username) |
785 | @@trac_db_username = username
|
786 | end
|
787 | |
788 | def self.set_trac_db_password(password) |
789 | @@trac_db_password = password
|
790 | end
|
791 | |
792 | def self.set_trac_db_schema(schema) |
793 | @@trac_db_schema = schema
|
794 | end
|
795 | |
796 | mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password |
797 | |
798 | def self.trac_db_path; "#{trac_directory}/db/trac.db" end |
799 | def self.trac_attachments_directory; "#{trac_directory}/attachments" end |
800 | |
801 | def self.target_project_identifier(identifier) |
802 | project = Project.find_by_identifier(identifier)
|
803 | if !project
|
804 | # create the target project
|
805 | project = Project.new :name => identifier.humanize, |
806 | :description => '' |
807 | project.identifier = identifier |
808 | puts "Unable to create a project with identifier '#{identifier}'!" unless project.save |
809 | # enable issues and wiki for the created project
|
810 | # Enable all project modules by default
|
811 | project.enabled_module_names = ['issue_tracking', 'wiki', 'time_tracking', 'news', 'documents', 'files', 'repository', 'boards', 'calendar', 'gantt'] |
812 | else
|
813 | puts |
814 | puts "This project already exists in your Redmine database."
|
815 | print "Are you sure you want to append data to this project ? [Y/n] "
|
816 | STDOUT.flush
|
817 | exit if STDIN.gets.match(/^n$/i) |
818 | end
|
819 | project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG) |
820 | project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE) |
821 | # Add Task type to the project
|
822 | project.trackers << TRACKER_TASK unless project.trackers.include?(TRACKER_TASK) |
823 | @target_project = project.new_record? ? nil : project |
824 | @target_project.reload
|
825 | end
|
826 | |
827 | def self.connection_params |
828 | if %w(sqlite sqlite3).include?(trac_adapter) |
829 | {:adapter => trac_adapter,
|
830 | :database => trac_db_path}
|
831 | else
|
832 | {:adapter => trac_adapter,
|
833 | :database => trac_db_name,
|
834 | :host => trac_db_host,
|
835 | :port => trac_db_port,
|
836 | :username => trac_db_username,
|
837 | :password => trac_db_password,
|
838 | :schema_search_path => trac_db_schema
|
839 | } |
840 | end
|
841 | end
|
842 | |
843 | def self.establish_connection |
844 | constants.each do |const|
|
845 | klass = const_get(const) |
846 | next unless klass.respond_to? 'establish_connection' |
847 | klass.establish_connection connection_params |
848 | end
|
849 | end
|
850 | |
851 | private |
852 | def self.encode(text) |
853 | @ic.iconv text
|
854 | rescue
|
855 | text |
856 | end
|
857 | end
|
858 | |
859 | puts |
860 | if Redmine::DefaultData::Loader.no_data? |
861 | puts "Redmine configuration need to be loaded before importing data."
|
862 | puts "Please, run this first:"
|
863 | puts |
864 | puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
|
865 | exit |
866 | end
|
867 | |
868 | puts "WARNING: a new project will be added to Redmine during this process."
|
869 | print "Are you sure you want to continue ? [y/N] "
|
870 | STDOUT.flush
|
871 | break unless STDIN.gets.match(/^y$/i) |
872 | puts |
873 | |
874 | DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432} |
875 | |
876 | prompt('Trac directory', :default => ENV['TRAC_ENV']) {|directory| TracMigrate.set_trac_directory directory.strip} |
877 | prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite3') {|adapter| TracMigrate.set_trac_adapter adapter} |
878 | unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter) |
879 | prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host} |
880 | prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port} |
881 | prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name} |
882 | prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema} |
883 | prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username} |
884 | prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password} |
885 | end
|
886 | prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding} |
887 | prompt('Target project identifier', :default => File.basename(ENV['TRAC_ENV']).downcase!) {|identifier| TracMigrate.target_project_identifier identifier.downcase} |
888 | puts |
889 | |
890 | # Turn off email notifications
|
891 | Setting.notified_events = []
|
892 | |
893 | TracMigrate.migrate
|
894 | end
|
895 | |
896 | |
897 | desc 'Subversion migration script'
|
898 | task :migrate_from_trac12_svn => :environment do |
899 | |
900 | require 'redmine/scm/adapters/abstract_adapter'
|
901 | require 'redmine/scm/adapters/subversion_adapter'
|
902 | require 'rexml/document'
|
903 | require 'uri'
|
904 | require 'tempfile'
|
905 | |
906 | module SvnMigrate |
907 | TICKET_MAP = []
|
908 | |
909 | class Commit |
910 | attr_accessor :revision, :message |
911 | |
912 | def initialize(attributes={}) |
913 | self.message = attributes[:message] || "" |
914 | self.revision = attributes[:revision] |
915 | end
|
916 | end
|
917 | |
918 | class SvnExtendedAdapter < Redmine::Scm::Adapters::SubversionAdapter |
919 | |
920 | def set_message(path=nil, revision=nil, msg=nil) |
921 | path ||= ''
|
922 | |
923 | Tempfile.open('msg') do |tempfile| |
924 | |
925 | # This is a weird thing. We need to cleanup cr/lf so we have uniform line separators
|
926 | tempfile.print msg.gsub(/\r\n/,'\n') |
927 | tempfile.flush |
928 | |
929 | filePath = tempfile.path.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR) |
930 | |
931 | cmd = "#{SVN_BIN} propset svn:log --quiet --revprop -r #{revision} -F \"#{filePath}\" "
|
932 | cmd << credentials_string |
933 | cmd << ' ' + target(URI.escape(path)) |
934 | |
935 | shellout(cmd) do |io|
|
936 | begin
|
937 | loop do
|
938 | line = io.readline |
939 | puts line |
940 | end
|
941 | rescue EOFError |
942 | end
|
943 | end
|
944 | |
945 | raise if $? && $?.exitstatus != 0 |
946 | |
947 | end
|
948 | |
949 | end
|
950 | |
951 | def messages(path=nil) |
952 | path ||= ''
|
953 | |
954 | commits = Array.new
|
955 | |
956 | cmd = "#{SVN_BIN} log --xml -r 1:HEAD"
|
957 | cmd << credentials_string |
958 | cmd << ' ' + target(URI.escape(path)) |
959 | |
960 | shellout(cmd) do |io|
|
961 | begin
|
962 | doc = REXML::Document.new(io) |
963 | doc.elements.each("log/logentry") do |logentry| |
964 | |
965 | commits << Commit.new(
|
966 | { |
967 | :revision => logentry.attributes['revision'].to_i, |
968 | :message => logentry.elements['msg'].text |
969 | }) |
970 | end
|
971 | rescue => e
|
972 | puts"Error !!!"
|
973 | puts e |
974 | end
|
975 | end
|
976 | return nil if $? && $?.exitstatus != 0 |
977 | commits |
978 | end
|
979 | |
980 | end
|
981 | |
982 | def self.migrate |
983 | |
984 | project = Project.find(@@redmine_project) |
985 | if !project
|
986 | puts "Could not find project identifier '#{@@redmine_project}'"
|
987 | raise |
988 | end
|
989 | |
990 | tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" }) |
991 | if !tid
|
992 | puts "Could not find issue custom field 'TracID'"
|
993 | raise |
994 | end
|
995 | |
996 | Issue.find( :all, :conditions => { :project_id => project }).each do |issue| |
997 | val = nil
|
998 | issue.custom_values.each do |value|
|
999 | if value.custom_field.id == tid.id
|
1000 | val = value |
1001 | break
|
1002 | end
|
1003 | end
|
1004 | |
1005 | TICKET_MAP[val.value.to_i] = issue.id if !val.nil? |
1006 | end
|
1007 | |
1008 | svn = self.scm
|
1009 | msgs = svn.messages(@svn_url)
|
1010 | msgs.each do |commit|
|
1011 | |
1012 | newText = convert_wiki_text(commit.message) |
1013 | |
1014 | if newText != commit.message
|
1015 | puts "Updating message #{commit.revision}"
|
1016 | scm.set_message(@svn_url, commit.revision, newText)
|
1017 | end
|
1018 | end
|
1019 | |
1020 | |
1021 | end
|
1022 | |
1023 | # Basic wiki syntax conversion
|
1024 | def self.convert_wiki_text(text) |
1025 | convert_wiki_text_mapping(text, TICKET_MAP)
|
1026 | end
|
1027 | |
1028 | def self.set_svn_url(url) |
1029 | @@svn_url = url
|
1030 | end
|
1031 | |
1032 | def self.set_svn_username(username) |
1033 | @@svn_username = username
|
1034 | end
|
1035 | |
1036 | def self.set_svn_password(password) |
1037 | @@svn_password = password
|
1038 | end
|
1039 | |
1040 | def self.set_redmine_project_identifier(identifier) |
1041 | @@redmine_project = identifier
|
1042 | end
|
1043 | |
1044 | def self.scm |
1045 | # Bugfix, with redmine 1.0.1 (Debian's) it wasn't working anymore
|
1046 | @scm ||= SvnExtendedAdapter.new @@svn_url, @@svn_url, @@svn_username, @@svn_password |
1047 | @scm
|
1048 | end
|
1049 | end
|
1050 | |
1051 | puts |
1052 | if Redmine::DefaultData::Loader.no_data? |
1053 | puts "Redmine configuration need to be loaded before importing data."
|
1054 | puts "Please, run this first:"
|
1055 | puts |
1056 | puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
|
1057 | exit |
1058 | end
|
1059 | |
1060 | puts "WARNING: all commit messages with references to trac pages will be modified"
|
1061 | print "Are you sure you want to continue ? [y/N] "
|
1062 | break unless STDIN.gets.match(/^y$/i) |
1063 | puts |
1064 | |
1065 | prompt('Subversion repository url') {|repository| SvnMigrate.set_svn_url repository.strip} |
1066 | prompt('Subversion repository username') {|username| SvnMigrate.set_svn_username username} |
1067 | prompt('Subversion repository password') {|password| SvnMigrate.set_svn_password password} |
1068 | prompt('Redmine project identifier') {|identifier| SvnMigrate.set_redmine_project_identifier identifier} |
1069 | puts |
1070 | |
1071 | SvnMigrate.migrate
|
1072 | |
1073 | end
|
1074 | |
1075 | # Prompt
|
1076 | def prompt(text, options = {}, &block) |
1077 | default = options[:default] || '' |
1078 | while true |
1079 | print "#{text} [#{default}]: "
|
1080 | STDOUT.flush
|
1081 | value = STDIN.gets.chomp!
|
1082 | value = default if value.blank?
|
1083 | break if yield value |
1084 | end
|
1085 | end
|
1086 | |
1087 | # Basic wiki syntax conversion
|
1088 | def convert_wiki_text_mapping(text, ticket_map = []) |
1089 | # Hide links
|
1090 | def wiki_links_hide(src) |
1091 | @wiki_links = []
|
1092 | @wiki_links_hash = "####WIKILINKS#{src.hash.to_s}####" |
1093 | src.gsub(/(\[\[.+?\|.+?\]\])/) do |
1094 | @wiki_links << $1 |
1095 | @wiki_links_hash
|
1096 | end
|
1097 | end
|
1098 | # Restore links
|
1099 | def wiki_links_restore(src) |
1100 | @wiki_links.each do |s| |
1101 | src = src.sub("#{@wiki_links_hash}", s.to_s)
|
1102 | end
|
1103 | src |
1104 | end
|
1105 | # Hidding code blocks
|
1106 | def code_hide(src) |
1107 | @code = []
|
1108 | @code_hash = "####CODEBLOCK#{src.hash.to_s}####" |
1109 | src.gsub(/(\{\{\{.+?\}\}\}|`.+?`)/m) do |
1110 | @code << $1 |
1111 | @code_hash
|
1112 | end
|
1113 | end
|
1114 | # Convert code blocks
|
1115 | def code_convert(src) |
1116 | @code.each do |s| |
1117 | s = s.to_s |
1118 | if s =~ (/`(.+?)`/m) || s =~ (/\{\{\{(.+?)\}\}\}/) then |
1119 | # inline code
|
1120 | s = s.replace("@#{$1}@")
|
1121 | else
|
1122 | # We would like to convert the Code highlighting too
|
1123 | # This will go into the next line.
|
1124 | shebang_line = false
|
1125 | # Reguar expression for start of code
|
1126 | pre_re = /\{\{\{/
|
1127 | # Code hightlighing...
|
1128 | shebang_re = /^\#\!([a-z]+)/
|
1129 | # Regular expression for end of code
|
1130 | pre_end_re = /\}\}\}/
|
1131 | |
1132 | # Go through the whole text..extract it line by line
|
1133 | s = s.gsub(/^(.*)$/) do |line| |
1134 | m_pre = pre_re.match(line) |
1135 | if m_pre
|
1136 | line = '<pre>'
|
1137 | else
|
1138 | m_sl = shebang_re.match(line) |
1139 | if m_sl
|
1140 | shebang_line = true
|
1141 | line = '<code class="' + m_sl[1] + '">' |
1142 | end
|
1143 | m_pre_end = pre_end_re.match(line) |
1144 | if m_pre_end
|
1145 | line = '</pre>'
|
1146 | if shebang_line
|
1147 | line = '</code>' + line
|
1148 | end
|
1149 | end
|
1150 | end
|
1151 | line |
1152 | end
|
1153 | end
|
1154 | src = src.sub("#{@code_hash}", s)
|
1155 | end
|
1156 | src |
1157 | end
|
1158 | |
1159 | # Hide code blocks
|
1160 | text = code_hide(text) |
1161 | # New line
|
1162 | text = text.gsub(/\[\[[Bb][Rr]\]\]/, "\n") # This has to go before the rules below |
1163 | # Titles (only h1. to h6., and remove #...)
|
1164 | text = text.gsub(/(?:^|^\ +)(\={1,6})\ (.+)\ (?:\1)(?:\ *(\ \#.*))?/) {|s| "\nh#{$1.length}. #{$2}#{$3}\n"} |
1165 | |
1166 | # External Links:
|
1167 | # [http://example.com/]
|
1168 | text = text.gsub(/\[((?:https?|s?ftp)\:\S+)\]/, '\1') |
1169 | # [http://example.com/ Example],[http://example.com/ "Example"]
|
1170 | # [http://example.com/ "Example for "Example""] -> "Example for 'Example'":http://example.com/
|
1171 | text = text.gsub(/\[((?:https?|s?ftp)\:\S+)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "\"#{$3.tr('"','\'')}\":#{$1}"} |
1172 | # [mailto:some@example.com],[mailto:"some@example.com"]
|
1173 | text = text.gsub(/\[mailto\:([\"']?)(.+?)\1\]/, '\2') |
1174 | |
1175 | # Ticket links:
|
1176 | # [ticket:234 Text],[ticket:234 This is a test],[ticket:234 "This is a test"]
|
1177 | # [ticket:234 "Test "with quotes""] -> "Test 'with quotes'":issues/show/234
|
1178 | text = text.gsub(/\[ticket\:(\d+)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "\"#{$3.tr('"','\'')}\":/issues/show/#{$1}"} |
1179 | # ticket:1234
|
1180 | # excluding ticket:1234:file.txt (used in macros)
|
1181 | # #1 - working cause Redmine uses the same syntax.
|
1182 | text = text.gsub(/ticket\:(\d+?)([^\:])/, '#\1\2') |
1183 | |
1184 | # Source & attachments links:
|
1185 | # [source:/trunk/readme.txt Readme File],[source:"/trunk/readme.txt" Readme File],
|
1186 | # [source:/trunk/readme.txt],[source:"/trunk/readme.txt"]
|
1187 | # The text "Readme File" is not converted,
|
1188 | # cause Redmine's wiki does not support this.
|
1189 | # Attachments use same syntax.
|
1190 | text = text.gsub(/\[(source|attachment)\:([\"']?)([^\"']+?)\2(?:\ +(.+?))?\]/, '\1:"\3"') |
1191 | # source:"/trunk/readme.txt"
|
1192 | # source:/trunk/readme.txt - working cause Redmine uses the same syntax.
|
1193 | text = text.gsub(/(source|attachment)\:([\"'])([^\"']+?)\2/, '\1:"\3"') |
1194 | |
1195 | # Milestone links:
|
1196 | # [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)],
|
1197 | # [milestone:"0.1.0 Mercury"],milestone:"0.1.0 Mercury"
|
1198 | # The text "Milestone 0.1.0 (Mercury)" is not converted,
|
1199 | # cause Redmine's wiki does not support this.
|
1200 | text = text.gsub(/\[milestone\:([\"'])([^\"']+?)\1(?:\ +(.+?))?\]/, 'version:"\2"') |
1201 | text = text.gsub(/milestone\:([\"'])([^\"']+?)\1/, 'version:"\2"') |
1202 | # [milestone:0.1.0],milestone:0.1.0
|
1203 | text = text.gsub(/\[milestone\:([^\ ]+?)\]/, 'version:\1') |
1204 | text = text.gsub(/milestone\:([^\ ]+?)/, 'version:\1') |
1205 | |
1206 | # Internal Links:
|
1207 | # ["Some Link"]
|
1208 | text = text.gsub(/\[([\"'])(.+?)\1\]/) {|s| "[[#{$2.delete(',./?;|:')}]]"} |
1209 | # [wiki:"Some Link" "Link description"],[wiki:"Some Link" Link description]
|
1210 | text = text.gsub(/\[wiki\:([\"'])([^\]\"']+?)\1[\ \t]+([\"']?)(.+?)\3\]/) {|s| "[[#{$2.delete(',./?;|:')}|#{$4}]]"} |
1211 | # [wiki:"Some Link"]
|
1212 | text = text.gsub(/\[wiki\:([\"'])([^\]\"']+?)\1\]/) {|s| "[[#{$2.delete(',./?;|:')}]]"} |
1213 | # [wiki:SomeLink]
|
1214 | text = text.gsub(/\[wiki\:([^\s\]]+?)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"} |
1215 | # [wiki:SomeLink Link description],[wiki:SomeLink "Link description"]
|
1216 | text = text.gsub(/\[wiki\:([^\s\]\"']+?)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$3}]]"} |
1217 | |
1218 | # Before convert CamelCase links, must hide wiki links with description.
|
1219 | # Like this: [[http://www.freebsd.org|Hello FreeBSD World]]
|
1220 | text = wiki_links_hide(text) |
1221 | # Links to CamelCase pages (not work for unicode)
|
1222 | # UsingJustWikiCaps,UsingJustWikiCaps/Subpage
|
1223 | text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+(?:\/[^\s[:punct:]]+)*)/) {|s| "#{$1}#{$2}[[#{$3.delete('/')}]]"} |
1224 | # Normalize things that were supposed to not be links
|
1225 | # like !NotALink
|
1226 | text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2') |
1227 | # Now restore hidden links
|
1228 | text = wiki_links_restore(text) |
1229 | |
1230 | # Revisions links
|
1231 | text = text.gsub(/\[(\d+)\]/, 'r\1') |
1232 | # Ticket number re-writing
|
1233 | text = text.gsub(/#(\d+)/) do |s| |
1234 | if $1.length < 10 |
1235 | #ticket_map[$1.to_i] ||= $1
|
1236 | "\##{ticket_map[$1.to_i] || $1}"
|
1237 | else
|
1238 | s |
1239 | end
|
1240 | end
|
1241 | |
1242 | # Highlighting
|
1243 | text = text.gsub(/'''''([^\s])/, '_*\1') |
1244 | text = text.gsub(/([^\s])'''''/, '\1*_') |
1245 | text = text.gsub(/'''/, '*') |
1246 | text = text.gsub(/''/, '_') |
1247 | text = text.gsub(/__/, '+') |
1248 | text = text.gsub(/~~/, '-') |
1249 | text = text.gsub(/,,/, '~') |
1250 | # Tables
|
1251 | text = text.gsub(/\|\|/, '|') |
1252 | # Lists:
|
1253 | # bullet
|
1254 | text = text.gsub(/^(\ +)[\*-] /) {|s| '*' * $1.length + " "} |
1255 | # numbered
|
1256 | text = text.gsub(/^(\ +)\d+\. /) {|s| '#' * $1.length + " "} |
1257 | # Images (work for only attached in current page [[Image(picture.gif)]])
|
1258 | # need rules for: * [[Image(wiki:WikiFormatting:picture.gif)]] (referring to attachment on another page)
|
1259 | # * [[Image(ticket:1:picture.gif)]] (file attached to a ticket)
|
1260 | # * [[Image(htdocs:picture.gif)]] (referring to a file inside project htdocs)
|
1261 | # * [[Image(source:/trunk/trac/htdocs/trac_logo_mini.png)]] (a file in repository)
|
1262 | text = text.gsub(/\[\[image\((.+?)(?:,.+?)?\)\]\]/i, '!\1!') |
1263 | # TOC (is right-aligned, because that in Trac)
|
1264 | text = text.gsub(/\[\[TOC(?:\((.*?)\))?\]\]/m) {|s| "{{>toc}}\n"} |
1265 | |
1266 | # Restore and convert code blocks
|
1267 | text = code_convert(text) |
1268 | |
1269 | text |
1270 | end
|
1271 | |
1272 | # Simple progress bar
|
1273 | def simplebar(title, current, total, out = STDOUT) |
1274 | def get_width |
1275 | default_width = 80
|
1276 | begin
|
1277 | tiocgwinsz = 0x5413
|
1278 | data = [0, 0, 0, 0].pack("SSSS") |
1279 | if out.ioctl(tiocgwinsz, data) >= 0 then |
1280 | rows, cols, xpixels, ypixels = data.unpack("SSSS")
|
1281 | if cols >= 0 then cols else default_width end |
1282 | else
|
1283 | default_width |
1284 | end
|
1285 | rescue Exception |
1286 | default_width |
1287 | end
|
1288 | end
|
1289 | mark = "*"
|
1290 | title_width = 40
|
1291 | max = get_width - title_width - 10
|
1292 | format = "%-#{title_width}s [%-#{max}s] %3d%% %s"
|
1293 | bar = current * max / total |
1294 | percentage = bar * 100 / max
|
1295 | current == total ? eol = "\n" : eol ="\r" |
1296 | printf(format, title, mark * bar, percentage, eol) |
1297 | out.flush |
1298 | end
|
1299 | end
|