Engine implementation of conversations: #3808
This introduces Geary.Conversations into the API, which scans a Folder and arranges the messages into threaded conversations.
This commit is contained in:
parent
8ab948bce4
commit
cd0b926d57
13 changed files with 731 additions and 27 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,3 +5,4 @@ build/
|
||||||
/geary
|
/geary
|
||||||
/console
|
/console
|
||||||
/norman
|
/norman
|
||||||
|
/theseus
|
||||||
|
|
|
||||||
53
src/common/common-async.vala
Normal file
53
src/common/common-async.vala
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
73
src/engine/api/geary-conversation.vala
Normal file
73
src/engine/api/geary-conversation.vala
Normal file
|
|
@ -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<Geary.ConversationNode>? 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<Geary.ConversationNode>? get_pool() {
|
||||||
|
Gee.HashSet<ConversationNode> pool = new Gee.HashSet<ConversationNode>();
|
||||||
|
gather(pool, get_origin());
|
||||||
|
|
||||||
|
return (pool.size > 0) ? pool : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void gather(Gee.Set<ConversationNode> pool, ConversationNode? current) {
|
||||||
|
if (current == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
pool.add(current);
|
||||||
|
|
||||||
|
Gee.Collection<Geary.ConversationNode>? children = get_replies(current);
|
||||||
|
if (children != null) {
|
||||||
|
foreach (Geary.ConversationNode child in children)
|
||||||
|
gather(pool, child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
457
src/engine/api/geary-conversations.vala
Normal file
457
src/engine/api/geary-conversations.vala
Normal file
|
|
@ -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<RFC822.MessageID>? 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<RFC822.MessageID>(Geary.Hashable.hash_func,
|
||||||
|
Geary.Equalable.equal_func);
|
||||||
|
}
|
||||||
|
|
||||||
|
children_ids.add(child_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Gee.Set<RFC822.MessageID>? 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<Geary.ConversationNode>? 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<RFC822.MessageID>? children_ids = node.get_children();
|
||||||
|
if (children_ids == null || children_ids.size == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
Gee.Set<Geary.ConversationNode> child_nodes = new Gee.HashSet<Geary.ConversationNode>();
|
||||||
|
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<Geary.RFC822.MessageID, Node> id_map = new Gee.HashMap<Geary.RFC822.MessageID,
|
||||||
|
Node>(Geary.Hashable.hash_func, Geary.Equalable.equal_func);
|
||||||
|
private Gee.Set<ImplConversation> conversations = new Gee.HashSet<ImplConversation>();
|
||||||
|
|
||||||
|
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<Conversation> conversations) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual signal void conversation_appended(Conversation conversation,
|
||||||
|
Gee.Collection<Geary.Email> email) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual signal void updated_placeholders(Conversation conversation,
|
||||||
|
Gee.Collection<Geary.Email> 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<Conversation> conversations) {
|
||||||
|
conversations_added(conversations);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void notify_conversation_appended(Conversation conversation,
|
||||||
|
Gee.Collection<Geary.Email> email) {
|
||||||
|
conversation_appended(conversation, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected virtual void notify_updated_placeholders(Conversation conversation,
|
||||||
|
Gee.Collection<Geary.Email> email) {
|
||||||
|
updated_placeholders(conversation, email);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Gee.Collection<Conversation> 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<Email>? 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<Geary.Email>? 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<Geary.Email> 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<Conversation> new_conversations = new Gee.HashSet<Conversation>();
|
||||||
|
Gee.MultiMap<Conversation, Geary.Email> appended_conversations = new Gee.HashMultiMap<
|
||||||
|
Conversation, Geary.Email>();
|
||||||
|
Gee.MultiMap<Conversation, Geary.Email> 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<RFC822.MessageID> ancestors = new Gee.ArrayList<RFC822.MessageID>();
|
||||||
|
|
||||||
|
// 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<RFC822.MessageID> seen = new Gee.HashSet<RFC822.MessageID>(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<RFC822.MessageID>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,15 +275,11 @@ private class Geary.EngineFolder : Geary.AbstractFolder {
|
||||||
if (remote_position < local_low) {
|
if (remote_position < local_low) {
|
||||||
debug("do_replay_remove_message: Not removing message at %d from local store, not present",
|
debug("do_replay_remove_message: Not removing message at %d from local store, not present",
|
||||||
remote_position);
|
remote_position);
|
||||||
|
} else {
|
||||||
remote_count = new_remote_count;
|
// Adjust remote position to local position
|
||||||
|
yield local_folder.remove_email_async((remote_position - local_low) + 1);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust remote position to local position
|
|
||||||
yield local_folder.remove_email_async((remote_position - local_low) + 1);
|
|
||||||
|
|
||||||
// save new remote count
|
// save new remote count
|
||||||
remote_count = new_remote_count;
|
remote_count = new_remote_count;
|
||||||
|
|
||||||
|
|
@ -445,10 +441,8 @@ private class Geary.EngineFolder : Geary.AbstractFolder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!found) {
|
if (!found)
|
||||||
debug("Need email at %d in %s", position, to_string());
|
|
||||||
needed_by_position += position;
|
needed_by_position += position;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needed_by_position.length == 0) {
|
if (needed_by_position.length == 0) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ public interface Geary.RFC822.MessageData : Geary.Common.MessageData {
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Geary.RFC822.MessageID : Geary.Common.StringMessageData, Geary.RFC822.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) {
|
public MessageID(string value) {
|
||||||
base (value);
|
base (value);
|
||||||
}
|
}
|
||||||
|
|
@ -27,31 +29,36 @@ public class Geary.RFC822.MessageID : Geary.Common.StringMessageData, Geary.RFC8
|
||||||
if (this == message_id)
|
if (this == message_id)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
if (to_hash() != message_id.to_hash())
|
||||||
|
return false;
|
||||||
|
|
||||||
return value == message_id.value;
|
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 {
|
public class Geary.RFC822.MessageIDList : Geary.Common.StringMessageData, Geary.RFC822.MessageData {
|
||||||
private Gee.List<MessageID>? list = null;
|
public Gee.List<MessageID>? list { get; private set; }
|
||||||
|
|
||||||
public MessageIDList(string value) {
|
public MessageIDList(string value) {
|
||||||
base (value);
|
base (value);
|
||||||
}
|
|
||||||
|
|
||||||
public Gee.List<MessageID> decoded() {
|
|
||||||
if (list != null)
|
|
||||||
return list;
|
|
||||||
|
|
||||||
list = new Gee.ArrayList<MessageID>(Equalable.equal_func);
|
string[] ids = value.split_set(" \n\r\t");
|
||||||
|
|
||||||
string[] ids = value.split(" ");
|
|
||||||
foreach (string id in ids) {
|
foreach (string id in ids) {
|
||||||
id = id.strip();
|
id = id.strip();
|
||||||
if (!String.is_empty(id))
|
if (!String.is_empty(id)) {
|
||||||
|
if (list == null)
|
||||||
|
list = new Gee.ArrayList<MessageID>();
|
||||||
|
|
||||||
list.add(new MessageID(id));
|
list.add(new MessageID(id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,9 +102,11 @@ private class Geary.Sqlite.Folder : Geary.AbstractFolder, Geary.LocalFolder, Gea
|
||||||
id.uid.to_string(), to_string());
|
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,
|
message_id = yield message_table.create_async(transaction,
|
||||||
new MessageRow.from_email(message_table, email),
|
new MessageRow.from_email(message_table, email), cancellable);
|
||||||
cancellable);
|
|
||||||
|
|
||||||
// create the message location in the location lookup table using its UID for the ordering
|
// create the message location in the location lookup table using its UID for the ordering
|
||||||
// (which fulfills the requirements for the ordering column)
|
// (which fulfills the requirements for the ordering column)
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ public class Geary.Sqlite.MessageTable : Geary.Sqlite.Table {
|
||||||
|
|
||||||
if (row.fields.is_any_set(Geary.Email.Field.REFERENCES)) {
|
if (row.fields.is_any_set(Geary.Email.Field.REFERENCES)) {
|
||||||
query = locked.prepare(
|
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(0, row.message_id);
|
||||||
query.bind_string(1, row.in_reply_to);
|
query.bind_string(1, row.in_reply_to);
|
||||||
query.bind_string(2, row.references);
|
query.bind_string(2, row.references);
|
||||||
|
|
|
||||||
87
src/theseus/theseus.vala
Normal file
87
src/theseus/theseus.vala
Normal file
|
|
@ -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<Geary.ConversationNode>? 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 <username> <password> [folder]\n");
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TheseusAsync(args, args[1], args[2], (args.length == 4) ? args[3] : null).exec();
|
||||||
|
}
|
||||||
|
|
||||||
21
src/theseus/wscript_build
Normal file
21
src/theseus/wscript_build
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
def build(bld):
|
def build(bld):
|
||||||
bld.common_src = [
|
bld.common_src = [
|
||||||
'../common/common-arrays.vala',
|
'../common/common-arrays.vala',
|
||||||
|
'../common/common-async.vala',
|
||||||
'../common/common-date.vala',
|
'../common/common-date.vala',
|
||||||
'../common/common-intl.vala',
|
'../common/common-intl.vala',
|
||||||
'../common/common-yorba-application.vala'
|
'../common/common-yorba-application.vala'
|
||||||
|
|
@ -17,6 +18,8 @@ def build(bld):
|
||||||
bld.engine_src = [
|
bld.engine_src = [
|
||||||
'../engine/api/geary-account.vala',
|
'../engine/api/geary-account.vala',
|
||||||
'../engine/api/geary-composed-email.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-credentials.vala',
|
||||||
'../engine/api/geary-email-identifier.vala',
|
'../engine/api/geary-email-identifier.vala',
|
||||||
'../engine/api/geary-email-location.vala',
|
'../engine/api/geary-email-location.vala',
|
||||||
|
|
@ -152,4 +155,5 @@ def build(bld):
|
||||||
bld.recurse('client')
|
bld.recurse('client')
|
||||||
bld.recurse('console')
|
bld.recurse('console')
|
||||||
bld.recurse('norman')
|
bld.recurse('norman')
|
||||||
|
bld.recurse('theseus')
|
||||||
|
|
||||||
|
|
|
||||||
4
wscript
4
wscript
|
|
@ -113,6 +113,7 @@ def post_build(bld):
|
||||||
geary_path = 'build/src/client/geary'
|
geary_path = 'build/src/client/geary'
|
||||||
console_path = 'build/src/console/console'
|
console_path = 'build/src/console/console'
|
||||||
norman_path = 'build/src/norman/norman'
|
norman_path = 'build/src/norman/norman'
|
||||||
|
theseus_path = 'build/src/theseus/theseus'
|
||||||
|
|
||||||
if os.path.isfile(geary_path) :
|
if os.path.isfile(geary_path) :
|
||||||
shutil.copy2(geary_path, 'geary')
|
shutil.copy2(geary_path, 'geary')
|
||||||
|
|
@ -123,6 +124,9 @@ def post_build(bld):
|
||||||
if os.path.isfile(norman_path) :
|
if os.path.isfile(norman_path) :
|
||||||
shutil.copy2(norman_path, 'norman')
|
shutil.copy2(norman_path, 'norman')
|
||||||
|
|
||||||
|
if os.path.isfile(theseus_path) :
|
||||||
|
shutil.copy2(theseus_path, 'theseus')
|
||||||
|
|
||||||
# Compile schemas for local (non-intall) build.
|
# Compile schemas for local (non-intall) build.
|
||||||
client_build_path = 'build/src/client'
|
client_build_path = 'build/src/client'
|
||||||
shutil.copy2('src/client/org.yorba.geary.gschema.xml', client_build_path)
|
shutil.copy2('src/client/org.yorba.geary.gschema.xml', client_build_path)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue