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(/</, '<').gsub(/>/, '>').gsub(/&/, '&')
|
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(/</, '<').gsub(/>/, '>').gsub(/&/, '&')
|
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.
|