Project

General

Profile

RE: Trac Importer Patch Coordination » migrate_from_trac.rake

Anthony Callegaro, 2011-05-19 16:12

 
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_trac => :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
          def now
99
            real_now - @fake_diff.to_i
100
          end
101
          def fake(time)
102
            @fake_diff = real_now - time
103
            res = yield
104
            @fake_diff = 0
105
           res
106
          end
107
        end
108
      end
109

    
110
      class TracComponent < ActiveRecord::Base
111
        set_table_name :component
112
      end
113

    
114
      class TracMilestone < ActiveRecord::Base
115
        set_table_name :milestone
116
        # If this attribute is set a milestone has a defined target timepoint
117
        def due
118
          if read_attribute(:due) && read_attribute(:due) > 0
119
            Time.at(read_attribute(:due)).to_date
120
          else
121
            nil
122
          end
123
        end
124
        # This is the real timepoint at which the milestone has finished.
125
        def completed
126
          if read_attribute(:completed) && read_attribute(:completed) > 0
127
            Time.at(read_attribute(:completed)).to_date
128
          else
129
            nil
130
          end
131
        end
132

    
133
        def description
134
          # Attribute is named descr in Trac v0.8.x
135
          has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description)
136
        end
137
      end
138

    
139
      class TracTicketCustom < ActiveRecord::Base
140
        set_table_name :ticket_custom
141
      end
142

    
143
      class TracAttachment < ActiveRecord::Base
144
        set_table_name :attachment
145
        set_inheritance_column :none
146

    
147
        def time; Time.at(read_attribute(:time)) end
148

    
149
        def original_filename
150
          filename
151
        end
152

    
153
        def content_type
154
          ''
155
        end
156

    
157
        def exist?
158
          File.file? trac_fullpath
159
        end
160

    
161
        def open
162
          File.open("#{trac_fullpath}", 'rb') {|f|
163
            @file = f
164
            yield self
165
          }
166
        end
167

    
168
        def read(*args)
169
          @file.read(*args)
170
        end
171

    
172
        def description
173
          read_attribute(:description).to_s.slice(0,255)
174
        end
175

    
176
      private
177
        def trac_fullpath
178
          attachment_type = read_attribute(:type)
179
          trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*]/n ) {|x| sprintf('%%%02X', x[0]) }
180
          trac_dir = id.gsub( /[^a-zA-Z0-9\-_\.!~*\\\/]/n ) {|x| sprintf('%%%02X', x[0]) }
181
          "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{trac_dir}/#{trac_file}"
182
        end
183
      end
184

    
185
      class TracTicket < ActiveRecord::Base
186
        set_table_name :ticket
187
        set_inheritance_column :none
188

    
189
        # ticket changes: only migrate status changes and comments
190
        has_many :changes, :class_name => "TracTicketChange", :foreign_key => :ticket
191
        has_many :attachments, :class_name => "TracAttachment",
192
                               :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
193
                                              " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'ticket'" +
194
                                              ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{TracMigrate::TracAttachment.connection.quote_string(id.to_s)}\''
195
        has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket
196

    
197
        def ticket_type
198
          read_attribute(:type)
199
        end
200

    
201
        def summary
202
          read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary)
203
        end
204

    
205
        def description
206
          read_attribute(:description).blank? ? summary : read_attribute(:description)
207
        end
208

    
209
        def time; Time.at(read_attribute(:time)) end
210
        def changetime; Time.at(read_attribute(:changetime)) end
211
      end
212

    
213
      class TracTicketChange < ActiveRecord::Base
214
        set_table_name :ticket_change
215

    
216
        def time; Time.at(read_attribute(:time)) end
217
      end
218

    
219
      TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup \
220
                           TracBrowser TracCgi TracChangeset TracInstallPlatforms TracMultipleProjects TracModWSGI \
221
                           TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
222
                           TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
223
                           TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
224
                           TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
225
                           WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
226
                           CamelCase TitleIndex TracNavigation TracFineGrainedPermissions TracWorkflow TimingAndEstimationPluginUserManual \
227
                           PageTemplates)
228
      class TracWikiPage < ActiveRecord::Base
229
        set_table_name :wiki
230
        set_primary_key :name
231

    
232
        has_many :attachments, :class_name => "TracAttachment",
233
                               :finder_sql => "SELECT DISTINCT attachment.* FROM #{TracMigrate::TracAttachment.table_name}" +
234
                                      " WHERE #{TracMigrate::TracAttachment.table_name}.type = 'wiki'" +
235
                                      ' AND #{TracMigrate::TracAttachment.table_name}.id = \'#{TracMigrate::TracAttachment.connection.quote_string(id.to_s)}\''
236

    
237
        def self.columns
238
          # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
239
          super.select {|column| column.name.to_s != 'readonly'}
240
        end
241

    
242
        def time; Time.at(read_attribute(:time)) end
243
      end
244

    
245
      class TracPermission < ActiveRecord::Base
246
        set_table_name :permission
247
      end
248

    
249
      class TracSessionAttribute < ActiveRecord::Base
250
        set_table_name :session_attribute
251
      end
252

    
253
      def self.find_or_create_user(username, project_member = false)
254
        return User.anonymous if username.blank?
255

    
256
        u = User.find_by_login(username)
257
        if !u
258
          # Create a new user if not found
259
          mail = username[0,limit_for(User, 'mail')]
260
          if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email')
261
            mail = mail_attr.value
262
          end
263
          mail = "#{mail}@foo.bar" unless mail.include?("@")
264

    
265
          name = username
266
          if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
267
            name = name_attr.value
268
          end
269
          name =~ (/(.+?)(?:[\ \t]+(.+)?|[\ \t]+|)$/)
270
          fn = $1.strip
271
          # Add a dash for lastname or the user is not saved (bugfix)
272
          ln = ($2 || '-').strip
273

    
274
          u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
275
                       :firstname => fn[0, limit_for(User, 'firstname')],
276
                       :lastname => ln[0, limit_for(User, 'lastname')]
277

    
278
          u.login = username[0,limit_for(User, 'login')].gsub(/[^a-z0-9_\-@\.]/i, '-')
279
          u.password = 'trac'
280
          u.admin = true if TracPermission.find_by_username_and_action(username, 'admin')
281
          # finally, a default user is used if the new user is not valid
282
          u = User.find(:first) unless u.save
283
        end
284
        # Make sure he is a member of the project
285
        if project_member && !u.member_of?(@target_project)
286
          role = DEFAULT_ROLE
287
          if u.admin
288
            role = ROLE_MAPPING['admin']
289
          elsif TracPermission.find_by_username_and_action(username, 'developer')
290
            role = ROLE_MAPPING['developer']
291
          end
292
          Member.create(:user => u, :project => @target_project, :roles => [role])
293
          u.reload
294
        end
295
        u
296
      end
297

    
298
      # Basic wiki syntax conversion
299
      def self.convert_wiki_text(text)
300
        convert_wiki_text_mapping(text, TICKET_MAP)
301
      end
302

    
303
      def self.migrate
304
        establish_connection
305

    
306
        # Quick database test
307
        TracComponent.count
308

    
309
        migrated_components = 0
310
        migrated_milestones = 0
311
        migrated_tickets = 0
312
        migrated_custom_values = 0
313
        migrated_ticket_attachments = 0
314
        migrated_wiki_edits = 0
315
        migrated_wiki_attachments = 0
316

    
317
        # Wiki system initializing...
318
        @target_project.wiki.destroy if @target_project.wiki
319
        @target_project.reload
320
        wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
321
        wiki_edit_count = 0
322

    
323
        # Components
324
        who = "Migrating components"
325
        issues_category_map = {}
326
        components_total = TracComponent.count
327
        TracComponent.find(:all).each do |component|
328
          c = IssueCategory.new :project => @target_project,
329
                                :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
330
        # Owner
331
        unless component.owner.blank?
332
          c.assigned_to = find_or_create_user(component.owner, true)
333
        end
334
        next unless c.save
335
        issues_category_map[component.name] = c
336
        migrated_components += 1
337
        simplebar(who, migrated_components, components_total)
338
        end
339
        puts if migrated_components < components_total
340

    
341
        # Milestones
342
        who = "Migrating milestones"
343
        version_map = {}
344
        milestone_wiki = Array.new
345
        milestones_total = TracMilestone.count
346
        TracMilestone.find(:all).each do |milestone|
347
          # First we try to find the wiki page...
348
          p = wiki.find_or_new_page(milestone.name.to_s)
349
          p.content = WikiContent.new(:page => p) if p.new_record?
350
          p.content.text = milestone.description.to_s
351
          p.content.author = find_or_create_user('trac')
352
          p.content.comments = 'Milestone'
353
          p.save
354

    
355
          v = Version.new :project => @target_project,
356
                          :name => encode(milestone.name[0, limit_for(Version, 'name')]),
357
                          :description => nil,
358
                          :wiki_page_title => milestone.name.to_s,
359
                          :effective_date => milestone.completed
360

    
361
          next unless v.save
362
          version_map[milestone.name] = v
363
          milestone_wiki.push(milestone.name);
364
          migrated_milestones += 1
365
          simplebar(who, migrated_milestones, milestones_total)
366
        end
367
        puts if migrated_milestones < milestones_total
368

    
369
        # Custom fields
370
        # TODO: read trac.ini instead
371
        #print "Migrating custom fields"
372
        custom_field_map = {}
373
        TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
374
          #print '.' # Maybe not needed this out?
375
          #STDOUT.flush
376
          # Redmine custom field name
377
          field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
378
          # Find if the custom already exists in Redmine
379
          f = IssueCustomField.find_by_name(field_name)
380
          # Ugly hack to handle billable checkbox. Would require to read the ini file to be cleaner
381
          if field_name == 'Billable'
382
            format = 'bool'
383
          else
384
            format = 'string'
385
          end
386
          # Or create a new one
387
          f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
388
                                        :field_format => format)
389

    
390
          next if f.new_record?
391
          f.trackers = Tracker.find(:all)
392
          f.projects << @target_project
393
          custom_field_map[field.name] = f
394
        end
395
        #puts
396

    
397
        # Trac 'resolution' field as a Redmine custom field
398
        r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
399
        r = IssueCustomField.new(:name => 'Resolution',
400
                                 :field_format => 'list',
401
                                 :is_filter => true) if r.nil?
402
        r.trackers = Tracker.find(:all)
403
        r.projects << @target_project
404
        r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
405
        r.save!
406
        custom_field_map['resolution'] = r
407

    
408
        # Trac 'keywords' field as a Redmine custom field
409
        k = IssueCustomField.find(:first, :conditions => { :name => "Keywords" })
410
        k = IssueCustomField.new(:name => 'Keywords',
411
                                 :field_format => 'string',
412
                                 :is_filter => true) if k.nil?
413
        k.trackers = Tracker.find(:all)
414
        k.projects << @target_project
415
        k.save!
416
        custom_field_map['keywords'] = k
417

    
418
        # Trac ticket id as a Redmine custom field
419
        tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
420
        tid = IssueCustomField.new(:name => 'TracID',
421
                                 :field_format => 'string',
422
                                 :is_filter => true) if tid.nil?
423
        tid.trackers = Tracker.find(:all)
424
        tid.projects << @target_project
425
        tid.save!
426
        custom_field_map['tracid'] = tid
427
  
428
        # Tickets
429
        who = "Migrating tickets"
430
          tickets_total = TracTicket.count
431
          TracTicket.find_each(:batch_size => 200) do |ticket|
432
          i = Issue.new :project => @target_project,
433
                          :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
434
                          :description => encode(ticket.description),
435
                          :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
436
                          :created_on => ticket.time
437
          # Add the ticket's author to project's reporter list (bugfix)
438
          i.author = find_or_create_user(ticket.reporter,true)
439
          # Extrapolate done_ratio from ticket's resolution
440
          i.done_ratio = RATIO_MAPPING[ticket.resolution] || 0 
441
          i.category = issues_category_map[ticket.component] unless ticket.component.blank?
442
          i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
443
          i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
444
          i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
445
          i.id = ticket.id unless Issue.exists?(ticket.id)
446
          next unless Time.fake(ticket.changetime) { i.save }
447
          TICKET_MAP[ticket.id] = i.id
448
          migrated_tickets += 1
449
          simplebar(who, migrated_tickets, tickets_total)
450
          # Owner
451
            unless ticket.owner.blank?
452
              i.assigned_to = find_or_create_user(ticket.owner, true)
453
              Time.fake(ticket.changetime) { i.save }
454
            end
455
          # Handle CC field
456
          ticket.cc.split(',').each do |email|
457
            w = Watcher.new :watchable_type => 'Issue',
458
                            :watchable_id => i.id,
459
                            :user_id => find_or_create_user(email.strip).id 
460
            w.save
461
          end
462

    
463
          # Necessary to handle direct link to note from timelogs and putting the right start time in issue
464
          noteid = 1
465
          # Comments and status/resolution/keywords changes
466
          ticket.changes.group_by(&:time).each do |time, changeset|
467
              status_change = changeset.select {|change| change.field == 'status'}.first
468
              resolution_change = changeset.select {|change| change.field == 'resolution'}.first
469
              keywords_change = changeset.select {|change| change.field == 'keywords'}.first
470
              comment_change = changeset.select {|change| change.field == 'comment'}.first
471
              # Handle more ticket changes (owner, component, milestone, priority, summary, type, done_ratio and hours)
472
              assigned_change = changeset.select {|change| change.field == 'owner'}.first
473
              category_change = changeset.select {|change| change.field == 'component'}.first
474
              version_change = changeset.select {|change| change.field == 'milestone'}.first
475
              priority_change = changeset.select {|change| change.field == 'priority'}.first
476
              subject_change = changeset.select {|change| change.field == 'summary'}.first
477
              tracker_change = changeset.select {|change| change.field == 'type'}.first
478
              time_change = changeset.select {|change| change.field == 'hours'}.first
479

    
480
              # If it's the first note then we set the start working time to handle calendar and gantts
481
              if noteid == 1
482
                i.start_date = time
483
              end
484

    
485
              n = Journal.new :notes => (comment_change ? encode(comment_change.newvalue) : ''),
486
                              :created_on => time
487
              n.user = find_or_create_user(changeset.first.author)
488
              n.journalized = i
489
              if status_change &&
490
                   STATUS_MAPPING[status_change.oldvalue] &&
491
                   STATUS_MAPPING[status_change.newvalue] &&
492
                   (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
493
                n.details << JournalDetail.new(:property => 'attr',
494
                                               :prop_key => PROP_MAPPING['status'],
495
                                               :old_value => STATUS_MAPPING[status_change.oldvalue].id,
496
                                               :value => STATUS_MAPPING[status_change.newvalue].id)
497
              end
498
              if resolution_change
499
                n.details << JournalDetail.new(:property => 'cf',
500
                                               :prop_key => custom_field_map['resolution'].id,
501
                                               :old_value => resolution_change.oldvalue,
502
                                               :value => resolution_change.newvalue)
503
                # Add a change for the done_ratio
504
                n.details << JournalDetail.new(:property => 'attr',
505
                                               :prop_key => 'done_ratio',
506
                                               :old_value => RATIO_MAPPING[resolution_change.oldvalue],
507
                                               :value => RATIO_MAPPING[resolution_change.newvalue])
508
                # Arbitrary set the due time to the day the ticket was resolved for calendar and gantts
509
                case RATIO_MAPPING[resolution_change.newvalue]
510
                when 0
511
                  i.due_date = nil
512
                when 100
513
                  i.due_date = time
514
                end               
515
              end
516
              if keywords_change
517
                n.details << JournalDetail.new(:property => 'cf',
518
                                               :prop_key => custom_field_map['keywords'].id,
519
                                               :old_value => keywords_change.oldvalue,
520
                                               :value => keywords_change.newvalue)
521
              end
522
              # Handle assignement/owner changes
523
              if assigned_change
524
                n.details << JournalDetail.new(:property => 'attr',
525
                                               :prop_key => PROP_MAPPING['owner'],
526
                                               :old_value => find_or_create_user(assigned_change.oldvalue, true),
527
                                               :value => find_or_create_user(assigned_change.newvalue, true))
528
              end
529
              # Handle component/category changes
530
              if category_change
531
                n.details << JournalDetail.new(:property => 'attr',
532
                                               :prop_key => PROP_MAPPING['component'],
533
                                               :old_value => issues_category_map[category_change.oldvalue],
534
                                               :value => issues_category_map[category_change.newvalue])
535
              end
536
              # Handle version/mileston changes
537
              if version_change
538
                n.details << JournalDetail.new(:property => 'attr',
539
                                               :prop_key => PROP_MAPPING['milestone'],
540
                                               :old_value => version_map[version_change.oldvalue],
541
                                               :value => version_map[version_change.newvalue])
542
              end
543
              # Handle priority changes
544
              if priority_change
545
                n.details << JournalDetail.new(:property => 'attr',
546
                                               :prop_key => PROP_MAPPING['priority'],
547
                                               :old_value => PRIORITY_MAPPING[priority_change.oldvalue],
548
                                               :value => PRIORITY_MAPPING[priority_change.newvalue])
549
              end
550
              # Handle subject/summary changes
551
              if subject_change
552
                n.details << JournalDetail.new(:property => 'attr',
553
                                               :prop_key => PROP_MAPPING['summary'],
554
                                               :old_value => encode(subject_change.oldvalue[0, limit_for(Issue, 'subject')]),
555
                                               :value => encode(subject_change.newvalue[0, limit_for(Issue, 'subject')]))
556
              end
557
              # Handle tracker/type (bug, feature) changes
558
              if tracker_change
559
                n.details << JournalDetail.new(:property => 'attr',
560
                                               :prop_key => PROP_MAPPING['type'],
561
                                               :old_value => TRACKER_MAPPING[tracker_change.oldvalue] || DEFAULT_TRACKER,
562
                                               :value => TRACKER_MAPPING[tracker_change.newvalue] || DEFAULT_TRACKER)
563
              end              
564
              # Add timelog entries for each time changes (from timeandestimation plugin)
565
              if time_change && time_change.newvalue != '0' && time_change.newvalue != ''
566
                t = TimeEntry.new(:project => @target_project, 
567
                                  :issue => i, 
568
                                  :user => n.user,
569
                                  :spent_on => time,
570
                                  :hours => time_change.newvalue,
571
                                  :created_on => time,
572
                                  :updated_on => time,
573
                                  :activity_id => TimeEntryActivity.find_by_position(2).id,
574
                                  :comments => "#{convert_wiki_text(n.notes.each_line.first.chomp)[0,100] unless !n.notes.each_line.first}... \"more\":/issues/#{i.id}#note-#{noteid}")
575
                t.save
576
                t.errors.each_full{|msg| puts msg }
577
              end
578
              # Set correct changetime of the issue
579
              next unless Time.fake(ticket.changetime) { i.save }
580
              n.save unless n.details.empty? && n.notes.blank?
581
              noteid += 1
582
          end
583

    
584
          # Attachments
585
          ticket.attachments.each do |attachment|
586
            next unless attachment.exist?
587
              attachment.open {
588
                a = Attachment.new :created_on => attachment.time
589
                a.file = attachment
590
                a.author = find_or_create_user(attachment.author)
591
                a.container = i
592
                a.description = attachment.description
593
                migrated_ticket_attachments += 1 if a.save
594
              }
595
          end
596

    
597
          # Custom fields
598
          custom_values = ticket.customs.inject({}) do |h, custom|
599
            if custom_field = custom_field_map[custom.name]
600
              h[custom_field.id] = custom.value
601
              migrated_custom_values += 1
602
            end
603
            h
604
          end
605
          if custom_field_map['resolution'] && !ticket.resolution.blank?
606
            custom_values[custom_field_map['resolution'].id] = ticket.resolution
607
          end
608
          if custom_field_map['keywords'] && !ticket.keywords.blank?
609
            custom_values[custom_field_map['keywords'].id] = ticket.keywords
610
          end
611
          if custom_field_map['tracid'] 
612
            custom_values[custom_field_map['tracid'].id] = ticket.id
613
          end
614
          i.custom_field_values = custom_values
615
          i.save_custom_field_values
616
        end
617

    
618
        # update issue id sequence if needed (postgresql)
619
        Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
620
        puts if migrated_tickets < tickets_total
621

    
622
        # Wiki
623
        who = "Migrating wiki"
624
        if wiki.save
625
          wiki_edits_total = TracWikiPage.count
626
          TracWikiPage.find(:all, :order => 'name, version').each do |page|
627
            # Do not migrate Trac manual wiki pages
628
            if TRAC_WIKI_PAGES.include?(page.name) then
629
              wiki_edits_total -= 1
630
              next
631
            end
632
            p = wiki.find_or_new_page(page.name)
633
            p.content = WikiContent.new(:page => p) if p.new_record?
634
            p.content.text = page.text
635
            p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
636
            p.content.comments = page.comment
637
            Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
638
            migrated_wiki_edits += 1
639
            simplebar(who, migrated_wiki_edits, wiki_edits_total)
640

    
641
            next if p.content.new_record?
642

    
643
            # Attachments
644
            page.attachments.each do |attachment|
645
              next unless attachment.exist?
646
              next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page
647
              attachment.open {
648
                a = Attachment.new :created_on => attachment.time
649
                a.file = attachment
650
                a.author = find_or_create_user(attachment.author)
651
                a.description = attachment.description
652
                a.container = p
653
                migrated_wiki_attachments += 1 if a.save
654
              }
655
            end
656
          end
657

    
658
        end
659
        puts if migrated_wiki_edits < wiki_edits_total
660

    
661
        # Now load each wiki page and transform its content into textile format
662
        puts "\nTransform texts to textile format:"
663
    
664
        wiki_pages_count = 0
665
        issues_count = 0
666
        milestone_wiki_count = 0
667

    
668
        who = "   in Wiki pages"
669
        wiki.reload
670
        wiki_pages_total = wiki.pages.count
671
        wiki.pages.each do |page|
672
          page.content.text = convert_wiki_text(page.content.text)
673
          Time.fake(page.content.updated_on) { page.content.save }
674
          wiki_pages_count += 1
675
          simplebar(who, wiki_pages_count, wiki_pages_total)
676
        end
677
        puts if wiki_pages_count < wiki_pages_total
678
        
679
        who = "   in Issues"
680
        issues_total = TICKET_MAP.count
681
        TICKET_MAP.each do |newId|
682
          issues_count += 1
683
          simplebar(who, issues_count, issues_total)
684
          next if newId.nil?
685
          issue = findIssue(newId)
686
          next if issue.nil?
687
          # convert issue description
688
          issue.description = convert_wiki_text(issue.description)
689
          # Converted issue comments had their last updated time set to the day of the migration (bugfix)
690
          next unless Time.fake(issue.updated_on) { issue.save }
691
          # convert issue journals
692
          issue.journals.find(:all).each do |journal|
693
            journal.notes = convert_wiki_text(journal.notes)
694
            journal.save
695
          end
696
        end
697
        puts if issues_count < issues_total
698

    
699
        who = "   in Milestone descriptions"
700
        milestone_wiki_total = milestone_wiki.count
701
        milestone_wiki.each do |name|
702
          milestone_wiki_count += 1
703
          simplebar(who, milestone_wiki_count, milestone_wiki_total)
704
          p = wiki.find_page(name)            
705
          next if p.nil?
706
          p.content.text = convert_wiki_text(p.content.text)
707
          p.content.save
708
        end
709
        puts if milestone_wiki_count < milestone_wiki_total
710

    
711
        puts
712
        puts "Components:      #{migrated_components}/#{components_total}"
713
        puts "Milestones:      #{migrated_milestones}/#{milestones_total}"
714
        puts "Tickets:         #{migrated_tickets}/#{tickets_total}"
715
        puts "Ticket files:    #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
716
        puts "Custom values:   #{migrated_custom_values}/#{TracTicketCustom.count}"
717
        puts "Wiki edits:      #{migrated_wiki_edits}/#{wiki_edits_total}"
718
        puts "Wiki files:      #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
719
      end
720
      
721
      def self.findIssue(id)
722
        return Issue.find(id)
723
      rescue ActiveRecord::RecordNotFound
724
        puts "[#{id}] not found"
725
        nil
726
      end
727
      
728
      def self.limit_for(klass, attribute)
729
        klass.columns_hash[attribute.to_s].limit
730
      end
731

    
732
      def self.encoding(charset)
733
        @ic = Iconv.new('UTF-8', charset)
734
      rescue Iconv::InvalidEncoding
735
        puts "Invalid encoding!"
736
        return false
737
      end
738

    
739
      def self.set_trac_directory(path)
740
        @@trac_directory = path
741
        raise "This directory doesn't exist!" unless File.directory?(path)
742
        raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory)
743
        @@trac_directory
744
      rescue Exception => e
745
        puts e
746
        return false
747
      end
748

    
749
      def self.trac_directory
750
        @@trac_directory
751
      end
752

    
753
      def self.set_trac_adapter(adapter)
754
        return false if adapter.blank?
755
        raise "Unknown adapter: #{adapter}!" unless %w(sqlite sqlite3 mysql postgresql).include?(adapter)
756
        # If adapter is sqlite or sqlite3, make sure that trac.db exists
757
        raise "#{trac_db_path} doesn't exist!" if %w(sqlite sqlite3).include?(adapter) && !File.exist?(trac_db_path)
758
        @@trac_adapter = adapter
759
      rescue Exception => e
760
        puts e
761
        return false
762
      end
763

    
764
      def self.set_trac_db_host(host)
765
        return nil if host.blank?
766
        @@trac_db_host = host
767
      end
768

    
769
      def self.set_trac_db_port(port)
770
        return nil if port.to_i == 0
771
        @@trac_db_port = port.to_i
772
      end
773

    
774
      def self.set_trac_db_name(name)
775
        return nil if name.blank?
776
        @@trac_db_name = name
777
      end
778

    
779
      def self.set_trac_db_username(username)
780
        @@trac_db_username = username
781
      end
782

    
783
      def self.set_trac_db_password(password)
784
        @@trac_db_password = password
785
      end
786

    
787
      def self.set_trac_db_schema(schema)
788
        @@trac_db_schema = schema
789
      end
790

    
791
      mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password
792

    
793
      def self.trac_db_path; "#{trac_directory}/db/trac.db" end
794
      def self.trac_attachments_directory; "#{trac_directory}/attachments" end
795

    
796
      def self.target_project_identifier(identifier)
797
        project = Project.find_by_identifier(identifier)
798
        if !project
799
          # create the target project
800
          project = Project.new :name => identifier.humanize,
801
                                :description => ''
802
          project.identifier = identifier
803
          puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
804
          # enable issues and wiki for the created project
805
          # Enable all project modules by default
806
          project.enabled_module_names = ['issue_tracking', 'wiki', 'time_tracking', 'news', 'documents', 'files', 'repository', 'boards', 'calendar', 'gantt']
807
        else
808
          puts
809
          puts "This project already exists in your Redmine database."
810
          print "Are you sure you want to append data to this project ? [Y/n] "
811
          STDOUT.flush
812
          exit if STDIN.gets.match(/^n$/i)
813
        end
814
        project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
815
        project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
816
        # Add Task type to the project
817
        project.trackers << TRACKER_TASK unless project.trackers.include?(TRACKER_TASK)
818
        @target_project = project.new_record? ? nil : project
819
        @target_project.reload
820
      end
821

    
822
      def self.connection_params
823
        if %w(sqlite sqlite3).include?(trac_adapter)
824
          {:adapter => trac_adapter,
825
           :database => trac_db_path}
826
        else
827
          {:adapter => trac_adapter,
828
           :database => trac_db_name,
829
           :host => trac_db_host,
830
           :port => trac_db_port,
831
           :username => trac_db_username,
832
           :password => trac_db_password,
833
           :schema_search_path => trac_db_schema
834
          }
835
        end
836
      end
837

    
838
      def self.establish_connection
839
        constants.each do |const|
840
          klass = const_get(const)
841
          next unless klass.respond_to? 'establish_connection'
842
          klass.establish_connection connection_params
843
        end
844
      end
845

    
846
    private
847
      def self.encode(text)
848
        @ic.iconv text
849
      rescue
850
        text
851
      end
852
    end
853

    
854
    puts
855
    if Redmine::DefaultData::Loader.no_data?
856
      puts "Redmine configuration need to be loaded before importing data."
857
      puts "Please, run this first:"
858
      puts
859
      puts "  rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
860
      exit
861
    end
862

    
863
    puts "WARNING: a new project will be added to Redmine during this process."
864
    print "Are you sure you want to continue ? [y/N] "
865
    STDOUT.flush
866
    break unless STDIN.gets.match(/^y$/i)
867
    puts
868

    
869
    DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
870

    
871
    prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
872
    prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite3') {|adapter| TracMigrate.set_trac_adapter adapter}
873
    unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
874
      prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
875
      prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
876
      prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name}
877
      prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema}
878
      prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username}
879
      prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
880
    end
881
    prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
882
    prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier.downcase}
883
    puts
884
    
885
    # Turn off email notifications
886
    Setting.notified_events = []
887
    
888
    TracMigrate.migrate
889
  end
890

    
891

    
892
  desc 'Subversion migration script'
893
  task :migrate_from_trac_svn => :environment do
894

    
895
    require 'redmine/scm/adapters/abstract_adapter'
896
    require 'redmine/scm/adapters/subversion_adapter'
897
    require 'rexml/document'
898
    require 'uri'
899
    require 'tempfile'
900

    
901
    module SvnMigrate 
902
        TICKET_MAP = []
903

    
904
        class Commit
905
          attr_accessor :revision, :message
906
          
907
          def initialize(attributes={})
908
            self.message = attributes[:message] || ""
909
            self.revision = attributes[:revision]
910
          end
911
        end
912
        
913
        class SvnExtendedAdapter < Redmine::Scm::Adapters::SubversionAdapter
914

    
915
            def set_message(path=nil, revision=nil, msg=nil)
916
              path ||= ''
917

    
918
              Tempfile.open('msg') do |tempfile|
919

    
920
                # This is a weird thing. We need to cleanup cr/lf so we have uniform line separators              
921
                tempfile.print msg.gsub(/\r\n/,'\n')
922
                tempfile.flush
923

    
924
                filePath = tempfile.path.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR)
925

    
926
                cmd = "#{SVN_BIN} propset svn:log --quiet --revprop -r #{revision}  -F \"#{filePath}\" "
927
                cmd << credentials_string
928
                cmd << ' ' + target(URI.escape(path))
929

    
930
                shellout(cmd) do |io|
931
                  begin
932
                    loop do 
933
                      line = io.readline
934
                      puts line
935
                    end
936
                  rescue EOFError
937
                  end  
938
                end
939

    
940
                raise if $? && $?.exitstatus != 0
941

    
942
              end
943
              
944
            end
945
        
946
            def messages(path=nil)
947
              path ||= ''
948

    
949
              commits = Array.new
950

    
951
              cmd = "#{SVN_BIN} log --xml -r 1:HEAD"
952
              cmd << credentials_string
953
              cmd << ' ' + target(URI.escape(path))
954
                            
955
              shellout(cmd) do |io|
956
                begin
957
                  doc = REXML::Document.new(io)
958
                  doc.elements.each("log/logentry") do |logentry|
959

    
960
                    commits << Commit.new(
961
                                                {
962
                                                  :revision => logentry.attributes['revision'].to_i,
963
                                                  :message => logentry.elements['msg'].text
964
                                                })
965
                  end
966
                rescue => e
967
                  puts"Error !!!"
968
                  puts e
969
                end
970
              end
971
              return nil if $? && $?.exitstatus != 0
972
              commits
973
            end
974
          
975
        end
976
        
977
        def self.migrate
978

    
979
          project = Project.find(@@redmine_project)
980
          if !project
981
            puts "Could not find project identifier '#{@@redmine_project}'"
982
            raise 
983
          end
984
                    
985
          tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
986
          if !tid
987
            puts "Could not find issue custom field 'TracID'"
988
            raise 
989
          end
990
          
991
          Issue.find( :all, :conditions => { :project_id => project }).each do |issue|
992
            val = nil
993
            issue.custom_values.each do |value|
994
              if value.custom_field.id == tid.id
995
                val = value
996
                break
997
              end
998
            end
999
            
1000
            TICKET_MAP[val.value.to_i] = issue.id if !val.nil?            
1001
          end
1002
          
1003
          svn = self.scm          
1004
          msgs = svn.messages(@svn_url)
1005
          msgs.each do |commit| 
1006
          
1007
            newText = convert_wiki_text(commit.message)
1008
            
1009
            if newText != commit.message             
1010
              puts "Updating message #{commit.revision}"
1011
              scm.set_message(@svn_url, commit.revision, newText)
1012
            end
1013
          end
1014
          
1015
          
1016
        end
1017
        
1018
        # Basic wiki syntax conversion
1019
        def self.convert_wiki_text(text)
1020
          convert_wiki_text_mapping(text, TICKET_MAP)
1021
        end
1022
        
1023
        def self.set_svn_url(url)
1024
          @@svn_url = url
1025
        end
1026

    
1027
        def self.set_svn_username(username)
1028
          @@svn_username = username
1029
        end
1030

    
1031
        def self.set_svn_password(password)
1032
          @@svn_password = password
1033
        end
1034

    
1035
        def self.set_redmine_project_identifier(identifier)
1036
          @@redmine_project = identifier
1037
        end
1038
      
1039
        def self.scm
1040
          # Bugfix, with redmine 1.0.1 (Debian's) it wasn't working anymore
1041
          @scm ||= SvnExtendedAdapter.new @@svn_url, @@svn_url, @@svn_username, @@svn_password
1042
          @scm
1043
        end
1044
    end
1045

    
1046
    puts
1047
    if Redmine::DefaultData::Loader.no_data?
1048
      puts "Redmine configuration need to be loaded before importing data."
1049
      puts "Please, run this first:"
1050
      puts
1051
      puts "  rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
1052
      exit
1053
    end
1054

    
1055
    puts "WARNING: all commit messages with references to trac pages will be modified"
1056
    print "Are you sure you want to continue ? [y/N] "
1057
    break unless STDIN.gets.match(/^y$/i)
1058
    puts
1059

    
1060
    prompt('Subversion repository url') {|repository| SvnMigrate.set_svn_url repository.strip}
1061
    prompt('Subversion repository username') {|username| SvnMigrate.set_svn_username username}
1062
    prompt('Subversion repository password') {|password| SvnMigrate.set_svn_password password}
1063
    prompt('Redmine project identifier') {|identifier| SvnMigrate.set_redmine_project_identifier identifier}
1064
    puts
1065

    
1066
    SvnMigrate.migrate
1067
    
1068
  end
1069

    
1070
  # Prompt
1071
  def prompt(text, options = {}, &block)
1072
    default = options[:default] || ''
1073
    while true
1074
      print "#{text} [#{default}]: "
1075
      STDOUT.flush
1076
      value = STDIN.gets.chomp!
1077
      value = default if value.blank?
1078
      break if yield value
1079
    end
1080
  end
1081

    
1082
  # Basic wiki syntax conversion
1083
  def convert_wiki_text_mapping(text, ticket_map = [])
1084
        # Hide links
1085
        def wiki_links_hide(src)
1086
          @wiki_links = []
1087
          @wiki_links_hash = "####WIKILINKS#{src.hash.to_s}####"
1088
          src.gsub(/(\[\[.+?\|.+?\]\])/) do
1089
            @wiki_links << $1
1090
            @wiki_links_hash
1091
          end
1092
        end
1093
        # Restore links
1094
        def wiki_links_restore(src)
1095
          @wiki_links.each do |s|
1096
            src = src.sub("#{@wiki_links_hash}", s.to_s)
1097
          end
1098
          src
1099
        end
1100
        # Hidding code blocks
1101
        def code_hide(src)
1102
          @code = []
1103
          @code_hash = "####CODEBLOCK#{src.hash.to_s}####"
1104
          src.gsub(/(\{\{\{.+?\}\}\}|`.+?`)/m) do
1105
            @code << $1
1106
            @code_hash
1107
          end
1108
        end
1109
        # Convert code blocks
1110
        def code_convert(src)
1111
          @code.each do |s|
1112
            s = s.to_s
1113
            if s =~ (/`(.+?)`/m) || s =~ (/\{\{\{(.+?)\}\}\}/) then
1114
              # inline code
1115
              s = s.replace("@#{$1}@")
1116
            else
1117
              # We would like to convert the Code highlighting too
1118
              # This will go into the next line.
1119
              shebang_line = false
1120
              # Reguar expression for start of code
1121
              pre_re = /\{\{\{/
1122
              # Code hightlighing...
1123
              shebang_re = /^\#\!([a-z]+)/
1124
              # Regular expression for end of code
1125
              pre_end_re = /\}\}\}/
1126
      
1127
              # Go through the whole text..extract it line by line
1128
              s = s.gsub(/^(.*)$/) do |line|
1129
                m_pre = pre_re.match(line)
1130
                if m_pre
1131
                  line = '<pre>'
1132
                else
1133
                  m_sl = shebang_re.match(line)
1134
                  if m_sl
1135
                    shebang_line = true
1136
                    line = '<code class="' + m_sl[1] + '">'
1137
                  end
1138
                  m_pre_end = pre_end_re.match(line)
1139
                  if m_pre_end
1140
                    line = '</pre>'
1141
                    if shebang_line
1142
                      line = '</code>' + line
1143
                    end
1144
                  end
1145
                end
1146
                line
1147
              end
1148
            end
1149
            src = src.sub("#{@code_hash}", s)
1150
          end
1151
          src
1152
        end
1153

    
1154
        # Hide code blocks
1155
        text = code_hide(text)
1156
        # New line
1157
        text = text.gsub(/\[\[[Bb][Rr]\]\]/, "\n") # This has to go before the rules below
1158
        # Titles (only h1. to h6., and remove #...)
1159
        text = text.gsub(/(?:^|^\ +)(\={1,6})\ (.+)\ (?:\1)(?:\ *(\ \#.*))?/) {|s| "\nh#{$1.length}. #{$2}#{$3}\n"}
1160
        
1161
        # External Links:
1162
        #      [http://example.com/]
1163
        text = text.gsub(/\[((?:https?|s?ftp)\:\S+)\]/, '\1')
1164
        #      [http://example.com/ Example],[http://example.com/ "Example"]
1165
        #      [http://example.com/ "Example for "Example""] -> "Example for 'Example'":http://example.com/
1166
        text = text.gsub(/\[((?:https?|s?ftp)\:\S+)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "\"#{$3.tr('"','\'')}\":#{$1}"}
1167
        #      [mailto:some@example.com],[mailto:"some@example.com"]
1168
        text = text.gsub(/\[mailto\:([\"']?)(.+?)\1\]/, '\2')
1169
        
1170
        # Ticket links:
1171
        #      [ticket:234 Text],[ticket:234 This is a test],[ticket:234 "This is a test"]
1172
        #      [ticket:234 "Test "with quotes""] -> "Test 'with quotes'":issues/show/234
1173
        text = text.gsub(/\[ticket\:(\d+)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "\"#{$3.tr('"','\'')}\":/issues/show/#{$1}"}
1174
        #      ticket:1234
1175
        #      excluding ticket:1234:file.txt (used in macros)
1176
        #      #1 - working cause Redmine uses the same syntax.
1177
        text = text.gsub(/ticket\:(\d+?)([^\:])/, '#\1\2')
1178

    
1179
        # Source & attachments links:
1180
        #      [source:/trunk/readme.txt Readme File],[source:"/trunk/readme.txt" Readme File],
1181
        #      [source:/trunk/readme.txt],[source:"/trunk/readme.txt"]
1182
        #       The text "Readme File" is not converted,
1183
        #       cause Redmine's wiki does not support this.
1184
        #      Attachments use same syntax.
1185
        text = text.gsub(/\[(source|attachment)\:([\"']?)([^\"']+?)\2(?:\ +(.+?))?\]/, '\1:"\3"')
1186
        #      source:"/trunk/readme.txt"
1187
        #      source:/trunk/readme.txt - working cause Redmine uses the same syntax.
1188
        text = text.gsub(/(source|attachment)\:([\"'])([^\"']+?)\2/, '\1:"\3"')
1189

    
1190
        # Milestone links:
1191
        #      [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)],
1192
        #      [milestone:"0.1.0 Mercury"],milestone:"0.1.0 Mercury"
1193
        #       The text "Milestone 0.1.0 (Mercury)" is not converted,
1194
        #       cause Redmine's wiki does not support this.
1195
        text = text.gsub(/\[milestone\:([\"'])([^\"']+?)\1(?:\ +(.+?))?\]/, 'version:"\2"')
1196
        text = text.gsub(/milestone\:([\"'])([^\"']+?)\1/, 'version:"\2"')
1197
        #      [milestone:0.1.0],milestone:0.1.0
1198
        text = text.gsub(/\[milestone\:([^\ ]+?)\]/, 'version:\1')
1199
        text = text.gsub(/milestone\:([^\ ]+?)/, 'version:\1')
1200

    
1201
        # Internal Links:
1202
        #      ["Some Link"]
1203
        text = text.gsub(/\[([\"'])(.+?)\1\]/) {|s| "[[#{$2.delete(',./?;|:')}]]"}
1204
        #      [wiki:"Some Link" "Link description"],[wiki:"Some Link" Link description]
1205
        text = text.gsub(/\[wiki\:([\"'])([^\]\"']+?)\1[\ \t]+([\"']?)(.+?)\3\]/) {|s| "[[#{$2.delete(',./?;|:')}|#{$4}]]"}
1206
        #      [wiki:"Some Link"]
1207
        text = text.gsub(/\[wiki\:([\"'])([^\]\"']+?)\1\]/) {|s| "[[#{$2.delete(',./?;|:')}]]"}
1208
        #      [wiki:SomeLink]
1209
        text = text.gsub(/\[wiki\:([^\s\]]+?)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
1210
        #      [wiki:SomeLink Link description],[wiki:SomeLink "Link description"]
1211
        text = text.gsub(/\[wiki\:([^\s\]\"']+?)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$3}]]"}
1212

    
1213
        # Before convert CamelCase links, must hide wiki links with description.
1214
        # Like this: [[http://www.freebsd.org|Hello FreeBSD World]]
1215
        text = wiki_links_hide(text)
1216
        # Links to CamelCase pages (not work for unicode)
1217
        #      UsingJustWikiCaps,UsingJustWikiCaps/Subpage
1218
        text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+(?:\/[^\s[:punct:]]+)*)/) {|s| "#{$1}#{$2}[[#{$3.delete('/')}]]"}
1219
        # Normalize things that were supposed to not be links
1220
        # like !NotALink
1221
        text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
1222
        # Now restore hidden links
1223
        text = wiki_links_restore(text)
1224
        
1225
        # Revisions links
1226
        text = text.gsub(/\[(\d+)\]/, 'r\1')
1227
        # Ticket number re-writing
1228
        text = text.gsub(/#(\d+)/) do |s|
1229
          if $1.length < 10
1230
            #ticket_map[$1.to_i] ||= $1
1231
            "\##{ticket_map[$1.to_i] || $1}"
1232
          else
1233
            s
1234
          end
1235
        end
1236
        
1237
        # Highlighting
1238
        text = text.gsub(/'''''([^\s])/, '_*\1')
1239
        text = text.gsub(/([^\s])'''''/, '\1*_')
1240
        text = text.gsub(/'''/, '*')
1241
        text = text.gsub(/''/, '_')
1242
        text = text.gsub(/__/, '+')
1243
        text = text.gsub(/~~/, '-')
1244
        text = text.gsub(/,,/, '~')
1245
        # Tables
1246
        text = text.gsub(/\|\|/, '|')
1247
        # Lists:
1248
        #      bullet
1249
        text = text.gsub(/^(\ +)[\*-] /) {|s| '*' * $1.length + " "}
1250
        #      numbered
1251
        text = text.gsub(/^(\ +)\d+\. /) {|s| '#' * $1.length + " "}
1252
        # Images (work for only attached in current page [[Image(picture.gif)]])
1253
        # need rules for:  * [[Image(wiki:WikiFormatting:picture.gif)]] (referring to attachment on another page)
1254
        #                  * [[Image(ticket:1:picture.gif)]] (file attached to a ticket)
1255
        #                  * [[Image(htdocs:picture.gif)]] (referring to a file inside project htdocs)
1256
        #                  * [[Image(source:/trunk/trac/htdocs/trac_logo_mini.png)]] (a file in repository) 
1257
        text = text.gsub(/\[\[image\((.+?)(?:,.+?)?\)\]\]/i, '!\1!')
1258
        # TOC (is right-aligned, because that in Trac)
1259
        text = text.gsub(/\[\[TOC(?:\((.*?)\))?\]\]/m) {|s| "{{>toc}}\n"}
1260

    
1261
        # Restore and convert code blocks
1262
        text = code_convert(text)
1263

    
1264
        text
1265
  end
1266
  
1267
  # Simple progress bar
1268
  def simplebar(title, current, total, out = STDOUT)
1269
    def get_width
1270
      default_width = 80
1271
      begin
1272
        tiocgwinsz = 0x5413
1273
        data = [0, 0, 0, 0].pack("SSSS")
1274
        if out.ioctl(tiocgwinsz, data) >= 0 then
1275
          rows, cols, xpixels, ypixels = data.unpack("SSSS")
1276
          if cols >= 0 then cols else default_width end
1277
        else
1278
          default_width
1279
        end
1280
      rescue Exception
1281
        default_width
1282
      end
1283
    end
1284
    mark = "*"
1285
    title_width = 40
1286
    max = get_width - title_width - 10
1287
    format = "%-#{title_width}s [%-#{max}s] %3d%%  %s"
1288
    bar = current * max / total
1289
    percentage = bar * 100 / max
1290
    current == total ? eol = "\n" : eol ="\r"
1291
    printf(format, title, mark * bar, percentage, eol)
1292
    out.flush
1293
  end
1294
end
1295

    
(1-1/2)