Commit f9d4c7cb authored by Matthew Pirocchi's avatar Matthew Pirocchi

Autocomplete addresses in To, CC, BCC fields: Closes #4284.

parent bb3fc220
--
-- Create ContactTable for autocompletion contacts. Data is migrated in vala upgrade hooks.
--
CREATE TABLE ContactTable (
id INTEGER PRIMARY KEY,
normalized_email TEXT NOT NULL,
real_name TEXT,
email TEXT UNIQUE NOT NULL,
highest_importance INTEGER NOT NULL
);
......@@ -19,6 +19,9 @@ engine/api/geary-account-information.vala
engine/api/geary-account-settings.vala
engine/api/geary-attachment.vala
engine/api/geary-composed-email.vala
engine/api/geary-contact.vala
engine/api/geary-contact-importance.vala
engine/api/geary-contact-store.vala
engine/api/geary-conversation.vala
engine/api/geary-conversation-monitor.vala
engine/api/geary-credentials.vala
......@@ -104,8 +107,10 @@ engine/imap/transport/imap-serializable.vala
engine/imap/transport/imap-serializer.vala
engine/imap-db/imap-db-account.vala
engine/imap-db/imap-db-contact.vala
engine/imap-db/imap-db-database.vala
engine/imap-db/imap-db-folder.vala
engine/imap-db/imap-db-message-addresses.vala
engine/imap-db/imap-db-message-row.vala
engine/imap-db/outbox/smtp-outbox-email-identifier.vala
engine/imap-db/outbox/smtp-outbox-email-properties.vala
......@@ -202,6 +207,7 @@ client/notification/notification-bubble.vala
client/notification/null-indicator.vala
client/ui/composer-window.vala
client/ui/contact-entry-completion.vala
client/ui/geary-login.vala
client/ui/email-entry.vala
client/ui/icon-factory.vala
......
......@@ -1055,7 +1055,8 @@ public class GearyController {
}
private void create_compose_window(Geary.ComposedEmail? prefill = null) {
ComposerWindow window = new ComposerWindow(prefill);
Geary.ContactStore? contact_store = account == null ? null : account.get_contact_store();
ComposerWindow window = new ComposerWindow(contact_store, prefill);
window.set_position(Gtk.WindowPosition.CENTER);
window.send.connect(on_send);
......
......@@ -132,8 +132,14 @@ public class ComposerWindow : Gtk.Window {
// garbage-collected.
private WebViewEditFixer edit_fixer;
private Gtk.UIManager ui;
public ComposerWindow(Geary.ComposedEmail? prefill = null) {
private ContactEntryCompletion[] contact_entry_completions;
public ComposerWindow(Geary.ContactStore? contact_store, Geary.ComposedEmail? prefill = null) {
contact_entry_completions = {
new ContactEntryCompletion(contact_store),
new ContactEntryCompletion(contact_store),
new ContactEntryCompletion(contact_store)
};
setup_drag_destination(this);
add_events(Gdk.EventMask.KEY_PRESS_MASK | Gdk.EventMask.KEY_RELEASE_MASK);
......@@ -151,11 +157,16 @@ public class ComposerWindow : Gtk.Window {
visible_on_attachment_drag_over_child = (Gtk.Widget) builder.get_object("visible_on_attachment_drag_over_child");
visible_on_attachment_drag_over.remove(visible_on_attachment_drag_over_child);
// TODO: It would be nicer to set the completions inside the EmailEntry constructor. But in
// testing, this can cause non-deterministic segfaults. Investigate why, and fix if possible.
to_entry = new EmailEntry();
to_entry.completion = contact_entry_completions[0];
(builder.get_object("to") as Gtk.EventBox).add(to_entry);
cc_entry = new EmailEntry();
cc_entry.completion = contact_entry_completions[1];
(builder.get_object("cc") as Gtk.EventBox).add(cc_entry);
bcc_entry = new EmailEntry();
bcc_entry.completion = contact_entry_completions[2];
(builder.get_object("bcc") as Gtk.EventBox).add(bcc_entry);
subject_entry = builder.get_object("subject") as Gtk.Entry;
Gtk.Alignment message_area = builder.get_object("message area") as Gtk.Alignment;
......
/* Copyright 2012 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
public class ContactEntryCompletion : Gtk.EntryCompletion {
// Sort column indices.
private const int SORT_COLUMN = 0;
private Gtk.ListStore list_store;
private enum Column {
CONTACT_OBJECT,
CONTACT_MARKUP_NAME,
LAST_KEY;
public static Type[] get_types() {
return {
typeof (Geary.Contact), // CONTACT_OBJECT
typeof (string), // CONTACT_MARKUP_NAME
typeof (string) // LAST_KEY
};
}
}
public ContactEntryCompletion(Geary.ContactStore? contact_store) {
list_store = new Gtk.ListStore.newv(Column.get_types());
list_store.set_sort_func(SORT_COLUMN, sort_func);
list_store.set_sort_column_id(SORT_COLUMN, Gtk.SortType.ASCENDING);
if (contact_store == null)
return;
foreach (Geary.Contact contact in contact_store.contacts)
add_contact(contact);
contact_store.contact_added.connect(on_contact_added);
contact_store.contact_updated.connect(on_contact_updated);
model = list_store;
set_match_func(completion_match_func);
Gtk.CellRendererText text_renderer = new Gtk.CellRendererText();
pack_start(text_renderer, true);
add_attribute(text_renderer, "markup", Column.CONTACT_MARKUP_NAME);
match_selected.connect(on_match_selected);
}
private void add_contact(Geary.Contact contact) {
string full_address = contact.get_rfc822_address().get_full_address();
Gtk.TreeIter iter;
list_store.append(out iter);
list_store.set(iter,
Column.CONTACT_OBJECT, contact,
Column.CONTACT_MARKUP_NAME, Markup.escape_text(full_address),
Column.LAST_KEY, "");
}
private void update_contact(Geary.Contact updated_contact) {
Gtk.TreeIter iter;
if (!list_store.get_iter_first(out iter))
return;
do {
if (get_contact(iter) != updated_contact)
continue;
Gtk.TreePath? path = list_store.get_path(iter);
if (path != null)
list_store.row_changed(path, iter);
return;
} while (list_store.iter_next(ref iter));
}
private void on_contact_added(Geary.Contact contact) {
add_contact(contact);
}
private void on_contact_updated(Geary.Contact contact) {
update_contact(contact);
}
private bool on_match_selected(Gtk.EntryCompletion sender, Gtk.TreeModel model, Gtk.TreeIter iter) {
string? full_address = get_full_address(iter);
if (full_address == null)
return false;
Gtk.Entry? entry = sender.get_entry() as Gtk.Entry;
if (entry == null)
return false;
int current_address_index;
string current_address_remainder;
Gee.List<string> addresses = get_addresses(sender, out current_address_index, null,
out current_address_remainder);
addresses[current_address_index] = full_address;
if (!Geary.String.is_null_or_whitespace(current_address_remainder))
addresses.insert(current_address_index + 1, current_address_remainder);
string delimiter = ", ";
entry.text = concat_strings(addresses, delimiter);
int characters_seen_so_far = 0;
for (int i = 0; i <= current_address_index; i++)
characters_seen_so_far += addresses[i].char_count() + delimiter.char_count();
entry.set_position(characters_seen_so_far);
return true;
}
private Geary.Contact? get_contact(Gtk.TreeIter iter) {
GLib.Value contact_value;
list_store.get_value(iter, Column.CONTACT_OBJECT, out contact_value);
return contact_value.get_object() as Geary.Contact;
}
private string? get_full_address(Gtk.TreeIter iter) {
Geary.Contact? contact = get_contact(iter);
return contact == null ? null : contact.get_rfc822_address().get_full_address();
}
private bool completion_match_func(Gtk.EntryCompletion completion, string key, Gtk.TreeIter iter) {
// We don't use the provided key, because the user can enter multiple addresses.
int current_address_index;
string current_address_key;
get_addresses(completion, out current_address_index, out current_address_key);
Geary.Contact? contact = get_contact(iter);
if (contact == null)
return false;
string highlighted_result;
if (!match_prefix_contact(current_address_key, contact, out highlighted_result))
return false;
// Changing a row in the list store causes Gtk.EntryCompletion to re-evaluate
// completion_match_func for that row. Thus we need to make sure the key has
// actually changed before settings the highlighting--otherwise we will cause
// an infinite loop.
GLib.Value last_key_value;
list_store.get_value(iter, Column.LAST_KEY, out last_key_value);
string? last_key = last_key_value.get_string();
if (current_address_key != last_key) {
list_store.set(iter,
Column.CONTACT_MARKUP_NAME, highlighted_result,
Column.LAST_KEY, current_address_key, -1);
}
return true;
}
private Gee.List<string> get_addresses(Gtk.EntryCompletion completion,
out int current_address_index = null, out string current_address_key = null,
out string current_address_remainder = null) {
current_address_index = 0;
current_address_key = "";
current_address_remainder = "";
Gtk.Entry? entry = completion.get_entry() as Gtk.Entry;
Gee.List<string> empty_addresses = new Gee.ArrayList<string>();
empty_addresses.add("");
if (entry == null)
return empty_addresses;
int cursor_position = entry.cursor_position;
if (cursor_position < 0)
return empty_addresses;
string? original_text = entry.get_text();
if (original_text == null)
return empty_addresses;
Gee.List<string> addresses = new Gee.ArrayList<string>();
string delimiter = ",";
string[] addresses_array = original_text.split(delimiter);
foreach (string address in addresses_array)
addresses.add(address);
if (addresses.size < 1)
return empty_addresses;
int characters_seen_so_far = 0;
current_address_index = addresses.size - 1;
for (int i = 0; i < addresses.size; i++) {
int token_chars = addresses[i].char_count() + delimiter.char_count();
if ((characters_seen_so_far + token_chars) > cursor_position) {
current_address_index = i;
current_address_key = addresses[i]
.substring(0, cursor_position - characters_seen_so_far)
.strip().normalize().casefold();
current_address_remainder = addresses[i]
.substring(cursor_position - characters_seen_so_far).strip();
break;
}
characters_seen_so_far += token_chars;
}
return addresses;
}
// We could only add the delimiter *between* each string (i.e., don't add it after the last
// string). But it's easier for the user if they don't have to manually type a comma after
// adding each address. So we add the delimiter after every string.
private string concat_strings(Gee.List<string> strings, string delimiter) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < strings.size; i++) {
builder.append(strings[i]);
builder.append(delimiter);
}
return builder.str;
}
private bool match_prefix_contact(string needle, Geary.Contact contact,
out string highlighted_result = null) {
string email_result;
bool email_match = match_prefix_string(needle, contact.normalized_email, out email_result);
string real_name_result;
bool real_name_match = match_prefix_string(needle, contact.real_name, out real_name_result);
// email_result and real_name_result were already escaped, then <b></b> tags were added to
// highlight matches. We don't want to escape them again.
highlighted_result = contact.real_name == null ? email_result :
real_name_result + Markup.escape_text(" <") + email_result + Markup.escape_text(">");
return email_match || real_name_match;
}
private bool match_prefix_string(string needle, string? haystack = null,
out string highlighted_result = null) {
highlighted_result = "";
if (haystack == null)
return false;
string escaped_haystack = Markup.escape_text(haystack);
// Default result if there is no match or we encounter an error.
highlighted_result = escaped_haystack;
try {
string escaped_needle = Regex.escape_string(Markup.escape_text(needle.normalize()));
Regex regex = new Regex("\\b" + escaped_needle, RegexCompileFlags.CASELESS);
if (regex.match(escaped_haystack)) {
highlighted_result = regex.replace_eval(escaped_haystack, -1, 0, 0, eval_callback);
return true;
}
} catch (RegexError err) {
debug("Error matching regex: %s", err.message);
}
return false;
}
private bool eval_callback(MatchInfo match_info, StringBuilder result) {
string? match = match_info.fetch(0);
if (match != null) {
// The target was escaped before the regex was run against it, so we don't have to
// worry about markup injections here.
result.append("<b>%s</b>".printf(match));
}
return false;
}
private int sort_func(Gtk.TreeModel model, Gtk.TreeIter aiter, Gtk.TreeIter biter) {
// Order by importance, then by real name, then by email.
GLib.Value avalue, bvalue;
model.get_value(aiter, Column.CONTACT_OBJECT, out avalue);
model.get_value(biter, Column.CONTACT_OBJECT, out bvalue);
Geary.Contact? acontact = avalue.get_object() as Geary.Contact;
Geary.Contact? bcontact = bvalue.get_object() as Geary.Contact;
// Contacts can be null if the sort func is called between TreeModel.append and
// TreeModel.set.
if (acontact == bcontact)
return 0;
if (acontact == null && bcontact != null)
return -1;
if (acontact != null && bcontact == null)
return 1;
// First order by importance.
if (acontact.highest_importance > bcontact.highest_importance)
return -1;
if (acontact.highest_importance < bcontact.highest_importance)
return 1;
// Then order by real name.
string? anormalized_real_name = acontact.real_name == null ? null :
acontact.real_name.normalize().casefold();
string? bnormalized_real_name = bcontact.real_name == null ? null :
bcontact.real_name.normalize().casefold();
// strcmp correctly marks 'null' as first in lexigraphic order, so we don't need to
// special-case it.
int result = strcmp(anormalized_real_name, bnormalized_real_name);
if (result != 0)
return result;
// Finally, order by email.
return strcmp(acontact.normalized_email, bcontact.normalized_email);
}
}
......@@ -14,7 +14,6 @@ public class EmailEntry : Gtk.Entry {
public EmailEntry() {
changed.connect(on_changed);
// TODO: Contact completion with libfolks
}
private void on_changed() {
......
......@@ -36,6 +36,8 @@ public abstract class Geary.AbstractAccount : Object, Geary.Account {
public abstract async Gee.Collection<Geary.Folder> list_folders_async(Geary.FolderPath? parent,
Cancellable? cancellable = null) throws Error;
public abstract Geary.ContactStore get_contact_store();
public abstract async bool folder_exists_async(Geary.FolderPath path, Cancellable? cancellable = null)
throws Error;
......
......@@ -68,6 +68,11 @@ public interface Geary.Account : Object {
public abstract async Gee.Collection<Geary.Folder> list_folders_async(Geary.FolderPath? parent,
Cancellable? cancellable = null) throws Error;
/**
* Gets a perpetually update-to-date collection of autocompletion contacts.
*/
public abstract Geary.ContactStore get_contact_store();
/**
* Returns true if the folder exists.
*
......
/* Copyright 2012 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* Represents the relative "importance" of an occurance of a contact in a message.
*
* The first word (before the underscore) indicates where the account owner appeared in the
* message. The second word (after the underscore) indicates where the contact appeared in the
* message.
*
* || "Token" || "Definition" ||
* || FROM || appeared in the 'from' or 'sender' fields ||
* || TO || appeared in in the 'to' field ||
* || CC || appeared in the 'CC' or 'BCC' fields OR did not appear in any field (assuming BCC) ||
*
* "Examples:"
*
* || "Enum Value" || "Account Owner" || "Contact" ||
* || FROM_TO || Appeared in 'from' or 'sender' || Appeared in 'to' ||
* || CC_FROM || Appeared in 'CC', 'BCC', or did not appear || Appeared in 'from' or 'sender'. ||
*/
public enum Geary.ContactImportance {
FROM_FROM = 100,
FROM_TO = 90,
FROM_CC = 80,
TO_FROM = 70,
TO_TO = 60,
TO_CC = 50,
CC_FROM = 40,
CC_TO = 30,
CC_CC = 20,
// Minimum visibility for the contact to appear in autocompletion.
VISIBILITY_THRESHOLD = TO_TO;
}
/* Copyright 2012 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
public class Geary.ContactStore : Object {
public Gee.Collection<Contact> contacts {
owned get { return contact_map.values; }
}
private Gee.Map<string, Contact> contact_map;
public signal void contact_added(Contact contact);
public signal void contact_updated(Contact contact);
internal ContactStore() {
contact_map = new Gee.HashMap<string, Contact>();
}
public void update_contacts(Gee.Collection<Contact> new_contacts) {
foreach (Contact contact in new_contacts)
update_contact(contact);
}
private void update_contact(Contact contact) {
Contact? old_contact = contact_map[contact.normalized_email];
if (old_contact == null) {
contact_map[contact.normalized_email] = contact;
contact_added(contact);
} else if (old_contact.highest_importance < contact.highest_importance) {
old_contact.highest_importance = contact.highest_importance;
contact_updated(old_contact);
}
}
}
/* Copyright 2012 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
public class Geary.Contact : Object {
public string normalized_email { get; private set; }
public string email { get; private set; }
public string? real_name { get; private set; }
public int highest_importance { get; set; }
public Contact(string email, string? real_name, int highest_importance, string? normalized_email = null) {
this.normalized_email = normalized_email ?? email.normalize().casefold();
this.email = email;
this.real_name = real_name;
this.highest_importance = highest_importance;
}
public Contact.from_rfc822_address(RFC822.MailboxAddress address, int highest_importance) {
this(address.address, address.name, highest_importance);
}
public RFC822.MailboxAddress get_rfc822_address() {
return new RFC822.MailboxAddress(real_name, email);
}
}
......@@ -7,24 +7,16 @@
public class Geary.Db.VersionedDatabase : Geary.Db.Database {
public File schema_dir { get; private set; }
public virtual signal void pre_upgrade(int version) {
}
public virtual signal void post_upgrade(int version) {
}
public VersionedDatabase(File db_file, File schema_dir) {
base (db_file);
this.schema_dir = schema_dir;
}
protected virtual void notify_pre_upgrade(int version) {
pre_upgrade(version);
protected virtual void pre_upgrade(int version) {
}
protected virtual void notify_post_upgrade(int version) {
post_upgrade(version);
protected virtual void post_upgrade(int version) {
}
// TODO: Initialize database from version-001.sql and upgrade with version-nnn.sql
......@@ -48,7 +40,7 @@ public class Geary.Db.VersionedDatabase : Geary.Db.Database {
if (!upgrade_script.query_exists(cancellable))
break;
notify_pre_upgrade(db_version);
pre_upgrade(db_version);
check_cancelled("VersionedDatabase.open", cancellable);
......@@ -66,7 +58,7 @@ public class Geary.Db.VersionedDatabase : Geary.Db.Database {
throw err;
}
notify_post_upgrade(db_version);
post_upgrade(db_version);
}
}
}
......
......@@ -18,14 +18,21 @@ private class Geary.ImapDB.Account : Object {
// Only available when the Account is opened
public SmtpOutboxFolder? outbox { get; private set; default = null; }
// TODO: This should be updated when Geary no longer assumes username is email.
public string account_owner_email {
get { return settings.credentials.user; }
}
private string name;
private AccountSettings settings;
private ImapDB.Database? db = null;
private Gee.HashMap<Geary.FolderPath, FolderReference> folder_refs =
new Gee.HashMap<Geary.FolderPath, FolderReference>(Hashable.hash_func, Equalable.equal_func);
public ContactStore contact_store { get; private set; }
public Account(Geary.AccountSettings settings) {
this.settings = settings;
contact_store = new ContactStore();
name = "IMAP database account for %s".printf(settings.credentials.user);
}
......@@ -40,9 +47,7 @@ private class Geary.ImapDB.Account : Object {
if (db != null)
throw new EngineError.ALREADY_OPEN("IMAP database already open");
db = new ImapDB.Database(user_data_dir, schema_dir);
db.pre_upgrade.connect(on_pre_upgrade);
db.post_upgrade.connect(on_post_upgrade);
db = new ImapDB.Database(user_data_dir, schema_dir, account_owner_email);
try {
db.open(Db.DatabaseFlags.CREATE_DIRECTORY | Db.DatabaseFlags.CREATE_FILE, null,
......@@ -56,6 +61,8 @@ private class Geary.ImapDB.Account : Object {
throw err;
}
initialize_contacts(cancellable);
// ImapDB.Account holds the Outbox, which is tied to the database it maintains
outbox = new SmtpOutboxFolder(db, settings);
......@@ -161,6 +168,39 @@ private class Geary.ImapDB.Account : Object {
db_folder.set_properties(properties);
}
private void initialize_contacts(Cancellable? cancellable = null) throws Error {
check_open();
Gee.Collection<Contact> contacts = new Gee.LinkedList<Contact>();
Db.TransactionOutcome outcome = db.exec_transaction(Db.TransactionType.RO,
(context) => {
Db.Statement statement = context.prepare(
"SELECT email, real_name, highest_importance, normalized_email " +
"FROM ContactTable WHERE highest_importance >= ?");
statement.bind_int(0, ContactImportance.VISIBILITY_THRESHOLD);
Db.Result result = statement.exec(cancellable);
while (!result.finished) {
try {
Contact contact = new Contact(result.string_at(0), result.string_at(1),
result.int_at(2), result.string_at(3));
contacts.add(contact);
} catch (Geary.DatabaseError err) {
// We don't want to abandon loading all contacts just because there was a
// problem with one.
debug("Problem loading contact: %s", err.message);
}
result.next();
}
return Db.TransactionOutcome.DONE;
}, cancellable);
if (outcome == Db.TransactionOutcome.DONE)
contact_store.update_contacts(contacts);
}
public async Gee.Collection<Geary.ImapDB.Folder> list_folders_async(Geary.FolderPath? parent,
Cancellable? cancellable = null) throws Error {
check_open();
......@@ -321,7 +361,8 @@ private class Geary.ImapDB.Account : Object {
}
// create folder
folder = new Geary.ImapDB.Folder(db, path, folder_id, properties);
folder = new Geary.ImapDB.Folder(db, path, contact_store, account_owner_email, folder_id,
properties);
// build a reference to it
FolderReference folder_ref = new FolderReference(folder, path);
......@@ -340,14 +381,6 @@ private class Geary.ImapDB.Account : Object {
folder_refs.unset(folder_ref.path);
}
private void on_pre_upgrade(int version){
// TODO Add per-version data massaging.
}
private void on_post_upgrade(int version) {
// TODO Add per-version data massaging.
}
private void clear_duplicate_folders() {
int count = 0;
......
/* Copyright 2011-2012 Yorba Foundation
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
namespace Geary.ImapDB {
private static void do_update_contact_importance(Db.Connection connection, Contact contact,
Cancellable? cancellable = null) throws Error {
// TODO: Don't overwrite a non-null real_name with a null real_name.
Db.Statement statement = connection.prepare(
"INSERT OR REPLACE INTO ContactTable(normalized_email, email, real_name, highest_importance)
VALUES(?, ?, ?, MAX(COALESCE((SELECT highest_importance FROM ContactTable
WHERE email=?1), -1), ?))");
statement.bind_string(0, contact.normalized_email);
statement.bind_string(1, contact.email);
statement.bind_string(2, contact.real_name);
statement.bind_int(3, contact.highest_importance);
statement.exec(cancellable);
}
}