diff options
Diffstat (limited to 'plugins/shotwell-publishing-extras')
-rw-r--r-- | plugins/shotwell-publishing-extras/Makefile | 27 | ||||
-rw-r--r-- | plugins/shotwell-publishing-extras/TumblrPublishing.vala | 1157 | ||||
-rw-r--r-- | plugins/shotwell-publishing-extras/YandexPublishing.vala | 665 | ||||
-rw-r--r-- | plugins/shotwell-publishing-extras/shotwell-publishing-extras.vala | 42 | ||||
-rw-r--r-- | plugins/shotwell-publishing-extras/tumblr.png | bin | 0 -> 1007 bytes | |||
-rw-r--r-- | plugins/shotwell-publishing-extras/tumblr_authentication_pane.glade | 132 | ||||
-rw-r--r-- | plugins/shotwell-publishing-extras/tumblr_publishing_options_pane.glade | 167 | ||||
-rw-r--r-- | plugins/shotwell-publishing-extras/yandex_publish_model.glade | 175 |
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 Binary files differnew file mode 100644 index 0000000..d5bf02b --- /dev/null +++ b/plugins/shotwell-publishing-extras/tumblr.png 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> |