11 | <% last_time = Time.unix(0) %>
12 | <% messages.each do |message| %>
13 | <% msg_time = message.local_time %>
14 | <% day_gap = msg_time.day != last_time.day %>
15 | <% if day_gap %>
16 |
17 |
18 | <%= msg_time.to_s("%A, %B %-d") %>
19 |
20 |
21 | <% end %>
22 |
23 |
">
24 | <% big_gap = day_gap || (msg_time - last_time).minutes > 30 %>
25 |
"
27 | title="<%= msg_time.to_s("%a, %b %-d %Y %T") %>"
28 | >
29 | <%= msg_time.to_s("%R") %>
30 |
31 |
32 | <%= MessagePresenter.new(message).display_html %>
33 |
34 |
35 | <% last_time = msg_time %>
36 | <% end %>
37 |
38 |
--------------------------------------------------------------------------------
/src/database.cr:
--------------------------------------------------------------------------------
1 | require "db"
2 | require "file_utils"
3 | require "sqlite3"
4 |
5 | class Database
6 | getter db_path : String
7 | getter db : DB::Database
8 |
9 | def initialize
10 | @db_path = copy_db
11 | @db = DB.open("sqlite3://#{@db_path}")
12 | end
13 |
14 | def all_chats
15 | query = Queries::AllChats.new
16 | ORM::Chats.new(db, query).objects
17 | end
18 |
19 | def chat_messages(chat_row_id)
20 | query = Queries::ChatMessages.new
21 | ORM::Messages.new(db, query).objects(chat_row_id)
22 | end
23 |
24 | def finalize
25 | db_cleanup
26 | end
27 |
28 | private def original_db_path
29 | raise "Could not determine HOME directory" unless ENV.has_key?("HOME")
30 | "#{ENV["HOME"]}/Library/Messages/chat.db"
31 | end
32 |
33 | private def copied_db_path
34 | "/tmp/chat-#{Process.pid}.db"
35 | end
36 |
37 | private def copy_db
38 | original_path = original_db_path
39 | destination_path = copied_db_path
40 | raise "#{original_path} does not exist" unless File.exists?(original_path)
41 | raise "#{original_path} is not a file" unless File.file?(original_path)
42 | FileUtils.cp(original_path, destination_path)
43 | destination_path
44 | end
45 |
46 | private def db_cleanup
47 | db.close
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/src/queries/chat_messages.cr:
--------------------------------------------------------------------------------
1 | module Queries
2 | struct ChatMessages
3 | # Table aliases:
4 | # M => message
5 | # CMJ => chat_message_join
6 | # H => handle
7 | # MAJ => message_attachment_join
8 | # A => attachment
9 |
10 | def sql
11 | "#{select_clause} #{from_clause} #{order_by_clause};"
12 | end
13 |
14 | def read_types
15 | {
16 | row_id: Int32,
17 | text: String,
18 | handle_id: String,
19 | date: Int64,
20 | from_me: Bool,
21 | name: String?,
22 | path: String?,
23 | bytes: Int32?,
24 | }
25 | end
26 |
27 | private def fields
28 | %w[
29 | M.ROWID
30 | M.text
31 | H.id
32 | M.date
33 | M.is_from_me
34 | A.transfer_name
35 | A.filename
36 | A.total_bytes
37 | ]
38 | end
39 |
40 | private def select_clause
41 | "SELECT #{fields.join(", ")}"
42 | end
43 |
44 | private def from_clause
45 | "FROM message M " \
46 | "JOIN chat_message_join CMJ " \
47 | "ON CMJ.chat_id=? AND M.ROWID=CMJ.message_id " \
48 | "JOIN handle H ON M.handle_id = H.ROWID " \
49 | "LEFT JOIN message_attachment_join MAJ ON MAJ.message_id = M.ROWID " \
50 | "LEFT JOIN attachment A ON MAJ.attachment_id = A.ROWID"
51 | end
52 |
53 | private def order_by_clause
54 | "ORDER BY M.date"
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/src/orm/messages.cr:
--------------------------------------------------------------------------------
1 | module ORM
2 | class Messages
3 | getter db : DB::Database
4 | getter query : Queries::ChatMessages
5 |
6 | def initialize(@db, @query)
7 | end
8 |
9 | def objects(chat_row_id : Int32)
10 | db.query(query.sql, chat_row_id) do |rs|
11 | messages = {} of Int32 => Message
12 | rs.each do
13 | row = rs.read(**query.read_types)
14 | row_id = row[:row_id]
15 | text = row[:text]
16 | user_id = row[:handle_id]
17 | timestamp = row[:date]
18 | from_me = row[:from_me]
19 | attachment_name = row[:name]
20 | attachment_path = row[:path]
21 | attachment_bytes = row[:bytes]
22 | if attachment_name && attachment_path && attachment_bytes
23 | attachment = Attachment.new(
24 | attachment_name,
25 | attachment_path,
26 | attachment_bytes
27 | )
28 | else
29 | attachment = nil
30 | end
31 | if messages[row_id]?.nil?
32 | messages[row_id] = Message.new(
33 | row_id,
34 | text,
35 | Time.from_mac_nanoseconds(timestamp),
36 | User.new(user_id),
37 | from_me,
38 | attachment ? [attachment] : [] of Attachment
39 | )
40 | elsif attachment
41 | messages[row_id].attachments << attachment
42 | end
43 | end
44 | messages.values
45 | end
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/public/index.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | color: #2c2d30;
3 | font-family: sans-serif;
4 | font-size: 16px;
5 | margin: 0;
6 | }
7 |
8 | html, body, .container {
9 | height: 100%;
10 | position: relative;
11 | }
12 |
13 | .chats {
14 | bottom: 0;
15 | left: 0;
16 | margin: 0;
17 | overflow-y: scroll;
18 | padding: 1em 0;
19 | position: fixed;
20 | top: 0;
21 | width: 200px;
22 | }
23 |
24 | .chats-list {
25 | border-right: 1px solid #eee;
26 | list-style: none;
27 | margin: 0;
28 | padding: 0;
29 | }
30 |
31 | .chats-list li {
32 | overflow: hidden;
33 | padding: 0 1em;
34 | transition: background 0.1s;
35 | }
36 |
37 | .chats-list li:hover {
38 | background: #f0f0f0;
39 | }
40 |
41 | .chats-list li.active {
42 | background: #38a3f7;
43 | }
44 |
45 | .chats-list li.active a {
46 | color: white;
47 | }
48 |
49 | .chats-list a {
50 | color: #666;
51 | display: inline-block;
52 | padding: 0.4em 0;
53 | text-decoration: none;
54 | width: 100%;
55 | }
56 |
57 | .messages {
58 | margin-left: 200px;
59 | padding: 1em;
60 | }
61 |
62 | .messages .empty-prompt {
63 | color: #888;
64 | font-size: 1.7em;
65 | font-weight: bold;
66 | margin: 5rem 0;
67 | text-align: center;
68 | }
69 |
70 | .message {
71 | border-radius: 0.2em;
72 | padding: 0.3rem 0;
73 | transition: background 0.1s;
74 | }
75 | .message.mine {
76 | color: #38a3f7;
77 | }
78 |
79 | .message:hover {
80 | background-color: #eee;
81 | }
82 |
83 | .message-timestamp {
84 | color: #888;
85 | float: left;
86 | font-size: 0.9em;
87 | margin-right: 1rem;
88 | opacity: 0;
89 | text-align: right;
90 | transition: opacity 0.1s;
91 | width: 64px;
92 | }
93 | .message-timestamp.visible {
94 | opacity: 1;
95 | }
96 |
97 | .message:hover .message-timestamp {
98 | opacity: 1;
99 | }
100 |
101 | .message-text {
102 | margin-left: 80px;
103 | padding-left: 0.5rem;
104 | -webkit-user-select: none;
105 | }
106 |
107 | .message-dayGap {
108 | cursor: default;
109 | font-weight: bold;
110 | padding: 0.5rem 0;
111 | position: relative;
112 | text-align: center;
113 | -webkit-user-select: none;
114 | }
115 | .message-dayGap::before {
116 | bottom: 1em;
117 | background-color: #ccc;
118 | content: '';
119 | height: 1px;
120 | left: 0;
121 | position: absolute;
122 | right: 0;
123 | }
124 |
125 | .message-dayGap--date {
126 | background: white;
127 | border-radius: 0.5rem;
128 | padding: 0.2rem 0.8rem;
129 | position: relative;
130 | }
131 |
132 | .message-attachment {
133 | background-color: #eee;
134 | border: 1px solid #ccc;
135 | border-radius: 0.5em;
136 | color: #38a3f7;
137 | display: inline-block;
138 | font-size: 0.9em;
139 | margin-right: 0.5em;
140 | padding: 0.4em;
141 | text-decoration: none;
142 | }
--------------------------------------------------------------------------------
/src/contacts.cr:
--------------------------------------------------------------------------------
1 | require "db"
2 | require "file_utils"
3 | require "sqlite3"
4 |
5 | class Contacts
6 | getter email_index : Hash(String, String)
7 | getter phone_index : Hash(String, String)
8 |
9 | NORMALIZE_PHONE_REGEX = /\D/
10 |
11 | def initialize
12 | db_paths = copy_dbs!
13 | query = Queries::Contacts.new
14 | emails = {} of String => Array(String)
15 | phones = {} of String => Array(String)
16 | db_paths.each do |db_path|
17 | DB.open("sqlite3://#{db_path}") do |db|
18 | db.query(query.sql) do |rs|
19 | rs.each do
20 | row = rs.read(**query.read_types)
21 | email = row[:email]
22 | phone = row[:phone]
23 | first_name = row[:first_name]
24 | last_name = row[:last_name]
25 | name_key = "#{first_name} #{last_name}"
26 | if email && !email.blank?
27 | if (r = emails[name_key]?)
28 | r << email
29 | else
30 | emails[name_key] = [email]
31 | end
32 | end
33 | if phone && !phone.blank?
34 | normalized_phone = phone.gsub(NORMALIZE_PHONE_REGEX, "")
35 | if (r = phones[name_key]?)
36 | r << normalized_phone
37 | else
38 | phones[name_key] = [normalized_phone]
39 | end
40 | end
41 | end
42 | end
43 | end
44 | end
45 | @email_index = {} of String => String
46 | emails.each do |name, emails|
47 | emails.each { |email| @email_index[email] = name }
48 | end
49 | @phone_index = {} of String => String
50 | phones.each do |name, phones|
51 | phones.each { |phone| @phone_index[phone] = name }
52 | end
53 | end
54 |
55 | def name_from_email(name)
56 | email_index[name]?
57 | end
58 |
59 | def name_from_phone(phone)
60 | search_phone = phone.gsub(NORMALIZE_PHONE_REGEX, "")
61 | return nil if search_phone.blank?
62 | if result = phone_index[search_phone]?
63 | result
64 | else
65 | start_index = search_phone.size - 10
66 | return nil if start_index < 1
67 | phone_without_country = search_phone[start_index, 10]
68 | phone_index[phone_without_country]?
69 | end
70 | end
71 |
72 | private def sources_path
73 | raise "Could not determine HOME directory" unless ENV.has_key?("HOME")
74 | "#{ENV["HOME"]}/Library/Application Support/AddressBook/Sources"
75 | end
76 |
77 | private def source_database_paths
78 | Dir.children(sources_path).map do |source_uuid|
79 | "#{sources_path}/#{source_uuid}/AddressBook-v22.abcddb"
80 | end
81 | end
82 |
83 | private def copy_dbs!
84 | db_paths = source_database_paths
85 | db_paths.each_with_index do |original_path, index|
86 | destination_path = "/tmp/contacts-#{Process.pid}-#{index}.db"
87 | raise "#{original_path} does not exist" unless File.exists?(original_path)
88 | raise "#{original_path} is not a file" unless File.file?(original_path)
89 | FileUtils.cp(original_path, destination_path)
90 | end
91 | db_paths
92 | end
93 | end
94 |
--------------------------------------------------------------------------------