From cd0b926d57f1f9cfb62471e5191183267dd8c128 Mon Sep 17 00:00:00 2001 From: Jim Nelson Date: Fri, 21 Oct 2011 17:04:33 -0700 Subject: [PATCH] Engine implementation of conversations: #3808 This introduces Geary.Conversations into the API, which scans a Folder and arranges the messages into threaded conversations. --- .gitignore | 1 + src/common/common-async.vala | 53 ++ src/engine/api/geary-conversation.vala | 73 +++ src/engine/api/geary-conversations.vala | 457 ++++++++++++++++++ src/engine/imap/transport/imap-mailbox.vala | 3 +- src/engine/impl/geary-engine-folder.vala | 14 +- src/engine/rfc822/rfc822-message-data.vala | 33 +- src/engine/sqlite/api/sqlite-folder.vala | 6 +- .../sqlite/email/sqlite-message-table.vala | 2 +- src/theseus/theseus.vala | 87 ++++ src/theseus/wscript_build | 21 + src/wscript | 4 + wscript | 4 + 13 files changed, 731 insertions(+), 27 deletions(-) create mode 100644 src/common/common-async.vala create mode 100644 src/engine/api/geary-conversation.vala create mode 100644 src/engine/api/geary-conversations.vala create mode 100644 src/theseus/theseus.vala create mode 100644 src/theseus/wscript_build diff --git a/.gitignore b/.gitignore index 787789c9..1029e2ff 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ build/ /geary /console /norman +/theseus diff --git a/src/common/common-async.vala b/src/common/common-async.vala new file mode 100644 index 00000000..7acc5282 --- /dev/null +++ b/src/common/common-async.vala @@ -0,0 +1,53 @@ +/* Copyright 2011 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. + */ + +/** + * A utility class for building async-based command-line programs. Users should subclass MainAsync + * and provide an exec_async() method. It can return an int as the program's exit code. It can + * also throw an exception which is caught by MainAsync and printed to stderr. + * + * Then, in main(), create the subclasses object and call its exec() method, returning its int + * from main as the program's exit code. (If exec_async() throws an exception, main will return + * EXCEPTION_EXIT_CODE, which is 255.) Thus, main() should look something like this: + * + * int main(string[] args) { + * return new MyMainAsync(args).exec(); + * } + */ +public abstract class MainAsync : Object { + public const int EXCEPTION_EXIT_CODE = 255; + + public string[] args; + + private MainLoop main_loop = new MainLoop(); + private int ec = 0; + + public MainAsync(string[] args) { + this.args = args; + } + + public int exec() { + exec_async.begin(on_exec_completed); + + main_loop.run(); + + return ec; + } + + private void on_exec_completed(Object? source, AsyncResult result) { + try { + ec = exec_async.end(result); + } catch (Error err) { + stderr.printf("%s\n", err.message); + ec = EXCEPTION_EXIT_CODE; + } + + main_loop.quit(); + } + + protected abstract async int exec_async() throws Error; +} + diff --git a/src/engine/api/geary-conversation.vala b/src/engine/api/geary-conversation.vala new file mode 100644 index 00000000..6659c1f8 --- /dev/null +++ b/src/engine/api/geary-conversation.vala @@ -0,0 +1,73 @@ +/* Copyright 2011 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 interface Geary.ConversationNode : Object { + /** + * Returns the Email represented by this ConversationNode. If the Email is available + * in the remote or local Folder and as loaded into the Conversations object either by a manual + * scan or through monitoring the Folder, then that Email will be returned (with all the fields + * specified in the Conversation's constructor available). If, however, the Email was merely + * referred to by another Email in the Conversation but is unavailable, then this will return + * null (it's a placeholder). + */ + public abstract Geary.Email? get_email(); +} + +public abstract class Geary.Conversation : Object { + protected Conversation() { + } + + /** + * Returns a ConversationNode that is the origin of the entire conversation. + * + * Returns null if the Conversation has been disconnected from the master Conversations holder + * (probably due to it being destroyed). + */ + public abstract Geary.ConversationNode? get_origin(); + + /** + * Returns the ConversationNode that the supplied node is in reply to (i.e. its parent). + * + * Returns null if the node has no parent (it's the origin), is not part of the conversation, + * or the conversation has been disconnected (see get_origin()). + */ + public abstract Geary.ConversationNode? get_in_reply_to(Geary.ConversationNode node); + + /** + * Returns a Collection of ConversationNodes that replied to the supplied node (i.e. its + * children). + * + * Returns null if the node has no replies, is not part of the conversation, or if the + * conversation has been disconnected (see get_origin()). + */ + public abstract Gee.Collection? get_replies(Geary.ConversationNode node); + + /** + * Returns all ConversationNodes in the conversation, which can then be sorted by the caller's + * own requirements. + * + */ + public virtual Gee.Collection? get_pool() { + Gee.HashSet pool = new Gee.HashSet(); + gather(pool, get_origin()); + + return (pool.size > 0) ? pool : null; + } + + private void gather(Gee.Set pool, ConversationNode? current) { + if (current == null) + return; + + pool.add(current); + + Gee.Collection? children = get_replies(current); + if (children != null) { + foreach (Geary.ConversationNode child in children) + gather(pool, child); + } + } +} + diff --git a/src/engine/api/geary-conversations.vala b/src/engine/api/geary-conversations.vala new file mode 100644 index 00000000..4d10cfa6 --- /dev/null +++ b/src/engine/api/geary-conversations.vala @@ -0,0 +1,457 @@ +/* Copyright 2011 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.Conversations : Object { + /** + * These are the fields Conversations require to thread emails together. These fields will + * be retrieved irregardless of the Field parameter passed to the constructor. + */ + public const Geary.Email.Field REQUIRED_FIELDS = Geary.Email.Field.REFERENCES; + + private class Node : Object, ConversationNode { + public RFC822.MessageID? node_id { get; private set; } + public Geary.Email? email { get; set; } + public RFC822.MessageID? parent_id { get; set; default = null; } + public ImplConversation? conversation { get; set; default = null; } + + private Gee.Set? children_ids = null; + + public Node(RFC822.MessageID? node_id, Geary.Email? email) { + this.node_id = node_id; + this.email = email; + } + + public void add_child(RFC822.MessageID child_id) { + if (children_ids == null) { + children_ids = new Gee.HashSet(Geary.Hashable.hash_func, + Geary.Equalable.equal_func); + } + + children_ids.add(child_id); + } + + public Gee.Set? get_children() { + return (children_ids != null) ? children_ids.read_only_view : null; + } + + public Geary.Email? get_email() { + return email; + } + } + + private class SingleConversationNode : Object, ConversationNode { + public Geary.Email email; + + public SingleConversationNode(Geary.Email email) { + this.email = email; + } + + public Geary.Email? get_email() { + return email; + } + } + + private class ImplConversation : Conversation { + public weak Geary.Conversations? owner; + public RFC822.MessageID? origin; + public SingleConversationNode? single_node; + + public ImplConversation(Geary.Conversations owner, ConversationNode origin_node) { + this.owner = owner; + + // Rather than keep a reference to the origin node (creating a cross-reference from it + // to the conversation), keep only the Message-ID, unless it's a SingleConversationNode, + // which can't be addressed by a Message-ID (it has none) + single_node = origin_node as SingleConversationNode; + if (single_node != null) { + origin = null; + } else { + origin = ((Node) origin_node).node_id; + assert(origin != null); + } + } + + public override Geary.ConversationNode? get_origin() { + if (owner == null) + return null; + + if (origin == null) { + assert(single_node != null); + + return single_node; + } + + Node? node = owner.get_node(origin); + if (node == null) + return null; + + // Since the ImplConversation holds the Message-ID of the origin, this should always + // be true. Other accessors return null instead because it's possible the caller is + // passing in ConversationNodes for another Conversation, and would rather warn than + // assert on that case. + assert(node.conversation == this); + + return node; + } + + public override Geary.ConversationNode? get_in_reply_to(Geary.ConversationNode cnode) { + if (owner == null) + return null; + + Node? node = cnode as Node; + if (node == null) + return null; + + if (node.conversation != this) { + warning("Conversation node %s not in conversation", node.node_id.to_string()); + + return null; + } + + if (node.parent_id == null) + return null; + + Node? parent = owner.get_node(node.parent_id); + assert(parent != null); + + if (parent.conversation != this) { + warning("Parent of conversation node %s not in conversation", node.node_id.to_string()); + + return null; + } + + return parent; + } + + public override Gee.Collection? get_replies( + Geary.ConversationNode cnode) { + Node? node = cnode as Node; + if (node == null) + return null; + + if (node.conversation != this) { + warning("Conversation node %s not in conversation", node.node_id.to_string()); + + return null; + } + + if (owner == null) + return null; + + Gee.Set? children_ids = node.get_children(); + if (children_ids == null || children_ids.size == 0) + return null; + + Gee.Set child_nodes = new Gee.HashSet(); + foreach (RFC822.MessageID child_id in children_ids) { + Node? child_node = owner.get_node(child_id); + assert(child_node != null); + + // assert on this because the sub-nodes are maintained by the current node + assert(child_node.conversation == this); + + child_nodes.add(child_node); + } + + return child_nodes; + } + } + + public Geary.Folder folder { get; private set; } + + private Geary.Email.Field required_fields; + private Gee.Map id_map = new Gee.HashMap(Geary.Hashable.hash_func, Geary.Equalable.equal_func); + private Gee.Set conversations = new Gee.HashSet(); + + public virtual signal void scan_started(int low, int count) { + } + + public virtual signal void scan_error(Error err) { + } + + public virtual signal void scan_completed() { + } + + public virtual signal void conversations_added(Gee.Collection conversations) { + } + + public virtual signal void conversation_appended(Conversation conversation, + Gee.Collection email) { + } + + public virtual signal void updated_placeholders(Conversation conversation, + Gee.Collection email) { + } + + public Conversations(Geary.Folder folder, Geary.Email.Field required_fields) { + this.folder = folder; + this.required_fields = required_fields | REQUIRED_FIELDS; + } + + ~Conversations() { + // Manually detach all the weak refs in the Conversation objects + foreach (ImplConversation conversation in conversations) + conversation.owner = null; + } + + protected virtual void notify_scan_started(int low, int count) { + scan_started(low, count); + } + + protected virtual void notify_scan_error(Error err) { + scan_error(err); + } + + protected virtual void notify_scan_completed() { + scan_completed(); + } + + protected virtual void notify_conversations_added(Gee.Collection conversations) { + conversations_added(conversations); + } + + protected virtual void notify_conversation_appended(Conversation conversation, + Gee.Collection email) { + conversation_appended(conversation, email); + } + + protected virtual void notify_updated_placeholders(Conversation conversation, + Gee.Collection email) { + updated_placeholders(conversation, email); + } + + public Gee.Collection get_conversations() { + return conversations.read_only_view; + } + + public async void load_async(int low, int count, Geary.Folder.ListFlags flags, + Cancellable? cancellable) throws Error { + notify_scan_started(low, count); + try { + Gee.List? list = yield folder.list_email_async(low, count, required_fields, flags); + on_email_listed(list, null); + if (list != null) + on_email_listed(null, null); + } catch (Error err) { + on_email_listed(null, err); + } + } + + public void lazy_load(int low, int count, Geary.Folder.ListFlags flags, Cancellable? cancellable) + throws Error { + notify_scan_started(low, count); + folder.lazy_list_email(low, count, required_fields, flags, on_email_listed, cancellable); + } + + private void on_email_listed(Gee.List? emails, Error? err) { + if (err != null) + notify_scan_error(err); + + // check for completion + if (emails == null) { + notify_scan_completed(); + + return; + } + + process_email(emails); + } + + private void process_email(Gee.List emails) { + // take each email and toss it into the node pool (also adding its ancestors along the way) + // and track what signalable events need to be reported: new Conversations, Emails updating + // placeholders, and Conversation objects having a new Email added to them + Gee.HashSet new_conversations = new Gee.HashSet(); + Gee.MultiMap appended_conversations = new Gee.HashMultiMap< + Conversation, Geary.Email>(); + Gee.MultiMap updated_placeholders = new Gee.HashMultiMap< + Conversation, Geary.Email>(); + foreach (Geary.Email email in emails) { + // Right now, all threading is done with Message-IDs (no parsing of subject lines, etc.) + // If a message doesn't have a Message-ID, it's treated as its own conversation that + // cannot be referenced through the node pool (but is available through the + // conversations list) + if (email.message_id == null) { + debug("Email %s: No Message-ID", email.to_string()); + + bool added = new_conversations.add( + new ImplConversation(this, new SingleConversationNode(email))); + assert(added); + + continue; + } + + // this email is authoritative ... although it might be an old node that's now being + // filled in with a retrieved email, any other fields that may already be filled in will + // be replaced by this node + + // see if a Node already exists for this email (it's possible that earlier processed + // emails refer to this one) + Node? node = get_node(email.message_id); + if (node != null) { + if (node.email != null) { + message("Duplicate email found while threading: %s vs. %s", node.email.to_string(), + email.to_string()); + } + + // even with duplicates, this new email is considered authoritative + // (Note that if the node's conversation is null then it's been added this loop, + // in which case it's not an updated placeholder but part of a new conversation) + node.email = email; + if (node.conversation != null) + updated_placeholders.set(node.conversation, email); + } else { + node = add_node(new Node(email.message_id, email)); + } + + // build lineage of this email + Gee.ArrayList ancestors = new Gee.ArrayList(); + + // References list the email trail back to its source + if (email.references != null && email.references.list != null) + ancestors.add_all(email.references.list); + + // RFC822 requires the In-Reply-To Message-ID be prepended to the References list, but + // this ensures that's the case + if (email.in_reply_to != null) { + if (ancestors.size == 0 || !ancestors.last().equals(email.in_reply_to)) + ancestors.add(email.in_reply_to); + } + + // track whether this email has been reported as appended to a conversation, as the + // MultiMap will add as many of the same value as you throw at it + ImplConversation? found_conversation = node.conversation; + + // Watch for loops + Gee.HashSet seen = new Gee.HashSet(Hashable.hash_func, + Equalable.equal_func); + seen.add(node.node_id); + + // Walk ancestor IDs, creating nodes if necessary and chaining them together + // NOTE: References are stored from earliest to latest, but we're walking the opposite + // direction + Node current_node = node; + Node? ancestor_node = null; + for (int ctr = ancestors.size - 1; ctr >= 0; ctr--) { + RFC822.MessageID ancestor_id = ancestors[ctr]; + + if (seen.contains(ancestor_id)) { + warning("Loop detected in conversation: %s seen twice", ancestor_id.to_string()); + + continue; + } + + seen.add(ancestor_id); + + // create if necessary + ancestor_node = get_node(ancestor_id); + if (ancestor_node == null) + ancestor_node = add_node(new Node(ancestor_id, null)); + + // if current_node is in a conversation and its parent_id is null, that means + // it's the origin of a conversation, in which case making it a child of ancestor + // is potentially creating a loop + bool is_origin = (current_node.conversation != null && current_node.parent_id == null); + + // This watches for emails with contradictory References paths and new loops; + // essentially, first email encountered wins when assigning parentage + if (!is_origin && (current_node.parent_id == null || current_node.parent_id.equals(ancestor_id))) { + // link up only if + current_node.parent_id = ancestor_id; + ancestor_node.add_child(current_node.node_id); + + // See if chaining up uncovers an existing conversation + if (found_conversation == null) + found_conversation = ancestor_node.conversation; + } else if (!is_origin) { + warning("Email %s parent already assigned to %s, %s is orphaned", + current_node.node_id.to_string(), current_node.parent_id.to_string(), + ancestor_id.to_string()); + } else { + warning("Email %s already origin of conversation, %s is orphaned", + current_node.node_id.to_string(), ancestor_id.to_string()); + } + + // move up the chain + current_node = ancestor_node; + } + + // if found a conversation, mark all in chain as part of that conversation and note + // that this email was appended to the conversation, otherwise create a new one and + // note that as well + if (found_conversation != null) { + appended_conversations.set(found_conversation, email); + } else { + found_conversation = new ImplConversation(this, current_node); + bool added = new_conversations.add(found_conversation); + assert(added); + } + + assign_conversation(current_node, found_conversation); + } + + // Go through all the emails and verify they've all been marked as part of a conversation + // TODO: Make this optional at compile time (essentially a giant and expensive assertion) + foreach (Geary.Email email in emails) { + if (email.message_id == null) + continue; + + Node? node = get_node(email.message_id); + assert(node != null); + assert(node.conversation != null); + } + + // Save and signal the new conversations + if (new_conversations.size > 0) { + conversations.add_all(new_conversations); + notify_conversations_added(new_conversations); + } + + // fire signals for other changes + foreach (Conversation conversation in appended_conversations.get_all_keys()) + notify_conversation_appended(conversation, appended_conversations.get(conversation)); + + foreach (Conversation conversation in updated_placeholders.get_all_keys()) + notify_updated_placeholders(conversation, updated_placeholders.get(conversation)); + } + + private Node add_node(Node node) { + assert(node.node_id != null); + + // add to id_map (all Nodes are referenceable by their Message-ID) + if (id_map.has_key(node.node_id)) { + debug("WARNING: Replacing node in conversation model with new node of same Message-ID %s", + node.node_id.to_string()); + } + + id_map.set(node.node_id, node); + + return node; + } + + private inline Node? get_node(RFC822.MessageID node_id) { + return id_map.get(node_id); + } + + private void assign_conversation(Node node, ImplConversation conversation) { + if (node.conversation != null && node.conversation != conversation) + warning("Reassigning node %s to another conversation", node.node_id.to_string()); + + node.conversation = conversation; + + Gee.Collection? children = node.get_children(); + if (children != null) { + foreach (RFC822.MessageID child_id in children) { + Node? child = get_node(child_id); + assert(child != null); + + assign_conversation(child, conversation); + } + } + } +} + diff --git a/src/engine/imap/transport/imap-mailbox.vala b/src/engine/imap/transport/imap-mailbox.vala index d0c2c584..a4c72d83 100644 --- a/src/engine/imap/transport/imap-mailbox.vala +++ b/src/engine/imap/transport/imap-mailbox.vala @@ -380,7 +380,8 @@ public class Geary.Imap.Mailbox : Geary.SmartReference { } } - email.set_full_references(message_id, in_reply_to, references); + if (fields.require(Geary.Email.Field.REFERENCES)) + email.set_full_references(message_id, in_reply_to, references); } } diff --git a/src/engine/impl/geary-engine-folder.vala b/src/engine/impl/geary-engine-folder.vala index 0660fdd1..495a16ce 100644 --- a/src/engine/impl/geary-engine-folder.vala +++ b/src/engine/impl/geary-engine-folder.vala @@ -275,15 +275,11 @@ private class Geary.EngineFolder : Geary.AbstractFolder { if (remote_position < local_low) { debug("do_replay_remove_message: Not removing message at %d from local store, not present", remote_position); - - remote_count = new_remote_count; - - return; + } else { + // Adjust remote position to local position + yield local_folder.remove_email_async((remote_position - local_low) + 1); } - // Adjust remote position to local position - yield local_folder.remove_email_async((remote_position - local_low) + 1); - // save new remote count remote_count = new_remote_count; @@ -445,10 +441,8 @@ private class Geary.EngineFolder : Geary.AbstractFolder { } } - if (!found) { - debug("Need email at %d in %s", position, to_string()); + if (!found) needed_by_position += position; - } } if (needed_by_position.length == 0) { diff --git a/src/engine/rfc822/rfc822-message-data.vala b/src/engine/rfc822/rfc822-message-data.vala index 042a039b..63575d6d 100644 --- a/src/engine/rfc822/rfc822-message-data.vala +++ b/src/engine/rfc822/rfc822-message-data.vala @@ -14,7 +14,9 @@ public interface Geary.RFC822.MessageData : Geary.Common.MessageData { } public class Geary.RFC822.MessageID : Geary.Common.StringMessageData, Geary.RFC822.MessageData, - Geary.Equalable { + Geary.Equalable, Geary.Hashable { + private uint hash = 0; + public MessageID(string value) { base (value); } @@ -27,31 +29,36 @@ public class Geary.RFC822.MessageID : Geary.Common.StringMessageData, Geary.RFC8 if (this == message_id) return true; + if (to_hash() != message_id.to_hash()) + return false; + return value == message_id.value; } + + public uint to_hash() { + return (hash != 0) ? hash : (hash = str_hash(value)); + } } +/** + * A Message-ID list stores its IDs from earliest to latest. + */ public class Geary.RFC822.MessageIDList : Geary.Common.StringMessageData, Geary.RFC822.MessageData { - private Gee.List? list = null; + public Gee.List? list { get; private set; } public MessageIDList(string value) { base (value); - } - - public Gee.List decoded() { - if (list != null) - return list; - list = new Gee.ArrayList(Equalable.equal_func); - - string[] ids = value.split(" "); + string[] ids = value.split_set(" \n\r\t"); foreach (string id in ids) { id = id.strip(); - if (!String.is_empty(id)) + if (!String.is_empty(id)) { + if (list == null) + list = new Gee.ArrayList(); + list.add(new MessageID(id)); + } } - - return list; } } diff --git a/src/engine/sqlite/api/sqlite-folder.vala b/src/engine/sqlite/api/sqlite-folder.vala index e59ad81c..f60e8b56 100644 --- a/src/engine/sqlite/api/sqlite-folder.vala +++ b/src/engine/sqlite/api/sqlite-folder.vala @@ -102,9 +102,11 @@ private class Geary.Sqlite.Folder : Geary.AbstractFolder, Geary.LocalFolder, Gea id.uid.to_string(), to_string()); } + // TODO: Also check by Message-ID (and perhaps other EmailProperties) to link an existing + // message in the database to this Folder + message_id = yield message_table.create_async(transaction, - new MessageRow.from_email(message_table, email), - cancellable); + new MessageRow.from_email(message_table, email), cancellable); // create the message location in the location lookup table using its UID for the ordering // (which fulfills the requirements for the ordering column) diff --git a/src/engine/sqlite/email/sqlite-message-table.vala b/src/engine/sqlite/email/sqlite-message-table.vala index ecf9c9b6..2c03f923 100644 --- a/src/engine/sqlite/email/sqlite-message-table.vala +++ b/src/engine/sqlite/email/sqlite-message-table.vala @@ -118,7 +118,7 @@ public class Geary.Sqlite.MessageTable : Geary.Sqlite.Table { if (row.fields.is_any_set(Geary.Email.Field.REFERENCES)) { query = locked.prepare( - "UPDATE MessageTable SET message_id=?, in_reply_to=?, reference_ids = ? WHERE id=?"); + "UPDATE MessageTable SET message_id=?, in_reply_to=?, reference_ids=? WHERE id=?"); query.bind_string(0, row.message_id); query.bind_string(1, row.in_reply_to); query.bind_string(2, row.references); diff --git a/src/theseus/theseus.vala b/src/theseus/theseus.vala new file mode 100644 index 00000000..5a64be91 --- /dev/null +++ b/src/theseus/theseus.vala @@ -0,0 +1,87 @@ +/* Copyright 2011 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. + */ + +class TheseusAsync : MainAsync { + public string username; + public string password; + public string folder; + + public TheseusAsync(string[] args, string username, string password, string? folder = null) { + base (args); + + this.username = username; + this.password = password; + this.folder = folder ?? "INBOX"; + } + + protected override async int exec_async() throws Error { + Geary.Account account = Geary.Engine.open(new Geary.Credentials(username, password), + File.new_for_path(Environment.get_user_data_dir()).get_child("geary"), + File.new_for_path(Environment.get_current_dir())); + + Geary.Folder inbox = yield account.fetch_folder_async(new Geary.FolderRoot(folder, null, true)); + yield inbox.open_async(true); + + Geary.Conversations threads = new Geary.Conversations(inbox, Geary.Email.Field.ENVELOPE); + yield threads.load_async(-1, -1, Geary.Folder.ListFlags.NONE, null); + + yield inbox.close_async(); + + foreach (Geary.Conversation conversation in threads.get_conversations()) { + print_thread(conversation, conversation.get_origin(), 0); + stdout.printf("\n"); + } + + return 0; + } + + private void print_thread(Geary.Conversation conversation, Geary.ConversationNode node, int level) { + for (int ctr = 0; ctr < level; ctr++) + stdout.printf(" "); + + print_email(node); + + Gee.Collection? children = conversation.get_replies(node); + if (children != null) { + foreach (Geary.ConversationNode child_node in children) + print_thread(conversation, child_node, level + 1); + } + } + + private void print_email(Geary.ConversationNode node) { + Geary.Email? email = node.get_email(); + if (email == null) + stdout.printf("(no message available)\n"); + else + stdout.printf("%s\n", get_details(email)); + } + + private string get_details(Geary.Email email) { + StringBuilder builder = new StringBuilder(); + + if (email.subject != null) + builder.append_printf("%.40s ", email.subject.value); + + if (email.from != null) + builder.append_printf("(%.20s) ", email.from.to_string()); + + if (email.date != null) + builder.append_printf("[%s] ", email.date.to_string()); + + return builder.str; + } +} + +int main(string[] args) { + if (args.length != 3 && args.length != 4) { + stderr.printf("usage: theseus [folder]\n"); + + return 1; + } + + return new TheseusAsync(args, args[1], args[2], (args.length == 4) ? args[3] : null).exec(); +} + diff --git a/src/theseus/wscript_build b/src/theseus/wscript_build new file mode 100644 index 00000000..59f25e62 --- /dev/null +++ b/src/theseus/wscript_build @@ -0,0 +1,21 @@ +#! /usr/bin/env python +# encoding: utf-8 +# +# Copyright 2011 Yorba Foundation + +theseus_src = [ +'theseus.vala' +] + +theseus_uselib = 'GEE GTK GLIB' +theseus_packages = [ 'gee-1.0', 'glib-2.0' ] + +bld.program( + target = 'theseus', + vapi_dirs = '../../vapi', + threading = True, + uselib = theseus_uselib + ' ' + bld.engine_uselib + ' ' + bld.common_uselib, + packages = theseus_packages + bld.engine_packages + bld.common_packages, + source = theseus_src + bld.common_src + bld.engine_src +) + diff --git a/src/wscript b/src/wscript index 2b089a87..83c49141 100644 --- a/src/wscript +++ b/src/wscript @@ -6,6 +6,7 @@ def build(bld): bld.common_src = [ '../common/common-arrays.vala', + '../common/common-async.vala', '../common/common-date.vala', '../common/common-intl.vala', '../common/common-yorba-application.vala' @@ -17,6 +18,8 @@ def build(bld): bld.engine_src = [ '../engine/api/geary-account.vala', '../engine/api/geary-composed-email.vala', + '../engine/api/geary-conversation.vala', + '../engine/api/geary-conversations.vala', '../engine/api/geary-credentials.vala', '../engine/api/geary-email-identifier.vala', '../engine/api/geary-email-location.vala', @@ -152,4 +155,5 @@ def build(bld): bld.recurse('client') bld.recurse('console') bld.recurse('norman') + bld.recurse('theseus') diff --git a/wscript b/wscript index 9ecedae1..c3659112 100644 --- a/wscript +++ b/wscript @@ -113,6 +113,7 @@ def post_build(bld): geary_path = 'build/src/client/geary' console_path = 'build/src/console/console' norman_path = 'build/src/norman/norman' + theseus_path = 'build/src/theseus/theseus' if os.path.isfile(geary_path) : shutil.copy2(geary_path, 'geary') @@ -123,6 +124,9 @@ def post_build(bld): if os.path.isfile(norman_path) : shutil.copy2(norman_path, 'norman') + if os.path.isfile(theseus_path) : + shutil.copy2(theseus_path, 'theseus') + # Compile schemas for local (non-intall) build. client_build_path = 'build/src/client' shutil.copy2('src/client/org.yorba.geary.gschema.xml', client_build_path)