summaryrefslogtreecommitdiff
path: root/plugins/shotwell-publishing-extras
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/shotwell-publishing-extras')
-rw-r--r--plugins/shotwell-publishing-extras/Makefile27
-rw-r--r--plugins/shotwell-publishing-extras/TumblrPublishing.vala1157
-rw-r--r--plugins/shotwell-publishing-extras/YandexPublishing.vala665
-rw-r--r--plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala42
-rw-r--r--plugins/shotwell-publishing-extras/tumblr.pngbin0 -> 1007 bytes
-rw-r--r--plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade132
-rw-r--r--plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade167
-rw-r--r--plugins/shotwell-publishing-extras/yandex_publish_model.glade175
8 files changed, 2365 insertions, 0 deletions
diff --git a/plugins/shotwell-publishing-extras/Makefile b/plugins/shotwell-publishing-extras/Makefile
new file mode 100644
index 0000000..51f649f
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/Makefile
@@ -0,0 +1,27 @@
+
+PLUGIN := shotwell-publishing-extras
+
+PLUGIN_PKGS := \
+ gtk+-3.0 \
+ libsoup-2.4 \
+ libxml-2.0 \
+ webkitgtk-3.0 \
+ gee-0.8 \
+ rest-0.7 \
+ json-glib-1.0
+
+SRC_FILES := \
+ shotwell-publishing-extras.vala \
+ YandexPublishing.vala \
+ TumblrPublishing.vala \
+ ../../src/util/string.vala \
+ ../common/RESTSupport.vala
+
+RC_FILES := \
+ yandex_publish_model.glade \
+ tumblr.png \
+ tumblr_authentication_pane.glade \
+ tumblr_publishing_options_pane.glade
+
+include ../Makefile.plugin.mk
+
diff --git a/plugins/shotwell-publishing-extras/TumblrPublishing.vala b/plugins/shotwell-publishing-extras/TumblrPublishing.vala
new file mode 100644
index 0000000..6bafb21
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/TumblrPublishing.vala
@@ -0,0 +1,1157 @@
+/* Copyright 2012 BJA Electronics
+ * Author: Jeroen Arnoldus (b.j.arnoldus@bja-electronics.nl)
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+
+extern string hmac_sha1(string key, string message);
+public class TumblrService : Object, Spit.Pluggable, Spit.Publishing.Service {
+ private const string ICON_FILENAME = "tumblr.png";
+
+ private static Gdk.Pixbuf[] icon_pixbuf_set = null;
+
+ public TumblrService(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 "org.yorba.shotwell.publishing.tumblr";
+ }
+
+ public unowned string get_pluggable_name() {
+ return "Tumblr";
+ }
+
+ public void get_info(ref Spit.PluggableInfo info) {
+ info.authors = "Jeroen Arnoldus";
+ info.copyright = _("Copyright 2012 BJA Electronics");
+ info.translators = Resources.TRANSLATORS;
+ info.version = _VERSION;
+ info.website_name = Resources.WEBSITE_NAME;
+ info.website_url = Resources.WEBSITE_URL;
+ info.is_license_wordwrapped = false;
+ info.license = Resources.LICENSE;
+ info.icons = icon_pixbuf_set;
+ }
+
+ public void activation(bool enabled) {
+ }
+
+ public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) {
+ return new Publishing.Tumblr.TumblrPublisher(this, host);
+ }
+
+ public Spit.Publishing.Publisher.MediaType get_supported_media() {
+ return (Spit.Publishing.Publisher.MediaType.PHOTO |
+ Spit.Publishing.Publisher.MediaType.VIDEO);
+ }
+}
+
+namespace Publishing.Tumblr {
+
+internal const string SERVICE_NAME = "Tumblr";
+internal const string ENDPOINT_URL = "http://www.tumblr.com/";
+internal const string API_KEY = "NdXvXQuKVccOsCOj0H4k9HUJcbcjDBYSo2AkaHzXFECHGNuP9k";
+internal const string API_SECRET = "BN0Uoig0MwbeD27OgA0IwYlp3Uvonyfsrl9pf1cnnMj1QoEUvi";
+internal const string ENCODE_RFC_3986_EXTRA = "!*'();:@&=+$,/?%#[] \\";
+internal const int ORIGINAL_SIZE = -1;
+
+
+
+private class BlogEntry {
+ public string blog;
+ public string url;
+ public BlogEntry(string creator_blog, string creator_url) {
+ blog = creator_blog;
+ url = creator_url;
+ }
+}
+
+private class SizeEntry {
+ public string title;
+ public int size;
+
+ public SizeEntry(string creator_title, int creator_size) {
+ title = creator_title;
+ size = creator_size;
+ }
+}
+
+public class TumblrPublisher : Spit.Publishing.Publisher, GLib.Object {
+ private Spit.Publishing.Service service;
+ private Spit.Publishing.PluginHost host;
+ private Spit.Publishing.ProgressCallback progress_reporter = null;
+ private bool running = false;
+ private bool was_started = false;
+ private Session session = null;
+ private PublishingOptionsPane publishing_options_pane = null;
+ private SizeEntry[] sizes = null;
+ private BlogEntry[] blogs = null;
+ private string username = "";
+
+
+ private SizeEntry[] create_sizes() {
+ SizeEntry[] result = new SizeEntry[0];
+
+ result += new SizeEntry(_("500 x 375 pixels"), 500);
+ result += new SizeEntry(_("1024 x 768 pixels"), 1024);
+ result += new SizeEntry(_("1280 x 853 pixels"), 1280);
+//Larger images make no sense for Tumblr
+// result += new SizeEntry(_("2048 x 1536 pixels"), 2048);
+// result += new SizeEntry(_("4096 x 3072 pixels"), 4096);
+// result += new SizeEntry(_("Original size"), ORIGINAL_SIZE);
+
+ return result;
+ }
+
+ private BlogEntry[] create_blogs() {
+ BlogEntry[] result = new BlogEntry[0];
+
+
+ return result;
+ }
+
+ public TumblrPublisher(Spit.Publishing.Service service,
+ Spit.Publishing.PluginHost host) {
+ debug("TumblrPublisher instantiated.");
+ this.service = service;
+ this.host = host;
+ this.session = new Session();
+ this.sizes = this.create_sizes();
+ this.blogs = this.create_blogs();
+ session.authenticated.connect(on_session_authenticated);
+ }
+
+ ~TumblrPublisher() {
+ session.authenticated.disconnect(on_session_authenticated);
+ }
+
+ private void invalidate_persistent_session() {
+ set_persistent_access_phase_token("");
+ set_persistent_access_phase_token_secret("");
+ }
+ // Publisher interface implementation
+
+ public Spit.Publishing.Service get_service() {
+ return service;
+ }
+
+ public Spit.Publishing.PluginHost get_host() {
+ return host;
+ }
+
+ public bool is_running() {
+ return running;
+ }
+
+ private bool is_persistent_session_valid() {
+ string? access_phase_token = get_persistent_access_phase_token();
+ string? access_phase_token_secret = get_persistent_access_phase_token_secret();
+
+ bool valid = ((access_phase_token != null) && (access_phase_token_secret != null));
+
+ if (valid)
+ debug("existing Tumblr session found in configuration database; using it.");
+ else
+ debug("no persisted Tumblr session exists.");
+
+ return valid;
+ }
+
+
+
+
+ public string? get_persistent_access_phase_token() {
+ return host.get_config_string("token", null);
+ }
+
+ private void set_persistent_access_phase_token(string? token) {
+ host.set_config_string("token", token);
+ }
+
+ public string? get_persistent_access_phase_token_secret() {
+ return host.get_config_string("token_secret", null);
+ }
+
+ private void set_persistent_access_phase_token_secret(string? token_secret) {
+ host.set_config_string("token_secret", token_secret);
+ }
+
+ internal int get_persistent_default_size() {
+ return host.get_config_int("default_size", 1);
+ }
+
+ internal void set_persistent_default_size(int size) {
+ host.set_config_int("default_size", size);
+ }
+
+ internal int get_persistent_default_blog() {
+ return host.get_config_int("default_blog", 0);
+ }
+
+ internal void set_persistent_default_blog(int blog) {
+ host.set_config_int("default_blog", blog);
+ }
+
+ // Actions and events implementation
+
+ /**
+ * Action that shows the authentication pane.
+ *
+ * This action method shows the authentication pane. It is shown at the
+ * very beginning of the interaction when no persistent parameters are found
+ * or after a failed login attempt using persisted parameters. It can be
+ * given a mode flag to specify whether it should be displayed in initial
+ * mode or in any of the error modes that it supports.
+ *
+ * @param mode the mode for the authentication pane
+ */
+ private void do_show_authentication_pane(AuthenticationPane.Mode mode = AuthenticationPane.Mode.INTRO) {
+ debug("ACTION: installing authentication pane");
+
+ host.set_service_locked(false);
+ AuthenticationPane authentication_pane =
+ new AuthenticationPane(this, mode);
+ authentication_pane.login.connect(on_authentication_pane_login_clicked);
+ host.install_dialog_pane(authentication_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE);
+ host.set_dialog_default_widget(authentication_pane.get_default_widget());
+ }
+
+ /**
+ * Event triggered when the login button in the authentication panel is
+ * clicked.
+ *
+ * This event is triggered when the login button in the authentication
+ * panel is clicked. It then triggers a network login interaction.
+ *
+ * @param username the name of the Tumblr user as entered in the dialog
+ * @param password the password of the Tumblr as entered in the dialog
+ */
+ private void on_authentication_pane_login_clicked( string username, string password ) {
+ debug("EVENT: on_authentication_pane_login_clicked");
+ if (!running)
+ return;
+
+ do_network_login(username, password);
+ }
+
+ /**
+ * Action to perform a network login to a Tumblr blog.
+ *
+ * This action performs a network login a Tumblr blog specified the given user name and password as credentials.
+ *
+ * @param username the name of the Tumblr user used to login
+ * @param password the password of the Tumblr user used to login
+ */
+ private void do_network_login(string username, string password) {
+ debug("ACTION: logging in");
+ host.set_service_locked(true);
+ host.install_login_wait_pane();
+
+
+ AccessTokenFetchTransaction txn = new AccessTokenFetchTransaction(session,username,password);
+ txn.completed.connect(on_auth_request_txn_completed);
+ txn.network_error.connect(on_auth_request_txn_error);
+
+ try {
+ txn.execute();
+ } catch (Spit.Publishing.PublishingError err) {
+ host.post_error(err);
+ }
+ }
+
+
+ private void on_auth_request_txn_completed(Publishing.RESTSupport.Transaction txn) {
+ txn.completed.disconnect(on_auth_request_txn_completed);
+ txn.network_error.disconnect(on_auth_request_txn_error);
+
+ if (!is_running())
+ return;
+
+ debug("EVENT: OAuth authentication request transaction completed; response = '%s'",
+ txn.get_response());
+
+ do_parse_token_info_from_auth_request(txn.get_response());
+ }
+
+ private void on_auth_request_txn_error(Publishing.RESTSupport.Transaction txn,
+ Spit.Publishing.PublishingError err) {
+ txn.completed.disconnect(on_auth_request_txn_completed);
+ txn.network_error.disconnect(on_auth_request_txn_error);
+
+ if (!is_running())
+ return;
+
+ debug("EVENT: OAuth authentication request transaction caused a network error");
+ host.post_error(err);
+ }
+
+
+ private void do_parse_token_info_from_auth_request(string response) {
+ debug("ACTION: parsing authorization request response '%s' into token and secret", response);
+
+ string? oauth_token = null;
+ string? oauth_token_secret = null;
+
+ string[] key_value_pairs = response.split("&");
+ foreach (string pair in key_value_pairs) {
+ string[] split_pair = pair.split("=");
+
+ if (split_pair.length != 2)
+ host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(
+ _("'%s' isn't a valid response to an OAuth authentication request")));
+
+ if (split_pair[0] == "oauth_token")
+ oauth_token = split_pair[1];
+ else if (split_pair[0] == "oauth_token_secret")
+ oauth_token_secret = split_pair[1];
+ }
+
+ if (oauth_token == null || oauth_token_secret == null)
+ host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(
+ _("'%s' isn't a valid response to an OAuth authentication request")));
+
+ session.set_access_phase_credentials(oauth_token, oauth_token_secret);
+ }
+
+
+
+ private void on_session_authenticated() {
+ if (!is_running())
+ return;
+
+ debug("EVENT: a fully authenticated session has become available");
+ set_persistent_access_phase_token(session.get_access_phase_token());
+ set_persistent_access_phase_token_secret(session.get_access_phase_token_secret());
+ do_get_blogs();
+
+}
+
+ private void do_get_blogs() {
+ debug("ACTION: obtain all blogs of the tumblr user");
+ UserInfoFetchTransaction txn = new UserInfoFetchTransaction(session);
+ txn.completed.connect(on_info_request_txn_completed);
+ txn.network_error.connect(on_info_request_txn_error);
+
+ try {
+ txn.execute();
+ } catch (Spit.Publishing.PublishingError err) {
+ host.post_error(err);
+ }
+
+
+ }
+
+
+ private void on_info_request_txn_completed(Publishing.RESTSupport.Transaction txn) {
+ txn.completed.disconnect(on_info_request_txn_completed);
+ txn.network_error.disconnect(on_info_request_txn_error);
+
+ if (!is_running())
+ return;
+
+ debug("EVENT: user info request transaction completed; response = '%s'",
+ txn.get_response());
+ do_parse_token_info_from_user_request(txn.get_response());
+ do_show_publishing_options_pane();
+ }
+
+
+ private void do_parse_token_info_from_user_request(string response) {
+ debug("ACTION: parsing info request response '%s' into list of available blogs", response);
+ try {
+ var parser = new Json.Parser();
+ parser.load_from_data (response, -1);
+ var root_object = parser.get_root().get_object();
+ this.username = root_object.get_object_member("response").get_object_member("user").get_string_member ("name");
+ debug("Got user name: %s",username);
+ foreach (var blognode in root_object.get_object_member("response").get_object_member("user").get_array_member("blogs").get_elements ()) {
+ var blog = blognode.get_object ();
+ string name = blog.get_string_member ("name");
+ string url = blog.get_string_member ("url").replace("http://","").replace("/","");
+ debug("Got blog name: %s and url: %s", name, url);
+ this.blogs += new BlogEntry(name,url);
+ }
+ } catch (Error err) {
+ host.post_error(err);
+ }
+ }
+
+ private void on_info_request_txn_error(Publishing.RESTSupport.Transaction txn,
+ Spit.Publishing.PublishingError err) {
+ txn.completed.disconnect(on_info_request_txn_completed);
+ txn.network_error.disconnect(on_info_request_txn_error);
+
+ if (!is_running())
+ return;
+
+ session.deauthenticate();
+ invalidate_persistent_session();
+ debug("EVENT: user info request transaction caused a network error");
+ host.post_error(err);
+ }
+
+ private void do_show_publishing_options_pane() {
+ debug("ACTION: displaying publishing options pane");
+ host.set_service_locked(false);
+ PublishingOptionsPane publishing_options_pane =
+ new PublishingOptionsPane(this, host.get_publishable_media_type(), this.sizes, this.blogs, this.username);
+ 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 on_publishing_options_pane_publish() {
+ if (publishing_options_pane != null) {
+ 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 clicked the 'Publish' button in the publishing options pane");
+ do_publish();
+ }
+
+ private void on_publishing_options_pane_logout() {
+ if (publishing_options_pane != null) {
+ 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 clicked the 'Logout' button in the publishing options pane");
+
+ do_logout();
+ }
+
+ public static int tumblr_date_time_compare_func(Spit.Publishing.Publishable a,
+ Spit.Publishing.Publishable b) {
+ return a.get_exposure_date_time().compare(b.get_exposure_date_time());
+ }
+
+ private void do_publish() {
+ debug("ACTION: uploading media items to remote server.");
+
+ host.set_service_locked(true);
+
+ progress_reporter = host.serialize_publishables(sizes[get_persistent_default_size()].size);
+
+ // 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;
+
+ // Sort publishables in reverse-chronological order.
+ Spit.Publishing.Publishable[] publishables = host.get_publishables();
+ Gee.ArrayList<Spit.Publishing.Publishable> sorted_list =
+ new Gee.ArrayList<Spit.Publishing.Publishable>();
+ foreach (Spit.Publishing.Publishable p in publishables) {
+ debug("ACTION: add publishable");
+ sorted_list.add(p);
+ }
+ sorted_list.sort(tumblr_date_time_compare_func);
+ string blog_url = this.blogs[get_persistent_default_blog()].url;
+
+ Uploader uploader = new Uploader(session, sorted_list.to_array(),blog_url);
+ uploader.upload_complete.connect(on_upload_complete);
+ uploader.upload_error.connect(on_upload_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();
+ }
+
+
+ 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_upload_complete(Publishing.RESTSupport.BatchUploader uploader,
+ int num_published) {
+ if (!is_running())
+ return;
+
+ debug("EVENT: uploader reports upload complete; %d items published.", num_published);
+
+ uploader.upload_complete.disconnect(on_upload_complete);
+ uploader.upload_error.disconnect(on_upload_error);
+
+ do_show_success_pane();
+ }
+
+ private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader,
+ Spit.Publishing.PublishingError err) {
+ if (!is_running())
+ return;
+
+ debug("EVENT: uploader reports upload error = '%s'.", err.message);
+
+ uploader.upload_complete.disconnect(on_upload_complete);
+ uploader.upload_error.disconnect(on_upload_error);
+
+ host.post_error(err);
+ }
+
+
+ private void do_logout() {
+ debug("ACTION: logging user out, deauthenticating session, and erasing stored credentials");
+
+ session.deauthenticate();
+ invalidate_persistent_session();
+
+ running = false;
+
+ attempt_start();
+ }
+
+ public void attempt_start() {
+ if (is_running())
+ return;
+
+ debug("TumblrPublisher: starting interaction.");
+
+ running = true;
+ if (is_persistent_session_valid()) {
+ debug("attempt start: a persistent session is available; using it");
+
+ session.authenticate_from_persistent_credentials(get_persistent_access_phase_token(),
+ get_persistent_access_phase_token_secret());
+ } else {
+ debug("attempt start: no persistent session available; showing login welcome pane");
+
+ do_show_authentication_pane();
+ }
+ }
+
+ public void start() {
+ if (is_running())
+ return;
+
+ if (was_started)
+ error(_("TumblrPublisher: start( ): can't start; this publisher is not restartable."));
+
+ debug("TumblrPublisher: starting interaction.");
+
+ attempt_start();
+ }
+
+ public void stop() {
+ debug("TumblrPublisher: stop( ) invoked.");
+
+// if (session != null)
+// session.stop_transactions();
+
+ running = false;
+ }
+
+
+// UI elements
+
+/**
+ * The authentication pane used when asking service URL, user name and password
+ * from the user.
+ */
+internal class AuthenticationPane : Spit.Publishing.DialogPane, Object {
+ public enum Mode {
+ INTRO,
+ FAILED_RETRY_USER
+ }
+ private static string INTRO_MESSAGE = _("Enter the username and password associated with your Tumblr account.");
+ private static string FAILED_RETRY_USER_MESSAGE = _("Username and/or password invalid. Please try again");
+
+ private Gtk.Box pane_widget = null;
+ private Gtk.Builder builder;
+ private Gtk.Entry username_entry;
+ private Gtk.Entry password_entry;
+ private Gtk.Button login_button;
+
+ public signal void login(string user, string password);
+
+ public AuthenticationPane(TumblrPublisher publisher, Mode mode = Mode.INTRO) {
+ this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+
+ File ui_file = publisher.get_host().get_module_file().get_parent().
+ get_child("tumblr_authentication_pane.glade");
+
+ try {
+ builder = new Gtk.Builder();
+ builder.add_from_file(ui_file.get_path());
+ builder.connect_signals(null);
+ Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment;
+
+ Gtk.Label message_label = builder.get_object("message_label") as Gtk.Label;
+ switch (mode) {
+ case Mode.INTRO:
+ message_label.set_text(INTRO_MESSAGE);
+ break;
+
+ case Mode.FAILED_RETRY_USER:
+ message_label.set_markup("<b>%s</b>\n\n%s".printf(_(
+ "Invalid User Name or Password"), FAILED_RETRY_USER_MESSAGE));
+ break;
+ }
+
+ username_entry = builder.get_object ("username_entry") as Gtk.Entry;
+
+ password_entry = builder.get_object ("password_entry") as Gtk.Entry;
+
+
+
+ login_button = builder.get_object("login_button") as Gtk.Button;
+
+ username_entry.changed.connect(on_user_changed);
+ password_entry.changed.connect(on_password_changed);
+ login_button.clicked.connect(on_login_button_clicked);
+
+ align.reparent(pane_widget);
+ publisher.get_host().set_dialog_default_widget(login_button);
+ } catch (Error e) {
+ warning(_("Could not load UI: %s"), e.message);
+ }
+ }
+
+ public Gtk.Widget get_default_widget() {
+ return login_button;
+ }
+
+ private void on_login_button_clicked() {
+ login(username_entry.get_text(),
+ password_entry.get_text());
+ }
+
+
+ private void on_user_changed() {
+ update_login_button_sensitivity();
+ }
+
+ private void on_password_changed() {
+ update_login_button_sensitivity();
+ }
+
+ private void update_login_button_sensitivity() {
+ login_button.set_sensitive(
+ !is_string_empty(username_entry.get_text()) &&
+ !is_string_empty(password_entry.get_text())
+ );
+ }
+
+ 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() {
+ username_entry.grab_focus();
+ password_entry.set_activates_default(true);
+ login_button.can_default = true;
+ update_login_button_sensitivity();
+ }
+
+ public void on_pane_uninstalled() {
+ }
+}
+
+
+/**
+ * The publishing options pane.
+ */
+
+
+internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object {
+
+
+
+ private Gtk.Builder builder;
+ private Gtk.Box pane_widget = null;
+ private Gtk.Label upload_info_label = null;
+ private Gtk.Label size_label = null;
+ private Gtk.Label blog_label = null;
+ private Gtk.Button logout_button = null;
+ private Gtk.Button publish_button = null;
+ private Gtk.ComboBoxText size_combo = null;
+ private Gtk.ComboBoxText blog_combo = null;
+ private SizeEntry[] sizes = null;
+ private BlogEntry[] blogs = null;
+ private string username = "";
+ private TumblrPublisher publisher = null;
+ private Spit.Publishing.Publisher.MediaType media_type;
+
+ public signal void publish();
+ public signal void logout();
+
+ public PublishingOptionsPane(TumblrPublisher publisher, Spit.Publishing.Publisher.MediaType media_type, SizeEntry[] sizes, BlogEntry[] blogs, string username) {
+
+ this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+ this.username = username;
+ this.publisher = publisher;
+ this.media_type = media_type;
+ this.sizes = sizes;
+ this.blogs=blogs;
+ File ui_file = publisher.get_host().get_module_file().get_parent().
+ get_child("tumblr_publishing_options_pane.glade");
+
+ try {
+ builder = new Gtk.Builder();
+ builder.add_from_file(ui_file.get_path());
+ builder.connect_signals(null);
+
+ // pull in the necessary widgets from the glade file
+ pane_widget = (Gtk.Box) this.builder.get_object("tumblr_pane");
+ upload_info_label = (Gtk.Label) this.builder.get_object("upload_info_label");
+ logout_button = (Gtk.Button) this.builder.get_object("logout_button");
+ publish_button = (Gtk.Button) this.builder.get_object("publish_button");
+ size_combo = (Gtk.ComboBoxText) this.builder.get_object("size_combo");
+ size_label = (Gtk.Label) this.builder.get_object("size_label");
+ blog_combo = (Gtk.ComboBoxText) this.builder.get_object("blog_combo");
+ blog_label = (Gtk.Label) this.builder.get_object("blog_label");
+
+
+ string upload_label_text = _("You are logged into Tumblr as %s.\n\n").printf(this.username);
+ upload_info_label.set_label(upload_label_text);
+
+ populate_blog_combo();
+ blog_combo.changed.connect(on_blog_changed);
+
+ if ((media_type != Spit.Publishing.Publisher.MediaType.VIDEO)) {
+ populate_size_combo();
+ size_combo.changed.connect(on_size_changed);
+ } else {
+ // publishing -only- video - don't let the user manipulate the photo size choices.
+ size_combo.set_sensitive(false);
+ size_label.set_sensitive(false);
+ }
+
+ logout_button.clicked.connect(on_logout_clicked);
+ publish_button.clicked.connect(on_publish_clicked);
+ } catch (Error e) {
+ warning(_("Could not load UI: %s"), e.message);
+ }
+ }
+
+
+
+
+
+ private void on_logout_clicked() {
+ logout();
+ }
+
+ private void on_publish_clicked() {
+
+
+ publish();
+ }
+
+
+ private void populate_blog_combo() {
+ if (blogs != null) {
+ foreach (BlogEntry b in blogs)
+ blog_combo.append_text(b.blog);
+ blog_combo.set_active(publisher.get_persistent_default_blog());
+ }
+ }
+
+ private void on_blog_changed() {
+ publisher.set_persistent_default_blog(blog_combo.get_active());
+ }
+
+ private void populate_size_combo() {
+ if (sizes != null) {
+ foreach (SizeEntry e in sizes)
+ size_combo.append_text(e.title);
+ size_combo.set_active(publisher.get_persistent_default_size());
+ }
+ }
+
+ private void on_size_changed() {
+ publisher.set_persistent_default_size(size_combo.get_active());
+ }
+
+
+ protected void notify_publish() {
+ publish();
+ }
+
+ protected void notify_logout() {
+ logout();
+ }
+
+ 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() {
+ publish.connect(notify_publish);
+ logout.connect(notify_logout);
+ }
+
+ public void on_pane_uninstalled() {
+ publish.disconnect(notify_publish);
+ logout.disconnect(notify_logout);
+ }
+}
+
+
+// REST support classes
+internal class Transaction : Publishing.RESTSupport.Transaction {
+ public Transaction(Session session, Publishing.RESTSupport.HttpMethod method =
+ Publishing.RESTSupport.HttpMethod.POST) {
+ base(session, method);
+
+ }
+
+ public Transaction.with_uri(Session session, string uri,
+ Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.POST) {
+ base.with_endpoint_url(session, uri, method);
+
+ add_argument("oauth_nonce", session.get_oauth_nonce());
+ add_argument("oauth_signature_method", "HMAC-SHA1");
+ add_argument("oauth_version", "1.0");
+ add_argument("oauth_timestamp", session.get_oauth_timestamp());
+ add_argument("oauth_consumer_key", API_KEY);
+ if (session.get_access_phase_token() != null) {
+ add_argument("oauth_token", session.get_access_phase_token());
+ }
+ }
+
+ public override void execute() throws Spit.Publishing.PublishingError {
+ ((Session) get_parent_session()).sign_transaction(this);
+
+ base.execute();
+ }
+
+}
+
+
+internal class AccessTokenFetchTransaction : Transaction {
+ public AccessTokenFetchTransaction(Session session, string username, string password) {
+ base.with_uri(session, "https://www.tumblr.com/oauth/access_token",
+ Publishing.RESTSupport.HttpMethod.POST);
+ add_argument("x_auth_username", Soup.URI.encode(username, ENCODE_RFC_3986_EXTRA));
+ add_argument("x_auth_password", password);
+ add_argument("x_auth_mode", "client_auth");
+ }
+}
+
+internal class UserInfoFetchTransaction : Transaction {
+ public UserInfoFetchTransaction(Session session) {
+ base.with_uri(session, "http://api.tumblr.com/v2/user/info",
+ Publishing.RESTSupport.HttpMethod.POST);
+ }
+}
+
+
+internal class UploadTransaction : Publishing.RESTSupport.UploadTransaction {
+ private Session session;
+ private Publishing.RESTSupport.Argument[] auth_header_fields;
+
+
+//Workaround for Soup.URI.encode() to support binary data (i.e. string with \0)
+ private string encode( uint8[] data ){
+ var s = new StringBuilder();
+ char[] bytes = new char[2];
+ bytes[1] = 0;
+ foreach( var byte in data )
+ {
+ if(byte == 0) {
+ s.append( "%00" );
+ } else {
+ bytes[0] = (char)byte;
+ s.append( Soup.URI.encode((string) bytes, ENCODE_RFC_3986_EXTRA) );
+ }
+ }
+ return s.str;
+ }
+
+
+ public UploadTransaction(Session session,Spit.Publishing.Publishable publishable, string blog_url) {
+ debug("Init upload transaction");
+ base.with_endpoint_url(session, publishable,"http://api.tumblr.com/v2/blog/%s/post".printf(blog_url) );
+ this.session = session;
+
+ }
+
+
+
+ public void add_authorization_header_field(string key, string value) {
+ auth_header_fields += new Publishing.RESTSupport.Argument(key, value);
+ }
+
+ public Publishing.RESTSupport.Argument[] get_authorization_header_fields() {
+ return auth_header_fields;
+ }
+
+ public string get_authorization_header_string() {
+ string result = "OAuth ";
+
+ for (int i = 0; i < auth_header_fields.length; i++) {
+ result += auth_header_fields[i].key;
+ result += "=";
+ result += ("\"" + auth_header_fields[i].value + "\"");
+
+ if (i < auth_header_fields.length - 1)
+ result += ", ";
+ }
+
+ return result;
+ }
+
+ public override void execute() throws Spit.Publishing.PublishingError {
+ add_authorization_header_field("oauth_nonce", session.get_oauth_nonce());
+ add_authorization_header_field("oauth_signature_method", "HMAC-SHA1");
+ add_authorization_header_field("oauth_version", "1.0");
+ add_authorization_header_field("oauth_timestamp", session.get_oauth_timestamp());
+ add_authorization_header_field("oauth_consumer_key", API_KEY);
+ add_authorization_header_field("oauth_token", session.get_access_phase_token());
+
+
+ string payload;
+ size_t payload_length;
+ try {
+ FileUtils.get_contents(base.publishable.get_serialized_file().get_path(), out payload,
+ out payload_length);
+
+ string reqdata = this.encode(payload.data[0:payload_length]);
+
+
+
+ add_argument("data[0]", reqdata);
+ add_argument("type", "photo");
+ string[] keywords = base.publishable.get_publishing_keywords();
+ string tags = "";
+ if (keywords != null) {
+ foreach (string tag in keywords) {
+ if (!is_string_empty(tags)) {
+ tags += ",";
+ }
+ tags += tag;
+ }
+ }
+ add_argument("tags", Soup.URI.encode(tags, ENCODE_RFC_3986_EXTRA));
+
+ } catch (FileError e) {
+ throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(
+ _("A temporary file needed for publishing is unavailable"));
+
+ }
+
+
+ session.sign_transaction(this);
+
+ string authorization_header = get_authorization_header_string();
+
+ debug("executing upload transaction: authorization header string = '%s'",
+ authorization_header);
+ add_header("Authorization", authorization_header);
+
+ Publishing.RESTSupport.Argument[] request_arguments = get_arguments();
+ assert(request_arguments.length > 0);
+
+ string request_data = "";
+ for (int i = 0; i < request_arguments.length; i++) {
+ request_data += (request_arguments[i].key + "=" + request_arguments[i].value);
+ if (i < request_arguments.length - 1)
+ request_data += "&";
+ }
+ Soup.Message outbound_message = new Soup.Message( "POST", get_endpoint_url());
+ outbound_message.set_request("application/x-www-form-urlencoded", Soup.MemoryUse.COPY, request_data.data);
+
+ // TODO: there must be a better way to iterate over a map
+ Gee.MapIterator<string, string> i = base.message_headers.map_iterator();
+ bool cont = i.next();
+ while(cont) {
+ outbound_message.request_headers.append(i.get_key(), i.get_value());
+ cont = i.next();
+ }
+ set_message(outbound_message);
+
+ set_is_executed(true);
+
+ send();
+ }
+}
+
+
+
+internal class Uploader : Publishing.RESTSupport.BatchUploader {
+ private string blog_url = "";
+ public Uploader(Session session, Spit.Publishing.Publishable[] publishables, string blog_url) {
+ base(session, publishables);
+ this.blog_url=blog_url;
+
+ }
+
+
+ protected override Publishing.RESTSupport.Transaction create_transaction(
+ Spit.Publishing.Publishable publishable) {
+ debug("Create upload transaction");
+ return new UploadTransaction((Session) get_session(), get_current_publishable(), this.blog_url);
+
+ }
+}
+
+/**
+ * Session class that keeps track of the authentication status and of the
+ * user token tumblr.
+ */
+internal class Session : Publishing.RESTSupport.Session {
+ private string? access_phase_token = null;
+ private string? access_phase_token_secret = null;
+
+
+ public Session() {
+ base(ENDPOINT_URL);
+ }
+
+ public override bool is_authenticated() {
+ return (access_phase_token != null && access_phase_token_secret != null);
+ }
+
+ public void authenticate_from_persistent_credentials(string token, string secret) {
+ this.access_phase_token = token;
+ this.access_phase_token_secret = secret;
+
+
+ authenticated();
+ }
+
+ public void deauthenticate() {
+ access_phase_token = null;
+ access_phase_token_secret = null;
+ }
+
+ public void sign_transaction(Publishing.RESTSupport.Transaction txn) {
+ string http_method = txn.get_method().to_string();
+
+ debug("signing transaction with parameters:");
+ debug("HTTP method = " + http_method);
+ string? signing_key = null;
+ if (access_phase_token_secret != null) {
+ debug("access phase token secret available; using it as signing key");
+
+ signing_key = API_SECRET + "&" + this.get_access_phase_token_secret();
+ } else {
+ debug("Access phase token secret not available; using API " +
+ "key as signing key");
+
+ signing_key = API_SECRET + "&";
+ }
+
+
+ Publishing.RESTSupport.Argument[] base_string_arguments = txn.get_arguments();
+
+ UploadTransaction? upload_txn = txn as UploadTransaction;
+ if (upload_txn != null) {
+ debug("this transaction is an UploadTransaction; including Authorization header " +
+ "fields in signature base string");
+
+ Publishing.RESTSupport.Argument[] auth_header_args =
+ upload_txn.get_authorization_header_fields();
+
+ foreach (Publishing.RESTSupport.Argument arg in auth_header_args)
+ base_string_arguments += arg;
+ }
+
+ Publishing.RESTSupport.Argument[] sorted_args =
+ Publishing.RESTSupport.Argument.sort(base_string_arguments);
+
+ string arguments_string = "";
+ for (int i = 0; i < sorted_args.length; i++) {
+ arguments_string += (sorted_args[i].key + "=" + sorted_args[i].value);
+ if (i < sorted_args.length - 1)
+ arguments_string += "&";
+ }
+
+
+ string signature_base_string = http_method + "&" + Soup.URI.encode(
+ txn.get_endpoint_url(), ENCODE_RFC_3986_EXTRA) + "&" +
+ Soup.URI.encode(arguments_string, ENCODE_RFC_3986_EXTRA);
+
+ debug("signature base string = '%s'", signature_base_string);
+ debug("signing key = '%s'", signing_key);
+
+ // compute the signature
+ string signature = hmac_sha1(signing_key, signature_base_string);
+ debug("signature = '%s'", signature);
+ signature = Soup.URI.encode(signature, ENCODE_RFC_3986_EXTRA);
+
+ debug("signature after RFC encode = '%s'", signature);
+
+ if (upload_txn != null)
+ upload_txn.add_authorization_header_field("oauth_signature", signature);
+ else
+ txn.add_argument("oauth_signature", signature);
+
+
+ }
+
+ public void set_access_phase_credentials(string token, string secret) {
+ this.access_phase_token = token;
+ this.access_phase_token_secret = secret;
+
+
+ authenticated();
+ }
+
+ public string get_access_phase_token() {
+ return access_phase_token;
+ }
+
+
+ public string get_access_phase_token_secret() {
+ return access_phase_token_secret;
+ }
+
+ public string get_oauth_nonce() {
+ TimeVal currtime = TimeVal();
+ currtime.get_current_time();
+
+ return Checksum.compute_for_string(ChecksumType.MD5, currtime.tv_sec.to_string() +
+ currtime.tv_usec.to_string());
+ }
+
+ public string get_oauth_timestamp() {
+ return GLib.get_real_time().to_string().substring(0, 10);
+ }
+
+}
+
+
+} //class TumblrPublisher
+
+} //namespace Publishing.Tumblr
+
diff --git a/plugins/shotwell-publishing-extras/YandexPublishing.vala b/plugins/shotwell-publishing-extras/YandexPublishing.vala
new file mode 100644
index 0000000..36a3ede
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/YandexPublishing.vala
@@ -0,0 +1,665 @@
+/* Copyright 2010+ Evgeniy Polyakov <zbr@ioremap.net>
+ *
+ * This software is licensed under the GNU LGPL (version 2.1 or later).
+ * See the COPYING file in this distribution.
+ */
+
+public class YandexService : Object, Spit.Pluggable, Spit.Publishing.Service {
+ 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 "org.yorba.shotwell.publishing.yandex-fotki";
+ }
+
+ public unowned string get_pluggable_name() {
+ return "Yandex.Fotki";
+ }
+
+ public void get_info(ref Spit.PluggableInfo info) {
+ info.authors = "Evgeniy Polyakov <zbr@ioremap.net>";
+ info.copyright = _("Copyright 2010+ Evgeniy Polyakov <zbr@ioremap.net>");
+ info.translators = Resources.TRANSLATORS;
+ info.version = _VERSION;
+ info.website_name = _("Visit the Yandex.Fotki web site");
+ info.website_url = "http://fotki.yandex.ru/";
+ info.is_license_wordwrapped = false;
+ info.license = Resources.LICENSE;
+ }
+
+ public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) {
+ return new Publishing.Yandex.YandexPublisher(this, host);
+ }
+
+ public Spit.Publishing.Publisher.MediaType get_supported_media() {
+ return (Spit.Publishing.Publisher.MediaType.PHOTO);
+ }
+
+ public void activation(bool enabled) {
+ }
+}
+
+namespace Publishing.Yandex {
+
+internal const string SERVICE_NAME = "Yandex.Fotki";
+
+private const string client_id = "52be4756dee3438792c831a75d7cd360";
+
+internal class Transaction: Publishing.RESTSupport.Transaction {
+ public Transaction.with_url(Session session, string url, Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.GET) {
+ base.with_endpoint_url(session, url, method);
+ add_headers();
+ }
+
+ private void add_headers() {
+ if (((Session) get_parent_session()).is_authenticated()) {
+ add_header("Authorization", "OAuth %s".printf(((Session) get_parent_session()).get_auth_token()));
+ add_header("Connection", "close");
+ }
+ }
+
+ public Transaction(Session session, Publishing.RESTSupport.HttpMethod method = Publishing.RESTSupport.HttpMethod.GET) {
+ base(session, method);
+ add_headers();
+ }
+
+ public void add_data(string type, string data) {
+ set_custom_payload(data, type);
+ }
+}
+
+internal class Session : Publishing.RESTSupport.Session {
+ private string? auth_token = null;
+
+ public Session() {
+ }
+
+ public override bool is_authenticated() {
+ return (auth_token != null);
+ }
+
+ public void deauthenticate() {
+ auth_token = null;
+ }
+
+ public void set_auth_token(string token) {
+ this.auth_token = token;
+ }
+
+ public string? get_auth_token() {
+ return auth_token;
+ }
+}
+
+internal class WebAuthPane : Spit.Publishing.DialogPane, GLib.Object {
+ private WebKit.WebView webview = null;
+ private Gtk.Box pane_widget = null;
+ private Gtk.ScrolledWindow webview_frame = null;
+
+ private Regex re;
+ private string? login_url = null;
+
+ public signal void login_succeeded(string success_url);
+ public signal void login_failed();
+
+ public WebAuthPane(string login_url) {
+ this.login_url = login_url;
+
+ try {
+ this.re = new Regex("(.*)#access_token=([a-zA-Z0-9]*)&");
+ } catch (RegexError e) {
+ critical("%s", e.message);
+ }
+
+ pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+
+ webview_frame = new Gtk.ScrolledWindow(null, null);
+ webview_frame.set_shadow_type(Gtk.ShadowType.ETCHED_IN);
+ webview_frame.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
+
+ webview = new WebKit.WebView();
+ webview.get_settings().enable_plugins = false;
+ webview.get_settings().enable_default_context_menu = false;
+
+ webview.load_finished.connect(on_page_load);
+ webview.load_started.connect(on_load_started);
+ webview.navigation_requested.connect(navigation_requested);
+
+ webview_frame.add(webview);
+ pane_widget.pack_start(webview_frame, true, true, 0);
+ }
+
+ private void on_page_load(WebKit.WebFrame origin_frame) {
+ pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR));
+ }
+
+ private WebKit.NavigationResponse navigation_requested (WebKit.WebFrame frame, WebKit.NetworkRequest req) {
+ debug("Navigating to '%s'", req.uri);
+
+ MatchInfo info = null;
+
+ if (re.match(req.uri, 0, out info)) {
+ string access_token = info.fetch_all()[2];
+
+ debug("Load completed: %s", access_token);
+ pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR));
+ if (access_token != null) {
+ login_succeeded(access_token);
+ return WebKit.NavigationResponse.IGNORE;
+ } else
+ login_failed();
+ }
+ return WebKit.NavigationResponse.ACCEPT;
+ }
+
+ private void on_load_started(WebKit.WebFrame frame) {
+ pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.WATCH));
+ }
+
+ public Gtk.Widget get_widget() {
+ return pane_widget;
+ }
+
+ public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() {
+ return Spit.Publishing.DialogPane.GeometryOptions.RESIZABLE;
+ }
+
+ public void on_pane_installed() {
+ webview.open(login_url);
+ }
+
+ public void on_pane_uninstalled() {
+ }
+}
+
+internal class PublishOptions {
+ public bool disable_comments = false;
+ public bool hide_original = false;
+ public string access_type;
+
+ public string destination_album = null;
+ public string destination_album_url = null;
+}
+
+internal class PublishingOptionsPane: Spit.Publishing.DialogPane, GLib.Object {
+ private Gtk.Box box;
+ private Gtk.Builder builder;
+ private Gtk.Button logout_button;
+ private Gtk.Button publish_button;
+ private Gtk.ComboBoxText album_list;
+
+ private weak PublishOptions options;
+
+ public signal void publish();
+ public signal void logout();
+
+ public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() {
+ return Spit.Publishing.DialogPane.GeometryOptions.NONE;
+ }
+ public void on_pane_installed() {
+ }
+ public void on_pane_uninstalled() {
+ }
+ public Gtk.Widget get_widget() {
+ return box;
+ }
+
+ public PublishingOptionsPane(PublishOptions options, Gee.HashMap<string, string> list,
+ Spit.Publishing.PluginHost host) {
+ this.options = options;
+
+ box = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
+
+ File ui_file = host.get_module_file().get_parent().get_child("yandex_publish_model.glade");
+
+ try {
+ builder = new Gtk.Builder();
+ builder.add_from_file(ui_file.get_path());
+ builder.connect_signals(null);
+ Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment;
+
+ album_list = builder.get_object ("album_list") as Gtk.ComboBoxText;
+ foreach (string key in list.keys)
+ album_list.append_text(key);
+
+ album_list.set_active(0);
+
+ publish_button = builder.get_object("publish_button") as Gtk.Button;
+ logout_button = builder.get_object("logout_button") as Gtk.Button;
+
+ publish_button.clicked.connect(on_publish_clicked);
+ logout_button.clicked.connect(on_logout_clicked);
+
+ align.reparent(box);
+ box.set_child_packing(align, true, true, 0, Gtk.PackType.START);
+ } catch (Error e) {
+ warning("Could not load UI: %s", e.message);
+ }
+ }
+
+ private void on_logout_clicked() {
+ logout();
+ }
+
+ private void on_publish_clicked() {
+ options.destination_album = album_list.get_active_text();
+
+ Gtk.CheckButton tmp = builder.get_object("hide_original_check") as Gtk.CheckButton;
+ options.hide_original = tmp.active;
+
+ tmp = builder.get_object("disable_comments_check") as Gtk.CheckButton;
+ options.disable_comments = tmp.active;
+
+ Gtk.ComboBoxText access_type = builder.get_object("access_type_list") as Gtk.ComboBoxText;
+ options.access_type = access_type.get_active_text();
+
+ publish();
+ }
+}
+
+private class Uploader: Publishing.RESTSupport.BatchUploader {
+ private weak PublishOptions options;
+
+ public Uploader(Session session, PublishOptions options, Spit.Publishing.Publishable[] photos) {
+ base(session, photos);
+
+ this.options = options;
+ }
+
+ protected override Publishing.RESTSupport.Transaction create_transaction(Spit.Publishing.Publishable publishable) {
+ debug("create transaction");
+ return new UploadTransaction(((Session) get_session()), options, get_current_publishable());
+ }
+}
+
+private class UploadTransaction: Transaction {
+ public UploadTransaction(Session session, PublishOptions options, Spit.Publishing.Publishable photo) {
+ base.with_url(session, options.destination_album_url, Publishing.RESTSupport.HttpMethod.POST);
+
+ set_custom_payload("qwe", "image/jpeg", 1);
+
+ debug("Uploading '%s' -> %s : %s", photo.get_publishing_name(), options.destination_album, options.destination_album_url);
+
+ Soup.Multipart message_parts = new Soup.Multipart("multipart/form-data");
+ message_parts.append_form_string("title", photo.get_publishing_name());
+ message_parts.append_form_string("hide_original", options.hide_original.to_string());
+ message_parts.append_form_string("disable_comments", options.disable_comments.to_string());
+ message_parts.append_form_string("access", options.access_type.down());
+
+ string photo_data;
+ size_t data_length;
+
+ try {
+ FileUtils.get_contents(photo.get_serialized_file().get_path(), out photo_data, out data_length);
+ } catch (GLib.FileError e) {
+ critical("Failed to read data file '%s': %s", photo.get_serialized_file().get_path(), e.message);
+ }
+
+ int image_part_num = message_parts.get_length();
+
+ Soup.Buffer bindable_data = new Soup.Buffer(Soup.MemoryUse.COPY, photo_data.data[0:data_length]);
+ message_parts.append_form_file("", photo.get_serialized_file().get_path(), "image/jpeg", bindable_data);
+
+ unowned Soup.MessageHeaders image_part_header;
+ unowned Soup.Buffer image_part_body;
+ message_parts.get_part(image_part_num, out image_part_header, out image_part_body);
+
+ GLib.HashTable<string, string> result = new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal);
+ result.insert("name", "image");
+ result.insert("filename", "unused");
+
+ image_part_header.set_content_disposition("form-data", result);
+
+ Soup.Message outbound_message = soup_form_request_new_from_multipart(get_endpoint_url(), message_parts);
+ outbound_message.request_headers.append("Authorization", ("OAuth %s").printf(session.get_auth_token()));
+ outbound_message.request_headers.append("Connection", "close");
+ set_message(outbound_message);
+ }
+}
+
+public class YandexPublisher : Spit.Publishing.Publisher, GLib.Object {
+ private weak Spit.Publishing.PluginHost host = null;
+ private Spit.Publishing.ProgressCallback progress_reporter = null;
+ private weak Spit.Publishing.Service service = null;
+
+ private string service_url = null;
+
+ private Gee.HashMap<string, string> album_list = null;
+ private PublishOptions options;
+
+ private bool running = false;
+
+ private WebAuthPane web_auth_pane = null;
+
+ private Session session;
+
+ public YandexPublisher(Spit.Publishing.Service service, Spit.Publishing.PluginHost host) {
+ this.service = service;
+ this.host = host;
+ this.session = new Session();
+ this.album_list = new Gee.HashMap<string, string>();
+ this.options = new PublishOptions();
+ }
+
+ internal string? get_persistent_auth_token() {
+ return host.get_config_string("auth_token", null);
+ }
+
+ internal void set_persistent_auth_token(string auth_token) {
+ host.set_config_string("auth_token", auth_token);
+ }
+
+ internal void invalidate_persistent_session() {
+ host.unset_config_key("auth_token");
+ }
+
+ internal bool is_persistent_session_available() {
+ return (get_persistent_auth_token() != null);
+ }
+
+ public bool is_running() {
+ return running;
+ }
+
+ public Spit.Publishing.Service get_service() {
+ return service;
+ }
+
+ private new string? check_response(Publishing.RESTSupport.XmlDocument doc) {
+ return null;
+ }
+
+ private void parse_album_entry(Xml.Node *e) throws Spit.Publishing.PublishingError {
+ string title = null;
+ string link = null;
+
+ for (Xml.Node* c = e->children ; c != null; c = c->next) {
+ if (c->name == "title")
+ title = c->get_content();
+
+ if ((c->name == "link") && (c->get_prop("rel") == "photos"))
+ link = c->get_prop("href");
+
+ if (title != null && link != null) {
+ debug("Added album: '%s', link: %s", title, link);
+ album_list.set(title, link);
+ title = null;
+ link = null;
+ break;
+ }
+ }
+ }
+
+ public void parse_album_creation(string data) throws Spit.Publishing.PublishingError {
+ Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(data, check_response);
+ Xml.Node *root = doc.get_root_node();
+
+ parse_album_entry(root);
+ }
+
+ public void parse_album_list(string data) throws Spit.Publishing.PublishingError {
+ Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(data, check_response);
+ Xml.Node *root = doc.get_root_node();
+
+ for (Xml.Node *e = root->children ; e != null; e = e->next) {
+ if (e->name != "entry")
+ continue;
+
+ parse_album_entry(e);
+ }
+ }
+
+ private void album_creation_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) {
+ t.completed.disconnect(album_creation_complete);
+ t.network_error.disconnect(album_creation_error);
+
+ warning("Album creation error: %s", err.message);
+ }
+
+ private void album_creation_complete(Publishing.RESTSupport.Transaction t) {
+ t.completed.disconnect(album_creation_complete);
+ t.network_error.disconnect(album_creation_error);
+
+ try {
+ parse_album_creation(t.get_response());
+ } catch (Spit.Publishing.PublishingError err) {
+ host.post_error(err);
+ return;
+ }
+
+ if (album_list.get(options.destination_album) != null)
+ start_upload();
+ else
+ host.post_error(new Spit.Publishing.PublishingError.PROTOCOL_ERROR("Server did not create album"));
+ }
+
+ private void create_destination_album() {
+ string album = options.destination_album;
+ string data = "<entry xmlns=\"http://www.w3.org/2005/Atom\" xmlns:f=\"yandex:fotki\"><title>%s</title></entry>".printf(album);
+
+ Transaction t = new Transaction.with_url(session, service_url, Publishing.RESTSupport.HttpMethod.POST);
+
+ t.add_data("application/atom+xml; charset=utf-8; type=entry", data);
+
+ t.completed.connect(album_creation_complete);
+ t.network_error.connect(album_creation_error);
+
+ try {
+ t.execute();
+ } catch (Spit.Publishing.PublishingError err) {
+ host.post_error(err);
+ }
+ }
+
+ private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, int num_published) {
+ uploader.upload_complete.disconnect(on_upload_complete);
+ uploader.upload_error.disconnect(on_upload_error);
+
+ if (num_published == 0)
+ host.post_error(new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(""));
+
+ host.set_service_locked(false);
+
+ host.install_success_pane();
+ }
+
+ private void on_upload_error(Publishing.RESTSupport.BatchUploader uploader, Spit.Publishing.PublishingError err) {
+ uploader.upload_complete.disconnect(on_upload_complete);
+ uploader.upload_error.disconnect(on_upload_error);
+
+ warning("Photo upload error: %s", err.message);
+ }
+
+ private void on_upload_status_updated(int file_number, double completed_fraction) {
+ debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction);
+
+ assert(progress_reporter != null);
+
+ progress_reporter(file_number, completed_fraction);
+ }
+
+ private void start_upload() {
+ host.set_service_locked(true);
+
+ progress_reporter = host.serialize_publishables(0);
+
+ options.destination_album_url = album_list.get(options.destination_album);
+ Spit.Publishing.Publishable[] publishables = host.get_publishables();
+ Uploader uploader = new Uploader(session, options, publishables);
+
+ uploader.upload_complete.connect(on_upload_complete);
+ uploader.upload_error.connect(on_upload_error);
+ uploader.upload(on_upload_status_updated);
+ }
+
+ private void on_logout() {
+ if (!is_running())
+ return;
+
+ session.deauthenticate();
+ invalidate_persistent_session();
+
+ running = false;
+
+ start();
+ }
+
+ private void on_publish() {
+ debug("Going to publish to '%s' : %s", options.destination_album, album_list.get(options.destination_album));
+ if (album_list.get(options.destination_album) == null)
+ create_destination_album();
+ else
+ start_upload();
+ }
+
+ public void service_get_album_list_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) {
+ t.completed.disconnect(service_get_album_list_complete);
+ t.network_error.disconnect(service_get_album_list_error);
+
+ invalidate_persistent_session();
+ warning("Failed to get album list: %s", err.message);
+ }
+
+ public void service_get_album_list_complete(Publishing.RESTSupport.Transaction t) {
+ t.completed.disconnect(service_get_album_list_complete);
+ t.network_error.disconnect(service_get_album_list_error);
+
+ debug("service_get_album_list_complete: %s", t.get_response());
+ try {
+ parse_album_list(t.get_response());
+ } catch (Spit.Publishing.PublishingError err) {
+ host.post_error(err);
+ }
+
+ PublishingOptionsPane publishing_options_pane = new PublishingOptionsPane(options, album_list,
+ host);
+
+ publishing_options_pane.publish.connect(on_publish);
+ publishing_options_pane.logout.connect(on_logout);
+ host.install_dialog_pane(publishing_options_pane);
+ }
+
+ public void service_get_album_list(string url) {
+ service_url = url;
+
+ Transaction t = new Transaction.with_url(session, url);
+ t.completed.connect(service_get_album_list_complete);
+ t.network_error.connect(service_get_album_list_error);
+
+ try {
+ t.execute();
+ } catch (Spit.Publishing.PublishingError err) {
+ host.post_error(err);
+ }
+ }
+
+ public void fetch_account_error(Publishing.RESTSupport.Transaction t, Spit.Publishing.PublishingError err) {
+ t.completed.disconnect(fetch_account_complete);
+ t.network_error.disconnect(fetch_account_error);
+
+ warning("Failed to fetch account info: %s", err.message);
+ }
+
+ public void fetch_account_complete(Publishing.RESTSupport.Transaction t) {
+ t.completed.disconnect(fetch_account_complete);
+ t.network_error.disconnect(fetch_account_error);
+
+ debug("account info: %s", t.get_response());
+ try {
+ Publishing.RESTSupport.XmlDocument doc = Publishing.RESTSupport.XmlDocument.parse_string(t.get_response(), check_response);
+ Xml.Node* root = doc.get_root_node();
+
+ for (Xml.Node* work = root->children ; work != null; work = work->next) {
+ if (work->name != "workspace")
+ continue;
+ for (Xml.Node* c = work->children ; c != null; c = c->next) {
+ if (c->name != "collection")
+ continue;
+
+ if (c->get_prop("id") == "album-list") {
+ string url = c->get_prop("href");
+
+ set_persistent_auth_token(session.get_auth_token());
+ service_get_album_list(url);
+ break;
+ }
+ }
+ }
+ } catch (Spit.Publishing.PublishingError err) {
+ host.post_error(err);
+ }
+ }
+
+ public void fetch_account_information(string auth_token) {
+ session.set_auth_token(auth_token);
+
+ Transaction t = new Transaction.with_url(session, "http://api-fotki.yandex.ru/api/me/");
+ t.completed.connect(fetch_account_complete);
+ t.network_error.connect(fetch_account_error);
+
+ try {
+ t.execute();
+ } catch (Spit.Publishing.PublishingError err) {
+ host.post_error(err);
+ }
+ }
+
+ private void web_auth_login_succeeded(string access_token) {
+ debug("login succeeded with token %s", access_token);
+
+ host.set_service_locked(true);
+ host.install_account_fetch_wait_pane();
+
+ fetch_account_information(access_token);
+ }
+
+ private void web_auth_login_failed() {
+ debug("login failed");
+ }
+
+ private void start_web_auth() {
+ host.set_service_locked(false);
+
+ web_auth_pane = new WebAuthPane(("http://oauth.yandex.ru/authorize?client_id=%s&response_type=token").printf(client_id));
+ web_auth_pane.login_succeeded.connect(web_auth_login_succeeded);
+ web_auth_pane.login_failed.connect(web_auth_login_failed);
+
+ host.install_dialog_pane(web_auth_pane, Spit.Publishing.PluginHost.ButtonMode.CANCEL);
+ }
+
+ private void show_welcome_page() {
+ host.install_welcome_pane(_("You are not currently logged into Yandex.Fotki."),
+ start_web_auth);
+ }
+
+ public void start() {
+ if (is_running())
+ return;
+
+ if (host == null)
+ error("YandexPublisher: start( ): can't start; this publisher is not restartable.");
+
+ debug("YandexPublisher: starting interaction.");
+
+ running = true;
+
+ if (is_persistent_session_available()) {
+ session.set_auth_token(get_persistent_auth_token());
+
+ fetch_account_information(get_persistent_auth_token());
+ } else {
+ show_welcome_page();
+ }
+ }
+
+ public void stop() {
+ debug("YandexPublisher: stop( ) invoked.");
+
+ host = null;
+ running = false;
+ }
+}
+
+}
+
diff --git a/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala b/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala
new file mode 100644
index 0000000..c83acf1
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala
@@ -0,0 +1,42 @@
+/* Copyright 2011-2014 Yorba Foundation
+ *
+ * This software is licensed under the GNU Lesser General Public License
+ * (version 2.1 or later). See the COPYING file in this distribution.
+ */
+
+extern const string _VERSION;
+
+private class ShotwellPublishingExtraServices : Object, Spit.Module {
+ private Spit.Pluggable[] pluggables = new Spit.Pluggable[0];
+
+ public ShotwellPublishingExtraServices(GLib.File module_file) {
+ pluggables += new YandexService();
+ pluggables += new TumblrService(module_file.get_parent());
+ }
+
+ public unowned string get_module_name() {
+ return _("Shotwell Extra Publishing Services");
+ }
+
+ public unowned string get_version() {
+ return _VERSION;
+ }
+
+ public unowned string get_id() {
+ return "org.yorba.shotwell.publishing.extras";
+ }
+
+ public unowned Spit.Pluggable[]? get_pluggables() {
+ return pluggables;
+ }
+}
+
+// This entry point is required for all SPIT modules.
+public Spit.Module? spit_entry_point(Spit.EntryPointParams *params) {
+ params->module_spit_interface = Spit.negotiate_interfaces(params->host_min_spit_interface,
+ params->host_max_spit_interface, Spit.CURRENT_INTERFACE);
+
+ return (params->module_spit_interface != Spit.UNSUPPORTED_INTERFACE)
+ ? new ShotwellPublishingExtraServices(params->module_file) : null;
+}
+
diff --git a/plugins/shotwell-publishing-extras/tumblr.png b/plugins/shotwell-publishing-extras/tumblr.png
new file mode 100644
index 0000000..d5bf02b
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/tumblr.png
Binary files differ
diff --git a/plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade b/plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade
new file mode 100644
index 0000000..9b43309
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <!-- interface-requires gtk+ 3.0 -->
+ <object class="GtkWindow" id="authentication_pane">
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkAlignment" id="alignment">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="xscale">0.5</property>
+ <property name="yscale">0.5</property>
+ <child>
+ <object class="GtkVBox" id="vbox1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="margin_left">30</property>
+ <property name="margin_right">30</property>
+ <property name="hexpand">True</property>
+ <property name="spacing">8</property>
+ <child>
+ <object class="GtkLabel" id="message_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="hexpand">True</property>
+ <property name="vexpand">True</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">label</property>
+ <property name="wrap">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkTable" id="field_table">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="n_rows">2</property>
+ <property name="n_columns">2</property>
+ <property name="column_spacing">8</property>
+ <property name="row_spacing">2</property>
+ <child>
+ <object class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="xalign">0</property>
+ <property name="label" translatable="yes">_Email address</property>
+ <property name="use_underline">True</property>
+ <property name="mnemonic_widget">username_entry</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label3">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="xalign">0</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="top_attach">1</property>
+ <property name="bottom_attach">2</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">1</property>
+ <property name="right_attach">2</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="invisible_char_set">True</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">1</property>
+ <property name="bottom_attach">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHButtonBox" id="hbuttonbox1">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkButton" id="login_button">
+ <property name="label" translatable="yes">Login</property>
+ <property name="use_action_appearance">False</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_action_appearance">False</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+</interface>
diff --git a/plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade b/plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade
new file mode 100644
index 0000000..102e260
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade
@@ -0,0 +1,167 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <!-- interface-requires gtk+ 3.0 -->
+ <object class="GtkBox" id="tumblr_pane">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">16</property>
+ <property name="homogeneous">True</property>
+ <child>
+ <object class="GtkBox" id="inner_wrapper">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">40</property>
+ <child>
+ <object class="GtkLabel" id="upload_info_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="no">You are logged into Tumblr as (name).
+
+(this label's string is populated and set inside the code,
+so changes made here will not display)</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="padding">16</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkGrid" id="visibility_and_size_grid">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="halign">center</property>
+ <property name="valign">center</property>
+ <property name="row_spacing">8</property>
+ <property name="column_spacing">24</property>
+ <child>
+ <object class="GtkLabel" id="blog_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Blogs:</property>
+ <property name="use_underline">True</property>
+ <property name="justify">right</property>
+ <property name="mnemonic_widget">blog_combo</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="GtkLabel" id="size_label">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="xalign">1</property>
+ <property name="label" translatable="yes">Photo _size:</property>
+ <property name="use_underline">True</property>
+ <property name="mnemonic_widget">size_combo</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="GtkComboBoxText" id="blog_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="GtkComboBoxText" id="size_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">1</property>
+ <property name="width">1</property>
+ <property name="height">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="padding">18</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="box2">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="halign">center</property>
+ <property name="spacing">48</property>
+ <property name="homogeneous">True</property>
+ <child>
+ <object class="GtkButton" id="logout_button">
+ <property name="label" translatable="yes">_Logout</property>
+ <property name="use_action_appearance">False</property>
+ <property name="width_request">96</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_action_appearance">False</property>
+ <property name="use_underline">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="padding">24</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="publish_button">
+ <property name="label" translatable="yes">_Publish</property>
+ <property name="use_action_appearance">False</property>
+ <property name="width_request">96</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_action_appearance">False</property>
+ <property name="use_underline">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="padding">24</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="padding">1</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="padding">8</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+</interface>
diff --git a/plugins/shotwell-publishing-extras/yandex_publish_model.glade b/plugins/shotwell-publishing-extras/yandex_publish_model.glade
new file mode 100644
index 0000000..175bafd
--- /dev/null
+++ b/plugins/shotwell-publishing-extras/yandex_publish_model.glade
@@ -0,0 +1,175 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <requires lib="gtk+" version="2.16"/>
+ <!-- interface-naming-policy project-wide -->
+ <object class="GtkWindow" id="publish_options_window">
+ <child>
+ <object class="GtkAlignment" id="alignment">
+ <property name="visible">True</property>
+ <property name="xalign">0.30000001192092896</property>
+ <property name="xscale">0.10000000149011612</property>
+ <property name="yscale">0.10000000149011612</property>
+ <child>
+ <object class="GtkVBox" id="vbox1">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkTable" id="table1">
+ <property name="visible">True</property>
+ <property name="n_rows">2</property>
+ <property name="n_columns">2</property>
+ <child>
+ <object class="GtkLabel" id="label2">
+ <property name="visible">True</property>
+ <property name="xalign">0.30000001192092896</property>
+ <property name="label" translatable="yes">_Albums (or write new):</property>
+ <property name="use_underline">True</property>
+ <property name="mnemonic_widget">album_list</property>
+ </object>
+ <packing>
+ <property name="top_attach">1</property>
+ <property name="bottom_attach">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="label1">
+ <property name="visible">True</property>
+ <property name="xalign">0</property>
+ <property name="ypad">6</property>
+ <property name="label" translatable="yes">Access _type:</property>
+ <property name="use_underline">True</property>
+ <property name="mnemonic_widget">access_type_list</property>
+ </object>
+ </child>
+ <child>
+ <object class="GtkComboBoxText" id="access_type_list">
+ <property name="visible">True</property>
+ <property name="model">liststore1</property>
+ <property name="active">0</property>
+ <property name="text_column">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="y_padding">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkComboBoxText" id="album_list">
+ <property name="visible">True</property>
+ <property name="model">liststore2</property>
+ <property name="active">0</property>
+ <property name="text_column">0</property>
+ </object>
+ <packing>
+ <property name="left_attach">1</property>
+ <property name="right_attach">2</property>
+ <property name="top_attach">1</property>
+ <property name="bottom_attach">2</property>
+ <property name="y_padding">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <placeholder/>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="disable_comments_check">
+ <property name="label" translatable="yes">Disable _comments</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="use_underline">True</property>
+ <property name="xalign">0.30000001192092896</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="padding">2</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="hide_original_check">
+ <property name="label" translatable="yes">_Forbid downloading original photo</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="use_underline">True</property>
+ <property name="xalign">0.30000001192092896</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkHButtonBox" id="hbuttonbox1">
+ <property name="visible">True</property>
+ <property name="spacing">2</property>
+ <property name="layout_style">spread</property>
+ <child>
+ <object class="GtkButton" id="logout_button">
+ <property name="label" translatable="yes">_Logout</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_underline">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="publish_button">
+ <property name="label" translatable="yes">_Publish</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ <property name="use_underline">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="padding">12</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ </object>
+ <object class="GtkListStore" id="liststore1">
+ <columns>
+ <!-- column-name text -->
+ <column type="gchararray"/>
+ </columns>
+ <data>
+ <row>
+ <col id="0" translatable="yes">Public</col>
+ </row>
+ <row>
+ <col id="0" translatable="yes">Friends</col>
+ </row>
+ <row>
+ <col id="0" translatable="yes">Private</col>
+ </row>
+ </data>
+ </object>
+ <object class="GtkListStore" id="liststore2">
+ <columns>
+ <!-- column-name gchararray1 -->
+ <column type="gchararray"/>
+ </columns>
+ </object>
+</interface>