001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2017
005 * Space Science and Engineering Center (SSEC)
006 * University of Wisconsin - Madison
007 * 1225 W. Dayton Street, Madison, WI 53706, USA
008 * https://www.ssec.wisc.edu/mcidas
009 * 
010 * All Rights Reserved
011 * 
012 * McIDAS-V is built on Unidata's IDV and SSEC's VisAD libraries, and
013 * some McIDAS-V source code is based on IDV and VisAD source code.  
014 * 
015 * McIDAS-V is free software; you can redistribute it and/or modify
016 * it under the terms of the GNU Lesser Public License as published by
017 * the Free Software Foundation; either version 3 of the License, or
018 * (at your option) any later version.
019 * 
020 * McIDAS-V is distributed in the hope that it will be useful,
021 * but WITHOUT ANY WARRANTY; without even the implied warranty of
022 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
023 * GNU Lesser Public License for more details.
024 * 
025 * You should have received a copy of the GNU Lesser Public License
026 * along with this program.  If not, see http://www.gnu.org/licenses.
027 */
028package edu.wisc.ssec.mcidasv.servermanager;
029
030import static edu.wisc.ssec.mcidasv.servermanager.EntryStore.getLocalPort;
031import static java.util.Objects.requireNonNull;
032
033import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.arrList;
034import static edu.wisc.ssec.mcidasv.util.CollectionHelpers.newLinkedHashSet;
035import static edu.wisc.ssec.mcidasv.util.McVGuiUtils.runOnEDT;
036import static edu.wisc.ssec.mcidasv.util.McVGuiUtils.safeGetText;
037import static ucar.unidata.ui.Help.getDefaultHelp;
038
039import java.awt.BorderLayout;
040import java.awt.Component;
041import java.awt.Dimension;
042import java.awt.Font;
043import java.awt.event.MouseAdapter;
044import java.awt.event.MouseEvent;
045import java.awt.event.WindowAdapter;
046import java.awt.event.WindowEvent;
047import java.io.File;
048import java.util.Collection;
049import java.util.Collections;
050import java.util.EnumSet;
051import java.util.List;
052import java.util.Set;
053import java.util.concurrent.Callable;
054import java.util.concurrent.CompletionService;
055import java.util.concurrent.ExecutionException;
056import java.util.concurrent.ExecutorCompletionService;
057import java.util.concurrent.ExecutorService;
058import java.util.concurrent.Executors;
059import java.util.regex.Pattern;
060
061import javax.swing.Box;
062import javax.swing.BoxLayout;
063import javax.swing.GroupLayout;
064import javax.swing.Icon;
065import javax.swing.JButton;
066import javax.swing.JCheckBox;
067import javax.swing.JCheckBoxMenuItem;
068import javax.swing.JComponent;
069import javax.swing.JDialog;
070import javax.swing.JFileChooser;
071import javax.swing.JFrame;
072import javax.swing.JLabel;
073import javax.swing.JMenu;
074import javax.swing.JMenuBar;
075import javax.swing.JMenuItem;
076import javax.swing.JPanel;
077import javax.swing.JScrollPane;
078import javax.swing.JSeparator;
079import javax.swing.JTabbedPane;
080import javax.swing.JTable;
081import javax.swing.JTextField;
082import javax.swing.LayoutStyle;
083import javax.swing.ListSelectionModel;
084import javax.swing.SwingUtilities;
085import javax.swing.UIManager;
086import javax.swing.border.EmptyBorder;
087import javax.swing.event.ChangeEvent;
088import javax.swing.event.ListSelectionEvent;
089import javax.swing.table.AbstractTableModel;
090import javax.swing.table.DefaultTableCellRenderer;
091
092import net.miginfocom.swing.MigLayout;
093
094import org.bushe.swing.event.EventBus;
095import org.bushe.swing.event.annotation.AnnotationProcessor;
096import org.bushe.swing.event.annotation.EventSubscriber;
097
098import org.slf4j.Logger;
099import org.slf4j.LoggerFactory;
100
101import ucar.unidata.idv.IdvObjectStore;
102import ucar.unidata.ui.Help;
103import ucar.unidata.util.GuiUtils;
104import ucar.unidata.util.LogUtil;
105
106import edu.wisc.ssec.mcidasv.servermanager.AddeEntry.EntrySource;
107import edu.wisc.ssec.mcidasv.servermanager.AddeEntry.EntryType;
108import edu.wisc.ssec.mcidasv.servermanager.AddeEntry.EntryValidity;
109import edu.wisc.ssec.mcidasv.servermanager.AddeThread.McservEvent;
110import edu.wisc.ssec.mcidasv.servermanager.EntryStore.Event;
111import edu.wisc.ssec.mcidasv.servermanager.RemoteEntryEditor.AddeStatus;
112import edu.wisc.ssec.mcidasv.ui.BetterJTable;
113import edu.wisc.ssec.mcidasv.util.McVTextField.Prompt;
114import java.awt.event.ActionEvent;
115import java.util.stream.Collectors;
116
117/**
118 * This class is the GUI frontend to {@link EntryStore} (the server manager).
119 * It allows users to manipulate their local and remote ADDE data.
120 */
121// TODO(jon): don't forget to persist tab choice and window position. maybe also the "positions" of the scrollpanes (if possible).
122// TODO(jon): GUI could look much better.
123// TODO(jon): finish up the javadocs.
124@SuppressWarnings({"serial", "AssignmentToStaticFieldFromInstanceMethod", "FieldCanBeLocal"})
125public class TabbedAddeManager extends JFrame {
126
127    /** Pretty typical logger object. */
128    private final static Logger logger =
129        LoggerFactory.getLogger(TabbedAddeManager.class);
130
131    /** Path to the help resources. */
132    private static final String HELP_TOP_DIR = "/docs/userguide";
133
134    /** Help target for the remote servers. */
135    private static final String REMOTE_HELP_TARGET = "idv.tools.remotedata";
136
137    /** Help target for the local servers. */
138    private static final String LOCAL_HELP_TARGET = "idv.tools.localdata";
139
140    /** ID used to save/restore the last visible tab between sessions. */
141    private static final String LAST_TAB = "mcv.adde.lasttab";
142
143    /** ID used to save/restore last directory that contained a MCTABLE.TXT. */
144    private static final String LAST_IMPORTED = "mcv.adde.lastmctabledir";
145
146    /** Size of the ADDE entry verification thread pool. */
147    private static final int POOL = 2;
148
149    /** Static reference to an instance of this class. Bad idea! */
150    private static TabbedAddeManager staticTabbedManager;
151
152    /**
153     * These are the various {@literal "events"} that the server manager GUI
154     * supports. These are published via the wonderful
155     * {@link EventBus#publish(Object)} method.
156     */
157    public enum Event { 
158        /** The GUI was created. */
159        OPENED,
160        /** The GUI was hidden/minimized/etc. */
161        HIDDEN,
162        /** GUI was unhidden or some such thing. */
163        SHOWN,
164        /** The GUI was closed. */
165        CLOSED
166    }
167
168    /** Reference to the actual server manager. */
169    private final EntryStore serverManager;
170
171    /** */
172    private final List<RemoteAddeEntry> selectedRemoteEntries;
173
174    /** */
175    private final List<LocalAddeEntry> selectedLocalEntries;
176
177    /** */
178    private JTextField importUser;
179
180    /** */
181    private JTextField importProject;
182
183    /** Whether or not {@link #initComponents()} has been called. */
184    private boolean guiInitialized = false;
185
186    /**
187     * Creates a standalone server manager GUI.
188     */
189    public TabbedAddeManager() {
190        //noinspection AssignmentToNull
191        AnnotationProcessor.process(this);
192        this.serverManager = null;
193        this.selectedLocalEntries = arrList();
194        this.selectedRemoteEntries = arrList();
195
196        SwingUtilities.invokeLater(this::initComponents);
197    }
198
199    /**
200     * Creates a server manager GUI that's linked back to the rest of McIDAS-V.
201     * 
202     * @param entryStore Server manager reference.
203     * 
204     * @throws NullPointerException if {@code entryStore} is {@code null}.
205     */
206    public TabbedAddeManager(final EntryStore entryStore) {
207        requireNonNull(entryStore, "Cannot pass a null server manager");
208        AnnotationProcessor.process(this);
209        this.serverManager = entryStore; 
210        this.selectedLocalEntries = arrList();
211        this.selectedRemoteEntries = arrList();
212        SwingUtilities.invokeLater(this::initComponents);
213    }
214
215    /** 
216     * Returns an instance of this class. The instance <i>should</i> correspond
217     * to the one being used by the {@literal "rest"} of McIDAS-V.
218     * 
219     * @return Either an instance of this class or {@code null}.
220     */
221    public static TabbedAddeManager getTabbedManager() {
222        return staticTabbedManager;
223    }
224
225    /**
226     * If the GUI isn't shown, this method will display things. If the GUI
227     * <i>is shown</i>, bring it to the front.
228     * 
229     * <p>This method publishes {@link Event#SHOWN}.
230     */
231    public void showManager() {
232        if (isVisible()) {
233            toFront();
234        } else {
235            setVisible(true);
236        }
237        staticTabbedManager = this;
238        EventBus.publish(Event.SHOWN);
239    }
240
241    /**
242     * Closes and disposes (if needed) the GUI.
243     */
244    public void closeManager() {
245        //noinspection AssignmentToNull
246        staticTabbedManager = null;
247        EventBus.publish(Event.CLOSED);
248        if (isDisplayable()) {
249            dispose();
250        }
251    }
252
253    /**
254     * Attempts to refresh the contents of both the local and remote dataset
255     * tables. 
256     */
257    public void refreshDisplay() {
258        if (guiInitialized) {
259            ((RemoteAddeTableModel)remoteTable.getModel()).refreshEntries();
260            ((LocalAddeTableModel)localTable.getModel()).refreshEntries();
261        }
262    }
263
264    /**
265     * Create and show the GUI the remote ADDE dataset GUI. Since no 
266     * {@link RemoteAddeEntry RemoteAddeEntries} have been provided, none of 
267     * the fields will be prefilled (user is creating a new dataset). 
268     */
269    // TODO(jon): differentiate between showRemoteEditor() and showRemoteEditor(entries)
270    public void showRemoteEditor() {
271        if (tabbedPane.getSelectedIndex() != 0) {
272            tabbedPane.setSelectedIndex(0);
273        }
274        RemoteEntryEditor editor =
275            new RemoteEntryEditor(this, true, this, serverManager);
276        editor.setVisible(true);
277    }
278
279    /**
280     * Create and show the GUI the remote ADDE dataset GUI. Since some 
281     * {@link RemoteAddeEntry RemoteAddeEntries} have been provided, all of the
282     * applicable fields will be filled (user is editing an existing dataset).
283     * 
284     * @param entries Selection to edit. Should not be {@code null}.
285     */
286    // TODO(jon): differentiate between showRemoteEditor() and showRemoteEditor(entries)
287    public void showRemoteEditor(final List<RemoteAddeEntry> entries) {
288        if (tabbedPane.getSelectedIndex() != 0) {
289            tabbedPane.setSelectedIndex(0);
290        }
291        RemoteEntryEditor editor =
292            new RemoteEntryEditor(this, true, this, serverManager, entries);
293        editor.setVisible(true);
294    }
295
296    /**
297     * Removes the given remote ADDE entries from the server manager GUI.
298     * 
299     * @param entries Entries to remove. {@code null} is permissible, but is a
300     * {@literal "no-op"}.
301     */
302    public void removeRemoteEntries(final Collection<RemoteAddeEntry> entries) {
303        if (entries == null) {
304            return;
305        }
306        List<RemoteAddeEntry> removable = arrList(entries.size());
307        removable.addAll(
308            entries.stream()
309                   .filter(e -> e.getEntrySource() != EntrySource.SYSTEM)
310                   .collect(Collectors.toList()));
311
312        if (serverManager.removeEntries(removable)) {
313            RemoteAddeTableModel tableModel =
314                (RemoteAddeTableModel)remoteTable.getModel();
315            int first = Integer.MAX_VALUE;
316            int last = Integer.MIN_VALUE;
317            for (RemoteAddeEntry entry : removable) {
318                int index = tableModel.getRowForEntry(entry);
319                if (index >= 0) {
320                    if (index < first) {
321                        first = index;
322                    }
323                    if (index > last) {
324                        last = index;
325                    }
326                }
327            }
328            tableModel.fireTableDataChanged();
329            refreshDisplay();
330            remoteTable.revalidate();
331            if (first < remoteTable.getRowCount()) {
332                remoteTable.setRowSelectionInterval(first, first);
333            }
334        } else {
335            logger.debug("could not remove entries={}", removable);
336        }
337    }
338
339    /**
340     * Shows a local ADDE entry editor <b>without</b> anything pre-populated 
341     * (creating a new local ADDE dataset).
342     */
343    public void showLocalEditor() {
344        // TODO(jon): differentiate between showLocalEditor() and showLocalEditor(entry)
345        if (tabbedPane.getSelectedIndex() != 1) {
346            tabbedPane.setSelectedIndex(1);
347        }
348        LocalEntryEditor editor =
349            new LocalEntryEditor(this, true, this, serverManager);
350        editor.setVisible(true);
351    }
352
353    /**
354     * Shows a local ADDE entry editor <b>with</b> the appropriate fields
355     * pre-populated, using the values from {@code entry}. This is intended to 
356     * handle {@literal "editing"} a local ADDE dataset.
357     * 
358     * @param entry Entry to edit; should not be {@code null}.
359     */
360    public void showLocalEditor(final LocalAddeEntry entry) {
361        // TODO(jon): differentiate between showLocalEditor() and showLocalEditor(entry)
362        if (tabbedPane.getSelectedIndex() != 1) {
363            tabbedPane.setSelectedIndex(1);
364        }
365        LocalEntryEditor editor =
366            new LocalEntryEditor(this, true, this, serverManager, entry);
367        editor.setVisible(true);
368    }
369
370    /**
371     * Removes the given local ADDE entries from the server manager GUI.
372     * 
373     * @param entries Entries to remove. {@code null} is permissible, but is a
374     * {@literal "no-op"}.
375     */
376    public void removeLocalEntries(final Collection<LocalAddeEntry> entries) {
377        if (entries == null) {
378            return;
379        }
380
381        if (serverManager.removeEntries(entries)) {
382            logger.trace("successful removal of entries={}",entries);
383            LocalAddeTableModel tableModel =
384                (LocalAddeTableModel)localTable.getModel();
385            int first = Integer.MAX_VALUE;
386            int last = Integer.MIN_VALUE;
387            for (LocalAddeEntry entry : entries) {
388                int index = tableModel.getRowForEntry(entry);
389                if (index >= 0) {
390                    if (index < first) {
391                        first = index;
392                    }
393                    if (index > last) {
394                        last = index;
395                    }
396                }
397            }
398            tableModel.fireTableDataChanged();
399            refreshDisplay();
400            localTable.revalidate();
401            if (first < localTable.getRowCount()) {
402                localTable.setRowSelectionInterval(first, first);
403            }
404        } else {
405            logger.debug("could not remove entries={}", entries);
406        }
407    }
408
409    /**
410     * Extracts datasets from a given MCTABLE.TXT and adds them to the server
411     * manager.
412     * 
413     * @param path Path to the MCTABLE.TXT. Cannot be {@code null}.
414     * @param username ADDE username to use for verifying extracted datasets.
415     * Cannot be {@code null}.
416     * @param project ADDE project number to use for verifying extracted
417     * datasets. Cannot be {@code null}.
418     */
419    public void importMctable(final String path, final String username,
420                              final String project)
421    {
422        logger.trace("extracting path={} username={}, project={}", path, username, project);
423        final Set<RemoteAddeEntry> imported =
424            EntryTransforms.extractMctableEntries(path, username, project);
425        logger.trace("extracted entries={}", imported);
426        if (imported.equals(Collections.emptySet())) {
427            LogUtil.userErrorMessage("Selection does not appear to a valid MCTABLE.TXT file:\n"+path);
428        } else {
429            logger.trace("adding extracted entries...");
430            // verify entries first!
431            serverManager.addEntries(imported);
432            refreshDisplay();
433            repaint();
434            Thread t = new Thread(() -> checkDatasets(imported));
435            t.start();
436        }
437    }
438
439    /**
440     * Attempts to start the local servers. 
441     * 
442     * @see EntryStore#startLocalServer()
443     */
444    public void startLocalServers() {
445        logger.trace("starting local servers...?");
446        serverManager.startLocalServer();
447    }
448
449    /**
450     * Attempts to stop the local servers.
451     * 
452     * @see EntryStore#stopLocalServer()
453     */
454    public void stopLocalServers() {
455        logger.trace("stopping local servers...?");
456        serverManager.stopLocalServer();
457    }
458
459    /**
460     * Responds to local server events and attempts to update the GUI status
461     * message.
462     * 
463     * @param event Local server event. Should not be {@code null}.
464     */
465    @EventSubscriber(eventClass=AddeThread.McservEvent.class)
466    public void mcservUpdated(final AddeThread.McservEvent event) {
467        logger.trace("eventbus evt={}", event.toString());
468        final String msg;
469        switch (event) {
470            case ACTIVE: case DIED: case STOPPED:
471                msg = event.getMessage();
472                break;
473            case STARTED:
474                msg = String.format(event.getMessage(), getLocalPort());
475                break;
476            default:
477                msg = "Unknown local servers status: "+event.toString();
478                break;
479        }
480        SwingUtilities.invokeLater(() -> {
481            if (statusLabel != null) {
482                statusLabel.setText(msg);
483            }
484        });
485    }
486
487    public void handleUrlImportMenuItem(ActionEvent e) {
488        SwingUtilities.invokeLater(() -> {
489            try {
490                ImportUrl dialog = new ImportUrl(serverManager, this);
491                dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
492                dialog.setVisible(true);
493            } catch (Exception ex) {
494                logger.error("error importing from url", ex);
495            }
496        });
497    }
498
499    /**
500     * Builds the server manager GUI.
501     */
502    @SuppressWarnings({"unchecked", "FeatureEnvy", "MagicNumber"})
503    public void initComponents() {
504        Dimension frameSize = new Dimension(730, 460);
505        Help.setTopDir(HELP_TOP_DIR);
506        system = icon("/edu/wisc/ssec/mcidasv/resources/icons/servermanager/padlock_closed.png");
507        mctable = icon("/edu/wisc/ssec/mcidasv/resources/icons/servermanager/bug.png");
508        user = icon("/edu/wisc/ssec/mcidasv/resources/icons/servermanager/hand_pro.png");
509        invalid = icon("/edu/wisc/ssec/mcidasv/resources/icons/servermanager/emotion_sad.png");
510        unverified = icon("/edu/wisc/ssec/mcidasv/resources/icons/servermanager/eye_inv.png");
511        setTitle("ADDE Data Manager");
512        setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
513        setSize(frameSize);
514        setMinimumSize(frameSize);
515        addWindowListener(new WindowAdapter() {
516            public void windowClosed(WindowEvent evt) {
517                formWindowClosed(evt);
518            }
519        });
520
521        JMenuBar menuBar = new JMenuBar();
522        setJMenuBar(menuBar);
523
524        JMenu fileMenu = new JMenu("File");
525        menuBar.add(fileMenu);
526
527        JMenuItem remoteNewMenuItem = new JMenuItem("New Remote Dataset");
528        remoteNewMenuItem.addActionListener(evt -> showRemoteEditor());
529        fileMenu.add(remoteNewMenuItem);
530
531        JMenuItem localNewMenuItem = new JMenuItem("New Local Dataset");
532        localNewMenuItem.addActionListener(evt -> showLocalEditor());
533        fileMenu.add(localNewMenuItem);
534
535        fileMenu.add(new JSeparator());
536
537        JMenuItem importMctableMenuItem = new JMenuItem("Import MCTABLE...");
538        importMctableMenuItem.addActionListener(this::importButtonActionPerformed);
539        fileMenu.add(importMctableMenuItem);
540
541        JMenuItem importUrlMenuItem = new JMenuItem("Import from URL...");
542        importUrlMenuItem.addActionListener(this::handleUrlImportMenuItem);
543        fileMenu.add(importUrlMenuItem);
544
545        fileMenu.add(new JSeparator());
546
547        JMenuItem closeMenuItem = new JMenuItem("Close");
548        closeMenuItem.addActionListener(evt -> {
549            closeManager();
550        });
551        fileMenu.add(closeMenuItem);
552
553        JMenu editMenu = new JMenu("Edit");
554        menuBar.add(editMenu);
555
556        editMenuItem = new JMenuItem("Edit Entry...");
557        editMenuItem.setEnabled(false);
558        editMenuItem.addActionListener(evt -> {
559            if (tabbedPane.getSelectedIndex() == 0) {
560                showRemoteEditor(getSelectedRemoteEntries());
561            } else {
562                showLocalEditor(getSingleLocalSelection());
563            }
564        });
565        editMenu.add(editMenuItem);
566
567        removeMenuItem = new JMenuItem("Remove Selection");
568        removeMenuItem.setEnabled(false);
569        removeMenuItem.addActionListener(evt -> {
570            if (tabbedPane.getSelectedIndex() == 0) {
571                removeRemoteEntries(getSelectedRemoteEntries());
572            } else {
573                removeLocalEntries(getSelectedLocalEntries());
574            }
575        });
576        editMenu.add(removeMenuItem);
577
578        JMenu localServersMenu = new JMenu("Local Servers");
579        menuBar.add(localServersMenu);
580
581        JMenuItem startLocalMenuItem = new JMenuItem("Start Local Servers");
582        startLocalMenuItem.addActionListener(e -> startLocalServers());
583        localServersMenu.add(startLocalMenuItem);
584
585        JMenuItem stopLocalMenuItem = new JMenuItem("Stop Local Servers");
586        stopLocalMenuItem.addActionListener(e -> stopLocalServers());
587        localServersMenu.add(stopLocalMenuItem);
588
589        JMenu helpMenu = new JMenu("Help");
590        menuBar.add(helpMenu);
591
592        JMenuItem remoteHelpMenuItem = new JMenuItem("Show Remote Data Help");
593        remoteHelpMenuItem.addActionListener(evt -> getDefaultHelp().gotoTarget(REMOTE_HELP_TARGET));
594        helpMenu.add(remoteHelpMenuItem);
595
596        JMenuItem localHelpMenuItem = new JMenuItem("Show Local Data Help");
597        localHelpMenuItem.addActionListener(evt -> getDefaultHelp().gotoTarget(LOCAL_HELP_TARGET));
598        helpMenu.add(localHelpMenuItem);
599
600        contentPane = new JPanel();
601        contentPane.setBorder(null);
602        setContentPane(contentPane);
603        contentPane.setLayout(new MigLayout("", "[grow]", "[grow][grow][grow]"));
604
605        tabbedPane = new JTabbedPane(JTabbedPane.TOP);
606        tabbedPane.addChangeListener(this::handleTabStateChanged);
607        contentPane.add(tabbedPane, "cell 0 0 1 3,grow");
608
609        JPanel remoteTab = new JPanel();
610        remoteTab.setBorder(new EmptyBorder(0, 4, 4, 4));
611        tabbedPane.addTab("Remote Data", null, remoteTab, null);
612        remoteTab.setLayout(new BoxLayout(remoteTab, BoxLayout.Y_AXIS));
613
614        remoteTable = new BetterJTable();
615        JScrollPane remoteScroller =
616            BetterJTable.createStripedJScrollPane(remoteTable);
617
618        remoteTable.setModel(new RemoteAddeTableModel(serverManager));
619        remoteTable.setAutoCreateRowSorter(true);
620        remoteTable.setColumnSelectionAllowed(false);
621        remoteTable.setRowSelectionAllowed(true);
622        remoteTable.getTableHeader().setReorderingAllowed(false);
623        remoteTable.setFont(UIManager.getFont("Table.font").deriveFont(11.0f));
624        remoteTable.getColumnModel().getSelectionModel().setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
625        remoteTable.setDefaultRenderer(String.class, new TextRenderer());
626        remoteTable.getColumnModel().getColumn(0).setPreferredWidth(10);
627        remoteTable.getColumnModel().getColumn(1).setPreferredWidth(10);
628        remoteTable.getColumnModel().getColumn(3).setPreferredWidth(50);
629        remoteTable.getColumnModel().getColumn(4).setPreferredWidth(50);
630        remoteTable.getColumnModel().getColumn(0).setCellRenderer(new EntryValidityRenderer());
631        remoteTable.getColumnModel().getColumn(1).setCellRenderer(new EntrySourceRenderer());
632        remoteTable.getSelectionModel().addListSelectionListener(this::remoteSelectionModelChanged);
633        remoteTable.addMouseListener(new MouseAdapter() {
634            @Override public void mouseClicked(final MouseEvent e) {
635                if ((e.getClickCount() == 2) && hasSingleRemoteSelection()) {
636                    showRemoteEditor(getSelectedRemoteEntries());
637                }
638            }
639        });
640        remoteScroller.setViewportView(remoteTable);
641        remoteTab.add(remoteScroller);
642
643        JPanel remoteActionPanel = new JPanel();
644        remoteTab.add(remoteActionPanel);
645        remoteActionPanel.setLayout(new BoxLayout(remoteActionPanel, BoxLayout.X_AXIS));
646
647        newRemoteButton = new JButton("Add New Dataset");
648        newRemoteButton.addActionListener(e -> showRemoteEditor());
649        newRemoteButton.setToolTipText("Create a new remote ADDE dataset.");
650        remoteActionPanel.add(newRemoteButton);
651
652        editRemoteButton = new JButton("Edit Dataset");
653        editRemoteButton.addActionListener(e -> showRemoteEditor(getSelectedRemoteEntries()));
654        editRemoteButton.setToolTipText("Edit an existing remote ADDE dataset.");
655        remoteActionPanel.add(editRemoteButton);
656
657        removeRemoteButton = new JButton("Remove Selection");
658        removeRemoteButton.addActionListener(e -> removeRemoteEntries(getSelectedRemoteEntries()));
659        removeRemoteButton.setToolTipText("Remove the selected remote ADDE datasets.");
660        remoteActionPanel.add(removeRemoteButton);
661
662        importRemoteButton = new JButton("Import MCTABLE...");
663        importRemoteButton.addActionListener(e -> importButtonActionPerformed(e));
664        remoteActionPanel.add(importRemoteButton);
665
666        JPanel localTab = new JPanel();
667        localTab.setBorder(new EmptyBorder(0, 4, 4, 4));
668        tabbedPane.addTab("Local Data", null, localTab, null);
669        localTab.setLayout(new BoxLayout(localTab, BoxLayout.Y_AXIS));
670
671        localTable = new BetterJTable();
672        JScrollPane localScroller =
673            BetterJTable.createStripedJScrollPane(localTable);
674        localTable.setModel(new LocalAddeTableModel(serverManager));
675        localTable.setAutoCreateRowSorter(true);
676        localTable.setColumnSelectionAllowed(false);
677        localTable.setRowSelectionAllowed(true);
678        localTable.getTableHeader().setReorderingAllowed(false);
679        localTable.setFont(UIManager.getFont("Table.font").deriveFont(11.0f));
680        localTable.setDefaultRenderer(String.class, new TextRenderer());
681        localTable.getColumnModel().getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
682        localTable.getSelectionModel().addListSelectionListener(this::localSelectionModelChanged);
683        localTable.addMouseListener(new MouseAdapter() {
684            @Override public void mouseClicked(final MouseEvent e) {
685                if ((e.getClickCount() == 2) && hasSingleLocalSelection()) {
686                    showLocalEditor(getSingleLocalSelection());
687                }
688            }
689        });
690        localScroller.setViewportView(localTable);
691        localTab.add(localScroller);
692
693        JPanel localActionPanel = new JPanel();
694        localTab.add(localActionPanel);
695        localActionPanel.setLayout(new BoxLayout(localActionPanel, BoxLayout.X_AXIS));
696
697        newLocalButton = new JButton("Add New Dataset");
698        newLocalButton.addActionListener(e -> showLocalEditor());
699        newLocalButton.setToolTipText("Create a new local ADDE dataset.");
700        localActionPanel.add(newLocalButton);
701
702        editLocalButton = new JButton("Edit Dataset");
703        editLocalButton.setEnabled(false);
704        editLocalButton.addActionListener(e -> showLocalEditor(getSingleLocalSelection()));
705        editLocalButton.setToolTipText("Edit an existing local ADDE dataset.");
706        localActionPanel.add(editLocalButton);
707
708        removeLocalButton = new JButton("Remove Selection");
709        removeLocalButton.setEnabled(false);
710        removeLocalButton.addActionListener(e -> removeLocalEntries(getSelectedLocalEntries()));
711        removeLocalButton.setToolTipText("Remove the selected local ADDE datasets.");
712        localActionPanel.add(removeLocalButton);
713
714        JComponent statusPanel = new JPanel();
715        statusPanel.setBorder(new EmptyBorder(0, 6, 0, 6));
716        contentPane.add(statusPanel, "cell 0 3,grow");
717        statusPanel.setLayout(new BorderLayout(0, 0));
718
719        Box statusMessageBox = Box.createHorizontalBox();
720        statusPanel.add(statusMessageBox, BorderLayout.WEST);
721
722        String statusMessage = McservEvent.STOPPED.getMessage();
723        if (serverManager.checkLocalServer()) {
724            statusMessage = McservEvent.ACTIVE.getMessage();
725        }
726        statusLabel = new JLabel(statusMessage);
727        statusMessageBox.add(statusLabel);
728        statusLabel.setEnabled(false);
729
730        Box frameControlBox = Box.createHorizontalBox();
731        statusPanel.add(frameControlBox, BorderLayout.EAST);
732
733        okButton = new JButton("Ok");
734        okButton.addActionListener(e -> closeManager());
735        frameControlBox.add(okButton);
736        tabbedPane.setSelectedIndex(getLastTab());
737        guiInitialized = true;
738    }
739
740    /**
741     * Respond to changes in {@link #tabbedPane}; primarily switching tabs.
742     * 
743     * @param event Event being handled. Ignored for now.
744     */
745    private void handleTabStateChanged(final ChangeEvent event) {
746        assert SwingUtilities.isEventDispatchThread();
747        boolean hasSelection = false;
748        int index = 0;
749        if (guiInitialized) {
750            index = tabbedPane.getSelectedIndex();
751            if (index == 0) {
752                hasSelection = hasRemoteSelection();
753                editRemoteButton.setEnabled(hasSelection);
754                removeRemoteButton.setEnabled(hasSelection);
755            } else {
756                hasSelection = hasLocalSelection();
757                editLocalButton.setEnabled(hasSelection);
758                removeLocalButton.setEnabled(hasSelection);
759            }
760            editMenuItem.setEnabled(hasSelection);
761            removeMenuItem.setEnabled(hasSelection);
762            setLastTab(index);
763        }
764        logger.trace("index={} hasRemote={} hasLocal={} guiInit={}", index, hasRemoteSelection(), hasLocalSelection(), guiInitialized);
765    }
766
767    /**
768     * Respond to events.
769     * 
770     * @param e {@link ListSelectionEvent} that necessitated this call.
771     */
772    private void remoteSelectionModelChanged(final ListSelectionEvent e) {
773        if (e.getValueIsAdjusting()) {
774            return;
775        }
776
777        int selectedRowCount = 0;
778        ListSelectionModel selModel = (ListSelectionModel)e.getSource();
779        Set<RemoteAddeEntry> selectedEntries;
780        if (selModel.isSelectionEmpty()) {
781            selectedEntries = Collections.emptySet();
782        } else {
783            int min = selModel.getMinSelectionIndex();
784            int max = selModel.getMaxSelectionIndex();
785            RemoteAddeTableModel tableModel = (RemoteAddeTableModel)remoteTable.getModel();
786            selectedEntries = newLinkedHashSet((max - min) * AddeEntry.EntryType.values().length);
787            for (int i = min; i <= max; i++) {
788                if (selModel.isSelectedIndex(i)) {
789                    int realRow = remoteTable.convertRowIndexToModel(i);
790                    logger.trace("original row: {} real row: {}", i, realRow);
791                    List<RemoteAddeEntry> entries = tableModel.getEntriesAtRow(realRow);
792                    
793                    selectedEntries.addAll(entries);
794                    selectedRowCount++;
795                }
796            }
797        }
798
799        boolean onlyDefaultEntries = true;
800        for (RemoteAddeEntry entry : selectedEntries) {
801            if (entry.getEntrySource() != EntrySource.SYSTEM) {
802                onlyDefaultEntries = false;
803                break;
804            }
805        }
806        setSelectedRemoteEntries(selectedEntries);
807
808        // the current "edit" dialog doesn't work so well with multiple 
809        // servers/datasets, so only allow the user to edit entries one at a time.
810        boolean singleSelection = selectedRowCount == 1;
811        editRemoteButton.setEnabled(singleSelection);
812        editMenuItem.setEnabled(singleSelection);
813
814        boolean hasSelection = (selectedRowCount >= 1) && !onlyDefaultEntries;
815        removeRemoteButton.setEnabled(hasSelection);
816        removeMenuItem.setEnabled(hasSelection);
817    }
818
819    /**
820     * Respond to events from the local dataset table.
821     * 
822     * @param e {@link ListSelectionEvent} that necessitated this call.
823     */
824    private void localSelectionModelChanged(final ListSelectionEvent e) {
825        if (e.getValueIsAdjusting()) {
826            return;
827        }
828        ListSelectionModel selModel = (ListSelectionModel)e.getSource();
829        Set<LocalAddeEntry> selectedEntries;
830        if (selModel.isSelectionEmpty()) {
831            selectedEntries = Collections.emptySet();
832        } else {
833            int min = selModel.getMinSelectionIndex();
834            int max = selModel.getMaxSelectionIndex();
835            LocalAddeTableModel tableModel = (LocalAddeTableModel)localTable.getModel();
836            selectedEntries = newLinkedHashSet(max - min);
837            for (int i = min; i <= max; i++) {
838                if (selModel.isSelectedIndex(i)) {
839                    int realRow = localTable.convertRowIndexToModel(i);
840                    selectedEntries.add(tableModel.getEntryAtRow(realRow));
841                }
842            }
843        }
844
845        setSelectedLocalEntries(selectedEntries);
846
847        // the current "edit" dialog doesn't work so well with multiple 
848        // servers/datasets, so only allow the user to edit entries one at a time.
849        boolean singleSelection = selectedEntries.size() == 1;
850        this.editRemoteButton.setEnabled(singleSelection);
851        this.editMenuItem.setEnabled(singleSelection);
852
853        boolean hasSelection = !selectedEntries.isEmpty();
854        removeRemoteButton.setEnabled(hasSelection);
855        removeMenuItem.setEnabled(hasSelection);
856    }
857
858    /**
859     * Checks to see if {@link #selectedRemoteEntries} contains any 
860     * {@link RemoteAddeEntry}s.
861     *
862     * @return Whether or not any {@code RemoteAddeEntry} values are selected.
863     */
864    private boolean hasRemoteSelection() {
865        return !selectedRemoteEntries.isEmpty();
866    }
867
868    /**
869     * Checks to see if {@link #selectedLocalEntries} contains any
870     * {@link LocalAddeEntry}s.
871     *
872     * @return Whether or not any {@code LocalAddeEntry} values are selected.
873     */
874    private boolean hasLocalSelection() {
875        return !selectedLocalEntries.isEmpty();
876    }
877
878    /**
879     * Checks to see if the user has select a <b>single</b> remote dataset.
880     * 
881     * @return {@code true} if there is a single remote dataset selected.
882     * {@code false} otherwise.
883     */
884    private boolean hasSingleRemoteSelection() {
885        String entryText = null;
886        boolean result = true;
887        for (RemoteAddeEntry entry : selectedRemoteEntries) {
888            if (entryText == null) {
889                entryText = entry.getEntryText();
890            }
891            if (!entry.getEntryText().equals(entryText)) {
892                result = false;
893                break;
894            }
895        }
896        return result;
897    }
898
899    /**
900     * Checks to see if the user has select a <b>single</b> local dataset.
901     * 
902     * @return {@code true} if there is a single local dataset selected. {@code false} otherwise.
903     */
904    private boolean hasSingleLocalSelection() {
905        return selectedLocalEntries.size() == 1;
906    }
907
908    /**
909     * If there is a single local dataset selected, this method will return that
910     * dataset.
911     * 
912     * @return Either the single selected local dataset, or {@link LocalAddeEntry#INVALID_ENTRY}.
913     */
914    private LocalAddeEntry getSingleLocalSelection() {
915        LocalAddeEntry entry = LocalAddeEntry.INVALID_ENTRY;
916        if (selectedLocalEntries.size() == 1) {
917            entry = selectedLocalEntries.get(0);
918        }
919        return entry;
920    }
921
922    /**
923     * Corresponds to the selected remote ADDE entries in the GUI.
924     * 
925     * @param entries Should not be {@code null}.
926     */
927    private void setSelectedRemoteEntries(final Collection<RemoteAddeEntry> entries) {
928        selectedRemoteEntries.clear();
929        selectedRemoteEntries.addAll(entries);
930        this.editRemoteButton.setEnabled(entries.size() == 1);
931        this.removeRemoteButton.setEnabled(!entries.isEmpty());
932        logger.trace("remote entries={}", entries);
933    }
934
935    /**
936     * Gets the selected remote ADDE entries.
937     * 
938     * @return Either an empty list or the remote entries selected in the GUI.
939     */
940    private List<RemoteAddeEntry> getSelectedRemoteEntries() {
941        List<RemoteAddeEntry> selected = Collections.emptyList();
942        if (!selectedRemoteEntries.isEmpty()) {
943            selected = arrList(selectedRemoteEntries);
944        }
945        return selected;
946    }
947
948    /**
949     * Corresponds to the selected local ADDE entries in the GUI.
950     * 
951     * @param entries Should not be {@code null}.
952     */
953    private void setSelectedLocalEntries(final Collection<LocalAddeEntry> entries) {
954        selectedLocalEntries.clear();
955        selectedLocalEntries.addAll(entries);
956        this.editLocalButton.setEnabled(entries.size() == 1);
957        this.removeLocalButton.setEnabled(!entries.isEmpty());
958        logger.trace("local entries={}", entries);
959    }
960
961    /**
962     * Gets the selected local ADDE entries.
963     * 
964     * @return Either an empty list or the local entries selected in the GUI.
965     */
966    private List<LocalAddeEntry> getSelectedLocalEntries() {
967        List<LocalAddeEntry> selected = Collections.emptyList();
968        if (!selectedLocalEntries.isEmpty()) {
969            selected = arrList(selectedLocalEntries);
970        }
971        return selected;
972    }
973
974    /**
975     * Handles the user closing the server manager GUI.
976     * 
977     * @param evt Event that triggered this method call. Currently ignored.
978     * 
979     * @see #closeManager()
980     */
981    private void formWindowClosed(WindowEvent evt) {
982        closeManager();
983    }
984
985    @SuppressWarnings({"MagicNumber"})
986    private JPanel makeFileChooserAccessory() {
987        assert SwingUtilities.isEventDispatchThread();
988        JPanel accessory = new JPanel();
989        accessory.setLayout(new BoxLayout(accessory, BoxLayout.PAGE_AXIS));
990        importAccountBox = new JCheckBox("Use ADDE Accounting?");
991        importAccountBox.setSelected(false);
992        importAccountBox.addActionListener(evt -> {
993            boolean selected = importAccountBox.isSelected();
994            importUser.setEnabled(selected);
995            importProject.setEnabled(selected);
996        });
997        String clientProp = "JComponent.sizeVariant";
998        String propVal = "mini";
999
1000        importUser = new JTextField();
1001        importUser.putClientProperty(clientProp, propVal);
1002        Prompt userPrompt = new Prompt(importUser, "Username");
1003        userPrompt.putClientProperty(clientProp, propVal);
1004        importUser.setEnabled(importAccountBox.isSelected());
1005
1006        importProject = new JTextField();
1007        Prompt projPrompt = new Prompt(importProject, "Project Number");
1008        projPrompt.putClientProperty(clientProp, propVal);
1009        importProject.putClientProperty(clientProp, propVal);
1010        importProject.setEnabled(importAccountBox.isSelected());
1011
1012        GroupLayout layout = new GroupLayout(accessory);
1013        accessory.setLayout(layout);
1014        layout.setHorizontalGroup(
1015            layout.createParallelGroup(GroupLayout.Alignment.LEADING)
1016            .addComponent(importAccountBox)
1017            .addGroup(layout.createSequentialGroup()
1018                .addContainerGap()
1019                .addGroup(layout.createParallelGroup(GroupLayout.Alignment.TRAILING, false)
1020                    .addComponent(importProject, GroupLayout.Alignment.LEADING)
1021                    .addComponent(importUser, GroupLayout.Alignment.LEADING, GroupLayout.DEFAULT_SIZE, 131, Short.MAX_VALUE)))
1022        );
1023        layout.setVerticalGroup(
1024            layout.createParallelGroup(GroupLayout.Alignment.LEADING)
1025            .addGroup(layout.createSequentialGroup()
1026                .addComponent(importAccountBox)
1027                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
1028                .addComponent(importUser, GroupLayout.PREFERRED_SIZE, GroupLayout.DEFAULT_SIZE, GroupLayout.PREFERRED_SIZE)
1029                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
1030                .addComponent(importProject, GroupLayout.PREFERRED_SIZE, GroupLayout.DEFAULT_SIZE, GroupLayout.PREFERRED_SIZE)
1031                .addContainerGap(55, (int)Short.MAX_VALUE))
1032        );
1033        return accessory;
1034    }
1035
1036    private void importButtonActionPerformed(ActionEvent evt) {
1037        assert SwingUtilities.isEventDispatchThread();
1038        JFileChooser fc = new JFileChooser(getLastImportPath());
1039        fc.setAccessory(makeFileChooserAccessory());
1040        fc.setFileSelectionMode(JFileChooser.FILES_ONLY);
1041        int ret = fc.showOpenDialog(this);
1042        if (ret == JFileChooser.APPROVE_OPTION) {
1043            File f = fc.getSelectedFile();
1044            String path = f.getPath();
1045
1046            boolean defaultUser = false;
1047            String forceUser = safeGetText(importUser);
1048            if (forceUser.isEmpty()) {
1049                forceUser = AddeEntry.DEFAULT_ACCOUNT.getUsername();
1050                defaultUser = true;
1051            }
1052
1053            boolean defaultProj = false;
1054            String forceProj = safeGetText(importProject);
1055            if (forceProj.isEmpty()) {
1056                forceProj = AddeEntry.DEFAULT_ACCOUNT.getProject();
1057                defaultProj = true;
1058            }
1059
1060            if (importAccountBox.isSelected() && (defaultUser || defaultProj)) {
1061                logger.warn("bad acct dialog: forceUser={} forceProj={}", forceUser, forceProj);
1062            } else {
1063                logger.warn("acct appears valid: forceUser={} forceProj={}", forceUser, forceProj);
1064                importMctable(path, forceUser, forceProj);
1065                // don't worry about file validity; i'll just assume the user clicked
1066                // on the wrong entry by accident.
1067                setLastImportPath(f.getParent());
1068            }
1069        }
1070    }
1071
1072    /**
1073     * Returns the directory that contained the most recently imported MCTABLE.TXT.
1074     *
1075     * @return Either the path to the most recently imported MCTABLE.TXT file,
1076     * or an empty {@code String}.
1077     */
1078    private String getLastImportPath() {
1079        String lastPath = serverManager.getIdvStore().get(LAST_IMPORTED, "");
1080        logger.trace("last path='{}'", lastPath);
1081        return lastPath;
1082    }
1083
1084    /**
1085     * Saves the directory that contained the most recently imported MCTABLE.TXT.
1086     *
1087     * @param path Path to the most recently imported MCTABLE.TXT file.
1088     * {@code null} values are replaced with an empty {@code String}.
1089     */
1090    private void setLastImportPath(final String path) {
1091        String okayPath = (path == null) ? "" : path;
1092        logger.trace("saving path='{}'", okayPath);
1093        serverManager.getIdvStore().put(LAST_IMPORTED, okayPath);
1094    }
1095
1096    /**
1097     * Returns the index of the user's last server manager tab.
1098     *
1099     * @return Index of the user's most recently viewed server manager tab, or {@code 0}.
1100     */
1101    private int getLastTab() {
1102        int index = serverManager.getIdvStore().get(LAST_TAB, 0);
1103        logger.trace("last tab={}", index);
1104        return index;
1105    }
1106
1107    /**
1108     * Saves the index of the last server manager tab the user was looking at.
1109     * 
1110     * @param index Index of the user's most recently viewed server manager tab.
1111     */
1112    private void setLastTab(final int index) {
1113        int okayIndex = ((index >= 0) && (index < 2)) ? index : 0;
1114        IdvObjectStore store = serverManager.getIdvStore();
1115        logger.trace("storing tab={}", okayIndex);
1116        store.put(LAST_TAB, okayIndex);
1117    }
1118
1119    // stupid adde.ucar.edu entries never seem to time out! great! making the gui hang is just so awesome!
1120    @SuppressWarnings({"ObjectAllocationInLoop"})
1121    public Set<RemoteAddeEntry> checkDatasets(final Collection<RemoteAddeEntry> entries) {
1122        requireNonNull(entries, "can't check a null collection of entries");
1123        if (entries.isEmpty()) {
1124            return Collections.emptySet();
1125        }
1126
1127        Set<RemoteAddeEntry> valid = newLinkedHashSet();
1128        ExecutorService exec = Executors.newFixedThreadPool(POOL);
1129        CompletionService<List<RemoteAddeEntry>> ecs =
1130            new ExecutorCompletionService<>(exec);
1131        final RemoteAddeTableModel tableModel =
1132            (RemoteAddeTableModel)remoteTable.getModel();
1133
1134        // place entries
1135        for (RemoteAddeEntry entry : entries) {
1136            ecs.submit(new BetterCheckTask(entry));
1137            logger.trace("submitting entry={}", entry);
1138            final int row = tableModel.getRowForEntry(entry);
1139            runOnEDT(() -> tableModel.fireTableRowsUpdated(row, row));
1140        }
1141
1142        // work through the entries
1143        try {
1144            for (int i = 0; i < entries.size(); i++) {
1145                final List<RemoteAddeEntry> checkedEntries = ecs.take().get();
1146                if (!checkedEntries.isEmpty()) {
1147                    final int row =
1148                        tableModel.getRowForEntry(checkedEntries.get(0));
1149                    runOnEDT(() -> {
1150                        List<RemoteAddeEntry> old =
1151                            tableModel.getEntriesAtRow(row);
1152                        serverManager.replaceEntries(old, checkedEntries);
1153                        tableModel.fireTableRowsUpdated(row, row);
1154                    });
1155                }
1156                valid.addAll(checkedEntries);
1157            }
1158        } catch (InterruptedException e) {
1159            LogUtil.logException("Interrupted while validating entries", e);
1160        } catch (ExecutionException e) {
1161            LogUtil.logException("ADDE validation execution error", e);
1162        } finally {
1163            exec.shutdown();
1164        }
1165        return valid;
1166    }
1167
1168    private static class BetterCheckTask implements Callable<List<RemoteAddeEntry>> {
1169        private final RemoteAddeEntry entry;
1170        public BetterCheckTask(final RemoteAddeEntry entry) {
1171            this.entry = entry;
1172            this.entry.setEntryValidity(EntryValidity.VALIDATING);
1173        }
1174        @SuppressWarnings({"FeatureEnvy"})
1175        public List<RemoteAddeEntry> call() {
1176            List<RemoteAddeEntry> valid = arrList();
1177            if (RemoteAddeEntry.checkHost(entry)) {
1178                EntryTransforms.createEntriesFrom(entry)
1179                               .stream()
1180                               .filter(tmp -> RemoteAddeEntry.checkEntry(false, tmp) == AddeStatus.OK)
1181                               .forEach(tmp -> {
1182                                   tmp.setEntryValidity(EntryValidity.VERIFIED);
1183                                   valid.add(tmp);
1184                               });
1185            }
1186            if (valid.isEmpty()) {
1187                entry.setEntryValidity(EntryValidity.INVALID);
1188            } else {
1189                entry.setEntryValidity(EntryValidity.VERIFIED);
1190            }
1191            return valid;
1192        }
1193    }
1194
1195    private class CheckEntryTask implements Callable<RemoteAddeEntry> {
1196        private final RemoteAddeEntry entry;
1197        public CheckEntryTask(final RemoteAddeEntry entry) {
1198            requireNonNull(entry);
1199            this.entry = entry;
1200            this.entry.setEntryValidity(EntryValidity.VALIDATING);
1201        }
1202        @SuppressWarnings({"FeatureEnvy"})
1203        public RemoteAddeEntry call() {
1204            AddeStatus status = RemoteAddeEntry.checkEntry(entry);
1205            switch (status) {
1206                case OK: entry.setEntryValidity(EntryValidity.VERIFIED); break;
1207                default: entry.setEntryValidity(EntryValidity.INVALID); break;
1208            }
1209            return entry;
1210        }
1211    }
1212
1213    private static class RemoteAddeTableModel extends AbstractTableModel {
1214
1215        // TODO(jon): these constants can go once things calm down
1216        private static final int VALID = 0;
1217        private static final int SOURCE = 1;
1218        private static final int DATASET = 2;
1219        private static final int ACCT = 3;
1220        private static final int TYPES = 4;
1221        private static final Pattern ENTRY_ID_SPLITTER = Pattern.compile("!");
1222
1223        /** Labels that appear as the column headers. */
1224        private final String[] columnNames = {
1225            "Valid", "Source", "Dataset", "Accounting", "Data Types"
1226        };
1227
1228        private final List<String> servers;
1229
1230        /** {@link EntryStore} used to query and apply changes. */
1231        private final EntryStore entryStore;
1232
1233        /**
1234         * Builds an {@link javax.swing.table.AbstractTableModel} with some
1235         * extensions that facilitate working with
1236         * {@link RemoteAddeEntry RemoteAddeEntrys}.
1237         * 
1238         * @param entryStore Server manager object.
1239         */
1240        public RemoteAddeTableModel(final EntryStore entryStore) {
1241            requireNonNull(entryStore, "Cannot query a null EntryStore");
1242            this.entryStore = entryStore;
1243            this.servers = arrList(entryStore.getRemoteEntryTexts());
1244        }
1245
1246        /**
1247         * Returns the {@link RemoteAddeEntry} at the given index.
1248         * 
1249         * @param row Index of the entry.
1250         * 
1251         * @return {@code RemoteAddeEntry} at index specified by {@code row}.
1252         */
1253        protected List<RemoteAddeEntry> getEntriesAtRow(final int row) {
1254            String server = servers.get(row).replace('/', '!');
1255            List<RemoteAddeEntry> matches = arrList();
1256            matches.addAll(
1257                entryStore.searchWithPrefix(server)
1258                          .stream()
1259                          .filter(entry -> entry instanceof RemoteAddeEntry)
1260                          .map(entry -> (RemoteAddeEntry) entry)
1261                          .collect(Collectors.toList()));
1262            return matches;
1263        }
1264
1265        /**
1266         * Returns the index of the given {@code entry}.
1267         *
1268         * @param entry {@link RemoteAddeEntry} whose row is desired.
1269         *
1270         * @return Index of the desired {@code entry}, or {@code -1} if the
1271         * entry wasn't found.
1272         */
1273        protected int getRowForEntry(final RemoteAddeEntry entry) {
1274            return getRowForEntry(entry.getEntryText());
1275        }
1276
1277        /**
1278         * Returns the index of the given entry text within the table.
1279         *
1280         * @param entryText String representation of the desired entry.
1281         *
1282         * @return Index of the desired entry, or {@code -1} if the entry was
1283         * not found.
1284         *
1285         * @see AddeEntry#getEntryText()
1286         */
1287        protected int getRowForEntry(final String entryText) {
1288            return servers.indexOf(entryText);
1289        }
1290
1291        /**
1292         * Clears and re-adds all {@link RemoteAddeEntry}s within
1293         * {@link #entryStore}.
1294         */
1295        public void refreshEntries() {
1296            servers.clear();
1297            servers.addAll(entryStore.getRemoteEntryTexts());
1298            this.fireTableDataChanged();
1299        }
1300
1301        /**
1302         * Returns the length of {@link #columnNames}.
1303         * 
1304         * @return The number of columns.
1305         */
1306        @Override public int getColumnCount() {
1307            return columnNames.length;
1308        }
1309
1310        /**
1311         * Returns the number of entries being managed.
1312         */
1313        @Override public int getRowCount() {
1314            return servers.size();
1315        }
1316
1317        /**
1318         * Finds the value at the given coordinates.
1319         * 
1320         * @param row Table row.
1321         * @param column Table column.
1322         * 
1323         * @return Value stored at the given {@code row} and {@code column}
1324         * coordinates
1325         * 
1326         * @throws IndexOutOfBoundsException if {@code row} or {@code column}
1327         * refer to an invalid table cell.
1328         */
1329        @Override public Object getValueAt(int row, int column) {
1330            String serverText = servers.get(row);
1331            String prefix = serverText.replace('/', '!');
1332            switch (column) {
1333                case VALID: return formattedValidity(prefix, entryStore);
1334                case SOURCE: return formattedSource(prefix, entryStore);
1335                case DATASET: return serverText;
1336                case ACCT: return formattedAccounting(prefix, entryStore);
1337                case TYPES: return formattedTypes(prefix, entryStore);
1338                default: throw new IndexOutOfBoundsException();
1339            }
1340        }
1341
1342        private static String formattedSource(final String serv,
1343                                              final EntryStore manager)
1344        {
1345            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1346            EntrySource source = EntrySource.INVALID;
1347            if (!matches.isEmpty()) {
1348                for (AddeEntry entry : matches) {
1349                    if (entry.getEntrySource() == EntrySource.USER) {
1350                        return EntrySource.USER.toString();
1351                    }
1352                }
1353              source = matches.get(0).getEntrySource();
1354            }
1355            return source.toString();
1356        }
1357
1358        private static String formattedValidity(final String serv,
1359                                                final EntryStore manager)
1360        {
1361            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1362            EntryValidity validity = EntryValidity.INVALID;
1363            if (!matches.isEmpty()) {
1364                validity = matches.get(0).getEntryValidity();
1365            }
1366            return validity.toString();
1367        }
1368
1369        private static String formattedAccounting(final String serv,
1370                                                  final EntryStore manager)
1371        {
1372            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1373            AddeAccount acct = AddeEntry.DEFAULT_ACCOUNT;
1374            if (!matches.isEmpty()) {
1375                acct = matches.get(0).getAccount();
1376            }
1377            if (AddeEntry.DEFAULT_ACCOUNT.equals(acct)) {
1378                return "public dataset";
1379            }
1380            return acct.friendlyString();
1381        }
1382
1383        private static boolean hasType(final String serv,
1384                                       final EntryStore manager,
1385                                       final EntryType type)
1386        {
1387            String[] chunks = ENTRY_ID_SPLITTER.split(serv);
1388            Set<EntryType> types = Collections.emptySet();
1389            if (chunks.length == 2) {
1390                types = manager.getTypes(chunks[0], chunks[1]);
1391            }
1392            return types.contains(type);
1393        }
1394
1395        private static String formattedTypes(final String serv,
1396                                             final EntryStore manager)
1397        {
1398            String[] chunks = ENTRY_ID_SPLITTER.split(serv);
1399//            Set<EntryType> types = Collections.emptySet();
1400//            if (chunks.length == 2) {
1401//                types = manager.getTypes(chunks[0], chunks[1]);
1402//            }
1403            Set<EntryType> types = chunks.length == 2
1404                                   ? manager.getTypes(chunks[0], chunks[1])
1405                                   : Collections.emptySet();
1406
1407
1408//            @SuppressWarnings({"MagicNumber"})
1409//            StringBuilder sb = new StringBuilder(30);
1410//            for (EntryType type : EnumSet.of(EntryType.IMAGE, EntryType.GRID, EntryType.NAV, EntryType.POINT, EntryType.RADAR, EntryType.TEXT)) {
1411//                if (types.contains(type)) {
1412//                    sb.append(type.toString()).append(' ');
1413//                }
1414//            }
1415            return EnumSet.of(EntryType.IMAGE, EntryType.GRID, EntryType.NAV,
1416                              EntryType.POINT, EntryType.RADAR, EntryType.TEXT
1417                   ).stream()
1418                   .filter(types::contains)
1419                   .map(String::valueOf)
1420                   .collect(Collectors.joining(" "));
1421//            return sb.toString().toLowerCase();
1422        }
1423
1424        /**
1425         * Returns the column name associated with {@code column}.
1426         * 
1427         * @return One of {@link #columnNames}.
1428         */
1429        @Override public String getColumnName(final int column) {
1430            return columnNames[column];
1431        }
1432
1433        @Override public Class<?> getColumnClass(final int column) {
1434            return String.class;
1435        }
1436
1437        @Override public boolean isCellEditable(final int row,
1438                                                final int column)
1439        {
1440            return false;
1441        }
1442    }
1443
1444    private static class LocalAddeTableModel extends AbstractTableModel {
1445
1446        /** Labels that appear as the column headers. */
1447        private final String[] columnNames = {
1448            "Dataset (e.g. MYDATA)", "Image Type (e.g. JAN 07 GOES)",
1449            "Format", "Directory"
1450        };
1451
1452        /** Entries that currently populate the server manager. */
1453        private final List<LocalAddeEntry> entries;
1454
1455        /** {@link EntryStore} used to query and apply changes. */
1456        private final EntryStore entryStore;
1457
1458        public LocalAddeTableModel(final EntryStore entryStore) {
1459            requireNonNull(entryStore, "Cannot query a null EntryStore");
1460            this.entryStore = entryStore;
1461            this.entries = arrList(entryStore.getLocalEntries());
1462        }
1463
1464        /**
1465         * Returns the {@link LocalAddeEntry} at the given index.
1466         * 
1467         * @param row Index of the entry.
1468         * 
1469         * @return {@code LocalAddeEntry} at index specified by {@code row}.
1470         */
1471        protected LocalAddeEntry getEntryAtRow(final int row) {
1472            return entries.get(row);
1473        }
1474
1475        protected int getRowForEntry(final LocalAddeEntry entry) {
1476            return entries.indexOf(entry);
1477        }
1478
1479        protected List<LocalAddeEntry> getSelectedEntries(final int[] rows) {
1480            List<LocalAddeEntry> selected = arrList(rows.length);
1481            int rowCount = entries.size();
1482            for (int tmpIdx : rows) {
1483                if ((tmpIdx >= 0) && (tmpIdx < rowCount)) {
1484                    selected.add(entries.get(tmpIdx));
1485                } else {
1486                    throw new IndexOutOfBoundsException();
1487                }
1488            }
1489            return selected;
1490        }
1491
1492        public void refreshEntries() {
1493            entries.clear();
1494            entries.addAll(entryStore.getLocalEntries());
1495            this.fireTableDataChanged();
1496        }
1497
1498        /**
1499         * Returns the length of {@link #columnNames}.
1500         * 
1501         * @return The number of columns.
1502         */
1503        @Override public int getColumnCount() {
1504            return columnNames.length;
1505        }
1506
1507        /**
1508         * Returns the number of entries being managed.
1509         */
1510        @Override public int getRowCount() {
1511            return entries.size();
1512        }
1513
1514        /**
1515         * Finds the value at the given coordinates.
1516         * 
1517         * @param row Table row.
1518         * @param column Table column.
1519         * 
1520         * @return Value stored at the given {@code row} and {@code column}
1521         * coordinates
1522         *
1523         * @throws IndexOutOfBoundsException if {@code row} or {@code column}
1524         * refer to an invalid table cell.
1525         */
1526        @Override public Object getValueAt(int row, int column) {
1527            LocalAddeEntry entry = entries.get(row);
1528            if (entry == null) {
1529                throw new IndexOutOfBoundsException(); // still questionable...
1530            }
1531
1532            switch (column) {
1533                case 0: return entry.getGroup();
1534                case 1: return entry.getName();
1535                case 2: return entry.getFormat();
1536                case 3: return entry.getMask();
1537                default: throw new IndexOutOfBoundsException();
1538            }
1539        }
1540
1541        /**
1542         * Returns the column name associated with {@code column}.
1543         * 
1544         * @return One of {@link #columnNames}.
1545         */
1546        @Override public String getColumnName(final int column) {
1547            return columnNames[column];
1548        }
1549    }
1550
1551    // i need the following icons:
1552    // something to convey entry validity: invalid, verified, unverified
1553    // a "system" entry icon (thinking of something with prominent "V")
1554    // a "mctable" entry icon (similar to above, but with a prominent "X")
1555    // a "user" entry icon (no idea yet!)
1556    public class EntrySourceRenderer extends DefaultTableCellRenderer {
1557
1558        public Component getTableCellRendererComponent(JTable table,
1559                                                       Object value,
1560                                                       boolean isSelected,
1561                                                       boolean hasFocus,
1562                                                       int row,
1563                                                       int column)
1564        {
1565            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1566            EntrySource source = EntrySource.valueOf((String)value);
1567            EntrySourceRenderer renderer = (EntrySourceRenderer)comp;
1568            Icon icon = null;
1569            String tooltip = null;
1570            switch (source) {
1571                case SYSTEM:
1572                    icon = system;
1573                    tooltip = "Default dataset and cannot be removed, only disabled.";
1574                    break;
1575                case MCTABLE:
1576                    icon = mctable;
1577                    tooltip = "Dataset imported from a MCTABLE.TXT.";
1578                    break;
1579                case USER:
1580                    icon = user;
1581                    tooltip = "Dataset created or altered by you!";
1582                    break;
1583            }
1584            renderer.setIcon(icon);
1585            renderer.setToolTipText(tooltip);
1586            renderer.setText(null);
1587            return comp;
1588        }
1589    }
1590
1591    public class EntryValidityRenderer extends DefaultTableCellRenderer {
1592        public Component getTableCellRendererComponent(JTable table,
1593                                                       Object value,
1594                                                       boolean isSelected,
1595                                                       boolean hasFocus,
1596                                                       int row,
1597                                                       int column)
1598        {
1599            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1600            EntryValidity validity = EntryValidity.valueOf((String)value);
1601            EntryValidityRenderer renderer = (EntryValidityRenderer)comp;
1602            Icon icon = null;
1603            String msg = null;
1604            String tooltip = null;
1605            switch (validity) {
1606                case INVALID:
1607                    icon = invalid;
1608                    tooltip = "Dataset verification failed.";
1609                    break;
1610                case VERIFIED:
1611                    break;
1612                case UNVERIFIED:
1613                    icon = unverified;
1614                    tooltip = "Dataset has not been verified.";
1615                    break;
1616                case VALIDATING:
1617                    msg = "Checking...";
1618                    break;
1619            }
1620            renderer.setIcon(icon);
1621            renderer.setToolTipText(tooltip);
1622            renderer.setText(msg);
1623            return comp;
1624        }
1625    }
1626
1627    public static class TextRenderer extends DefaultTableCellRenderer {
1628
1629        /** */
1630        private Font bold;
1631
1632        /** */
1633        private Font boldItalic;
1634
1635        public Component getTableCellRendererComponent(JTable table,
1636                                                       Object value,
1637                                                       boolean isSelected,
1638                                                       boolean hasFocus,
1639                                                       int row,
1640                                                       int column)
1641        {
1642            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1643            Font currentFont = comp.getFont();
1644            if (bold == null) {
1645                bold = currentFont.deriveFont(Font.BOLD); 
1646            }
1647            if (boldItalic == null) {
1648                boldItalic = currentFont.deriveFont(Font.BOLD | Font.ITALIC);
1649            }
1650            if (column == 2) {
1651                comp.setFont(bold);
1652            } else if (column == 3) {
1653                // why can't i set the color for just a single column!?
1654            } else if (column == 4) {
1655                comp.setFont(boldItalic);
1656            }
1657            return comp;
1658        }
1659    }
1660
1661    /**
1662     * Construct an {@link Icon} object using the image at the specified
1663     * {@code path}.
1664     * 
1665     * @param path Path to image to use as an icon. Should not be {@code null}.
1666     * 
1667     * @return Icon object with the desired image.
1668     */
1669    private static Icon icon(final String path) {
1670        return GuiUtils.getImageIcon(path, TabbedAddeManager.class, true);
1671    }
1672
1673    /**
1674     * Launch the application. Makes for a simplistic test.
1675     * 
1676     * @param args Command line arguments. These are currently ignored.
1677     */
1678    public static void main(String[] args) {
1679        SwingUtilities.invokeLater(() -> {
1680            try {
1681                TabbedAddeManager frame = new TabbedAddeManager();
1682                frame.setVisible(true);
1683            } catch (Exception e) {
1684                e.printStackTrace();
1685            }
1686        });
1687    }
1688
1689    private JPanel contentPane;
1690    private JTable remoteTable;
1691    private JTable localTable;
1692    private JTabbedPane tabbedPane;
1693    private JLabel statusLabel;
1694    private JButton newRemoteButton;
1695    private JButton editRemoteButton;
1696    private JButton removeRemoteButton;
1697    private JButton importRemoteButton;
1698    private JButton newLocalButton;
1699    private JButton editLocalButton;
1700    private JButton removeLocalButton;
1701    private JButton okButton;
1702    private JMenuItem editMenuItem;
1703    private JMenuItem removeMenuItem;
1704    private JCheckBox importAccountBox;
1705
1706    /** Icon for datasets that are part of a default McIDAS-V install. */
1707    private Icon system;
1708
1709    /** Icon for datasets that originate from a MCTABLE.TXT. */
1710    private Icon mctable;
1711
1712    /** Icon for datasets that the user has provided. */
1713    private Icon user;
1714
1715    /** Icon for invalid datasets. */
1716    private Icon invalid;
1717
1718    /** Icon for datasets that have not been verified. */
1719    private Icon unverified;
1720}