summaryrefslogtreecommitdiff
path: root/plugins/shotwell-publishing/FlickrPublishing.vala
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/shotwell-publishing/FlickrPublishing.vala')
-rw-r--r--plugins/shotwell-publishing/FlickrPublishing.vala1371
1 files changed, 1371 insertions, 0 deletions
diff --git a/plugins/shotwell-publishing/FlickrPublishing.vala b/plugins/shotwell-publishing/FlickrPublishing.vala
new file mode 100644
index 0000000..dc483cc
--- /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"));
+
+ 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"));
+
+
+ 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());
+ }
+}
+
+}
+