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