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

Pedro Algarvio, 2011-06-18 05:15 pm

Download (52.8 kB)

 
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