1/* 2* Copyright (c) 2009-2013 Yorba Foundation 3* 4* This program is free software; you can redistribute it and/or 5* modify it under the terms of the GNU Lesser General Public 6* License as published by the Free Software Foundation; either 7* version 2.1 of the License, or (at your option) any later version. 8* 9* This program is distributed in the hope that it will be useful, 10* but WITHOUT ANY WARRANTY; without even the implied warranty of 11* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 12* General Public License for more details. 13* 14* You should have received a copy of the GNU General Public 15* License along with this program; if not, write to the 16* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, 17* Boston, MA 02110-1301 USA 18*/ 19 20public class PicasaService : Object, Spit.Pluggable, Spit.Publishing.Service { 21 private GLib.Icon icon; 22 23 public PicasaService (GLib.File resource_directory) { 24 icon = new ThemedIcon ("google-photos"); 25 } 26 27 public int get_pluggable_interface (int min_host_interface, int max_host_interface) { 28 return Spit.negotiate_interfaces (min_host_interface, max_host_interface, 29 Spit.Publishing.CURRENT_INTERFACE); 30 } 31 32 public unowned string get_id () { 33 return "io.elementary.photos.publishing.picasa"; 34 } 35 36 public unowned string get_pluggable_name () { 37 return "Google Photos"; 38 } 39 40 public void get_info (ref Spit.PluggableInfo info) { 41 info.authors = "Lucas Beeler"; 42 info.copyright = _ ("Copyright 2009-2013 Yorba Foundation"); 43 info.translators = Resources.TRANSLATORS; 44 info.version = _VERSION; 45 info.website_name = Resources.WEBSITE_NAME; 46 info.website_url = Resources.WEBSITE_URL; 47 info.is_license_wordwrapped = false; 48 info.license = Resources.LICENSE; 49 info.icon = icon; 50 } 51 52 public Spit.Publishing.Publisher create_publisher (Spit.Publishing.PluginHost host) { 53 return new Publishing.Picasa.PicasaPublisher (this, host); 54 } 55 56 public Spit.Publishing.Publisher.MediaType get_supported_media () { 57 return (Spit.Publishing.Publisher.MediaType.PHOTO | 58 Spit.Publishing.Publisher.MediaType.VIDEO); 59 } 60 61 public void activation (bool enabled) { 62 } 63} 64 65namespace Publishing.Picasa { 66 67internal const string SERVICE_WELCOME_MESSAGE = 68 _ ("You are not currently logged into Google Photos.\n\nClick Login to log into Google Photos in your Web browser. You will have to authorize elementary OS to link to your Google Photos account."); 69 70public class PicasaPublisher : Publishing.RESTSupport.GooglePublisher { 71 private bool running; 72 private Spit.Publishing.ProgressCallback progress_reporter; 73 private PublishingParameters publishing_parameters; 74 private string? refresh_token; 75 76 public PicasaPublisher (Spit.Publishing.Service service, 77 Spit.Publishing.PluginHost host) { 78 base (service, host, "http://picasaweb.google.com/data/"); 79 80 this.publishing_parameters = new PublishingParameters (); 81 load_parameters_from_configuration_system (publishing_parameters); 82 83 Spit.Publishing.Publisher.MediaType media_type = Spit.Publishing.Publisher.MediaType.NONE; 84 foreach (Spit.Publishing.Publishable p in host.get_publishables ()) 85 media_type |= p.get_media_type (); 86 publishing_parameters.set_media_type (media_type); 87 88 this.refresh_token = host.get_config_string ("refresh_token", null); 89 this.progress_reporter = null; 90 } 91 92 private void load_parameters_from_configuration_system (PublishingParameters parameters) { 93 parameters.set_major_axis_size_selection_id (get_host ().get_config_int ("default-size", 0)); 94 parameters.set_strip_metadata (get_host ().get_config_bool ("strip-metadata", false)); 95 } 96 97 private void save_parameters_to_configuration_system (PublishingParameters parameters) { 98 get_host ().set_config_int ("default-size", parameters.get_major_axis_size_selection_id ()); 99 get_host ().set_config_bool ("strip_metadata", parameters.get_strip_metadata ()); 100 } 101 102 private void on_service_welcome_login () { 103 debug ("EVENT: user clicked 'Login' in welcome pane."); 104 105 if (!is_running ()) 106 return; 107 108 start_oauth_flow (refresh_token); 109 } 110 111 protected override void on_login_flow_complete () { 112 debug ("EVENT: OAuth login flow complete."); 113 114 get_host ().set_config_string ("refresh_token", get_session ().get_refresh_token ()); 115 116 publishing_parameters.set_user_name (get_session ().get_user_name ()); 117 118 do_fetch_account_information (); 119 } 120 121 private void on_publishing_options_logout () { 122 if (!is_running ()) 123 return; 124 125 debug ("EVENT: user clicked 'Logout' in the publishing options pane."); 126 127 do_logout (); 128 } 129 130 private void on_publishing_options_publish () { 131 if (!is_running ()) 132 return; 133 134 debug ("EVENT: user clicked 'Publish' in the publishing options pane."); 135 136 save_parameters_to_configuration_system (publishing_parameters); 137 do_upload (); 138 } 139 140 private void on_upload_status_updated (int file_number, double completed_fraction) { 141 if (!is_running ()) 142 return; 143 144 debug ("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction); 145 146 assert (progress_reporter != null); 147 148 progress_reporter (file_number, completed_fraction); 149 } 150 151 private void on_upload_complete (Publishing.RESTSupport.BatchUploader uploader, 152 int num_published) { 153 if (!is_running ()) 154 return; 155 156 debug ("EVENT: uploader reports upload complete; %d items published.", num_published); 157 158 uploader.upload_complete.disconnect (on_upload_complete); 159 uploader.upload_error.disconnect (on_upload_error); 160 161 do_show_success_pane (); 162 } 163 164 private void on_upload_error (Publishing.RESTSupport.BatchUploader uploader, 165 Spit.Publishing.PublishingError err) { 166 if (!is_running ()) 167 return; 168 169 debug ("EVENT: uploader reports upload error = '%s'.", err.message); 170 171 uploader.upload_complete.disconnect (on_upload_complete); 172 uploader.upload_error.disconnect (on_upload_error); 173 174 get_host ().post_error (err); 175 } 176 177 private void do_show_service_welcome_pane () { 178 debug ("ACTION: showing service welcome pane."); 179 180 get_host ().install_welcome_pane (SERVICE_WELCOME_MESSAGE, on_service_welcome_login); 181 } 182 183 private void do_fetch_account_information () { 184 debug ("ACTION: fetching account and album information."); 185 186 get_host ().install_account_fetch_wait_pane (); 187 get_host ().set_service_locked (true); 188 189 do_show_publishing_options_pane (); 190 } 191 192 private void do_show_publishing_options_pane () { 193 debug ("ACTION: showing publishing options pane."); 194 Gtk.Builder builder = new Gtk.Builder (); 195 196 try { 197 builder.add_from_resource ("/io/elementary/photos/plugins/publishing/ui/picasa_publishing_options_pane.ui"); 198 } catch (Error e) { 199 warning ("Could not parse UI file! Error: %s.", e.message); 200 get_host ().post_error ( 201 new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR ( 202 _ ("A file required for publishing is unavailable. Publishing to Picasa can't continue."))); 203 return; 204 } 205 206 PublishingOptionsPane opts_pane = new PublishingOptionsPane (builder, publishing_parameters); 207 opts_pane.publish.connect (on_publishing_options_publish); 208 opts_pane.logout.connect (on_publishing_options_logout); 209 get_host ().install_dialog_pane (opts_pane); 210 211 get_host ().set_service_locked (false); 212 } 213 214 private void do_upload () { 215 debug ("ACTION: uploading media items to remote server."); 216 217 get_host ().set_service_locked (true); 218 219 progress_reporter = get_host ().serialize_publishables ( 220 publishing_parameters.get_major_axis_size_pixels (), 221 publishing_parameters.get_strip_metadata ()); 222 223 // Serialization is a long and potentially cancellable operation, so before we use 224 // the publishables, make sure that the publishing interaction is still running. If it 225 // isn't the publishing environment may be partially torn down so do a short-circuit 226 // return 227 if (!is_running ()) 228 return; 229 230 Spit.Publishing.Publishable[] publishables = get_host ().get_publishables (); 231 Uploader uploader = new Uploader (get_session (), publishables, publishing_parameters); 232 233 uploader.upload_complete.connect (on_upload_complete); 234 uploader.upload_error.connect (on_upload_error); 235 236 uploader.upload (on_upload_status_updated); 237 } 238 239 private void do_show_success_pane () { 240 debug ("ACTION: showing success pane."); 241 242 get_host ().set_service_locked (false); 243 get_host ().install_success_pane (); 244 } 245 246 protected override void do_logout () { 247 debug ("ACTION: logging out user."); 248 249 get_session ().deauthenticate (); 250 refresh_token = null; 251 get_host ().unset_config_key ("refresh_token"); 252 253 254 do_show_service_welcome_pane (); 255 } 256 257 public override bool is_running () { 258 return running; 259 } 260 261 public override void start () { 262 debug ("PicasaPublisher: start( ) invoked."); 263 264 if (is_running ()) 265 return; 266 267 running = true; 268 269 if (refresh_token == null) 270 do_show_service_welcome_pane (); 271 else 272 start_oauth_flow (refresh_token); 273 } 274 275 public override void stop () { 276 debug ("PicasaPublisher: stop( ) invoked."); 277 278 get_session ().stop_transactions (); 279 280 running = false; 281 } 282} 283 284internal class UploadTransaction : 285 Publishing.RESTSupport.GooglePublisher.AuthenticatedTransaction { 286 private PublishingParameters parameters; 287 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>"; 288 private Publishing.RESTSupport.GoogleSession session; 289 private string mime_type; 290 private Spit.Publishing.Publishable publishable; 291 private MappedFile mapped_file; 292 293 public UploadTransaction (Publishing.RESTSupport.GoogleSession session, 294 PublishingParameters parameters, Spit.Publishing.Publishable publishable) { 295 base (session, "https://picasaweb.google.com/data/feed/api/user/default/albumid/default", 296 Publishing.RESTSupport.HttpMethod.POST); 297 assert (session.is_authenticated ()); 298 this.session = session; 299 this.parameters = parameters; 300 this.publishable = publishable; 301 this.mime_type = (publishable.get_media_type () == Spit.Publishing.Publisher.MediaType.VIDEO) ? 302 "video/mpeg" : "image/jpeg"; 303 } 304 305 public override void execute () throws Spit.Publishing.PublishingError { 306 // create the multipart request container 307 Soup.Multipart message_parts = new Soup.Multipart ("multipart/related"); 308 309 string summary = ""; 310 if (publishable.get_publishing_name () != "") { 311 summary = "<atom:summary>%s</atom:summary>".printf ( 312 Publishing.RESTSupport.decimal_entity_encode (publishable.get_publishing_name ())); 313 } 314 315 string[] keywords = publishable.get_publishing_keywords (); 316 string keywords_string = ""; 317 if (keywords.length > 0) { 318 for (int i = 0; i < keywords.length; i++) { 319 string[] tmp; 320 321 if (keywords[i].has_prefix ("/")) 322 tmp = keywords[i].substring (1).split ("/"); 323 else 324 tmp = keywords[i].split ("/"); 325 326 if (keywords_string.length > 0) 327 keywords_string = string.join (", ", keywords_string, string.joinv (", ", tmp)); 328 else 329 keywords_string = string.joinv (", ", tmp); 330 } 331 332 keywords_string = Publishing.RESTSupport.decimal_entity_encode (keywords_string); 333 keywords_string = "<mrss:group><mrss:keywords>%s</mrss:keywords></mrss:group>".printf (keywords_string); 334 } 335 336 string metadata = METADATA_TEMPLATE.printf (Publishing.RESTSupport.decimal_entity_encode ( 337 publishable.get_param_string (Spit.Publishing.Publishable.PARAM_STRING_BASENAME)), 338 summary, keywords_string); 339 Soup.Buffer metadata_buffer = new Soup.Buffer (Soup.MemoryUse.COPY, metadata.data); 340 message_parts.append_form_file ("", "", "application/atom+xml", metadata_buffer); 341 342 // attempt to map the binary image data from disk into memory 343 try { 344 mapped_file = new MappedFile (publishable.get_serialized_file ().get_path (), false); 345 } catch (FileError e) { 346 string msg = "Picasa: couldn't read data from %s: %s".printf ( 347 publishable.get_serialized_file ().get_path (), e.message); 348 warning ("%s", msg); 349 350 throw new Spit.Publishing.PublishingError.LOCAL_FILE_ERROR (msg); 351 } 352 unowned uint8[] photo_data = (uint8[]) mapped_file.get_contents (); 353 photo_data.length = (int) mapped_file.get_length (); 354 355 // bind the binary image data read from disk into a Soup.Buffer object so that we 356 // can attach it to the multipart request, then actaully append the buffer 357 // to the multipart request. Then, set the MIME type for this part. 358 Soup.Buffer bindable_data = new Soup.Buffer (Soup.MemoryUse.TEMPORARY, photo_data); 359 360 message_parts.append_form_file ("", publishable.get_serialized_file ().get_path (), mime_type, 361 bindable_data); 362 // create a message that can be sent over the wire whose payload is the multipart container 363 // that we've been building up 364 Soup.Message outbound_message = 365 soup_form_request_new_from_multipart (get_endpoint_url (), message_parts); 366 outbound_message.request_headers.append ("Authorization", "Bearer " + 367 session.get_access_token ()); 368 set_message (outbound_message); 369 370 // send the message and get its response 371 set_is_executed (true); 372 send (); 373 } 374} 375 376internal class PublishingOptionsPane : Spit.Publishing.DialogPane, GLib.Object { 377 private class SizeDescription { 378 public string name; 379 public int major_axis_pixels; 380 381 public SizeDescription (string name, int major_axis_pixels) { 382 this.name = name; 383 this.major_axis_pixels = major_axis_pixels; 384 } 385 } 386 387 private const string DEFAULT_SIZE_CONFIG_KEY = "default_size"; 388 private const string LAST_ALBUM_CONFIG_KEY = "last_album"; 389 390 private Gtk.Builder builder = null; 391 private Gtk.Box pane_widget = null; 392 private Gtk.Label login_identity_label = null; 393 private Gtk.ComboBoxText size_combo = null; 394 private Gtk.CheckButton strip_metadata_check = null; 395 private Gtk.Button publish_button = null; 396 private Gtk.Button logout_button = null; 397 private SizeDescription[] size_descriptions; 398 private PublishingParameters parameters; 399 400 public signal void publish (); 401 public signal void logout (); 402 403 public PublishingOptionsPane (Gtk.Builder builder, PublishingParameters parameters) { 404 size_descriptions = create_size_descriptions (); 405 406 this.builder = builder; 407 assert (builder != null); 408 assert (builder.get_objects ().length () > 0); 409 410 this.parameters = parameters; 411 412 // pull in all widgets from builder. 413 pane_widget = (Gtk.Box) builder.get_object ("picasa_pane_widget"); 414 login_identity_label = (Gtk.Label) builder.get_object ("login_identity_label"); 415 size_combo = (Gtk.ComboBoxText) builder.get_object ("size_combo"); 416 strip_metadata_check = (Gtk.CheckButton) this.builder.get_object ("strip_metadata_check"); 417 publish_button = (Gtk.Button) builder.get_object ("publish_button"); 418 logout_button = (Gtk.Button) builder.get_object ("logout_button"); 419 420 // populate any widgets whose contents are programmatically-generated. 421 login_identity_label.set_label (_ ("You are logged into Picasa Web Albums as %s.").printf ( 422 parameters.get_user_name ())); 423 strip_metadata_check.set_active (parameters.get_strip_metadata ()); 424 425 426 if ((parameters.get_media_type () & Spit.Publishing.Publisher.MediaType.PHOTO) == 0) { 427 size_combo.set_visible (false); 428 size_combo.set_sensitive (false); 429 } else { 430 foreach (SizeDescription desc in size_descriptions) { 431 size_combo.append_text (desc.name); 432 } 433 size_combo.set_visible (true); 434 size_combo.set_sensitive (true); 435 size_combo.set_active (parameters.get_major_axis_size_selection_id ()); 436 } 437 438 // connect all signals. 439 logout_button.clicked.connect (on_logout_clicked); 440 publish_button.clicked.connect (on_publish_clicked); 441 } 442 443 private void on_publish_clicked () { 444 // size_combo won't have been set to anything useful if this is the first time we've 445 // published to Picasa, and/or we've only published video before, so it may be negative, 446 // indicating nothing was selected. Clamp it to a valid value... 447 int size_combo_last_active = (size_combo.get_active () >= 0) ? size_combo.get_active () : 0; 448 449 parameters.set_major_axis_size_selection_id (size_combo_last_active); 450 parameters.set_major_axis_size_pixels ( 451 size_descriptions[size_combo_last_active].major_axis_pixels); 452 parameters.set_strip_metadata (strip_metadata_check.get_active ()); 453 publish (); 454 } 455 456 private void on_logout_clicked () { 457 logout (); 458 } 459 460 private SizeDescription[] create_size_descriptions () { 461 SizeDescription[] result = new SizeDescription[0]; 462 463 result += new SizeDescription (_ ("Small (640 x 480 pixels)"), 640); 464 result += new SizeDescription (_ ("Medium (1024 x 768 pixels)"), 1024); 465 result += new SizeDescription (_ ("Recommended (1600 x 1200 pixels)"), 1600); 466 result += new SizeDescription (_ ("Google+ (2048 x 1536 pixels)"), 2048); 467 result += new SizeDescription (_ ("Original Size"), PublishingParameters.ORIGINAL_SIZE); 468 469 return result; 470 } 471 472 public Gtk.Widget get_widget () { 473 return pane_widget; 474 } 475 476 public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry () { 477 return Spit.Publishing.DialogPane.GeometryOptions.NONE; 478 } 479 480 public void on_pane_installed () { 481 482 } 483 484 public void on_pane_uninstalled () { 485 } 486} 487 488internal class PublishingParameters { 489 public const int ORIGINAL_SIZE = -1; 490 491 private bool strip_metadata; 492 private int major_axis_size_pixels; 493 private int major_axis_size_selection_id; 494 private string user_name; 495 private Spit.Publishing.Publisher.MediaType media_type; 496 497 public PublishingParameters () { 498 this.user_name = "[unknown]"; 499 this.major_axis_size_selection_id = 0; 500 this.major_axis_size_pixels = ORIGINAL_SIZE; 501 this.strip_metadata = false; 502 this.media_type = Spit.Publishing.Publisher.MediaType.PHOTO; 503 } 504 505 public string get_user_name () { 506 return user_name; 507 } 508 509 public void set_user_name (string user_name) { 510 this.user_name = user_name; 511 } 512 513 public void set_major_axis_size_pixels (int pixels) { 514 this.major_axis_size_pixels = pixels; 515 } 516 517 public int get_major_axis_size_pixels () { 518 return major_axis_size_pixels; 519 } 520 521 public void set_major_axis_size_selection_id (int selection_id) { 522 this.major_axis_size_selection_id = selection_id; 523 } 524 525 public int get_major_axis_size_selection_id () { 526 return major_axis_size_selection_id; 527 } 528 529 public void set_strip_metadata (bool strip_metadata) { 530 this.strip_metadata = strip_metadata; 531 } 532 533 public bool get_strip_metadata () { 534 return strip_metadata; 535 } 536 537 public void set_media_type (Spit.Publishing.Publisher.MediaType media_type) { 538 this.media_type = media_type; 539 } 540 541 public Spit.Publishing.Publisher.MediaType get_media_type () { 542 return media_type; 543 } 544} 545 546internal class Uploader : Publishing.RESTSupport.BatchUploader { 547 private PublishingParameters parameters; 548 549 public Uploader (Publishing.RESTSupport.GoogleSession session, 550 Spit.Publishing.Publishable[] publishables, PublishingParameters parameters) { 551 base (session, publishables); 552 553 this.parameters = parameters; 554 } 555 556 protected override Publishing.RESTSupport.Transaction create_transaction ( 557 Spit.Publishing.Publishable publishable) { 558 return new UploadTransaction ((Publishing.RESTSupport.GoogleSession) get_session (), 559 parameters, get_current_publishable ()); 560 } 561} 562} 563