tender_import 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # Tender Import Scripts
2
+
3
+ This is a repository of code for producing [Tender import
4
+ archives](https://help.tenderapp.com/faqs/setup-installation/importing),
5
+ for Tender customers who wish to move their discussions from another service.
6
+
7
+ The methods used to produce an import archive will vary with the service
8
+ and the facilities they provide, such as data dumps or API access. These
9
+ scripts are being made available in the hopes that they'll be useful, but
10
+ it's up to you to review them before running them on your own computer or
11
+ against an existing service. We aren't responsible if your computer blows
12
+ up or you are banned from an existing service for TOS violations, or both.
13
+
14
+ That said, if you make useful or necessary modifications to a script, or
15
+ produce a script for importing from a new service, please
16
+ [open a Tender discussion](https://help.tenderapp.com/discussions/suggestions#new_topic_form)
17
+ or send a pull request to let us know.
18
+
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env ruby -rubygems -Ilib
2
+ #
3
+ # Produce a Tender import archive by collecting tickets and discussions from
4
+ # the ZenDesk API. Requires the ZenDesk subdomain and login credentials.
5
+ #
6
+ # For more info: https://help.tenderapp.com/faqs/setup-installation/importing
7
+ #
8
+ # Usage:
9
+ # zendesk2tender -e <email> -p <password> -s <subdomain>
10
+ #
11
+ # `zendesk2tender --help' displays detailed option info.
12
+ #
13
+ # Prerequisites:
14
+ # # Ruby gems
15
+ # gem install faraday -v "~>0.4.5"
16
+ # gem install trollop
17
+ # gem install yajl-ruby
18
+ # # Python tools (must be in your PATH)
19
+ # html2text.py: # https://github.com/aaronsw/html2text
20
+ #
21
+ require 'tender_import'
22
+ TenderImport::ZendeskApiImport.run
@@ -0,0 +1,216 @@
1
+ #
2
+ # Provides a Ruby API for constructing Tender import archives.
3
+ #
4
+ # https://help.tenderapp.com/faqs/setup-installation/importing
5
+ #
6
+ # ## Example
7
+ #
8
+ # # Currently requires a site name.
9
+ # archive = TenderImport::Archive.new('tacotown')
10
+ #
11
+ # # Can add users, categories and discussions to the archive.
12
+ # archive.add_user :email => 'frank@tacotown.com', :state => 'support'
13
+ # archive.add_user :email => 'bob@bobfoo.com'
14
+ #
15
+ # # When you add a category you'll get a handle needed to add discusions.
16
+ # category = archive.add_category :name => 'Tacos'
17
+ #
18
+ # # Discussions must have at least one comment.
19
+ # archive.add_discussion category, :title => 'your tacos',
20
+ # :author_email => 'bob@bobfoo.com',
21
+ # :comments => [{
22
+ # :author_email => 'bob@bobfoo.com',
23
+ # :body => 'They are not so good.'
24
+ # }, {
25
+ # :author_email => 'frank@tacotown.com',
26
+ # :body => 'You have terrible taste in tacos. Good day, sir.'
27
+ # }]
28
+ #
29
+ # # By default, files are written as you add them, so this will just
30
+ # # assemble a gzipped tar archive from those files.
31
+ # filename = archive.write_archive
32
+ # puts "your import file is #{filename}"
33
+ #
34
+ # # If any errors are reported, some records were not included in the archive.
35
+ # if !archive.report.empty?
36
+ # puts "Problems reported: ", *archive.report
37
+ # end
38
+ #
39
+ require 'yajl'
40
+ require 'fileutils'
41
+ class TenderImport::Archive
42
+ class Error < StandardError; end
43
+ include FileUtils
44
+ attr_reader :site, :report, :stats, :buffer
45
+
46
+ # Options:
47
+ #
48
+ # buffer:: When true, don't flush to disk until the end. Defaults to false.
49
+ #
50
+ def initialize site_name, options = {}
51
+ @site = site_name
52
+ @export_dir = ".#{site_name}-export-#{$$}"
53
+ @report = []
54
+ @import = {}
55
+ @stats = {}
56
+ @buffer = options.key?(:buffer) ? !!options[:buffer] : false
57
+ @category_counter = Hash.new(0)
58
+ end
59
+
60
+ # Returns the params on success, nil on failure
61
+ def add_user params
62
+ validate_and_store :user, {:state => 'user'}.merge(params)
63
+ end
64
+
65
+ # Returns a handle needed for adding discussions
66
+ def add_category params
67
+ cat = validate_and_store :category, params
68
+ cat ? category_key(cat) : nil
69
+ end
70
+
71
+ def add_discussion category_key, params
72
+ raise Error, "add_discussion: missing category key" if category_key.nil?
73
+ validate_and_store :discussion, params, :key => category_key
74
+ end
75
+
76
+ def category_key cat
77
+ "category:#{category_id cat}".downcase
78
+ end
79
+
80
+ def category_id cat
81
+ cat[:name].gsub(/\W+/,'_').downcase
82
+ end
83
+
84
+ def categories
85
+ @import[:category]
86
+ end
87
+
88
+ def discussions category_key
89
+ raise Error, "discussions: missing category key" if category_key.nil?
90
+ @import[category_key] || []
91
+ end
92
+
93
+ def users
94
+ @import[:user]
95
+ end
96
+
97
+ def write_archive
98
+ write_users if users
99
+ write_categories_and_discussions if categories
100
+ export_file = "export_#{site}.tgz"
101
+ system "tar -zcf #{export_file} -C #{export_dir} ."
102
+ system "rm -rf #{export_dir}"
103
+ return export_file
104
+ end
105
+
106
+ def write_user user
107
+ return unless user
108
+ mkdir_p export_dir('users')
109
+ File.open(File.join(export_dir('users'), "#{user[:email].gsub(/\W+/,'_')}.json"), "w") do |file|
110
+ file.puts Yajl::Encoder.encode(user)
111
+ end
112
+ end
113
+
114
+ def write_users
115
+ users.each do |u|
116
+ write_user u
117
+ end
118
+ end
119
+
120
+ def write_category c
121
+ mkdir_p export_dir('categories')
122
+ File.open(File.join(export_dir('categories'), "#{category_id(c)}.json"), "w") do |file|
123
+ file.puts Yajl::Encoder.encode(c)
124
+ end
125
+ end
126
+
127
+ def write_categories_and_discussions
128
+ categories.each do |c|
129
+ write_category c
130
+ write_discussions c
131
+ end
132
+ end
133
+
134
+ def write_discussion category_id, discussion
135
+ @category_counter[category_id] += 1
136
+ dir = File.join(export_dir('categories'), category_id)
137
+ mkdir_p dir
138
+ File.open(File.join(dir, "#{@category_counter[category_id]}.json"), "w") do |file|
139
+ file.puts Yajl::Encoder.encode(discussion)
140
+ end
141
+ end
142
+
143
+ def write_discussions category
144
+ discussions(category_key(category)).each do |d|
145
+ write_discussion category_id(category), d
146
+ end
147
+ end
148
+
149
+ protected
150
+
151
+ def validate_and_store *args
152
+ type, params, options = args
153
+ options ||= {}
154
+ key = options[:key] || type
155
+ @import[key] ||= []
156
+ if valid? type, params
157
+ if buffer
158
+ # save in memory and flush to disk at the end
159
+ @import[key] << params
160
+ else
161
+ # write immediately instead of storing in memory
162
+ write *args
163
+ end
164
+ @stats[key] ||= 0
165
+ @stats[key] += 1
166
+ params
167
+ else
168
+ @stats["invalid:#{key}"] ||= 0
169
+ @stats["invalid:#{key}"] += 1
170
+ nil
171
+ end
172
+ end
173
+
174
+ def write type, params, options = {}
175
+ case type
176
+ when :discussion
177
+ # ughh
178
+ write_discussion options[:key].split(':',2)[1], params
179
+ when :category
180
+ write_category params
181
+ when :user
182
+ write_user params
183
+ end
184
+ end
185
+
186
+ def valid? type, params
187
+ problems = []
188
+ # XXX this is not really enough validation, also it's ugly as fuck
189
+ if type == :user && (params[:email].nil? || params[:email].empty?)
190
+ problems << "Missing email in user data: #{params.inspect}."
191
+ end
192
+ if type == :user && !%w[user support].include?(params[:state])
193
+ problems << "Invalid state in user data: #{params.inspect}."
194
+ end
195
+ if type == :category && (params[:name].nil? || params[:name].empty?)
196
+ problems << "Missing name in category data: #{params.inspect}."
197
+ end
198
+ if type == :discussion && (params[:author_email].nil? || params[:author_email].empty?)
199
+ problems << "Missing author_email in discussion data: #{params.inspect}."
200
+ end
201
+ if type == :discussion && (params[:comments].nil? || params[:comments].any? {|c| c[:author_email].nil? || c[:author_email].empty?})
202
+ problems << "Missing comments and authors in discussion data: #{params.inspect}."
203
+ end
204
+ if problems.empty?
205
+ true
206
+ else
207
+ @report += problems
208
+ false
209
+ end
210
+ end
211
+
212
+ def export_dir subdir=nil
213
+ subdir.nil? ? @export_dir : File.join(@export_dir, subdir.to_s)
214
+ end
215
+
216
+ end
@@ -0,0 +1,4 @@
1
+ module TenderImport
2
+ VERSION = '0.0.1'
3
+ end
4
+
@@ -0,0 +1,307 @@
1
+ require 'yajl'
2
+ require 'faraday'
3
+ require 'trollop'
4
+ require 'fileutils'
5
+ require 'logger'
6
+
7
+ # Produce a Tender import archive from a ZenDesk site using the ZenDesk API.
8
+ class TenderImport::ZendeskApiImport
9
+ class Error < StandardError; end
10
+ class ResponseJSON < Faraday::Response::Middleware
11
+ def parse(body)
12
+ Yajl::Parser.parse(body)
13
+ end
14
+ end
15
+
16
+ module Log # {{{
17
+ attr_reader :logger
18
+ def log string
19
+ logger.info "#{to_s}: #{string}"
20
+ end
21
+
22
+ def debug string
23
+ logger.debug "#{to_s}: #{string}"
24
+ end
25
+ end # }}}
26
+
27
+ class Client # {{{
28
+ include Log
29
+ attr_reader :opts, :conn, :subdomain
30
+
31
+ # If no options are provided they will be obtained from the command-line.
32
+ #
33
+ # The options are subdomain, email and password.
34
+ #
35
+ # There is also an optional logger option, for use with Ruby/Rails
36
+ def initialize(options = nil)
37
+ @opts = options || command_line_options
38
+ @subdomain = opts[:subdomain]
39
+ @logger = opts[:logger] || Logger.new(STDOUT).tap {|l| l.level = Logger::INFO}
40
+ @conn = Faraday::Connection.new("http://#{subdomain}.zendesk.com") do |b|
41
+ b.adapter :net_http
42
+ b.use ResponseJSON
43
+ end
44
+ conn.basic_auth(opts[:email], opts[:password])
45
+ end
46
+
47
+ def to_s
48
+ "#{self.class.name} (#{subdomain})"
49
+ end
50
+
51
+ # API helpers # {{{
52
+ def user user_id
53
+ fetch_resource("users/#{user_id}.json")
54
+ end
55
+
56
+ def users
57
+ fetch_paginated_resources("users.json?page=%d")
58
+ end
59
+
60
+ def forums
61
+ fetch_resource("forums.json")
62
+ end
63
+
64
+ def entries forum_id
65
+ fetch_paginated_resources("forums/#{forum_id}/entries.json?page=%d")
66
+ end
67
+
68
+ def posts entry_id
69
+ fetch_paginated_resources("entries/#{entry_id}/posts.json?page=%d", 'posts')
70
+ end
71
+
72
+ def open_tickets
73
+ fetch_paginated_resources("search.json?query=type:ticket+status:open+status:pending+status:new&page=%d")
74
+ end
75
+ # }}}
76
+
77
+ protected # {{{
78
+
79
+ # Fetch every page of a given resource. Must provide a sprintf format string
80
+ # with a single integer for the page specification.
81
+ #
82
+ # Example: "users.json?page=%d"
83
+ #
84
+ # In some cases the desired data is not in the top level of the payload. In
85
+ # that case specify resource_key to pull the data from that key.
86
+ def fetch_resource resource_url, resource_key = nil
87
+ debug "fetching #{resource_url}"
88
+ loop do
89
+ response = conn.get(resource_url)
90
+ if response.success?
91
+ return resource_key ? response.body[resource_key] : response.body
92
+ elsif response.status == 503
93
+ log "got a 503 (API throttle), waiting 30 seconds..."
94
+ sleep 30
95
+ else
96
+ raise Error, "failed to get resource #{resource_format}: #{response.inspect}"
97
+ end
98
+ end
99
+ end
100
+
101
+ # Fetch every page of a given resource. Must provide a sprintf format string
102
+ # with a single integer for the page specification.
103
+ #
104
+ # Example: "users.json?page=%d"
105
+ #
106
+ # In some cases the desired data is not in the top level of the payload. In
107
+ # that case specify resource_key to pull the data from that key.
108
+ def fetch_paginated_resources resource_format, resource_key = nil
109
+ resources = []
110
+ page = 1
111
+ loop do
112
+ resource = fetch_resource(resource_format % page, resource_key)
113
+ break if resource.empty?
114
+ page += 1
115
+ resources += resource
116
+ end
117
+ resources
118
+ end
119
+
120
+ def command_line_options
121
+ options = Trollop::options do
122
+ banner <<-EOM
123
+ Usage:
124
+ #{$0} -e <email> -p <password> -s <subdomain>
125
+
126
+ Prerequisites:
127
+ # Ruby gems
128
+ gem install faraday -v "~>0.4.5"
129
+ gem install trollop
130
+ gem install yajl-ruby
131
+ # Python tools (must be in your PATH)
132
+ html2text.py: http://www.aaronsw.com/2002/html2text/
133
+
134
+ Options:
135
+ EOM
136
+ opt :email, "user email address", :type => String
137
+ opt :password, "user password", :type => String
138
+ opt :subdomain, "subdomain", :type => String
139
+ end
140
+
141
+ [:email, :password, :subdomain ].each do |option|
142
+ Trollop::die option, "is required" if options[option].nil?
143
+ end
144
+ return options
145
+ end
146
+ # }}}
147
+
148
+ end # }}}
149
+
150
+ class Exporter # {{{
151
+ attr_reader :logger, :client
152
+ include Log
153
+ include FileUtils
154
+
155
+ def initialize client
156
+ @client = client
157
+ @author_email = {}
158
+ @logger = client.logger
159
+ @archive = TenderImport::Archive.new(client.subdomain)
160
+ if `which html2text.py`.empty?
161
+ raise Error, 'missing prerequisite: html2text.py is not in your PATH'
162
+ end
163
+ end
164
+
165
+ def to_s
166
+ "#{self.class.name} (#{client.subdomain})"
167
+ end
168
+
169
+ def stats
170
+ @archive.stats
171
+ end
172
+
173
+ def report
174
+ @archive.report
175
+ end
176
+
177
+ def export_users # {{{
178
+ log 'exporting users'
179
+ client.users.each do |user|
180
+ @author_email[user['id'].to_s] = user['email']
181
+ log "exporting user #{user['email']}"
182
+ @archive.add_user \
183
+ :name => user['name'],
184
+ :email => user['email'],
185
+ :created_at => user['created_at'],
186
+ :updated_at => user['updated_at'],
187
+ :state => (user['roles'].to_i == 0 ? 'user' : 'support')
188
+ end
189
+ end # }}}
190
+
191
+ def export_categories # {{{
192
+ log 'exporting categories'
193
+ client.forums.each do |forum|
194
+ log "exporting category #{forum['name']}"
195
+ category = @archive.add_category \
196
+ :name => forum['name'],
197
+ :summary => forum['description']
198
+ export_discussions(forum['id'], category)
199
+ end
200
+ end # }}}
201
+
202
+ def export_tickets # {{{
203
+ log "exporting open tickets"
204
+ tickets = client.open_tickets
205
+ if tickets.size > 0
206
+ # create category for tickets
207
+ log "creating ticket category"
208
+ category = @archive.add_category \
209
+ :name => 'Tickets',
210
+ :summary => 'Imported from ZenDesk.'
211
+ # export tickets into new category
212
+ tickets.each do |ticket|
213
+ comments = ticket['comments'].map do |post|
214
+ {
215
+ :body => post['value'],
216
+ :author_email => author_email(post['author_id']),
217
+ :created_at => post['created_at'],
218
+ :updated_at => post['updated_at'],
219
+ }
220
+ end
221
+ log "exporting ticket #{ticket['nice_id']}"
222
+ @archive.add_discussion category,
223
+ :title => ticket['subject'],
224
+ :author_email => author_email(ticket['submitter_id']),
225
+ :created_at => ticket['created_at'],
226
+ :updated_at => ticket['updated_at'],
227
+ :comments => comments
228
+ end
229
+ end
230
+ end # }}}
231
+
232
+ def export_discussions forum_id, category # {{{
233
+ client.entries(forum_id).each do |entry|
234
+ comments = client.posts(entry['id']).map do |post|
235
+ dump_body post, post['body']
236
+ {
237
+ :body => load_body(entry),
238
+ :author_email => author_email(post['user_id']),
239
+ :created_at => post['created_at'],
240
+ :updated_at => post['updated_at'],
241
+ }
242
+ end
243
+ dump_body entry, entry['body']
244
+ log "exporting discussion #{entry['title']}"
245
+ @archive.add_discussion category,
246
+ :title => entry['title'],
247
+ :author_email => author_email(entry['submitter_id']),
248
+ :comments => [{
249
+ :body => load_body(entry),
250
+ :author_email => author_email(entry['submitter_id']),
251
+ :created_at => entry['created_at'],
252
+ :updated_at => entry['updated_at'],
253
+ }] + comments
254
+ rm "tmp/#{entry['id']}_body.html"
255
+ end
256
+ end # }}}
257
+
258
+ def create_archive # {{{
259
+ export_file = @archive.write_archive
260
+ log "created #{export_file}"
261
+ end # }}}
262
+
263
+ protected
264
+
265
+ def author_email user_id
266
+ # the cache should be populated during export_users but we'll attempt
267
+ # to fetch unrecognized ids just in case
268
+ @author_email[user_id.to_s] ||= (client.user(user_id)['email'] rescue nil)
269
+ end
270
+
271
+ def dump_body entry, body
272
+ File.open(File.join("tmp", "#{entry['id']}_body.html"), "w") do |file|
273
+ file.write(body)
274
+ end
275
+ end
276
+
277
+ def load_body entry
278
+ `html2text.py /$PWD/tmp/#{entry['id']}_body.html`
279
+ end
280
+
281
+ end # }}}
282
+
283
+ # Produce a complete import archive either from API or command line options.
284
+ def self.run options=nil
285
+ begin
286
+ client = Client.new options
287
+ exporter = Exporter.new client
288
+ exporter.export_users
289
+ exporter.export_categories
290
+ exporter.export_tickets
291
+ exporter.create_archive
292
+ rescue Error => e
293
+ puts "FAILED WITH AN ERROR"
294
+ puts e.to_s
295
+ exit 1
296
+ ensure
297
+ if exporter
298
+ puts "RESULTS"
299
+ puts exporter.stats.inspect
300
+ puts exporter.report.join("\n")
301
+ end
302
+ end
303
+ end
304
+
305
+ end
306
+
307
+ # vi:foldmethod=marker
@@ -0,0 +1,4 @@
1
+ module TenderImport
2
+ autoload :Archive, 'tender_import/archive'
3
+ autoload :ZendeskApiImport, 'tender_import/zendesk_api_import'
4
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tender_import
3
+ version: !ruby/object:Gem::Version
4
+ hash: 29
5
+ prerelease:
6
+ segments:
7
+ - 0
8
+ - 0
9
+ - 1
10
+ version: 0.0.1
11
+ platform: ruby
12
+ authors:
13
+ - Zack Hobson
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-08-03 00:00:00 -07:00
19
+ default_executable:
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: faraday
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 3
30
+ segments:
31
+ - 0
32
+ version: "0"
33
+ type: :runtime
34
+ version_requirements: *id001
35
+ - !ruby/object:Gem::Dependency
36
+ name: trollop
37
+ prerelease: false
38
+ requirement: &id002 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ hash: 3
44
+ segments:
45
+ - 0
46
+ version: "0"
47
+ type: :runtime
48
+ version_requirements: *id002
49
+ - !ruby/object:Gem::Dependency
50
+ name: yajl-ruby
51
+ prerelease: false
52
+ requirement: &id003 !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ hash: 3
58
+ segments:
59
+ - 0
60
+ version: "0"
61
+ type: :runtime
62
+ version_requirements: *id003
63
+ description: |
64
+ These are tools written in Ruby to support importing data into Tender.
65
+
66
+ For more information:
67
+
68
+ https://help.tenderapp.com/kb/setup-installation/importing
69
+
70
+ email: zack@zackhobson.com
71
+ executables:
72
+ - zendesk2tender
73
+ extensions: []
74
+
75
+ extra_rdoc_files: []
76
+
77
+ files:
78
+ - README.md
79
+ - lib/tender_import/archive.rb
80
+ - lib/tender_import/version.rb
81
+ - lib/tender_import/zendesk_api_import.rb
82
+ - lib/tender_import.rb
83
+ - bin/zendesk2tender
84
+ has_rdoc: true
85
+ homepage: https://help.tenderapp.com/kb/setup-installation/importing
86
+ licenses: []
87
+
88
+ post_install_message:
89
+ rdoc_options: []
90
+
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ none: false
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ hash: 3
99
+ segments:
100
+ - 0
101
+ version: "0"
102
+ required_rubygems_version: !ruby/object:Gem::Requirement
103
+ none: false
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ hash: 3
108
+ segments:
109
+ - 0
110
+ version: "0"
111
+ requirements: []
112
+
113
+ rubyforge_project:
114
+ rubygems_version: 1.4.1
115
+ signing_key:
116
+ specification_version: 3
117
+ summary: Tools for producing Tender import archives.
118
+ test_files: []
119
+