diff options
Diffstat (limited to 'plugins/shotwell-publishing')
19 files changed, 7855 insertions, 0 deletions
diff --git a/plugins/shotwell-publishing/FacebookPublishing.vala b/plugins/shotwell-publishing/FacebookPublishing.vala new file mode 100644 index 0000000..79b7a0a --- /dev/null +++ b/plugins/shotwell-publishing/FacebookPublishing.vala @@ -0,0 +1,1676 @@ +/* Copyright 2009-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. + */ + +public class FacebookService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "facebook.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public FacebookService(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.facebook"; + } + + public unowned string get_pluggable_name() { + return "Facebook"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Lucas Beeler"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + 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.Facebook.FacebookPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO); + } +} + +namespace Publishing.Facebook { +// global parameters for the Facebook publishing plugin -- don't touch these (unless you really, +// truly, deep-down know what you're doing) +public const string SERVICE_NAME = "facebook"; +internal const string USER_VISIBLE_NAME = "Facebook"; +internal const string APPLICATION_ID = "162702932093"; +internal const string DEFAULT_ALBUM_NAME = _("Shotwell Connect"); +internal const string SERVICE_WELCOME_MESSAGE = + _("You are not currently logged into Facebook.\n\nIf you don't yet have a Facebook account, you can create one during the login process. During login, Shotwell Connect may ask you for permission to upload photos and publish to your feed. These permissions are required for Shotwell Connect to function."); +internal const string RESTART_ERROR_MESSAGE = + _("You have already logged in and out of Facebook during this Shotwell session.\nTo continue publishing to Facebook, quit and restart Shotwell, then try publishing again."); +internal const string USER_AGENT = "Java/1.6.0_16"; +internal const int EXPIRED_SESSION_STATUS_CODE = 400; + +internal class Album { + public string name; + public string id; + + public Album(string name, string id) { + this.name = name; + this.id = id; + } +} + +internal enum Resolution { + STANDARD, + HIGH; + + public string get_name() { + switch (this) { + case STANDARD: + return _("Standard (720 pixels)"); + + case HIGH: + return _("Large (2048 pixels)"); + + default: + error("Unknown resolution %s", this.to_string()); + } + } + + public int get_pixels() { + switch (this) { + case STANDARD: + return 720; + + case HIGH: + return 2048; + + default: + error("Unknown resolution %s", this.to_string()); + } + } +} + +internal class PublishingParameters { + public const int UNKNOWN_ALBUM = -1; + + public bool strip_metadata; + public Album[] albums; + public int target_album; + public string? new_album_name; // the name of the new album being created during this + // publishing interaction or null if publishing to an existing + // album + + public string? privacy_object; // a serialized JSON object encoding the privacy settings of the + // published resources + public Resolution resolution; + + public PublishingParameters() { + this.albums = null; + this.privacy_object = null; + this.target_album = UNKNOWN_ALBUM; + this.new_album_name = null; + this.strip_metadata = false; + this.resolution = Resolution.HIGH; + } + + public void add_album(string name, string id) { + if (albums == null) + albums = new Album[0]; + + Album new_album = new Album(name, id); + albums += new_album; + } + + public void set_target_album_by_name(string? name) { + if (name == null) { + target_album = UNKNOWN_ALBUM; + return; + } + + for (int i = 0; i < albums.length; i++) { + + if (albums[i].name == name) { + target_album = i; + return; + } + } + + target_album = UNKNOWN_ALBUM; + } + + public string? get_target_album_name() { + if (albums == null || target_album == UNKNOWN_ALBUM) + return null; + + return albums[target_album].name; + } + + public string? get_target_album_id() { + if (albums == null || target_album == UNKNOWN_ALBUM) + return null; + + return albums[target_album].id; + } +} + +public class FacebookPublisher : Spit.Publishing.Publisher, GLib.Object { + private PublishingParameters publishing_params; + private weak Spit.Publishing.PluginHost host = null; + private WebAuthenticationPane web_auth_pane = null; + private Spit.Publishing.ProgressCallback progress_reporter = null; + private weak Spit.Publishing.Service service = null; + private bool running = false; + private GraphSession graph_session; + private PublishingOptionsPane? publishing_options_pane = null; + private Uploader? uploader = null; + private string? uid = null; + private string? username = null; + + public FacebookPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + debug("FacebookPublisher instantiated."); + + this.service = service; + this.host = host; + + this.publishing_params = new PublishingParameters(); + + this.graph_session = new GraphSession(); + graph_session.authenticated.connect(on_session_authenticated); + } + + private bool is_persistent_session_valid() { + string? token = get_persistent_access_token(); + + if (token != null) + debug("existing Facebook session found in configuration database (access_token = %s).", + token); + else + debug("no existing Facebook session available."); + + return token != null; + } + + private string? get_persistent_access_token() { + return host.get_config_string("access_token", null); + } + + private bool get_persistent_strip_metadata() { + return host.get_config_bool("strip_metadata", false); + } + + private void set_persistent_access_token(string access_token) { + host.set_config_string("access_token", access_token); + } + + private void set_persistent_strip_metadata(bool strip_metadata) { + host.set_config_bool("strip_metadata", strip_metadata); + } + + // Part of the fix for #3232. These have to be + // public so the legacy options pane may use them. + public int get_persistent_default_size() { + return host.get_config_int("default_size", 0); + } + + public void set_persistent_default_size(int size) { + host.set_config_int("default_size", size); + } + + private void invalidate_persistent_session() { + debug("invalidating saved Facebook session."); + + set_persistent_access_token(""); + } + + private void do_show_service_welcome_pane() { + debug("ACTION: showing service welcome pane."); + + host.install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_login_clicked); + host.set_service_locked(false); + } + + private void do_test_connection_to_endpoint() { + debug("ACTION: testing connection to Facebook endpoint."); + host.set_service_locked(true); + + host.install_static_message_pane(_("Testing connection to Facebook...")); + + GraphMessage endpoint_test_message = graph_session.new_endpoint_test(); + endpoint_test_message.completed.connect(on_endpoint_test_completed); + endpoint_test_message.failed.connect(on_endpoint_test_error); + + graph_session.send_message(endpoint_test_message); + } + + private void do_fetch_user_info() { + debug("ACTION: fetching user information."); + + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + GraphMessage user_info_message = graph_session.new_query("/me"); + + user_info_message.completed.connect(on_fetch_user_info_completed); + user_info_message.failed.connect(on_fetch_user_info_error); + + graph_session.send_message(user_info_message); + } + + private void do_fetch_album_descriptions() { + debug("ACTION: fetching album list."); + + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + GraphMessage albums_message = graph_session.new_query("/%s/albums".printf(uid)); + + albums_message.completed.connect(on_fetch_albums_completed); + albums_message.failed.connect(on_fetch_albums_error); + + graph_session.send_message(albums_message); + } + + private void do_extract_user_info_from_json(string json) { + debug("ACTION: extracting user info from JSON response."); + + try { + Json.Parser parser = new Json.Parser(); + parser.load_from_data(json); + + Json.Node root = parser.get_root(); + Json.Object response_object = root.get_object(); + uid = response_object.get_string_member("id"); + username = response_object.get_string_member("name"); + } catch (Error error) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(error.message)); + return; + } + + on_user_info_extracted(); + } + + private void do_extract_albums_from_json(string json) { + debug("ACTION: extracting album info from JSON response."); + + try { + Json.Parser parser = new Json.Parser(); + parser.load_from_data(json); + + Json.Node root = parser.get_root(); + Json.Object response_object = root.get_object(); + Json.Array album_list = response_object.get_array_member("data"); + + publishing_params.albums = new Album[0]; + + for (int i = 0; i < album_list.get_length(); i++) { + Json.Object current_album = album_list.get_object_element(i); + string album_id = current_album.get_string_member("id"); + string album_name = current_album.get_string_member("name"); + + // Note that we are completely ignoring the "can_upload" flag in the list of albums + // that we pulled from facebook eariler -- effectively, we add every album to the + // publishing_params album list regardless of the value of its can_upload flag. In + // the future we may wish to make adding to the publishing_params album list + // conditional on the value of the can_upload flag being true + publishing_params.add_album(album_name, album_id); + } + } catch (Error error) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(error.message)); + return; + } + + on_albums_extracted(); + } + + private void do_create_new_album() { + debug("ACTION: creating a new album named \"%s\".\n", publishing_params.new_album_name); + + host.set_service_locked(true); + host.install_static_message_pane(_("Creating album...")); + + GraphMessage create_album_message = graph_session.new_create_album( + publishing_params.new_album_name, publishing_params.privacy_object); + + create_album_message.completed.connect(on_create_album_completed); + create_album_message.failed.connect(on_create_album_error); + + graph_session.send_message(create_album_message); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: showing publishing options pane."); + + host.set_service_locked(false); + Gtk.Builder builder = new Gtk.Builder(); + + try { + // the trailing get_path() is required, since add_from_file can't cope + // with File objects directly and expects a pathname instead. + builder.add_from_file( + host.get_module_file().get_parent(). + get_child("facebook_publishing_options_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + host.post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Facebook can't continue."))); + return; + } + + publishing_options_pane = new PublishingOptionsPane(username, publishing_params.albums, + host.get_publishable_media_type(), this, builder, get_persistent_strip_metadata()); + publishing_options_pane.logout.connect(on_publishing_options_pane_logout); + publishing_options_pane.publish.connect(on_publishing_options_pane_publish); + host.install_dialog_pane(publishing_options_pane, + Spit.Publishing.PluginHost.ButtonMode.CANCEL); + } + + private void do_logout() { + debug("ACTION: clearing persistent session information and restaring interaction."); + + invalidate_persistent_session(); + + running = false; + start(); + } + + private void do_add_new_local_album_from_json(string album_name, string json) { + try { + Json.Parser parser = new Json.Parser(); + parser.load_from_data(json); + + Json.Node root = parser.get_root(); + Json.Object response_object = root.get_object(); + string album_id = response_object.get_string_member("id"); + + publishing_params.add_album(album_name, album_id); + } catch (Error error) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE(error.message)); + return; + } + + publishing_params.set_target_album_by_name(album_name); + do_upload(); + } + + private void do_hosted_web_authentication() { + debug("ACTION: doing hosted web authentication."); + + host.set_service_locked(false); + + web_auth_pane = new WebAuthenticationPane(); + web_auth_pane.login_succeeded.connect(on_web_auth_pane_login_succeeded); + web_auth_pane.login_failed.connect(on_web_auth_pane_login_failed); + + host.install_dialog_pane(web_auth_pane, + Spit.Publishing.PluginHost.ButtonMode.CANCEL); + + } + + private void do_authenticate_session(string good_login_uri) { + debug("ACTION: preparing to extract session information encoded in uri = '%s'", + good_login_uri); + + // the raw uri is percent-encoded, so decode it + string decoded_uri = Soup.URI.decode(good_login_uri); + + // locate the access token within the URI + string? access_token = null; + int index = decoded_uri.index_of("#access_token="); + if (index >= 0) + access_token = decoded_uri[index:decoded_uri.length]; + if (access_token == null) { + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "Server redirect URL contained no access token")); + return; + } + + // remove any trailing parameters from the session description string + string? trailing_params = null; + index = access_token.index_of_char('&'); + if (index >= 0) + trailing_params = access_token[index:access_token.length]; + if (trailing_params != null) + access_token = access_token.replace(trailing_params, ""); + + // remove the key from the session description string + access_token = access_token.replace("#access_token=", ""); + + // we've got an access token! + graph_session.authenticated.connect(on_session_authenticated); + graph_session.authenticate(access_token); + } + + private void do_save_session_information() { + debug("ACTION: saving session information to configuration system."); + + set_persistent_access_token(graph_session.get_access_token()); + } + + private void do_upload() { + debug("ACTION: uploading photos to album '%s'", + publishing_params.target_album == PublishingParameters.UNKNOWN_ALBUM ? "(none)" : + publishing_params.get_target_album_name()); + + host.set_service_locked(true); + + progress_reporter = host.serialize_publishables(publishing_params.resolution.get_pixels(), + publishing_params.strip_metadata); + + // Serialization is a long and potentially cancellable operation, so before we use + // the publishables, make sure that the publishing interaction is still running. If it + // isn't the publishing environment may be partially torn down so do a short-circuit + // return + if (!is_running()) + return; + + Spit.Publishing.Publishable[] publishables = host.get_publishables(); + uploader = new Uploader(graph_session, publishing_params, publishables); + + 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_generic_error(Spit.Publishing.PublishingError error) { + if (error is Spit.Publishing.PublishingError.EXPIRED_SESSION) + do_logout(); + else + host.post_error(error); + } + + private void on_login_clicked() { + if (!is_running()) + return; + + debug("EVENT: user clicked 'Login' on welcome pane."); + + do_test_connection_to_endpoint(); + } + + private void on_endpoint_test_completed(GraphMessage message) { + message.completed.disconnect(on_endpoint_test_completed); + message.failed.disconnect(on_endpoint_test_error); + + if (!is_running()) + return; + + debug("EVENT: endpoint test transaction detected that the Facebook endpoint is alive."); + + do_hosted_web_authentication(); + } + + private void on_endpoint_test_error(GraphMessage message, + Spit.Publishing.PublishingError error) { + message.completed.disconnect(on_endpoint_test_completed); + message.failed.disconnect(on_endpoint_test_error); + + if (!is_running()) + return; + + debug("EVENT: endpoint test transaction failed to detect a connection to the Facebook " + + "endpoint"); + + on_generic_error(error); + } + + private void on_web_auth_pane_login_succeeded(string success_url) { + if (!is_running()) + return; + + debug("EVENT: hosted web login succeeded."); + + do_authenticate_session(success_url); + } + + + + private void on_web_auth_pane_login_failed() { + if (!is_running()) + return; + + debug("EVENT: hosted web login failed."); + + // In this case, "failed" doesn't mean that the user didn't enter the right username and + // password -- Facebook handles that case inside the Facebook Connect web control. Instead, + // it means that no session was initiated in response to our login request. The only + // way this happens is if the user clicks the "Cancel" button that appears inside + // the web control. In this case, the correct behavior is to return the user to the + // service welcome pane so that they can start the web interaction again. + do_show_service_welcome_pane(); + } + + private void on_session_authenticated() { + graph_session.authenticated.disconnect(on_session_authenticated); + + if (!is_running()) + return; + + assert(graph_session.is_authenticated()); + debug("EVENT: an authenticated session has become available."); + + do_save_session_information(); + do_fetch_user_info(); + } + + private void on_fetch_user_info_completed(GraphMessage message) { + message.completed.disconnect(on_fetch_user_info_completed); + message.failed.disconnect(on_fetch_user_info_error); + + if (!is_running()) + return; + + debug("EVENT: user info fetch completed; response = '%s'.", message.get_response_body()); + + do_extract_user_info_from_json(message.get_response_body()); + } + + private void on_fetch_user_info_error(GraphMessage message, + Spit.Publishing.PublishingError error) { + message.completed.disconnect(on_fetch_user_info_completed); + message.failed.disconnect(on_fetch_user_info_error); + + if (!is_running()) + return; + + debug("EVENT: fetching user info generated and error."); + + on_generic_error(error); + } + + private void on_user_info_extracted() { + if (!is_running()) + return; + + debug("EVENT: user info extracted from JSON response: uid = %s; name = %s.", uid, username); + + do_fetch_album_descriptions(); + } + + private void on_fetch_albums_completed(GraphMessage message) { + message.completed.disconnect(on_fetch_albums_completed); + message.failed.disconnect(on_fetch_albums_error); + + if (!is_running()) + return; + + debug("EVENT: album descriptions fetch transaction completed; response = '%s'.", + message.get_response_body()); + + do_extract_albums_from_json(message.get_response_body()); + } + + private void on_fetch_albums_error(GraphMessage message, + Spit.Publishing.PublishingError err) { + message.completed.disconnect(on_fetch_albums_completed); + message.failed.disconnect(on_fetch_albums_error); + + if (!is_running()) + return; + + debug("EVENT: album description fetch attempt generated an error."); + + on_generic_error(err); + } + + private void on_albums_extracted() { + if (!is_running()) + return; + + debug("EVENT: successfully extracted %d albums from JSON response", + publishing_params.albums.length); + + do_show_publishing_options_pane(); + } + + private void on_publishing_options_pane_logout() { + publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish); + publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout); + + if (!is_running()) + return; + + debug("EVENT: user clicked 'Logout' in publishing options pane."); + + do_logout(); + } + + private void on_publishing_options_pane_publish(string? target_album, string privacy_setting, + Resolution resolution, bool strip_metadata) { + 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 'Publish' in publishing options pane."); + + publishing_params.strip_metadata = strip_metadata; + set_persistent_strip_metadata(strip_metadata); + publishing_params.resolution = resolution; + set_persistent_default_size(resolution); + publishing_params.privacy_object = privacy_setting; + + if (target_album != null) { + // we are publishing at least one photo so we need the name of an album to which + // we'll upload the photo(s) + publishing_params.set_target_album_by_name(target_album); + if (publishing_params.target_album != PublishingParameters.UNKNOWN_ALBUM) { + do_upload(); + } else { + publishing_params.new_album_name = target_album; + do_create_new_album(); + } + } else { + // we're publishing only videos and we don't need an album name + do_upload(); + } + } + + private void on_create_album_completed(GraphMessage message) { + message.completed.disconnect(on_create_album_completed); + message.failed.disconnect(on_create_album_error); + + assert(publishing_params.new_album_name != null); + + if (!is_running()) + return; + + debug("EVENT: created new album resource on remote host; response body = %s.\n", + message.get_response_body()); + + do_add_new_local_album_from_json(publishing_params.new_album_name, + message.get_response_body()); + } + + private void on_create_album_error(GraphMessage message, Spit.Publishing.PublishingError err) { + message.completed.disconnect(on_create_album_completed); + message.failed.disconnect(on_create_album_error); + + if (!is_running()) + return; + + debug("EVENT: attempt to create new album generated an error."); + + on_generic_error(err); + } + + private void on_upload_status_updated(int file_number, double completed_fraction) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + progress_reporter(file_number, completed_fraction); + } + + private void on_upload_complete(Uploader uploader, int num_published) { + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + if (!is_running()) + return; + + debug("EVENT: uploader reports upload complete; %d items published.", num_published); + + do_show_success_pane(); + } + + private void on_upload_error(Uploader uploader, Spit.Publishing.PublishingError err) { + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + if (!is_running()) + return; + + debug("EVENT: uploader reports upload error = '%s'.", err.message); + + host.post_error(err); + } + + public Spit.Publishing.Service get_service() { + return service; + } + + public string get_service_name() { + return SERVICE_NAME; + } + + public string get_user_visible_name() { + return USER_VISIBLE_NAME; + } + + public void start() { + if (is_running()) + return; + + debug("FacebookPublisher: starting interaction."); + + running = true; + + // reset all publishing parameters to their default values -- in case this start is + // actually a restart + publishing_params = new PublishingParameters(); + + // Do we have saved user credentials? If so, go ahead and authenticate the session + // with the saved credentials and proceed with the publishing interaction. Otherwise, show + // the Welcome pane + if (is_persistent_session_valid()) { + graph_session.authenticate(get_persistent_access_token()); + } else { + if (WebAuthenticationPane.is_cache_dirty()) { + host.set_service_locked(false); + host.install_static_message_pane(RESTART_ERROR_MESSAGE, + Spit.Publishing.PluginHost.ButtonMode.CANCEL); + } else { + do_show_service_welcome_pane(); + } + } + } + + public void stop() { + debug("FacebookPublisher: stop( ) invoked."); + + if (graph_session != null) + graph_session.stop_transactions(); + + host = null; + running = false; + } + + public bool is_running() { + return running; + } +} + +internal class WebAuthenticationPane : Spit.Publishing.DialogPane, Object { + private WebKit.WebView webview = null; + private Gtk.Box pane_widget = null; + private Gtk.ScrolledWindow webview_frame = null; + private static bool cache_dirty = false; + + public signal void login_succeeded(string success_url); + public signal void login_failed(); + + public WebAuthenticationPane() { + 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_frame.add(webview); + pane_widget.pack_start(webview_frame, true, true, 0); + } + + private class LocaleLookup { + public string prefix; + public string translation; + public string? exception_code; + public string? exception_translation; + public string? exception_code_2; + public string? exception_translation_2; + + public LocaleLookup(string prefix, string translation, string? exception_code = null, + string? exception_translation = null, string? exception_code_2 = null, + string? exception_translation_2 = null) { + this.prefix = prefix; + this.translation = translation; + this.exception_code = exception_code; + this.exception_translation = exception_translation; + this.exception_code_2 = exception_code_2; + this.exception_translation_2 = exception_translation_2; + } + + } + + private LocaleLookup[] locale_lookup_table = { + new LocaleLookup( "es", "es-la", "ES", "es-es" ), + new LocaleLookup( "en", "en-gb", "US", "en-us" ), + new LocaleLookup( "fr", "fr-fr", "CA", "fr-ca" ), + new LocaleLookup( "pt", "pt-br", "PT", "pt-pt" ), + new LocaleLookup( "zh", "zh-cn", "HK", "zh-hk", "TW", "zh-tw" ), + new LocaleLookup( "af", "af-za" ), + new LocaleLookup( "ar", "ar-ar" ), + new LocaleLookup( "nb", "nb-no" ), + new LocaleLookup( "no", "nb-no" ), + new LocaleLookup( "id", "id-id" ), + new LocaleLookup( "ms", "ms-my" ), + new LocaleLookup( "ca", "ca-es" ), + new LocaleLookup( "cs", "cs-cz" ), + new LocaleLookup( "cy", "cy-gb" ), + new LocaleLookup( "da", "da-dk" ), + new LocaleLookup( "de", "de-de" ), + new LocaleLookup( "tl", "tl-ph" ), + new LocaleLookup( "ko", "ko-kr" ), + new LocaleLookup( "hr", "hr-hr" ), + new LocaleLookup( "it", "it-it" ), + new LocaleLookup( "lt", "lt-lt" ), + new LocaleLookup( "hu", "hu-hu" ), + new LocaleLookup( "nl", "nl-nl" ), + new LocaleLookup( "ja", "ja-jp" ), + new LocaleLookup( "nb", "nb-no" ), + new LocaleLookup( "no", "nb-no" ), + new LocaleLookup( "pl", "pl-pl" ), + new LocaleLookup( "ro", "ro-ro" ), + new LocaleLookup( "ru", "ru-ru" ), + new LocaleLookup( "sk", "sk-sk" ), + new LocaleLookup( "sl", "sl-si" ), + new LocaleLookup( "sv", "sv-se" ), + new LocaleLookup( "th", "th-th" ), + new LocaleLookup( "vi", "vi-vn" ), + new LocaleLookup( "tr", "tr-tr" ), + new LocaleLookup( "el", "el-gr" ), + new LocaleLookup( "bg", "bg-bg" ), + new LocaleLookup( "sr", "sr-rs" ), + new LocaleLookup( "he", "he-il" ), + new LocaleLookup( "hi", "hi-in" ), + new LocaleLookup( "bn", "bn-in" ), + new LocaleLookup( "pa", "pa-in" ), + new LocaleLookup( "ta", "ta-in" ), + new LocaleLookup( "te", "te-in" ), + new LocaleLookup( "ml", "ml-in" ) + }; + + private string get_system_locale_as_facebook_locale() { + unowned string? raw_system_locale = Intl.setlocale(LocaleCategory.ALL, ""); + if (raw_system_locale == null || raw_system_locale == "") + return "www"; + + string system_locale = raw_system_locale.split(".")[0]; + + foreach (LocaleLookup locale_lookup in locale_lookup_table) { + if (!system_locale.has_prefix(locale_lookup.prefix)) + continue; + + if (locale_lookup.exception_code != null) { + assert(locale_lookup.exception_translation != null); + + if (system_locale.contains(locale_lookup.exception_code)) + return locale_lookup.exception_translation; + } + + if (locale_lookup.exception_code_2 != null) { + assert(locale_lookup.exception_translation_2 != null); + + if (system_locale.contains(locale_lookup.exception_code_2)) + return locale_lookup.exception_translation_2; + } + + return locale_lookup.translation; + } + + // default + return "www"; + } + + private string get_login_url() { + string facebook_locale = get_system_locale_as_facebook_locale(); + + return "https://%s.facebook.com/dialog/oauth?client_id=%s&redirect_uri=https://www.facebook.com/connect/login_success.html&scope=publish_actions,user_photos,user_videos&response_type=token".printf(facebook_locale, APPLICATION_ID); + } + + private void on_page_load(WebKit.WebFrame origin_frame) { + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.LEFT_PTR)); + + string loaded_url = origin_frame.get_uri().dup(); + + // strip parameters from the loaded url + if (loaded_url.contains("?")) { + int index = loaded_url.index_of_char('?'); + string params = loaded_url[index:loaded_url.length]; + loaded_url = loaded_url.replace(params, ""); + } + + // were we redirected to the facebook login success page? + if (loaded_url.contains("login_success")) { + cache_dirty = true; + login_succeeded(origin_frame.get_uri()); + return; + } + + // were we redirected to the login total failure page? + if (loaded_url.contains("login_failure")) { + login_failed(); + return; + } + } + + private void on_load_started(WebKit.WebFrame frame) { + pane_widget.get_window().set_cursor(new Gdk.Cursor(Gdk.CursorType.WATCH)); + } + + public static bool is_cache_dirty() { + return cache_dirty; + } + + 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() { + webview.open(get_login_url()); + } + + public void on_pane_uninstalled() { + } +} + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + private Gtk.Builder builder; + private Gtk.Box pane_widget = null; + private Gtk.RadioButton use_existing_radio = null; + private Gtk.RadioButton create_new_radio = null; + private Gtk.ComboBoxText existing_albums_combo = null; + private Gtk.ComboBoxText visibility_combo = null; + private Gtk.Entry new_album_entry = null; + private Gtk.CheckButton strip_metadata_check = null; + private Gtk.Button publish_button = null; + private Gtk.Button logout_button = null; + private Gtk.Label how_to_label = null; + private Album[] albums = null; + private FacebookPublisher publisher = null; + private PrivacyDescription[] privacy_descriptions; + + private Resolution[] possible_resolutions; + private Gtk.ComboBoxText resolution_combo = null; + + private Spit.Publishing.Publisher.MediaType media_type; + + private const string HEADER_LABEL_TEXT = _("You are logged into Facebook as %s.\n\n"); + private const string PHOTOS_LABEL_TEXT = _("Where would you like to publish the selected photos?"); + private const string RESOLUTION_LABEL_TEXT = _("Upload _size:"); + private const int CONTENT_GROUP_SPACING = 32; + private const int STANDARD_ACTION_BUTTON_WIDTH = 128; + + public signal void logout(); + public signal void publish(string? target_album, string privacy_setting, + Resolution target_resolution, bool strip_metadata); + + private class PrivacyDescription { + public string description; + public string privacy_setting; + + public PrivacyDescription(string description, string privacy_setting) { + this.description = description; + this.privacy_setting = privacy_setting; + } + } + + public PublishingOptionsPane(string username, Album[] albums, + Spit.Publishing.Publisher.MediaType media_type, FacebookPublisher publisher, + Gtk.Builder builder, bool strip_metadata) { + + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + this.albums = albums; + this.privacy_descriptions = create_privacy_descriptions(); + + this.possible_resolutions = create_resolution_list(); + this.publisher = publisher; + + // we'll need to know if the user is importing video or not when sorting out visibility. + this.media_type = media_type; + + pane_widget = (Gtk.Box) builder.get_object("facebook_pane_box"); + pane_widget.set_border_width(16); + + use_existing_radio = (Gtk.RadioButton) this.builder.get_object("use_existing_radio"); + create_new_radio = (Gtk.RadioButton) this.builder.get_object("create_new_radio"); + existing_albums_combo = (Gtk.ComboBoxText) this.builder.get_object("existing_albums_combo"); + visibility_combo = (Gtk.ComboBoxText) this.builder.get_object("visibility_combo"); + publish_button = (Gtk.Button) this.builder.get_object("publish_button"); + logout_button = (Gtk.Button) this.builder.get_object("logout_button"); + new_album_entry = (Gtk.Entry) this.builder.get_object("new_album_entry"); + resolution_combo = (Gtk.ComboBoxText) this.builder.get_object("resolution_combo"); + how_to_label = (Gtk.Label) this.builder.get_object("how_to_label"); + strip_metadata_check = (Gtk.CheckButton) this.builder.get_object("strip_metadata_check"); + + create_new_radio.clicked.connect(on_create_new_toggled); + use_existing_radio.clicked.connect(on_use_existing_toggled); + + string label_text = HEADER_LABEL_TEXT.printf(username); + if ((media_type & Spit.Publishing.Publisher.MediaType.PHOTO) != 0) + label_text += PHOTOS_LABEL_TEXT; + how_to_label.set_label(label_text); + strip_metadata_check.set_active(strip_metadata); + + setup_visibility_combo(); + visibility_combo.set_active(0); + + publish_button.clicked.connect(on_publish_button_clicked); + logout_button.clicked.connect(on_logout_button_clicked); + + setup_resolution_combo(); + resolution_combo.set_active(publisher.get_persistent_default_size()); + resolution_combo.changed.connect(on_size_changed); + + // Ticket #3175, part 2: make sure this widget starts out sensitive + // if it needs to by checking whether we're starting with a video + // or a new gallery. + visibility_combo.set_sensitive( + (create_new_radio != null && create_new_radio.active) || + ((media_type & Spit.Publishing.Publisher.MediaType.VIDEO) != 0)); + + // if publishing only videos, disable all photo-specific controls + if (media_type == Spit.Publishing.Publisher.MediaType.VIDEO) { + strip_metadata_check.set_active(false); + strip_metadata_check.set_sensitive(false); + resolution_combo.set_sensitive(false); + use_existing_radio.set_sensitive(false); + create_new_radio.set_sensitive(false); + existing_albums_combo.set_sensitive(false); + new_album_entry.set_sensitive(false); + } + } + + private bool publishing_photos() { + return (media_type & Spit.Publishing.Publisher.MediaType.PHOTO) != 0; + } + + private void setup_visibility_combo() { + foreach (PrivacyDescription p in privacy_descriptions) + visibility_combo.append_text(p.description); + } + + private void setup_resolution_combo() { + foreach (Resolution res in possible_resolutions) + resolution_combo.append_text(res.get_name()); + } + + private void on_use_existing_toggled() { + if (use_existing_radio.active) { + existing_albums_combo.set_sensitive(true); + new_album_entry.set_sensitive(false); + + // Ticket #3175 - if we're not adding a new gallery + // or a video, then we shouldn't be allowed tof + // choose visibility, since it has no effect. + visibility_combo.set_sensitive((media_type & Spit.Publishing.Publisher.MediaType.VIDEO) != 0); + + existing_albums_combo.grab_focus(); + } + } + + private void on_create_new_toggled() { + if (create_new_radio.active) { + existing_albums_combo.set_sensitive(false); + new_album_entry.set_sensitive(true); + new_album_entry.grab_focus(); + + // Ticket #3175 - if we're creating a new gallery, make sure this is + // active, since it may have possibly been set inactive. + visibility_combo.set_sensitive(true); + } + } + + private void on_size_changed() { + publisher.set_persistent_default_size(resolution_combo.get_active()); + } + + private void on_logout_button_clicked() { + logout(); + } + + private void on_publish_button_clicked() { + string album_name; + string privacy_setting = privacy_descriptions[visibility_combo.get_active()].privacy_setting; + + Resolution resolution_setting; + + if (publishing_photos()) { + resolution_setting = possible_resolutions[resolution_combo.get_active()]; + if (use_existing_radio.active) { + album_name = existing_albums_combo.get_active_text(); + } else { + album_name = new_album_entry.get_text(); + } + } else { + resolution_setting = Resolution.STANDARD; + album_name = null; + } + + publish(album_name, privacy_setting, resolution_setting, strip_metadata_check.get_active()); + } + + private PrivacyDescription[] create_privacy_descriptions() { + PrivacyDescription[] result = new PrivacyDescription[0]; + + result += new PrivacyDescription(_("Just me"), "{ 'value' : 'SELF' }"); + result += new PrivacyDescription(_("Friends"), "{ 'value' : 'ALL_FRIENDS' }"); + result += new PrivacyDescription(_("Everyone"), "{ 'value' : 'EVERYONE' }"); + + return result; + } + + private Resolution[] create_resolution_list() { + Resolution[] result = new Resolution[0]; + + result += Resolution.STANDARD; + result += Resolution.HIGH; + + return result; + } + + public void installed() { + if (publishing_photos()) { + if (albums.length == 0) { + create_new_radio.set_active(true); + new_album_entry.set_text(DEFAULT_ALBUM_NAME); + existing_albums_combo.set_sensitive(false); + use_existing_radio.set_sensitive(false); + } else { + int default_album_seq_num = -1; + int ticker = 0; + foreach (Album album in albums) { + existing_albums_combo.append_text(album.name); + if (album.name == DEFAULT_ALBUM_NAME) + default_album_seq_num = ticker; + ticker++; + } + if (default_album_seq_num != -1) { + existing_albums_combo.set_active(default_album_seq_num); + use_existing_radio.set_active(true); + new_album_entry.set_sensitive(false); + } + else { + create_new_radio.set_active(true); + existing_albums_combo.set_active(0); + existing_albums_combo.set_sensitive(false); + new_album_entry.set_text(DEFAULT_ALBUM_NAME); + } + } + } + + publish_button.grab_focus(); + } + + private void notify_logout() { + logout(); + } + + private void notify_publish(string? target_album, string privacy_setting, Resolution target_resolution) { + publish(target_album, privacy_setting, target_resolution, strip_metadata_check.get_active()); + } + + 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() { + logout.connect(notify_logout); + publish.connect(notify_publish); + + installed(); + } + + public void on_pane_uninstalled() { + logout.disconnect(notify_logout); + publish.disconnect(notify_publish); + } +} + +internal enum Endpoint { + DEFAULT, + VIDEO, + TEST_CONNECTION; + + public string to_uri() { + switch (this) { + case DEFAULT: + return "https://graph.facebook.com/"; + + case VIDEO: + return "https://graph-video.facebook.com/"; + + case TEST_CONNECTION: + return "https://www.facebook.com/"; + + default: + assert_not_reached(); + } + } +} + +internal abstract class GraphMessage { + public signal void completed(); + public signal void failed(Spit.Publishing.PublishingError err); + public signal void data_transmitted(int bytes_sent_so_far, int total_bytes); + + public abstract string get_uri(); + public abstract string get_response_body(); +} + +internal class GraphSession { + private abstract class GraphMessageImpl : GraphMessage { + public Publishing.RESTSupport.HttpMethod method; + public string uri; + public string access_token; + public Soup.Message soup_message; + public weak GraphSession host_session; + public int bytes_so_far; + + public GraphMessageImpl(GraphSession host_session, Publishing.RESTSupport.HttpMethod method, + string relative_uri, string access_token, Endpoint endpoint = Endpoint.DEFAULT) { + this.method = method; + this.access_token = access_token; + this.host_session = host_session; + this.bytes_so_far = 0; + + string endpoint_uri = endpoint.to_uri(); + try { + Regex starting_slashes = new Regex("^/+"); + this.uri = endpoint_uri + starting_slashes.replace(relative_uri, -1, 0, ""); + } catch (RegexError err) { + assert_not_reached(); + } + } + + public virtual bool prepare_for_transmission() { + return true; + } + + public override string get_uri() { + return uri; + } + + public override string get_response_body() { + return (string) soup_message.response_body.data; + } + + public void on_wrote_body_data(Soup.Buffer chunk) { + bytes_so_far += (int) chunk.length; + + data_transmitted(bytes_so_far, (int) soup_message.request_body.length); + } + } + + private class GraphQueryMessage : GraphMessageImpl { + public GraphQueryMessage(GraphSession host_session, string relative_uri, + string access_token) { + base(host_session, Publishing.RESTSupport.HttpMethod.GET, relative_uri, access_token); + + Soup.URI destination_uri = new Soup.URI(uri + "?access_token=" + access_token); + soup_message = new Soup.Message.from_uri(method.to_string(), destination_uri); + soup_message.wrote_body_data.connect(on_wrote_body_data); + } + } + + private class GraphEndpointProbeMessage : GraphMessageImpl { + public GraphEndpointProbeMessage(GraphSession host_session) { + base(host_session, Publishing.RESTSupport.HttpMethod.GET, "/", "", + Endpoint.TEST_CONNECTION); + + soup_message = new Soup.Message.from_uri(method.to_string(), new Soup.URI(uri)); + soup_message.wrote_body_data.connect(on_wrote_body_data); + } + } + + private class GraphUploadMessage : GraphMessageImpl { + private MappedFile mapped_file = null; + private Spit.Publishing.Publishable publishable; + + public GraphUploadMessage(GraphSession host_session, string access_token, + string relative_uri, Spit.Publishing.Publishable publishable, + bool suppress_titling, string? resource_privacy = null) { + base(host_session, Publishing.RESTSupport.HttpMethod.POST, relative_uri, access_token, + (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) ? + Endpoint.VIDEO : Endpoint.DEFAULT); + + // Video uploads require a privacy string at the per-resource level. Since they aren't + // placed in albums, they can't inherit their privacy settings from their containing + // album like photos do + assert(publishable.get_media_type() != Spit.Publishing.Publisher.MediaType.VIDEO || + resource_privacy != null); + + this.publishable = publishable; + + // attempt to map the binary payload from disk into memory + try { + this.mapped_file = new MappedFile(publishable.get_serialized_file().get_path(), + false); + } catch (FileError e) { + return; + } + + this.soup_message = new Soup.Message.from_uri(method.to_string(), new Soup.URI(uri)); + soup_message.wrote_body_data.connect(on_wrote_body_data); + + unowned uint8[] payload = (uint8[]) mapped_file.get_contents(); + payload.length = (int) mapped_file.get_length(); + + Soup.Buffer image_data = new Soup.Buffer(Soup.MemoryUse.TEMPORARY, payload); + + Soup.Multipart mp_envelope = new Soup.Multipart("multipart/form-data"); + + mp_envelope.append_form_string("access_token", access_token); + + if (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) + mp_envelope.append_form_string("privacy", resource_privacy); + + string publishable_title = publishable.get_publishing_name(); + if (!suppress_titling && publishable_title != "") + mp_envelope.append_form_string("name", publishable_title); + + string source_file_mime_type = + (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) ? + "video" : "image/jpeg"; + mp_envelope.append_form_file("source", publishable.get_serialized_file().get_basename(), + source_file_mime_type, image_data); + + mp_envelope.to_message(soup_message.request_headers, soup_message.request_body); + } + + public override bool prepare_for_transmission() { + if (mapped_file == null) { + failed(new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + "File %s is unavailable.".printf(publishable.get_serialized_file().get_path()))); + return false; + } else { + return true; + } + } + } + + private class GraphCreateAlbumMessage : GraphMessageImpl { + public GraphCreateAlbumMessage(GraphSession host_session, string access_token, + string album_name, string album_privacy) { + base(host_session, Publishing.RESTSupport.HttpMethod.POST, "/me/albums", access_token); + + assert(album_privacy != null && album_privacy != ""); + + this.soup_message = new Soup.Message.from_uri(method.to_string(), new Soup.URI(uri)); + + Soup.Multipart mp_envelope = new Soup.Multipart("multipart/form-data"); + + mp_envelope.append_form_string("access_token", access_token); + mp_envelope.append_form_string("name", album_name); + mp_envelope.append_form_string("privacy", album_privacy); + + mp_envelope.to_message(soup_message.request_headers, soup_message.request_body); + } + } + + public signal void authenticated(); + + private Soup.Session soup_session; + private string? access_token; + private GraphMessage? current_message; + + public GraphSession() { + this.soup_session = new Soup.SessionAsync(); + this.soup_session.request_unqueued.connect(on_request_unqueued); + this.soup_session.timeout = 15; + this.access_token = null; + this.current_message = null; + } + + ~GraphSession() { + soup_session.request_unqueued.disconnect(on_request_unqueued); + } + + private void manage_message(GraphMessage msg) { + assert(current_message == null); + + current_message = msg; + } + + private void unmanage_message(GraphMessage msg) { + assert(current_message != null); + + current_message = null; + } + + private void on_request_unqueued(Soup.Message msg) { + assert(current_message != null); + GraphMessageImpl real_message = (GraphMessageImpl) current_message; + assert(real_message.soup_message == msg); + + // these error types are always recoverable given the unique behavior of the Facebook + // endpoint, so try again + if (msg.status_code == Soup.KnownStatusCode.IO_ERROR || + msg.status_code == Soup.KnownStatusCode.MALFORMED || + msg.status_code == Soup.KnownStatusCode.TRY_AGAIN) { + real_message.bytes_so_far = 0; + soup_session.queue_message(msg, null); + return; + } + + unmanage_message(real_message); + msg.wrote_body_data.disconnect(real_message.on_wrote_body_data); + + Spit.Publishing.PublishingError? error = null; + switch (msg.status_code) { + case Soup.KnownStatusCode.OK: + case Soup.KnownStatusCode.CREATED: // HTTP code 201 (CREATED) signals that a new + // resource was created in response to a PUT + // or POST + break; + + case EXPIRED_SESSION_STATUS_CODE: + error = new Spit.Publishing.PublishingError.EXPIRED_SESSION( + "OAuth Access Token has Expired. Logout user."); + break; + + case Soup.KnownStatusCode.CANT_RESOLVE: + case Soup.KnownStatusCode.CANT_RESOLVE_PROXY: + error = new Spit.Publishing.PublishingError.NO_ANSWER( + "Unable to resolve %s (error code %u)", real_message.get_uri(), msg.status_code); + break; + + case Soup.KnownStatusCode.CANT_CONNECT: + case Soup.KnownStatusCode.CANT_CONNECT_PROXY: + error = new Spit.Publishing.PublishingError.NO_ANSWER( + "Unable to connect to %s (error code %u)", real_message.get_uri(), msg.status_code); + break; + + default: + // status codes below 100 are used by Soup, 100 and above are defined HTTP + // codes + if (msg.status_code >= 100) { + error = new Spit.Publishing.PublishingError.NO_ANSWER( + "Service %s returned HTTP status code %u %s", real_message.get_uri(), + msg.status_code, msg.reason_phrase); + } else { + error = new Spit.Publishing.PublishingError.NO_ANSWER( + "Failure communicating with %s (error code %u)", real_message.get_uri(), + msg.status_code); + } + break; + } + + // All valid communication with Facebook involves body data in the response + if (error == null) + if (msg.response_body.data == null || msg.response_body.data.length == 0) + error = new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "No response data from %s", real_message.get_uri()); + + if (error == null) + real_message.completed(); + else + real_message.failed(error); + } + + public void authenticate(string access_token) { + this.access_token = access_token; + authenticated(); + } + + public bool is_authenticated() { + return access_token != null; + } + + public string get_access_token() { + assert(is_authenticated()); + return access_token; + } + + public GraphMessage new_endpoint_test() { + return new GraphEndpointProbeMessage(this); + } + + public GraphMessage new_query(string resource_path) { + return new GraphQueryMessage(this, resource_path, access_token); + } + + public GraphMessage new_upload(string resource_path, Spit.Publishing.Publishable publishable, + bool suppress_titling, string? resource_privacy = null) { + return new GraphUploadMessage(this, access_token, resource_path, publishable, + suppress_titling, resource_privacy); + } + + public GraphMessage new_create_album(string album_name, string privacy) { + return new GraphSession.GraphCreateAlbumMessage(this, access_token, album_name, privacy); + } + + public void send_message(GraphMessage message) { + GraphMessageImpl real_message = (GraphMessageImpl) message; + + debug("making HTTP request to URI: " + real_message.soup_message.uri.to_string(false)); + + if (real_message.prepare_for_transmission()) { + manage_message(message); + soup_session.queue_message(real_message.soup_message, null); + } + } + + public void stop_transactions() { + soup_session.abort(); + } +} + +internal class Uploader { + private int current_file; + private Spit.Publishing.Publishable[] publishables; + private GraphSession session; + private PublishingParameters publishing_params; + private unowned Spit.Publishing.ProgressCallback? status_updated = null; + + public signal void upload_complete(int num_photos_published); + public signal void upload_error(Spit.Publishing.PublishingError err); + + public Uploader(GraphSession session, PublishingParameters publishing_params, + Spit.Publishing.Publishable[] publishables) { + this.current_file = 0; + this.publishables = publishables; + this.session = session; + this.publishing_params = publishing_params; + } + + private void send_current_file() { + Spit.Publishing.Publishable publishable = publishables[current_file]; + GLib.File? file = publishable.get_serialized_file(); + + // if the current publishable hasn't been serialized, then skip it + if (file == null) { + current_file++; + return; + } + + string resource_uri = + (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.PHOTO) ? + "/%s/photos".printf(publishing_params.get_target_album_id()) : "/me/videos"; + string? resource_privacy = + (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) ? + publishing_params.privacy_object : null; + GraphMessage upload_message = session.new_upload(resource_uri, publishable, + publishing_params.strip_metadata, resource_privacy); + + upload_message.data_transmitted.connect(on_chunk_transmitted); + upload_message.completed.connect(on_message_completed); + upload_message.failed.connect(on_message_failed); + + session.send_message(upload_message); + } + + private void send_files() { + current_file = 0; + send_current_file(); + } + + private void on_chunk_transmitted(int bytes_written_so_far, int total_bytes) { + double file_span = 1.0 / publishables.length; + double this_file_fraction_complete = ((double) bytes_written_so_far) / total_bytes; + double fraction_complete = (current_file * file_span) + (this_file_fraction_complete * + file_span); + + if (status_updated != null) + status_updated(current_file + 1, fraction_complete); + } + + private void on_message_completed(GraphMessage message) { + message.data_transmitted.disconnect(on_chunk_transmitted); + message.completed.disconnect(on_message_completed); + message.failed.disconnect(on_message_failed); + + current_file++; + if (current_file < publishables.length) { + send_current_file(); + } else { + upload_complete(current_file); + } + } + + private void on_message_failed(GraphMessage message, Spit.Publishing.PublishingError error) { + message.data_transmitted.disconnect(on_chunk_transmitted); + message.completed.disconnect(on_message_completed); + message.failed.disconnect(on_message_failed); + + upload_error(error); + } + + public void upload(Spit.Publishing.ProgressCallback? status_updated = null) { + this.status_updated = status_updated; + + if (publishables.length > 0) + send_files(); + } +} + +} + diff --git a/plugins/shotwell-publishing/FlickrPublishing.vala b/plugins/shotwell-publishing/FlickrPublishing.vala new file mode 100644 index 0000000..dcf7971 --- /dev/null +++ b/plugins/shotwell-publishing/FlickrPublishing.vala @@ -0,0 +1,1371 @@ +/* Copyright 2009-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 string hmac_sha1(string key, string message); + +public class FlickrService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "flickr.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public FlickrService(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.flickr"; + } + + public unowned string get_pluggable_name() { + return "Flickr"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Lucas Beeler"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + 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.Flickr.FlickrPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO); + } +} + +namespace Publishing.Flickr { + +internal const string SERVICE_NAME = "Flickr"; +internal const string SERVICE_WELCOME_MESSAGE = + _("You are not currently logged into Flickr.\n\nClick Login to log into Flickr in your Web browser. You will have to authorize Shotwell Connect to link to your Flickr account."); +internal const string RESTART_ERROR_MESSAGE = + _("You have already logged in and out of Flickr during this Shotwell session.\nTo continue publishing to Flickr, quit and restart Shotwell, then try publishing again."); +internal const string ENDPOINT_URL = "https://api.flickr.com/services/rest"; +internal const string API_KEY = "60dd96d4a2ad04888b09c9e18d82c26f"; +internal const string API_SECRET = "d0960565e03547c1"; +internal const int ORIGINAL_SIZE = -1; +internal const string EXPIRED_SESSION_ERROR_CODE = "98"; +internal const string ENCODE_RFC_3986_EXTRA = "!*'();:@&=+$,/?%#[] \\"; + +internal enum UserKind { + PRO, + FREE, +} + +internal class VisibilitySpecification { + public int friends_level; + public int family_level; + public int everyone_level; + + public VisibilitySpecification(int friends_level, int family_level, int everyone_level) { + this.friends_level = friends_level; + this.family_level = family_level; + this.everyone_level = everyone_level; + } +} + +// not a struct because we want reference semantics +internal class PublishingParameters { + public UserKind user_kind; + public int quota_free_mb; + public int photo_major_axis_size; + public string username; + public VisibilitySpecification visibility_specification; + + public PublishingParameters() { + } +} + +public class FlickrPublisher : 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 PublishingParameters parameters = null; + + public FlickrPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + debug("FlickrPublisher instantiated."); + this.service = service; + this.host = host; + this.session = new Session(); + this.parameters = new PublishingParameters(); + + session.authenticated.connect(on_session_authenticated); + } + + ~FlickrPublisher() { + session.authenticated.disconnect(on_session_authenticated); + } + + private void invalidate_persistent_session() { + set_persistent_access_phase_token(""); + set_persistent_access_phase_token_secret(""); + set_persistent_access_phase_username(""); + } + + private bool is_persistent_session_valid() { + return (get_persistent_access_phase_username() != null && + get_persistent_access_phase_token() != null && + get_persistent_access_phase_token_secret() != null); + } + + private string? get_persistent_access_phase_username() { + return host.get_config_string("access_phase_username", null); + } + + private void set_persistent_access_phase_username(string username) { + host.set_config_string("access_phase_username", username); + } + + private string? get_persistent_access_phase_token() { + return host.get_config_string("access_phase_token", null); + } + + private void set_persistent_access_phase_token(string token) { + host.set_config_string("access_phase_token", token); + } + + private string? get_persistent_access_phase_token_secret() { + return host.get_config_string("access_phase_token_secret", null); + } + + private void set_persistent_access_phase_token_secret(string secret) { + host.set_config_string("access_phase_token_secret", secret); + } + + private bool get_persistent_strip_metadata() { + return host.get_config_bool("strip_metadata", false); + } + + private void set_persistent_strip_metadata(bool strip_metadata) { + host.set_config_bool("strip_metadata", strip_metadata); + } + + private void on_welcome_pane_login_clicked() { + if (!running) + return; + + debug("EVENT: user clicked 'Login' button in the welcome pane"); + + do_run_authentication_request_transaction(); + } + + 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 on_authentication_token_available(string token, string token_secret) { + debug("EVENT: OAuth authentication token (%s) and token secret (%s) available", + token, token_secret); + + session.set_request_phase_credentials(token, token_secret); + + do_launch_system_browser(token); + } + + private void on_system_browser_launched() { + if (!is_running()) + return; + + debug("EVENT: system browser launched."); + + do_show_pin_entry_pane(); + } + + private void on_pin_entry_proceed(PinEntryPane sender, string pin) { + sender.proceed.disconnect(on_pin_entry_proceed); + + if (!is_running()) + return; + + debug("EVENT: user clicked 'Continue' in PIN entry pane."); + + do_verify_pin(pin); + } + + private void on_access_token_fetch_txn_completed(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_access_token_fetch_txn_completed); + txn.network_error.disconnect(on_access_token_fetch_error); + + if (!is_running()) + return; + + debug("EVENT: fetching OAuth access token over the network succeeded"); + + do_extract_access_phase_credentials_from_reponse(txn.get_response()); + } + + private void on_access_token_fetch_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_access_token_fetch_txn_completed); + txn.network_error.disconnect(on_access_token_fetch_error); + + if (!is_running()) + return; + + debug("EVENT: fetching OAuth access token over the network caused an error."); + + host.post_error(err); + } + + private void on_session_authenticated() { + if (!is_running()) + return; + + debug("EVENT: a fully authenticated session has become available"); + + parameters.username = session.get_username(); + + set_persistent_access_phase_token(session.get_access_phase_token()); + set_persistent_access_phase_token_secret(session.get_access_phase_token_secret()); + set_persistent_access_phase_username(session.get_username()); + + do_fetch_account_info(); + } + + private void on_account_fetch_txn_completed(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_account_fetch_txn_completed); + txn.network_error.disconnect(on_account_fetch_txn_error); + + if (!is_running()) + return; + + debug("EVENT: account fetch transaction response received over the network"); + do_parse_account_info_from_xml(txn.get_response()); + } + + private void on_account_fetch_txn_error(Publishing.RESTSupport.Transaction txn, + Spit.Publishing.PublishingError err) { + txn.completed.disconnect(on_account_fetch_txn_completed); + txn.network_error.disconnect(on_account_fetch_txn_error); + + if (!is_running()) + return; + + debug("EVENT: account fetch transaction caused a network error"); + host.post_error(err); + } + + private void on_account_info_available() { + if (!is_running()) + return; + + debug("EVENT: account information has become available"); + do_show_publishing_options_pane(); + } + + private void on_publishing_options_pane_publish(bool strip_metadata) { + 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(strip_metadata); + } + + private void on_publishing_options_pane_logout() { + publishing_options_pane.publish.disconnect(on_publishing_options_pane_publish); + publishing_options_pane.logout.disconnect(on_publishing_options_pane_logout); + + if (!is_running()) + return; + + debug("EVENT: user clicked the 'Logout' button in the publishing options pane"); + + do_logout(); + } + + 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_show_login_welcome_pane() { + debug("ACTION: installing login welcome pane"); + + host.set_service_locked(false); + host.install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_welcome_pane_login_clicked); + } + + private void do_run_authentication_request_transaction() { + debug("ACTION: running authentication request transaction"); + + host.set_service_locked(true); + host.install_static_message_pane(_("Preparing for login...")); + + AuthenticationRequestTransaction txn = new AuthenticationRequestTransaction(session); + 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 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", response)); + + 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", response)); + + + on_authentication_token_available(oauth_token, oauth_token_secret); + } + + private void do_launch_system_browser(string token) { + string login_uri = "https://www.flickr.com/services/oauth/authorize?oauth_token=" + token + + "&perms=write"; + + debug("ACTION: launching system browser with uri = '%s'", login_uri); + + try { + Process.spawn_command_line_async("xdg-open " + login_uri); + } catch (SpawnError e) { + host.post_error(new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + "couldn't launch system web browser to complete Flickr login")); + return; + } + + on_system_browser_launched(); + } + + private void do_show_pin_entry_pane() { + debug("ACTION: showing PIN entry pane"); + + Gtk.Builder builder = new Gtk.Builder(); + + try { + builder.add_from_file(host.get_module_file().get_parent().get_child("flickr_pin_entry_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + host.post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Flickr can't continue."))); + return; + } + + PinEntryPane pin_entry_pane = new PinEntryPane(builder); + pin_entry_pane.proceed.connect(on_pin_entry_proceed); + host.install_dialog_pane(pin_entry_pane); + } + + private void do_verify_pin(string pin) { + debug("ACTION: validating authorization PIN %s", pin); + + host.set_service_locked(true); + host.install_static_message_pane(_("Verifying authorization...")); + + AccessTokenFetchTransaction txn = new AccessTokenFetchTransaction(session, pin); + txn.completed.connect(on_access_token_fetch_txn_completed); + txn.network_error.connect(on_access_token_fetch_error); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void do_extract_access_phase_credentials_from_reponse(string response) { + debug("ACTION: extracting access phase credentials from '%s'", response); + + string[] key_value_pairs = response.split("&"); + + string? token = null; + string? token_secret = null; + string? username = null; + foreach (string key_value_pair in key_value_pairs) { + string[] split_pair = key_value_pair.split("="); + + if (split_pair.length != 2) + continue; + + string key = split_pair[0]; + string value = split_pair[1]; + + if (key == "oauth_token") + token = value; + else if (key == "oauth_token_secret") + token_secret = value; + else if (key == "username") + username = value; + } + + debug("access phase credentials: { token = '%s'; token_secret = '%s'; username = '%s' }", + token, token_secret, username); + + if (token == null || token_secret == null || username == null) + host.post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("expected " + + "access phase credentials to contain token, token secret, and username but at " + + "least one of these is absent")); + + session.set_access_phase_credentials(token, token_secret, username); + } + + private void do_fetch_account_info() { + debug("ACTION: running network transaction to fetch account information"); + + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + AccountInfoFetchTransaction txn = new AccountInfoFetchTransaction(session); + txn.completed.connect(on_account_fetch_txn_completed); + txn.network_error.connect(on_account_fetch_txn_error); + + try { + txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + host.post_error(err); + } + } + + private void do_parse_account_info_from_xml(string xml) { + debug("ACTION: parsing account information from xml = '%s'", xml); + try { + Publishing.RESTSupport.XmlDocument response_doc = Transaction.parse_flickr_response(xml); + Xml.Node* root_node = response_doc.get_root_node(); + + Xml.Node* user_node = response_doc.get_named_child(root_node, "user"); + + string is_pro_str = response_doc.get_property_value(user_node, "ispro"); + + Xml.Node* bandwidth_node = response_doc.get_named_child(user_node, "bandwidth"); + + string remaining_kb_str = response_doc.get_property_value(bandwidth_node, "remainingkb"); + + UserKind user_kind; + if (is_pro_str == "0") + user_kind = UserKind.FREE; + else if (is_pro_str == "1") + user_kind = UserKind.PRO; + else + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "Unable to determine if user has free or pro account"); + + int quota_mb_left = int.parse(remaining_kb_str) / 1024; + + parameters.quota_free_mb = quota_mb_left; + parameters.user_kind = user_kind; + + } catch (Spit.Publishing.PublishingError err) { + // expired session errors are recoverable, so handle it and then short-circuit return. + // don't call post_error( ) on the plug-in host because that's intended for + // unrecoverable errors and will halt publishing + if (err is Spit.Publishing.PublishingError.EXPIRED_SESSION) { + do_logout(); + return; + } + + host.post_error(err); + return; + } + + on_account_info_available(); + } + + private void do_logout() { + debug("ACTION: logging user out, deauthenticating session, and erasing stored credentials"); + + session.deauthenticate(); + invalidate_persistent_session(); + + running = false; + + attempt_start(); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: displaying publishing options pane"); + + host.set_service_locked(false); + + Gtk.Builder builder = new Gtk.Builder(); + + try { + // the trailing get_path() is required, since add_from_file can't cope + // with File objects directly and expects a pathname instead. + builder.add_from_file( + host.get_module_file().get_parent(). + get_child("flickr_publishing_options_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + host.post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Flickr can't continue."))); + return; + } + + publishing_options_pane = new PublishingOptionsPane(this, parameters, + host.get_publishable_media_type(), builder, get_persistent_strip_metadata()); + 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); + } + + public static int flickr_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(bool strip_metadata) { + set_persistent_strip_metadata(strip_metadata); + debug("ACTION: uploading media items to remote server."); + + host.set_service_locked(true); + progress_reporter = host.serialize_publishables(parameters.photo_major_axis_size, strip_metadata); + + // Serialization is a long and potentially cancellable operation, so before we use + // the publishables, make sure that the publishing interaction is still running. If it + // isn't the publishing environment may be partially torn down so do a short-circuit + // return + if (!is_running()) + return; + + // 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) { + sorted_list.add(p); + } + sorted_list.sort(flickr_date_time_compare_func); + + Uploader uploader = new Uploader(session, sorted_list.to_array(), parameters, strip_metadata); + 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(); + } + + internal int get_persistent_visibility() { + return host.get_config_int("visibility", 0); + } + + internal void set_persistent_visibility(int vis) { + host.set_config_int("visibility", vis); + } + + 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); + } + + public Spit.Publishing.Service get_service() { + return service; + } + + public bool is_running() { + return running; + } + + // this helper doesn't check state, merely validates and authenticates the session and installs + // the proper panes + private void attempt_start() { + running = true; + was_started = 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(), get_persistent_access_phase_username()); + } else { + debug("attempt start: no persistent session available; showing login welcome pane"); + + do_show_login_welcome_pane(); + } + } + + public void start() { + if (is_running()) + return; + + if (was_started) + error("FlickrPublisher: start( ): can't start; this publisher is not restartable."); + + debug("FlickrPublisher: starting interaction."); + + attempt_start(); + } + + public void stop() { + debug("FlickrPublisher: stop( ) invoked."); + + if (session != null) + session.stop_transactions(); + + running = false; + } +} + +internal class PinEntryPane : Spit.Publishing.DialogPane, GLib.Object { + private Gtk.Box pane_widget = null; + private Gtk.Button continue_button = null; + private Gtk.Entry pin_entry = null; + private Gtk.Label pin_entry_caption = null; + private Gtk.Label explanatory_text = null; + private Gtk.Builder builder = null; + + public signal void proceed(PinEntryPane sender, string authorization_pin); + + public PinEntryPane(Gtk.Builder builder) { + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + explanatory_text = builder.get_object("explanatory_text") as Gtk.Label; + pin_entry_caption = builder.get_object("pin_entry_caption") as Gtk.Label; + pin_entry = builder.get_object("pin_entry") as Gtk.Entry; + continue_button = builder.get_object("continue_button") as Gtk.Button; + + pane_widget = builder.get_object("pane_widget") as Gtk.Box; + + pane_widget.show_all(); + + on_pin_entry_contents_changed(); + } + + private void on_continue_clicked() { + proceed(this, pin_entry.get_text()); + } + + private void on_pin_entry_contents_changed() { + continue_button.set_sensitive(pin_entry.text_length > 0); + } + + 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() { + continue_button.clicked.connect(on_continue_clicked); + pin_entry.changed.connect(on_pin_entry_contents_changed); + } + + public void on_pane_uninstalled() { + continue_button.clicked.disconnect(on_continue_clicked); + pin_entry.changed.disconnect(on_pin_entry_contents_changed); + } +} + +internal class Transaction : Publishing.RESTSupport.Transaction { + public Transaction(Session session, Publishing.RESTSupport.HttpMethod method = + Publishing.RESTSupport.HttpMethod.POST) { + base(session, 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_callback", "oob"); + add_argument("oauth_timestamp", session.get_oauth_timestamp()); + add_argument("oauth_consumer_key", API_KEY); + } + + 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_callback", "oob"); + add_argument("oauth_timestamp", session.get_oauth_timestamp()); + add_argument("oauth_consumer_key", API_KEY); + } + + public override void execute() throws Spit.Publishing.PublishingError { + ((Session) get_parent_session()).sign_transaction(this); + + base.execute(); + } + + public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* root = doc.get_root_node(); + string? status = root->get_prop("stat"); + + // treat malformed root as an error condition + if (status == null) + return "No status property in root node"; + + if (status == "ok") + return null; + + Xml.Node* errcode; + try { + errcode = doc.get_named_child(root, "err"); + } catch (Spit.Publishing.PublishingError err) { + return "No error code specified"; + } + + // this error format is mandatory, because the parse_flickr_response( ) expects error + // messages to be in this format. If you want to change the error reporting format, you + // need to modify parse_flickr_response( ) to parse the new format too. + return "%s (error code %s)".printf(errcode->get_prop("msg"), errcode->get_prop("code")); + } + + // Flickr responses have a special flavor of expired session reporting. Expired sessions + // are reported as just another service error, so they have to be converted from + // service errors. Always use this wrapper function to parse Flickr response XML instead + // of the generic Publishing.RESTSupport.XmlDocument.parse_string( ) from the Yorba + // REST support classes. While using Publishing.RESTSupport.XmlDocument.parse_string( ) won't + // cause anything really bad to happen, it will make expired session errors unrecoverable, + // which is annoying for users. + public static Publishing.RESTSupport.XmlDocument parse_flickr_response(string xml) + throws Spit.Publishing.PublishingError { + Publishing.RESTSupport.XmlDocument? result = null; + + try { + result = Publishing.RESTSupport.XmlDocument.parse_string(xml, validate_xml); + } catch (Spit.Publishing.PublishingError e) { + if (e.message.contains("(error code %s)".printf(EXPIRED_SESSION_ERROR_CODE))) { + throw new Spit.Publishing.PublishingError.EXPIRED_SESSION(e.message); + } else { + throw e; + } + } + + return result; + } +} + +internal class AuthenticationRequestTransaction : Transaction { + public AuthenticationRequestTransaction(Session session) { + base.with_uri(session, "https://www.flickr.com/services/oauth/request_token", + Publishing.RESTSupport.HttpMethod.GET); + } +} + +internal class AccessTokenFetchTransaction : Transaction { + public AccessTokenFetchTransaction(Session session, string user_verifier) { + base.with_uri(session, "https://www.flickr.com/services/oauth/access_token", + Publishing.RESTSupport.HttpMethod.GET); + add_argument("oauth_verifier", user_verifier); + add_argument("oauth_token", session.get_request_phase_token()); + } +} + +internal class AccountInfoFetchTransaction : Transaction { + public AccountInfoFetchTransaction(Session session) { + base(session, Publishing.RESTSupport.HttpMethod.GET); + add_argument("method", "flickr.people.getUploadStatus"); + add_argument("oauth_token", session.get_access_phase_token()); + } +} + +private class UploadTransaction : Publishing.RESTSupport.UploadTransaction { + private PublishingParameters parameters; + private Session session; + private Publishing.RESTSupport.Argument[] auth_header_fields; + + public UploadTransaction(Session session, PublishingParameters parameters, + Spit.Publishing.Publishable publishable) { + base.with_endpoint_url(session, publishable, "https://api.flickr.com/services/upload"); + + this.parameters = parameters; + this.session = session; + this.auth_header_fields = new Publishing.RESTSupport.Argument[0]; + + 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_callback", "oob"); + 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()); + + add_argument("is_public", ("%d".printf(parameters.visibility_specification.everyone_level))); + add_argument("is_friend", ("%d".printf(parameters.visibility_specification.friends_level))); + add_argument("is_family", ("%d".printf(parameters.visibility_specification.family_level))); + + GLib.HashTable<string, string> disposition_table = + new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal); + string? filename = publishable.get_publishing_name(); + if (filename == null || filename == "") + filename = publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME); + + /// TODO: This may need to be revisited to send the title separately; please see + /// http://www.flickr.com/services/api/upload.api.html for more details. + disposition_table.insert("filename", Soup.URI.encode( + publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME), null)); + + disposition_table.insert("name", "photo"); + + set_binary_disposition_table(disposition_table); + } + + 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 { + 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); + + base.execute(); + } +} + +internal class Session : Publishing.RESTSupport.Session { + private string? request_phase_token = null; + private string? request_phase_token_secret = null; + private string? access_phase_token = null; + private string? access_phase_token_secret = null; + private string? username = null; + + public Session() { + base(ENDPOINT_URL); + } + + public override bool is_authenticated() { + return (access_phase_token != null && access_phase_token_secret != null && + username != null); + } + + public void authenticate_from_persistent_credentials(string token, string secret, + string username) { + this.access_phase_token = token; + this.access_phase_token_secret = secret; + this.username = username; + + authenticated(); + } + + public void deauthenticate() { + access_phase_token = null; + access_phase_token_secret = null; + username = 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); + + 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? signing_key = null; + if (access_phase_token_secret != null) { + debug("access phase token secret available; using it as signing key"); + + signing_key = API_SECRET + "&" + access_phase_token_secret; + } else if (request_phase_token_secret != null) { + debug("request phase token secret available; using it as signing key"); + + signing_key = API_SECRET + "&" + request_phase_token_secret; + } else { + debug("neither access phase nor request phase token secrets available; using API " + + "key as signing key"); + + signing_key = API_SECRET + "&"; + } + + 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); + signature = Soup.URI.encode(signature, ENCODE_RFC_3986_EXTRA); + + debug("signature = '%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_request_phase_credentials(string token, string secret) { + this.request_phase_token = token; + this.request_phase_token_secret = secret; + } + + public void set_access_phase_credentials(string token, string secret, string username) { + this.access_phase_token = token; + this.access_phase_token_secret = secret; + this.username = username; + + authenticated(); + } + + 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); + } + + public string get_request_phase_token() { + assert(request_phase_token != null); + return request_phase_token; + } + + public string get_access_phase_token() { + assert(access_phase_token != null); + return access_phase_token; + } + + public string get_access_phase_token_secret() { + assert(access_phase_token_secret != null); + return access_phase_token_secret; + } + + public string get_username() { + assert(is_authenticated()); + return username; + } +} + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + private class SizeEntry { + public string title; + public int size; + + public SizeEntry(string creator_title, int creator_size) { + title = creator_title; + size = creator_size; + } + } + + private class VisibilityEntry { + public VisibilitySpecification specification; + public string title; + + public VisibilityEntry(string creator_title, VisibilitySpecification creator_specification) { + specification = creator_specification; + title = creator_title; + } + } + + private Gtk.Builder builder; + private Gtk.Box pane_widget = null; + private Gtk.Label visibility_label = null; + private Gtk.Label upload_info_label = null; + private Gtk.Label size_label = null; + private Gtk.Button logout_button = null; + private Gtk.Button publish_button = null; + private Gtk.ComboBoxText visibility_combo = null; + private Gtk.ComboBoxText size_combo = null; + private Gtk.CheckButton strip_metadata_check = null; + private VisibilityEntry[] visibilities = null; + private SizeEntry[] sizes = null; + private PublishingParameters parameters = null; + private FlickrPublisher publisher = null; + private Spit.Publishing.Publisher.MediaType media_type; + + public signal void publish(bool strip_metadata); + public signal void logout(); + + public PublishingOptionsPane(FlickrPublisher publisher, PublishingParameters parameters, + Spit.Publishing.Publisher.MediaType media_type, Gtk.Builder builder, bool strip_metadata) { + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + // pull in the necessary widgets from the glade file + pane_widget = (Gtk.Box) this.builder.get_object("flickr_pane"); + visibility_label = (Gtk.Label) this.builder.get_object("visibility_label"); + 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"); + visibility_combo = (Gtk.ComboBoxText) this.builder.get_object("visibility_combo"); + size_combo = (Gtk.ComboBoxText) this.builder.get_object("size_combo"); + size_label = (Gtk.Label) this.builder.get_object("size_label"); + strip_metadata_check = (Gtk.CheckButton) this.builder.get_object("strip_metadata_check"); + + this.parameters = parameters; + this.publisher = publisher; + this.media_type = media_type; + + visibilities = create_visibilities(); + sizes = create_sizes(); + + string upload_label_text = _("You are logged into Flickr as %s.\n\n").printf(parameters.username); + if (parameters.user_kind == UserKind.FREE) { + upload_label_text += _("Your free Flickr account limits how much data you can upload per month.\nThis month, you have %d megabytes remaining in your upload quota.").printf(parameters.quota_free_mb); + } else { + upload_label_text += _("Your Flickr Pro account entitles you to unlimited uploads."); + } + + upload_info_label.set_label(upload_label_text); + + string visibility_label_text = _("Photos _visible to:"); + if ((media_type == Spit.Publishing.Publisher.MediaType.VIDEO)) { + visibility_label_text = _("Videos _visible to:"); + } else if ((media_type == (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO))) { + visibility_label_text = _("Photos and videos _visible to:"); + } + + visibility_label.set_label(visibility_label_text); + + populate_visibility_combo(); + visibility_combo.changed.connect(on_visibility_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); + } + + strip_metadata_check.set_active(strip_metadata); + + logout_button.clicked.connect(on_logout_clicked); + publish_button.clicked.connect(on_publish_clicked); + } + + private void on_logout_clicked() { + logout(); + } + + private void on_publish_clicked() { + parameters.visibility_specification = + visibilities[visibility_combo.get_active()].specification; + + if ((media_type & Spit.Publishing.Publisher.MediaType.PHOTO) != 0) + parameters.photo_major_axis_size = sizes[size_combo.get_active()].size; + + publish(strip_metadata_check.get_active()); + } + + private VisibilityEntry[] create_visibilities() { + VisibilityEntry[] result = new VisibilityEntry[0]; + + result += new VisibilityEntry(_("Everyone"), new VisibilitySpecification(1, 1, 1)); + result += new VisibilityEntry(_("Friends & family only"), new VisibilitySpecification(1, 1, 0)); + result += new VisibilityEntry(_("Family only"), new VisibilitySpecification(0, 1, 0)); + result += new VisibilityEntry(_("Friends only"), new VisibilitySpecification(1, 0, 0)); + result += new VisibilityEntry(_("Just me"), new VisibilitySpecification(0, 0, 0)); + + return result; + } + + private void populate_visibility_combo() { + if (visibilities == null) + visibilities = create_visibilities(); + + foreach (VisibilityEntry v in visibilities) + visibility_combo.append_text(v.title); + + visibility_combo.set_active(publisher.get_persistent_visibility()); + } + + 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(_("2048 x 1536 pixels"), 2048); + result += new SizeEntry(_("4096 x 3072 pixels"), 4096); + result += new SizeEntry(_("Original size"), ORIGINAL_SIZE); + + return result; + } + + private void populate_size_combo() { + if (sizes == null) + sizes = create_sizes(); + + 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()); + } + + private void on_visibility_changed() { + publisher.set_persistent_visibility(visibility_combo.get_active()); + } + + protected void notify_publish() { + publish(strip_metadata_check.get_active()); + } + + 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); + } +} + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private PublishingParameters parameters; + private bool strip_metadata; + + public Uploader(Session session, Spit.Publishing.Publishable[] publishables, + PublishingParameters parameters, bool strip_metadata) { + base(session, publishables); + + this.parameters = parameters; + this.strip_metadata = strip_metadata; + } + + private void preprocess_publishable(Spit.Publishing.Publishable publishable) { + if (publishable.get_media_type() != Spit.Publishing.Publisher.MediaType.PHOTO) + return; + + GExiv2.Metadata publishable_metadata = new GExiv2.Metadata(); + try { + publishable_metadata.open_path(publishable.get_serialized_file().get_path()); + } catch (GLib.Error err) { + warning("couldn't read metadata from file '%s' for upload preprocessing.", + publishable.get_serialized_file().get_path()); + } + + // Flickr internationalization issues only affect IPTC tags; XMP, being an XML + // grammar and using standard XML internationalization mechanisms, doesn't need any i18n + // massaging before upload, so if the publishable doesn't have any IPTC metadata, then + // just do a short-circuit return + if (!publishable_metadata.has_iptc()) + return; + + if (publishable_metadata.has_tag("Iptc.Application2.Caption")) + publishable_metadata.set_tag_string("Iptc.Application2.Caption", + Publishing.RESTSupport.asciify_string(publishable_metadata.get_tag_string( + "Iptc.Application2.Caption"))); + + if (publishable_metadata.has_tag("Iptc.Application2.Headline")) + publishable_metadata.set_tag_string("Iptc.Application2.Headline", + Publishing.RESTSupport.asciify_string(publishable_metadata.get_tag_string( + "Iptc.Application2.Headline"))); + + if (publishable_metadata.has_tag("Iptc.Application2.Keywords")) { + Gee.Set<string> keyword_set = new Gee.HashSet<string>(); + string[] iptc_keywords = publishable_metadata.get_tag_multiple("Iptc.Application2.Keywords"); + if (iptc_keywords != null) + foreach (string keyword in iptc_keywords) + keyword_set.add(keyword); + + string[] xmp_keywords = publishable_metadata.get_tag_multiple("Xmp.dc.subject"); + if (xmp_keywords != null) + foreach (string keyword in xmp_keywords) + keyword_set.add(keyword); + + string[] all_keywords = keyword_set.to_array(); + // append a null pointer to the end of all_keywords -- this is a necessary workaround + // for http://trac.yorba.org/ticket/3264. See also http://trac.yorba.org/ticket/3257, + // which describes the user-visible behavior seen in the Flickr Connector as a result + // of the former bug. + all_keywords += null; + + string[] no_keywords = new string[1]; + // append a null pointer to the end of no_keywords -- this is a necessary workaround + // for http://trac.yorba.org/ticket/3264. See also http://trac.yorba.org/ticket/3257, + // which describes the user-visible behavior seen in the Flickr Connector as a result + // of the former bug. + no_keywords[0] = null; + + publishable_metadata.set_tag_multiple("Xmp.dc.subject", all_keywords); + publishable_metadata.set_tag_multiple("Iptc.Application2.Keywords", no_keywords); + + try { + publishable_metadata.save_file(publishable.get_serialized_file().get_path()); + } catch (GLib.Error err) { + warning("couldn't write metadata to file '%s' for upload preprocessing.", + publishable.get_serialized_file().get_path()); + } + } + } + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + preprocess_publishable(get_current_publishable()); + return new UploadTransaction((Session) get_session(), parameters, + get_current_publishable()); + } +} + +} + diff --git a/plugins/shotwell-publishing/Makefile b/plugins/shotwell-publishing/Makefile new file mode 100644 index 0000000..639fa88 --- /dev/null +++ b/plugins/shotwell-publishing/Makefile @@ -0,0 +1,39 @@ + +PLUGIN := shotwell-publishing + +PLUGIN_PKGS := \ + gtk+-3.0 \ + libsoup-2.4 \ + libxml-2.0 \ + webkitgtk-3.0 \ + gexiv2 \ + rest-0.7 \ + gee-0.8 \ + json-glib-1.0 + +SRC_FILES := \ + shotwell-publishing.vala \ + FacebookPublishing.vala \ + PicasaPublishing.vala \ + FlickrPublishing.vala \ + YouTubePublishing.vala \ + PiwigoPublishing.vala \ + ../../src/util/string.vala \ + ../common/RESTSupport.vala + +RC_FILES := \ + facebook.png \ + facebook_publishing_options_pane.glade \ + flickr.png \ + flickr_publishing_options_pane.glade \ + flickr_pin_entry_pane.glade \ + picasa.png \ + picasa_publishing_options_pane.glade \ + piwigo.png \ + piwigo_authentication_pane.glade \ + piwigo_publishing_options_pane.glade \ + youtube.png \ + youtube_publishing_options_pane.glade + +include ../Makefile.plugin.mk + diff --git a/plugins/shotwell-publishing/PicasaPublishing.vala b/plugins/shotwell-publishing/PicasaPublishing.vala new file mode 100644 index 0000000..c360e59 --- /dev/null +++ b/plugins/shotwell-publishing/PicasaPublishing.vala @@ -0,0 +1,940 @@ +/* Copyright 2009-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. + */ + +public class PicasaService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "picasa.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public PicasaService(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.picasa"; + } + + public unowned string get_pluggable_name() { + return "Picasa Web Albums"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Lucas Beeler"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + 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 Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.Picasa.PicasaPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO | + Spit.Publishing.Publisher.MediaType.VIDEO); + } + + public void activation(bool enabled) { + } +} + +namespace Publishing.Picasa { + +internal const string SERVICE_WELCOME_MESSAGE = + _("You are not currently logged into Picasa Web Albums.\n\nClick Login to log into Picasa Web Albums in your Web browser. You will have to authorize Shotwell Connect to link to your Picasa Web Albums account."); +internal const string DEFAULT_ALBUM_NAME = _("Shotwell Connect"); + +public class PicasaPublisher : Publishing.RESTSupport.GooglePublisher { + private bool running; + private Spit.Publishing.ProgressCallback progress_reporter; + private PublishingParameters publishing_parameters; + private string? refresh_token; + + public PicasaPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + base(service, host, "http://picasaweb.google.com/data/"); + + this.publishing_parameters = new PublishingParameters(); + load_parameters_from_configuration_system(publishing_parameters); + + Spit.Publishing.Publisher.MediaType media_type = Spit.Publishing.Publisher.MediaType.NONE; + foreach(Spit.Publishing.Publishable p in host.get_publishables()) + media_type |= p.get_media_type(); + publishing_parameters.set_media_type(media_type); + + this.refresh_token = host.get_config_string("refresh_token", null); + this.progress_reporter = null; + } + + private Album[] extract_albums_helper(Xml.Node* document_root) + throws Spit.Publishing.PublishingError { + Album[] result = new Album[0]; + + Xml.Node* doc_node_iter = null; + if (document_root->name == "feed") + doc_node_iter = document_root->children; + else if (document_root->name == "entry") + doc_node_iter = document_root; + else + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("response root node " + + "isn't a <feed> or <entry>"); + + for ( ; doc_node_iter != null; doc_node_iter = doc_node_iter->next) { + if (doc_node_iter->name != "entry") + continue; + + string name_val = null; + string url_val = null; + Xml.Node* album_node_iter = doc_node_iter->children; + for ( ; album_node_iter != null; album_node_iter = album_node_iter->next) { + if (album_node_iter->name == "title") { + name_val = album_node_iter->get_content(); + } else if (album_node_iter->name == "id") { + // we only want nodes in the default namespace -- the feed that we get back + // from Google also defines <entry> child nodes named <id> in the gphoto and + // media namespaces + if (album_node_iter->ns->prefix != null) + continue; + url_val = album_node_iter->get_content(); + } + } + + result += new Album(name_val, url_val); + } + + return result; + } + + private void load_parameters_from_configuration_system(PublishingParameters parameters) { + parameters.set_major_axis_size_selection_id(get_host().get_config_int("default-size", 0)); + parameters.set_strip_metadata(get_host().get_config_bool("strip-metadata", false)); + parameters.set_target_album_name(get_host().get_config_string("last-album", null)); + } + + private void save_parameters_to_configuration_system(PublishingParameters parameters) { + get_host().set_config_int("default-size", parameters.get_major_axis_size_selection_id()); + get_host().set_config_bool("strip_metadata", parameters.get_strip_metadata()); + get_host().set_config_string("last-album", parameters.get_target_album_name()); + } + + private void on_service_welcome_login() { + debug("EVENT: user clicked 'Login' in welcome pane."); + + if (!is_running()) + return; + + start_oauth_flow(refresh_token); + } + + protected override void on_login_flow_complete() { + debug("EVENT: OAuth login flow complete."); + + get_host().set_config_string("refresh_token", get_session().get_refresh_token()); + + publishing_parameters.set_user_name(get_session().get_user_name()); + + do_fetch_account_information(); + } + + private void on_initial_album_fetch_complete(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_initial_album_fetch_complete); + txn.network_error.disconnect(on_initial_album_fetch_error); + + if (!is_running()) + return; + + debug("EVENT: finished fetching account and album information."); + + do_parse_and_display_account_information((AlbumDirectoryTransaction) txn); + } + + private void on_initial_album_fetch_error(Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err) { + bad_txn.completed.disconnect(on_initial_album_fetch_complete); + bad_txn.network_error.disconnect(on_initial_album_fetch_error); + + if (!is_running()) + return; + + debug("EVENT: fetching account and album information failed; response = '%s'.", + bad_txn.get_response()); + + if (bad_txn.get_status_code() == 403 || bad_txn.get_status_code() == 404) { + do_logout(); + } else { + // If we get any other kind of error, we can't recover, so just post it to the user + get_host().post_error(err); + } + } + + private void on_publishing_options_logout() { + if (!is_running()) + return; + + debug("EVENT: user clicked 'Logout' in the publishing options pane."); + + do_logout(); + } + + private void on_publishing_options_publish() { + if (!is_running()) + return; + + debug("EVENT: user clicked 'Publish' in the publishing options pane."); + + save_parameters_to_configuration_system(publishing_parameters); + + if (publishing_parameters.is_to_new_album()) { + do_create_album(); + } else { + do_upload(); + } + } + + private void on_album_creation_complete(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_album_creation_complete); + txn.network_error.disconnect(on_album_creation_error); + + if (!is_running()) + return; + + debug("EVENT: finished creating album on remote server."); + + AlbumCreationTransaction downcast_txn = (AlbumCreationTransaction) txn; + Publishing.RESTSupport.XmlDocument response_doc; + try { + response_doc = Publishing.RESTSupport.XmlDocument.parse_string( + downcast_txn.get_response(), AlbumDirectoryTransaction.validate_xml); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + Album[] response_albums; + try { + response_albums = extract_albums_helper(response_doc.get_root_node()); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + if (response_albums.length != 1) { + get_host().post_error(new Spit.Publishing.PublishingError.MALFORMED_RESPONSE("album " + + "creation transaction responses must contain one and only one album directory " + + "entry")); + return; + } + + publishing_parameters.set_target_album_entry_url(response_albums[0].url); + + do_upload(); + } + + private void on_album_creation_error(Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err) { + bad_txn.completed.disconnect(on_album_creation_complete); + bad_txn.network_error.disconnect(on_album_creation_error); + + if (!is_running()) + return; + + debug("EVENT: creating album on remote server failed; response = '%s'.", + bad_txn.get_response()); + + get_host().post_error(err); + } + + private void on_upload_status_updated(int file_number, double completed_fraction) { + if (!is_running()) + return; + + debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); + + assert(progress_reporter != null); + + progress_reporter(file_number, completed_fraction); + } + + private void on_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); + + get_host().post_error(err); + } + + private void do_show_service_welcome_pane() { + debug("ACTION: showing service welcome pane."); + + get_host().install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_service_welcome_login); + } + + private void do_fetch_account_information() { + debug("ACTION: fetching account and album information."); + + get_host().install_account_fetch_wait_pane(); + get_host().set_service_locked(true); + + AlbumDirectoryTransaction directory_trans = + new AlbumDirectoryTransaction(get_session()); + directory_trans.network_error.connect(on_initial_album_fetch_error); + directory_trans.completed.connect(on_initial_album_fetch_complete); + + try { + directory_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + // don't post the error here -- some errors are recoverable so let's let the error + // handler function sort out whether the error is recoverable or not. If the error + // isn't recoverable, the error handler will post the error to the host + on_initial_album_fetch_error(directory_trans, err); + } + } + + private void do_parse_and_display_account_information(AlbumDirectoryTransaction transaction) { + debug("ACTION: parsing account and album information from server response XML"); + + Publishing.RESTSupport.XmlDocument response_doc; + try { + response_doc = Publishing.RESTSupport.XmlDocument.parse_string( + transaction.get_response(), AlbumDirectoryTransaction.validate_xml); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + try { + publishing_parameters.set_albums(extract_albums_helper(response_doc.get_root_node())); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + do_show_publishing_options_pane(); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: showing publishing options pane."); + Gtk.Builder builder = new Gtk.Builder(); + + try { + // the trailing get_path() is required, since add_from_file can't cope + // with File objects directly and expects a pathname instead. + builder.add_from_file( + get_host().get_module_file().get_parent(). + get_child("picasa_publishing_options_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + get_host().post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Picasa can't continue."))); + return; + } + + PublishingOptionsPane opts_pane = new PublishingOptionsPane(builder, publishing_parameters); + opts_pane.publish.connect(on_publishing_options_publish); + opts_pane.logout.connect(on_publishing_options_logout); + get_host().install_dialog_pane(opts_pane); + + get_host().set_service_locked(false); + } + + private void do_create_album() { + assert(publishing_parameters.is_to_new_album()); + + debug("ACTION: creating new album '%s' on remote server.", + publishing_parameters.get_target_album_name()); + + get_host().install_static_message_pane(_("Creating album...")); + + get_host().set_service_locked(true); + + AlbumCreationTransaction creation_trans = new AlbumCreationTransaction(get_session(), + publishing_parameters); + creation_trans.network_error.connect(on_album_creation_error); + creation_trans.completed.connect(on_album_creation_complete); + try { + creation_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + } + } + + private void do_upload() { + debug("ACTION: uploading media items to remote server."); + + get_host().set_service_locked(true); + + progress_reporter = get_host().serialize_publishables( + publishing_parameters.get_major_axis_size_pixels(), + publishing_parameters.get_strip_metadata()); + + // Serialization is a long and potentially cancellable operation, so before we use + // the publishables, make sure that the publishing interaction is still running. If it + // isn't the publishing environment may be partially torn down so do a short-circuit + // return + if (!is_running()) + return; + + Spit.Publishing.Publishable[] publishables = get_host().get_publishables(); + Uploader uploader = new Uploader(get_session(), publishables, publishing_parameters); + + 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."); + + get_host().set_service_locked(false); + get_host().install_success_pane(); + } + + protected override void do_logout() { + debug("ACTION: logging out user."); + + get_session().deauthenticate(); + refresh_token = null; + get_host().unset_config_key("refresh_token"); + + + do_show_service_welcome_pane(); + } + + public override bool is_running() { + return running; + } + + public override void start() { + debug("PicasaPublisher: start( ) invoked."); + + if (is_running()) + return; + + running = true; + + if (refresh_token == null) + do_show_service_welcome_pane(); + else + start_oauth_flow(refresh_token); + } + + public override void stop() { + debug("PicasaPublisher: stop( ) invoked."); + + get_session().stop_transactions(); + + running = false; + } +} + +internal class Album { + public string name; + public string url; + + public Album(string name, string url) { + this.name = name; + this.url = url; + } +} + +internal class AlbumDirectoryTransaction : + Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private const string ENDPOINT_URL = "http://picasaweb.google.com/data/feed/api/user/" + + "default"; + + public AlbumDirectoryTransaction(Publishing.RESTSupport.GoogleSession session) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET); + } + + public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* document_root = doc.get_root_node(); + if ((document_root->name == "feed") || (document_root->name == "entry")) + return null; + else + return "response root node isn't a <feed> or <entry>"; + } +} + +private class AlbumCreationTransaction : + Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private const string ENDPOINT_URL = "http://picasaweb.google.com/data/feed/api/user/" + + "default"; + private const string ALBUM_ENTRY_TEMPLATE = "<?xml version='1.0' encoding='utf-8'?><entry xmlns='http://www.w3.org/2005/Atom' xmlns:gphoto='http://schemas.google.com/photos/2007'><title type='text'>%s</title><gphoto:access>%s</gphoto:access><category scheme='http://schemas.google.com/g/2005#kind' term='http://schemas.google.com/photos/2007#album'></category></entry>"; + + public AlbumCreationTransaction(Publishing.RESTSupport.GoogleSession session, + PublishingParameters parameters) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.POST); + + string post_body = ALBUM_ENTRY_TEMPLATE.printf(Publishing.RESTSupport.decimal_entity_encode( + parameters.get_target_album_name()), parameters.is_new_album_public() ? + "public" : "private"); + + set_custom_payload(post_body, "application/atom+xml"); + } +} + +internal class UploadTransaction : + Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private PublishingParameters parameters; + private const string METADATA_TEMPLATE = "<?xml version=\"1.0\" ?><atom:entry xmlns:atom='http://www.w3.org/2005/Atom' xmlns:mrss='http://search.yahoo.com/mrss/'> <atom:title>%s</atom:title> %s <atom:category scheme='http://schemas.google.com/g/2005#kind' term='http://schemas.google.com/photos/2007#photo'/> %s </atom:entry>"; + private Publishing.RESTSupport.GoogleSession session; + private string mime_type; + private Spit.Publishing.Publishable publishable; + private MappedFile mapped_file; + + public UploadTransaction(Publishing.RESTSupport.GoogleSession session, + PublishingParameters parameters, Spit.Publishing.Publishable publishable) { + base(session, parameters.get_target_album_feed_url(), + Publishing.RESTSupport.HttpMethod.POST); + assert(session.is_authenticated()); + this.session = session; + this.parameters = parameters; + this.publishable = publishable; + this.mime_type = (publishable.get_media_type() == Spit.Publishing.Publisher.MediaType.VIDEO) ? + "video/mpeg" : "image/jpeg"; + } + + public override void execute() throws Spit.Publishing.PublishingError { + // create the multipart request container + Soup.Multipart message_parts = new Soup.Multipart("multipart/related"); + + string summary = ""; + if (publishable.get_publishing_name() != "") { + summary = "<atom:summary>%s</atom:summary>".printf( + Publishing.RESTSupport.decimal_entity_encode(publishable.get_publishing_name())); + } + + string[] keywords = publishable.get_publishing_keywords(); + string keywords_string = ""; + if (keywords.length > 0) { + for (int i = 0; i < keywords.length; i++) { + string[] tmp; + + if (keywords[i].has_prefix("/")) + tmp = keywords[i].substring(1).split("/"); + else + tmp = keywords[i].split("/"); + + if (keywords_string.length > 0) + keywords_string = string.join(", ", keywords_string, string.joinv(", ", tmp)); + else + keywords_string = string.joinv(", ", tmp); + } + + keywords_string = Publishing.RESTSupport.decimal_entity_encode(keywords_string); + keywords_string = "<mrss:group><mrss:keywords>%s</mrss:keywords></mrss:group>".printf(keywords_string); + } + + string metadata = METADATA_TEMPLATE.printf(Publishing.RESTSupport.decimal_entity_encode( + publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME)), + summary, keywords_string); + Soup.Buffer metadata_buffer = new Soup.Buffer(Soup.MemoryUse.COPY, metadata.data); + message_parts.append_form_file("", "", "application/atom+xml", metadata_buffer); + + // attempt to map the binary image data from disk into memory + try { + mapped_file = new MappedFile(publishable.get_serialized_file().get_path(), false); + } catch (FileError e) { + string msg = "Picasa: couldn't read data from %s: %s".printf( + publishable.get_serialized_file().get_path(), e.message); + warning("%s", msg); + + throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(msg); + } + unowned uint8[] photo_data = (uint8[]) mapped_file.get_contents(); + photo_data.length = (int) mapped_file.get_length(); + + // bind the binary image data read from disk into a Soup.Buffer object so that we + // can attach it to the multipart request, then actaully append the buffer + // to the multipart request. Then, set the MIME type for this part. + Soup.Buffer bindable_data = new Soup.Buffer(Soup.MemoryUse.TEMPORARY, photo_data); + + message_parts.append_form_file("", publishable.get_serialized_file().get_path(), mime_type, + bindable_data); + // create a message that can be sent over the wire whose payload is the multipart container + // that we've been building up + Soup.Message outbound_message = + soup_form_request_new_from_multipart(get_endpoint_url(), message_parts); + outbound_message.request_headers.append("Authorization", "Bearer " + + session.get_access_token()); + set_message(outbound_message); + + // send the message and get its response + set_is_executed(true); + send(); + } +} + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + private class SizeDescription { + public string name; + public int major_axis_pixels; + + public SizeDescription(string name, int major_axis_pixels) { + this.name = name; + this.major_axis_pixels = major_axis_pixels; + } + } + + private const string DEFAULT_SIZE_CONFIG_KEY = "default_size"; + private const string LAST_ALBUM_CONFIG_KEY = "last_album"; + + private Gtk.Builder builder = null; + private Gtk.Box pane_widget = null; + private Gtk.Label login_identity_label = null; + private Gtk.Label publish_to_label = null; + private Gtk.RadioButton use_existing_radio = null; + private Gtk.ComboBoxText existing_albums_combo = null; + private Gtk.RadioButton create_new_radio = null; + private Gtk.Entry new_album_entry = null; + private Gtk.CheckButton public_check = null; + private Gtk.ComboBoxText size_combo = null; + private Gtk.CheckButton strip_metadata_check = null; + private Gtk.Button publish_button = null; + private Gtk.Button logout_button = null; + private SizeDescription[] size_descriptions; + private PublishingParameters parameters; + + public signal void publish(); + public signal void logout(); + + public PublishingOptionsPane(Gtk.Builder builder, PublishingParameters parameters) { + size_descriptions = create_size_descriptions(); + + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + this.parameters = parameters; + + // pull in all widgets from builder. + pane_widget = (Gtk.Box) builder.get_object("picasa_pane_widget"); + login_identity_label = (Gtk.Label) builder.get_object("login_identity_label"); + publish_to_label = (Gtk.Label) builder.get_object("publish_to_label"); + use_existing_radio = (Gtk.RadioButton) builder.get_object("use_existing_radio"); + existing_albums_combo = (Gtk.ComboBoxText) builder.get_object("existing_albums_combo"); + create_new_radio = (Gtk.RadioButton) builder.get_object("create_new_radio"); + new_album_entry = (Gtk.Entry) builder.get_object("new_album_entry"); + public_check = (Gtk.CheckButton) builder.get_object("public_check"); + size_combo = (Gtk.ComboBoxText) builder.get_object("size_combo"); + strip_metadata_check = (Gtk.CheckButton) this.builder.get_object("strip_metadata_check"); + publish_button = (Gtk.Button) builder.get_object("publish_button"); + logout_button = (Gtk.Button) builder.get_object("logout_button"); + + // populate any widgets whose contents are programmatically-generated. + login_identity_label.set_label(_("You are logged into Picasa Web Albums as %s.").printf( + parameters.get_user_name())); + strip_metadata_check.set_active(parameters.get_strip_metadata()); + + + if((parameters.get_media_type() & Spit.Publishing.Publisher.MediaType.PHOTO) == 0) { + publish_to_label.set_label(_("Videos will appear in:")); + size_combo.set_visible(false); + size_combo.set_sensitive(false); + } + else { + publish_to_label.set_label(_("Photos will appear in:")); + foreach(SizeDescription desc in size_descriptions) { + size_combo.append_text(desc.name); + } + size_combo.set_visible(true); + size_combo.set_sensitive(true); + size_combo.set_active(parameters.get_major_axis_size_selection_id()); + } + + // connect all signals. + use_existing_radio.clicked.connect(on_use_existing_radio_clicked); + create_new_radio.clicked.connect(on_create_new_radio_clicked); + new_album_entry.changed.connect(on_new_album_entry_changed); + logout_button.clicked.connect(on_logout_clicked); + publish_button.clicked.connect(on_publish_clicked); + } + + private void on_publish_clicked() { + // size_combo won't have been set to anything useful if this is the first time we've + // published to Picasa, and/or we've only published video before, so it may be negative, + // indicating nothing was selected. Clamp it to a valid value... + int size_combo_last_active = (size_combo.get_active() >= 0) ? size_combo.get_active() : 0; + + parameters.set_major_axis_size_selection_id(size_combo_last_active); + parameters.set_major_axis_size_pixels( + size_descriptions[size_combo_last_active].major_axis_pixels); + parameters.set_strip_metadata(strip_metadata_check.get_active()); + + Album[] albums = parameters.get_albums(); + + if (create_new_radio.get_active()) { + parameters.set_target_album_name(new_album_entry.get_text()); + parameters.set_is_to_new_album(true); + parameters.set_is_new_album_public(public_check.get_active()); + publish(); + } else { + parameters.set_target_album_name(albums[existing_albums_combo.get_active()].name); + parameters.set_is_to_new_album(false); + parameters.set_target_album_entry_url(albums[existing_albums_combo.get_active()].url); + publish(); + } + } + + private void on_use_existing_radio_clicked() { + existing_albums_combo.set_sensitive(true); + new_album_entry.set_sensitive(false); + existing_albums_combo.grab_focus(); + update_publish_button_sensitivity(); + public_check.set_sensitive(false); + } + + private void on_create_new_radio_clicked() { + new_album_entry.set_sensitive(true); + existing_albums_combo.set_sensitive(false); + new_album_entry.grab_focus(); + update_publish_button_sensitivity(); + public_check.set_sensitive(true); + } + + private void on_logout_clicked() { + logout(); + } + + private void update_publish_button_sensitivity() { + string album_name = new_album_entry.get_text(); + publish_button.set_sensitive(!(album_name.strip() == "" && + create_new_radio.get_active())); + } + + private void on_new_album_entry_changed() { + update_publish_button_sensitivity(); + } + + private SizeDescription[] create_size_descriptions() { + SizeDescription[] result = new SizeDescription[0]; + + result += new SizeDescription(_("Small (640 x 480 pixels)"), 640); + result += new SizeDescription(_("Medium (1024 x 768 pixels)"), 1024); + result += new SizeDescription(_("Recommended (1600 x 1200 pixels)"), 1600); + result += new SizeDescription(_("Google+ (2048 x 1536 pixels)"), 2048); + result += new SizeDescription(_("Original Size"), PublishingParameters.ORIGINAL_SIZE); + + return result; + } + + public void installed() { + int default_album_id = -1; + string last_album = parameters.get_target_album_name(); + + Album[] albums = parameters.get_albums(); + + for (int i = 0; i < albums.length; i++) { + existing_albums_combo.append_text(albums[i].name); + if (albums[i].name == last_album || + (albums[i].name == DEFAULT_ALBUM_NAME && default_album_id == -1)) + default_album_id = i; + } + + if (albums.length == 0) { + existing_albums_combo.set_sensitive(false); + use_existing_radio.set_sensitive(false); + create_new_radio.set_active(true); + new_album_entry.grab_focus(); + new_album_entry.set_text(DEFAULT_ALBUM_NAME); + } else { + if (default_album_id >= 0) { + use_existing_radio.set_active(true); + existing_albums_combo.set_active(default_album_id); + new_album_entry.set_sensitive(false); + public_check.set_sensitive(false); + } else { + create_new_radio.set_active(true); + existing_albums_combo.set_active(0); + new_album_entry.set_text(DEFAULT_ALBUM_NAME); + new_album_entry.grab_focus(); + public_check.set_sensitive(true); + } + } + update_publish_button_sensitivity(); + } + + public Gtk.Widget get_widget() { + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + installed(); + } + + public void on_pane_uninstalled() { + } +} + +internal class PublishingParameters { + public const int ORIGINAL_SIZE = -1; + + private string? target_album_name; + private string? target_album_url; + private bool album_public; + private bool strip_metadata; + private int major_axis_size_pixels; + private int major_axis_size_selection_id; + private string user_name; + private Album[] albums; + private Spit.Publishing.Publisher.MediaType media_type; + private bool to_new_album; + + public PublishingParameters() { + this.user_name = "[unknown]"; + this.target_album_name = null; + this.major_axis_size_selection_id = 0; + this.major_axis_size_pixels = ORIGINAL_SIZE; + this.target_album_url = null; + this.album_public = false; + this.albums = null; + this.strip_metadata = false; + this.media_type = Spit.Publishing.Publisher.MediaType.PHOTO; + this.to_new_album = true; + } + + public bool is_to_new_album() { + return to_new_album; + } + + public void set_is_to_new_album(bool to_new_album) { + this.to_new_album = to_new_album; + } + + public void set_is_new_album_public(bool album_public) { + this.album_public = album_public; + } + + public bool is_new_album_public() { + return album_public; + } + + public string get_target_album_name() { + return target_album_name; + } + + public void set_target_album_name(string target_album_name) { + this.target_album_name = target_album_name; + } + + public void set_target_album_entry_url(string target_album_url) { + this.target_album_url = target_album_url; + } + + public string get_target_album_entry_url() { + return target_album_url; + } + + public string get_target_album_feed_url() { + string entry_url = get_target_album_entry_url(); + string feed_url = entry_url.replace("entry", "feed"); + + return feed_url; + } + + public string get_user_name() { + return user_name; + } + + public void set_user_name(string user_name) { + this.user_name = user_name; + } + + public Album[] get_albums() { + return albums; + } + + public void set_albums(Album[] albums) { + this.albums = albums; + } + + public void set_major_axis_size_pixels(int pixels) { + this.major_axis_size_pixels = pixels; + } + + public int get_major_axis_size_pixels() { + return major_axis_size_pixels; + } + + public void set_major_axis_size_selection_id(int selection_id) { + this.major_axis_size_selection_id = selection_id; + } + + public int get_major_axis_size_selection_id() { + return major_axis_size_selection_id; + } + + public void set_strip_metadata(bool strip_metadata) { + this.strip_metadata = strip_metadata; + } + + public bool get_strip_metadata() { + return strip_metadata; + } + + public void set_media_type(Spit.Publishing.Publisher.MediaType media_type) { + this.media_type = media_type; + } + + public Spit.Publishing.Publisher.MediaType get_media_type() { + return media_type; + } +} + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private PublishingParameters parameters; + + public Uploader(Publishing.RESTSupport.GoogleSession session, + Spit.Publishing.Publishable[] publishables, PublishingParameters parameters) { + base(session, publishables); + + this.parameters = parameters; + } + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + return new UploadTransaction((Publishing.RESTSupport.GoogleSession) get_session(), + parameters, get_current_publishable()); + } +} + +} + diff --git a/plugins/shotwell-publishing/PiwigoPublishing.vala b/plugins/shotwell-publishing/PiwigoPublishing.vala new file mode 100644 index 0000000..8deada4 --- /dev/null +++ b/plugins/shotwell-publishing/PiwigoPublishing.vala @@ -0,0 +1,1736 @@ +/* Copyright 2009-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. + */ + +public class PiwigoService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "piwigo.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public PiwigoService(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.piwigo"; + } + + public unowned string get_pluggable_name() { + return "Piwigo"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Bruno Girin"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + 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.Piwigo.PiwigoPublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return (Spit.Publishing.Publisher.MediaType.PHOTO); + } +} + +namespace Publishing.Piwigo { + +internal const string SERVICE_NAME = "Piwigo"; +internal const string PIWIGO_WS = "ws.php"; +internal const int ORIGINAL_SIZE = -1; + +internal class Category { + public int id; + public string name; + public string comment; + public string display_name; + public string uppercats; + public static const int NO_ID = -1; + + public Category(int id, string name, string uppercats, string? comment = "") { + this.id = id; + this.name = name; + this.uppercats = uppercats; + this.comment = comment; + } + + public Category.local(string name, int parent_id, string? comment = "") { + this.id = NO_ID; + this.name = name; + // for new categories abuse the uppercats value for + // the id of the new parent! + this.uppercats = parent_id.to_string(); + this.comment = comment; + } + + public bool is_local() { + return this.id == NO_ID; + } +} + +internal class PermissionLevel { + public int id; + public string name; + + public PermissionLevel(int id, string name) { + this.id = id; + this.name = name; + } +} + +internal class SizeEntry { + public int id; + public string name; + + public SizeEntry(int id, string name) { + this.id = id; + this.name = name; + } +} + +internal class PublishingParameters { + public Category category = null; + public PermissionLevel perm_level = null; + public SizeEntry photo_size = null; + public bool title_as_comment = false; + public bool no_upload_tags = false; + + public PublishingParameters() { + } +} + +public class PiwigoPublisher : Spit.Publishing.Publisher, GLib.Object { + private Spit.Publishing.Service service; + private Spit.Publishing.PluginHost host; + private bool running = false; + private bool strip_metadata = false; + private Session session; + private Category[] categories = null; + private PublishingParameters parameters = null; + private Spit.Publishing.ProgressCallback progress_reporter = null; + + public PiwigoPublisher(Spit.Publishing.Service service, + Spit.Publishing.PluginHost host) { + debug("PiwigoPublisher instantiated."); + this.service = service; + this.host = host; + session = new Session(); + } + + // 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; + } + + public void start() { + if (is_running()) + return; + + debug("PiwigoPublisher: starting interaction."); + + running = true; + + if (session.is_authenticated()) { + debug("PiwigoPublisher: session is authenticated."); + do_fetch_categories(); + } else { + debug("PiwigoPublisher: session is not authenticated."); + string? persistent_url = get_persistent_url(); + string? persistent_username = get_persistent_username(); + string? persistent_password = get_persistent_password(); + if (persistent_url != null && persistent_username != null && persistent_password != null) + do_network_login(persistent_url, persistent_username, + persistent_password, get_remember_password()); + else + do_show_authentication_pane(); + } + } + + public void stop() { + running = false; + } + + // Session and persistent data + + public string? get_persistent_url() { + return host.get_config_string("url", null); + } + + private void set_persistent_url(string url) { + host.set_config_string("url", url); + } + + public string? get_persistent_username() { + return host.get_config_string("username", null); + } + + private void set_persistent_username(string username) { + host.set_config_string("username", username); + } + + public string? get_persistent_password() { + return host.get_config_string("password", null); + } + + private void set_persistent_password(string? password) { + host.set_config_string("password", password); + } + + public bool get_remember_password() { + return host.get_config_bool("remember-password", false); + } + + private void set_remember_password(bool remember_password) { + host.set_config_bool("remember-password", remember_password); + } + + public int get_last_category() { + return host.get_config_int("last-category", -1); + } + + private void set_last_category(int last_category) { + host.set_config_int("last-category", last_category); + } + + public int get_last_permission_level() { + return host.get_config_int("last-permission-level", -1); + } + + private void set_last_permission_level(int last_permission_level) { + host.set_config_int("last-permission-level", last_permission_level); + } + + public int get_last_photo_size() { + return host.get_config_int("last-photo-size", -1); + } + + private void set_last_photo_size(int last_photo_size) { + host.set_config_int("last-photo-size", last_photo_size); + } + + private bool get_last_title_as_comment() { + return host.get_config_bool("last-title-as-comment", false); + } + + private void set_last_title_as_comment(bool title_as_comment) { + host.set_config_bool("last-title-as-comment", title_as_comment); + } + + private bool get_last_no_upload_tags() { + return host.get_config_bool("last-no-upload-tags", false); + } + + private void set_last_no_upload_tags(bool no_upload_tags) { + host.set_config_bool("last-no-upload-tags", no_upload_tags); + } + + private bool get_metadata_removal_choice() { + return host.get_config_bool("strip_metadata", false); + } + + private void set_metadata_removal_choice(bool strip_metadata) { + host.set_config_bool("strip_metadata", strip_metadata); + } + + // 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 url the URL of the Piwigo service as entered in the dialog + * @param username the name of the Piwigo user as entered in the dialog + * @param password the password of the Piwigo as entered in the dialog + */ + private void on_authentication_pane_login_clicked( + string url, string username, string password, bool remember_password + ) { + debug("EVENT: on_authentication_pane_login_clicked"); + if (!running) + return; + + do_network_login(url, username, password, remember_password); + } + + /** + * Action to perform a network login to a Piwigo service. + * + * This action performs a network login a Piwigo service specified by a + * URL and using the given user name and password as credentials. + * + * @param url the URL of the Piwigo service; this URL will be normalised + * before being used + * @param username the name of the Piwigo user used to login + * @param password the password of the Piwigo user used to login + */ + private void do_network_login(string url, string username, string password, bool remember_password) { + debug("ACTION: logging in"); + host.set_service_locked(true); + host.install_login_wait_pane(); + + set_remember_password(remember_password); + if (remember_password) + set_persistent_password(password); + else + set_persistent_password(null); + + SessionLoginTransaction login_trans = new SessionLoginTransaction( + session, normalise_url(url), username, password); + login_trans.network_error.connect(on_login_network_error); + login_trans.completed.connect(on_login_network_complete); + + try { + login_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_network_login"); + do_show_error(err); + } + } + + public static string normalise_url(string url) { + string norm_url = url; + + if(!norm_url.has_suffix(".php")) { + if(!norm_url.has_suffix("/")) { + norm_url = norm_url + "/"; + } + norm_url = norm_url + PIWIGO_WS; + } + + if(!norm_url.has_prefix("http://") && !norm_url.has_prefix("https://")) { + norm_url = "http://" + norm_url; + } + + return norm_url; + } + + /** + * Event triggered when the network login action is complete and successful. + * + * This event is triggered on successful completion of a network login. + * Calling this event implies that the URL, user name and password provided + * in the authentication pane are valid and that the transaction should + * contain a Set-Cookie header that includes the value pwg_id for that + * user. As a result, this event will also authenticate the session and + * persist all values so that they can be re-used during the next publishing + * interaction. + * + * @param txn the received REST transaction + */ + private void on_login_network_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_login_network_complete"); + txn.completed.disconnect(on_login_network_complete); + txn.network_error.disconnect(on_login_network_error); + + try { + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.validate_xml); + } catch (Spit.Publishing.PublishingError err) { + // Get error code first + try { + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.get_error_code); + } catch (Spit.Publishing.PublishingError code) { + int code_int = int.parse(code.message); + if (code_int == 999) { + debug("ERROR: on_login_network_complete, code 999"); + do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_USER); + } else { + debug("ERROR: on_login_network_complete"); + do_show_error(err); + } + } + return; + } + // Get session ID and authenticate the session + string endpoint_url = txn.get_endpoint_url(); + debug("Setting endpoint URL to %s", endpoint_url); + string pwg_id = get_pwg_id_from_transaction(txn); + debug("Setting session pwg_id to %s", pwg_id); + session = new Session(); + session.set_pwg_id(pwg_id); + + do_fetch_session_status(endpoint_url, pwg_id); + } + + /** + * Event triggered when a network login action fails due to a network error. + * + * This event triggered as a result of a network error during the login + * transaction. As a result, it assumes that the service URL entered in the + * authentication dialog is incorrect and re-presents the authentication + * dialog with FAILED_RETRY_URL mode. + * + * @param bad_txn the received REST transaction + * @param err the received error + */ + private void on_login_network_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_login_network_error"); + bad_txn.completed.disconnect(on_login_network_complete); + bad_txn.network_error.disconnect(on_login_network_error); + + if (session.is_authenticated()) // ignore these events if the session is already auth'd + return; + + do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_URL); + } + + /** + * Action to fetch the session status for a known Piwigo user. + * + * This action fetches the session status for a Piwigo user for whom the + * pwg_id is known. If triggered after a network login, it should just + * confirm that the session is OK. It can also be triggered as the first + * action of the interaction for users for who the pwg_id was previously + * persisted. In this case, it will log the user in and confirm the + * identity. + */ + private void do_fetch_session_status(string url = "", string pwg_id = "") { + debug("ACTION: fetching session status"); + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + if (!session.is_authenticated()) { + SessionGetStatusTransaction status_txn = new SessionGetStatusTransaction.unauthenticated(session, url, pwg_id); + status_txn.network_error.connect(on_session_get_status_error); + status_txn.completed.connect(on_session_get_status_complete); + + try { + status_txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_fetch_session_status, not authenticated"); + do_show_error(err); + } + } else { + SessionGetStatusTransaction status_txn = new SessionGetStatusTransaction(session); + status_txn.network_error.connect(on_session_get_status_error); + status_txn.completed.connect(on_session_get_status_complete); + + try { + status_txn.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_fetch_session_status, authenticated"); + do_show_error(err); + } + } + } + + /** + * Event triggered when the get session status action completes successfully. + * + * This event being triggered confirms that the session is valid and can becyclonic enema + * used. If the session is not fully authenticated yet, this event finalises + * session authentication. It then triggers the fetch categories action. + */ + private void on_session_get_status_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_session_get_status_complete"); + txn.completed.disconnect(on_session_get_status_complete); + txn.network_error.disconnect(on_session_get_status_error); + + if (!session.is_authenticated()) { + string endpoint_url = txn.get_endpoint_url(); + string pwg_id = session.get_pwg_id(); + debug("Fetching session status for pwg_id %s", pwg_id); + // Parse the response + try { + Publishing.RESTSupport.XmlDocument doc = + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.validate_xml); + Xml.Node* root = doc.get_root_node(); + Xml.Node* username_node; + try { + username_node = doc.get_named_child(root, "username"); + string username = username_node->get_content(); + debug("Returned username is %s", username); + session.authenticate(endpoint_url, username, pwg_id); + set_persistent_url(session.get_pwg_url()); + set_persistent_username(session.get_username()); + do_fetch_categories(); + } catch (Spit.Publishing.PublishingError err2) { + debug("ERROR: on_session_get_status_complete, inner"); + do_show_error(err2); + return; + } + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: on_session_get_status_complete, outer"); + do_show_error(err); + return; + } + } else { + // This should never happen as the session should not be + // authenticated at that point so this call is a safeguard + // against the interaction not happening properly. + do_fetch_categories(); + } + } + + /** + * Event triggered when the get session status fails due to a network error. + */ + private void on_session_get_status_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_session_get_status_error"); + bad_txn.completed.disconnect(on_session_get_status_complete); + bad_txn.network_error.disconnect(on_session_get_status_error); + on_network_error(bad_txn, err); + } + + /** + * Action that fetches all available categories from the Piwigo service. + * + * This action fetches all categories from the Piwigo service in order + * to populate the publishing pane presented to the user. + */ + private void do_fetch_categories() { + debug("ACTION: fetching categories"); + host.set_service_locked(true); + host.install_account_fetch_wait_pane(); + + CategoriesGetListTransaction cat_trans = new CategoriesGetListTransaction(session); + cat_trans.network_error.connect(on_category_fetch_error); + cat_trans.completed.connect(on_category_fetch_complete); + + try { + cat_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_fetch_categories"); + do_show_error(err); + } + } + + /** + * Event triggered when the fetch categories action completes successfully. + * + * This event retrieves all categories from the received transaction and + * populates the categories list. It then triggers the display of the + * publishing options pane. + */ + private void on_category_fetch_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_category_fetch_complete"); + txn.completed.disconnect(on_category_fetch_complete); + txn.network_error.disconnect(on_category_fetch_error); + debug("PiwigoConnector: list of categories: %s", txn.get_response()); + // Empty the categories + if (categories != null) { + categories = null; + } + // Parse the response + try { + Publishing.RESTSupport.XmlDocument doc = + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.validate_xml); + Xml.Node* root = doc.get_root_node(); + Xml.Node* categories_node = root->first_element_child(); + Xml.Node* category_node_iter = categories_node->children; + Xml.Node* name_node; + Xml.Node* uppercats_node; + string name = ""; + string id_string = ""; + string uppercats = ""; + for ( ; category_node_iter != null; category_node_iter = category_node_iter->next) { + name_node = doc.get_named_child(category_node_iter, "name"); + name = name_node->get_content(); + uppercats_node = doc.get_named_child(category_node_iter, "uppercats"); + uppercats = (string)uppercats_node->get_content(); + id_string = category_node_iter->get_prop("id"); + if (categories == null) { + categories = new Category[0]; + } + categories += new Category(int.parse(id_string), name, uppercats); + } + // compute the display name for the categories + // currently done by an unnecessary triple loop + // one could make a loop that goes over the categories + // and creates a list of back references cat_id -> index + // but since cat_ids are not guaranteed to be continuous + // that needs a perl hash ;-) + for(int i = 0; i < categories.length; i++) { + string[] upcatids = categories[i].uppercats.split(","); + var builder = new StringBuilder(); + for (int j=0; j < upcatids.length; j++) { + builder.append ("/ "); + // search for the upper category + for (int k=0; k < categories.length; k++) { + if (upcatids[j] == categories[k].id.to_string()) { + builder.append (categories[k].name); + break; + } + } + builder.append (" "); + } + categories[i].display_name = builder.str; + } + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: on_category_fetch_complete"); + do_show_error(err); + return; + } + + do_show_publishing_options_pane(); + } + + /** + * Event triggered when the fetch categories transaction fails due to a + * network error. + */ + private void on_category_fetch_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_category_fetch_error"); + bad_txn.completed.disconnect(on_category_fetch_complete); + bad_txn.network_error.disconnect(on_category_fetch_error); + on_network_error(bad_txn, err); + } + + /** + * Action that shows the publishing options pane. + * + * This action method shows the publishing options pane. + */ + private void do_show_publishing_options_pane() { + debug("ACTION: installing publishing options pane"); + + host.set_service_locked(false); + PublishingOptionsPane opts_pane = new PublishingOptionsPane( + this, categories, get_last_category(), get_last_permission_level(), get_last_photo_size(), + get_last_title_as_comment(), get_last_no_upload_tags(), get_metadata_removal_choice()); + opts_pane.logout.connect(on_publishing_options_pane_logout_clicked); + opts_pane.publish.connect(on_publishing_options_pane_publish_clicked); + host.install_dialog_pane(opts_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE); + host.set_dialog_default_widget(opts_pane.get_default_widget()); + } + + /** + * Event triggered when the user clicks logout in the publishing options pane. + */ + private void on_publishing_options_pane_logout_clicked() { + debug("EVENT: on_publishing_options_pane_logout_clicked"); + SessionLogoutTransaction logout_trans = new SessionLogoutTransaction(session); + logout_trans.network_error.connect(on_logout_network_error); + logout_trans.completed.connect(on_logout_network_complete); + + try { + logout_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: on_publishing_options_pane_logout_clicked"); + do_show_error(err); + } + } + + /** + * Event triggered when the logout action completes successfully. + * + * This event de-authenticates the session and shows the authentication + * pane again. + */ + private void on_logout_network_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_logout_network_complete"); + txn.completed.disconnect(on_logout_network_complete); + txn.network_error.disconnect(on_logout_network_error); + + session.deauthenticate(); + + do_show_authentication_pane(AuthenticationPane.Mode.INTRO); + } + + /** + * Event triggered when the logout action fails due to a network error. + */ + private void on_logout_network_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_logout_network_error"); + bad_txn.completed.disconnect(on_logout_network_complete); + bad_txn.network_error.disconnect(on_logout_network_error); + on_network_error(bad_txn, err); + } + + /** + * Event triggered when the user clicks publish in the publishing options pane. + * + * This event first saves the parameters so that they can re-used later. + * If the publishing parameters indicate that the user wants to create a new + * category, the create category action is called. Otherwise, the upload + * action is called. + * + * @param parameters the publishing parameters + */ + private void on_publishing_options_pane_publish_clicked(PublishingParameters parameters, + bool strip_metadata) { + debug("EVENT: on_publishing_options_pane_publish_clicked"); + this.parameters = parameters; + this.strip_metadata = strip_metadata; + + if (parameters.category.is_local()) { + do_create_category(parameters.category); + } else { + do_upload(this.strip_metadata); + } + } + + /** + * Action that creates a new category in the Piwigo library. + * + * This actions runs a REST transaction to create a new category in the + * Piwigo library. It displays a wait pane with an information message + * while the transaction is running. This action should only be called with + * a local cateogory, i.e. one that does not exist on the server and does + * not yet have an ID. + * + * @param category the new category to create on the server + */ + private void do_create_category(Category category) { + debug("ACTION: creating a new category: %s".printf(category.name)); + assert(category.is_local()); + + host.set_service_locked(true); + host.install_static_message_pane(_("Creating album %s...").printf(category.name)); + + CategoriesAddTransaction creation_trans = new CategoriesAddTransaction( + session, category.name.strip(), int.parse(category.uppercats), category.comment); + creation_trans.network_error.connect(on_category_add_error); + creation_trans.completed.connect(on_category_add_complete); + + try { + creation_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: do_create_category"); + do_show_error(err); + } + } + + /** + * Event triggered when the add category action completes successfully. + * + * This event parses the ID assigned to new category out of the received + * transaction and assigns that ID to the category currently held in + * the publishing parameters. It then calls the upload action. + */ + private void on_category_add_complete(Publishing.RESTSupport.Transaction txn) { + debug("EVENT: on_category_add_complete"); + txn.completed.disconnect(on_category_add_complete); + txn.network_error.disconnect(on_category_add_error); + + // Parse the response + try { + Publishing.RESTSupport.XmlDocument doc = + Publishing.RESTSupport.XmlDocument.parse_string( + txn.get_response(), Transaction.validate_xml); + Xml.Node* rsp = doc.get_root_node(); + Xml.Node* id_node; + id_node = doc.get_named_child(rsp, "id"); + string id_string = id_node->get_content(); + int id = int.parse(id_string); + parameters.category.id = id; + do_upload(strip_metadata); + } catch (Spit.Publishing.PublishingError err) { + debug("ERROR: on_category_add_complete"); + do_show_error(err); + } + } + + /** + * Event triggered when the add category action fails due to a network error. + */ + private void on_category_add_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_category_add_error"); + bad_txn.completed.disconnect(on_category_add_complete); + bad_txn.network_error.disconnect(on_category_add_error); + on_network_error(bad_txn, err); + } + + /** + * Upload action: the big one, the one we've been waiting for! + */ + private void do_upload(bool strip_metadata) { + this.strip_metadata = strip_metadata; + debug("ACTION: uploading pictures"); + + host.set_service_locked(true); + // Save last category, permission level and size for next use + set_last_category(parameters.category.id); + set_last_permission_level(parameters.perm_level.id); + set_last_photo_size(parameters.photo_size.id); + set_last_title_as_comment(parameters.title_as_comment); + set_last_no_upload_tags(parameters.no_upload_tags); + set_metadata_removal_choice(strip_metadata); + + progress_reporter = host.serialize_publishables(parameters.photo_size.id, this.strip_metadata); + Spit.Publishing.Publishable[] publishables = host.get_publishables(); + + Uploader uploader = new Uploader(session, publishables, parameters); + uploader.upload_complete.connect(on_upload_complete); + uploader.upload_error.connect(on_upload_error); + uploader.upload(on_upload_status_updated); + } + + /** + * Event triggered when the batch uploader reports that at least one of the + * network transactions encapsulating uploads has completed successfully + */ + private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, int num_published) { + debug("EVENT: on_upload_complete"); + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + // TODO: should a message be displayed to the user if num_published is zero? + + do_show_success_pane(); + } + + /** + * Event triggered when the batch uploader reports that at least one of the + * network transactions encapsulating uploads has caused a network error + */ + private void on_upload_error( + Publishing.RESTSupport.BatchUploader uploader, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_upload_error"); + uploader.upload_complete.disconnect(on_upload_complete); + uploader.upload_error.disconnect(on_upload_error); + + do_show_error(err); + } + + /** + * Event triggered when upload progresses and the status needs to be updated. + */ + 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); + } + + /** + * Action to display the success pane in the publishing dialog. + */ + private void do_show_success_pane() { + debug("ACTION: installing success pane"); + + host.set_service_locked(false); + host.install_success_pane(); + } + + /** + * Helper event to handle network errors. + */ + private void on_network_error( + Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err + ) { + debug("EVENT: on_network_error"); + do_show_error(err); + } + + /** + * Action to display an error to the user. + */ + private void do_show_error(Spit.Publishing.PublishingError e) { + debug("ACTION: do_show_error"); + string error_type = "UNKNOWN"; + if (e is Spit.Publishing.PublishingError.NO_ANSWER) { + do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_URL); + return; + } else if(e is Spit.Publishing.PublishingError.COMMUNICATION_FAILED) { + error_type = "COMMUNICATION_FAILED"; + } else if(e is Spit.Publishing.PublishingError.PROTOCOL_ERROR) { + error_type = "PROTOCOL_ERROR"; + } else if(e is Spit.Publishing.PublishingError.SERVICE_ERROR) { + error_type = "SERVICE_ERROR"; + } else if(e is Spit.Publishing.PublishingError.MALFORMED_RESPONSE) { + error_type = "MALFORMED_RESPONSE"; + } else if(e is Spit.Publishing.PublishingError.LOCAL_FILE_ERROR) { + error_type = "LOCAL_FILE_ERROR"; + } else if(e is Spit.Publishing.PublishingError.EXPIRED_SESSION) { + error_type = "EXPIRED_SESSION"; + } + + debug("Unhandled error: type=%s; message='%s'".printf(error_type, e.message)); + do_show_error_message(_("An error message occurred when publishing to Piwigo. Please try again.")); + } + + /** + * Action to display an error message to the user. + */ + private void do_show_error_message(string message) { + debug("ACTION: do_show_error_message"); + host.install_static_message_pane(message, + Spit.Publishing.PluginHost.ButtonMode.CLOSE); + } + + // Helper methods + + /** + * Retrieves session ID from a REST Transaction received + * + * This helper method extracts the pwg_id out of the Set-Cookie header if + * present in the received transaction. + * + * @param txn the received transaction + * @return the value of pwg_id if present or null if not found + */ + private new string? get_pwg_id_from_transaction(Publishing.RESTSupport.Transaction txn) { + string cookie = txn.get_response_headers().get_list("Set-Cookie"); + string pwg_id = null; + debug("Full cookie string: %s".printf(cookie)); + if (!is_string_empty(cookie)) { + string[] cookie_segments = cookie.split(";"); + debug("Split full string into %d individual segments".printf(cookie_segments.length)); + foreach(string cookie_segment in cookie_segments) { + debug("Individual cookie segment: %s".printf(cookie_segment)); + string[] cookie_sub_segments = cookie_segment.split(","); + debug("Split segment into %d individual sub-segments".printf(cookie_sub_segments.length)); + foreach(string cookie_sub_segment in cookie_sub_segments) { + debug("Individual cookie sub-segment: %s".printf(cookie_sub_segment)); + string[] cookie_kv = cookie_sub_segment.split("="); + debug("Split sub-segment into %d chunks".printf(cookie_kv.length)); + if (cookie_kv.length > 1 && cookie_kv[0].strip() == "pwg_id") { + debug("Found pwg_id: %s".printf(cookie_kv[1].strip())); + pwg_id = cookie_kv[1].strip(); + } + } + } + } + + return pwg_id; + } +} + +// The uploader + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private PublishingParameters parameters; + + public Uploader(Session session, Spit.Publishing.Publishable[] publishables, + PublishingParameters parameters) { + base(session, publishables); + + this.parameters = parameters; + } + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + return new ImagesAddTransaction((Session) get_session(), parameters, + publishable); + } +} + +// 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_URL, + FAILED_RETRY_USER + } + private static string INTRO_MESSAGE = _("Enter the URL of your Piwigo photo library as well as the username and password associated with your Piwigo account for that library."); + private static string FAILED_RETRY_URL_MESSAGE = _("Shotwell cannot contact your Piwigo photo library. Please verify the URL you entered"); + 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 url_entry; + private Gtk.Entry username_entry; + private Gtk.Entry password_entry; + private Gtk.CheckButton remember_password_checkbutton; + private Gtk.Button login_button; + + public signal void login(string url, string user, string password, bool remember_password); + + public AuthenticationPane(PiwigoPublisher 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("piwigo_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_URL: + message_label.set_markup("<b>%s</b>\n\n%s".printf(_( + "Invalid URL"), FAILED_RETRY_URL_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; + } + + url_entry = builder.get_object ("url_entry") as Gtk.Entry; + string? persistent_url = publisher.get_persistent_url(); + if (persistent_url != null) { + url_entry.set_text(persistent_url); + } + username_entry = builder.get_object ("username_entry") as Gtk.Entry; + string? persistent_username = publisher.get_persistent_username(); + if (persistent_username != null) { + username_entry.set_text(persistent_username); + } + password_entry = builder.get_object ("password_entry") as Gtk.Entry; + string? persistent_password = publisher.get_persistent_password(); + if (persistent_password != null) { + password_entry.set_text(persistent_password); + } + remember_password_checkbutton = + builder.get_object ("remember_password_checkbutton") as Gtk.CheckButton; + remember_password_checkbutton.set_active(publisher.get_remember_password()); + + login_button = builder.get_object("login_button") as Gtk.Button; + + username_entry.changed.connect(on_user_changed); + url_entry.changed.connect(on_url_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(url_entry.get_text(), username_entry.get_text(), + password_entry.get_text(), remember_password_checkbutton.get_active()); + } + + private void on_url_changed() { + update_login_button_sensitivity(); + } + + 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(url_entry.get_text()) && + !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() { + url_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, Object { + + private static string DEFAULT_CATEGORY_NAME = _("Shotwell Connect"); + + private Gtk.Box pane_widget = null; + private Gtk.Builder builder; + private Gtk.RadioButton use_existing_radio; + private Gtk.RadioButton create_new_radio; + private Gtk.ComboBoxText existing_categories_combo; + private Gtk.Entry new_category_entry; + private Gtk.Label within_existing_label; + private Gtk.ComboBoxText within_existing_combo; + private Gtk.ComboBoxText perms_combo; + private Gtk.ComboBoxText size_combo; + private Gtk.CheckButton strip_metadata_check = null; + private Gtk.CheckButton title_as_comment_check = null; + private Gtk.CheckButton no_upload_tags_check = null; + private Gtk.Button logout_button; + private Gtk.Button publish_button; + private Gtk.TextView album_comment; + private Gtk.Label album_comment_label; + + private Category[] existing_categories; + private PermissionLevel[] perm_levels; + private SizeEntry[] photo_sizes; + + private int last_category; + private int last_permission_level; + private int last_photo_size; + private bool last_title_as_comment; + private bool last_no_upload_tags; + + public signal void publish(PublishingParameters parameters, bool strip_metadata); + public signal void logout(); + + public PublishingOptionsPane( + PiwigoPublisher publisher, Category[] categories, + int last_category, int last_permission_level, int last_photo_size, + bool last_title_as_comment, bool last_no_upload_tags, bool strip_metadata_enabled + ) { + this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0); + this.last_category = last_category; + this.last_permission_level = last_permission_level; + this.last_photo_size = last_photo_size; + this.last_title_as_comment = last_title_as_comment; + this.last_no_upload_tags = last_no_upload_tags; + + File ui_file = publisher.get_host().get_module_file().get_parent(). + get_child("piwigo_publishing_options_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; + + use_existing_radio = builder.get_object("use_existing_radio") as Gtk.RadioButton; + create_new_radio = builder.get_object("create_new_radio") as Gtk.RadioButton; + existing_categories_combo = builder.get_object("existing_categories_combo") as Gtk.ComboBoxText; + new_category_entry = builder.get_object ("new_category_entry") as Gtk.Entry; + within_existing_label = builder.get_object ("within_existing_label") as Gtk.Label; + within_existing_combo = builder.get_object ("within_existing_combo") as Gtk.ComboBoxText; + + album_comment = builder.get_object ("album_comment") as Gtk.TextView; + album_comment.buffer = new Gtk.TextBuffer(null); + album_comment_label = builder.get_object ("album_comment_label") as Gtk.Label; + + perms_combo = builder.get_object("perms_combo") as Gtk.ComboBoxText; + size_combo = builder.get_object("size_combo") as Gtk.ComboBoxText; + + strip_metadata_check = builder.get_object("strip_metadata_check") as Gtk.CheckButton; + strip_metadata_check.set_active(strip_metadata_enabled); + + title_as_comment_check = builder.get_object("title_as_comment_check") as Gtk.CheckButton; + title_as_comment_check.set_active(last_title_as_comment); + + no_upload_tags_check = builder.get_object("no_upload_tags_check") as Gtk.CheckButton; + no_upload_tags_check.set_active(last_no_upload_tags); + + logout_button = builder.get_object("logout_button") as Gtk.Button; + logout_button.clicked.connect(on_logout_button_clicked); + + publish_button = builder.get_object("publish_button") as Gtk.Button; + publish_button.clicked.connect(on_publish_button_clicked); + + use_existing_radio.clicked.connect(on_use_existing_radio_clicked); + create_new_radio.clicked.connect(on_create_new_radio_clicked); + new_category_entry.changed.connect(on_new_category_entry_changed); + within_existing_combo.changed.connect(on_existing_combo_changed); + + align.reparent(pane_widget); + pane_widget.set_child_packing(align, true, true, 0, Gtk.PackType.START); + } catch (Error e) { + warning("Could not load UI: %s", e.message); + } + + this.existing_categories = categories; + this.perm_levels = create_perm_levels(); + this.photo_sizes = create_sizes(); + this.album_comment.buffer.set_text(get_common_comment_if_possible(publisher)); + } + + public Gtk.Widget get_default_widget() { + return publish_button; + } + + private PermissionLevel[] create_perm_levels() { + PermissionLevel[] result = new PermissionLevel[0]; + + result += new PermissionLevel(0, _("Everyone")); + result += new PermissionLevel(1, _("Admins, Family, Friends, Contacts")); + result += new PermissionLevel(2, _("Admins, Family, Friends")); + result += new PermissionLevel(4, _("Admins, Family")); + result += new PermissionLevel(8, _("Admins")); + + return result; + } + + private SizeEntry[] create_sizes() { + SizeEntry[] result = new SizeEntry[0]; + + result += new SizeEntry(500, _("500 x 375 pixels")); + result += new SizeEntry(1024, _("1024 x 768 pixels")); + result += new SizeEntry(2048, _("2048 x 1536 pixels")); + result += new SizeEntry(4096, _("4096 x 3072 pixels")); + result += new SizeEntry(ORIGINAL_SIZE, _("Original size")); + + return result; + } + + private void on_logout_button_clicked() { + logout(); + } + + private void on_publish_button_clicked() { + PublishingParameters params = new PublishingParameters(); + params.perm_level = perm_levels[perms_combo.get_active()]; + params.photo_size = photo_sizes[size_combo.get_active()]; + params.title_as_comment = title_as_comment_check.get_active(); + params.no_upload_tags = no_upload_tags_check.get_active(); + if (create_new_radio.get_active()) { + string uploadcomment = album_comment.buffer.text.strip(); + int a = within_existing_combo.get_active(); + if (a == 0) { + params.category = new Category.local(new_category_entry.get_text(), 0, uploadcomment); + } else { + // the list in existing_categories and in the within_existing_combo are shifted + // by 1, since we add the root + a--; + params.category = new Category.local(new_category_entry.get_text(), + existing_categories[a].id, uploadcomment); + } + } else { + params.category = existing_categories[existing_categories_combo.get_active()]; + } + publish(params, strip_metadata_check.get_active()); + } + + // UI interaction + private void on_use_existing_radio_clicked() { + existing_categories_combo.set_sensitive(true); + new_category_entry.set_sensitive(false); + within_existing_label.set_sensitive(false); + within_existing_combo.set_sensitive(false); + existing_categories_combo.grab_focus(); + album_comment_label.set_sensitive(false); + album_comment.set_sensitive(false); + update_publish_button_sensitivity(); + } + + private void on_create_new_radio_clicked() { + new_category_entry.set_sensitive(true); + within_existing_label.set_sensitive(true); + within_existing_combo.set_sensitive(true); + album_comment_label.set_sensitive(true); + album_comment.set_sensitive(true); + existing_categories_combo.set_sensitive(false); + new_category_entry.grab_focus(); + update_publish_button_sensitivity(); + } + + private void on_new_category_entry_changed() { + update_publish_button_sensitivity(); + } + + private void on_existing_combo_changed() { + update_publish_button_sensitivity(); + } + + private void update_publish_button_sensitivity() { + string category_name = new_category_entry.get_text().strip(); + int a = within_existing_combo.get_active(); + string search_name; + if (a <= 0) { + search_name = "/ " + category_name; + } else { + a--; + search_name = existing_categories[a].display_name + "/ " + category_name; + } + publish_button.set_sensitive( + !( + create_new_radio.get_active() && + ( + is_string_empty(category_name) || + category_already_exists(search_name) + ) + ) + ); + } + + 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() { + create_categories_combo(); + create_within_categories_combo(); + create_permissions_combo(); + create_size_combo(); + + publish_button.can_default = true; + update_publish_button_sensitivity(); + } + + private string get_common_comment_if_possible(PiwigoPublisher publisher) { + // we have to determine whether all the publishing items + // belong to the same event + Spit.Publishing.Publishable[] publishables = publisher.get_host().get_publishables(); + string common = ""; + bool isfirst = true; + if (publishables != null) { + foreach (Spit.Publishing.Publishable pub in publishables) { + string cur = pub.get_param_string( + Spit.Publishing.Publishable.PARAM_STRING_EVENTCOMMENT); + if (isfirst) { + common = cur; + isfirst = false; + } else { + if (cur != common) { + common = ""; + break; + } + } + } + } + debug("PiwigoConnector: found common event comment %s\n", common); + return common; + } + + private void create_categories_combo() { + foreach (Category cat in existing_categories) { + existing_categories_combo.append_text(cat.display_name); + } + if (existing_categories.length == 0) { + // if no existing categories, disable the option to choose one + existing_categories_combo.set_sensitive(false); + use_existing_radio.set_sensitive(false); + create_new_radio.set_active(true); + album_comment.set_sensitive(true); + album_comment_label.set_sensitive(true); + new_category_entry.grab_focus(); + } else { + int last_category_index = find_category_index(last_category); + if (last_category_index < 0) { + existing_categories_combo.set_active(0); + } else { + existing_categories_combo.set_active(last_category_index); + } + new_category_entry.set_sensitive(false); + album_comment.set_sensitive(false); + album_comment_label.set_sensitive(false); + } + if (!category_already_exists(DEFAULT_CATEGORY_NAME)) + new_category_entry.set_text(DEFAULT_CATEGORY_NAME); + } + + private void create_within_categories_combo() { + // root menu + within_existing_combo.append_text("/ "); + foreach (Category cat in existing_categories) { + within_existing_combo.append_text(cat.display_name); + } + // by default select root album as target + within_existing_label.set_sensitive(false); + within_existing_combo.set_active(0); + within_existing_combo.set_sensitive(false); + } + + private void create_permissions_combo() { + foreach (PermissionLevel perm in perm_levels) { + perms_combo.append_text(perm.name); + } + int last_permission_level_index = find_permission_level_index(last_permission_level); + if (last_permission_level_index < 0) { + perms_combo.set_active(0); + } else { + perms_combo.set_active(last_permission_level_index); + } + } + + private void create_size_combo() { + foreach (SizeEntry size in photo_sizes) { + size_combo.append_text(size.name); + } + int last_size_index = find_size_index(last_photo_size); + if (last_size_index < 0) { + size_combo.set_active(find_size_index(ORIGINAL_SIZE)); + } else { + size_combo.set_active(last_size_index); + } + } + + public void on_pane_uninstalled() { + } + + private int find_category_index(int category_id) { + int result = -1; + for(int i = 0; i < existing_categories.length; i++) { + if (existing_categories[i].id == category_id) { + result = i; + break; + } + } + return result; + } + + private int find_permission_level_index(int permission_level_id) { + int result = -1; + for(int i = 0; i < perm_levels.length; i++) { + if (perm_levels[i].id == permission_level_id) { + result = i; + break; + } + } + return result; + } + + private int find_size_index(int size_id) { + int result = -1; + for(int i = 0; i < photo_sizes.length; i++) { + if (photo_sizes[i].id == size_id) { + result = i; + break; + } + } + return result; + } + + private bool category_already_exists(string category_name) { + bool result = false; + foreach(Category category in existing_categories) { + if (category.display_name.strip() == category_name) { + result = true; + break; + } + } + return result; + } +} + +// REST support classes + +/** + * Session class that keeps track of the authentication status and of the + * user token pwg_id. + */ +internal class Session : Publishing.RESTSupport.Session { + private string? pwg_url = null; + private string? pwg_id = null; + private string? username = null; + + public Session() { + base(""); + } + + public override bool is_authenticated() { + return (pwg_id != null && pwg_url != null && username != null); + } + + public void authenticate(string url, string username, string id) { + this.pwg_url = url; + this.username = username; + this.pwg_id = id; + } + + public void deauthenticate() { + pwg_url = null; + pwg_id = null; + username = null; + } + + public string get_username() { + return username; + } + + public string get_pwg_url() { + return pwg_url; + } + + public string get_pwg_id() { + return pwg_id; + } + + public void set_pwg_id(string id) { + pwg_id = id; + } +} + +/** + * Generic REST transaction class. + * + * This class implements the generic logic for all REST transactions used + * by the Piwigo publishing plugin. In particular, it ensures that if the + * session has been authenticated, the pwg_id token is included in the + * transaction header. + */ +internal class Transaction : Publishing.RESTSupport.Transaction { + public Transaction(Session session) { + base(session); + if (session.is_authenticated()) { + add_header("Cookie", "pwg_id=".concat(session.get_pwg_id())); + } + } + + public Transaction.authenticated(Session session) { + base.with_endpoint_url(session, session.get_pwg_url()); + add_header("Cookie", "pwg_id=".concat(session.get_pwg_id())); + } + + public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* root = doc.get_root_node(); + string? status = root->get_prop("stat"); + + // treat malformed root as an error condition + if (status == null) + return "No status property in root node"; + + if (status == "ok") + return null; + + Xml.Node* errcode; + try { + errcode = doc.get_named_child(root, "err"); + } catch (Spit.Publishing.PublishingError err) { + return "No error code specified"; + } + + return "%s (error code %s)".printf(errcode->get_prop("msg"), errcode->get_prop("code")); + } + + public static new string? get_error_code(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* root = doc.get_root_node(); + Xml.Node* errcode; + try { + errcode = doc.get_named_child(root, "err"); + } catch (Spit.Publishing.PublishingError err) { + return "0"; + } + return errcode->get_prop("code"); + } +} + +/** + * Transaction used to implement the network login interaction. + */ +internal class SessionLoginTransaction : Transaction { + public SessionLoginTransaction(Session session, string url, string username, string password) { + base.with_endpoint_url(session, url); + + add_argument("method", "pwg.session.login"); + add_argument("username", username); + add_argument("password", password); + } +} + +/** + * Transaction used to implement the get status interaction. + */ +internal class SessionGetStatusTransaction : Transaction { + public SessionGetStatusTransaction.unauthenticated(Session session, string url, string pwg_id) { + base.with_endpoint_url(session, url); + add_header("Cookie", "pwg_id=".concat(session.get_pwg_id())); + + add_argument("method", "pwg.session.getStatus"); + } + + public SessionGetStatusTransaction(Session session) { + base.authenticated(session); + + add_argument("method", "pwg.session.getStatus"); + } +} + +/** + * Transaction used to implement the fetch categories interaction. + */ +private class CategoriesGetListTransaction : Transaction { + public CategoriesGetListTransaction(Session session) { + base.authenticated(session); + + add_argument("method", "pwg.categories.getList"); + add_argument("recursive", "true"); + } +} + +private class SessionLogoutTransaction : Transaction { + public SessionLogoutTransaction(Session session) { + base.authenticated(session); + + add_argument("method", "pwg.session.logout"); + } +} + +private class CategoriesAddTransaction : Transaction { + public CategoriesAddTransaction(Session session, string category, int parent_id = 0, string? comment = "") { + base.authenticated(session); + + add_argument("method", "pwg.categories.add"); + add_argument("name", category); + + if (parent_id != 0) { + add_argument("parent", parent_id.to_string()); + } + + if (comment != "") { + add_argument("comment", comment); + } + } +} + +private class ImagesAddTransaction : Publishing.RESTSupport.UploadTransaction { + private PublishingParameters parameters = null; + + public ImagesAddTransaction(Session session, PublishingParameters parameters, Spit.Publishing.Publishable publishable) { + base.with_endpoint_url(session, publishable, session.get_pwg_url()); + if (session.is_authenticated()) { + add_header("Cookie", "pwg_id=".concat(session.get_pwg_id())); + } + this.parameters = parameters; + + string[] keywords = publishable.get_publishing_keywords(); + string tags = ""; + if (keywords != null) { + foreach (string tag in keywords) { + if (!is_string_empty(tags)) { + tags += ","; + } + tags += tag; + } + } + + debug("PiwigoConnector: Uploading photo %s to category id %d with perm level %d", + publishable.get_serialized_file().get_basename(), + parameters.category.id, parameters.perm_level.id); + string name = publishable.get_publishing_name(); + string comment = publishable.get_param_string( + Spit.Publishing.Publishable.PARAM_STRING_COMMENT); + if (is_string_empty(name)) { + name = publishable.get_param_string( + Spit.Publishing.Publishable.PARAM_STRING_BASENAME); + add_argument("name", name); + if (!is_string_empty(comment)) { + add_argument("comment", comment); + } + } else { + // name is set + if (!is_string_empty(comment)) { + add_argument("name", name); + add_argument("comment", comment); + } else { + // name is set, comment is unset + // for backward compatibility with people having used + // the title as comment field, keep this option + if (parameters.title_as_comment) { + add_argument("comment", name); + } else { + add_argument("name", name); + } + } + } + add_argument("method", "pwg.images.addSimple"); + add_argument("category", parameters.category.id.to_string()); + add_argument("level", parameters.perm_level.id.to_string()); + if (!parameters.no_upload_tags) + if (!is_string_empty(tags)) + add_argument("tags", tags); + // TODO: update the Publishable interface so that it gives access to + // the image's meta-data where the author (artist) is kept + /*if (!is_string_empty(author)) + add_argument("author", author);*/ + + // TODO: implement description in APIGlue + /*if (!is_string_empty(publishable.get_publishing_description())) + add_argument("comment", publishable.get_publishing_description());*/ + + GLib.HashTable<string, string> disposition_table = + new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal); + disposition_table.insert("filename", Soup.URI.encode( + publishable.get_param_string( + Spit.Publishing.Publishable.PARAM_STRING_BASENAME), null)); + disposition_table.insert("name", "image"); + + set_binary_disposition_table(disposition_table); + } +} + +} // namespace + diff --git a/plugins/shotwell-publishing/YouTubePublishing.vala b/plugins/shotwell-publishing/YouTubePublishing.vala new file mode 100644 index 0000000..c84240b --- /dev/null +++ b/plugins/shotwell-publishing/YouTubePublishing.vala @@ -0,0 +1,627 @@ +/* Copyright 2009-2014 Yorba Foundation + * + * This software is licensed under the GNU LGPL (version 2.1 or later). + * See the COPYING file in this distribution. + */ + +public class YouTubeService : Object, Spit.Pluggable, Spit.Publishing.Service { + private const string ICON_FILENAME = "youtube.png"; + + private static Gdk.Pixbuf[] icon_pixbuf_set = null; + + public YouTubeService(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.youtube"; + } + + public unowned string get_pluggable_name() { + return "YouTube"; + } + + public void get_info(ref Spit.PluggableInfo info) { + info.authors = "Jani Monoses, Lucas Beeler"; + info.copyright = _("Copyright 2009-2014 Yorba Foundation"); + 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 Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) { + return new Publishing.YouTube.YouTubePublisher(this, host); + } + + public Spit.Publishing.Publisher.MediaType get_supported_media() { + return Spit.Publishing.Publisher.MediaType.VIDEO; + } + + public void activation(bool enabled) { + } +} + +namespace Publishing.YouTube { + +private const string SERVICE_WELCOME_MESSAGE = + _("You are not currently logged into YouTube.\n\nYou must have already signed up for a Google account and set it up for use with YouTube to continue. You can set up most accounts by using your browser to log into the YouTube site at least once."); +private const string DEVELOPER_KEY = + "AI39si5VEpzWK0z-pzo4fonEj9E4driCpEs9lK8y3HJsbbebIIRWqW3bIyGr42bjQv-N3siAfqVoM8XNmtbbp5x2gpbjiSAMTQ"; + +private enum PrivacySetting { + PUBLIC, + UNLISTED, + PRIVATE +} + +private class PublishingParameters { + private PrivacySetting privacy; + private string? channel_name; + private string? user_name; + + public PublishingParameters() { + this.privacy = PrivacySetting.PRIVATE; + this.channel_name = null; + this.user_name = null; + } + + public PrivacySetting get_privacy() { + return this.privacy; + } + + public void set_privacy(PrivacySetting privacy) { + this.privacy = privacy; + } + + public string? get_channel_name() { + return channel_name; + } + + public void set_channel_name(string? channel_name) { + this.channel_name = channel_name; + } + + public string? get_user_name() { + return user_name; + } + + public void set_user_name(string? user_name) { + this.user_name = user_name; + } +} + +public class YouTubePublisher : Publishing.RESTSupport.GooglePublisher { + private class ChannelDirectoryTransaction : + Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private const string ENDPOINT_URL = "http://gdata.youtube.com/feeds/users/default"; + + public ChannelDirectoryTransaction(Publishing.RESTSupport.GoogleSession session) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.GET); + } + + public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) { + Xml.Node* document_root = doc.get_root_node(); + if ((document_root->name == "feed") || (document_root->name == "entry")) + return null; + else + return "response root node isn't a <feed> or <entry>"; + } + } + + private bool running; + private string? refresh_token; + private PublishingParameters publishing_parameters; + private Spit.Publishing.ProgressCallback? progress_reporter; + + public YouTubePublisher(Spit.Publishing.Service service, Spit.Publishing.PluginHost host) { + base(service, host, "https://gdata.youtube.com/"); + + this.running = false; + this.refresh_token = host.get_config_string("refresh_token", null); + this.publishing_parameters = new PublishingParameters(); + this.progress_reporter = null; + } + + public override bool is_running() { + return running; + } + + public override void start() { + debug("YouTubePublisher: started."); + + if (is_running()) + return; + + running = true; + + if (refresh_token == null) + do_show_service_welcome_pane(); + else + start_oauth_flow(refresh_token); + } + + public override void stop() { + debug("YouTubePublisher: stopped."); + + running = false; + + get_session().stop_transactions(); + } + + private string extract_channel_name_helper(Xml.Node* document_root) throws + Spit.Publishing.PublishingError { + string result = ""; + + Xml.Node* doc_node_iter = null; + if (document_root->name == "feed") + doc_node_iter = document_root->children; + else if (document_root->name == "entry") + doc_node_iter = document_root; + else + throw new Spit.Publishing.PublishingError.MALFORMED_RESPONSE( + "response root node isn't a <feed> or <entry>"); + + for ( ; doc_node_iter != null; doc_node_iter = doc_node_iter->next) { + if (doc_node_iter->name != "entry") + continue; + + string name_val = null; + string url_val = null; + Xml.Node* channel_node_iter = doc_node_iter->children; + for ( ; channel_node_iter != null; channel_node_iter = channel_node_iter->next) { + if (channel_node_iter->name == "title") { + name_val = channel_node_iter->get_content(); + } else if (channel_node_iter->name == "id") { + // we only want nodes in the default namespace -- the feed that we get back + // from Google also defines <entry> child nodes named <id> in the media + // namespace + if (channel_node_iter->ns->prefix != null) + continue; + url_val = channel_node_iter->get_content(); + } + } + + result = name_val; + break; + } + + debug("YouTubePublisher: extracted channel name '%s' from response XML.", result); + + return result; + } + + private void on_service_welcome_login() { + debug("EVENT: user clicked 'Login' in welcome pane."); + + if (!is_running()) + return; + + start_oauth_flow(refresh_token); + } + + protected override void on_login_flow_complete() { + debug("EVENT: OAuth login flow complete."); + + get_host().set_config_string("refresh_token", get_session().get_refresh_token()); + + publishing_parameters.set_user_name(get_session().get_user_name()); + + do_fetch_account_information(); + } + + private void on_initial_channel_fetch_complete(Publishing.RESTSupport.Transaction txn) { + txn.completed.disconnect(on_initial_channel_fetch_complete); + txn.network_error.disconnect(on_initial_channel_fetch_error); + + debug("EVENT: finished fetching account and channel information."); + + if (!is_running()) + return; + + do_parse_and_display_account_information((ChannelDirectoryTransaction) txn); + } + + private void on_initial_channel_fetch_error(Publishing.RESTSupport.Transaction bad_txn, + Spit.Publishing.PublishingError err) { + bad_txn.completed.disconnect(on_initial_channel_fetch_complete); + bad_txn.network_error.disconnect(on_initial_channel_fetch_error); + + debug("EVENT: fetching account and channel information failed; response = '%s'.", + bad_txn.get_response()); + + if (!is_running()) + return; + + get_host().post_error(err); + } + + private void on_publishing_options_logout() { + debug("EVENT: user clicked 'Logout' in the publishing options pane."); + + if (!is_running()) + return; + + do_logout(); + } + + private void on_publishing_options_publish() { + debug("EVENT: user clicked 'Publish' in the publishing options pane."); + + if (!is_running()) + return; + + do_upload(); + } + + 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); + + if (!is_running()) + return; + + progress_reporter(file_number, completed_fraction); + } + + 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); + + debug("EVENT: uploader reports upload complete; %d items published.", num_published); + + if (!is_running()) + return; + + do_show_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); + + if (!is_running()) + return; + + debug("EVENT: uploader reports upload error = '%s'.", err.message); + + get_host().post_error(err); + } + + private void do_show_service_welcome_pane() { + debug("ACTION: showing service welcome pane."); + + get_host().install_welcome_pane(SERVICE_WELCOME_MESSAGE, on_service_welcome_login); + } + + private void do_fetch_account_information() { + debug("ACTION: fetching channel information."); + + get_host().install_account_fetch_wait_pane(); + get_host().set_service_locked(true); + + ChannelDirectoryTransaction directory_trans = + new ChannelDirectoryTransaction(get_session()); + directory_trans.network_error.connect(on_initial_channel_fetch_error); + directory_trans.completed.connect(on_initial_channel_fetch_complete); + + try { + directory_trans.execute(); + } catch (Spit.Publishing.PublishingError err) { + on_initial_channel_fetch_error(directory_trans, err); + } + } + + private void do_parse_and_display_account_information(ChannelDirectoryTransaction transaction) { + debug("ACTION: extracting account and channel information from body of server response"); + + Publishing.RESTSupport.XmlDocument response_doc; + try { + response_doc = Publishing.RESTSupport.XmlDocument.parse_string( + transaction.get_response(), ChannelDirectoryTransaction.validate_xml); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + try { + publishing_parameters.set_channel_name(extract_channel_name_helper( + response_doc.get_root_node())); + } catch (Spit.Publishing.PublishingError err) { + get_host().post_error(err); + return; + } + + do_show_publishing_options_pane(); + } + + private void do_show_publishing_options_pane() { + debug("ACTION: showing publishing options pane."); + + Gtk.Builder builder = new Gtk.Builder(); + + try { + builder.add_from_file( + get_host().get_module_file().get_parent().get_child("youtube_publishing_options_pane.glade").get_path()); + } catch (Error e) { + warning("Could not parse UI file! Error: %s.", e.message); + get_host().post_error( + new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR( + _("A file required for publishing is unavailable. Publishing to Youtube can't continue."))); + return; + } + + PublishingOptionsPane opts_pane = new PublishingOptionsPane(get_host(), builder, + publishing_parameters); + opts_pane.publish.connect(on_publishing_options_publish); + opts_pane.logout.connect(on_publishing_options_logout); + get_host().install_dialog_pane(opts_pane); + + get_host().set_service_locked(false); + } + + private void do_upload() { + debug("ACTION: uploading media items to remote server."); + + get_host().set_service_locked(true); + get_host().install_account_fetch_wait_pane(); + + + progress_reporter = get_host().serialize_publishables(-1); + + // 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; + + Spit.Publishing.Publishable[] publishables = get_host().get_publishables(); + Uploader uploader = new Uploader(get_session(), publishables, publishing_parameters); + + 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."); + + get_host().set_service_locked(false); + get_host().install_success_pane(); + } + + protected override void do_logout() { + debug("ACTION: logging out user."); + + get_session().deauthenticate(); + refresh_token = null; + get_host().unset_config_key("refresh_token"); + + + do_show_service_welcome_pane(); + } +} + +internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { + private class PrivacyDescription { + public string description; + public PrivacySetting privacy_setting; + + public PrivacyDescription(string description, PrivacySetting privacy_setting) { + this.description = description; + this.privacy_setting = privacy_setting; + } + } + + public signal void publish(); + public signal void logout(); + + private Gtk.Box pane_widget = null; + private Gtk.ComboBoxText privacy_combo = null; + private Gtk.Label publish_to_label = null; + private Gtk.Label login_identity_label = null; + private Gtk.Button publish_button = null; + private Gtk.Button logout_button = null; + private Gtk.Builder builder = null; + private Gtk.Label privacy_label = null; + private PrivacyDescription[] privacy_descriptions; + private PublishingParameters publishing_parameters; + + public PublishingOptionsPane(Spit.Publishing.PluginHost host, Gtk.Builder builder, + PublishingParameters publishing_parameters) { + this.privacy_descriptions = create_privacy_descriptions(); + this.publishing_parameters = publishing_parameters; + + this.builder = builder; + assert(builder != null); + assert(builder.get_objects().length() > 0); + + login_identity_label = this.builder.get_object("login_identity_label") as Gtk.Label; + privacy_combo = this.builder.get_object("privacy_combo") as Gtk.ComboBoxText; + publish_to_label = this.builder.get_object("publish_to_label") as Gtk.Label; + publish_button = this.builder.get_object("publish_button") as Gtk.Button; + logout_button = this.builder.get_object("logout_button") as Gtk.Button; + pane_widget = this.builder.get_object("youtube_pane_widget") as Gtk.Box; + privacy_label = this.builder.get_object("privacy_label") as Gtk.Label; + + login_identity_label.set_label(_("You are logged into YouTube as %s.").printf( + publishing_parameters.get_user_name())); + publish_to_label.set_label(_("Videos will appear in '%s'").printf( + publishing_parameters.get_channel_name())); + + foreach(PrivacyDescription desc in privacy_descriptions) { + privacy_combo.append_text(desc.description); + } + + privacy_combo.set_active(PrivacySetting.PUBLIC); + privacy_label.set_mnemonic_widget(privacy_combo); + + logout_button.clicked.connect(on_logout_clicked); + publish_button.clicked.connect(on_publish_clicked); + } + + private void on_publish_clicked() { + publishing_parameters.set_privacy( + privacy_descriptions[privacy_combo.get_active()].privacy_setting); + + publish(); + } + + private void on_logout_clicked() { + logout(); + } + + private void update_publish_button_sensitivity() { + publish_button.set_sensitive(true); + } + + private PrivacyDescription[] create_privacy_descriptions() { + PrivacyDescription[] result = new PrivacyDescription[0]; + + result += new PrivacyDescription(_("Public listed"), PrivacySetting.PUBLIC); + result += new PrivacyDescription(_("Public unlisted"), PrivacySetting.UNLISTED); + result += new PrivacyDescription(_("Private"), PrivacySetting.PRIVATE); + + return result; + } + + public Gtk.Widget get_widget() { + assert (pane_widget != null); + return pane_widget; + } + + public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() { + return Spit.Publishing.DialogPane.GeometryOptions.NONE; + } + + public void on_pane_installed() { + update_publish_button_sensitivity(); + } + + public void on_pane_uninstalled() { + } +} + +internal class UploadTransaction : Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { + private const string ENDPOINT_URL = "http://uploads.gdata.youtube.com/feeds/api/users/default/uploads"; + private const string UNLISTED_XML = "<yt:accessControl action='list' permission='denied'/>"; + private const string PRIVATE_XML = "<yt:private/>"; + private const string METADATA_TEMPLATE ="""<?xml version='1.0'?> + <entry xmlns='http://www.w3.org/2005/Atom' + xmlns:media='http://search.yahoo.com/mrss/' + xmlns:yt='http://gdata.youtube.com/schemas/2007'> + <media:group> + <media:title type='plain'>%s</media:title> + <media:category + scheme='http://gdata.youtube.com/schemas/2007/categories.cat'>People + </media:category> + %s + </media:group> + %s + </entry>"""; + private PublishingParameters parameters; + private Publishing.RESTSupport.GoogleSession session; + private Spit.Publishing.Publishable publishable; + + public UploadTransaction(Publishing.RESTSupport.GoogleSession session, + PublishingParameters parameters, Spit.Publishing.Publishable publishable) { + base(session, ENDPOINT_URL, Publishing.RESTSupport.HttpMethod.POST); + assert(session.is_authenticated()); + this.session = session; + this.parameters = parameters; + this.publishable = publishable; + } + + public override void execute() throws Spit.Publishing.PublishingError { + // create the multipart request container + Soup.Multipart message_parts = new Soup.Multipart("multipart/related"); + + string unlisted_video = + (parameters.get_privacy() == PrivacySetting.UNLISTED) ? UNLISTED_XML : ""; + + string private_video = + (parameters.get_privacy() == PrivacySetting.PRIVATE) ? PRIVATE_XML : ""; + + // Set title to publishing name, but if that's empty default to filename. + string title = publishable.get_publishing_name(); + if (title == "") { + title = publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME); + } + + string metadata = METADATA_TEMPLATE.printf(Publishing.RESTSupport.decimal_entity_encode(title), + private_video, unlisted_video); + Soup.Buffer metadata_buffer = new Soup.Buffer(Soup.MemoryUse.COPY, metadata.data); + message_parts.append_form_file("", "", "application/atom+xml", metadata_buffer); + + // attempt to read the binary video data from disk + string video_data; + size_t data_length; + try { + FileUtils.get_contents(publishable.get_serialized_file().get_path(), out video_data, + out data_length); + } catch (FileError e) { + string msg = "YouTube: couldn't read data from %s: %s".printf( + publishable.get_serialized_file().get_path(), e.message); + warning("%s", msg); + + throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR(msg); + } + + // bind the binary video data read from disk into a Soup.Buffer object so that we + // can attach it to the multipart request, then actaully append the buffer + // to the multipart request. Then, set the MIME type for this part. + Soup.Buffer bindable_data = new Soup.Buffer(Soup.MemoryUse.COPY, + video_data.data[0:data_length]); + + message_parts.append_form_file("", publishable.get_serialized_file().get_path(), + "video/mpeg", bindable_data); + // create a message that can be sent over the wire whose payload is the multipart container + // that we've been building up + Soup.Message outbound_message = + soup_form_request_new_from_multipart(get_endpoint_url(), message_parts); + outbound_message.request_headers.append("X-GData-Key", "key=%s".printf(DEVELOPER_KEY)); + outbound_message.request_headers.append("Slug", + publishable.get_param_string(Spit.Publishing.Publishable.PARAM_STRING_BASENAME)); + outbound_message.request_headers.append("Authorization", "Bearer " + + session.get_access_token()); + set_message(outbound_message); + + // send the message and get its response + set_is_executed(true); + send(); + } +} + +internal class Uploader : Publishing.RESTSupport.BatchUploader { + private PublishingParameters parameters; + + public Uploader(Publishing.RESTSupport.GoogleSession session, + Spit.Publishing.Publishable[] publishables, PublishingParameters parameters) { + base(session, publishables); + + this.parameters = parameters; + } + + protected override Publishing.RESTSupport.Transaction create_transaction( + Spit.Publishing.Publishable publishable) { + return new UploadTransaction((Publishing.RESTSupport.GoogleSession) get_session(), + parameters, get_current_publishable()); + } +} + +} + diff --git a/plugins/shotwell-publishing/facebook.png b/plugins/shotwell-publishing/facebook.png Binary files differnew file mode 100644 index 0000000..384609f --- /dev/null +++ b/plugins/shotwell-publishing/facebook.png diff --git a/plugins/shotwell-publishing/facebook_publishing_options_pane.glade b/plugins/shotwell-publishing/facebook_publishing_options_pane.glade new file mode 100644 index 0000000..7eb30f6 --- /dev/null +++ b/plugins/shotwell-publishing/facebook_publishing_options_pane.glade @@ -0,0 +1,243 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="facebook_pane_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkBox" id="facebook_pane_inner_box"> + <property name="width_request">1</property> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">16</property> + <child> + <object class="GtkLabel" id="how_to_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0.30000001192092896</property> + <property name="ypad">16</property> + <property name="label" translatable="no"> (text depends on fb username and is modified in the app - +anything put into this field won't display)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkGrid" id="grid1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="row_spacing">8</property> + <property name="column_spacing">32</property> + <property name="column_homogeneous">True</property> + <child> + <object class="GtkRadioButton" id="use_existing_radio"> + <property name="label" translatable="yes">Publish to an e_xisting album:</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</property> + <property name="draw_indicator">True</property> + <property name="group">create_new_radio</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">0</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkRadioButton" id="create_new_radio"> + <property name="label" translatable="yes">Create a _new album named:</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="margin_bottom">8</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="new_album_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="margin_bottom">8</property> + <property name="invisible_char">●</property> + <property name="invisible_char_set">True</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="existing_albums_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">0</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="size_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Upload _size:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">resolution_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">3</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="resolution_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">start</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">3</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="visibility_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Videos and new photo albums _visible to:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">visibility_combo</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">2</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="visibility_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">start</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">2</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">4</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="strip_metadata_check"> + <property name="label" translatable="yes">_Remove location, camera, and other identifying information before uploading</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.10000000149011612</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="spacing">32</property> + <property name="homogeneous">True</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">True</property> + <property name="padding">80</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">True</property> + <property name="padding">80</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">2</property> + <property name="position">3</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="padding">8</property> + <property name="pack_type">end</property> + <property name="position">0</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/flickr.png b/plugins/shotwell-publishing/flickr.png Binary files differnew file mode 100644 index 0000000..b6cae3e --- /dev/null +++ b/plugins/shotwell-publishing/flickr.png diff --git a/plugins/shotwell-publishing/flickr_pin_entry_pane.glade b/plugins/shotwell-publishing/flickr_pin_entry_pane.glade new file mode 100644 index 0000000..e20fb38 --- /dev/null +++ b/plugins/shotwell-publishing/flickr_pin_entry_pane.glade @@ -0,0 +1,101 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="pane_widget"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkLabel" id="explanatory_text"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">16</property> + <property name="margin_right">16</property> + <property name="margin_top">48</property> + <property name="margin_bottom">16</property> + <property name="label" translatable="yes">Enter the confirmation number which appears after you log into Flickr in your Web browser.</property> + <property name="angle">0.0099999997764825821</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</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="margin_bottom">80</property> + <child> + <object class="GtkLabel" id="pin_entry_caption"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xpad">1</property> + <property name="ypad">1</property> + <property name="label" translatable="yes">Authorization _Number:</property> + <property name="use_underline">True</property> + <property name="justify">right</property> + <property name="mnemonic_widget">pin_entry</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="pin_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="margin_left">8</property> + <property name="invisible_char">●</property> + <property name="invisible_char_set">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</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="GtkBox" id="box1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkButton" id="continue_button"> + <property name="label" translatable="yes">Con_tinue</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="margin_left">240</property> + <property name="margin_right">240</property> + <property name="margin_bottom">160</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="position">3</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/flickr_publishing_options_pane.glade b/plugins/shotwell-publishing/flickr_publishing_options_pane.glade new file mode 100644 index 0000000..995f2c2 --- /dev/null +++ b/plugins/shotwell-publishing/flickr_publishing_options_pane.glade @@ -0,0 +1,183 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="flickr_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">24</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 Flickr 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="margin_bottom">8</property> + <property name="row_spacing">8</property> + <property name="column_spacing">24</property> + <child> + <object class="GtkLabel" id="visibility_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="no">_visibility label (populated in the code)</property> + <property name="use_underline">True</property> + <property name="justify">right</property> + <property name="mnemonic_widget">visibility_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="visibility_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> + <child> + <object class="GtkCheckButton" id="strip_metadata_check"> + <property name="label" translatable="yes">_Remove location, camera, and other identifying information before uploading</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="halign">center</property> + <property name="margin_top">16</property> + <property name="use_underline">True</property> + <property name="xalign">0.17000000178813934</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">2</property> + <property name="width">2</property> + <property name="height">1</property> + </packing> + </child> + </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="margin_bottom">8</property> + <property name="spacing">64</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkButton" id="logout_button"> + <property name="label" translatable="yes">_Logout</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_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="width_request">96</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">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="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/picasa.png b/plugins/shotwell-publishing/picasa.png Binary files differnew file mode 100644 index 0000000..999be78 --- /dev/null +++ b/plugins/shotwell-publishing/picasa.png diff --git a/plugins/shotwell-publishing/picasa_publishing_options_pane.glade b/plugins/shotwell-publishing/picasa_publishing_options_pane.glade new file mode 100644 index 0000000..adee4d2 --- /dev/null +++ b/plugins/shotwell-publishing/picasa_publishing_options_pane.glade @@ -0,0 +1,263 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="picasa_pane_widget"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">1</property> + <child> + <placeholder/> + </child> + <child> + <object class="GtkLabel" id="login_identity_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">12</property> + <property name="margin_bottom">32</property> + <property name="label" translatable="no">'you are logged in as $name' +(populated in the application code)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkBox" id="album_gallery_layout_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">32</property> + <property name="margin_right">32</property> + <property name="orientation">vertical</property> + <child> + <placeholder/> + </child> + <child> + <object class="GtkGrid" id="album_choice_area_grid"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">24</property> + <property name="margin_right">24</property> + <property name="row_spacing">8</property> + <property name="column_homogeneous">True</property> + <child> + <object class="GtkComboBoxText" id="existing_albums_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">1</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="new_album_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="top_attach">2</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkRadioButton" id="use_existing_radio"> + <property name="label" translatable="yes">An _existing album:</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</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + <property name="group">create_new_radio</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="GtkRadioButton" id="create_new_radio"> + <property name="label" translatable="yes">A _new album named:</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</property> + <property name="yalign">0.47999998927116394</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">2</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="public_check"> + <property name="label" translatable="yes">L_ist album in public gallery</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</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">3</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="publish_to_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">32</property> + <property name="xalign">0</property> + <property name="label" translatable="no">$mediatype will appear in +(populated in code)</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">0</property> + <property name="width">2</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">32</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Photo _size preset:</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">4</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="margin_top">32</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">4</property> + <property name="width">1</property> + <property name="height">1</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="strip_metadata_check"> + <property name="label" translatable="yes">_Remove location, camera, and other identifying information before uploading</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="halign">center</property> + <property name="margin_top">16</property> + <property name="hexpand">True</property> + <property name="use_underline">True</property> + <property name="xalign">0.20000000298023224</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">5</property> + <property name="width">2</property> + <property name="height">1</property> + </packing> + </child> + <child> + <placeholder/> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <placeholder/> + </child> + <child> + <object class="GtkBox" id="button_area_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">112</property> + <property name="margin_right">112</property> + <property name="margin_top">48</property> + <property name="margin_bottom">24</property> + <property name="spacing">128</property> + <property name="homogeneous">True</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">True</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">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">4</property> + </packing> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/piwigo.png b/plugins/shotwell-publishing/piwigo.png Binary files differnew file mode 100644 index 0000000..cf9dbc3 --- /dev/null +++ b/plugins/shotwell-publishing/piwigo.png diff --git a/plugins/shotwell-publishing/piwigo_authentication_pane.glade b/plugins/shotwell-publishing/piwigo_authentication_pane.glade new file mode 100644 index 0000000..367f957 --- /dev/null +++ b/plugins/shotwell-publishing/piwigo_authentication_pane.glade @@ -0,0 +1,175 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <requires lib="gtk+" version="2.16"/> + <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">3</property> + <property name="n_columns">2</property> + <property name="column_spacing">8</property> + <property name="row_spacing">2</property> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">_URL of your Piwigo photo library</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">url_entry</property> + </object> + </child> + <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">User _name</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">username_entry</property> + </object> + <packing> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </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">2</property> + <property name="bottom_attach">3</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="url_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="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> + <property name="top_attach">1</property> + <property name="bottom_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">2</property> + <property name="bottom_attach">3</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="GtkCheckButton" id="remember_password_checkbutton"> + <property name="label" translatable="yes">_Remember Password</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">False</property> + <property name="use_action_appearance">False</property> + <property name="use_underline">True</property> + <property name="xalign">0</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">2</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="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/piwigo_publishing_options_pane.glade b/plugins/shotwell-publishing/piwigo_publishing_options_pane.glade new file mode 100644 index 0000000..7197603 --- /dev/null +++ b/plugins/shotwell-publishing/piwigo_publishing_options_pane.glade @@ -0,0 +1,314 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkWindow" id="publishing_options_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="GtkBox" id="vbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">10</property> + <property name="margin_right">10</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">0</property> + <property name="label" translatable="yes">Photos will appear in:</property> + </object> + <packing> + <property name="expand">True</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">7</property> + <property name="n_columns">2</property> + <child> + <object class="GtkRadioButton" id="use_existing_radio"> + <property name="label" translatable="yes">An _existing category:</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">1</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + </object> + </child> + <child> + <object class="GtkRadioButton" id="create_new_radio"> + <property name="label" translatable="yes">A _new album named:</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">1</property> + <property name="active">True</property> + <property name="draw_indicator">True</property> + <property name="group">use_existing_radio</property> + </object> + <packing> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="existing_categories_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="new_category_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> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="within_existing_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkSeparator" id="hseparator1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="right_attach">2</property> + <property name="top_attach">4</property> + <property name="bottom_attach">5</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="xpad">5</property> + <property name="label" translatable="yes">Photos will be _visible by:</property> + <property name="use_underline">True</property> + <property name="mnemonic_widget">perms_combo</property> + </object> + <packing> + <property name="top_attach">5</property> + <property name="bottom_attach">6</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="perms_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">5</property> + <property name="bottom_attach">6</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label3"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="xpad">5</property> + <property name="label" translatable="yes">Photo size:</property> + </object> + <packing> + <property name="top_attach">6</property> + <property name="bottom_attach">7</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="size_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">6</property> + <property name="bottom_attach">7</property> + <property name="y_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="within_existing_label"> + <property name="visible">True</property> + <property name="sensitive">False</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="xpad">5</property> + <property name="label" translatable="yes">within category:</property> + </object> + <packing> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + </packing> + </child> + <child> + <object class="GtkScrolledWindow" id="album_comment_scroll"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="shadow_type">in</property> + <child> + <object class="GtkTextView" id="album_comment"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="wrap_mode">word</property> + </object> + </child> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">3</property> + <property name="bottom_attach">4</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="album_comment_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="xalign">1</property> + <property name="yalign">0</property> + <property name="xpad">5</property> + <property name="label" translatable="yes">Album comment:</property> + </object> + <packing> + <property name="top_attach">3</property> + <property name="bottom_attach">4</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="GtkCheckButton" id="strip_metadata_check"> + <property name="label" translatable="yes">_Remove location, camera, and other identifying information before uploading</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</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="title_as_comment_check"> + <property name="label" translatable="yes">_If a title is set and comment unset, use title as comment</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</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">3</property> + </packing> + </child> + <child> + <object class="GtkCheckButton" id="no_upload_tags_check"> + <property name="label" translatable="yes">_Do no upload tags</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</property> + <property name="draw_indicator">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">4</property> + </packing> + </child> + <child> + <object class="GtkButtonBox" id="hbuttonbox1"> + <property name="visible">True</property> + <property name="can_focus">False</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> + </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> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">5</property> + </packing> + </child> + </object> + </child> + </object> + </child> + </object> +</interface> diff --git a/plugins/shotwell-publishing/shotwell-publishing.vala b/plugins/shotwell-publishing/shotwell-publishing.vala new file mode 100644 index 0000000..f9f2080 --- /dev/null +++ b/plugins/shotwell-publishing/shotwell-publishing.vala @@ -0,0 +1,50 @@ +/* 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; + +// "core services" are: Facebook, Flickr, Picasa Web Albums, Piwigo and YouTube +private class ShotwellPublishingCoreServices : Object, Spit.Module { + private Spit.Pluggable[] pluggables = new Spit.Pluggable[0]; + + // we need to get a module file handle because our pluggables have to load resources from the + // module file directory + public ShotwellPublishingCoreServices(GLib.File module_file) { + GLib.File resource_directory = module_file.get_parent(); + + pluggables += new FacebookService(resource_directory); + pluggables += new PicasaService(resource_directory); + pluggables += new FlickrService(resource_directory); + pluggables += new YouTubeService(resource_directory); + pluggables += new PiwigoService(resource_directory); + } + + public unowned string get_module_name() { + return _("Core Publishing Services"); + } + + public unowned string get_version() { + return _VERSION; + } + + public unowned string get_id() { + return "org.yorba.shotwell.publishing.core_services"; + } + + 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 ShotwellPublishingCoreServices(params->module_file) : null; +} + diff --git a/plugins/shotwell-publishing/youtube.png b/plugins/shotwell-publishing/youtube.png Binary files differnew file mode 100644 index 0000000..214e1de --- /dev/null +++ b/plugins/shotwell-publishing/youtube.png diff --git a/plugins/shotwell-publishing/youtube_publishing_options_pane.glade b/plugins/shotwell-publishing/youtube_publishing_options_pane.glade new file mode 100644 index 0000000..cdf82af --- /dev/null +++ b/plugins/shotwell-publishing/youtube_publishing_options_pane.glade @@ -0,0 +1,137 @@ +<?xml version="1.0" encoding="UTF-8"?> +<interface> + <!-- interface-requires gtk+ 3.0 --> + <object class="GtkBox" id="youtube_pane_widget"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">1</property> + <child> + <object class="GtkLabel" id="login_identity_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">24</property> + <property name="margin_bottom">32</property> + <property name="label" comments="This string is altered in the code, so it's safe to ignore it during translation.">'you are logged in as $name' +(populated in the application code)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="publish_to_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">8</property> + <property name="margin_bottom">64</property> + <property name="label" comments="This string does not require translation and may be safely skipped.">videos will appear in +(populated in the application code.)</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">40</property> + <property name="margin_right">40</property> + <property name="margin_top">16</property> + <property name="homogeneous">True</property> + <child> + <object class="GtkLabel" id="privacy_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">16</property> + <property name="margin_right">16</property> + <property name="xpad">10</property> + <property name="label" translatable="yes">Video privacy _setting:</property> + <property name="use_underline">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkComboBoxText" id="privacy_combo"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">16</property> + <property name="margin_right">32</property> + <property name="entry_text_column">0</property> + <property name="id_column">1</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="padding">4</property> + <property name="position">3</property> + </packing> + </child> + <child> + <object class="GtkBox" id="button_area_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">112</property> + <property name="margin_right">112</property> + <property name="margin_top">48</property> + <property name="margin_bottom">24</property> + <property name="spacing">128</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="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="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="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="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">4</property> + </packing> + </child> + </object> +</interface> |