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.GridBagConstraints;
013import java.awt.GridBagLayout;
014import java.awt.event.ActionEvent;
015import java.awt.event.WindowAdapter;
016import java.awt.event.WindowEvent;
017import java.beans.PropertyChangeEvent;
018import java.beans.PropertyChangeListener;
019import java.lang.Character.UnicodeBlock;
020import java.util.ArrayList;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Locale;
025import java.util.Map;
026import java.util.Map.Entry;
027import java.util.stream.Collectors;
028
029import javax.swing.AbstractAction;
030import javax.swing.BorderFactory;
031import javax.swing.JButton;
032import javax.swing.JOptionPane;
033import javax.swing.JPanel;
034import javax.swing.JSplitPane;
035import javax.swing.JTabbedPane;
036
037import org.openstreetmap.josm.data.APIDataSet;
038import org.openstreetmap.josm.data.osm.Changeset;
039import org.openstreetmap.josm.data.osm.DataSet;
040import org.openstreetmap.josm.data.osm.OsmPrimitive;
041import org.openstreetmap.josm.gui.HelpAwareOptionPane;
042import org.openstreetmap.josm.gui.MainApplication;
043import org.openstreetmap.josm.gui.help.ContextSensitiveHelpAction;
044import org.openstreetmap.josm.gui.help.HelpUtil;
045import org.openstreetmap.josm.gui.tagging.TagEditorPanel;
046import org.openstreetmap.josm.gui.util.GuiHelper;
047import org.openstreetmap.josm.gui.util.MultiLineFlowLayout;
048import org.openstreetmap.josm.gui.util.WindowGeometry;
049import org.openstreetmap.josm.io.OsmApi;
050import org.openstreetmap.josm.io.UploadStrategy;
051import org.openstreetmap.josm.io.UploadStrategySpecification;
052import org.openstreetmap.josm.spi.preferences.Config;
053import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
054import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
055import org.openstreetmap.josm.spi.preferences.Setting;
056import org.openstreetmap.josm.tools.GBC;
057import org.openstreetmap.josm.tools.ImageProvider;
058import org.openstreetmap.josm.tools.InputMapUtils;
059import org.openstreetmap.josm.tools.Utils;
060
061/**
062 * This is a dialog for entering upload options like the parameters for
063 * the upload changeset and the strategy for opening/closing a changeset.
064 * @since 2025
065 */
066public class UploadDialog extends AbstractUploadDialog implements PreferenceChangedListener, PropertyChangeListener {
067    /** the unique instance of the upload dialog */
068    private static UploadDialog uploadDialog;
069
070    /** the panel with the objects to upload */
071    private UploadedObjectsSummaryPanel pnlUploadedObjects;
072
073    /** the "description" tab */
074    private BasicUploadSettingsPanel pnlBasicUploadSettings;
075
076    /** the panel to select the changeset used */
077    private ChangesetManagementPanel pnlChangesetManagement;
078    /** the panel to select the upload strategy */
079    private UploadStrategySelectionPanel pnlUploadStrategySelectionPanel;
080
081    /** the tag editor panel */
082    private TagEditorPanel pnlTagEditor;
083    /** the tabbed pane used below of the list of primitives  */
084    private JTabbedPane tpConfigPanels;
085    /** the upload button */
086    private JButton btnUpload;
087
088    /** the model keeping the state of the changeset tags */
089    private final transient UploadDialogModel model = new UploadDialogModel();
090
091    private transient DataSet dataSet;
092
093    /**
094     * Constructs a new {@code UploadDialog}.
095     */
096    protected UploadDialog() {
097        super(GuiHelper.getFrameForComponent(MainApplication.getMainFrame()), ModalityType.DOCUMENT_MODAL);
098        build();
099        pack();
100    }
101
102    /**
103     * Replies the unique instance of the upload dialog
104     *
105     * @return the unique instance of the upload dialog
106     */
107    public static synchronized UploadDialog getUploadDialog() {
108        if (uploadDialog == null) {
109            uploadDialog = new UploadDialog();
110        }
111        return uploadDialog;
112    }
113
114    /**
115     * builds the content panel for the upload dialog
116     *
117     * @return the content panel
118     */
119    protected JPanel buildContentPanel() {
120        final JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT);
121        splitPane.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
122
123        // the panel with the list of uploaded objects
124        pnlUploadedObjects = new UploadedObjectsSummaryPanel();
125        pnlUploadedObjects.setMinimumSize(new Dimension(200, 50));
126        splitPane.setLeftComponent(pnlUploadedObjects);
127
128        // a tabbed pane with configuration panels in the lower half
129        tpConfigPanels = new CompactTabbedPane();
130        splitPane.setRightComponent(tpConfigPanels);
131
132        pnlBasicUploadSettings = new BasicUploadSettingsPanel(model);
133        tpConfigPanels.add(pnlBasicUploadSettings);
134        tpConfigPanels.setTitleAt(0, tr("Description"));
135        tpConfigPanels.setToolTipTextAt(0, tr("Describe the changes you made"));
136
137        JPanel pnlSettings = new JPanel(new GridBagLayout());
138        pnlSettings.setBorder(BorderFactory.createEmptyBorder(3, 3, 3, 3));
139        JPanel pnlTagEditorBorder = new JPanel(new BorderLayout());
140        pnlTagEditorBorder.setBorder(BorderFactory.createTitledBorder(tr("Changeset tags:")));
141        pnlTagEditor = new TagEditorPanel(model, null, Changeset.MAX_CHANGESET_TAG_LENGTH);
142        pnlTagEditorBorder.add(pnlTagEditor, BorderLayout.CENTER);
143
144        pnlChangesetManagement = new ChangesetManagementPanel();
145        pnlUploadStrategySelectionPanel = new UploadStrategySelectionPanel();
146        pnlSettings.add(pnlChangesetManagement, GBC.eop().fill(GridBagConstraints.HORIZONTAL));
147        pnlSettings.add(pnlUploadStrategySelectionPanel, GBC.eop().fill(GridBagConstraints.HORIZONTAL));
148        pnlSettings.add(pnlTagEditorBorder, GBC.eol().fill(GridBagConstraints.BOTH));
149
150        tpConfigPanels.add(pnlSettings);
151        tpConfigPanels.setTitleAt(1, tr("Settings"));
152        tpConfigPanels.setToolTipTextAt(1, tr("Decide how to upload the data and which changeset to use"));
153
154        JPanel pnl = new JPanel(new BorderLayout());
155        pnl.add(splitPane, BorderLayout.CENTER);
156        pnl.add(buildActionPanel(), BorderLayout.SOUTH);
157        return pnl;
158    }
159
160    /**
161     * builds the panel with the OK and CANCEL buttons
162     *
163     * @return The panel with the OK and CANCEL buttons
164     */
165    protected JPanel buildActionPanel() {
166        JPanel pnl = new JPanel(new MultiLineFlowLayout(FlowLayout.CENTER));
167        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
168
169        // -- upload button
170        btnUpload = new JButton(new UploadAction(this));
171        pnl.add(btnUpload);
172        btnUpload.setFocusable(true);
173        InputMapUtils.enableEnter(btnUpload);
174        InputMapUtils.addCtrlEnterAction(getRootPane(), btnUpload.getAction());
175
176        // -- cancel button
177        CancelAction cancelAction = new CancelAction(this);
178        pnl.add(new JButton(cancelAction));
179        InputMapUtils.addEscapeAction(getRootPane(), cancelAction);
180
181        // -- help button
182        pnl.add(new JButton(new ContextSensitiveHelpAction(ht("/Dialog/Upload"))));
183        HelpUtil.setHelpContext(getRootPane(), ht("/Dialog/Upload"));
184        return pnl;
185    }
186
187    /**
188     * builds the gui
189     */
190    protected void build() {
191        setTitle(tr("Upload to ''{0}''", OsmApi.getOsmApi().getBaseUrl()));
192        setContentPane(buildContentPanel());
193
194        addWindowListener(new WindowEventHandler());
195
196        // make sure the configuration panels listen to each others changes
197        //
198        UploadParameterSummaryPanel sp = pnlBasicUploadSettings.getUploadParameterSummaryPanel();
199        // the summary panel must know everything
200        pnlChangesetManagement.addPropertyChangeListener(sp);
201        pnlUploadedObjects.addPropertyChangeListener(sp);
202        pnlUploadStrategySelectionPanel.addPropertyChangeListener(sp);
203
204        // update tags from selected changeset
205        pnlChangesetManagement.addPropertyChangeListener(this);
206
207        // users can click on either of two links in the upload parameter
208        // summary handler. This installs the handler for these two events.
209        // We simply select the appropriate tab in the tabbed pane with the configuration dialogs.
210        //
211        pnlBasicUploadSettings.getUploadParameterSummaryPanel().setConfigurationParameterRequestListener(
212                () -> tpConfigPanels.setSelectedIndex(2)
213        );
214
215        // Enable/disable the upload button if at least an upload validator rejects upload
216        pnlBasicUploadSettings.getUploadTextValidators().forEach(v -> v.addChangeListener(e -> btnUpload.setEnabled(
217                pnlBasicUploadSettings.getUploadTextValidators().stream().noneMatch(UploadTextComponentValidator::isUploadRejected))));
218
219        setMinimumSize(new Dimension(600, 350));
220
221        Config.getPref().addPreferenceChangeListener(this);
222    }
223
224    /**
225     * Initializes this life cycle of the dialog.
226     *
227     * Initializes the dialog each time before it is made visible. We cannot do
228     * this in the constructor because the dialog is a singleton.
229     *
230     * @param dataSet The Dataset we want to upload
231     * @since 18173
232     */
233    public void initLifeCycle(DataSet dataSet) {
234        Map<String, String> map = new HashMap<>();
235        this.dataSet = dataSet;
236        pnlBasicUploadSettings.initLifeCycle(map);
237        pnlChangesetManagement.initLifeCycle();
238        model.clear();
239        model.putAll(map);          // init with tags from history
240        model.putAll(this.dataSet); // overwrite with tags from the dataset
241
242        tpConfigPanels.setSelectedIndex(0);
243        pnlTagEditor.initAutoCompletion(MainApplication.getLayerManager().getEditLayer());
244        pnlUploadStrategySelectionPanel.initFromPreferences();
245
246        // update the summary
247        UploadParameterSummaryPanel sumPnl = pnlBasicUploadSettings.getUploadParameterSummaryPanel();
248        sumPnl.setUploadStrategySpecification(pnlUploadStrategySelectionPanel.getUploadStrategySpecification());
249        sumPnl.setCloseChangesetAfterNextUpload(pnlChangesetManagement.isCloseChangesetAfterUpload());
250    }
251
252    /**
253     * Sets the collection of primitives to upload
254     *
255     * @param toUpload the dataset with the objects to upload. If null, assumes the empty
256     * set of objects to upload
257     *
258     */
259    public void setUploadedPrimitives(APIDataSet toUpload) {
260        UploadParameterSummaryPanel sumPnl = pnlBasicUploadSettings.getUploadParameterSummaryPanel();
261        if (toUpload == null) {
262            if (pnlUploadedObjects != null) {
263                List<OsmPrimitive> emptyList = Collections.emptyList();
264                pnlUploadedObjects.setUploadedPrimitives(emptyList, emptyList, emptyList);
265                sumPnl.setNumObjects(0);
266            }
267            return;
268        }
269        List<OsmPrimitive> l = toUpload.getPrimitives();
270        pnlBasicUploadSettings.setUploadedPrimitives(l);
271        pnlUploadedObjects.setUploadedPrimitives(
272                toUpload.getPrimitivesToAdd(),
273                toUpload.getPrimitivesToUpdate(),
274                toUpload.getPrimitivesToDelete()
275        );
276        sumPnl.setNumObjects(l.size());
277        pnlUploadStrategySelectionPanel.setNumUploadedObjects(l.size());
278    }
279
280    /**
281     * Sets the input focus to upload button.
282     * @since 18173
283     */
284    public void setFocusToUploadButton() {
285        btnUpload.requestFocus();
286    }
287
288    @Override
289    public void rememberUserInput() {
290        pnlBasicUploadSettings.rememberUserInput();
291        pnlUploadStrategySelectionPanel.rememberUserInput();
292    }
293
294    /**
295     * Returns the changeset to use complete with tags
296     *
297     * @return the changeset to use
298     */
299    public Changeset getChangeset() {
300        Changeset cs = pnlChangesetManagement.getSelectedChangeset();
301        cs.setKeys(getTags(true));
302        return cs;
303    }
304
305    /**
306     * Sets the changeset to be used in the next upload
307     *
308     * @param cs the changeset
309     */
310    public void setSelectedChangesetForNextUpload(Changeset cs) {
311        pnlChangesetManagement.setSelectedChangesetForNextUpload(cs);
312    }
313
314    @Override
315    public UploadStrategySpecification getUploadStrategySpecification() {
316        UploadStrategySpecification spec = pnlUploadStrategySelectionPanel.getUploadStrategySpecification();
317        spec.setCloseChangesetAfterUpload(pnlChangesetManagement.isCloseChangesetAfterUpload());
318        return spec;
319    }
320
321    /**
322     * Get the upload dialog model.
323     *
324     * @return The model.
325     * @since 18173
326     */
327    public UploadDialogModel getModel() {
328        return model;
329    }
330
331    @Override
332    public String getUploadComment() {
333        return model.getValue(UploadDialogModel.COMMENT);
334    }
335
336    @Override
337    public String getUploadSource() {
338        return model.getValue(UploadDialogModel.SOURCE);
339    }
340
341    @Override
342    public void setVisible(boolean visible) {
343        if (visible) {
344            new WindowGeometry(
345                    getClass().getName() + ".geometry",
346                    WindowGeometry.centerInWindow(
347                            MainApplication.getMainFrame(),
348                            new Dimension(800, 600)
349                    )
350            ).applySafe(this);
351        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
352            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
353        }
354        super.setVisible(visible);
355    }
356
357    static final class CompactTabbedPane extends JTabbedPane {
358        @Override
359        public Dimension getPreferredSize() {
360            // This probably fixes #18523. Don't know why. Don't know how. It just does.
361            super.getPreferredSize();
362            // make sure the tabbed pane never grabs more space than necessary
363            return super.getMinimumSize();
364        }
365    }
366
367    /**
368     * Handles an upload.
369     */
370    static class UploadAction extends AbstractAction {
371
372        private final transient IUploadDialog dialog;
373
374        UploadAction(IUploadDialog dialog) {
375            this.dialog = dialog;
376            putValue(NAME, tr("Upload Changes"));
377            new ImageProvider("upload").getResource().attachImageIcon(this, true);
378            putValue(SHORT_DESCRIPTION, tr("Upload the changed primitives"));
379        }
380
381        protected void warnIllegalChunkSize() {
382            HelpAwareOptionPane.showOptionDialog(
383                    (Component) dialog,
384                    tr("Please enter a valid chunk size first"),
385                    tr("Illegal chunk size"),
386                    JOptionPane.ERROR_MESSAGE,
387                    ht("/Dialog/Upload#IllegalChunkSize")
388            );
389        }
390
391        static boolean isUploadCommentTooShort(String comment) {
392            String s = Utils.strip(comment);
393            if (s.isEmpty()) {
394                return true;
395            }
396            UnicodeBlock block = Character.UnicodeBlock.of(s.charAt(0));
397            if (block != null && block.toString().contains("CJK")) {
398                return s.length() < 4;
399            } else {
400                return s.length() < 10;
401            }
402        }
403
404        private static String lower(String s) {
405            return s.toLowerCase(Locale.ENGLISH);
406        }
407
408        static String validateUploadTag(String uploadValue, String preferencePrefix,
409                List<String> defMandatory, List<String> defForbidden, List<String> defException) {
410            String uploadValueLc = lower(uploadValue);
411            // Check mandatory terms
412            List<String> missingTerms = Config.getPref().getList(preferencePrefix+".mandatory-terms", defMandatory)
413                .stream().map(UploadAction::lower).filter(x -> !uploadValueLc.contains(x)).collect(Collectors.toList());
414            if (!missingTerms.isEmpty()) {
415                return tr("The following required terms are missing: {0}", missingTerms);
416            }
417            // Check forbidden terms
418            List<String> exceptions = Config.getPref().getList(preferencePrefix+".exception-terms", defException);
419            List<String> forbiddenTerms = Config.getPref().getList(preferencePrefix+".forbidden-terms", defForbidden)
420                    .stream().map(UploadAction::lower)
421                    .filter(x -> uploadValueLc.contains(x) && exceptions.stream().noneMatch(uploadValueLc::contains))
422                    .collect(Collectors.toList());
423            if (!forbiddenTerms.isEmpty()) {
424                return tr("The following forbidden terms have been found: {0}", forbiddenTerms);
425            }
426            return null;
427        }
428
429        @Override
430        public void actionPerformed(ActionEvent e) {
431            Map<String, String> tags = dialog.getTags(true);
432
433            // If there are empty tags in the changeset proceed only after user's confirmation.
434            List<String> emptyChangesetTags = new ArrayList<>();
435            for (final Entry<String, String> i : tags.entrySet()) {
436                final boolean isKeyEmpty = Utils.isStripEmpty(i.getKey());
437                final boolean isValueEmpty = Utils.isStripEmpty(i.getValue());
438                final boolean ignoreKey = UploadDialogModel.isCommentOrSource(i.getKey());
439                if ((isKeyEmpty || isValueEmpty) && !ignoreKey) {
440                    emptyChangesetTags.add(tr("{0}={1}", i.getKey(), i.getValue()));
441                }
442            }
443            if (!emptyChangesetTags.isEmpty() && JOptionPane.OK_OPTION != JOptionPane.showConfirmDialog(
444                    MainApplication.getMainFrame(),
445                    trn(
446                            "<html>The following changeset tag contains an empty key/value:<br>{0}<br>Continue?</html>",
447                            "<html>The following changeset tags contain an empty key/value:<br>{0}<br>Continue?</html>",
448                            emptyChangesetTags.size(), Utils.joinAsHtmlUnorderedList(emptyChangesetTags)),
449                    tr("Empty metadata"),
450                    JOptionPane.OK_CANCEL_OPTION,
451                    JOptionPane.WARNING_MESSAGE
452            )) {
453                dialog.handleMissingComment();
454                return;
455            }
456
457            UploadStrategySpecification strategy = dialog.getUploadStrategySpecification();
458            if (strategy.getStrategy() == UploadStrategy.CHUNKED_DATASET_STRATEGY
459                    && strategy.getChunkSize() == UploadStrategySpecification.UNSPECIFIED_CHUNK_SIZE) {
460                warnIllegalChunkSize();
461                dialog.handleIllegalChunkSize();
462                return;
463            }
464            if (dialog instanceof AbstractUploadDialog) {
465                ((AbstractUploadDialog) dialog).setCanceled(false);
466                ((AbstractUploadDialog) dialog).setVisible(false);
467            }
468        }
469    }
470
471    /**
472     * Action for canceling the dialog.
473     */
474    static class CancelAction extends AbstractAction {
475
476        private final transient IUploadDialog dialog;
477
478        CancelAction(IUploadDialog dialog) {
479            this.dialog = dialog;
480            putValue(NAME, tr("Cancel"));
481            new ImageProvider("cancel").getResource().attachImageIcon(this, true);
482            putValue(SHORT_DESCRIPTION, tr("Cancel the upload and resume editing"));
483        }
484
485        @Override
486        public void actionPerformed(ActionEvent e) {
487            if (dialog instanceof AbstractUploadDialog) {
488                ((AbstractUploadDialog) dialog).setCanceled(true);
489                ((AbstractUploadDialog) dialog).setVisible(false);
490            }
491        }
492    }
493
494    /**
495     * Listens to window closing events and processes them as cancel events.
496     * Listens to window open events and initializes user input
497     */
498    class WindowEventHandler extends WindowAdapter {
499        private boolean activatedOnce;
500
501        @Override
502        public void windowClosing(WindowEvent e) {
503            setCanceled(true);
504        }
505
506        @Override
507        public void windowActivated(WindowEvent e) {
508            if (!activatedOnce && tpConfigPanels.getSelectedIndex() == 0) {
509                pnlBasicUploadSettings.initEditingOfUploadComment();
510                activatedOnce = true;
511            }
512        }
513    }
514
515    /* -------------------------------------------------------------------------- */
516    /* Interface PropertyChangeListener                                           */
517    /* -------------------------------------------------------------------------- */
518    @Override
519    public void propertyChange(PropertyChangeEvent evt) {
520        if (evt.getPropertyName().equals(ChangesetManagementPanel.SELECTED_CHANGESET_PROP)) {
521            // put the tags from the newly selected changeset into the model
522            Changeset cs = (Changeset) evt.getNewValue();
523            if (cs != null) {
524                for (Map.Entry<String, String> entry : cs.getKeys().entrySet()) {
525                    String key = entry.getKey();
526                    // do NOT overwrite comment and source when selecting a changeset, it is confusing
527                    if (!UploadDialogModel.isCommentOrSource(key))
528                        model.put(key, entry.getValue());
529                }
530            }
531        }
532    }
533
534    /* -------------------------------------------------------------------------- */
535    /* Interface PreferenceChangedListener                                        */
536    /* -------------------------------------------------------------------------- */
537    @Override
538    public void preferenceChanged(PreferenceChangeEvent e) {
539        if (e.getKey() != null
540                && e.getSource() != getClass()
541                && e.getSource() != BasicUploadSettingsPanel.class) {
542            switch (e.getKey()) {
543                case "osm-server.url":
544                    osmServerUrlChanged(e.getNewValue());
545                    break;
546                default:
547                    return;
548            }
549        }
550    }
551
552    private void osmServerUrlChanged(Setting<?> newValue) {
553        final String url;
554        if (newValue == null || newValue.getValue() == null) {
555            url = OsmApi.getOsmApi().getBaseUrl();
556        } else {
557            url = newValue.getValue().toString();
558        }
559        setTitle(tr("Upload to ''{0}''", url));
560    }
561
562    /* -------------------------------------------------------------------------- */
563    /* Interface IUploadDialog                                                    */
564    /* -------------------------------------------------------------------------- */
565    @Override
566    public Map<String, String> getTags(boolean keepEmpty) {
567        saveEdits();
568        return model.getTags(keepEmpty);
569    }
570
571    @Override
572    public void handleMissingComment() {
573        tpConfigPanels.setSelectedIndex(0);
574        pnlBasicUploadSettings.initEditingOfUploadComment();
575    }
576
577    @Override
578    public void handleMissingSource() {
579        tpConfigPanels.setSelectedIndex(0);
580        pnlBasicUploadSettings.initEditingOfUploadSource();
581    }
582
583    @Override
584    public void handleIllegalChunkSize() {
585        tpConfigPanels.setSelectedIndex(0);
586    }
587
588    /**
589     * Save all outstanding edits to the model.
590     * <p>
591     * The combobox editors and the tag cell editor need to be manually saved
592     * because they normally save on focus loss, eg. when the "Upload" button is
593     * pressed, but there's no focus change when Ctrl+Enter is pressed.
594     *
595     * @since 18173
596     */
597    public void saveEdits() {
598        pnlBasicUploadSettings.saveEdits();
599        pnlTagEditor.saveEdits();
600    }
601
602    /**
603     * Clean dialog state and release resources.
604     * @since 14251
605     */
606    public void clean() {
607        setUploadedPrimitives(null);
608        dataSet = null;
609    }
610}