au.org.ala.delta.intkey.model.IntkeyContext.java Source code

Java tutorial

Introduction

Here is the source code for au.org.ala.delta.intkey.model.IntkeyContext.java

Source

/*******************************************************************************
 * Copyright (C) 2011 Atlas of Living Australia
 * All Rights Reserved.
 * 
 * The contents of this file are subject to the Mozilla Public
 * License Version 1.1 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of
 * the License at http://www.mozilla.org/MPL/
 * 
 * Software distributed under the License is distributed on an "AS
 * IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
 * implied. See the License for the specific language governing
 * rights and limitations under the License.
 ******************************************************************************/
package au.org.ala.delta.intkey.model;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.io.PrintStream;
import java.io.StringReader;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

import javax.swing.SwingUtilities;
import javax.swing.SwingWorker;

import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.math.IntRange;

import au.org.ala.delta.Logger;
import au.org.ala.delta.best.Best;
import au.org.ala.delta.best.DiagType;
import au.org.ala.delta.directives.AbstractDeltaContext;
import au.org.ala.delta.intkey.LongRunningDirectiveSwingWorker;
import au.org.ala.delta.intkey.directives.IntkeyDirectiveParseException;
import au.org.ala.delta.intkey.directives.IntkeyDirectiveParser;
import au.org.ala.delta.intkey.directives.SetMatchDirective;
import au.org.ala.delta.intkey.directives.invocation.IntkeyDirectiveInvocation;
import au.org.ala.delta.intkey.directives.invocation.IntkeyDirectiveInvocationException;
import au.org.ala.delta.intkey.directives.invocation.LongRunningIntkeyDirectiveInvocation;
import au.org.ala.delta.intkey.directives.invocation.UseDirectiveInvocation;
import au.org.ala.delta.intkey.ui.DirectivePopulator;
import au.org.ala.delta.intkey.ui.IntKeyDialogController;
import au.org.ala.delta.intkey.ui.IntkeyUI;
import au.org.ala.delta.intkey.ui.UIUtils;
import au.org.ala.delta.model.Attribute;
import au.org.ala.delta.model.Character;
import au.org.ala.delta.model.Item;
import au.org.ala.delta.model.MatchType;
import au.org.ala.delta.model.ResourceSettings;
import au.org.ala.delta.model.Specimen;
import au.org.ala.delta.model.image.ImageSettings;
import au.org.ala.delta.model.image.ImageSettings.FontInfo;
import au.org.ala.delta.rtf.RTFUtils;
import au.org.ala.delta.translation.PrintFile;
import au.org.ala.delta.util.Pair;
import au.org.ala.delta.util.Utils;

/**
 * Model. Maintains global application state.
 * 
 * @author Chris
 * 
 */
public class IntkeyContext extends AbstractDeltaContext {

    /** Pre-defined character keywords */
    public static final String CHARACTER_KEYWORD_ALL = "all";
    public static final String CHARACTER_KEYWORD_USED = "used";
    public static final String CHARACTER_KEYWORD_AVAILABLE = "available";
    public static final String CHARACTER_KEYWORD_NONE = "none";
    public static final String CHARACTER_KEYWORD_SELECTED = "selected";

    /** Pre-defined taxon keywords */
    public static final String TAXON_KEYWORD_ALL = "all";
    public static final String TAXON_KEYWORD_ELIMINATED = "eliminated";
    public static final String TAXON_KEYWORD_REMAINING = "remaining";
    public static final String TAXON_KEYWORD_NONE = "none";
    public static final String TAXON_KEYWORD_SELECTED = "selected";

    /** Pre-defined keyword for the specimen */
    public static final String SPECIMEN_KEYWORD = "specimen";

    /** Width of output files - including log and journal files */
    public static final int OUTPUT_FILE_WIDTH = 80;

    /** The taxa file associated with the dataset. This is usually called iitems */
    private File _taxaFile;

    /**
     * The characters file associated with the dataset. This is usually called
     * ichars
     */
    private File _charactersFile;

    /** The currently-loaded dataset */
    private IntkeyDataset _dataset;

    /**
     * A URL pointing to the dataset startup file. If the dataset is located on
     * disk, this will be a file URL
     */
    private URL _datasetStartupURL;

    /**
     * A .ink file used to load a new dataset. May be a jnlp style file
     * specifying where data is to be downloaded from. Or may be a directives
     * file that is run to load the dataset in intkey, initialize values etc. In
     * the latter case, this value will be the same as _initializationFile (see
     * below).
     * 
     * If _datasetStartupURL points to remote content (i.e. it is not a file
     * URL), this will be a temporary file used to save the remote content
     * referenced by the URL.
     */
    private File _datasetStartupFile;

    /**
     * A directives file that is run to load the dataset in intkey, initialize
     * values etc. Typically a .ini file, but can also have extension .ink.
     */
    private File _initializationFile;

    /**
     * A directives file that sets preferred parameter settings. The directives
     * in this file are executed every time a new dataset is loaded.
     */
    private File _preferencesFile;

    private StartupFileData _startupFileData;

    private IntkeyDirectiveParser _directiveParser;

    /**
     * Is a file containing directive calls currently being processed?
     */
    private boolean _processingDirectivesFile;

    /**
     * Is an input file - a file with directive calls as specified by the FILE
     * INPUT directive currently being processed?
     */
    private boolean _processingInputFile;

    private List<IntkeyDirectiveInvocation> _executedDirectives;

    /**
     * The Intkey UI
     */
    private IntkeyUI _appUI;

    /**
     * Handles user prompts for input when handling directives
     */
    private DirectivePopulator _directivePopulator;

    /**
     * The specimen. Holds character values set by the user pertaining to the
     * current investigation
     */
    private Specimen _specimen;

    /** values set by SET directives */
    private boolean _autoTolerance;
    private int _diagLevel;
    private DiagType _diagType;
    private boolean _charactersFixed;
    private List<Integer> _fixedCharactersList;
    private Set<Integer> _exactCharactersSet;
    private List<String> _imagePathLocations;
    private List<String> _infoPathLocations;
    private boolean _matchInapplicables;
    private boolean _matchUnknowns;
    private MatchType _matchType;
    private double _rbase;
    private int _stopBest;
    private int _tolerance;
    private double _varyWeight;

    private boolean _demonstrationMode;

    /**
     * A saved version of the values set for the SET, INCLUDE and DISPLAY
     * directives at the time that SET DEMONSTRATION was set to ON. While SET
     * DEMONSTRATION is on, the IntkeyContext is reverted back to these baseline
     * settings every time the investigation is restarted (RESTART directive)
     */
    private DemonstrationModeSettings _demonstrationModeSettings;

    // values set by DISPLAY directives
    private boolean _displayNumbering;
    private boolean _displayInapplicables;
    private boolean _displayUnknowns;
    private boolean _displayComments;
    private boolean _displayContinuous;
    private ImageDisplayMode _displayImagesMode;
    private boolean _displayKeywords;
    private boolean _displayScaled;
    private boolean _displayEndIdentify;
    private boolean _displayInput;

    /** Ordering type to use when sorting characters for display */
    private IntkeyCharacterOrder _characterOrder;

    /** The taxon to be separated when using the SEPARATE character order. */
    private int _taxonToSeparate;

    /**
     * The BEST characters, or the characters used to SEPARATE a taxon, along
     * with the weight of each character as determined by the BEST algorithm. A
     * linked hash map is used to maintain character ordering.
     */
    private LinkedHashMap<Character, Double> _bestOrSeparateCharacters;

    /** The set of currently included characters */
    private Set<Integer> _includedCharacters;

    /** The set of currently included taxa */
    private Set<Integer> _includedTaxa;

    private List<Pair<String, String>> _taxonInformationDialogCommands;

    /**
     * The user defined character keywords, along with the characters associated
     * with each keyword. A linked hashmap is used so that the keys list will be
     * returned in order of insertion.
     */
    private LinkedHashMap<String, Set<Integer>> _userDefinedCharacterKeywords;

    /**
     * The user defined taxon keywords, along with the characters associated
     * with each keyword. A linked hashmap is used so that the keys list will be
     * returned in order of insertion.
     */
    private LinkedHashMap<String, Set<Integer>> _userDefinedTaxonKeywords;

    /**
     * A list of directive commands to run when a taxon is identified (i.e. 1
     * taxon remains during an investigation) and _displayEndIdentify is true
     */
    private List<String> _endIdentifyCommands;

    /**
     * Image subjects. The words or phrases are placed in the "subjects" list
     * box in the "select multiple images" dialog box. The images displayed are
     * then restricted to those whose "subjects" contain any of the words of
     * phrases selected in the list box
     */
    private List<String> _imageSubjects;

    /**
     * True if the last line output to an output file a comment, output via the
     * OUTPUT COMMENT directive
     */
    private boolean _lastOutputLineWasComment;

    /**
     * The log file
     */
    private File _logFile;

    /**
     * The journal file
     */
    private File _journalFile;

    /**
     * PrintFile wrapper for log file
     */
    private PrintFile _logPrintFile;

    /**
     * PrintFile wrapper for the journal file
     */
    private PrintFile _journalPrintFile;

    /**
     * The current output file. Intkey can only write to a single output file at
     * a time, so need to keep track of the current file
     */
    private File _currentOutputFile;

    /**
     * PrintFile wrapper for the current output file
     */
    private PrintFile _currentOutputPrintFile;

    /**
     * Cache of all lines output to log files
     */
    private List<String> _logCache;

    /**
     * Cache of all lines output to journal files
     */
    private List<String> _journalCache;

    /**
     * The directory used to cache images and files downloaded from remote
     * locations on the imagepath and infopath
     */
    private File _fileCacheDirectory;

    /**
     * Constructor
     * 
     * @param appUI
     *            A reference to the main Intkey UI
     * @param directivePopulator
     *            A reference to the directive populator
     */
    public IntkeyContext(IntkeyUI appUI, DirectivePopulator directivePopulator) {
        if (appUI == null) {
            throw new IllegalArgumentException("UI Reference cannot be null");
        }

        if (directivePopulator == null) {
            throw new IllegalArgumentException("Directive populator cannot be null");
        }

        _appUI = appUI;
        _directivePopulator = directivePopulator;
        _processingDirectivesFile = false;
        _processingInputFile = false;

        _directiveParser = IntkeyDirectiveParser.createInstance();
        _logCache = new ArrayList<String>();
        _journalCache = new ArrayList<String>();
        _characterOrder = IntkeyCharacterOrder.BEST;
        _displayImagesMode = ImageDisplayMode.AUTO;
        _displayEndIdentify = true;

        initializeIdentification();
    }

    /**
     * Called to set the initial state when a new dataset is loaded
     */
    private void initializeIdentification() {
        // Use linked hashmap so that the keys list will be returned in
        // order of insertion.
        _userDefinedCharacterKeywords = new LinkedHashMap<String, Set<Integer>>();

        // Use linked hashmap so that the keys list will be returned in
        // order of insertion.
        _userDefinedTaxonKeywords = new LinkedHashMap<String, Set<Integer>>();

        _executedDirectives = new ArrayList<au.org.ala.delta.intkey.directives.invocation.IntkeyDirectiveInvocation>();

        _matchInapplicables = true;
        _matchUnknowns = true;
        _matchType = MatchType.OVERLAP;
        _diagType = DiagType.SPECIMENS;

        _tolerance = 0;
        _rbase = 1.1;
        _varyWeight = 1;
        _diagLevel = 1;

        _taxonToSeparate = -1;
        _bestOrSeparateCharacters = null;

        _imagePathLocations = new ArrayList<String>();
        _infoPathLocations = new ArrayList<String>();

        _taxonInformationDialogCommands = new ArrayList<Pair<String, String>>();

        _charactersFixed = false;
        _fixedCharactersList = new ArrayList<Integer>();
        _exactCharactersSet = new HashSet<Integer>();

        _displayNumbering = false;
        _displayInapplicables = true;
        _displayUnknowns = true;
        _displayComments = false;
        _displayKeywords = true;
        _displayScaled = true;
        _displayScaled = true;

        _endIdentifyCommands = new ArrayList<String>();
        _imageSubjects = new ArrayList<String>();
    }

    /**
     * Set the preferences file
     * 
     * @param preferencesFile
     *            the preferences file
     */
    public synchronized void setPreferencesFile(File preferencesFile) {
        _preferencesFile = preferencesFile;
    }

    /**
     * Execute the directives in the preferences file, if a preferences file has
     * been set.
     */
    public synchronized void executePreferencesFileDirectives() {
        if (_preferencesFile != null) {
            processDirectivesFile(_preferencesFile);
            updateUI();
        }
    }

    /**
     * Process the directives a file supplied via the FILE INPUT directive
     * 
     * @param directivesFile
     *            the directives file
     */
    public synchronized void processInputFile(File directivesFile) {
        // Keep track of old value for _processingInputFile. There could
        // be a case where there is a call to FILE INPUT inside a
        // preferences file itself. Unlikely, but it could happen
        boolean oldProcessingInputFile = _processingInputFile;
        _processingInputFile = true;
        processDirectivesFile(directivesFile);
        _processingInputFile = oldProcessingInputFile;
        updateUI();
    }

    /**
     * Set the current characters file. If a new taxa file has previously been
     * set, calling this method will result in the new dataset being loaded. The
     * calling thread will block while the dataset is loaded.
     * 
     * @param charactersFile
     *            The characters file
     */
    public synchronized void setFileCharacters(File charactersFile) {
        Logger.log("Setting characters file to: %s", charactersFile.getAbsolutePath());

        if (!charactersFile.exists()) {
            String absoluteFileName = charactersFile.getAbsolutePath();
            throw new IllegalArgumentException(
                    UIUtils.getResourceString("CharactersFileNotFound.error", absoluteFileName));
        }

        _charactersFile = charactersFile;

        if (_dataset == null && _taxaFile != null) {
            createNewDataSet();
        } else {
            // cleanup the old dataset, in case the FILE CHARACTERS directive
            // has
            // been called without loading a new dataset file.
            if (_dataset != null) {
                _dataset.close();
            }

            _dataset = null;
            _taxaFile = null;
        }
    }

    /**
     * Set the current taxa (items) file. If a new characters file has
     * previously been set, calling this method will result in the new dataset
     * being loaded. The calling thread will block while the dataset is loaded.
     * 
     * @param taxaFile
     *            The taxa (items) file
     */
    public synchronized void setFileTaxa(File taxaFile) {
        Logger.log("Setting taxa file to: %s", taxaFile.getAbsolutePath());

        if (!taxaFile.exists()) {
            String absoluteFileName = taxaFile.getAbsolutePath();
            throw new IllegalArgumentException(
                    UIUtils.getResourceString("TaxaFileNotFound.error", absoluteFileName));
        }

        _taxaFile = taxaFile;

        if (_dataset == null && _charactersFile != null) {
            createNewDataSet();
        } else {
            // cleanup the old dataset, in case the FILE TAXA directive has
            // been called without loading a new dataset file.
            if (_dataset != null) {
                _dataset.close();
            }

            _dataset = null;
            _charactersFile = null;
        }
    }

    /**
     * Process the directives in the supplied file
     * 
     * @param directivesFile
     *            The directives file
     */
    private synchronized void processDirectivesFile(File directivesFile) {
        Logger.log("Reading in directives from file: %s", directivesFile.getAbsolutePath());

        if (directivesFile == null || !directivesFile.exists()) {
            throw new IllegalArgumentException("Could not open input file " + directivesFile.getAbsolutePath());
        }

        // May be in several levels of input file, so need to ensure we set this
        // back to the correct value.
        boolean oldProcessingInputFile = _processingDirectivesFile;
        _processingDirectivesFile = true;

        IntkeyDirectiveParser parser = IntkeyDirectiveParser.createInstance();

        try {
            parser.parse(directivesFile, IntkeyContext.this);
        } catch (Throwable th) {
            th.printStackTrace();
            Logger.error(th);
            _appUI.displayErrorMessage(UIUtils.getResourceString("ErrorProcessingDirectivesFile.error",
                    directivesFile.getAbsolutePath(), th.getMessage()));
        }

        _processingDirectivesFile = oldProcessingInputFile;
    }

    /**
     * Called to read the characters and taxa files and create a new dataset.
     * This method will block while the dataset information is read from the
     * character and taxa files
     */
    private void createNewDataSet() {
        _dataset = IntkeyDatasetFileReader.readDataSet(_charactersFile, _taxaFile);

        _specimen = new Specimen(_dataset, false, _matchInapplicables, _matchInapplicables, _matchType);

        _includedCharacters = new HashSet<Integer>();
        IntRange charNumRange = new IntRange(1, _dataset.getNumberOfCharacters());
        for (int i : charNumRange.toArray()) {
            _includedCharacters.add(i);
        }

        _includedTaxa = new HashSet<Integer>();
        IntRange taxaNumRange = new IntRange(1, _dataset.getNumberOfTaxa());
        for (int i : taxaNumRange.toArray()) {
            _includedTaxa.add(i);
        }

        // TODO need a proper listener pattern here?
        if (!_processingDirectivesFile) {
            _appUI.handleNewDataset(_dataset);
        }

        _stopBest = _dataset.getNumberOfCharacters();
        _appUI.clearToolbar();
    }

    /**
     * Read and execute the specified dataset startup file. This file may be
     * either a "webstart" file, or a file containing actual directives to
     * initialize the dataset.
     * 
     * This method will block while the calling thread while the file is read,
     * the dataset is loaded, and other directives in the file are executed.
     * 
     * @param datasetFileURL
     *            The dataset initialization file
     * @return SwingWorker used to load the dataset in a separate thread - unit
     *         tests need this so that they can block until the dataset is
     *         loaded.
     */
    public synchronized void newDataSetFile(final URL datasetFileURL) {
        Logger.log("Reading in directives from url: %s", datasetFileURL.toString());

        // Close any dialogs that have been left open.
        IntKeyDialogController.closeWindows();

        cleanupOldDataset();

        initializeIdentification();

        // Loading of a new dataset can take a long time and hence can lock up
        // the UI. If this method is called from the Swing Event Dispatch
        // Thread, load the
        // new dataset on a background thread using a SwingWorker.
        if (SwingUtilities.isEventDispatchThread()) {
            _appUI.displayBusyMessage(UIUtils.getResourceString("LoadingDataset.caption"));
            SwingWorker<Void, Void> startupWorker = new SwingWorker<Void, Void>() {

                @Override
                protected Void doInBackground() throws Exception {
                    processStartupFile(datasetFileURL);
                    return null;
                }

                @Override
                protected void done() {
                    try {
                        get();

                        if (_dataset.getHeading() != null) {
                            appendToLog(_dataset.getHeadingWithoutFormatting());
                        }

                        if (_dataset.getSubHeading() != null) {
                            appendToLog(_dataset.getSubheadingWithoutFormatting());
                        }

                        _appUI.handleNewDataset(_dataset);
                    } catch (Exception ex) {
                        Logger.error("Error reading dataset file", ex);
                        _appUI.displayErrorMessage(UIUtils.getResourceString("ErrorReadingReadsetFile.error",
                                datasetFileURL.toString(), ex.getMessage()));
                    } finally {
                        _appUI.removeBusyMessage();
                    }
                }
            };

            startupWorker.execute();
        } else {
            try {
                processStartupFile(datasetFileURL);

                if (_dataset.getHeading() != null) {
                    appendToLog(_dataset.getHeadingWithoutFormatting());
                }

                if (_dataset.getSubHeading() != null) {
                    appendToLog(_dataset.getSubheadingWithoutFormatting());
                }

                _appUI.handleNewDataset(_dataset);
            } catch (Exception ex) {
                Logger.error("Error reading dataset file", ex);
                _appUI.displayErrorMessage(UIUtils.getResourceString("ErrorReadingReadsetFile.error",
                        datasetFileURL.toString(), ex.getMessage()));
            }
        }
    }

    /**
     * Process the directives in a dataset initialization file (typically will
     * have extension .ink or .ini)
     * 
     * @param initializationFile
     *            The initialization file
     */
    private void processInitializationFile(File initializationFile) {
        _initializationFile = initializationFile;
        processDirectivesFile(initializationFile);
        executePreferencesFileDirectives();
    }

    /**
     * Parse and execute a directive command
     * 
     * @param command
     *            The directive command
     */
    public synchronized void parseAndExecuteDirective(String command) {
        try {
            _directiveParser.parse(new StringReader(command), this);
        } catch (Throwable th) {
            String msg;
            if (th instanceof IntkeyDirectiveParseException) {
                msg = th.getMessage();
            } else {
                msg = UIUtils.getResourceString("ErrorWhileProcessingCommand.error", command.toUpperCase(),
                        th.getMessage());
            }
            _appUI.displayErrorMessage(msg);
            Logger.error(msg);
            Logger.error(th);
        }
    }

    /**
     * Execute the supplied command pattern object representing the invocation
     * of a directive
     * 
     * @param invoc
     *            a command pattern object representing the invocation of a
     *            directive
     */
    public synchronized void executeDirective(IntkeyDirectiveInvocation invoc) {
        // record correct insertion index in case execution of directive results
        // in further directives being
        // run (such as in the case of the File Input directive).
        int executedDirectivesIndex = _executedDirectives.size();

        // Record correct insertion index for the directive call here so that if
        // unsuccessful the directive call can be removed from the log. It is
        // necessary to add the directive call to the log here to ensure that it
        // appears above any subsequent status messages in the log.
        int logInsertionIndex = _logCache.size();

        // The use directive is a special case. The use directive logic handles
        // appending to the log itself.
        if (!(invoc instanceof UseDirectiveInvocation)) {
            if (!_processingDirectivesFile || (_processingInputFile && _displayInput)) {
                appendToLog("*" + invoc.toString());
            }
        }

        // If this is a long running directive and we are on the event dispatch
        // thread, run the task in the
        // background using a SwingWorker.
        if (invoc instanceof LongRunningIntkeyDirectiveInvocation && SwingUtilities.isEventDispatchThread()
                && !_processingInputFile && !_processingDirectivesFile) {
            LongRunningIntkeyDirectiveInvocation<?> longInvoc = (LongRunningIntkeyDirectiveInvocation<?>) invoc;
            LongRunningDirectiveSwingWorker worker = new LongRunningDirectiveSwingWorker(longInvoc, this, _appUI,
                    executedDirectivesIndex, logInsertionIndex);
            worker.execute();
        } else {
            try {
                boolean success = invoc.execute(this);
                if (success) {
                    handleDirectiveExecutionComplete(invoc, executedDirectivesIndex);
                } else {
                    handleDirectiveExecutionFailed(invoc, logInsertionIndex);
                }
            } catch (IntkeyDirectiveInvocationException ex) {
                _appUI.displayErrorMessage(ex.getMessage());
            }
        }
    }

    /**
     * Called when a directive has successfully been executed
     * 
     * @param invoc
     *            Command pattern object representing the executed directive
     * @param executedDirectivesIndex
     *            index in which to insert the executed directive in the list of
     *            executed directives
     */
    public void handleDirectiveExecutionComplete(IntkeyDirectiveInvocation invoc, int executedDirectivesIndex) {
        // Omit directive calls from the log and journal if a directives
        // file is being processes, except in the case
        // that the file is an "input" file as specified by FILE INPUT, and
        // DISPLAY INPUT is set to ON.
        if (!_processingDirectivesFile || (_processingInputFile && _displayInput)) {
            if (_executedDirectives.size() < executedDirectivesIndex) {
                // executed directives list has been cleared, just add this
                // directive to the end of the list
                _executedDirectives.add(invoc);
            } else {
                _executedDirectives.add(executedDirectivesIndex, invoc);
            }
            appendToJournal("*" + invoc.toString());
        }
    }

    /**
     * Called when execution of a directive failed.
     * 
     * @param invoc
     *            Command pattern object representing the executed directive
     * @param logInsertionIndex
     *            The in the log at which the directive was recorded.
     */
    public void handleDirectiveExecutionFailed(IntkeyDirectiveInvocation invoc, int logInsertionIndex) {
        // The directive failed so remove the directive call from the log. The
        // use case is a special case. The use directive logic handles appending
        // to
        // the log itself.
        if (!(invoc instanceof UseDirectiveInvocation)) {
            _logCache.remove(logInsertionIndex);
        }
    }

    /**
     * @return the currently loaded dataset
     */
    public synchronized IntkeyDataset getDataset() {
        return _dataset;
    }

    /**
     * Set the value for a character in the current specimen
     * 
     * @param ch
     *            the character
     * @param attribute
     *            the character value
     */
    public synchronized void setSpecimenAttributeForCharacter(au.org.ala.delta.model.Character ch,
            Attribute attribute) {
        Logger.log("Using character");
        _specimen.setAttributeForCharacter(ch, attribute);
    }

    /**
     * Remove the value for a character from the current specimen
     * 
     * @param ch
     */
    public synchronized void removeValueForCharacter(Character ch) {
        Logger.log("Deleting character");
        _specimen.removeValueForCharacter(ch);

        if (_charactersFixed) {
            // Need to manually box the int - otherwise will use the character
            // id as an index.
            _fixedCharactersList.remove(Integer.valueOf(ch.getCharacterId()));
        }
    }

    /**
     * Called at the end of a series of updates to the current specimen to
     * inform the context that all updates have been performed
     */
    public synchronized void specimenUpdateComplete() {
        // the specimen has been updated so the currently cached best characters
        // are no longer
        // valid
        _bestOrSeparateCharacters = null;

        // if the autotolerance (as specified by SET AUTOTOLERANCE directive) is
        // on,
        // reduce the tolerance to the smallest value such that the number of
        // taxa remaining is non-zero.
        if (_autoTolerance) {
            Map<Item, Set<Character>> taxaDifferingCharacters = _specimen.getTaxonDifferences();

            int minDiff = Integer.MAX_VALUE;
            for (Set<Character> differingCharacters : taxaDifferingCharacters.values()) {
                if (differingCharacters.size() < minDiff) {
                    minDiff = differingCharacters.size();
                }

                if (minDiff == 0) {
                    break;
                }
            }

            _tolerance = minDiff;
        }

        updateUI();

        // write remaining number of taxa to the log
        int numAvailableTaxa = getAvailableTaxa().size();
        if (numAvailableTaxa == 1) {
            appendToLog(UIUtils.getResourceString("OneTaxonRemains.log"));
        } else {
            appendToLog(UIUtils.getResourceString("MultipleTaxaRemain.log", numAvailableTaxa));
        }

        // If a taxon has been identified, run commands specified by the DEFINE
        // ENDIDENTIFY directive. Only do this
        // if DISPLAY ENDIDENTIFY is set to ON.
        if (_displayEndIdentify && getAvailableTaxa().size() == 1) {
            executeEndIdentifyCommands();
        }
    }

    /**
     * Add, modify or remove a character keyword
     * 
     * @param keyword
     *            The keyword. Note that the system-defined keywords "all",
     *            "used", "available" and "none" cannot be used.
     * @param characterNumbers
     *            The set of characters to be represented by the keyword. If
     *            empty, the specified keyword will be removed. Otherwise the
     *            keyword will be added, modified to point to the specified
     *            characters
     */
    public synchronized void setCharacterKeyword(String keyword, Set<Integer> characterNumbers) {
        if (_dataset == null) {
            throw new IllegalStateException("Cannot define a character keyword if no dataset loaded");
        }

        keyword = keyword.toLowerCase();
        if (keyword.equals(CHARACTER_KEYWORD_ALL) || keyword.equals(CHARACTER_KEYWORD_USED)
                || keyword.equals(CHARACTER_KEYWORD_AVAILABLE) || keyword.equals(CHARACTER_KEYWORD_NONE)
                || keyword.equals(CHARACTER_KEYWORD_SELECTED)) {
            throw new IllegalArgumentException(UIUtils.getResourceString("RedefineSystemKeyword.error", keyword));
        }

        if (characterNumbers.isEmpty()) {
            _userDefinedCharacterKeywords.remove(keyword);
            appendToLog(UIUtils.getResourceString("KeywordDeleted.log"));
        } else {
            for (int chNum : characterNumbers) {
                if (chNum < 1 || chNum > _dataset.getNumberOfCharacters()) {
                    throw new IllegalArgumentException(String.format("Invalid character number %s", chNum));
                }
            }
            _userDefinedCharacterKeywords.put(keyword, characterNumbers);
        }
    }

    /**
     * Get the list of characters that are represented by the supplied keyword
     * 
     * @param keyword
     *            the keyword.
     * @return the list of characters that are represented by the supplied
     *         keyword
     */
    public synchronized List<au.org.ala.delta.model.Character> getCharactersForKeyword(String keyword) {
        keyword = keyword.toLowerCase();

        if (keyword.equals(CHARACTER_KEYWORD_ALL)) {
            return new ArrayList<au.org.ala.delta.model.Character>(_dataset.getCharactersAsList());
        } else if (keyword.equals(CHARACTER_KEYWORD_USED)) {
            return _specimen.getUsedCharacters();
        } else if (keyword.equals(CHARACTER_KEYWORD_AVAILABLE)) {
            List<au.org.ala.delta.model.Character> availableCharacters = new ArrayList<au.org.ala.delta.model.Character>(
                    _dataset.getCharactersAsList());
            availableCharacters.removeAll(_specimen.getUsedCharacters());
            return availableCharacters;
        } else if (keyword.equals(CHARACTER_KEYWORD_SELECTED)) {
            return _appUI.getSelectedCharacters();
        } else if (keyword.equals(CHARACTER_KEYWORD_NONE)) {
            return Collections.EMPTY_LIST;
        } else {
            Set<Integer> characterNumbersSet = _userDefinedCharacterKeywords.get(keyword.toLowerCase());

            // If there is no exact match for the specified keyword text, try
            // and match a single
            // keyword that begins with the text
            if (characterNumbersSet == null) {
                List<String> matches = new ArrayList<String>();

                if (CHARACTER_KEYWORD_ALL.startsWith(keyword)) {
                    matches.add(CHARACTER_KEYWORD_ALL);
                }

                if (CHARACTER_KEYWORD_ALL.startsWith(keyword)) {
                    matches.add(CHARACTER_KEYWORD_USED);
                }

                if (CHARACTER_KEYWORD_AVAILABLE.startsWith(keyword)) {
                    matches.add(CHARACTER_KEYWORD_AVAILABLE);
                }

                if (CHARACTER_KEYWORD_NONE.startsWith(keyword)) {
                    matches.add(CHARACTER_KEYWORD_NONE);
                }

                for (String savedKeyword : _userDefinedCharacterKeywords.keySet()) {

                    // Ignore case when matching keywords
                    String modifiedKeyword = keyword.toLowerCase();
                    String modifiedSavedKeyword = savedKeyword.toLowerCase();

                    // Ignore whitespace characters
                    modifiedKeyword = modifiedKeyword.replaceAll("\\s", "");
                    modifiedSavedKeyword = modifiedSavedKeyword.replaceAll("\\s", "");

                    // Ignore trailing and leading whitespace
                    modifiedKeyword = modifiedKeyword.trim();
                    modifiedSavedKeyword = modifiedSavedKeyword.trim();

                    if (modifiedSavedKeyword.startsWith(modifiedKeyword)) {
                        matches.add(savedKeyword);
                    }
                }

                if (matches.size() == 0) {
                    throw new IllegalArgumentException("Keyword not found");
                } else if (matches.size() == 1) {
                    return getCharactersForKeyword(matches.get(0));
                } else {
                    throw new IllegalArgumentException("Keyword ambiguous");
                }
            } else {
                List<au.org.ala.delta.model.Character> retList = new ArrayList<au.org.ala.delta.model.Character>();
                for (int charNum : characterNumbersSet) {
                    retList.add(_dataset.getCharacter(charNum));
                }
                Collections.sort(retList);
                return retList;
            }
        }
    }

    /**
     * @return A list of all character keywords. Includes the system defined
     *         keyords "all" and "available", as well as "used" if any
     *         characters have been used.
     */
    public synchronized List<String> getCharacterKeywords() {
        List<String> retList = new ArrayList<String>();
        retList.add(CHARACTER_KEYWORD_ALL);

        // Include "Used" keyword if there are any used characters
        if (_specimen.getUsedCharacters().size() > 0) {
            retList.add(CHARACTER_KEYWORD_USED);
        }

        // Include "selected" keyword if there are any selected characters
        if (_appUI != null && !_appUI.getSelectedCharacters().isEmpty()) {
            retList.add(CHARACTER_KEYWORD_SELECTED);
        }

        retList.add(CHARACTER_KEYWORD_AVAILABLE);
        retList.add(CHARACTER_KEYWORD_NONE);
        retList.addAll(_userDefinedCharacterKeywords.keySet());

        return retList;
    }

    /**
     * Add, modify or remove a taxa keyword
     * 
     * @param keyword
     *            The keyword. Note that the system-defined keywords "all",
     *            "eliminated", "remaining", "selected", "none" and "specimen"
     *            cannot be used.
     * @param taxaNumbers
     *            The set of taxa to be represented by the keyword. If empty,
     *            the specified keyword will be removed. Otherwise the keyword
     *            will be added, modified to point to the specified taxa
     */
    public synchronized void setTaxaKeyword(String keyword, Set<Integer> taxaNumbers) {
        if (_dataset == null) {
            throw new IllegalStateException("Cannot define a taxa keyword if no dataset loaded");
        }

        keyword = keyword.toLowerCase();
        if (keyword.equals(TAXON_KEYWORD_ALL) || keyword.equals(TAXON_KEYWORD_ELIMINATED)
                || keyword.equals(TAXON_KEYWORD_REMAINING) || keyword.equals(TAXON_KEYWORD_SELECTED)
                || keyword.equals(TAXON_KEYWORD_NONE) || keyword.equals(SPECIMEN_KEYWORD)) {
            throw new IllegalArgumentException(UIUtils.getResourceString("RedefineSystemKeyword.error", keyword));
        }

        for (int taxonNum : taxaNumbers) {
            if (taxonNum < 1 || taxonNum > _dataset.getNumberOfTaxa()) {
                throw new IllegalArgumentException(String.format("Invalid taxon number %s", taxonNum));
            }
        }

        _userDefinedTaxonKeywords.put(keyword, taxaNumbers);
    }

    /**
     * Get the list of taxa that are represented by the supplied keyword
     * 
     * @param keyword
     *            the keyword.
     * @return the list of taxa that are represented by the supplied keyword
     */
    public synchronized List<Item> getTaxaForKeyword(String keyword) {
        List<Item> retList = new ArrayList<Item>();

        keyword = keyword.toLowerCase();

        if (keyword.equals(TAXON_KEYWORD_ALL)) {
            return _dataset.getItemsAsList();
        } else if (keyword.equals(TAXON_KEYWORD_ELIMINATED)) {
            return getEliminatedTaxa();
        } else if (keyword.equals(TAXON_KEYWORD_REMAINING)) {
            return getAvailableTaxa();
        } else if (keyword.equals(TAXON_KEYWORD_SELECTED)) {
            return _appUI.getSelectedTaxa();
        } else if (keyword.equals(TAXON_KEYWORD_NONE)) {
            return Collections.EMPTY_LIST;
        } else {
            Set<Integer> taxaNumbersSet = _userDefinedTaxonKeywords.get(keyword);

            // If there is no exact match for the specified keyword text, try
            // and match a single
            // keyword that begins with the text
            if (taxaNumbersSet == null) {
                List<String> matches = new ArrayList<String>();

                if (TAXON_KEYWORD_ALL.startsWith(keyword)) {
                    matches.add(TAXON_KEYWORD_ALL);
                }

                if (TAXON_KEYWORD_ELIMINATED.startsWith(keyword)) {
                    matches.add(TAXON_KEYWORD_ELIMINATED);
                }

                if (TAXON_KEYWORD_REMAINING.startsWith(keyword)) {
                    matches.add(TAXON_KEYWORD_REMAINING);
                }

                if (TAXON_KEYWORD_SELECTED.startsWith(keyword)) {
                    matches.add(TAXON_KEYWORD_SELECTED);
                }

                if (TAXON_KEYWORD_NONE.startsWith(keyword)) {
                    matches.add(TAXON_KEYWORD_NONE);
                }

                for (String savedKeyword : _userDefinedTaxonKeywords.keySet()) {
                    // Ignore case when matching keywords
                    String modifiedKeyword = keyword.toLowerCase();
                    String modifiedSavedKeyword = savedKeyword.toLowerCase();

                    // Ignore whitespace characters
                    modifiedKeyword = modifiedKeyword.replaceAll("\\s", "");
                    modifiedSavedKeyword = modifiedSavedKeyword.replaceAll("\\s", "");

                    // Ignore trailing and leading whitespace
                    modifiedKeyword = modifiedKeyword.trim();
                    modifiedSavedKeyword = modifiedSavedKeyword.trim();

                    if (modifiedSavedKeyword.startsWith(modifiedKeyword)) {
                        matches.add(savedKeyword);
                    }
                }

                if (matches.size() == 0) {
                    throw new IllegalArgumentException("keyword not found");
                } else if (matches.size() == 1) {
                    return getTaxaForKeyword(matches.get(0));
                } else {
                    throw new IllegalArgumentException("keyword ambiguous");
                }
            } else {
                for (int taxonNumber : taxaNumbersSet) {
                    retList.add(_dataset.getItem(taxonNumber));
                }
            }
        }

        Collections.sort(retList);

        return retList;
    }

    /**
     * Return a list of valid taxon keywords
     * 
     * @param includeSpecimen
     *            if true, include the specimen keyword in the list if the
     *            current specimen is not empty i.e. characters have been used
     * @return A list of valid taxon keywords. The specimen keyword is included
     *         if applicable.
     */
    public synchronized List<String> getTaxaKeywords(boolean includeSpecimen) {
        List<String> retList = new ArrayList<String>();
        retList.add(TAXON_KEYWORD_ALL);

        Map<Item, Set<Character>> taxonDifferingCharacters = _specimen.getTaxonDifferences();

        int remainingTaxaCount = 0;

        if (taxonDifferingCharacters == null) {
            remainingTaxaCount = _dataset.getNumberOfTaxa();
        } else {
            for (Item taxon : taxonDifferingCharacters.keySet()) {
                int diffCount = taxonDifferingCharacters.get(taxon).size();
                if (diffCount <= _tolerance) {
                    remainingTaxaCount++;
                }
            }
        }

        // Include "eliminated" keyword if taxa have been eliminated
        if (remainingTaxaCount < _dataset.getNumberOfTaxa()) {
            retList.add(TAXON_KEYWORD_ELIMINATED);
        }

        // Include "remaining" keyword if there are remaining taxa
        if (remainingTaxaCount > 0) {
            retList.add(TAXON_KEYWORD_REMAINING);
        }

        // Include "selected" keyword if there are selected taxa
        if (_appUI != null && !_appUI.getSelectedTaxa().isEmpty()) {
            retList.add(TAXON_KEYWORD_SELECTED);
        }

        retList.add(TAXON_KEYWORD_NONE);

        // Include the "specimen" keyword if desired, and the specimen is not
        // empty.
        if (includeSpecimen && !_specimen.getUsedCharacters().isEmpty()) {
            retList.add(SPECIMEN_KEYWORD);
        }

        retList.addAll(_userDefinedTaxonKeywords.keySet());

        return retList;
    }

    /**
     * @return A list of command pattern objects representing all directives
     *         that have been executed
     */
    public synchronized List<IntkeyDirectiveInvocation> getExecutedDirectives() {
        return new ArrayList<IntkeyDirectiveInvocation>(_executedDirectives);
    }

    /**
     * Resets the context state to prepare for a new identification
     */
    public synchronized void restartIdentification() {

        if (_dataset != null) {

            Specimen oldSpecimen = _specimen;

            // Create a new blank specimen
            _specimen = new Specimen(_dataset, false, _matchInapplicables, _matchUnknowns, _matchType);

            // Any character values that have been fixed need to be copied into
            // the new specimen
            if (_charactersFixed) {
                for (int characterNumber : _fixedCharactersList) {
                    Character ch = _dataset.getCharacter(characterNumber);
                    _specimen.setAttributeForCharacter(ch, oldSpecimen.getAttributeForCharacter(ch));
                }
            }

            // As we are starting from the beginning, best characters must be
            // cleared as they are no longer valid
            _bestOrSeparateCharacters = null;

            // If demonstration mode is on, need to revert back to the values of
            // SET, DISPLAY and INCLUDE directives
            // that were saved when demonstration mode enabled.
            if (_demonstrationMode) {
                _demonstrationModeSettings.loadIntoContext(this);
            }

            _appUI.handleIdentificationRestarted();
        }
    }

    /**
     * @return The current specimen
     */
    public synchronized Specimen getSpecimen() {
        return _specimen;
    }

    /**
     * @return true if an input file is current being processed
     */
    public synchronized boolean isProcessingDirectivesFile() {
        return _processingDirectivesFile;
    }

    /**
     * FOR UNIT TESTING ONLY
     * 
     * @param processing
     *            if true, a directives file is currently being processed.
     */
    public synchronized void setProcessingDirectivesFile(boolean processing) {
        _processingDirectivesFile = processing;
    }

    /**
     * @return The current error tolerance. This is used when determining which
     *         taxa to eliminate following characters being used.
     */
    public synchronized int getTolerance() {
        return _tolerance;
    }

    /**
     * Set the current error tolerance. This is used when determining which taxa
     * to eliminate following characters being used.
     * 
     * @param toleranceValue
     */
    public synchronized void setTolerance(int toleranceValue) {
        _tolerance = toleranceValue;

        // best characters need to be recalculated when the error tolerance is
        // changed.
        _bestOrSeparateCharacters = null;

        if (_dataset != null) {
            updateUI();
        }

        appendToLog(UIUtils.getResourceString("ErrorToleranceSet.log", _tolerance));
    }

    /**
     * @return The current vary weight. This is used by the BEST algorithm
     */
    public synchronized double getVaryWeight() {
        return _varyWeight;
    }

    /**
     * Set the current vary weight. This is used by the BEST algorithm.
     * 
     * @param varyWeight
     *            The current vary weight
     */
    public synchronized void setVaryWeight(double varyWeight) {
        if (varyWeight >= 0.0 && varyWeight <= 1.0) {
            _varyWeight = varyWeight;
        } else {
            throw new IllegalArgumentException("VaryWt must be a double value in range 0-1");
        }
    }

    /**
     * Gets the rbase - the base of the logarithmic character-reliability scale,
     * which is used in determining the BEST characters during an identification
     * 
     * @return the current rbase value
     */
    public synchronized double getRBase() {
        return _rbase;
    }

    /**
     * Sets the rbase - the base of the logarithmic character-reliability scale,
     * which is used in determining the BEST characters during an identification
     * 
     * @param rbase
     *            the current rbase value
     */
    public synchronized void setRBase(double rbase) {
        if (rbase >= 1.0 && rbase <= 5.0) {
            _rbase = rbase;
        } else {
            throw new IllegalArgumentException("RBase must be a double value in range 1-5");
        }

    }

    /**
     * @return The required level for diagnostic descriptions
     */
    public synchronized int getDiagLevel() {
        return _diagLevel;
    }

    /**
     * Sets the required level of diagnostic descriptions.
     * 
     * @param diagLevel
     */
    public synchronized void setDiagLevel(int diagLevel) {
        if (diagLevel >= 1) {
            this._diagLevel = diagLevel;
        } else {
            throw new IllegalArgumentException("DiagLevel must be an integer greater than one");
        }
    }

    /**
     * @return a reference to the current taxa file, or null if one has not been
     *         set
     */
    public synchronized File getTaxaFile() {
        return _taxaFile;
    }

    /**
     * @return a reference to the current characters file, or null if one has
     *         not been set
     */
    public synchronized File getCharactersFile() {
        return _charactersFile;
    }

    /**
     * @return An .ink file used to load a new dataset. May be a jnlp style file
     *         specifying where data is to be downloaded from. Or may be a
     *         directives file that is run to load the dataset in intkey,
     *         initialize values etc. In the latter case, this value will be the
     *         same as _initializationFile (see below).
     * 
     *         If _datasetStartupURL points to remote content (i.e. it is not a
     *         file URL), this will be a temporary file used to save the remote
     *         content referenced by the URL.
     * 
     *         If no dataset has been loaded, NULL will be returned
     * 
     */
    public synchronized File getDatasetStartupFile() {
        return _datasetStartupFile;
    }

    /**
     * @return A URL pointing to the dataset startup file. If the dataset is
     *         located on disk, this will be a file URL. NULL will be returned
     *         if no dataset has been loaded
     */
    public synchronized URL getDatasetStartupURL() {
        return _datasetStartupURL;
    }

    /**
     * @return The directory in which the currently loaded dataset is located,
     *         or NULL is no dataset has been loaded
     */
    public synchronized File getDatasetDirectory() {
        if (_initializationFile != null) {
            return _initializationFile.getParentFile();
        } else if (_charactersFile != null) {
            return _charactersFile.getParentFile();
        } else if (_taxaFile != null) {
            return _taxaFile.getParentFile();
        } else {
            return null;
        }
    }

    /**
     * @return The current character order being used to list available
     *         characters in the application
     */
    public synchronized IntkeyCharacterOrder getCharacterOrder() {
        return _characterOrder;
    }

    /**
     * Use BEST character ordering
     */
    public synchronized void setCharacterOrderBest() {
        this._characterOrder = IntkeyCharacterOrder.BEST;
        this._taxonToSeparate = -1;
        _bestOrSeparateCharacters = null;
        if (_dataset != null) {
            updateUI();
        }
    }

    /**
     * Use NATURAL character ordering
     */
    public synchronized void setCharacterOrderNatural() {
        this._characterOrder = IntkeyCharacterOrder.NATURAL;
        this._taxonToSeparate = -1;
        if (_dataset != null) {
            updateUI();
        }
    }

    /**
     * Use SEPARATE character ordering to separate the specified taxon
     * 
     * @param taxonToSeparate
     *            the number of the taxon to separate
     */
    public synchronized void setCharacterOrderSeparate(int taxonToSeparate) {
        this._characterOrder = IntkeyCharacterOrder.SEPARATE;
        this._taxonToSeparate = taxonToSeparate;
        _bestOrSeparateCharacters = null;
        if (_dataset != null) {
            updateUI();
        }
    }

    /**
     * @return The number of the taxon currently being separated using SEPARATE
     *         character ordering
     */
    public synchronized int getTaxonToSeparate() {
        return _taxonToSeparate;
    }

    /**
     * @return The current best characters if they are cached
     */
    public synchronized LinkedHashMap<Character, Double> getBestOrSeparateCharacters() {
        return _bestOrSeparateCharacters;
    }

    /**
     * Clear the cached best characters. Used to force the UI to recalculate the
     * best characters next time it needs them
     */
    public synchronized void clearBestOrSeparateCharacters() {
        _bestOrSeparateCharacters = null;
    }

    /**
     * Calculates the best characters using the BEST algorithm. This method will
     * block the calling thread while the calculation is performed
     */
    public synchronized void calculateBestOrSeparateCharacters() {
        List<Integer> characterNumbers = new ArrayList<Integer>();
        List<Integer> taxonNumbers = new ArrayList<Integer>();

        List<Character> availableCharacters = getAvailableCharacters();
        availableCharacters.removeAll(_dataset.getCharactersToIgnoreForBest());

        for (Character ch : availableCharacters) {
            characterNumbers.add(ch.getCharacterId());
        }

        for (Item taxon : getAvailableTaxa()) {
            taxonNumbers.add(taxon.getItemNumber());
        }

        if (_characterOrder == IntkeyCharacterOrder.BEST) {
            _bestOrSeparateCharacters = Best.orderBest(_dataset, characterNumbers, taxonNumbers, _rbase,
                    _varyWeight);
        } else if (_characterOrder == IntkeyCharacterOrder.SEPARATE) {
            _bestOrSeparateCharacters = Best.orderSeparate(_taxonToSeparate, _dataset, characterNumbers,
                    taxonNumbers, _rbase, _varyWeight);
        }
    }

    /**
     * @return true if inapplicable should match any value when comparing
     *         character values when processing the USE, DIFFERENCES or
     *         SIMILARITIES directives
     */
    public synchronized boolean getMatchInapplicables() {
        return _matchInapplicables;
    }

    /**
     * @return true if inapplicable should match any value when comparing
     *         character values when processing the USE, DIFFERENCES or
     *         SIMILARITIES directives
     */
    public synchronized boolean getMatchUnknowns() {
        return _matchUnknowns;
    }

    /**
     * @return The match type - one of overlap (two sets of values match if they
     *         overlap), subset (two sets of values match if one is a subset of
     *         the other) or exact (two sets of values match only if they are
     *         identical) - to use when comparing character values when
     *         processing the USE, DIFFERENCES or SIMILARITIES directives
     */
    public synchronized MatchType getMatchType() {
        return _matchType;
    }

    /**
     * Set the match settings - these specifiy which character values are to be
     * regarded as equal when processing the USE, DIFFERENCES or SIMILARITIES
     * directives
     * 
     * @param matchUnknowns
     *            if true, unknown should match any value
     * @param matchInapplicables
     *            if true, inapplicable should match any value
     * @param matchType
     *            the match type - one of overlap (two sets of values match if
     *            they overlap), subset (two sets of values match if one is a
     *            subset of the other) or exact (two sets of values match only
     *            if they are identical)
     */
    public synchronized void setMatchSettings(boolean matchUnknowns, boolean matchInapplicables,
            MatchType matchType) {
        _matchType = matchType;

        // A match type of EXACT implies that inapplicables and unknowns should
        // not be matched.
        if (_matchType == MatchType.EXACT) {
            _matchInapplicables = false;
            _matchUnknowns = false;
        } else {
            _matchUnknowns = matchUnknowns;
            _matchInapplicables = matchInapplicables;
        }

        updateSpecimenMatchSettings();

        // Write a log message informing if the new match setting.
        List<String> matchSettingWords = new ArrayList<String>();
        if (matchInapplicables) {
            matchSettingWords.add(SetMatchDirective.INAPPLICABLES_WORD);
        }

        if (matchUnknowns) {
            matchSettingWords.add(SetMatchDirective.UNKNOWNS_WORD);
        }

        matchSettingWords.add(matchType.toString().toLowerCase());

        appendToLog(UIUtils.getResourceString("SetMatch.log", StringUtils.join(matchSettingWords, ", ")));
    }

    /**
     * Update the specimen with new match settings. This needs to be called each
     * time the match settings are updated so that the eliminated taxa can be
     * recalculated given the new match settings.
     */
    private void updateSpecimenMatchSettings() {
        if (_dataset != null) {
            Specimen newSpecimen = new Specimen(_dataset, false, _matchInapplicables, _matchUnknowns, _matchType,
                    _specimen);
            _specimen = newSpecimen;
            updateUI();
        }
    }

    /**
     * @return The diagType. This specifies whether DIAGNOSE will generate
     *         specimen- or taxon-diagnostic descriptions. A specimen-diagnostic
     *         description must be able to distinguish any specimen belonging to
     *         the diagnosed taxon from any specimen belonging to any other
     *         taxon. A taxon-diagnostic description must be able to distinguish
     *         the diagnosed taxon from any other taxon. When generating
     *         specimen-diagnostic descriptions, the program will not include
     *         characters which may be inapplicable to specimens in the
     *         diagnosed taxon, because, for some specimens, such characters
     *         cannot contribute to the desired separation. The default is
     *         SPECIMENS.
     */
    public synchronized DiagType getDiagType() {
        return _diagType;
    }

    /**
     * Sets the diag type. The diagType Specifies whether DIAGNOSE will generate
     * specimen- or taxon-diagnostic descriptions. A specimen-diagnostic
     * description must be able to distinguish any specimen belonging to the
     * diagnosed taxon from any specimen belonging to any other taxon. A
     * taxon-diagnostic description must be able to distinguish the diagnosed
     * taxon from any other taxon. When generating specimen-diagnostic
     * descriptions, the program will not include characters which may be
     * inapplicable to specimens in the diagnosed taxon, because, for some
     * specimens, such characters cannot contribute to the desired separation.
     * The default is SPECIMENS.
     * 
     * @param diagType
     *            The diag type
     */
    public synchronized void setDiagType(DiagType diagType) {
        this._diagType = diagType;

        appendToLog(UIUtils.getResourceString("DiagtypeSet.log", diagType.toString()));
    }

    /**
     * @return included characters ordered by character number
     */
    public synchronized List<Character> getIncludedCharacters() {
        List<Character> retList = new ArrayList<Character>();

        for (int charNum : _includedCharacters) {
            retList.add(_dataset.getCharacter(charNum));
        }

        Collections.sort(retList);

        return retList;
    }

    /**
     * @return excluded characters ordered by character number
     */
    public synchronized List<Character> getExcludedCharacters() {
        List<Character> excludedCharacters = _dataset.getCharactersAsList();
        excludedCharacters.removeAll(getIncludedCharacters());
        return excludedCharacters;
    }

    /**
     * @return included taxa ordered by taxon number
     */
    public synchronized List<Item> getIncludedTaxa() {
        List<Item> retList = new ArrayList<Item>();

        for (int charNum : _includedTaxa) {
            retList.add(_dataset.getItem(charNum));
        }

        Collections.sort(retList);

        return retList;
    }

    /**
     * @return excluded taxa ordered by taxon number
     */
    public synchronized List<Item> getExcludedTaxa() {
        List<Item> excludedTaxa = _dataset.getItemsAsList();
        excludedTaxa.removeAll(getIncludedTaxa());
        return excludedTaxa;
    }

    /**
     * Set what characters are included
     * 
     * @param includedCharacters
     *            the set of characters that are included
     */
    public synchronized void setIncludedCharacters(Set<Integer> includedCharacters) {
        doSetIncludedCharacters(includedCharacters);

        if (_includedCharacters.size() == 1) {
            appendToLog(UIUtils.getResourceString("OneCharacterIncluded.log"));
        } else {
            appendToLog(UIUtils.getResourceString("MultipleCharactersIncluded.log", _includedCharacters.size()));
        }
    }

    /**
     * Helper method for setIncludedCharacters() and setExcludedCharacters()
     * 
     * @param includedCharacters
     *            the set of included characters
     */
    private synchronized void doSetIncludedCharacters(Set<Integer> includedCharacters) {
        if (includedCharacters == null || includedCharacters.isEmpty()) {
            throw new IllegalArgumentException("Cannot exclude all characters");
        }

        // defensive copy
        _includedCharacters = new HashSet<Integer>(includedCharacters);

        // best characters need to be recalculated to account for characters
        // that
        // have been included/excluded
        _bestOrSeparateCharacters = null;

        if (_dataset != null) {
            updateUI();
        }
    }

    /**
     * Set what taxa are included
     * 
     * @param includedTaxa
     *            the set of included taxa
     */
    public synchronized void setIncludedTaxa(Set<Integer> includedTaxa) {
        doSetIncludedTaxa(includedTaxa);

        if (_includedTaxa.size() == 1) {
            appendToLog(UIUtils.getResourceString("OneTaxonIncluded.log"));
        } else {
            appendToLog(UIUtils.getResourceString("MultipleTaxaIncluded.log", _includedTaxa.size()));
        }
    }

    /**
     * Helper method for setIncludedTaxa() and setExcludedTaxa()
     * 
     * @param includedTaxa
     */
    private synchronized void doSetIncludedTaxa(Set<Integer> includedTaxa) {
        if (includedTaxa == null || includedTaxa.isEmpty()) {
            throw new IllegalArgumentException("Cannot exclude all taxa");
        }

        // defensive copy
        _includedTaxa = new HashSet<Integer>(includedTaxa);

        // best characters need to be recalculated to account for taxa that
        // have been included/excluded
        _bestOrSeparateCharacters = null;

        if (_dataset != null) {
            updateUI();
        }
    }

    /**
     * Set what characters are excluded. All characters aside from those
     * specified will be included.
     * 
     * @param excludedCharacters
     *            The set of excluded characters.
     */
    public synchronized void setExcludedCharacters(Set<Integer> excludedCharacters) {
        Set<Integer> includedCharacters = new HashSet<Integer>();
        for (int i = 1; i < _dataset.getNumberOfCharacters() + 1; i++) {
            includedCharacters.add(i);
        }

        includedCharacters.removeAll(excludedCharacters);

        doSetIncludedCharacters(includedCharacters);

        if (excludedCharacters.size() == 1) {
            appendToLog(UIUtils.getResourceString("OneCharacterExcluded.log"));
        } else {
            appendToLog(UIUtils.getResourceString("MultipleCharactersExcluded.log", excludedCharacters.size()));
        }
    }

    /**
     * Set what characters are excluded. All characters aside from those
     * specified will be included.
     * 
     * @param excludedCharacters
     *            The set of excluded characters.
     */
    public synchronized void setExcludedTaxa(Set<Integer> excludedTaxa) {
        Set<Integer> includedTaxa = new HashSet<Integer>();
        for (int i = 1; i < _dataset.getNumberOfTaxa() + 1; i++) {
            includedTaxa.add(i);
        }

        includedTaxa.removeAll(excludedTaxa);

        doSetIncludedTaxa(includedTaxa);

        if (excludedTaxa.size() == 1) {
            appendToLog(UIUtils.getResourceString("OneTaxonExcluded.log"));
        } else {
            appendToLog(UIUtils.getResourceString("MultipleTaxaExcluded.log", excludedTaxa.size()));
        }
    }

    /**
     * @return The currently included characters minus the characters that have
     *         values set in the specimen. Ordered by character number.
     */
    public synchronized List<Character> getAvailableCharacters() {
        List<Character> retList = getIncludedCharacters();

        // Used characters are not available
        retList.removeAll(_specimen.getUsedCharacters());

        // Neither are characters that have been made inapplicable
        retList.removeAll(_specimen.getInapplicableCharacters());

        return retList;
    }

    /**
     * @return The list of characters that have values set in the specimen.
     *         Ordered by character number
     */
    public synchronized List<Character> getUsedCharacters() {
        return _specimen.getUsedCharacters();
    }

    /**
     * @return The list of included taxa, minus the characters that have been
     *         eliminated from the current investigation. Ordered by taxon
     *         number.
     */
    public synchronized List<Item> getAvailableTaxa() {
        List<Item> availableTaxa = getIncludedTaxa();
        availableTaxa.removeAll(getEliminatedTaxa());
        return availableTaxa;
    }

    /**
     * @return The list of taxa that have been eliminated from the current
     *         investigation. Ordered by taxon number.
     */
    public synchronized List<Item> getEliminatedTaxa() {
        Map<Item, Set<Character>> taxaDifferingCharacters = _specimen.getTaxonDifferences();

        List<Item> includedTaxa = getIncludedTaxa();
        List<Item> eliminatedTaxa = new ArrayList<Item>();

        if (taxaDifferingCharacters != null) {
            for (Item taxon : includedTaxa) {
                if (taxaDifferingCharacters.containsKey(taxon)) {

                    // A taxon is eliminated if:
                    // 1. Its value for a character specifed as an "exact"
                    // character does not match
                    // the value set in the specimen
                    // 2. The total number of characters for the taxon whose
                    // values differ to the specimen
                    // is greater than the tolerance setting.
                    Set<Character> taxonDifferingCharacters = taxaDifferingCharacters.get(taxon);

                    boolean nonMatchingExactCharacter = false;
                    for (Character ch : taxonDifferingCharacters) {
                        if (isCharacterExact(ch)) {
                            nonMatchingExactCharacter = true;
                            break;
                        }
                    }

                    if (nonMatchingExactCharacter) {
                        eliminatedTaxa.add(taxon);
                    } else {
                        int diffCount = taxaDifferingCharacters.get(taxon).size();
                        if (diffCount > _tolerance) {
                            eliminatedTaxa.add(taxon);
                        }
                    }
                }
            }
        }

        return eliminatedTaxa;
    }

    /**
     * @return An instance of ImageSettings which contains the default settings
     *         to use when displaying images and image overlays.
     */
    public synchronized ImageSettings getImageSettings() {
        ImageSettings imageSettings = new ImageSettings();

        List<FontInfo> overlayFonts = _dataset.getOverlayFonts();

        if (overlayFonts.size() > 0) {
            FontInfo defaultOverlayFontInfo = overlayFonts.get(0);
            imageSettings.setDefaultFontInfo(defaultOverlayFontInfo);
        }

        if (overlayFonts.size() > 1) {
            FontInfo buttonOverlayFontInfo = overlayFonts.get(1);
            imageSettings.setDefaultButtonFontInfo(buttonOverlayFontInfo);
        }

        if (overlayFonts.size() > 2) {
            FontInfo featureOverlayFontInfo = overlayFonts.get(2);
            imageSettings.setDefaultFeatureFontInfo(featureOverlayFontInfo);
        }

        imageSettings.setDataSetPath(getDatasetDirectory().getAbsolutePath());

        imageSettings.setResourcePaths(_imagePathLocations);

        imageSettings.setDatasetName(_dataset.getHeading());

        imageSettings.setCacheDirectory(_fileCacheDirectory);

        return imageSettings;
    }

    public synchronized ResourceSettings getInfoSettings() {
        ResourceSettings infoSettings = new ResourceSettings();

        infoSettings.setDataSetPath(getDatasetDirectory().getAbsolutePath());

        infoSettings.setResourcePaths(_infoPathLocations);

        infoSettings.setCacheDirectory(_fileCacheDirectory);

        return infoSettings;
    }

    /**
     * This method is only used for saving settings when SET DEMONSTRATION is
     * set to ON.
     * 
     * @return The list of locations to search for images.
     */
    synchronized List<String> getImagePaths() {
        return new ArrayList<String>(_imagePathLocations);
    }

    /**
     * Sets locations which will be searched for images (characters, taxa,
     * keywords or startup images).
     * 
     * @param imagePaths
     *            The list of locations to search for images. These may be
     *            either file paths (relative to the dataset directory if not
     *            absolute) or urls for remote locations.
     */
    public synchronized void setImagePaths(List<String> imagePaths) {
        _imagePathLocations = new ArrayList<String>(imagePaths);

        appendToLog(UIUtils.getResourceString("ImagepathSet.log", StringUtils.join(imagePaths, ";")));
    }

    /**
     * This method is only used for saving settings when SET DEMONSTRATION is
     * set to ON.
     * 
     * @return The list of locations to search for information files.
     */
    synchronized List<String> getInfoPaths() {
        return new ArrayList<String>(_infoPathLocations);
    }

    /**
     * Sets locations which will be searched for files listed in the taxon
     * information dialog (accessed via the INFORMATION directive).
     * 
     * @param infoPaths
     *            The list of locations to search for information files. These
     *            may be either file paths (relative to the dataset directory if
     *            not absolute) or urls for remote locations.
     */
    public synchronized void setInfoPaths(List<String> infoPaths) {
        _infoPathLocations = new ArrayList<String>(infoPaths);
        appendToLog(UIUtils.getResourceString("InfopathSet.log", StringUtils.join(infoPaths, ";")));
    }

    /**
     * Makes a command available in the taxon information dialog.
     * 
     * @param subject
     *            The subject under which the command should be listed in the
     *            taxon information dialog
     * @param command
     *            The directive command to run when the associated subject is
     *            selected in the taxon information dialog. This may include the
     *            special parameter "?S" which will be substituted for the
     *            number of the taxon being examined in the taxon information
     *            dialog.
     */
    public synchronized void addTaxonInformationDialogCommand(String subject, String command) {
        _taxonInformationDialogCommands.add(new Pair<String, String>(subject, command));
    }

    /**
     * @return The subject/command pairs that are listed in the taxon
     *         information dialog. The command text may include the special
     *         parameter "?S" which will be substituted for the number of the
     *         taxon being examined in the dialog when the command is run.
     */
    public synchronized List<Pair<String, String>> getTaxonInformationDialogCommands() {
        return new ArrayList<Pair<String, String>>(_taxonInformationDialogCommands);
    }

    /**
     * Called before a new dataset is loaded
     */
    private void cleanupOldDataset() {
        // need to do this first, as the UI may want to access the
        // _startupFileData as part of its cleanup process
        _appUI.handleDatasetClosed();

        if (_dataset != null) {
            _dataset.close();
        }

        if (_startupFileData != null) {
            try {
                FileUtils.deleteDirectory(_startupFileData.getDataFileLocalCopy().getParentFile());
                _startupFileData = null;
            } catch (IOException ex) {
                // do nothing, as we are closing the dataset there is not a lot
                // we can
                // do. The worst that can
                // happen is that files get left in the temporary folder.
            }
        }
    }

    /**
     * Called prior to application shutdown.
     */
    public synchronized void cleanupForShutdown() {
        cleanupOldDataset();
        if (_logPrintFile != null) {
            _logPrintFile.close();
        }

        if (_journalPrintFile != null) {
            _journalPrintFile.close();
        }

        if (_currentOutputPrintFile != null) {
            _currentOutputPrintFile.close();
        }
    }

    /**
     * @return The IntkeyUI
     */
    public synchronized IntkeyUI getUI() {
        return _appUI;
    }

    /**
     * 
     * @return The data contained within the JNLP-style startup file for the
     *         current dataset, if the dataset was loaded using such a file.
     *         Otherwise the method returns null.
     */
    public synchronized StartupFileData getStartupFileData() {
        return _startupFileData;
    }

    /**
     * @return The directive populator
     */
    public synchronized DirectivePopulator getDirectivePopulator() {
        return _directivePopulator;
    }

    /**
     * 
     * @return true if the values (attributes) set for characters in the
     *         specimen are fixed such that they are not cleared by the RESTART
     *         command. In addition, the values set for these characters cannot
     *         be changed. Used by the SET FIX directive.
     */
    public synchronized boolean charactersFixed() {
        return _charactersFixed;
    }

    /**
     * Used by the SET FIX directive. Sets whether or not character values
     * (attributes) in the specimen are fixed.
     * 
     * @param charactersFixed
     *            If true, the values (attributes) set for characters in the
     *            specimen will fixed such that they are not cleared by the
     *            RESTART command. In addition, the values set for these
     *            characters will be unable to be changed.
     */
    public synchronized void setCharactersFixed(boolean charactersFixed) {
        if (charactersFixed != this._charactersFixed) {
            this._charactersFixed = charactersFixed;
            if (_charactersFixed) {
                _fixedCharactersList = new ArrayList<Integer>();
                for (Character usedCharacter : getUsedCharacters()) {
                    _fixedCharactersList.add(usedCharacter.getCharacterId());
                }
                appendToLog(UIUtils.getResourceString("CharactersFixed.log"));
            } else {
                appendToLog(UIUtils.getResourceString("FixOff.log"));
                _fixedCharactersList = new ArrayList<Integer>();
                // If in demonstration mode, need to temporarily disable it,
                // otherwise
                // fix mode back be immediately turned back on by restarting the
                // identification.
                if (_demonstrationMode) {
                    _demonstrationMode = false;
                    restartIdentification();
                    _demonstrationMode = true;
                } else {
                    restartIdentification();
                }
            }
        }
    }

    /**
     * @return A list of character ids for the characters that have been fixed,
     *         or null if characters have not been fixed.
     */
    public synchronized List<Integer> getFixedCharactersList() {
        return _fixedCharactersList;
    }

    /**
     * Take the supplied attributes, set them in the specimen, then set the
     * corresponding characters as being fixed.
     * 
     * @param attrs
     *            The list of attributes. These should be in the order of use in
     *            the specimen - i.e. attributes for any controlling characters
     *            should appear in the list before their dependent characters
     */
    synchronized void setFixedCharactersFromAttributes(List<Attribute> attrs) {
        this._charactersFixed = true;
        _fixedCharactersList.clear();
        for (Attribute attr : attrs) {
            _specimen.setAttributeForCharacter(attr.getCharacter(), attr);
            _fixedCharactersList.add(attr.getCharacter().getCharacterId());
        }
    }

    /**
     * @return true if the TOLERANCE is decreased automatically by the program
     *         to the smallest value such that the number of taxa remaining is
     *         non-zero; the value is never automatically increased. The default
     *         is ON in Normal mode, and OFF in Advanced mode.
     */
    public synchronized boolean isAutoTolerance() {
        return _autoTolerance;
    }

    /**
     * Sets auto tolerance on or off.
     * 
     * @param autoTolerance
     *            if true then the TOLERANCE is decreased automatically by the
     *            program to the smallest value such that the number of taxa
     *            remaining is non-zero; the value is never automatically
     *            increased. The default is ON in Normal mode, and OFF in
     *            Advanced mode.
     */
    public synchronized void setAutoTolerance(boolean autoTolerance) {
        this._autoTolerance = autoTolerance;
    }

    /**
     * When DEMONSTRATION mode is ON, the RESTART command restores the SET,
     * DISPLAY, and INCLUDE parameters to the values they had when demonstration
     * mode was turned on, or the values associated with the selected operation
     * mode (automatic or manual). When DEMONSTRATION is turned ON from an input
     * command file it cannot subsequently be turned OFF except from another
     * input file. This option is intended for running unattended demonstrations
     * of the program. The default is OFF.
     * 
     * @return true if demonstration mode is set to ON.
     */
    public synchronized boolean isDemonstrationMode() {
        return _demonstrationMode;
    }

    /**
     * Sets demonstration mode on or off. When DEMONSTRATION mode is ON, the
     * RESTART command restores the SET, DISPLAY, and INCLUDE parameters to the
     * values they had when demonstration mode was turned on, or the values
     * associated with the selected operation mode (automatic or manual). When
     * DEMONSTRATION is turned ON from an input command file it cannot
     * subsequently be turned OFF except from another input file. This option is
     * intended for running unattended demonstrations of the program. The
     * default is OFF.
     * 
     * @param demonstrationMode
     */
    public synchronized void setDemonstrationMode(boolean demonstrationMode) {
        this._demonstrationMode = demonstrationMode;
        if (_demonstrationMode) {
            _demonstrationModeSettings = new DemonstrationModeSettings(this);
        } else {
            _demonstrationModeSettings = null;
        }
        getUI().setDemonstrationMode(demonstrationMode);
    }

    /**
     * Gets the set of exact characters. These are characters which are to be
     * regarded as not subject to error. When such a character is USEd, taxa
     * inconsistent with the specified value are eliminated, regardless of the
     * TOLERANCE value.
     * 
     * @return The set of exact characters
     */
    public synchronized Set<Character> getExactCharacters() {
        Set<Character> exactCharacters = new HashSet<Character>();
        for (int charNum : _exactCharactersSet) {
            exactCharacters.add(_dataset.getCharacter(charNum));
        }
        return exactCharacters;
    }

    /**
     * Sets the set of exact characters. These are characters which are to be
     * regarded as not subject to error. When such a character is USEd, taxa
     * inconsistent with the specified value are eliminated, regardless of the
     * TOLERANCE value.
     * 
     * @param characters
     *            The set of exact characters
     */
    public synchronized void setExactCharacters(Set<Integer> characters) {
        _exactCharactersSet = new HashSet<Integer>(characters);

        if (_exactCharactersSet.isEmpty()) {
            appendToLog(UIUtils.getResourceString("NoExactCharacters.log"));
        } else {
            appendToLog(UIUtils.getResourceString("ExactCharacters.log"));
        }
    }

    /**
     * Returns true if a character is exact - if it is regarded as not subject
     * to error. When such a character is USEd, taxa inconsistent with the
     * specified value are eliminated, regardless of the TOLERANCE value.
     * 
     * @param ch
     *            The character
     * @return true if the specified character is set to exact.
     */
    private boolean isCharacterExact(Character ch) {
        return _exactCharactersSet.contains(ch.getCharacterId());
    }

    /**
     * @return The number of characters which a DIAGNOSE command must find
     *         before it stops searching for acceptable characters. Characters
     *         are processed in order of decreasing reliability. If n is 0,
     *         which is the default, all the characters are examined.
     */
    public synchronized int getStopBest() {
        return _stopBest;
    }

    /**
     * Sets the stopbest value - the number of characters which a DIAGNOSE
     * command must find before it stops searching for acceptable characters.
     * Characters are processed in order of decreasing reliability. If n is 0,
     * which is the default, all the characters are examined.
     * 
     * @param stopBest
     *            The stopbest value
     */
    public synchronized void setStopBest(int stopBest) {
        this._stopBest = stopBest;
    }

    /**
     * @return true of numbering should be displayed beside character and taxon
     *         names
     */
    public synchronized boolean displayNumbering() {
        return _displayNumbering;
    }

    /**
     * Sets whether or not numbering should be displayed beside character and
     * taxon names
     * 
     * @param displayNumbering
     *            if true, numbering will be displayed
     */
    public synchronized void setDisplayNumbering(boolean displayNumbering) {
        this._displayNumbering = displayNumbering;
        updateUI();
    }

    /**
     * @return true if inapplicable characters should be displayed in the output
     *         of the DESCRIBE command
     */
    public synchronized boolean displayInapplicables() {
        return _displayInapplicables;
    }

    /**
     * Sets whether or not inapplicable characters should be displayed in the
     * output of the DESCIRBE command.
     * 
     * @param displayInapplicables
     *            if true, inapplicable characters will be displayed in the
     *            output of the DESCRIBE command.
     */
    public synchronized void setDisplayInapplicables(boolean displayInapplicables) {
        this._displayInapplicables = displayInapplicables;
    }

    /**
     * @return true if unknown characters should be displayed in the output of
     *         the DESCRIBE command
     */
    public synchronized boolean displayUnknowns() {
        return _displayUnknowns;
    }

    /**
     * Sets whether or not unknown characters should be displayed in the output
     * of the DESCIRBE command.
     * 
     * @param displayUnknowns
     *            if true, inapplicable characters will be displayed in the
     *            output of the DESCRIBE command.
     */
    public synchronized void setDisplayUnknowns(boolean displayUnknowns) {
        this._displayUnknowns = displayUnknowns;
    }

    /**
     * @return true if comments in taxon names should be displayed in list boxes
     *         and in the output of the TAXA command
     */
    public synchronized boolean displayComments() {
        return _displayComments;
    }

    /**
     * Sets whether or not comments in taxon names should be displayed in list
     * boxes and in the output of the TAXA command
     * 
     * @param displayComments
     *            if true, comments in taxon names will be displayed in list
     *            boxes and in the output of the TAXA command
     */
    public synchronized void setDisplayComments(boolean displayComments) {
        this._displayComments = displayComments;
        updateUI();
    }

    /**
     * @return true if CONTINUOUS is on. When CONTINUOUS is ON, images are
     *         displayed in a continuous loop, and the "Multiple Images" option
     *         in taxon-image windows is unavailable. The default is OFF.
     */
    public synchronized boolean displayContinuous() {
        return _displayContinuous;
    }

    /**
     * Sets whether or not CONTINUOUS is on. When CONTINUOUS is ON, images are
     * displayed in a continuous loop, and the "Multiple Images" option in
     * taxon-image windows is unavailable. The default is OFF.
     * 
     * @param displayContinuous
     *            if true, continuous will be set to on.
     */
    public synchronized void setDisplayContinuous(boolean displayContinuous) {
        this._displayContinuous = displayContinuous;
    }

    /**
     * Gets the image display mode
     * 
     * @return the image display mode
     */
    public synchronized ImageDisplayMode getImageDisplayMode() {
        return _displayImagesMode;
    }

    /**
     * Sets the image display mode
     * 
     * @param imageDisplayMode
     *            the image display mode
     */
    public synchronized void setImageDisplayMode(ImageDisplayMode imageDisplayMode) {
        this._displayImagesMode = imageDisplayMode;
    }

    /**
     * @return true if keyword selection dialogs should be displayed when
     *         prompting for characters or taxa. If false, the default character
     *         or taxon menu will be displayed
     */
    public synchronized boolean displayKeywords() {
        return _displayKeywords;
    }

    /**
     * Sets whether or not keyword selection dialogs should be displayed when
     * prompting for characters or tax
     * 
     * @param displayKeywords
     *            if true, keyword selection dialogs will be displayed when
     *            prompting for characters or taxa. If false, the default
     *            character or taxon menu will be displayed
     */
    public synchronized void setDisplayKeywords(boolean displayKeywords) {
        this._displayKeywords = displayKeywords;
    }

    /**
     * @return If true, images should be automatically scaled to fit within the
     *         image window.
     */
    public synchronized boolean displayScaled() {
        return _displayScaled;
    }

    /**
     * Sets whether or not images should be automatically scaled to fit within
     * the image window.
     * 
     * @param displayScaled
     *            True if images should be automatically scaled to fit within
     *            the image window
     */
    public synchronized void setDisplayScaled(boolean displayScaled) {
        this._displayScaled = displayScaled;
    }

    /**
     * @return True if commands specified by DEFINE ENDIDENTIFY are executed at
     *         the end of an identification.
     */
    public synchronized boolean displayEndIdentify() {
        return _displayEndIdentify;
    }

    /**
     * Sets whether or not commands specified by DEFINE ENDIDENTIFY are executed
     * when a taxon is successfully identified in an investigation.
     * 
     * @param displayEndIdentify
     *            True if commands specified by DEFINE ENDIDENTIFY are executed
     *            when a taxon is successfully identified in an investigation.
     */
    public synchronized void setDisplayEndIdentify(boolean displayEndIdentify) {
        this._displayEndIdentify = displayEndIdentify;
    }

    /**
     * Sets the directive commands that should be run when a taxon is
     * successfully identified in an investigation.
     * 
     * @param commands
     *            The commands to run when a taxon is successfully identified in
     *            an investigation
     */
    public synchronized void setEndIdentifyCommands(List<String> commands) {
        _endIdentifyCommands = new ArrayList<String>(commands);
    }

    /**
     * Run the directive commands set for execution when a taxon is successfully
     * identified in an investigation.
     */
    private void executeEndIdentifyCommands() {
        if (_endIdentifyCommands != null) {
            for (String cmd : _endIdentifyCommands) {
                parseAndExecuteDirective(cmd);
            }
        }
    }

    /**
     * @return true if the contents of an input file are displayed (and included
     *         in the LOG and JOURNAL files) as it is executed
     */
    public synchronized boolean displayInput() {
        return _displayInput;
    }

    /**
     * Sets whether or not the contents of an input file are displayed (and
     * included in the LOG and JOURNAL files) as it is executed
     * 
     * @param displayInput
     *            If true, the contents of an input file will be displayed (and
     *            included in the LOG and JOURNAL files) as it is executed
     */
    public synchronized void setDisplayInput(boolean displayInput) {
        this._displayInput = displayInput;
    }

    /**
     * Sets the file to write logging output to
     * 
     * @param logFile
     *            the log file
     * @throws IOException
     */
    public synchronized void setLogFile(File logFile) throws IOException {
        if (_logPrintFile != null) {
            _logPrintFile.close();
        }

        _logPrintFile = new PrintFile(new PrintStream(logFile), OUTPUT_FILE_WIDTH);
        _logFile = logFile;

        _logPrintFile.outputLine(_logCache.toString());
        _logPrintFile.setTrimInput(false, true);
    }

    /**
     * Sets the file to write journal output to
     * 
     * @param journalFile
     *            the journal file
     * @throws IOException
     */
    public synchronized void setJournalFile(File journalFile) throws IOException {
        if (_journalPrintFile != null) {
            _journalPrintFile.close();
        }

        _journalPrintFile = new PrintFile(new PrintStream(journalFile), OUTPUT_FILE_WIDTH);
        _journalFile = journalFile;

        StringBuilder journalContentBuilder = new StringBuilder();
        for (String line : _journalCache) {
            journalContentBuilder.append(line);
            journalContentBuilder.append("\n");
        }
        _journalPrintFile.outputLine(journalContentBuilder.toString());
        _journalPrintFile.setTrimInput(false, true);
    }

    /**
     * Use a new file to write output information to
     * 
     * @param outputFile
     *            the new output file
     * @throws IOException
     */
    public synchronized void newOutputFile(File outputFile) throws IOException {
        if (_currentOutputFile != null && !_currentOutputFile.equals(outputFile)) {
            if (_currentOutputFile != null) {
                closeOutputFile(_currentOutputFile);
            }
        }

        _currentOutputFile = outputFile;
        _currentOutputPrintFile = new PrintFile(new PrintStream(outputFile), OUTPUT_FILE_WIDTH);
        _currentOutputPrintFile.setTrimInput(false, true);
    }

    /**
     * Close the specified output file
     * 
     * @param outputFile
     *            the output file to close
     */
    public synchronized void closeOutputFile(File outputFile) throws IOException {
        if (_currentOutputFile != null || _currentOutputFile.equals(outputFile)) {
            _currentOutputPrintFile.close();
            _currentOutputFile = null;
        }
    }

    /**
     * @return The journal file, or null if one has not been specified
     */
    public synchronized File getJournalFile() {
        return _journalFile;
    }

    /**
     * The log file, or null if one has not been specified
     * 
     * @return
     */
    public synchronized File getLogFile() {
        return _logFile;
    }

    /**
     * The current output file, or null if one has not been specified.
     * 
     * @return
     */
    public synchronized File getOutputFile() {
        return _currentOutputFile;
    }

    /**
     * Appends the supplied text to the current output file. Note that
     * whitespace characters will be trimmed from the beginning of lines when
     * line wrapping is done. To insert blank lines in the output file, use
     * 
     * @param text
     */
    public synchronized void appendTextToOutputFile(String text) {
        if (_currentOutputFile == null) {
            throw new IllegalStateException("No output file is open");
        }

        _currentOutputPrintFile.outputLine(text);
    }

    /**
     * Appends a blank line to the current output file
     */
    public synchronized void appendBlankLineToOutputFile() {
        if (_currentOutputFile == null) {
            throw new IllegalStateException("No output file is open");
        }

        _currentOutputPrintFile.writeBlankLines(1, 0);
    }

    /**
     * Returns true if the last line appended to the current output file was a
     * comment specified via the OUTPUT COMMENT directive
     * 
     * @return
     */
    public synchronized boolean getLastOutputLineWasComment() {
        return _lastOutputLineWasComment;
    }

    /**
     * Sets whether or not the last line appended to the current output file was
     * a comment specified via the OUTPUT COMMENT directive
     * 
     * @param lastOutputLineWasComment
     *            true if the last line appended to the current output file was
     *            a comment specified via the OUTPUT COMMENT directive
     * @return
     */
    public synchronized boolean setLastOutputLineWasComment(boolean lastOutputLineWasComment) {
        return _lastOutputLineWasComment = lastOutputLineWasComment;
    }

    /**
     * @return The image subjects. These words or phrases are placed in the
     *         "Subjects" list box in the "Select Multiple Images" dialog box,
     *         which is invoked by the "Multiple Images" option of the "Control"
     *         menu in taxon-image windows. The images displayed are restricted
     *         to those whose "subjects" contain any of the words or phrases
     *         selected in the list box.
     */
    public synchronized List<String> getImageSubjects() {
        return _imageSubjects;
    }

    /**
     * Sets the image subjects.
     * 
     * @param imageSubjects
     *            These words or phrases will be placed in the "Subjects" list
     *            box in the "Select Multiple Images" dialog box, which is
     *            invoked by the "Multiple Images" option of the "Control" menu
     *            in taxon-image windows. The images displayed are restricted to
     *            those whose "subjects" contain any of the words or phrases
     *            selected in the list box.
     */
    public synchronized void setImageSubjects(List<String> imageSubjects) {
        _imageSubjects = new ArrayList<String>(imageSubjects);
    }

    /**
     * Appends the supplied text to the log
     * 
     * @param text
     */
    public synchronized void appendToLog(String text) {
        try {
            if (StringUtils.isBlank(text)) {
                return;
            }
            if (_logPrintFile != null) {
                _logPrintFile.outputLine(text);
            }

            _logCache.add(text);

            _appUI.updateLog();
        } catch (Exception e) {
            Logger.error("Unable to update the Intkey log", e);
        }
    }

    /**
     * Appends the supplied text to the journal
     * 
     * @param text
     */
    public synchronized void appendToJournal(String text) {
        if (_journalPrintFile != null) {
            _journalPrintFile.outputLine(text);
        }

        _journalCache.add(text);
    }

    /**
     * Returns all lines previously written to the log
     * 
     * @return The list of lines previously written to the log
     */
    public synchronized List<String> getLogEntries() {
        return new ArrayList<String>(_logCache);
    }

    /**
     * @return The directory in which images and information files pulled from
     *         remote locations are cached.
     */
    public synchronized File getFileCacheDirectory() {
        return _fileCacheDirectory;
    }

    /**
     * Updates the entire UI.
     */
    private void updateUI() {
        // Don't update the UI in the middle of processing an input file. Wait
        // and update once when the
        // entire file has finished being processed.
        if (!_processingDirectivesFile) {
            _appUI.handleUpdateAll();
        }
    }

    /**
     * Process the dataset startup file at the specified URL. This URL may point
     * to either a JNLP style "webstart" file for a dataset, or an actual
     * directives file with directive commands used to initalize and load the
     * dataset.
     * 
     * @param startupFileUrl
     *            URL to the startup file.
     * @throws Exception
     */
    private synchronized void processStartupFile(URL startupFileUrl) throws Exception {
        URL inkFileLocation = null;
        URL dataFileLocation = null;
        String initializationFileLocation = null;
        String imagePath = null;
        String infoPath = null;

        File startupFile = Utils.saveURLToTempFile(startupFileUrl, "Intkey", 30000);

        BufferedReader reader = new BufferedReader(new FileReader(startupFile));

        String line;
        while ((line = reader.readLine()) != null) {
            String[] tokens = line.split("=");

            if (tokens.length == 2) {
                String keyword = tokens[0];
                String value = tokens[1];

                if (keyword.equals(Constants.INIT_FILE_INK_FILE_KEYWORD)) {
                    // Datasets saved with the old implementation of Intkey
                    // used simple file paths
                    // for startup files that were saved to disk. Check if
                    // this is the format that is
                    // used before attempting to read as a URL.
                    if (value.equals(startupFile.getAbsolutePath())) {
                        inkFileLocation = startupFileUrl.toURI().toURL();
                    } else {
                        inkFileLocation = new URL(value);
                    }
                } else if (keyword.equals(Constants.INIT_FILE_DATA_FILE_KEYWORD)) {
                    dataFileLocation = new URL(value);
                } else if (keyword.equals(Constants.INIT_FILE_INITIALIZATION_FILE_KEYWORD)) {
                    initializationFileLocation = value;
                } else if (keyword.equals(Constants.INIT_FILE_IMAGE_PATH_KEYWORD)) {
                    imagePath = value;
                } else if (keyword.equals(Constants.INIT_FILE_INFO_PATH_KEYWORD)) {
                    infoPath = value;
                }
            }
        }

        if (inkFileLocation != null && initializationFileLocation != null && dataFileLocation != null) {
            File tempDir = new File(FileUtils.getTempDirectory(), UUID.randomUUID().toString());
            tempDir.mkdir();
            tempDir.deleteOnExit();

            String[] dataFileLocationTokens = dataFileLocation.toString().split("/");
            String dataFileName = dataFileLocationTokens[dataFileLocationTokens.length - 1];
            File localDataFile = new File(tempDir, dataFileName);

            // If the ink file location points to a local file, the dataset
            // has been saved locally. Look for the
            // zipped data file in the same directory as the ink file.
            boolean savedDatasetOpened = false;
            if (inkFileLocation.getProtocol().equals("file")) {
                File savedInkFile = new File(inkFileLocation.toURI());
                if (savedInkFile.exists()) {
                    File saveDirectory = savedInkFile.getParentFile();
                    File savedDataFile = new File(saveDirectory, dataFileName);
                    if (savedDataFile.exists()) {
                        FileUtils.copyFile(savedDataFile, localDataFile);
                        savedDatasetOpened = true;
                    }
                }
            }

            if (!savedDatasetOpened) {
                // Data set is hosted remotely. Download it.
                FileUtils.copyURLToFile(dataFileLocation, localDataFile, 30000, 30000);
            }

            Utils.extractZipFile(localDataFile, tempDir);

            StartupFileData startupFileData = new StartupFileData();
            startupFileData.setInkFileLocation(inkFileLocation);
            startupFileData.setDataFileLocation(dataFileLocation);
            startupFileData.setInitializationFileLocation(initializationFileLocation);
            startupFileData.setDataFileLocalCopy(localDataFile);
            startupFileData.setInitializationFileLocalCopy(new File(tempDir, initializationFileLocation));
            startupFileData.setImagePath(imagePath);
            startupFileData.setInfoPath(infoPath);
            startupFileData.setRemoteDataset(!savedDatasetOpened);

            _startupFileData = startupFileData;
            processInitializationFile(_startupFileData.getInitializationFileLocalCopy());

            if (startupFileData.getImagePath() != null) {
                String startupImagePath = _startupFileData.getImagePath();
                List<String> imagePaths = ResourceSettings.parse(startupImagePath);
                setImagePaths(imagePaths);
            }

            if (startupFileData.getInfoPath() != null) {
                setInfoPaths(Arrays.asList(_startupFileData.getInfoPath()));
            }
        } else {
            processInitializationFile(startupFile);
            _startupFileData = null;
        }

        _datasetStartupFile = startupFile;
        _datasetStartupURL = startupFileUrl;

        // create a directory to save cached versions of any images or files
        // downloaded for this dataset from
        // remote locations on the image path or info path. Use a random UUID as
        // the name of the directory
        _fileCacheDirectory = new File(FileUtils.getTempDirectory(), UUID.randomUUID().toString());
        _fileCacheDirectory.mkdir();
        _fileCacheDirectory.deleteOnExit();

    }

}