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 +7 -0
- data/.gitignore +11 -0
- data/.rspec +1 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +57 -0
- data/Rakefile +2 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/exe/sms_backup_renderer +23 -0
- data/lib/sms_backup_renderer/assets/conversation.css +50 -0
- data/lib/sms_backup_renderer/models.rb +112 -0
- data/lib/sms_backup_renderer/parser.rb +159 -0
- data/lib/sms_backup_renderer/renderer.rb +120 -0
- data/lib/sms_backup_renderer/templates/conversation.html.erb +36 -0
- data/lib/sms_backup_renderer/templates/index.html.erb +15 -0
- data/lib/sms_backup_renderer/version.rb +3 -0
- data/lib/sms_backup_renderer.rb +42 -0
- data/sms_backup_renderer.gemspec +27 -0
- metadata +119 -0
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
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--require spec_helper
|
data/Gemfile
ADDED
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
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,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,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: []
|