From 0258a20ad9a67486e166e428dd3c869fb720c4e5 Mon Sep 17 00:00:00 2001 From: Nate Lillich Date: Fri, 8 Jun 2012 12:39:27 -0700 Subject: [PATCH] Closes #3809. Attachments are now available through the message viewer and are saved as individual files outside of the database. --- sql/Version-002.sql | 15 + src/CMakeLists.txt | 4 + src/client/geary-controller.vala | 50 +++ src/client/ui/message-viewer.vala | 340 +++++++++++++++--- src/client/util/util-webkit.vala | 33 ++ src/common/common-files.vala | 39 ++ src/engine/api/geary-attachment.vala | 32 ++ src/engine/api/geary-email.vala | 24 +- src/engine/rfc822/rfc822-message.vala | 85 +++-- .../sqlite/abstract/sqlite-database.vala | 6 +- src/engine/sqlite/api/sqlite-folder.vala | 250 ++++++++----- .../sqlite/email/sqlite-mail-database.vala | 15 +- .../email/sqlite-message-attachment-row.vala | 39 ++ .../sqlite-message-attachment-table.vala | 90 +++++ .../sqlite/email/sqlite-message-row.vala | 59 --- 15 files changed, 842 insertions(+), 239 deletions(-) create mode 100644 sql/Version-002.sql create mode 100644 src/common/common-files.vala create mode 100644 src/engine/api/geary-attachment.vala create mode 100644 src/engine/sqlite/email/sqlite-message-attachment-row.vala create mode 100644 src/engine/sqlite/email/sqlite-message-attachment-table.vala diff --git a/sql/Version-002.sql b/sql/Version-002.sql new file mode 100644 index 00000000..623d2e1f --- /dev/null +++ b/sql/Version-002.sql @@ -0,0 +1,15 @@ + +-- +-- MessageAttachmentTable +-- + +CREATE TABLE MessageAttachmentTable ( + id INTEGER PRIMARY KEY, + message_id INTEGER REFERENCES MessageTable ON DELETE CASCADE, + filename TEXT, + mime_type TEXT, + filesize INTEGER +); + +CREATE INDEX MessageAttachmentTableMessageIDIndex ON MessageAttachmentTable(message_id); + diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5ded1c4c..e617721d 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -5,6 +5,7 @@ set(COMMON_SRC common/common-arrays.vala common/common-async.vala common/common-date.vala +common/common-files.vala common/common-intl.vala common/common-yorba-application.vala ) @@ -12,6 +13,7 @@ common/common-yorba-application.vala set(ENGINE_SRC engine/api/geary-account.vala engine/api/geary-account-information.vala +engine/api/geary-attachment.vala engine/api/geary-batch-operations.vala engine/api/geary-composed-email.vala engine/api/geary-conversation.vala @@ -133,6 +135,8 @@ engine/sqlite/api/sqlite-folder.vala engine/sqlite/email/sqlite-folder-row.vala engine/sqlite/email/sqlite-folder-table.vala engine/sqlite/email/sqlite-mail-database.vala +engine/sqlite/email/sqlite-message-attachment-row.vala +engine/sqlite/email/sqlite-message-attachment-table.vala engine/sqlite/email/sqlite-message-location-row.vala engine/sqlite/email/sqlite-message-location-table.vala engine/sqlite/email/sqlite-message-row.vala diff --git a/src/client/geary-controller.vala b/src/client/geary-controller.vala index f1812aea..015779a9 100644 --- a/src/client/geary-controller.vala +++ b/src/client/geary-controller.vala @@ -112,6 +112,8 @@ public class GearyController { main_window.message_viewer.reply_all_message.connect(on_reply_all_message); main_window.message_viewer.forward_message.connect(on_forward_message); main_window.message_viewer.mark_message.connect(on_message_viewer_mark_message); + main_window.message_viewer.open_attachment.connect(on_open_attachment); + main_window.message_viewer.save_attachments.connect(on_save_attachments); main_window.message_list_view.grab_focus(); @@ -942,6 +944,54 @@ public class GearyController { set_busy(false); } + private void on_open_attachment(Geary.Attachment attachment) { + open_uri("file://" + attachment.filepath); + } + + private void on_save_attachments(Gee.List attachments) { + Gtk.FileChooserAction action = attachments.size == 1 + ? Gtk.FileChooserAction.SAVE + : Gtk.FileChooserAction.SELECT_FOLDER; + Gtk.FileChooserDialog dialog = new Gtk.FileChooserDialog(null, main_window, action, + Gtk.Stock.CANCEL, Gtk.ResponseType.CANCEL, Gtk.Stock.SAVE, Gtk.ResponseType.ACCEPT, null); + dialog.set_filename(attachments[0].filepath); + if (dialog.run() != Gtk.ResponseType.ACCEPT) { + dialog.destroy(); + return; + } + + // Get the selected location. + string filename = dialog.get_filename(); + debug("Saving attachment to: %s", filename); + + // Save the attachments. + // TODO Handle attachments with the same name being saved into the same directory. + File destination = File.new_for_path(filename); + if (attachments.size == 1) { + File source = File.new_for_path(attachments[0].filepath); + source.copy_async.begin(destination, FileCopyFlags.OVERWRITE, Priority.DEFAULT, null, + null, on_save_completed); + } else { + foreach (Geary.Attachment attachment in attachments) { + File dest_name = destination.get_child(attachment.filename); + File source = File.new_for_path(attachment.filepath); + debug("Saving %s to %s", source.get_path(), dest_name.get_path()); + source.copy_async.begin(dest_name, FileCopyFlags.OVERWRITE, Priority.DEFAULT, null, + null, on_save_completed); + } + } + + dialog.destroy(); + } + + private void on_save_completed(Object? source, AsyncResult result) { + try { + ((File) source).copy_async.end(result); + } catch (Error error) { + warning("Failed to copy attachment to destination: %s", error.message); + } + } + // Opens a link in an external browser. private void open_uri(string _link) { string link = _link; diff --git a/src/client/ui/message-viewer.vala b/src/client/ui/message-viewer.vala index 4b577126..4fe04ce8 100644 --- a/src/client/ui/message-viewer.vala +++ b/src/client/ui/message-viewer.vala @@ -14,7 +14,8 @@ public class MessageViewer : WebKit.WebView { | Geary.Email.Field.DATE | Geary.Email.Field.FLAGS | Geary.Email.Field.PREVIEW; - + + private const int ATTACHMENT_PREVIEW_SIZE = 50; private const string MESSAGE_CONTAINER_ID = "message_container"; private const string SELECTION_COUNTER_ID = "multiple_messages"; private const string HTML_BODY = """ @@ -185,8 +186,9 @@ public class MessageViewer : WebKit.WebView { background-color: #e8e8e8 } .email.hide:not(:last-of-type) .body, - .email:not(.hide) .preview, - .email:last-of-type .preview { + .email.hide:not(:last-of-type) > .attachment_container, + .email:not(.hide) .header_container .preview, + .email:last-of-type .header_container .preview { display: none; } .email:not(:last-of-type) .header_container { @@ -219,6 +221,76 @@ public class MessageViewer : WebKit.WebView { } + .email:not(.attachment) .attachment.icon { + display: none; + } + .email .header_container .attachment.icon { + float: right; + margin-top: 7px; + } + .email > .attachment_container { + background-color: #ddd; + border-radius: 4px; + padding-bottom: 10px; + } + .email > .attachment_container > .top_border { + border-bottom: 1px solid #999; + border-radius: 0 0 4px 4px; + height: 10px; + background-color: white; + margin-bottom: 5px; + box-shadow: 0 3px 5px #c0c0c0; + } + .email > .attachment_container > .attachment { + margin: 10px 10px 0 10px; + padding: 2px; + overflow: hidden; + font-size: 10pt; + cursor: pointer; + border: 1px solid transparent; + border-radius: 5px; + display: inline; + } + .email > .attachment_container > .attachment:hover, + .email > .attachment_container > .attachment:active { + border-color: #999; + background-color: #e8e8e8; + } + .email > .attachment_container > .attachment:active { + padding: 3px 1px 1px 3px; + box-shadow: inset 3px 3px 5px #ccc, inset -1px -1px 3px #ccc; + } + .email > .attachment_container > .attachment .preview { + width: 52px; + height: 52px; + text-align: center; + vertical-align: middle; + } + .email > .attachment_container > .attachment .preview img { + max-width: 50px; + max-height: 50px; + } + .email > .attachment_container > .attachment .preview .thumbnail { + border: 1px solid #999; + box-shadow: 0 0 5px #b8b8b8; + background-size: 16px 16px; + background-position:0 0, 8px 0, 8px -8px, 0px 8px; + } + .email > .attachment_container > .attachment:hover .preview .thumbnail { + background-image: + -webkit-linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent), + -webkit-linear-gradient(-45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent), + -webkit-linear-gradient(45deg, transparent 75%, rgba(0, 0, 0, 0.1) 75%), + -webkit-linear-gradient(-45deg, transparent 75%, rgba(0, 0, 0, 0.1) 75%); + } + .email > .attachment_container > .attachment .info { + vertical-align: middle; + padding-left: 5px; + } + .email > .attachment_container > .attachment .info > :not(.filename) { + color: #666; + } + .header { overflow: hidden; } @@ -314,7 +386,8 @@ public class MessageViewer : WebKit.WebView { width: auto; padding: 15px; } - #email_template { + #email_template, + #attachment_template { display: none; } blockquote { @@ -336,12 +409,23 @@ public class MessageViewer : WebKit.WebView {
+
+
+
+ + + +
+
+
+
+
"""; // Fired when the user clicks a link. @@ -362,10 +446,17 @@ public class MessageViewer : WebKit.WebView { // Fired when the user marks a message. public signal void mark_message(Geary.EmailFlags? flags_to_add, Geary.EmailFlags? flags_to_remove); + // Fired when the user opens an attachment. + public signal void open_attachment(Geary.Attachment attachment); + + // Fired when the user wants to save one or more attachments. + public signal void save_attachments(Gee.List attachment); + // List of emails in this view. public Gee.TreeSet messages { get; private set; default = new Gee.TreeSet((CompareFunc) Geary.Email.compare_date_ascending); } public Geary.Email? active_email = null; + public Geary.Attachment? active_attachment = null; // HTML element that contains message DIVs. private WebKit.DOM.HTMLDivElement container; @@ -415,6 +506,7 @@ public class MessageViewer : WebKit.WebView { set_icon_src("#email_template .menu .icon", "down"); set_icon_src("#email_template .starred .icon", "starred"); set_icon_src("#email_template .unstarred .icon", "non-starred-grey"); + set_icon_src("#email_template .attachment.icon", "mail-attachment"); } @@ -431,7 +523,7 @@ public class MessageViewer : WebKit.WebView { private void set_icon_src(string selector, string icon_name) { try { // Load the icon. - string icon_filename = IconFactory.instance.lookup_icon(icon_name, 16, 0).get_filename(); + string icon_filename = IconFactory.instance.lookup_icon(icon_name, 16).get_filename(); uint8[] icon_content; FileUtils.get_data(icon_filename, out icon_content); @@ -441,15 +533,56 @@ public class MessageViewer : WebKit.WebView { icon_content, out uncertain_content_type)); // Then set the source to a data url. - WebKit.DOM.HTMLImageElement icon = get_dom_document().query_selector(selector) + WebKit.DOM.HTMLImageElement img = Util.DOM.select(get_dom_document(), selector) as WebKit.DOM.HTMLImageElement; - icon.set_attribute("src", "data:%s;base64,%s".printf(icon_mimetype, - Base64.encode(icon_content))); + set_data_url(img, icon_mimetype, icon_content); } catch (Error error) { warning("Failed to load icon '%s': %s", icon_name, error.message); } } + private void set_image_src(WebKit.DOM.HTMLImageElement img, string mime_type, string filename, + int maxwidth, int maxheight = -1) { + if( maxheight == -1 ){ + maxheight = maxwidth; + } + + try { + // If the file is an image, use it. Otherwise get the icon for this mime_type. + uint8[] content; + string content_type = ContentType.from_mime_type(mime_type); + string icon_mime_type = mime_type; + if (mime_type.has_prefix("image/")) { + // Get a thumbnail for the image. + // TODO Generate and save the thumbnail when extracting the attachments rather than + // when showing them in the viewer. + img.get_class_list().add("thumbnail"); + Gdk.Pixbuf image = new Gdk.Pixbuf.from_file_at_scale(filename, maxwidth, maxheight, + true); + image.save_to_buffer(out content, "png"); + icon_mime_type = "image/png"; + } else { + // Load the icon for this mime type. + ThemedIcon icon = ContentType.get_icon(content_type) as ThemedIcon; + string icon_filename = IconFactory.instance.lookup_icon(icon.names[0], maxwidth) + .get_filename(); + FileUtils.get_data(icon_filename, out content); + icon_mime_type = ContentType.get_mime_type(ContentType.guess(icon_filename, content, + null)); + } + + // Then set the source to a data url. + set_data_url(img, icon_mime_type, content); + } catch (Error error) { + warning("Failed to load image '%s': %s", filename, error.message); + } + } + + private void set_data_url(WebKit.DOM.HTMLImageElement img, string mime_type, uint8[] content) + throws Error { + img.set_attribute("src", "data:%s;base64,%s".printf(mime_type, Base64.encode(content))); + } + // Removes all displayed e-mails from the view. public void clear() { // Remove all messages from DOM. @@ -547,12 +680,10 @@ public class MessageViewer : WebKit.WebView { // // // - div_message = get_dom_document().get_element_by_id("email_template").clone_node(true) - as WebKit.DOM.HTMLElement; + div_message = Util.DOM.clone_select(get_dom_document(), "#email_template"); div_message.set_attribute("id", message_id); container.insert_before(div_message, insert_before); - div_email_container = div_message.query_selector("div.email_container") - as WebKit.DOM.HTMLElement; + div_email_container = Util.DOM.select(div_message, "div.email_container"); if (email.is_unread() == Geary.Trillian.FALSE) { div_message.get_class_list().add("hide"); } @@ -598,7 +729,7 @@ public class MessageViewer : WebKit.WebView { // Add the avatar. try { - WebKit.DOM.HTMLImageElement icon = get_dom_document().query_selector("#%s .avatar".printf(message_id)) + WebKit.DOM.HTMLImageElement icon = Util.DOM.select(div_message, ".avatar") as WebKit.DOM.HTMLImageElement; string checksum = GLib.Checksum.compute_for_string ( GLib.ChecksumType.MD5, email.sender.get(0).address); @@ -610,8 +741,8 @@ public class MessageViewer : WebKit.WebView { // Insert the preview text. try { - WebKit.DOM.HTMLElement preview = div_message.query_selector(".header_container .preview") - as WebKit.DOM.HTMLElement; + WebKit.DOM.HTMLElement preview = + Util.DOM.select(div_message, ".header_container .preview"); string preview_str = email.get_preview_as_string(); if (preview_str.length == Geary.Email.MAX_PREVIEW_BYTES) { preview_str += "…"; @@ -637,28 +768,35 @@ public class MessageViewer : WebKit.WebView { // Graft header and email body into the email container. try { - WebKit.DOM.HTMLElement table_header = div_email_container - .query_selector(".header_container .header") as WebKit.DOM.HTMLElement; + WebKit.DOM.HTMLElement table_header = + Util.DOM.select(div_email_container, ".header_container .header"); table_header.set_inner_html(header); - WebKit.DOM.HTMLElement span_body = div_email_container.query_selector(".body") - as WebKit.DOM.HTMLElement; + WebKit.DOM.HTMLElement span_body = Util.DOM.select(div_email_container, ".body"); span_body.set_inner_html(body_text); + } catch (Error html_error) { warning("Error setting HTML for message: %s", html_error.message); } + // Add the attachments container if we have any attachments. + if (email.attachments.size > 0) { + insert_attachments(div_message, email.attachments); + } + // Add classes according to the state of the email. update_flags(email); // Attach to the click events for hiding/showing quotes, opening the menu, and so forth. bind_event(this, ".email", "contextmenu", (Callback) on_context_menu, this); - bind_event(this,".quote_container > .hider", "click", (Callback) on_hide_quote_clicked); - bind_event(this,".quote_container > .shower", "click", (Callback) on_show_quote_clicked); - bind_event(this,".email_container .menu", "click", (Callback) on_menu_clicked, this); - bind_event(this,".email_container .starred", "click", (Callback) on_unstar_clicked, this); - bind_event(this,".email_container .unstarred", "click", (Callback) on_star_clicked, this); - bind_event(this,".email .header_container", "click", (Callback) on_body_toggle_clicked, this); + bind_event(this, ".quote_container > .hider", "click", (Callback) on_hide_quote_clicked); + bind_event(this, ".quote_container > .shower", "click", (Callback) on_show_quote_clicked); + bind_event(this, ".email_container .menu", "click", (Callback) on_menu_clicked, this); + bind_event(this, ".email_container .starred", "click", (Callback) on_unstar_clicked, this); + bind_event(this, ".email_container .unstarred", "click", (Callback) on_star_clicked, this); + bind_event(this, ".email .header_container", "click", (Callback) on_body_toggle_clicked, this); + bind_event(this, ".attachment_container .attachment", "click", (Callback) on_attachment_clicked, this); + bind_event(this, ".attachment_container .attachment", "contextmenu", (Callback) on_attachment_menu, this); } private WebKit.DOM.HTMLElement? closest_ancestor(WebKit.DOM.Element element, string selector) { @@ -714,7 +852,7 @@ public class MessageViewer : WebKit.WebView { Geary.EmailFlags flags = email.email_flags; - // Update the flags in out message set. + // Update the flags in our message set. foreach (Geary.Email message in messages) { if (message.id.equals(email.id)) { message.set_flags(flags); @@ -726,21 +864,14 @@ public class MessageViewer : WebKit.WebView { WebKit.DOM.HTMLElement container = email_to_element.get(email.id); try { WebKit.DOM.DOMTokenList class_list = container.get_class_list(); - if (flags.is_unread()) { - class_list.remove("read"); - } else { - class_list.add("read"); - } - if (flags.is_flagged()) { - class_list.add("starred"); - } else { - class_list.remove("starred"); - } + Util.DOM.toggle_class(class_list, "read", !flags.is_unread()); + Util.DOM.toggle_class(class_list, "starred", flags.is_flagged()); + Util.DOM.toggle_class(class_list, "attachment", email.attachments.size > 0); } catch (Error e) { warning("Failed to set classes on .email: %s", e.message); } } - + private static void on_context_menu(WebKit.DOM.Element element, WebKit.DOM.Event event, MessageViewer message_viewer) { message_viewer.active_email = message_viewer.get_email_from_element(element); @@ -768,10 +899,10 @@ public class MessageViewer : WebKit.WebView { private static void on_menu_clicked(WebKit.DOM.Element element, WebKit.DOM.Event event, MessageViewer message_viewer) { event.stop_propagation(); - message_viewer.on_menu_clicked_async(element); + message_viewer.on_menu_clicked_self(element); } - private void on_menu_clicked_async(WebKit.DOM.Element element) { + private void on_menu_clicked_self(WebKit.DOM.Element element) { active_email = get_email_from_element(element); show_message_menu(element); } @@ -779,10 +910,10 @@ public class MessageViewer : WebKit.WebView { private static void on_unstar_clicked(WebKit.DOM.Element element, WebKit.DOM.Event event, MessageViewer message_viewer) { event.stop_propagation(); - message_viewer.on_unstar_clicked_async(element); + message_viewer.on_unstar_clicked_self(element); } - private void on_unstar_clicked_async(WebKit.DOM.Element element){ + private void on_unstar_clicked_self(WebKit.DOM.Element element){ active_email = get_email_from_element(element); on_unflag_message(); } @@ -790,10 +921,10 @@ public class MessageViewer : WebKit.WebView { private static void on_star_clicked(WebKit.DOM.Element element, WebKit.DOM.Event event, MessageViewer message_viewer) { event.stop_propagation(); - message_viewer.on_star_clicked_async(element); + message_viewer.on_star_clicked_self(element); } - private void on_star_clicked_async(WebKit.DOM.Element element){ + private void on_star_clicked_self(WebKit.DOM.Element element){ active_email = get_email_from_element(element); on_flag_message(); } @@ -815,15 +946,53 @@ public class MessageViewer : WebKit.WebView { class_list.add("hide"); } } catch (Error error) { - warning("Error toggline message: %s", error.message); + warning("Error toggling message: %s", error.message); + } + } + + private static void on_attachment_clicked(WebKit.DOM.Element element, WebKit.DOM.Event event, + MessageViewer message_viewer) { + message_viewer.on_attachment_clicked_self(element); + } + + private void on_attachment_clicked_self(WebKit.DOM.Element element) { + try { + int64 attachment_id = int64.parse(element.get_attribute("data-attachment-id")); + open_attachment(get_email_from_element(element).get_attachment(attachment_id)); + } catch (Error error) { + warning("Error opening attachment: %s", error.message); + } + } + + private static void on_attachment_menu(WebKit.DOM.Element element, WebKit.DOM.Event event, + MessageViewer message_viewer) { + try { + event.stop_propagation(); + message_viewer.active_email = message_viewer.get_email_from_element(element); + message_viewer.active_attachment = message_viewer.active_email.get_attachment( + int64.parse(element.get_attribute("data-attachment-id"))); + message_viewer.show_message_menu(element); + } catch (Error error) { + warning("Error opening attachment menu: %s", error.message); } } private void on_message_menu_selection_done() { active_email = null; + active_attachment = null; message_menu = null; } + private void on_save_attachment() { + Gee.List attachments = new Gee.ArrayList(); + attachments.add(active_attachment != null ? active_attachment : active_email.attachments[0]); + save_attachments(attachments); + } + + private void on_save_all_attachments() { + save_attachments(active_email.attachments); + } + private void on_reply_to_message() { reply_to_message(); } @@ -874,6 +1043,29 @@ public class MessageViewer : WebKit.WebView { message_menu = new Gtk.Menu(); message_menu.selection_done.connect(on_message_menu_selection_done); + if (active_email.attachments.size > 0) { + // Save attachment as... + if (active_attachment != null) { + Gtk.MenuItem save_attachment_item = new Gtk.MenuItem.with_mnemonic(_("_Save As...")); + save_attachment_item.activate.connect(on_save_attachment); + message_menu.append(save_attachment_item); + } + + // Save all attachments + if (active_email.attachments.size > 1) { + Gtk.MenuItem save_all_item = new Gtk.MenuItem.with_mnemonic(_("Save All A_ttachments...")); + save_all_item.activate.connect(on_save_all_attachments); + message_menu.append(save_all_item); + } else if (active_attachment == null) { + Gtk.MenuItem save_all_item = new Gtk.MenuItem.with_mnemonic(_("Save A_ttachment...")); + save_all_item.activate.connect(on_save_attachment); + message_menu.append(save_all_item); + } + + // Separator. + message_menu.append(new Gtk.SeparatorMenuItem()); + } + // Reply to a message. Gtk.MenuItem reply_item = new Gtk.MenuItem.with_mnemonic(_("_Reply")); reply_item.activate.connect(on_reply_to_message); @@ -1002,7 +1194,7 @@ public class MessageViewer : WebKit.WebView { // Copy the stuff before the quote, then the wrapped quote. WebKit.DOM.Element quote_container = create_quote_container(); - ((WebKit.DOM.HTMLElement) quote_container.query_selector(".quote")).set_inner_html( + Util.DOM.select(quote_container, ".quote").set_inner_html( text.substring(quote_start, quote_end - quote_start)); container.append_child(quote_container); if (quote_start > offset) { @@ -1042,7 +1234,7 @@ public class MessageViewer : WebKit.WebView { // Some HTML messages like to wrap themselves in full, proper html, head, and body tags. // If we have that here, lets remove it since we are sticking it in our own document. - WebKit.DOM.HTMLElement? body = container.query_selector("body") as WebKit.DOM.HTMLElement; + WebKit.DOM.HTMLElement? body = Util.DOM.select(container, "body"); if (body != null) { container.set_inner_html(body.get_inner_html()); } @@ -1065,7 +1257,7 @@ public class MessageViewer : WebKit.WebView { // blockquote // sibling WebKit.DOM.Element quote_container = create_quote_container(); - quote_container.query_selector(".quote").append_child(blockquote_node); + Util.DOM.select(quote_container, ".quote").append_child(blockquote_node); if (next_sibling == null) { parent.append_child(quote_container); } else { @@ -1217,7 +1409,59 @@ public class MessageViewer : WebKit.WebView { output = r.replace_eval(output, -1, 0, 0, is_valid_url); return output.replace(" \01 ", "<").replace(" \02 ", ">"); } - + + private void insert_attachments(WebKit.DOM.HTMLElement email_container, + Gee.List attachments) { + + //
+ //
+ // + // + // + // + // + //
+ // + // + //
+ //
+ //
+ //
+ + try { + // Prepare the dom for our attachments. + WebKit.DOM.Document document = get_dom_document(); + WebKit.DOM.HTMLElement attachment_container = + Util.DOM.clone_select(document, "#attachment_template"); + WebKit.DOM.HTMLElement attachment_template = + Util.DOM.select(attachment_container, ".attachment"); + attachment_container.remove_attribute("id"); + attachment_container.remove_child(attachment_template); + + // Create an attachment table for each attachment. + foreach (Geary.Attachment attachment in attachments) { + // Generate the attachment table. + WebKit.DOM.HTMLElement attachment_table = Util.DOM.clone_node(attachment_template); + Util.DOM.select(attachment_table, ".info .filename") + .set_inner_text(attachment.filename); + Util.DOM.select(attachment_table, ".info .filesize") + .set_inner_text(Files.get_filesize_as_string(attachment.filesize)); + attachment_table.set_attribute("data-attachment-id", "%lld".printf(attachment.id)); + + // Set the image preview and insert it into the container. + WebKit.DOM.HTMLImageElement img = + Util.DOM.select(attachment_table, ".preview img") as WebKit.DOM.HTMLImageElement; + set_image_src(img, attachment.mime_type, attachment.filepath, ATTACHMENT_PREVIEW_SIZE); + attachment_container.append_child(attachment_table); + } + + // Append the attachments to the email. + email_container.append_child(attachment_container); + } catch (Error error) { + debug("Failed to insert attachments: %s", error.message); + } + } + // Validates a URL. // Ensures the URL begins with a valid protocol specifier. (If not, we don't // want to linkify it.) diff --git a/src/client/util/util-webkit.vala b/src/client/util/util-webkit.vala index 25ea7922..1d50652b 100644 --- a/src/client/util/util-webkit.vala +++ b/src/client/util/util-webkit.vala @@ -11,6 +11,39 @@ public const string URL_REGEX = "(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|ww // Regex to determine if a URL has a known protocol. public const string PROTOCOL_REGEX = "^(aim|apt|bitcoin|cvs|ed2k|ftp|file|finger|git|gtalk|http|https|irc|ircs|irc6|lastfm|ldap|ldaps|magnet|news|nntp|rsync|sftp|skype|smb|sms|svn|telnet|tftp|ssh|webcal|xmpp):"; +// TODO Move these other functions and variables into this namespace. +namespace Util.DOM { + public WebKit.DOM.HTMLElement? select(WebKit.DOM.Node node, string selector) { + try { + if (node is WebKit.DOM.Document) { + return (node as WebKit.DOM.Document).query_selector(selector) as WebKit.DOM.HTMLElement; + } else { + return (node as WebKit.DOM.Element).query_selector(selector) as WebKit.DOM.HTMLElement; + } + } catch (Error error) { + debug("Error selecting element %s: %s", selector, error.message); + return null; + } + } + + public WebKit.DOM.HTMLElement? clone_node(WebKit.DOM.Node node, bool deep = true) { + return node.clone_node(deep) as WebKit.DOM.HTMLElement; + } + + public WebKit.DOM.HTMLElement? clone_select(WebKit.DOM.Node node, string selector, + bool deep = true) { + return clone_node(select(node, selector), deep); + } + + public void toggle_class(WebKit.DOM.DOMTokenList class_list, string clas, bool add) throws Error { + if (add) { + class_list.add(clas); + } else { + class_list.remove(clas); + } + } +} + public void bind_event(WebKit.WebView view, string selector, string event, Callback callback, Object? extra = null) { try { diff --git a/src/common/common-files.vala b/src/common/common-files.vala new file mode 100644 index 00000000..3d4b09c7 --- /dev/null +++ b/src/common/common-files.vala @@ -0,0 +1,39 @@ +/* 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 Files { + +public const int64 KILOBYTE = 1024; +public const int64 MEGABYTE = KILOBYTE * 1024; +public const int64 GIGABYTE = MEGABYTE * 1024; +public const int64 TERABYTE = GIGABYTE * 1024; + +public string get_filesize_as_string(int64 filesize) { + int64 scale = 1; + string units = _("bytes"); + if (filesize > TERABYTE) { + scale = TERABYTE; + units = C_("Abbreviation for terabyte", "TB"); + } else if (filesize > GIGABYTE) { + scale = GIGABYTE; + units = C_("Abbreviation for gigabyte", "GB"); + } else if (filesize > MEGABYTE) { + scale = MEGABYTE; + units = C_("Abbreviation for megabyte", "MB"); + } else if (filesize > KILOBYTE) { + scale = KILOBYTE; + units = C_("Abbreviation for kilobyte", "KB"); + } + + if (scale == 1) { + return "%lld %s".printf(filesize, units); + } else { + return "%.2f %s".printf((float) filesize / (float) scale, units); + } +} + +} + diff --git a/src/engine/api/geary-attachment.vala b/src/engine/api/geary-attachment.vala new file mode 100644 index 00000000..1529ec4a --- /dev/null +++ b/src/engine/api/geary-attachment.vala @@ -0,0 +1,32 @@ +/* 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. + */ + +public class Geary.Attachment { + public const Email.Field REQUIRED_FIELDS = Email.Field.HEADER | Email.Field.BODY; + + public string filename { get; private set; } + public string filepath { get; private set; } + public string mime_type { get; private set; } + public int64 filesize { get; private set; } + public int64 id { get; private set; } + + internal Attachment(File data_dir, string filename, string mime_type, int64 filesize, + int64 message_id, int64 attachment_id) { + + this.filename = filename; + this.mime_type = mime_type; + this.filesize = filesize; + this.filepath = get_path(data_dir, message_id, attachment_id, filename); + this.id = attachment_id; + } + + internal static string get_path(File data_dir, int64 message_id, int64 attachment_id, + string filename) { + return "%s/attachments/%lld/%lld/%s".printf(data_dir.get_path(), message_id, attachment_id, + filename); + } +} + diff --git a/src/engine/api/geary-email.vala b/src/engine/api/geary-email.vala index 1b7abbc6..3d48f402 100644 --- a/src/engine/api/geary-email.vala +++ b/src/engine/api/geary-email.vala @@ -113,6 +113,8 @@ public class Geary.Email : Object { // BODY public RFC822.Text? body { get; private set; default = null; } + public Gee.List attachments { get; private set; + default = new Gee.ArrayList(); } // PROPERTIES public Geary.EmailProperties? properties { get; private set; default = null; } @@ -216,13 +218,17 @@ public class Geary.Email : Object { fields |= Field.PREVIEW; } - + public void set_flags(Geary.EmailFlags email_flags) { this.email_flags = email_flags; fields |= Field.FLAGS; } - + + public void add_attachment(Geary.Attachment attachment) { + attachments.add(attachment); + } + /** * This method requires Geary.Email.Field.HEADER and Geary.Email.Field.BODY be present. * If not, EngineError.INCOMPLETE_MESSAGE is thrown. @@ -238,7 +244,19 @@ public class Geary.Email : Object { return message; } - + + public Geary.Attachment? get_attachment(int64 attachment_id) throws EngineError, RFC822Error { + if (!fields.fulfills(Field.HEADER | Field.BODY)) + throw new EngineError.INCOMPLETE_MESSAGE("Parsed email requires HEADER and BODY"); + + foreach (Geary.Attachment attachment in attachments) { + if (attachment.id == attachment_id) { + return attachment; + } + } + return null; + } + /** * Returns a list of this email's ancestry by Message-ID. IDs are not returned in any * particular order. The ancestry is made up from this email's Message-ID, its References, diff --git a/src/engine/rfc822/rfc822-message.vala b/src/engine/rfc822/rfc822-message.vala index 633d597c..32f306c1 100644 --- a/src/engine/rfc822/rfc822-message.vala +++ b/src/engine/rfc822/rfc822-message.vala @@ -265,28 +265,9 @@ public class Geary.RFC822.Message : Object { } // convert payload to a buffer - GMime.DataWrapper? wrapper = part.get_content_object(); - if (wrapper == null) { - throw new RFC822Error.INVALID("Could not get the content wrapper for content-type %s", - content_type); - } - - ByteArray byte_array = new ByteArray(); - GMime.StreamMem stream = new GMime.StreamMem.with_byte_array(byte_array); - stream.set_owner(false); - - // Convert encoding to UTF-8. - GMime.StreamFilter stream_filter = new GMime.StreamFilter(stream); - string? charset = part.get_content_type_parameter("charset"); - if (charset == null) - charset = DEFAULT_ENCODING; - stream_filter.add(new GMime.FilterCharset(charset, "UTF8")); - - wrapper.write_to_stream(stream_filter); - - return new Geary.Memory.Buffer(byte_array.data, byte_array.len); + return mime_part_to_memory_buffer(part, true); } - + private GMime.Part? find_first_mime_part(GMime.Object current_root, string content_type) { // descend looking for the content type in a GMime.Part GMime.Multipart? multipart = current_root as GMime.Multipart; @@ -298,14 +279,68 @@ public class Geary.RFC822.Message : Object { return child_part; } } - + GMime.Part? part = current_root as GMime.Part; - if (part != null && part.get_content_type().to_string() == content_type) + if (part != null && part.get_content_type().to_string() == content_type && + part.get_disposition() != "attachment") { return part; - + } + return null; } - + + internal Gee.List get_attachments() throws RFC822Error { + Gee.List attachments = new Gee.ArrayList(); + find_attachments( ref attachments, message.get_mime_part() ); + return attachments; + } + + private void find_attachments(ref Gee.List attachments, GMime.Object root) + throws RFC822Error { + + // If this is a multipart container, dive into each of its children. + if (root is GMime.Multipart) { + GMime.Multipart multipart = root as GMime.Multipart; + int count = multipart.get_count(); + for (int i = 0; i < count; ++i) { + find_attachments(ref attachments, multipart.get_part(i)); + } + return; + } + + // Otherwise see if it has a content disposition of "attachment." + if (root is GMime.Part && root.get_disposition() == "attachment") { + attachments.add(root as GMime.Part); + } + } + + private Geary.Memory.AbstractBuffer mime_part_to_memory_buffer(GMime.Part part, + bool to_utf8 = false) throws RFC822Error { + + GMime.DataWrapper? wrapper = part.get_content_object(); + if (wrapper == null) { + throw new RFC822Error.INVALID("Could not get the content wrapper for content-type %s", + part.get_content_type().to_string()); + } + + ByteArray byte_array = new ByteArray(); + GMime.StreamMem stream = new GMime.StreamMem.with_byte_array(byte_array); + stream.set_owner(false); + + // Convert encoding to UTF-8. + GMime.StreamFilter stream_filter = new GMime.StreamFilter(stream); + if (to_utf8) { + string? charset = part.get_content_type_parameter("charset"); + if (charset == null) + charset = DEFAULT_ENCODING; + stream_filter.add(new GMime.FilterCharset(charset, "UTF8")); + } + + wrapper.write_to_stream(stream_filter); + + return new Geary.Memory.Buffer(byte_array.data, byte_array.len); + } + public string to_string() { return message.to_string(); } diff --git a/src/engine/sqlite/abstract/sqlite-database.vala b/src/engine/sqlite/abstract/sqlite-database.vala index 28f933b4..58ed69b2 100644 --- a/src/engine/sqlite/abstract/sqlite-database.vala +++ b/src/engine/sqlite/abstract/sqlite-database.vala @@ -6,6 +6,7 @@ public abstract class Geary.Sqlite.Database { internal SQLHeavy.VersionedDatabase db; + internal File data_dir; internal File schema_dir; private Gee.HashMap table_map = new Gee.HashMap< @@ -17,8 +18,9 @@ public abstract class Geary.Sqlite.Database { public Database(File db_file, File schema_dir) throws Error { this.schema_dir = schema_dir; - if (!db_file.get_parent().query_exists()) - db_file.get_parent().make_directory_with_parents(); + data_dir = db_file.get_parent(); + if (!data_dir.query_exists()) + data_dir.make_directory_with_parents(); db = new SQLHeavy.VersionedDatabase(db_file.get_path(), schema_dir.get_path()); db.foreign_keys = true; diff --git a/src/engine/sqlite/api/sqlite-folder.vala b/src/engine/sqlite/api/sqlite-folder.vala index 9dc216a0..cb1aad0d 100644 --- a/src/engine/sqlite/api/sqlite-folder.vala +++ b/src/engine/sqlite/api/sqlite-folder.vala @@ -35,6 +35,7 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { private Geary.Imap.FolderProperties? properties; private MessageTable message_table; private MessageLocationTable location_table; + private MessageAttachmentTable attachment_table; private ImapMessagePropertiesTable imap_message_properties_table; private Geary.FolderPath path; @@ -47,6 +48,7 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { message_table = db.get_message_table(); location_table = db.get_message_location_table(); + attachment_table = db.get_message_attachment_table(); imap_message_properties_table = db.get_imap_message_properties_table(); } @@ -232,7 +234,13 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { MessageLocationRow location_row = new MessageLocationRow(location_table, Row.INVALID_ID, message_id, folder_row.id, email.id.ordering, email.position); yield location_table.create_async(transaction, location_row, cancellable); - + + // Also add attachments if we have them. + if (email.fields.fulfills(Attachment.REQUIRED_FIELDS)) { + Gee.List attachments = email.get_message().get_attachments(); + yield save_attachments_async(transaction, attachments, message_id, cancellable); + } + // only write out the IMAP email properties if they're supplied and there's something to // write out -- no need to create an empty row Geary.Imap.EmailProperties? properties = (Geary.Imap.EmailProperties?) email.properties; @@ -251,7 +259,7 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { return true; } - + public async Gee.List? list_email_async(int low, int count, Geary.Email.Field required_fields, ListFlags flags, Cancellable? cancellable) throws Error { check_open(); @@ -325,76 +333,22 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { if (list == null || list.size == 0) return null; - - bool partial_ok = flags.is_all_set(ListFlags.PARTIAL_OK); - bool include_removed = flags.is_all_set(ListFlags.INCLUDE_MARKED_FOR_REMOVE); - + // TODO: As this loop involves multiple database operations to form an email, might make // sense in the future to launch each async method separately, putting the final results // together when all the information is fetched Gee.List emails = new Gee.ArrayList(); foreach (MessageLocationRow location_row in list) { - // PROPERTIES and FLAGS are held in separate table from messages, pull from MessageTable - // only if something is needed from there - Geary.Email.Field message_fields = - required_fields.clear(Geary.Email.Field.PROPERTIES | Geary.Email.Field.FLAGS); - - // fetch the message itself - MessageRow? message_row = null; - if (message_fields != Geary.Email.Field.NONE) { - message_row = yield message_table.fetch_async(transaction, location_row.message_id, - message_fields, cancellable); - assert(message_row != null); - - // only add to the list if the email contains all the required fields (because - // properties comes out of a separate table, skip this if properties are requested) - if (!partial_ok && !message_row.fields.fulfills(message_fields)) - continue; - } - - ImapMessagePropertiesRow? properties = null; - if (required_fields.is_any_set(Geary.Email.Field.PROPERTIES | Geary.Email.Field.FLAGS)) { - properties = yield imap_message_properties_table.fetch_async(transaction, - location_row.message_id, cancellable); - if (!partial_ok && properties == null) - continue; - } - - Geary.Imap.UID uid = new Geary.Imap.UID(location_row.ordering); - int position = yield location_row.get_position_async(transaction, include_removed, - cancellable); - if (position == -1) { - debug("WARNING: Unable to locate position of email during list of %s, dropping", - to_string()); - - continue; - } - - Geary.Imap.EmailIdentifier email_id = new Geary.Imap.EmailIdentifier(uid); - - Geary.Email email = (message_row != null) - ? message_row.to_email(position, email_id) - : new Geary.Email(position, email_id); - - if (properties != null) { - if (required_fields.require(Geary.Email.Field.PROPERTIES)) { - Imap.EmailProperties? email_properties = properties.get_imap_email_properties(); - if (email_properties != null) - email.set_email_properties(email_properties); - else if (!partial_ok) - continue; - } - - if (required_fields.require(Geary.Email.Field.FLAGS)) { - EmailFlags? email_flags = properties.get_email_flags(); - if (email_flags != null) - email.set_flags(email_flags); - else if (!partial_ok) - continue; + try { + emails.add(yield location_to_email_async(transaction, location_row, required_fields, + flags, cancellable)); + } catch (EngineError error) { + if (error is EngineError.NOT_FOUND) { + debug("WARNING: Message not found, dropping: %s", error.message); + } else if (!(error is EngineError.INCOMPLETE_MESSAGE)) { + throw error; } } - - emails.add(email); } return (emails.size > 0) ? emails : null; @@ -405,8 +359,6 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { check_open(); Geary.Imap.UID uid = ((Imap.EmailIdentifier) id).uid; - bool partial_ok = flags.is_all_set(ListFlags.PARTIAL_OK); - bool include_removed = flags.is_all_set(ListFlags.INCLUDE_MARKED_FOR_REMOVE); Transaction transaction = yield db.begin_transaction_async("Folder.fetch_email_async", cancellable); @@ -417,37 +369,55 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { throw new EngineError.NOT_FOUND("No message with ID %s in folder %s", id.to_string(), to_string()); } - + + return yield location_to_email_async(transaction, location_row, required_fields, flags, + cancellable); + } + + public async Geary.Email location_to_email_async(Transaction transaction, + MessageLocationRow location_row, Geary.Email.Field required_fields, ListFlags flags, + Cancellable? cancellable = null) throws Error { + + // Prepare our IDs and flags. + Geary.Imap.UID uid = new Geary.Imap.UID(location_row.ordering); + Geary.Imap.EmailIdentifier id = new Geary.Imap.EmailIdentifier(uid); + bool partial_ok = flags.is_all_set(ListFlags.PARTIAL_OK); + bool include_removed = flags.is_all_set(ListFlags.INCLUDE_MARKED_FOR_REMOVE); + + // PROPERTIES and FLAGS are held in separate table from messages, pull from MessageTable + // only if something is needed from there + Geary.Email.Field message_fields = + required_fields.clear(Geary.Email.Field.PROPERTIES | Geary.Email.Field.FLAGS); + int position = yield location_row.get_position_async(transaction, include_removed, cancellable); if (position == -1) { throw new EngineError.NOT_FOUND("Unable to determine position of email %s in %s", id.to_string(), to_string()); } - + // loopback on perverse case if (required_fields == Geary.Email.Field.NONE) return new Geary.Email(position, id); - + // Only fetch message row if we have fields other than Properties and Flags MessageRow? message_row = null; - if (required_fields.clear(Geary.Email.Field.PROPERTIES | Geary.Email.Field.FLAGS) != 0) { - message_row = yield message_table.fetch_async(transaction, - location_row.message_id, required_fields, cancellable); + if (message_fields != Geary.Email.Field.NONE) { + message_row = yield message_table.fetch_async(transaction, location_row.message_id, + message_fields, cancellable); if (message_row == null) { throw new EngineError.NOT_FOUND("No message with ID %s in folder %s", id.to_string(), to_string()); } - + // see if the message row fulfills everything but properties, which are held in // separate table - if (!partial_ok && - !message_row.fields.fulfills(required_fields.clear(Geary.Email.Field.PROPERTIES | Geary.Email.Field.FLAGS))) { + if (!partial_ok && !message_row.fields.fulfills(message_fields)) { throw new EngineError.INCOMPLETE_MESSAGE( - "Message %s in folder %s only fulfills %Xh fields (required: %Xh)", id.to_string(), - to_string(), message_row.fields, required_fields); + "Message %s in folder %s only fulfills %Xh fields (required: %Xh)", + id.to_string(), to_string(), message_row.fields, required_fields); } } - + ImapMessagePropertiesRow? properties = null; if (required_fields.is_any_set(Geary.Email.Field.PROPERTIES | Geary.Email.Field.FLAGS)) { properties = yield imap_message_properties_table.fetch_async(transaction, @@ -458,19 +428,20 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { id.to_string(), to_string()); } } - - Geary.Email email; - email = message_row != null ? message_row.to_email(position, id) : email = - new Geary.Email(position, id); - + + Geary.Email email = message_row != null + ? message_row.to_email(position, id) + : new Geary.Email(position, id); + if (properties != null) { if (required_fields.require(Geary.Email.Field.PROPERTIES)) { Imap.EmailProperties? email_properties = properties.get_imap_email_properties(); if (email_properties != null) { email.set_email_properties(email_properties); } else if (!partial_ok) { - throw new EngineError.INCOMPLETE_MESSAGE("Message %s in folder %s does not have PROPERTIES fields", - id.to_string(), to_string()); + throw new EngineError.INCOMPLETE_MESSAGE( + "Message %s in folder %s does not have PROPERTIES fields", id.to_string(), + to_string()); } } @@ -479,15 +450,26 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { if (email_flags != null) { email.set_flags(email_flags); } else if (!partial_ok) { - throw new EngineError.INCOMPLETE_MESSAGE("Message %s in folder %s does not have FLAGS fields", - id.to_string(), to_string()); + throw new EngineError.INCOMPLETE_MESSAGE( + "Message %s in folder %s does not have FLAGS fields", id.to_string(), + to_string()); } } } - + + // Load the attachments as well if we have the full message. + if (required_fields.fulfills(Geary.Attachment.REQUIRED_FIELDS)) { + Gee.List attachments = yield attachment_table.list_async( + transaction, location_row.message_id, cancellable); + foreach (MessageAttachmentRow row in attachments) { + email.add_attachment(row.to_attachment()); + } + } + return email; + } - + public async Geary.Imap.UID? get_earliest_uid_async(Cancellable? cancellable = null) throws Error { return yield get_uid_extremes_async(true, cancellable); } @@ -624,20 +606,34 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { // if nothing to merge, nothing to do if (email.fields == Geary.Email.Field.NONE) return; - + // Only merge with MessageTable if has fields applicable to it if (email.fields.clear(Geary.Email.Field.PROPERTIES | Geary.Email.Field.FLAGS) != 0) { - MessageRow? message_row = yield message_table.fetch_async(transaction, message_id, email.fields, - cancellable); + MessageRow? message_row = yield message_table.fetch_async(transaction, message_id, + (email.fields | Attachment.REQUIRED_FIELDS), cancellable); + assert(message_row != null); - + Geary.Email.Field db_fields = message_row.fields; message_row.merge_from_remote(email); - - // possible nothing has changed or been added - if (message_row.fields != Geary.Email.Field.NONE) + + // Get the combined email from the merge which will be used below to save the attachments. + Geary.Email combined_email = message_row.to_email(email.position, email.id); + + // Next see if all the fields we've received are already in the DB. If they are then + // there is nothing for us to do. + if ((db_fields & email.fields) != email.fields) { yield message_table.merge_async(transaction, message_row, cancellable); + + // Also update the saved attachments if we don't already have them in the database + // and between the database and the new fields we have what is required. + if (!db_fields.fulfills(Attachment.REQUIRED_FIELDS) && + combined_email.fields.fulfills(Attachment.REQUIRED_FIELDS)) { + yield save_attachments_async(transaction, + combined_email.get_message().get_attachments(), message_id, cancellable); + } + } } - + // update IMAP properties if (email.fields.fulfills(Geary.Email.Field.PROPERTIES)) { Geary.Imap.EmailProperties properties = (Geary.Imap.EmailProperties) email.properties; @@ -658,7 +654,63 @@ private class Geary.Sqlite.Folder : Object, Geary.ReferenceSemantics { flags, cancellable); } } - + + private async void save_attachments_async(Transaction transaction, + Gee.List attachments, int64 message_id, Cancellable? cancellable = null) + throws Error { + + // Nothing to do if no attachments. + if (attachments.size == 0){ + return; + } + + foreach (GMime.Part attachment in attachments) { + // Get the info about the attachment. + string? filename = attachment.get_filename(); + string mime_type = attachment.get_content_type().to_string(); + if (filename == null || filename.length == 0) { + filename = _("none"); + } + + // Convert the attachment content into a usable ByteArray. + GMime.DataWrapper attachment_data = attachment.get_content_object(); + ByteArray byte_array = new ByteArray(); + GMime.StreamMem stream = new GMime.StreamMem.with_byte_array(byte_array); + stream.set_owner(false); + attachment_data.write_to_stream(stream); + uint filesize = byte_array.len; + + // Insert it into the database. + MessageAttachmentRow attachment_row = new MessageAttachmentRow(attachment_table, 0, + message_id, filename, mime_type, filesize); + int64 attachment_id = yield attachment_table.create_async(transaction, attachment_row, + cancellable); + + try { + // Create the file where the attachment will be saved and get the output stream. + string saved_name = Attachment.get_path(db.data_dir, message_id, attachment_id, + filename); + debug("Saving attachment to %s", saved_name); + File saved_file = File.new_for_path(saved_name); + saved_file.get_parent().make_directory_with_parents(); + FileOutputStream saved_stream = yield saved_file.create_async( + FileCreateFlags.REPLACE_DESTINATION, Priority.DEFAULT, cancellable); + + // Save the data to disk and flush it. + yield saved_stream.write_async(byte_array.data[0:filesize], Priority.DEFAULT, + cancellable); + yield saved_stream.flush_async(); + } catch (Error error) { + // An error occurred while saving the attachment, so lets remove the attachment from + // the database. + // TODO Use SQLite transactions here and do a rollback. + debug("Failed to save attachment: %s", error.message); + yield attachment_table.remove_async(transaction, attachment_id, cancellable); + throw error; + } + } + } + public async void remove_marked_email_async(Geary.EmailIdentifier id, out bool marked, Cancellable? cancellable) throws Error { check_open(); diff --git a/src/engine/sqlite/email/sqlite-mail-database.vala b/src/engine/sqlite/email/sqlite-mail-database.vala index a36e6be6..8a2a197c 100644 --- a/src/engine/sqlite/email/sqlite-mail-database.vala +++ b/src/engine/sqlite/email/sqlite-mail-database.vala @@ -6,10 +6,9 @@ public class Geary.Sqlite.MailDatabase : Geary.Sqlite.Database { public const string FILENAME = "geary.db"; - + public MailDatabase(string user, File user_data_dir, File resource_dir) throws Error { - base (user_data_dir.get_child(user).get_child(FILENAME), - resource_dir.get_child("sql")); + base (user_data_dir.get_child(user).get_child(FILENAME), resource_dir.get_child("sql")); } public Geary.Sqlite.FolderTable get_folder_table() { @@ -39,5 +38,15 @@ public class Geary.Sqlite.MailDatabase : Geary.Sqlite.Database { ? location_table : (MessageLocationTable) add_table(new MessageLocationTable(this, heavy_table)); } + + public Geary.Sqlite.MessageAttachmentTable get_message_attachment_table() { + SQLHeavy.Table heavy_table; + MessageAttachmentTable? attachment_table = get_table("MessageAttachmentTable", out heavy_table) + as MessageAttachmentTable; + + return (attachment_table != null) + ? attachment_table + : (MessageAttachmentTable) add_table(new MessageAttachmentTable(this, heavy_table)); + } } diff --git a/src/engine/sqlite/email/sqlite-message-attachment-row.vala b/src/engine/sqlite/email/sqlite-message-attachment-row.vala new file mode 100644 index 00000000..b1160f2a --- /dev/null +++ b/src/engine/sqlite/email/sqlite-message-attachment-row.vala @@ -0,0 +1,39 @@ +/* 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. + */ + +public class Geary.Sqlite.MessageAttachmentRow : Geary.Sqlite.Row { + public int64 id { get; private set; } + public int64 message_id { get; private set; } + public int64 filesize { get; private set; } + public string filename { get; private set; } + public string mime_type { get; private set; } + + public MessageAttachmentRow(MessageAttachmentTable table, int64 id, int64 message_id, + string filename, string mime_type, int64 filesize) { + base (table); + + this.id = id; + this.message_id = message_id; + this.filename = filename; + this.mime_type = mime_type; + this.filesize = filesize; + } + + public MessageAttachmentRow.from_query_result(MessageAttachmentTable table, + SQLHeavy.QueryResult result) throws Error { + base (table); + + id = fetch_int64_for(result, MessageAttachmentTable.Column.ID); + message_id = fetch_int64_for(result, MessageAttachmentTable.Column.MESSAGE_ID); + filename = fetch_string_for(result, MessageAttachmentTable.Column.FILENAME); + mime_type = fetch_string_for(result, MessageAttachmentTable.Column.MIME_TYPE); + } + + public Geary.Attachment to_attachment() { + return new Attachment(table.gdb.data_dir, filename, mime_type, filesize, message_id, id); + } +} + diff --git a/src/engine/sqlite/email/sqlite-message-attachment-table.vala b/src/engine/sqlite/email/sqlite-message-attachment-table.vala new file mode 100644 index 00000000..9038a315 --- /dev/null +++ b/src/engine/sqlite/email/sqlite-message-attachment-table.vala @@ -0,0 +1,90 @@ +/* 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. + */ + +public class Geary.Sqlite.MessageAttachmentTable : Geary.Sqlite.Table { + // This row *must* match the order in the schema + public enum Column { + ID, + MESSAGE_ID, + FILENAME, + MIME_TYPE, + FILESIZE + } + + public MessageAttachmentTable(Geary.Sqlite.Database db, SQLHeavy.Table table) { + base (db, table); + } + + public async int64 create_async(Transaction? transaction, MessageAttachmentRow row, + Cancellable? cancellable) throws Error { + Transaction locked = yield obtain_lock_async(transaction, "MessageAttachmentTable.create_async", + cancellable); + + SQLHeavy.Query query = locked.prepare( + "INSERT INTO MessageAttachmentTable (message_id, filename, mime_type, filesize) " + + "VALUES (?, ?, ?, ?)"); + query.bind_int64(0, row.message_id); + query.bind_string(1, row.filename); + query.bind_string(2, row.mime_type); + query.bind_int64(3, row.filesize); + + int64 id = yield query.execute_insert_async(cancellable); + locked.set_commit_required(); + + yield release_lock_async(transaction, locked, cancellable); + + check_cancel(cancellable, "create_async"); + + return id; + } + + public async Gee.List? list_async(Transaction? transaction, + int64 message_id, Cancellable? cancellable) throws Error { + + Transaction locked = yield obtain_lock_async(transaction, "MessageAttachmentTable.list_async", + cancellable); + + SQLHeavy.Query query = locked.prepare( + "SELECT id, filename, mime_type, filesize FROM MessageAttachmentTable " + + "WHERE message_id = ? ORDER BY id"); + query.bind_int64(0, message_id); + + SQLHeavy.QueryResult results = yield query.execute_async(); + check_cancel(cancellable, "list_async"); + + Gee.List list = new Gee.ArrayList(); + if (results.finished) + return list; + + do { + list.add(new MessageAttachmentRow(this, results.fetch_int64(0), message_id, + results.fetch_string(1), results.fetch_string(2), results.fetch_int64(3))); + + yield results.next_async(); + + check_cancel(cancellable, "list_async"); + } while (!results.finished); + + return list; + } + + public async void remove_async(Transaction? transaction, int64 attachment_id, + Cancellable? cancellable) throws Error { + + Transaction locked = yield obtain_lock_async(transaction, + "MessageAttachmentTable.remove_async", cancellable); + + SQLHeavy.Query query = locked.prepare( + "DELETE FROM MessageAttachmentTable WHERE attachment_id = ?"); + query.bind_int64(0, attachment_id); + + yield query.execute_async(); + locked.set_commit_required(); + yield release_lock_async(transaction, locked, cancellable); + check_cancel(cancellable, "remove_async"); + } +} + diff --git a/src/engine/sqlite/email/sqlite-message-row.vala b/src/engine/sqlite/email/sqlite-message-row.vala index d17d1d5a..14b855aa 100644 --- a/src/engine/sqlite/email/sqlite-message-row.vala +++ b/src/engine/sqlite/email/sqlite-message-row.vala @@ -132,8 +132,6 @@ public class Geary.Sqlite.MessageRow : Geary.Sqlite.Row { foreach (Geary.Email.Field field in Geary.Email.Field.all()) { if ((email.fields & field) != 0) set_from_email(field, email); - else - unset_fields(field); } } @@ -224,62 +222,5 @@ public class Geary.Sqlite.MessageRow : Geary.Sqlite.Row { this.fields = this.fields.set(Geary.Email.Field.PREVIEW); } } - - private void unset_fields(Geary.Email.Field fields) { - if ((fields & Geary.Email.Field.DATE) != 0) { - date = null; - date_time_t = -1; - - this.fields = this.fields.clear(Geary.Email.Field.DATE); - } - - if ((fields & Geary.Email.Field.ORIGINATORS) != 0) { - from = null; - sender = null; - reply_to = null; - - this.fields = this.fields.clear(Geary.Email.Field.ORIGINATORS); - } - - if ((fields & Geary.Email.Field.RECEIVERS) != 0) { - to = null; - cc = null; - bcc = null; - - this.fields = this.fields.clear(Geary.Email.Field.RECEIVERS); - } - - if ((fields & Geary.Email.Field.REFERENCES) != 0) { - message_id = null; - in_reply_to = null; - references = null; - - this.fields = this.fields.clear(Geary.Email.Field.REFERENCES); - } - - if ((fields & Geary.Email.Field.SUBJECT) != 0) { - subject = null; - - this.fields = this.fields.clear(Geary.Email.Field.SUBJECT); - } - - if ((fields & Geary.Email.Field.HEADER) != 0) { - header = null; - - this.fields = this.fields.clear(Geary.Email.Field.HEADER); - } - - if ((fields & Geary.Email.Field.BODY) != 0) { - body = null; - - this.fields = this.fields.clear(Geary.Email.Field.BODY); - } - - if ((fields & Geary.Email.Field.PREVIEW) != 0) { - preview = null; - - this.fields = this.fields.clear(Geary.Email.Field.PREVIEW); - } - } }