Project

General

Profile

RE: Importing XML data from Basecamp to Redmine » basecamp2redmine.rb

Import Basecamp XML backup file into Redmine - Ted Behling, 2010-08-23 22:00

 
1
#!/usr/bin/env ruby
2

    
3
# This Ruby script will extract data from a Basecamp "backup" XML file and import
4
# it into Redmine.
5
#
6
# You must install the Nokogiri gem, which is an XML parser: sudo gem install nokogiri
7
#
8
# This script is a "code generator", in that it writes a new Ruby script to STDOUT.
9
# This script contains invocations of Redmine's ActiveRecord models.  The resulting
10
# "import script" can be edited before being executed, if desired. 
11
#
12
# Before running this script, you must create a Tracker inside Redmine called "Basecamp Todo".
13
# You do not need to associate it with any existing projects.
14
#
15
# This script, if saved as filename basecamp2redmine.rb, can be invoked as follows.
16
# This will generate an ActiveRecord-based import script in the current directory,
17
# which should be the root directory of the Redmine installation.
18
#
19
# ruby basecamp2redmine.rb my-basecamp-backup.xml > basecamp-import.rb
20
# script/runner -e development basecamp-import.rb
21
#
22
# The import process can be reversed by running:
23
#
24
# ruby basecamp2redmine_undo.rb my-basecamp-backup.xml > basecamp-undo.rb
25
# script/runner -e development basecamp-undo.rb
26
#
27
# Author: Ted Behling <ted@tedb.us>
28
# Available at http://gist.github.com/tedb
29
#
30
# Thanks to Tactio Interaction Design (www.tactio.com.br) for funding this work!
31
#
32
# See MIT License below.  You are not required to provide the author with changes
33
# you make to this software, but it would be appreciated, as a courtesy, if it is possible.
34
#
35
# LEGAL NOTE:
36
# The Basecamp name is a registered trademark of 37 Signals, LLC.  Use of this trademark
37
# is for reference only, and does not imply any relationship or affiliation with or endorsement
38
# from or by 37 Signals, LLC.
39
# Ted Behling, the author of this script, has no affiliation with 37 Signals, LLC.
40
# All source code contained in this file is the original work of Ted Behling.
41
# Product names, logos, brands, and other trademarks featured or referred to within
42
# this software are the property of their respective trademark holders.
43
# 37 Signals does not sponsor or endorse this script or its author.
44
#
45
# DHH, please don't sue me for trademark infringement.  I don't have anything you'd want anyway.
46
#
47

    
48
require 'rubygems'
49
require 'nokogiri'
50

    
51
PROJECT_NAME_LENGTH = 30 - 5
52
TRACKER = 'Basecamp Todo'
53

    
54
filename = ARGV[0] or raise ArgumentError, "Must have filename specified on command line"
55

    
56
def truncate_name(name, length, ellipsis)
57
  name.size <= length ? name : name[0..length / 2 - 1] + ellipsis + name[-(length/2 - 2)..-1]
58
end
59

    
60
src = []
61
src << %{projects = {}}
62
src << %{todo_lists = {}} # Todo lists are actually tasks that have sub-tasks --- was sub-projects
63
src << %{todos = {}}
64
src << %{journals = {}}
65
src << %{messages = {}}
66
src << %{comments = {}}
67

    
68
src << %{BASECAMP_TRACKER = Tracker.find_by_name '#{TRACKER}'}
69
src << %{raise "Tracker named '#{TRACKER}' must exist" unless BASECAMP_TRACKER}
70

    
71
src << %{DEFAULT_STATUS = IssueStatus.default}
72
src << %{CLOSED_STATUS = IssueStatus.find :first, :conditions => { :is_closed => true }}
73
src << %{AUTHOR = User.anonymous  #User.find 1}
74

    
75
src << %{begin}
76

    
77
x = Nokogiri::XML(File.read filename)
78
x.xpath('//project').each do |project|
79
  name = (project % 'name').content
80
  short_name = truncate_name(name, PROJECT_NAME_LENGTH, '...')
81
  id = (project % 'id').content
82
  
83
  src << %{print "About to create project #{id} ('#{short_name}')..."}
84
  src << %{  projects['#{id}'] = Project.new(:name => %{#{short_name}}, :description => "#{name} (Basecamp)", :identifier => "basecamp-p-#{id}")}
85
  src << %{  projects['#{id}'].enabled_module_names = ['issue_tracking', 'boards']}
86
  src << %{  projects['#{id}'].trackers << BASECAMP_TRACKER}
87
  src << %{  projects['#{id}'].boards << Board.new(:name => '#{short_name} (BC)', :description => "#{name}")}
88
  src << %{  projects['#{id}'].save!}
89
  src << %{puts " Saved as Issue ID " + projects['#{id}'].id.to_s}
90
  
91
  # TODO add members to project with roles
92
  # Member.create(:user => u, :project => @target_project, :roles => [role])
93
end
94

    
95
x.xpath('//todo-list').each do |todo_list|
96
  name = (todo_list % 'name').content
97
  #short_name = truncate_name(name, PROJECT_NAME_LENGTH, '...')
98
  id = (todo_list % 'id').content
99
  description = (todo_list % 'description').content
100
  parent_project_id = (todo_list % 'project-id').content
101
  complete = (todo_list % 'complete').content == 'true'
102
  
103
# Commented because we don't want Todo Lists created as Sub-Projects.  Using Sub-Tasks instead.
104
#  src << %{print "About to create todo-list #{id} ('#{short_name}') as sub-project of #{parent_project_id}..."}
105
#  src << %{  todo_lists['#{id}'] = Project.new(:name => '#{short_name} (BC)', :description => "#{name}#{description.size > 0 ? "\n\n" + description : ''}", :identifier => "basecamp-tl-#{id}")}
106
#  src << %{  todo_lists['#{id}'].enabled_module_names = ['issue_tracking']}
107
#  src << %{  todo_lists['#{id}'].trackers << BASECAMP_TRACKER}
108
#  src << %{  todo_lists['#{id}'].save!}
109
#  src << %{  projects['#{parent_project_id}'].children << todo_lists['#{id}']}
110
#  src << %{  projects['#{parent_project_id}'].save!}
111
#  src << %{puts " Saved."}
112

    
113
  src << %{print "About to create todo-list #{id} ('#{name}') as Redmine issue under project #{parent_project_id}..."}
114
  src << %{    todo_lists['#{id}'] = Issue.new :subject => '#{name}', :description => '#{description}'}
115
                #:created_on => bug.date_submitted,
116
                #:updated_on => bug.last_updated
117
  #i.author = User.find_by_id(users_map[bug.reporter_id])
118
  #i.category = IssueCategory.find_by_project_id_and_name(i.project_id, bug.category[0,30]) unless bug.category.blank?
119
  src << %{    todo_lists['#{id}'].status = #{complete} ? CLOSED_STATUS : DEFAULT_STATUS}
120
  src << %{    todo_lists['#{id}'].tracker = BASECAMP_TRACKER}
121
  src << %{    todo_lists['#{id}'].author = AUTHOR}
122
  src << %{    todo_lists['#{id}'].project = projects['#{parent_project_id}']}
123
  src << %{    todo_lists['#{id}'].save!}
124
  src << %{puts " Saved as Issue ID " + todo_lists['#{id}'].id.to_s}
125
end
126

    
127
x.xpath('//todo-item').each do |todo_item|
128
  content = (todo_item % 'content').content
129
  id = (todo_item % 'id').content
130
  parent_todo_list_id = (todo_item % 'todo-list-id').content
131
  complete = (todo_item % 'completed').content == 'true'
132
  created_at = (todo_item % 'created-at').content
133
  #completed_at = (todo_item % 'completed-at').content rescue nil
134
  
135
  src << %{print "About to create todo #{id} as Redmine sub-issue under issue #{parent_todo_list_id}..."}
136
  src << %{    todos['#{id}'] = Issue.new :subject => '#{content[0..255]}', :description => '#{content}',
137
                :created_on => '#{created_at}' }
138
                #:completed_at => '#{completed_at}'
139
  #i.category = IssueCategory.find_by_project_id_and_name(i.project_id, bug.category[0,30]) unless bug.category.blank?
140
  src << %{    todos['#{id}'].status = #{complete} ? CLOSED_STATUS : DEFAULT_STATUS}
141
  src << %{    todos['#{id}'].tracker = BASECAMP_TRACKER}
142
  src << %{    todos['#{id}'].author = AUTHOR}
143
  src << %{    todos['#{id}'].project = todo_lists['#{parent_todo_list_id}'].project}
144
  src << %{    todos['#{id}'].parent_issue_id = todo_lists['#{parent_todo_list_id}'].id}
145
  src << %{    todos['#{id}'].save!}
146
  src << %{puts " Saved as Issue ID " + todos['#{id}'].id.to_s}
147
end
148

    
149
x.xpath('//post').each do |post|
150
  # Convert some HTML tags
151
  body = (post % 'body').content.gsub(/&lt;/, '<').gsub(/&gt;/, '>').gsub(/&amp;/, '&')
152
  body.gsub!(/<div[^>]*>/, '')
153
  body.gsub!(/<\/div>/, "\n")
154
  body.gsub!(/<br ?\/?>/, "\n")
155
  
156
  title = (post % 'title').content
157
  id = (post % 'id').content
158
  parent_project_id = (post % 'project-id').content
159
  author_name = (post % 'author-name').content
160
  posted_on = (post % 'posted-on').content
161
  
162
  src << %{print "About to create post #{id} as Redmine message under project #{parent_project_id}..."}
163
  src << %{    messages['#{id}'] = Message.new :board => projects['#{parent_project_id}'].boards.first,
164
                :subject => '#{title}', :content => %{#{body}\\n\\n-- \\n#{author_name}},
165
                :created_on => '#{posted_on}', :author => AUTHOR }
166
                #:completed_at => '#{completed_at}'
167
  #src << %{    messages['#{id}'].author = AUTHOR}
168
  src << %{    messages['#{id}'].save!}
169
  src << %{puts " Saved as Message ID " + messages['#{id}'].id.to_s}
170
  
171
  post.xpath('.//comment[commentable-type = "Post"]').each do |comment|
172
    # Convert some HTML tags
173
    comment_body = (comment % 'body').content.gsub(/&lt;/, '<').gsub(/&gt;/, '>').gsub(/&amp;/, '&')
174
    comment_body.gsub!(/<div[^>]*>/, '')
175
    comment_body.gsub!(/<\/div>/, "\n")
176
    comment_body.gsub!(/<br ?\/?>/, "\n")
177
    
178
    comment_id = (comment % 'id').content
179
    parent_message_id = (comment % 'commentable-id').content
180
    comment_author_name = (comment % 'author-name').content
181
    comment_created_at = (comment % 'created-at').content
182
    
183
    src << %{print "About to create post comment #{comment_id} as Redmine sub-message under project #{parent_project_id}..."}
184
    src << %{    comments['#{comment_id}'] = Message.new :board => projects['#{parent_project_id}'].boards.first,
185
                  :subject => 'Re: #{title}', :content => %{#{comment_body}\\n\\n-- \\n#{comment_author_name}},
186
                  :created_on => '#{comment_created_at}', :author => AUTHOR, :parent => messages['#{id}'] }
187
    src << %{    comments['#{comment_id}'].save!}
188
    src << %{puts " Saved comment as Message ID " + comments['#{comment_id}'].id.to_s}
189
  end
190
end
191

    
192
  src << %{puts "\\n\\n-----------\\nUndo Script\\n-----------\\nTo undo this import, run script/console and paste in this Ruby code.  This will delete only the projects created by the import process.\\n\\n"}
193

    
194
# don't actually need to delete all the objects individually; deleting the project will cascade deletes
195
#src << %{puts '[' + journals.values.map(&:id).map(&:to_s).join(',') + '].each   { |i| Journal.destroy i }'}
196
#src << %{puts '[' + todos.values.map(&:id).map(&:to_s).join(',') + '].each      { |i| Issue.destroy i }'}
197
#src << %{puts '[' + todo_lists.values.map(&:id).map(&:to_s).join(',') + '].each { |i| Issue.destroy i }'}
198
src << %{puts '[' + projects.values.map(&:id).map(&:to_s).join(',') + '].each   { |i| Project.destroy i }'}
199

    
200
# More verbose BUT more clear...
201
#src << %{puts journals.values.map{|p| "Journal.destroy " + p.id.to_s}.join("; ")}
202
#src << %{puts todos.values.map{|p| "Issue.destroy " + p.id.to_s}.join("; ")}
203
#src << %{puts todo_lists.values.map{|p| "Issue.destroy " + p.id.to_s}.join("; ")}
204
#src << %{puts projects.values.map{|p| "Project.destroy " + p.id.to_s}.join("; ")}
205

    
206
src << %{rescue => e}
207
src << %{  file = e.backtrace.grep /\#{File.basename(__FILE__)}/}
208
src << %{  puts "\\n\\nException was raised at \#{file}; deleting all imported projects..." }
209

    
210
#src << %{  journals.each_value do |j| j.destroy unless j.new_record?; end }
211
#src << %{  todos.each_value do |t| t.destroy unless t.new_record?; end }
212
#src << %{  todo_lists.each_value do |t| t.destroy unless t.new_record?; end }
213
src << %{  projects.each_value do |p| p.destroy unless p.new_record?; end }
214

    
215
src << %{  raise e}
216
src << %{end}
217

    
218

    
219
puts src.join "\n"
220

    
221
__END__
222

    
223
-------
224
Nokogiri usage note:
225
doc.xpath('//h3/a[@class="l"]').each do |link|
226
 puts link.content
227
end
228
-------
229

    
230
The MIT License
231

    
232
Copyright (c) 2010 Ted Behling
233

    
234
Permission is hereby granted, free of charge, to any person obtaining a copy
235
of this software and associated documentation files (the "Software"), to deal
236
in the Software without restriction, including without limitation the rights
237
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
238
copies of the Software, and to permit persons to whom the Software is
239
furnished to do so, subject to the following conditions:
240

    
241
The above copyright notice and this permission notice shall be included in
242
all copies or substantial portions of the Software.
243

    
244
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
245
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
246
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
247
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
248
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
249
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
250
THE SOFTWARE.
(1-1/2)