com.ggvaidya.scinames.dataset.BinomialChangesSceneController.java Source code

Java tutorial

Introduction

Here is the source code for com.ggvaidya.scinames.dataset.BinomialChangesSceneController.java

Source

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

import java.awt.Desktop;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;

import com.ggvaidya.scinames.model.Change;
import com.ggvaidya.scinames.model.ChangeType;
import com.ggvaidya.scinames.model.Dataset;
import com.ggvaidya.scinames.model.DatasetColumn;
import com.ggvaidya.scinames.model.DatasetRow;
import com.ggvaidya.scinames.model.Name;
import com.ggvaidya.scinames.model.Project;
import com.ggvaidya.scinames.model.change.ChangeTypeStringConverter;
import com.ggvaidya.scinames.model.change.NameSetStringConverter;
import com.ggvaidya.scinames.model.change.PotentialChange;
import com.ggvaidya.scinames.model.filters.ChangeFilter;
import com.ggvaidya.scinames.tabulardata.TabularDataViewController;
import com.ggvaidya.scinames.util.SimplifiedDate;

import javafx.beans.Observable;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.ReadOnlyStringWrapper;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import javafx.collections.transformation.SortedList;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Dialog;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableColumn.SortType;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.control.cell.ComboBoxTableCell;
import javafx.scene.control.cell.PropertyValueFactory;
import javafx.scene.control.cell.TextFieldTableCell;
import javafx.stage.FileChooser;
import javafx.stage.Stage;

/**
 * FXML Controller class for a view of a Dataset in a project. This does a bunch of cool
 * things:
 * 
 * - 1. We provide editable information on dataset rows for a dataset.
 * - 2. We provide editable information on changes for a checklist.
 *
 * @author Gaurav Vaidya <gaurav@ggvaidya.com>
 */
public class BinomialChangesSceneController {
    private static final Logger LOGGER = Logger.getLogger(BinomialChangesSceneController.class.getSimpleName());

    /**
     * If a dataset contains more than this number of changes, then we won't calculate additional
     * data on them at all. (Eventually, we should just calculate additional data 
     */
    public static final int ADDITIONAL_DATA_CHANGE_COUNT_LIMIT = 1500;

    private BinomialChangesView binomialChangesView;
    private Project project;

    public BinomialChangesSceneController() {
    }

    public void setBinomialChangesView(BinomialChangesView tv) {
        binomialChangesView = tv;
        project = tv.getProjectView().getProject();

        // Reinitialize UI to the selected timepoint.
        setupTableWithBinomialChanges();
        updateAdditionalData();

        LOGGER.info("Finished setBinomialChangesView()");
    }

    /**
     * Initializes the controller class.
     */
    public void initialize() {
        initAdditionalData();
        setupMagicButtons();
    }

    /*
     * User interface.
     */
    @FXML
    private TableView<PotentialChange> changesTableView;

    private void setupTableWithBinomialChanges() {
        changesTableView.setEditable(false);
        changesTableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
        changesTableView.setItems(potentialChanges);

        changesTableView.getColumns().clear();

        TableColumn<PotentialChange, ChangeType> colChangeType = new TableColumn<>("Type");
        colChangeType.setCellFactory(ComboBoxTableCell.forTableColumn(new ChangeTypeStringConverter(),
                ChangeType.ADDITION, ChangeType.DELETION, ChangeType.RENAME, ChangeType.LUMP, ChangeType.SPLIT,
                ChangeType.COMPLEX, ChangeType.ERROR));
        colChangeType.setCellValueFactory(new PropertyValueFactory<>("type"));
        colChangeType.setPrefWidth(100.0);
        colChangeType.setEditable(true);
        changesTableView.getColumns().add(colChangeType);

        TableColumn<PotentialChange, ObservableSet<Name>> colChangeFrom = new TableColumn<>("From");
        colChangeFrom.setCellFactory(TextFieldTableCell.forTableColumn(new NameSetStringConverter()));
        colChangeFrom.setCellValueFactory(new PropertyValueFactory<>("from"));
        colChangeFrom.setPrefWidth(200.0);
        colChangeFrom.setEditable(true);
        changesTableView.getColumns().add(colChangeFrom);

        TableColumn<PotentialChange, ObservableSet<Name>> colChangeTo = new TableColumn<>("To");
        colChangeTo.setCellFactory(TextFieldTableCell.forTableColumn(new NameSetStringConverter()));
        colChangeTo.setCellValueFactory(new PropertyValueFactory<>("to"));
        colChangeTo.setPrefWidth(200.0);
        colChangeTo.setEditable(true);
        changesTableView.getColumns().add(colChangeTo);

        TableColumn<PotentialChange, String> colDataset = new TableColumn<>("Dataset");
        colDataset.setCellValueFactory(cvf -> {
            return new ReadOnlyStringWrapper(cvf.getValue().getDataset().toString());
        });
        colDataset.setPrefWidth(150.0);
        changesTableView.getColumns().add(colDataset);

        TableColumn<PotentialChange, SimplifiedDate> dateCol = new TableColumn<>("Date");
        dateCol.setCellFactory(
                TextFieldTableCell.forTableColumn(new SimplifiedDate.SimplifiedDateStringConverter()));
        dateCol.setCellValueFactory(cvf -> new ReadOnlyObjectWrapper<>(cvf.getValue().getDataset().getDate()));
        dateCol.setPrefWidth(150);
        dateCol.setSortable(true);
        dateCol.setSortType(SortType.ASCENDING);
        changesTableView.getColumns().add(dateCol);
        changesTableView.getSortOrder().add(dateCol);

        TableColumn<PotentialChange, String> colChangeSummary = new TableColumn<>("Changes summary");
        colChangeSummary.setCellValueFactory(cvf -> {
            Set<Change> changes = changesByPotentialChange.get(cvf.getValue());
            return new ReadOnlyStringWrapper(changes.size() + ": "
                    + changes.stream().map(ch -> ch.toString()).collect(Collectors.joining("; ")));
        });
        colChangeSummary.setPrefWidth(200.0);
        changesTableView.getColumns().add(colChangeSummary);

        /*
        TableColumn<PotentialChange, String> colExplicit = new TableColumn<>("Explicit or implicit?");
        colExplicit.setCellValueFactory(
           (TableColumn.CellDataFeatures<Change, String> features) -> 
        new ReadOnlyStringWrapper(
           features.getValue().getDataset().isChangeImplicit(features.getValue()) ? "Implicit" : "Explicit"
        )
        );
        tv.getColumns().add(colExplicit);
            
        ChangeFilter cf = binomialChangesView.getProjectView().getProject().getChangeFilter();
        TableColumn<Change, String> colFiltered = new TableColumn<>("Eliminated by filter?");
        colFiltered.setCellValueFactory(
           (TableColumn.CellDataFeatures<Change, String> features) -> 
        new ReadOnlyStringWrapper(
           cf.test(features.getValue()) ? "Allowed" : "Eliminated"
        )
        );
        tv.getColumns().add(colFiltered);
        */

        TableColumn<PotentialChange, String> colNote = new TableColumn<>("Note");
        colNote.setCellFactory(TextFieldTableCell.forTableColumn());
        colNote.setCellValueFactory(new PropertyValueFactory<>("note"));
        colNote.setPrefWidth(100.0);
        changesTableView.getColumns().add(colNote);

        TableColumn<PotentialChange, String> colReason = new TableColumn<>("Reason");
        colReason.setCellValueFactory(cvf -> new ReadOnlyStringWrapper(calculateReason(cvf.getValue())));
        colReason.setPrefWidth(100.0);
        changesTableView.getColumns().add(colReason);

        TableColumn<PotentialChange, String> colReasonDate = new TableColumn<>("ReasonDate");
        colReasonDate.setCellValueFactory(cvf -> {
            String result;
            Set<SimplifiedDate> dates = calculateReasonDate(cvf.getValue());

            if (dates.size() > 1) {
                result = "(" + dates.size() + ") " + dates.stream().distinct().sorted()
                        .map(sd -> sd.asYYYYmmDD("-")).collect(Collectors.joining("|"));

            } else if (dates.size() == 1) {
                result = dates.iterator().next().asYYYYmmDD("-");

            } else {
                result = "NA";
            }

            return new ReadOnlyStringWrapper(result);
        });
        colReasonDate.setPrefWidth(100.0);
        changesTableView.getColumns().add(colReasonDate);

        TableColumn<PotentialChange, String> colCitations = new TableColumn<>("Citations");
        colCitations.setCellValueFactory(
                (TableColumn.CellDataFeatures<PotentialChange, String> features) -> new ReadOnlyStringWrapper(
                        features.getValue().getCitationStream().map(citation -> citation.getCitation()).sorted()
                                .collect(Collectors.joining("; "))));
        changesTableView.getColumns().add(colCitations);

        TableColumn<PotentialChange, String> colGenera = new TableColumn<>("Genera");
        colGenera.setCellValueFactory(
                (TableColumn.CellDataFeatures<PotentialChange, String> features) -> new ReadOnlyStringWrapper(
                        String.join(", ", features.getValue().getAllNames().stream().map(n -> n.getGenus())
                                .distinct().sorted().collect(Collectors.toList()))));
        changesTableView.getColumns().add(colGenera);

        TableColumn<PotentialChange, String> colSpecificEpithet = new TableColumn<>("Specific epithets");
        colSpecificEpithet.setCellValueFactory(
                (TableColumn.CellDataFeatures<PotentialChange, String> features) -> new ReadOnlyStringWrapper(String
                        .join(", ", features.getValue().getAllNames().stream().map(n -> n.getSpecificEpithet())
                                .filter(s -> s != null).distinct().sorted().collect(Collectors.toList()))));
        changesTableView.getColumns().add(colSpecificEpithet);

        // The infraspecific string.
        TableColumn<PotentialChange, String> colInfraspecificEpithet = new TableColumn<>("Infraspecific epithets");
        colInfraspecificEpithet.setCellValueFactory(
                (TableColumn.CellDataFeatures<PotentialChange, String> features) -> new ReadOnlyStringWrapper(
                        String.join(", ",
                                features.getValue().getAllNames().stream()
                                        .map(n -> n.getInfraspecificEpithetsAsString()).filter(s -> s != null)
                                        .distinct().sorted().collect(Collectors.toList()))));
        changesTableView.getColumns().add(colInfraspecificEpithet);

        // The very last epithet of all
        TableColumn<PotentialChange, String> colTerminalEpithet = new TableColumn<>("Terminal epithet");
        colTerminalEpithet.setCellValueFactory(
                (TableColumn.CellDataFeatures<PotentialChange, String> features) -> new ReadOnlyStringWrapper(
                        String.join(", ", features.getValue().getAllNames().stream().map(n -> {
                            List<Name.InfraspecificEpithet> infraspecificEpithets = n.getInfraspecificEpithets();
                            if (!infraspecificEpithets.isEmpty()) {
                                return infraspecificEpithets.get(infraspecificEpithets.size() - 1).getValue();
                            } else {
                                return n.getSpecificEpithet();
                            }
                        }).filter(s -> s != null).distinct().sorted().collect(Collectors.toList()))));
        changesTableView.getColumns().add(colTerminalEpithet);

        TableColumn<PotentialChange, String> dateForRCol = new TableColumn<>("DateYMD");
        dateForRCol.setCellValueFactory(
                cvf -> new ReadOnlyObjectWrapper<>(cvf.getValue().getDataset().getDate().asYYYYmmDD("-")));
        changesTableView.getColumns().add(dateForRCol);

        // Properties
        TableColumn<PotentialChange, String> colProperties = new TableColumn<>("Properties");
        colProperties.setCellValueFactory(
                (TableColumn.CellDataFeatures<PotentialChange, String> features) -> new ReadOnlyStringWrapper(
                        features.getValue().getProperties().entrySet().stream()
                                .map(entry -> entry.getKey() + ": " + entry.getValue()).sorted()
                                .collect(Collectors.joining("; "))));
        changesTableView.getColumns().add(colProperties);

        fillTableWithBinomialChanges();

        // When someone selects a cell in the Table, try to select the appropriate data in the
        // additional data view.
        changesTableView.getSelectionModel().getSelectedItems()
                .addListener((ListChangeListener<PotentialChange>) lcl -> {
                    AdditionalData aData = additionalDataCombobox.getSelectionModel().getSelectedItem();

                    if (aData != null) {
                        aData.onSelectChange(changesTableView.getSelectionModel().getSelectedItems());
                    }
                });

        // Create a right-click menu for table rows.
        changesTableView.setRowFactory(table -> {
            TableRow<PotentialChange> row = new TableRow<>();

            row.setOnContextMenuRequested(event -> {
                if (row.isEmpty())
                    return;

                // We don't currently use the clicked change, since currently all options
                // change *all* the selected changes, but this may change in the future.
                PotentialChange change = row.getItem();

                ContextMenu changeMenu = new ContextMenu();

                Menu lookupChange = new Menu("Look up change");
                lookupChange.getItems().addAll(changesByPotentialChange.getOrDefault(change, new HashSet<>())
                        .stream()
                        .map(ch -> createMenuItem(ch.toString() + " in " + ch.getDataset().toString(), action -> {
                            binomialChangesView.getProjectView().openDetailedView(ch);
                        })).collect(Collectors.toList()));
                changeMenu.getItems().add(lookupChange);

                changeMenu.getItems().add(new SeparatorMenuItem());

                Menu searchForName = new Menu("Search for name");
                searchForName.getItems().addAll(
                        change.getAllNames().stream().sorted().map(n -> createMenuItem(n.getFullName(), action -> {
                            binomialChangesView.getProjectView().openDetailedView(n);
                        })).collect(Collectors.toList()));
                changeMenu.getItems().add(searchForName);

                changeMenu.getItems().add(new SeparatorMenuItem());

                // Create a submenu for tags and urls.
                String note = change.noteProperty().get();

                Menu removeTags = new Menu("Tags");
                removeTags.getItems().addAll(change.getTags().stream().sorted()
                        .map(tag -> new MenuItem(tag.getName())).collect(Collectors.toList()));

                Menu lookupURLs = new Menu("Lookup URL");
                change.getURIs().stream().sorted().map(uri -> {
                    return createMenuItem(uri.toString(), evt -> {
                        try {
                            Desktop.getDesktop().browse(uri);
                        } catch (IOException ex) {
                            LOGGER.warning("Could not open URL '" + uri + "': " + ex);
                        }
                    });
                }).forEach(mi -> lookupURLs.getItems().add(mi));
                changeMenu.getItems().add(lookupURLs);

                changeMenu.show(binomialChangesView.getScene().getWindow(), event.getScreenX(), event.getScreenY());

            });

            return row;
        });

        LOGGER.info("setupTableWithChanges() completed");
    }

    private String calculateReason(PotentialChange pc) {
        Set<Change> changes = changesByPotentialChange.get(pc);

        Pattern findReason = Pattern.compile("^\\s*#(\\w+)\\b");

        return changes.stream().flatMap(ch -> {
            Optional<String> optNote = ch.getNote();
            if (!optNote.isPresent())
                return Stream.empty();
            String note = optNote.get();

            Set<String> reasons = new HashSet<>();

            // Each note only contains a single reason.
            Matcher matcher = findReason.matcher(note);
            while (matcher.find()) {
                reasons.add(tweakReason(matcher.group(1), note, pc));
            }

            return reasons.stream();
        }).distinct().sorted().collect(Collectors.joining("|"));
    }

    private String tweakReason(String reason, String note, PotentialChange pch) {
        Project project = binomialChangesView.getProjectView().getProject();

        if (reason.equals("described") || reason.equals("renamed") || reason.equals("spnov")
                || reason.equals("combnov")) {
            // was the description since the date of the first checklist?
            Optional<Dataset> optDsFirst = project.getFirstDataset();
            if (optDsFirst.isPresent()) {
                SimplifiedDate earliest_date = optDsFirst.get().getDate();

                if (earliest_date != null) {
                    Set<SimplifiedDate> dates = calculateReasonDate(pch);

                    boolean anyMatch = dates.stream().anyMatch(sd -> earliest_date.compareTo(sd) < 0);
                    boolean allMatch = dates.stream().allMatch(sd -> earliest_date.compareTo(sd) < 0);

                    if (allMatch) {
                        reason = reason + "_all_after_earliest";
                    } else if (anyMatch) {
                        reason = reason + "_some_after_earliest";
                    } else {
                        reason = reason + "_before_earliest";
                    }
                }
            }
        }

        return reason;
    }

    private Set<SimplifiedDate> calculateReasonDate(PotentialChange pc) {
        Set<Change> changes = changesByPotentialChange.get(pc);

        Pattern findDate = Pattern.compile("\\s+(\\d{4})\\b");

        return changes.stream().flatMap(ch -> {
            Optional<String> note = ch.getNote();
            if (note.isPresent())
                return Stream.of(note.get());
            else
                return Stream.empty();
        }).flatMap(note -> {
            List<SimplifiedDate> dates = new LinkedList<>();

            // Each note may contain multiple dates.
            Matcher matcher = findDate.matcher(note);
            while (matcher.find()) {
                dates.add(new SimplifiedDate(matcher.group(1)));
            }

            return dates.stream();
        }).collect(Collectors.toSet());
    }

    private MenuItem createMenuItem(String name, EventHandler<ActionEvent> handler) {
        MenuItem mi = new MenuItem(name);
        mi.onActionProperty().set(handler);
        return mi;
    }

    private Optional<String> askUserForTextField(String text) {
        TextField textfield = new TextField();

        Dialog<ButtonType> dialog = new Dialog<>();
        dialog.getDialogPane().headerTextProperty().set(text);
        dialog.getDialogPane().contentProperty().set(textfield);
        dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
        Optional<ButtonType> result = dialog.showAndWait();

        if (result.isPresent() && result.get().equals(ButtonType.OK))
            return Optional.of(textfield.getText());
        else
            return Optional.empty();
    }

    private Optional<String> askUserForTextArea(String label) {
        return askUserForTextArea(label, null);
    }

    private Optional<String> askUserForTextArea(String label, String initialText) {
        TextArea textarea = new TextArea();
        if (initialText != null)
            textarea.setText(initialText);

        Dialog<ButtonType> dialog = new Dialog<>();
        dialog.getDialogPane().headerTextProperty().set(label);
        dialog.getDialogPane().contentProperty().set(textarea);
        dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
        Optional<ButtonType> result = dialog.showAndWait();

        if (result.isPresent() && result.get().equals(ButtonType.OK))
            return Optional.of(textarea.getText());
        else
            return Optional.empty();
    }

    /**
     * Fill the main changes table with potential changes representing binomial changes
     * made in this dataset. Also updates an additional data object that allows us to
     * see which changes have been combined for each potential change.  
     * 
     */
    private void fillTableWithBinomialChanges() {
        // Preserve search order and selected item.
        List<TableColumn<PotentialChange, ?>> sortByCols = new LinkedList<>(changesTableView.getSortOrder());
        List<PotentialChange> selectedChanges = new LinkedList<>(
                changesTableView.getSelectionModel().getSelectedItems());

        LOGGER.info("About to set changes table items: sortByCols = " + sortByCols + ", selectedChanges = "
                + selectedChanges);
        calculateAllBinomialChanges();
        LOGGER.info("tv.setItems() completed");

        for (PotentialChange ch : selectedChanges) {
            changesTableView.getSelectionModel().select(ch);
        }
        changesTableView.getSortOrder().addAll(sortByCols);
        LOGGER.info("fillTableWithChanges() completed");
    }

    ObservableList<PotentialChange> potentialChanges = FXCollections.observableList(new LinkedList<>());
    ObservableMap<PotentialChange, Set<Change>> changesByPotentialChange = FXCollections.observableHashMap();

    private void calculateAllBinomialChanges() {
        potentialChanges.clear();
        changesByPotentialChange.clear();

        Dataset prevDataset = null;
        for (Dataset ds : project.getDatasets()) {
            if (prevDataset == null) {
                prevDataset = ds;
                continue;
            }

            // Step 1. Figure out which binomial names were added and removed.
            Set<Name> binomialNamesInPrev = prevDataset.getRecognizedNames(project).flatMap(n -> n.asBinomial())
                    .collect(Collectors.toSet());
            Set<Name> binomialNamesInCurrent = ds.getRecognizedNames(project).flatMap(n -> n.asBinomial())
                    .collect(Collectors.toSet());

            Set<Name> namesAdded = new HashSet<>(binomialNamesInCurrent);
            namesAdded.removeAll(binomialNamesInPrev);

            Set<Name> namesDeleted = new HashSet<>(binomialNamesInPrev);
            namesDeleted.removeAll(binomialNamesInCurrent);

            // Step 2. Map all changes involving binomial name changes to the
            // binomial names they involve.
            // 
            // Note that this means deliberately skipping changes that *don't* affect
            // binomial composition, such as if a form or variety is deleted but that
            // doesn't result in the binomial name changing.
            List<Change> datasetChanges = ds.getChanges(project).collect(Collectors.toList());
            Map<Name, Set<Change>> changesByBinomialName = new HashMap<>();

            for (Change ch : datasetChanges) {
                Set<Name> changeNames = ch.getAllNames();
                Set<Name> changeBinomialNames = changeNames.stream().flatMap(n -> n.asBinomial())
                        .collect(Collectors.toSet());

                boolean involvesAddedNames = changeBinomialNames.stream().anyMatch(n -> namesAdded.contains(n));
                boolean involvesDeletedNames = changeBinomialNames.stream().anyMatch(n -> namesDeleted.contains(n));

                if (involvesAddedNames || involvesDeletedNames) {
                    // Oh goody, involves one of our binomial names.
                    //
                    // Record all the changes by binomial name
                    for (Name binomialName : changeBinomialNames) {
                        if (!changesByBinomialName.containsKey(binomialName))
                            changesByBinomialName.put(binomialName, new HashSet<>());

                        changesByBinomialName.get(binomialName).add(ch);
                    }

                } else {
                    // This change is an error or involves non-binomial names only.
                    // Ignore!
                }
            }

            // Step 3. Convert the additions and deletions into potential changes,
            // based on the changes they include.
            Set<Name> namesChanged = new HashSet<>(namesAdded);
            namesChanged.addAll(namesDeleted);

            Set<Change> changesSummarized = new HashSet<>();

            for (Name n : namesChanged) {
                Set<Change> allChangesAssociatedWithName = changesByBinomialName.get(n);

                // TODO: am I sure this is being handled correctly?
                if (allChangesAssociatedWithName == null)
                    continue;

                Set<Change> changes = allChangesAssociatedWithName.stream()
                        // Don't summarize the same change into multiple changes
                        // (e.g. if A + B -> C, we don't want this to turn up three times,
                        //  under 'A', 'B' and 'C'
                        .filter(ch -> !changesSummarized.contains(ch)).collect(Collectors.toSet());

                // No changes left? Skip this name!
                if (changes.isEmpty())
                    continue;

                changesSummarized.addAll(changes);

                PotentialChange potentialChange = new PotentialChange(ds,
                        (namesAdded.contains(n) ? ChangeType.ADDITION : ChangeType.DELETION),
                        (namesAdded.contains(n) ? Stream.empty() : Stream.of(n)),
                        (namesAdded.contains(n) ? Stream.of(n) : Stream.empty()),
                        BinomialChangesSceneController.class, "Created from " + changes.size() + " changes: "
                                + changes.stream().map(ch -> ch.toString()).collect(Collectors.joining(";")));

                // Now, by default, the potential change writes in a long creation note, but
                // we don't want that, do we?
                potentialChange.getProperties().put("created", potentialChange.getNote().orElse(""));
                potentialChange.getProperties().remove("note");

                Set<ChangeType> changeTypes = new HashSet<>();

                for (Change ch : changes) {
                    changeTypes.add(ch.getType());

                    potentialChange.fromProperty().addAll(ch.getFrom());
                    potentialChange.toProperty().addAll(ch.getTo());

                    Optional<String> currentNote = potentialChange.getNote();
                    Optional<String> changeNote = ch.getNote();

                    if (currentNote.isPresent() && changeNote.isPresent()) {
                        potentialChange.noteProperty().set(currentNote.get() + "; " + changeNote.get());

                    } else if (!currentNote.isPresent() && changeNote.isPresent()) {
                        potentialChange.noteProperty().set(changeNote.get());

                    } else {
                        // Nothing to get hung about.
                    }
                }

                // Finally, figure out this potential change's type.
                if (changeTypes.size() == 1)
                    potentialChange.typeProperty().set(changeTypes.iterator().next());
                else {
                    potentialChange.typeProperty().set(ChangeType.COMPLEX);
                }

                // All done!
                potentialChanges.add(potentialChange);
                changesByPotentialChange.put(potentialChange, changes);
            }

            // Ready for next!
            prevDataset = ds;
        }
    }

    public void selectChange(Change ch) {
        int row = changesTableView.getItems().indexOf(ch);
        if (row == -1)
            row = 0;

        LOGGER.fine("Selecting change in row " + row + " (change " + ch + ")");

        changesTableView.getSelectionModel().clearAndSelect(row);
        changesTableView.scrollTo(row);
    }

    // Export to CSV
    public List<List<String>> getDataAsTable(TableView tv) {
        // What columns do we have?
        List<List<String>> result = new LinkedList<>();
        List<TableColumn> columns = tv.getColumns();

        columns.forEach(col -> {
            List<String> column = new LinkedList<>();

            // Add the header.
            column.add(col.getText());

            // Add the data.
            for (int x = 0; x < tv.getItems().size(); x++) {
                ObservableValue cellObservableValue = col.getCellObservableValue(x);
                Object val = (Object) cellObservableValue.getValue();
                if (val == null)
                    column.add(""); // or NA?
                else
                    column.add(val.toString());
            }

            result.add(column);
        });

        return result;
    }

    private void fillCSVFormat(CSVFormat format, Appendable destination, List<List<String>> data)
            throws IOException {
        try (CSVPrinter printer = format.print(destination)) {
            List<List<String>> dataAsTable = data;
            if (dataAsTable.isEmpty())
                return;

            for (int x = 0; x < dataAsTable.get(0).size(); x++) {
                for (int y = 0; y < dataAsTable.size(); y++) {
                    String value = dataAsTable.get(y).get(x);
                    printer.print(value);
                }
                printer.println();
            }
        }
    }

    private void exportToCSV(TableView tv, ActionEvent evt) {
        FileChooser chooser = new FileChooser();
        chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter("CSV file", "*.csv"),
                new FileChooser.ExtensionFilter("Tab-delimited file", "*.txt"));
        File file = chooser.showSaveDialog(binomialChangesView.getStage());
        if (file != null) {
            CSVFormat format = CSVFormat.RFC4180;

            String outputFormat = chooser.getSelectedExtensionFilter().getDescription();
            if (outputFormat.equalsIgnoreCase("Tab-delimited file"))
                format = CSVFormat.TDF;

            try {
                List<List<String>> dataAsTable = getDataAsTable(tv);
                fillCSVFormat(format, new FileWriter(file), dataAsTable);

                Alert window = new Alert(Alert.AlertType.CONFIRMATION,
                        "CSV file '" + file + "' saved with " + (dataAsTable.get(0).size() - 1) + " rows.");
                window.showAndWait();

            } catch (IOException e) {
                Alert window = new Alert(Alert.AlertType.ERROR, "Could not save CSV to '" + file + "': " + e);
                window.showAndWait();
            }
        }
    }

    @FXML
    private void exportChangesToCSV(ActionEvent evt) {
        exportToCSV(changesTableView, evt);
    }

    @FXML
    private void refreshChanges(ActionEvent evt) {
        fillTableWithBinomialChanges();
    }

    private void setupMagicButtons() {
        // Disable everything to begin with.

        /*
        // Switch them on and off based on the selection.
        changesTableView.getSelectionModel().getSelectedItems().addListener((ListChangeListener<Change>) ch -> {
           int countSelectionItems = ch.getList().size();
               
           if(countSelectionItems == 0) {
        // No selection? None of those buttons should be on.
        combineChangesButton.disableProperty().set(true);
        divideChangeButton.disableProperty().set(true);
        deleteExplicitChangeButton.disableProperty().set(true);
           } else if(countSelectionItems == 1) {
        // Exactly one? We can split, but not combine.
        combineChangesButton.disableProperty().set(true);
        divideChangeButton.disableProperty().set(false);
        deleteExplicitChangeButton.disableProperty().set(false);            
           } else {
        // More than one? We can combine, but not split.
        combineChangesButton.disableProperty().set(false);
        divideChangeButton.disableProperty().set(true);
        deleteExplicitChangeButton.disableProperty().set(false);            
           }
        });
        */
    }

    /*
     * The additional data system.
     * 
     * Here's how this works:
     *    - Everything is wrapped up into an AdditionalData class.
     *  - There's a bunch of code that knows how to convert AdditionalData objects into
     *    list/table combinations.
     *  - There's a separate bunch of code that builds AdditionalData objects.
     */

    private class AdditionalData<ListOf, TableOf> {
        private String name;

        public String getName() {
            return name;
        }

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

        private ObservableList<ListOf> listOf;
        private ObservableMap<ListOf, List<TableOf>> tableOf;
        private List<TableColumn<TableOf, String>> columns;
        private Function<List<Change>, List<ListOf>> onSelectChange;

        private AdditionalData(String name, List<ListOf> listOf, Map<ListOf, List<TableOf>> tableOfMap,
                List<TableColumn<TableOf, String>> columns) {
            this(name, listOf, tableOfMap, columns, null);
        }

        private AdditionalData(String name, List<ListOf> listOf, Map<ListOf, List<TableOf>> tableOfMap,
                List<TableColumn<TableOf, String>> columns, Function<List<Change>, List<ListOf>> onSelectChange) {
            this.name = name;
            this.listOf = FXCollections.observableList(listOf);
            this.tableOf = FXCollections.observableMap(tableOfMap);
            this.columns = FXCollections.observableList(columns);
            this.onSelectChange = onSelectChange;
        }

        public List<ListOf> getList() {
            return listOf;
        }

        public List<TableOf> getTableRowsFor(ListOf listOfItem) {
            return tableOf.getOrDefault(listOfItem, new ArrayList<>());
        }

        public List<TableColumn<TableOf, String>> getColumns() {
            return columns;
        }

        public void onSelectChange(List<Change> selectedChanges) {
            if (onSelectChange == null)
                return;

            additionalListView.getSelectionModel().clearSelection();
            List<ListOf> listOfs = onSelectChange.apply(selectedChanges);
            if (listOfs.isEmpty())
                return;

            for (ListOf lo : listOfs) {
                additionalListView.getSelectionModel().select(lo);
            }

            // Scroll to the first name.
            additionalListView.scrollTo(listOfs.get(0));
        }
    }

    @SuppressWarnings("rawtypes")
    @FXML
    private TableView additionalDataTableView;
    @SuppressWarnings("rawtypes")
    @FXML
    private ListView additionalListView;
    @SuppressWarnings("rawtypes")
    @FXML
    private ComboBox<AdditionalData> additionalDataCombobox;

    @SuppressWarnings("rawtypes")
    private ObservableList tableItems = FXCollections.observableList(new LinkedList());

    // The following methods switch between additional data views.
    @SuppressWarnings("unchecked")
    private void initAdditionalData() {
        // Resize to fit columns, as per https://stackoverflow.com/a/22488513/27310
        additionalDataTableView.setColumnResizePolicy((param) -> true);

        // Set up additional data objects.
        additionalDataTableView.setRowFactory(table -> {
            @SuppressWarnings("rawtypes")
            TableRow row = new TableRow<>();

            row.setOnMouseClicked(event -> {
                if (row.isEmpty())
                    return;
                Object item = row.getItem();

                if (event.getClickCount() == 2) {
                    // Try opening the detailed view on this item -- if we can.
                    binomialChangesView.getProjectView().openDetailedView(item);
                }
            });

            return row;
        });
        additionalDataTableView.setItems(new SortedList<>(tableItems));

        // Set up events.
        additionalDataCombobox.getSelectionModel().selectedItemProperty()
                .addListener((Observable o) -> additionalDataUpdateList());
        additionalListView.getSelectionModel().setSelectionMode(SelectionMode.SINGLE);
        additionalListView.getSelectionModel().selectedItemProperty()
                .addListener((Observable o) -> additionalDataUpdateTable());

        additionalDataCombobox.getSelectionModel().select(0);

        // When the change is changed, select an item.
        changesTableView.getSelectionModel().getSelectedItems()
                .addListener((ListChangeListener<Change>) c -> additionalDataUpdateList());
    }

    private void additionalDataUpdateList() {
        // Which AdditionalData and ListOf are we in right now?
        AdditionalData aData = additionalDataCombobox.getSelectionModel().getSelectedItem();

        // No aData? Do nothing!
        if (aData == null)
            return;

        // Object currentSelection = additionalListView.getSelectionModel().getSelectedItem();

        additionalListView.setItems(FXCollections.observableList(aData.getList()));
        additionalListView.getSelectionModel().clearAndSelect(0);

        // This is also the right time to set up columns for the table.
        additionalDataTableView.getColumns().clear();
        additionalDataTableView.getColumns().addAll(aData.getColumns());

        // additionalListView.getSelectionModel().select(prevSelection);
    }

    private void additionalDataUpdateTable() {
        // Which AdditionalData and ListOf are we in right now?
        AdditionalData aData = additionalDataCombobox.getSelectionModel().getSelectedItem();

        // Redraw the table.
        tableItems.clear();
        tableItems.addAll(aData.getTableRowsFor(additionalListView.getSelectionModel().getSelectedItem()));
    }

    // The following AdditionalData objects provide all the additional data views we need.
    @SuppressWarnings("rawtypes")
    private void updateAdditionalData() {
        ObservableList<AdditionalData> addDataItems = FXCollections.observableArrayList();

        // Done!
        additionalDataCombobox.setItems(addDataItems);

        // We can just about get away with doing this for around ADDITIONAL_DATA_CHANGE_COUNT_LIMIT changes.
        // if(dataset.getAllChanges().count() > ADDITIONAL_DATA_CHANGE_COUNT_LIMIT) return;
        // TODO: fix this by lazy-evaluating these durned lists.

        // 0. Summary.
        addDataItems.add(createSummaryAdditionalData());

        // 1.5. Changes by name
        LOGGER.info("Creating changes by potential change additional data");
        addDataItems.add(createChangesByPotentialChangeAdditionalData());
        LOGGER.info("Finished changes by potential change additional data");

        // 1. Changes by name
        /*
        LOGGER.info("Creating changes by name additional data");
        addDataItems.add(createChangesByNameAdditionalData());
        LOGGER.info("Finished changes by name additional data");
            
        // 2. Data by name
        LOGGER.info("Creating data by name additional data");
        addDataItems.add(createDataByNameAdditionalData());
        LOGGER.info("Finished changes by name additional data");
        */

        // 3. Changes by subname
        //LOGGER.info("Creating changes by subnames additional data");
        //addDataItems.add(createChangesBySubnamesAdditionalData());
        //LOGGER.info("Finished changes by subname additional data");

        // 4. Data in this dataset
        /*
        LOGGER.info("Creating data by name additional data");
        addDataItems.add(createDataAdditionalData());
        LOGGER.info("Finished changes by name additional data");
        */

        // 5. Properties
        //LOGGER.info("Creating properties additional data");
        //addDataItems.add(createPropertiesAdditionalData());
        //LOGGER.info("Finished properties additional data");         

        additionalDataCombobox.getSelectionModel().clearAndSelect(0);

        LOGGER.info("Finished updateAdditionalData()");
    }

    /*
    private AdditionalData<String, DatasetRow> createDataAdditionalData() {
       Map<String, List<DatasetRow>> map = new HashMap<>();
       map.put("All data (" + dataset.getRowCount() + " rows)", new ArrayList<DatasetRow>(dataset.rowsProperty()));
           
       List<TableColumn<DatasetRow, String>> cols = new LinkedList<>(); 
       for(DatasetColumn col: dataset.getColumns()) {
     TableColumn<DatasetRow, String> column = new TableColumn<>(col.getName());
     column.setCellValueFactory(cdf -> new ReadOnlyStringWrapper(cdf.getValue().get(col)));
     cols.add(column);
       }
           
       return new AdditionalData(
     "Data",
     Arrays.asList("All data (" + dataset.getRowCount() + " rows)"),
     map,
     cols
       );
    }*/

    private AdditionalData<String, Map.Entry<String, String>> createSummaryAdditionalData() {
        List<Map.Entry<String, String>> summary = new ArrayList<>();

        // Calculate some summary values.
        long numChanges = potentialChanges.size();
        summary.add(new AbstractMap.SimpleEntry<String, String>("Number of binomial changes",
                String.valueOf(potentialChanges.size())));

        // How many have a note?
        summary.add(new AbstractMap.SimpleEntry<String, String>("Number of changes with annotations",
                String.valueOf(potentialChanges.stream().filter(ch -> ch.getNote().isPresent()).count())));

        // Calculate overall addition and deletion.

        // Summarize by types of change.
        Map<ChangeType, List<Change>> potentialChangesByType = potentialChanges.stream()
                .collect(Collectors.groupingBy(ch -> ch.getType()));
        summary.addAll(potentialChangesByType.keySet().stream().sorted()
                .map(type -> new AbstractMap.SimpleEntry<String, String>(
                        "Number of binomial changes of type '" + type + "'",
                        String.valueOf(potentialChangesByType.get(type).size())))
                .collect(Collectors.toList()));

        // Summarize by reason.
        Map<String, Long> potentialChangesByReason = potentialChanges.stream()
                .map(pc -> pc.getType() + " because of " + calculateReason(pc))
                .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
        summary.addAll(potentialChangesByReason.keySet().stream().sorted()
                .map(reason -> new AbstractMap.SimpleEntry<String, String>(
                        "Number of binomial changes for reason '" + reason + "'",
                        potentialChangesByReason.get(reason).toString()))
                .collect(Collectors.toList()));

        // Make an additional data about it.
        Map<String, List<Map.Entry<String, String>>> map = new HashMap<>();
        map.put("Summary", summary);

        List<TableColumn<Map.Entry<String, String>, String>> cols = new ArrayList<>();

        TableColumn<Map.Entry<String, String>, String> colKey = new TableColumn<>("Property");
        colKey.setCellValueFactory(cdf -> new ReadOnlyStringWrapper(cdf.getValue().getKey()));
        cols.add(colKey);

        TableColumn<Map.Entry<String, String>, String> colValue = new TableColumn<>("Value");
        colValue.setCellValueFactory(cdf -> new ReadOnlyStringWrapper(cdf.getValue().getValue()));
        cols.add(colValue);

        TableColumn<Map.Entry<String, String>, String> colPercent = new TableColumn<>("Percentage");
        colPercent.setCellValueFactory(cdf -> {
            String result = "NA";

            if (cdf.getValue() != null && cdf.getValue().getValue() != null
                    && !cdf.getValue().getValue().equals("null")) {
                long longVal = Long.parseLong(cdf.getValue().getValue());

                result = (longVal == 0) ? "NA" : (((double) longVal / numChanges * 100) + "%");
            }

            return new ReadOnlyStringWrapper(result);
        });
        cols.add(colPercent);

        return new AdditionalData<String, Entry<String, String>>("Summary", Arrays.asList("Summary"), map, cols);
    }

    private AdditionalData<Name, Map.Entry<String, String>> createDataByNameAdditionalData() {
        // Which names area we interested in?
        List<PotentialChange> selectedChanges = changesTableView.getItems();

        List<Name> names = selectedChanges.stream().flatMap(ch -> {
            Set<Name> allNames = ch.getAllNames();
            List<Name> binomials = allNames.stream().flatMap(n -> n.asBinomial()).collect(Collectors.toList());
            List<Name> genus = allNames.stream().flatMap(n -> n.asGenus()).collect(Collectors.toList());

            allNames.addAll(binomials);
            allNames.addAll(genus);

            return allNames.stream();
        }).distinct().sorted().collect(Collectors.toList());

        Project proj = binomialChangesView.getProjectView().getProject();

        Map<Name, List<Map.Entry<String, String>>> map = new HashMap<>();
        for (Name n : names) {
            Map<DatasetColumn, Set<String>> dataForName = proj.getDataForName(n);
            Map<String, String> mapForName = dataForName.entrySet().stream()
                    .collect(Collectors.toMap(
                            (Map.Entry<DatasetColumn, Set<String>> entry) -> entry.getKey().toString(),
                            (Map.Entry<DatasetColumn, Set<String>> entry) -> entry.getValue().toString()));
            map.put(n, new ArrayList<>(mapForName.entrySet()));
        }

        List<TableColumn<Map.Entry<String, String>, String>> cols = new ArrayList<>();

        TableColumn<Map.Entry<String, String>, String> colKey = new TableColumn<>("Key");
        colKey.setCellValueFactory(cdf -> new ReadOnlyStringWrapper(cdf.getValue().getKey()));
        cols.add(colKey);

        TableColumn<Map.Entry<String, String>, String> colValue = new TableColumn<>("Value");
        colValue.setCellValueFactory(cdf -> new ReadOnlyStringWrapper(cdf.getValue().getValue()));
        cols.add(colValue);

        return new AdditionalData<Name, Map.Entry<String, String>>("Data by name", names, map, cols,
                changes -> changes.stream().flatMap(ch -> ch.getAllNames().stream()).collect(Collectors.toList()));
    }

    private TableColumn<Change, String> getChangeTableColumn(String colName, Function<Change, String> func) {
        TableColumn<Change, String> col = new TableColumn<>(colName);
        col.setCellValueFactory(cdf -> new ReadOnlyStringWrapper(func.apply(cdf.getValue())));
        return col;
    }

    private AdditionalData<PotentialChange, Change> createChangesByPotentialChangeAdditionalData() {
        // Which names area we interested in?
        Project proj = binomialChangesView.getProjectView().getProject();

        Map<PotentialChange, List<Change>> changesByPotentialChangeAsList = new HashMap<>();
        for (PotentialChange pc : potentialChanges) {
            changesByPotentialChangeAsList.put(pc, new ArrayList<>(changesByPotentialChange.get(pc)));
        }

        List<TableColumn<Change, String>> cols = new ArrayList<>();

        cols.add(getChangeTableColumn("Dataset", ch -> ch.getDataset().toString()));
        cols.add(getChangeTableColumn("Type", ch -> ch.getType().toString()));
        cols.add(getChangeTableColumn("From", ch -> ch.getFrom().toString()));
        cols.add(getChangeTableColumn("To", ch -> ch.getTo().toString()));
        cols.add(getChangeTableColumn("Note", ch -> ch.getNote().orElse("")));

        return new AdditionalData<PotentialChange, Change>("Changes by potential change", potentialChanges,
                changesByPotentialChangeAsList, cols, changes -> (List) changes);
    }

    private AdditionalData<Name, Change> createChangesByNameAdditionalData() {
        // Which names area we interested in?
        List<PotentialChange> selectedChanges = changesTableView.getItems();

        List<Name> names = selectedChanges.stream().flatMap(ch -> {
            Set<Name> allNames = ch.getAllNames();
            List<Name> binomials = allNames.stream().flatMap(n -> n.asBinomial()).collect(Collectors.toList());
            List<Name> genus = allNames.stream().flatMap(n -> n.asGenus()).collect(Collectors.toList());

            allNames.addAll(binomials);
            allNames.addAll(genus);

            return allNames.stream();
        }).distinct().sorted().collect(Collectors.toList());

        Project proj = binomialChangesView.getProjectView().getProject();

        Map<Name, List<Change>> map = new HashMap<>();
        for (Name n : names) {
            map.put(n, proj.getDatasets().stream().flatMap(ds -> ds.getAllChanges())
                    .filter(ch -> ch.getAllNames().contains(n)).collect(Collectors.toList()));
        }

        List<TableColumn<Change, String>> cols = new ArrayList<>();

        cols.add(getChangeTableColumn("Dataset", ch -> ch.getDataset().toString()));
        cols.add(getChangeTableColumn("Type", ch -> ch.getType().toString()));
        cols.add(getChangeTableColumn("From", ch -> ch.getFrom().toString()));
        cols.add(getChangeTableColumn("To", ch -> ch.getTo().toString()));
        cols.add(getChangeTableColumn("Note", ch -> ch.getNote().orElse("")));

        return new AdditionalData<Name, Change>("Changes by name", names, map, cols,
                changes -> changes.stream().flatMap(ch -> ch.getAllNames().stream()).collect(Collectors.toList()));
    }

    private AdditionalData<Name, Change> createChangesBySubnamesAdditionalData() {
        // Which names area we interested in?
        List<PotentialChange> selectedChanges = changesTableView.getItems();

        List<Name> names = selectedChanges.stream().flatMap(ch -> {
            Set<Name> allNames = ch.getAllNames();
            List<Name> binomials = allNames.stream().flatMap(n -> n.asBinomial()).collect(Collectors.toList());
            List<Name> genus = allNames.stream().flatMap(n -> n.asGenus()).collect(Collectors.toList());

            allNames.addAll(binomials);
            allNames.addAll(genus);

            return allNames.stream();
        }).distinct().sorted().collect(Collectors.toList());

        Project proj = binomialChangesView.getProjectView().getProject();

        Map<Name, List<Change>> map = new HashMap<>();
        for (Name query : names) {
            map.put(query,
                    proj.getDatasets().stream().flatMap(ds -> ds.getAllChanges())
                            .filter(ch -> ch.getAllNames().contains(query)
                                    || ch.getAllNames().stream().flatMap(n -> n.asBinomial())
                                            .anyMatch(binomial -> query.equals(binomial))
                                    || ch.getAllNames().stream().flatMap(n -> n.asGenus())
                                            .anyMatch(genus -> query.equals(genus)))
                            .collect(Collectors.toList()));
        }

        List<TableColumn<Change, String>> cols = new ArrayList<>();

        cols.add(getChangeTableColumn("Dataset", ch -> ch.getDataset().toString()));
        cols.add(getChangeTableColumn("Type", ch -> ch.getType().toString()));
        cols.add(getChangeTableColumn("From", ch -> ch.getFrom().toString()));
        cols.add(getChangeTableColumn("To", ch -> ch.getTo().toString()));
        cols.add(getChangeTableColumn("Note", ch -> ch.getNote().orElse("")));

        return new AdditionalData<Name, Change>("Changes by subname", names, map, cols,
                changes -> changes.stream().flatMap(ch -> ch.getAllNames().stream()).collect(Collectors.toList()));
    }
}