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
20// namespace for future migration of AppWindow alert and other question dialogs into single
21// place: http://trac.yorba.org/ticket/3452
22namespace Dialogs {
23
24private static bool negate_affirm_question (string message, string title) {
25    var dialog = new Granite.MessageDialog.with_image_from_icon_name (
26        title,
27        message,
28        "dialog-question",
29        Gtk.ButtonsType.NONE
30    );
31    dialog.transient_for = AppWindow.get_instance ();
32    dialog.set_urgency_hint (true);
33    dialog.add_button (_("_Cancel"), Gtk.ResponseType.NO);
34
35    var delete_button = (Gtk.Button) dialog.add_button (_("_Delete"), Gtk.ResponseType.YES);
36    delete_button.get_style_context ().add_class (Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION);
37
38    bool response = (dialog.run () == Gtk.ResponseType.YES);
39
40    dialog.destroy ();
41
42    return response;
43}
44
45public bool confirm_delete_tag (Tag tag) {
46    int count = tag.get_sources_count ();
47    if (count == 0)
48        return true;
49    string msg = ngettext (
50                     "This will remove the tag \"%s\" from one photo. Continue?",
51                     "This will remove the tag \"%s\" from %d photos. Continue?",
52                     count).printf (tag.get_user_visible_name (), count);
53
54    return negate_affirm_question (msg, Resources.DELETE_TAG_TITLE);
55}
56
57public bool confirm_delete_saved_search (SavedSearch search) {
58    string msg = _ ("This will remove the smart album \"%s\". Continue?")
59                 .printf (search.get_name ());
60
61    return negate_affirm_question (msg, Resources.DELETE_SAVED_SEARCH_DIALOG_TITLE);
62}
63
64public bool confirm_warn_developer_changed (int number) {
65    string secondary_text = ngettext (
66        "Switching developers will undo all changes you have made to this photo in Photos",
67        "Switching developers will undo all changes you have made to these photos in Photos",
68        number
69    );
70
71    var dialog = new Granite.MessageDialog (
72        _("Are You Sure You Want to Switch Developers?"),
73        secondary_text,
74        new ThemedIcon ("dialog-question"),
75        Gtk.ButtonsType.CANCEL
76    );
77    dialog.transient_for = AppWindow.get_instance ();
78
79    var switch_button = (Gtk.Button) dialog.add_button (_("_Switch Developer"), Gtk.ResponseType.YES);
80    switch_button.get_style_context ().add_class (Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION);
81
82    int response = dialog.run ();
83    dialog.destroy ();
84
85    return response == Gtk.ResponseType.YES;
86}
87
88}
89
90namespace ExportUI {
91private static File current_export_dir = null;
92
93public File? choose_file (string current_file_basename) {
94    if (current_export_dir == null)
95        current_export_dir = File.new_for_path (Environment.get_home_dir ());
96
97    var chooser = new Gtk.FileChooserNative (
98        VideoReader.is_supported_video_filename (current_file_basename) ? _("Export Video") : _("Export Photo"),
99        AppWindow.get_instance (),
100        Gtk.FileChooserAction.SAVE,
101        _("_Save"),
102        _("_Cancel")
103    );
104    chooser.set_do_overwrite_confirmation (true);
105    chooser.set_current_folder (current_export_dir.get_path ());
106    chooser.set_current_name (current_file_basename);
107    chooser.set_local_only (false);
108
109    File file = null;
110    if (chooser.run () == Gtk.ResponseType.ACCEPT) {
111        file = File.new_for_path (chooser.get_filename ());
112        current_export_dir = file.get_parent ();
113    }
114
115    chooser.destroy ();
116
117    return file;
118}
119
120public File? choose_dir (string? user_title = null) {
121    if (current_export_dir == null)
122        current_export_dir = File.new_for_path (Environment.get_home_dir ());
123
124    if (user_title == null)
125        user_title = _("Export Photos");
126
127    var chooser = new Gtk.FileChooserNative (
128        user_title,
129        AppWindow.get_instance (),
130        Gtk.FileChooserAction.SELECT_FOLDER,
131        _("_Select"),
132        _("_Cancel")
133    );
134    chooser.set_current_folder (current_export_dir.get_path ());
135    chooser.set_local_only (false);
136
137    File dir = null;
138    if (chooser.run () == Gtk.ResponseType.ACCEPT) {
139        dir = File.new_for_path (chooser.get_filename ());
140        current_export_dir = dir;
141    }
142
143    chooser.destroy ();
144
145    return dir;
146}
147}
148
149// Ticket #3023
150// Attempt to replace the system error with something friendlier
151// if we can't copy an image over for editing in an external tool.
152public void open_external_editor_error_dialog (Error err, Photo photo) {
153    // Did we fail because we can't write to this directory?
154    if (err is IOError.PERMISSION_DENIED || err is FileError.PERM) {
155        // Yes - display an alternate error message here.
156        AppWindow.error_message (
157            _ ("Photos couldn't create a file for editing this photo because you do not have permission to write to %s.").printf (photo.get_master_file ().get_parent ().get_path ()));
158    } else {
159        // No - something else is wrong, display the error message
160        // the system gave us.
161        AppWindow.error_message (Resources.launch_editor_failed (err));
162    }
163}
164
165public Gtk.ResponseType export_error_dialog (File dest, bool photos_remaining) {
166    string message = _ ("Unable to export the following photo due to a file error.\n\n") +
167                     dest.get_path ();
168
169    Gtk.ResponseType response = Gtk.ResponseType.NONE;
170
171    if (photos_remaining) {
172        message += _ ("\n\nWould you like to continue exporting?");
173        response = AppWindow.cancel_affirm_question (message, _ ("Con_tinue"));
174    } else {
175        AppWindow.error_message (message);
176    }
177
178    return response;
179}
180
181namespace ImportUI {
182private const int REPORT_FAILURE_COUNT = 4;
183internal const string SAVE_RESULTS_BUTTON_NAME = _ ("Save Details…");
184internal const string SAVE_RESULTS_FILE_CHOOSER_TITLE = _ ("Save Details");
185internal const int SAVE_RESULTS_RESPONSE_ID = 1024;
186
187private string? generate_import_failure_list (Gee.List<BatchImportResult> failed, bool show_dest_id) {
188    if (failed.size == 0)
189        return null;
190
191    string list = "";
192    for (int ctr = 0; ctr < REPORT_FAILURE_COUNT && ctr < failed.size; ctr++) {
193        list += "%s\n".printf (show_dest_id ? failed.get (ctr).dest_identifier :
194                               failed.get (ctr).src_identifier);
195    }
196
197    int remaining = failed.size - REPORT_FAILURE_COUNT;
198    if (remaining > 0)
199        list += _ ("(and %d more)\n").printf (remaining);
200
201    return list;
202}
203
204public class QuestionParams {
205    public string question;
206    public string yes_button;
207    public string no_button;
208
209    public QuestionParams (string question, string yes_button, string no_button) {
210        this.question = question;
211        this.yes_button = yes_button;
212        this.no_button = no_button;
213    }
214}
215
216public bool import_has_photos (Gee.Collection<BatchImportResult> import_collection) {
217    foreach (BatchImportResult current_result in import_collection) {
218        if (current_result.file != null
219                && PhotoFileFormat.get_by_file_extension (current_result.file) != PhotoFileFormat.UNKNOWN) {
220            return true;
221        }
222    }
223    return false;
224}
225
226public bool import_has_videos (Gee.Collection<BatchImportResult> import_collection) {
227    foreach (BatchImportResult current_result in import_collection) {
228        if (current_result.file != null && VideoReader.is_supported_video_file (current_result.file))
229            return true;
230    }
231    return false;
232}
233
234public string get_media_specific_string (Gee.Collection<BatchImportResult> import_collection,
235        string photos_msg, string videos_msg, string both_msg, string neither_msg) {
236    bool has_photos = import_has_photos (import_collection);
237    bool has_videos = import_has_videos (import_collection);
238
239    if (has_photos && has_videos)
240        return both_msg;
241    else if (has_photos)
242        return photos_msg;
243    else if (has_videos)
244        return videos_msg;
245    else
246        return neither_msg;
247}
248
249public string create_result_report_from_manifest (ImportManifest manifest) {
250    StringBuilder builder = new StringBuilder ();
251
252    string header = _ ("Import Results Report") + " (Photos " + Resources.APP_VERSION + " @ " +
253                    (new DateTime.now_local ()).format_iso8601 () + ")\n\n";
254    builder.append (header);
255
256    string subhead = (ngettext ("Attempted to import %d file.", "Attempted to import %d files.",
257                                manifest.all.size)).printf (manifest.all.size);
258    subhead += " ";
259    subhead += (ngettext ("Of these, %d file was successfully imported.",
260                          "Of these, %d files were successfully imported.", manifest.success.size)).printf (
261                   manifest.success.size);
262    subhead += "\n\n";
263    builder.append (subhead);
264
265    string current_file_summary = "";
266
267    //
268    // Duplicates
269    //
270    if (manifest.already_imported.size > 0) {
271        builder.append (_ ("Duplicate Photos/Videos Not Imported:") + "\n\n");
272
273        foreach (BatchImportResult result in manifest.already_imported) {
274            current_file_summary = result.src_identifier + " " +
275                                   _ ("duplicates existing media item") + "\n\t" +
276                                   result.duplicate_of.get_file ().get_path () + "\n\n";
277
278            builder.append (current_file_summary);
279        }
280    }
281
282    //
283    // Files Not Imported Due to Camera Errors
284    //
285    if (manifest.camera_failed.size > 0) {
286        builder.append (_ ("Photos/Videos Not Imported Due to Camera Errors:") + "\n\n");
287
288        foreach (BatchImportResult result in manifest.camera_failed) {
289            current_file_summary = result.src_identifier + "\n\t" + _ ("error message:") + " " +
290                                   result.errmsg + "\n\n";
291
292            builder.append (current_file_summary);
293        }
294    }
295
296    //
297    // Files Not Imported Because They Weren't Recognized as Photos or Videos
298    //
299    if (manifest.skipped_files.size > 0) {
300        builder.append (_ ("Files Not Imported Because They Weren't Recognized as Photos or Videos:")
301                        + "\n\n");
302
303        foreach (BatchImportResult result in manifest.skipped_files) {
304            current_file_summary = result.src_identifier + "\n\t" + _ ("error message:") + " " +
305                                   result.errmsg + "\n\n";
306
307            builder.append (current_file_summary);
308        }
309    }
310
311    //
312    // Photos/Videos Not Imported Because They Weren't in a Format Photos Understands
313    //
314    if (manifest.skipped_photos.size > 0) {
315        builder.append (_ ("Photos/Videos Not Imported Because They Weren't in a Format Photos Understands:")
316                        + "\n\n");
317
318        foreach (BatchImportResult result in manifest.skipped_photos) {
319            current_file_summary = result.src_identifier + "\n\t" + _ ("error message:") + " " +
320                                   result.errmsg + "\n\n";
321
322            builder.append (current_file_summary);
323        }
324    }
325
326    //
327    // Photos/Videos Not Imported Because Photos Couldn't Copy Them into its Library
328    //
329    if (manifest.write_failed.size > 0) {
330        builder.append (_ ("Photos/Videos Not Imported Because Photos Couldn't Copy Them into its Library:")
331                        + "\n\n");
332
333        foreach (BatchImportResult result in manifest.write_failed) {
334            current_file_summary = (_ ("couldn't copy %s\n\tto %s")).printf (result.src_identifier,
335                                   result.dest_identifier) + "\n\t" + _ ("error message:") + " " +
336                                   result.errmsg + "\n\n";
337
338            builder.append (current_file_summary);
339        }
340    }
341
342    //
343    // Photos/Videos Not Imported Because GDK Pixbuf Library Identified them as Corrupt
344    //
345    if (manifest.corrupt_files.size > 0) {
346        builder.append (_ ("Photos/Videos Not Imported Because Files Are Corrupt:")
347                        + "\n\n");
348
349        foreach (BatchImportResult result in manifest.corrupt_files) {
350            current_file_summary = result.src_identifier + "\n\t" + _ ("error message:") + " |" +
351                                   result.errmsg + "|\n\n";
352
353            builder.append (current_file_summary);
354        }
355    }
356
357    //
358    // Photos/Videos Not Imported for Other Reasons
359    //
360    if (manifest.failed.size > 0) {
361        builder.append (_ ("Photos/Videos Not Imported for Other Reasons:") + "\n\n");
362
363        foreach (BatchImportResult result in manifest.failed) {
364            current_file_summary = result.src_identifier + "\n\t" + _ ("error message:") + " " +
365                                   result.errmsg + "\n\n";
366
367            builder.append (current_file_summary);
368        }
369    }
370
371    return builder.str;
372}
373
374// Summarizes the contents of an import manifest in an on-screen message window. Returns
375// true if the user selected the yes action, false otherwise.
376public bool report_manifest (ImportManifest manifest, bool show_dest_id,
377                             QuestionParams? question = null) {
378    string message = "";
379
380    if (manifest.already_imported.size > 0) {
381        string photos_message = (ngettext ("1 duplicate photo was not imported:\n",
382                                           "%d duplicate photos were not imported:\n",
383                                           manifest.already_imported.size)).printf (manifest.already_imported.size);
384        string videos_message = (ngettext ("1 duplicate video was not imported:\n",
385                                           "%d duplicate videos were not imported:\n",
386                                           manifest.already_imported.size)).printf (manifest.already_imported.size);
387        string both_message = (ngettext ("1 duplicate photo/video was not imported:\n",
388                                         "%d duplicate photos/videos were not imported:\n",
389                                         manifest.already_imported.size)).printf (manifest.already_imported.size);
390
391        message += get_media_specific_string (manifest.already_imported, photos_message,
392                                              videos_message, both_message, both_message);
393
394        message += generate_import_failure_list (manifest.already_imported, show_dest_id);
395    }
396
397    if (manifest.failed.size > 0) {
398        if (message.length > 0)
399            message += "\n";
400
401        string photos_message = (ngettext ("1 photo failed to import due to a file or hardware error:\n",
402                                           "%d photos failed to import due to a file or hardware error:\n",
403                                           manifest.failed.size)).printf (manifest.failed.size);
404        string videos_message = (ngettext ("1 video failed to import due to a file or hardware error:\n",
405                                           "%d videos failed to import due to a file or hardware error:\n",
406                                           manifest.failed.size)).printf (manifest.failed.size);
407        string both_message = (ngettext ("1 photo/video failed to import due to a file or hardware error:\n",
408                                         "%d photos/videos failed to import due to a file or hardware error:\n",
409                                         manifest.failed.size)).printf (manifest.failed.size);
410        string neither_message = (ngettext ("1 file failed to import due to a file or hardware error:\n",
411                                            "%d files failed to import due to a file or hardware error:\n",
412                                            manifest.failed.size)).printf (manifest.failed.size);
413
414        message += get_media_specific_string (manifest.failed, photos_message, videos_message,
415                                              both_message, neither_message);
416
417        message += generate_import_failure_list (manifest.failed, show_dest_id);
418    }
419
420    if (manifest.write_failed.size > 0) {
421        if (message.length > 0)
422            message += "\n";
423
424        string photos_message = (ngettext ("1 photo failed to import because the photo library folder was not writable:\n",
425                                           "%d photos failed to import because the photo library folder was not writable:\n",
426                                           manifest.write_failed.size)).printf (manifest.write_failed.size);
427        string videos_message = (ngettext ("1 video failed to import because the photo library folder was not writable:\n",
428                                           "%d videos failed to import because the photo library folder was not writable:\n",
429                                           manifest.write_failed.size)).printf (manifest.write_failed.size);
430        string both_message = (ngettext ("1 photo/video failed to import because the photo library folder was not writable:\n",
431                                         "%d photos/videos failed to import because the photo library folder was not writable:\n",
432                                         manifest.write_failed.size)).printf (manifest.write_failed.size);
433        string neither_message = (ngettext ("1 file failed to import because the photo library folder was not writable:\n",
434                                            "%d files failed to import because the photo library folder was not writable:\n",
435                                            manifest.write_failed.size)).printf (manifest.write_failed.size);
436
437        message += get_media_specific_string (manifest.write_failed, photos_message, videos_message,
438                                              both_message, neither_message);
439
440        message += generate_import_failure_list (manifest.write_failed, show_dest_id);
441    }
442
443    if (manifest.camera_failed.size > 0) {
444        if (message.length > 0)
445            message += "\n";
446
447        string photos_message = (ngettext ("1 photo failed to import due to a camera error:\n",
448                                           "%d photos failed to import due to a camera error:\n",
449                                           manifest.camera_failed.size)).printf (manifest.camera_failed.size);
450        string videos_message = (ngettext ("1 video failed to import due to a camera error:\n",
451                                           "%d videos failed to import due to a camera error:\n",
452                                           manifest.camera_failed.size)).printf (manifest.camera_failed.size);
453        string both_message = (ngettext ("1 photo/video failed to import due to a camera error:\n",
454                                         "%d photos/videos failed to import due to a camera error:\n",
455                                         manifest.camera_failed.size)).printf (manifest.camera_failed.size);
456        string neither_message = (ngettext ("1 file failed to import due to a camera error:\n",
457                                            "%d files failed to import due to a camera error:\n",
458                                            manifest.camera_failed.size)).printf (manifest.camera_failed.size);
459
460        message += get_media_specific_string (manifest.camera_failed, photos_message, videos_message,
461                                              both_message, neither_message);
462
463        message += generate_import_failure_list (manifest.camera_failed, show_dest_id);
464    }
465
466    if (manifest.corrupt_files.size > 0) {
467        if (message.length > 0)
468            message += "\n";
469
470        string photos_message = (ngettext ("1 photo failed to import because it was corrupt:\n",
471                                           "%d photos failed to import because they were corrupt:\n",
472                                           manifest.corrupt_files.size)).printf (manifest.corrupt_files.size);
473        string videos_message = (ngettext ("1 video failed to import because it was corrupt:\n",
474                                           "%d videos failed to import because they were corrupt:\n",
475                                           manifest.corrupt_files.size)).printf (manifest.corrupt_files.size);
476        string both_message = (ngettext ("1 photo/video failed to import because it was corrupt:\n",
477                                         "%d photos/videos failed to import because they were corrupt:\n",
478                                         manifest.corrupt_files.size)).printf (manifest.corrupt_files.size);
479        string neither_message = (ngettext ("1 file failed to import because it was corrupt:\n",
480                                            "%d files failed to import because it was corrupt:\n",
481                                            manifest.corrupt_files.size)).printf (manifest.corrupt_files.size);
482
483        message += get_media_specific_string (manifest.corrupt_files, photos_message, videos_message,
484                                              both_message, neither_message);
485
486        message += generate_import_failure_list (manifest.corrupt_files, show_dest_id);
487    }
488
489    if (manifest.skipped_photos.size > 0) {
490        if (message.length > 0)
491            message += "\n";
492        // we have no notion of "unsupported" video files right now in Photos (all
493        // standard container formats are supported, it's just that the streams in them
494        // might or might not be interpretable), so this message does not need to be
495        // media specific
496        string skipped_photos_message = (ngettext ("1 unsupported photo skipped:\n",
497                                         "%d unsupported photos skipped:\n", manifest.skipped_photos.size)).printf (
498                                            manifest.skipped_photos.size);
499
500        message += skipped_photos_message;
501
502        message += generate_import_failure_list (manifest.skipped_photos, show_dest_id);
503    }
504
505    if (manifest.skipped_files.size > 0) {
506        if (message.length > 0)
507            message += "\n";
508
509        // we have no notion of "non-video" video files right now in Photos, so this
510        // message doesn't need to be media specific
511        string skipped_files_message = (ngettext ("1 non-image file skipped.\n",
512                                        "%d non-image files skipped.\n", manifest.skipped_files.size)).printf (
513                                           manifest.skipped_files.size);
514
515        message += skipped_files_message;
516    }
517
518    if (manifest.aborted.size > 0) {
519        if (message.length > 0)
520            message += "\n";
521
522        string photos_message = (ngettext ("1 photo skipped due to user cancel:\n",
523                                           "%d photos skipped due to user cancel:\n",
524                                           manifest.aborted.size)).printf (manifest.aborted.size);
525        string videos_message = (ngettext ("1 video skipped due to user cancel:\n",
526                                           "%d videos skipped due to user cancel:\n",
527                                           manifest.aborted.size)).printf (manifest.aborted.size);
528        string both_message = (ngettext ("1 photo/video skipped due to user cancel:\n",
529                                         "%d photos/videos skipped due to user cancel:\n",
530                                         manifest.aborted.size)).printf (manifest.aborted.size);
531        string neither_message = (ngettext ("1 file skipped due to user cancel:\n",
532                                            "%d file skipped due to user cancel:\n",
533                                            manifest.aborted.size)).printf (manifest.aborted.size);
534
535        message += get_media_specific_string (manifest.aborted, photos_message, videos_message,
536                                              both_message, neither_message);
537
538        message += generate_import_failure_list (manifest.aborted, show_dest_id);
539    }
540
541    if (manifest.success.size > 0) {
542        if (message.length > 0)
543            message += "\n";
544
545        string photos_message = (ngettext ("1 photo successfully imported.\n",
546                                           "%d photos successfully imported.\n",
547                                           manifest.success.size)).printf (manifest.success.size);
548        string videos_message = (ngettext ("1 video successfully imported.\n",
549                                           "%d videos successfully imported.\n",
550                                           manifest.success.size)).printf (manifest.success.size);
551        string both_message = (ngettext ("1 photo/video successfully imported.\n",
552                                         "%d photos/videos successfully imported.\n",
553                                         manifest.success.size)).printf (manifest.success.size);
554
555        message += get_media_specific_string (manifest.success, photos_message, videos_message,
556                                              both_message, "");
557    }
558
559    int total = manifest.success.size + manifest.failed.size + manifest.camera_failed.size
560                + manifest.skipped_photos.size + manifest.skipped_files.size + manifest.corrupt_files.size
561                + manifest.already_imported.size + manifest.aborted.size + manifest.write_failed.size;
562    assert (total == manifest.all.size);
563
564    // if no media items were imported at all (i.e. an empty directory attempted), need to at least
565    // report that nothing was imported
566    if (total == 0)
567        message += _ ("No photos or videos imported.\n");
568
569    Granite.MessageDialog dialog = null;
570    int dialog_response = Gtk.ResponseType.NONE;
571    if (question == null) {
572        dialog = new Granite.MessageDialog.with_image_from_icon_name (
573            _("Import Complete"),
574            message,
575            "document-import",
576            Gtk.ButtonsType.NONE
577        );
578        dialog.transient_for = AppWindow.get_instance ();
579
580        var save_results_button = dialog.add_button (ImportUI.SAVE_RESULTS_BUTTON_NAME, ImportUI.SAVE_RESULTS_RESPONSE_ID);
581        save_results_button.set_visible (manifest.success.size < manifest.all.size);
582
583        var ok_button = dialog.add_button (_("_Done"), Gtk.ResponseType.OK);
584
585        dialog.set_default (ok_button);
586        dialog_response = dialog.run ();
587        dialog.destroy ();
588
589        var dialog_parent = (Gtk.Window) dialog.get_parent ();
590
591        if (dialog_response == ImportUI.SAVE_RESULTS_RESPONSE_ID) {
592            save_import_results (dialog_parent, create_result_report_from_manifest (manifest));
593        }
594
595    } else {
596        message += ("\n" + question.question);
597
598        dialog = new Granite.MessageDialog.with_image_from_icon_name (
599            _("Import Complete"),
600            message,
601            "dialog-question",
602            Gtk.ButtonsType.NONE
603        );
604        dialog.transient_for = AppWindow.get_instance ();
605
606        var save_results_button = dialog.add_button (ImportUI.SAVE_RESULTS_BUTTON_NAME, ImportUI.SAVE_RESULTS_RESPONSE_ID);
607        save_results_button.set_visible (manifest.success.size < manifest.all.size);
608
609        var no_button = dialog.add_button (question.no_button, Gtk.ResponseType.NO);
610
611        dialog.add_button (question.yes_button, Gtk.ResponseType.YES);
612        dialog.set_default (no_button);
613
614        dialog_response = dialog.run ();
615        while (dialog_response == ImportUI.SAVE_RESULTS_RESPONSE_ID) {
616            save_import_results (dialog, create_result_report_from_manifest (manifest));
617            dialog_response = dialog.run ();
618        }
619
620        dialog.hide ();
621        dialog.destroy ();
622    }
623
624    return (dialog_response == Gtk.ResponseType.YES);
625}
626
627internal void save_import_results (Gtk.Window? chooser_dialog_parent, string results_log) {
628    var chooser_dialog = new Gtk.FileChooserNative (
629        ImportUI.SAVE_RESULTS_FILE_CHOOSER_TITLE,
630        chooser_dialog_parent,
631        Gtk.FileChooserAction.SAVE,
632        _("_Save"),
633        _("_Cancel")
634    );
635    chooser_dialog.set_do_overwrite_confirmation (true);
636    chooser_dialog.set_current_folder (Environment.get_home_dir ());
637    chooser_dialog.set_current_name ("Photos Import Log.txt");
638    chooser_dialog.set_local_only (false);
639
640    int dialog_result = chooser_dialog.run ();
641    File? chosen_file = chooser_dialog.get_file ();
642    chooser_dialog.hide ();
643    chooser_dialog.destroy ();
644
645    if (dialog_result == Gtk.ResponseType.ACCEPT && chosen_file != null) {
646        try {
647            FileOutputStream outstream = chosen_file.replace (null, false, FileCreateFlags.NONE);
648            outstream.write (results_log.data);
649            outstream.close ();
650        } catch (Error err) {
651            critical ("couldn't save import results to log file %s: %s", chosen_file.get_path (),
652                      err.message);
653        }
654    }
655}
656
657}
658
659public abstract class TextEntryDialogMediator {
660    private TextEntryDialog dialog;
661
662    protected TextEntryDialogMediator (string title, string label, string? initial_text = null,
663                                       Gee.Collection<string>? completion_list = null, string? completion_delimiter = null) {
664        dialog = new TextEntryDialog (on_modify_validate, title, label, initial_text, completion_list, completion_delimiter);
665    }
666
667    protected virtual bool on_modify_validate (string text) {
668        return true;
669    }
670
671    protected string? _execute () {
672        return dialog.execute ();
673    }
674}
675
676// This method takes primary and secondary texts and returns ready-to-use pango markup
677// for a HIG-compliant alert dialog. Please see
678// http://library.gnome.org/devel/hig-book/2.32/windows-alert.html.en for details.
679public string build_alert_body_text (string? primary_text, string? secondary_text, bool should_escape = true) {
680    if (should_escape) {
681        return "<span weight=\"Bold\" size=\"larger\">%s</span>\n%s".printf (
682                   guarded_markup_escape_text (primary_text), guarded_markup_escape_text (secondary_text));
683    }
684
685    return "<span weight=\"Bold\" size=\"larger\">%s</span>\n%s".printf (
686               guarded_markup_escape_text (primary_text), secondary_text);
687}
688
689// Entry completion for values separated by separators (e.g. comma in the case of tags)
690// Partly inspired by the class of the same name in gtkmm-utils by Marko Anastasov
691public class EntryMultiCompletion : Gtk.EntryCompletion {
692    private string delimiter;
693
694    public EntryMultiCompletion (Gee.Collection<string> completion_list, string? delimiter) {
695        assert (delimiter == null || delimiter.length == 1);
696        this.delimiter = delimiter;
697
698        set_model (create_completion_store (completion_list));
699        set_text_column (0);
700        set_match_func (match_func);
701    }
702
703    private static Gtk.ListStore create_completion_store (Gee.Collection<string> completion_list) {
704        Gtk.ListStore completion_store = new Gtk.ListStore (1, typeof (string));
705        Gtk.TreeIter store_iter;
706        Gee.Iterator<string> completion_iter = completion_list.iterator ();
707        while (completion_iter.next ()) {
708            completion_store.append (out store_iter);
709            completion_store.set (store_iter, 0, completion_iter.get (), -1);
710        }
711
712        return completion_store;
713    }
714
715    private bool match_func (Gtk.EntryCompletion completion, string key, Gtk.TreeIter iter) {
716        Gtk.TreeModel model = completion.get_model ();
717        string possible_match;
718        model.get (iter, 0, out possible_match);
719
720        // Normalize key and possible matches to allow comparison of non-ASCII characters.
721        // Use a "COMPOSE" normalization to allow comparison to the position value returned by
722        // Gtk.Entry, i.e. one character=one position. Using the default normalization a character
723        // like "é" or "ö" would have a length of two.
724        possible_match = possible_match.casefold ().normalize (-1, NormalizeMode.ALL_COMPOSE);
725        string normed_key = key.normalize (-1, NormalizeMode.ALL_COMPOSE);
726
727        if (delimiter == null) {
728            return possible_match.has_prefix (normed_key.strip ());
729        } else {
730            if (normed_key.contains (delimiter)) {
731                // check whether cursor is before last delimiter
732                int offset = normed_key.char_count (normed_key.last_index_of_char (delimiter[0]));
733                int position = ((Gtk.Entry) get_entry ()).get_position ();
734                if (position <= offset)
735                    return false; // TODO: Autocompletion for tags not last in list
736            }
737
738            string last_part = get_last_part (normed_key.strip (), delimiter);
739
740            if (last_part.length == 0)
741                return false; // need at least one character to show matches
742
743            return possible_match.has_prefix (last_part.strip ());
744        }
745    }
746
747    public override bool match_selected (Gtk.TreeModel model, Gtk.TreeIter iter) {
748        string match;
749        model.get (iter, 0, out match);
750
751        Gtk.Entry entry = (Gtk.Entry)get_entry ();
752
753        string old_text = entry.get_text ().normalize (-1, NormalizeMode.ALL_COMPOSE);
754        if (old_text.length > 0) {
755            if (old_text.contains (delimiter)) {
756                old_text = old_text.substring (0, old_text.last_index_of_char (delimiter[0]) + 1) + (delimiter != " " ? " " : "");
757            } else
758                old_text = "";
759        }
760
761        string new_text = old_text + match + delimiter + (delimiter != " " ? " " : "");
762        entry.set_text (new_text);
763        entry.set_position ((int) new_text.length);
764
765        return true;
766    }
767
768    // Find last string after any delimiter
769    private static string get_last_part (string s, string delimiter) {
770        string[] split = s.split (delimiter);
771
772        if ((split != null) && (split[0] != null)) {
773            return split[split.length - 1];
774        } else {
775            return "";
776        }
777    }
778}
779
780public class EventRenameDialog : TextEntryDialogMediator {
781    public EventRenameDialog (string? event_name) {
782        base (_ ("Rename Event"), _ ("Name:"), event_name);
783    }
784
785    public virtual string? execute () {
786        return Event.prep_event_name (_execute ());
787    }
788}
789
790public bool revert_editable_dialog (Gtk.Window parent, Gee.Collection<Photo> photos) {
791    int count = 0;
792    foreach (Photo photo in photos) {
793        if (photo.has_editable ())
794            count++;
795    }
796
797    if (count == 0)
798        return false;
799
800    string primary_text = ngettext ("Revert External Edit?", "Revert External Edits?", count);
801    string secondary_text = ngettext (
802        "This will destroy all changes made to the external file. Continue?",
803        "This will destroy all changes made to %d external files. Continue?",
804        count
805    ).printf (count);
806
807    string action = ngettext ("Re_vert External Edit", "Re_vert External Edits", count);
808
809    var dialog = new Granite.MessageDialog.with_image_from_icon_name (
810        primary_text,
811        secondary_text,
812        "dialog-warning",
813        Gtk.ButtonsType.CANCEL
814    );
815    dialog.transient_for = parent;
816
817    var revert_button = dialog.add_button (action, Gtk.ResponseType.YES);
818    revert_button.get_style_context ().add_class (Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION);
819
820    var result = (Gtk.ResponseType) dialog.run ();
821
822    dialog.destroy ();
823
824    return result == Gtk.ResponseType.YES;
825}
826
827public bool remove_offline_dialog (Gtk.Window owner, int count) {
828    if (count == 0) {
829        return false;
830    }
831
832    string primary_text = ngettext (
833        _("Remove Photo From Library"),
834        _("Remove Photos From Library"),
835        count
836    );
837
838    string secondary_text = ngettext (
839        "This will remove the photo from the library. Continue?",
840        "This will remove %d photos from the library. Continue?",
841         count
842    ).printf (count);
843
844    var dialog = new Granite.MessageDialog.with_image_from_icon_name (
845        primary_text,
846        secondary_text,
847        "dialog-warning",
848        Gtk.ButtonsType.CANCEL
849    );
850    dialog.transient_for = owner;
851
852    var remove_button = (Gtk.Button) dialog.add_button (_ ("_Remove"), Gtk.ResponseType.OK);
853    remove_button.get_style_context ().add_class (Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION);
854
855    var result = (Gtk.ResponseType) dialog.run ();
856
857    dialog.destroy ();
858
859    return result == Gtk.ResponseType.OK;
860}
861
862public const int MAX_OBJECTS_DISPLAYED = 3;
863public void multiple_object_error_dialog (Gee.ArrayList<DataObject> objects, string message, string title) {
864    string dialog_message = message + "\n";
865
866    //add objects
867    for (int i = 0; i < MAX_OBJECTS_DISPLAYED && objects.size > i; i++)
868        dialog_message += "\n" + objects.get (i).to_string ();
869
870    int remainder = objects.size - MAX_OBJECTS_DISPLAYED;
871    if (remainder > 0) {
872        dialog_message += ngettext ("\n\nAnd %d other.", "\n\nAnd %d others.",
873                                    remainder).printf (remainder);
874    }
875
876    var dialog = new Granite.MessageDialog.with_image_from_icon_name (
877        title,
878        dialog_message,
879        "dialog-error",
880        Gtk.ButtonsType.CLOSE
881    );
882    dialog.transient_for = AppWindow.get_instance ();
883
884    dialog.run ();
885    dialog.destroy ();
886}
887
888public interface WelcomeServiceEntry : GLib.Object {
889    public abstract void execute ();
890}
891
892// This function is used to determine whether or not files should be copied or linked when imported.
893// Returns ACCEPT for copy, REJECT for link, and CANCEL for (drum-roll) cancel.
894public Gtk.ResponseType copy_files_dialog () {
895    string msg = _ ("Photos can copy the photos into your library folder or it can import them without copying.");
896
897    var dialog = new Granite.MessageDialog.with_image_from_icon_name (
898        _("Import to Library"),
899        msg,
900        "dialog-question",
901        Gtk.ButtonsType.CANCEL
902    );
903    dialog.transient_for = AppWindow.get_instance ();
904
905    dialog.add_button (_("Co_py Photos"), Gtk.ResponseType.ACCEPT);
906    dialog.add_button (_("_Import in Place"), Gtk.ResponseType.REJECT);
907
908    var result = (Gtk.ResponseType) dialog.run ();
909
910    dialog.destroy ();
911
912    return result;
913}
914
915public void remove_photos_from_library (Gee.Collection<LibraryPhoto> photos) {
916    remove_from_app (photos, _ ("Remove From Library"),
917                     ngettext ("Removing Photo From Library", "Removing Photos From Library", photos.size), false);
918}
919
920public void remove_from_app (Gee.Collection<MediaSource> sources, string dialog_title,
921                             string progress_dialog_text, bool delete_files) {
922    if (sources.size == 0)
923        return;
924
925    Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto> ();
926    Gee.ArrayList<Video> videos = new Gee.ArrayList<Video> ();
927    MediaSourceCollection.filter_media (sources, photos, videos);
928
929    AppWindow.get_instance ().set_busy_cursor ();
930
931    ProgressDialog progress = null;
932    ProgressMonitor monitor = null;
933    if (sources.size >= 20) {
934        progress = new ProgressDialog (AppWindow.get_instance (), progress_dialog_text);
935        monitor = progress.monitor;
936    }
937
938    // Remove and attempt to trash.
939    LibraryPhoto.global.remove_from_app (photos, delete_files, monitor, null);
940    Video.global.remove_from_app (videos, delete_files, monitor, null);
941
942    if (delete_files) {
943        // Attempt to delete the files.
944        Gee.ArrayList<LibraryPhoto> not_deleted_photos = new Gee.ArrayList<LibraryPhoto> ();
945        Gee.ArrayList<Video> not_deleted_videos = new Gee.ArrayList<Video> ();
946        LibraryPhoto.global.delete_backing_files (photos, monitor, not_deleted_photos);
947        Video.global.delete_backing_files (videos, monitor, not_deleted_videos);
948
949        int num_not_deleted = not_deleted_photos.size + not_deleted_videos.size;
950        if (num_not_deleted > 0) {
951            // Alert the user that the files were not removed.
952            string delete_failed_message =
953                ngettext ("The photo or video cannot be deleted.",
954                          "%d photos/videos cannot be deleted.",
955                          num_not_deleted).printf (num_not_deleted);
956            AppWindow.error_message (dialog_title, delete_failed_message);
957        }
958    }
959
960    if (progress != null)
961        progress.close ();
962
963    AppWindow.get_instance ().set_normal_cursor ();
964}
965