stackbuilders-campfire_export 0.4.0

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.
@@ -0,0 +1,78 @@
1
+ module CampfireExport
2
+ class Upload
3
+ include CampfireExport::IO
4
+ attr_accessor :message, :room, :date, :id, :filename, :content_type, :byte_size, :full_url
5
+
6
+ def initialize(message)
7
+ @message = message
8
+ @room = message.room
9
+ @date = message.date
10
+ @deleted = false
11
+ end
12
+
13
+ def deleted?
14
+ @deleted
15
+ end
16
+
17
+ def is_image?
18
+ content_type.start_with?("image/")
19
+ end
20
+
21
+ def upload_dir
22
+ "uploads/#{id}"
23
+ end
24
+
25
+ # Image thumbnails are used to inline image uploads in HTML transcripts.
26
+ def thumb_dir
27
+ "thumbs/#{id}"
28
+ end
29
+
30
+ def export
31
+ begin
32
+ log(:info, " #{message.body} ... ")
33
+
34
+ # Get the upload object corresponding to this message.
35
+ upload_path = "/room/#{room.id}/messages/#{message.id}/upload.xml"
36
+ upload = Nokogiri::XML get(upload_path).body
37
+
38
+ # Get the upload itself and export it.
39
+ @id = upload.xpath('/upload/id').text
40
+ @byte_size = upload.xpath('/upload/byte-size').text.to_i
41
+ @content_type = upload.xpath('/upload/content-type').text
42
+ @filename = upload.xpath('/upload/name').text
43
+ @full_url = upload.xpath('/upload/full-url').text
44
+
45
+ export_content(upload_dir)
46
+ export_content(thumb_dir, path_component="thumb/#{id}", verify=false) if is_image?
47
+
48
+ log(:info, "ok\n")
49
+ rescue CampfireExport::Exception => e
50
+ if e.code == 404
51
+ # If the upload 404s, that should mean it was subsequently deleted.
52
+ @deleted = true
53
+ log(:info, "deleted\n")
54
+ else
55
+ raise e
56
+ end
57
+ end
58
+ end
59
+
60
+ def export_content(content_dir, path_component=nil, verify=true)
61
+ # If the export directory name is different than the URL path component,
62
+ # the caller can define the path_component separately.
63
+ path_component ||= content_dir
64
+
65
+ # Write uploads to a subdirectory, using the upload ID as a directory
66
+ # name to avoid overwriting multiple uploads of the same file within
67
+ # the same day (for instance, if 'Picture 1.png' is uploaded twice
68
+ # in a day, this will preserve both copies). This path pattern also
69
+ # matches the tail of the upload path in the HTML transcript, making
70
+ # it easier to make downloads functional from the HTML transcripts.
71
+ content_path = "/room/#{room.id}/#{path_component}/#{CGI.escape(filename)}"
72
+ content = get(content_path).body
73
+ FileUtils.mkdir_p(File.join(export_dir, content_dir))
74
+ export_file(content, "#{content_dir}/#{filename}", 'wb')
75
+ verify_export("#{content_dir}/#{filename}", byte_size) if verify
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,3 @@
1
+ module CampfireExport
2
+ VERSION = "0.4.0"
3
+ end
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Portions copyright 2011 Marc Hedlund <marc@precipice.org>.
4
+ # Adapted from https://gist.github.com/821553 and ancestors.
5
+
6
+ # Licensed under the Apache License, Version 2.0 (the "License");
7
+ # you may not use this file except in compliance with the License.
8
+ # You may obtain a copy of the License at
9
+ #
10
+ # http://www.apache.org/licenses/LICENSE-2.0
11
+ #
12
+ # Unless required by applicable law or agreed to in writing, software
13
+ # distributed under the License is distributed on an "AS IS" BASIS,
14
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ # See the License for the specific language governing permissions and
16
+ # limitations under the License.
17
+
18
+ # campfire_export.rb -- export Campfire transcripts and uploaded files.
19
+ #
20
+ # Since Campfire (www.campfirenow.com) doesn't provide an export feature,
21
+ # this script implements one via the Campfire API.
22
+
23
+ require 'rubygems'
24
+
25
+ require 'cgi'
26
+ require 'fileutils'
27
+ require 'httparty'
28
+ require 'nokogiri'
29
+ require 'retryable'
30
+ require 'time'
31
+ require 'yaml'
32
+
33
+ module CampfireExport
34
+ module IO
35
+ MAX_RETRIES = 5
36
+
37
+ def api_url(path)
38
+ "#{CampfireExport::Account.base_url}#{path}"
39
+ end
40
+
41
+ def get(path, params = {})
42
+ url = api_url(path)
43
+
44
+ response = Retryable.retryable(:tries => MAX_RETRIES) do |retries, exception|
45
+ if retries > 0
46
+ msg = "Attempt ##{retries} to fetch #{path} failed, " +
47
+ "#{MAX_RETRIES - retries} attempts remaining"
48
+ log :error, msg, exception
49
+ end
50
+
51
+ HTTParty.get(url, :query => params, :basic_auth =>
52
+ {:username => CampfireExport::Account.api_token, :password => 'X'})
53
+ end
54
+
55
+ if response.code >= 400
56
+ raise CampfireExport::Exception.new(url, response.message, response.code)
57
+ end
58
+ response
59
+ end
60
+
61
+ def zero_pad(number)
62
+ "%02d" % number
63
+ end
64
+
65
+ # Requires that room and date be defined in the calling object.
66
+ def export_dir
67
+ "campfire/#{Account.subdomain}/#{room.name}/" +
68
+ "#{date.year}/#{zero_pad(date.mon)}/#{zero_pad(date.day)}"
69
+ end
70
+
71
+ # Requires that room_name and date be defined in the calling object.
72
+ def export_file(content, filename, mode='w')
73
+ # Check to make sure we're writing into the target directory tree.
74
+ true_path = File.expand_path(File.join(export_dir, filename))
75
+
76
+ unless true_path.start_with?(File.expand_path(export_dir))
77
+ raise CampfireExport::Exception.new("#{export_dir}/#{filename}",
78
+ "can't export file to a directory higher than target directory; " +
79
+ "expected: #{File.expand_path(export_dir)}, actual: #{true_path}.")
80
+ end
81
+
82
+ if File.exists?("#{export_dir}/#{filename}")
83
+ log(:error, "#{export_dir}/#{filename} failed: file already exists")
84
+ else
85
+ open("#{export_dir}/#{filename}", mode) do |file|
86
+ file.write content
87
+ end
88
+ end
89
+ end
90
+
91
+ def verify_export(filename, expected_size)
92
+ full_path = "#{export_dir}/#{filename}"
93
+ unless File.exists?(full_path)
94
+ raise CampfireExport::Exception.new(full_path,
95
+ "file should have been exported but did not make it to disk")
96
+ end
97
+ unless File.size(full_path) == expected_size
98
+ raise CampfireExport::Exception.new(full_path,
99
+ "exported file exists but is not the right size " +
100
+ "(expected: #{expected_size}, actual: #{File.size(full_path)})")
101
+ end
102
+ end
103
+
104
+ def log(level, message, exception=nil)
105
+ case level
106
+ when :error
107
+ short_error = ["*** Error: #{message}", exception].compact.join(": ")
108
+ $stderr.puts short_error
109
+ open("campfire/export_errors.txt", 'a') do |log|
110
+ log.write short_error
111
+ unless exception.nil?
112
+ log.write %Q{\n\t#{exception.backtrace.join("\n\t")}}
113
+ end
114
+ log.write "\n"
115
+ end
116
+ else
117
+ print message
118
+ $stdout.flush
119
+ end
120
+ end
121
+ end
122
+
123
+ end
124
+
125
+ require 'campfire_export/timezone'
126
+
127
+ require 'campfire_export/account'
128
+ require 'campfire_export/exception'
129
+ require 'campfire_export/message'
130
+ require 'campfire_export/room'
131
+ require 'campfire_export/transcript'
132
+ require 'campfire_export/upload'
133
+ require 'campfire_export/version'
@@ -0,0 +1,85 @@
1
+ require 'spec_helper'
2
+
3
+ module CampfireExport
4
+ describe Account do
5
+ before(:each) do
6
+ @subdomain = "test-subdomain"
7
+ @api_token = "test-apikey"
8
+ @account = Account.new(@subdomain, @api_token)
9
+
10
+ @good_timezone = '<?xml version="1.0" encoding="UTF-8"?>' +
11
+ '<account>' +
12
+ ' <time-zone>America/Los_Angeles</time-zone>' +
13
+ ' <owner-id type="integer">99999</owner-id>' +
14
+ ' <created-at type="datetime">2010-01-31T18:30:18Z</created-at>' +
15
+ ' <storage type="integer">9999999</storage>' +
16
+ ' <plan>basic</plan>' +
17
+ ' <updated-at type="datetime">2010-01-31T18:31:55Z</updated-at>' +
18
+ ' <subdomain>example</subdomain>' +
19
+ ' <name>Example</name>' +
20
+ ' <id type="integer">999999</id>' +
21
+ '</account>'
22
+
23
+ @bad_timezone = @good_timezone.gsub('America/Los_Angeles',
24
+ 'No Such Timezone')
25
+ @account_xml = stub("Account XML")
26
+ @account_xml.stub(:body).and_return(@good_timezone)
27
+ end
28
+
29
+ context "when it is created" do
30
+ it "sets up the account config variables" do
31
+ Account.subdomain.should equal(@subdomain)
32
+ Account.api_token.should equal(@api_token)
33
+ Account.base_url.should == "https://#{@subdomain}.campfirenow.com"
34
+ end
35
+ end
36
+
37
+ context "when timezone is loaded" do
38
+ it "determines the user's timezone" do
39
+ @account.should_receive(:get).with("/account.xml"
40
+ ).and_return(@account_xml)
41
+ @account.find_timezone
42
+ Account.timezone.to_s.should == "America - Los Angeles"
43
+ end
44
+
45
+ it "raises an error if it gets a bad time zone identifier" do
46
+ @account_xml.stub(:body).and_return(@bad_timezone)
47
+ @account.stub(:get).with("/account.xml"
48
+ ).and_return(@account_xml)
49
+ expect {
50
+ @account.find_timezone
51
+ }.to raise_error(TZInfo::InvalidTimezoneIdentifier)
52
+ end
53
+
54
+ it "raises an error if it can't get the account settings at all" do
55
+ @account.stub(:get).with("/account.xml"
56
+ ).and_raise(CampfireExport::Exception.new("/account/settings",
57
+ "Not Found", 404))
58
+ expect {
59
+ @account.find_timezone
60
+ }.to raise_error(CampfireExport::Exception)
61
+ end
62
+ end
63
+
64
+ context "when rooms are requested" do
65
+ it "returns an array of rooms" do
66
+ room_xml = "<rooms><room>1</room><room>2</room><room>3</room></rooms>"
67
+ room_doc = mock("room doc")
68
+ room_doc.should_receive(:body).and_return(room_xml)
69
+ @account.should_receive(:get).with('/rooms.xml').and_return(room_doc)
70
+ room = mock("room")
71
+ Room.should_receive(:new).exactly(3).times.and_return(room)
72
+ @account.rooms.should have(3).items
73
+ end
74
+
75
+ it "raises an error if it can't get the room list" do
76
+ @account.stub(:get).with('/rooms.xml'
77
+ ).and_raise(CampfireExport::Exception.new('/rooms.xml',
78
+ "Not Found", 404))
79
+ expect {
80
+ @account.rooms
81
+ }.to raise_error(CampfireExport::Exception)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,60 @@
1
+ require 'spec_helper'
2
+
3
+ module CampfireExport
4
+ describe Message do
5
+ include TimeZone
6
+
7
+ before :each do
8
+ @messages = Nokogiri::XML <<XML
9
+ <messages>
10
+ <message>
11
+ <created-at type="datetime">2012-05-11T17:45:00Z</created-at>
12
+ <id type="integer">111</id>
13
+ <room-id type="integer">222</room-id>
14
+ <user-id type="integer" nil="true"/>
15
+ <body nil="true"/>
16
+ <type>TimestampMessage</type>
17
+ </message>
18
+ <message>
19
+ <created-at type="datetime">2012-05-11T17:47:20Z</created-at>
20
+ <id type="integer">333</id>
21
+ <room-id type="integer">222</room-id>
22
+ <user-id type="integer">555</user-id>
23
+ <body>This is a tweet</body>
24
+ <type>TweetMessage</type>
25
+ <tweet>
26
+ <id>20100487385931234</id>
27
+ <message>This is a tweet</message>
28
+ <author_username>twitter_user</author_username>
29
+ <author_avatar_url>avatar.jpg</author_avatar_url>
30
+ </tweet>
31
+ </message>
32
+ <message>
33
+ <created-at type="datetime">2012-05-11T17:47:23Z</created-at>
34
+ <id type="integer">666</id>
35
+ <room-id type="integer">222</room-id>
36
+ <user-id type="integer">555</user-id>
37
+ <body>Regular message</body>
38
+ <type>TextMessage</type>
39
+ </message>
40
+ </messages>
41
+ XML
42
+ Account.timezone = find_tzinfo("America/Los_Angeles")
43
+ end
44
+
45
+ context "when it is created" do
46
+ it "sets up basic properties" do
47
+ message = Message.new(@messages.xpath('/messages/message[3]')[0], nil, nil)
48
+ message.body.should == "Regular message"
49
+ message.id.should == "666"
50
+ message.timestamp.should == "10:47 AM"
51
+ end
52
+
53
+ it "handles tweets correctly" do
54
+ message = Message.new(@messages.xpath('/messages/message[2]'), nil, nil)
55
+ message.body.should == "This is a tweet"
56
+ message.id.should == "333"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,27 @@
1
+ require 'spec_helper'
2
+
3
+ module CampfireExport
4
+ describe Room do
5
+ include TimeZone
6
+
7
+ before :each do
8
+ doc = Nokogiri::XML "<room><name>Test Room</name><id>666</id>" +
9
+ "<created-at>2009-11-17T19:41:38Z</created-at></room>"
10
+ @room_xml = doc.xpath('/room')
11
+ Account.timezone = find_tzinfo("America/Los_Angeles")
12
+ end
13
+
14
+ context "when it is created" do
15
+ it "sets up basic properties" do
16
+ room = Room.new(@room_xml)
17
+ room.name.should == "Test Room"
18
+ room.id.should == "666"
19
+ room.created_at.should == DateTime.parse("2009-11-17T11:41:38Z")
20
+ end
21
+ end
22
+
23
+ context "when it finds the last update" do
24
+ it "loads the last update from the most recent message"
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,4 @@
1
+ require 'rspec'
2
+
3
+ require 'campfire_export'
4
+ require 'tzinfo'
metadata ADDED
@@ -0,0 +1,174 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stackbuilders-campfire_export
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Marc Hedlund
8
+ - Justin Leitgeb
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2015-03-18 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '1'
21
+ type: :development
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '1'
28
+ - !ruby/object:Gem::Dependency
29
+ name: rake
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - "~>"
33
+ - !ruby/object:Gem::Version
34
+ version: '10'
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - "~>"
40
+ - !ruby/object:Gem::Version
41
+ version: '10'
42
+ - !ruby/object:Gem::Dependency
43
+ name: rspec
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - "~>"
47
+ - !ruby/object:Gem::Version
48
+ version: '2.6'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - "~>"
54
+ - !ruby/object:Gem::Version
55
+ version: '2.6'
56
+ - !ruby/object:Gem::Dependency
57
+ name: tzinfo
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '1.2'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '1.2'
70
+ - !ruby/object:Gem::Dependency
71
+ name: httparty
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '0.13'
77
+ type: :runtime
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - "~>"
82
+ - !ruby/object:Gem::Version
83
+ version: '0.13'
84
+ - !ruby/object:Gem::Dependency
85
+ name: nokogiri
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - "~>"
89
+ - !ruby/object:Gem::Version
90
+ version: '1.6'
91
+ type: :runtime
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - "~>"
96
+ - !ruby/object:Gem::Version
97
+ version: '1.6'
98
+ - !ruby/object:Gem::Dependency
99
+ name: retryable
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - "~>"
103
+ - !ruby/object:Gem::Version
104
+ version: '2.0'
105
+ type: :runtime
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '2.0'
112
+ description: |-
113
+ Exports content from all rooms in a
114
+ 37signals Campfire account. Creates a directory containing transcripts
115
+ and content uploaded to each one of your rooms. Can be configured to
116
+ recognize start and end date of content export.
117
+ email:
118
+ - marc@precipice.org
119
+ - justin@stackbuilders.com
120
+ executables:
121
+ - campfire_export
122
+ extensions: []
123
+ extra_rdoc_files: []
124
+ files:
125
+ - ".gitignore"
126
+ - ".travis.yml"
127
+ - Gemfile
128
+ - LICENSE.txt
129
+ - README.md
130
+ - Rakefile
131
+ - bin/campfire_export
132
+ - campfire_export.gemspec
133
+ - lib/campfire_export.rb
134
+ - lib/campfire_export/account.rb
135
+ - lib/campfire_export/exception.rb
136
+ - lib/campfire_export/message.rb
137
+ - lib/campfire_export/room.rb
138
+ - lib/campfire_export/timezone.rb
139
+ - lib/campfire_export/transcript.rb
140
+ - lib/campfire_export/upload.rb
141
+ - lib/campfire_export/version.rb
142
+ - spec/campfire_export/account_spec.rb
143
+ - spec/campfire_export/message_spec.rb
144
+ - spec/campfire_export/room_spec.rb
145
+ - spec/spec_helper.rb
146
+ homepage: https://github.com/stackbuilders/campfire_export
147
+ licenses:
148
+ - Apache 2.0
149
+ metadata: {}
150
+ post_install_message:
151
+ rdoc_options: []
152
+ require_paths:
153
+ - lib
154
+ required_ruby_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: 1.9.3
159
+ required_rubygems_version: !ruby/object:Gem::Requirement
160
+ requirements:
161
+ - - ">="
162
+ - !ruby/object:Gem::Version
163
+ version: '0'
164
+ requirements: []
165
+ rubyforge_project: campfire_export
166
+ rubygems_version: 2.4.6
167
+ signing_key:
168
+ specification_version: 4
169
+ summary: Export transcripts and uploaded files from your 37signals' Campfire account.
170
+ test_files:
171
+ - spec/campfire_export/account_spec.rb
172
+ - spec/campfire_export/message_spec.rb
173
+ - spec/campfire_export/room_spec.rb
174
+ - spec/spec_helper.rb