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

Matthias Scholz, 2012-01-26 01:28 pm

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