001/*
002 * This file is part of McIDAS-V
003 *
004 * Copyright 2007-2024
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 https://www.gnu.org/licenses/.
027 */
028package edu.wisc.ssec.mcidasv.servermanager;
029
030import static edu.wisc.ssec.mcidasv.util.McVGuiUtils.safeGetText;
031
032import java.awt.Component;
033import java.awt.Container;
034import java.awt.event.ActionEvent;
035
036import java.io.File;
037
038import java.util.Collections;
039import java.util.Set;
040
041import javax.swing.DefaultComboBoxModel;
042import javax.swing.JButton;
043import javax.swing.JComboBox;
044import javax.swing.JDialog;
045import javax.swing.JFileChooser;
046import javax.swing.JLabel;
047import javax.swing.JList;
048import javax.swing.JOptionPane;
049import javax.swing.JTextField;
050import javax.swing.SwingUtilities;
051import javax.swing.WindowConstants;
052import javax.swing.plaf.basic.BasicComboBoxRenderer;
053
054import javafx.stage.DirectoryChooser;
055
056import net.miginfocom.swing.MigLayout;
057
058import ucar.unidata.xml.XmlObjectStore;
059
060import edu.wisc.ssec.mcidasv.McIDASV;
061import edu.wisc.ssec.mcidasv.servermanager.LocalAddeEntry.AddeFormat;
062import edu.wisc.ssec.mcidasv.servermanager.AddeEntry.EditorAction;
063import edu.wisc.ssec.mcidasv.servermanager.AddeEntry.EntryStatus;
064import edu.wisc.ssec.mcidasv.util.McVGuiUtils;
065import edu.wisc.ssec.mcidasv.util.McVTextField;
066import edu.wisc.ssec.mcidasv.util.nativepathchooser.NativeDirectoryChooser;
067
068/**
069 * A dialog that allows the user to define or modify
070 * {@link LocalAddeEntry LocalAddeEntries}.
071 */
072@SuppressWarnings("serial")
073public class LocalEntryEditor extends JDialog {
074
075//    private static final Logger logger = LoggerFactory.getLogger(LocalEntryEditor.class);
076
077    /** Property ID for the last directory selected. */
078    private static final String PROP_LAST_PATH = "mcv.localdata.lastpath";
079
080    /** The valid local ADDE formats. */
081    private static final DefaultComboBoxModel<AddeFormat> formats =
082        new DefaultComboBoxModel<>(new AddeFormat[] {
083            // note: if you are looking to add a new value you may need to make
084            // changes to LocalAddeEntry's ServerName and AddeFormat enums,
085            // the format combo box in LocalEntryShortcut, and the _formats
086            // dictionary in mcvadde.py.
087            AddeFormat.MCIDAS_AREA,
088            AddeFormat.AMSRE_L1B,
089            AddeFormat.AMSRE_L2A,
090            AddeFormat.AMSRE_RAIN_PRODUCT,
091            AddeFormat.GINI,
092            AddeFormat.GOES16_ABI,
093            AddeFormat.HIMAWARI8,
094            AddeFormat.HIMAWARICAST,
095            AddeFormat.INSAT3D_IMAGER,
096            AddeFormat.INSAT3D_SOUNDER,
097            AddeFormat.LRIT_GOES9,
098            AddeFormat.LRIT_GOES10,
099            AddeFormat.LRIT_GOES11,
100            AddeFormat.LRIT_GOES12,
101            AddeFormat.LRIT_MET5,
102            AddeFormat.LRIT_MET7,
103            AddeFormat.LRIT_MTSAT1R,
104            AddeFormat.METEOSAT_OPENMTP,
105            AddeFormat.METOP_AVHRR_L1B,
106            AddeFormat.MODIS_L1B_MOD02,
107            AddeFormat.MODIS_L2_MOD06,
108            AddeFormat.MODIS_L2_MOD07,
109            AddeFormat.MODIS_L2_MOD35,
110            AddeFormat.MODIS_L2_MOD04,
111            AddeFormat.MODIS_L2_MOD28,
112            AddeFormat.MODIS_L2_MODR,
113            AddeFormat.MSG_HRIT_FD,
114            AddeFormat.MSG_HRIT_HRV,
115            AddeFormat.MSG_NATIVE,
116            AddeFormat.MTSAT_HRIT,
117            AddeFormat.NOAA_AVHRR_L1B,
118            AddeFormat.SCMI,
119            AddeFormat.SSMI,
120            AddeFormat.TRMM,
121            AddeFormat.VIIRSI,
122            AddeFormat.VIIRSM,
123            AddeFormat.VIIRSD,
124            AddeFormat.VIIREI,
125            AddeFormat.VIIREM,
126            // AddeFormat.MCIDAS_MD
127        });
128
129    /** The server manager GUI. Be aware that this can be {@code null}. */
130    private final TabbedAddeManager managerController;
131
132    /** Reference back to the server manager. */
133    private final EntryStore entryStore;
134
135    private final LocalAddeEntry currentEntry;
136
137    /** Either the path to an ADDE directory as selected by the user or an empty {@link String}. */
138    private String selectedPath = "";
139
140    /** The last dialog action performed by the user. */
141    private EditorAction editorAction = EditorAction.INVALID;
142
143    private final String datasetText;
144
145    /**
146     * Creates a modal local ADDE data editor. It's pretty useful when adding
147     * from a chooser.
148     * 
149     * @param entryStore The server manager. Should not be {@code null}.
150     * @param group Name of the group/dataset containing the desired data. Be aware that {@code null} is okay.
151     */
152    public LocalEntryEditor(final EntryStore entryStore, final String group) {
153        super((JDialog)null, true);
154        this.managerController = null;
155        this.entryStore = entryStore;
156        this.datasetText = group;
157        this.currentEntry = null;
158        SwingUtilities.invokeLater(() -> initComponents(LocalAddeEntry.INVALID_ENTRY));
159    }
160
161    // TODO(jon): hold back on javadocs, this is likely to change
162    public LocalEntryEditor(java.awt.Frame parent, boolean modal,
163                            final TabbedAddeManager manager,
164                            final EntryStore store)
165    {
166        super(manager, modal);
167        this.managerController = manager;
168        this.entryStore = store;
169        this.datasetText = null;
170        this.currentEntry = null;
171        SwingUtilities.invokeLater(() -> initComponents(LocalAddeEntry.INVALID_ENTRY));
172    }
173
174    // TODO(jon): hold back on javadocs, this is likely to change
175    public LocalEntryEditor(java.awt.Frame parent, boolean modal,
176                            final TabbedAddeManager manager,
177                            final EntryStore store,
178                            final LocalAddeEntry entry)
179    {
180        super(manager, modal);
181        this.managerController = manager;
182        this.entryStore = store;
183        this.datasetText = null;
184        this.currentEntry = entry;
185        SwingUtilities.invokeLater(() -> initComponents(entry));
186    }
187
188    /**
189     * Creates the editor dialog and initializes the various GUI components.
190     * 
191     * @param initEntry Use {@link LocalAddeEntry#INVALID_ENTRY} to specify 
192     * that the user is creating a new entry; otherwise provide the actual
193     * entry that the user is editing.
194     */
195    private void initComponents(final LocalAddeEntry initEntry) {
196        JLabel datasetLabel = new JLabel("Dataset (e.g. MYDATA):");
197        datasetField =
198            McVGuiUtils.makeTextFieldDeny("", 8, true, McVTextField.mcidasDeny);
199        datasetLabel.setLabelFor(datasetField);
200        datasetField.setColumns(20);
201        if (datasetText != null) {
202            datasetField.setText(datasetText);
203        }
204
205        JLabel typeLabel = new JLabel("Image Type (e.g. JAN 07 GOES):");
206        typeField = new JTextField();
207        typeLabel.setLabelFor(typeField);
208        typeField.setColumns(20);
209
210        JLabel formatLabel = new JLabel("Format:");
211        formatComboBox = new JComboBox<>();
212        formatComboBox.setRenderer(new TooltipComboBoxRenderer());
213
214        // TJJ Apr 2016
215        // certain local servers are not available on Windows, remove them from the list
216        if (McIDASV.isWindows()) {
217            formats.removeElement(AddeFormat.GOES16_ABI);
218            formats.removeElement(AddeFormat.HIMAWARI8);
219            formats.removeElement(AddeFormat.HIMAWARICAST);
220            formats.removeElement(AddeFormat.INSAT3D_IMAGER);
221            formats.removeElement(AddeFormat.INSAT3D_SOUNDER);
222            formats.removeElement(AddeFormat.SCMI);
223            formats.removeElement(AddeFormat.VIIRSI);
224            formats.removeElement(AddeFormat.VIIRSM);
225            formats.removeElement(AddeFormat.VIIRSD);
226            formats.removeElement(AddeFormat.VIIREI);
227            formats.removeElement(AddeFormat.VIIREM);
228            formats.removeElement(AddeFormat.MSG_NATIVE);
229        }
230
231        formatComboBox.setModel(formats);
232        formatComboBox.setSelectedIndex(0);
233        formatLabel.setLabelFor(formatComboBox);
234
235        JLabel directoryLabel = new JLabel("Directory:");
236        directoryField = new JTextField();
237        directoryLabel.setLabelFor(directoryField);
238        directoryField.setColumns(20);
239
240        JButton browseButton = new JButton("Browse...");
241        browseButton.addActionListener(this::browseButtonActionPerformed);
242
243        JButton saveButton = new JButton("Add Dataset");
244        saveButton.addActionListener(evt -> {
245            if (initEntry == LocalAddeEntry.INVALID_ENTRY) {
246                saveButtonActionPerformed(evt);
247            } else {
248                editButtonActionPerformed(evt);
249            }
250        });
251
252        JButton cancelButton = new JButton("Cancel");
253        cancelButton.addActionListener(this::cancelButtonActionPerformed);
254
255        if (initEntry == LocalAddeEntry.INVALID_ENTRY) {
256            setTitle("Add Local Dataset");
257        } else {
258            setTitle("Edit Local Dataset");
259            saveButton.setText("Save Changes");
260            datasetField.setText(initEntry.getGroup());
261            typeField.setText(initEntry.getName());
262            directoryField.setText(EntryTransforms.demungeFileMask(initEntry.getFileMask()));
263            formatComboBox.setSelectedItem(initEntry.getFormat());
264        }
265
266        setResizable(false);
267        setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE);
268        Container c = getContentPane();
269        c.setLayout(new MigLayout(
270            "",                    // general layout constraints; currently
271                                   // none are specified.
272            "[align right][fill]", // column constraints; defined two columns
273                                   // leftmost aligns the components right;
274                                   // rightmost simply fills the remaining space
275            "[][][][][][]"));      // row constraints; possibly not needed in
276                                   // this particular example?
277
278        // done via WindowBuilder + Eclipse
279//        c.add(datasetLabel,   "cell 0 0"); // row: 0; col: 0
280//        c.add(datasetField,   "cell 1 0"); // row: 0; col: 1
281//        c.add(typeLabel,      "cell 0 1"); // row: 1; col: 0
282//        c.add(typeField,      "cell 1 1"); // row: 1; col: 1
283//        c.add(formatLabel,    "cell 0 2"); // row: 2; col: 0
284//        c.add(formatComboBox, "cell 1 2"); // row: 2; col: 1
285//        c.add(directoryLabel, "cell 0 3"); // row: 3; col: 0 ... etc!
286//        c.add(directoryField, "flowx,cell 1 3");
287//        c.add(browseButton,   "cell 1 3,alignx right");
288//        c.add(saveButton,     "flowx,cell 1 5,alignx right,aligny top");
289//        c.add(cancelButton,   "cell 1 5,alignx right,aligny top");
290
291        // another way to accomplish the above layout.
292        c.add(datasetLabel);
293        c.add(datasetField,   "wrap"); // think "newline" or "new row"
294        c.add(typeLabel);
295        c.add(typeField,      "wrap"); // think "newline" or "new row"
296        c.add(formatLabel);
297        c.add(formatComboBox, "wrap"); // think "newline" or "new row"
298        c.add(directoryLabel);
299        c.add(directoryField, "flowx, split 2"); // split this current cell 
300                                                 // into two "subcells"; this
301                                                 // will cause browseButton to
302                                                 // be grouped into the current
303                                                 // cell.
304        c.add(browseButton,   "alignx right, wrap");
305
306        // skips "cell 0 5" causing this row to start in "cell 1 5"; splits 
307        // the cell so that saveButton and cancelButton both occupy cell 1 5.
308        c.add(saveButton,     "flowx, split 2, skip 1, alignx right, aligny top");
309        c.add(cancelButton,   "alignx right, aligny top");
310        pack();
311        setLocationRelativeTo(managerController);
312    }// </editor-fold>
313 
314    /**
315     * Triggered when the {@literal "add"} button is clicked.
316     *
317     * @param evt Ignored.
318     */
319    private void saveButtonActionPerformed(final ActionEvent evt) {
320        addEntry();
321    }
322
323    /**
324     * Triggered when the {@literal "edit"} button is clicked.
325     *
326     * @param evt Ignored.
327     */
328    private void editButtonActionPerformed(final ActionEvent evt) {
329        editEntry();
330    }
331
332    /**
333     * Triggered when the {@literal "file picker"} button is clicked.
334     *
335     * @param evt Ignored.
336     */
337    private void browseButtonActionPerformed(final ActionEvent evt) {
338        String lastPath;
339        if (currentEntry != null) {
340            lastPath = currentEntry.getMask();
341        } else {
342            lastPath = getLastPath();
343        }
344        selectedPath = getDataDirectory(lastPath);
345        // yes, the "!=" is intentional! getDataDirectory(String) will return
346        // the exact String it is given if the user cancelled the file picker
347        if (selectedPath != lastPath) {
348            directoryField.setText(selectedPath);
349            setLastPath(selectedPath);
350        }
351    }
352
353    /**
354     * Returns the value of the {@link #PROP_LAST_PATH} McIDAS-V property.
355     * 
356     * @return Either the {@code String} representation of the last path 
357     * selected by the user, or an empty {@code String}.
358     */
359    private String getLastPath() {
360        McIDASV mcv = McIDASV.getStaticMcv();
361        String path = "";
362        if (mcv != null) {
363            path = mcv.getObjectStore().get(PROP_LAST_PATH, "");
364        }
365        return path;
366    }
367
368    /**
369     * Sets the value of the {@link #PROP_LAST_PATH} McIDAS-V property to be
370     * the contents of {@code path}.
371     * 
372     * @param path New value for {@link #PROP_LAST_PATH}. {@code null} will be
373     * converted to an empty {@code String}.
374     */
375    public void setLastPath(final String path) {
376        String okayPath = (path != null) ? path : "";
377        McIDASV mcv = McIDASV.getStaticMcv();
378        if (mcv != null) {
379            XmlObjectStore store = mcv.getObjectStore();
380            store.put(PROP_LAST_PATH, okayPath);
381            store.saveIfNeeded();
382        }
383    }
384
385    /**
386     * Calls {@link #dispose} if the dialog is visible.
387     *
388     * @param evt Ignored.
389     */
390    private void cancelButtonActionPerformed(ActionEvent evt) {
391        if (isDisplayable()) {
392            dispose();
393        }
394    }
395
396    /**
397     * Poll the various UI components and attempt to construct valid ADDE
398     * entries based upon the information provided by the user.
399     * 
400     * @param newEntry a boolean, {@code true} if we are adding a new entry.
401     *
402     * @return {@link Set} of entries that represent the user's input, or an
403     * empty {@code Set} if the input was somehow invalid.
404     */
405    private Set<LocalAddeEntry> pollWidgets(boolean newEntry) {
406        String group = safeGetText(datasetField);
407        String name = safeGetText(typeField);
408        String mask = getLastPath();
409        
410        // consider the UI in error if any field is blank
411        if (group.isEmpty() || name.isEmpty() || mask.isEmpty()) {
412            JOptionPane.showMessageDialog(this.getContentPane(),
413                "Group, Name, or Mask field is empty, please correct this.");
414            return Collections.emptySet();
415        }
416
417        // if there is something in the directoryField, that's the value we
418        // should be using.
419        if (!safeGetText(directoryField).isEmpty()) {
420            mask = safeGetText(directoryField);
421            setLastPath(mask);
422        }
423        
424        AddeFormat format = (AddeFormat)formatComboBox.getSelectedItem();
425        LocalAddeEntry entry = new LocalAddeEntry.Builder(name, group, mask, format).status(EntryStatus.ENABLED).build();
426        
427        // if adding a new entry, make sure dataset is not a duplicate
428        if (newEntry) {
429            String newGroup = entry.getGroup();
430            for (AddeEntry storeEntry : entryStore.getEntrySet()) {
431                String storeGroup = storeEntry.getGroup();
432                if (newGroup.equals(storeGroup)) {
433                    // only apply this restriction to MSG HRIT data
434                    if ((format == AddeFormat.MSG_HRIT_FD) || (format == AddeFormat.MSG_HRIT_HRV)) {
435                        JOptionPane.showMessageDialog(this.getContentPane(),
436                            "Dataset specified is a duplicate, not supported with MSG HRIT format.");
437                        return Collections.emptySet();
438                    }
439                }
440            }
441        }
442        return Collections.singleton(entry);
443    }
444
445    /**
446     * Creates new {@link LocalAddeEntry}s based upon the contents of the dialog
447     * and adds {@literal "them"} to the managed servers. If the dialog is
448     * displayed, we call {@link #dispose()} and attempt to refresh the
449     * server manager GUI if it is available.
450     */
451    private void addEntry() {
452        Set<LocalAddeEntry> addedEntries = pollWidgets(true);
453        entryStore.addEntries(addedEntries);
454        if (isDisplayable()) {
455            dispose();
456        }
457        if (managerController != null) {
458            managerController.refreshDisplay();
459        }
460    }
461
462    private void editEntry() {
463        Set<LocalAddeEntry> newEntries = pollWidgets(false);
464        Set<LocalAddeEntry> currentEntries = Collections.singleton(currentEntry);
465        entryStore.replaceEntries(currentEntries, newEntries);
466        if (isDisplayable()) {
467            dispose();
468        }
469        if (managerController != null) {
470            managerController.refreshDisplay();
471        }
472    }
473    
474    /**
475     * Ask the user for a data directory from which to create a MASK=
476     *
477     * @param startDir If this is a valid path, then the file picker will
478     * (presumably) use that as its initial location. Should not be
479     * {@code null}?
480     *
481     * @return Either a path to a data directory or {@code startDir}.
482     */
483    private String getDataDirectory(final String startDir) {
484        NativeDirectoryChooser chooser = new NativeDirectoryChooser(() -> {
485            DirectoryChooser ch = new DirectoryChooser();
486            ch.setTitle("Select the data directory");
487            File initialDirectory = new File(startDir);
488            if (!initialDirectory.exists()) {
489                initialDirectory = new File(System.getProperty("user.home"));
490            }
491            ch.setInitialDirectory(initialDirectory);
492            return ch;
493        });
494        
495        String s = startDir;
496        File chosenPath = chooser.showOpenDialog();
497        
498        if (chosenPath != null) {
499            s = chosenPath.getAbsolutePath();
500        }
501        return s;
502    }
503    
504    /**
505     * Returns the last {@link EditorAction} that was performed.
506     *
507     * @return Last editor action performed.
508     *
509     * @see #editorAction
510     */
511    public EditorAction getEditorAction() {
512        return editorAction;
513    }
514
515    /**
516     * Dave's nice combobox tooltip renderer!
517     */
518    private static class TooltipComboBoxRenderer extends BasicComboBoxRenderer {
519        @Override public Component getListCellRendererComponent(JList list, 
520            Object value, int index, boolean isSelected, boolean cellHasFocus) 
521        {
522            if (isSelected) {
523                setBackground(list.getSelectionBackground());
524                setForeground(list.getSelectionForeground());
525                if (value instanceof AddeFormat) {
526                    list.setToolTipText(((AddeFormat)value).getTooltip());
527                }
528            } else {
529                setBackground(list.getBackground());
530                setForeground(list.getForeground());
531            }
532            setFont(list.getFont());
533            setText((value == null) ? "" : value.toString());
534            return this;
535        }
536    }
537
538    // Variables declaration - do not modify
539    private JTextField datasetField;
540    private JTextField directoryField;
541    private JComboBox<AddeFormat> formatComboBox;
542    private JTextField typeField;
543    // End of variables declaration
544}