forge.gui.ImportDialog.java Source code

Java tutorial

Introduction

Here is the source code for forge.gui.ImportDialog.java

Source

/*
 * Forge: Play Magic: the Gathering.
 * Copyright (c) 2013  Forge Team
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package forge.gui;

import java.awt.Font;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentSkipListMap;

import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JProgressBar;
import javax.swing.JScrollBar;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;
import javax.swing.Timer;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;

import net.miginfocom.swing.MigLayout;

import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;

import com.google.common.collect.ImmutableList;

import forge.UiCommand;
import forge.assets.FSkinProp;
import forge.error.BugReporter;
import forge.gui.ImportSourceAnalyzer.OpType;
import forge.properties.ForgeConstants;
import forge.toolbox.FButton;
import forge.toolbox.FCheckBox;
import forge.toolbox.FComboBoxWrapper;
import forge.toolbox.FLabel;
import forge.toolbox.FOptionPane;
import forge.toolbox.FOverlay;
import forge.toolbox.FPanel;
import forge.toolbox.FScrollPane;
import forge.toolbox.FSkin;
import forge.toolbox.FTextField;
import forge.toolbox.SmartScroller;

/**
 * This class implements an overlay-based dialog that imports data from a user-selected directory
 * into the correct locations in the user and cache directories.  There is a lot of I/O and data
 * processing done in this class, so most operations are asynchronous.
 */
public class ImportDialog {
    private final FButton _btnStart;
    private final FButton _btnCancel;
    private final FLabel _btnChooseDir;
    private final FPanel _topPanel;
    private final JPanel _selectionPanel;
    private final FTextField _txfSrc;

    private final String forcedSrcDir;
    private final boolean isMigration;

    // volatile since it is checked from multiple threads
    private volatile boolean _cancel;

    private static final ImmutableList<String> fixOrContinue = ImmutableList.of("Whoops, let me fix that!",
            "Continue with the import, I know what I'm doing.");

    @SuppressWarnings("serial")
    public ImportDialog(final String forcedSrcDir, final Runnable onDialogClose) {
        this.forcedSrcDir = forcedSrcDir;

        _topPanel = new FPanel(new MigLayout("insets dialog, gap 0, center, wrap, fill"));
        _topPanel.setOpaque(false);
        _topPanel.setBackgroundTexture(FSkin.getIcon(FSkinProp.BG_TEXTURE));

        isMigration = !StringUtils.isEmpty(forcedSrcDir);

        // header
        _topPanel.add(new FLabel.Builder().text((isMigration ? "Migrate" : "Import") + " profile data").fontSize(15)
                .build(), "center");

        // add some help text if this is for the initial data migration
        if (isMigration) {
            final FPanel blurbPanel = new FPanel(new MigLayout("insets panel, gap 10, fill"));
            blurbPanel.setOpaque(false);
            final JPanel blurbPanelInterior = new JPanel(
                    new MigLayout("insets dialog, gap 10, center, wrap, fill"));
            blurbPanelInterior.setOpaque(false);
            blurbPanelInterior.add(new FLabel.Builder().text("<html><b>What's this?</b></html>").build(),
                    "growx, w 50:50:");
            blurbPanelInterior.add(new FLabel.Builder()
                    .text("<html>Over the last several years, people have had to jump through a lot of hoops to"
                            + " update to the most recent version.  We hope to reduce this workload to a point where a new"
                            + " user will find that it is fairly painless to update.  In order to make this happen, Forge"
                            + " has changed where it stores your data so that it is outside of the program installation directory."
                            + "  This way, when you upgrade, you will no longer need to import your data every time to get things"
                            + " working.  There are other benefits to having user data separate from program data, too, and it"
                            + " lays the groundwork for some cool new features.</html>")
                    .build(), "growx, w 50:50:");
            blurbPanelInterior.add(
                    new FLabel.Builder().text("<html><b>So where's my data going?</b></html>").build(),
                    "growx, w 50:50:");
            blurbPanelInterior.add(new FLabel.Builder().text(
                    "<html>Forge will now store your data in the same place as other applications on your system."
                            + "  Specifically, your personal data, like decks, quest progress, and program preferences will be"
                            + " stored in <b>" + ForgeConstants.USER_DIR
                            + "</b> and all downloaded content, such as card pictures,"
                            + " skins, and quest world prices will be under <b>" + ForgeConstants.CACHE_DIR
                            + "</b>.  If, for whatever"
                            + " reason, you need to set different paths, cancel out of this dialog, exit Forge, and find the <b>"
                            + ForgeConstants.PROFILE_TEMPLATE_FILE
                            + "</b> file in the program installation directory.  Copy or rename" + " it to <b>"
                            + ForgeConstants.PROFILE_FILE
                            + "</b> and edit the paths inside it.  Then restart Forge and use"
                            + " this dialog to move your data to the paths that you set.  Keep in mind that if you install a future"
                            + " version of Forge into a different directory, you'll need to copy this file over so Forge will know"
                            + " where to find your data.</html>")
                    .build(), "growx, w 50:50:");
            blurbPanelInterior.add(new FLabel.Builder().text(
                    "<html><b>Remember, your data won't be available until you complete this step!</b></html>")
                    .build(), "growx, w 50:50:");

            final FScrollPane blurbScroller = new FScrollPane(blurbPanelInterior, true,
                    ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
                    ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
            blurbPanel.add(blurbScroller, "hmin 150, growy, growx, center, gap 0 0 5 5");
            _topPanel.add(blurbPanel, "gap 10 10 20 0, growy, growx, w 50:50:");
        }

        // import source widgets
        final JPanel importSourcePanel = new JPanel(new MigLayout("insets 0, gap 10"));
        importSourcePanel.setOpaque(false);
        importSourcePanel.add(new FLabel.Builder().text("Import from:").build());
        _txfSrc = new FTextField.Builder().readonly().build();
        importSourcePanel.add(_txfSrc, "pushx, growx");
        _btnChooseDir = new FLabel.ButtonBuilder().text("Choose directory...").enabled(!isMigration).build();
        final JFileChooser _fileChooser = new JFileChooser();
        _fileChooser.setMultiSelectionEnabled(false);
        _fileChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
        _btnChooseDir.setCommand(new UiCommand() {
            @Override
            public void run() {
                // bring up a file open dialog and, if the OK button is selected, apply the filename
                // to the import source text field
                if (JFileChooser.APPROVE_OPTION == _fileChooser.showOpenDialog(JOptionPane.getRootFrame())) {
                    final File f = _fileChooser.getSelectedFile();
                    if (!f.canRead()) {
                        FOptionPane.showErrorDialog("Cannot access selected directory (Permission denied).");
                    } else {
                        _txfSrc.setText(f.getAbsolutePath());
                    }
                }
            }
        });
        importSourcePanel.add(_btnChooseDir, "h pref+8!, w pref+12!");

        // add change handler to the import source text field that starts up a
        // new analyzer.  it also interacts with the current active analyzer,
        // if any, to make sure it cancels out before the new one is initiated
        _txfSrc.getDocument().addDocumentListener(new DocumentListener() {
            boolean _analyzerActive; // access synchronized on _onAnalyzerDone
            String prevText;

            private final Runnable _onAnalyzerDone = new Runnable() {
                @Override
                public synchronized void run() {
                    _analyzerActive = false;
                    notify();
                }
            };

            @Override
            public void removeUpdate(final DocumentEvent e) {
            }

            @Override
            public void changedUpdate(final DocumentEvent e) {
            }

            @Override
            public void insertUpdate(final DocumentEvent e) {
                // text field is read-only, so the only time this will get updated
                // is when _btnChooseDir does it
                final String text = _txfSrc.getText();
                if (text.equals(prevText)) {
                    // only restart the analyzer if the directory has changed
                    return;
                }
                prevText = text;

                // cancel any active analyzer
                _cancel = true;

                if (!text.isEmpty()) {
                    // ensure we don't get two instances of this function running at the same time
                    _btnChooseDir.setEnabled(false);

                    // re-disable the start button.  it will be enabled if the previous analyzer has
                    // already successfully finished
                    _btnStart.setEnabled(false);

                    // we have to wait in a background thread since we can't block in the GUI thread
                    final SwingWorker<Void, Void> analyzerStarter = new SwingWorker<Void, Void>() {
                        @Override
                        protected Void doInBackground() throws Exception {
                            // wait for active analyzer (if any) to quit
                            synchronized (_onAnalyzerDone) {
                                while (_analyzerActive) {
                                    _onAnalyzerDone.wait();
                                }
                            }
                            return null;
                        }

                        // executes in gui event loop thread
                        @Override
                        protected void done() {
                            _cancel = false;
                            synchronized (_onAnalyzerDone) {
                                // this will populate the panel with data selection widgets
                                final _AnalyzerUpdater analyzer = new _AnalyzerUpdater(text, _onAnalyzerDone,
                                        isMigration);
                                analyzer.run();
                                _analyzerActive = true;
                            }
                            if (!isMigration) {
                                // only enable the directory choosing button if this is not a migration dialog
                                // since in that case we're permanently locked to the starting directory
                                _btnChooseDir.setEnabled(true);
                            }
                        }
                    };
                    analyzerStarter.execute();
                }
            }
        });
        _topPanel.add(importSourcePanel, "gaptop 20, pushx, growx");

        // prepare import selection panel (will be cleared and filled in later by an analyzer)
        _selectionPanel = new JPanel();
        _selectionPanel.setOpaque(false);
        _topPanel.add(_selectionPanel, "growx, growy, gaptop 10");

        // action button widgets
        final Runnable cleanup = new Runnable() {
            @Override
            public void run() {
                SOverlayUtils.hideOverlay();
            }
        };
        _btnStart = new FButton("Start import");
        _btnStart.setEnabled(false);
        _btnCancel = new FButton("Cancel");
        _btnCancel.addActionListener(new ActionListener() {
            @Override
            public void actionPerformed(final ActionEvent e) {
                _cancel = true;
                cleanup.run();
                if (null != onDialogClose) {
                    onDialogClose.run();
                }
            }
        });

        final JPanel southPanel = new JPanel(new MigLayout("ax center"));
        southPanel.setOpaque(false);
        southPanel.add(_btnStart, "center, w pref+144!, h pref+12!");
        southPanel.add(_btnCancel, "center, w pref+144!, h pref+12!, gap 72");
        _topPanel.add(southPanel, "growx");
    }

    public void show() {
        final JPanel overlay = FOverlay.SINGLETON_INSTANCE.getPanel();
        overlay.setLayout(new MigLayout("insets 0, gap 0, wrap, ax center, ay center"));
        overlay.add(_topPanel, "w 500::90%, h 100::90%");
        SOverlayUtils.showOverlay();

        // focus cancel button after the dialog is shown
        SwingUtilities.invokeLater(new Runnable() {
            @Override
            public void run() {
                _btnCancel.requestFocusInWindow();
            }
        });

        // if our source dir is provided, set the text, which will fire off an analyzer
        if (isMigration) {
            final File srcDirFile = new File(forcedSrcDir);
            _txfSrc.setText(srcDirFile.getAbsolutePath());
        }
    }

    // encapsulates the choices in the combobox for choosing the destination paths for
    // decks of unknown type
    private class _UnknownDeckChoice {
        public final String name;
        public final String path;

        public _UnknownDeckChoice(final String name0, final String path0) {
            name = name0;
            path = path0;
        }

        @Override
        public String toString() {
            return name;
        }
    }

    // this class owns the import selection widgets and bridges them with the running
    // MigrationSourceAnalyzer instance
    private class _AnalyzerUpdater extends SwingWorker<Void, Void> {
        // associates a file operation type with its enablement checkbox and the set
        // of file move/copy operations that enabling it would entail
        private final Map<OpType, Pair<FCheckBox, ? extends Map<File, File>>> _selections = new HashMap<OpType, Pair<FCheckBox, ? extends Map<File, File>>>();

        // attached to all changeable widgets to keep the UI in sync
        private final ChangeListener _stateChangedListener = new ChangeListener() {
            @Override
            public void stateChanged(final ChangeEvent arg0) {
                _updateUI();
            }
        };

        private final String _srcDir;
        private final Runnable _onAnalyzerDone;
        private final boolean _isMigration;
        private final FLabel _unknownDeckLabel;
        private final FComboBoxWrapper<_UnknownDeckChoice> _unknownDeckCombo;
        private final FCheckBox _moveCheckbox;
        private final FCheckBox _overwriteCheckbox;
        private final JTextArea _operationLog;
        private final JScrollPane _operationLogScroller;
        private final JProgressBar _progressBar;

        // updates the _operationLog widget asynchronously to keep the UI responsive
        private final _OperationLogAsyncUpdater _operationLogUpdater;

        public _AnalyzerUpdater(final String srcDir, final Runnable onAnalyzerDone, final boolean isMigration) {
            _srcDir = srcDir;
            _onAnalyzerDone = onAnalyzerDone;
            _isMigration = isMigration;

            _selectionPanel.removeAll();
            _selectionPanel.setLayout(new MigLayout("insets 0, gap 5, wrap, fill"));

            final JPanel cbPanel = new JPanel(new MigLayout("insets 0, gap 5"));
            cbPanel.setOpaque(false);

            // add deck selections
            final JPanel knownDeckPanel = new JPanel(new MigLayout("insets 0, gap 5, wrap 2"));
            knownDeckPanel.setOpaque(false);
            knownDeckPanel.add(new FLabel.Builder().text("Decks").build(), "wrap");
            _addSelectionWidget(knownDeckPanel, OpType.CONSTRUCTED_DECK, "Constructed decks");
            _addSelectionWidget(knownDeckPanel, OpType.DRAFT_DECK, "Draft decks");
            _addSelectionWidget(knownDeckPanel, OpType.PLANAR_DECK, "Planar decks");
            _addSelectionWidget(knownDeckPanel, OpType.SCHEME_DECK, "Scheme decks");
            _addSelectionWidget(knownDeckPanel, OpType.SEALED_DECK, "Sealed decks");
            _addSelectionWidget(knownDeckPanel, OpType.UNKNOWN_DECK, "Unknown decks");
            final JPanel unknownDeckPanel = new JPanel(new MigLayout("insets 0, gap 5"));
            unknownDeckPanel.setOpaque(false);
            _unknownDeckCombo = new FComboBoxWrapper<_UnknownDeckChoice>();
            _unknownDeckCombo.addItem(new _UnknownDeckChoice("Constructed", ForgeConstants.DECK_CONSTRUCTED_DIR));
            _unknownDeckCombo.addItem(new _UnknownDeckChoice("Draft", ForgeConstants.DECK_DRAFT_DIR));
            _unknownDeckCombo.addItem(new _UnknownDeckChoice("Planar", ForgeConstants.DECK_PLANE_DIR));
            _unknownDeckCombo.addItem(new _UnknownDeckChoice("Scheme", ForgeConstants.DECK_SCHEME_DIR));
            _unknownDeckCombo.addItem(new _UnknownDeckChoice("Sealed", ForgeConstants.DECK_SEALED_DIR));
            _unknownDeckCombo.addActionListener(new ActionListener() {
                @Override
                public void actionPerformed(final ActionEvent arg0) {
                    _updateUI();
                }
            });
            _unknownDeckLabel = new FLabel.Builder().text("Treat unknown decks as:").build();
            unknownDeckPanel.add(_unknownDeckLabel);
            _unknownDeckCombo.addTo(unknownDeckPanel);
            knownDeckPanel.add(unknownDeckPanel, "span");
            cbPanel.add(knownDeckPanel, "aligny top");

            // add other userDir data elements
            final JPanel dataPanel = new JPanel(new MigLayout("insets 0, gap 5, wrap"));
            dataPanel.setOpaque(false);
            dataPanel.add(new FLabel.Builder().text("Other data").build());
            _addSelectionWidget(dataPanel, OpType.GAUNTLET_DATA, "Gauntlet data");
            _addSelectionWidget(dataPanel, OpType.QUEST_DATA, "Quest saves");
            _addSelectionWidget(dataPanel, OpType.PREFERENCE_FILE, "Preference files");
            cbPanel.add(dataPanel, "aligny top");

            // add cacheDir data elements
            final JPanel cachePanel = new JPanel(new MigLayout("insets 0, gap 5, wrap 2"));
            cachePanel.setOpaque(false);
            cachePanel.add(new FLabel.Builder().text("Cached data").build(), "wrap");
            _addSelectionWidget(cachePanel, OpType.DEFAULT_CARD_PIC, "Default card pics");
            _addSelectionWidget(cachePanel, OpType.SET_CARD_PIC, "Set-specific card pics");
            _addSelectionWidget(cachePanel, OpType.TOKEN_PIC, "Card token pics");
            _addSelectionWidget(cachePanel, OpType.QUEST_PIC, "Quest-related pics");
            _addSelectionWidget(cachePanel, OpType.DB_FILE, "Database files", true, null, "wrap");

            _addSelectionWidget(cachePanel, OpType.POSSIBLE_SET_CARD_PIC,
                    "Import possible set pics from as-yet unsupported cards", false,
                    "<html>Picture files that are not recognized as belonging to any known card.<br>"
                            + "It could be that these pictures belong to cards that are not yet supported<br>"
                            + "by Forge.  If you know this to be the case and want the pictures imported for<br>"
                            + "future use, select this option.<html>",
                    "span");
            cbPanel.add(cachePanel, "aligny top");
            _selectionPanel.add(cbPanel, "center");

            // add move/copy and overwrite checkboxes
            final JPanel ioOptionPanel = new JPanel(new MigLayout("insets 0, gap 10"));
            ioOptionPanel.setOpaque(false);
            _moveCheckbox = new FCheckBox("Remove source files after copy");
            _moveCheckbox.setToolTipText("Move files into the data directories instead of just copying them");
            _moveCheckbox.setSelected(isMigration);
            _moveCheckbox.addChangeListener(_stateChangedListener);
            ioOptionPanel.add(_moveCheckbox);
            _overwriteCheckbox = new FCheckBox("Overwrite files in destination");
            _overwriteCheckbox.setToolTipText("Overwrite existing data with the imported data");
            _overwriteCheckbox.addChangeListener(_stateChangedListener);
            ioOptionPanel.add(_overwriteCheckbox);
            _selectionPanel.add(ioOptionPanel);

            // add operation summary textfield
            _operationLog = new JTextArea();
            _operationLog.setFont(new Font("Monospaced", Font.PLAIN, 10));
            _operationLog.setOpaque(false);
            _operationLog.setWrapStyleWord(true);
            _operationLog.setLineWrap(true);
            _operationLog.setEditable(false);
            // autoscroll when we set/add text unless the user has intentionally scrolled somewhere else
            _operationLogScroller = new JScrollPane(_operationLog);
            _operationLogScroller.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
            new SmartScroller(_operationLogScroller).attach();
            _selectionPanel.add(_operationLogScroller, "w 400:400:, hmin 60, growy, growx");

            // add progress bar
            _progressBar = new JProgressBar();
            _progressBar.setString("Preparing to analyze source directory...");
            _progressBar.setStringPainted(true);
            _selectionPanel.add(_progressBar, "w 100%!");

            // start the op log updater
            _operationLogUpdater = new _OperationLogAsyncUpdater(_selections, _operationLog);
            _operationLogUpdater.start();

            // set initial checkbox labels
            _updateUI();

            // resize the panel properly now that the _selectionPanel is filled in
            _selectionPanel.getParent().validate();
            _selectionPanel.getParent().invalidate();
        }

        private void _addSelectionWidget(final JPanel parent, final OpType type, final String name) {
            _addSelectionWidget(parent, type, name, true, null, null);
        }

        private void _addSelectionWidget(final JPanel parent, final OpType type, final String name,
                final boolean selected, final String tooltip, final String constraints) {
            final FCheckBox cb = new FCheckBox();
            cb.setName(name);
            cb.setSelected(selected);
            cb.setToolTipText(tooltip);
            cb.addChangeListener(_stateChangedListener);

            // use a skip list map instead of a regular hashmap so that the files are sorted
            // alphabetically in the logs.  note that this is a concurrent data structure
            // since it will be modified and read simultaneously by different threads
            _selections.put(type, Pair.of(cb, new ConcurrentSkipListMap<File, File>()));
            parent.add(cb, constraints);
        }

        // must be called from GUI event loop thread
        private void _updateUI() {
            // update checkbox text labels with current totals
            final Set<OpType> selectedOptions = new HashSet<OpType>();
            for (final Map.Entry<OpType, Pair<FCheckBox, ? extends Map<File, File>>> entry : _selections
                    .entrySet()) {
                final Pair<FCheckBox, ? extends Map<File, File>> selection = entry.getValue();
                final FCheckBox cb = selection.getLeft();

                if (cb.isSelected()) {
                    selectedOptions.add(entry.getKey());
                }

                cb.setText(String.format("%s (%d)", cb.getName(), selection.getRight().size()));
            }

            // asynchronously update the text in the op log, which may be many tens of thousands of lines long
            // if this were done synchronously the UI would slow to a crawl
            _operationLogUpdater.requestUpdate(selectedOptions, _unknownDeckCombo.getSelectedItem(),
                    _moveCheckbox.isSelected(), _overwriteCheckbox.isSelected());
        }

        @Override
        protected Void doInBackground() throws Exception {
            Timer timer = null;

            try {
                final Map<OpType, Map<File, File>> selections = new HashMap<OpType, Map<File, File>>();
                for (final Map.Entry<OpType, Pair<FCheckBox, ? extends Map<File, File>>> entry : _selections
                        .entrySet()) {
                    selections.put(entry.getKey(), entry.getValue().getRight());
                }

                final ImportSourceAnalyzer.AnalysisCallback cb = new ImportSourceAnalyzer.AnalysisCallback() {
                    @Override
                    public boolean checkCancel() {
                        return _cancel;
                    }

                    @Override
                    public void addOp(final OpType type, final File src, final File dest) {
                        // add to concurrent map
                        _selections.get(type).getRight().put(src, dest);
                    }
                };

                final ImportSourceAnalyzer msa = new ImportSourceAnalyzer(_srcDir, cb);
                final int numFilesToAnalyze = msa.getNumFilesToAnalyze();

                // update only once every half-second so we're not flooding the UI with updates
                timer = new Timer(500, null);
                timer.setInitialDelay(100);
                final Timer finalTimer = timer;
                timer.addActionListener(new ActionListener() {
                    @Override
                    public void actionPerformed(final ActionEvent arg0) {
                        if (_cancel) {
                            finalTimer.stop();
                            return;
                        }

                        // timers run in the gui event loop, so it's ok to interact with widgets
                        _progressBar.setValue(msa.getNumFilesAnalyzed());
                        _updateUI();

                        // allow the the panel to resize to accommodate additional text
                        _selectionPanel.getParent().validate();
                        _selectionPanel.getParent().invalidate();
                    }
                });

                // update the progress bar widget from the GUI event loop
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        if (_cancel) {
                            return;
                        }
                        _progressBar.setString("Analyzing...");
                        _progressBar.setMaximum(numFilesToAnalyze);
                        _progressBar.setValue(0);
                        _progressBar.setIndeterminate(false);

                        // start update timer
                        finalTimer.start();
                    }
                });

                // does not return until analysis is complete or has been canceled
                msa.doAnalysis();
            } catch (final Exception e) {
                _cancel = true;

                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        _progressBar.setString("Error");
                        BugReporter.reportException(e);
                    }
                });
            } finally {
                // ensure the UI update timer is stopped after analysis is complete
                if (null != timer) {
                    timer.stop();
                }
            }

            return null;
        }

        // executes in gui event loop thread
        @Override
        protected void done() {
            if (!_cancel) {
                _progressBar.setValue(_progressBar.getMaximum());
                _updateUI();
                _progressBar.setString("Analysis complete");

                // clear any previously-set action listeners on the start button
                // in case we've previously completed an analysis but changed the directory
                // instead of starting the import
                for (final ActionListener a : _btnStart.getActionListeners()) {
                    _btnStart.removeActionListener(a);
                }

                // deselect and disable all options that have 0 operations associated with
                // them to highlight the important options
                for (final Pair<FCheckBox, ? extends Map<File, File>> p : _selections.values()) {
                    final FCheckBox cb = p.getLeft();
                    if (0 == p.getRight().size()) {
                        cb.removeChangeListener(_stateChangedListener);
                        cb.setSelected(false);
                        cb.setEnabled(false);
                    }
                }

                if (0 == _selections.get(OpType.UNKNOWN_DECK).getRight().size()) {
                    _unknownDeckLabel.setEnabled(false);
                    _unknownDeckCombo.setEnabled(false);
                }

                // set up the start button to start the prepared import on click
                _btnStart.addActionListener(new ActionListener() {
                    @Override
                    public void actionPerformed(final ActionEvent arg0) {
                        // if this is a migration, warn if active settings will not complete a migration and give the
                        // user an option to fix
                        if (_isMigration) {
                            // assemble a list of selections that need to be selected to complete a full migration
                            final List<String> unselectedButShouldBe = new ArrayList<String>();
                            for (final Map.Entry<OpType, Pair<FCheckBox, ? extends Map<File, File>>> entry : _selections
                                    .entrySet()) {
                                if (OpType.POSSIBLE_SET_CARD_PIC == entry.getKey()) {
                                    continue;
                                }

                                // add name to list if checkbox is unselected, but contains operations
                                final Pair<FCheckBox, ? extends Map<File, File>> p = entry.getValue();
                                final FCheckBox cb = p.getLeft();
                                if (!cb.isSelected() && 0 < p.getRight().size()) {
                                    unselectedButShouldBe.add(cb.getName());
                                }
                            }

                            if (!unselectedButShouldBe.isEmpty() || !_moveCheckbox.isSelected()) {
                                final StringBuilder sb = new StringBuilder("<html>");
                                if (!unselectedButShouldBe.isEmpty()) {
                                    sb.append(
                                            "It looks like the following options are not selected, which will result in an incomplete migration:");
                                    sb.append("<ul>");
                                    for (final String cbName : unselectedButShouldBe) {
                                        sb.append("<li><b>").append(cbName).append("</b></li>");
                                    }
                                    sb.append("</ul>");
                                }

                                if (!_moveCheckbox.isSelected()) {
                                    sb.append(unselectedButShouldBe.isEmpty() ? "It " : "It also ")
                                            .append("looks like the <b>");
                                    sb.append(_moveCheckbox.getText())
                                            .append("</b> option is not selected.<br><br>");
                                }

                                sb.append(
                                        "You can continue anyway, but the migration will be incomplete, and the data migration prompt<br>");
                                sb.append(
                                        "will come up again the next time you start Forge in order to migrate the remaining files<br>");
                                sb.append("unless you move or delete them manually.</html>");

                                final int chosen = FOptionPane.showOptionDialog(sb.toString(), "Migration warning",
                                        FOptionPane.WARNING_ICON, fixOrContinue);

                                if (chosen != 1) {
                                    // i.e. option 0 was chosen or the dialog was otherwise closed
                                    return;
                                }
                            }
                        }

                        // ensure no other actions (except for cancel) can be taken while the import is in progress
                        _btnStart.setEnabled(false);
                        _btnChooseDir.setEnabled(false);

                        for (final Pair<FCheckBox, ? extends Map<File, File>> selection : _selections.values()) {
                            selection.getLeft().setEnabled(false);
                        }
                        _unknownDeckCombo.setEnabled(false);
                        _moveCheckbox.setEnabled(false);
                        _overwriteCheckbox.setEnabled(false);

                        // stop updating the operation log -- the importer needs it now
                        _operationLogUpdater.requestStop();

                        // jump to the bottom of the log text area so it starts autoscrolling again
                        // note that since it is controlled by a SmartScroller, just setting the caret position will not work
                        final JScrollBar scrollBar = _operationLogScroller.getVerticalScrollBar();
                        scrollBar.setValue(scrollBar.getMaximum());

                        // start importing!
                        final _Importer importer = new _Importer(_srcDir, _selections, _unknownDeckCombo,
                                _operationLog, _progressBar, _moveCheckbox.isSelected(),
                                _overwriteCheckbox.isSelected());
                        importer.run();

                        _btnCancel.requestFocusInWindow();
                    }
                });

                // import ready to proceed: enable the start button
                _btnStart.setEnabled(true);
            }

            // report to the Choose Directory button that this analysis run has stopped
            _onAnalyzerDone.run();
        }
    }

    // asynchronously iterates through the given concurrent maps and populates the operation log with
    // the proposed operations
    private class _OperationLogAsyncUpdater extends Thread {
        final Map<OpType, Map<File, File>> _selections;
        final JTextArea _operationLog; // safe to set text from another thread

        // synchronized-access data
        private int _updateCallCnt = 0;
        private Set<OpType> _selectedOptions;
        private _UnknownDeckChoice _unknownDeckChoice;
        private boolean _isMove;
        private boolean _isOverwrite;
        private boolean _stop;

        // only accessed from the event loop thread
        int _maxLogLength = 0;

        public _OperationLogAsyncUpdater(final Map<OpType, Pair<FCheckBox, ? extends Map<File, File>>> selections,
                final JTextArea operationLog) {
            super("OperationLogUpdater");
            setDaemon(true);

            _selections = new HashMap<OpType, Map<File, File>>();
            _operationLog = operationLog;

            // remove references to FCheckBox when populating map -- we can't safely access it from a thread
            // anyway and it's better to keep our data structure clean to prevent mistakes
            for (final Map.Entry<OpType, Pair<FCheckBox, ? extends Map<File, File>>> entry : selections
                    .entrySet()) {
                _selections.put(entry.getKey(), entry.getValue().getRight());
            }
        }

        // updates the synchronized data with values for the next iteration in _run
        public synchronized void requestUpdate(final Set<OpType> selectedOptions,
                final _UnknownDeckChoice unknownDeckChoice, final boolean isMove, final boolean isOverwrite) {
            ++_updateCallCnt;
            _selectedOptions = selectedOptions;
            _unknownDeckChoice = unknownDeckChoice;
            _isMove = isMove;
            _isOverwrite = isOverwrite;

            // notify waiter
            notify();
        }

        public synchronized void requestStop() {
            _stop = true;

            // notify waiter
            notify();
        }

        private void _run() throws InterruptedException {
            int lastUpdateCallCnt = _updateCallCnt;
            Set<OpType> selectedOptions;
            _UnknownDeckChoice unknownDeckChoice;
            boolean isMove;
            boolean isOverwrite;

            while (true) {
                synchronized (this) {
                    // can't check _stop in the while condition since we have to do it in a synchronized block
                    if (_stop) {
                        break;
                    }

                    // if we're stopped while looping here, run through the update one last time
                    // before returning
                    while (lastUpdateCallCnt == _updateCallCnt && !_stop) {
                        wait();
                    }

                    // safely copy synchronized data to local values that we will use for this runthrough
                    lastUpdateCallCnt = _updateCallCnt;
                    selectedOptions = _selectedOptions;
                    unknownDeckChoice = _unknownDeckChoice;
                    isMove = _isMove;
                    isOverwrite = _isOverwrite;
                }

                // build operation log
                final StringBuilder log = new StringBuilder();
                int totalOps = 0;
                for (final OpType opType : selectedOptions) {
                    final Map<File, File> ops = _selections.get(opType);
                    totalOps += ops.size();

                    for (final Map.Entry<File, File> op : ops.entrySet()) {
                        File dest = op.getValue();
                        if (OpType.UNKNOWN_DECK == opType) {
                            dest = new File(unknownDeckChoice.path, dest.getName());
                        }
                        log.append(op.getKey().getAbsolutePath()).append(" -> ");
                        log.append(dest.getAbsolutePath()).append("\n");
                    }
                }

                // append summary
                if (0 < totalOps) {
                    log.append("\n");
                }
                log.append("Prepared to ").append(isMove ? "move" : "copy");
                log.append(" ").append(totalOps).append(" files\n");
                log.append(isOverwrite ? "O" : "Not o").append("verwriting existing files");

                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        final String logText = log.toString();

                        // setText is thread-safe, but the resizing is not, so might as well do this in the swing event loop thread
                        _operationLog.setText(log.toString());

                        if (_maxLogLength < logText.length()) {
                            _maxLogLength = logText.length();

                            // resize the panel properly for the new log contents
                            _selectionPanel.getParent().validate();
                            _selectionPanel.getParent().invalidate();
                            _topPanel.getParent().validate();
                            _topPanel.getParent().invalidate();
                        }
                    }
                });
            }
        }

        @Override
        public void run() {
            try {
                _run();
            } catch (final InterruptedException e) {
                _cancel = true;
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        // we never interrupt the thread, so this is not expected to happen
                        BugReporter.reportException(e);
                    }
                });
            }
        }
    }

    // asynchronously completes the specified I/O operations and updates the progress bar and operation log
    private class _Importer extends SwingWorker<Void, Void> {
        private final String _srcDir;
        private final Map<File, File> _operations;
        private final JTextArea _operationLog;
        private final JProgressBar _progressBar;
        private final boolean _move;
        private final boolean _overwrite;

        public _Importer(final String srcDir,
                final Map<OpType, Pair<FCheckBox, ? extends Map<File, File>>> selections,
                final FComboBoxWrapper<_UnknownDeckChoice> unknownDeckCombo, final JTextArea operationLog,
                final JProgressBar progressBar, final boolean move, final boolean overwrite) {
            _srcDir = srcDir;
            _operationLog = operationLog;
            _progressBar = progressBar;
            _move = move;
            _overwrite = overwrite;

            // build local operations map that only includes data that we can access from the background thread
            // use a tree map to maintain alphabetical order
            _operations = new TreeMap<File, File>();
            for (final Map.Entry<OpType, Pair<FCheckBox, ? extends Map<File, File>>> entry : selections
                    .entrySet()) {
                final Pair<FCheckBox, ? extends Map<File, File>> selection = entry.getValue();
                if (selection.getLeft().isSelected()) {
                    if (OpType.UNKNOWN_DECK != entry.getKey()) {
                        _operations.putAll(selection.getRight());
                    } else {
                        // map unknown decks to selected directory
                        for (final Map.Entry<File, File> op : selection.getRight().entrySet()) {
                            final _UnknownDeckChoice choice = unknownDeckCombo.getSelectedItem();
                            _operations.put(op.getKey(), new File(choice.path, op.getValue().getName()));
                        }
                    }
                }
            }

            // set progress bar bounds
            _progressBar.setString(_move ? "Moving files..." : "Copying files...");
            _progressBar.setMinimum(0);
            _progressBar.setMaximum(_operations.size());
        }

        @Override
        protected Void doInBackground() throws Exception {
            try {
                // working with textbox text is thread safe
                _operationLog.setText("");

                // only update the text box once very half second, but make the first
                // update after only 100ms
                final long updateIntervalMs = 500;
                long lastUpdateTimestampMs = System.currentTimeMillis() - 400;
                final StringBuffer opLogBuf = new StringBuffer();

                // only update the progress bar when we expect the visual value to change
                final long progressInterval = Math.max(1, _operations.size() / _progressBar.getWidth());

                // the length of the prefix to remove from source paths
                final int srcPathPrefixLen;
                if (_srcDir.endsWith("/") || _srcDir.endsWith(File.separator)) {
                    srcPathPrefixLen = _srcDir.length();
                } else {
                    srcPathPrefixLen = _srcDir.length() + 1;
                }

                // stats maintained during import sequence
                int numOps = 0;
                int numExisting = 0;
                int numSucceeded = 0;
                int numFailed = 0;
                for (final Map.Entry<File, File> op : _operations.entrySet()) {
                    if (_cancel) {
                        break;
                    }

                    final int curOpNum = ++numOps;
                    if (0 == curOpNum % progressInterval) {
                        SwingUtilities.invokeLater(new Runnable() {
                            @Override
                            public void run() {
                                if (_cancel) {
                                    return;
                                }
                                _progressBar.setValue(curOpNum);
                            }
                        });
                    }

                    final long curTimeMs = System.currentTimeMillis();
                    if (updateIntervalMs <= curTimeMs - lastUpdateTimestampMs) {
                        lastUpdateTimestampMs = curTimeMs;

                        // working with textbox text is thread safe
                        _operationLog.append(opLogBuf.toString());
                        opLogBuf.setLength(0);
                    }

                    final File srcFile = op.getKey();
                    final File destFile = op.getValue();

                    try {
                        // simplify logged source path and log next attempted operation
                        String srcPath = srcFile.getAbsolutePath();
                        // I doubt that the srcPath will start with anything other than _srcDir, even with symlinks,
                        // hardlinks, or Windows junctioned nodes, but it's better to be safe than to have malformed output
                        if (srcPath.startsWith(_srcDir)) {
                            srcPath = srcPath.substring(srcPathPrefixLen);
                        }
                        opLogBuf.append(_move ? "Moving " : "Copying ").append(srcPath).append(" -> ");
                        opLogBuf.append(destFile.getAbsolutePath()).append("\n");

                        if (!destFile.exists()) {
                            _copyFile(srcFile, destFile, _move);
                        } else {
                            if (_overwrite) {
                                opLogBuf.append("  Destination file exists; overwriting\n");
                                _copyFile(srcFile, destFile, _move);
                            } else {
                                opLogBuf.append("  Destination file exists; skipping copy\n");
                            }
                            ++numExisting;
                        }

                        if (_move) {
                            // source file may have been deleted already if _copyFile was called
                            srcFile.delete();
                            opLogBuf.append("  Removed source file after successful copy\n");
                        }

                        ++numSucceeded;
                    } catch (final IOException e) {
                        opLogBuf.append("  Operation failed: ").append(e.getMessage()).append("\n");
                        ++numFailed;
                    }
                }

                // append summary footer
                opLogBuf.append("\nImport complete: ");
                opLogBuf.append(numSucceeded).append(" operation").append(1 == numSucceeded ? "" : "s")
                        .append(" succeeded, ");
                opLogBuf.append(numFailed).append(" error").append(1 == numFailed ? "" : "s");
                if (0 < numExisting) {
                    opLogBuf.append(", ").append(numExisting);
                    if (_overwrite) {
                        opLogBuf.append(" existing destination files overwritten");
                    } else {
                        opLogBuf.append(" copy operations skipped due to existing destination files");
                    }
                }
                _operationLog.append(opLogBuf.toString());
            } catch (final Exception e) {
                _cancel = true;

                // report any exceptions in a standard dialog
                // note that regular I/O errors don't throw, they'll just be mentioned in the log
                SwingUtilities.invokeLater(new Runnable() {
                    @Override
                    public void run() {
                        _progressBar.setString("Error");
                        BugReporter.reportException(e);
                    }
                });
            }

            return null;
        }

        @Override
        protected void done() {
            _btnCancel.requestFocusInWindow();
            if (_cancel) {
                return;
            }

            _progressBar.setValue(_progressBar.getMaximum());
            _progressBar.setString("Import complete");
            _btnCancel.setText("Done");
        }
    }

    // when copying is required, uses java nio classes for ultra-fast I/O
    private static void _copyFile(final File srcFile, final File destFile, final boolean deleteSrcAfter)
            throws IOException {
        destFile.getParentFile().mkdirs();

        // if this is a move, try a simple rename first
        if (deleteSrcAfter) {
            if (srcFile.renameTo(destFile)) {
                return;
            }
        }

        if (!destFile.exists()) {
            destFile.createNewFile();
        }

        FileChannel src = null;
        FileChannel dest = null;
        try {
            src = new FileInputStream(srcFile).getChannel();
            dest = new FileOutputStream(destFile).getChannel();
            dest.transferFrom(src, 0, src.size());
        } finally {
            if (src != null) {
                src.close();
            }
            if (dest != null) {
                dest.close();
            }
        }

        if (deleteSrcAfter) {
            srcFile.delete();
        }
    }
}