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