You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

migrate_from_trac.rake 30KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. # Redmine - project management software
  2. # Copyright (C) 2006-2013 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. require 'active_record'
  18. require 'iconv' if RUBY_VERSION < '1.9'
  19. require 'pp'
  20. namespace :redmine do
  21. desc 'Trac migration script'
  22. task :migrate_from_trac => :environment do
  23. module TracMigrate
  24. TICKET_MAP = []
  25. DEFAULT_STATUS = IssueStatus.default
  26. assigned_status = IssueStatus.find_by_position(2)
  27. resolved_status = IssueStatus.find_by_position(3)
  28. feedback_status = IssueStatus.find_by_position(4)
  29. closed_status = IssueStatus.where(:is_closed => true).first
  30. STATUS_MAPPING = {'new' => DEFAULT_STATUS,
  31. 'reopened' => feedback_status,
  32. 'assigned' => assigned_status,
  33. 'closed' => closed_status
  34. }
  35. priorities = IssuePriority.all
  36. DEFAULT_PRIORITY = priorities[0]
  37. PRIORITY_MAPPING = {'lowest' => priorities[0],
  38. 'low' => priorities[0],
  39. 'normal' => priorities[1],
  40. 'high' => priorities[2],
  41. 'highest' => priorities[3],
  42. # ---
  43. 'trivial' => priorities[0],
  44. 'minor' => priorities[1],
  45. 'major' => priorities[2],
  46. 'critical' => priorities[3],
  47. 'blocker' => priorities[4]
  48. }
  49. TRACKER_BUG = Tracker.find_by_position(1)
  50. TRACKER_FEATURE = Tracker.find_by_position(2)
  51. DEFAULT_TRACKER = TRACKER_BUG
  52. TRACKER_MAPPING = {'defect' => TRACKER_BUG,
  53. 'enhancement' => TRACKER_FEATURE,
  54. 'task' => TRACKER_FEATURE,
  55. 'patch' =>TRACKER_FEATURE
  56. }
  57. roles = Role.where(:builtin => 0).order('position ASC').all
  58. manager_role = roles[0]
  59. developer_role = roles[1]
  60. DEFAULT_ROLE = roles.last
  61. ROLE_MAPPING = {'admin' => manager_role,
  62. 'developer' => developer_role
  63. }
  64. class ::Time
  65. class << self
  66. alias :real_now :now
  67. def now
  68. real_now - @fake_diff.to_i
  69. end
  70. def fake(time)
  71. @fake_diff = real_now - time
  72. res = yield
  73. @fake_diff = 0
  74. res
  75. end
  76. end
  77. end
  78. class TracComponent < ActiveRecord::Base
  79. self.table_name = :component
  80. end
  81. class TracMilestone < ActiveRecord::Base
  82. self.table_name = :milestone
  83. # If this attribute is set a milestone has a defined target timepoint
  84. def due
  85. if read_attribute(:due) && read_attribute(:due) > 0
  86. Time.at(read_attribute(:due)).to_date
  87. else
  88. nil
  89. end
  90. end
  91. # This is the real timepoint at which the milestone has finished.
  92. def completed
  93. if read_attribute(:completed) && read_attribute(:completed) > 0
  94. Time.at(read_attribute(:completed)).to_date
  95. else
  96. nil
  97. end
  98. end
  99. def description
  100. # Attribute is named descr in Trac v0.8.x
  101. has_attribute?(:descr) ? read_attribute(:descr) : read_attribute(:description)
  102. end
  103. end
  104. class TracTicketCustom < ActiveRecord::Base
  105. self.table_name = :ticket_custom
  106. end
  107. class TracAttachment < ActiveRecord::Base
  108. self.table_name = :attachment
  109. set_inheritance_column :none
  110. def time; Time.at(read_attribute(:time)) end
  111. def original_filename
  112. filename
  113. end
  114. def content_type
  115. ''
  116. end
  117. def exist?
  118. File.file? trac_fullpath
  119. end
  120. def open
  121. File.open("#{trac_fullpath}", 'rb') {|f|
  122. @file = f
  123. yield self
  124. }
  125. end
  126. def read(*args)
  127. @file.read(*args)
  128. end
  129. def description
  130. read_attribute(:description).to_s.slice(0,255)
  131. end
  132. private
  133. def trac_fullpath
  134. attachment_type = read_attribute(:type)
  135. #replace exotic characters with their hex representation to avoid invalid filenames
  136. trac_file = filename.gsub( /[^a-zA-Z0-9\-_\.!~*']/n ) do |x|
  137. codepoint = RUBY_VERSION < '1.9' ? x[0] : x.codepoints.to_a[0]
  138. sprintf('%%%02x', codepoint)
  139. end
  140. "#{TracMigrate.trac_attachments_directory}/#{attachment_type}/#{id}/#{trac_file}"
  141. end
  142. end
  143. class TracTicket < ActiveRecord::Base
  144. self.table_name = :ticket
  145. set_inheritance_column :none
  146. # ticket changes: only migrate status changes and comments
  147. has_many :ticket_changes, :class_name => "TracTicketChange", :foreign_key => :ticket
  148. has_many :customs, :class_name => "TracTicketCustom", :foreign_key => :ticket
  149. def attachments
  150. TracMigrate::TracAttachment.all(:conditions => ["type = 'ticket' AND id = ?", self.id.to_s])
  151. end
  152. def ticket_type
  153. read_attribute(:type)
  154. end
  155. def summary
  156. read_attribute(:summary).blank? ? "(no subject)" : read_attribute(:summary)
  157. end
  158. def description
  159. read_attribute(:description).blank? ? summary : read_attribute(:description)
  160. end
  161. def time; Time.at(read_attribute(:time)) end
  162. def changetime; Time.at(read_attribute(:changetime)) end
  163. end
  164. class TracTicketChange < ActiveRecord::Base
  165. self.table_name = :ticket_change
  166. def self.columns
  167. # Hides Trac field 'field' to prevent clash with AR field_changed? method (Rails 3.0)
  168. super.select {|column| column.name.to_s != 'field'}
  169. end
  170. def time; Time.at(read_attribute(:time)) end
  171. end
  172. TRAC_WIKI_PAGES = %w(InterMapTxt InterTrac InterWiki RecentChanges SandBox TracAccessibility TracAdmin TracBackup TracBrowser TracCgi TracChangeset \
  173. TracEnvironment TracFastCgi TracGuide TracImport TracIni TracInstall TracInterfaceCustomization \
  174. TracLinks TracLogging TracModPython TracNotification TracPermissions TracPlugins TracQuery \
  175. TracReports TracRevisionLog TracRoadmap TracRss TracSearch TracStandalone TracSupport TracSyntaxColoring TracTickets \
  176. TracTicketsCustomFields TracTimeline TracUnicode TracUpgrade TracWiki WikiDeletePage WikiFormatting \
  177. WikiHtml WikiMacros WikiNewPage WikiPageNames WikiProcessors WikiRestructuredText WikiRestructuredTextLinks \
  178. CamelCase TitleIndex)
  179. class TracWikiPage < ActiveRecord::Base
  180. self.table_name = :wiki
  181. set_primary_key :name
  182. def self.columns
  183. # Hides readonly Trac field to prevent clash with AR readonly? method (Rails 2.0)
  184. super.select {|column| column.name.to_s != 'readonly'}
  185. end
  186. def attachments
  187. TracMigrate::TracAttachment.all(:conditions => ["type = 'wiki' AND id = ?", self.id.to_s])
  188. end
  189. def time; Time.at(read_attribute(:time)) end
  190. end
  191. class TracPermission < ActiveRecord::Base
  192. self.table_name = :permission
  193. end
  194. class TracSessionAttribute < ActiveRecord::Base
  195. self.table_name = :session_attribute
  196. end
  197. def self.find_or_create_user(username, project_member = false)
  198. return User.anonymous if username.blank?
  199. u = User.find_by_login(username)
  200. if !u
  201. # Create a new user if not found
  202. mail = username[0, User::MAIL_LENGTH_LIMIT]
  203. if mail_attr = TracSessionAttribute.find_by_sid_and_name(username, 'email')
  204. mail = mail_attr.value
  205. end
  206. mail = "#{mail}@foo.bar" unless mail.include?("@")
  207. name = username
  208. if name_attr = TracSessionAttribute.find_by_sid_and_name(username, 'name')
  209. name = name_attr.value
  210. end
  211. name =~ (/(\w+)(\s+\w+)?/)
  212. fn = ($1 || "-").strip
  213. ln = ($2 || '-').strip
  214. u = User.new :mail => mail.gsub(/[^-@a-z0-9\.]/i, '-'),
  215. :firstname => fn[0, limit_for(User, 'firstname')],
  216. :lastname => ln[0, limit_for(User, 'lastname')]
  217. u.login = username[0, User::LOGIN_LENGTH_LIMIT].gsub(/[^a-z0-9_\-@\.]/i, '-')
  218. u.password = 'trac'
  219. u.admin = true if TracPermission.find_by_username_and_action(username, 'admin')
  220. # finally, a default user is used if the new user is not valid
  221. u = User.first unless u.save
  222. end
  223. # Make sure user is a member of the project
  224. if project_member && !u.member_of?(@target_project)
  225. role = DEFAULT_ROLE
  226. if u.admin
  227. role = ROLE_MAPPING['admin']
  228. elsif TracPermission.find_by_username_and_action(username, 'developer')
  229. role = ROLE_MAPPING['developer']
  230. end
  231. Member.create(:user => u, :project => @target_project, :roles => [role])
  232. u.reload
  233. end
  234. u
  235. end
  236. # Basic wiki syntax conversion
  237. def self.convert_wiki_text(text)
  238. # Titles
  239. text = text.gsub(/^(\=+)\s(.+)\s(\=+)/) {|s| "\nh#{$1.length}. #{$2}\n"}
  240. # External Links
  241. text = text.gsub(/\[(http[^\s]+)\s+([^\]]+)\]/) {|s| "\"#{$2}\":#{$1}"}
  242. # Ticket links:
  243. # [ticket:234 Text],[ticket:234 This is a test]
  244. text = text.gsub(/\[ticket\:([^\ ]+)\ (.+?)\]/, '"\2":/issues/show/\1')
  245. # ticket:1234
  246. # #1 is working cause Redmine uses the same syntax.
  247. text = text.gsub(/ticket\:([^\ ]+)/, '#\1')
  248. # Milestone links:
  249. # [milestone:"0.1.0 Mercury" Milestone 0.1.0 (Mercury)]
  250. # The text "Milestone 0.1.0 (Mercury)" is not converted,
  251. # cause Redmine's wiki does not support this.
  252. text = text.gsub(/\[milestone\:\"([^\"]+)\"\ (.+?)\]/, 'version:"\1"')
  253. # [milestone:"0.1.0 Mercury"]
  254. text = text.gsub(/\[milestone\:\"([^\"]+)\"\]/, 'version:"\1"')
  255. text = text.gsub(/milestone\:\"([^\"]+)\"/, 'version:"\1"')
  256. # milestone:0.1.0
  257. text = text.gsub(/\[milestone\:([^\ ]+)\]/, 'version:\1')
  258. text = text.gsub(/milestone\:([^\ ]+)/, 'version:\1')
  259. # Internal Links
  260. text = text.gsub(/\[\[BR\]\]/, "\n") # This has to go before the rules below
  261. text = text.gsub(/\[\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
  262. text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
  263. text = text.gsub(/\[wiki:\"(.+)\".*\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
  264. text = text.gsub(/\[wiki:([^\s\]]+)\]/) {|s| "[[#{$1.delete(',./?;|:')}]]"}
  265. text = text.gsub(/\[wiki:([^\s\]]+)\s(.*)\]/) {|s| "[[#{$1.delete(',./?;|:')}|#{$2.delete(',./?;|:')}]]"}
  266. # Links to pages UsingJustWikiCaps
  267. text = text.gsub(/([^!]|^)(^| )([A-Z][a-z]+[A-Z][a-zA-Z]+)/, '\\1\\2[[\3]]')
  268. # Normalize things that were supposed to not be links
  269. # like !NotALink
  270. text = text.gsub(/(^| )!([A-Z][A-Za-z]+)/, '\1\2')
  271. # Revisions links
  272. text = text.gsub(/\[(\d+)\]/, 'r\1')
  273. # Ticket number re-writing
  274. text = text.gsub(/#(\d+)/) do |s|
  275. if $1.length < 10
  276. # TICKET_MAP[$1.to_i] ||= $1
  277. "\##{TICKET_MAP[$1.to_i] || $1}"
  278. else
  279. s
  280. end
  281. end
  282. # We would like to convert the Code highlighting too
  283. # This will go into the next line.
  284. shebang_line = false
  285. # Reguar expression for start of code
  286. pre_re = /\{\{\{/
  287. # Code hightlighing...
  288. shebang_re = /^\#\!([a-z]+)/
  289. # Regular expression for end of code
  290. pre_end_re = /\}\}\}/
  291. # Go through the whole text..extract it line by line
  292. text = text.gsub(/^(.*)$/) do |line|
  293. m_pre = pre_re.match(line)
  294. if m_pre
  295. line = '<pre>'
  296. else
  297. m_sl = shebang_re.match(line)
  298. if m_sl
  299. shebang_line = true
  300. line = '<code class="' + m_sl[1] + '">'
  301. end
  302. m_pre_end = pre_end_re.match(line)
  303. if m_pre_end
  304. line = '</pre>'
  305. if shebang_line
  306. line = '</code>' + line
  307. end
  308. end
  309. end
  310. line
  311. end
  312. # Highlighting
  313. text = text.gsub(/'''''([^\s])/, '_*\1')
  314. text = text.gsub(/([^\s])'''''/, '\1*_')
  315. text = text.gsub(/'''/, '*')
  316. text = text.gsub(/''/, '_')
  317. text = text.gsub(/__/, '+')
  318. text = text.gsub(/~~/, '-')
  319. text = text.gsub(/`/, '@')
  320. text = text.gsub(/,,/, '~')
  321. # Lists
  322. text = text.gsub(/^([ ]+)\* /) {|s| '*' * $1.length + " "}
  323. text
  324. end
  325. def self.migrate
  326. establish_connection
  327. # Quick database test
  328. TracComponent.count
  329. migrated_components = 0
  330. migrated_milestones = 0
  331. migrated_tickets = 0
  332. migrated_custom_values = 0
  333. migrated_ticket_attachments = 0
  334. migrated_wiki_edits = 0
  335. migrated_wiki_attachments = 0
  336. #Wiki system initializing...
  337. @target_project.wiki.destroy if @target_project.wiki
  338. @target_project.reload
  339. wiki = Wiki.new(:project => @target_project, :start_page => 'WikiStart')
  340. wiki_edit_count = 0
  341. # Components
  342. print "Migrating components"
  343. issues_category_map = {}
  344. TracComponent.all.each do |component|
  345. print '.'
  346. STDOUT.flush
  347. c = IssueCategory.new :project => @target_project,
  348. :name => encode(component.name[0, limit_for(IssueCategory, 'name')])
  349. next unless c.save
  350. issues_category_map[component.name] = c
  351. migrated_components += 1
  352. end
  353. puts
  354. # Milestones
  355. print "Migrating milestones"
  356. version_map = {}
  357. TracMilestone.all.each do |milestone|
  358. print '.'
  359. STDOUT.flush
  360. # First we try to find the wiki page...
  361. p = wiki.find_or_new_page(milestone.name.to_s)
  362. p.content = WikiContent.new(:page => p) if p.new_record?
  363. p.content.text = milestone.description.to_s
  364. p.content.author = find_or_create_user('trac')
  365. p.content.comments = 'Milestone'
  366. p.save
  367. v = Version.new :project => @target_project,
  368. :name => encode(milestone.name[0, limit_for(Version, 'name')]),
  369. :description => nil,
  370. :wiki_page_title => milestone.name.to_s,
  371. :effective_date => milestone.completed
  372. next unless v.save
  373. version_map[milestone.name] = v
  374. migrated_milestones += 1
  375. end
  376. puts
  377. # Custom fields
  378. # TODO: read trac.ini instead
  379. print "Migrating custom fields"
  380. custom_field_map = {}
  381. TracTicketCustom.find_by_sql("SELECT DISTINCT name FROM #{TracTicketCustom.table_name}").each do |field|
  382. print '.'
  383. STDOUT.flush
  384. # Redmine custom field name
  385. field_name = encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize
  386. # Find if the custom already exists in Redmine
  387. f = IssueCustomField.find_by_name(field_name)
  388. # Or create a new one
  389. f ||= IssueCustomField.create(:name => encode(field.name[0, limit_for(IssueCustomField, 'name')]).humanize,
  390. :field_format => 'string')
  391. next if f.new_record?
  392. f.trackers = Tracker.all
  393. f.projects << @target_project
  394. custom_field_map[field.name] = f
  395. end
  396. puts
  397. # Trac 'resolution' field as a Redmine custom field
  398. r = IssueCustomField.where(:name => "Resolution").first
  399. r = IssueCustomField.new(:name => 'Resolution',
  400. :field_format => 'list',
  401. :is_filter => true) if r.nil?
  402. r.trackers = Tracker.all
  403. r.projects << @target_project
  404. r.possible_values = (r.possible_values + %w(fixed invalid wontfix duplicate worksforme)).flatten.compact.uniq
  405. r.save!
  406. custom_field_map['resolution'] = r
  407. # Tickets
  408. print "Migrating tickets"
  409. TracTicket.find_each(:batch_size => 200) do |ticket|
  410. print '.'
  411. STDOUT.flush
  412. i = Issue.new :project => @target_project,
  413. :subject => encode(ticket.summary[0, limit_for(Issue, 'subject')]),
  414. :description => convert_wiki_text(encode(ticket.description)),
  415. :priority => PRIORITY_MAPPING[ticket.priority] || DEFAULT_PRIORITY,
  416. :created_on => ticket.time
  417. i.author = find_or_create_user(ticket.reporter)
  418. i.category = issues_category_map[ticket.component] unless ticket.component.blank?
  419. i.fixed_version = version_map[ticket.milestone] unless ticket.milestone.blank?
  420. i.status = STATUS_MAPPING[ticket.status] || DEFAULT_STATUS
  421. i.tracker = TRACKER_MAPPING[ticket.ticket_type] || DEFAULT_TRACKER
  422. i.id = ticket.id unless Issue.exists?(ticket.id)
  423. next unless Time.fake(ticket.changetime) { i.save }
  424. TICKET_MAP[ticket.id] = i.id
  425. migrated_tickets += 1
  426. # Owner
  427. unless ticket.owner.blank?
  428. i.assigned_to = find_or_create_user(ticket.owner, true)
  429. Time.fake(ticket.changetime) { i.save }
  430. end
  431. # Comments and status/resolution changes
  432. ticket.ticket_changes.group_by(&:time).each do |time, changeset|
  433. status_change = changeset.select {|change| change.field == 'status'}.first
  434. resolution_change = changeset.select {|change| change.field == 'resolution'}.first
  435. comment_change = changeset.select {|change| change.field == 'comment'}.first
  436. n = Journal.new :notes => (comment_change ? convert_wiki_text(encode(comment_change.newvalue)) : ''),
  437. :created_on => time
  438. n.user = find_or_create_user(changeset.first.author)
  439. n.journalized = i
  440. if status_change &&
  441. STATUS_MAPPING[status_change.oldvalue] &&
  442. STATUS_MAPPING[status_change.newvalue] &&
  443. (STATUS_MAPPING[status_change.oldvalue] != STATUS_MAPPING[status_change.newvalue])
  444. n.details << JournalDetail.new(:property => 'attr',
  445. :prop_key => 'status_id',
  446. :old_value => STATUS_MAPPING[status_change.oldvalue].id,
  447. :value => STATUS_MAPPING[status_change.newvalue].id)
  448. end
  449. if resolution_change
  450. n.details << JournalDetail.new(:property => 'cf',
  451. :prop_key => custom_field_map['resolution'].id,
  452. :old_value => resolution_change.oldvalue,
  453. :value => resolution_change.newvalue)
  454. end
  455. n.save unless n.details.empty? && n.notes.blank?
  456. end
  457. # Attachments
  458. ticket.attachments.each do |attachment|
  459. next unless attachment.exist?
  460. attachment.open {
  461. a = Attachment.new :created_on => attachment.time
  462. a.file = attachment
  463. a.author = find_or_create_user(attachment.author)
  464. a.container = i
  465. a.description = attachment.description
  466. migrated_ticket_attachments += 1 if a.save
  467. }
  468. end
  469. # Custom fields
  470. custom_values = ticket.customs.inject({}) do |h, custom|
  471. if custom_field = custom_field_map[custom.name]
  472. h[custom_field.id] = custom.value
  473. migrated_custom_values += 1
  474. end
  475. h
  476. end
  477. if custom_field_map['resolution'] && !ticket.resolution.blank?
  478. custom_values[custom_field_map['resolution'].id] = ticket.resolution
  479. end
  480. i.custom_field_values = custom_values
  481. i.save_custom_field_values
  482. end
  483. # update issue id sequence if needed (postgresql)
  484. Issue.connection.reset_pk_sequence!(Issue.table_name) if Issue.connection.respond_to?('reset_pk_sequence!')
  485. puts
  486. # Wiki
  487. print "Migrating wiki"
  488. if wiki.save
  489. TracWikiPage.order('name, version').all.each do |page|
  490. # Do not migrate Trac manual wiki pages
  491. next if TRAC_WIKI_PAGES.include?(page.name)
  492. wiki_edit_count += 1
  493. print '.'
  494. STDOUT.flush
  495. p = wiki.find_or_new_page(page.name)
  496. p.content = WikiContent.new(:page => p) if p.new_record?
  497. p.content.text = page.text
  498. p.content.author = find_or_create_user(page.author) unless page.author.blank? || page.author == 'trac'
  499. p.content.comments = page.comment
  500. Time.fake(page.time) { p.new_record? ? p.save : p.content.save }
  501. next if p.content.new_record?
  502. migrated_wiki_edits += 1
  503. # Attachments
  504. page.attachments.each do |attachment|
  505. next unless attachment.exist?
  506. next if p.attachments.find_by_filename(attachment.filename.gsub(/^.*(\\|\/)/, '').gsub(/[^\w\.\-]/,'_')) #add only once per page
  507. attachment.open {
  508. a = Attachment.new :created_on => attachment.time
  509. a.file = attachment
  510. a.author = find_or_create_user(attachment.author)
  511. a.description = attachment.description
  512. a.container = p
  513. migrated_wiki_attachments += 1 if a.save
  514. }
  515. end
  516. end
  517. wiki.reload
  518. wiki.pages.each do |page|
  519. page.content.text = convert_wiki_text(page.content.text)
  520. Time.fake(page.content.updated_on) { page.content.save }
  521. end
  522. end
  523. puts
  524. puts
  525. puts "Components: #{migrated_components}/#{TracComponent.count}"
  526. puts "Milestones: #{migrated_milestones}/#{TracMilestone.count}"
  527. puts "Tickets: #{migrated_tickets}/#{TracTicket.count}"
  528. puts "Ticket files: #{migrated_ticket_attachments}/" + TracAttachment.count(:conditions => {:type => 'ticket'}).to_s
  529. puts "Custom values: #{migrated_custom_values}/#{TracTicketCustom.count}"
  530. puts "Wiki edits: #{migrated_wiki_edits}/#{wiki_edit_count}"
  531. puts "Wiki files: #{migrated_wiki_attachments}/" + TracAttachment.count(:conditions => {:type => 'wiki'}).to_s
  532. end
  533. def self.limit_for(klass, attribute)
  534. klass.columns_hash[attribute.to_s].limit
  535. end
  536. def self.encoding(charset)
  537. @charset = charset
  538. end
  539. def self.set_trac_directory(path)
  540. @@trac_directory = path
  541. raise "This directory doesn't exist!" unless File.directory?(path)
  542. raise "#{trac_attachments_directory} doesn't exist!" unless File.directory?(trac_attachments_directory)
  543. @@trac_directory
  544. rescue Exception => e
  545. puts e
  546. return false
  547. end
  548. def self.trac_directory
  549. @@trac_directory
  550. end
  551. def self.set_trac_adapter(adapter)
  552. return false if adapter.blank?
  553. raise "Unknown adapter: #{adapter}!" unless %w(sqlite3 mysql postgresql).include?(adapter)
  554. # If adapter is sqlite or sqlite3, make sure that trac.db exists
  555. raise "#{trac_db_path} doesn't exist!" if %w(sqlite3).include?(adapter) && !File.exist?(trac_db_path)
  556. @@trac_adapter = adapter
  557. rescue Exception => e
  558. puts e
  559. return false
  560. end
  561. def self.set_trac_db_host(host)
  562. return nil if host.blank?
  563. @@trac_db_host = host
  564. end
  565. def self.set_trac_db_port(port)
  566. return nil if port.to_i == 0
  567. @@trac_db_port = port.to_i
  568. end
  569. def self.set_trac_db_name(name)
  570. return nil if name.blank?
  571. @@trac_db_name = name
  572. end
  573. def self.set_trac_db_username(username)
  574. @@trac_db_username = username
  575. end
  576. def self.set_trac_db_password(password)
  577. @@trac_db_password = password
  578. end
  579. def self.set_trac_db_schema(schema)
  580. @@trac_db_schema = schema
  581. end
  582. mattr_reader :trac_directory, :trac_adapter, :trac_db_host, :trac_db_port, :trac_db_name, :trac_db_schema, :trac_db_username, :trac_db_password
  583. def self.trac_db_path; "#{trac_directory}/db/trac.db" end
  584. def self.trac_attachments_directory; "#{trac_directory}/attachments" end
  585. def self.target_project_identifier(identifier)
  586. project = Project.find_by_identifier(identifier)
  587. if !project
  588. # create the target project
  589. project = Project.new :name => identifier.humanize,
  590. :description => ''
  591. project.identifier = identifier
  592. puts "Unable to create a project with identifier '#{identifier}'!" unless project.save
  593. # enable issues and wiki for the created project
  594. project.enabled_module_names = ['issue_tracking', 'wiki']
  595. else
  596. puts
  597. puts "This project already exists in your Redmine database."
  598. print "Are you sure you want to append data to this project ? [Y/n] "
  599. STDOUT.flush
  600. exit if STDIN.gets.match(/^n$/i)
  601. end
  602. project.trackers << TRACKER_BUG unless project.trackers.include?(TRACKER_BUG)
  603. project.trackers << TRACKER_FEATURE unless project.trackers.include?(TRACKER_FEATURE)
  604. @target_project = project.new_record? ? nil : project
  605. @target_project.reload
  606. end
  607. def self.connection_params
  608. if trac_adapter == 'sqlite3'
  609. {:adapter => 'sqlite3',
  610. :database => trac_db_path}
  611. else
  612. {:adapter => trac_adapter,
  613. :database => trac_db_name,
  614. :host => trac_db_host,
  615. :port => trac_db_port,
  616. :username => trac_db_username,
  617. :password => trac_db_password,
  618. :schema_search_path => trac_db_schema
  619. }
  620. end
  621. end
  622. def self.establish_connection
  623. constants.each do |const|
  624. klass = const_get(const)
  625. next unless klass.respond_to? 'establish_connection'
  626. klass.establish_connection connection_params
  627. end
  628. end
  629. def self.encode(text)
  630. if RUBY_VERSION < '1.9'
  631. @ic ||= Iconv.new('UTF-8', @charset)
  632. @ic.iconv text
  633. else
  634. text.to_s.force_encoding(@charset).encode('UTF-8')
  635. end
  636. end
  637. end
  638. puts
  639. if Redmine::DefaultData::Loader.no_data?
  640. puts "Redmine configuration need to be loaded before importing data."
  641. puts "Please, run this first:"
  642. puts
  643. puts " rake redmine:load_default_data RAILS_ENV=\"#{ENV['RAILS_ENV']}\""
  644. exit
  645. end
  646. puts "WARNING: a new project will be added to Redmine during this process."
  647. print "Are you sure you want to continue ? [y/N] "
  648. STDOUT.flush
  649. break unless STDIN.gets.match(/^y$/i)
  650. puts
  651. def prompt(text, options = {}, &block)
  652. default = options[:default] || ''
  653. while true
  654. print "#{text} [#{default}]: "
  655. STDOUT.flush
  656. value = STDIN.gets.chomp!
  657. value = default if value.blank?
  658. break if yield value
  659. end
  660. end
  661. DEFAULT_PORTS = {'mysql' => 3306, 'postgresql' => 5432}
  662. prompt('Trac directory') {|directory| TracMigrate.set_trac_directory directory.strip}
  663. prompt('Trac database adapter (sqlite3, mysql2, postgresql)', :default => 'sqlite3') {|adapter| TracMigrate.set_trac_adapter adapter}
  664. unless %w(sqlite3).include?(TracMigrate.trac_adapter)
  665. prompt('Trac database host', :default => 'localhost') {|host| TracMigrate.set_trac_db_host host}
  666. prompt('Trac database port', :default => DEFAULT_PORTS[TracMigrate.trac_adapter]) {|port| TracMigrate.set_trac_db_port port}
  667. prompt('Trac database name') {|name| TracMigrate.set_trac_db_name name}
  668. prompt('Trac database schema', :default => 'public') {|schema| TracMigrate.set_trac_db_schema schema}
  669. prompt('Trac database username') {|username| TracMigrate.set_trac_db_username username}
  670. prompt('Trac database password') {|password| TracMigrate.set_trac_db_password password}
  671. end
  672. prompt('Trac database encoding', :default => 'UTF-8') {|encoding| TracMigrate.encoding encoding}
  673. prompt('Target project identifier') {|identifier| TracMigrate.target_project_identifier identifier}
  674. puts
  675. old_notified_events = Setting.notified_events
  676. old_password_min_length = Setting.password_min_length
  677. begin
  678. # Turn off email notifications temporarily
  679. Setting.notified_events = []
  680. Setting.password_min_length = 4
  681. # Run the migration
  682. TracMigrate.migrate
  683. ensure
  684. # Restore previous settings
  685. Setting.notified_events = old_notified_events
  686. Setting.password_min_length = old_password_min_length
  687. end
  688. end
  689. end