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.Component;
009import java.awt.Dimension;
010import java.awt.FlowLayout;
011import java.awt.GridBagLayout;
012import java.awt.event.ActionEvent;
013import java.awt.event.WindowAdapter;
014import java.awt.event.WindowEvent;
015import java.beans.PropertyChangeEvent;
016import java.beans.PropertyChangeListener;
017import java.lang.Character.UnicodeBlock;
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.Iterator;
024import java.util.List;
025import java.util.Locale;
026import java.util.Map;
027import java.util.Map.Entry;
028import java.util.Optional;
029import java.util.stream.Collectors;
030
031import javax.swing.AbstractAction;
032import javax.swing.BorderFactory;
033import javax.swing.Icon;
034import javax.swing.JButton;
035import javax.swing.JOptionPane;
036import javax.swing.JPanel;
037import javax.swing.JTabbedPane;
038
039import org.openstreetmap.josm.data.APIDataSet;
040import org.openstreetmap.josm.data.Version;
041import org.openstreetmap.josm.data.osm.Changeset;
042import org.openstreetmap.josm.data.osm.DataSet;
043import org.openstreetmap.josm.data.osm.OsmPrimitive;
044import org.openstreetmap.josm.gui.ExtendedDialog;
045import org.openstreetmap.josm.gui.HelpAwareOptionPane;
046import org.openstreetmap.josm.gui.MainApplication;
047import org.openstreetmap.josm.gui.help.ContextSensitiveHelpAction;
048import org.openstreetmap.josm.gui.help.HelpUtil;
049import org.openstreetmap.josm.gui.util.GuiHelper;
050import org.openstreetmap.josm.gui.util.MultiLineFlowLayout;
051import org.openstreetmap.josm.gui.util.WindowGeometry;
052import org.openstreetmap.josm.io.OsmApi;
053import org.openstreetmap.josm.io.UploadStrategy;
054import org.openstreetmap.josm.io.UploadStrategySpecification;
055import org.openstreetmap.josm.spi.preferences.Config;
056import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
057import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
058import org.openstreetmap.josm.spi.preferences.Setting;
059import org.openstreetmap.josm.tools.GBC;
060import org.openstreetmap.josm.tools.ImageOverlay;
061import org.openstreetmap.josm.tools.ImageProvider;
062import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
063import org.openstreetmap.josm.tools.InputMapUtils;
064import org.openstreetmap.josm.tools.Utils;
065
066/**
067 * This is a dialog for entering upload options like the parameters for
068 * the upload changeset and the strategy for opening/closing a changeset.
069 * @since 2025
070 */
071public class UploadDialog extends AbstractUploadDialog implements PropertyChangeListener, PreferenceChangedListener {
072    /** the unique instance of the upload dialog */
073    private static UploadDialog uploadDialog;
074
075    /** list of custom components that can be added by plugins at JOSM startup */
076    private static final Collection<Component> customComponents = new ArrayList<>();
077
078    /** the "created_by" changeset OSM key */
079    private static final String CREATED_BY = "created_by";
080
081    /** the panel with the objects to upload */
082    private UploadedObjectsSummaryPanel pnlUploadedObjects;
083    /** the panel to select the changeset used */
084    private ChangesetManagementPanel pnlChangesetManagement;
085
086    private BasicUploadSettingsPanel pnlBasicUploadSettings;
087
088    private UploadStrategySelectionPanel pnlUploadStrategySelectionPanel;
089
090    /** checkbox for selecting whether an atomic upload is to be used  */
091    private TagSettingsPanel pnlTagSettings;
092    /** the tabbed pane used below of the list of primitives  */
093    private JTabbedPane tpConfigPanels;
094    /** the upload button */
095    private JButton btnUpload;
096
097    /** the changeset comment model keeping the state of the changeset comment */
098    private final transient ChangesetCommentModel changesetCommentModel = new ChangesetCommentModel();
099    private final transient ChangesetCommentModel changesetSourceModel = new ChangesetCommentModel();
100    private final transient ChangesetReviewModel changesetReviewModel = new ChangesetReviewModel();
101
102    private transient DataSet dataSet;
103
104    /**
105     * Constructs a new {@code UploadDialog}.
106     */
107    public UploadDialog() {
108        super(GuiHelper.getFrameForComponent(MainApplication.getMainFrame()), ModalityType.DOCUMENT_MODAL);
109        build();
110        pack();
111    }
112
113    /**
114     * Replies the unique instance of the upload dialog
115     *
116     * @return the unique instance of the upload dialog
117     */
118    public static synchronized UploadDialog getUploadDialog() {
119        if (uploadDialog == null) {
120            uploadDialog = new UploadDialog();
121        }
122        return uploadDialog;
123    }
124
125    /**
126     * builds the content panel for the upload dialog
127     *
128     * @return the content panel
129     */
130    protected JPanel buildContentPanel() {
131        JPanel pnl = new JPanel(new GridBagLayout());
132        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
133
134        // the panel with the list of uploaded objects
135        pnlUploadedObjects = new UploadedObjectsSummaryPanel();
136        pnl.add(pnlUploadedObjects, GBC.eol().fill(GBC.BOTH));
137
138        // Custom components
139        for (Component c : customComponents) {
140            pnl.add(c, GBC.eol().fill(GBC.HORIZONTAL));
141        }
142
143        // a tabbed pane with configuration panels in the lower half
144        tpConfigPanels = new CompactTabbedPane();
145
146        pnlBasicUploadSettings = new BasicUploadSettingsPanel(changesetCommentModel, changesetSourceModel, changesetReviewModel);
147        tpConfigPanels.add(pnlBasicUploadSettings);
148        tpConfigPanels.setTitleAt(0, tr("Settings"));
149        tpConfigPanels.setToolTipTextAt(0, tr("Decide how to upload the data and which changeset to use"));
150
151        pnlTagSettings = new TagSettingsPanel(changesetCommentModel, changesetSourceModel, changesetReviewModel);
152        tpConfigPanels.add(pnlTagSettings);
153        tpConfigPanels.setTitleAt(1, tr("Tags of new changeset"));
154        tpConfigPanels.setToolTipTextAt(1, tr("Apply tags to the changeset data is uploaded to"));
155
156        pnlChangesetManagement = new ChangesetManagementPanel(changesetCommentModel);
157        tpConfigPanels.add(pnlChangesetManagement);
158        tpConfigPanels.setTitleAt(2, tr("Changesets"));
159        tpConfigPanels.setToolTipTextAt(2, tr("Manage open changesets and select a changeset to upload to"));
160
161        pnlUploadStrategySelectionPanel = new UploadStrategySelectionPanel();
162        tpConfigPanels.add(pnlUploadStrategySelectionPanel);
163        tpConfigPanels.setTitleAt(3, tr("Advanced"));
164        tpConfigPanels.setToolTipTextAt(3, tr("Configure advanced settings"));
165
166        pnl.add(tpConfigPanels, GBC.eol().fill(GBC.HORIZONTAL));
167
168        pnl.add(buildActionPanel(), GBC.eol().fill(GBC.HORIZONTAL));
169        return pnl;
170    }
171
172    /**
173     * builds the panel with the OK and CANCEL buttons
174     *
175     * @return The panel with the OK and CANCEL buttons
176     */
177    protected JPanel buildActionPanel() {
178        JPanel pnl = new JPanel(new MultiLineFlowLayout(FlowLayout.CENTER));
179        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
180
181        // -- upload button
182        btnUpload = new JButton(new UploadAction(this));
183        pnl.add(btnUpload);
184        btnUpload.setFocusable(true);
185        InputMapUtils.enableEnter(btnUpload);
186        InputMapUtils.addCtrlEnterAction(getRootPane(), btnUpload.getAction());
187
188        // -- cancel button
189        CancelAction cancelAction = new CancelAction(this);
190        pnl.add(new JButton(cancelAction));
191        InputMapUtils.addEscapeAction(getRootPane(), cancelAction);
192        pnl.add(new JButton(new ContextSensitiveHelpAction(ht("/Dialog/Upload"))));
193        HelpUtil.setHelpContext(getRootPane(), ht("/Dialog/Upload"));
194        return pnl;
195    }
196
197    /**
198     * builds the gui
199     */
200    protected void build() {
201        setTitle(tr("Upload to ''{0}''", OsmApi.getOsmApi().getBaseUrl()));
202        setContentPane(buildContentPanel());
203
204        addWindowListener(new WindowEventHandler());
205
206        // make sure the configuration panels listen to each other changes
207        //
208        pnlChangesetManagement.addPropertyChangeListener(this);
209        pnlChangesetManagement.addPropertyChangeListener(
210                pnlBasicUploadSettings.getUploadParameterSummaryPanel()
211        );
212        pnlChangesetManagement.addPropertyChangeListener(this);
213        pnlUploadedObjects.addPropertyChangeListener(
214                pnlBasicUploadSettings.getUploadParameterSummaryPanel()
215        );
216        pnlUploadedObjects.addPropertyChangeListener(pnlUploadStrategySelectionPanel);
217        pnlUploadStrategySelectionPanel.addPropertyChangeListener(
218                pnlBasicUploadSettings.getUploadParameterSummaryPanel()
219        );
220
221        // users can click on either of two links in the upload parameter
222        // summary handler. This installs the handler for these two events.
223        // We simply select the appropriate tab in the tabbed pane with the configuration dialogs.
224        //
225        pnlBasicUploadSettings.getUploadParameterSummaryPanel().setConfigurationParameterRequestListener(
226                new ConfigurationParameterRequestHandler() {
227                    @Override
228                    public void handleUploadStrategyConfigurationRequest() {
229                        tpConfigPanels.setSelectedIndex(3);
230                    }
231
232                    @Override
233                    public void handleChangesetConfigurationRequest() {
234                        tpConfigPanels.setSelectedIndex(2);
235                    }
236                }
237        );
238
239        pnlBasicUploadSettings.setUploadTagDownFocusTraversalHandlers(e -> btnUpload.requestFocusInWindow());
240
241        setMinimumSize(new Dimension(600, 350));
242
243        Config.getPref().addPreferenceChangeListener(this);
244    }
245
246    /**
247     * Sets the collection of primitives to upload
248     *
249     * @param toUpload the dataset with the objects to upload. If null, assumes the empty
250     * set of objects to upload
251     *
252     */
253    public void setUploadedPrimitives(APIDataSet toUpload) {
254        if (toUpload == null) {
255            if (pnlUploadedObjects != null) {
256                List<OsmPrimitive> emptyList = Collections.emptyList();
257                pnlUploadedObjects.setUploadedPrimitives(emptyList, emptyList, emptyList);
258            }
259            return;
260        }
261        pnlUploadedObjects.setUploadedPrimitives(
262                toUpload.getPrimitivesToAdd(),
263                toUpload.getPrimitivesToUpdate(),
264                toUpload.getPrimitivesToDelete()
265        );
266    }
267
268    /**
269     * Sets the tags for this upload based on (later items overwrite earlier ones):
270     * <ul>
271     * <li>previous "source" and "comment" input</li>
272     * <li>the tags set in the dataset (see {@link DataSet#getChangeSetTags()})</li>
273     * <li>the tags from the selected open changeset</li>
274     * <li>the JOSM user agent (see {@link Version#getAgentString(boolean)})</li>
275     * </ul>
276     *
277     * @param dataSet to obtain the tags set in the dataset
278     */
279    public void setChangesetTags(DataSet dataSet) {
280        setChangesetTags(dataSet, false);
281    }
282
283    /**
284     * Sets the tags for this upload based on (later items overwrite earlier ones):
285     * <ul>
286     * <li>previous "source" and "comment" input</li>
287     * <li>the tags set in the dataset (see {@link DataSet#getChangeSetTags()})</li>
288     * <li>the tags from the selected open changeset</li>
289     * <li>the JOSM user agent (see {@link Version#getAgentString(boolean)})</li>
290     * </ul>
291     *
292     * @param dataSet to obtain the tags set in the dataset
293     * @param keepSourceComment if {@code true}, keep upload {@code source} and {@code comment} current values from models
294     */
295    private void setChangesetTags(DataSet dataSet, boolean keepSourceComment) {
296        final Map<String, String> tags = new HashMap<>();
297
298        // obtain from previous input
299        if (!keepSourceComment) {
300            tags.put("source", getLastChangesetSourceFromHistory());
301            tags.put("comment", getLastChangesetCommentFromHistory());
302        }
303
304        // obtain from dataset
305        if (dataSet != null) {
306            tags.putAll(dataSet.getChangeSetTags());
307        }
308        this.dataSet = dataSet;
309
310        // obtain from selected open changeset
311        if (pnlChangesetManagement.getSelectedChangeset() != null) {
312            tags.putAll(pnlChangesetManagement.getSelectedChangeset().getKeys());
313        }
314
315        // set/adapt created_by
316        final String agent = Version.getInstance().getAgentString(false);
317        final String createdBy = tags.get(CREATED_BY);
318        if (createdBy == null || createdBy.isEmpty()) {
319            tags.put(CREATED_BY, agent);
320        } else if (!createdBy.contains(agent)) {
321            tags.put(CREATED_BY, createdBy + ';' + agent);
322        }
323
324        // remove empty values
325        final Iterator<String> it = tags.keySet().iterator();
326        while (it.hasNext()) {
327            final String v = tags.get(it.next());
328            if (v == null || v.isEmpty()) {
329                it.remove();
330            }
331        }
332
333        // ignore source/comment to keep current values from models ?
334        if (keepSourceComment) {
335            tags.put("source", changesetSourceModel.getComment());
336            tags.put("comment", changesetCommentModel.getComment());
337        }
338
339        pnlTagSettings.initFromTags(tags);
340        pnlTagSettings.tableChanged(null);
341        pnlBasicUploadSettings.discardAllUndoableEdits();
342    }
343
344    @Override
345    public void rememberUserInput() {
346        pnlBasicUploadSettings.rememberUserInput();
347        pnlUploadStrategySelectionPanel.rememberUserInput();
348    }
349
350    /**
351     * Initializes the panel for user input
352     */
353    public void startUserInput() {
354        tpConfigPanels.setSelectedIndex(0);
355        pnlBasicUploadSettings.startUserInput();
356        pnlTagSettings.startUserInput();
357        pnlUploadStrategySelectionPanel.initFromPreferences();
358        UploadParameterSummaryPanel pnl = pnlBasicUploadSettings.getUploadParameterSummaryPanel();
359        pnl.setUploadStrategySpecification(pnlUploadStrategySelectionPanel.getUploadStrategySpecification());
360        pnl.setCloseChangesetAfterNextUpload(pnlChangesetManagement.isCloseChangesetAfterUpload());
361        pnl.setNumObjects(pnlUploadedObjects.getNumObjectsToUpload());
362    }
363
364    /**
365     * Replies the current changeset
366     *
367     * @return the current changeset
368     */
369    public Changeset getChangeset() {
370        Changeset cs = Optional.ofNullable(pnlChangesetManagement.getSelectedChangeset()).orElseGet(Changeset::new);
371        cs.setKeys(pnlTagSettings.getTags(false));
372        return cs;
373    }
374
375    /**
376     * Sets the changeset to be used in the next upload
377     *
378     * @param cs the changeset
379     */
380    public void setSelectedChangesetForNextUpload(Changeset cs) {
381        pnlChangesetManagement.setSelectedChangesetForNextUpload(cs);
382    }
383
384    @Override
385    public UploadStrategySpecification getUploadStrategySpecification() {
386        UploadStrategySpecification spec = pnlUploadStrategySelectionPanel.getUploadStrategySpecification();
387        spec.setCloseChangesetAfterUpload(pnlChangesetManagement.isCloseChangesetAfterUpload());
388        return spec;
389    }
390
391    @Override
392    public String getUploadComment() {
393        return changesetCommentModel.getComment();
394    }
395
396    @Override
397    public String getUploadSource() {
398        return changesetSourceModel.getComment();
399    }
400
401    @Override
402    public void setVisible(boolean visible) {
403        if (visible) {
404            new WindowGeometry(
405                    getClass().getName() + ".geometry",
406                    WindowGeometry.centerInWindow(
407                            MainApplication.getMainFrame(),
408                            new Dimension(400, 600)
409                    )
410            ).applySafe(this);
411            startUserInput();
412        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
413            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
414        }
415        super.setVisible(visible);
416    }
417
418    /**
419     * Adds a custom component to this dialog.
420     * Custom components added at JOSM startup are displayed between the objects list and the properties tab pane.
421     * @param c The custom component to add. If {@code null}, this method does nothing.
422     * @return {@code true} if the collection of custom components changed as a result of the call
423     * @since 5842
424     */
425    public static boolean addCustomComponent(Component c) {
426        if (c != null) {
427            return customComponents.add(c);
428        }
429        return false;
430    }
431
432    static final class CompactTabbedPane extends JTabbedPane {
433        @Override
434        public Dimension getPreferredSize() {
435            // This probably fixes #18523. Don't know why. Don't know how. It just does.
436            super.getPreferredSize();
437            // make sure the tabbed pane never grabs more space than necessary
438            return super.getMinimumSize();
439        }
440    }
441
442    /**
443     * Handles an upload.
444     */
445    static class UploadAction extends AbstractAction {
446
447        private final transient IUploadDialog dialog;
448
449        UploadAction(IUploadDialog dialog) {
450            this.dialog = dialog;
451            putValue(NAME, tr("Upload Changes"));
452            new ImageProvider("upload").getResource().attachImageIcon(this, true);
453            putValue(SHORT_DESCRIPTION, tr("Upload the changed primitives"));
454        }
455
456        /**
457         * Displays a warning message indicating that the upload comment is empty/short.
458         * @return true if the user wants to revisit, false if they want to continue
459         */
460        protected boolean warnUploadComment() {
461            return warnUploadTag(
462                    tr("Please revise upload comment"),
463                    tr("Your upload comment is <i>empty</i>, or <i>very short</i>.<br /><br />" +
464                            "This is technically allowed, but please consider that many users who are<br />" +
465                            "watching changes in their area depend on meaningful changeset comments<br />" +
466                            "to understand what is going on!<br /><br />" +
467                            "If you spend a minute now to explain your change, you will make life<br />" +
468                            "easier for many other mappers."),
469                    "upload_comment_is_empty_or_very_short"
470            );
471        }
472
473        /**
474         * Displays a warning message indicating that no changeset source is given.
475         * @return true if the user wants to revisit, false if they want to continue
476         */
477        protected boolean warnUploadSource() {
478            return warnUploadTag(
479                    tr("Please specify a changeset source"),
480                    tr("You did not specify a source for your changes.<br />" +
481                            "It is technically allowed, but this information helps<br />" +
482                            "other users to understand the origins of the data.<br /><br />" +
483                            "If you spend a minute now to explain your change, you will make life<br />" +
484                            "easier for many other mappers."),
485                    "upload_source_is_empty"
486            );
487        }
488
489        /**
490         * Displays a warning message indicating that the upload comment is rejected.
491         * @param details details explaining why
492         * @return {@code true}
493         */
494        protected boolean warnRejectedUploadComment(String details) {
495            return warnRejectedUploadTag(
496                    tr("Please revise upload comment"),
497                    tr("Your upload comment is <i>rejected</i>.") + "<br />" + details
498            );
499        }
500
501        /**
502         * Displays a warning message indicating that the changeset source is rejected.
503         * @param details details explaining why
504         * @return {@code true}
505         */
506        protected boolean warnRejectedUploadSource(String details) {
507            return warnRejectedUploadTag(
508                    tr("Please revise changeset source"),
509                    tr("Your changeset source is <i>rejected</i>.") + "<br />" + details
510            );
511        }
512
513        /**
514         * Warn about an upload tag with the possibility of resuming the upload.
515         * @param title dialog title
516         * @param message dialog message
517         * @param togglePref preference entry to offer the user a "Do not show again" checkbox for the dialog
518         * @return {@code true} if the user wants to revise the upload tag
519         */
520        protected boolean warnUploadTag(final String title, final String message, final String togglePref) {
521            return warnUploadTag(title, message, togglePref, true);
522        }
523
524        /**
525         * Warn about an upload tag without the possibility of resuming the upload.
526         * @param title dialog title
527         * @param message dialog message
528         * @return {@code true}
529         */
530        protected boolean warnRejectedUploadTag(final String title, final String message) {
531            return warnUploadTag(title, message, null, false);
532        }
533
534        private boolean warnUploadTag(final String title, final String message, final String togglePref, boolean allowContinue) {
535            List<String> buttonTexts = new ArrayList<>(Arrays.asList(tr("Revise"), tr("Cancel")));
536            List<Icon> buttonIcons = new ArrayList<>(Arrays.asList(
537                    new ImageProvider("ok").setMaxSize(ImageSizes.LARGEICON).get(),
538                    new ImageProvider("cancel").setMaxSize(ImageSizes.LARGEICON).get()));
539            List<String> tooltips = new ArrayList<>(Arrays.asList(
540                    tr("Return to the previous dialog to enter a more descriptive comment"),
541                    tr("Cancel and return to the previous dialog")));
542            if (allowContinue) {
543                buttonTexts.add(tr("Continue as is"));
544                buttonIcons.add(new ImageProvider("upload").setMaxSize(ImageSizes.LARGEICON).addOverlay(
545                        new ImageOverlay(new ImageProvider("warning-small"), 0.5, 0.5, 1.0, 1.0)).get());
546                tooltips.add(tr("Ignore this hint and upload anyway"));
547            }
548
549            ExtendedDialog dlg = new ExtendedDialog((Component) dialog, title, buttonTexts.toArray(new String[] {})) {
550                @Override
551                public void setupDialog() {
552                    super.setupDialog();
553                    InputMapUtils.addCtrlEnterAction(getRootPane(), buttons.get(buttons.size() - 1).getAction());
554                }
555            };
556            dlg.setContent("<html>" + message + "</html>");
557            dlg.setButtonIcons(buttonIcons.toArray(new Icon[] {}));
558            dlg.setToolTipTexts(tooltips.toArray(new String[] {}));
559            dlg.setIcon(JOptionPane.WARNING_MESSAGE);
560            if (allowContinue) {
561                dlg.toggleEnable(togglePref);
562            }
563            dlg.setCancelButton(1, 2);
564            return dlg.showDialog().getValue() != 3;
565        }
566
567        protected void warnIllegalChunkSize() {
568            HelpAwareOptionPane.showOptionDialog(
569                    (Component) dialog,
570                    tr("Please enter a valid chunk size first"),
571                    tr("Illegal chunk size"),
572                    JOptionPane.ERROR_MESSAGE,
573                    ht("/Dialog/Upload#IllegalChunkSize")
574            );
575        }
576
577        static boolean isUploadCommentTooShort(String comment) {
578            String s = Utils.strip(comment);
579            boolean result = true;
580            if (!s.isEmpty()) {
581                UnicodeBlock block = Character.UnicodeBlock.of(s.charAt(0));
582                if (block != null && block.toString().contains("CJK")) {
583                    result = s.length() < 4;
584                } else {
585                    result = s.length() < 10;
586                }
587            }
588            return result;
589        }
590
591        private static String lower(String s) {
592            return s.toLowerCase(Locale.ENGLISH);
593        }
594
595        static String validateUploadTag(String uploadValue, String preferencePrefix,
596                List<String> defMandatory, List<String> defForbidden, List<String> defException) {
597            String uploadValueLc = lower(uploadValue);
598            // Check mandatory terms
599            List<String> missingTerms = Config.getPref().getList(preferencePrefix+".mandatory-terms", defMandatory)
600                .stream().map(UploadAction::lower).filter(x -> !uploadValueLc.contains(x)).collect(Collectors.toList());
601            if (!missingTerms.isEmpty()) {
602                return tr("The following required terms are missing: {0}", missingTerms);
603            }
604            // Check forbidden terms
605            List<String> exceptions = Config.getPref().getList(preferencePrefix+".exception-terms", defException);
606            List<String> forbiddenTerms = Config.getPref().getList(preferencePrefix+".forbidden-terms", defForbidden)
607                    .stream().map(UploadAction::lower)
608                    .filter(x -> uploadValueLc.contains(x) && exceptions.stream().noneMatch(uploadValueLc::contains))
609                    .collect(Collectors.toList());
610            if (!forbiddenTerms.isEmpty()) {
611                return tr("The following forbidden terms have been found: {0}", forbiddenTerms);
612            }
613            return null;
614        }
615
616        @Override
617        public void actionPerformed(ActionEvent e) {
618            // force update of model in case dialog is closed before focus lost event, see #17452
619            dialog.forceUpdateActiveField();
620
621            final List<String> def = Collections.emptyList();
622            final String uploadComment = dialog.getUploadComment();
623            final String uploadCommentRejection = validateUploadTag(
624                    uploadComment, "upload.comment", def, def, def);
625            if ((isUploadCommentTooShort(uploadComment) && warnUploadComment()) ||
626                (uploadCommentRejection != null && warnRejectedUploadComment(uploadCommentRejection))) {
627                // abort for missing or rejected comment
628                dialog.handleMissingComment();
629                return;
630            }
631            final String uploadSource = dialog.getUploadSource();
632            final String uploadSourceRejection = validateUploadTag(
633                    uploadSource, "upload.source", def, def, def);
634            if ((Utils.isStripEmpty(uploadSource) && warnUploadSource()) ||
635                    (uploadSourceRejection != null && warnRejectedUploadSource(uploadSourceRejection))) {
636                // abort for missing or rejected changeset source
637                dialog.handleMissingSource();
638                return;
639            }
640
641            /* test for empty tags in the changeset metadata and proceed only after user's confirmation.
642             * though, accept if key and value are empty (cf. xor). */
643            List<String> emptyChangesetTags = new ArrayList<>();
644            for (final Entry<String, String> i : dialog.getTags(true).entrySet()) {
645                final boolean isKeyEmpty = Utils.isStripEmpty(i.getKey());
646                final boolean isValueEmpty = Utils.isStripEmpty(i.getValue());
647                final boolean ignoreKey = "comment".equals(i.getKey()) || "source".equals(i.getKey());
648                if ((isKeyEmpty ^ isValueEmpty) && !ignoreKey) {
649                    emptyChangesetTags.add(tr("{0}={1}", i.getKey(), i.getValue()));
650                }
651            }
652            if (!emptyChangesetTags.isEmpty() && JOptionPane.OK_OPTION != JOptionPane.showConfirmDialog(
653                    MainApplication.getMainFrame(),
654                    trn(
655                            "<html>The following changeset tag contains an empty key/value:<br>{0}<br>Continue?</html>",
656                            "<html>The following changeset tags contain an empty key/value:<br>{0}<br>Continue?</html>",
657                            emptyChangesetTags.size(), Utils.joinAsHtmlUnorderedList(emptyChangesetTags)),
658                    tr("Empty metadata"),
659                    JOptionPane.OK_CANCEL_OPTION,
660                    JOptionPane.WARNING_MESSAGE
661            )) {
662                dialog.handleMissingComment();
663                return;
664            }
665
666            UploadStrategySpecification strategy = dialog.getUploadStrategySpecification();
667            if (strategy.getStrategy() == UploadStrategy.CHUNKED_DATASET_STRATEGY
668                    && strategy.getChunkSize() == UploadStrategySpecification.UNSPECIFIED_CHUNK_SIZE) {
669                warnIllegalChunkSize();
670                dialog.handleIllegalChunkSize();
671                return;
672            }
673            if (dialog instanceof AbstractUploadDialog) {
674                ((AbstractUploadDialog) dialog).setCanceled(false);
675                ((AbstractUploadDialog) dialog).setVisible(false);
676            }
677        }
678    }
679
680    /**
681     * Action for canceling the dialog.
682     */
683    static class CancelAction extends AbstractAction {
684
685        private final transient IUploadDialog dialog;
686
687        CancelAction(IUploadDialog dialog) {
688            this.dialog = dialog;
689            putValue(NAME, tr("Cancel"));
690            new ImageProvider("cancel").getResource().attachImageIcon(this, true);
691            putValue(SHORT_DESCRIPTION, tr("Cancel the upload and resume editing"));
692        }
693
694        @Override
695        public void actionPerformed(ActionEvent e) {
696            if (dialog instanceof AbstractUploadDialog) {
697                ((AbstractUploadDialog) dialog).setCanceled(true);
698                ((AbstractUploadDialog) dialog).setVisible(false);
699            }
700        }
701    }
702
703    /**
704     * Listens to window closing events and processes them as cancel events.
705     * Listens to window open events and initializes user input
706     */
707    class WindowEventHandler extends WindowAdapter {
708        private boolean activatedOnce;
709
710        @Override
711        public void windowClosing(WindowEvent e) {
712            setCanceled(true);
713        }
714
715        @Override
716        public void windowActivated(WindowEvent e) {
717            if (!activatedOnce && tpConfigPanels.getSelectedIndex() == 0) {
718                pnlBasicUploadSettings.initEditingOfUploadComment();
719                activatedOnce = true;
720            }
721        }
722    }
723
724    /* -------------------------------------------------------------------------- */
725    /* Interface PropertyChangeListener                                           */
726    /* -------------------------------------------------------------------------- */
727    @Override
728    public void propertyChange(PropertyChangeEvent evt) {
729        if (evt.getPropertyName().equals(ChangesetManagementPanel.SELECTED_CHANGESET_PROP)) {
730            Changeset cs = (Changeset) evt.getNewValue();
731            setChangesetTags(dataSet, cs == null); // keep comment/source of first tab for new changesets
732            if (cs == null) {
733                tpConfigPanels.setTitleAt(1, tr("Tags of new changeset"));
734            } else {
735                tpConfigPanels.setTitleAt(1, tr("Tags of changeset {0}", cs.getId()));
736            }
737        }
738    }
739
740    /* -------------------------------------------------------------------------- */
741    /* Interface PreferenceChangedListener                                        */
742    /* -------------------------------------------------------------------------- */
743    @Override
744    public void preferenceChanged(PreferenceChangeEvent e) {
745        if (e.getKey() != null
746                && e.getSource() != getClass()
747                && e.getSource() != BasicUploadSettingsPanel.class) {
748            switch (e.getKey()) {
749                case "osm-server.url":
750                    osmServerUrlChanged(e.getNewValue());
751                    break;
752                case BasicUploadSettingsPanel.HISTORY_KEY:
753                case BasicUploadSettingsPanel.SOURCE_HISTORY_KEY:
754                    pnlBasicUploadSettings.refreshHistoryComboBoxes();
755                    break;
756                default:
757                    return;
758            }
759        }
760    }
761
762    private void osmServerUrlChanged(Setting<?> newValue) {
763        final String url;
764        if (newValue == null || newValue.getValue() == null) {
765            url = OsmApi.getOsmApi().getBaseUrl();
766        } else {
767            url = newValue.getValue().toString();
768        }
769        setTitle(tr("Upload to ''{0}''", url));
770    }
771
772    private static String getLastChangesetTagFromHistory(String historyKey, List<String> def) {
773        Collection<String> history = Config.getPref().getList(historyKey, def);
774        long age = System.currentTimeMillis() / 1000 - BasicUploadSettingsPanel.getHistoryLastUsedKey();
775        if (age < BasicUploadSettingsPanel.getHistoryMaxAgeKey() && !history.isEmpty()) {
776            return history.iterator().next();
777        }
778        return null;
779    }
780
781    /**
782     * Returns the last changeset comment from history.
783     * @return the last changeset comment from history
784     */
785    public static String getLastChangesetCommentFromHistory() {
786        return getLastChangesetTagFromHistory(BasicUploadSettingsPanel.HISTORY_KEY, new ArrayList<String>());
787    }
788
789    /**
790     * Returns the last changeset source from history.
791     * @return the last changeset source from history
792     */
793    public static String getLastChangesetSourceFromHistory() {
794        return getLastChangesetTagFromHistory(BasicUploadSettingsPanel.SOURCE_HISTORY_KEY, BasicUploadSettingsPanel.getDefaultSources());
795    }
796
797    @Override
798    public Map<String, String> getTags(boolean keepEmpty) {
799        return pnlTagSettings.getTags(keepEmpty);
800    }
801
802    @Override
803    public void handleMissingComment() {
804        tpConfigPanels.setSelectedIndex(0);
805        pnlBasicUploadSettings.initEditingOfUploadComment();
806    }
807
808    @Override
809    public void handleMissingSource() {
810        tpConfigPanels.setSelectedIndex(0);
811        pnlBasicUploadSettings.initEditingOfUploadSource();
812    }
813
814    @Override
815    public void handleIllegalChunkSize() {
816        tpConfigPanels.setSelectedIndex(0);
817    }
818
819    @Override
820    public void forceUpdateActiveField() {
821        if (tpConfigPanels.getSelectedComponent() == pnlBasicUploadSettings) {
822            pnlBasicUploadSettings.forceUpdateActiveField();
823        }
824    }
825
826    /**
827     * Clean dialog state and release resources.
828     * @since 14251
829     */
830    public void clean() {
831        setUploadedPrimitives(null);
832        dataSet = null;
833    }
834}