001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2016
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.setColumnSelectionAllowed(false);
620        remoteTable.setRowSelectionAllowed(true);
621        remoteTable.getTableHeader().setReorderingAllowed(false);
622        remoteTable.setFont(UIManager.getFont("Table.font").deriveFont(11.0f));
623        remoteTable.getColumnModel().getSelectionModel().setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
624        remoteTable.setDefaultRenderer(String.class, new TextRenderer());
625        remoteTable.getColumnModel().getColumn(0).setPreferredWidth(10);
626        remoteTable.getColumnModel().getColumn(1).setPreferredWidth(10);
627        remoteTable.getColumnModel().getColumn(3).setPreferredWidth(50);
628        remoteTable.getColumnModel().getColumn(4).setPreferredWidth(50);
629        remoteTable.getColumnModel().getColumn(0).setCellRenderer(new EntryValidityRenderer());
630        remoteTable.getColumnModel().getColumn(1).setCellRenderer(new EntrySourceRenderer());
631        remoteTable.getSelectionModel().addListSelectionListener(this::remoteSelectionModelChanged);
632        remoteTable.addMouseListener(new MouseAdapter() {
633            @Override public void mouseClicked(final MouseEvent e) {
634                if ((e.getClickCount() == 2) && hasSingleRemoteSelection()) {
635                    showRemoteEditor(getSelectedRemoteEntries());
636                }
637            }
638        });
639        remoteScroller.setViewportView(remoteTable);
640        remoteTab.add(remoteScroller);
641
642        JPanel remoteActionPanel = new JPanel();
643        remoteTab.add(remoteActionPanel);
644        remoteActionPanel.setLayout(new BoxLayout(remoteActionPanel, BoxLayout.X_AXIS));
645
646        newRemoteButton = new JButton("Add New Dataset");
647        newRemoteButton.addActionListener(e -> showRemoteEditor());
648        newRemoteButton.setToolTipText("Create a new remote ADDE dataset.");
649        remoteActionPanel.add(newRemoteButton);
650
651        editRemoteButton = new JButton("Edit Dataset");
652        editRemoteButton.addActionListener(e -> showRemoteEditor(getSelectedRemoteEntries()));
653        editRemoteButton.setToolTipText("Edit an existing remote ADDE dataset.");
654        remoteActionPanel.add(editRemoteButton);
655
656        removeRemoteButton = new JButton("Remove Selection");
657        removeRemoteButton.addActionListener(e -> removeRemoteEntries(getSelectedRemoteEntries()));
658        removeRemoteButton.setToolTipText("Remove the selected remote ADDE datasets.");
659        remoteActionPanel.add(removeRemoteButton);
660
661        importRemoteButton = new JButton("Import MCTABLE...");
662        importRemoteButton.addActionListener(e -> importButtonActionPerformed(e));
663        remoteActionPanel.add(importRemoteButton);
664
665        JPanel localTab = new JPanel();
666        localTab.setBorder(new EmptyBorder(0, 4, 4, 4));
667        tabbedPane.addTab("Local Data", null, localTab, null);
668        localTab.setLayout(new BoxLayout(localTab, BoxLayout.Y_AXIS));
669
670        localTable = new BetterJTable();
671        JScrollPane localScroller =
672            BetterJTable.createStripedJScrollPane(localTable);
673        localTable.setModel(new LocalAddeTableModel(serverManager));
674        localTable.setColumnSelectionAllowed(false);
675        localTable.setRowSelectionAllowed(true);
676        localTable.getTableHeader().setReorderingAllowed(false);
677        localTable.setFont(UIManager.getFont("Table.font").deriveFont(11.0f));
678        localTable.setDefaultRenderer(String.class, new TextRenderer());
679        localTable.getColumnModel().getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
680        localTable.getSelectionModel().addListSelectionListener(this::localSelectionModelChanged);
681        localTable.addMouseListener(new MouseAdapter() {
682            @Override public void mouseClicked(final MouseEvent e) {
683                if ((e.getClickCount() == 2) && hasSingleLocalSelection()) {
684                    showLocalEditor(getSingleLocalSelection());
685                }
686            }
687        });
688        localScroller.setViewportView(localTable);
689        localTab.add(localScroller);
690
691        JPanel localActionPanel = new JPanel();
692        localTab.add(localActionPanel);
693        localActionPanel.setLayout(new BoxLayout(localActionPanel, BoxLayout.X_AXIS));
694
695        newLocalButton = new JButton("Add New Dataset");
696        newLocalButton.addActionListener(e -> showLocalEditor());
697        newLocalButton.setToolTipText("Create a new local ADDE dataset.");
698        localActionPanel.add(newLocalButton);
699
700        editLocalButton = new JButton("Edit Dataset");
701        editLocalButton.setEnabled(false);
702        editLocalButton.addActionListener(e -> showLocalEditor(getSingleLocalSelection()));
703        editLocalButton.setToolTipText("Edit an existing local ADDE dataset.");
704        localActionPanel.add(editLocalButton);
705
706        removeLocalButton = new JButton("Remove Selection");
707        removeLocalButton.setEnabled(false);
708        removeLocalButton.addActionListener(e -> removeLocalEntries(getSelectedLocalEntries()));
709        removeLocalButton.setToolTipText("Remove the selected local ADDE datasets.");
710        localActionPanel.add(removeLocalButton);
711
712        JComponent statusPanel = new JPanel();
713        statusPanel.setBorder(new EmptyBorder(0, 6, 0, 6));
714        contentPane.add(statusPanel, "cell 0 3,grow");
715        statusPanel.setLayout(new BorderLayout(0, 0));
716
717        Box statusMessageBox = Box.createHorizontalBox();
718        statusPanel.add(statusMessageBox, BorderLayout.WEST);
719
720        String statusMessage = McservEvent.STOPPED.getMessage();
721        if (serverManager.checkLocalServer()) {
722            statusMessage = McservEvent.ACTIVE.getMessage();
723        }
724        statusLabel = new JLabel(statusMessage);
725        statusMessageBox.add(statusLabel);
726        statusLabel.setEnabled(false);
727
728        Box frameControlBox = Box.createHorizontalBox();
729        statusPanel.add(frameControlBox, BorderLayout.EAST);
730
731        okButton = new JButton("Ok");
732        okButton.addActionListener(e -> closeManager());
733        frameControlBox.add(okButton);
734        tabbedPane.setSelectedIndex(getLastTab());
735        guiInitialized = true;
736    }
737
738    /**
739     * Respond to changes in {@link #tabbedPane}; primarily switching tabs.
740     * 
741     * @param event Event being handled. Ignored for now.
742     */
743    private void handleTabStateChanged(final ChangeEvent event) {
744        assert SwingUtilities.isEventDispatchThread();
745        boolean hasSelection = false;
746        int index = 0;
747        if (guiInitialized) {
748            index = tabbedPane.getSelectedIndex();
749            if (index == 0) {
750                hasSelection = hasRemoteSelection();
751                editRemoteButton.setEnabled(hasSelection);
752                removeRemoteButton.setEnabled(hasSelection);
753            } else {
754                hasSelection = hasLocalSelection();
755                editLocalButton.setEnabled(hasSelection);
756                removeLocalButton.setEnabled(hasSelection);
757            }
758            editMenuItem.setEnabled(hasSelection);
759            removeMenuItem.setEnabled(hasSelection);
760            setLastTab(index);
761        }
762        logger.trace("index={} hasRemote={} hasLocal={} guiInit={}", new Object[] {index, hasRemoteSelection(), hasLocalSelection(), guiInitialized});
763    }
764
765    /**
766     * Respond to events.
767     * 
768     * @param e {@link ListSelectionEvent} that necessitated this call.
769     */
770    private void remoteSelectionModelChanged(final ListSelectionEvent e) {
771        if (e.getValueIsAdjusting()) {
772            return;
773        }
774
775        int selectedRowCount = 0;
776        ListSelectionModel selModel = (ListSelectionModel)e.getSource();
777        Set<RemoteAddeEntry> selectedEntries;
778        if (selModel.isSelectionEmpty()) {
779            selectedEntries = Collections.emptySet();
780        } else {
781            int min = selModel.getMinSelectionIndex();
782            int max = selModel.getMaxSelectionIndex();
783            RemoteAddeTableModel tableModel = (RemoteAddeTableModel)remoteTable.getModel();
784            selectedEntries = newLinkedHashSet((max - min) * AddeEntry.EntryType.values().length);
785            for (int i = min; i <= max; i++) {
786                if (selModel.isSelectedIndex(i)) {
787                    List<RemoteAddeEntry> entries = tableModel.getEntriesAtRow(i);
788                    selectedEntries.addAll(entries);
789                    selectedRowCount++;
790                }
791            }
792        }
793
794        boolean onlyDefaultEntries = true;
795        for (RemoteAddeEntry entry : selectedEntries) {
796            if (entry.getEntrySource() != EntrySource.SYSTEM) {
797                onlyDefaultEntries = false;
798                break;
799            }
800        }
801        setSelectedRemoteEntries(selectedEntries);
802
803        // the current "edit" dialog doesn't work so well with multiple 
804        // servers/datasets, so only allow the user to edit entries one at a time.
805        boolean singleSelection = selectedRowCount == 1;
806        editRemoteButton.setEnabled(singleSelection);
807        editMenuItem.setEnabled(singleSelection);
808
809        boolean hasSelection = (selectedRowCount >= 1) && !onlyDefaultEntries;
810        removeRemoteButton.setEnabled(hasSelection);
811        removeMenuItem.setEnabled(hasSelection);
812    }
813
814    /**
815     * Respond to events from the local dataset table.
816     * 
817     * @param e {@link ListSelectionEvent} that necessitated this call.
818     */
819    private void localSelectionModelChanged(final ListSelectionEvent e) {
820        if (e.getValueIsAdjusting()) {
821            return;
822        }
823        ListSelectionModel selModel = (ListSelectionModel)e.getSource();
824        Set<LocalAddeEntry> selectedEntries;
825        if (selModel.isSelectionEmpty()) {
826            selectedEntries = Collections.emptySet();
827        } else {
828            int min = selModel.getMinSelectionIndex();
829            int max = selModel.getMaxSelectionIndex();
830            LocalAddeTableModel tableModel = (LocalAddeTableModel)localTable.getModel();
831            selectedEntries = newLinkedHashSet(max - min);
832            for (int i = min; i <= max; i++) {
833                if (selModel.isSelectedIndex(i)) {
834                    selectedEntries.add(tableModel.getEntryAtRow(i));
835                }
836            }
837        }
838
839        setSelectedLocalEntries(selectedEntries);
840
841        // the current "edit" dialog doesn't work so well with multiple 
842        // servers/datasets, so only allow the user to edit entries one at a time.
843        boolean singleSelection = selectedEntries.size() == 1;
844        this.editRemoteButton.setEnabled(singleSelection);
845        this.editMenuItem.setEnabled(singleSelection);
846
847        boolean hasSelection = !selectedEntries.isEmpty();
848        removeRemoteButton.setEnabled(hasSelection);
849        removeMenuItem.setEnabled(hasSelection);
850    }
851
852    /**
853     * Checks to see if {@link #selectedRemoteEntries} contains any 
854     * {@link RemoteAddeEntry}s.
855     *
856     * @return Whether or not any {@code RemoteAddeEntry} values are selected.
857     */
858    private boolean hasRemoteSelection() {
859        return !selectedRemoteEntries.isEmpty();
860    }
861
862    /**
863     * Checks to see if {@link #selectedLocalEntries} contains any
864     * {@link LocalAddeEntry}s.
865     *
866     * @return Whether or not any {@code LocalAddeEntry} values are selected.
867     */
868    private boolean hasLocalSelection() {
869        return !selectedLocalEntries.isEmpty();
870    }
871
872    /**
873     * Checks to see if the user has select a <b>single</b> remote dataset.
874     * 
875     * @return {@code true} if there is a single remote dataset selected.
876     * {@code false} otherwise.
877     */
878    private boolean hasSingleRemoteSelection() {
879        String entryText = null;
880        boolean result = true;
881        for (RemoteAddeEntry entry : selectedRemoteEntries) {
882            if (entryText == null) {
883                entryText = entry.getEntryText();
884            }
885            if (!entry.getEntryText().equals(entryText)) {
886                result = false;
887                break;
888            }
889        }
890        return result;
891    }
892
893    /**
894     * Checks to see if the user has select a <b>single</b> local dataset.
895     * 
896     * @return {@code true} if there is a single local dataset selected. {@code false} otherwise.
897     */
898    private boolean hasSingleLocalSelection() {
899        return selectedLocalEntries.size() == 1;
900    }
901
902    /**
903     * If there is a single local dataset selected, this method will return that
904     * dataset.
905     * 
906     * @return Either the single selected local dataset, or {@link LocalAddeEntry#INVALID_ENTRY}.
907     */
908    private LocalAddeEntry getSingleLocalSelection() {
909        LocalAddeEntry entry = LocalAddeEntry.INVALID_ENTRY;
910        if (selectedLocalEntries.size() == 1) {
911            entry = selectedLocalEntries.get(0);
912        }
913        return entry;
914    }
915
916    /**
917     * Corresponds to the selected remote ADDE entries in the GUI.
918     * 
919     * @param entries Should not be {@code null}.
920     */
921    private void setSelectedRemoteEntries(final Collection<RemoteAddeEntry> entries) {
922        selectedRemoteEntries.clear();
923        selectedRemoteEntries.addAll(entries);
924        this.editRemoteButton.setEnabled(entries.size() == 1);
925        this.removeRemoteButton.setEnabled(!entries.isEmpty());
926        logger.trace("remote entries={}", entries);
927    }
928
929    /**
930     * Gets the selected remote ADDE entries.
931     * 
932     * @return Either an empty list or the remote entries selected in the GUI.
933     */
934    private List<RemoteAddeEntry> getSelectedRemoteEntries() {
935        List<RemoteAddeEntry> selected = Collections.emptyList();
936        if (!selectedRemoteEntries.isEmpty()) {
937            selected = arrList(selectedRemoteEntries);
938        }
939        return selected;
940    }
941
942    /**
943     * Corresponds to the selected local ADDE entries in the GUI.
944     * 
945     * @param entries Should not be {@code null}.
946     */
947    private void setSelectedLocalEntries(final Collection<LocalAddeEntry> entries) {
948        selectedLocalEntries.clear();
949        selectedLocalEntries.addAll(entries);
950        this.editLocalButton.setEnabled(entries.size() == 1);
951        this.removeLocalButton.setEnabled(!entries.isEmpty());
952        logger.trace("local entries={}", entries);
953    }
954
955    /**
956     * Gets the selected local ADDE entries.
957     * 
958     * @return Either an empty list or the local entries selected in the GUI.
959     */
960    private List<LocalAddeEntry> getSelectedLocalEntries() {
961        List<LocalAddeEntry> selected = Collections.emptyList();
962        if (!selectedLocalEntries.isEmpty()) {
963            selected = arrList(selectedLocalEntries);
964        }
965        return selected;
966    }
967
968    /**
969     * Handles the user closing the server manager GUI.
970     * 
971     * @param evt Event that triggered this method call. Currently ignored.
972     * 
973     * @see #closeManager()
974     */
975    private void formWindowClosed(WindowEvent evt) {
976        closeManager();
977    }
978
979    @SuppressWarnings({"MagicNumber"})
980    private JPanel makeFileChooserAccessory() {
981        assert SwingUtilities.isEventDispatchThread();
982        JPanel accessory = new JPanel();
983        accessory.setLayout(new BoxLayout(accessory, BoxLayout.PAGE_AXIS));
984        importAccountBox = new JCheckBox("Use ADDE Accounting?");
985        importAccountBox.setSelected(false);
986        importAccountBox.addActionListener(evt -> {
987            boolean selected = importAccountBox.isSelected();
988            importUser.setEnabled(selected);
989            importProject.setEnabled(selected);
990        });
991        String clientProp = "JComponent.sizeVariant";
992        String propVal = "mini";
993
994        importUser = new JTextField();
995        importUser.putClientProperty(clientProp, propVal);
996        Prompt userPrompt = new Prompt(importUser, "Username");
997        userPrompt.putClientProperty(clientProp, propVal);
998        importUser.setEnabled(importAccountBox.isSelected());
999
1000        importProject = new JTextField();
1001        Prompt projPrompt = new Prompt(importProject, "Project Number");
1002        projPrompt.putClientProperty(clientProp, propVal);
1003        importProject.putClientProperty(clientProp, propVal);
1004        importProject.setEnabled(importAccountBox.isSelected());
1005
1006        GroupLayout layout = new GroupLayout(accessory);
1007        accessory.setLayout(layout);
1008        layout.setHorizontalGroup(
1009            layout.createParallelGroup(GroupLayout.Alignment.LEADING)
1010            .addComponent(importAccountBox)
1011            .addGroup(layout.createSequentialGroup()
1012                .addContainerGap()
1013                .addGroup(layout.createParallelGroup(GroupLayout.Alignment.TRAILING, false)
1014                    .addComponent(importProject, GroupLayout.Alignment.LEADING)
1015                    .addComponent(importUser, GroupLayout.Alignment.LEADING, GroupLayout.DEFAULT_SIZE, 131, Short.MAX_VALUE)))
1016        );
1017        layout.setVerticalGroup(
1018            layout.createParallelGroup(GroupLayout.Alignment.LEADING)
1019            .addGroup(layout.createSequentialGroup()
1020                .addComponent(importAccountBox)
1021                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
1022                .addComponent(importUser, GroupLayout.PREFERRED_SIZE, GroupLayout.DEFAULT_SIZE, GroupLayout.PREFERRED_SIZE)
1023                .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED)
1024                .addComponent(importProject, GroupLayout.PREFERRED_SIZE, GroupLayout.DEFAULT_SIZE, GroupLayout.PREFERRED_SIZE)
1025                .addContainerGap(55, (int)Short.MAX_VALUE))
1026        );
1027        return accessory;
1028    }
1029
1030    private void importButtonActionPerformed(ActionEvent evt) {
1031        assert SwingUtilities.isEventDispatchThread();
1032        JFileChooser fc = new JFileChooser(getLastImportPath());
1033        fc.setAccessory(makeFileChooserAccessory());
1034        fc.setFileSelectionMode(JFileChooser.FILES_ONLY);
1035        int ret = fc.showOpenDialog(this);
1036        if (ret == JFileChooser.APPROVE_OPTION) {
1037            File f = fc.getSelectedFile();
1038            String path = f.getPath();
1039
1040            boolean defaultUser = false;
1041            String forceUser = safeGetText(importUser);
1042            if (forceUser.isEmpty()) {
1043                forceUser = AddeEntry.DEFAULT_ACCOUNT.getUsername();
1044                defaultUser = true;
1045            }
1046
1047            boolean defaultProj = false;
1048            String forceProj = safeGetText(importProject);
1049            if (forceProj.isEmpty()) {
1050                forceProj = AddeEntry.DEFAULT_ACCOUNT.getProject();
1051                defaultProj = true;
1052            }
1053
1054            if (importAccountBox.isSelected() && (defaultUser || defaultProj)) {
1055                logger.warn("bad acct dialog: forceUser={} forceProj={}", forceUser, forceProj);
1056            } else {
1057                logger.warn("acct appears valid: forceUser={} forceProj={}", forceUser, forceProj);
1058                importMctable(path, forceUser, forceProj);
1059                // don't worry about file validity; i'll just assume the user clicked
1060                // on the wrong entry by accident.
1061                setLastImportPath(f.getParent());
1062            }
1063        }
1064    }
1065
1066    /**
1067     * Returns the directory that contained the most recently imported MCTABLE.TXT.
1068     *
1069     * @return Either the path to the most recently imported MCTABLE.TXT file,
1070     * or an empty {@code String}.
1071     */
1072    private String getLastImportPath() {
1073        String lastPath = serverManager.getIdvStore().get(LAST_IMPORTED, "");
1074        logger.trace("last path='{}'", lastPath);
1075        return lastPath;
1076    }
1077
1078    /**
1079     * Saves the directory that contained the most recently imported MCTABLE.TXT.
1080     *
1081     * @param path Path to the most recently imported MCTABLE.TXT file.
1082     * {@code null} values are replaced with an empty {@code String}.
1083     */
1084    private void setLastImportPath(final String path) {
1085        String okayPath = (path == null) ? "" : path;
1086        logger.trace("saving path='{}'", okayPath);
1087        serverManager.getIdvStore().put(LAST_IMPORTED, okayPath);
1088    }
1089
1090    /**
1091     * Returns the index of the user's last server manager tab.
1092     *
1093     * @return Index of the user's most recently viewed server manager tab, or {@code 0}.
1094     */
1095    private int getLastTab() {
1096        int index = serverManager.getIdvStore().get(LAST_TAB, 0);
1097        logger.trace("last tab={}", index);
1098        return index;
1099    }
1100
1101    /**
1102     * Saves the index of the last server manager tab the user was looking at.
1103     * 
1104     * @param index Index of the user's most recently viewed server manager tab.
1105     */
1106    private void setLastTab(final int index) {
1107        int okayIndex = ((index >= 0) && (index < 2)) ? index : 0;
1108        IdvObjectStore store = serverManager.getIdvStore();
1109        logger.trace("storing tab={}", okayIndex);
1110        store.put(LAST_TAB, okayIndex);
1111    }
1112
1113    // stupid adde.ucar.edu entries never seem to time out! great! making the gui hang is just so awesome!
1114    @SuppressWarnings({"ObjectAllocationInLoop"})
1115    public Set<RemoteAddeEntry> checkDatasets(final Collection<RemoteAddeEntry> entries) {
1116        requireNonNull(entries, "can't check a null collection of entries");
1117        if (entries.isEmpty()) {
1118            return Collections.emptySet();
1119        }
1120
1121        Set<RemoteAddeEntry> valid = newLinkedHashSet();
1122        ExecutorService exec = Executors.newFixedThreadPool(POOL);
1123        CompletionService<List<RemoteAddeEntry>> ecs =
1124            new ExecutorCompletionService<>(exec);
1125        final RemoteAddeTableModel tableModel =
1126            (RemoteAddeTableModel)remoteTable.getModel();
1127
1128        // place entries
1129        for (RemoteAddeEntry entry : entries) {
1130            ecs.submit(new BetterCheckTask(entry));
1131            logger.trace("submitting entry={}", entry);
1132            final int row = tableModel.getRowForEntry(entry);
1133            runOnEDT(() -> tableModel.fireTableRowsUpdated(row, row));
1134        }
1135
1136        // work through the entries
1137        try {
1138            for (int i = 0; i < entries.size(); i++) {
1139                final List<RemoteAddeEntry> checkedEntries = ecs.take().get();
1140                if (!checkedEntries.isEmpty()) {
1141                    final int row =
1142                        tableModel.getRowForEntry(checkedEntries.get(0));
1143                    runOnEDT(() -> {
1144                        List<RemoteAddeEntry> old =
1145                            tableModel.getEntriesAtRow(row);
1146                        serverManager.replaceEntries(old, checkedEntries);
1147                        tableModel.fireTableRowsUpdated(row, row);
1148                    });
1149                }
1150                valid.addAll(checkedEntries);
1151            }
1152        } catch (InterruptedException e) {
1153            LogUtil.logException("Interrupted while validating entries", e);
1154        } catch (ExecutionException e) {
1155            LogUtil.logException("ADDE validation execution error", e);
1156        } finally {
1157            exec.shutdown();
1158        }
1159        return valid;
1160    }
1161
1162    private static class BetterCheckTask implements Callable<List<RemoteAddeEntry>> {
1163        private final RemoteAddeEntry entry;
1164        public BetterCheckTask(final RemoteAddeEntry entry) {
1165            this.entry = entry;
1166            this.entry.setEntryValidity(EntryValidity.VALIDATING);
1167        }
1168        @SuppressWarnings({"FeatureEnvy"})
1169        public List<RemoteAddeEntry> call() {
1170            List<RemoteAddeEntry> valid = arrList();
1171            if (RemoteAddeEntry.checkHost(entry)) {
1172                EntryTransforms.createEntriesFrom(entry)
1173                               .stream()
1174                               .filter(tmp -> RemoteAddeEntry.checkEntry(false, tmp) == AddeStatus.OK)
1175                               .forEach(tmp -> {
1176                                   tmp.setEntryValidity(EntryValidity.VERIFIED);
1177                                   valid.add(tmp);
1178                               });
1179            }
1180            if (valid.isEmpty()) {
1181                entry.setEntryValidity(EntryValidity.INVALID);
1182            } else {
1183                entry.setEntryValidity(EntryValidity.VERIFIED);
1184            }
1185            return valid;
1186        }
1187    }
1188
1189    private class CheckEntryTask implements Callable<RemoteAddeEntry> {
1190        private final RemoteAddeEntry entry;
1191        public CheckEntryTask(final RemoteAddeEntry entry) {
1192            requireNonNull(entry);
1193            this.entry = entry;
1194            this.entry.setEntryValidity(EntryValidity.VALIDATING);
1195        }
1196        @SuppressWarnings({"FeatureEnvy"})
1197        public RemoteAddeEntry call() {
1198            AddeStatus status = RemoteAddeEntry.checkEntry(entry);
1199            switch (status) {
1200                case OK: entry.setEntryValidity(EntryValidity.VERIFIED); break;
1201                default: entry.setEntryValidity(EntryValidity.INVALID); break;
1202            }
1203            return entry;
1204        }
1205    }
1206
1207    private static class RemoteAddeTableModel extends AbstractTableModel {
1208
1209        // TODO(jon): these constants can go once things calm down
1210        private static final int VALID = 0;
1211        private static final int SOURCE = 1;
1212        private static final int DATASET = 2;
1213        private static final int ACCT = 3;
1214        private static final int TYPES = 4;
1215        private static final Pattern ENTRY_ID_SPLITTER = Pattern.compile("!");
1216
1217        /** Labels that appear as the column headers. */
1218        private final String[] columnNames = {
1219            "Valid", "Source", "Dataset", "Accounting", "Data Types"
1220        };
1221
1222        private final List<String> servers;
1223
1224        /** {@link EntryStore} used to query and apply changes. */
1225        private final EntryStore entryStore;
1226
1227        /**
1228         * Builds an {@link javax.swing.table.AbstractTableModel} with some
1229         * extensions that facilitate working with
1230         * {@link RemoteAddeEntry RemoteAddeEntrys}.
1231         * 
1232         * @param entryStore Server manager object.
1233         */
1234        public RemoteAddeTableModel(final EntryStore entryStore) {
1235            requireNonNull(entryStore, "Cannot query a null EntryStore");
1236            this.entryStore = entryStore;
1237            this.servers = arrList(entryStore.getRemoteEntryTexts());
1238        }
1239
1240        /**
1241         * Returns the {@link RemoteAddeEntry} at the given index.
1242         * 
1243         * @param row Index of the entry.
1244         * 
1245         * @return {@code RemoteAddeEntry} at index specified by {@code row}.
1246         */
1247        protected List<RemoteAddeEntry> getEntriesAtRow(final int row) {
1248            String server = servers.get(row).replace('/', '!');
1249            List<RemoteAddeEntry> matches = arrList();
1250            matches.addAll(
1251                entryStore.searchWithPrefix(server)
1252                          .stream()
1253                          .filter(entry -> entry instanceof RemoteAddeEntry)
1254                          .map(entry -> (RemoteAddeEntry) entry)
1255                          .collect(Collectors.toList()));
1256            return matches;
1257        }
1258
1259        /**
1260         * Returns the index of the given {@code entry}.
1261         *
1262         * @param entry {@link RemoteAddeEntry} whose row is desired.
1263         *
1264         * @return Index of the desired {@code entry}, or {@code -1} if the
1265         * entry wasn't found.
1266         */
1267        protected int getRowForEntry(final RemoteAddeEntry entry) {
1268            return getRowForEntry(entry.getEntryText());
1269        }
1270
1271        /**
1272         * Returns the index of the given entry text within the table.
1273         *
1274         * @param entryText String representation of the desired entry.
1275         *
1276         * @return Index of the desired entry, or {@code -1} if the entry was
1277         * not found.
1278         *
1279         * @see AddeEntry#getEntryText()
1280         */
1281        protected int getRowForEntry(final String entryText) {
1282            return servers.indexOf(entryText);
1283        }
1284
1285        /**
1286         * Clears and re-adds all {@link RemoteAddeEntry}s within
1287         * {@link #entryStore}.
1288         */
1289        public void refreshEntries() {
1290            servers.clear();
1291            servers.addAll(entryStore.getRemoteEntryTexts());
1292            this.fireTableDataChanged();
1293        }
1294
1295        /**
1296         * Returns the length of {@link #columnNames}.
1297         * 
1298         * @return The number of columns.
1299         */
1300        @Override public int getColumnCount() {
1301            return columnNames.length;
1302        }
1303
1304        /**
1305         * Returns the number of entries being managed.
1306         */
1307        @Override public int getRowCount() {
1308            return servers.size();
1309        }
1310
1311        /**
1312         * Finds the value at the given coordinates.
1313         * 
1314         * @param row Table row.
1315         * @param column Table column.
1316         * 
1317         * @return Value stored at the given {@code row} and {@code column}
1318         * coordinates
1319         * 
1320         * @throws IndexOutOfBoundsException if {@code row} or {@code column}
1321         * refer to an invalid table cell.
1322         */
1323        @Override public Object getValueAt(int row, int column) {
1324            String serverText = servers.get(row);
1325            String prefix = serverText.replace('/', '!');
1326            switch (column) {
1327                case VALID: return formattedValidity(prefix, entryStore);
1328                case SOURCE: return formattedSource(prefix, entryStore);
1329                case DATASET: return serverText;
1330                case ACCT: return formattedAccounting(prefix, entryStore);
1331                case TYPES: return formattedTypes(prefix, entryStore);
1332                default: throw new IndexOutOfBoundsException();
1333            }
1334        }
1335
1336        private static String formattedSource(final String serv,
1337                                              final EntryStore manager)
1338        {
1339            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1340            EntrySource source = EntrySource.INVALID;
1341            if (!matches.isEmpty()) {
1342                for (AddeEntry entry : matches) {
1343                    if (entry.getEntrySource() == EntrySource.USER) {
1344                        return EntrySource.USER.toString();
1345                    }
1346                }
1347              source = matches.get(0).getEntrySource();
1348            }
1349            return source.toString();
1350        }
1351
1352        private static String formattedValidity(final String serv,
1353                                                final EntryStore manager)
1354        {
1355            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1356            EntryValidity validity = EntryValidity.INVALID;
1357            if (!matches.isEmpty()) {
1358                validity = matches.get(0).getEntryValidity();
1359            }
1360            return validity.toString();
1361        }
1362
1363        private static String formattedAccounting(final String serv,
1364                                                  final EntryStore manager)
1365        {
1366            List<AddeEntry> matches = manager.searchWithPrefix(serv);
1367            AddeAccount acct = AddeEntry.DEFAULT_ACCOUNT;
1368            if (!matches.isEmpty()) {
1369                acct = matches.get(0).getAccount();
1370            }
1371            if (AddeEntry.DEFAULT_ACCOUNT.equals(acct)) {
1372                return "public dataset";
1373            }
1374            return acct.friendlyString();
1375        }
1376
1377        private static boolean hasType(final String serv,
1378                                       final EntryStore manager,
1379                                       final EntryType type)
1380        {
1381            String[] chunks = ENTRY_ID_SPLITTER.split(serv);
1382            Set<EntryType> types = Collections.emptySet();
1383            if (chunks.length == 2) {
1384                types = manager.getTypes(chunks[0], chunks[1]);
1385            }
1386            return types.contains(type);
1387        }
1388
1389        private static String formattedTypes(final String serv,
1390                                             final EntryStore manager)
1391        {
1392            String[] chunks = ENTRY_ID_SPLITTER.split(serv);
1393//            Set<EntryType> types = Collections.emptySet();
1394//            if (chunks.length == 2) {
1395//                types = manager.getTypes(chunks[0], chunks[1]);
1396//            }
1397            Set<EntryType> types = chunks.length == 2
1398                                   ? manager.getTypes(chunks[0], chunks[1])
1399                                   : Collections.emptySet();
1400
1401
1402//            @SuppressWarnings({"MagicNumber"})
1403//            StringBuilder sb = new StringBuilder(30);
1404//            for (EntryType type : EnumSet.of(EntryType.IMAGE, EntryType.GRID, EntryType.NAV, EntryType.POINT, EntryType.RADAR, EntryType.TEXT)) {
1405//                if (types.contains(type)) {
1406//                    sb.append(type.toString()).append(' ');
1407//                }
1408//            }
1409            return EnumSet.of(EntryType.IMAGE, EntryType.GRID, EntryType.NAV,
1410                              EntryType.POINT, EntryType.RADAR, EntryType.TEXT
1411                   ).stream()
1412                   .filter(types::contains)
1413                   .map(String::valueOf)
1414                   .collect(Collectors.joining(" "));
1415//            return sb.toString().toLowerCase();
1416        }
1417
1418        /**
1419         * Returns the column name associated with {@code column}.
1420         * 
1421         * @return One of {@link #columnNames}.
1422         */
1423        @Override public String getColumnName(final int column) {
1424            return columnNames[column];
1425        }
1426
1427        @Override public Class<?> getColumnClass(final int column) {
1428            return String.class;
1429        }
1430
1431        @Override public boolean isCellEditable(final int row,
1432                                                final int column)
1433        {
1434            return false;
1435        }
1436    }
1437
1438    private static class LocalAddeTableModel extends AbstractTableModel {
1439
1440        /** Labels that appear as the column headers. */
1441        private final String[] columnNames = {
1442            "Dataset (e.g. MYDATA)", "Image Type (e.g. JAN 07 GOES)",
1443            "Format", "Directory"
1444        };
1445
1446        /** Entries that currently populate the server manager. */
1447        private final List<LocalAddeEntry> entries;
1448
1449        /** {@link EntryStore} used to query and apply changes. */
1450        private final EntryStore entryStore;
1451
1452        public LocalAddeTableModel(final EntryStore entryStore) {
1453            requireNonNull(entryStore, "Cannot query a null EntryStore");
1454            this.entryStore = entryStore;
1455            this.entries = arrList(entryStore.getLocalEntries());
1456        }
1457
1458        /**
1459         * Returns the {@link LocalAddeEntry} at the given index.
1460         * 
1461         * @param row Index of the entry.
1462         * 
1463         * @return {@code LocalAddeEntry} at index specified by {@code row}.
1464         */
1465        protected LocalAddeEntry getEntryAtRow(final int row) {
1466            return entries.get(row);
1467        }
1468
1469        protected int getRowForEntry(final LocalAddeEntry entry) {
1470            return entries.indexOf(entry);
1471        }
1472
1473        protected List<LocalAddeEntry> getSelectedEntries(final int[] rows) {
1474            List<LocalAddeEntry> selected = arrList(rows.length);
1475            int rowCount = entries.size();
1476            for (int tmpIdx : rows) {
1477                if ((tmpIdx >= 0) && (tmpIdx < rowCount)) {
1478                    selected.add(entries.get(tmpIdx));
1479                } else {
1480                    throw new IndexOutOfBoundsException();
1481                }
1482            }
1483            return selected;
1484        }
1485
1486        public void refreshEntries() {
1487            entries.clear();
1488            entries.addAll(entryStore.getLocalEntries());
1489            this.fireTableDataChanged();
1490        }
1491
1492        /**
1493         * Returns the length of {@link #columnNames}.
1494         * 
1495         * @return The number of columns.
1496         */
1497        @Override public int getColumnCount() {
1498            return columnNames.length;
1499        }
1500
1501        /**
1502         * Returns the number of entries being managed.
1503         */
1504        @Override public int getRowCount() {
1505            return entries.size();
1506        }
1507
1508        /**
1509         * Finds the value at the given coordinates.
1510         * 
1511         * @param row Table row.
1512         * @param column Table column.
1513         * 
1514         * @return Value stored at the given {@code row} and {@code column}
1515         * coordinates
1516         *
1517         * @throws IndexOutOfBoundsException if {@code row} or {@code column}
1518         * refer to an invalid table cell.
1519         */
1520        @Override public Object getValueAt(int row, int column) {
1521            LocalAddeEntry entry = entries.get(row);
1522            if (entry == null) {
1523                throw new IndexOutOfBoundsException(); // still questionable...
1524            }
1525
1526            switch (column) {
1527                case 0: return entry.getGroup();
1528                case 1: return entry.getName();
1529                case 2: return entry.getFormat();
1530                case 3: return entry.getMask();
1531                default: throw new IndexOutOfBoundsException();
1532            }
1533        }
1534
1535        /**
1536         * Returns the column name associated with {@code column}.
1537         * 
1538         * @return One of {@link #columnNames}.
1539         */
1540        @Override public String getColumnName(final int column) {
1541            return columnNames[column];
1542        }
1543    }
1544
1545    // i need the following icons:
1546    // something to convey entry validity: invalid, verified, unverified
1547    // a "system" entry icon (thinking of something with prominent "V")
1548    // a "mctable" entry icon (similar to above, but with a prominent "X")
1549    // a "user" entry icon (no idea yet!)
1550    public class EntrySourceRenderer extends DefaultTableCellRenderer {
1551
1552        public Component getTableCellRendererComponent(JTable table,
1553                                                       Object value,
1554                                                       boolean isSelected,
1555                                                       boolean hasFocus,
1556                                                       int row,
1557                                                       int column)
1558        {
1559            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1560            EntrySource source = EntrySource.valueOf((String)value);
1561            EntrySourceRenderer renderer = (EntrySourceRenderer)comp;
1562            Icon icon = null;
1563            String tooltip = null;
1564            switch (source) {
1565                case SYSTEM:
1566                    icon = system;
1567                    tooltip = "Default dataset and cannot be removed, only disabled.";
1568                    break;
1569                case MCTABLE:
1570                    icon = mctable;
1571                    tooltip = "Dataset imported from a MCTABLE.TXT.";
1572                    break;
1573                case USER:
1574                    icon = user;
1575                    tooltip = "Dataset created or altered by you!";
1576                    break;
1577            }
1578            renderer.setIcon(icon);
1579            renderer.setToolTipText(tooltip);
1580            renderer.setText(null);
1581            return comp;
1582        }
1583    }
1584
1585    public class EntryValidityRenderer extends DefaultTableCellRenderer {
1586        public Component getTableCellRendererComponent(JTable table,
1587                                                       Object value,
1588                                                       boolean isSelected,
1589                                                       boolean hasFocus,
1590                                                       int row,
1591                                                       int column)
1592        {
1593            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1594            EntryValidity validity = EntryValidity.valueOf((String)value);
1595            EntryValidityRenderer renderer = (EntryValidityRenderer)comp;
1596            Icon icon = null;
1597            String msg = null;
1598            String tooltip = null;
1599            switch (validity) {
1600                case INVALID:
1601                    icon = invalid;
1602                    tooltip = "Dataset verification failed.";
1603                    break;
1604                case VERIFIED:
1605                    break;
1606                case UNVERIFIED:
1607                    icon = unverified;
1608                    tooltip = "Dataset has not been verified.";
1609                    break;
1610                case VALIDATING:
1611                    msg = "Checking...";
1612                    break;
1613            }
1614            renderer.setIcon(icon);
1615            renderer.setToolTipText(tooltip);
1616            renderer.setText(msg);
1617            return comp;
1618        }
1619    }
1620
1621    public static class TextRenderer extends DefaultTableCellRenderer {
1622
1623        /** */
1624        private Font bold;
1625
1626        /** */
1627        private Font boldItalic;
1628
1629        public Component getTableCellRendererComponent(JTable table,
1630                                                       Object value,
1631                                                       boolean isSelected,
1632                                                       boolean hasFocus,
1633                                                       int row,
1634                                                       int column)
1635        {
1636            Component comp = super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
1637            Font currentFont = comp.getFont();
1638            if (bold == null) {
1639                bold = currentFont.deriveFont(Font.BOLD); 
1640            }
1641            if (boldItalic == null) {
1642                boldItalic = currentFont.deriveFont(Font.BOLD | Font.ITALIC);
1643            }
1644            if (column == 2) {
1645                comp.setFont(bold);
1646            } else if (column == 3) {
1647                // why can't i set the color for just a single column!?
1648            } else if (column == 4) {
1649                comp.setFont(boldItalic);
1650            }
1651            return comp;
1652        }
1653    }
1654
1655    /**
1656     * Construct an {@link Icon} object using the image at the specified
1657     * {@code path}.
1658     * 
1659     * @param path Path to image to use as an icon. Should not be {@code null}.
1660     * 
1661     * @return Icon object with the desired image.
1662     */
1663    private static Icon icon(final String path) {
1664        return GuiUtils.getImageIcon(path, TabbedAddeManager.class, true);
1665    }
1666
1667    /**
1668     * Launch the application. Makes for a simplistic test.
1669     * 
1670     * @param args Command line arguments. These are currently ignored.
1671     */
1672    public static void main(String[] args) {
1673        SwingUtilities.invokeLater(() -> {
1674            try {
1675                TabbedAddeManager frame = new TabbedAddeManager();
1676                frame.setVisible(true);
1677            } catch (Exception e) {
1678                e.printStackTrace();
1679            }
1680        });
1681    }
1682
1683    private JPanel contentPane;
1684    private JTable remoteTable;
1685    private JTable localTable;
1686    private JTabbedPane tabbedPane;
1687    private JLabel statusLabel;
1688    private JButton newRemoteButton;
1689    private JButton editRemoteButton;
1690    private JButton removeRemoteButton;
1691    private JButton importRemoteButton;
1692    private JButton newLocalButton;
1693    private JButton editLocalButton;
1694    private JButton removeLocalButton;
1695    private JButton okButton;
1696    private JMenuItem editMenuItem;
1697    private JMenuItem removeMenuItem;
1698    private JCheckBox importAccountBox;
1699
1700    /** Icon for datasets that are part of a default McIDAS-V install. */
1701    private Icon system;
1702
1703    /** Icon for datasets that originate from a MCTABLE.TXT. */
1704    private Icon mctable;
1705
1706    /** Icon for datasets that the user has provided. */
1707    private Icon user;
1708
1709    /** Icon for invalid datasets. */
1710    private Icon invalid;
1711
1712    /** Icon for datasets that have not been verified. */
1713    private Icon unverified;
1714}