1 #!/usr/bin/env ruby
2
3 # addrbook - A contact management program.
4 #
5 # See README for the required directory structure at ADDRBOOK_PATH.
6 # I'm hard-coding my path as the fallback.
7 $mydir = ( ENV['ADDRBOOK_PATH'] || '/home/dave/doc/addrbook' ).chomp('/')
8
9 # Determine text editing program from environment, vim as fallback.
10 $editor = ENV['VISUAL'] || ENV['EDITOR'] || 'vim'
11
12 # Determine text viewing program from env or less as fallback
13 $viewer = ENV['PAGER'] || 'less'
14
15 # This adds the getch method to STDIN so I can do "Press any key..."
16 require 'io/console'
17
18 # Requires installing sqlite3 extension, e.g.: 'gem install sqlite3'
19 require 'sqlite3'
20
21 db_path = "#{$mydir}/addrbook.db"
22 $db = SQLite3::Database.new db_path
23
24 def die_with(msg)
25 puts msg
26 # Because this was intended to work from within aerc and the 'term' command
27 # doesn't have an option to keep the terminal open after the given command
28 # exits (which is normally what I want), keep open until user hits a key so
29 # they can read the error message.
30 puts "Press any key to continue..."
31 STDIN.getch
32 exit 1
33 end
34
35 def find_by_email(email)
36 rows = $db.execute('SELECT contact_id FROM email WHERE email = ?', [email])
37
38 if rows.count < 1
39 return nil
40 end
41
42 # id field from first (only) row
43 return rows[0][0]
44 end
45
46 def find_or_die(email)
47 id = find_by_email(email)
48 if !id
49 die_with "Could not find a contact with email '#{email}'"
50 end
51
52 return id
53 end
54
55 def add_contact(email, name)
56 sql = "INSERT INTO contact (name) VALUES (?) returning contact_id"
57 contact_row = $db.execute(sql, [name])
58 id = contact_row[0][0]
59
60 sql = "INSERT INTO email (contact_id, email) VALUES (?, ?)"
61 $db.execute(sql, [id, email])
62
63 return id
64 end
65
66 def make_contact_fname(id)
67 return "#{$mydir}/txt/#{id}.txt"
68 end
69
70 def open_as_txt(id, open_with)
71 sql = "SELECT contact_id, name, added, notes,
72 (select string_agg(email, ' % ') from email where contact_id = c.contact_id),
73 (select string_agg(phone, ' % ') from phone where contact_id = c.contact_id),
74 (select string_agg(url, ' % ') from url where contact_id = c.contact_id)
75 FROM contact c
76 WHERE c.contact_id = ?"
77 row = $db.execute(sql, [id])[0] # first row
78 id = row[0]
79 name = row[1]
80 added = row[2]
81 notes = row[3]
82 emails = row[4]
83 phones = row[5]
84 urls = row[6]
85
86 # Split relation items or make blank placeholders for empty ones
87 if !emails then emails = [''] else emails = emails.split(' % ') end
88 if !phones then phones = [''] else phones = phones.split(' % ') end
89 if !urls then urls = [''] else urls = urls.split(' % ') end
90
91 contact_fname = make_contact_fname(id)
92 File.open(contact_fname, 'w') do |f|
93 f.puts "Contact: #{id}"
94 f.puts " Name: #{name}"
95 f.puts " Added: #{added}"
96 emails.each {|email| f.puts " Email: #{email}" }
97 phones.each {|phone| f.puts " Phone: #{phone}" }
98 urls.each {|url| f.puts " URL: #{url}" }
99 f.puts
100 f.puts notes
101 end
102
103 # Now open with the supplied application or command
104 system("#{open_with} #{contact_fname}")
105 end
106
107 # Read txt file and import changes into *existing* DB record.
108 def import_txt(id)
109 contact_fname = make_contact_fname(id)
110 extract_field = /\s*([^:]+):\s*(.*)/;
111 linenum = 0
112 in_notes = false
113 name = nil
114 added = nil
115 notes = nil
116 emails = []
117 phones = []
118 urls = []
119 hint = "You can fix this by editing '#{contact_fname}' and then run 'addrbook reimport #{id}' to avoid losing your changes."
120
121 # Process txt lines
122 File.foreach(contact_fname) do |line|
123 line.chomp!
124 linenum += 1
125
126 # Gather notes lines together
127 if notes then
128 notes = "#{notes}#{line}\n"
129 next
130 end
131 # Blank line starts notes
132 if line == '' then notes = '' ; next end
133
134 x = extract_field.match(line)
135 if !x
136 die_with "Did not understand line #{linenum}: #{line}\n#{hint}"
137 end
138 field = x[1]
139 value = x[2] == '' ? nil : x[2]
140
141 if field == 'Contact'
142 # Sanity check
143 if Integer(value) != Integer(id)
144 die_with "Problem with line #{linenum}: Contact '#{value}' doesn't match expected '#{id}'.\n#{hint}"
145 end
146 elsif field == 'Name'
147 name = value
148 elsif field == 'Added'
149 added = value
150 elsif field == 'Email'
151 if value then emails.push value end
152 elsif field == 'Phone'
153 if value then phones.push value end
154 elsif field == 'URL'
155 if value then urls.push value end
156 else
157 die_with "Problem with line #{linenum}: Did not recognize field '#{field}'\n#{hint}"
158 end
159 end
160
161 # Update DB!
162 # Start a SQLite3 transaction so that re-creation of entries is atomic.
163 $db.transaction do |tdb|
164 tdb.execute('DELETE FROM email WHERE contact_id = ?', [id])
165 tdb.execute('DELETE FROM phone WHERE contact_id = ?', [id])
166 tdb.execute('DELETE FROM url WHERE contact_id = ?', [id])
167 sql = "UPDATE contact SET
168 name = ?,
169 added = ?,
170 notes = ?
171 WHERE contact_id = ?"
172 tdb.execute(sql, [name, added, notes, id])
173 emails.each do |email|
174 sql = "INSERT INTO email (contact_id, email) VALUES (?, ?)"
175 tdb.execute(sql, [id, email])
176 end
177 phones.each do |phone|
178 sql = "INSERT INTO phone (contact_id, phone) VALUES (?, ?)"
179 tdb.execute(sql, [id, phone])
180 end
181 urls.each do |url|
182 sql = "INSERT INTO url (contact_id, url) VALUES (?, ?)"
183 tdb.execute(sql, [id, url])
184 end
185 end
186 end
187
188 if ARGV[0] == 'add'
189 # Both params are optional and could be nil:
190 email = ARGV[1]
191 name = ARGV[2]
192
193 if email
194 id = find_by_email(email)
195 if id
196 die_with "Contact already exists with email address '#{email}'."
197 end
198 end
199
200 # Create the contact and open for editing
201 id = add_contact(email, name)
202 open_as_txt(id, $editor)
203 import_txt id
204 exit
205 end
206
207 # Used by the next three commands
208 def get_id_from_arg
209 if(Integer(ARGV[1]) rescue false)
210 id = ARGV[1]
211 else
212 id = find_or_die(ARGV[1])
213 end
214
215 return id
216 end
217
218
219 if ARGV[0] == 'edit'
220 id = get_id_from_arg
221 open_as_txt(id, $editor)
222 import_txt id
223 exit
224 end
225
226 if ARGV[0] == 'view'
227 id = get_id_from_arg
228 open_as_txt(id, $viewer)
229 exit
230 end
231
232 if ARGV[0] == 'cat'
233 id = get_id_from_arg
234 open_as_txt(id, 'cat')
235 exit
236 end
237
238 if ARGV[0] == 'reimport'
239 id = Integer(ARGV[1]) rescue false
240 if !id
241 die_with "The id of the txt entry to import must be a number (e.g. 1, 16, ...)"
242 end
243
244 import_txt id
245 exit
246 end
247
248 if ARGV[0] == 'list'
249 # Grouping will mean we only get first email addr.
250 sql = "SELECT contact_id, name, email
251 FROM contact
252 LEFT JOIN email USING (contact_id)
253 GROUP BY contact_id"
254 $db.execute(sql) do |row|
255 puts "%3s %-30s %s" % row
256 end
257 exit
258 end
259
260 # If we got this far, no valid command given
261 die_with "Usage: addrbook <command> [params]
262
263 Available commands:
264 add [email addr] [name] Create new contact entry
265 edit <email addr | ID> Update contact as text, import changes.
266 view <email addr | ID> Display contact as text with pager
267 cat <email addr | ID> Output contact as text (to 'cat')
268 reimport <contact number> Updates contact DB entry from text.
269 list Display list of current contacts.
270 autocomplete-email <fuzzy> TODO
271
272 The 'edit' command is the normal way to update a contact. It:
273
274 1. Writes the database contents of the entry to a text file.
275 2. Opens the text file in your editor.
276 3. Imports the edited contact information back to the database.
277
278 The 'reimport' command performs only Step 3 and exists mostly for
279 recovery if the edit process was interrupted.
280 "