From f9d4c7cb9e1a34d50cfc88a5c95dd9e4dfc339db Mon Sep 17 00:00:00 2001 From: Matthew Pirocchi Date: Fri, 20 Jul 2012 17:51:28 -0700 Subject: [PATCH] Autocomplete addresses in To, CC, BCC fields: Closes #4284. --- sql/version-005.sql | 13 + src/CMakeLists.txt | 6 + src/client/geary-controller.vala | 3 +- src/client/ui/composer-window.vala | 13 +- src/client/ui/contact-entry-completion.vala | 306 ++++++++++++++++++ src/client/ui/email-entry.vala | 1 - .../abstract/geary-abstract-account.vala | 2 + src/engine/api/geary-account.vala | 5 + src/engine/api/geary-contact-importance.vala | 38 +++ src/engine/api/geary-contact-store.vala | 37 +++ src/engine/api/geary-contact.vala | 27 ++ src/engine/db/db-versioned-database.vala | 18 +- src/engine/imap-db/imap-db-account.vala | 57 +++- src/engine/imap-db/imap-db-contact.vala | 25 ++ src/engine/imap-db/imap-db-database.vala | 21 +- src/engine/imap-db/imap-db-folder.vala | 51 ++- .../imap-db/imap-db-message-addresses.vala | 134 ++++++++ .../imap-engine-generic-account.vala | 4 + src/engine/rfc822/rfc822-mailbox-address.vala | 2 +- .../rfc822/rfc822-mailbox-addresses.vala | 14 + 20 files changed, 735 insertions(+), 42 deletions(-) create mode 100644 sql/version-005.sql create mode 100644 src/client/ui/contact-entry-completion.vala create mode 100644 src/engine/api/geary-contact-importance.vala create mode 100644 src/engine/api/geary-contact-store.vala create mode 100644 src/engine/api/geary-contact.vala create mode 100644 src/engine/imap-db/imap-db-contact.vala create mode 100644 src/engine/imap-db/imap-db-message-addresses.vala diff --git a/sql/version-005.sql b/sql/version-005.sql new file mode 100644 index 00000000..1b154890 --- /dev/null +++ b/sql/version-005.sql @@ -0,0 +1,13 @@ + +-- +-- 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 +); + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9cae315d..743df2f1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/client/geary-controller.vala b/src/client/geary-controller.vala index b7425aea..7f3e96e9 100644 --- a/src/client/geary-controller.vala +++ b/src/client/geary-controller.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); diff --git a/src/client/ui/composer-window.vala b/src/client/ui/composer-window.vala index 889e0002..12348e93 100644 --- a/src/client/ui/composer-window.vala +++ b/src/client/ui/composer-window.vala @@ -132,8 +132,14 @@ public class ComposerWindow : Gtk.Window { // garbage-collected. private WebViewEditFixer edit_fixer; private Gtk.UIManager ui; + private ContactEntryCompletion[] contact_entry_completions; - public ComposerWindow(Geary.ComposedEmail? prefill = null) { + 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; diff --git a/src/client/ui/contact-entry-completion.vala b/src/client/ui/contact-entry-completion.vala new file mode 100644 index 00000000..b29b2a3f --- /dev/null +++ b/src/client/ui/contact-entry-completion.vala @@ -0,0 +1,306 @@ +/* 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 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 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 empty_addresses = new Gee.ArrayList(); + 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 addresses = new Gee.ArrayList(); + 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 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 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("%s".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); + } +} + diff --git a/src/client/ui/email-entry.vala b/src/client/ui/email-entry.vala index af7eadf3..5478ff17 100644 --- a/src/client/ui/email-entry.vala +++ b/src/client/ui/email-entry.vala @@ -14,7 +14,6 @@ public class EmailEntry : Gtk.Entry { public EmailEntry() { changed.connect(on_changed); - // TODO: Contact completion with libfolks } private void on_changed() { diff --git a/src/engine/abstract/geary-abstract-account.vala b/src/engine/abstract/geary-abstract-account.vala index d7105e8f..4319dc41 100644 --- a/src/engine/abstract/geary-abstract-account.vala +++ b/src/engine/abstract/geary-abstract-account.vala @@ -36,6 +36,8 @@ public abstract class Geary.AbstractAccount : Object, Geary.Account { public abstract async Gee.Collection 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; diff --git a/src/engine/api/geary-account.vala b/src/engine/api/geary-account.vala index 8e01e20e..51fddef2 100644 --- a/src/engine/api/geary-account.vala +++ b/src/engine/api/geary-account.vala @@ -68,6 +68,11 @@ public interface Geary.Account : Object { public abstract async Gee.Collection 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. * diff --git a/src/engine/api/geary-contact-importance.vala b/src/engine/api/geary-contact-importance.vala new file mode 100644 index 00000000..969f734e --- /dev/null +++ b/src/engine/api/geary-contact-importance.vala @@ -0,0 +1,38 @@ +/* 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; +} diff --git a/src/engine/api/geary-contact-store.vala b/src/engine/api/geary-contact-store.vala new file mode 100644 index 00000000..8d15b41d --- /dev/null +++ b/src/engine/api/geary-contact-store.vala @@ -0,0 +1,37 @@ +/* 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 contacts { + owned get { return contact_map.values; } + } + + private Gee.Map contact_map; + + public signal void contact_added(Contact contact); + + public signal void contact_updated(Contact contact); + + internal ContactStore() { + contact_map = new Gee.HashMap(); + } + + public void update_contacts(Gee.Collection 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); + } + } +} diff --git a/src/engine/api/geary-contact.vala b/src/engine/api/geary-contact.vala new file mode 100644 index 00000000..527d00fb --- /dev/null +++ b/src/engine/api/geary-contact.vala @@ -0,0 +1,27 @@ +/* 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); + } +} diff --git a/src/engine/db/db-versioned-database.vala b/src/engine/db/db-versioned-database.vala index c6aeac20..ba308ba0 100644 --- a/src/engine/db/db-versioned-database.vala +++ b/src/engine/db/db-versioned-database.vala @@ -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); } } } diff --git a/src/engine/imap-db/imap-db-account.vala b/src/engine/imap-db/imap-db-account.vala index fdb6409a..56a493f3 100644 --- a/src/engine/imap-db/imap-db-account.vala +++ b/src/engine/imap-db/imap-db-account.vala @@ -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 folder_refs = new Gee.HashMap(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 contacts = new Gee.LinkedList(); + 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 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; diff --git a/src/engine/imap-db/imap-db-contact.vala b/src/engine/imap-db/imap-db-contact.vala new file mode 100644 index 00000000..2f12be25 --- /dev/null +++ b/src/engine/imap-db/imap-db-contact.vala @@ -0,0 +1,25 @@ +/* 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); +} + +} + diff --git a/src/engine/imap-db/imap-db-database.vala b/src/engine/imap-db/imap-db-database.vala index 07f80753..638e6054 100644 --- a/src/engine/imap-db/imap-db-database.vala +++ b/src/engine/imap-db/imap-db-database.vala @@ -7,9 +7,11 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase { private const string DB_FILENAME = "geary.db"; private const int BUSY_TIMEOUT_MSEC = Db.Connection.RECOMMENDED_BUSY_TIMEOUT_MSEC; + private string account_owner_email; - public Database(File db_dir, File schema_dir) { + public Database(File db_dir, File schema_dir, string account_owner_email) { base (db_dir.get_child(DB_FILENAME), schema_dir); + this.account_owner_email = account_owner_email; } public override void open(Db.DatabaseFlags flags, Db.PrepareConnection? prepare_cb, @@ -22,6 +24,23 @@ private class Geary.ImapDB.Database : Geary.Db.VersionedDatabase { base.open(flags, on_prepare_database_connection, cancellable); } + protected override void post_upgrade(int version) { + if (version == 5) { + try { + Db.Result result = query("SELECT sender, from_field, to_field, cc, bcc FROM MessageTable"); + while (!result.finished) { + MessageAddresses message_addresses = + new MessageAddresses.from_result(account_owner_email, result); + foreach (Contact contact in message_addresses.contacts) + do_update_contact_importance(get_master_connection(), contact); + result.next(); + } + } catch (Error err) { + debug("Error population autocompletion table during upgrade to database schema 5"); + } + } + } + private void on_prepare_database_connection(Db.Connection cx) throws Error { cx.set_busy_timeout_msec(BUSY_TIMEOUT_MSEC); cx.set_foreign_keys(true); diff --git a/src/engine/imap-db/imap-db-folder.vala b/src/engine/imap-db/imap-db-folder.vala index 7e8626cf..7b3e4851 100644 --- a/src/engine/imap-db/imap-db-folder.vala +++ b/src/engine/imap-db/imap-db-folder.vala @@ -55,15 +55,19 @@ private class Geary.ImapDB.Folder : Object, Geary.ReferenceSemantics { private ImapDB.Database db; private Geary.FolderPath path; + private ContactStore contact_store; + private string account_owner_email; private int64 folder_id; private Geary.Imap.FolderProperties? properties; private Gee.HashSet marked_removed = new Gee.HashSet( Hashable.hash_func, Equalable.equal_func); - - internal Folder(ImapDB.Database db, Geary.FolderPath path, int64 folder_id, - Geary.Imap.FolderProperties? properties) { + + internal Folder(ImapDB.Database db, Geary.FolderPath path, ContactStore contact_store, + string account_owner_email, int64 folder_id, Geary.Imap.FolderProperties? properties) { this.db = db; this.path = path; + this.contact_store = contact_store; + this.account_owner_email = account_owner_email; this.folder_id = folder_id; this.properties = properties; } @@ -202,12 +206,17 @@ private class Geary.ImapDB.Folder : Object, Geary.ReferenceSemantics { check_open(); bool created = false; - yield db.exec_transaction_async(Db.TransactionType.RW, (cx) => { - created = do_create_or_merge_email(cx, email, cancellable); + Gee.Collection? updated_contacts = null; + Db.TransactionOutcome outcome = yield db.exec_transaction_async(Db.TransactionType.RW, + (cx) => { + created = do_create_or_merge_email(cx, email, out updated_contacts, cancellable); return Db.TransactionOutcome.COMMIT; }, cancellable); + if (outcome == Db.TransactionOutcome.COMMIT && updated_contacts != null) + contact_store.update_contacts(updated_contacts); + return created; } @@ -732,7 +741,7 @@ private class Geary.ImapDB.Folder : Object, Geary.ReferenceSemantics { } private bool do_create_or_merge_email(Db.Connection cx, Geary.Email email, - Cancellable? cancellable) throws Error { + out Gee.Collection updated_contacts, Cancellable? cancellable) throws Error { // see if message already present in current folder, if not, search for duplicate throughout // mailbox bool associated; @@ -743,7 +752,7 @@ private class Geary.ImapDB.Folder : Object, Geary.ReferenceSemantics { if (!associated) do_associate_with_folder(cx, message_id, email, cancellable); - do_merge_email(cx, message_id, email, cancellable); + do_merge_email(cx, message_id, email, out updated_contacts, cancellable); // return false to indicate a merge return false; @@ -787,6 +796,12 @@ private class Geary.ImapDB.Folder : Object, Geary.ReferenceSemantics { if (email.fields.fulfills(Attachment.REQUIRED_FIELDS)) do_save_attachments(cx, message_id, email.get_message().get_attachments(), cancellable); + MessageAddresses message_addresses = + new MessageAddresses.from_email(account_owner_email, email); + foreach (Contact contact in message_addresses.contacts) + do_update_contact_importance(cx, contact, cancellable); + updated_contacts = message_addresses.contacts; + return true; } @@ -977,8 +992,11 @@ private class Geary.ImapDB.Folder : Object, Geary.ReferenceSemantics { return true; } - private void do_merge_message_row(Db.Connection cx, MessageRow row, Cancellable? cancellable) - throws Error { + private void do_merge_message_row(Db.Connection cx, MessageRow row, + out Gee.Collection updated_contacts, Cancellable? cancellable) throws Error { + // Initialize to an empty list, in case we return early. + updated_contacts = new Gee.LinkedList(); + Geary.Email.Field available_fields; if (!do_fetch_email_fields(cx, row.id, out available_fields, cancellable)) throw new EngineError.NOT_FOUND("No message with ID %s found in database", row.id.to_string()); @@ -1097,13 +1115,22 @@ private class Geary.ImapDB.Folder : Object, Geary.ReferenceSemantics { stmt.bind_rowid(1, row.id); stmt.exec(cancellable); + + // Update the autocompletion table. + MessageAddresses message_addresses = + new MessageAddresses.from_row(account_owner_email, row); + foreach (Geary.Contact contact in message_addresses.contacts) + do_update_contact_importance(cx, contact, cancellable); + updated_contacts = message_addresses.contacts; } private void do_merge_email(Db.Connection cx, int64 message_id, Geary.Email email, - Cancellable? cancellable) throws Error { + out Gee.Collection updated_contacts, Cancellable? cancellable) throws Error { assert(message_id != Db.INVALID_ROWID); - // nothing to merge, nothing to do + // Default to an empty list, in case we never call do_merge_message_row. + updated_contacts = new Gee.LinkedList(); + if (email.fields == Geary.Email.Field.NONE) return; @@ -1118,7 +1145,7 @@ private class Geary.ImapDB.Folder : Object, Geary.ReferenceSemantics { // Merge in any fields in the submitted email that aren't already in the database or are mutable if (((db_fields & email.fields) != email.fields) || email.fields.is_any_set(Geary.Email.MUTABLE_FIELDS)) { - do_merge_message_row(cx, row, cancellable); + do_merge_message_row(cx, row, out updated_contacts, cancellable); // Update attachments if not already in the database if (!db_fields.fulfills(Attachment.REQUIRED_FIELDS) diff --git a/src/engine/imap-db/imap-db-message-addresses.vala b/src/engine/imap-db/imap-db-message-addresses.vala new file mode 100644 index 00000000..b4c51a7d --- /dev/null +++ b/src/engine/imap-db/imap-db-message-addresses.vala @@ -0,0 +1,134 @@ +/* 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. + */ + +private class Geary.ImapDB.MessageAddresses : Object { + // Read-only view. + public Gee.Collection contacts { get; private set; } + + private RFC822.MailboxAddresses? sender_addresses; + private RFC822.MailboxAddresses? from_addresses; + private RFC822.MailboxAddresses? to_addresses; + private RFC822.MailboxAddresses? cc_addresses; + private RFC822.MailboxAddresses? bcc_addresses; + + private int from_importance; + private int to_importance; + private int cc_importance; + + private MessageAddresses(string account_owner_email, RFC822.MailboxAddresses? sender_addresses, + RFC822.MailboxAddresses? from_addresses, RFC822.MailboxAddresses? to_addresses, + RFC822.MailboxAddresses? cc_addresses, RFC822.MailboxAddresses? bcc_addresses) { + this.sender_addresses = sender_addresses; + this.from_addresses = from_addresses; + this.to_addresses = to_addresses; + this.cc_addresses = cc_addresses; + this.bcc_addresses = bcc_addresses; + + calculate_importance(account_owner_email); + contacts = build_contacts(); + } + + private MessageAddresses.from_strings(string account_owner_email, string? sender_field, + string? from_field, string? to_field, string? cc_field, string? bcc_field) { + this(account_owner_email, get_addresses_from_string(sender_field), + get_addresses_from_string(from_field), get_addresses_from_string(to_field), + get_addresses_from_string(cc_field), get_addresses_from_string(bcc_field)); + } + + public MessageAddresses.from_email(string account_owner_email, Geary.Email email) { + this(account_owner_email, email.sender, email.from, email.to, email.cc, email.bcc); + } + + public MessageAddresses.from_row(string account_owner_email, MessageRow row) { + this.from_strings(account_owner_email, row.sender, row.from, row.to, row.cc, row.bcc); + } + + public MessageAddresses.from_result(string account_owner_email, Db.Result result) { + this.from_strings(account_owner_email, get_string_or_null(result, "sender"), + get_string_or_null(result, "from_field"), get_string_or_null(result, "to_field"), + get_string_or_null(result, "cc"), get_string_or_null(result, "bcc")); + } + + private static string? get_string_or_null(Db.Result result, string column) { + try { + return result.string_for(column); + } catch (Geary.DatabaseError err) { + debug("Error fetching addresses from message row: %s", err.message); + return null; + } + } + + private static RFC822.MailboxAddresses? get_addresses_from_string(string? field) { + return field == null ? null : new RFC822.MailboxAddresses.from_rfc822_string(field); + } + + private void calculate_importance(string account_owner_email) { + // "Sender" is different than "from", but we give it the same importance. + bool account_owner_in_from = + (sender_addresses != null && sender_addresses.contains_normalized(account_owner_email)) || + (from_addresses != null && from_addresses.contains_normalized(account_owner_email)); + bool account_owner_in_to = to_addresses != null && + to_addresses.contains_normalized(account_owner_email); + + // If the account owner's address does not appear in any of these fields, we assume they + // were BCC'd. + bool account_owner_in_cc = + (cc_addresses != null && cc_addresses.contains_normalized(account_owner_email)) || + (bcc_addresses != null && bcc_addresses.contains_normalized(account_owner_email)) || + !(account_owner_in_from || account_owner_in_to); + + from_importance = -1; + to_importance = -1; + cc_importance = -1; + + if (account_owner_in_from) { + from_importance = int.max(from_importance, ContactImportance.FROM_FROM); + to_importance = int.max(to_importance, ContactImportance.FROM_TO); + cc_importance = int.max(cc_importance, ContactImportance.FROM_CC); + } + + if (account_owner_in_to) { + from_importance = int.max(from_importance, ContactImportance.TO_FROM); + to_importance = int.max(to_importance, ContactImportance.TO_TO); + cc_importance = int.max(cc_importance, ContactImportance.TO_CC); + } + + if (account_owner_in_cc) { + from_importance = int.max(from_importance, ContactImportance.CC_FROM); + to_importance = int.max(to_importance, ContactImportance.CC_TO); + cc_importance = int.max(cc_importance, ContactImportance.CC_CC); + } + } + + private Gee.Collection build_contacts() { + Gee.Map contacts_map = new Gee.HashMap(); + + add_contacts(contacts_map, sender_addresses, from_importance); + add_contacts(contacts_map, from_addresses, from_importance); + add_contacts(contacts_map, to_addresses, to_importance); + add_contacts(contacts_map, cc_addresses, cc_importance); + add_contacts(contacts_map, bcc_addresses, cc_importance); + + return contacts_map.values; + } + + private void add_contacts(Gee.Map contacts_map, RFC822.MailboxAddresses? addresses, + int importance) { + if (addresses == null) + return; + + foreach (RFC822.MailboxAddress address in addresses) + add_contact(contacts_map, address, importance); + } + + private void add_contact(Gee.Map contacts_map, RFC822.MailboxAddress address, + int importance) { + Contact contact = new Contact.from_rfc822_address(address, importance); + Contact? old_contact = contacts_map[contact.normalized_email]; + if (old_contact == null || old_contact.highest_importance < contact.highest_importance) + contacts_map[contact.normalized_email] = contact; + } +} diff --git a/src/engine/imap-engine/imap-engine-generic-account.vala b/src/engine/imap-engine/imap-engine-generic-account.vala index b42927d2..9d7f5011 100644 --- a/src/engine/imap-engine/imap-engine-generic-account.vala +++ b/src/engine/imap-engine/imap-engine-generic-account.vala @@ -147,6 +147,10 @@ private abstract class Geary.ImapEngine.GenericAccount : Geary.EngineAccount { return engine_list; } + public override Geary.ContactStore get_contact_store() { + return local.contact_store; + } + public override async bool folder_exists_async(Geary.FolderPath path, Cancellable? cancellable = null) throws Error { check_open(); diff --git a/src/engine/rfc822/rfc822-mailbox-address.vala b/src/engine/rfc822/rfc822-mailbox-address.vala index f2888eac..1f3d01d3 100644 --- a/src/engine/rfc822/rfc822-mailbox-address.vala +++ b/src/engine/rfc822/rfc822-mailbox-address.vala @@ -93,7 +93,7 @@ public class Geary.RFC822.MailboxAddress { public bool is_valid() { return is_valid_address(address); } - + /** * Returns true if the email syntax is valid. */ diff --git a/src/engine/rfc822/rfc822-mailbox-addresses.vala b/src/engine/rfc822/rfc822-mailbox-addresses.vala index df47ebdb..9e05e1e7 100644 --- a/src/engine/rfc822/rfc822-mailbox-addresses.vala +++ b/src/engine/rfc822/rfc822-mailbox-addresses.vala @@ -47,6 +47,20 @@ public class Geary.RFC822.MailboxAddresses : Geary.Common.MessageData, Geary.RFC return addrs.read_only_view; } + public bool contains_normalized(string address) { + if (addrs.size < 1) + return false; + + string normalized_address = address.normalize().casefold(); + + foreach (MailboxAddress mailbox_address in addrs) { + if (mailbox_address.address.normalize().casefold() == normalized_address) + return true; + } + + return false; + } + public bool contains(string address) { if (addrs.size < 1) return false;