/* Copyright 2016 Software Freedom Conservancy Inc.
 *
 * This software is licensed under the GNU LGPL (version 2.1 or later).
 * See the COPYING file in this distribution.
 */

// namespace for future migration of AppWindow alert and other question dialogs into single
// place: http://trac.yorba.org/ticket/3452
namespace Dialogs {

public bool confirm_delete_tag(Tag tag) {
    int count = tag.get_sources_count();
    if (count == 0)
        return true;
    string msg = ngettext(
        "This will remove the tag “%s” from one photo. Continue?",
        "This will remove the tag “%s” from %d photos. Continue?",
        count).printf(tag.get_user_visible_name(), count);
    
    return AppWindow.negate_affirm_question(msg, _("_Cancel"), _("_Delete"),
        Resources.DELETE_TAG_TITLE);
}

public bool confirm_delete_saved_search(SavedSearch search) {
    string msg = _("This will remove the saved search “%s”. Continue?")
        .printf(search.get_name());
    
    return AppWindow.negate_affirm_question(msg, _("_Cancel"), _("_Delete"),
        Resources.DELETE_SAVED_SEARCH_DIALOG_TITLE);
}

public bool confirm_warn_developer_changed(int number) {
    Gtk.MessageDialog dialog = new Gtk.MessageDialog.with_markup(AppWindow.get_instance(),
        Gtk.DialogFlags.MODAL, Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE,
        "<span weight=\"bold\" size=\"larger\">%s</span>",
        ngettext("Switching developers will undo all changes you have made to this photo in Shotwell",
        "Switching developers will undo all changes you have made to these photos in Shotwell", number));

    dialog.add_buttons(Resources.CANCEL_LABEL, Gtk.ResponseType.CANCEL);
    dialog.add_buttons(_("_Switch Developer"), Gtk.ResponseType.YES);
    
    int response = dialog.run();
    
    dialog.destroy();
    
    return response == Gtk.ResponseType.YES;
}

}

namespace ExportUI {
private static File current_export_dir = null;

public File? choose_file(string current_file_basename) {
    if (current_export_dir == null)
        current_export_dir = File.new_for_path(Environment.get_home_dir());

    string file_chooser_title = VideoReader.is_supported_video_filename(current_file_basename) ?
        _("Export Video") : _("Export Photo");
        
    Gtk.FileChooserDialog chooser = new Gtk.FileChooserDialog(file_chooser_title,
        AppWindow.get_instance(), Gtk.FileChooserAction.SAVE, Resources.CANCEL_LABEL, 
        Gtk.ResponseType.CANCEL, Resources.SAVE_LABEL, Gtk.ResponseType.ACCEPT, null);
    chooser.set_do_overwrite_confirmation(true);
    chooser.set_current_folder(current_export_dir.get_path());
    chooser.set_current_name(current_file_basename);
    chooser.set_local_only(false);
    
    File file = null;
    if (chooser.run() == Gtk.ResponseType.ACCEPT) {
        file = File.new_for_path(chooser.get_filename());
        current_export_dir = file.get_parent();
    }
    chooser.destroy();
    
    return file;
}

public File? choose_dir(string? user_title = null) {
    if (current_export_dir == null)
        current_export_dir = File.new_for_path(Environment.get_home_dir());

    if (user_title == null)
        user_title = _("Export Photos");

    Gtk.FileChooserDialog chooser = new Gtk.FileChooserDialog(user_title,
        AppWindow.get_instance(), Gtk.FileChooserAction.SELECT_FOLDER, Resources.CANCEL_LABEL, 
        Gtk.ResponseType.CANCEL, Resources.OK_LABEL, Gtk.ResponseType.ACCEPT, null);
    chooser.set_current_folder(current_export_dir.get_path());
    chooser.set_local_only(false);
    
    File dir = null;
    if (chooser.run() == Gtk.ResponseType.ACCEPT) {
        dir = File.new_for_path(chooser.get_filename());
        current_export_dir = dir;
    }
    
    chooser.destroy();
    
    return dir;
}
}

// Ticket #3023
// Attempt to replace the system error with something friendlier
// if we can't copy an image over for editing in an external tool.
public void open_external_editor_error_dialog(Error err, Photo photo) {
    // Did we fail because we can't write to this directory?
    if (err is IOError.PERMISSION_DENIED || err is FileError.PERM) {
         // Yes - display an alternate error message here.
         AppWindow.error_message(          
            _("Shotwell 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()));
    } else {
        // No - something else is wrong, display the error message 
        // the system gave us.
        AppWindow.error_message(Resources.launch_editor_failed(err));
    }         
}

public Gtk.ResponseType export_error_dialog(File dest, bool photos_remaining) {
    string message = _("Unable to export the following photo due to a file error.\n\n") +
        dest.get_path();

    Gtk.ResponseType response = Gtk.ResponseType.NONE;

    if (photos_remaining) {
        message += _("\n\nWould you like to continue exporting?");
        response = AppWindow.affirm_cancel_question(message, _("Con_tinue"));
    } else {
        AppWindow.error_message(message);
    }

    return response;
}


public class ExportDialog : Gtk.Dialog {
    public const int DEFAULT_SCALE = 1200;

    // "Unmodified" and "Current," though they appear in the "Format:" popup menu, really
    // aren't formats so much as they are operating modes that determine specific formats.
    // Hereafter we'll refer to these as "special formats."
    public const int NUM_SPECIAL_FORMATS = 2;
    public const string UNMODIFIED_FORMAT_LABEL = _("Unmodified");
    public const string CURRENT_FORMAT_LABEL = _("Current");
    
    public const ScaleConstraint[] CONSTRAINT_ARRAY = { ScaleConstraint.ORIGINAL,
        ScaleConstraint.DIMENSIONS, ScaleConstraint.WIDTH, ScaleConstraint.HEIGHT };
    
    public const Jpeg.Quality[] QUALITY_ARRAY = { Jpeg.Quality.LOW, Jpeg.Quality.MEDIUM, 
        Jpeg.Quality.HIGH, Jpeg.Quality.MAXIMUM };

    private static ScaleConstraint current_constraint = ScaleConstraint.ORIGINAL;
    private static ExportFormatParameters current_parameters = ExportFormatParameters.current();
    private static int current_scale = DEFAULT_SCALE;
    
    private Gtk.Grid table = new Gtk.Grid();
    private Gtk.ComboBoxText quality_combo;
    private Gtk.ComboBoxText constraint_combo;
    private Gtk.ComboBoxText format_combo;
    private Gtk.Switch export_metadata;
    private Gee.ArrayList<string> format_options = new Gee.ArrayList<string>();
    private Gtk.Entry pixels_entry;
    private Gtk.Widget ok_button;
    private bool in_insert = false;
    
    public ExportDialog(string title) {
        bool use_header;
        Gtk.Settings.get_default ().get ("gtk-dialogs-use-header", out use_header);
        Object (use_header_bar: use_header ? 1 : 0);
        
        this.title = title;
        resizable = false;

        //get information about the export settings out of our config backend
        Config.Facade config = Config.Facade.get_instance();
        current_parameters.mode = config.get_export_export_format_mode(); //ExportFormatMode
        current_parameters.specified_format = config.get_export_photo_file_format(); //PhotoFileFormat
        current_parameters.quality = config.get_export_quality(); //quality
        current_parameters.export_metadata = config.get_export_export_metadata(); //export metadata
        current_constraint = config.get_export_constraint(); //constraint
        current_scale = config.get_export_scale(); //scale

        quality_combo = new Gtk.ComboBoxText();
        int ctr = 0;
        foreach (Jpeg.Quality quality in QUALITY_ARRAY) {
            quality_combo.append_text(quality.to_string());
            if (quality == current_parameters.quality)
                quality_combo.set_active(ctr);
            ctr++;
        }
        
        constraint_combo = new Gtk.ComboBoxText();
        ctr = 0;
        foreach (ScaleConstraint constraint in CONSTRAINT_ARRAY) {
            constraint_combo.append_text(constraint.to_string());
            if (constraint == current_constraint)
                constraint_combo.set_active(ctr);
            ctr++;
        }

        format_combo = new Gtk.ComboBoxText();
        format_add_option(UNMODIFIED_FORMAT_LABEL);
        format_add_option(CURRENT_FORMAT_LABEL);
        foreach (PhotoFileFormat format in PhotoFileFormat.get_writeable()) {
            format_add_option(format.get_properties().get_user_visible_name());
        }

        pixels_entry = new Gtk.Entry();
        pixels_entry.set_max_length(6);
        pixels_entry.set_text("%d".printf(current_scale));
        
        // register after preparation to avoid signals during init
        constraint_combo.changed.connect(on_constraint_changed);
        format_combo.changed.connect(on_format_changed);
        pixels_entry.changed.connect(on_pixels_changed);
        pixels_entry.insert_text.connect(on_pixels_insert_text);
        pixels_entry.activate.connect(on_activate);

        // layout controls
        add_label(_("_Format:"), 0, 0, format_combo);
        add_control(format_combo, 1, 0);

        add_label(_("_Quality:"), 0, 1, quality_combo);
        add_control(quality_combo, 1, 1);
        
        add_label(_("_Scaling constraint:"), 0, 2, constraint_combo);
        add_control(constraint_combo, 1, 2);

        add_label(_("_Pixels:"), 0, 3, pixels_entry);
        add_control(pixels_entry, 1, 3);
        
        export_metadata = new Gtk.Switch ();
        add_label(_("Export _metadata:"), 0, 4, export_metadata);
        add_control(export_metadata, 1, 4);
        export_metadata.active = true;
        export_metadata.halign = Gtk.Align.START;
        
        table.set_row_spacing(6);
        table.set_column_spacing(12);
        table.set_border_width(18);
        
        ((Gtk.Box) get_content_area()).add(table);
        
        // add buttons to action area
        add_button(Resources.CANCEL_LABEL, Gtk.ResponseType.CANCEL);
        ok_button = add_button(Resources.OK_LABEL, Gtk.ResponseType.OK);
        set_default_response(Gtk.ResponseType.OK);
        
        ok_button.set_can_default(true);
        ok_button.has_default = true;
        set_default(ok_button);

        if (current_constraint == ScaleConstraint.ORIGINAL) {
            pixels_entry.sensitive = false;
            quality_combo.sensitive = false;
        }

        ok_button.grab_focus();
    }
    
    private void format_add_option(string format_name) {
        format_options.add(format_name);
        format_combo.append_text(format_name);
    }
    
    private void format_set_active_text(string text) {
        int selection_ticker = 0;

        foreach (string current_text in format_options) {
            if (current_text == text) {
                format_combo.set_active(selection_ticker);
                return;
            }
            selection_ticker++;
        }
        
        error("format_set_active_text: text '%s' isn't in combo box", text);
    }
    
    private PhotoFileFormat get_specified_format() {
        int index = format_combo.get_active();
        if (index < NUM_SPECIAL_FORMATS)
            index = NUM_SPECIAL_FORMATS;

        index -= NUM_SPECIAL_FORMATS;
        PhotoFileFormat[] writeable_formats = PhotoFileFormat.get_writeable();
        return writeable_formats[index];
    }
    
    private string get_label_for_parameters(ExportFormatParameters params) {
        switch(params.mode) {
            case ExportFormatMode.UNMODIFIED:
                return UNMODIFIED_FORMAT_LABEL;
            
            case ExportFormatMode.CURRENT:
                return CURRENT_FORMAT_LABEL;
            
            case ExportFormatMode.SPECIFIED:
                return params.specified_format.get_properties().get_user_visible_name();            
            
            default:
                error("get_label_for_parameters: unrecognized export format mode");
        }
    }
    
    // unlike other parameters, which should be persisted across dialog executions, the
    // format parameters must be set each time the dialog is executed -- this is why
    // it's passed qualified as ref and not as out
    public bool execute(out int scale, out ScaleConstraint constraint,
        ref ExportFormatParameters parameters) {
        show_all();

        // if the export format mode isn't set to last (i.e., don't use the persisted settings),
        // reset the scale constraint to original size
        if (parameters.mode != ExportFormatMode.LAST) {
            current_constraint = constraint = ScaleConstraint.ORIGINAL;
            constraint_combo.set_active(0);
        }

        if (parameters.mode == ExportFormatMode.LAST)
            parameters = current_parameters;
        else if (parameters.mode == ExportFormatMode.SPECIFIED && !parameters.specified_format.can_write())
            parameters.specified_format = PhotoFileFormat.get_system_default_format();

        format_set_active_text(get_label_for_parameters(parameters));
        on_format_changed();
        
        bool ok = (run() == Gtk.ResponseType.OK);
        if (ok) {
            int index = constraint_combo.get_active();
            assert(index >= 0);
            constraint = CONSTRAINT_ARRAY[index];
            current_constraint = constraint;
            
            scale = int.parse(pixels_entry.get_text());
            if (constraint != ScaleConstraint.ORIGINAL)
                assert(scale > 0);
            current_scale = scale;
            
            parameters.export_metadata = export_metadata.sensitive ? export_metadata.active : false;
            
            if (format_combo.get_active_text() == UNMODIFIED_FORMAT_LABEL) {
                parameters.mode = current_parameters.mode = ExportFormatMode.UNMODIFIED;
            } else if (format_combo.get_active_text() == CURRENT_FORMAT_LABEL) {
                parameters.mode = current_parameters.mode = ExportFormatMode.CURRENT;
            } else {
                parameters.mode = current_parameters.mode = ExportFormatMode.SPECIFIED;
                parameters.specified_format = current_parameters.specified_format = get_specified_format();
                if (current_parameters.specified_format == PhotoFileFormat.JFIF)
                    parameters.quality = current_parameters.quality = QUALITY_ARRAY[quality_combo.get_active()];
            }

            //save current settings in config backend for reusing later
            Config.Facade config = Config.Facade.get_instance();
            config.set_export_export_format_mode(current_parameters.mode); //ExportFormatMode
            config.set_export_photo_file_format(current_parameters.specified_format); //PhotoFileFormat
            config.set_export_quality(current_parameters.quality); //quality
            config.set_export_export_metadata(current_parameters.export_metadata); //export metadata
            config.set_export_constraint(current_constraint); //constraint
            config.set_export_scale(current_scale); //scale
        } else {
            scale = 0;
            constraint = ScaleConstraint.ORIGINAL;
        }
        
        destroy();
        
        return ok;
    }
    
    private void add_label(string text, int x, int y, Gtk.Widget? widget = null) {
        Gtk.Label new_label = new Gtk.Label.with_mnemonic(text);
        new_label.halign = Gtk.Align.END;
        new_label.valign = Gtk.Align.CENTER;
        new_label.set_use_underline(true);
        
        if (widget != null)
            new_label.set_mnemonic_widget(widget);
        
        table.attach(new_label, x, y, 1, 1);
    }
    
    private void add_control(Gtk.Widget widget, int x, int y) {
        widget.halign = Gtk.Align.FILL;
        widget.valign = Gtk.Align.CENTER;
        widget.hexpand = true;
        widget.vexpand = true;
        
        table.attach(widget, x, y, 1, 1);
    }
    
    private void on_constraint_changed() {
        bool original = CONSTRAINT_ARRAY[constraint_combo.get_active()] == ScaleConstraint.ORIGINAL;
        bool jpeg = format_combo.get_active_text() ==
            PhotoFileFormat.JFIF.get_properties().get_user_visible_name();
        pixels_entry.sensitive = !original;
        quality_combo.sensitive = !original && jpeg;
        if (original)
            ok_button.sensitive = true;
        else
            on_pixels_changed();
    }

    private void on_format_changed() {
        bool original = CONSTRAINT_ARRAY[constraint_combo.get_active()] == ScaleConstraint.ORIGINAL;
        
        if (format_combo.get_active_text() == UNMODIFIED_FORMAT_LABEL) {
            // if the user wishes to export the media unmodified, then we just copy the original
            // files, so parameterizing size, quality, etc. is impossible -- these are all
            // just as they are in the original file. In this case, we set the scale constraint to
            // original and lock out all the controls
            constraint_combo.set_active(0); /* 0 == original size */
            constraint_combo.set_sensitive(false);
            quality_combo.set_sensitive(false);
            pixels_entry.sensitive = false;
            export_metadata.active = false;
            export_metadata.sensitive = false;
        } else if (format_combo.get_active_text() == CURRENT_FORMAT_LABEL) {
            // if the user wishes to export the media in its current format, we allow sizing but
            // not JPEG quality customization, because in a batch of many photos, it's not
            // guaranteed that all of them will be JPEGs or RAWs that get converted to JPEGs. Some
            // could be PNGs, and PNG has no notion of quality. So lock out the quality control.
            // If the user wants to set JPEG quality, he or she can explicitly specify the JPEG
            // format.
            constraint_combo.set_sensitive(true);
            quality_combo.set_sensitive(false);
            pixels_entry.sensitive = !original;
            export_metadata.sensitive = true;
        } else {
            // if the user has chosen a specific format, then allow JPEG quality customization if
            // the format is JPEG and the user is re-sizing the image, otherwise, disallow JPEG
            // quality customization; always allow scaling.
            constraint_combo.set_sensitive(true);
            bool jpeg = get_specified_format() == PhotoFileFormat.JFIF;
            quality_combo.sensitive = !original && jpeg;
            export_metadata.sensitive = true;
        }
    }
    
    private void on_activate() {
        response(Gtk.ResponseType.OK);
    }
    
    private void on_pixels_changed() {
        ok_button.sensitive = (pixels_entry.get_text_length() > 0) && (int.parse(pixels_entry.get_text()) > 0);
    }
    
    private void on_pixels_insert_text(string text, int length, ref int position) {
        // This is necessary because SignalHandler.block_by_func() is not properly bound
        if (in_insert)
            return;
        
        in_insert = true;
        
        if (length == -1)
            length = (int) text.length;
        
        // only permit numeric text
        string new_text = "";
        for (int ctr = 0; ctr < length; ctr++) {
            if (text[ctr].isdigit()) {
                new_text += ((char) text[ctr]).to_string();
            }
        }
        
        if (new_text.length > 0)
            pixels_entry.insert_text(new_text, (int) new_text.length, ref position);
        
        Signal.stop_emission_by_name(pixels_entry, "insert-text");
        
        in_insert = false;
    }
}

namespace ImportUI {
private const int REPORT_FAILURE_COUNT = 4;
internal const string SAVE_RESULTS_BUTTON_NAME = _("Save Details…");
internal const string SAVE_RESULTS_FILE_CHOOSER_TITLE = _("Save Details");
internal const int SAVE_RESULTS_RESPONSE_ID = 1024;

private string? generate_import_failure_list(Gee.List<BatchImportResult> failed, bool show_dest_id) {
    if (failed.size == 0)
        return null;
    
    string list = "";
    for (int ctr = 0; ctr < REPORT_FAILURE_COUNT && ctr < failed.size; ctr++) {
        list += "%s\n".printf(show_dest_id ? failed.get(ctr).dest_identifier : 
            failed.get(ctr).src_identifier);
    }
    
    int remaining = failed.size - REPORT_FAILURE_COUNT;
    if (remaining > 0)
        list += _("(and %d more)\n").printf(remaining);
    
    return list;
}

public class QuestionParams {
    public string question;
    public string yes_button;
    public string no_button;
    
    public QuestionParams(string question, string yes_button, string no_button) {
        this.question = question;
        this.yes_button = yes_button;
        this.no_button = no_button;
    }
}

public bool import_has_photos(Gee.Collection<BatchImportResult> import_collection) {
    foreach (BatchImportResult current_result in import_collection) {
        if (current_result.file != null
            && PhotoFileFormat.get_by_file_extension(current_result.file) != PhotoFileFormat.UNKNOWN) {
            return true;
        }
    }
    return false;
}

public bool import_has_videos(Gee.Collection<BatchImportResult> import_collection) {
    foreach (BatchImportResult current_result in import_collection) {
        if (current_result.file != null && VideoReader.is_supported_video_file(current_result.file))
            return true;
    }
    return false;
}

public string get_media_specific_string(Gee.Collection<BatchImportResult> import_collection,
    string photos_msg, string videos_msg, string both_msg, string neither_msg) {
    bool has_photos = import_has_photos(import_collection);
    bool has_videos = import_has_videos(import_collection);
        
    if (has_photos && has_videos)
        return both_msg;
    else if (has_photos)
        return photos_msg;
    else if (has_videos)
        return videos_msg;
    else
        return neither_msg;
}

public string create_result_report_from_manifest(ImportManifest manifest) {
    StringBuilder builder = new StringBuilder();
    
    string header = _("Import Results Report") + " (Shotwell " + Resources.APP_VERSION + " @ " +
        TimeVal().to_iso8601() + ")\n\n";
    builder.append(header);
    
    string subhead = (ngettext("Attempted to import %d file.", "Attempted to import %d files.",
        manifest.all.size)).printf(manifest.all.size);
    subhead += " ";
    subhead += (ngettext("Of these, %d file was successfully imported.",
        "Of these, %d files were successfully imported.", manifest.success.size)).printf(
        manifest.success.size);
    subhead += "\n\n";
    builder.append(subhead);
    
    string current_file_summary = "";
    
    //
    // Duplicates
    //
    if (manifest.already_imported.size > 0) {
        builder.append(_("Duplicate Photos/Videos Not Imported:") + "\n\n");
        
        foreach (BatchImportResult result in manifest.already_imported) {
            current_file_summary = result.src_identifier + " " +
            _("duplicates existing media item") + "\n\t" +
            result.duplicate_of.get_file().get_path() + "\n\n";
            
            builder.append(current_file_summary);
        }
    }
    
    //
    // Files Not Imported Due to Camera Errors
    //
    if (manifest.camera_failed.size > 0) {
        builder.append(_("Photos/Videos Not Imported Due to Camera Errors:") + "\n\n");
        
        foreach (BatchImportResult result in manifest.camera_failed) {
            current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " " +
            result.errmsg + "\n\n";
            
            builder.append(current_file_summary);
        }
    }
    
    //
    // Files Not Imported Because They Weren't Recognized as Photos or Videos
    //
    if (manifest.skipped_files.size > 0) {
        builder.append(_("Files Not Imported Because They Weren’t Recognized as Photos or Videos:")
            + "\n\n");
        
        foreach (BatchImportResult result in manifest.skipped_files) {
            current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " " +
            result.errmsg + "\n\n";
            
            builder.append(current_file_summary);
        }
    }
    
    //
    // Photos/Videos Not Imported Because They Weren't in a Format Shotwell Understands
    //
    if (manifest.skipped_photos.size > 0) {
        builder.append(_("Photos/Videos Not Imported Because They Weren’t in a Format Shotwell Understands:")
            + "\n\n");
        
        foreach (BatchImportResult result in manifest.skipped_photos) {
            current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " " +
                result.errmsg + "\n\n";
            
            builder.append(current_file_summary);
        }
    }
    
    //
    // Photos/Videos Not Imported Because Shotwell Couldn't Copy Them into its Library
    //
    if (manifest.write_failed.size > 0) {
        builder.append(_("Photos/Videos Not Imported Because Shotwell Couldn’t Copy Them into its Library:")
             + "\n\n");
        
        foreach (BatchImportResult result in manifest.write_failed) {
            current_file_summary = (_("couldn’t copy %s\n\tto %s")).printf(result.src_identifier,
            result.dest_identifier) + "\n\t" + _("error message:") + " " +
            result.errmsg + "\n\n";

            builder.append(current_file_summary);
        }
    }

    //
    // Photos/Videos Not Imported Because GDK Pixbuf Library Identified them as Corrupt
    //
    if (manifest.corrupt_files.size > 0) {
        builder.append(_("Photos/Videos Not Imported Because Files Are Corrupt:")
             + "\n\n");
        
        foreach (BatchImportResult result in manifest.corrupt_files) {
            current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " |" +
                result.errmsg + "|\n\n";

            builder.append(current_file_summary);
        }
    }
    
    //
    // Photos/Videos Not Imported for Other Reasons
    //
    if (manifest.failed.size > 0) {
        builder.append(_("Photos/Videos Not Imported for Other Reasons:") + "\n\n");
        
        foreach (BatchImportResult result in manifest.failed) {
            current_file_summary = result.src_identifier + "\n\t" + _("error message:") + " " +
            result.errmsg + "\n\n";
            
            builder.append(current_file_summary);
        }
    }
    
    return builder.str;
}

// Summarizes the contents of an import manifest in an on-screen message window. Returns
// true if the user selected the yes action, false otherwise.
public bool report_manifest(ImportManifest manifest, bool show_dest_id, 
    QuestionParams? question = null) {
    string message = "";
    
    if (manifest.already_imported.size > 0) {
        string photos_message = (ngettext("1 duplicate photo was not imported:\n",
            "%d duplicate photos were not imported:\n",
            manifest.already_imported.size)).printf(manifest.already_imported.size);
        string videos_message = (ngettext("1 duplicate video was not imported:\n",
            "%d duplicate videos were not imported:\n",
            manifest.already_imported.size)).printf(manifest.already_imported.size);
        string both_message = (ngettext("1 duplicate photo/video was not imported:\n",
            "%d duplicate photos/videos were not imported:\n",
            manifest.already_imported.size)).printf(manifest.already_imported.size);

        message += get_media_specific_string(manifest.already_imported, photos_message,
            videos_message, both_message, both_message);
        
        message += generate_import_failure_list(manifest.already_imported, show_dest_id);
    }
    
    if (manifest.failed.size > 0) {
        if (message.length > 0)
            message += "\n";
        
        string photos_message = (ngettext("1 photo failed to import due to a file or hardware error:\n",
            "%d photos failed to import due to a file or hardware error:\n",
            manifest.failed.size)).printf(manifest.failed.size);
        string videos_message = (ngettext("1 video failed to import due to a file or hardware error:\n",
            "%d videos failed to import due to a file or hardware error:\n",
            manifest.failed.size)).printf(manifest.failed.size);
        string both_message = (ngettext("1 photo/video failed to import due to a file or hardware error:\n",
            "%d photos/videos failed to import due to a file or hardware error:\n",
            manifest.failed.size)).printf(manifest.failed.size);
        string neither_message = (ngettext("1 file failed to import due to a file or hardware error:\n",
            "%d files failed to import due to a file or hardware error:\n",
            manifest.failed.size)).printf(manifest.failed.size);
        
        message += get_media_specific_string(manifest.failed, photos_message, videos_message,
            both_message, neither_message);
        
        message += generate_import_failure_list(manifest.failed, show_dest_id);
    }
    
    if (manifest.write_failed.size > 0) {
        if (message.length > 0)
            message += "\n";
        
        string photos_message = (ngettext("1 photo failed to import because the photo library folder was not writable:\n",
            "%d photos failed to import because the photo library folder was not writable:\n",
            manifest.write_failed.size)).printf(manifest.write_failed.size);
        string videos_message = (ngettext("1 video failed to import because the photo library folder was not writable:\n",
            "%d videos failed to import because the photo library folder was not writable:\n",
            manifest.write_failed.size)).printf(manifest.write_failed.size);
        string both_message = (ngettext("1 photo/video failed to import because the photo library folder was not writable:\n",
            "%d photos/videos failed to import because the photo library folder was not writable:\n",
            manifest.write_failed.size)).printf(manifest.write_failed.size);
        string neither_message = (ngettext("1 file failed to import because the photo library folder was not writable:\n",
            "%d files failed to import because the photo library folder was not writable:\n",
            manifest.write_failed.size)).printf(manifest.write_failed.size);
        
        message += get_media_specific_string(manifest.write_failed, photos_message, videos_message,
            both_message, neither_message);
        
        message += generate_import_failure_list(manifest.write_failed, show_dest_id);
    }
    
    if (manifest.camera_failed.size > 0) {
        if (message.length > 0)
            message += "\n";

        string photos_message = (ngettext("1 photo failed to import due to a camera error:\n",
            "%d photos failed to import due to a camera error:\n",
            manifest.camera_failed.size)).printf(manifest.camera_failed.size);
        string videos_message = (ngettext("1 video failed to import due to a camera error:\n",
            "%d videos failed to import due to a camera error:\n",
            manifest.camera_failed.size)).printf(manifest.camera_failed.size);
        string both_message = (ngettext("1 photo/video failed to import due to a camera error:\n",
            "%d photos/videos failed to import due to a camera error:\n",
            manifest.camera_failed.size)).printf(manifest.camera_failed.size);
        string neither_message = (ngettext("1 file failed to import due to a camera error:\n",
            "%d files failed to import due to a camera error:\n",
            manifest.camera_failed.size)).printf(manifest.camera_failed.size);
        
        message += get_media_specific_string(manifest.camera_failed, photos_message, videos_message,
            both_message, neither_message);
        
        message += generate_import_failure_list(manifest.camera_failed, show_dest_id);
    }

    if (manifest.corrupt_files.size > 0) {
        if (message.length > 0)
            message += "\n";
        
        string photos_message = (ngettext("1 photo failed to import because it was corrupt:\n",
            "%d photos failed to import because they were corrupt:\n",
            manifest.corrupt_files.size)).printf(manifest.corrupt_files.size);
        string videos_message = (ngettext("1 video failed to import because it was corrupt:\n",
            "%d videos failed to import because they were corrupt:\n",
            manifest.corrupt_files.size)).printf(manifest.corrupt_files.size);
        string both_message = (ngettext("1 photo/video failed to import because it was corrupt:\n",
            "%d photos/videos failed to import because they were corrupt:\n",
            manifest.corrupt_files.size)).printf(manifest.corrupt_files.size);
        string neither_message = (ngettext("1 file failed to import because it was corrupt:\n",
            "%d files failed to import because it was corrupt:\n",
            manifest.corrupt_files.size)).printf(manifest.corrupt_files.size);
        
        message += get_media_specific_string(manifest.corrupt_files, photos_message, videos_message,
            both_message, neither_message);
        
        message += generate_import_failure_list(manifest.corrupt_files, show_dest_id);
    }
    
    if (manifest.skipped_photos.size > 0) {
        if (message.length > 0)
            message += "\n";
        // we have no notion of "unsupported" video files right now in Shotwell (all
        // standard container formats are supported, it's just that the streams in them
        // might or might not be interpretable), so this message does not need to be
        // media specific
        string skipped_photos_message = (ngettext("1 unsupported photo skipped:\n",
            "%d unsupported photos skipped:\n", manifest.skipped_photos.size)).printf(
            manifest.skipped_photos.size);

        message += skipped_photos_message;
        
        message += generate_import_failure_list(manifest.skipped_photos, show_dest_id);
    }

    if (manifest.skipped_files.size > 0) {
        if (message.length > 0)
            message += "\n";

        // we have no notion of "non-video" video files right now in Shotwell, so this
        // message doesn't need to be media specific
        string skipped_files_message = (ngettext("1 non-image file skipped.\n",
            "%d non-image files skipped.\n", manifest.skipped_files.size)).printf(
            manifest.skipped_files.size);

        message += skipped_files_message;
    }
    
    if (manifest.aborted.size > 0) {
        if (message.length > 0)
            message += "\n";

        string photos_message = (ngettext("1 photo skipped due to user cancel:\n",
            "%d photos skipped due to user cancel:\n",
            manifest.aborted.size)).printf(manifest.aborted.size);
        string videos_message = (ngettext("1 video skipped due to user cancel:\n",
            "%d videos skipped due to user cancel:\n",
            manifest.aborted.size)).printf(manifest.aborted.size);
        string both_message = (ngettext("1 photo/video skipped due to user cancel:\n",
            "%d photos/videos skipped due to user cancel:\n",
            manifest.aborted.size)).printf(manifest.aborted.size);
        string neither_message = (ngettext("1 file skipped due to user cancel:\n",
            "%d file skipped due to user cancel:\n",
            manifest.aborted.size)).printf(manifest.aborted.size);
        
        message += get_media_specific_string(manifest.aborted, photos_message, videos_message,
            both_message, neither_message);
        
        message += generate_import_failure_list(manifest.aborted, show_dest_id);
    }
    
    if (manifest.success.size > 0) {
        if (message.length > 0)
            message += "\n";

        string photos_message = (ngettext("1 photo successfully imported.\n",
            "%d photos successfully imported.\n",
            manifest.success.size)).printf(manifest.success.size);
        string videos_message = (ngettext("1 video successfully imported.\n",
            "%d videos successfully imported.\n",
            manifest.success.size)).printf(manifest.success.size);
        string both_message = (ngettext("1 photo/video successfully imported.\n",
            "%d photos/videos successfully imported.\n",
            manifest.success.size)).printf(manifest.success.size);
        
        message += get_media_specific_string(manifest.success, photos_message, videos_message,
            both_message, "");
    }
    
    int total = manifest.success.size + manifest.failed.size + manifest.camera_failed.size
        + manifest.skipped_photos.size + manifest.skipped_files.size + manifest.corrupt_files.size
        + manifest.already_imported.size + manifest.aborted.size + manifest.write_failed.size;
    assert(total == manifest.all.size);
    
    // if no media items were imported at all (i.e. an empty directory attempted), need to at least
    // report that nothing was imported
    if (total == 0)
        message += _("No photos or videos imported.\n");
    
    Gtk.MessageDialog dialog = null;
    int dialog_response = Gtk.ResponseType.NONE;
    if (question == null) {
        dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL,
            Gtk.MessageType.INFO, Gtk.ButtonsType.NONE, "%s", message);
        dialog.title = _("Import Complete");
        Gtk.Widget save_results_button = dialog.add_button(ImportUI.SAVE_RESULTS_BUTTON_NAME,
            ImportUI.SAVE_RESULTS_RESPONSE_ID);
        save_results_button.set_visible(manifest.success.size < manifest.all.size);
        Gtk.Widget ok_button = dialog.add_button(Resources.OK_LABEL, Gtk.ResponseType.OK);
        dialog.set_default(ok_button);
        
        Gtk.Window dialog_parent = (Gtk.Window) dialog.get_parent();
        dialog_response = dialog.run();
        dialog.destroy();
        
        if (dialog_response == ImportUI.SAVE_RESULTS_RESPONSE_ID)
            save_import_results(dialog_parent, create_result_report_from_manifest(manifest));

    } else {
        message += ("\n" + question.question);
        
        dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL,
            Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "%s", message);
        dialog.title = _("Import Complete");
        Gtk.Widget save_results_button = dialog.add_button(ImportUI.SAVE_RESULTS_BUTTON_NAME,
            ImportUI.SAVE_RESULTS_RESPONSE_ID);
        save_results_button.set_visible(manifest.success.size < manifest.all.size);
        Gtk.Widget no_button = dialog.add_button(question.no_button, Gtk.ResponseType.NO);
        dialog.add_button(question.yes_button, Gtk.ResponseType.YES);
        dialog.set_default(no_button);
        
        dialog_response = dialog.run();
        while (dialog_response == ImportUI.SAVE_RESULTS_RESPONSE_ID) {
            save_import_results(dialog, create_result_report_from_manifest(manifest));
            dialog_response = dialog.run();
        }
        
        dialog.hide();
        dialog.destroy();
    }
    
    return (dialog_response == Gtk.ResponseType.YES);
}

internal void save_import_results(Gtk.Window? chooser_dialog_parent, string results_log) {
    Gtk.FileChooserDialog chooser_dialog = new Gtk.FileChooserDialog(
        ImportUI.SAVE_RESULTS_FILE_CHOOSER_TITLE, chooser_dialog_parent, Gtk.FileChooserAction.SAVE,
        Resources.CANCEL_LABEL, Gtk.ResponseType.CANCEL, Resources.SAVE_AS_LABEL, Gtk.ResponseType.ACCEPT, null);
    chooser_dialog.set_do_overwrite_confirmation(true);
    chooser_dialog.set_current_folder(Environment.get_home_dir());
    chooser_dialog.set_current_name("Shotwell Import Log.txt");
    chooser_dialog.set_local_only(false);
    
    int dialog_result = chooser_dialog.run();
    File? chosen_file = chooser_dialog.get_file();
    chooser_dialog.hide();
    chooser_dialog.destroy();
    
    if (dialog_result == Gtk.ResponseType.ACCEPT && chosen_file != null) {
        try {
            FileOutputStream outstream = chosen_file.replace(null, false, FileCreateFlags.NONE);
            outstream.write(results_log.data);
            outstream.close();
        } catch (Error err) {
            critical("couldn't save import results to log file %s: %s", chosen_file.get_path(),
                err.message);
        }
    }
}

}

public abstract class TextEntryDialogMediator {
    private TextEntryDialog dialog;
    
    public TextEntryDialogMediator(string title, string label, string? initial_text = null,
        Gee.Collection<string>? completion_list = null, string? completion_delimiter = null) {
        Gtk.Builder builder = AppWindow.create_builder();
        dialog = new TextEntryDialog();
        dialog.get_content_area().add((Gtk.Box) builder.get_object("dialog-vbox2"));
        dialog.set_builder(builder);
        dialog.setup(on_modify_validate, title, label, initial_text, completion_list, completion_delimiter);
    }
    
    protected virtual bool on_modify_validate(string text) {
        return true;
    }

    protected string? _execute() {
        return dialog.execute();
    }
}

public abstract class MultiTextEntryDialogMediator {
    private MultiTextEntryDialog dialog;
    
    public MultiTextEntryDialogMediator(string title, string label, string? initial_text = null) {
        Gtk.Builder builder = AppWindow.create_builder();
        dialog = new MultiTextEntryDialog();
        dialog.get_content_area().add((Gtk.Box) builder.get_object("dialog-vbox4"));
        dialog.set_builder(builder);
        dialog.setup(on_modify_validate, title, label, initial_text);
    }
    
    protected virtual bool on_modify_validate(string text) {
        return true;
    }

    protected string? _execute() {
        return dialog.execute();
    }
}


// This method takes primary and secondary texts and returns ready-to-use pango markup 
// for a HIG-compliant alert dialog. Please see 
// http://library.gnome.org/devel/hig-book/2.32/windows-alert.html.en for details.
public string build_alert_body_text(string? primary_text, string? secondary_text, bool should_escape = true) {
    if (should_escape) {
        return "<span weight=\"Bold\" size=\"larger\">%s</span>\n%s".printf(
            guarded_markup_escape_text(primary_text), guarded_markup_escape_text(secondary_text));
    }
    
    return "<span weight=\"Bold\" size=\"larger\">%s</span>\n%s".printf(
        guarded_markup_escape_text(primary_text), secondary_text);
}

// Entry completion for values separated by separators (e.g. comma in the case of tags)
// Partly inspired by the class of the same name in gtkmm-utils by Marko Anastasov
public class EntryMultiCompletion : Gtk.EntryCompletion {
    private string delimiter;
    
    public EntryMultiCompletion(Gee.Collection<string> completion_list, string? delimiter) {
        assert(delimiter == null || delimiter.length == 1);
        this.delimiter = delimiter;
        
        set_model(create_completion_store(completion_list));
        set_text_column(0);
        set_match_func(match_func);
    }
    
    private static Gtk.ListStore create_completion_store(Gee.Collection<string> completion_list) {
        Gtk.ListStore completion_store = new Gtk.ListStore(1, typeof(string));
        Gtk.TreeIter store_iter;
        Gee.Iterator<string> completion_iter = completion_list.iterator();
        while (completion_iter.next()) {
            completion_store.append(out store_iter);
            completion_store.set(store_iter, 0, completion_iter.get(), -1);
        }
        
        return completion_store;
    }
    
    private bool match_func(Gtk.EntryCompletion completion, string key, Gtk.TreeIter iter) {
        Gtk.TreeModel model = completion.get_model();
        string possible_match;
        model.get(iter, 0, out possible_match);
        
        // Normalize key and possible matches to allow comparison of non-ASCII characters.
        // Use a "COMPOSE" normalization to allow comparison to the position value returned by 
        // Gtk.Entry, i.e. one character=one position. Using the default normalization a character
        // like "é" or "ö" would have a length of two.
        possible_match = possible_match.casefold().normalize(-1, NormalizeMode.ALL_COMPOSE);
        string normed_key = key.normalize(-1, NormalizeMode.ALL_COMPOSE);
        
        if (delimiter == null) {
            return possible_match.has_prefix(normed_key.strip());
        } else {
            if (normed_key.contains(delimiter)) {
                // check whether cursor is before last delimiter
                int offset = normed_key.char_count(normed_key.last_index_of_char(delimiter[0]));
                int position = ((Gtk.Entry) get_entry()).get_position();
                if (position <= offset)
                    return false; // TODO: Autocompletion for tags not last in list
            }
            
            string last_part = get_last_part(normed_key.strip(), delimiter);
            
            if (last_part.length == 0) 
                return false; // need at least one character to show matches
                
            return possible_match.has_prefix(last_part.strip());
        }
    }

    public override bool match_selected(Gtk.TreeModel model, Gtk.TreeIter iter) {
        string match;
        model.get(iter, 0, out match);
        
        Gtk.Entry entry = (Gtk.Entry)get_entry();
        
        string old_text = entry.get_text().normalize(-1, NormalizeMode.ALL_COMPOSE);
        if (old_text.length > 0) {
            if (old_text.contains(delimiter)) {
                old_text = old_text.substring(0, old_text.last_index_of_char(delimiter[0]) + 1) + (delimiter != " " ? " " : "");
            } else
                old_text = "";
        }
        
        string new_text = old_text + match + delimiter + (delimiter != " " ? " " : "");
        entry.set_text(new_text);
        entry.set_position((int) new_text.length);
        
        return true;
    }
    
    // Find last string after any delimiter
    private static string get_last_part(string s, string delimiter) {
        string[] split = s.split(delimiter);
        
        if((split != null) && (split[0] != null)) {
            return split[split.length - 1];
        } else {
            return "";
        }
    }
}

public abstract class SetBackgroundDialog {
    protected Gtk.Dialog dialog;
    protected Gtk.CheckButton desktop_background_button;
    protected Gtk.CheckButton screensaver_button;
    protected Gtk.Button ok_button;
    // the checkbuttons themselves are initialized to these values
    protected bool desktop = true;
    protected bool screensaver = false;

    public SetBackgroundDialog(Gtk.Builder builder) {
        
        dialog = builder.get_object("dialog1") as Gtk.Dialog;
        dialog.set_type_hint(Gdk.WindowTypeHint.DIALOG);
        dialog.set_parent_window(AppWindow.get_instance().get_parent_window());
        dialog.set_transient_for(AppWindow.get_instance());
        dialog.set_default_response(Gtk.ResponseType.OK);
        
        desktop_background_button = builder.get_object("desktop_background_checkbox") as Gtk.CheckButton;
        desktop_background_button.active = desktop;
        desktop_background_button.toggled.connect(on_checkbox_clicked);
        screensaver_button = builder.get_object("screensaver_checkbox") as Gtk.CheckButton;
        screensaver_button.active = screensaver;
        screensaver_button.toggled.connect(on_checkbox_clicked);
        
        ok_button = builder.get_object("ok_button") as Gtk.Button;
    }
    
    protected void on_checkbox_clicked() {
        desktop = desktop_background_button.active;
        screensaver = screensaver_button.active;

        if (!desktop && !screensaver) {
            ok_button.sensitive = false;
        } else {
            ok_button.sensitive = true;
        }
    }
    
    protected bool execute_base() {
        dialog.show_all();
        bool result = dialog.run() == Gtk.ResponseType.OK;
        dialog.destroy();
        
        return result;
    }
}

public class SetBackgroundPhotoDialog : SetBackgroundDialog {
    
    public SetBackgroundPhotoDialog() {
        Gtk.Builder builder = AppWindow.create_builder("set_background_dialog.ui", this);
        base(builder);
    }
    
    public bool execute(out bool desktop_background, out bool screensaver) {
        bool result = execute_base();
        
        desktop_background = this.desktop;
        screensaver = this.screensaver;
        
        return result;
    }
}

public class SetBackgroundSlideshowDialog : SetBackgroundDialog {
    private Gtk.Label delay_value_label;
    private Gtk.Scale delay_scale;
    private int delay_value = 0;
    
    public SetBackgroundSlideshowDialog() {
        Gtk.Builder builder = AppWindow.create_builder("set_background_slideshow_dialog.ui", this);
        base(builder);
        
        delay_value_label = builder.get_object("delay_value_label") as Gtk.Label;
        
        delay_scale = builder.get_object("delay_scale") as Gtk.Scale;
        delay_scale.value_changed.connect(on_delay_scale_value_changed);
        delay_scale.adjustment.value = 50;
    }
    
    private void on_delay_scale_value_changed() {
        double value = delay_scale.adjustment.value;
        
        // f(x)=x^5 allows to have fine-grained values (seconds) to the left
        // and very coarse-grained values (hours) to the right of the slider.
        // We limit maximum value to 1 day and minimum to 5 seconds.
        delay_value = (int) (Math.pow(value, 5) / Math.pow(90, 5) * 60 * 60 * 24 + 5);
        
        // convert to text and remove fractions from values > 1 minute
        string text;
        if (delay_value < 60) {
            text = ngettext("%d second", "%d seconds", delay_value).printf(delay_value);
        } else if (delay_value < 60 * 60) {
            int minutes = delay_value / 60;
            text = ngettext("%d minute", "%d minutes", minutes).printf(minutes);
            delay_value = minutes * 60; 
        } else if (delay_value < 60 * 60 * 24) {
            int hours = delay_value / (60 * 60);
            text = ngettext("%d hour", "%d hours", hours).printf(hours);
            delay_value = hours * (60 * 60);
        } else {
            text = _("1 day");
            delay_value = 60 * 60 * 24;
        }
        
        delay_value_label.label = text;
    }
    
    public bool execute(out int delay_value, out bool desktop_background, out bool screensaver) {
        bool result = execute_base();
        
        delay_value = this.delay_value;
        desktop_background = this.desktop;
        screensaver = this.screensaver;
        
        return result;
    }
}

public class TextEntryDialog : Gtk.Dialog {
    public delegate bool OnModifyValidateType(string text);
    
    private unowned OnModifyValidateType on_modify_validate;
    private Gtk.Entry entry;
    private Gtk.Builder builder;
    private Gtk.Button button1;
    private Gtk.Button button2;
    
    public TextEntryDialog() {
        bool use_header;
        Gtk.Settings.get_default ().get ("gtk-dialogs-use-header", out use_header);
        Object (use_header_bar: use_header ? 1 : 0);
    }
    
    public void set_builder(Gtk.Builder builder) {
        this.builder = builder;
    }
    
    public void setup(OnModifyValidateType? modify_validate, string title, string label, 
        string? initial_text, Gee.Collection<string>? completion_list, string? completion_delimiter) {
        set_title(title);
        set_resizable(true);
        set_parent_window(AppWindow.get_instance().get_parent_window());
        set_transient_for(AppWindow.get_instance());
        on_modify_validate = modify_validate;

        Gtk.Label name_label = builder.get_object("label") as Gtk.Label;
        name_label.set_text(label);

        entry = builder.get_object("entry") as Gtk.Entry;
        entry.set_text(initial_text != null ? initial_text : "");
        entry.grab_focus();
        entry.changed.connect(on_entry_changed);
        
        button1 = (Gtk.Button) add_button(Resources.CANCEL_LABEL, Gtk.ResponseType.CANCEL);
        button2 = (Gtk.Button) add_button(Resources.SAVE_LABEL, Gtk.ResponseType.OK);
        set_default_response(Gtk.ResponseType.OK);
        
        if (completion_list != null) { // Textfield with autocompletion
            EntryMultiCompletion completion = new EntryMultiCompletion(completion_list,
                completion_delimiter);
            entry.set_completion(completion);
        }
        
        set_default_response(Gtk.ResponseType.OK);
    }

    public string? execute() {
        string? text = null;
        
        // validate entry to start with
        set_response_sensitive(Gtk.ResponseType.OK, on_modify_validate(entry.get_text()));
        
        show_all();
        
        if (run() == Gtk.ResponseType.OK)
            text = entry.get_text();
        
        entry.changed.disconnect(on_entry_changed);
        destroy();
        
        return text;
    }
    
    public void on_entry_changed() {
        set_response_sensitive(Gtk.ResponseType.OK, on_modify_validate(entry.get_text()));
    }
}

public class MultiTextEntryDialog : Gtk.Dialog {
    public delegate bool OnModifyValidateType(string text);
    
    private unowned OnModifyValidateType on_modify_validate;
    private Gtk.TextView entry;
    private Gtk.Builder builder;
    private Gtk.Button button1;
    private Gtk.Button button2;
    
    public MultiTextEntryDialog() {
        bool use_header;
        Gtk.Settings.get_default ().get ("gtk-dialogs-use-header", out use_header);
        Object (use_header_bar: use_header ? 1 : 0);
    }
    
    public void set_builder(Gtk.Builder builder) {
        this.builder = builder;
    }
    
    public void setup(OnModifyValidateType? modify_validate, string title, string label, string? initial_text) {
        set_title(title);
        set_resizable(true);
        set_default_size(500,300);
        set_parent_window(AppWindow.get_instance().get_parent_window());
        set_transient_for(AppWindow.get_instance());
        on_modify_validate = modify_validate;
        
        entry = builder.get_object("textview1") as Gtk.TextView;
        entry.set_wrap_mode (Gtk.WrapMode.WORD);
        entry.buffer = new Gtk.TextBuffer(null);
        entry.buffer.text = (initial_text != null ? initial_text : "");
        
        entry.grab_focus();
        
        button1 = (Gtk.Button) add_button(Resources.CANCEL_LABEL, Gtk.ResponseType.CANCEL);
        button2 = (Gtk.Button) add_button(Resources.SAVE_LABEL, Gtk.ResponseType.OK);
        set_default_response(Gtk.ResponseType.OK);
    }
        
    public string? execute() {
        string? text = null;
        
        show_all();
        
        if (run() == Gtk.ResponseType.OK)
            text = entry.buffer.text;
        
        destroy();
        
        return text;
    }
}

public class EventRenameDialog : TextEntryDialogMediator {
    public EventRenameDialog(string? event_name) {
        base (_("Rename Event"), _("Name:"), event_name);
    }

    public virtual string? execute() {
        return Event.prep_event_name(_execute());
    }
}

public class EditTitleDialog : TextEntryDialogMediator {
    public EditTitleDialog(string? photo_title) {
        // Dialog title
        base (C_("Dialog Title", "Edit Title"),
            _("Title:"), photo_title);
    }
    
    public virtual string? execute() {
        return MediaSource.prep_title(_execute());
    }
    
    protected override bool on_modify_validate(string text) {
        return true;
    }
}

public class EditCommentDialog : MultiTextEntryDialogMediator {
    public EditCommentDialog(string? comment, bool is_event = false) {
        string title_tmp = (is_event)
            // Dialog title
            ? _("Edit Event Comment")
            : _("Edit Photo/Video Comment");
        base(title_tmp, _("Comment:"), comment);
    }
    
    public virtual string? execute() {
        return MediaSource.prep_comment(_execute());
    }
    
    protected override bool on_modify_validate(string text) {
        return true;
    }
}

// Returns: Gtk.ResponseType.YES (trash photos), Gtk.ResponseType.NO (only remove photos) and
// Gtk.ResponseType.CANCEL.
public Gtk.ResponseType remove_from_library_dialog(Gtk.Window owner, string title,
    string user_message, int count) {
    string trash_action = ngettext("Remove and _Trash File", "Remove and _Trash Files", count);
    
    Gtk.MessageDialog dialog = new Gtk.MessageDialog(owner, Gtk.DialogFlags.MODAL,
        Gtk.MessageType.WARNING, Gtk.ButtonsType.CANCEL, "%s", user_message);
    dialog.add_button(_("_Remove From Library"), Gtk.ResponseType.NO);
    dialog.add_button(trash_action, Gtk.ResponseType.YES);

    // This dialog was previously created outright; we now 'hijack' 
    // dialog's old title and use it as the primary text, along with
    // using the message as the secondary text.
    dialog.set_markup(build_alert_body_text(title, user_message));
    
    Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
    
    dialog.destroy();
    
    return result;
}

// Returns: Gtk.ResponseType.YES (delete photos), Gtk.ResponseType.NO (keep photos)
public Gtk.ResponseType remove_from_filesystem_dialog(Gtk.Window owner, string title,
    string user_message) {
    Gtk.MessageDialog dialog = new Gtk.MessageDialog(owner, Gtk.DialogFlags.MODAL,
        Gtk.MessageType.QUESTION, Gtk.ButtonsType.NONE, "%s", user_message);
    dialog.add_button(_("_Keep"), Gtk.ResponseType.NO);
    dialog.add_button(_("_Delete"), Gtk.ResponseType.YES);
    dialog.set_default_response( Gtk.ResponseType.NO);
   
    dialog.set_markup(build_alert_body_text(title, user_message));
    
    Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
    
    dialog.destroy();
    
    return result;
}

public bool revert_editable_dialog(Gtk.Window owner, Gee.Collection<Photo> photos) {
    int count = 0;
    foreach (Photo photo in photos) {
        if (photo.has_editable())
            count++;
    }
    
    if (count == 0)
        return false;
           
    string headline = (count == 1) ? _("Revert External Edit?") : _("Revert External Edits?");
    string msg = ngettext(
        "This will destroy all changes made to the external file. Continue?",
        "This will destroy all changes made to %d external files. Continue?",
        count).printf(count);

    string action = (count == 1) ? _("Re_vert External Edit") : _("Re_vert External Edits");
    
    Gtk.MessageDialog dialog = new Gtk.MessageDialog(owner, Gtk.DialogFlags.MODAL,
        Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE, "%s", msg);
    dialog.add_button(_("_Cancel"), Gtk.ResponseType.CANCEL);
    dialog.add_button(action, Gtk.ResponseType.YES);

    dialog.set_markup(build_alert_body_text(headline, msg));
    
    Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
    
    dialog.destroy();
    
    return result == Gtk.ResponseType.YES;
}

public bool remove_offline_dialog(Gtk.Window owner, int count) {
    if (count == 0)
        return false;
    
    string msg = ngettext(
        "This will remove the photo from the library. Continue?",
        "This will remove %d photos from the library. Continue?",
        count).printf(count);
    
    Gtk.MessageDialog dialog = new Gtk.MessageDialog(owner, Gtk.DialogFlags.MODAL,
        Gtk.MessageType.WARNING, Gtk.ButtonsType.NONE, "%s", msg);
    dialog.add_button(_("_Cancel"), Gtk.ResponseType.CANCEL);
    dialog.add_button(_("_Remove"), Gtk.ResponseType.OK);
    dialog.title = (count == 1) ? _("Remove Photo From Library") : _("Remove Photos From Library");
    
    Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
    
    dialog.destroy();
    
    return result == Gtk.ResponseType.OK;
}

public class ProgressDialog : Gtk.Window {
    private Gtk.ProgressBar progress_bar = new Gtk.ProgressBar();
    private Gtk.Button cancel_button = null;
    private Cancellable cancellable;
    private uint64 last_count = uint64.MAX;
    private int update_every = 1;
    private int minimum_on_screen_time_msec = 500;
    private ulong time_started;
#if UNITY_SUPPORT
    UnityProgressBar uniprobar = UnityProgressBar.get_instance();
#endif
    
    public ProgressDialog(Gtk.Window? owner, string text, Cancellable? cancellable = null) {
        this.cancellable = cancellable;
        
        set_title(text);
        set_resizable(false);
        if (owner != null)
            set_transient_for(owner);
        set_modal(true);
        set_type_hint(Gdk.WindowTypeHint.DIALOG);
        
        progress_bar.set_size_request(300, -1);
        progress_bar.set_show_text(true);
        
        Gtk.Box vbox_bar = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
        vbox_bar.pack_start(progress_bar, true, false, 0);
        
        if (cancellable != null) {
            cancel_button = new Gtk.Button.with_mnemonic(Resources.CANCEL_LABEL);
            cancel_button.clicked.connect(on_cancel);
            delete_event.connect(on_window_closed);
        }
        
        Gtk.Box hbox = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
        hbox.pack_start(vbox_bar, true, false, 0);
        if (cancel_button != null)
            hbox.pack_end(cancel_button, false, false, 0);
        
        Gtk.Label primary_text_label = new Gtk.Label("");
        primary_text_label.set_markup("<span weight=\"bold\">%s</span>".printf(text));
        primary_text_label.xalign = 0.0f;
        primary_text_label.yalign = 0.5f;

        Gtk.Box vbox = new Gtk.Box(Gtk.Orientation.VERTICAL, 12);
        vbox.pack_start(primary_text_label, false, false, 0);
        vbox.pack_start(hbox, true, false, 0);
        vbox.halign = Gtk.Align.CENTER;
        vbox.valign = Gtk.Align.CENTER;
        vbox.hexpand = true;
        vbox.vexpand = true;
        vbox.margin_start = 12;
        vbox.margin_end = 12;
        vbox.margin_top = 12;
        vbox.margin_bottom = 12;

        add(vbox);

        time_started = now_ms();
    }
    
    public override void realize() {
        base.realize();
        
        // if unable to cancel the progress bar, remove the close button
        if (cancellable == null)
            get_window().set_functions(Gdk.WMFunction.MOVE);
    }
    
    public void update_display_every(int update_every) {
        assert(update_every >= 1);
        
        this.update_every = update_every;
    }
    
    public void set_minimum_on_screen_time_msec(int minimum_on_screen_time_msec) {
        this.minimum_on_screen_time_msec = minimum_on_screen_time_msec;
    }
    
    public void set_fraction(int current, int total) {
        set_percentage((double) current / (double) total);
    }
    
    public void set_percentage(double pct) {
        pct = pct.clamp(0.0, 1.0);
        
        maybe_show_all(pct);
        
        progress_bar.set_fraction(pct);
        progress_bar.set_text(_("%d%%").printf((int) (pct * 100.0)));
        
#if UNITY_SUPPORT
        //UnityProgressBar: set progress
        uniprobar.set_progress(pct);
#endif
    }
    
    public void set_status(string text) {
        progress_bar.set_text(text);
        
#if UNITY_SUPPORT
        //UnityProgressBar: try to draw progress bar
        uniprobar.set_visible(true);
#endif
        show_all();
    }
    
    // This can be used as a ProgressMonitor delegate.
    public bool monitor(uint64 count, uint64 total, bool do_event_loop = true) {
        if ((last_count == uint64.MAX) || (count - last_count) >= update_every) {
            set_percentage((double) count / (double) total);
            last_count = count;
        }
        
        bool keep_going = (cancellable != null) ? !cancellable.is_cancelled() : true;
        
        // TODO: get rid of this.  non-trivial, as some progress-monitor operations are blocking
        // and need to allow the event loop to spin
        //
        // Important: Since it's possible the progress dialog might be destroyed inside this call,
        // avoid referring to "this" afterwards at all costs (in case all refs have been dropped)
        
        if (do_event_loop)
            spin_event_loop();
        
        return keep_going;
    }
    
    public new void close() {
#if UNITY_SUPPORT
        //UnityProgressBar: reset
        uniprobar.reset();
#endif
        hide();
        destroy();
    }
    
    private bool on_window_closed() {
        on_cancel();
        return false; // return false so that the system handler will remove the window from
                      // the screen
    }
    
    private void on_cancel() {
        if (cancellable != null)
            cancellable.cancel();
        
        cancel_button.sensitive = false;
    }
    
    private void maybe_show_all(double pct) {
        // Appear only after a while because some jobs may take only a 
        // fraction of second to complete so there's no point in showing progress.
        if (!this.visible && now_ms() - time_started > minimum_on_screen_time_msec) {
            // calculate percents completed in one ms
            double pps = pct * 100.0 / minimum_on_screen_time_msec;
            // calculate [very rough] estimate of time to complete in ms
            double ttc = 100.0 / pps;
            // If there is still more work to do for at least MINIMUM_ON_SCREEN_TIME_MSEC, 
            // finally display the dialog.
            if (ttc > minimum_on_screen_time_msec) {
#if UNITY_SUPPORT
                //UnityProgressBar: try to draw progress bar
                uniprobar.set_visible(true);
#endif
                show_all(); 
                spin_event_loop();
            }
        }
    }
}

public class AdjustDateTimeDialog : Gtk.Dialog {
    private const int64 SECONDS_IN_DAY = 60 * 60 * 24;
    private const int64 SECONDS_IN_HOUR = 60 * 60;
    private const int64 SECONDS_IN_MINUTE = 60;
    private const int YEAR_OFFSET = 1900;
    private bool no_original_time = false;
    
    private const int CALENDAR_THUMBNAIL_SCALE = 1;

    time_t original_time;
    Gtk.Label original_time_label;
    Gtk.Calendar calendar;
    Gtk.SpinButton hour;
    Gtk.SpinButton minute;
    Gtk.SpinButton second;
    Gtk.ComboBoxText system;
    Gtk.RadioButton relativity_radio_button;
    Gtk.RadioButton batch_radio_button;
    Gtk.CheckButton modify_originals_check_button;
    Gtk.Label notification;

    private enum TimeSystem {
        AM,
        PM,
        24HR;
    }    

    TimeSystem previous_time_system;

    public AdjustDateTimeDialog(Dateable source, int photo_count, bool display_options = true,
        bool contains_video = false, bool only_video = false) {
        assert(source != null);

        bool use_header;
        Gtk.Settings.get_default ().get ("gtk-dialogs-use-header", out use_header);
        Object(use_header_bar: use_header ? 1 : 0);
        
        set_modal(true);
        set_resizable(false);
        set_transient_for(AppWindow.get_instance());

        add_buttons(Resources.CANCEL_LABEL, Gtk.ResponseType.CANCEL,
                    Resources.OK_LABEL, Gtk.ResponseType.OK);
        set_title(Resources.ADJUST_DATE_TIME_LABEL);

        calendar = new Gtk.Calendar();
        calendar.day_selected.connect(on_time_changed);
        calendar.month_changed.connect(on_time_changed);
        calendar.next_year.connect(on_time_changed);
        calendar.prev_year.connect(on_time_changed);

        if (Config.Facade.get_instance().get_use_24_hour_time())
            hour = new Gtk.SpinButton.with_range(0, 23, 1);
        else
            hour = new Gtk.SpinButton.with_range(1, 12, 1);

        hour.output.connect(on_spin_button_output);
        hour.set_width_chars(2);  
        hour.set_max_width_chars(2);

        minute = new Gtk.SpinButton.with_range(0, 59, 1);
        minute.set_width_chars(2);
        minute.set_max_width_chars(2);
        minute.output.connect(on_spin_button_output);

        second = new Gtk.SpinButton.with_range(0, 59, 1);
        second.set_width_chars(2);
        second.set_max_width_chars(2);
        second.output.connect(on_spin_button_output);

        system = new Gtk.ComboBoxText();
        system.append_text(_("AM"));
        system.append_text(_("PM"));
        system.append_text(_("24 Hr"));
        system.changed.connect(on_time_system_changed);

        Gtk.Box clock = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 3);

        clock.pack_start(hour, false, false, 0);
        clock.pack_start(new Gtk.Label(":"), false, false, 0); // internationalize?
        clock.pack_start(minute, false, false, 0);
        clock.pack_start(new Gtk.Label(":"), false, false, 0);
        clock.pack_start(second, false, false, 0);
        clock.pack_start(system, false, false, 0);

        set_default_response(Gtk.ResponseType.OK);
        
        relativity_radio_button = new Gtk.RadioButton.with_mnemonic(null, 
            _("_Shift photos/videos by the same amount"));
        relativity_radio_button.set_active(Config.Facade.get_instance().get_keep_relativity());
        relativity_radio_button.sensitive = display_options && photo_count > 1;

        batch_radio_button = new Gtk.RadioButton.with_mnemonic(relativity_radio_button.get_group(),
            _("Set _all photos/videos to this time"));
        batch_radio_button.set_active(!Config.Facade.get_instance().get_keep_relativity());
        batch_radio_button.sensitive = display_options && photo_count > 1;
        batch_radio_button.toggled.connect(on_time_changed);

        if (contains_video) {
            var text = ngettext ("_Modify original photo file", "_Modify original photo files",
                                 photo_count);
            modify_originals_check_button = new Gtk.CheckButton.with_mnemonic(text);
        } else {
            var text = ngettext ("_Modify original file", "_Modify original files", photo_count);
            modify_originals_check_button = new Gtk.CheckButton.with_mnemonic(text);
        }

        modify_originals_check_button.set_active(Config.Facade.get_instance().get_commit_metadata_to_masters() &&
            display_options);
        modify_originals_check_button.sensitive = (!only_video) &&
            (!Config.Facade.get_instance().get_commit_metadata_to_masters() && display_options);

        Gtk.Box time_content = new Gtk.Box(Gtk.Orientation.VERTICAL, 5);

        time_content.pack_start(calendar, true, false, 0);
        time_content.pack_start(clock, true, false, 0);

        if (display_options) {
            time_content.pack_start(relativity_radio_button, true, false, 0);
            time_content.pack_start(batch_radio_button, true, false, 0);
            time_content.pack_start(modify_originals_check_button, true, false, 0);
        }

        Gdk.Pixbuf preview = null;
        try {
            // Instead of calling get_pixbuf() here, we use the thumbnail instead;
            // this was needed for Videos, since they don't support get_pixbuf().
            preview = source.get_thumbnail(CALENDAR_THUMBNAIL_SCALE);
        } catch (Error err) {
            warning("Unable to fetch preview for %s", source.to_string());
        }
        
        Gtk.Box image_content = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
        image_content.set_valign(Gtk.Align.START);
        image_content.set_homogeneous(true);
        Gtk.Image image = (preview != null) ? new Gtk.Image.from_pixbuf(preview) : new Gtk.Image();
        original_time_label = new Gtk.Label(null);
        image_content.pack_start(image, true, false, 0);
        image_content.pack_start(original_time_label, true, false, 0);

        Gtk.Box hbox = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 10);
        hbox.set_border_width(3);
        hbox.pack_start(image_content, true, false, 0);
        hbox.pack_start(time_content, true, false, 0);
        hbox.halign = Gtk.Align.CENTER;
        hbox.valign = Gtk.Align.CENTER;
        hbox.hexpand = false;
        hbox.vexpand = false;

        ((Gtk.Box) get_content_area()).pack_start(hbox, true, false, 0);

        notification = new Gtk.Label("");
        notification.set_line_wrap(true);
        notification.set_justify(Gtk.Justification.CENTER);

        ((Gtk.Box) get_content_area()).pack_start(notification, true, true, 0);
        
        original_time = source.get_exposure_time();

        if (original_time == 0) {
            original_time = time_t();
            no_original_time = true;
        }

        set_time(Time.local(original_time));
        set_original_time_label(Config.Facade.get_instance().get_use_24_hour_time());
    }

    private void set_time(Time time) {
        calendar.select_month(time.month, time.year + YEAR_OFFSET);
        calendar.select_day(time.day);

        if (Config.Facade.get_instance().get_use_24_hour_time()) {
            system.set_active(TimeSystem.24HR);
            hour.set_value(time.hour);
        } else {
            int AMPM_hour = time.hour % 12;
            hour.set_value((AMPM_hour == 0) ? 12 : AMPM_hour);
            system.set_active((time.hour >= 12) ? TimeSystem.PM : TimeSystem.AM);
        }

        minute.set_value(time.minute);
        second.set_value(time.second);

        previous_time_system = (TimeSystem) system.get_active();
    }

    private void set_original_time_label(bool use_24_hr_format) {
        if (no_original_time)
            return;

        original_time_label.set_text(_("Original: ") + 
            Time.local(original_time).format(use_24_hr_format ? _("%m/%d/%Y, %H:%M:%S") :
            _("%m/%d/%Y, %I:%M:%S %p")));
    }

    private time_t get_time() {
        Time time = Time();

        time.second = (int) second.get_value();
        time.minute = (int) minute.get_value();

        // convert to 24 hr
        int hour = (int) hour.get_value();
        time.hour = (hour == 12 && system.get_active() != TimeSystem.24HR) ? 0 : hour;
        time.hour += ((system.get_active() == TimeSystem.PM) ? 12 : 0);

        uint year, month, day;
        calendar.get_date(out year, out month, out day);
        time.year = ((int) year) - YEAR_OFFSET;
        time.month = (int) month;
        time.day = (int) day;

        time.isdst = -1;

        return time.mktime();
    }

    public bool execute(out int64 time_shift, out bool keep_relativity, 
        out bool modify_originals) {
        show_all();

        bool response = false;

        if (run() == Gtk.ResponseType.OK) {
            if (no_original_time)
                time_shift = (int64) get_time();
            else
                time_shift = (int64) (get_time() - original_time);

            keep_relativity = relativity_radio_button.get_active();

            if (relativity_radio_button.sensitive)
                Config.Facade.get_instance().set_keep_relativity(keep_relativity);

            modify_originals = modify_originals_check_button.get_active();

            if (modify_originals_check_button.sensitive)
                Config.Facade.get_instance().set_modify_originals(modify_originals);

            response = true;
        } else {
            time_shift = 0;
            keep_relativity = true;
            modify_originals = false;
        }

        destroy();

        return response;
    }

    private bool on_spin_button_output(Gtk.SpinButton button) {
        button.set_text("%02d".printf((int) button.get_value()));

        on_time_changed();

        return true;
    }

    private void on_time_changed() {
        int64 time_shift = ((int64) get_time() - (int64) original_time);

        previous_time_system = (TimeSystem) system.get_active();

        if (time_shift == 0 || no_original_time || (batch_radio_button.get_active() &&
            batch_radio_button.sensitive)) {
            notification.hide();
        } else {
            bool forward = time_shift > 0;
            int days, hours, minutes, seconds;

            time_shift = time_shift.abs();

            days = (int) (time_shift / SECONDS_IN_DAY);
            time_shift = time_shift % SECONDS_IN_DAY;
            hours = (int) (time_shift / SECONDS_IN_HOUR);
            time_shift = time_shift % SECONDS_IN_HOUR;
            minutes = (int) (time_shift / SECONDS_IN_MINUTE);
            seconds = (int) (time_shift % SECONDS_IN_MINUTE);

            string shift_status = (forward) ?
                _("Exposure time will be shifted forward by\n%d %s, %d %s, %d %s, and %d %s.") :
                _("Exposure time will be shifted backward by\n%d %s, %d %s, %d %s, and %d %s.");

            notification.set_text(shift_status.printf(days, ngettext("day", "days", days),
                hours, ngettext("hour", "hours", hours), minutes, 
                ngettext("minute", "minutes", minutes), seconds, 
                ngettext("second", "seconds", seconds)));

            notification.show();
        }
    }

    private void on_time_system_changed() {
        if (previous_time_system == system.get_active())
            return;

        Config.Facade.get_instance().set_use_24_hour_time(system.get_active() == TimeSystem.24HR);

        if (system.get_active() == TimeSystem.24HR) {
            int time = (hour.get_value() == 12.0) ? 0 : (int) hour.get_value();
            time = time + ((previous_time_system == TimeSystem.PM) ? 12 : 0);

            hour.set_range(0, 23);
            set_original_time_label(true);

            hour.set_value(time);
        } else {
            int AMPM_hour = ((int) hour.get_value()) % 12;

            hour.set_range(1, 12);
            set_original_time_label(false);

            hour.set_value((AMPM_hour == 0) ? 12 : AMPM_hour);
        }

        on_time_changed();
    }
}

public const int MAX_OBJECTS_DISPLAYED = 3;
public void multiple_object_error_dialog(Gee.ArrayList<DataObject> objects, string message, 
    string title) {
    string dialog_message = message + "\n";

    //add objects
    for(int i = 0; i < MAX_OBJECTS_DISPLAYED && objects.size > i; i++)
        dialog_message += "\n" + objects.get(i).to_string();

    int remainder = objects.size - MAX_OBJECTS_DISPLAYED;
    if (remainder > 0) {
        dialog_message += ngettext("\n\nAnd %d other.", "\n\nAnd %d others.",
            remainder).printf(remainder);
    }

    Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(),
        Gtk.DialogFlags.MODAL, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, "%s", dialog_message);
    
    dialog.title = title;
    
    dialog.run();
    dialog.destroy();
}

public abstract class TagsDialog : TextEntryDialogMediator {
    public TagsDialog(string title, string label, string? initial_text = null) {
        base (title, label, initial_text, HierarchicalTagIndex.get_global_index().get_all_tags(),
            ",");
    }
}

public class AddTagsDialog : TagsDialog {
    public AddTagsDialog() {
        var title = GLib.dpgettext2 (null, "Dialog Title",
                Resources.ADD_TAGS_TITLE);
        base (title, _("Tags (separated by commas):"));
    }

    public string[]? execute() {
        string? text = _execute();
        if (text == null)
            return null;
        
        // only want to return null if the user chose cancel, however, on_modify_validate ensures
        // that Tag.prep_tag_names won't return a zero-length array (and it never returns null)
        return Tag.prep_tag_names(text.split(","));
    }

    protected override bool on_modify_validate(string text) {
        if (text.contains(Tag.PATH_SEPARATOR_STRING))
            return false;
            
        // Can't simply call Tag.prep_tag_names().length because of this bug:
        // https://bugzilla.gnome.org/show_bug.cgi?id=602208
        string[] names = Tag.prep_tag_names(text.split(","));
        
        return names.length > 0;
    }
}

public class ModifyTagsDialog : TagsDialog {
    public ModifyTagsDialog(MediaSource source) {
        base (Resources.MODIFY_TAGS_LABEL, _("Tags (separated by commas):"), 
            get_initial_text(source));
    }
    
    private static string? get_initial_text(MediaSource source) {
        Gee.Collection<Tag>? source_tags = Tag.global.fetch_for_source(source);
        if (source_tags == null)
            return null;

        Gee.Collection<Tag> terminal_tags = Tag.get_terminal_tags(source_tags);
        
        Gee.SortedSet<string> tag_basenames = new Gee.TreeSet<string>();
        foreach (Tag tag in terminal_tags)
            tag_basenames.add(HierarchicalTagUtilities.get_basename(tag.get_path()));
        
        string? text = null;
        foreach (string name in tag_basenames) {
            if (text == null)
                text = "";
            else
                text += ", ";
            
            text += name;
        }
        
        return text;
    }
    
    public Gee.ArrayList<Tag>? execute() {
        string? text = _execute();
        if (text == null)
            return null;
        
        Gee.ArrayList<Tag> new_tags = new Gee.ArrayList<Tag>();
        
        // return empty list if no tags specified
        if (is_string_empty(text))
            return new_tags;
        
        // break up by comma-delimiter, prep for use, and separate into list
        string[] tag_names = Tag.prep_tag_names(text.split(","));
        
        tag_names = HierarchicalTagIndex.get_global_index().get_paths_for_names_array(tag_names);

        foreach (string name in tag_names)
            new_tags.add(Tag.for_path(name));
        
        return new_tags;
    }
    
    protected override bool on_modify_validate(string text) {
        return (!text.contains(Tag.PATH_SEPARATOR_STRING));
    }
    
}

public interface WelcomeServiceEntry : GLib.Object {
    public abstract string get_service_name();
    
    public abstract void execute();
}

public class WelcomeDialog : Gtk.Dialog {
    Gtk.CheckButton hide_button;
    Gtk.CheckButton? system_pictures_import_check = null;
    Gtk.CheckButton[] external_import_checks = new Gtk.CheckButton[0];
    WelcomeServiceEntry[] external_import_entries = new WelcomeServiceEntry[0];
    Gtk.Label secondary_text;
    Gtk.Label instruction_header;
    Gtk.Box import_content;
    Gtk.Box import_action_checkbox_packer;
    Gtk.Box external_import_action_checkbox_packer;
    Spit.DataImports.WelcomeImportMetaHost import_meta_host;
    bool import_content_already_installed = false;
    bool ok_clicked = false;
    
    public WelcomeDialog(Gtk.Window owner) {
        import_meta_host = new Spit.DataImports.WelcomeImportMetaHost(this);
        bool show_system_pictures_import = is_system_pictures_import_possible();
        Gtk.Widget ok_button = add_button(Resources.OK_LABEL, Gtk.ResponseType.OK);
        set_title(_("Welcome!"));
        set_resizable(false);
        set_type_hint(Gdk.WindowTypeHint.DIALOG);
        set_transient_for(owner);

        Gtk.Label primary_text = new Gtk.Label("");
        primary_text.set_markup(
            "<span size=\"large\" weight=\"bold\">%s</span>".printf(_("Welcome to Shotwell!")));
        primary_text.xalign = 0.0f;
        primary_text.yalign = 0.5f;
        secondary_text = new Gtk.Label("");
        secondary_text.set_markup("<span weight=\"normal\">%s</span>".printf(
            _("To get started, import photos in any of these ways:")));
        secondary_text.xalign = 0.0f;
        secondary_text.yalign = 0.5f;
        var image = new Gtk.Image.from_icon_name ("shotwell", Gtk.IconSize.DIALOG);
        
        Gtk.Box header_text = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
        header_text.pack_start(primary_text, false, false, 5);
        header_text.pack_start(secondary_text, false, false, 0);

        Gtk.Box header_content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 12);
        header_content.pack_start(image, false, false, 0);
        header_content.pack_start(header_text, false, false, 0);

        Gtk.Label instructions = new Gtk.Label("");
        string indent_prefix = "   "; // we can't tell what the indent prefix is going to be so assume we need one
        
        string arrow_glyph = (get_direction() == Gtk.TextDirection.RTL) ? "◂" : "▸";
        
        instructions.set_markup(((indent_prefix + "&#8226; %s\n") + (indent_prefix + "&#8226; %s\n")
            + (indent_prefix + "&#8226; %s")).printf(
            _("Choose <span weight=\"bold\">File %s Import From Folder</span>").printf(arrow_glyph),
            _("Drag and drop photos onto the Shotwell window"),
            _("Connect a camera to your computer and import")));
        instructions.xalign = 0.0f;
        instructions.yalign = 0.5f;
        
        import_action_checkbox_packer = new Gtk.Box(Gtk.Orientation.VERTICAL, 2);
        
        external_import_action_checkbox_packer = new Gtk.Box(Gtk.Orientation.VERTICAL, 2);
        import_action_checkbox_packer.add(external_import_action_checkbox_packer);
        
        if (show_system_pictures_import) {
            system_pictures_import_check = new Gtk.CheckButton.with_mnemonic(
                _("_Import photos from your %s folder").printf(
                get_display_pathname(AppDirs.get_import_dir())));
            import_action_checkbox_packer.add(system_pictures_import_check);
            system_pictures_import_check.set_active(true);
        }
        
        instruction_header = new Gtk.Label(
            _("You can also import photos in any of these ways:"));
        instruction_header.xalign = 0.0f;
        instruction_header.yalign = 0.5f;
        instruction_header.set_margin_top(20);
        
        Gtk.Box content = new Gtk.Box(Gtk.Orientation.VERTICAL, 16);
        content.pack_start(header_content, true, true, 0);
        import_content = new Gtk.Box(Gtk.Orientation.VERTICAL, 2);
        content.add(import_content);
        content.pack_start(instructions, false, false, 0);

        hide_button = new Gtk.CheckButton.with_mnemonic(_("_Don’t show this message again"));
        hide_button.set_active(true);
        content.pack_start(hide_button, false, false, 6);
        content.halign = Gtk.Align.FILL;
        content.valign = Gtk.Align.FILL;
        content.hexpand = false;
        content.vexpand = false;
        content.margin_top = 12;
        content.margin_bottom = 0;
        content.margin_start = 12;
        content.margin_end = 12;

        ((Gtk.Box) get_content_area()).pack_start(content, false, false, 0);

        ok_button.grab_focus();

        install_import_content();

        import_meta_host.start();
    }

    private void install_import_content() {
        if (
            (external_import_checks.length > 0 || system_pictures_import_check != null) &&
            (import_content_already_installed == false)
        ) {
            secondary_text.set_markup("");
            import_content.add(import_action_checkbox_packer);
            import_content.add(instruction_header);
            import_content_already_installed = true;
        }
    }
    
    public void install_service_entry(WelcomeServiceEntry entry) {
        debug("WelcomeDialog: Installing service entry for %s".printf(entry.get_service_name()));
        external_import_entries += entry;
        Gtk.CheckButton entry_check = new Gtk.CheckButton.with_label(
            _("Import photos from your %s library").printf(entry.get_service_name()));
        external_import_checks += entry_check;
        entry_check.set_active(true);
        external_import_action_checkbox_packer.add(entry_check);
        install_import_content();
    }

    /**
     * Connected to the 'response' signal.  This is part of a workaround
     * for the fact that run()-ning this dialog can interfere with displaying
     * images from a camera; please see #4997 for details.
     */
    private void on_dismiss(int resp) {
        if (resp == Gtk.ResponseType.OK) {
            ok_clicked = true;
        }
        hide();
        Gtk.main_quit();
    }

    public bool execute(out WelcomeServiceEntry[] selected_import_entries, out bool do_system_pictures_import) {
        // it's unsafe to call run() here - it interferes with displaying
        // images from a camera - so we process the dialog ourselves.
        response.connect(on_dismiss);
        show_all();
        show();

        // this will block the thread we're in until a matching call
        // to main_quit() is encountered; this happens when either the window
        // is closed or OK is clicked.
        Gtk.main();
        
        // at this point, the inner main loop will have been exited.
        // we've got the response, so we don't need this signal anymore.
        response.disconnect(on_dismiss);

        bool ok = ok_clicked;
        bool show_dialog = true;

        if (ok)
            show_dialog = !hide_button.get_active();

        // Use a temporary variable as += cannot be used on parameters
        WelcomeServiceEntry[] result = new WelcomeServiceEntry[0];
        for (int i = 0; i < external_import_entries.length; i++) {
            if (external_import_checks[i].get_active() == true)
                result += external_import_entries[i];
        }
        selected_import_entries = result;
        do_system_pictures_import = 
            (system_pictures_import_check != null) ? system_pictures_import_check.get_active() : false;

        destroy();

        return show_dialog;
    }
    
    private static bool is_system_pictures_import_possible() {
        File system_pictures = AppDirs.get_import_dir();
        if (!system_pictures.query_exists(null))
            return false;
        
        if (!(system_pictures.query_file_type(FileQueryInfoFlags.NONE, null) == FileType.DIRECTORY))
            return false;

        try {
            FileEnumerator syspics_child_enum = system_pictures.enumerate_children("standard::*",
                FileQueryInfoFlags.NONE, null);
            return (syspics_child_enum.next_file(null) != null);
        } catch (Error e) {
            return false;
        }
    }
}

public class PreferencesDialog {
    private class PathFormat {
        public PathFormat(string name, string? pattern) {
            this.name = name;
            this.pattern = pattern;
        }
        public string name;
        public string? pattern;
    } 
    
    private static PreferencesDialog preferences_dialog;
    
    private Gtk.Dialog dialog;
    private Gtk.Builder builder;
    private Gtk.Adjustment bg_color_adjustment;
    private Gtk.Scale bg_color_slider;
    private Gtk.ComboBox photo_editor_combo;
    private Gtk.ComboBox raw_editor_combo;
    private SortedList<AppInfo> external_raw_apps;
    private SortedList<AppInfo> external_photo_apps;
    private Gtk.FileChooserButton library_dir_button;
    private Gtk.ComboBoxText dir_pattern_combo;
    private Gtk.Entry dir_pattern_entry;
    private Gtk.Label dir_pattern_example;
    private bool allow_closing = false;
    private string? lib_dir = null;
    private Gee.ArrayList<PathFormat> path_formats = new Gee.ArrayList<PathFormat>();
    private GLib.DateTime example_date = new GLib.DateTime.local(2009, 3, 10, 18, 16, 11);
    private Gtk.CheckButton lowercase;
    private Plugins.ManifestWidgetMediator plugins_mediator = new Plugins.ManifestWidgetMediator();
    private Gtk.ComboBoxText default_raw_developer_combo;

    private PreferencesDialog() {
        builder = AppWindow.create_builder();
        
        dialog = builder.get_object("preferences_dialog") as Gtk.Dialog;
        bool use_header;
        Gtk.Settings.get_default ().get ("gtk-dialogs-use-header", out use_header);
        if (!use_header) {
            Gtk.Widget null_titlebar = null;
            dialog.set_titlebar (null_titlebar);
        }
        dialog.set_parent_window(AppWindow.get_instance().get_parent_window());
        dialog.set_transient_for(AppWindow.get_instance());
        dialog.delete_event.connect(on_delete);
        dialog.response.connect(on_close);
        
        bg_color_adjustment = builder.get_object("bg_color_adjustment") as Gtk.Adjustment;
        bg_color_adjustment.set_value(bg_color_adjustment.get_upper() - 
            (Config.Facade.get_instance().get_bg_color().red * 65535.0));
        bg_color_adjustment.value_changed.connect(on_value_changed);

        bg_color_slider = builder.get_object("bg_color_slider") as Gtk.Scale;
        bg_color_slider.button_press_event.connect(on_bg_color_reset);

        library_dir_button = builder.get_object("library_dir_button") as Gtk.FileChooserButton;
        
        photo_editor_combo = builder.get_object("external_photo_editor_combo") as Gtk.ComboBox;
        raw_editor_combo = builder.get_object("external_raw_editor_combo") as Gtk.ComboBox;
        
        Gtk.Label pattern_help = builder.get_object("pattern_help") as Gtk.Label;

        // Ticket #3162 - Move dir pattern blurb into Gnome help. 
        // Because specifying a particular snippet of the help requires 
        // us to know where its located, we can't hardcode a URL anymore;
        // instead, we ask for the help path, and if we find it, we tell
        // yelp to read from there, otherwise, we read from system-wide. 
        string help_path = Resources.get_help_path();
        
        if (help_path == null) {
            // We're installed system-wide, so use the system help.
            pattern_help.set_markup("<a href=\"" + Resources.DIR_PATTERN_URI_SYSWIDE + "\">" + _("(Help)") + "</a>");
        } else {
            // We're being run from the build directory; we'll have to handle clicks to this
            // link manually ourselves, due to a limitation of help: URIs.
            pattern_help.set_markup("<a href=\"dummy:\">" + _("(Help)") + "</a>");
            pattern_help.activate_link.connect(on_local_pattern_help);
        }
        
        dir_pattern_combo = builder.get_object("dir choser") as Gtk.ComboBoxText;
        dir_pattern_entry = builder.get_object("dir_pattern_entry") as Gtk.Entry;
        dir_pattern_example = builder.get_object("dynamic example") as Gtk.Label;
        add_to_dir_formats(_("Year%sMonth%sDay").printf(Path.DIR_SEPARATOR_S, Path.DIR_SEPARATOR_S), 
            "%Y" + Path.DIR_SEPARATOR_S + "%m" + Path.DIR_SEPARATOR_S + "%d");
        add_to_dir_formats(_("Year%sMonth").printf(Path.DIR_SEPARATOR_S), "%Y" +
            Path.DIR_SEPARATOR_S + "%m");
        add_to_dir_formats(_("Year%sMonth-Day").printf(Path.DIR_SEPARATOR_S), 
            "%Y" + Path.DIR_SEPARATOR_S + "%m-%d");
        add_to_dir_formats(_("Year-Month-Day"), "%Y-%m-%d");
        add_to_dir_formats(_("Custom"), null); // Custom must always be last.
        dir_pattern_combo.changed.connect(on_dir_pattern_combo_changed);
        dir_pattern_entry.changed.connect(on_dir_pattern_entry_changed);
        
        (builder.get_object("dir_structure_label") as Gtk.Label).set_mnemonic_widget(dir_pattern_combo);
        
        lowercase = builder.get_object("lowercase") as Gtk.CheckButton;
        lowercase.toggled.connect(on_lowercase_toggled);
        
        var notebook = builder.get_object("preferences-notebook") as Gtk.Notebook;
        (notebook.get_nth_page (2) as Gtk.Container).add (plugins_mediator.widget);

        populate_preference_options();

        photo_editor_combo.changed.connect(on_photo_editor_changed);
        raw_editor_combo.changed.connect(on_raw_editor_changed);
        
        Gtk.CheckButton auto_import_button = builder.get_object("autoimport") as Gtk.CheckButton;
        auto_import_button.set_active(Config.Facade.get_instance().get_auto_import_from_library());
        
        Gtk.CheckButton commit_metadata_button = builder.get_object("write_metadata") as Gtk.CheckButton;
        commit_metadata_button.set_active(Config.Facade.get_instance().get_commit_metadata_to_masters());
        
        default_raw_developer_combo = builder.get_object("default_raw_developer") as Gtk.ComboBoxText;
        default_raw_developer_combo.append_text(RawDeveloper.CAMERA.get_label());
        default_raw_developer_combo.append_text(RawDeveloper.SHOTWELL.get_label());
        set_raw_developer_combo(Config.Facade.get_instance().get_default_raw_developer());
        default_raw_developer_combo.changed.connect(on_default_raw_developer_changed);
        
        dialog.map_event.connect(map_event);
    }
    
    public void populate_preference_options() {
        populate_app_combo_box(photo_editor_combo, PhotoFileFormat.get_editable_mime_types(), 
            Config.Facade.get_instance().get_external_photo_app(), out external_photo_apps);

        populate_app_combo_box(raw_editor_combo, PhotoFileFormat.RAW.get_mime_types(), 
            Config.Facade.get_instance().get_external_raw_app(), out external_raw_apps);
        
        setup_dir_pattern(dir_pattern_combo, dir_pattern_entry);
        
        lowercase.set_active(Config.Facade.get_instance().get_use_lowercase_filenames());
    }
    
    // Ticket #3162, part II - if we're not yet installed, then we have to manually launch
    // the help viewer and specify the full path to the subsection we want...
    private bool on_local_pattern_help(string ignore) {
        try {
            Resources.launch_help(AppWindow.get_instance().get_screen(), "other-files.page");
        } catch (Error e) {
            message("Unable to launch help: %s", e.message);
        }
        return true;
    }
    
    private void populate_app_combo_box(Gtk.ComboBox combo_box, string[] mime_types,
        string current_app_executable, out SortedList<AppInfo> external_apps) {
        // get list of all applications for the given mime types
        assert(mime_types.length != 0);
        external_apps = DesktopIntegration.get_apps_for_mime_types(mime_types);
        
        if (external_apps.size == 0)
            return;
        
        // populate application ComboBox with app names and icons
        Gtk.CellRendererPixbuf pixbuf_renderer = new Gtk.CellRendererPixbuf();
        Gtk.CellRendererText text_renderer = new Gtk.CellRendererText();
        combo_box.clear();
        combo_box.pack_start(pixbuf_renderer, false);
        combo_box.pack_start(text_renderer, false);
        combo_box.add_attribute(pixbuf_renderer, "pixbuf", 0);
        combo_box.add_attribute(text_renderer, "text", 1);
        
        // TODO: need more space between icons and text
        Gtk.ListStore combo_store = new Gtk.ListStore(2, typeof(Gdk.Pixbuf), typeof(string));
        Gtk.TreeIter iter;
        
        int current_app = -1;
        
        foreach (AppInfo app in external_apps) {
            combo_store.append(out iter);

            Icon app_icon = app.get_icon();
            try {
                if (app_icon is FileIcon) {
                    combo_store.set_value(iter, 0, scale_pixbuf(new Gdk.Pixbuf.from_file(
                        ((FileIcon) app_icon).get_file().get_path()), Resources.DEFAULT_ICON_SCALE,
                        Gdk.InterpType.BILINEAR, false));
                } else if (app_icon is ThemedIcon) {
                    Gdk.Pixbuf icon_pixbuf = 
                        Gtk.IconTheme.get_default().load_icon(((ThemedIcon) app_icon).get_names()[0],
                        Resources.DEFAULT_ICON_SCALE, Gtk.IconLookupFlags.FORCE_SIZE);
                    
                    combo_store.set_value(iter, 0, icon_pixbuf);
                }
            } catch (GLib.Error error) {
                warning("Error loading icon pixbuf: " + error.message);
            }

            combo_store.set_value(iter, 1, app.get_name());
            
            if (app.get_commandline() == current_app_executable)
                current_app = external_apps.index_of(app);
        }
        
        // TODO: allow users to choose unlisted applications like Nautilus's "Open with -> Other Application..."

        combo_box.set_model(combo_store);

        if (current_app != -1)
            combo_box.set_active(current_app);
    }
    
    private void setup_dir_pattern(Gtk.ComboBox combo_box, Gtk.Entry entry) {
        string? pattern = Config.Facade.get_instance().get_directory_pattern();
        bool found = false;
        if (null != pattern) {
            // Locate pre-built text.
            int i = 0;
            foreach (PathFormat pf in path_formats) {
                if (pf.pattern == pattern) {
                    combo_box.set_active(i);
                    found = true;
                    break;
                }
                i++;
            }
        } else {
            // Custom path.
            string? s = Config.Facade.get_instance().get_directory_pattern_custom();
            if (!is_string_empty(s)) {
                combo_box.set_active(path_formats.size - 1); // Assume "custom" is last.
                found = true;
            }
        }
        
        if (!found) {
            combo_box.set_active(0);
        }
        
        on_dir_pattern_combo_changed();
    }
    
    public static void show() {
        if (preferences_dialog == null) 
            preferences_dialog = new PreferencesDialog();
        
        preferences_dialog.populate_preference_options();
        preferences_dialog.dialog.show_all();
        preferences_dialog.library_dir_button.set_current_folder(AppDirs.get_import_dir().get_path());

        // Ticket #3001: Cause the dialog to become active if the user chooses 'Preferences'
        // from the menus a second time.
        preferences_dialog.dialog.present();
    }

    // For items that should only be committed when the dialog is closed, not as soon as the change
    // is made.
    private void commit_on_close() {
        Config.Facade.get_instance().commit_bg_color();
        
        Gtk.CheckButton? autoimport = builder.get_object("autoimport") as Gtk.CheckButton;
        if (autoimport != null)
            Config.Facade.get_instance().set_auto_import_from_library(autoimport.active);
        
        Gtk.CheckButton? commit_metadata = builder.get_object("write_metadata") as Gtk.CheckButton;
        if (commit_metadata != null)
            Config.Facade.get_instance().set_commit_metadata_to_masters(commit_metadata.active);
       
        if (lib_dir != null)
            AppDirs.set_import_dir(lib_dir);
        
        PathFormat pf = path_formats.get(dir_pattern_combo.get_active());
        if (null == pf.pattern) {
            Config.Facade.get_instance().set_directory_pattern_custom(dir_pattern_entry.text);
            Config.Facade.get_instance().set_directory_pattern(null);
        } else {
            Config.Facade.get_instance().set_directory_pattern(pf.pattern);
        }
    }
    
    private bool on_delete() {
        if (!get_allow_closing())
            return true;
        
        commit_on_close();
        return dialog.hide_on_delete(); //prevent widgets from getting destroyed
    }
    
    private void on_close() {
        if (!get_allow_closing())
            return;
            
        dialog.hide();
        commit_on_close();
    }
    
    private void on_value_changed() {
        set_background_color((double)(bg_color_adjustment.get_upper() - 
            bg_color_adjustment.get_value()) / 65535.0);
    }

    private bool on_bg_color_reset(Gdk.EventButton event) {
        if (event.button == 1 && event.type == Gdk.EventType.BUTTON_PRESS
            && has_only_key_modifier(event.state, Gdk.ModifierType.CONTROL_MASK)) {
            // Left Mouse Button and CTRL pressed
            bg_color_slider.set_value(bg_color_adjustment.get_upper() - 
                (parse_color(Config.Facade.DEFAULT_BG_COLOR).red * 65536.0f));
            on_value_changed();

            return true;
        }

        return false;
    }
    
    private void on_dir_pattern_combo_changed() {
        PathFormat pf = path_formats.get(dir_pattern_combo.get_active());
        if (null == pf.pattern) {
            // Custom format.
            string? dir_pattern = Config.Facade.get_instance().get_directory_pattern_custom();
            if (is_string_empty(dir_pattern))
                dir_pattern = "";
            dir_pattern_entry.set_text(dir_pattern);
            dir_pattern_entry.editable = true;
            dir_pattern_entry.sensitive = true;
        } else {
            dir_pattern_entry.set_text(pf.pattern);
            dir_pattern_entry.editable = false;
            dir_pattern_entry.sensitive = false;
        }
    }
    
    private void on_dir_pattern_entry_changed() {
         string example = example_date.format(dir_pattern_entry.text);
         if (is_string_empty(example) && !is_string_empty(dir_pattern_entry.text)) {
            // Invalid pattern.
            dir_pattern_example.set_text(_("Invalid pattern"));
            dir_pattern_entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, "dialog-error");
            dir_pattern_entry.set_icon_activatable(Gtk.EntryIconPosition.SECONDARY, false);
            set_allow_closing(false);
         } else {
            // Valid pattern.
            dir_pattern_example.set_text(example);
            dir_pattern_entry.set_icon_from_icon_name(Gtk.EntryIconPosition.SECONDARY, null);
            set_allow_closing(true);
         }
    }
    
    private void set_allow_closing(bool allow) {
        dialog.set_deletable(allow);
        allow_closing = allow;
    }
    
    private bool get_allow_closing() {
        return allow_closing;
    }

    private void set_background_color(double bg_color_value) {
        Config.Facade.get_instance().set_bg_color(to_grayscale(bg_color_value));
    }

    private Gdk.RGBA to_grayscale(double color_value) {
        Gdk.RGBA color = Gdk.RGBA();
        
        color.red = color_value;
        color.green = color_value;
        color.blue = color_value;
        color.alpha = 1.0;
        
        return color;
    }
    
    private void on_photo_editor_changed() {
        int photo_app_choice_index = (photo_editor_combo.get_active() < external_photo_apps.size) ? 
            photo_editor_combo.get_active() : external_photo_apps.size;
            
        AppInfo app = external_photo_apps.get_at(photo_app_choice_index);

        Config.Facade.get_instance().set_external_photo_app(DesktopIntegration.get_app_open_command(app));

        debug("setting external photo editor to: %s", DesktopIntegration.get_app_open_command(app));
    }
    
    private void on_raw_editor_changed() {
        int raw_app_choice_index = (raw_editor_combo.get_active() < external_raw_apps.size) ? 
            raw_editor_combo.get_active() : external_raw_apps.size;
        
        AppInfo app = external_raw_apps.get_at(raw_app_choice_index);
        
        Config.Facade.get_instance().set_external_raw_app(app.get_commandline());
        
        debug("setting external raw editor to: %s", app.get_commandline());
    }
    
    private RawDeveloper raw_developer_from_combo() {
        if (default_raw_developer_combo.get_active() == 0)
            return RawDeveloper.CAMERA;
        return RawDeveloper.SHOTWELL;
    }
    
    private void set_raw_developer_combo(RawDeveloper d) {
        if (d == RawDeveloper.CAMERA)
            default_raw_developer_combo.set_active(0);
        else
            default_raw_developer_combo.set_active(1);
    }
    
    private void on_default_raw_developer_changed() {
        Config.Facade.get_instance().set_default_raw_developer(raw_developer_from_combo());
    }
    
    private void on_current_folder_changed() {
        lib_dir = library_dir_button.get_filename();
    }
    
    private bool map_event() {
        // Set the signal for the lib dir button after the dialog is displayed, 
        // because the FileChooserButton has a nasty habbit of selecting a
        // different folder when displayed if the provided path doesn't exist.
        // See ticket #3000 for more info.
        library_dir_button.current_folder_changed.connect(on_current_folder_changed);
        return true;
    }
    
    private void add_to_dir_formats(string name, string? pattern) {
        PathFormat pf = new PathFormat(name, pattern);
        path_formats.add(pf);
        dir_pattern_combo.append_text(name);
    }
    
    private void on_lowercase_toggled() {
        Config.Facade.get_instance().set_use_lowercase_filenames(lowercase.get_active());
    }
}

// This function is used to determine whether or not files should be copied or linked when imported.
// Returns ACCEPT for copy, REJECT for link, and CANCEL for (drum-roll) cancel.
public Gtk.ResponseType copy_files_dialog() {
    string msg = _("Shotwell can copy the photos into your library folder or it can import them without copying.");

    Gtk.MessageDialog dialog = new Gtk.MessageDialog(AppWindow.get_instance(), Gtk.DialogFlags.MODAL,
        Gtk.MessageType.QUESTION, Gtk.ButtonsType.CANCEL, "%s", msg);

    dialog.add_button(_("Co_py Photos"), Gtk.ResponseType.ACCEPT);
    dialog.add_button(_("_Import in Place"), Gtk.ResponseType.REJECT);
    dialog.title = _("Import to Library");

    Gtk.ResponseType result = (Gtk.ResponseType) dialog.run();
    
    dialog.destroy();

    return result;
}

public void remove_photos_from_library(Gee.Collection<LibraryPhoto> photos) {
    remove_from_app(photos, _("Remove From Library"),
        (photos.size == 1) ? _("Removing Photo From Library") : _("Removing Photos From Library"));
}

public void remove_from_app(Gee.Collection<MediaSource> sources, string dialog_title, 
    string progress_dialog_text) {
    if (sources.size == 0)
        return;
    
    Gee.ArrayList<LibraryPhoto> photos = new Gee.ArrayList<LibraryPhoto>();
    Gee.ArrayList<Video> videos = new Gee.ArrayList<Video>();
    MediaSourceCollection.filter_media(sources, photos, videos);
    
    string? user_message = null;
    if ((!photos.is_empty) && (!videos.is_empty)) {
        user_message = ngettext("This will remove the photo/video from your Shotwell library. Would you also like to move the file to your desktop trash?\n\nThis action cannot be undone.",
            "This will remove %d photos/videos from your Shotwell library. Would you also like to move the files to your desktop trash?\n\nThis action cannot be undone.",
             sources.size).printf(sources.size);
    } else if (!videos.is_empty) {
        user_message = ngettext("This will remove the video from your Shotwell library. Would you also like to move the file to your desktop trash?\n\nThis action cannot be undone.",
            "This will remove %d videos from your Shotwell library. Would you also like to move the files to your desktop trash?\n\nThis action cannot be undone.",
             sources.size).printf(sources.size);
    } else {
        user_message = ngettext("This will remove the photo from your Shotwell library. Would you also like to move the file to your desktop trash?\n\nThis action cannot be undone.",
            "This will remove %d photos from your Shotwell library. Would you also like to move the files to your desktop trash?\n\nThis action cannot be undone.",
             sources.size).printf(sources.size);
    }
    
    Gtk.ResponseType result = remove_from_library_dialog(AppWindow.get_instance(), dialog_title,
        user_message, sources.size);
    if (result != Gtk.ResponseType.YES && result != Gtk.ResponseType.NO)
        return;
    
    bool delete_backing = (result == Gtk.ResponseType.YES);
    
    AppWindow.get_instance().set_busy_cursor();
    
    ProgressDialog progress = null;
    ProgressMonitor monitor = null;
    if (sources.size >= 20) {
        progress = new ProgressDialog(AppWindow.get_instance(), progress_dialog_text);
        monitor = progress.monitor;
    }
        
    Gee.ArrayList<LibraryPhoto> not_removed_photos = new Gee.ArrayList<LibraryPhoto>();
    Gee.ArrayList<Video> not_removed_videos = new Gee.ArrayList<Video>();
    
    // Remove and attempt to trash.
    LibraryPhoto.global.remove_from_app(photos, delete_backing, monitor, not_removed_photos);
    Video.global.remove_from_app(videos, delete_backing, monitor, not_removed_videos);
    
    // Check for files we couldn't trash.
    int num_not_removed = not_removed_photos.size + not_removed_videos.size;
    if (delete_backing && num_not_removed > 0) {
        string not_deleted_message = 
            ngettext("The photo or video cannot be moved to your desktop trash. Delete this file?",
                "%d photos/videos cannot be moved to your desktop trash. Delete these files?",
                num_not_removed).printf(num_not_removed);
        Gtk.ResponseType result_delete = remove_from_filesystem_dialog(AppWindow.get_instance(), 
            dialog_title, not_deleted_message);
            
        if (Gtk.ResponseType.YES == result_delete) {
            // Attempt to delete the files.
            Gee.ArrayList<LibraryPhoto> not_deleted_photos = new Gee.ArrayList<LibraryPhoto>();
            Gee.ArrayList<Video> not_deleted_videos = new Gee.ArrayList<Video>();
            LibraryPhoto.global.delete_backing_files(not_removed_photos, monitor, not_deleted_photos);
            Video.global.delete_backing_files(not_removed_videos, monitor, not_deleted_videos);
            
            int num_not_deleted = not_deleted_photos.size + not_deleted_videos.size;
            if (num_not_deleted > 0) {
                // Alert the user that the files were not removed.
                string delete_failed_message = 
                    ngettext("The photo or video cannot be deleted.",
                        "%d photos/videos cannot be deleted.",
                        num_not_deleted).printf(num_not_deleted);
                AppWindow.error_message_with_title(dialog_title, delete_failed_message, AppWindow.get_instance());
            }
        }
    }
    
    if (progress != null)
        progress.close();
    
    AppWindow.get_instance().set_normal_cursor();
}