├── .travis.yml ├── .gitignore ├── src ├── queries.cr ├── version.cr ├── orm.cr ├── .DS_Store ├── models │ ├── attachment.cr │ ├── user.cr │ ├── chat.cr │ └── message.cr ├── views │ ├── layout.ecr │ ├── index.ecr │ └── messages.ecr ├── time.cr ├── app.cr ├── presenters │ └── message_presenter.cr ├── orm │ ├── chats.cr │ └── messages.cr ├── queries │ ├── all_chats.cr │ ├── contacts.cr │ └── chat_messages.cr ├── database.cr └── contacts.cr ├── spec ├── spec_helper.cr └── messages_browser_spec.cr ├── .DS_Store ├── .editorconfig ├── shard.yml ├── shard.lock ├── README.md ├── LICENSE └── public └── index.css /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | .DS_Store 6 | -------------------------------------------------------------------------------- /src/queries.cr: -------------------------------------------------------------------------------- 1 | require "./queries/*" 2 | 3 | module Queries 4 | end 5 | -------------------------------------------------------------------------------- /src/version.cr: -------------------------------------------------------------------------------- 1 | module MessagesBrowser 2 | VERSION = "0.1.0" 3 | end 4 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/messages_browser" 3 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feifanzhou/messages-browser/HEAD/.DS_Store -------------------------------------------------------------------------------- /src/orm.cr: -------------------------------------------------------------------------------- 1 | require "./models/*" 2 | require "./orm/*" 3 | 4 | module ORM 5 | end 6 | -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feifanzhou/messages-browser/HEAD/src/.DS_Store -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cr] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /spec/messages_browser_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe MessagesBrowser do 4 | # TODO: Write tests 5 | 6 | it "works" do 7 | false.should eq(true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /src/models/attachment.cr: -------------------------------------------------------------------------------- 1 | struct Attachment 2 | getter name : String 3 | getter file_path : String 4 | getter bytes : Int32 5 | 6 | def initialize(@name, @file_path, @bytes) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /src/views/layout.ecr: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mac Messages Browser 5 | 6 | 7 | 8 |
9 | <%= content %> 10 |
11 | -------------------------------------------------------------------------------- /src/models/user.cr: -------------------------------------------------------------------------------- 1 | struct User 2 | @@contacts = Contacts.new 3 | 4 | getter id : String 5 | 6 | def initialize(@id) 7 | end 8 | 9 | def ==(other) 10 | other.id == self.id 11 | end 12 | 13 | def display_name 14 | @@contacts.name_from_email(id) || 15 | @@contacts.name_from_phone(id) || 16 | id 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/time.cr: -------------------------------------------------------------------------------- 1 | struct Time 2 | # Midnight on Jan 1, 2001 3 | # Seconds after UNIX epoch 4 | MAC_EPOCH = 978307200 5 | 6 | def self.from_mac_nanoseconds(timestamp : Int64) 7 | from_mac_seconds(timestamp // 1_000_000_000) 8 | end 9 | 10 | def self.from_mac_seconds(timestamp : Int64) 11 | unix(MAC_EPOCH + timestamp) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /src/views/index.ecr: -------------------------------------------------------------------------------- 1 |
2 |
    3 | <% chats.each do |chat| %> 4 |
  1. 5 | <%= chat.display_name %> 6 |
  2. 7 | <% end %> 8 |
9 |
10 |
11 |

12 | Select a chat to see messages 13 |

14 |
15 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: messages_browser 2 | version: 0.1.0 3 | 4 | authors: 5 | - Feifan Zhou 6 | 7 | targets: 8 | messages_browser: 9 | main: src/messages_browser.cr 10 | 11 | crystal: 1.5.0 12 | 13 | license: MIT 14 | 15 | dependencies: 16 | kemal: 17 | github: kemalcr/kemal 18 | version: 1.2.0 19 | sqlite3: 20 | github: crystal-lang/crystal-sqlite3 21 | -------------------------------------------------------------------------------- /src/models/chat.cr: -------------------------------------------------------------------------------- 1 | struct Chat 2 | getter row_id : Int32 3 | getter id : String 4 | getter service : String 5 | getter participants : Set(User) 6 | 7 | def initialize(@row_id, @id, @service) 8 | @participants = Set(User).new 9 | end 10 | 11 | def add_participant(user) 12 | participants << user 13 | end 14 | 15 | def display_name 16 | participants.map(&.display_name).join(", ") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/models/message.cr: -------------------------------------------------------------------------------- 1 | struct Message 2 | getter row_id : Int32 3 | getter text : String 4 | getter time : Time 5 | getter sender : User 6 | getter from_me : Bool 7 | property attachments : Array(Attachment) 8 | 9 | def initialize(@row_id, @text, @time, @sender, @from_me, @attachments) 10 | end 11 | 12 | def local_time 13 | time.to_local 14 | end 15 | 16 | def mine? 17 | from_me 18 | end 19 | 20 | def has_attachments? 21 | attachments.size > 0 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /src/app.cr: -------------------------------------------------------------------------------- 1 | require "kemal" 2 | require "./*" 3 | require "./presenters/*" 4 | 5 | print "Starting…\n" 6 | print "Opening database…\r" 7 | db = Database.new 8 | print "\e[KDatabase ready\n" 9 | 10 | get "/" do 11 | chats = db.all_chats 12 | render "src/views/index.ecr", "src/views/layout.ecr" 13 | end 14 | 15 | get "/chat/:chat_id" do |env| 16 | chats = db.all_chats 17 | chat_id = env.params.url["chat_id"].try(&.to_i) 18 | messages = db.chat_messages(chat_id) 19 | render "src/views/messages.ecr", "src/views/layout.ecr" 20 | end 21 | 22 | Kemal.run do |config| 23 | config.port = ENV.fetch("PORT", "3000").to_i 24 | end 25 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | backtracer: 4 | git: https://github.com/sija/backtracer.cr.git 5 | version: 1.2.1 6 | 7 | db: 8 | git: https://github.com/crystal-lang/crystal-db.git 9 | version: 0.7.0 10 | 11 | exception_page: 12 | git: https://github.com/crystal-loot/exception_page.git 13 | version: 0.2.2 14 | 15 | kemal: 16 | git: https://github.com/kemalcr/kemal.git 17 | version: 1.2.0 18 | 19 | radix: 20 | git: https://github.com/luislavena/radix.git 21 | version: 0.4.1 22 | 23 | sqlite3: 24 | git: https://github.com/crystal-lang/crystal-sqlite3.git 25 | version: 0.14.0 26 | 27 | -------------------------------------------------------------------------------- /src/presenters/message_presenter.cr: -------------------------------------------------------------------------------- 1 | struct MessagePresenter 2 | getter message : Message 3 | 4 | def initialize(@message) 5 | end 6 | 7 | def display_html 8 | if message.has_attachments? 9 | attachments_html 10 | else 11 | HTML.escape(message.text) 12 | end 13 | end 14 | 15 | private def attachments_html 16 | message.attachments.map do |attachment| 17 | "" \ 19 | "#{attachment.name} (#{attachment.bytes} bytes)" \ 20 | "" 21 | end.join 22 | end 23 | 24 | private def href_for_attachment(attachment) 25 | path = attachment.file_path 26 | .gsub("~", ENV["HOME"]) 27 | .gsub(" ", "%20") 28 | "file://#{path}" 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /src/orm/chats.cr: -------------------------------------------------------------------------------- 1 | module ORM 2 | class Chats 3 | getter db : DB::Database 4 | getter query : Queries::AllChats 5 | 6 | def initialize(@db, @query) 7 | end 8 | 9 | def objects 10 | db.query(query.sql) do |rs| 11 | chats = {} of String => Chat 12 | rs.each do 13 | row = rs.read(**query.read_types) 14 | row_id = row[:row_id] 15 | chat_id = row[:chat_identifier] 16 | service = row[:service_name] 17 | user_id = row[:handle_id] 18 | if (chat = chats[chat_id]?) 19 | chat.add_participant(User.new(user_id)) 20 | else 21 | chat = Chat.new(row_id, chat_id, service) 22 | chat.add_participant(User.new(user_id)) 23 | chats[chat_id] = chat 24 | end 25 | end 26 | chats.values 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Tested with Crystal 1.5.0 on macOS 12.4 2 | 3 | # Running this project 4 | 5 | 1. Ensure your Terminal has [Full Disk Access](http://osxdaily.com/2018/10/09/fix-operation-not-permitted-terminal-error-macos/), since this app will try to open system files. 6 | 2. Run `crystal run src/app.cr`. 7 | 8 | If you run into a `ld: library not found for -lssl` error while compiling, [try this](https://github.com/brianmario/mysql2/issues/795#issuecomment-337006164) 9 | 10 | ## Contributing 11 | 12 | 1. Fork it ( https://github.com/feifanzhou/messages_browser/fork ) 13 | 2. Create your feature branch (git checkout -b my-new-feature) 14 | 3. Commit your changes (git commit -am 'Add some feature') 15 | 4. Push to the branch (git push origin my-new-feature) 16 | 5. Create a new Pull Request 17 | 18 | ## Contributors 19 | 20 | - [feifanzhou](https://github.com/feifanzhou) Feifan Zhou - creator, maintainer 21 | -------------------------------------------------------------------------------- /src/queries/all_chats.cr: -------------------------------------------------------------------------------- 1 | module Queries 2 | struct AllChats 3 | # Table aliases: 4 | # C => chat 5 | # CHJ => chat_handle_join 6 | # H => handle 7 | 8 | def sql 9 | "#{select_clause} #{from_clause};" 10 | end 11 | 12 | def read_types 13 | { 14 | row_id: Int32, 15 | chat_identifier: String, 16 | service_name: String, 17 | handle_id: String, 18 | } 19 | end 20 | 21 | private def fields 22 | %w[ 23 | C.ROWID 24 | C.chat_identifier 25 | C.service_name 26 | H.id 27 | ] 28 | end 29 | 30 | private def select_clause 31 | "SELECT #{fields.join(", ")}" 32 | end 33 | 34 | private def from_clause 35 | "FROM chat C " \ 36 | "JOIN chat_handle_join CHJ ON C.ROWID = CHJ.chat_id " \ 37 | "JOIN handle H ON CHJ.handle_id = H.ROWID" 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/queries/contacts.cr: -------------------------------------------------------------------------------- 1 | module Queries 2 | struct Contacts 3 | # Table aliases: 4 | # R => ZABCDRecord 5 | # P => ZABCDPHONENUMBER 6 | # E => ZABCDEMAILADDRESS 7 | 8 | def sql 9 | "#{select_clause} #{from_clause};" 10 | end 11 | 12 | def read_types 13 | { 14 | email: String?, 15 | phone: String?, 16 | first_name: String?, 17 | last_name: String?, 18 | } 19 | end 20 | 21 | private def fields 22 | %w[ 23 | E.ZADDRESSNORMALIZED 24 | P.ZFULLNUMBER 25 | R.ZFIRSTNAME 26 | R.ZLASTNAME 27 | ] 28 | end 29 | 30 | private def select_clause 31 | "SELECT #{fields.join(", ")}" 32 | end 33 | 34 | private def from_clause 35 | "FROM ZABCDRECORD R " \ 36 | "LEFT JOIN ZABCDPHONENUMBER P " \ 37 | "ON P.ZOWNER = R.Z_PK " \ 38 | "LEFT JOIN ZABCDEMAILADDRESS E " \ 39 | "ON E.ZOWNER = R.Z_PK" 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Feifan Zhou 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. 22 | -------------------------------------------------------------------------------- /src/views/messages.ecr: -------------------------------------------------------------------------------- 1 |
2 |
    3 | <% chats.each do |chat| %> 4 |
  1. "> 5 | <%= chat.display_name %> 6 |
  2. 7 | <% end %> 8 |
9 |
10 |
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 | --------------------------------------------------------------------------------