tender_import 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+