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

Should also migrate registered accounts - Pedro Algarvio, 2011-06-19 02:27 pm

Download (54 kB)

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