com.github.fengtan.sophie.tables.DocumentsTable.java Source code

Java tutorial

Introduction

Here is the source code for com.github.fengtan.sophie.tables.DocumentsTable.java

Source

/**
 * Sophie - A Solr browser and administration tool
 * 
 * Copyright (C) 2016 fengtan<https://github.com/fengtan>
 *
 * 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.github.fengtan.sophie.tables;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.Writer;
import java.text.SimpleDateFormat;
import java.util.AbstractList;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.lang3.StringUtils;
import org.apache.solr.client.solrj.SolrQuery;
import org.apache.solr.client.solrj.SolrQuery.ORDER;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.impl.NoOpResponseParser;
import org.apache.solr.client.solrj.request.QueryRequest;
import org.apache.solr.client.solrj.response.FacetField;
import org.apache.solr.client.solrj.response.FacetField.Count;
import org.apache.solr.client.solrj.response.LukeResponse.FieldInfo;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
import org.apache.solr.common.SolrException;
import org.apache.solr.common.SolrInputDocument;
import org.apache.solr.common.luke.FieldFlag;
import org.apache.solr.common.params.ModifiableSolrParams;
import org.apache.solr.common.util.NamedList;
import org.eclipse.jface.dialogs.IDialogConstants;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.CCombo;
import org.eclipse.swt.custom.TableEditor;
import org.eclipse.swt.events.KeyAdapter;
import org.eclipse.swt.events.KeyEvent;
import org.eclipse.swt.events.SelectionAdapter;
import org.eclipse.swt.events.SelectionEvent;
import org.eclipse.swt.events.SelectionListener;
import org.eclipse.swt.graphics.Color;
import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.layout.GridData;
import org.eclipse.swt.widgets.Composite;
import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Event;
import org.eclipse.swt.widgets.FileDialog;
import org.eclipse.swt.widgets.Listener;
import org.eclipse.swt.widgets.Table;
import org.eclipse.swt.widgets.TableColumn;
import org.eclipse.swt.widgets.TableItem;

import com.github.fengtan.sophie.Sophie;
import com.github.fengtan.sophie.beans.Config;
import com.github.fengtan.sophie.beans.SolrUtils;
import com.github.fengtan.sophie.beans.SophieException;
import com.github.fengtan.sophie.dialogs.EditDateValueDialog;
import com.github.fengtan.sophie.dialogs.EditListValueDialog;
import com.github.fengtan.sophie.dialogs.EditTextValueDialog;
import com.github.fengtan.sophie.dialogs.EditValueDialog;
import com.github.fengtan.sophie.dialogs.ExceptionDialog;
import com.github.fengtan.sophie.toolbars.ChangeListener;

/**
 * Table listing Solr documents.
 */
public class DocumentsTable {

    /**
     * Yellow - color of modified documents.
     */
    private static final Color YELLOW = Display.getCurrent().getSystemColor(SWT.COLOR_YELLOW);

    /**
     * Red - color of deleted documents.
     */
    private static final Color RED = Display.getCurrent().getSystemColor(SWT.COLOR_RED);

    /**
     * Green - color of added documents.
     */
    private static final Color GREEN = Display.getCurrent().getSystemColor(SWT.COLOR_GREEN);

    /**
     * Label displayed in lieu of the value when the field is not stored.
     */
    private static final String LABEL_NOT_STORED = "(not stored)";

    /**
     * Label displayed then the document has no value for a specific field.
     */
    private static final String LABEL_EMPTY = "(empty)";

    /**
     * Page size - how many documents do we fetch from Solr at a time.
     */
    private static final int PAGE_SIZE = Config.getDocumentsPageSize();

    /**
     * Facet limit - how many facets values do we display at most in each
     * filter.
     */
    private static final int FACET_LIMIT = Config.getDocumentsFacetsLimit();

    /**
     * Boost assigned to new field values.
     */
    private static final float BOOST = 1.0f;

    /**
     * Cached documents fetched from the server, keyed by page ID.
     */
    private Map<Integer, SolrDocumentList> pages;

    /**
     * Current active filters (filter values keyed by filter name).
     */
    private Map<String, String> filters = new HashMap<String, String>();

    /**
     * Unique key field name.
     */
    private String uniqueField;

    /**
     * Name of the column currently sorted.
     */
    private String sortField;

    /**
     * Whether the table is currently sorted in ascending or descending order.
     */
    private ORDER sortOrder;

    /**
     * List of documents locally updated.
     */
    private List<SolrDocument> documentsUpdated;

    /**
     * List of documents locally deleted.
     */
    private List<SolrDocument> documentsDeleted;

    /**
     * List of documents locally added.
     */
    private List<SolrDocument> documentsAdded;

    /**
     * Change listener - allows the 'Upload' button to detect whether there are
     * local changes to send to Solr.
     */
    private ChangeListener changeListener;

    /**
     * Table.
     */
    private Table table;

    /**
     * Table editor.
     */
    private TableEditor editor;

    /**
     * Parent composite.
     */
    private Composite composite;

    /**
     * Create a new table listing Solr documents.
     * 
     * @param composite
     *            Parent composite.
     * @param selectionListener
     *            Selection listener - allows certain buttons to be enabled only
     *            when a document is selected.
     * @param changeListener
     *            Change listener - allows the 'Upload' button to detect whether
     *            there are local changes to send to Solr.
     * @throws SophieException
     *             If the table could not be created.
     */
    public DocumentsTable(Composite composite, SelectionListener selectionListener, ChangeListener changeListener)
            throws SophieException {
        // Instantiate table.
        this.composite = composite;
        createTable();
        this.editor = new TableEditor(table);
        this.changeListener = changeListener;
        table.addSelectionListener(selectionListener);

        // Add first column (row #).
        TableColumn columnNumber = new TableColumn(table, SWT.LEFT);
        columnNumber.setText("#");
        columnNumber.pack();

        // Get facet values.
        List<FieldInfo> fields = SolrUtils.getRemoteFields();
        Map<String, FacetField> facets;
        try {
            facets = getRemoteFacets(fields);
        } catch (SophieException e) {
            Sophie.log.warn("Unable to refresh filters", e);
            facets = Collections.emptyMap();
        }

        // Sort fields by field names.
        Collections.sort(fields, new Comparator<FieldInfo>() {
            @Override
            public int compare(FieldInfo field1, FieldInfo field2) {
                return (field1 == null) ? -1 : field1.getName().compareTo(field2.getName());
            }
        });

        // Add subsequent columns (fields).
        for (int i = 0; i < fields.size(); i++) {
            FieldInfo field = fields.get(i);
            FacetField facet = facets.get(field.getName());
            addField(field, facet, i + 1); // First row is used for row #.
        }

        // Initialize cache + row count.
        refresh();
    }

    /**
     * Create the Table.
     */
    private void createTable() {
        // Instantiate the table.
        int style = SWT.SINGLE | SWT.BORDER | SWT.H_SCROLL | SWT.V_SCROLL | SWT.FULL_SELECTION | SWT.HIDE_SELECTION
                | SWT.VIRTUAL;
        table = new Table(composite, style);

        // Set layout.
        GridData gridData = new GridData(GridData.FILL_BOTH);
        gridData.grabExcessVerticalSpace = true;
        table.setLayoutData(gridData);

        // Set styles.
        table.setLinesVisible(true);
        table.setHeaderVisible(true);

        // Add KeyListener to delete documents.
        table.addKeyListener(new KeyAdapter() {
            @Override
            public void keyPressed(KeyEvent event) {
                if (event.keyCode == SWT.DEL) {
                    deleteSelectedDocument();
                }
            }
        });

        // Initialize item count to 1 so we can populate the first row with
        // filters.
        table.setItemCount(1);

        // Populate subsequent rows with remote documents (virtual table).
        table.addListener(SWT.SetData, new Listener() {
            @Override
            public void handleEvent(Event event) {
                TableItem item = (TableItem) event.item;
                int rowIndex = table.indexOf(item);

                // The first line is populated by filters.
                if (rowIndex == 0) {
                    return;
                }

                SolrDocument document;
                // The last lines are populated by local additions.
                if (rowIndex >= table.getItemCount() - documentsAdded.size()) {
                    document = documentsAdded.get(documentsAdded.size() - table.getItemCount() + rowIndex);
                    item.setBackground(GREEN);
                } else {
                    try {
                        // rowIndex - 1 since the first line is used for
                        // filters.
                        document = getRemoteDocument(rowIndex - 1);
                    } catch (SophieException e) {
                        ExceptionDialog.open(composite.getShell(),
                                new SophieException("Unable to populate table", e));
                        return;
                    }
                }

                // First column is used to show the row ID.
                item.setText(0, Integer.toString(rowIndex));

                // Subsequent columns are used to show field values.
                for (int index = 1; index < table.getColumnCount(); index++) {
                    TableColumn column = table.getColumn(index);
                    String fieldName = (String) column.getData("fieldName");
                    FieldInfo field = (FieldInfo) column.getData("field");
                    // If field is not stored, display message.
                    if (!SolrUtils.getFlags(field).contains(FieldFlag.STORED)) {
                        item.setText(index, LABEL_NOT_STORED);
                    } else {
                        Object value = document.getFieldValue(fieldName);
                        item.setText(index, value == null ? StringUtils.EMPTY : value.toString());
                    }
                }

                // Store document in item.
                item.setData("document", document);
            }
        });

        // Add doubleclick listener to edit values.
        table.addListener(SWT.MouseDoubleClick, new Listener() {
            public void handleEvent(Event event) {
                Point point = new Point(event.x, event.y);
                TableItem item = table.getItem(point);
                if (item == null) {
                    return;
                }
                // The first row is used for filters.
                if (table.indexOf(item) == 0) {
                    return;
                }
                // We add 1 since the first column is used for row ID's.
                for (int i = 1; i < table.getColumnCount(); i++) {
                    Rectangle rect = item.getBounds(i);
                    if (rect.contains(point)) {
                        SolrDocument document = (SolrDocument) item.getData("document");
                        String fieldName = (String) table.getColumn(i).getData("fieldName");
                        FieldInfo field = (FieldInfo) table.getColumn(i).getData("field");
                        Object defaultValue = document.getFieldValue(fieldName);
                        // Add editor dialog:
                        // - list widget if we are dealing with a multi-valued
                        // field.
                        // - datepicker if we field type contains "date".
                        // - text if we are dealing with any other field type.
                        EditValueDialog dialog;
                        if (SolrUtils.getFlags(field).contains(FieldFlag.MULTI_VALUED)) {
                            dialog = new EditListValueDialog(composite.getShell(), (AbstractList<?>) defaultValue);
                        } else if (StringUtils.containsIgnoreCase(field.getType(), "date")) {
                            dialog = new EditDateValueDialog(composite.getShell(), (Date) defaultValue);
                        } else {
                            String oldValueString = Objects.toString(defaultValue, StringUtils.EMPTY);
                            dialog = new EditTextValueDialog(composite.getShell(), oldValueString);
                        }
                        dialog.open();
                        if (dialog.getReturnCode() != IDialogConstants.OK_ID) {
                            return;
                        }
                        Object value = dialog.getValue();
                        if (!Objects.equals(defaultValue, value)) {
                            updateDocument(item, i, value);
                        }
                    }
                }
            }
        });
    }

    /**
     * Get field names.
     * 
     * @return List of field names.
     */
    public List<String> getFieldNames() {
        List<String> fieldNames = new ArrayList<String>();
        for (int i = 0; i < table.getColumnCount(); i++) {
            String fieldName = (String) table.getColumn(i).getData("fieldName");
            // Field name is empty in the first column (row #).
            if (StringUtils.isNotEmpty(fieldName)) {
                fieldNames.add(fieldName);
            }
        }
        return fieldNames;
    }

    /**
     * Add (if value is not empty) or remove (if value is empty) a filter.
     * 
     * @param fieldName
     *            Field name.
     * @param value
     *            Filter value.
     */
    private void updateFilters(String fieldName, String value) {
        if (StringUtils.isEmpty(value)) {
            filters.remove(fieldName);
        } else {
            filters.put(fieldName, value);
        }
    }

    /**
     * Get how many documents are in the remote index.
     * 
     * @return How many documents are in the remote index.
     * @throws SophieException
     *             If the number of documents could not be determined.
     */
    private int getRemoteCount() throws SophieException {
        SolrQuery query = getBaseQuery(0, 0);
        try {
            // Solr returns a long, table expects an int.
            long count = Sophie.client.query(query).getResults().getNumFound();
            return Integer.parseInt(String.valueOf(count));
        } catch (SolrServerException | IOException | SolrException e) {
            throw new SophieException("Unable to count remote documents", e);
        }
    }

    /**
     * Get a list of remote facets keyed by field name.
     * 
     * @param fields
     *            Fields.
     * @return List of facets keyed by field name.
     * @throws SophieException
     *             If facets could not be fetched.
     */
    private Map<String, FacetField> getRemoteFacets(List<FieldInfo> fields) throws SophieException {
        // Prepare query.
        SolrQuery query = getBaseQuery(0, 0);
        query.setFacet(true);
        query.setFacetSort("index");
        query.setFacetLimit(FACET_LIMIT);
        query.setFacetMissing(true);

        // For each field, determine whether Solr can generate a facet (fq works
        // only on indexed fields). If yes, then list that field in the query.
        for (FieldInfo field : fields) {
            if (SolrUtils.getFlags(field).contains(FieldFlag.INDEXED)) {
                query.addFacetField(field.getName());
            }
        }

        // Send query.
        Map<String, FacetField> facets = new HashMap<String, FacetField>();
        try {
            for (FacetField facet : Sophie.client.query(query).getFacetFields()) {
                facets.put(facet.getName(), facet);
            }
        } catch (SolrServerException | IOException | SolrException e) {
            throw new SophieException("Unable to fetch remote facets", e);
        }

        // Return facets keyed by field name.
        return facets;
    }

    /**
     * Get document to display at a specific row index. The document is
     * retrieved from the cache. If it is not in the cache, then we fetch a new
     * page of results and we populate the cache.
     * 
     * @param rowIndex
     *            Row index.
     * @return Solr document.
     * @throws SophieException
     *             If the document could not be fetched from Solr.
     */
    private SolrDocument getRemoteDocument(int rowIndex) throws SophieException {
        // Compute page ID.
        int page = rowIndex / PAGE_SIZE;

        // If page is not in the cache, then fetch it from Solr and populate the
        // cache.
        if (!pages.containsKey(page)) {
            SolrQuery query = getBaseQuery(page * PAGE_SIZE, PAGE_SIZE);
            query.setSort(sortField, sortOrder);
            try {
                pages.put(page, Sophie.client.query(query).getResults());
            } catch (SolrServerException | IOException | SolrException e) {
                throw new SophieException("Unable to fetch remote document " + rowIndex, e);
            }
        }

        // Return Solr document from the cache.
        return pages.get(page).get(rowIndex % PAGE_SIZE);
    }

    /**
     * Get select query and populate the start/rows/fq parameters.
     * 
     * @param start
     *            Offset at which Solr should being returning documents.
     * @param rows
     *            How many rows Solr should return.
     * @return Solr query.
     */
    private SolrQuery getBaseQuery(int start, int rows) {
        SolrQuery query = new SolrQuery("*:*");
        query.setStart(start);
        query.setRows(rows);
        // Add filters.
        for (Entry<String, String> filter : filters.entrySet()) {
            if (StringUtils.equals(filter.getValue(), LABEL_EMPTY)) {
                // Empty value needs a special syntax.
                query.addFilterQuery("-" + filter.getKey() + ":[* TO *]");
            } else {
                // Colons in value need to be escaped to avoid a syntax error.
                query.addFilterQuery(filter.getKey() + ":" + filter.getValue().replace(":", "\\:"));
            }
        }
        return query;
    }

    /**
     * Get the currently selected document.
     * 
     * @return Currently selected document, or null if not row is selected.
     */
    public SolrDocument getSelectedDocument() {
        TableItem[] items = table.getSelection();
        if (items.length == 0) {
            return null;
        }
        return (SolrDocument) items[0].getData("document");
    }

    /**
     * Clear table and flush internal cache. This causes the table to be
     * re-populated with remote documents.
     *
     * TODO re-populate columns/filters ?
     * 
     * @throws SophieException
     *             If the documents could not be fetched from Solr.
     */
    public void refresh() throws SophieException {
        // Reset unique key, sort field and sort order
        // Rows always need to be sorted so locally updated documents do not end
        // up at the end of the list after the user upload them. We sort by
        // unique field by default.
        uniqueField = SolrUtils.getRemoteUniqueField(false);
        sortField = uniqueField;
        sortOrder = ORDER.asc;

        // Flush cache.
        documentsUpdated = new ArrayList<SolrDocument>();
        documentsDeleted = new ArrayList<SolrDocument>();
        documentsAdded = new ArrayList<SolrDocument>();
        pages = new HashMap<Integer, SolrDocumentList>();
        changeListener.unchanged();

        // Clear table.
        try {
            // First row is for filters, the rest is for documents (remote +
            // locally added - though no local addition since we have just
            // refreshed documents).
            table.setItemCount(1 + getRemoteCount());
            table.clearAll();
        } catch (SophieException e) {
            table.setItemCount(1);
            table.clearAll();
            throw new SophieException("Unable to refresh documents from Solr server", e);
        }
    }

    /**
     * Export documents into CSV file.
     * 
     * @throws SophieException
     *             If the documents could not be exported into a CSV file.
     */
    public void export() throws SophieException {
        // Open dialog to let the user select where the file will be dumped.
        FileDialog dialog = new FileDialog(table.getShell(), SWT.SAVE);
        dialog.setFilterNames(new String[] { "CSV Files (*.csv)", "All Files (*.*)" });
        dialog.setFilterExtensions(new String[] { "*.csv", "*.*" });
        String date = new SimpleDateFormat("yyyy-MM-dd-HH:mm").format(new Date());
        dialog.setFileName("documents_" + date + ".csv");
        String path = dialog.open();

        // User did not selected any location.
        if (path == null) {
            return;
        }

        // Send Solr query and write result into file.
        SolrQuery query = getBaseQuery(0, table.getItemCount());
        QueryRequest request = new QueryRequest(query);
        request.setResponseParser(new NoOpResponseParser("csv"));
        NamedList<Object> response;
        try {
            response = Sophie.client.request(request);
        } catch (SolrServerException | IOException | SolrException e) {
            throw new SophieException("Unable to get CSV documents from Solr", e);
        }

        String csv = (String) response.get("response");
        try {
            Writer writer = new PrintWriter(path, "UTF-8");
            writer.write(csv);
            writer.close();
        } catch (IOException e) {
            throw new SophieException("Unable to write into file " + path, e);
        }
    }

    /**
     * Upload local changes to the Solr server.
     * 
     * @throws SophieException
     *             If the local changes could not be uploaded to the Solr
     *             server.
     */
    public void upload() throws SophieException {
        // Upload local updates.
        for (SolrDocument document : documentsUpdated) {
            SolrInputDocument input = new SolrInputDocument();
            for (String name : document.getFieldNames()) {
                input.addField(name, document.getFieldValue(name), 1.0f);
            }
            try {
                // Returned object seems to have no relevant information.
                Sophie.client.add(input);
            } catch (SolrServerException | IOException | SolrException e) {
                throw new SophieException("Unable to update document in index: " + input.toString(), e);
            }
        }
        // Upload local deletions.
        for (SolrDocument document : documentsDeleted) {
            String id = Objects.toString(document.getFieldValue(uniqueField), StringUtils.EMPTY);
            try {
                Sophie.client.deleteById(id);
            } catch (SolrServerException | IOException | SolrException e) {
                throw new SophieException("Unable to delete document from index: " + id, e);
            }
        }
        // Upload local additions.
        for (SolrDocument document : documentsAdded) {
            SolrInputDocument input = new SolrInputDocument();
            for (String name : document.getFieldNames()) {
                input.addField(name, document.getFieldValue(name), BOOST);
            }
            try {
                // Returned object seems to have no relevant information.
                Sophie.client.add(input);
            } catch (SolrServerException | IOException | SolrException e) {
                throw new SophieException("Unable to add document to index: " + input.toString(), e);
            }
        }
        // Commit the index.
        try {
            Sophie.client.commit();
        } catch (SolrServerException | IOException | SolrException e) {
            throw new SophieException("Unable to commit index", e);
        }

        // Refresh so user can see the new state of the server.
        refresh();
    }

    /**
     * Restore index from a backup
     * 
     * @param backupName
     *            Backup file name.
     * @throws SophieException
     *             If the backup could not be restored.
     */
    public void restore(String backupName) throws SophieException {
        ModifiableSolrParams params = new ModifiableSolrParams();
        params.set("command", "restore");
        params.set("name", backupName);
        QueryRequest request = new QueryRequest(params);
        request.setPath("/replication");
        try {
            Sophie.client.request(request);
            refresh();
        } catch (SolrServerException | IOException | SolrException e) {
            throw new SophieException("Unable to restore backup \"" + backupName + "\"", e);
        }
        refresh();
    }

    /**
     * Update a document locally.
     * 
     * @param item
     *            Row containing the document to update.
     * @param columnIndex
     *            Column index of the field to update.
     * @param newValue
     *            New value.
     */
    private void updateDocument(TableItem item, int columnIndex, Object newValue) {
        SolrDocument document = (SolrDocument) item.getData("document");
        // The row may not contain any document (e.g. the first row, which
        // contains the filters).
        if (document == null) {
            return;
        }

        String fieldName = (String) table.getColumn(columnIndex).getData("fieldName");
        // We reduce by 1 since the first column is used for row ID.
        document.setField(fieldName, newValue);
        item.setText(columnIndex, Objects.toString(newValue, StringUtils.EMPTY));
        changeListener.changed();

        // If document was locally added, then leave it in documentsAdded and
        // return so it remains green.
        if (documentsAdded.contains(document)) {
            return;
        }

        // If document was locally deleted, then remove it from documentsDeleted
        // and let it go into documentsUpdated.
        if (documentsDeleted.contains(document)) {
            documentsDeleted.remove(document);
        }

        // Add document to documentsUpdated if it is not already there.
        if (!documentsUpdated.contains(document)) {
            documentsUpdated.add(document);
        }
        item.setBackground(YELLOW);
    }

    /**
     * Delete the selected document locally.
     * 
     * @param item
     *            Row containing the document to delete.
     */
    public void deleteSelectedDocument() {
        SolrDocument document = getSelectedDocument();
        // The row may not contain any document (e.g. the first row, which
        // contains the filters).
        if (document == null) {
            return;
        }

        // If document is already deleted locally, then do nothing.
        if (documentsDeleted.contains(document)) {
            return;
        }

        int rowIndex = table.getSelectionIndex();
        // If document was locally added, then just remove it from local
        // additions.
        if (documentsAdded.contains(document)) {
            documentsAdded.remove(document);
            table.remove(rowIndex);
            changeListener.changed();
            return;
        }

        // If document was locally updated, then remove it from documentsUpdated
        // and let it go into documentsDeleted.
        if (documentsUpdated.contains(document)) {
            documentsUpdated.remove(document);
        }

        // Remove document.
        documentsDeleted.add(document);
        table.getItem(rowIndex).setBackground(RED);
        changeListener.changed();
    }

    /**
     * Add a document locally.
     * 
     * @param document
     *            The new document.
     */
    public void addDocument(SolrDocument document) {
        documentsAdded.add(document);
        table.setItemCount(table.getItemCount() + 1);
        // Scroll to the bottom of the table so we reveal the new document.
        table.setTopIndex(table.getItemCount() - 1);
        changeListener.changed();
    }

    /**
     * Add a new (custom) column to the table.
     * 
     * This is used for dynamic fields, where field name may be "ss_*" but we
     * want the column name to be "ss_foobar".
     * 
     * @param fieldName
     *            New field name.
     * @param fieldInfo
     *            New field definition.
     */
    private void addColumn(final String fieldName, FieldInfo fieldInfo) {
        final TableColumn column = new TableColumn(table, SWT.LEFT);
        // Add space padding so we can see the sort signifier.
        final boolean isFieldSortable = SolrUtils.isFieldSortable(fieldInfo);
        column.setText(fieldName + (isFieldSortable ? "     " : " " + Sophie.SIGNIFIER_UNSORTABLE));
        column.setData("field", fieldInfo);
        column.setData("fieldName", fieldName);
        if (!isFieldSortable) {
            column.setToolTipText("Cannot sort on a field that is not indexed, is multivalued or has doc values");
        }
        // Sort column when click on the header
        column.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(SelectionEvent event) {
                if (!isFieldSortable) {
                    return;
                }
                // Clicking on the current sort field toggles the direction.
                // Clicking on a new field changes the sort field.
                if (StringUtils.equals(sortField, fieldName)) {
                    sortOrder = ORDER.asc.equals(sortOrder) ? ORDER.desc : ORDER.asc;
                } else {
                    sortField = fieldName;
                }
                // Clear signifier on all columns, add signifier on sorted
                // column.
                char signifier = ORDER.asc.equals(sortOrder) ? Sophie.SIGNIFIER_SORTED_ASC
                        : Sophie.SIGNIFIER_SORTED_DESC;
                for (TableColumn c : table.getColumns()) {
                    String columnFieldName = (String) c.getData("fieldName");
                    FieldInfo columnField = (FieldInfo) c.getData("field");
                    if (columnFieldName != null && columnField != null) {
                        if (!SolrUtils.isFieldSortable(columnField)) {
                            c.setText(columnFieldName + " " + Sophie.SIGNIFIER_UNSORTABLE);
                        } else {
                            c.setText(columnFieldName + ((column == c) ? " " + signifier : StringUtils.EMPTY));
                        }
                    }

                }
                // Re-populate table.
                try {
                    refresh();
                } catch (SophieException e) {
                    ExceptionDialog.open(composite.getShell(),
                            new SophieException("Unable to refresh documents from Solr server", e));
                }
            }
        });
        column.pack();
    }

    /**
     * Add a filter (combo).
     * 
     * @param fieldName
     *            Field name.
     * @param field
     *            Field definition.
     * @param facet
     *            Facet - the values will be used to populate the values of the
     *            combo.
     * @param index
     *            Column index.
     */
    private void addCombo(String fieldName, FieldInfo field, final FacetField facet, int index) {
        final CCombo combo = new CCombo(table, SWT.BORDER);
        combo.add(StringUtils.EMPTY);
        // If the number of facet values is the max, then the list of facet
        // values might not be complete. Hence we use a free text field instead
        // of populating the combo.
        if (facet.getValueCount() < FACET_LIMIT) {
            for (Count count : facet.getValues()) {
                combo.add(Objects.toString(count.getName(), LABEL_EMPTY) + " (" + count.getCount() + ")");
            }
            combo.addSelectionListener(new SelectionAdapter() {
                @Override
                public void widgetSelected(SelectionEvent event) {
                    // Extract original value (e.g. "Foobar") from widget value
                    // (e.g. "Foobar (3)")
                    Pattern pattern = Pattern.compile("^(.*) \\([0-9]+\\)$");
                    Matcher matcher = pattern.matcher(combo.getText());
                    // If we found the original value, then populate the combo.
                    // Otherwise, log a warning message.
                    if (matcher.find()) {
                        combo.setText(matcher.group(1));
                    } else {
                        Sophie.log.warn("Unable to extract original value from \"" + combo.getText() + "\"");
                    }

                };
            });
        } else {
            combo.add(LABEL_EMPTY);
        }

        // Fire filters + refresh when user selects a value.
        combo.addSelectionListener(new SelectionAdapter() {
            @Override
            public void widgetSelected(SelectionEvent event) {
                String filterName = facet.getName();
                String filterNewValue = combo.getText();
                String filterOldValue = filters.get(filterName);
                // No need to re-send a request to Solr and the user selected
                // the same filter as the current filter.
                if (StringUtils.equals(filterOldValue, filterNewValue)
                        || (StringUtils.isBlank(filterOldValue) && StringUtils.isBlank(filterNewValue))) {
                    return;
                }
                updateFilters(filterName, filterNewValue);
                try {
                    refresh();
                } catch (SophieException e) {
                    ExceptionDialog.open(composite.getShell(),
                            new SophieException("Unable to refresh documents from Solr server", e));
                }
            }
        });

        // Filter (refresh) results when user hits "Enter" while editing one of
        // the combos.
        combo.addListener(SWT.KeyUp, new Listener() {
            @Override
            public void handleEvent(Event event) {
                if (event.character == SWT.CR) {
                    updateFilters(facet.getName(), combo.getText());
                    try {
                        refresh();
                    } catch (SophieException e) {
                        ExceptionDialog.open(composite.getShell(),
                                new SophieException("Unable to refresh documents from Solr server", e));
                    }
                }
            }
        });
        if (combo != null) {
            editor.grabHorizontal = true;
            editor.setEditor(combo, table.getItem(0), index);
            editor = new TableEditor(table);
        }
    }

    /**
     * Add a new field to the table.
     * 
     * @param fieldName
     *            Field name.
     * @param field
     *            Field definition.
     * @param facet
     *            Facet - the values will be used to populate the values of the
     *            combo.
     * @param index
     *            Column index.
     */
    private void addField(String fieldName, FieldInfo field, FacetField facet, int index) {
        addColumn(fieldName, field);
        // If field or facet is null then we cannot filter on this field (e.g.
        // the field is not indexed).
        if (fieldName != null && field != null && facet != null) {
            addCombo(fieldName, field, facet, index);
        }
    }

    /**
     * Add a new field to the table. The field name is deduced from the field
     * definition.
     * 
     * @param field
     *            Field definition.
     * @param facet
     *            Facet - the values will be used to populate the values of the
     *            combo.
     * @param index
     *            Column index.
     */
    private void addField(FieldInfo field, FacetField facet, int index) {
        addField(field.getName(), field, facet, index);
    }

    /**
     * Add new field to the table. No values are provided to the filter.
     * 
     * @param fieldName
     *            Field name.
     * @param field
     *            Field definition.
     */
    public void addField(String fieldName, FieldInfo field) {
        FacetField facet = new FacetField(fieldName);
        int index = table.getColumnCount();
        addField(fieldName, field, facet, index);
    }

}