001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.io;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006import static org.openstreetmap.josm.tools.I18n.trn;
007
008import java.awt.BorderLayout;
009import java.awt.Component;
010import java.awt.Dimension;
011import java.awt.FlowLayout;
012import java.awt.GridBagLayout;
013import java.awt.Image;
014import java.awt.event.ActionEvent;
015import java.awt.event.KeyEvent;
016import java.awt.event.WindowAdapter;
017import java.awt.event.WindowEvent;
018import java.beans.PropertyChangeEvent;
019import java.beans.PropertyChangeListener;
020import java.util.ArrayList;
021import java.util.Collection;
022import java.util.Collections;
023import java.util.List;
024import java.util.Map;
025import java.util.Map.Entry;
026
027import javax.swing.AbstractAction;
028import javax.swing.BorderFactory;
029import javax.swing.Icon;
030import javax.swing.ImageIcon;
031import javax.swing.JButton;
032import javax.swing.JComponent;
033import javax.swing.JDialog;
034import javax.swing.JOptionPane;
035import javax.swing.JPanel;
036import javax.swing.JTabbedPane;
037import javax.swing.KeyStroke;
038
039import org.openstreetmap.josm.Main;
040import org.openstreetmap.josm.data.APIDataSet;
041import org.openstreetmap.josm.data.Preferences.PreferenceChangeEvent;
042import org.openstreetmap.josm.data.Preferences.PreferenceChangedListener;
043import org.openstreetmap.josm.data.Preferences.Setting;
044import org.openstreetmap.josm.data.osm.Changeset;
045import org.openstreetmap.josm.data.osm.OsmPrimitive;
046import org.openstreetmap.josm.gui.ExtendedDialog;
047import org.openstreetmap.josm.gui.HelpAwareOptionPane;
048import org.openstreetmap.josm.gui.SideButton;
049import org.openstreetmap.josm.gui.help.ContextSensitiveHelpAction;
050import org.openstreetmap.josm.gui.help.HelpUtil;
051import org.openstreetmap.josm.io.OsmApi;
052import org.openstreetmap.josm.tools.GBC;
053import org.openstreetmap.josm.tools.ImageProvider;
054import org.openstreetmap.josm.tools.InputMapUtils;
055import org.openstreetmap.josm.tools.Utils;
056import org.openstreetmap.josm.tools.WindowGeometry;
057
058/**
059 * This is a dialog for entering upload options like the parameters for
060 * the upload changeset and the strategy for opening/closing a changeset.
061 *
062 */
063public class UploadDialog extends JDialog implements PropertyChangeListener, PreferenceChangedListener{
064    /**  the unique instance of the upload dialog */
065    private static UploadDialog uploadDialog;
066
067    /**
068     * List of custom components that can be added by plugins at JOSM startup.
069     */
070    private static final Collection<Component> customComponents = new ArrayList<>();
071
072    /**
073     * Replies the unique instance of the upload dialog
074     *
075     * @return the unique instance of the upload dialog
076     */
077    public static UploadDialog getUploadDialog() {
078        if (uploadDialog == null) {
079            uploadDialog = new UploadDialog();
080        }
081        return uploadDialog;
082    }
083
084    /** the panel with the objects to upload */
085    private UploadedObjectsSummaryPanel pnlUploadedObjects;
086    /** the panel to select the changeset used */
087    private ChangesetManagementPanel pnlChangesetManagement;
088
089    private BasicUploadSettingsPanel pnlBasicUploadSettings;
090
091    private UploadStrategySelectionPanel pnlUploadStrategySelectionPanel;
092
093    /** checkbox for selecting whether an atomic upload is to be used  */
094    private TagSettingsPanel pnlTagSettings;
095    /** the tabbed pane used below of the list of primitives  */
096    private JTabbedPane tpConfigPanels;
097    /** the upload button */
098    private JButton btnUpload;
099    private boolean canceled = false;
100
101    /** the changeset comment model keeping the state of the changeset comment */
102    private final ChangesetCommentModel changesetCommentModel = new ChangesetCommentModel();
103    private final ChangesetCommentModel changesetSourceModel = new ChangesetCommentModel();
104
105    /**
106     * builds the content panel for the upload dialog
107     *
108     * @return the content panel
109     */
110    protected JPanel buildContentPanel() {
111        JPanel pnl = new JPanel(new GridBagLayout());
112        pnl.setBorder(BorderFactory.createEmptyBorder(5,5,5,5));
113
114        // the panel with the list of uploaded objects
115        //
116        pnl.add(pnlUploadedObjects = new UploadedObjectsSummaryPanel(), GBC.eol().fill(GBC.BOTH));
117
118        // Custom components
119        for (Component c : customComponents) {
120            pnl.add(c, GBC.eol().fill(GBC.HORIZONTAL));
121        }
122
123        // a tabbed pane with configuration panels in the lower half
124        //
125        tpConfigPanels = new JTabbedPane() {
126            @Override
127            public Dimension getPreferredSize() {
128                // make sure the tabbed pane never grabs more space than necessary
129                //
130                return super.getMinimumSize();
131            }
132        };
133
134        tpConfigPanels.add(pnlBasicUploadSettings = new BasicUploadSettingsPanel(changesetCommentModel, changesetSourceModel));
135        tpConfigPanels.setTitleAt(0, tr("Settings"));
136        tpConfigPanels.setToolTipTextAt(0, tr("Decide how to upload the data and which changeset to use"));
137
138        tpConfigPanels.add(pnlTagSettings = new TagSettingsPanel(changesetCommentModel, changesetSourceModel));
139        tpConfigPanels.setTitleAt(1, tr("Tags of new changeset"));
140        tpConfigPanels.setToolTipTextAt(1, tr("Apply tags to the changeset data is uploaded to"));
141
142        tpConfigPanels.add(pnlChangesetManagement = new ChangesetManagementPanel(changesetCommentModel));
143        tpConfigPanels.setTitleAt(2, tr("Changesets"));
144        tpConfigPanels.setToolTipTextAt(2, tr("Manage open changesets and select a changeset to upload to"));
145
146        tpConfigPanels.add(pnlUploadStrategySelectionPanel = new UploadStrategySelectionPanel());
147        tpConfigPanels.setTitleAt(3, tr("Advanced"));
148        tpConfigPanels.setToolTipTextAt(3, tr("Configure advanced settings"));
149
150        pnl.add(tpConfigPanels, GBC.eol().fill(GBC.HORIZONTAL));
151        return pnl;
152    }
153
154    /**
155     * builds the panel with the OK and CANCEL buttons
156     *
157     * @return The panel with the OK and CANCEL buttons
158     */
159    protected JPanel buildActionPanel() {
160        JPanel pnl = new JPanel();
161        pnl.setLayout(new FlowLayout(FlowLayout.CENTER));
162        pnl.setBorder(BorderFactory.createEmptyBorder(5,5,5,5));
163
164        // -- upload button
165        UploadAction uploadAction = new UploadAction();
166        pnl.add(btnUpload = new SideButton(uploadAction));
167        btnUpload.setFocusable(true);
168        InputMapUtils.enableEnter(btnUpload);
169
170        // -- cancel button
171        CancelAction cancelAction = new CancelAction();
172        pnl.add(new SideButton(cancelAction));
173        getRootPane().registerKeyboardAction(
174                cancelAction,
175                KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE,0),
176                JComponent.WHEN_IN_FOCUSED_WINDOW
177        );
178        pnl.add(new SideButton(new ContextSensitiveHelpAction(ht("/Dialog/Upload"))));
179        HelpUtil.setHelpContext(getRootPane(),ht("/Dialog/Upload"));
180        return pnl;
181    }
182
183    /**
184     * builds the gui
185     */
186    protected void build() {
187        setTitle(tr("Upload to ''{0}''", OsmApi.getOsmApi().getBaseUrl()));
188        getContentPane().setLayout(new BorderLayout());
189        getContentPane().add(buildContentPanel(), BorderLayout.CENTER);
190        getContentPane().add(buildActionPanel(), BorderLayout.SOUTH);
191
192        addWindowListener(new WindowEventHandler());
193
194
195        // make sure the configuration panels listen to each other
196        // changes
197        //
198        pnlChangesetManagement.addPropertyChangeListener(
199                pnlBasicUploadSettings.getUploadParameterSummaryPanel()
200        );
201        pnlChangesetManagement.addPropertyChangeListener(this);
202        pnlUploadedObjects.addPropertyChangeListener(
203                pnlBasicUploadSettings.getUploadParameterSummaryPanel()
204        );
205        pnlUploadedObjects.addPropertyChangeListener(pnlUploadStrategySelectionPanel);
206        pnlUploadStrategySelectionPanel.addPropertyChangeListener(
207                pnlBasicUploadSettings.getUploadParameterSummaryPanel()
208        );
209
210
211        // users can click on either of two links in the upload parameter
212        // summary handler. This installs the handler for these two events.
213        // We simply select the appropriate tab in the tabbed pane with the
214        // configuration dialogs.
215        //
216        pnlBasicUploadSettings.getUploadParameterSummaryPanel().setConfigurationParameterRequestListener(
217                new ConfigurationParameterRequestHandler() {
218                    @Override
219                    public void handleUploadStrategyConfigurationRequest() {
220                        tpConfigPanels.setSelectedIndex(3);
221                    }
222                    @Override
223                    public void handleChangesetConfigurationRequest() {
224                        tpConfigPanels.setSelectedIndex(2);
225                    }
226                }
227        );
228
229        pnlBasicUploadSettings.setUploadTagDownFocusTraversalHandlers(
230                new AbstractAction() {
231                    @Override
232                    public void actionPerformed(ActionEvent e) {
233                        btnUpload.requestFocusInWindow();
234                    }
235                }
236        );
237
238        Main.pref.addPreferenceChangeListener(this);
239    }
240
241    /**
242     * constructor
243     */
244    public UploadDialog() {
245        super(JOptionPane.getFrameForComponent(Main.parent), ModalityType.DOCUMENT_MODAL);
246        build();
247    }
248
249    /**
250     * Sets the collection of primitives to upload
251     *
252     * @param toUpload the dataset with the objects to upload. If null, assumes the empty
253     * set of objects to upload
254     *
255     */
256    public void setUploadedPrimitives(APIDataSet toUpload) {
257        if (toUpload == null) {
258            List<OsmPrimitive> emptyList = Collections.emptyList();
259            pnlUploadedObjects.setUploadedPrimitives(emptyList, emptyList, emptyList);
260            return;
261        }
262        pnlUploadedObjects.setUploadedPrimitives(
263                toUpload.getPrimitivesToAdd(),
264                toUpload.getPrimitivesToUpdate(),
265                toUpload.getPrimitivesToDelete()
266        );
267    }
268
269    /**
270     * Remembers the user input in the preference settings
271     */
272    public void rememberUserInput() {
273        pnlBasicUploadSettings.rememberUserInput();
274        pnlUploadStrategySelectionPanel.rememberUserInput();
275    }
276
277    /**
278     * Initializes the panel for user input
279     */
280    public void startUserInput() {
281        tpConfigPanels.setSelectedIndex(0);
282        pnlBasicUploadSettings.startUserInput();
283        pnlTagSettings.startUserInput();
284        pnlTagSettings.initFromChangeset(pnlChangesetManagement.getSelectedChangeset());
285        pnlUploadStrategySelectionPanel.initFromPreferences();
286        UploadParameterSummaryPanel pnl = pnlBasicUploadSettings.getUploadParameterSummaryPanel();
287        pnl.setUploadStrategySpecification(pnlUploadStrategySelectionPanel.getUploadStrategySpecification());
288        pnl.setCloseChangesetAfterNextUpload(pnlChangesetManagement.isCloseChangesetAfterUpload());
289        pnl.setNumObjects(pnlUploadedObjects.getNumObjectsToUpload());
290    }
291
292    /**
293     * Replies the current changeset
294     *
295     * @return the current changeset
296     */
297    public Changeset getChangeset() {
298        Changeset cs = pnlChangesetManagement.getSelectedChangeset();
299        if (cs == null) {
300            cs = new Changeset();
301        }
302        cs.setKeys(pnlTagSettings.getTags(false));
303        return cs;
304    }
305
306    public void setSelectedChangesetForNextUpload(Changeset cs) {
307        pnlChangesetManagement.setSelectedChangesetForNextUpload(cs);
308    }
309
310    public Map<String, String> getDefaultChangesetTags() {
311        return pnlTagSettings.getDefaultTags();
312    }
313
314    public void setDefaultChangesetTags(Map<String, String> tags) {
315        pnlTagSettings.setDefaultTags(tags);
316        changesetCommentModel.setComment(tags.get("comment"));
317        changesetSourceModel.setComment(tags.get("source"));
318    }
319
320    /**
321     * Replies the {@link UploadStrategySpecification} the user entered in the dialog.
322     *
323     * @return the {@link UploadStrategySpecification} the user entered in the dialog.
324     */
325    public UploadStrategySpecification getUploadStrategySpecification() {
326        UploadStrategySpecification spec = pnlUploadStrategySelectionPanel.getUploadStrategySpecification();
327        spec.setCloseChangesetAfterUpload(pnlChangesetManagement.isCloseChangesetAfterUpload());
328        return spec;
329    }
330
331    /**
332     * Returns the current value for the upload comment
333     *
334     * @return the current value for the upload comment
335     */
336    protected String getUploadComment() {
337        return changesetCommentModel.getComment();
338    }
339
340    /**
341     * Returns the current value for the changeset source
342     *
343     * @return the current value for the changeset source
344     */
345    protected String getUploadSource() {
346        return changesetSourceModel.getComment();
347    }
348
349    /**
350     * Returns true if the dialog was canceled
351     *
352     * @return true if the dialog was canceled
353     */
354    public boolean isCanceled() {
355        return canceled;
356    }
357
358    /**
359     * Sets whether the dialog was canceled
360     *
361     * @param canceled true if the dialog is canceled
362     */
363    protected void setCanceled(boolean canceled) {
364        this.canceled = canceled;
365    }
366
367    @Override
368    public void setVisible(boolean visible) {
369        if (visible) {
370            new WindowGeometry(
371                    getClass().getName() + ".geometry",
372                    WindowGeometry.centerInWindow(
373                            Main.parent,
374                            new Dimension(400,600)
375                    )
376            ).applySafe(this);
377            startUserInput();
378        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
379            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
380        }
381        super.setVisible(visible);
382    }
383
384    /**
385     * Adds a custom component to this dialog.
386     * Custom components added at JOSM startup are displayed between the objects list and the properties tab pane.
387     * @param c The custom component to add. If {@code null}, this method does nothing.
388     * @return {@code true} if the collection of custom components changed as a result of the call
389     * @since 5842
390     */
391    public static boolean addCustomComponent(Component c) {
392        if (c != null) {
393            return customComponents.add(c);
394        }
395        return false;
396    }
397
398    /**
399     * Handles an upload
400     *
401     */
402    class UploadAction extends AbstractAction {
403        public UploadAction() {
404            putValue(NAME, tr("Upload Changes"));
405            putValue(SMALL_ICON, ImageProvider.get("upload"));
406            putValue(SHORT_DESCRIPTION, tr("Upload the changed primitives"));
407        }
408
409        /**
410         * Displays a warning message indicating that the upload comment is empty/short.
411         * @return true if the user wants to revisit, false if they want to continue
412         */
413        protected boolean warnUploadComment() {
414            return warnUploadTag(
415                    tr("Please revise upload comment"),
416                    tr("Your upload comment is <i>empty</i>, or <i>very short</i>.<br /><br />" +
417                            "This is technically allowed, but please consider that many users who are<br />" +
418                            "watching changes in their area depend on meaningful changeset comments<br />" +
419                            "to understand what is going on!<br /><br />" +
420                            "If you spend a minute now to explain your change, you will make life<br />" +
421                            "easier for many other mappers."),
422                    "upload_comment_is_empty_or_very_short"
423            );
424        }
425
426        /**
427         * Displays a warning message indicating that no changeset source is given.
428         * @return true if the user wants to revisit, false if they want to continue
429         */
430        protected boolean warnUploadSource() {
431            return warnUploadTag(
432                    tr("Please specify a changeset source"),
433                    tr("You did not specify a source for your changes.<br />" +
434                            "It is technically allowed, but this information helps<br />" +
435                            "other users to understand the origins of the data.<br /><br />" +
436                            "If you spend a minute now to explain your change, you will make life<br />" +
437                            "easier for many other mappers."),
438                    "upload_source_is_empty"
439            );
440        }
441
442        protected boolean warnUploadTag(final String title, final String message, final String togglePref) {
443            ExtendedDialog dlg = new ExtendedDialog(UploadDialog.this,
444                    title,
445                    new String[] {tr("Revise"), tr("Cancel"), tr("Continue as is")});
446            dlg.setContent("<html>" + message + "</html>");
447            dlg.setButtonIcons(new Icon[] {
448                    ImageProvider.get("ok"),
449                    ImageProvider.get("cancel"),
450                    ImageProvider.overlay(
451                            ImageProvider.get("upload"),
452                            new ImageIcon(ImageProvider.get("warning-small").getImage().getScaledInstance(10 , 10, Image.SCALE_SMOOTH)),
453                            ImageProvider.OverlayPosition.SOUTHEAST)});
454            dlg.setToolTipTexts(new String[] {
455                    tr("Return to the previous dialog to enter a more descriptive comment"),
456                    tr("Cancel and return to the previous dialog"),
457                    tr("Ignore this hint and upload anyway")});
458            dlg.setIcon(JOptionPane.WARNING_MESSAGE);
459            dlg.toggleEnable(togglePref);
460            dlg.setCancelButton(1, 2);
461            return dlg.showDialog().getValue() != 3;
462        }
463
464        protected void warnIllegalChunkSize() {
465            HelpAwareOptionPane.showOptionDialog(
466                    UploadDialog.this,
467                    tr("Please enter a valid chunk size first"),
468                    tr("Illegal chunk size"),
469                    JOptionPane.ERROR_MESSAGE,
470                    ht("/Dialog/Upload#IllegalChunkSize")
471            );
472        }
473
474        @Override
475        public void actionPerformed(ActionEvent e) {
476            if ((getUploadComment().trim().length() < 10 && warnUploadComment()) /* abort for missing comment */
477                    || (getUploadSource().trim().isEmpty() && warnUploadSource()) /* abort for missing changeset source */
478                    ) {
479                tpConfigPanels.setSelectedIndex(0);
480                pnlBasicUploadSettings.initEditingOfUploadComment();
481                return;
482            }
483
484            /* test for empty tags in the changeset metadata and proceed only after user's confirmation.
485             * though, accept if key and value are empty (cf. xor). */
486            List<String> emptyChangesetTags = new ArrayList<>();
487            for (final Entry<String, String> i : pnlTagSettings.getTags(true).entrySet()) {
488                final boolean isKeyEmpty = i.getKey() == null || i.getKey().trim().isEmpty();
489                final boolean isValueEmpty = i.getValue() == null || i.getValue().trim().isEmpty();
490                final boolean ignoreKey = "comment".equals(i.getKey()) || "source".equals(i.getKey());
491                if ((isKeyEmpty ^ isValueEmpty) && !ignoreKey) {
492                    emptyChangesetTags.add(tr("{0}={1}", i.getKey(), i.getValue()));
493                }
494            }
495            if (!emptyChangesetTags.isEmpty() && JOptionPane.OK_OPTION != JOptionPane.showConfirmDialog(
496                    Main.parent,
497                    trn(
498                            "<html>The following changeset tag contains an empty key/value:<br>{0}<br>Continue?</html>",
499                            "<html>The following changeset tags contain an empty key/value:<br>{0}<br>Continue?</html>",
500                            emptyChangesetTags.size(), Utils.joinAsHtmlUnorderedList(emptyChangesetTags)),
501                    tr("Empty metadata"),
502                    JOptionPane.OK_CANCEL_OPTION,
503                    JOptionPane.WARNING_MESSAGE
504            )) {
505                tpConfigPanels.setSelectedIndex(0);
506                pnlBasicUploadSettings.initEditingOfUploadComment();
507                return;
508            }
509
510            UploadStrategySpecification strategy = getUploadStrategySpecification();
511            if (strategy.getStrategy().equals(UploadStrategy.CHUNKED_DATASET_STRATEGY)) {
512                if (strategy.getChunkSize() == UploadStrategySpecification.UNSPECIFIED_CHUNK_SIZE) {
513                    warnIllegalChunkSize();
514                    tpConfigPanels.setSelectedIndex(0);
515                    return;
516                }
517            }
518            setCanceled(false);
519            setVisible(false);
520        }
521    }
522
523    /**
524     * Action for canceling the dialog
525     *
526     */
527    class CancelAction extends AbstractAction {
528        public CancelAction() {
529            putValue(NAME, tr("Cancel"));
530            putValue(SMALL_ICON, ImageProvider.get("cancel"));
531            putValue(SHORT_DESCRIPTION, tr("Cancel the upload and resume editing"));
532        }
533
534        @Override
535        public void actionPerformed(ActionEvent e) {
536            setCanceled(true);
537            setVisible(false);
538        }
539    }
540
541    /**
542     * Listens to window closing events and processes them as cancel events.
543     * Listens to window open events and initializes user input
544     *
545     */
546    class WindowEventHandler extends WindowAdapter {
547        @Override
548        public void windowClosing(WindowEvent e) {
549            setCanceled(true);
550        }
551
552        @Override
553        public void windowActivated(WindowEvent arg0) {
554            if (tpConfigPanels.getSelectedIndex() == 0) {
555                pnlBasicUploadSettings.initEditingOfUploadComment();
556            }
557        }
558    }
559
560    /* -------------------------------------------------------------------------- */
561    /* Interface PropertyChangeListener                                           */
562    /* -------------------------------------------------------------------------- */
563    @Override
564    public void propertyChange(PropertyChangeEvent evt) {
565        if (evt.getPropertyName().equals(ChangesetManagementPanel.SELECTED_CHANGESET_PROP)) {
566            Changeset cs = (Changeset)evt.getNewValue();
567            if (cs == null) {
568                tpConfigPanels.setTitleAt(1, tr("Tags of new changeset"));
569            } else {
570                tpConfigPanels.setTitleAt(1, tr("Tags of changeset {0}", cs.getId()));
571            }
572        }
573    }
574
575    /* -------------------------------------------------------------------------- */
576    /* Interface PreferenceChangedListener                                        */
577    /* -------------------------------------------------------------------------- */
578    @Override
579    public void preferenceChanged(PreferenceChangeEvent e) {
580        if (e.getKey() == null || !"osm-server.url".equals(e.getKey()))
581            return;
582        final Setting<?> newValue = e.getNewValue();
583        final String url;
584        if (newValue == null || newValue.getValue() == null) {
585            url = OsmApi.getOsmApi().getBaseUrl();
586        } else {
587            url = newValue.getValue().toString();
588        }
589        setTitle(tr("Upload to ''{0}''", url));
590    }
591
592    private String getLastChangesetTagFromHistory(String historyKey) {
593        Collection<String> history = Main.pref.getCollection(historyKey, new ArrayList<String>());
594        int age = (int) (System.currentTimeMillis() / 1000 - Main.pref.getInteger(BasicUploadSettingsPanel.HISTORY_LAST_USED_KEY, 0));
595        if (age < Main.pref.getInteger(BasicUploadSettingsPanel.HISTORY_MAX_AGE_KEY, 4 * 3600 * 1000) && history != null && !history.isEmpty()) {
596            return history.iterator().next();
597        } else {
598            return null;
599        }
600    }
601
602    public String getLastChangesetCommentFromHistory() {
603        return getLastChangesetTagFromHistory(BasicUploadSettingsPanel.HISTORY_KEY);
604    }
605
606    public String getLastChangesetSourceFromHistory() {
607        return getLastChangesetTagFromHistory(BasicUploadSettingsPanel.SOURCE_HISTORY_KEY);
608    }
609}