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-19 04:23 pm

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