Project

General

Profile

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

Updated version of the basecamp2redmine.rb script. See above for usage details. - Ted Behling, 2010-11-21 20:55

 
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
# CHANGELOG
31
# 2010-08-23 Initial public release
32
# 2010-11-21 Applied bugfix to properly escape quotes
33
#
34
# Thanks to Tactio Interaction Design (www.tactio.com.br) for funding this work!
35
#
36
# See MIT License below.  You are not required to provide the author with changes
37
# you make to this software, but it would be appreciated, as a courtesy, if it is possible.
38
#
39
# LEGAL NOTE:
40
# The Basecamp name is a registered trademark of 37 Signals, LLC.  Use of this trademark
41
# is for reference only, and does not imply any relationship or affiliation with or endorsement
42
# from or by 37 Signals, LLC.
43
# Ted Behling, the author of this script, has no affiliation with 37 Signals, LLC.
44
# All source code contained in this file is the original work of Ted Behling.
45
# Product names, logos, brands, and other trademarks featured or referred to within
46
# this software are the property of their respective trademark holders.
47
# 37 Signals does not sponsor or endorse this script or its author.
48
#
49
# DHH, please don't sue me for trademark infringement.  I don't have anything you'd want anyway.
50
#
51

    
52
require 'rubygems'
53
require 'nokogiri'
54

    
55
PROJECT_NAME_LENGTH = 30 - 5
56
TRACKER = 'Basecamp Todo'
57

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

    
60
def truncate_name(name, length, ellipsis)
61
  name.size <= length ? name : name[0..length / 2 - 1] + ellipsis + name[-(length/2 - 2)..-1]
62
end
63

    
64
# Hack Nokogiri to escape our curly braces for us
65
# This script delimits strings with curly braces so it's a little easier to think about quoting in our generated code
66
module Nokogiri
67
  module XML
68
    class Node
69
      alias :my_original_content :content
70
      def content(*args)
71
        # Escape { and } with \
72
        my_original_content(*args).gsub(/\{|\}/, '\\\\\0')
73
      end
74
    end
75
  end
76
end
77

    
78
src = []
79
src << %{projects = {}}
80
src << %{todo_lists = {}} # Todo lists are actually tasks that have sub-tasks --- was sub-projects
81
src << %{todos = {}}
82
src << %{journals = {}}
83
src << %{messages = {}}
84
src << %{comments = {}}
85

    
86
src << %{BASECAMP_TRACKER = Tracker.find_by_name '#{TRACKER}'}
87
src << %{raise "Tracker named '#{TRACKER}' must exist" unless BASECAMP_TRACKER}
88

    
89
src << %{DEFAULT_STATUS = IssueStatus.default}
90
src << %{CLOSED_STATUS = IssueStatus.find :first, :conditions => { :is_closed => true }}
91
src << %{AUTHOR = User.anonymous  #User.find 1}
92

    
93
src << %{begin}
94

    
95
x = Nokogiri::XML(File.read filename)
96
x.xpath('//project').each do |project|
97
  name = (project % 'name').content
98
  short_name = truncate_name(name, PROJECT_NAME_LENGTH, '...')
99
  id = (project % 'id').content
100
  
101
  src << %{print "About to create project #{id} ('#{short_name}')..."}
102
  src << %{  projects['#{id}'] = Project.new(:name => %{#{short_name}}, :description => %{#{name} (Basecamp)}, :identifier => "basecamp-p-#{id}")}
103
  src << %{  projects['#{id}'].enabled_module_names = ['issue_tracking', 'boards']}
104
  src << %{  projects['#{id}'].trackers << BASECAMP_TRACKER}
105
  src << %{  projects['#{id}'].boards << Board.new(:name => %{#{short_name} (BC)}, :description => %{#{name}})}
106
  src << %{  projects['#{id}'].save!}
107
  src << %{puts " Saved as Issue ID " + projects['#{id}'].id.to_s}
108
  
109
  # TODO add members to project with roles
110
  # Member.create(:user => u, :project => @target_project, :roles => [role])
111
end
112

    
113
x.xpath('//todo-list').each do |todo_list|
114
  name = (todo_list % 'name').content
115
  #short_name = truncate_name(name, PROJECT_NAME_LENGTH, '...')
116
  id = (todo_list % 'id').content
117
  description = (todo_list % 'description').content
118
  parent_project_id = (todo_list % 'project-id').content
119
  complete = (todo_list % 'complete').content == 'true'
120
  
121
# Commented because we don't want Todo Lists created as Sub-Projects.  Using Sub-Tasks instead.
122
#  src << %{print "About to create todo-list #{id} ('#{short_name}') as sub-project of #{parent_project_id}..."}
123
#  src << %{  todo_lists['#{id}'] = Project.new(:name => '#{short_name} (BC)', :description => "#{name}#{description.size > 0 ? "\n\n" + description : ''}", :identifier => "basecamp-tl-#{id}")}
124
#  src << %{  todo_lists['#{id}'].enabled_module_names = ['issue_tracking']}
125
#  src << %{  todo_lists['#{id}'].trackers << BASECAMP_TRACKER}
126
#  src << %{  todo_lists['#{id}'].save!}
127
#  src << %{  projects['#{parent_project_id}'].children << todo_lists['#{id}']}
128
#  src << %{  projects['#{parent_project_id}'].save!}
129
#  src << %{puts " Saved."}
130

    
131
  src << %{print "About to create todo-list #{id} ('#{name}') as Redmine issue under project #{parent_project_id}..."}
132
  src << %{    todo_lists['#{id}'] = Issue.new :subject => %{#{name}}, :description => %{#{description}}}
133
                #:created_on => bug.date_submitted,
134
                #:updated_on => bug.last_updated
135
  #i.author = User.find_by_id(users_map[bug.reporter_id])
136
  #i.category = IssueCategory.find_by_project_id_and_name(i.project_id, bug.category[0,30]) unless bug.category.blank?
137
  src << %{    todo_lists['#{id}'].status = #{complete} ? CLOSED_STATUS : DEFAULT_STATUS}
138
  src << %{    todo_lists['#{id}'].tracker = BASECAMP_TRACKER}
139
  src << %{    todo_lists['#{id}'].author = AUTHOR}
140
  src << %{    todo_lists['#{id}'].project = projects['#{parent_project_id}']}
141
  src << %{    todo_lists['#{id}'].save!}
142
  src << %{puts " Saved as Issue ID " + todo_lists['#{id}'].id.to_s}
143
end
144

    
145
x.xpath('//todo-item').each do |todo_item|
146
  content = (todo_item % 'content').content
147
  id = (todo_item % 'id').content
148
  parent_todo_list_id = (todo_item % 'todo-list-id').content
149
  complete = (todo_item % 'completed').content == 'true'
150
  created_at = (todo_item % 'created-at').content
151
  #completed_at = (todo_item % 'completed-at').content rescue nil
152
  
153
  src << %{print "About to create todo #{id} as Redmine sub-issue under issue #{parent_todo_list_id}..."}
154
  src << %{    todos['#{id}'] = Issue.new :subject => %{#{content[0..255]}}, :description => %{#{content}},
155
                :created_on => '#{created_at}' }
156
                #:completed_at => '#{completed_at}'
157
  #i.category = IssueCategory.find_by_project_id_and_name(i.project_id, bug.category[0,30]) unless bug.category.blank?
158
  src << %{    todos['#{id}'].status = #{complete} ? CLOSED_STATUS : DEFAULT_STATUS}
159
  src << %{    todos['#{id}'].tracker = BASECAMP_TRACKER}
160
  src << %{    todos['#{id}'].author = AUTHOR}
161
  src << %{    todos['#{id}'].project = todo_lists['#{parent_todo_list_id}'].project}
162
  src << %{    todos['#{id}'].parent_issue_id = todo_lists['#{parent_todo_list_id}'].id}
163
  src << %{    todos['#{id}'].save!}
164
  src << %{puts " Saved as Issue ID " + todos['#{id}'].id.to_s}
165
end
166

    
167
x.xpath('//post').each do |post|
168
  # Convert some HTML tags
169
  body = (post % 'body').content.gsub(/&lt;/, '<').gsub(/&gt;/, '>').gsub(/&amp;/, '&')
170
  body.gsub!(/<div[^>]*>/, '')
171
  body.gsub!(/<\/div>/, "\n")
172
  body.gsub!(/<br ?\/?>/, "\n")
173
  
174
  title = (post % 'title').content
175
  id = (post % 'id').content
176
  parent_project_id = (post % 'project-id').content
177
  author_name = (post % 'author-name').content
178
  posted_on = (post % 'posted-on').content
179
  
180
  src << %{print "About to create post #{id} as Redmine message under project #{parent_project_id}..."}
181
  src << %{    messages['#{id}'] = Message.new :board => projects['#{parent_project_id}'].boards.first,
182
                :subject => %{#{title}}, :content => %{#{body}\\n\\n-- \\n#{author_name}},
183
                :created_on => '#{posted_on}', :author => AUTHOR }
184
                #:completed_at => '#{completed_at}'
185
  #src << %{    messages['#{id}'].author = AUTHOR}
186
  src << %{    messages['#{id}'].save!}
187
  src << %{puts " Saved as Message ID " + messages['#{id}'].id.to_s}
188
  
189
  post.xpath('.//comment[commentable-type = "Post"]').each do |comment|
190
    # Convert some HTML tags
191
    comment_body = (comment % 'body').content.gsub(/&lt;/, '<').gsub(/&gt;/, '>').gsub(/&amp;/, '&')
192
    comment_body.gsub!(/<div[^>]*>/, '')
193
    comment_body.gsub!(/<\/div>/, "\n")
194
    comment_body.gsub!(/<br ?\/?>/, "\n")
195
    
196
    comment_id = (comment % 'id').content
197
    parent_message_id = (comment % 'commentable-id').content
198
    comment_author_name = (comment % 'author-name').content
199
    comment_created_at = (comment % 'created-at').content
200
    
201
    src << %{print "About to create post comment #{comment_id} as Redmine sub-message under project #{parent_project_id}..."}
202
    src << %{    comments['#{comment_id}'] = Message.new :board => projects['#{parent_project_id}'].boards.first,
203
                  :subject => %{Re: #{title}}, :content => %{#{comment_body}\\n\\n-- \\n#{comment_author_name}},
204
                  :created_on => '#{comment_created_at}', :author => AUTHOR, :parent => messages['#{id}'] }
205
    src << %{    comments['#{comment_id}'].save!}
206
    src << %{puts " Saved comment as Message ID " + comments['#{comment_id}'].id.to_s}
207
  end
208
end
209

    
210
  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"}
211

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

    
218
# More verbose BUT more clear...
219
#src << %{puts journals.values.map{|p| "Journal.destroy " + p.id.to_s}.join("; ")}
220
#src << %{puts todos.values.map{|p| "Issue.destroy " + p.id.to_s}.join("; ")}
221
#src << %{puts todo_lists.values.map{|p| "Issue.destroy " + p.id.to_s}.join("; ")}
222
#src << %{puts projects.values.map{|p| "Project.destroy " + p.id.to_s}.join("; ")}
223

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

    
228
#src << %{  journals.each_value do |j| j.destroy unless j.new_record?; end }
229
#src << %{  todos.each_value do |t| t.destroy unless t.new_record?; end }
230
#src << %{  todo_lists.each_value do |t| t.destroy unless t.new_record?; end }
231
src << %{  projects.each_value do |p| p.destroy unless p.new_record?; end }
232

    
233
src << %{  raise e}
234
src << %{end}
235

    
236

    
237
puts src.join "\n"
238

    
239
__END__
240

    
241
-------
242
Nokogiri usage note:
243
doc.xpath('//h3/a[@class="l"]').each do |link|
244
 puts link.content
245
end
246
-------
247

    
248
The MIT License
249

    
250
Copyright (c) 2010 Ted Behling
251

    
252
Permission is hereby granted, free of charge, to any person obtaining a copy
253
of this software and associated documentation files (the "Software"), to deal
254
in the Software without restriction, including without limitation the rights
255
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
256
copies of the Software, and to permit persons to whom the Software is
257
furnished to do so, subject to the following conditions:
258

    
259
The above copyright notice and this permission notice shall be included in
260
all copies or substantial portions of the Software.
261

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