ChiliProject is not maintained anymore. Please be advised that there will be no more updates.

We do not recommend that you setup new ChiliProject instances and we urge all existing users to migrate their data to a maintained system, e.g. Redmine. We will provide a migration script later. In the meantime, you can use the instructions by Christian Daehn.

migrate_from_trac12.rake

Pedro Algarvio, 2011-06-20 03:09 pm

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