[shotwell] Publish to Gallery 3: Closes bug #717839



commit 2a6f84f55fecfdface61556aa7f30269c526e582
Author: Joe Sapp <sappj ieee org>
Date:   Tue May 6 11:37:42 2014 -0700

    Publish to Gallery 3: Closes bug #717839
    
    Added to Shotwell Publishing Extras.

 THANKS                                             |    1 +
 misc/org.yorba.shotwell.gschema.xml                |   51 +
 plugins/plugins.mk                                 |    3 +
 .../GalleryConnector.vala                          | 2034 ++++++++++++++++++++
 plugins/shotwell-publishing-extras/Makefile        |    4 +
 plugins/shotwell-publishing-extras/gallery3.png    |  Bin 0 -> 802 bytes
 .../gallery3_authentication_pane.glade             |  245 +++
 .../gallery3_publishing_options_pane.glade         |  282 +++
 .../shotwell-publishing-extras.vala                |    1 +
 po/POTFILES.in                                     |    3 +
 10 files changed, 2624 insertions(+), 0 deletions(-)
---
diff --git a/THANKS b/THANKS
index c6b7242..873a1d3 100644
--- a/THANKS
+++ b/THANKS
@@ -65,6 +65,7 @@ Alexandre Rosenfeld <alexandre rosenfeld gmail com>
 Elliott S <quantum analyst gmail com>
 Alexander Sack <asac ubuntu com>
 Michel Alexandre Salim <michael silvanus gmail com>
+Joe Sapp <sappj ieee org>
 Benedikt Sauer <filmor gmail com>
 Peter Seiderer <peter seiderer gmx de>
 Ville Skyttä <ville skytta iki fi>
diff --git a/misc/org.yorba.shotwell.gschema.xml b/misc/org.yorba.shotwell.gschema.xml
index 54675d5..1f6a093 100644
--- a/misc/org.yorba.shotwell.gschema.xml
+++ b/misc/org.yorba.shotwell.gschema.xml
@@ -323,6 +323,7 @@
     
     <child name="facebook" schema="org.yorba.shotwell.sharing.facebook" />
     <child name="flickr" schema="org.yorba.shotwell.sharing.flickr" />
+    <child name="gallery3" schema="org.yorba.shotwell.sharing.publishing-gallery3" />
     <child name="picasa" schema="org.yorba.shotwell.sharing.picasa" />
     <child name="youtube" schema="org.yorba.shotwell.sharing.youtube" />
 </schema>
@@ -485,6 +486,50 @@
     </key>
 </schema>
 
+<schema id="org.yorba.shotwell.sharing.publishing-gallery3" path="/org/yorba/shotwell/sharing/gallery3/">
+    <key name="username" type="s">
+        <default>""</default>
+        <summary>username</summary>
+        <description>Gallery3 username</description>
+    </key>
+
+    <key name="api-key" type="s">
+        <default>""</default>
+        <summary>API key</summary>
+        <description>Gallery3 API key</description>
+    </key>
+
+    <key name="url" type="s">
+        <default>""</default>
+        <summary>URL</summary>
+        <description>Gallery3 site URL</description>
+    </key>
+
+    <key name="last-album" type="s">
+        <default>""</default>
+        <summary>last album</summary>
+        <description>The name of the last album the user published photos to, if any</description>
+    </key>
+
+    <key name="strip-metadata" type="b">
+        <default>false</default>
+        <summary>remove sensitive info from uploads</summary>
+        <description>Indicates whether images being uploaded to Gallery3 should have their metadata removed 
first</description>
+    </key>
+
+    <key name="scaling-constraint-id" type="i">
+        <default>0</default>
+        <summary>scaling constraint of uploaded picture</summary>
+        <description>The scaling constraint ID of the picture to be uploaded</description>
+    </key>
+
+    <key name="scaling-pixels" type="i">
+        <default>1024</default>
+        <summary>pixels of the major axis uploaded picture</summary>
+        <description>The pixels of the major axis of the picture to be uploaded; used only if 
scaling-constraint-id is an appropriate value</description>
+    </key>
+</schema>
+
 <schema id="org.yorba.shotwell.sharing.youtube" path="/org/yorba/shotwell/sharing/youtube/">
     <key name="refresh-token" type="s">
         <default>""</default>
@@ -624,6 +669,12 @@
         <description>True if the Rajce publishing plugin is enabled, false otherwise</description>
     </key>
     
+    <key name="publishing-gallery3" type="b">
+        <default>false</default>
+        <summary>enable gallery3 publishing plugin</summary>
+        <description>True if the Gallery3 publishing plugin is enabled, false otherwise</description>
+    </key>
+
     <key name="dataimports-fspot" type="b">
         <default>true</default>
         <summary>enable F-Spot import plugin</summary>
diff --git a/plugins/plugins.mk b/plugins/plugins.mk
index e561150..cb7237f 100644
--- a/plugins/plugins.mk
+++ b/plugins/plugins.mk
@@ -25,6 +25,9 @@ EXTRA_PLUGINS := \
 EXTRA_PLUGINS_RC := \
        plugins/shotwell-publishing-extras/yandex_publish_model.glade \
        plugins/shotwell-data-imports/f-spot-24.png \
+       plugins/shotwell-publishing-extras/gallery3.png \
+       plugins/shotwell-publishing-extras/gallery3_authentication_pane.glade \
+       plugins/shotwell-publishing-extras/gallery3_publishing_options_pane.glade \
        plugins/shotwell-publishing-extras/tumblr.png \
        plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade \
        plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade \
diff --git a/plugins/shotwell-publishing-extras/GalleryConnector.vala 
b/plugins/shotwell-publishing-extras/GalleryConnector.vala
new file mode 100644
index 0000000..682aff0
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/GalleryConnector.vala
@@ -0,0 +1,2034 @@
+/* Copyright 2012-2013 Joe Sapp nixphoeni gentoo org
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+
+static const string G3_VERSION = "0.1";
+
+static const string G3_LICENSE = """
+The Gallery3Publishing module is free software; you can redistribute it
+and/or modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either version 2.1
+of the License, or (at your option) any later version.
+
+The Gallery3Publishing module is distributed in the hope that it will be
+useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser
+General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License
+along with The Gallery3Publishing module; if not, write to the Free
+Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
+02110-1301 USA
+""";
+
+static const string WEBSITE_URL =
+    "https://github.com/sappjw/shotwell-gallery3";;
+
+// This module's Spit.Module
+private class ShotwellPublishingGallery3 : Object, Spit.Module {
+    private Spit.Pluggable[] pluggables = new Spit.Pluggable[0];
+
+    public ShotwellPublishingGallery3(GLib.File module_file) {
+        GLib.File resource_directory = module_file.get_parent();
+
+        pluggables += new Gallery3Service(resource_directory);
+    }
+
+    public unowned string get_module_name() {
+        return _("Gallery3 publishing module");
+    }
+
+    public unowned string get_version() {
+        return G3_VERSION;
+    }
+
+    public unowned string get_id() {
+        return "org.yorba.shotwell.sharing.gallery3";
+    }
+
+    public unowned Spit.Pluggable[]? get_pluggables() {
+        return pluggables;
+    }
+}
+
+// The Pluggable
+public class Gallery3Service : Object, Spit.Pluggable,
+        Spit.Publishing.Service {
+    private const string ICON_FILENAME = "gallery3.png";
+
+    private static Gdk.Pixbuf[] icon_pixbuf_set = null;
+
+    public Gallery3Service(GLib.File resource_directory) {
+        if (icon_pixbuf_set == null)
+            icon_pixbuf_set = Resources.load_icon_set(
+                resource_directory.get_child(ICON_FILENAME));
+    }
+
+    public int get_pluggable_interface(int min_host_interface,
+            int max_host_interface) {
+        return Spit.negotiate_interfaces(min_host_interface,
+            max_host_interface,
+            Spit.Publishing.CURRENT_INTERFACE);
+    }
+
+    public unowned string get_id() {
+        return "publishing-gallery3";
+    }
+
+    public unowned string get_pluggable_name() {
+        return "Gallery3";
+    }
+
+    public void get_info(ref Spit.PluggableInfo info) {
+        info.authors = "Joe Sapp";
+        info.copyright = "2012-2013 Joe Sapp";
+        info.translators = Resources.TRANSLATORS;
+        info.version = G3_VERSION;
+        info.website_url = WEBSITE_URL;
+        info.is_license_wordwrapped = false;
+        info.license = G3_LICENSE;
+        info.icons = icon_pixbuf_set;
+    }
+
+    public void activation(bool enabled) {
+    }
+
+    public Spit.Publishing.Publisher create_publisher(
+            Spit.Publishing.PluginHost host) {
+        return new Publishing.Gallery3.GalleryPublisher(this, host);
+    }
+
+    public Spit.Publishing.Publisher.MediaType get_supported_media() {
+        return (Spit.Publishing.Publisher.MediaType.PHOTO |
+            Spit.Publishing.Publisher.MediaType.VIDEO);
+    }
+}
+
+
+namespace Publishing.Gallery3 {
+private const string SERVICE_NAME = "Gallery3";
+private const string SERVICE_WELCOME_MESSAGE =
+    _("You are not currently logged into your Gallery.\n\nYou must have already signed up for a Gallery3 
account to complete the login process.");
+private const string DEFAULT_ALBUM_DIR = _("Shotwell");
+private const string DEFAULT_ALBUM_TITLE =
+    _("Shotwell default directory");
+private const string REST_PATH = "/index.php/rest";
+
+private class Album {
+
+    // Properties
+    public string name { get; private set; default = ""; }
+    public string title { get; private set; default = ""; }
+    public string summary { get; private set; default = ""; }
+    public string parentname { get; private set; default = ""; }
+    public string url { get; private set; default = ""; }
+    public string path { get; private set; default = ""; }
+    public bool editable { get; private set; default = false; }
+
+    // Each element is a collection
+    public Album(Json.Object collection) {
+
+        unowned Json.Object entity =
+            collection.get_object_member("entity");
+
+        title = entity.get_string_member("title");
+        name = entity.get_string_member("name");
+        parentname = entity.get_string_member("parent");
+        url = collection.get_string_member("url");
+        editable = entity.get_boolean_member("can_edit");
+
+        // Get the path from the last two elements of the URL.
+        // This should always be "/item/#" where "#" is a number.
+        path = strip_session_url(url);
+
+    }
+
+}
+
+private class BaseGalleryTransaction :
+        Publishing.RESTSupport.Transaction {
+
+    protected Json.Parser parser;
+
+    // BaseGalleryTransaction constructor
+    public BaseGalleryTransaction(Session session, string endpoint_url,
+            string item_path = "",
+            Publishing.RESTSupport.HttpMethod method =
+            Publishing.RESTSupport.HttpMethod.POST) {
+
+        // TODO: eventually we can remove this
+        if ((item_path != "") && (item_path[0] != '/')) {
+            warning("Bad item path, this is a bug!");
+            error(item_path);
+        }
+
+        base.with_endpoint_url(session,
+            endpoint_url + REST_PATH + item_path,
+            method);
+
+        this.parser = new Json.Parser();
+
+    }
+
+    protected unowned Json.Node get_root_node()
+            throws Spit.Publishing.PublishingError {
+
+        string json_object;
+        unowned Json.Node root_node;
+
+        json_object = get_response();
+
+        if ((null == json_object) || (0 == json_object.length))
+            throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(
+                "No response data from %s", get_endpoint_url());
+
+        try {
+            this.parser.load_from_data(json_object);
+        }
+        catch (GLib.Error e) {
+            // If this didn't work, reset the "executed" state
+            warning("ERROR: didn't load JSON data");
+            set_is_executed(false);
+            throw new Spit.Publishing.PublishingError.PROTOCOL_ERROR(e.message);
+        }
+
+        root_node = this.parser.get_root();
+        if (root_node.is_null())
+            throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(
+                "Root node is null, doesn't appear to be JSON data");
+
+        return root_node;
+
+    }
+
+}
+
+private class KeyFetchTransaction : BaseGalleryTransaction {
+
+    private string key = "";
+
+    // KeyFetchTransaction constructor
+    //
+    // url: Base gallery URL
+    public KeyFetchTransaction(Session session, string url,
+            string username, string password) {
+        base(session, url);
+        add_argument("user", username);
+        add_argument("password", password);
+    }
+
+    public string get_key() {
+
+        if (key != "")
+            return key;
+
+        key = get_response();
+
+        // The returned data isn't actually a JSON object...
+        if (null == key || "" == key || 0 == key.length) {
+            warning("No response data from \"%s\"", get_endpoint_url());
+            return "";
+        }
+
+        // Eliminate quotes surrounding key
+        key = key[1:-1];
+
+        return key;
+    }
+
+}
+
+private class GalleryRequestTransaction : BaseGalleryTransaction {
+
+    // GalleryRequestTransaction constructor
+    //
+    // item: Item URL component
+    public GalleryRequestTransaction(Session session, string item,
+            Publishing.RESTSupport.HttpMethod method =
+            Publishing.RESTSupport.HttpMethod.GET) {
+
+        if (!session.is_authenticated()) {
+            error("Not authenticated");
+        }
+        else {
+            base(session, session.url, item, method);
+            add_header("X-Gallery-Request-Key", session.key);
+            add_header("X-Gallery-Request-Method", "GET");
+        }
+
+    }
+
+}
+
+private class GetAlbumURLsTransaction : GalleryRequestTransaction {
+
+    public GetAlbumURLsTransaction(Session session) {
+
+        base(session, "/item/1");
+        add_argument("type", "album");
+        add_argument("scope", "all");
+
+    }
+
+    public string [] get_album_urls() {
+
+        unowned Json.Node root_node;
+        unowned Json.Array all_members;
+
+        try {
+            root_node = get_root_node();
+        }
+        catch (Spit.Publishing.PublishingError e) {
+            error("Could not get root node");
+        }
+
+        all_members =
+            root_node.get_object().get_array_member("members");
+
+        string [] member_urls = null;
+
+        for (uint i = 0; i <= all_members.get_length() - 1; i++)
+            member_urls += all_members.get_string_element(i);
+
+        return member_urls;
+
+    }
+
+}
+
+private class GetAlbumsTransaction : GalleryRequestTransaction {
+
+    // Properties
+    // Original list of album URLs
+    public string [] album_urls { get; private set; default = null; }
+    // How many URLs have been sent?
+    public uint urls_sent { get; private set; default = 0; }
+    // Are there (possibly) more URLs to send?
+    public bool more_urls { get; private set; default = false; }
+
+    public GetAlbumsTransaction(Session session, string [] _album_urls,
+                                uint start = 0) {
+
+        base(session, "/items");
+        add_argument("scope", "all");
+
+        // Save original list of URLs
+        album_urls = _album_urls;
+
+        // Wrap each URL in double quotes and separate by a comma, but
+        // we should try to keep the length of the URL under 255
+        // characters.  We need to do this to avoid problems with URLs
+        // that are too long on some web servers (and, really, if there
+        // are alot of albums, this can get large quickly).
+        // The Gallery3 API should probably allow this in a POST
+        // transaction...
+        string url_list = "[";
+        string [] my_album_urls = null;
+        string? endpoint_url = session.get_endpoint_url();
+        int url_length = (null != endpoint_url) ?
+            endpoint_url.length : 0;
+        url_length += 18; // for: ?scope=all&urls=[]
+
+        // We have to allow at least one URL at a time
+        if (start <= album_urls.length - 1) {
+
+            urls_sent = start;
+            do {
+                my_album_urls += "\"" + album_urls[urls_sent] + "\"";
+                // Add 3 for: "",
+                url_length += album_urls[urls_sent].length + 3;
+                urls_sent++;
+            } while ((urls_sent <= album_urls.length - 1) &&
+                     (url_length +
+                      album_urls[urls_sent].length + 3 <= 255));
+            url_list += string.joinv(",", my_album_urls);
+
+            more_urls = (urls_sent <= (album_urls.length - 1));
+
+        }
+        url_list += "]";
+
+        add_argument("urls", url_list);
+
+    }
+
+    public Album [] get_albums()
+            throws Spit.Publishing.PublishingError {
+
+        Album [] albums = null;
+        Album tmp_album;
+        unowned Json.Node root_node = get_root_node();
+        unowned Json.Array members = root_node.get_array();
+
+        // Only add editable items
+        for (uint i = 0; i <= members.get_length() - 1; i++) {
+            tmp_album = new Album(members.get_object_element(i));
+
+            if (tmp_album.editable)
+                albums += tmp_album;
+            else
+                warning(@"Album \"$(tmp_album.title)\" is not editable");
+        }
+
+        return albums;
+    }
+
+}
+
+// Class to create or get a tag URL.
+// Tag URLs are placed in the "item_tags" object and relate an item and
+// its tags.
+private class GalleryGetTagTransaction : BaseGalleryTransaction {
+
+    public GalleryGetTagTransaction(Session session, string tag_name) {
+
+        if (!session.is_authenticated()) {
+            error("Not authenticated");
+        }
+        else {
+            Json.Generator entity = new Json.Generator();
+            Json.Node root_node = new Json.Node(Json.NodeType.OBJECT);
+            Json.Object obj = new Json.Object();
+
+            base(session, session.url,
+                "/tags",
+                Publishing.RESTSupport.HttpMethod.POST);
+            add_header("X-Gallery-Request-Key", session.key);
+            add_header("X-Gallery-Request-Method", "POST");
+
+            obj.set_string_member("name", tag_name);
+            root_node.set_object(obj);
+            entity.set_root(root_node);
+
+            size_t entity_length;
+            string entity_value = entity.to_data(out entity_length);
+
+            debug("created entity: %s", entity_value);
+
+            add_argument("entity", entity_value);
+        }
+
+    }
+
+    public string tag_url() {
+
+        unowned Json.Node root_node;
+        string url;
+
+        try {
+            root_node = get_root_node();
+        }
+        catch (Spit.Publishing.PublishingError e) {
+            error("Could not get root node");
+        }
+
+        url =
+            root_node.get_object().get_string_member("url");
+
+        return url;
+
+    }
+
+}
+
+// Get the item_tags URL for a given item
+private class GalleryGetItemTagsURLsTransaction :
+        GalleryRequestTransaction {
+
+    private string item_tags_path = "";
+
+    public GalleryGetItemTagsURLsTransaction(Session session,
+            string item_url) {
+
+        base(session, item_url);
+
+    }
+
+    public string get_item_tags_path() {
+
+        unowned Json.Node root_node;
+        unowned Json.Object relationships, tags;
+
+        if ("" == item_tags_path) {
+
+            try {
+                root_node = get_root_node();
+            }
+            catch (Spit.Publishing.PublishingError e) {
+                error("Could not get root node");
+            }
+
+            relationships =
+                root_node.get_object().get_object_member("relationships");
+            tags = relationships.get_object_member("tags");
+
+            item_tags_path = tags.get_string_member("url");
+
+            // Remove the session URL from the beginning of this URL
+            item_tags_path = strip_session_url(item_tags_path);
+
+        }
+
+        return item_tags_path;
+
+    }
+
+}
+
+// Set a tag relationship with an item
+private class GallerySetTagRelationshipTransaction :
+        BaseGalleryTransaction {
+
+    public GallerySetTagRelationshipTransaction(Session session,
+            string item_tags_path, string tag_url, string item_url) {
+
+        if (!session.is_authenticated()) {
+            error("Not authenticated");
+        }
+        else {
+            Json.Generator entity = new Json.Generator();
+            Json.Node root_node = new Json.Node(Json.NodeType.OBJECT);
+            Json.Object obj = new Json.Object();
+
+            base(session, session.url,
+                item_tags_path,
+                Publishing.RESTSupport.HttpMethod.POST);
+            add_header("X-Gallery-Request-Key", session.key);
+            add_header("X-Gallery-Request-Method", "POST");
+
+            obj.set_string_member("tag", tag_url);
+            obj.set_string_member("item", item_url);
+            root_node.set_object(obj);
+            entity.set_root(root_node);
+
+            size_t entity_length;
+            string entity_value = entity.to_data(out entity_length);
+
+            debug("created entity: %s", entity_value);
+
+            add_argument("entity", entity_value);
+        }
+
+    }
+
+}
+
+private class GalleryAlbumCreateTransaction : BaseGalleryTransaction {
+
+    // Properties
+    public PublishingParameters parameters { get; private set; }
+    // Private variables
+    private string? session_url;
+
+    // GalleryAlbumCreateTransaction constructor
+    //
+    // parameters: New album parameters
+    public GalleryAlbumCreateTransaction(Session session,
+            PublishingParameters parameters) {
+
+        if (!session.is_authenticated()) {
+            error("Not authenticated");
+        }
+        else {
+            Json.Generator entity = new Json.Generator();
+            Json.Node root_node = new Json.Node(Json.NodeType.OBJECT);
+            Json.Object obj = new Json.Object();
+
+            base(session, session.url, "/item/1",
+                Publishing.RESTSupport.HttpMethod.POST);
+            add_header("X-Gallery-Request-Key", session.key);
+            add_header("X-Gallery-Request-Method", "POST");
+
+            this.session_url = session.url;
+            this.parameters = parameters;
+
+            obj.set_string_member("name", parameters.album_name);
+            obj.set_string_member("type", "album");
+            obj.set_string_member("title", parameters.album_title);
+            root_node.set_object(obj);
+            entity.set_root(root_node);
+
+            string entity_value = entity.to_data(null);
+
+            debug("created entity: %s", entity_value);
+
+            add_argument("entity", entity_value);
+        }
+
+    }
+
+    public string get_new_album_path() {
+
+        unowned Json.Node root_node;
+        string new_path;
+
+        try {
+            root_node = get_root_node();
+        }
+        catch (Spit.Publishing.PublishingError e) {
+            error("Could not get root node");
+        }
+
+        new_path =
+            root_node.get_object().get_string_member("url");
+        new_path = strip_session_url(new_path);
+
+        return new_path;
+
+    }
+
+}
+
+private class GalleryUploadTransaction :
+        Publishing.RESTSupport.UploadTransaction {
+
+    private Session session;
+    private Json.Generator generator;
+    private PublishingParameters parameters;
+    private string item_url;
+    private string item_path;
+    private string item_tags_path;
+
+    public GalleryUploadTransaction(Session session,
+            PublishingParameters parameters,
+            Spit.Publishing.Publishable publishable) {
+
+        // TODO: eventually we can remove this
+        if (parameters.album_path[0] != '/') {
+            warning("Bad upload item path, this is a bug!");
+            error(parameters.album_path);
+        }
+
+        base.with_endpoint_url(session, publishable,
+            session.url + REST_PATH + parameters.album_path);
+
+        this.parameters = parameters;
+        this.session = session;
+
+        add_header("X-Gallery-Request-Key", session.key);
+        add_header("X-Gallery-Request-Method", "POST");
+
+        GLib.HashTable<string, string> disposition_table =
+            new GLib.HashTable<string, string>(GLib.str_hash,
+                                               GLib.str_equal);
+        string? title = publishable.get_publishing_name();
+        string filename = publishable.get_param_string(
+            Spit.Publishing.Publishable.PARAM_STRING_BASENAME);
+        if (title == null || title == "")
+            //TODO: remove extension?
+            title = filename;
+
+        disposition_table.insert("filename", @"$(filename)");
+        disposition_table.insert("name", "file");
+
+        set_binary_disposition_table(disposition_table);
+
+        // Do the JSON stuff
+        generator = new Json.Generator();
+        string desc = publishable.get_param_string(
+            Spit.Publishing.Publishable.PARAM_STRING_COMMENT);
+        string type = (publishable.get_media_type() ==
+            Spit.Publishing.Publisher.MediaType.VIDEO) ?
+                "movie" : "photo";
+
+        Json.Node root_node = new Json.Node(Json.NodeType.OBJECT);
+        Json.Object obj = new Json.Object();
+        obj.set_string_member("name", filename);
+        obj.set_string_member("type", type);
+        obj.set_string_member("title", title);
+        obj.set_string_member("description", desc);
+
+        root_node.set_object(obj);
+        generator.set_root(root_node);
+
+        add_argument("entity", generator.to_data(null));
+    }
+
+    private string get_new_item_url() {
+
+        string json_object;
+        string new_url;
+        unowned Json.Node root_node;
+        Json.Parser parser = new Json.Parser();
+
+        json_object = get_response();
+
+        if ((null == json_object) || (0 == json_object.length)) {
+            warning("No response data from %s", get_endpoint_url());
+            return "";
+        }
+
+        debug("json_object: %s", json_object);
+
+        try {
+            parser.load_from_data(json_object);
+        }
+        catch (GLib.Error e) {
+            // If this didn't work, reset the "executed" state
+            // TODO: can we recover from this?
+            warning("ERROR: didn't load JSON data");
+            set_is_executed(false);
+            error(e.message);
+        }
+
+        root_node = parser.get_root();
+        if (root_node.is_null()) {
+            warning("Root node is null, doesn't appear to be JSON data");
+            return "";
+        }
+
+        new_url =
+            root_node.get_object().get_string_member("url");
+
+        return new_url;
+
+    }
+
+    private void do_set_tag_relationship(string tag_url)
+            throws Spit.Publishing.PublishingError {
+        GallerySetTagRelationshipTransaction tag_txn =
+            new GallerySetTagRelationshipTransaction(
+                (Session) get_parent_session(), item_tags_path,
+                tag_url, item_url);
+
+        tag_txn.execute();
+
+        debug("Response from setting tag relationship: %s",
+            tag_txn.get_response());
+    }
+
+    private string get_new_item_tags_path() {
+        GalleryGetItemTagsURLsTransaction tag_urls_txn =
+            new GalleryGetItemTagsURLsTransaction(
+                (Session) get_parent_session(), item_path);
+
+        try {
+            tag_urls_txn.execute();
+        } catch (Spit.Publishing.PublishingError err) {
+            debug("Problem getting the item_tags URL: %s",
+                err.message);
+            return "";
+        }
+
+        return tag_urls_txn.get_item_tags_path();
+    }
+
+    private string get_tag_url(string tag) {
+
+        GalleryGetTagTransaction tag_txn =
+            new GalleryGetTagTransaction(
+                (Session) get_parent_session(), tag);
+
+        try {
+            tag_txn.execute();
+        } catch (Spit.Publishing.PublishingError err) {
+            debug("Problem getting the tags URL: %s",
+                err.message);
+            return "";
+        }
+
+        return tag_txn.tag_url();
+
+    }
+
+    private void on_upload_completed()
+            throws Spit.Publishing.PublishingError {
+
+        debug("EVENT: upload completed");
+
+        if (!parameters.strip_metadata) {
+
+            string[] keywords;
+
+            debug("EVENT: evaluating tags");
+
+            keywords = base.publishable.get_publishing_keywords();
+
+            // If this publishable has no tags, continue
+            if (null == keywords) {
+                debug("No tags");
+                return;
+            }
+
+            // Get URLs from the file we just finished uploading
+            item_url = get_new_item_url();
+            item_path = strip_session_url(item_url);
+            item_tags_path = get_new_item_tags_path();
+            debug("new item path is %s", item_path);
+            debug("item_tags path is %s", item_tags_path);
+
+            // Verify these aren't empty
+            if (("" == item_path) || ("" == item_tags_path)) {
+                throw new
+                    Spit.Publishing.PublishingError.COMMUNICATION_FAILED(
+                        "Could not obtain URL of uploaded item or its " +
+                        "\"item_tags\" relationship URL");
+            }
+
+            // Do the tagging here
+            foreach (string tag in keywords) {
+                debug(@"Found tag: $(tag)");
+                string new_tag_url = get_tag_url(tag);
+
+                try {
+                    do_set_tag_relationship(new_tag_url);
+                } catch (Spit.Publishing.PublishingError err) {
+                    debug("Problem setting the relationship between tag " +
+                        "and item: %s", err.message);
+                    throw err;
+                }
+            }
+
+        }
+
+    }
+
+    public override void execute()
+            throws Spit.Publishing.PublishingError {
+        base.execute();
+
+        // Run tagging operations here
+        on_upload_completed();
+    }
+
+}
+
+
+public class GalleryPublisher : Spit.Publishing.Publisher, GLib.Object {
+    private const string BAD_FILE_MSG = _("\n\nThe file \"%s\" may not be supported by or may be too large 
for this instance of Gallery3.");
+    private const string BAD_MOVIE_MSG = _("\nNote that Gallery3 only supports the video types that 
Flowplayer does.");
+
+    private weak Spit.Publishing.PluginHost host = null;
+    private Spit.Publishing.ProgressCallback progress_reporter = null;
+    private weak Spit.Publishing.Service service = null;
+    private Session session = null;
+    private bool running = false;
+    private Album[] albums = null;
+    private string key = null;
+
+    private PublishingOptionsPane publishing_options_pane = null;
+
+    public GalleryPublisher(Spit.Publishing.Service service,
+            Spit.Publishing.PluginHost host) {
+        this.service = service;
+        this.host = host;
+        this.session = new Session();
+    }
+
+    public bool is_running() {
+        return running;
+    }
+
+    public Spit.Publishing.Service get_service() {
+        return service;
+    }
+
+    public void start() {
+        if (is_running())
+            return;
+
+        if (host == null)
+            error("GalleryPublisher: start( ): can't start; this " +
+              "publisher is not restartable.");
+
+        debug("GalleryPublisher: starting interaction.");
+
+        running = true;
+
+        key = get_api_key();
+
+        if ((null == key) || ("" == key))
+            do_show_service_welcome_pane();
+        else {
+            string url = get_gallery_url();
+            string username = get_gallery_username();
+
+            if ((null == username) || (null == key) || (null == url))
+                do_show_service_welcome_pane();
+            else {
+                debug("ACTION: attempting network login for user " +
+                    "'%s' at URL '%s' from saved credentials.",
+                    username, url);
+
+                host.install_account_fetch_wait_pane();
+
+                session.authenticate(url, username, key);
+
+                // Initiate an album transaction
+                do_fetch_album_urls();
+            }
+        }
+    }
+
+    public void stop() {
+        debug("GalleryPublisher: stop( ) invoked.");
+
+        running = false;
+    }
+
+    // Config getters/setters
+    // API key
+    internal string? get_api_key() {
+        return host.get_config_string("api-key", null);
+    }
+
+    internal void set_api_key(string key) {
+        host.set_config_string("api-key", key);
+    }
+
+    // URL
+    internal string? get_gallery_url() {
+        return host.get_config_string("url", null);
+    }
+
+    internal void set_gallery_url(string url) {
+        host.set_config_string("url", url);
+    }
+
+    // Username
+    internal string? get_gallery_username() {
+        return host.get_config_string("username", null);
+    }
+
+    internal void set_gallery_username(string username) {
+        host.set_config_string("username", username);
+    }
+
+    internal bool? get_persistent_strip_metadata() {
+        return host.get_config_bool("strip-metadata", false);
+    }
+
+    internal void set_persistent_strip_metadata(bool strip_metadata) {
+        host.set_config_bool("strip-metadata", strip_metadata);
+    }
+
+    internal int? get_scaling_constraint_id() {
+        return host.get_config_int("scaling-constraint-id", 0);
+    }
+
+    internal void set_scaling_constraint_id(int constraint) {
+        host.set_config_int("scaling-constraint-id", constraint);
+    }
+
+    internal int? get_scaling_pixels() {
+        return host.get_config_int("scaling-pixels", 1024);
+    }
+
+    internal void set_scaling_pixels(int pixels) {
+        host.set_config_int("scaling-pixels", pixels);
+    }
+
+    // Pane installation functions
+    private void do_show_service_welcome_pane() {
+        debug("ACTION: showing service welcome pane.");
+
+        host.install_welcome_pane(SERVICE_WELCOME_MESSAGE,
+          on_service_welcome_login);
+    }
+
+    private void do_show_credentials_pane(CredentialsPane.Mode mode) {
+        debug("ACTION: showing credentials capture pane in %s mode.",
+          mode.to_string());
+
+        session.deauthenticate();
+
+        CredentialsPane creds_pane =
+            new CredentialsPane(host, mode, get_gallery_url(),
+                get_gallery_username(), get_api_key());
+        creds_pane.go_back.connect(on_credentials_go_back);
+        creds_pane.login.connect(on_credentials_login);
+
+        host.install_dialog_pane(creds_pane);
+    }
+
+    private void do_network_login(string url, string username,
+            string password) {
+        debug("ACTION: attempting network login for user '%s' at URL " +
+            "'%s'.", username, url);
+
+        host.install_login_wait_pane();
+
+        KeyFetchTransaction fetch_trans =
+            new KeyFetchTransaction(session, url, username, password);
+        fetch_trans.network_error.connect(on_key_fetch_error);
+        fetch_trans.completed.connect(on_key_fetch_complete);
+
+        try {
+            fetch_trans.execute();
+        } catch (Spit.Publishing.PublishingError err) {
+            debug("Caught an error attempting to login");
+            // 403 errors may be recoverable, so don't post the error to
+            // our host immediately; instead, try to recover from it
+            on_key_fetch_error(fetch_trans, err);
+        }
+    }
+
+    private void do_fetch_album_urls() {
+
+        host.install_account_fetch_wait_pane();
+
+        GetAlbumURLsTransaction album_trans =
+            new GetAlbumURLsTransaction(session);
+        album_trans.network_error.connect(on_album_urls_fetch_error);
+        album_trans.completed.connect(on_album_urls_fetch_complete);
+
+        try {
+            album_trans.execute();
+        } catch (Spit.Publishing.PublishingError err) {
+            debug("Caught an error attempting to fetch albums");
+            // 403 errors may be recoverable, so don't post the error to
+            // our host immediately; instead, try to recover from it
+            on_album_urls_fetch_error(album_trans, err);
+        }
+
+    }
+
+    private void do_fetch_albums(string [] album_urls, uint start = 0) {
+
+        GetAlbumsTransaction album_trans =
+            new GetAlbumsTransaction(session, album_urls, start);
+        album_trans.network_error.connect(on_album_fetch_error);
+        album_trans.completed.connect(on_album_fetch_complete);
+
+        try {
+            album_trans.execute();
+        } catch (Spit.Publishing.PublishingError err) {
+            // 403 errors may be recoverable, so don't post the error to
+            // our host immediately; instead, try to recover from it
+            on_album_fetch_error(album_trans, err);
+        }
+
+    }
+
+    private void do_show_publishing_options_pane(string url,
+            string username) {
+
+        debug("ACTION: showing publishing options pane");
+
+        Gtk.Builder builder = new Gtk.Builder();
+
+        try {
+            builder.add_from_file(
+                host.get_module_file().get_parent().get_child(
+                    "gallery3_publishing_options_pane.glade").get_path());
+        }
+        catch (Error e) {
+            warning("Could not parse UI file! Error: %s.", e.message);
+            host.post_error(
+                new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(
+                    _("A file required for publishing is " +
+                        "unavailable. Publishing to " + SERVICE_NAME +
+                        " can't continue.")));
+            return;
+        }
+
+        publishing_options_pane =
+            new PublishingOptionsPane(host, url, username, albums,
+                builder, get_persistent_strip_metadata(),
+                get_scaling_constraint_id(), get_scaling_pixels());
+        publishing_options_pane.publish.connect(
+            on_publishing_options_pane_publish);
+        publishing_options_pane.logout.connect(
+            on_publishing_options_pane_logout);
+        host.install_dialog_pane(publishing_options_pane);
+
+    }
+
+    private void do_create_album(PublishingParameters parameters) {
+
+        debug("ACTION: creating album");
+
+        GalleryAlbumCreateTransaction album_trans =
+            new GalleryAlbumCreateTransaction(session, parameters);
+        album_trans.network_error.connect(on_album_create_error);
+        album_trans.completed.connect(on_album_create_complete);
+
+        try {
+            album_trans.execute();
+        } catch (Spit.Publishing.PublishingError err) {
+            // 403 errors may be recoverable, so don't post the error to
+            // our host immediately; instead, try to recover from it
+            on_album_create_error(album_trans, err);
+        }
+
+    }
+
+    private void do_publish(PublishingParameters parameters) {
+
+        debug("ACTION: publishing items");
+
+        set_persistent_strip_metadata(parameters.strip_metadata);
+        set_scaling_constraint_id(
+            (parameters.photo_major_axis_size <= 0) ? 0 : 1);
+        set_scaling_pixels(parameters.photo_major_axis_size);
+        host.set_service_locked(true);
+        progress_reporter =
+            host.serialize_publishables(parameters.photo_major_axis_size,
+                parameters.strip_metadata);
+
+        // Serialization is a long and potentially cancellable
+        // operation, so before we use the publishables, make sure that
+        // the publishing interaction is still running. If it isn't, the
+        // publishing environment may be partially torn down so do a
+        // short-circuit return.
+        if (!is_running())
+            return;
+
+        Uploader uploader =
+            new Uploader(session, host.get_publishables(),
+                parameters);
+        uploader.upload_complete.connect(on_publish_complete);
+        uploader.upload_error.connect(on_publish_error);
+        uploader.upload(on_upload_status_updated);
+
+    }
+
+    private void do_show_success_pane() {
+        debug("ACTION: showing success pane.");
+
+        host.set_service_locked(false);
+        host.install_success_pane();
+    }
+
+    // Callbacks
+    private void on_service_welcome_login() {
+        if (!is_running())
+            return;
+
+        debug("EVENT: user clicked 'Login' in welcome pane.");
+
+        do_show_credentials_pane(CredentialsPane.Mode.INTRO);
+    }
+
+    private void on_credentials_login(string url, string username,
+            string password) {
+        if (!is_running())
+            return;
+
+        debug("EVENT: user '%s' clicked 'Login' in credentials pane.",
+          username);
+
+        set_gallery_url(url);
+        set_gallery_username(username);
+        do_network_login(url, username, password);
+    }
+
+    private void on_credentials_go_back() {
+        if (!is_running())
+            return;
+
+        debug("EVENT: user is attempting to go back.");
+
+        do_show_service_welcome_pane();
+    }
+
+    private void on_key_fetch_error(
+            Publishing.RESTSupport.Transaction bad_txn,
+            Spit.Publishing.PublishingError err) {
+        bad_txn.completed.disconnect(on_key_fetch_complete);
+        bad_txn.network_error.disconnect(on_key_fetch_error);
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is already auth'd
+        if (session.is_authenticated())
+            return;
+
+        debug("EVENT: network transaction to fetch key for login " +
+            "failed; response = '%s'.",
+            bad_txn.get_response());
+
+        // HTTP error 403 is invalid authentication -- if we get this
+        // error during key fetch then we can just show the login screen
+        // again with a retry message; if we get any error other than
+        // 403 though, we can't recover from it, so just post the error
+        // to the user
+        if (bad_txn.get_status_code() == 403) {
+            // TODO: can we give more detail on the problem?
+            do_show_credentials_pane(CredentialsPane.Mode.FAILED_RETRY);
+        }
+        else if (bad_txn.get_status_code() == 400) {
+            // This might not be a Gallery URL
+            // TODO: can we give more detail on the problem?
+            do_show_credentials_pane(CredentialsPane.Mode.NOT_GALLERY_URL);
+        }
+        else {
+            host.post_error(err);
+        }
+    }
+
+    private void on_key_fetch_complete(
+            Publishing.RESTSupport.Transaction txn) {
+        txn.completed.disconnect(on_key_fetch_complete);
+        txn.network_error.disconnect(on_key_fetch_error);
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is already auth'd
+        if (session.is_authenticated())
+            return;
+
+        key = (txn as KeyFetchTransaction).get_key();
+
+        if (key == null) error("key doesn\'t exist");
+        else {
+            string url = get_gallery_url();
+            string username = get_gallery_username();
+
+            debug("EVENT: network transaction to fetch key completed " +
+                  "successfully.");
+
+            set_api_key(key);
+            session.authenticate(url, username, key);
+
+            // Initiate an album transaction
+            do_fetch_album_urls();
+        }
+    }
+
+    private void on_album_urls_fetch_error(
+            Publishing.RESTSupport.Transaction bad_txn,
+            Spit.Publishing.PublishingError err) {
+        bad_txn.completed.disconnect(on_album_urls_fetch_complete);
+        bad_txn.network_error.disconnect(on_album_urls_fetch_error);
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is not auth'd
+        if (!session.is_authenticated())
+            return;
+
+        debug("EVENT: network transaction to fetch album URLs " +
+            "failed; response = \'%s\'.",
+            bad_txn.get_response());
+
+        // HTTP error 403 is invalid authentication -- if we get this
+        // error during key fetch then we can just show the login screen
+        // again with a retry message; if we get any error other than
+        // 403 though, we can't recover from it, so just post the error
+        // to the user
+        if (bad_txn.get_status_code() == 403) {
+            // TODO: can we give more detail on the problem?
+            do_show_credentials_pane(CredentialsPane.Mode.FAILED_RETRY);
+        }
+        else if (bad_txn.get_status_code() == 400) {
+            // This might not be a Gallery URL
+            // TODO: can we give more detail on the problem?
+            do_show_credentials_pane(CredentialsPane.Mode.NOT_GALLERY_URL);
+        }
+        else {
+            host.post_error(err);
+        }
+    }
+
+    private void on_album_urls_fetch_complete(
+            Publishing.RESTSupport.Transaction txn) {
+        txn.completed.disconnect(on_album_urls_fetch_complete);
+        txn.network_error.disconnect(on_album_urls_fetch_error);
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is not auth'd
+        if (!session.is_authenticated())
+            return;
+
+        debug("EVENT: retrieving all album URLs.");
+
+        string [] album_urls =
+            (txn as GetAlbumURLsTransaction).get_album_urls();
+
+        if (null == album_urls) {
+
+            string url = session.url;
+            string username = session.username;
+
+            do_show_publishing_options_pane(url, username);
+
+        }
+        else
+            do_fetch_albums(album_urls);
+    }
+
+    private void on_album_fetch_error(
+            Publishing.RESTSupport.Transaction bad_txn,
+            Spit.Publishing.PublishingError err) {
+        bad_txn.completed.disconnect(on_album_fetch_complete);
+        bad_txn.network_error.disconnect(on_album_fetch_error);
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is not auth'd
+        if (!session.is_authenticated())
+            return;
+
+        debug("EVENT: network transaction to fetch albums " +
+            "failed; response = \'%s\'.",
+            bad_txn.get_response());
+
+        // HTTP error 403 is invalid authentication -- if we get this
+        // error during key fetch then we can just show the login screen
+        // again with a retry message; if we get any error other than
+        // 403 though, we can't recover from it, so just post the error
+        // to the user
+        if (bad_txn.get_status_code() == 403) {
+            // TODO: can we give more detail on the problem?
+            do_show_credentials_pane(CredentialsPane.Mode.FAILED_RETRY);
+        }
+        else if (bad_txn.get_status_code() == 400) {
+            // This might not be a Gallery URL
+            // TODO: can we give more detail on the problem?
+            do_show_credentials_pane(CredentialsPane.Mode.NOT_GALLERY_URL);
+        }
+        else {
+            host.post_error(err);
+        }
+    }
+
+    private void on_album_fetch_complete(
+            Publishing.RESTSupport.Transaction txn) {
+        txn.completed.disconnect(on_album_fetch_complete);
+        txn.network_error.disconnect(on_album_fetch_error);
+
+        Album[] new_albums = null;
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is not auth'd
+        if (!session.is_authenticated())
+            return;
+
+        debug("EVENT: user is attempting to populate the album list.");
+
+        try {
+            new_albums =
+                (txn as GetAlbumsTransaction).get_albums();
+        } catch (Spit.Publishing.PublishingError err) {
+            on_album_fetch_error(txn, err);
+        }
+
+        // Append new albums to existing
+        for (int i = 0; i <= new_albums.length - 1; i++)
+            albums += new_albums[i];
+
+        if ((txn as GetAlbumsTransaction).more_urls) {
+
+            do_fetch_albums((txn as GetAlbumsTransaction).album_urls,
+                (txn as GetAlbumsTransaction).urls_sent);
+
+        }
+        else {
+
+            string url = session.url;
+            string username = session.username;
+
+            do_show_publishing_options_pane(url, username);
+
+        }
+    }
+
+    private void on_album_create_error(
+            Publishing.RESTSupport.Transaction bad_txn,
+            Spit.Publishing.PublishingError err) {
+        bad_txn.completed.disconnect(on_album_create_complete);
+        bad_txn.network_error.disconnect(on_album_create_error);
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is not auth'd
+        if (!session.is_authenticated())
+            return;
+
+        debug("EVENT: network transaction to create an album " +
+            "failed; response = \'%s\'.",
+            bad_txn.get_response());
+
+        // HTTP error 403 is invalid authentication -- if we get this
+        // error during key fetch then we can just show the login screen
+        // again with a retry message; if we get any error other than
+        // 403 though, we can't recover from it, so just post the error
+        // to the user
+        if (bad_txn.get_status_code() == 403) {
+            // TODO: can we give more detail on the problem?
+            do_show_credentials_pane(CredentialsPane.Mode.FAILED_RETRY);
+        }
+        else if (bad_txn.get_status_code() == 400) {
+            // This might not be a Gallery URL
+            // TODO: can we give more detail on the problem?
+            do_show_credentials_pane(CredentialsPane.Mode.NOT_GALLERY_URL);
+        }
+        else {
+            host.post_error(err);
+        }
+    }
+
+    private void on_album_create_complete(
+            Publishing.RESTSupport.Transaction txn) {
+        txn.completed.disconnect(on_album_create_complete);
+        txn.network_error.disconnect(on_album_create_error);
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is not auth'd
+        if (!session.is_authenticated())
+            return;
+
+        PublishingParameters new_params =
+            (txn as GalleryAlbumCreateTransaction).parameters;
+        new_params.album_path =
+            (txn as GalleryAlbumCreateTransaction).get_new_album_path();
+
+        debug("EVENT: user has created an album at \"%s\".",
+            new_params.album_path);
+
+        do_publish(new_params);
+    }
+
+    private void on_publish_error(
+            Publishing.RESTSupport.BatchUploader _uploader,
+            Spit.Publishing.PublishingError err) {
+        if (!is_running())
+            return;
+
+        Uploader uploader = _uploader as Uploader;
+        GLib.Error g3_err = err.copy();
+
+        debug("EVENT: uploader reports upload error = '%s' " +
+            "for file '%s' (code %d)", err.message,
+                uploader.current_publishable_name, uploader.status_code);
+
+        uploader.upload_complete.disconnect(on_publish_complete);
+        uploader.upload_error.disconnect(on_publish_error);
+
+        // Is this a 400 error? Then it may be a bad file.
+        if (uploader.status_code == 400) {
+            g3_err.message +=
+                BAD_FILE_MSG.printf(uploader.current_publishable_name);
+            // Add an additional message if this appears to be a video
+            // file.
+            if (uploader.current_publishable_type ==
+                    Spit.Publishing.Publisher.MediaType.VIDEO)
+                g3_err.message += BAD_MOVIE_MSG;
+        }
+        host.post_error(g3_err);
+    }
+
+    private void on_upload_status_updated(int file_number,
+        double completed_fraction) {
+
+        if (!is_running())
+            return;
+
+        debug("EVENT: uploader reports upload %.2f percent complete.",
+            100.0 * completed_fraction);
+
+        assert(progress_reporter != null);
+
+        progress_reporter(file_number, completed_fraction);
+
+    }
+
+    private void on_publish_complete(
+            Publishing.RESTSupport.BatchUploader uploader,
+            int num_published) {
+        uploader.upload_complete.disconnect(on_publish_complete);
+        uploader.upload_error.disconnect(on_publish_error);
+
+        if (!is_running())
+            return;
+
+        // ignore these events if the session is not auth'd
+        if (!session.is_authenticated())
+            return;
+
+        debug("EVENT: publishing complete; %d items published",
+            num_published);
+
+        do_show_success_pane();
+
+    }
+
+    private void on_publishing_options_pane_logout() {
+        publishing_options_pane.publish.disconnect(
+            on_publishing_options_pane_publish);
+        publishing_options_pane.logout.disconnect(
+            on_publishing_options_pane_logout);
+
+        if (!is_running())
+            return;
+
+        debug("EVENT: user is attempting to log out.");
+
+        session.deauthenticate();
+        do_show_service_welcome_pane();
+    }
+
+    private void on_publishing_options_pane_publish(PublishingParameters parameters) {
+        publishing_options_pane.publish.disconnect(
+            on_publishing_options_pane_publish);
+        publishing_options_pane.logout.disconnect(
+            on_publishing_options_pane_logout);
+
+        if (!is_running())
+            return;
+
+        debug("EVENT: user is attempting to publish something.");
+
+        if (parameters.is_to_new_album()) {
+            debug("EVENT: must create new album \"%s\" first.",
+                parameters.album_name);
+            do_create_album(parameters);
+        }
+        else {
+            do_publish(parameters);
+        }
+    }
+
+}
+
+internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object {
+    private const string DEFAULT_ALBUM_NAME = "";
+    private const string LAST_ALBUM_CONFIG_KEY = "last-album";
+
+    private Gtk.Builder builder = null;
+
+    private Gtk.Grid pane_widget = null;
+    private Gtk.Label title_label = null;
+    private Gtk.RadioButton use_existing_radio = null;
+    private Gtk.ComboBoxText existing_albums_combo = null;
+    private Gtk.RadioButton create_new_radio = null;
+    private Gtk.Entry new_album_entry = null;
+    private Gtk.ComboBoxText scaling_combo = null;
+    private Gtk.Entry pixels = null;
+    private Gtk.CheckButton strip_metadata_check = null;
+    private Gtk.Button publish_button = null;
+    private Gtk.Button logout_button = null;
+
+    private Album[] albums;
+    private weak Spit.Publishing.PluginHost host;
+
+    public signal void publish(PublishingParameters parameters);
+    public signal void logout();
+
+    public PublishingOptionsPane(Spit.Publishing.PluginHost host,
+            string url, string username, Album[] albums,
+            Gtk.Builder builder, bool strip_metadata,
+            int scaling_id, int scaling_pixels) {
+        this.albums = albums;
+        this.host = host;
+
+        this.builder = builder;
+        assert(null != builder);
+        assert(builder.get_objects().length() > 0);
+
+        // pull in all widgets from builder
+        pane_widget = builder.get_object("pane_widget") as Gtk.Grid;
+        title_label = builder.get_object("title_label") as Gtk.Label;
+        use_existing_radio = builder.get_object("publish_to_existing_radio") as Gtk.RadioButton;
+        existing_albums_combo = builder.get_object("existing_albums_combo") as Gtk.ComboBoxText;
+        scaling_combo = builder.get_object("scaling_constraint_combo") as Gtk.ComboBoxText;
+        pixels = builder.get_object("major_axis_pixels") as Gtk.Entry;
+        create_new_radio = builder.get_object("publish_new_radio") as Gtk.RadioButton;
+        new_album_entry = builder.get_object("new_album_name") as Gtk.Entry;
+        strip_metadata_check = this.builder.get_object("strip_metadata_check") as Gtk.CheckButton;
+        publish_button = builder.get_object("publish_button") as Gtk.Button;
+        logout_button = builder.get_object("logout_button") as Gtk.Button;
+
+        // populate any widgets whose contents are
+        // programmatically-generated
+        title_label.set_label(
+            _("Publishing to %s as %s.").printf(url, username));
+        strip_metadata_check.set_active(strip_metadata);
+        scaling_combo.set_active(scaling_id);
+        pixels.set_text(@"$(scaling_pixels)");
+
+        // connect all signals
+        use_existing_radio.clicked.connect(on_use_existing_radio_clicked);
+        create_new_radio.clicked.connect(on_create_new_radio_clicked);
+        new_album_entry.changed.connect(on_new_album_entry_changed);
+        scaling_combo.changed.connect(on_scaling_constraint_changed);
+        pixels.changed.connect(on_pixels_changed);
+        logout_button.clicked.connect(on_logout_clicked);
+        publish_button.clicked.connect(on_publish_clicked);
+    }
+
+    private void on_publish_clicked() {
+        string album_name;
+        int photo_major_axis_size =
+            (scaling_combo.get_active() == 1) ?
+                int.parse(pixels.get_text()) : -1;
+        PublishingParameters param;
+
+        if (create_new_radio.get_active()) {
+            album_name = new_album_entry.get_text();
+            host.set_config_string(LAST_ALBUM_CONFIG_KEY, album_name);
+            param =
+                new PublishingParameters.to_new_album(album_name);
+            debug("Trying to publish to \"%s\"", album_name);
+        } else {
+            album_name =
+                albums[existing_albums_combo.get_active()].title;
+            host.set_config_string(LAST_ALBUM_CONFIG_KEY, album_name);
+            string album_path =
+                albums[existing_albums_combo.get_active()].path;
+            param =
+                new PublishingParameters.to_existing_album(album_path);
+        }
+
+        param.photo_major_axis_size = photo_major_axis_size;
+        param.strip_metadata = strip_metadata_check.get_active();
+
+        publish(param);
+    }
+
+    private void on_use_existing_radio_clicked() {
+        existing_albums_combo.set_sensitive(true);
+        new_album_entry.set_sensitive(false);
+        existing_albums_combo.grab_focus();
+        update_publish_button_sensitivity();
+    }
+
+    private void on_create_new_radio_clicked() {
+        new_album_entry.set_sensitive(true);
+        existing_albums_combo.set_sensitive(false);
+        new_album_entry.grab_focus();
+        update_publish_button_sensitivity();
+    }
+
+    private void on_logout_clicked() {
+        logout();
+    }
+
+    private void update_publish_button_sensitivity() {
+        string album_name = new_album_entry.get_text();
+        publish_button.set_sensitive(!(album_name.strip() == "" &&
+            create_new_radio.get_active()));
+    }
+
+    private void on_new_album_entry_changed() {
+        update_publish_button_sensitivity();
+    }
+
+    private void update_pixel_entry_sensitivity() {
+        pixels.set_sensitive(scaling_combo.get_active() == 1);
+    }
+
+    private void on_scaling_constraint_changed() {
+        update_pixel_entry_sensitivity();
+    }
+
+    private void on_pixels_changed() {
+        string orig_text = pixels.get_text();
+        char last_char = orig_text[orig_text.length - 1];
+
+        if (orig_text.length > 0) {
+            if (!last_char.isdigit())
+                pixels.set_text(orig_text.substring(0,
+                    orig_text.length - 1));
+        }
+    }
+
+    public void installed() {
+        int default_album_id = -1;
+        string last_album =
+            host.get_config_string(LAST_ALBUM_CONFIG_KEY, "");
+        for (int i = 0; i <= albums.length - 1; i++) {
+            existing_albums_combo.append_text(albums[i].title);
+            if ((albums[i].title == last_album) ||
+                ((DEFAULT_ALBUM_NAME == albums[i].title) &&
+                    (-1 == default_album_id)))
+                default_album_id = i;
+        }
+
+        if (albums.length == 0) {
+            existing_albums_combo.set_sensitive(false);
+            use_existing_radio.set_sensitive(false);
+            create_new_radio.set_active(true);
+            new_album_entry.grab_focus();
+            new_album_entry.set_text(DEFAULT_ALBUM_NAME);
+        } else {
+            if (default_album_id >= 0) {
+                use_existing_radio.set_active(true);
+                existing_albums_combo.set_active(default_album_id);
+                new_album_entry.set_sensitive(false);
+            } else {
+                create_new_radio.set_active(true);
+                existing_albums_combo.set_active(0);
+                new_album_entry.set_text(DEFAULT_ALBUM_NAME);
+                new_album_entry.grab_focus();
+            }
+        }
+        update_publish_button_sensitivity();
+        update_pixel_entry_sensitivity();
+    }
+
+    public Gtk.Widget get_widget() {
+        return pane_widget;
+    }
+
+    public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() {
+        return Spit.Publishing.DialogPane.GeometryOptions.NONE;
+    }
+
+    public void on_pane_installed() {
+        installed();
+    }
+
+    public void on_pane_uninstalled() {
+    }
+}
+
+internal class PublishingParameters {
+
+    // Private variables for properties
+    private string _album_title = "";
+
+    // Properties
+    public string album_title {
+        get {
+            assert(is_to_new_album());
+            return _album_title;
+        }
+        private set { _album_title = value; }
+    }
+    public string album_name { get; private set; default = ""; }
+    public string album_path { get; set; default = ""; }
+    public string entity_title { get; private set; default = ""; }
+    public int photo_major_axis_size { get; set; default = -1; }
+    public bool strip_metadata { get; set; default = false; }
+
+    private PublishingParameters() {
+    }
+
+    public PublishingParameters.to_new_album(string album_title) {
+        this.album_name = album_title.delimit(" ", '-');
+        //this.album_name = this.album_name.delimit("\"\'", '');
+        this.album_title = album_title;
+    }
+
+    public PublishingParameters.to_existing_album(string album_path) {
+        this.album_path = album_path;
+    }
+
+    public bool is_to_new_album() {
+        return (album_name != "");
+    }
+}
+
+internal class CredentialsPane : Spit.Publishing.DialogPane, GLib.Object {
+    public enum Mode {
+        INTRO,
+        FAILED_RETRY,
+        NOT_GALLERY_URL;
+
+        public string to_string() {
+            switch (this) {
+                case Mode.INTRO:
+                    return "INTRO";
+
+                case Mode.FAILED_RETRY:
+                    return "FAILED_RETRY";
+
+                case Mode.NOT_GALLERY_URL:
+                    return "NOT_GALLERY_URL";
+
+                default:
+                    error("unrecognized CredentialsPane.Mode enumeration value");
+            }
+        }
+    }
+
+    private CredentialsGrid frame = null;
+    private Gtk.Widget grid_widget = null;
+
+    public signal void go_back();
+    public signal void login(string url, string uname, string password,
+        string key);
+
+    public CredentialsPane(Spit.Publishing.PluginHost host,
+            Mode mode = Mode.INTRO,
+            string? url = null, string? username = null,
+            string? key = null) {
+
+        Gtk.Builder builder = new Gtk.Builder();
+
+        try {
+            builder.add_from_file(
+                host.get_module_file().get_parent().get_child(
+                    "gallery3_authentication_pane.glade").get_path());
+        }
+        catch (Error e) {
+            warning("Could not parse UI file! Error: %s.", e.message);
+            host.post_error(
+                new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(
+                    _("A file required for publishing is " +
+                        "unavailable. Publishing to " + SERVICE_NAME +
+                        " can't continue.")));
+            return;
+        }
+
+        frame = new CredentialsGrid(host, mode, url, username, key, builder);
+        grid_widget = frame.pane_widget as Gtk.Widget;
+    }
+
+    protected void notify_go_back() {
+        go_back();
+    }
+
+    protected void notify_login(string url, string uname,
+            string password, string key) {
+        login(url, uname, password, key);
+    }
+
+    public Gtk.Widget get_widget() {
+        assert(null != grid_widget);
+        return grid_widget;
+    }
+
+    public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() {
+        return Spit.Publishing.DialogPane.GeometryOptions.NONE;
+    }
+
+    public void on_pane_installed() {
+        frame.go_back.connect(notify_go_back);
+        frame.login.connect(notify_login);
+
+        frame.installed();
+    }
+
+    public void on_pane_uninstalled() {
+        frame.go_back.disconnect(notify_go_back);
+        frame.login.disconnect(notify_login);
+    }
+}
+
+internal class CredentialsGrid : GLib.Object {
+    private const string INTRO_MESSAGE = _("Enter the URL for your Gallery3 site and the username and 
password (or API key) for your Gallery3 account.");
+    private const string FAILED_RETRY_MESSAGE = _("The username and password or API key were incorrect. To 
try again, re-enter your username and password below.");
+    private const string NOT_GALLERY_URL_MESSAGE = _("The URL entered does not appear to be the main 
directory of a Gallery3 instance. Please make sure you typed it correctly and it does not have any trailing 
components (e.g., index.php).");
+
+    public Gtk.Grid pane_widget { get; private set; default = null; }
+
+    private weak Spit.Publishing.PluginHost host = null;
+    private Gtk.Builder builder = null;
+    private Gtk.Label intro_message_label = null;
+    private Gtk.Entry url_entry = null;
+    private Gtk.Entry username_entry = null;
+    private Gtk.Entry password_entry = null;
+    private Gtk.Entry key_entry = null;
+    private Gtk.Button login_button = null;
+    private Gtk.Button go_back_button = null;
+    private string? url = null;
+    private string? username = null;
+    private string? key = null;
+
+    public signal void go_back();
+    public signal void login(string url, string username,
+        string password, string key);
+
+    public CredentialsGrid(Spit.Publishing.PluginHost host,
+            CredentialsPane.Mode mode = CredentialsPane.Mode.INTRO,
+            string? url = null, string? username = null,
+            string? key = null,
+            Gtk.Builder builder) {
+        this.host = host;
+        this.url = url;
+        this.key = key;
+        this.username = username;
+
+        this.builder = builder;
+        assert(builder != null);
+        assert(builder.get_objects().length() > 0);
+
+        // pull in all widgets from builder
+        pane_widget = builder.get_object("gallery3_auth_pane_widget") as Gtk.Grid;
+        intro_message_label = builder.get_object("intro_message_label") as Gtk.Label;
+        url_entry = builder.get_object("url_entry") as Gtk.Entry;
+        username_entry = builder.get_object("username_entry") as Gtk.Entry;
+        key_entry = builder.get_object("key_entry") as Gtk.Entry;
+        password_entry = builder.get_object("password_entry") as Gtk.Entry;
+        go_back_button = builder.get_object("go_back_button") as Gtk.Button;
+        login_button = builder.get_object("login_button") as Gtk.Button;
+
+        // Intro message
+        switch (mode) {
+            case CredentialsPane.Mode.INTRO:
+                intro_message_label.set_markup(INTRO_MESSAGE);
+            break;
+
+            case CredentialsPane.Mode.FAILED_RETRY:
+                intro_message_label.set_markup("<b>%s</b>\n\n%s".printf(_(
+                    "Unrecognized User"), FAILED_RETRY_MESSAGE));
+            break;
+
+            case CredentialsPane.Mode.NOT_GALLERY_URL:
+                intro_message_label.set_markup("<b>%s</b>\n\n%s".printf(
+                    _(SERVICE_NAME + " Site Not Found"),
+                    NOT_GALLERY_URL_MESSAGE));
+            break;
+
+            default:
+                error("Invalid CredentialsPane mode");
+        }
+
+        // Gallery URL
+        if (url != null) {
+            url_entry.set_text(url);
+            username_entry.grab_focus();
+        }
+        url_entry.changed.connect(on_url_or_username_changed);
+        // User name
+        if (username != null) {
+            username_entry.set_text(username);
+            password_entry.grab_focus();
+        }
+        username_entry.changed.connect(on_url_or_username_changed);
+
+        // Key
+        if (key != null) {
+            key_entry.set_text(key);
+            key_entry.grab_focus();
+        }
+        key_entry.changed.connect(on_url_or_username_changed);
+
+        // Buttons
+        go_back_button.clicked.connect(on_go_back_button_clicked);
+        login_button.clicked.connect(on_login_button_clicked);
+        login_button.set_sensitive((url != null) && (username != null));
+    }
+
+    private void on_login_button_clicked() {
+        login(url_entry.get_text(), username_entry.get_text(),
+            password_entry.get_text(), key_entry.get_text());
+    }
+
+    private void on_go_back_button_clicked() {
+        go_back();
+    }
+
+    private void on_url_or_username_changed() {
+        login_button.set_sensitive(
+            ((url_entry.get_text() != "") &&
+             (username_entry.get_text() != "")) ||
+            (key_entry.get_text() != ""));
+    }
+
+    public void installed() {
+        host.set_service_locked(false);
+
+        // TODO: following line necessary?
+        host.set_dialog_default_widget(login_button);
+    }
+}
+
+internal class Session : Publishing.RESTSupport.Session {
+
+    // Properties
+    public string? url { get; private set; default = null; }
+    public string? username { get; private set; default = null; }
+    public string? key { get; private set; default = null; }
+
+    public Session() {
+    }
+
+    public override bool is_authenticated() {
+        return (null != key);
+    }
+
+    public void authenticate(string gallery_url, string username, string key) {
+        this.url = gallery_url;
+        this.username = username;
+        this.key = key;
+
+        notify_authenticated();
+    }
+
+    public void deauthenticate() {
+        url = null;
+        username = null;
+        key = null;
+    }
+
+}
+
+internal class Uploader : Publishing.RESTSupport.BatchUploader {
+
+    private PublishingParameters parameters;
+    private string _current_publishable_name;
+    private Spit.Publishing.Publisher.MediaType _current_media_type;
+    private Publishing.RESTSupport.Transaction? _current_transaction;
+
+    /* Properties */
+    public string current_publishable_name {
+        get {
+            return _current_publishable_name;
+        }
+    }
+    public uint status_code {
+        get {
+            return _current_transaction.get_status_code();
+        }
+    }
+    public Spit.Publishing.Publisher.MediaType
+            current_publishable_type {
+        get {
+            return _current_media_type;
+        }
+    }
+
+    public Uploader(Session session,
+            Spit.Publishing.Publishable[] publishables,
+            PublishingParameters parameters) {
+
+        base(session, publishables);
+
+        this.parameters = parameters;
+
+    }
+
+    protected override Publishing.RESTSupport.Transaction
+            create_transaction(Spit.Publishing.Publishable publishable) {
+
+        Spit.Publishing.Publishable p = get_current_publishable();
+        _current_publishable_name =
+            p.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME);
+        _current_media_type = p.get_media_type();
+
+        _current_transaction =
+            new GalleryUploadTransaction((Session) get_session(),
+                parameters, p);
+        return _current_transaction;
+
+    }
+
+}
+
+private string strip_session_url(string url) {
+
+    // Remove the session URL from the beginning of this URL
+    debug("Searching for \"%s\" in \"%s\"",
+        REST_PATH, url);
+    int item_loc =
+        url.last_index_of(REST_PATH);
+
+    if (-1 == item_loc)
+        error("Did not find \"%s\" in the base of the new item " +
+            "URL \"%s\"", REST_PATH, url);
+
+    return url.substring(item_loc + REST_PATH.length);
+
+}
+
+}
+
+// vi:ts=4:sw=4:et
diff --git a/plugins/shotwell-publishing-extras/Makefile b/plugins/shotwell-publishing-extras/Makefile
index 94bfe43..485fb21 100644
--- a/plugins/shotwell-publishing-extras/Makefile
+++ b/plugins/shotwell-publishing-extras/Makefile
@@ -11,6 +11,7 @@ PLUGIN_PKGS := \
        json-glib-1.0
 
 SRC_FILES := \
+       GalleryConnector.vala \
        shotwell-publishing-extras.vala \
        YandexPublishing.vala \
        TumblrPublishing.vala \
@@ -19,6 +20,9 @@ SRC_FILES := \
        ../common/RESTSupport.vala
 
 RC_FILES := \
+       gallery3.png \
+       gallery3_authentication_pane.glade \
+       gallery3_publishing_options_pane.glade \
        yandex_publish_model.glade \
        tumblr.png \
        tumblr_authentication_pane.glade \
diff --git a/plugins/shotwell-publishing-extras/gallery3.png b/plugins/shotwell-publishing-extras/gallery3.png
new file mode 100644
index 0000000..9e3c5cc
Binary files /dev/null and b/plugins/shotwell-publishing-extras/gallery3.png differ
diff --git a/plugins/shotwell-publishing-extras/gallery3_authentication_pane.glade 
b/plugins/shotwell-publishing-extras/gallery3_authentication_pane.glade
new file mode 100644
index 0000000..43eb422
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/gallery3_authentication_pane.glade
@@ -0,0 +1,245 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <!-- interface-requires gtk+ 3.0 -->
+  <object class="GtkAction" id="go_back_action">
+    <property name="label" translatable="yes">Go _Back</property>
+  </object>
+  <object class="GtkAction" id="login_action">
+    <property name="label" translatable="yes">_Login</property>
+  </object>
+  <object class="GtkGrid" id="gallery3_auth_pane_widget">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <child>
+      <object class="GtkLabel" id="intro_message_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="ypad">15</property>
+        <property name="label" translatable="yes">Intro message replaced at runtime</property>
+        <property name="use_markup">True</property>
+        <property name="wrap">True</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+        <property name="width">5</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="url_entry_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_bottom">30</property>
+        <property name="label" translatable="yes">_Gallery3 URL:</property>
+        <property name="use_underline">True</property>
+        <property name="mnemonic_widget">url_entry</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkEntry" id="url_entry">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="has_focus">True</property>
+        <property name="margin_bottom">30</property>
+        <property name="invisible_char">●</property>
+      </object>
+      <packing>
+        <property name="left_attach">1</property>
+        <property name="top_attach">1</property>
+        <property name="width">4</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="username_entry_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label" translatable="yes">_User name:</property>
+        <property name="use_underline">True</property>
+        <property name="mnemonic_widget">username_entry</property>
+      </object>
+      <packing>
+        <property name="left_attach">2</property>
+        <property name="top_attach">2</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="password_entry_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label" translatable="yes">_Password:</property>
+        <property name="use_underline">True</property>
+        <property name="mnemonic_widget">password_entry</property>
+      </object>
+      <packing>
+        <property name="left_attach">2</property>
+        <property name="top_attach">3</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkEntry" id="username_entry">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="invisible_char">●</property>
+      </object>
+      <packing>
+        <property name="left_attach">3</property>
+        <property name="top_attach">2</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkEntry" id="password_entry">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="visibility">False</property>
+        <property name="invisible_char">●</property>
+        <property name="activates_default">True</property>
+      </object>
+      <packing>
+        <property name="left_attach">3</property>
+        <property name="top_attach">3</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkGrid" id="buttons_grid">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_top">30</property>
+        <child>
+          <object class="GtkButton" id="go_back_button">
+            <property name="label" translatable="yes">Go _Back</property>
+            <property name="related_action">go_back_action</property>
+            <property name="width_request">102</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+            <property name="halign">center</property>
+            <property name="valign">center</property>
+            <property name="hexpand">True</property>
+            <property name="use_underline">True</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="login_button">
+            <property name="label" translatable="yes">_Login</property>
+            <property name="related_action">login_action</property>
+            <property name="width_request">102</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="is_focus">True</property>
+            <property name="can_default">True</property>
+            <property name="has_default">True</property>
+            <property name="receives_default">True</property>
+            <property name="halign">center</property>
+            <property name="valign">center</property>
+            <property name="hexpand">True</property>
+            <property name="use_underline">True</property>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">6</property>
+        <property name="width">5</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="key_entry_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label" translatable="yes">API _Key:</property>
+        <property name="use_underline">True</property>
+        <property name="mnemonic_widget">key_entry</property>
+      </object>
+      <packing>
+        <property name="left_attach">2</property>
+        <property name="top_attach">5</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkEntry" id="key_entry">
+        <property name="visible">True</property>
+        <property name="can_focus">True</property>
+        <property name="invisible_char">●</property>
+        <property name="activates_default">True</property>
+        <property name="width_chars">33</property>
+      </object>
+      <packing>
+        <property name="left_attach">3</property>
+        <property name="top_attach">5</property>
+        <property name="width">1</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkLabel" id="or_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="label" translatable="yes">or</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">4</property>
+        <property name="width">5</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+    <child>
+      <placeholder/>
+    </child>
+  </object>
+</interface>
diff --git a/plugins/shotwell-publishing-extras/gallery3_publishing_options_pane.glade 
b/plugins/shotwell-publishing-extras/gallery3_publishing_options_pane.glade
new file mode 100644
index 0000000..17e3569
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/gallery3_publishing_options_pane.glade
@@ -0,0 +1,282 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <!-- interface-requires gtk+ 3.0 -->
+  <object class="GtkAction" id="logout_action">
+    <property name="label" translatable="yes">_Logout</property>
+  </object>
+  <object class="GtkAction" id="publish_action">
+    <property name="label" translatable="yes">_Publish</property>
+  </object>
+  <object class="GtkRadioAction" id="publish_new_radioaction">
+    <property name="label" translatable="yes">A _new album</property>
+    <property name="draw_as_radio">True</property>
+    <property name="value">1</property>
+    <property name="current_value">1</property>
+  </object>
+  <object class="GtkRadioAction" id="publish_to_existing_radioaction">
+    <property name="label" translatable="yes">An _existing album</property>
+    <property name="draw_as_radio">True</property>
+    <property name="group">publish_new_radioaction</property>
+  </object>
+  <object class="GtkToggleAction" id="strip_metadata_toggleaction">
+    <property name="label" translatable="yes">_Remove location, tag and camera-identifying data before 
uploading</property>
+  </object>
+  <object class="GtkGrid" id="pane_widget">
+    <property name="visible">True</property>
+    <property name="can_focus">False</property>
+    <child>
+      <object class="GtkLabel" id="title_label">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="xalign">0.30000001192092896</property>
+        <property name="ypad">16</property>
+        <property name="label" translatable="yes">'Publishing to $url as $username' (populated in 
application code)</property>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">0</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkGrid" id="options_grid">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_bottom">16</property>
+        <property name="hexpand">True</property>
+        <property name="row_spacing">8</property>
+        <property name="column_spacing">32</property>
+        <property name="column_homogeneous">True</property>
+        <child>
+          <object class="GtkRadioButton" id="publish_to_existing_radio">
+            <property name="related_action">publish_to_existing_radioaction</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">False</property>
+            <property name="xalign">0</property>
+            <property name="draw_indicator">True</property>
+            <property name="group">publish_new_radio</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkComboBoxText" id="existing_albums_combo">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="entry_text_column">0</property>
+            <property name="id_column">1</property>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkRadioButton" id="publish_new_radio">
+            <property name="related_action">publish_new_radioaction</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">False</property>
+            <property name="xalign">0</property>
+            <property name="draw_indicator">True</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">1</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkEntry" id="new_album_name">
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="invisible_char">●</property>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">1</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkCheckButton" id="strip_metadata_check">
+            <property name="related_action">strip_metadata_toggleaction</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">False</property>
+            <property name="valign">center</property>
+            <property name="margin_top">16</property>
+            <property name="hexpand">True</property>
+            <property name="xalign">0</property>
+            <property name="draw_indicator">True</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">5</property>
+            <property name="width">2</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkLabel" id="major_axis_label">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="halign">start</property>
+            <property name="label" translatable="yes">Scaling constraint:</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">3</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkGrid" id="pixels_grid">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="column_spacing">5</property>
+            <child>
+              <object class="GtkLabel" id="pixels_label">
+                <property name="visible">True</property>
+                <property name="can_focus">False</property>
+                <property name="label" translatable="yes">pixels</property>
+              </object>
+              <packing>
+                <property name="left_attach">1</property>
+                <property name="top_attach">0</property>
+                <property name="width">1</property>
+                <property name="height">1</property>
+              </packing>
+            </child>
+            <child>
+              <object class="GtkEntry" id="major_axis_pixels">
+                <property name="visible">True</property>
+                <property name="sensitive">False</property>
+                <property name="can_focus">True</property>
+                <property name="hexpand">True</property>
+                <property name="invisible_char">●</property>
+                <property name="truncate_multiline">True</property>
+                <property name="caps_lock_warning">False</property>
+                <property name="input_purpose">number</property>
+              </object>
+              <packing>
+                <property name="left_attach">0</property>
+                <property name="top_attach">0</property>
+                <property name="width">1</property>
+                <property name="height">1</property>
+              </packing>
+            </child>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">4</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkComboBoxText" id="scaling_constraint_combo">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="entry_text_column">0</property>
+            <property name="id_column">1</property>
+            <items>
+              <item translatable="yes">Original size</item>
+              <item translatable="yes">Width or height</item>
+            </items>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">3</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkSeparator" id="album_separator">
+            <property name="visible">True</property>
+            <property name="can_focus">False</property>
+            <property name="margin_left">5</property>
+            <property name="margin_right">5</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">2</property>
+            <property name="width">2</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <placeholder/>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">1</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+    <child>
+      <object class="GtkGrid" id="buttons_grid">
+        <property name="visible">True</property>
+        <property name="can_focus">False</property>
+        <property name="margin_left">112</property>
+        <property name="margin_right">112</property>
+        <property name="margin_top">48</property>
+        <property name="margin_bottom">24</property>
+        <property name="hexpand">True</property>
+        <property name="column_spacing">128</property>
+        <property name="column_homogeneous">True</property>
+        <child>
+          <object class="GtkButton" id="logout_button">
+            <property name="related_action">logout_action</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="receives_default">True</property>
+          </object>
+          <packing>
+            <property name="left_attach">0</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+        <child>
+          <object class="GtkButton" id="publish_button">
+            <property name="related_action">publish_action</property>
+            <property name="visible">True</property>
+            <property name="can_focus">True</property>
+            <property name="is_focus">True</property>
+            <property name="can_default">True</property>
+            <property name="has_default">True</property>
+            <property name="receives_default">True</property>
+          </object>
+          <packing>
+            <property name="left_attach">1</property>
+            <property name="top_attach">0</property>
+            <property name="width">1</property>
+            <property name="height">1</property>
+          </packing>
+        </child>
+      </object>
+      <packing>
+        <property name="left_attach">0</property>
+        <property name="top_attach">2</property>
+        <property name="width">2</property>
+        <property name="height">1</property>
+      </packing>
+    </child>
+  </object>
+</interface>
diff --git a/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala 
b/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala
index a303663..c5e32ee 100644
--- a/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala
+++ b/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala
@@ -13,6 +13,7 @@ private class ShotwellPublishingExtraServices : Object, Spit.Module {
         pluggables += new YandexService();
         pluggables += new TumblrService(module_file.get_parent());
         pluggables += new RajceService(module_file.get_parent());
+        pluggables += new Gallery3Service(module_file.get_parent());
     }
     
     public unowned string get_module_name() {
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 2ed84ee..47575fb 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -15,10 +15,13 @@ plugins/shotwell-data-imports/FSpotRollsTable.vala
 plugins/shotwell-data-imports/FSpotTableBehavior.vala
 plugins/shotwell-data-imports/FSpotTagsTable.vala
 plugins/shotwell-data-imports/shotwell-data-imports.vala
+plugins/shotwell-publishing-extras/GalleryConnector.vala
 plugins/shotwell-publishing-extras/RajcePublishing.vala
 plugins/shotwell-publishing-extras/TumblrPublishing.vala
 plugins/shotwell-publishing-extras/YandexPublishing.vala
 plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala
+[type: gettext/glade]plugins/shotwell-publishing-extras/gallery3_authentication_pane.glade
+[type: gettext/glade]plugins/shotwell-publishing-extras/gallery3_publishing_options_pane.glade
 [type: gettext/glade]plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade
 [type: gettext/glade]plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade
 [type: gettext/glade]plugins/shotwell-publishing-extras/rajce_authentication_pane.glade


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