Project

General

Profile

Patch #9181 ยป 41.patch

Toshi MARUYAMA, 2014-04-16 09:57

View differences:

lib/tasks/migrate_from_trac.rake
1
# redMine - project management software
1
# Redmine - project management software
2 2
# Copyright (C) 2006-2007  Jean-Philippe Lang
3
# Copyright (C) 2007-2011  Trac/Redmine Community
4
# References:
5
#  - http://www.redmine.org/boards/1/topics/12273 (Trac Importer Patch Coordination)
6
#  - http://github.com/landy2005/Redmine-migrate-from-Trac
3 7
#
4 8
# This program is free software; you can redistribute it and/or
5 9
# modify it under the terms of the GNU General Public License
......
52 56
                            'blocker' => priorities[4]
53 57
                            }
54 58

  
55
        TRACKER_BUG = Tracker.find_by_position(1)
56
        TRACKER_FEATURE = Tracker.find_by_position(2)
59
        TRACKER_BUG = Tracker.find_by_name('Bug')
60
        TRACKER_FEATURE = Tracker.find_by_name('Feature')
61
        TRACKER_SUPPORT = Tracker.find_by_name('Support')
57 62
        DEFAULT_TRACKER = TRACKER_BUG
58 63
        TRACKER_MAPPING = {'defect' => TRACKER_BUG,
59 64
                           'enhancement' => TRACKER_FEATURE,
60
                           'task' => TRACKER_FEATURE,
65
                           'task' => TRACKER_SUPPORT,
61 66
                           'patch' =>TRACKER_FEATURE
62 67
                           }
63 68

  
......
68 73
        ROLE_MAPPING = {'admin' => manager_role,
69 74
                        'developer' => developer_role
70 75
                        }
76
        # Add an Hash Table for comments' updatable fields
77
        PROP_MAPPING = {'status' => 'status_id',
78
                        'owner' => 'assigned_to_id',
79
                        'component' => 'category_id',
80
                        'milestone' => 'fixed_version_id',
81
                        'priority' => 'priority_id',
82
                        'summary' => 'subject',
83
                        'type' => 'tracker_id'}
84
        
85
        # Hash table to map completion ratio
86
        RATIO_MAPPING = {'' => 0,
87
                        'fixed' => 100,
88
                        'invalid' => 0,
89
                        'wontfix' => 0,
90
                        'duplicate' => 100,
91
                        'worksforme' => 0}
71 92

  
72 93
      class ::Time
73 94
        class << self
......
153 174
      private
154 175
        def trac_fullpath
155 176
          attachment_type = read_attribute(:type)
156
          trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
157
          "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
177
          trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*]/n ) {|x| sprintf('%%%02X', x[0]) }
178
          trac_dir = id.gsub( /[^a-zA-Z0-9\-_\.!~*\\\/]/n ) {|x| sprintf('%%%02X', x[0]) }
179
          "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{trac_dir}/#{trac_file}"
158 180
        end
159 181
      end
160 182

  
......
192 214
        def time; Time.at(read_attribute(:time)) end
193 215
      end
194 216

  
195
      TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
217
      TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup \
218
                           TracBrowser TracCgi TracChangeset TracInstallPlatforms TracMultipleProjects TracModWSGI \
196 219
                           TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
197 220
                           TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
198 221
                           TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
199 222
                           TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
200 223
                           WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
201
                           CamelCase TitleIndex)
202

  
224
                           CamelCase TitleIndex TracNavigation TracFineGrainedPermissions TracWorkflow TimingAndEstimationPluginUserManual \
225
                           PageTemplates)
203 226
      class TracWikiPage < ActiveRecord::Base
204 227
        set_table_name :wiki
205 228
        set_primary_key :name
......
225 248
        set_table_name :session_attribute
226 249
      end
227 250

  
251
# TODO put your Login Mapping in this method and rename method below
252
#      def self.find_or_create_user(username, project_member = false)
253
#        TRAC_REDMINE_LOGIN_MAP = []
254
#        return TRAC_REDMINE_LOGIN_MAP[username]
255
# OR more hard-coded:
256
#        if username == 'TracX'
257
#          username = 'RedmineX'
258
#        elsif username == 'gilles'
259
#          username = 'gcornu'
260
#        #elseif ...
261
#        else
262
#          username = 'gcornu'
263
#        end
264
#        return User.find_by_login(username)  
265
#      end
266

  
228 267
      def self.find_or_create_user(username, project_member = false)
229 268
        return User.anonymous if username.blank?
230 269

  
......
241 280
          if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
242 281
            name = name_attr.value
243 282
          end
244
          name =~ (/(.*)(\s+\w+)?/)
283
          name =~ (/(.+?)(?:[\ \t]+(.+)?|[\ \t]+|)$/)
245 284
          fn = $1.strip
285
          # Add a dash for lastname or the user is not saved (bugfix)
246 286
          ln = ($2 || '-').strip
247 287

  
248 288
          u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
......
271 311

  
272 312
      # Basic wiki syntax conversion
273 313
      def self.convert_wiki_text(text)
274
        # Titles
275
        text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
276
        # External Links
277
        text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
278
        # Ticket links:
279
        #      [ticket:234 Text],[ticket:234 This is a test]
280
        text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
281
        #      ticket:1234
282
        #      #1 is working cause Redmine uses the same syntax.
283
        text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
284
        # Milestone links:
285
        #      [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
286
        #      The text "Milestone 0.1.0 (Mercury)" is not converted,
287
        #      cause Redmine's wiki does not support this.
288
        text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
289
        #      [milestone:"0.1.0 Mercury"]
290
        text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
291
        text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
292
        #      milestone:0.1.0
293
        text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
294
        text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
295
        # Internal Links
296
        text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
297
        text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
298
        text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
299
        text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
300
        text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
301
        text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
302

  
303
  # Links to pages UsingJustWikiCaps
304
  text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
305
  # Normalize things that were supposed to not be links
306
  # like !NotALink
307
  text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
308
        # Revisions links
309
        text = text.gsub(/\[(\d+)\]/, 'r\1')
310
        # Ticket number re-writing
311
        text = text.gsub(/#(\d+)/) do |s|
312
          if $1.length < 10
313
#            TICKET_MAP[$1.to_i] ||= $1
314
            "\##{TICKET_MAP[$1.to_i] || $1}"
315
          else
316
            s
317
          end
318
        end
319
        # We would like to convert the Code highlighting too
320
        # This will go into the next line.
321
        shebang_line = false
322
        # Reguar expression for start of code
323
        pre_re = /\{\{\{/
324
        # Code hightlighing...
325
        shebang_re = /^\#\!([a-z]+)/
326
        # Regular expression for end of code
327
        pre_end_re = /\}\}\}/
328

  
329
        # Go through the whole text..extract it line by line
330
        text = text.gsub(/^(.*)$/) do |line|
331
          m_pre = pre_re.match(line)
332
          if m_pre
333
            line = '<pre>'
334
          else
335
            m_sl = shebang_re.match(line)
336
            if m_sl
337
              shebang_line = true
338
              line = '<code class="' + m_sl[1] + '">'
339
            end
340
            m_pre_end = pre_end_re.match(line)
341
            if m_pre_end
342
              line = '</pre>'
343
              if shebang_line
344
                line = '</code>' + line
345
              end
346
            end
347
          end
348
          line
349
        end
350

  
351
        # Highlighting
352
        text = text.gsub(/'''''([^\s])/, '_*\1')
353
        text = text.gsub(/([^\s])'''''/, '\1*_')
354
        text = text.gsub(/'''/, '*')
355
        text = text.gsub(/''/, '_')
356
        text = text.gsub(/__/, '+')
357
        text = text.gsub(/~~/, '-')
358
        text = text.gsub(/`/, '@')
359
        text = text.gsub(/,,/, '~')
360
        # Lists
361
        text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
362

  
363
        text
314
        convert_wiki_text_mapping(text, TICKET_MAP)
364 315
      end
365 316

  
366 317
      def self.migrate
......
377 328
        migrated_wiki_edits = 0
378 329
        migrated_wiki_attachments = 0
379 330

  
380
        #Wiki system initializing...
331
        # Wiki system initializing...
381 332
        @target_project.wiki.destroy if @target_project.wiki
382 333
        @target_project.reload
383 334
        wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
384 335
        wiki_edit_count = 0
385 336

  
386 337
        # Components
387
        print "Migrating components"
338
        who = "Migrating components"
388 339
        issues_category_map = {}
340
        components_total = TracComponent.count
389 341
        TracComponent.find(:all).each do |component|
390
        print '.'
391
        STDOUT.flush
392 342
          c = IssueCategory.new :project => @target_project,
393 343
                                :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
344
        # Owner
345
        unless component.owner.blank?
346
          c.assigned_to = find_or_create_user(component.owner, true)
347
        end
394 348
        next unless c.save
395 349
        issues_category_map[component.name] = c
396 350
        migrated_components += 1
351
        simplebar(who, migrated_components, components_total)
397 352
        end
398
        puts
353
        puts if migrated_components < components_total
399 354

  
400 355
        # Milestones
401
        print "Migrating milestones"
356
        who = "Migrating milestones"
402 357
        version_map = {}
358
        milestone_wiki = Array.new
359
        milestones_total = TracMilestone.count
403 360
        TracMilestone.find(:all).each do |milestone|
404
          print '.'
405
          STDOUT.flush
406 361
          # First we try to find the wiki page...
407 362
          p = wiki.find_or_new_page(milestone.name.to_s)
408 363
          p.content = WikiContent.new(:page => p) if p.new_record?
......
415 370
                          :name => encode(milestone.name[0, limit_for(Version, 'name')]),
416 371
                          :description => nil,
417 372
                          :wiki_page_title => milestone.name.to_s,
418
                          :effective_date => milestone.completed
373
                          :effective_date => (!milestone.completed.blank? ? milestone.completed : (!milestone.due.blank? ? milestone.due : nil))
419 374

  
420 375
          next unless v.save
421 376
          version_map[milestone.name] = v
377
          milestone_wiki.push(milestone.name);
422 378
          migrated_milestones += 1
379
          simplebar(who, migrated_milestones, milestones_total)
423 380
        end
424
        puts
381
        puts if migrated_milestones < milestones_total
425 382

  
426 383
        # Custom fields
427 384
        # TODO: read trac.ini instead
428
        print "Migrating custom fields"
385
        #print "Migrating custom fields"
429 386
        custom_field_map = {}
430 387
        TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
431
          print '.'
432
          STDOUT.flush
388
# use line below and adapt the WHERE condifiton, if you want to skip some unused custom fields
389
#        TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name} WHERE name NOT IN ('duration', 'software')").each do |field|
390
          #print '.' # Maybe not needed this out?
391
          #STDOUT.flush
433 392
          # Redmine custom field name
434 393
          field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
394

  
395
#          # Ugly hack to skip custom field 'Browser', which is in 'list' format...
396
#          next if field_name == 'browser'
397

  
435 398
          # Find if the custom already exists in Redmine
436 399
          f = IssueCustomField.find_by_name(field_name)
400
          # Ugly hack to handle billable checkbox. Would require to read the ini file to be cleaner
401
          if field_name == 'Billable'
402
            format = 'bool'
403
          else
404
            format = 'string'
405
          end
437 406
          # Or create a new one
438 407
          f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
439
                                        :field_format => 'string')
408
                                        :field_format => format)
440 409

  
441 410
          next if f.new_record?
442 411
          f.trackers = Tracker.find(:all)
443 412
          f.projects << @target_project
444 413
          custom_field_map[field.name] = f
445 414
        end
446
        puts
415
        #puts
416

  
417
#        # Trac custom field 'Browser' field as a Redmine custom field
418
#        b = IssueCustomField.find(:first, :conditions => { :name => "Browser" })
419
#        b = IssueCustomField.new(:name => 'Browser',
420
#                                 :field_format => 'list',
421
#                                 :is_filter => true) if b.nil?
422
#        b.trackers << [TRACKER_BUG, TRACKER_FEATURE, TRACKER_SUPPORT]
423
#        b.projects << @target_project
424
#        b.possible_values = (b.possible_values + %w(IE6 IE7 IE8 IE9 Firefox Chrome Safari Opera)).flatten.compact.uniq
425
#        b.save!
426
#        custom_field_map['browser'] = b
447 427

  
448 428
        # Trac 'resolution' field as a Redmine custom field
449 429
        r = IssueCustomField.find(:first, :conditions => { :name => "Resolution" })
450 430
        r = IssueCustomField.new(:name => 'Resolution',
451 431
                                 :field_format => 'list',
452 432
                                 :is_filter => true) if r.nil?
453
        r.trackers = Tracker.find(:all)
433
        r.trackers << [TRACKER_BUG, TRACKER_FEATURE, TRACKER_SUPPORT] 
454 434
        r.projects << @target_project
455 435
        r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
456 436
        r.save!
457 437
        custom_field_map['resolution'] = r
458 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
                                 :is_filter => true) if k.nil?
444
        k.trackers = Tracker.find(:all)
445
        k.projects << @target_project
446
        k.save!
447
        custom_field_map['keywords'] = k
448

  
449
        # Trac 'version' field as a Redmine custom field, taking advantage of feature #2096 (available since Redmine 1.2.0)
450
        v = IssueCustomField.find(:first, :conditions => { :name => "Found in Version" })
451
        v = IssueCustomField.new(:name => 'Found in Version',
452
                                 :field_format => 'version',
453
                                 :is_filter => true) if v.nil?
454
        # Only apply to BUG tracker (?)
455
        v.trackers << TRACKER_BUG
456
        #v.trackers << [TRACKER_BUG, TRACKER_FEATURE]
457

  
458
        # Affect custom field to current Project
459
        v.projects << @target_project
460

  
461
        v.save!
462
        custom_field_map['found_in_version'] = v
463

  
464
        # Trac ticket id as a Redmine custom field
465
        tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
466
        tid = IssueCustomField.new(:name => 'TracID',
467
                                 :field_format => 'string',
468
                                 :is_filter => true) if tid.nil?
469
        tid.trackers << [TRACKER_BUG, TRACKER_FEATURE, TRACKER_SUPPORT] 
470
        tid.projects << @target_project
471
        tid.save!
472
        custom_field_map['tracid'] = tid
473
  
459 474
        # Tickets
460
        print "Migrating tickets"
475
        who = "Migrating tickets"
476
          tickets_total = TracTicket.count
461 477
          TracTicket.find_each(:batch_size => 200) do |ticket|
462
          print '.'
463
          STDOUT.flush
464 478
          i = Issue.new :project => @target_project,
465 479
                          :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
466
                          :description => convert_wiki_text(encode(ticket.description)),
480
                          :description => encode(ticket.description),
467 481
                          :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
468 482
                          :created_on => ticket.time
469
          i.author = find_or_create_user(ticket.reporter)
483
          # Add the ticket's author to project's reporter list (bugfix)
484
          i.author = find_or_create_user(ticket.reporter,true)
485
          # Extrapolate done_ratio from ticket's resolution
486
          i.done_ratio = RATIO_MAPPING[ticket.resolution] || 0 
470 487
          i.category = issues_category_map[ticket.component] unless ticket.component.blank?
471 488
          i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
472 489
          i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
473 490
          i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
474
          i.id = ticket.id unless Issue.exists?(ticket.id)
491
          # Use the Redmine-genereated new ticket ID anyway (no Ticket ID recycling)
492
          #i.id = ticket.id unless Issue.exists?(ticket.id)
475 493
          next unless Time.fake(ticket.changetime) { i.save }
476 494
          TICKET_MAP[ticket.id] = i.id
477 495
          migrated_tickets += 1
478

  
496
          simplebar(who, migrated_tickets, tickets_total)
479 497
          # Owner
480 498
            unless ticket.owner.blank?
481 499
              i.assigned_to = find_or_create_user(ticket.owner, true)
482 500
              Time.fake(ticket.changetime) { i.save }
483 501
            end
484

  
485
          # Comments and status/resolution changes
502
          # Handle CC field
503
   # Feature disabled (CC field almost never used, No time to validate/test this recent improvments from A. Callegaro)
504
   #       ticket.cc.split(',').each do |email|
505
   #         w = Watcher.new :watchable_type => 'Issue',
506
   #                         :watchable_id => i.id,
507
   #                         :user_id => find_or_create_user(email.strip).id 
508
   #         w.save
509
   #       end
510

  
511
          # Necessary to handle direct link to note from timelogs and putting the right start time in issue
512
          noteid = 1
513
          # Comments and status/resolution/keywords changes
486 514
          ticket.changes.group_by(&:time).each do |time, changeset|
487 515
              status_change = changeset.select {|change| change.field == 'status'}.first
488 516
              resolution_change = changeset.select {|change| change.field == 'resolution'}.first
517
              keywords_change = changeset.select {|change| change.field == 'keywords'}.first
489 518
              comment_change = changeset.select {|change| change.field == 'comment'}.first
519
              # Handle more ticket changes (owner, component, milestone, priority, summary, type, done_ratio and hours)
520
              assigned_change = changeset.select {|change| change.field == 'owner'}.first
521
              category_change = changeset.select {|change| change.field == 'component'}.first
522
              version_change = changeset.select {|change| change.field == 'milestone'}.first
523
              priority_change = changeset.select {|change| change.field == 'priority'}.first
524
              subject_change = changeset.select {|change| change.field == 'summary'}.first
525
              tracker_change = changeset.select {|change| change.field == 'type'}.first
526
              time_change = changeset.select {|change| change.field == 'hours'}.first
527

  
528
              # If it's the first note then we set the start working time to handle calendar and gantts
529
              if noteid == 1
530
                i.start_date = time
531
              end
490 532

  
491
              n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
533
              n = Journal.new :notes => (comment_change ? encode(comment_change.newvalue) : ''),
492 534
                              :created_on => time
493 535
              n.user = find_or_create_user(changeset.first.author)
494 536
              n.journalized = i
......
497 539
                   STATUS_MAPPING[status_change.newvalue] &&
498 540
                   (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
499 541
                n.details << JournalDetail.new(:property => 'attr',
500
                                               :prop_key => 'status_id',
542
                                               :prop_key => PROP_MAPPING['status'],
501 543
                                               :old_value => STATUS_MAPPING[status_change.oldvalue].id,
502 544
                                               :value => STATUS_MAPPING[status_change.newvalue].id)
503 545
              end
......
506 548
                                               :prop_key => custom_field_map['resolution'].id,
507 549
                                               :old_value => resolution_change.oldvalue,
508 550
                                               :value => resolution_change.newvalue)
551
                # Add a change for the done_ratio
552
                n.details << JournalDetail.new(:property => 'attr',
553
                                               :prop_key => 'done_ratio',
554
                                               :old_value => RATIO_MAPPING[resolution_change.oldvalue],
555
                                               :value => RATIO_MAPPING[resolution_change.newvalue])
556
                # Arbitrary set the due time to the day the ticket was resolved for calendar and gantts
557
                case RATIO_MAPPING[resolution_change.newvalue]
558
                when 0
559
                  i.due_date = nil
560
                when 100
561
                  i.due_date = time
562
                end               
563
              end
564
              if keywords_change
565
                n.details << JournalDetail.new(:property => 'cf',
566
                                               :prop_key => custom_field_map['keywords'].id,
567
                                               :old_value => keywords_change.oldvalue,
568
                                               :value => keywords_change.newvalue)
569
              end
570
              # Handle assignement/owner changes
571
              if assigned_change
572
                n.details << JournalDetail.new(:property => 'attr',
573
                                               :prop_key => PROP_MAPPING['owner'],
574
                                               :old_value => find_or_create_user(assigned_change.oldvalue, true),
575
                                               :value => find_or_create_user(assigned_change.newvalue, true))
576
              end
577
              # Handle component/category changes
578
              if category_change
579
                n.details << JournalDetail.new(:property => 'attr',
580
                                               :prop_key => PROP_MAPPING['component'],
581
                                               :old_value => issues_category_map[category_change.oldvalue],
582
                                               :value => issues_category_map[category_change.newvalue])
583
              end
584
              # Handle version/mileston changes
585
              if version_change
586
                n.details << JournalDetail.new(:property => 'attr',
587
                                               :prop_key => PROP_MAPPING['milestone'],
588
                                               :old_value => version_map[version_change.oldvalue],
589
                                               :value => version_map[version_change.newvalue])
590
              end
591
              # Handle priority changes
592
              if priority_change
593
                n.details << JournalDetail.new(:property => 'attr',
594
                                               :prop_key => PROP_MAPPING['priority'],
595
                                               :old_value => PRIORITY_MAPPING[priority_change.oldvalue],
596
                                               :value => PRIORITY_MAPPING[priority_change.newvalue])
597
              end
598
              # Handle subject/summary changes
599
              if subject_change
600
                n.details << JournalDetail.new(:property => 'attr',
601
                                               :prop_key => PROP_MAPPING['summary'],
602
                                               :old_value => encode(subject_change.oldvalue[0, limit_for(Issue, 'subject')]),
603
                                               :value => encode(subject_change.newvalue[0, limit_for(Issue, 'subject')]))
604
              end
605
              # Handle tracker/type (bug, feature) changes
606
              if tracker_change
607
                n.details << JournalDetail.new(:property => 'attr',
608
                                               :prop_key => PROP_MAPPING['type'],
609
                                               :old_value => TRACKER_MAPPING[tracker_change.oldvalue] || DEFAULT_TRACKER,
610
                                               :value => TRACKER_MAPPING[tracker_change.newvalue] || DEFAULT_TRACKER)
611
              end              
612
              # Add timelog entries for each time changes (from timeandestimation plugin)
613
              if time_change && time_change.newvalue != '0' && time_change.newvalue != ''
614
                t = TimeEntry.new(:project => @target_project, 
615
                                  :issue => i, 
616
                                  :user => n.user,
617
                                  :spent_on => time,
618
                                  :hours => time_change.newvalue,
619
                                  :created_on => time,
620
                                  :updated_on => time,
621
                                  :activity_id => TimeEntryActivity.find_by_position(2).id,
622
                                  :comments => "#{convert_wiki_text(n.notes.each_line.first.chomp)[0,100] unless !n.notes.each_line.first}... \"more\":/issues/#{i.id}#note-#{noteid}")
623
                t.save
624
                t.errors.each_full{|msg| puts msg }
509 625
              end
626
              # Set correct changetime of the issue
627
              next unless Time.fake(ticket.changetime) { i.save }
510 628
              n.save unless n.details.empty? && n.notes.blank?
629
              noteid += 1
511 630
          end
512 631

  
513 632
          # Attachments
......
534 653
          if custom_field_map['resolution'] && !ticket.resolution.blank?
535 654
            custom_values[custom_field_map['resolution'].id] = ticket.resolution
536 655
          end
656
          if custom_field_map['keywords'] && !ticket.keywords.blank?
657
            custom_values[custom_field_map['keywords'].id] = ticket.keywords
658
          end
659
          if custom_field_map['tracid'] 
660
            custom_values[custom_field_map['tracid'].id] = ticket.id
661
          end
662

  
663
          if !ticket.version.blank? && custom_field_map['found_in_version']
664
            found_in = version_map[ticket.version]
665
            if !found_in.nil?
666
              puts "Issue #{i.id} found in #{found_in.name.to_s} (#{found_in.id.to_s}) - trac: #{ticket.version}"
667
            else
668
              #TODO: add better error management here...
669
              puts "Issue #{i.id} : ouch...  - trac: #{ticket.version}"  
670
            end 
671
            custom_values[custom_field_map['found_in_version'].id] = found_in.id.to_s
672
            STDOUT.flush
673
          end
674

  
537 675
          i.custom_field_values = custom_values
538 676
          i.save_custom_field_values
539 677
        end
540 678

  
541 679
        # update issue id sequence if needed (postgresql)
542 680
        Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
543
        puts
681
        puts if migrated_tickets < tickets_total
544 682

  
545 683
        # Wiki
546
        print "Migrating wiki"
684
        who = "Migrating wiki"
547 685
        if wiki.save
686
          wiki_edits_total = TracWikiPage.count
548 687
          TracWikiPage.find(:all, :order => 'name, version').each do |page|
549 688
            # Do not migrate Trac manual wiki pages
550
            next if TRAC_WIKI_PAGES.include?(page.name)
551
            wiki_edit_count += 1
552
            print '.'
553
            STDOUT.flush
689
            if TRAC_WIKI_PAGES.include?(page.name) then
690
              wiki_edits_total -= 1
691
              next
692
            end
554 693
            p = wiki.find_or_new_page(page.name)
555 694
            p.content = WikiContent.new(:page => p) if p.new_record?
556 695
            p.content.text = page.text
557 696
            p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
558 697
            p.content.comments = page.comment
559 698
            Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
699
            migrated_wiki_edits += 1
700
            simplebar(who, migrated_wiki_edits, wiki_edits_total)
560 701

  
561 702
            next if p.content.new_record?
562
            migrated_wiki_edits += 1
563 703

  
564 704
            # Attachments
565 705
            page.attachments.each do |attachment|
......
576 716
            end
577 717
          end
578 718

  
579
          wiki.reload
580
          wiki.pages.each do |page|
581
            page.content.text = convert_wiki_text(page.content.text)
582
            Time.fake(page.content.updated_on) { page.content.save }
719
        end
720
        puts if migrated_wiki_edits < wiki_edits_total
721

  
722
        # Now load each wiki page and transform its content into textile format
723
        puts "\nTransform texts to textile format:"
724
    
725
        wiki_pages_count = 0
726
        issues_count = 0
727
        milestone_wiki_count = 0
728

  
729
        who = "   in Wiki pages"
730
        wiki.reload
731
        wiki_pages_total = wiki.pages.count
732
        wiki.pages.each do |page|
733
          page.content.text = convert_wiki_text(page.content.text)
734
          Time.fake(page.content.updated_on) { page.content.save }
735
          wiki_pages_count += 1
736
          simplebar(who, wiki_pages_count, wiki_pages_total)
737
        end
738
        puts if wiki_pages_count < wiki_pages_total
739
        
740
        who = "   in Issues"
741
        #issues_total = TICKET_MAP.length #works with Ruby <= 1.8.6
742
        issues_total = TICKET_MAP.count #works with Ruby >= 1.8.7
743
        TICKET_MAP.each do |newId|
744
          issues_count += 1
745
          simplebar(who, issues_count, issues_total)
746
          next if newId.nil?
747
          issue = findIssue(newId)
748
          next if issue.nil?
749
          # convert issue description
750
          issue.description = convert_wiki_text(issue.description)
751
          # Converted issue comments had their last updated time set to the day of the migration (bugfix)
752
          next unless Time.fake(issue.updated_on) { issue.save }
753
          # convert issue journals
754
          issue.journals.find(:all).each do |journal|
755
            journal.notes = convert_wiki_text(journal.notes)
756
            journal.save
583 757
          end
584 758
        end
585
        puts
759
        puts if issues_count < issues_total
760

  
761
        who = "   in Milestone descriptions"
762
        #milestone_wiki_total = milestone_wiki.length #works with Ruby <= 1.8.6
763
        milestone_wiki_total = milestone_wiki.count #works with Ruby >= 1.8.7
764
        milestone_wiki.each do |name|
765
          milestone_wiki_count += 1
766
          simplebar(who, milestone_wiki_count, milestone_wiki_total)
767
          p = wiki.find_page(name)            
768
          next if p.nil?
769
          p.content.text = convert_wiki_text(p.content.text)
770
          p.content.save
771
        end
772
        puts if milestone_wiki_count < milestone_wiki_total
586 773

  
587 774
        puts
588
        puts "Components:      #{migrated_components}/#{TracComponent.count}"
589
        puts "Milestones:      #{migrated_milestones}/#{TracMilestone.count}"
590
        puts "Tickets:         #{migrated_tickets}/#{TracTicket.count}"
775
        puts "Components:      #{migrated_components}/#{components_total}"
776
        puts "Milestones:      #{migrated_milestones}/#{milestones_total}"
777
        puts "Tickets:         #{migrated_tickets}/#{tickets_total}"
591 778
        puts "Ticket files:    #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
592 779
        puts "Custom values:   #{migrated_custom_values}/#{TracTicketCustom.count}"
593
        puts "Wiki edits:      #{migrated_wiki_edits}/#{wiki_edit_count}"
780
        puts "Wiki edits:      #{migrated_wiki_edits}/#{wiki_edits_total}"
594 781
        puts "Wiki files:      #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
595 782
      end
596

  
783
      
784
      def self.findIssue(id)
785
        return Issue.find(id)
786
      rescue ActiveRecord::RecordNotFound
787
        puts "[#{id}] not found"
788
        nil
789
      end
790
      
597 791
      def self.limit_for(klass, attribute)
598 792
        klass.columns_hash[attribute.to_s].limit
599 793
      end
......
671 865
          project.identifier = identifier
672 866
          puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
673 867
          # enable issues and wiki for the created project
868
          # Enable only a minimal set of modules by default
674 869
          project.enabled_module_names = ['issue_tracking', 'wiki']
675 870
        else
676 871
          puts
......
681 876
        end
682 877
        project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
683 878
        project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
879
        project.trackers << TRACKER_SUPPORT unless project.trackers.include?(TRACKER_SUPPORT)
684 880
        @target_project = project.new_record? ? nil : project
685 881
        @target_project.reload
686 882
      end
......
732 928
    break unless STDIN.gets.match(/^y$/i)
733 929
    puts
734 930

  
735
    def prompt(text, options = {}, &block)
736
      default = options[:default] || ''
737
      while true
738
        print "#{text} [#{default}]: "
739
        STDOUT.flush
740
        value = STDIN.gets.chomp!
741
        value = default if value.blank?
742
        break if yield value
743
      end
744
    end
745

  
746 931
    DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
747 932

  
748 933
    prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
749
    prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite') {|adapter| TracMigrate.set_trac_adapter adapter}
934
    prompt('Trac database adapter (sqlite, sqlite3, mysql, postgresql)', :default => 'sqlite3') {|adapter| TracMigrate.set_trac_adapter adapter}
750 935
    unless %w(sqlite sqlite3).include?(TracMigrate.trac_adapter)
751 936
      prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
752 937
      prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
......
756 941
      prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
757 942
    end
758 943
    prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
759
    prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
944
    prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier.downcase}
760 945
    puts
761 946
    
762 947
    # Turn off email notifications
......
764 949
    
765 950
    TracMigrate.migrate
766 951
  end
952

  
953

  
954
  desc 'Subversion migration script'
955
  task :migrate_svn_commit_properties => :environment do
956

  
957
    require 'redmine/scm/adapters/abstract_adapter'
958
    require 'redmine/scm/adapters/subversion_adapter'
959
    require 'rexml/document'
960
    require 'uri'
961
    require 'tempfile'
962

  
963
    module SvnMigrate 
964
        TICKET_MAP = []
965

  
966
        class Commit
967
          attr_accessor :revision, :message, :author
968
          
969
          def initialize(attributes={})
970
            self.author = attributes[:author] || ""
971
            self.message = attributes[:message] || ""
972
            self.revision = attributes[:revision]
973
          end
974
        end
975
        
976
        class SvnExtendedAdapter < Redmine::Scm::Adapters::SubversionAdapter
977
        
978
            def set_author(path=nil, revision=nil, author=nil)
979
              path ||= ''
980

  
981
              cmd = "#{SVN_BIN} propset svn:author --quiet --revprop -r #{revision}  \"#{author}\" "
982
              cmd << credentials_string
983
              cmd << ' ' + target(URI.escape(path))
984

  
985
              shellout(cmd) do |io|
986
                begin
987
                  loop do 
988
                    line = io.readline
989
                    puts line
990
                  end
991
                rescue EOFError
992
                end  
993
              end
994

  
995
              raise if $? && $?.exitstatus != 0
996

  
997
            end
998

  
999
            def set_message(path=nil, revision=nil, msg=nil)
1000
              path ||= ''
1001

  
1002
              Tempfile.open('msg') do |tempfile|
1003

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

  
1008
                filePath = tempfile.path.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR)
1009

  
1010
                cmd = "#{SVN_BIN} propset svn:log --quiet --revprop -r #{revision}  -F \"#{filePath}\" "
1011
                cmd << credentials_string
1012
                cmd << ' ' + target(URI.escape(path))
1013

  
1014
                shellout(cmd) do |io|
1015
                  begin
1016
                    loop do 
1017
                      line = io.readline
1018
                      puts line
1019
                    end
1020
                  rescue EOFError
1021
                  end  
1022
                end
1023

  
1024
                raise if $? && $?.exitstatus != 0
1025

  
1026
              end
1027
              
1028
            end
1029
        
1030
            def messages(path=nil)
1031
              path ||= ''
1032

  
1033
              commits = Array.new
1034

  
1035
              cmd = "#{SVN_BIN} log --xml -r 1:HEAD"
1036
              cmd << credentials_string
1037
              cmd << ' ' + target(URI.escape(path))
1038
                            
1039
              shellout(cmd) do |io|
1040
                begin
1041
                  doc = REXML::Document.new(io)
1042
                  doc.elements.each("log/logentry") do |logentry|
1043

  
1044
                    commits << Commit.new(
1045
                                                {
1046
                                                  :revision => logentry.attributes['revision'].to_i,
1047
                                                  :message => logentry.elements['msg'].text,
1048
                                                  :author => logentry.elements['author'].text
1049
                                                })
1050
                  end
1051
                rescue => e
1052
                  puts"Error !!!"
1053
                  puts e
1054
                end
1055
              end
1056
              return nil if $? && $?.exitstatus != 0
1057
              commits
1058
            end
1059
          
1060
        end
1061
        
1062
        def self.migrate_authors
1063
          svn = self.scm          
1064
          commits = svn.messages(@svn_url)
1065
          commits.each do |commit| 
1066
            orig_author_name = commit.author
1067
            new_author_name = orig_author_name
1068
            
1069
            # TODO put your Trac/SVN/Redmine username mapping here:
1070
            if (commit.author == 'TracX')
1071
               new_author_name = 'RedmineY'
1072
            elsif (commit.author == 'gilles')
1073
               new_author_name = 'gcornu'
1074
            #elsif (commit.author == 'seco')
1075
            #...
1076
            else
1077
               new_author_name = 'RedmineY'
1078
            end
1079
            
1080
            if (new_author_name != orig_author_name)
1081
              scm.set_author(@svn_url, commit.revision, new_author_name)
1082
              puts "r#{commit.revision} - Author replaced: #{orig_author_name} -> #{new_author_name}"
1083
            else
1084
              puts "r#{commit.revision} - Author kept: #{orig_author_name} unchanged "
1085
            end
1086
          end
1087
        end
1088
        
1089
        def self.migrate_messages
1090

  
1091
          project = Project.find(@@redmine_project)
1092
          if !project
1093
            puts "Could not find project identifier '#{@@redmine_project}'"
1094
            raise 
1095
          end
1096
                    
1097
          tid = IssueCustomField.find(:first, :conditions => { :name => "TracID" })
1098
          if !tid
1099
            puts "Could not find issue custom field 'TracID'"
1100
            raise 
1101
          end
1102
          
1103
          Issue.find( :all, :conditions => { :project_id => project }).each do |issue|
1104
            val = nil
1105
            issue.custom_values.each do |value|
1106
              if value.custom_field.id == tid.id
1107
                val = value
1108
                break
1109
              end
1110
            end
1111
            
1112
            TICKET_MAP[val.value.to_i] = issue.id if !val.nil?            
1113
          end
1114
          
1115
          svn = self.scm          
1116
          msgs = svn.messages(@svn_url)
1117
          msgs.each do |commit| 
1118
          
1119
            newText = convert_wiki_text(commit.message)
1120
            
1121
            if newText != commit.message             
1122
              puts "Updating message #{commit.revision}"
1123
              
1124
              # Marcel Nadje enhancement, see http://www.redmine.org/issues/2748#note-3
1125
              # Hint: enable charset conversion if needed...
1126
              #newText = Iconv.conv('CP1252', 'UTF-8', newText)
1127
              
1128
              scm.set_message(@svn_url, commit.revision, newText)
1129
            end
1130
          end
1131
          
1132
          
1133
        end
1134
        
1135
        # Basic wiki syntax conversion
1136
        def self.convert_wiki_text(text)
1137
          convert_wiki_text_mapping(text, TICKET_MAP)
1138
        end
1139
        
1140
        def self.set_svn_url(url)
1141
          @@svn_url = url
1142
        end
1143

  
1144
        def self.set_svn_username(username)
1145
          @@svn_username = username
1146
        end
1147

  
1148
        def self.set_svn_password(password)
1149
          @@svn_password = password
1150
        end
1151

  
1152
        def self.set_redmine_project_identifier(identifier)
1153
          @@redmine_project = identifier
1154
        end
1155
      
1156
        def self.scm
1157
          # Thomas Recloux fix, see http://www.redmine.org/issues/2748#note-1
1158
          # The constructor of the SvnExtendedAdapter has ony got four parameters, 
1159
          # => parameters 5,6 and 7 removed
1160
          @scm ||= SvnExtendedAdapter.new @@svn_url, @@svn_url, @@svn_username, @@svn_password
1161
          #@scm ||= SvnExtendedAdapter.new @@svn_url, @@svn_url, @@svn_username, @@svn_password, 0, "", nil
1162
          @scm
1163
        end
1164
    end
1165

  
1166
    puts
1167
    prompt('Subversion repository url') {|repository| SvnMigrate.set_svn_url repository.strip}
1168
    prompt('Subversion repository username') {|username| SvnMigrate.set_svn_username username}
1169
    prompt('Subversion repository password') {|password| SvnMigrate.set_svn_password password}
1170
    puts
1171
        
1172
    author_migration_enabled = unsafe_prompt('1) Start Migration of SVN Commit Authors (y,n)?', {:default => 'n'}) == 'y'
1173
    puts
1174
    if author_migration_enabled
1175
      puts "WARNING: Some (maybe all) commit authors will be replaced"
1176
      print "Are you sure you want to continue ? [y/N] "
1177
      break unless STDIN.gets.match(/^y$/i)
1178
      
1179
      SvnMigrate.migrate_authors
1180
    end
1181

  
1182
    message_migration_enabled = unsafe_prompt('2) Start Migration of SVN Commit Messages (y,n)?', {:default => 'n'}) == 'y'
1183
    puts
1184
    if message_migration_enabled
1185
      if Redmine::DefaultData::Loader.no_data?
1186
        puts "Redmine configuration need to be loaded before importing data."
1187
        puts "Please, run this first:"
1188
        puts
1189
        puts "  rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
1190
        exit
1191
      end
1192
  
1193
      puts "WARNING: all commit messages with references to trac pages will be modified"
1194
      print "Are you sure you want to continue ? [y/N] "
1195
      break unless STDIN.gets.match(/^y$/i)
1196
      puts
1197
  
1198
      prompt('Redmine project identifier') {|identifier| SvnMigrate.set_redmine_project_identifier identifier}
1199
      puts
1200
  
1201
      SvnMigrate.migrate_messages
1202
    end
1203
  end
1204

  
1205
  # Prompt
1206
  def prompt(text, options = {}, &block)
1207
    default = options[:default] || ''
1208
    while true
1209
      print "#{text} [#{default}]: "
1210
      STDOUT.flush
1211
      value = STDIN.gets.chomp!
1212
      value = default if value.blank?
1213
      break if yield value
1214
    end
1215
  end
1216

  
1217
  # Sorry, I had troubles to intagrate 'prompt' and quickly went this way...
1218
  def unsafe_prompt(text, options = {})
1219
    default = options[:default] || ''
1220
    print "#{text} [#{default}]: "
1221
    value = STDIN.gets.chomp!
1222
    value = default if value.blank?
1223
    value
1224
  end
1225

  
1226
  # Basic wiki syntax conversion
1227
  def convert_wiki_text_mapping(text, ticket_map = [])
1228
        # Hide links
1229
        def wiki_links_hide(src)
1230
          @wiki_links = []
1231
          @wiki_links_hash = "####WIKILINKS#{src.hash.to_s}####"
1232
          src.gsub(/(\[\[.+?\|.+?\]\])/) do
1233
            @wiki_links << $1
1234
            @wiki_links_hash
1235
          end
1236
        end
1237
        # Restore links
1238
        def wiki_links_restore(src)
1239
          @wiki_links.each do |s|
1240
            src = src.sub("#{@wiki_links_hash}", s.to_s)
1241
          end
1242
          src
1243
        end
1244
        # Hidding code blocks
1245
        def code_hide(src)
1246
          @code = []
1247
          @code_hash = "####CODEBLOCK#{src.hash.to_s}####"
1248
          src.gsub(/(\{\{\{.+?\}\}\}|`.+?`)/m) do
1249
            @code << $1
1250
            @code_hash
1251
          end
1252
        end
1253
        # Convert code blocks
1254
        def code_convert(src)
1255
          @code.each do |s|
1256
            s = s.to_s
1257
            if s =~ (/`(.+?)`/m) || s =~ (/\{\{\{(.+?)\}\}\}/) then
1258
              # inline code
1259
              s = s.replace("@#{$1}@")
1260
            else
1261
              # We would like to convert the Code highlighting too
1262
              # This will go into the next line.
1263
              shebang_line = false
1264
              # Reguar expression for start of code
1265
              pre_re = /\{\{\{/
1266
              # Code hightlighing...
1267
              shebang_re = /^\#\!([a-z]+)/
1268
              # Regular expression for end of code
1269
              pre_end_re = /\}\}\}/
1270
      
1271
              # Go through the whole text..extract it line by line
1272
              s = s.gsub(/^(.*)$/) do |line|
1273
                m_pre = pre_re.match(line)
1274
                if m_pre
1275
                  line = '<pre>'
1276
                else
1277
                  m_sl = shebang_re.match(line)
1278
                  if m_sl
1279
                    shebang_line = true
1280
                    line = '<code class="' + m_sl[1] + '">'
1281
                  end
1282
                  m_pre_end = pre_end_re.match(line)
1283
                  if m_pre_end
1284
                    line = '</pre>'
1285
                    if shebang_line
1286
                      line = '</code>' + line
1287
                    end
1288
                  end
1289
                end
1290
                line
1291
              end
1292
            end
1293
            src = src.sub("#{@code_hash}", s)
1294
          end
1295
          src
1296
        end
1297

  
1298
        # Hide code blocks
1299
        text = code_hide(text)
1300
        # New line
1301
        text = text.gsub(/\[\[[Bb][Rr]\]\]/, "\n") # This has to go before the rules below
1302
        # Titles (only h1. to h6., and remove #...)
1303
        text = text.gsub(/(?:^|^\ +)(\={1,6})\ (.+)\ (?:\1)(?:\ *(\ \#.*))?/) {|s| "\nh#{$1.length}. #{$2}#{$3}\n"}
1304
        
1305
        # External Links:
1306
        #      [http://example.com/]
1307
        text = text.gsub(/\[((?:https?|s?ftp)\:\S+)\]/, '\1')
1308
        #      [http://example.com/ Example],[http://example.com/ "Example"]
1309
        #      [http://example.com/ "Example for "Example""] -> "Example for 'Example'":http://example.com/
1310
        text = text.gsub(/\[((?:https?|s?ftp)\:\S+)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "\"#{$3.tr('"','\'')}\":#{$1}"}
1311
        #      [mailto:some@example.com],[mailto:"some@example.com"]
1312
        text = text.gsub(/\[mailto\:([\"']?)(.+?)\1\]/, '\2')
1313
        
1314
        # Ticket links:
1315
        #      [ticket:234 Text],[ticket:234 This is a test],[ticket:234 "This is a test"]
1316
        #      [ticket:234 "Test "with quotes""] -> "Test 'with quotes'":issues/show/234
1317
        text = text.gsub(/\[ticket\:(\d+)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "\"#{$3.tr('"','\'')}\":/issues/show/#{$1}"}
1318
        #      ticket:1234
1319
        #      excluding ticket:1234:file.txt (used in macros)
1320
        #      #1 - working cause Redmine uses the same syntax.
1321
        text = text.gsub(/ticket\:(\d+?)([^\:])/, '#\1\2')
1322

  
1323
        # Source & attachments links:
1324
        #      [source:/trunk/readme.txt Readme File],[source:"/trunk/readme.txt" Readme File],
1325
        #      [source:/trunk/readme.txt],[source:"/trunk/readme.txt"]
1326
        #       The text "Readme File" is not converted,
1327
        #       cause Redmine's wiki does not support this.
1328
        #      Attachments use same syntax.
1329
        text = text.gsub(/\[(source|attachment)\:([\"']?)([^\"']+?)\2(?:\ +(.+?))?\]/, '\1:"\3"')
1330
        #      source:"/trunk/readme.txt"
1331
        #      source:/trunk/readme.txt - working cause Redmine uses the same syntax.
1332
        text = text.gsub(/(source|attachment)\:([\"'])([^\"']+?)\2/, '\1:"\3"')
1333

  
1334
        # Milestone links:
1335
        #      [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)],
1336
        #      [milestone:"0.1.0 Mercury"],milestone:"0.1.0 Mercury"
1337
        #       The text "Milestone 0.1.0 (Mercury)" is not converted,
1338
        #       cause Redmine's wiki does not support this.
1339
        text = text.gsub(/\[milestone\:([\"'])([^\"']+?)\1(?:\ +(.+?))?\]/, 'version:"\2"')
1340
        text = text.gsub(/milestone\:([\"'])([^\"']+?)\1/, 'version:"\2"')
1341
        #      [milestone:0.1.0],milestone:0.1.0
1342
        text = text.gsub(/\[milestone\:([^\ ]+?)\]/, 'version:\1')
1343
        text = text.gsub(/milestone\:([^\ ]+?)/, 'version:\1')
1344

  
1345
        # Internal Links:
1346
        #      ["Some Link"]
1347
        text = text.gsub(/\[([\"'])(.+?)\1\]/) {|s| "[[#{$2.delete(',./?;|:')}]]"}
1348
        #      [wiki:"Some Link" "Link description"],[wiki:"Some Link" Link description]
1349
        text = text.gsub(/\[wiki\:([\"'])([^\]\"']+?)\1[\ \t]+([\"']?)(.+?)\3\]/) {|s| "[[#{$2.delete(',./?;|:')}|#{$4}]]"}
1350
        #      [wiki:"Some Link"]
1351
        text = text.gsub(/\[wiki\:([\"'])([^\]\"']+?)\1\]/) {|s| "[[#{$2.delete(',./?;|:')}]]"}
1352
        #      [wiki:SomeLink]
1353
        text = text.gsub(/\[wiki\:([^\s\]]+?)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
1354
        #      [wiki:SomeLink Link description],[wiki:SomeLink "Link description"]
1355
        text = text.gsub(/\[wiki\:([^\s\]\"']+?)[\ \t]+([\"']?)(.+?)\2\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$3}]]"}
1356

  
1357
        # Before convert CamelCase links, must hide wiki links with description.
1358
        # Like this: [[http://www.freebsd.org|Hello FreeBSD World]]
1359
        text = wiki_links_hide(text)
1360
        # Links to CamelCase pages (not work for unicode)
1361
        #      UsingJustWikiCaps,UsingJustWikiCaps/Subpage
1362
        text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+(?:\/[^\s[:punct:]]+)*)/) {|s| "#{$1}#{$2}[[#{$3.delete('/')}]]"}
1363
        # Normalize things that were supposed to not be links
1364
        # like !NotALink
1365
        text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
1366
        # Now restore hidden links
1367
        text = wiki_links_restore(text)
1368
        
1369
        # Revisions links
1370
        text = text.gsub(/\[(\d+)\]/, 'r\1')
1371
        # Ticket number re-writing
1372
        text = text.gsub(/#(\d+)/) do |s|
1373
          if $1.length < 10
1374
            #ticket_map[$1.to_i] ||= $1
1375
            "\##{ticket_map[$1.to_i] || $1}"
1376
          else
1377
            s
1378
          end
1379
        end
1380
        
1381
        # Highlighting
1382
        text = text.gsub(/'''''([^\s])/, '_*\1')
1383
        text = text.gsub(/([^\s])'''''/, '\1*_')
1384
        text = text.gsub(/'''/, '*')
1385
        text = text.gsub(/''/, '_')
1386
        text = text.gsub(/__/, '+')
1387
        text = text.gsub(/~~/, '-')
1388
        text = text.gsub(/`/, '@')
1389
        text = text.gsub(/,,/, '~')
1390
        # Tables
1391
        text = text.gsub(/\|\|/, '|')
1392
        # Lists:
1393
        #      bullet
1394
        text = text.gsub(/^(\ +)[\*-] /) {|s| '*' * $1.length + " "}
1395
        #      numbered
1396
        text = text.gsub(/^(\ +)\d+\. /) {|s| '#' * $1.length + " "}
1397
        # Images (work for only attached in current page [[Image(picture.gif)]])
1398
        # need rules for:  * [[Image(wiki:WikiFormatting:picture.gif)]] (referring to attachment on another page)
1399
        #                  * [[Image(ticket:1:picture.gif)]] (file attached to a ticket)
1400
        #                  * [[Image(htdocs:picture.gif)]] (referring to a file inside project htdocs)
1401
        #                  * [[Image(source:/trunk/trac/htdocs/trac_logo_mini.png)]] (a file in repository) 
1402
        text = text.gsub(/\[\[image\((.+?)(?:,.+?)?\)\]\]/i, '!\1!')
1403
        # TOC (is right-aligned, because that in Trac)
1404
        text = text.gsub(/\[\[TOC(?:\((.*?)\))?\]\]/m) {|s| "{{>toc}}\n"}
1405

  
1406
        # Thomas Recloux enhancements, see http://www.redmine.org/issues/2748#note-1
1407
        # Redmine needs a space between keywords "refs,ref,fix" and the issue number (#1234) in subversion commit messages.
1408
        # TODO: rewrite it in a more regex-style way
1409

  
1410
        text = text.gsub("refs#", "refs #")
1411
        text = text.gsub("Refs#", "refs #")
1412
        text = text.gsub("REFS#", "refs #")
1413
        text = text.gsub("ref#", "refs #")
1414
        text = text.gsub("Ref#", "refs #")
1415
        text = text.gsub("REF#", "refs #")
1416

  
1417
        text = text.gsub("fix#", "fixes #")
1418
        text = text.gsub("Fix#", "fixes #")
1419
        text = text.gsub("FIX#", "fixes #")
1420
        text = text.gsub("fixes#", "fixes #")
1421
        text = text.gsub("Fixes#", "fixes #")
1422
        text = text.gsub("FIXES#", "fixes #")
1423
        
1424
        # Restore and convert code blocks
1425
        text = code_convert(text)
1426

  
1427
        text
1428
  end
1429
  
1430
  # Simple progress bar
1431
  def simplebar(title, current, total, out = STDOUT)
1432
    def get_width
1433
      default_width = 80
1434
      begin
1435
        tiocgwinsz = 0x5413
1436
        data = [0, 0, 0, 0].pack("SSSS")
1437
        if out.ioctl(tiocgwinsz, data) >= 0 then
1438
          rows, cols, xpixels, ypixels = data.unpack("SSSS")
1439
          if cols >= 0 then cols else default_width end
1440
        else
1441
          default_width
1442
        end
1443
      rescue Exception
1444
        default_width
1445
      end
1446
    end
1447
    mark = "*"
1448
    title_width = 40
1449
    max = get_width - title_width - 10
1450
    format = "%-#{title_width}s [%-#{max}s] %3d%%  %s"
1451
    bar = current * max / total
1452
    percentage = bar * 100 / max
1453
    current == total ? eol = "\n" : eol ="\r"
1454
    printf(format, title, mark * bar, percentage, eol)
1455
    out.flush
1456
  end
767 1457
end
768 1458

  
769
- 
lib/tasks/migrate_from_trac.rake
1 1
# Redmine - project management software
2
# Copyright (C) 2006-2007  Jean-Philippe Lang
2
# Copyright (C) 2006-2011  Jean-Philippe Lang
3 3
# Copyright (C) 2007-2011  Trac/Redmine Community
4 4
# References:
5 5
#  - http://www.redmine.org/boards/1/topics/12273 (Trac Importer Patch Coordination)
6
#  - http://github.com/landy2005/Redmine-migrate-from-Trac
7 6
#
8 7
# This program is free software; you can redistribute it and/or
9 8
# modify it under the terms of the GNU General Public License
......
56 55
                            'blocker' => priorities[4]
57 56
                            }
58 57

  
59
        TRACKER_BUG = Tracker.find_by_name('Bug')
60
        TRACKER_FEATURE = Tracker.find_by_name('Feature')
61
        TRACKER_SUPPORT = Tracker.find_by_name('Support')
58
        TRACKER_BUG = Tracker.find_by_position(1)
59
        TRACKER_FEATURE = Tracker.find_by_position(2)
62 60
        DEFAULT_TRACKER = TRACKER_BUG
63 61
        TRACKER_MAPPING = {'defect' => TRACKER_BUG,
64 62
                           'enhancement' => TRACKER_FEATURE,
65
                           'task' => TRACKER_SUPPORT,
63
                           'task' => TRACKER_FEATURE,
66 64
                           'patch' =>TRACKER_FEATURE
67 65
                           }
68 66

  
......
73 71
        ROLE_MAPPING = {'admin' => manager_role,
74 72
                        'developer' => developer_role
75 73
                        }
76
        # Add an Hash Table for comments' updatable fields
77
        PROP_MAPPING = {'status' => 'status_id',
78
                        'owner' => 'assigned_to_id',
79
                        'component' => 'category_id',
80
                        'milestone' => 'fixed_version_id',
81
                        'priority' => 'priority_id',
82
                        'summary' => 'subject',
83
                        'type' => 'tracker_id'}
84
        
85
        # Hash table to map completion ratio
86
        RATIO_MAPPING = {'' => 0,
87
                        'fixed' => 100,
88
                        'invalid' => 0,
89
                        'wontfix' => 0,
90
                        'duplicate' => 100,
91
                        'worksforme' => 0}
92 74

  
93 75
      class ::Time
94 76
        class << self
......
174 156
      private
175 157
        def trac_fullpath
176 158
          attachment_type = read_attribute(:type)
177
          trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*]/n ) {|x| sprintf('%%%02X', x[0]) }
178
          trac_dir = id.gsub( /[^a-zA-Z0-9\-_\.!~*\\\/]/n ) {|x| sprintf('%%%02X', x[0]) }
179
          "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{trac_dir}/#{trac_file}"
159
          trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) {|x| sprintf('%%%02x', x[0]) }
160
          "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
180 161
        end
181 162
      end
182 163

  
......
214 195
        def time; Time.at(read_attribute(:time)) end
215 196
      end
216 197

  
217
      TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup \
218
                           TracBrowser TracCgi TracChangeset TracInstallPlatforms TracMultipleProjects TracModWSGI \
198
      TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
219 199
                           TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
220 200
                           TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
221 201
                           TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
222 202
                           TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
223 203
                           WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
224
                           CamelCase TitleIndex TracNavigation TracFineGrainedPermissions TracWorkflow TimingAndEstimationPluginUserManual \
225
                           PageTemplates)
204
                           CamelCase TitleIndex)
205

  
226 206
      class TracWikiPage < ActiveRecord::Base
227 207
        set_table_name :wiki
228 208
        set_primary_key :name
......
248 228
        set_table_name :session_attribute
249 229
      end
250 230

  
251
# TODO put your Login Mapping in this method and rename method below
252
#      def self.find_or_create_user(username, project_member = false)
253
#        TRAC_REDMINE_LOGIN_MAP = []
254
#        return TRAC_REDMINE_LOGIN_MAP[username]
255
# OR more hard-coded:
256
#        if username == 'TracX'
257
#          username = 'RedmineX'
258
#        elsif username == 'gilles'
259
#          username = 'gcornu'
260
#        #elseif ...
261
#        else
262
#          username = 'gcornu'
263
#        end
264
#        return User.find_by_login(username)  
265
#      end
266

  
267 231
      def self.find_or_create_user(username, project_member = false)
268 232
        return User.anonymous if username.blank?
269 233

  
......
280 244
          if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
281 245
            name = name_attr.value
282 246
          end
283
          name =~ (/(.+?)(?:[\ \t]+(.+)?|[\ \t]+|)$/)
247
          name =~ (/(.*)(\s+\w+)?/)
284 248
          fn = $1.strip
285
          # Add a dash for lastname or the user is not saved (bugfix)
286 249
          ln = ($2 || '-').strip
287 250

  
288 251
          u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
......
311 274

  
312 275
      # Basic wiki syntax conversion
313 276
      def self.convert_wiki_text(text)
314
        convert_wiki_text_mapping(text, TICKET_MAP)
277
        # Titles
278
        text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
279
        # External Links
280
        text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
281
        # Ticket links:
282
        #      [ticket:234 Text],[ticket:234 This is a test]
283
        text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
284
        #      ticket:1234
285
        #      #1 is working cause Redmine uses the same syntax.
286
        text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
287
        # Milestone links:
288
        #      [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
289
        #      The text "Milestone 0.1.0 (Mercury)" is not converted,
290
        #      cause Redmine's wiki does not support this.
291
        text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
292
        #      [milestone:"0.1.0 Mercury"]
293
        text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
294
        text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
295
        #      milestone:0.1.0
296
        text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
297
        text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
298
        # Internal Links
299
        text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
300
        text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
301
        text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
302
        text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
303
        text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
304
        text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
305

  
306
  # Links to pages UsingJustWikiCaps
307
  text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
308
  # Normalize things that were supposed to not be links
309
  # like !NotALink
310
  text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
311
        # Revisions links
312
        text = text.gsub(/\[(\d+)\]/, 'r\1')
313
        # Ticket number re-writing
314
        text = text.gsub(/#(\d+)/) do |s|
315
          if $1.length < 10
316
#            TICKET_MAP[$1.to_i] ||= $1
317
            "\##{TICKET_MAP[$1.to_i] || $1}"
318
          else
319
            s
320
          end
321
        end
322
        # We would like to convert the Code highlighting too
323
        # This will go into the next line.
324
        shebang_line = false
325
        # Reguar expression for start of code
326
        pre_re = /\{\{\{/
327
        # Code hightlighing...
328
        shebang_re = /^\#\!([a-z]+)/
329
        # Regular expression for end of code
330
        pre_end_re = /\}\}\}/
331

  
332
        # Go through the whole text..extract it line by line
333
        text = text.gsub(/^(.*)$/) do |line|
334
          m_pre = pre_re.match(line)
335
          if m_pre
336
            line = '<pre>'
337
          else
338
            m_sl = shebang_re.match(line)
339
            if m_sl
340
              shebang_line = true
341
              line = '<code class="' + m_sl[1] + '">'
342
            end
343
            m_pre_end = pre_end_re.match(line)
344
            if m_pre_end
345
              line = '</pre>'
346
              if shebang_line
347
                line = '</code>' + line
348
              end
349
            end
350
          end
351
          line
352
        end
353

  
354
        # Highlighting
355
        text = text.gsub(/'''''([^\s])/, '_*\1')
356
        text = text.gsub(/([^\s])'''''/, '\1*_')
357
        text = text.gsub(/'''/, '*')
358
        text = text.gsub(/''/, '_')
359
        text = text.gsub(/__/, '+')
360
        text = text.gsub(/~~/, '-')
361
        text = text.gsub(/`/, '@')
362
        text = text.gsub(/,,/, '~')
363
        # Lists
364
        text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
365

  
366
        text
315 367
      end
... This diff was truncated because it exceeds the maximum size that can be displayed.
    (1-1/1)