/// <summary>Soft delete one or more files marked for deletion, and optionally the data associated with those files.</summary>
        private async void MenuEditDeleteFiles_Click(object sender, RoutedEventArgs e)
        {
            MenuItem menuItem = sender as MenuItem;

            // this callback is invoked by DeleteCurrentFile and DeleteFiles
            // The logic therefore branches for removing a single file versus all selected files marked for deletion.
            List<ImageRow> imagesToDelete;
            bool deleteCurrentImageOnly;
            bool deleteFilesAndData;
            if (menuItem.Name.Equals(this.MenuEditDeleteFiles.Name) || menuItem.Name.Equals(this.MenuEditDeleteFilesAndData.Name))
            {
                deleteCurrentImageOnly = false;
                deleteFilesAndData = menuItem.Name.Equals(this.MenuEditDeleteFilesAndData.Name);
                // get files marked for deletion in the current seletion
                imagesToDelete = this.dataHandler.FileDatabase.GetFilesMarkedForDeletion().ToList();
                for (int index = imagesToDelete.Count - 1; index >= 0; index--)
                {
                    if (this.dataHandler.FileDatabase.Files.Find(imagesToDelete[index].ID) == null)
                    {
                        imagesToDelete.Remove(imagesToDelete[index]);
                    }
                }
            }
            else
            {
                // delete current file
                deleteCurrentImageOnly = true;
                deleteFilesAndData = menuItem.Name.Equals(this.MenuEditDeleteCurrentFileAndData.Name);
                imagesToDelete = new List<ImageRow>();
                if (this.dataHandler.ImageCache.Current != null)
                {
                    imagesToDelete.Add(this.dataHandler.ImageCache.Current);
                }
            }

            // notify the user if no files are selected for deletion
            // This should be unreachable as the invoking menu item should be disabled.
            if (imagesToDelete == null || imagesToDelete.Count < 1)
            {
                MessageBox messageBox = new MessageBox("No files are marked for deletion.", this);
                messageBox.Message.Problem = "You are trying to delete files marked for deletion, but no files have thier 'Delete?' box checked.";
                messageBox.Message.Hint = "If you have files that you think should be deleted, check thier Delete? box.";
                messageBox.Message.StatusImage = MessageBoxImage.Information;
                messageBox.ShowDialog();
                return;
            }

            DeleteFiles deleteImagesDialog = new DeleteFiles(this.dataHandler.FileDatabase, imagesToDelete, deleteFilesAndData, deleteCurrentImageOnly, this);
            bool? result = deleteImagesDialog.ShowDialog();
            if (result == true)
            {
                // cache the current ID as the current image may be invalidated
                long currentFileID = this.dataHandler.ImageCache.Current.ID;

                Mouse.OverrideCursor = Cursors.Wait;
                List<ColumnTuplesWithWhere> imagesToUpdate = new List<ColumnTuplesWithWhere>();
                List<long> imageIDsToDropFromDatabase = new List<long>();
                foreach (ImageRow image in imagesToDelete)
                {
                    // invalidate cache so FileNoLongerAvailable placeholder will be displayed
                    // release any handle open on the file so it can be moved
                    this.dataHandler.ImageCache.TryInvalidate(image.ID);
                    if (image.TryMoveFileToDeletedFilesFolder(this.FolderPath) == false)
                    {
                        // attempt to soft delete file failed so leave the image as marked for deletion
                        continue;
                    }

                    if (deleteFilesAndData)
                    {
                        // mark the image row for dropping
                        imageIDsToDropFromDatabase.Add(image.ID);
                    }
                    else
                    {
                        // as only the file was deleted, change image quality to FileNoLongerAvailable and clear the delete flag
                        image.DeleteFlag = false;
                        image.ImageQuality = FileSelection.NoLongerAvailable;
                        List<ColumnTuple> columnTuples = new List<ColumnTuple>()
                        {
                            new ColumnTuple(Constant.DatabaseColumn.DeleteFlag, Boolean.FalseString),
                            new ColumnTuple(Constant.DatabaseColumn.ImageQuality, FileSelection.NoLongerAvailable.ToString())
                        };
                        imagesToUpdate.Add(new ColumnTuplesWithWhere(columnTuples, image.ID));
                    }
                }

                if (deleteFilesAndData)
                {
                    // drop images
                    this.dataHandler.FileDatabase.DeleteFilesAndMarkers(imageIDsToDropFromDatabase);

                    // Reload the file data table. Then find and show the file closest to the last one shown
                    if (this.dataHandler.FileDatabase.CurrentlySelectedFileCount > 0)
                    {
                        await this.SelectFilesAndShowFileAsync(currentFileID, this.dataHandler.FileDatabase.ImageSet.FileSelection);
                    }
                    else
                    {
                        await this.EnableOrDisableMenusAndControlsAsync();
                    }
                }
                else
                {
                    // update image properties
                    this.dataHandler.FileDatabase.UpdateFiles(imagesToUpdate);

                    // display the updated properties on the current file or, if data for the current file was dropped, the next one
                    await this.ShowFileAsync(this.dataHandler.FileDatabase.GetFileOrNextFileIndex(currentFileID));
                }
                Mouse.OverrideCursor = null;
            }
        }
        // Populate a data field from metadata (example metadata displayed from the currently selected image)
        private async void MenuEditPopulateFieldFromMetadata_Click(object sender, RoutedEventArgs e)
        {
            if (this.dataHandler.ImageCache.Current.IsDisplayable() == false)
            {
                int firstFileDisplayable = this.dataHandler.FileDatabase.GetCurrentOrNextDisplayableFile(this.dataHandler.ImageCache.CurrentRow);
                if (firstFileDisplayable == -1)
                {
                    // There are no displayable files and thus no metadata to choose from, so abort
                    MessageBox messageBox = new MessageBox("Can't populate a data field with image metadata.", this);
                    messageBox.Message.Problem = "Metadata is not available as no file in the image set can be read." + Environment.NewLine;
                    messageBox.Message.Reason += "Carnassial must have at least one valid file in order to get its metadata.  All files are either corrupted or removed.";
                    messageBox.Message.StatusImage = MessageBoxImage.Error;
                    messageBox.ShowDialog();
                    return;
                }
            }

            PopulateFieldWithMetadata populateField = new PopulateFieldWithMetadata(this.dataHandler.FileDatabase, this.dataHandler.ImageCache.Current.GetFilePath(this.FolderPath), this);
            await this.ShowBulkFileEditDialogAsync(populateField);
        }
 /// <summary>Correct for drifting clock times. Correction applied only to selected files.</summary>
 private async void MenuEditDateTimeLinearCorrection_Click(object sender, RoutedEventArgs e)
 {
     DateTimeLinearCorrection linearDateCorrection = new DateTimeLinearCorrection(this.dataHandler.FileDatabase, this);
     if (linearDateCorrection.Abort)
     {
         MessageBox messageBox = new MessageBox("Can't correct for clock drift.", this);
         messageBox.Message.Problem = "Can't correct for clock drift.";
         messageBox.Message.Reason = "All of the files selected have date/time fields whose contents are not recognizable as dates or times." + Environment.NewLine;
         messageBox.Message.Reason += "\u2022 dates should look like dd-MMM-yyyy e.g., 16-Jan-2016" + Environment.NewLine;
         messageBox.Message.Reason += "\u2022 times should look like HH:mm:ss using 24 hour time e.g., 01:05:30 or 13:30:00";
         messageBox.Message.Result = "Date correction will be aborted and nothing will be changed.";
         messageBox.Message.Hint = "Check the format of your dates and times. You may also want to change your selection if you're not viewing All files.";
         messageBox.Message.StatusImage = MessageBoxImage.Error;
         messageBox.ShowDialog();
         return;
     }
     await this.ShowBulkFileEditDialogAsync(linearDateCorrection);
 }
        /// <summary>Correct for daylight savings time</summary>
        private async void MenuEditDaylightSavingsTimeCorrection_Click(object sender, RoutedEventArgs e)
        {
            if (this.dataHandler.ImageCache.Current.IsDisplayable() == false)
            {
                // Just a corrupted image
                MessageBox messageBox = new MessageBox("Can't correct for daylight savings time.", this);
                messageBox.Message.Problem = "This is a corrupted file.";
                messageBox.Message.Solution = "To correct for daylight savings time, you need to:" + Environment.NewLine;
                messageBox.Message.Solution += "\u2022 be displaying a file with a valid date ";
                messageBox.Message.Solution += "\u2022 where that file should be the one at the daylight savings time threshold.";
                messageBox.ShowDialog();
                return;
            }

            DateDaylightSavingsTimeCorrection daylightSavingsCorrection = new DateDaylightSavingsTimeCorrection(this.dataHandler.FileDatabase, this.dataHandler.ImageCache, this);
            await this.ShowBulkFileEditDialogAsync(daylightSavingsCorrection);
        }
        /// <summary>
        /// Load the specified database template and then the associated file database.
        /// </summary>
        /// <param name="templateDatabasePath">Fully qualified path to the template database file.</param>
        /// <returns>true only if both the template and image database file are loaded (regardless of whether any images were loaded), false otherwise</returns>
        /// <remarks>This method doesn't particularly need to be public. But making it private imposes substantial complexity in invoking it via PrivateObject
        /// in unit tests.</remarks>
        public async Task<bool> TryOpenTemplateAndBeginLoadFoldersAsync(string templateDatabasePath)
        {
            // Try to create or open the template database
            TemplateDatabase templateDatabase;
            if (TemplateDatabase.TryCreateOrOpen(templateDatabasePath, out templateDatabase) == false)
            {
                // notify the user the template couldn't be loaded
                MessageBox messageBox = new MessageBox("Carnassial could not load the template.", this);
                messageBox.Message.Problem = "Carnassial could not load " + Path.GetFileName(templateDatabasePath) + Environment.NewLine;
                messageBox.Message.Reason = "\u2022 The template was created with the Timelapse template editor instead of the Carnassial editor." + Environment.NewLine;
                messageBox.Message.Reason = "\u2022 The template may be corrupted or somehow otherwise invalid.";
                messageBox.Message.Solution = String.Format("You may have to recreate the template, restore it from the {0} folder, or use another copy of it if you have one.", Constant.File.BackupFolder);
                messageBox.Message.Result = "Carnassial won't do anything.  You can try to select another template file.";
                messageBox.Message.Hint = "If the template can't be opened in a SQLite database editor the file is corrupt.";
                messageBox.Message.StatusImage = MessageBoxImage.Error;
                messageBox.ShowDialog();

                this.state.MostRecentImageSets.TryRemove(templateDatabasePath);
                this.MenuFileRecentImageSets_Refresh();
                return false;
            }

            // Try to get the file database file path
            // importImages will be true if it's a new image database file (meaning the user will be prompted import some images)
            string fileDatabaseFilePath;
            bool addFiles;
            if (this.TrySelectDatabaseFile(templateDatabasePath, out fileDatabaseFilePath, out addFiles) == false)
            {
                // No image database file was selected
                templateDatabase.Dispose();
                return false;
            }

            // Before running from an existing file database, check the controls in the template database are compatible with those
            // of the file database.
            FileDatabase fileDatabase;
            if (FileDatabase.TryCreateOrOpen(fileDatabaseFilePath, templateDatabase, this.state.OrderFilesByDateTime, this.state.CustomSelectionTermCombiningOperator, out fileDatabase) == false)
            {
                // notify the user the database couldn't be loaded
                MessageBox messageBox = new MessageBox("Carnassial could not load the database.", this);
                messageBox.Message.Problem = "Carnassial could not load " + Path.GetFileName(fileDatabaseFilePath) + Environment.NewLine;
                messageBox.Message.Reason = "\u2022 The database was created with Timelapse instead of Carnassial." + Environment.NewLine;
                messageBox.Message.Reason = "\u2022 The database may be corrupted or somehow otherwise invalid.";
                messageBox.Message.Solution = String.Format("You may have to recreate the database, restore it from the {0} folder, or use another copy of it if you have one.", Constant.File.BackupFolder);
                messageBox.Message.Result = "Carnassial won't do anything.  You can try to select another template or database file.";
                messageBox.Message.Hint = "If the database can't be opened in a SQLite database editor the file is corrupt.";
                messageBox.Message.StatusImage = MessageBoxImage.Error;
                messageBox.ShowDialog();
                return false;
            }
            templateDatabase.Dispose();

            if (fileDatabase.ControlSynchronizationIssues.Count > 0)
            {
                TemplateSynchronization templatesNotCompatibleDialog = new TemplateSynchronization(fileDatabase.ControlSynchronizationIssues, this);
                bool? result = templatesNotCompatibleDialog.ShowDialog();
                if (result == true)
                {
                    // user indicated not to update to the current template so exit.
                    Application.Current.Shutdown();
                    return false;
                }
                // user indicated to run with the stale copy of the template found in the image database
            }

            // valid template and file database loaded
            // generate and render the data entry controls regardless of whether there are actually any files in the file database.
            this.dataHandler = new DataEntryHandler(fileDatabase);
            this.DataEntryControls.CreateControls(fileDatabase, this.dataHandler);
            this.SetUserInterfaceCallbacks();

            this.MenuFileRecentImageSets_Refresh();
            this.state.MostRecentFileAddFolderPath = fileDatabase.FolderPath;
            this.state.MostRecentImageSets.SetMostRecent(templateDatabasePath);
            this.Title = Path.GetFileName(fileDatabase.FilePath) + " - " + Constant.MainWindowBaseTitle;

            // If this is a new file database, try to load files (if any) from the folder...  
            if (addFiles)
            {
                FolderLoad folderLoad = new FolderLoad();
                folderLoad.FolderPaths.Add(this.FolderPath);
                await this.TryBeginFolderLoadAsync(folderLoad);
            }

            await this.OnFolderLoadingCompleteAsync(false);
            return true;
        }
 // Correct ambiguous dates dialog (i.e. dates that could be read as either month/day or day/month
 private async void MenuEditCorrectAmbiguousDates_Click(object sender, RoutedEventArgs e)
 {
     DateCorrectAmbiguous ambiguousDateCorrection = new DateCorrectAmbiguous(this.dataHandler.FileDatabase, this);
     if (ambiguousDateCorrection.Abort)
     {
         MessageBox messageBox = new MessageBox("No ambiguous dates found.", this);
         messageBox.Message.Reason = "All of the selected images have unambguous date fields." + Environment.NewLine;
         messageBox.Message.Result = "No corrections needed, and no changes have been made." + Environment.NewLine;
         messageBox.Message.StatusImage = MessageBoxImage.Information;
         messageBox.ShowDialog();
         messageBox.Close();
         return;
     }
     await this.ShowBulkFileEditDialogAsync(ambiguousDateCorrection);
 }
        private async Task SelectFilesAndShowFileAsync(long fileID, FileSelection selection)
        {
            // change selection
            // if the data grid is bound the file database automatically updates its contents on SelectFiles()
            Debug.Assert(this.dataHandler != null, "SelectFilesAndShowFile() should not be reachable with a null data handler.  Is a menu item wrongly enabled?");
            Debug.Assert(this.dataHandler.FileDatabase != null, "SelectFilesAndShowFile() should not be reachable with a null database.  Is a menu item wrongly enabled?");
            this.dataHandler.FileDatabase.SelectFiles(selection);

            // explain to user if their selection has gone empty and change to all files
            if ((this.dataHandler.FileDatabase.CurrentlySelectedFileCount < 1) && (selection != FileSelection.All))
            {
                // These cases are reached when 
                // 1) datetime modifications result in no files matching a custom selection
                // 2) all files which match the selection get deleted
                this.statusBar.SetMessage("Resetting selection to 'All files'.");

                MessageBox messageBox = new MessageBox("Resetting selection to 'All files' (no files match the current selection)", this);
                messageBox.Message.StatusImage = MessageBoxImage.Information;
                switch (selection)
                {
                    case FileSelection.Corrupt:
                        messageBox.Message.Problem = "Corrupted files were previously selected but no files are currently corrupted, so nothing can be shown.";
                        messageBox.Message.Reason = "No files have their ImageQuality set to Corrupted.";
                        messageBox.Message.Hint = "If you have files you think should be marked as Corrupted, set their ImageQuality to Corrupted and then reselect corrupted files.";
                        break;
                    case FileSelection.Custom:
                        messageBox.Message.Problem = "No files currently match the custom selection so nothing can be shown.";
                        messageBox.Message.Reason = "No files match the criteria set in the current Custom selection.";
                        messageBox.Message.Hint = "Create a different custom selection and apply it view the matching files.";
                        break;
                    case FileSelection.Dark:
                        messageBox.Message.Problem = "Dark files were previously selected but no files are currently dark so nothing can be shown.";
                        messageBox.Message.Reason = "No files have their ImageQuality set to Dark.";
                        messageBox.Message.Hint = "If you have files you think should be marked as Dark, set their ImageQuality to Dark and then reselect dark files.";
                        break;
                    case FileSelection.NoLongerAvailable:
                        messageBox.Message.Problem = "Files no londer available were previously selected but all files are availale so nothing can be shown.";
                        messageBox.Message.Reason = "No files have their ImageQuality field set to FilesNoLongerAvailable.";
                        messageBox.Message.Hint = "If you have removed files set their ImageQuality field to FilesNoLongerAvailable and then reselect files no longer available.";
                        break;
                    case FileSelection.MarkedForDeletion:
                        messageBox.Message.Problem = "Files marked for deletion were previously selected but no files are currently marked so nothing can be shown.";
                        messageBox.Message.Reason = "No files have their Delete? box checked.";
                        messageBox.Message.Hint = "If you have files you think should be marked for deletion, check their Delete? box and then reselect files marked for deletion.";
                        break;
                    case FileSelection.Ok:
                        messageBox.Message.Problem = "Ok files were previously selected but no files are currently OK so nothing can be shown.";
                        messageBox.Message.Reason = "No files have their ImageQuality field set to Ok.";
                        messageBox.Message.Hint = "If you have files you think should be marked as Ok, set their ImageQuality field to Ok and then reselect Ok files.";
                        break;
                    default:
                        throw new NotSupportedException(String.Format("Unhandled selection {0}.", selection));
                }
                messageBox.Message.Result = "The 'All files' selection will be applied, where all files in your image set are displayed.";
                messageBox.ShowDialog();

                selection = FileSelection.All;
                this.dataHandler.FileDatabase.SelectFiles(selection);
            }

            // update status and menu state to reflect what the user selected
            string status;
            switch (selection)
            {
                case FileSelection.All:
                    status = "(all files selected)";
                    break;
                case FileSelection.Corrupt:
                    status = "corrupted files";
                    break;
                case FileSelection.Custom:
                    status = "files matching your custom selection";
                    break;
                case FileSelection.Dark:
                    status = "dark files";
                    break;
                case FileSelection.MarkedForDeletion:
                    status = "files marked for deletion";
                    break;
                case FileSelection.NoLongerAvailable:
                    status = "files no longer available";
                    break;
                case FileSelection.Ok:
                    status = "Ok files";
                    break;
                default:
                    throw new NotSupportedException(String.Format("Unhandled file selection {0}.", selection));
            }

            this.statusBar.SetView(status);

            this.MenuSelectAllFiles.IsChecked = selection == FileSelection.All;
            this.MenuSelectCorruptedFiles.IsChecked = selection == FileSelection.Corrupt;
            this.MenuSelectDarkFiles.IsChecked = selection == FileSelection.Dark;
            this.MenuSelectOkFiles.IsChecked = selection == FileSelection.Ok;
            this.MenuSelectFilesNoLongerAvailable.IsChecked = selection == FileSelection.NoLongerAvailable;
            this.MenuSelectFilesMarkedForDeletion.IsChecked = selection == FileSelection.MarkedForDeletion;
            this.MenuSelectCustom.IsChecked = selection == FileSelection.Custom;

            // after a selection change update the file navigatior slider's range and tick space
            this.FileNavigatorSlider_EnableOrDisableValueChangedCallback(false);
            this.FileNavigatorSlider.Maximum = this.dataHandler.FileDatabase.CurrentlySelectedFileCount;  // slider is one based so no - 1 on the count
            if (this.FileNavigatorSlider.Maximum <= 50)
            {
                this.FileNavigatorSlider.IsSnapToTickEnabled = true;
                this.FileNavigatorSlider.TickFrequency = 1.0;
            }
            else
            {
                this.FileNavigatorSlider.IsSnapToTickEnabled = false;
                this.FileNavigatorSlider.TickFrequency = 0.02 * this.FileNavigatorSlider.Maximum;
            }

            // Display the specified file or, if it's no longer selected, the next closest one
            // Showfile() handles empty image sets, so those don't need to be checked for here.
            await this.ShowFileAsync(this.dataHandler.FileDatabase.GetFileOrNextFileIndex(fileID));

            // Update the status bar accordingly
            this.statusBar.SetCurrentFile(this.dataHandler.ImageCache.CurrentRow);
            this.statusBar.SetFileCount(this.dataHandler.FileDatabase.CurrentlySelectedFileCount);
            this.FileNavigatorSlider_EnableOrDisableValueChangedCallback(true);
        }
        // out parameters can't be used in anonymous methods, so a separate pointer to backgroundWorker is required for return to the caller
        private async Task<bool> TryBeginFolderLoadAsync(FolderLoad folderLoad)
        {
            List<FileInfo> filesToAdd = folderLoad.GetFiles();
            if (filesToAdd.Count == 0)
            {
                // no images were found in folder; see if user wants to try again
                MessageBox messageBox = new MessageBox("Select a folder containing images or videos?", this, MessageBoxButton.YesNo);
                messageBox.Message.Problem = "There aren't any images or videos in the folder '" + this.FolderPath + "' so your image set is currentl empty.";
                messageBox.Message.Reason = "\u2022 This folder has no images in it (files ending in .jpg)." + Environment.NewLine;
                messageBox.Message.Reason += "\u2022 This folder has no videos in it (files ending in .avi or .mp4).";
                messageBox.Message.Solution = "Choose Yes and select a folder containing images and/or videos or choose No and add files later via the File menu.";
                messageBox.Message.Hint = "\u2022 The files may be in a subfolder of this folder." + Environment.NewLine;
                messageBox.Message.Hint += "\u2022 If you need to set the image set's time zone before adding files choose No." + Environment.NewLine;
                messageBox.Message.StatusImage = MessageBoxImage.Question;
                if (messageBox.ShowDialog() == false)
                {
                    return false;
                }

                IEnumerable<string> folderPaths;
                if (this.ShowFolderSelectionDialog(out folderPaths))
                {
                    folderLoad.FolderPaths.Clear();
                    folderLoad.FolderPaths.AddRange(folderPaths);
                    return await this.TryBeginFolderLoadAsync(folderLoad);
                }

                // exit if user changed their mind about trying again
                return false;
            }

            // update UI for import (visibility is inverse of RunWorkerCompleted)
            this.FeedbackControl.Visibility = Visibility.Visible;
            this.FileNavigatorSlider.Visibility = Visibility.Collapsed;
            IProgress<FolderLoadProgress> folderLoadStatus = new Progress<FolderLoadProgress>(this.UpdateFolderLoadProgress);
            FolderLoadProgress folderLoadProgress = new FolderLoadProgress(filesToAdd.Count, this.MarkableCanvas.Width > 0 ? (int)this.MarkableCanvas.Width : (int)this.Width);
            folderLoadStatus.Report(folderLoadProgress);
            if (this.state.SkipDarkImagesCheck)
            {
                this.statusBar.SetMessage("Loading folders...");
            }
            else
            {
                this.statusBar.SetMessage("Loading folders (if this is slower than you like and dark image detection isn't needed you can select Skip dark check in the Options menu right now)...");
            }
            this.FileViewPane.IsActive = true;

            // Load all files found
            // First pass: Examine files to extract their basic properties and build a list of files not already in the database
            //
            // With dark calculations enabled:
            // Profiling of a 1000 image load on quad core, single 80+MB/s capable SSD shows the following:
            // - one thread:   100% normalized execution time, 35% CPU, 16MB/s disk (100% normalized time = 1 minute 58 seconds)
            // - two threads:   55% normalized execution time, 50% CPU, 17MB/s disk (6.3% normalized time with dark checking skipped)
            // - three threads: 46% normalized execution time, 70% CPU, 20MB/s disk
            // This suggests memory bound operation due to image quality calculation.  The overhead of displaying preview images is fairly low; 
            // normalized time is about 5% with both dark checking and previewing skipped.
            //
            // For now, try to get at least two threads as that captures most of the benefit from parallel operation.  Video loading may be more CPU bound 
            // due to initial frame rendering and benefit from additional threads.  This requires further investigation.  It may also be desirable to reduce 
            // the pixel stride in image quality calculation, which would increase CPU load.
            //
            // With dark calculations disabled:
            // The bottleneck's the SQL insert though using more than four threads (or possibly more threads than the number of physical processors as the 
            // test machine was quad core) results in slow progress on the first 20 files or so, making the optimum number of loading threads complex as it
            // depends on amortizing startup lag across faster progress on the remaining import.  As this is comparatively minor relative to SQL (at least
            // for O(10,000) files for now just default to four threads in the disabled case.
            //
            // Note: the UI thread is free during loading.  So if loading's going slow the user can switch off dark checking asynchronously to speed up 
            // loading.
            //
            // A sequential partitioner is used as this keeps the preview images displayed to the user in pretty much the same order as they're named,
            // which is less confusing than TPL's default partitioning where the displayed image jumps back and forth through the image set.  Pulling files
            // nearly sequentially may also offer some minor disk performance benefit.
            List<ImageRow> filesToInsert = new List<ImageRow>();
            TimeZoneInfo imageSetTimeZone = this.dataHandler.FileDatabase.ImageSet.GetTimeZone();
            await Task.Run(() => Parallel.ForEach(
                new SequentialPartitioner<FileInfo>(filesToAdd),
                Utilities.GetParallelOptions(this.state.SkipDarkImagesCheck ? Environment.ProcessorCount : 2), 
                (FileInfo fileInfo) =>
                {
                    ImageRow file;
                    if (this.dataHandler.FileDatabase.GetOrCreateFile(fileInfo, imageSetTimeZone, out file))
                    {
                        // the database already has an entry for this file so skip it
                        // if needed, a separate list of files to update could be generated
                        return;
                    }

                    BitmapSource bitmapSource = null;
                    try
                    {
                        if (this.state.SkipDarkImagesCheck)
                        {
                            file.ImageQuality = FileSelection.Ok;
                        }
                        else
                        {
                            // load bitmap and determine its quality
                            // Parallel doesn't await async bodies so awaiting the load results in Parallel prematurely concluding the body task completed and
                            // dispatching another, resulting in system overload.  The simplest solution is to block this worker thread, which is OK as there
                            // are many workers and they're decoupled from the UI thread.
                            bitmapSource = file.LoadBitmapAsync(this.FolderPath, folderLoadProgress.RenderWidthBestEstimate).GetAwaiter().GetResult();
                            if (bitmapSource == Constant.Images.CorruptFile.Value)
                            {
                                file.ImageQuality = FileSelection.Corrupt;
                            }
                            else
                            {
                                file.ImageQuality = bitmapSource.AsWriteable().IsDark(this.state.DarkPixelThreshold, this.state.DarkPixelRatioThreshold);
                            }
                        }

                        // see if the datetime can be updated from the metadata
                        file.TryReadDateTimeOriginalFromMetadata(this.FolderPath, imageSetTimeZone);
                    }
                    catch (Exception exception)
                    {
                        Debug.Fail(String.Format("Load of {0} failed as it's likely corrupted.", file.FileName), exception.ToString());
                        bitmapSource = Constant.Images.CorruptFile.Value;
                        file.ImageQuality = FileSelection.Corrupt;
                    }

                    lock (filesToInsert)
                    {
                        filesToInsert.Add(file);
                    }

                    DateTime utcNow = DateTime.UtcNow;
                    if (utcNow - folderLoadProgress.MostRecentStatusDispatch > this.state.Throttles.DesiredIntervalBetweenRenders)
                    {
                        lock (folderLoadProgress)
                        {
                            if (utcNow - folderLoadProgress.MostRecentStatusDispatch > this.state.Throttles.DesiredIntervalBetweenRenders)
                            {
                                // if file was already loaded for dark checking use the resulting bitmap
                                // otherwise, load the file for display
                                if (bitmapSource != null)
                                {
                                    folderLoadProgress.BitmapSource = bitmapSource;
                                }
                                else
                                {
                                    folderLoadProgress.BitmapSource = null;
                                }
                                folderLoadProgress.CurrentFile = file;
                                folderLoadProgress.CurrentFileIndex = filesToInsert.Count;
                                folderLoadProgress.DisplayBitmap = true;
                                folderLoadProgress.MostRecentStatusDispatch = utcNow;
                                folderLoadStatus.Report(folderLoadProgress);
                            }
                        }
                    }
                }));

            // Second pass: Update database
            // Parallel execution above produces out of order results.  Put them back in order so the user sees images in file name order when
            // reviewing the image set.
            folderLoadProgress.DatabaseInsert = true;
            await Task.Run(() =>
                {
                    filesToInsert = filesToInsert.OrderBy(file => Path.Combine(file.RelativePath, file.FileName)).ToList();
                    this.dataHandler.FileDatabase.AddFiles(filesToInsert, (ImageRow file, int fileIndex) =>
                    {
                        // skip reloading images to display as the user's already seen them import
                        folderLoadProgress.BitmapSource = null;
                        folderLoadProgress.CurrentFile = file;
                        folderLoadProgress.CurrentFileIndex = fileIndex;
                        folderLoadProgress.DisplayBitmap = false;
                        folderLoadStatus.Report(folderLoadProgress);
                    });
                });

            // hide the feedback bar, show the file slider
            this.FeedbackControl.Visibility = Visibility.Collapsed;
            this.FileNavigatorSlider.Visibility = Visibility.Visible;

            await this.OnFolderLoadingCompleteAsync(true);

            // tell the user how many files were loaded
            this.MaybeShowFileCountsDialog(true);
            return true;
        }
        private async void MenuFileImportSpreadsheet_Click(object sender, RoutedEventArgs e)
        {
            if (this.state.SuppressSpreadsheetImportPrompt == false)
            {
                MessageBox messageBox = new MessageBox("How importing spreadsheet data works.", this, MessageBoxButton.OKCancel);
                messageBox.Message.What = "Importing data from .csv (comma separated value) and .xslx (Excel) files follows the rules below.";
                messageBox.Message.Reason = "Carnassial requires the file follow a specific format and processes its data in a specific way.";
                messageBox.Message.Solution = "Modifying and importing a spreadsheet is supported only if the file is exported from and then back into image set with the same template." + Environment.NewLine;
                messageBox.Message.Solution += "A limited set of modifications is allowed:" + Environment.NewLine;
                messageBox.Message.Solution += "\u2022 Counter data must be zero or a positive integer." + Environment.NewLine;
                messageBox.Message.Solution += "\u2022 Flag data must be 'true' or 'false', case insensitive." + Environment.NewLine;
                messageBox.Message.Solution += "\u2022 FixedChoice data must be a string that exactly matches one of the FixedChoice menu options or the field's default value." + Environment.NewLine;
                messageBox.Message.Solution += String.Format("\u2022 DateTime must be in '{0}' format.{1}", Constant.Time.DateTimeDatabaseFormat, Environment.NewLine);
                messageBox.Message.Solution += String.Format("\u2022 UtcOffset must be a floating point number between {0} and {1}, inclusive.{2}", DateTimeHandler.ToDatabaseUtcOffsetString(Constant.Time.MinimumUtcOffset), DateTimeHandler.ToDatabaseUtcOffsetString(Constant.Time.MinimumUtcOffset), Environment.NewLine);
                messageBox.Message.Solution += "Changing these things either doesn't work or is best done with care:" + Environment.NewLine;
                messageBox.Message.Solution += "\u2022 FileName and RelativePath identify the file updates are applied to.  Changing them causes a different file to be updated or a new file to be added." + Environment.NewLine;
                messageBox.Message.Solution += "\u2022 RelativePath is interpreted relative to the spreadsheet file.  Make sure it's in the right place!" + Environment.NewLine;
                messageBox.Message.Solution += "\u2022 Column names can be swapped to assign data to different fields." + Environment.NewLine;
                messageBox.Message.Solution += "\u2022 Adding, removing, or otherwise changing columns is not supported." + Environment.NewLine;
                messageBox.Message.Solution += String.Format("\u2022 Using a worksheet with a name other than '{0}' in .xlsx files is not supported.{1}", Constant.Excel.FileDataWorksheetName, Environment.NewLine);
                messageBox.Message.Result = String.Format("Carnassial will create a backup .ddb file in the {0} folder and then import as much data as it can.  If data can't be imported you'll get a dialog listing the problems.", Constant.File.BackupFolder);
                messageBox.Message.Hint = "\u2022 After you import, check your data. If it is not what you expect, restore your data by using that backup file." + Environment.NewLine;
                messageBox.Message.Hint += String.Format("\u2022 Usually the spreadsheet should be in the same folder as the data file ({0}) it was exported from.{1}", Constant.File.FileDatabaseFileExtension, Environment.NewLine);
                messageBox.Message.Hint += "\u2022 If you check 'Don't show this message' this dialog can be turned back on via the Options menu.";
                messageBox.Message.StatusImage = MessageBoxImage.Information;
                messageBox.DontShowAgain.Visibility = Visibility.Visible;

                bool? proceeed = messageBox.ShowDialog();
                if (proceeed != true)
                {
                    return;
                }

                if (messageBox.DontShowAgain.IsChecked.HasValue)
                {
                    this.state.SuppressSpreadsheetImportPrompt = messageBox.DontShowAgain.IsChecked.Value;
                    this.MenuOptionsEnableCsvImportPrompt.IsChecked = !this.state.SuppressSpreadsheetImportPrompt;
                }
            }

            string defaultSpreadsheetFileName = Path.GetFileNameWithoutExtension(this.dataHandler.FileDatabase.FileName) + Constant.File.ExcelFileExtension;
            string spreadsheetFilePath;
            if (Utilities.TryGetFileFromUser("Select a file to merge into the current image set",
                                             Path.Combine(this.dataHandler.FileDatabase.FolderPath, defaultSpreadsheetFileName),
                                             String.Format("Spreadsheet files (*{0};*{1})|*{0};*{1}", Constant.File.CsvFileExtension, Constant.File.ExcelFileExtension),
                                             out spreadsheetFilePath) == false)
            {
                return;
            }

            // Create a backup database file
            if (FileBackup.TryCreateBackup(this.dataHandler.FileDatabase.FilePath))
            {
                this.statusBar.SetMessage("Backup of data file made.");
            }
            else
            {
                this.statusBar.SetMessage("No data file backup was made.");
            }

            SpreadsheetReaderWriter spreadsheetReader = new SpreadsheetReaderWriter();
            try
            {
                List<string> importErrors;
                bool importSuccededFully;
                if (String.Equals(Path.GetExtension(spreadsheetFilePath), Constant.File.ExcelFileExtension, StringComparison.OrdinalIgnoreCase))
                {
                    importSuccededFully = spreadsheetReader.TryImportFileDataFromXlsx(spreadsheetFilePath, this.dataHandler.FileDatabase, out importErrors);
                }
                else
                {
                    importSuccededFully = spreadsheetReader.TryImportFileDataFromCsv(spreadsheetFilePath, this.dataHandler.FileDatabase, out importErrors);
                }
                if (importSuccededFully == false)
                {
                    MessageBox messageBox = new MessageBox("Spreadsheet import incomplete.", this);
                    messageBox.Message.StatusImage = MessageBoxImage.Error;
                    messageBox.Message.Problem = String.Format("The file {0} could not be fully read.", spreadsheetFilePath);
                    messageBox.Message.Reason = "The spreadsheet is not fully compatible with the current image set.";
                    messageBox.Message.Solution = "Check that:" + Environment.NewLine;
                    messageBox.Message.Solution += "\u2022 The first row of the file is a header line." + Environment.NewLine;
                    messageBox.Message.Solution += "\u2022 The column names in the header line match the database." + Environment.NewLine; 
                    messageBox.Message.Solution += "\u2022 Choice values use the correct case." + Environment.NewLine;
                    messageBox.Message.Solution += "\u2022 Counter values are numbers." + Environment.NewLine;
                    messageBox.Message.Solution += "\u2022 Flag values are either 'true' or 'false'.";
                    messageBox.Message.Result = "Either no data was imported or invalid parts of the spreadsheet were skipped.";
                    messageBox.Message.Hint = "The errors encountered were:";
                    foreach (string importError in importErrors)
                    {
                        messageBox.Message.Hint += "\u2022 " + importError;
                    }
                    messageBox.ShowDialog();
                }
            }
            catch (Exception exception)
            {
                MessageBox messageBox = new MessageBox("Can't import the .csv file.", this);
                messageBox.Message.StatusImage = MessageBoxImage.Error;
                messageBox.Message.Problem = String.Format("The file {0} could not be opened.", spreadsheetFilePath);
                messageBox.Message.Reason = "Most likely the file is open in another program.";
                messageBox.Message.Solution = "If the file is open in another program, close it.";
                messageBox.Message.Result = String.Format("{0}: {1}", exception.GetType().FullName, exception.Message);
                messageBox.Message.Hint = "Is the file open in Excel?";
                messageBox.ShowDialog();
            }

            // reload the file data table and update the enable/disable state of the user interface to match
            await this.SelectFilesAndShowFileAsync();
            await this.EnableOrDisableMenusAndControlsAsync();
            this.statusBar.SetMessage(".csv file imported.");
        }
        /// <summary>Write the .csv or .xlsx file and maybe send an open command to the system</summary>
        private void MenuFileExportSpreadsheet_Click(object sender, RoutedEventArgs e)
        {
            MenuItem menuItem = (MenuItem)sender;
            bool exportXlsx = (sender == this.MenuFileExportXlsxAndOpen) || (sender == this.MenuFileExportXlsx);
            bool openFile = (sender == this.MenuFileExportXlsxAndOpen) || (sender == this.MenuFileExportCsvAndOpen);

            // backup any existing file as it's overwritten on export
            string spreadsheetFileExtension = exportXlsx ? Constant.File.ExcelFileExtension : Constant.File.CsvFileExtension;
            string spreadsheetFileName = Path.GetFileNameWithoutExtension(this.dataHandler.FileDatabase.FileName) + spreadsheetFileExtension;
            string spreadsheetFilePath = Path.Combine(this.FolderPath, spreadsheetFileName);
            if (FileBackup.TryCreateBackup(this.FolderPath, spreadsheetFileName))
            {
                this.statusBar.SetMessage("Backup of {0} made.", spreadsheetFileName);
            }

            SpreadsheetReaderWriter spreadsheetWriter = new SpreadsheetReaderWriter();
            try
            {
                if (exportXlsx)
                {
                    spreadsheetWriter.ExportFileDataToXlsx(this.dataHandler.FileDatabase, spreadsheetFilePath);
                }
                else
                {
                    spreadsheetWriter.ExportFileDataToCsv(this.dataHandler.FileDatabase, spreadsheetFilePath);
                }
                this.statusBar.SetMessage("Data exported to " + spreadsheetFileName);

                if (openFile)
                {
                    // show the exported file in whatever program is associated with its extension
                    Process process = new Process();
                    process.StartInfo.UseShellExecute = true;
                    process.StartInfo.RedirectStandardOutput = false;
                    process.StartInfo.FileName = spreadsheetFilePath;
                    process.Start();
                }
            }
            catch (IOException exception)
            {
                MessageBox messageBox = new MessageBox("Can't write the spreadsheet file.", this);
                messageBox.Message.StatusImage = MessageBoxImage.Error;
                messageBox.Message.Problem = "The following file can't be written: " + spreadsheetFilePath;
                messageBox.Message.Reason = "You may already have it open in Excel or another application.";
                messageBox.Message.Solution = "If the file is open in another application, close it and try again.";
                messageBox.Message.Hint = String.Format("{0}: {1}", exception.GetType().FullName, exception.Message);
                messageBox.ShowDialog();
                return;
            }
        }
        private async void MenuFileMoveFiles_Click(object sender, RoutedEventArgs e)
        {
            CommonOpenFileDialog folderSelectionDialog = new CommonOpenFileDialog();
            folderSelectionDialog.Title = "Select the folder to move files to...";
            folderSelectionDialog.DefaultDirectory = this.FolderPath;
            folderSelectionDialog.InitialDirectory = folderSelectionDialog.DefaultDirectory;
            folderSelectionDialog.IsFolderPicker = true;
            folderSelectionDialog.FolderChanging += this.FolderSelectionDialog_FolderChanging;
            if (folderSelectionDialog.ShowDialog() == CommonFileDialogResult.Ok)
            {
                // move files
                List<string> immovableFiles = this.dataHandler.FileDatabase.MoveFilesToFolder(folderSelectionDialog.FileName);
                this.statusBar.SetMessage("Moved {0} of {1} files to {2}.", this.dataHandler.FileDatabase.CurrentlySelectedFileCount - immovableFiles.Count, this.dataHandler.FileDatabase.CurrentlySelectedFileCount, Path.GetFileName(folderSelectionDialog.FileName));
                if (immovableFiles.Count > 0)
                {
                    MessageBox messageBox = new MessageBox("Not all files could be moved.", this);
                    messageBox.Message.What = String.Format("{0} of {1} files were moved.", this.dataHandler.FileDatabase.CurrentlySelectedFileCount - immovableFiles.Count, this.dataHandler.FileDatabase.CurrentlySelectedFileCount);
                    messageBox.Message.Reason = "This occurs when the selection 1) contains multiple files with the same name and 2) files with the same name as files already in the destination folder.";
                    messageBox.Message.Solution = "Remove or rename the conflicting files and apply the move command again to move the remaining files.";
                    messageBox.Message.Result = "Carnassial moved the files which could be moved.  The remaining files were left in place.";
                    messageBox.Message.Hint = String.Format("The {0} files which could not be moved are{1}", immovableFiles.Count, Environment.NewLine);
                    foreach (string fileName in immovableFiles)
                    {
                        messageBox.Message.Hint += String.Format("\u2022 {0}", fileName);
                    }
                    messageBox.ShowDialog();
                }

                // refresh the current file to show its new relative path field 
                await this.ShowFileAsync(this.dataHandler.ImageCache.CurrentRow);
            }
        }
        /// <summary>
        /// Make a copy of the current file in the folder selected by the user and provide feedback in the status.
        /// </summary>
        private void MenuFileCloneCurrent_Click(object sender, RoutedEventArgs e)
        {
            Debug.Assert(this.dataHandler != null && this.dataHandler.ImageCache.Current != null, "MenuFileCloneCurrent unexpectedly enabled.");
            if (!this.dataHandler.ImageCache.Current.IsDisplayable())
            {
                MessageBox messageBox = new MessageBox("Can't copy this file!", this);
                messageBox.Message.StatusImage = MessageBoxImage.Error;
                messageBox.Message.Problem = "Carnassial can't copy the current file.";
                messageBox.Message.Reason = "It is likely corrupted or missing.";
                messageBox.Message.Solution = "Make sure you have navigated to, and are displaying, a valid file before you try to export it.";
                messageBox.ShowDialog();
                return;
            }

            string sourceFileName = this.dataHandler.ImageCache.Current.FileName;

            SaveFileDialog dialog = new SaveFileDialog();
            dialog.Title = "Make a copy of the currently displayed file";
            dialog.Filter = String.Format("*{0}|*{0}", Path.GetExtension(this.dataHandler.ImageCache.Current.FileName));
            dialog.FileName = sourceFileName;
            dialog.OverwritePrompt = true;

            DialogResult result = dialog.ShowDialog();
            if (result == System.Windows.Forms.DialogResult.OK)
            {
                // Set the source and destination file names, including the complete path
                string sourcePath = this.dataHandler.ImageCache.Current.GetFilePath(this.FolderPath);
                string destinationPath = dialog.FileName;

                // Try to copy the source file to the destination, overwriting the destination file if it already exists.
                // And giving some feedback about its success (or failure) 
                try
                {
                    File.Copy(sourcePath, destinationPath, true);
                    this.statusBar.SetMessage(sourceFileName + " copied to " + destinationPath);
                }
                catch (Exception exception)
                {
                    Debug.Fail(String.Format("Copy of '{0}' to '{1}' failed.", sourceFileName, destinationPath), exception.ToString());
                    this.statusBar.SetMessage("Copy failed with {0}.", exception.GetType().Name);
                }
            }
        }
 private MessageBox CreateMessageBox(Window owner, MessageBoxButton buttonType, MessageBoxImage iconType)
 {
     MessageBox messageBox = new MessageBox("Message box title.", owner, buttonType);
     messageBox.Message.StatusImage = iconType;
     messageBox.Message.Problem = "Problem description.";
     messageBox.Message.Reason = "Explanation of why issue is an issue.";
     messageBox.Message.Solution = "Suggested method for resolving the issue.";
     messageBox.Message.Result = "Current status.";
     messageBox.Message.Hint = "Additional suggestions as to how to resolve the issue.";
     return messageBox;
 }