[geary/wip/713592-drafts] First go, basic implementation and interface



commit 16ca99ccdb82253476b6367099b315ce9bbc5615
Author: Jim Nelson <jim yorba org>
Date:   Wed Jan 7 15:46:17 2015 -0800

    First go, basic implementation and interface
    
    DraftManager fleshed out but not hooked up to the composer

 src/CMakeLists.txt                                 |    1 +
 src/engine/app/app-draft-manager.vala              |  335 ++++++++++++++++++++
 .../app-conversation-operation-queue.vala          |    2 +-
 src/engine/nonblocking/nonblocking-mailbox.vala    |    2 +-
 4 files changed, 338 insertions(+), 2 deletions(-)
---
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 251b3b0..4f3a552 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -46,6 +46,7 @@ engine/api/geary-special-folder-type.vala
 
 engine/app/app-conversation.vala
 engine/app/app-conversation-monitor.vala
+engine/app/app-draft-manager.vala
 engine/app/app-email-store.vala
 
 engine/app/conversation-monitor/app-append-operation.vala
diff --git a/src/engine/app/app-draft-manager.vala b/src/engine/app/app-draft-manager.vala
new file mode 100644
index 0000000..2724686
--- /dev/null
+++ b/src/engine/app/app-draft-manager.vala
@@ -0,0 +1,335 @@
+/* Copyright 2014 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.
+ */
+
+/**
+ * Manage saving, replacing, and deleting the various versions of a draft message while the user is
+ * editing it.
+ *
+ * Each composer should create a single DraftManager object for the lifetime of the compose
+ * session.  The DraftManager interface offers "fire-and-forget" nonblocking (but not
+ * asynchronous) methods for the composer to schedule remote operations without worrying about
+ * synchronization, operation ordering, error-handling, and so forth.
+ *
+ * Important: This object should be used ''per'' composed email and not to manage multiple emails
+ * being composed to the same { link Account}.  DraftManager's internal state is solely for managing
+ * the lifecycle of a single email being composed by the user.
+ *
+ * The only async calls for DraftManager is { link open_async} and { link close_async}, which give
+ * it a chance to initialize and tear-down in an orderly manner.
+ */
+
+public class Geary.App.DraftManager : BaseObject {
+    public const string PROP_IS_OPEN = "is-open";
+    public const string PROP_DRAFT_STATE = "draft-state";
+    
+    /**
+     * Current saved state of the draft.
+     */
+    public enum DraftState {
+        /**
+         * Indicates not stored on the remote server (either not uploaded or discarded).
+         */
+        NOT_STORED,
+        /**
+         * A save (upload) is in process.
+         */
+        STORING,
+        /**
+         * Draft is stored on the remote.
+         */
+        STORED,
+        /**
+         * A non-fatal error occurred attempting to store the draft.
+         *
+         * @see draft_failed
+         */
+        ERROR
+    }
+    
+    private enum OperationType {
+        PUSH,
+        CLOSE
+    }
+    
+    private class Operation : BaseObject {
+        public OperationType op_type;
+        public ComposedEmail? draft;
+        public EmailFlags? flags;
+        public DateTime? date_received;
+        public Nonblocking.Semaphore? semaphore;
+        
+        public Operation(OperationType op_type, ComposedEmail? draft, EmailFlags? flags,
+            DateTime? date_received, Nonblocking.Semaphore? semaphore) {
+            this.op_type = op_type;
+            this.draft = draft;
+            this.flags = flags;
+            this.date_received = date_received;
+            this.semaphore = semaphore;
+        }
+    }
+    
+    /**
+     * Indicates the { link DraftsManager} is open and ready for service.
+     *
+     * Although this property can be monitored, the object is considered "open" when
+     * { link open_async} completes, not when this property changes to true.
+     */
+    public bool is_open { get; private set; default = false; }
+    
+    /**
+     * The current saved state of the draft.
+     */
+    public DraftState draft_state { get; private set; default = DraftState.NOT_STORED; }
+    
+    private Account account;
+    private Folder? drafts_folder = null;
+    private FolderSupport.Create? create_support = null;
+    private FolderSupport.Remove? remove_support = null;
+    private EmailIdentifier? current_draft = null;
+    private Nonblocking.Mailbox<Operation?> mailbox = new Nonblocking.Mailbox<Operation?>();
+    private bool was_opened = false;
+    private Error? fatal_err = null;
+    
+    /**
+     * Fired if an unrecoverable error occurs while processing drafts.
+     *
+     * The { link DraftManager} will be unable to process future drafts.
+     */
+    public virtual signal void fatal(Error err) {
+        fatal_err = err;
+        
+        debug("%s: Irrecoverable failure: %s", to_string(), err.message);
+    }
+    
+    /**
+     * Fired when unable to save a draft but the { link DraftManager} remains open.
+     *
+     * Due to unpredictability of errors being reported, it's possible this signal will fire after
+     * { link fatal}.  It should not be assumed this signal firing means DraftManager is still
+     * operational, but if fatal fires, it definitely is not operational.
+     */
+    public virtual signal void draft_failed(Geary.ComposedEmail draft, Error err) {
+        debug("%s: Unable to create draft: %s", to_string(), err.message);
+    }
+    
+    public DraftManager(Geary.Account account) {
+        this.account = account;
+    }
+    
+    /**
+     * Open the { link DraftManager} and prepare it for handling composed messages.
+     *
+     * An initial draft EmailIdentifier may be supplied indicating the starting draft (when editing,
+     * not creating a new draft).  No checking is performed to ensure this EmailIdentifier is valid
+     * for the drafts folder, nor is it downloaded by DraftManager.  In essence, this email is
+     * deleted by the manager when the first { link update} occurs or the draft is
+     * { link discard}ed.
+     *
+     * If an initial_draft is supplied, { link draft_state} will be set to { link DraftState.STORED}.
+     *
+     * Other method calls should not be invoked until this completes successfully.
+     *
+     * @see is_open
+     */
+    public async void open_async(Geary.EmailIdentifier? initial_draft, Cancellable? cancellable = null)
+        throws Error {
+        if (is_open)
+            throw new EngineError.ALREADY_OPEN("%s is already open", to_string());
+        else if (was_opened)
+            throw new EngineError.UNSUPPORTED("%s cannot be re-opened", to_string());
+        
+        was_opened = true;
+        
+        current_draft = initial_draft;
+        if (current_draft != null)
+            draft_state = DraftState.STORED;
+        
+        drafts_folder = account.get_special_folder(SpecialFolderType.DRAFTS);
+        if (drafts_folder == null)
+            throw new EngineError.NOT_FOUND("%s: No drafts folder found", to_string());
+        
+        create_support = drafts_folder as Geary.FolderSupport.Create;
+        remove_support = drafts_folder as Geary.FolderSupport.Remove;
+        if (create_support == null || remove_support == null) {
+            throw new EngineError.UNSUPPORTED("%s: Drafts folder %s does not support create and remove",
+                to_string(), drafts_folder.to_string());
+        }
+        
+        drafts_folder.closed.connect(on_folder_closed);
+        
+        yield drafts_folder.open_async(Folder.OpenFlags.NONE, cancellable);
+        
+        // start the operation message loop, which ensures commands are handled in orderly fashion
+        operation_loop_async.begin();
+        
+        // done
+        is_open = true;
+    }
+    
+    private void on_folder_closed(Folder.CloseReason reason) {
+        fatal(new EngineError.SERVER_UNAVAILABLE("%s: Unexpected drafts folder closed (%s)",
+            to_string(), reason.to_string()));
+    }
+    
+    /**
+     * Flush pending operations and close the { link DraftsManager}.
+     *
+     * Once closed, the object cannot be opened again.  Create a new object in that case.
+     *
+     * @see open_async
+     * @see is_open
+     */
+    public async void close_async(Cancellable? cancellable = null) throws Error {
+        if (!is_open || drafts_folder == null)
+            return;
+        
+        // prevent further operations
+        is_open = false;
+        
+        // don't flush a CLOSE down the pipe if failed, the operation loop is closed for business
+        if (fatal_err == null) {
+            // flush pending I/O
+            Nonblocking.Semaphore semaphore = new Nonblocking.Semaphore(cancellable);
+            mailbox.send(new Operation(OperationType.CLOSE, null, null, null, semaphore));
+            
+            // wait for close to complete
+            yield semaphore.wait_async(cancellable);
+        }
+        
+        // Disconnect before closing, as signal handler is for unexpected closes
+        drafts_folder.closed.disconnect(on_folder_closed);
+        
+        yield drafts_folder.close_async(cancellable);
+        drafts_folder = null;
+        create_support = null;
+        remove_support = null;
+    }
+    
+    private void check_open() throws EngineError {
+        if (!is_open)
+            throw new EngineError.OPEN_REQUIRED("%s is not open", to_string());
+    }
+    
+    /**
+     * Save draft on the server, potentially replacing (deleting) an already-existing version.
+     *
+     * See { link FolderSupport.Create.create_email_async} for more information on the flags and
+     * date_received arguments.
+     */
+    public void update(Geary.ComposedEmail draft, Geary.EmailFlags? flags, DateTime? date_received)
+        throws Error {
+        submit_push(draft, flags, date_received);
+    }
+    
+    /**
+     * Delete all versions of the composed email from the server.
+     *
+     * This is appropriate both for the user cancelling (discarding) a composed message or if the
+     * user sends it.
+     *
+     * Note: Replaced drafts are deleted, but on some services (i.e. Gmail) those deleted messages
+     * are actually moved to the Trash.  This call does not currently solve that problem.
+     */
+    public void discard() throws Error {
+        submit_push(null, null, null);
+    }
+    
+    private void submit_push(ComposedEmail? draft, EmailFlags? flags, DateTime? date_received)
+        throws Error {
+        check_open();
+        
+        // clear out pending updates
+        mailbox.revoke_matching((op) => {
+            return op.op_type == OperationType.PUSH;
+        });
+        
+        // schedule this draft for update (if null, it's a discard)
+        mailbox.send(new Operation(OperationType.PUSH, draft, flags, date_received, null));
+    }
+    
+    private async void operation_loop_async() {
+        for (;;) {
+            // if a fatal error occurred (it can happen outside the loop), shutdown without
+            // reporting it again
+            if (fatal_err != null)
+                break;
+            
+            Operation op;
+            try {
+                op = yield mailbox.recv_async(null);
+            } catch (Error err) {
+                fatal(err);
+                
+                break;
+            }
+            
+            bool continue_loop = yield operation_loop_iteration_async(op);
+            
+            // fire semaphore, if present
+            if (op.semaphore != null)
+                op.semaphore.blind_notify();
+            
+            if (!continue_loop)
+                break;
+        }
+    }
+    
+    // Returns false if time to exit.
+    private async bool operation_loop_iteration_async(Operation op) {
+        // watch for CLOSE
+        if (op.op_type == OperationType.CLOSE)
+            return false;
+        
+        // make sure there's a folder to work with
+        if (drafts_folder == null || drafts_folder.get_open_state() == Folder.OpenState.CLOSED) {
+            fatal(new EngineError.SERVER_UNAVAILABLE("%s: premature drafts folder close", to_string()));
+            
+            return false;
+        }
+        
+        // at this point, only operation left is PUSH
+        assert(op.op_type == OperationType.PUSH);
+        
+        draft_state = DraftState.STORING;
+        
+        // delete old draft for all PUSHes: best effort ... since create_email_async() will handle
+        // replacement in a transactional-kinda-way, only outright delete if not using create
+        if (current_draft != null && op.draft == null) {
+            try {
+                yield remove_support.remove_single_email_async(current_draft);
+            } catch (Error err) {
+                debug("%s: Unable to remove existing draft %s: %s", to_string(), current_draft.to_string(),
+                    err.message);
+            }
+            
+            current_draft = null;
+        }
+        
+        // if draft supplied, save it
+        if (op.draft != null) {
+            RFC822.Message rfc822 = new RFC822.Message.from_composed_email(op.draft, null);
+            try {
+                current_draft = yield create_support.create_email_async(rfc822, op.flags,
+                    op.date_received, current_draft, null);
+                draft_state = DraftState.STORED;
+            } catch (Error err) {
+                draft_state = DraftState.ERROR;
+                
+                // notify subscribers
+                draft_failed(op.draft, err);
+            }
+        } else {
+            draft_state = DraftState.NOT_STORED;
+        }
+        
+        return true;
+    }
+    
+    public string to_string() {
+        return "%s DraftManager".printf(account.to_string());
+    }
+}
+
diff --git a/src/engine/app/conversation-monitor/app-conversation-operation-queue.vala 
b/src/engine/app/conversation-monitor/app-conversation-operation-queue.vala
index 5d951ae..7577e4f 100644
--- a/src/engine/app/conversation-monitor/app-conversation-operation-queue.vala
+++ b/src/engine/app/conversation-monitor/app-conversation-operation-queue.vala
@@ -23,7 +23,7 @@ private class Geary.App.ConversationOperationQueue : BaseObject {
         FillWindowOperation? fill_op = op as FillWindowOperation;
         if (fill_op != null) {
             Gee.Collection<ConversationOperation> removed
-                = mailbox.remove_matching(o => o is FillWindowOperation);
+                = mailbox.revoke_matching(o => o is FillWindowOperation);
             
             // If there were any "insert" fill window ops, preserve that flag,
             // as otherwise we might miss some data.
diff --git a/src/engine/nonblocking/nonblocking-mailbox.vala b/src/engine/nonblocking/nonblocking-mailbox.vala
index 52d45db..3337b44 100644
--- a/src/engine/nonblocking/nonblocking-mailbox.vala
+++ b/src/engine/nonblocking/nonblocking-mailbox.vala
@@ -73,7 +73,7 @@ public class Geary.Nonblocking.Mailbox<G> : BaseObject {
     /**
      * Remove messages matching the given predicate.  Return the removed messages.
      */
-    public Gee.Collection<G> remove_matching(owned Gee.Predicate<G> predicate) {
+    public Gee.Collection<G> revoke_matching(owned Gee.Predicate<G> predicate) {
         Gee.ArrayList<G> removed = new Gee.ArrayList<G>();
         // Iterate over a copy so we can modify the original.
         foreach (G msg in queue.to_array()) {


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]