/* * Autopsy Forensic Browser * * Copyright 2013-2016 Basis Technology Corp. * Contact: carrier <at> sleuthkit <dot> org * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.sleuthkit.autopsy.corecomponents; import java.awt.Color; import java.awt.Component; import java.awt.Cursor; import java.awt.FontMetrics; import java.awt.Graphics; import java.awt.dnd.DnDConstants; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.beans.PropertyChangeEvent; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import javax.swing.JTable; import javax.swing.ListSelectionModel; import javax.swing.SwingUtilities; import javax.swing.event.ChangeEvent; import javax.swing.event.ListSelectionEvent; import javax.swing.event.TableColumnModelEvent; import javax.swing.event.TableColumnModelListener; import javax.swing.table.TableCellRenderer; import org.netbeans.swing.outline.DefaultOutlineCellRenderer; import org.netbeans.swing.outline.DefaultOutlineModel; import org.openide.explorer.ExplorerManager; import org.openide.explorer.view.OutlineView; import org.openide.nodes.AbstractNode; import org.openide.nodes.Children; import org.openide.nodes.Node; import org.openide.nodes.Node.Property; import org.openide.nodes.Node.PropertySet; import org.openide.nodes.NodeEvent; import org.openide.nodes.NodeListener; import org.openide.nodes.NodeMemberEvent; import org.openide.nodes.NodeReorderEvent; import org.openide.util.NbBundle; import org.openide.util.NbPreferences; import org.sleuthkit.autopsy.corecomponentinterfaces.DataResultViewer; /** * DataResult sortable table viewer */ // @@@ Restore implementation of DataResultViewerTable as a DataResultViewer // service provider when DataResultViewers can be made compatible with node // multiple selection actions. //@ServiceProvider(service = DataResultViewer.class) public class DataResultViewerTable extends AbstractDataResultViewer { private static final long serialVersionUID = 1L; private final String firstColumnLabel = NbBundle.getMessage(DataResultViewerTable.class, "DataResultViewerTable.firstColLbl"); /* The properties map maps * key: stored value of column index -> value: property at that index * We move around stored values instead of directly using the column indices * in order to not override settings for a column that may not appear in the * current table view due to its collection of its children's properties. */ private final Map<Integer, Property<?>> propertiesMap = new TreeMap<>(); private final DummyNodeListener dummyNodeListener = new DummyNodeListener(); private static final String DUMMY_NODE_DISPLAY_NAME = NbBundle.getMessage(DataResultViewerTable.class, "DataResultViewerTable.dummyNodeDisplayName"); private static final Color TAGGED_COLOR = new Color(200, 210, 220); private Node currentRoot; // When a column in the table is moved, these two variables keep track of where // the column started and where it ended up. private int startColumnIndex = -1; private int endColumnIndex = -1; /** * Creates a DataResultViewerTable object that is compatible with node * multiple selection actions. * * @param explorerManager allow for explorer manager sharing */ public DataResultViewerTable(ExplorerManager explorerManager) { super(explorerManager); initialize(); } /** * Creates a DataResultViewerTable object that is NOT compatible with node * multiple selection actions. */ public DataResultViewerTable() { initialize(); } private void initialize() { initComponents(); OutlineView ov = ((OutlineView) this.tableScrollPanel); ov.setAllowedDragActions(DnDConstants.ACTION_NONE); ov.getOutline().setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION); // don't show the root node ov.getOutline().setRootVisible(false); ov.getOutline().setDragEnabled(false); // add a listener so that when columns are moved, the new order is stored ov.getOutline().getColumnModel().addColumnModelListener(new TableColumnModelListener() { @Override public void columnAdded(TableColumnModelEvent e) { } @Override public void columnRemoved(TableColumnModelEvent e) { } @Override public void columnMarginChanged(ChangeEvent e) { } @Override public void columnSelectionChanged(ListSelectionEvent e) { } @Override public void columnMoved(TableColumnModelEvent e) { int fromIndex = e.getFromIndex(); int toIndex = e.getToIndex(); if (fromIndex == toIndex) { return; } /* Because a column may be dragged to several different positions before * the mouse is released (thus causing multiple TableColumnModelEvents to * be fired), we want to keep track of the starting column index in this * potential series of movements. Therefore we only keep track of the * original fromIndex in startColumnIndex, but we always update * endColumnIndex to know the final position of the moved column. * See the MouseListener mouseReleased method. */ if (startColumnIndex == -1) { startColumnIndex = fromIndex; } endColumnIndex = toIndex; // This array contains the keys of propertiesMap in order int[] indicesList = new int[propertiesMap.size()]; int pos = 0; for (int key : propertiesMap.keySet()) { indicesList[pos++] = key; } int leftIndex = Math.min(fromIndex, toIndex); int rightIndex = Math.max(fromIndex, toIndex); // Now we can copy the range of keys that have been affected by // the column movement int[] range = Arrays.copyOfRange(indicesList, leftIndex, rightIndex + 1); int rangeSize = range.length; // column moved right, shift all properties left, put in moved // property at the rightmost index if (fromIndex < toIndex) { Property<?> movedProp = propertiesMap.get(range[0]); for (int i = 0; i < rangeSize - 1; i++) { propertiesMap.put(range[i], propertiesMap.get(range[i + 1])); } propertiesMap.put(range[rangeSize - 1], movedProp); } // column moved left, shift all properties right, put in moved // property at the leftmost index else { Property<?> movedProp = propertiesMap.get(range[rangeSize - 1]); for (int i = rangeSize - 1; i > 0; i--) { propertiesMap.put(range[i], propertiesMap.get(range[i - 1])); } propertiesMap.put(range[0], movedProp); } storeState(); } }); // add a listener to move columns back if user tries to move the first column out of place ov.getOutline().getTableHeader().addMouseListener(new MouseAdapter() { @Override public void mouseReleased(MouseEvent e) { /* If the startColumnIndex is not -1 (which is the reset value), that * means columns have been moved around. We then check to see if either * the starting or end position is 0 (the first column), and then swap * them back if that is the case because we don't want to allow movement * of the first column. We then reset startColumnIndex to -1, the reset * value. * We check if startColumnIndex is at reset or not because it is * possible for the mouse to be released and a MouseEvent to be fired * without having moved any columns. */ if (startColumnIndex != -1 && (startColumnIndex == 0 || endColumnIndex == 0)) { ov.getOutline().moveColumn(endColumnIndex, startColumnIndex); } startColumnIndex = -1; } }); } /** * Expand node * * @param n Node to expand */ @Override public void expandNode(Node n) { super.expandNode(n); if (this.tableScrollPanel != null) { OutlineView ov = ((OutlineView) this.tableScrollPanel); ov.expandNode(n); } } /** * This method is called from within the constructor to initialize the form. * WARNING: Do NOT modify this code. The content of this method is always * regenerated by the Form Editor. */ @SuppressWarnings("unchecked") // <editor-fold defaultstate="collapsed" desc="Generated Code">//GEN-BEGIN:initComponents private void initComponents() { tableScrollPanel = new OutlineView(this.firstColumnLabel); //new TreeTableView() tableScrollPanel.addComponentListener(new java.awt.event.ComponentAdapter() { public void componentResized(java.awt.event.ComponentEvent evt) { tableScrollPanelComponentResized(evt); } }); javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this); this.setLayout(layout); layout.setHorizontalGroup( layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) .addComponent(tableScrollPanel, javax.swing.GroupLayout.DEFAULT_SIZE, 691, Short.MAX_VALUE) ); layout.setVerticalGroup( layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) .addComponent(tableScrollPanel, javax.swing.GroupLayout.DEFAULT_SIZE, 366, Short.MAX_VALUE) ); }// </editor-fold>//GEN-END:initComponents private void tableScrollPanelComponentResized(java.awt.event.ComponentEvent evt) {//GEN-FIRST:event_tableScrollPanelComponentResized }//GEN-LAST:event_tableScrollPanelComponentResized // Variables declaration - do not modify//GEN-BEGIN:variables private javax.swing.JScrollPane tableScrollPanel; // End of variables declaration//GEN-END:variables /** * Gets regular Bean property set properties from all children and, * recursively, subchildren of Node. Note: won't work out the box for lazy * load - you need to set all children props for the parent by hand * * @param parent Node with at least one child to get properties from * @param rows max number of rows to retrieve properties for (can be used * for memory optimization) */ private void getAllChildPropertyHeadersRec(Node parent, int rows, Set<Property<?>> propertiesAcc) { Children children = parent.getChildren(); int childCount = 0; for (Node child : children.getNodes()) { if (++childCount > rows) { return; } for (PropertySet ps : child.getPropertySets()) { final Property<?>[] props = ps.getProperties(); final int propsNum = props.length; for (int j = 0; j < propsNum; ++j) { propertiesAcc.add(props[j]); } } getAllChildPropertyHeadersRec(child, rows, propertiesAcc); } } @Override public boolean isSupported(Node selectedNode) { return true; } /** * Thread note: Make sure to run this in the EDT as it causes GUI * operations. * * @param selectedNode */ @Override public void setNode(Node selectedNode) { final OutlineView ov = ((OutlineView) this.tableScrollPanel); /* The quick filter must be reset because when determining column width, * ETable.getRowCount is called, and the documentation states that quick * filters must be unset for the method to work * "If the quick-filter is applied the number of rows do not match the number of rows in the model." */ ov.getOutline().unsetQuickFilter(); // change the cursor to "waiting cursor" for this operation this.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR)); try { boolean hasChildren = false; if (selectedNode != null) { // @@@ This just did a DB round trip to get the count and the results were not saved... hasChildren = selectedNode.getChildren().getNodesCount() > 0; } Node oldNode = this.em.getRootContext(); if (oldNode != null) { oldNode.removeNodeListener(dummyNodeListener); } // if there's no selection node, do nothing if (hasChildren) { Node root = selectedNode; dummyNodeListener.reset(); root.addNodeListener(dummyNodeListener); setupTable(root); } else { Node emptyNode = new AbstractNode(Children.LEAF); em.setRootContext(emptyNode); // make empty node ov.getOutline().setAutoResizeMode(JTable.AUTO_RESIZE_ALL_COLUMNS); ov.setPropertyColumns(); // set the empty property header } } finally { this.setCursor(null); } } /** * Create Column Headers based on the Content represented by the Nodes in * the table. * * @param root The parent Node of the ContentNodes */ private void setupTable(final Node root) { em.setRootContext(root); final OutlineView ov = ((OutlineView) this.tableScrollPanel); if (ov == null) { return; } currentRoot = root; List<Node.Property<?>> props = loadState(); /** * OutlineView makes the first column be the result of * node.getDisplayName with the icon. This duplicates our first column, * which is the file name, etc. So, pop that property off the list, but * use its display name as the header for the column so that the header * can change depending on the type of data being displayed. * * NOTE: This assumes that the first property is always the one that * duplicates getDisplayName(). The current implementation does not * allow the first property column to be moved. */ if (props.size() > 0) { Node.Property<?> prop = props.remove(0); ((DefaultOutlineModel) ov.getOutline().getOutlineModel()).setNodesColumnLabel(prop.getDisplayName()); } // Get the columns setup with respect to names and sortability String[] propStrings = new String[props.size() * 2]; for (int i = 0; i < props.size(); i++) { props.get(i).setValue("ComparableColumnTTV", Boolean.TRUE); //NON-NLS //First property column is sorted initially if (i == 0) { props.get(i).setValue("TreeColumnTTV", Boolean.TRUE); // Identifies special property representing first (tree) column. NON-NLS props.get(i).setValue("SortingColumnTTV", Boolean.TRUE); // TreeTableView should be initially sorted by this property column. NON-NLS } propStrings[2 * i] = props.get(i).getName(); propStrings[2 * i + 1] = props.get(i).getDisplayName(); } ov.setPropertyColumns(propStrings); // show the horizontal scroll panel and show all the content & header // If there is only one column (which was removed from props above) // Just let the table resize itself. ov.getOutline().setAutoResizeMode((props.size() > 0) ? JTable.AUTO_RESIZE_OFF : JTable.AUTO_RESIZE_ALL_COLUMNS); if (root.getChildren().getNodesCount() != 0) { final Graphics graphics = ov.getGraphics(); if (graphics != null) { final FontMetrics metrics = graphics.getFontMetrics(); int margin = 4; int padding = 8; for (int column = 0; column < ov.getOutline().getModel().getColumnCount(); column++) { int firstColumnPadding = (column == 0) ? 32 : 0; int columnWidthLimit = (column == 0) ? 350 : 300; int valuesWidth = 0; // find the maximum width needed to fit the values for the first 100 rows, at most for (int row = 0; row < Math.min(100, ov.getOutline().getRowCount()); row++) { TableCellRenderer renderer = ov.getOutline().getCellRenderer(row, column); Component comp = ov.getOutline().prepareRenderer(renderer, row, column); valuesWidth = Math.max(comp.getPreferredSize().width, valuesWidth); } int headerWidth = metrics.stringWidth(ov.getOutline().getColumnName(column)); valuesWidth += firstColumnPadding; // add extra padding for first column int columnWidth = Math.max(valuesWidth, headerWidth); columnWidth += 2 * margin + padding; // add margin and regular padding columnWidth = Math.min(columnWidth, columnWidthLimit); ov.getOutline().getColumnModel().getColumn(column).setPreferredWidth(columnWidth); } } } else { // if there's no content just auto resize all columns ov.getOutline().setAutoResizeMode(JTable.AUTO_RESIZE_ALL_COLUMNS); } /** * This custom renderer extends the renderer that was already being * used by the outline table. This renderer colors a row if the * tags property of the node is not empty. */ class ColorTagCustomRenderer extends DefaultOutlineCellRenderer { private static final long serialVersionUID = 1L; @Override public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int col) { Component component = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, col); // only override the color if a node is not selected if (!isSelected) { Node node = currentRoot.getChildren().getNodeAt(table.convertRowIndexToModel(row)); boolean tagFound = false; if (node != null) { Node.PropertySet[] propSets = node.getPropertySets(); if (propSets.length != 0) { // currently, a node has only one property set, named Sheet.PROPERTIES ("properties") Node.Property<?>[] props = propSets[0].getProperties(); for (Property<?> prop : props) { if (prop.getName().equals("Tags")) { try { tagFound = !prop.getValue().equals(""); } catch (IllegalAccessException | InvocationTargetException ignore) { } break; } } } } //if the node does have associated tags, set its background color if (tagFound) { component.setBackground(TAGGED_COLOR); } } return component; } } ov.getOutline().setDefaultRenderer(Object.class, new ColorTagCustomRenderer()); } /** * Store the current column order into a preference file. */ private synchronized void storeState() { if (currentRoot == null || propertiesMap.isEmpty()) { return; } TableFilterNode tfn; if (currentRoot instanceof TableFilterNode) { tfn = (TableFilterNode) currentRoot; } else { return; } // Store the current order of the columns into settings for (Map.Entry<Integer, Property<?>> entry : propertiesMap.entrySet()) { Property<?> prop = entry.getValue(); int storeValue = entry.getKey(); NbPreferences.forModule(this.getClass()).put(getColumnPreferenceKey(prop, tfn.getColumnOrderKey()), String.valueOf(storeValue)); } } /** * Loads the stored column order from the preference file. * * @return a List<Node.Property<?>> of the preferences in order */ private synchronized List<Node.Property<?>> loadState() { // This is a set because we add properties of up to 100 child nodes, and we want unique properties Set<Property<?>> propertiesAcc = new LinkedHashSet<>(); this.getAllChildPropertyHeadersRec(currentRoot, 100, propertiesAcc); List<Node.Property<?>> props = new ArrayList<>(propertiesAcc); // If node is not table filter node, use default order for columns TableFilterNode tfn; if (currentRoot instanceof TableFilterNode) { tfn = (TableFilterNode) currentRoot; } else { // The node is not a TableFilterNode, columns are going to be in default order return props; } propertiesMap.clear(); /* * We load column index values into the properties map. If a property's * index is outside the range of the number of properties or the index * has already appeared as the position of another property, we put that * property at the end. */ int offset = props.size(); boolean noPreviousSettings = true; for (Property<?> prop : props) { Integer value = Integer.valueOf(NbPreferences.forModule(this.getClass()).get(getColumnPreferenceKey(prop, tfn.getColumnOrderKey()), "-1")); if (value >= 0 && value < offset && !propertiesMap.containsKey(value)) { propertiesMap.put(value, prop); noPreviousSettings = false; } else { propertiesMap.put(offset, prop); offset++; } } // If none of the properties had previous settings, we should decrement // each value by the number of properties to make the values 0-indexed. if (noPreviousSettings) { Integer[] keys = propertiesMap.keySet().toArray(new Integer[propertiesMap.keySet().size()]); for (int key : keys) { propertiesMap.put(key - props.size(), propertiesMap.get(key)); propertiesMap.remove(key); } } return new ArrayList<>(propertiesMap.values()); } /** * Gets a key for the current node and a property of its child nodes to * store the column position into a preference file. * * @param prop Property of the column * @param type The type of the current node * @return A generated key for the preference file */ private String getColumnPreferenceKey(Property<?> prop, String type) { return type.replaceAll("[^a-zA-Z0-9_]", "") + "." + prop.getName().replaceAll("[^a-zA-Z0-9_]", "") + ".column"; } @Override public String getTitle() { return NbBundle.getMessage(this.getClass(), "DataResultViewerTable.title"); } @Override public DataResultViewer createInstance() { return new DataResultViewerTable(); } @Override public void clearComponent() { this.tableScrollPanel.removeAll(); this.tableScrollPanel = null; super.clearComponent(); } private class DummyNodeListener implements NodeListener { private volatile boolean load = true; public void reset() { load = true; } @Override public void childrenAdded(final NodeMemberEvent nme) { Node[] delta = nme.getDelta(); if (load && containsReal(delta)) { load = false; if (SwingUtilities.isEventDispatchThread()) { setupTable(nme.getNode()); } else { SwingUtilities.invokeLater(() -> { setupTable(nme.getNode()); }); } } } private boolean containsReal(Node[] delta) { for (Node n : delta) { if (!n.getDisplayName().equals(DUMMY_NODE_DISPLAY_NAME)) { return true; } } return false; } @Override public void childrenRemoved(NodeMemberEvent nme) { } @Override public void childrenReordered(NodeReorderEvent nre) { } @Override public void nodeDestroyed(NodeEvent ne) { } @Override public void propertyChange(PropertyChangeEvent evt) { } } }