sms_backup_renderer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: ca777b9b54c07e23ee9cd6ad8d5f759d35dda975
4
+ data.tar.gz: f0d1547ec2f8a012c58b3f09b3306b72db59c640
5
+ SHA512:
6
+ metadata.gz: e46fced0b335e29123b6435254010ce42e6ea857103f98efae0cdf32b5da8f09378b9ac12925d96e1222780cb08805ab3070ac524a3c6a768d0947fe0a48954d
7
+ data.tar.gz: 5345ec20e294ecf62d5fcd3c3086f1d56171d09afa86ad8bc897f150726ebdd214b1f18c75bb5ae7f363b41dc3ecc5dd4f39ae5b212e8c8b1501119557606514
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ .DS_Store
2
+ /.bundle/
3
+ /.yardoc
4
+ /Gemfile.lock
5
+ /_yardoc/
6
+ /coverage/
7
+ /doc/
8
+ /pkg/
9
+ /spec/examples.txt
10
+ /spec/reports/
11
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in sms_backup_renderer.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Jacob Williams
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # SmsBackupRenderer
2
+
3
+ This tool reads backup files created by [SMS Backup & Restore](https://www.carbonite.com/en/apps/call-log-sms-backup-restore) and generates static HTML files for viewing their contents.
4
+
5
+ It supports both SMS and MMS messages, including images and videos.
6
+ It attempts to create readable conversation-style views.
7
+
8
+ (I am not affiliated with Carbonite in any way.)
9
+
10
+ ## Installation
11
+
12
+ $ gem install sms_backup_renderer
13
+
14
+ ## Usage
15
+
16
+ $ sms_backup_renderer PATH_TO_XML_ARCHIVE PATH_TO_OUTPUT_DIRECTORY
17
+
18
+ I suggest creating a new, empty directory to use as the output directory the first time you run this, since multiple files and subdirectories will be created within it.
19
+
20
+ After the command runs, the output directory will contain an `index.html` file you can open in your browser.
21
+
22
+ ## Caveats
23
+
24
+ The backup files may represent the same number in different ways (e.g `+1 (234) 567-8901` and `2345678901`) at different times.
25
+ In order to group all messages for the same number into the same conversation, `sms_backup_renderer` must try to normalize the numbers into a consistent format.
26
+ Currently, the way this is done is pretty hacky and only knows about the US country code.
27
+ Non-US numbers might be grouped incorrectly in some cases.
28
+
29
+ Determining the contact names for group MMS messages also relies on some pretty hacky code.
30
+ It probably won't work right if you have contact names containing commas.
31
+ It may not work right anyway - I'm not sure if the assumptions it makes about the backup archive are always true, or if they are coincidental implementation details that may be affected by the messaging software on your phone.
32
+
33
+ Videos do not currently play in Chrome, and possibly other browsers, though they do in Safari. I haven't figured out why yet. You can still open the video file directly in the output directory (or download it through your browser) to play it.
34
+
35
+ ## Development
36
+
37
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
38
+
39
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
40
+
41
+ ## Testing
42
+
43
+ There are some very basic rspec tests which run the renderer on a sample archive. To view the generated HTML in your browser, run
44
+
45
+ $ KEEP_TEST_OUTPUT=1 bundle exec rspec
46
+
47
+ Then open `tmp/test-output/index.html`.
48
+
49
+ ## Contributing
50
+
51
+ Bug reports and pull requests are welcome on GitHub at https://github.com/brokensandals/sms_backup_renderer.
52
+
53
+
54
+ ## License
55
+
56
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
57
+
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "sms_backup_renderer"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ require 'sms_backup_renderer'
4
+
5
+ optparse = OptionParser.new do |opts|
6
+ opts.banner = 'Usage: sms_backup_renderer INPUT_FILE OUTPUT_DIR [options]'
7
+
8
+ opts.on('--version', "Print program version and exit") do
9
+ puts "sms_backup_renderer #{SmsBackupRenderer::VERSION}"
10
+ exit 0
11
+ end
12
+ end
13
+
14
+ optparse.parse!
15
+ if ARGV.length != 2
16
+ puts optparse
17
+ exit -1
18
+ end
19
+
20
+ input_file_path = ARGV.shift
21
+ output_dir_path = ARGV.shift
22
+
23
+ SmsBackupRenderer.generate_html_from_archive(input_file_path, output_dir_path)
@@ -0,0 +1,50 @@
1
+ h1 {
2
+ text-align: center;
3
+ }
4
+
5
+ body {
6
+ max-width: 600px;
7
+ }
8
+
9
+ .message {
10
+ margin-left: 40px;
11
+ max-width: 450px;
12
+ }
13
+
14
+ .message.outgoing {
15
+ margin-left: 150px;
16
+ }
17
+
18
+ .sender, .message-date-time {
19
+ font-size: 90%;
20
+ display: block;
21
+ margin: 0px;
22
+ }
23
+
24
+ .message-part {
25
+ margin: 10px 0px 10px 0px;
26
+ }
27
+
28
+ .message-part-image {
29
+ max-width: 100%;
30
+ }
31
+
32
+ .message-part-video {
33
+ max-width: 100%;
34
+ }
35
+
36
+ .message-part-text {
37
+ border-radius: 10px;
38
+ padding: 5px;
39
+ margin: 0;
40
+ }
41
+
42
+ .incoming .message-part-text {
43
+ background-color: beige;
44
+ display: inline-block;
45
+ }
46
+
47
+ .outgoing .message-part-text {
48
+ color: white;
49
+ background-color: darkgoldenrod;
50
+ }
@@ -0,0 +1,112 @@
1
+ module SmsBackupRenderer
2
+ # Represents a single sender or recipient of an SMS or MMS message.
3
+ class Participant
4
+ # Returns the String address of the participant, such as '1 (234) 567-890'.
5
+ attr_reader :address
6
+
7
+ # Returns the String contact name for the participant, or nil if unknown.
8
+ attr_reader :name
9
+
10
+ # Returns true if this participant is the owner of the archive, otherwise false.
11
+ attr_reader :owner
12
+
13
+ # Returns true if this participant is a sender of the message, false if they are a recipient.
14
+ attr_reader :sender
15
+
16
+ def initialize(args)
17
+ @address = args[:address]
18
+ @name = args[:name]
19
+ @owner = args[:owner]
20
+ @sender = args[:sender]
21
+ end
22
+
23
+ def normalized_address
24
+ @normalized_address ||= Participant.normalize_address(address)
25
+ end
26
+
27
+ # Normalizes a given address, for example '1 (234) 567-890' to '1234567890'.
28
+ #
29
+ # TODO: This is currently done in a very hacky, incomplete, embarrassingly-US-centric way.
30
+ #
31
+ # address - String address to normalize
32
+ #
33
+ # Returns the String normalized address.
34
+ def self.normalize_address(address)
35
+ address.gsub(/[\s\(\)\+\-]/, '')
36
+ .gsub(/\A1(\d{10})\z/, '\\1')
37
+ end
38
+ end
39
+
40
+ # Represents an SMS or MMS message.
41
+ class Message
42
+ # Returns a Time instance indicating when the message was sent or received.
43
+ attr_reader :date_time
44
+
45
+ # Returns true if the message was sent to address, false if received from address.
46
+ attr_reader :outgoing
47
+
48
+ # Returns an Array of Participant instances representing the senders and recipients of the message.
49
+ # For MMS messages there may be multiple recipients. For SMS messages, the originator of the archive
50
+ # is never represented, so there will only be a sender (for incoming messages) or a recipient
51
+ # (for outgoing messages).
52
+ attr_reader :participants
53
+
54
+ # Returns an Array of MessagePart instances representing the contents of the message.
55
+ attr_reader :parts
56
+
57
+ # Returns the String subject/title of the message, likely nil.
58
+ attr_reader :subject
59
+
60
+ def initialize(args)
61
+ @contact_name = args[:contact_name]
62
+ @date_time = args[:date_time]
63
+ @outgoing = args[:outgoing]
64
+ @participants = args[:participants]
65
+ @parts = args[:parts] || []
66
+ @subject = args[:subject]
67
+ end
68
+
69
+ # Returns the Participant instance for the message sender, or nil.
70
+ def sender
71
+ @sender ||= participants.detect(&:sender)
72
+ end
73
+ end
74
+
75
+ # Represents a piece of content in a message, such as text or an image. See subclasses.
76
+ class MessagePart
77
+ end
78
+
79
+ class UnsupportedPart < MessagePart
80
+ # Returns a String containing the XML data of the message part.
81
+ attr_reader :xml
82
+
83
+ def initialize(xml)
84
+ @xml = xml
85
+ end
86
+ end
87
+
88
+ class TextPart < MessagePart
89
+ # Returns the String content of the message part such as "Hey Jacob, don't you have something better to do?".
90
+ attr_reader :text
91
+
92
+ def initialize(text)
93
+ @text = text
94
+ end
95
+ end
96
+
97
+ class MediaPart < MessagePart
98
+ # Returns the String content type for the message part such as 'image/jpeg'.
99
+ attr_reader :content_type
100
+
101
+ # Returns the String file path at which the content has been stored.
102
+ attr_reader :path
103
+
104
+ def initialize(content_type, path)
105
+ @content_type = content_type
106
+ @path = path
107
+ end
108
+ end
109
+
110
+ class ImagePart < MediaPart; end
111
+ class VideoPart < MediaPart; end
112
+ end
@@ -0,0 +1,159 @@
1
+ require 'base64'
2
+ require 'digest'
3
+ require 'nokogiri'
4
+ require 'time'
5
+
6
+ module SmsBackupRenderer
7
+ HIGH_SURROGATES = 0xD800..0xDFFF
8
+
9
+ # Although the files claim to be UTF-8, SMS Backup & Restore produces files that incorrectly represent
10
+ # characters such as emoji using surrogate pairs, such that a single character is represented by two
11
+ # adjacent, separately-escaped characters which are supposed to be interpreted as a single
12
+ # Unicode surrogate pair. Nokogiri crashes when it encounters these, since it tries to interpret
13
+ # each part of the pair as a separate character. This method is a hacky workaround that simply searches
14
+ # the whole file for strings that look like escaped surrogate pairs and replaces them with the literal
15
+ # character they represent.
16
+ def self.fix_surrogate_pairs(string)
17
+ string.gsub!(/\&\#(\d{5})\;\&\#(\d{5})\;/) do |match|
18
+ high = Regexp.last_match[1].to_i
19
+ if HIGH_SURROGATES.include?(high)
20
+ low = Regexp.last_match[2].to_i
21
+ code_point = ((high - 0xD800) << 10) + (low - 0xDC00) + 0x010000
22
+ [code_point].pack('U*')
23
+ else
24
+ match[0]
25
+ end
26
+ end
27
+ end
28
+
29
+ def self.parse(input, data_dir_path)
30
+ messages = []
31
+ Nokogiri::XML::Reader(input).each do |node|
32
+ next unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
33
+ case node.name
34
+ when 'sms'
35
+ sms = Nokogiri::XML(node.outer_xml).at('/sms')
36
+ outgoing = sms_outgoing_type?(sms.attr('type'))
37
+ messages << Message.new(
38
+ date_time: Time.strptime(sms.attr('date'), '%Q'),
39
+ parts: sms.attr('body') ? [TextPart.new(sms.attr('body'))] : [],
40
+ outgoing: outgoing,
41
+ participants: [Participant.new(
42
+ address: sms.attr('address'),
43
+ name: sms.attr('contact_name'),
44
+ owner: false,
45
+ sender: !outgoing)],
46
+ subject: sms.attr('subject'))
47
+ when 'mms'
48
+ mms = Nokogiri::XML(node.outer_xml).at('/mms')
49
+ unless mms.attr('ct_t') == 'application/vnd.wap.multipart.related'
50
+ raise "Unrecognized MMS ct_t #{mms.attr('ct_t')}"
51
+ end
52
+
53
+ parts = mms.xpath('parts/part').map do |part|
54
+ case part.attr('ct')
55
+ when 'application/smil'
56
+ # should probably use this, but I think I can get by without it
57
+ nil
58
+ when 'text/plain'
59
+ TextPart.new(part.attr('text'))
60
+ when /\Aimage\/(.+)\z/
61
+ data = Base64.decode64(part.attr('data'))
62
+ digest = Digest::MD5.hexdigest(data)
63
+ path = File.join(data_dir_path, "#{digest}.#{$1}")
64
+ File.write(path, data)
65
+ ImagePart.new(part.attr('ct'), path)
66
+ when /\Avideo\/(.+)\z/
67
+ data = Base64.decode64(part.attr('data'))
68
+ digest = Digest::MD5.hexdigest(data)
69
+ path = File.join(data_dir_path, "#{digest}.#{$1}")
70
+ File.write(path, data)
71
+ VideoPart.new(part.attr('ct'), path)
72
+ else
73
+ UnsupportedPart.new(part.to_xml)
74
+ end
75
+ end.compact
76
+
77
+ non_owner_addresses = parse_mms_combined_address(mms.attr('address'))
78
+ address_contact_names = mms_address_contact_names(mms)
79
+ participants = mms.xpath('addrs/addr').map do |addr|
80
+ Participant.new(
81
+ address: addr.attr('address'),
82
+ name: address_contact_names[Participant.normalize_address(addr.attr('address'))],
83
+ owner: !non_owner_addresses.include?(Participant.normalize_address(addr.attr('address'))),
84
+ sender: mms_sender_addr_type?(addr.attr('type')))
85
+ end
86
+
87
+ messages << Message.new(
88
+ date_time: Time.strptime(mms.attr('date'), '%Q'),
89
+ outgoing: mms_outgoing_type?(mms.attr('m_type')),
90
+ participants: participants,
91
+ parts: parts)
92
+ end
93
+ end
94
+ messages
95
+ end
96
+
97
+ def self.sms_outgoing_type?(type)
98
+ case type
99
+ when '1'
100
+ false
101
+ when '2'
102
+ true
103
+ else
104
+ raise "Unrecognized SMS type #{type}"
105
+ end
106
+ end
107
+
108
+ def self.mms_outgoing_type?(type)
109
+ case type
110
+ when '132'
111
+ false
112
+ when '128'
113
+ true
114
+ else
115
+ raise "Unrecognized MMS m_type #{type}"
116
+ end
117
+ end
118
+
119
+ def self.mms_sender_addr_type?(type)
120
+ case type
121
+ when '137'
122
+ true
123
+ else
124
+ false
125
+ end
126
+ end
127
+
128
+ # Build a hash of normalized addresses to contact names using information in an MMS XML record.
129
+ # The data in the archive does not provide any explicit mapping of addresses to contact names, but
130
+ # at least for me it seems like the tilde-separated address attribute and the comma-separated
131
+ # contact_name attribute are provided in the same order, so we can try to use those to build
132
+ # a mapping. Obviously, this is error-prone, but seems better than nothing.
133
+ #
134
+ # mms - nokogiri object representing the MMS element
135
+ #
136
+ # Returns a Hash of String normalized addresses to String contact names.
137
+ def self.mms_address_contact_names(mms)
138
+ addresses = parse_mms_combined_address(mms.attr('address'))
139
+ contact_names = mms.attr('contact_name').split(',').map(&:strip)
140
+
141
+ # There may be more addresses than contact names. It seems like the addresses for unknown contacts
142
+ # are placed at the end of the list. We'll omit them from the hash.
143
+ addresses = addresses.take(contact_names.count)
144
+
145
+ addresses.zip(contact_names).to_h
146
+ end
147
+
148
+ # The XML for MMSes contains an 'address' attribute containing a list of addresses separated by
149
+ # tildes. Although there are also separate 'addr' elements for each address, the combined attribute
150
+ # can be useful because it appears to exclude the owner of the archive's address, and because the
151
+ # order can be correlated with the contact_name attribute.
152
+ #
153
+ # address_attribute - the value of the 'address' attribute from the XML element for the MMS message
154
+ #
155
+ # Returns an Array of String normalized addresses.
156
+ def self.parse_mms_combined_address(address_attribute)
157
+ address_attribute.split('~').map {|a| Participant.normalize_address(a)}
158
+ end
159
+ end
@@ -0,0 +1,120 @@
1
+ require 'digest'
2
+ require 'erb'
3
+ require 'pathname'
4
+
5
+ module SmsBackupRenderer
6
+ class BasePage
7
+ # Returns the String path to where this file will be written.
8
+ attr_reader :output_file_path
9
+
10
+ # Returns the String path to the folder where static assets have been copied to.
11
+ attr_reader :assets_dir_path
12
+
13
+ def initialize(output_file_path, assets_dir_path)
14
+ @output_file_path = output_file_path
15
+ @assets_dir_path = assets_dir_path
16
+ end
17
+
18
+ def render
19
+ ERB.new(File.read(File.join(File.dirname(__FILE__), 'templates', template_name))).result(binding)
20
+ end
21
+
22
+ def write
23
+ File.write(output_file_path, render)
24
+ end
25
+
26
+ def relative_path(path)
27
+ Pathname.new(path).relative_path_from(Pathname.new(File.dirname(output_file_path))).to_s
28
+ end
29
+
30
+ def asset_path(filename)
31
+ Pathname.new(File.join(assets_dir_path, filename))
32
+ .relative_path_from(Pathname.new(File.dirname(output_file_path))).to_s
33
+ end
34
+
35
+ def template_name
36
+ raise 'not implemented'
37
+ end
38
+ end
39
+
40
+ class IndexPage < BasePage
41
+ # Returns an Array ConversationPage instances which this page should link to.
42
+ attr_reader :conversation_pages
43
+
44
+ def initialize(output_file_path, assets_dir_path, conversation_pages)
45
+ super(output_file_path, assets_dir_path)
46
+ @conversation_pages = conversation_pages.sort_by(&:title)
47
+ end
48
+
49
+ def template_name
50
+ 'index.html.erb'
51
+ end
52
+ end
53
+
54
+ class ConversationPage < BasePage
55
+ # Returns an Array of Message instances to be shown on this page.
56
+ attr_reader :messages
57
+
58
+ def initialize(output_file_path, assets_dir_path, messages)
59
+ super(output_file_path, assets_dir_path)
60
+ @messages = messages.sort_by(&:date_time)
61
+ end
62
+
63
+ def template_name
64
+ 'conversation.html.erb'
65
+ end
66
+
67
+ def title
68
+ messages.first.participants.reject(&:owner).map do |participant|
69
+ if participant.name
70
+ "#{participant.name} (#{participant.normalized_address})"
71
+ else
72
+ participant.normalized_address
73
+ end
74
+ end.sort.join(', ')
75
+ end
76
+
77
+ def message_date_time_span(message, previous_message)
78
+ formatted = if previous_message && message.date_time.to_date == previous_message.date_time.to_date
79
+ if message.date_time.to_time - previous_message.date_time.to_time < 600
80
+ nil
81
+ else
82
+ message.date_time.strftime('%-I:%M%P')
83
+ end
84
+ else
85
+ message.date_time.strftime('%A, %b %-d, %Y at %-I:%M%P %Z')
86
+ end
87
+ return '' unless formatted
88
+ "<span class=\"message-date-time\">#{formatted}</span>"
89
+ end
90
+
91
+ def sender_span(message, previous_message)
92
+ if previous_message &&
93
+ message.outgoing == previous_message.outgoing &&
94
+ (message.outgoing || message.sender.normalized_address == previous_message.sender.normalized_address)
95
+ ''
96
+ else
97
+ sender = message.outgoing ? 'You' : (message.sender.name || message.sender.normalized_address)
98
+ "<span class=\"sender\">#{ERB::Util.html_escape(sender)}</span>"
99
+ end
100
+ end
101
+
102
+ # Returns a filename for a conversation page for the given participants, such as '123456789.html'.
103
+ # Should always return the same name for the same list of participants. The normalized addresses
104
+ # are used when they are numeric, but non-numeric addresses (such as email addresses) will be hex-encoded
105
+ # to avoid any problems with file name restrictions.
106
+ #
107
+ # participants - an Array of Participant instances
108
+ #
109
+ # Returns a String filename.
110
+ def self.build_filename(participants)
111
+ participants.map do |participant|
112
+ if participant.normalized_address =~ /\A\d+\z/
113
+ participant.normalized_address
114
+ else
115
+ '0x' + Digest.hexencode(participant.address)
116
+ end
117
+ end.sort.join('_') + '.html'
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,36 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <title><%= title %></title>
6
+ <link href="<%= asset_path('conversation.css') %>" rel="stylesheet"/>
7
+ </head>
8
+ <body>
9
+ <h1><%= title %></h1>
10
+ <% messages.zip([nil] + messages).each do |(message, previous_message)| %>
11
+ <div class="message <%= message.outgoing ? 'outgoing' : 'incoming' %>">
12
+ <%= sender_span(message, previous_message) %>
13
+ <%= message_date_time_span(message, previous_message) %>
14
+ <% message.parts.each do |part| %>
15
+ <div class="message-part">
16
+ <% case part
17
+ when TextPart %>
18
+ <p class="message-part-text">
19
+ <%= part.text.lines.map { |line| ERB::Util.html_escape(line) }.join('<br/>') %>
20
+ </p>
21
+ <% when ImagePart %>
22
+ <img class="message-part-image" src="<%= relative_path(part.path)%>" type="<%= part.content_type%>"/>
23
+ <% when VideoPart %>
24
+ <video class="message-part-video" controls>
25
+ <source src="<%= relative_path(part.path) %>" type="<%= part.content_type %>" />
26
+ Message contains a video, but your browser can't/won't display it.
27
+ </video>
28
+ <% else %>
29
+ <p class="message-part-unsupported">Message contains unsupported media type.</p>
30
+ <% end %>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+ <% end %>
35
+ </body>
36
+ </html>
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <title>SMS/MMS Conversations</title>
6
+ </head>
7
+ <body>
8
+ <h1>SMS/MMS Conversations</h1>
9
+ <ul>
10
+ <% conversation_pages.each do |page| %>
11
+ <li><a href="<%= relative_path(page.output_file_path) %>"><%= page.title %></a></li>
12
+ <% end %>
13
+ </ul>
14
+ </body>
15
+ </html>
@@ -0,0 +1,3 @@
1
+ module SmsBackupRenderer
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,42 @@
1
+ require 'sms_backup_renderer/models'
2
+ require 'sms_backup_renderer/parser'
3
+ require 'sms_backup_renderer/renderer'
4
+ require 'sms_backup_renderer/version'
5
+
6
+ require 'fileutils'
7
+ require 'tempfile'
8
+
9
+ module SmsBackupRenderer
10
+ def self.generate_html_from_archive(input_file_path, output_dir_path)
11
+ input_tempfile = Tempfile.new('sms_backup_renderer')
12
+ input_text = File.read(input_file_path)
13
+ SmsBackupRenderer.fix_surrogate_pairs(input_text)
14
+ File.write(input_tempfile.path, input_text)
15
+
16
+ data_dir_path = File.join(output_dir_path, 'data')
17
+ FileUtils.mkdir_p(data_dir_path)
18
+
19
+ input_file = File.open(input_tempfile.path)
20
+ messages = SmsBackupRenderer.parse(input_file, data_dir_path)
21
+ input_file.close
22
+ input_tempfile.close
23
+
24
+ message_groups = messages.group_by {|m| m.participants.reject(&:owner).map(&:normalized_address).sort}.values
25
+
26
+ assets_dir_path = File.join(output_dir_path, 'assets')
27
+ FileUtils.cp_r(File.join(File.dirname(__FILE__), 'sms_backup_renderer', 'assets'), output_dir_path)
28
+ conversations_dir_path = File.join(output_dir_path, 'conversations')
29
+ FileUtils.mkdir_p(conversations_dir_path)
30
+
31
+ conversation_pages = message_groups.map do |group_messages|
32
+ filename = ConversationPage.build_filename(group_messages.first.participants.reject(&:owner))
33
+ path = File.join(conversations_dir_path, filename)
34
+ SmsBackupRenderer::ConversationPage.new(path, assets_dir_path, group_messages)
35
+ end
36
+
37
+ conversation_pages.each(&:write)
38
+
39
+ SmsBackupRenderer::IndexPage.new(
40
+ File.join(output_dir_path, 'index.html'), assets_dir_path, conversation_pages).write
41
+ end
42
+ end
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'sms_backup_renderer/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "sms_backup_renderer"
8
+ spec.version = SmsBackupRenderer::VERSION
9
+ spec.authors = ["Jacob Williams"]
10
+ spec.email = ["jacobaw@gmail.com"]
11
+
12
+ spec.summary = %q{Generates static HTML pages for the contents of an SMS Backup & Restore archive.}
13
+ spec.homepage = "https://github.com/brokensandals/sms_backup_renderer"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency 'nokogiri', '~> 1.8'
24
+ spec.add_development_dependency "bundler", "~> 1.13"
25
+ spec.add_development_dependency "rake", "~> 10.0"
26
+ spec.add_development_dependency 'rspec', '~> 3.6'
27
+ end
metadata ADDED
@@ -0,0 +1,119 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sms_backup_renderer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jacob Williams
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-08-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.8'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.13'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.13'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.6'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.6'
69
+ description:
70
+ email:
71
+ - jacobaw@gmail.com
72
+ executables:
73
+ - sms_backup_renderer
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".gitignore"
78
+ - ".rspec"
79
+ - Gemfile
80
+ - LICENSE.txt
81
+ - README.md
82
+ - Rakefile
83
+ - bin/console
84
+ - bin/setup
85
+ - exe/sms_backup_renderer
86
+ - lib/sms_backup_renderer.rb
87
+ - lib/sms_backup_renderer/assets/conversation.css
88
+ - lib/sms_backup_renderer/models.rb
89
+ - lib/sms_backup_renderer/parser.rb
90
+ - lib/sms_backup_renderer/renderer.rb
91
+ - lib/sms_backup_renderer/templates/conversation.html.erb
92
+ - lib/sms_backup_renderer/templates/index.html.erb
93
+ - lib/sms_backup_renderer/version.rb
94
+ - sms_backup_renderer.gemspec
95
+ homepage: https://github.com/brokensandals/sms_backup_renderer
96
+ licenses:
97
+ - MIT
98
+ metadata: {}
99
+ post_install_message:
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubyforge_project:
115
+ rubygems_version: 2.4.8
116
+ signing_key:
117
+ specification_version: 4
118
+ summary: Generates static HTML pages for the contents of an SMS Backup & Restore archive.
119
+ test_files: []