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            // make sure the tabbed pane never grabs more space than necessary
436            return super.getMinimumSize();
437        }
438    }
439
440    /**
441     * Handles an upload.
442     */
443    static class UploadAction extends AbstractAction {
444
445        private final transient IUploadDialog dialog;
446
447        UploadAction(IUploadDialog dialog) {
448            this.dialog = dialog;
449            putValue(NAME, tr("Upload Changes"));
450            new ImageProvider("upload").getResource().attachImageIcon(this, true);
451            putValue(SHORT_DESCRIPTION, tr("Upload the changed primitives"));
452        }
453
454        /**
455         * Displays a warning message indicating that the upload comment is empty/short.
456         * @return true if the user wants to revisit, false if they want to continue
457         */
458        protected boolean warnUploadComment() {
459            return warnUploadTag(
460                    tr("Please revise upload comment"),
461                    tr("Your upload comment is <i>empty</i>, or <i>very short</i>.<br /><br />" +
462                            "This is technically allowed, but please consider that many users who are<br />" +
463                            "watching changes in their area depend on meaningful changeset comments<br />" +
464                            "to understand what is going on!<br /><br />" +
465                            "If you spend a minute now to explain your change, you will make life<br />" +
466                            "easier for many other mappers."),
467                    "upload_comment_is_empty_or_very_short"
468            );
469        }
470
471        /**
472         * Displays a warning message indicating that no changeset source is given.
473         * @return true if the user wants to revisit, false if they want to continue
474         */
475        protected boolean warnUploadSource() {
476            return warnUploadTag(
477                    tr("Please specify a changeset source"),
478                    tr("You did not specify a source for your changes.<br />" +
479                            "It is technically allowed, but this information helps<br />" +
480                            "other users to understand the origins of the data.<br /><br />" +
481                            "If you spend a minute now to explain your change, you will make life<br />" +
482                            "easier for many other mappers."),
483                    "upload_source_is_empty"
484            );
485        }
486
487        /**
488         * Displays a warning message indicating that the upload comment is rejected.
489         * @param details details explaining why
490         * @return {@code true}
491         */
492        protected boolean warnRejectedUploadComment(String details) {
493            return warnRejectedUploadTag(
494                    tr("Please revise upload comment"),
495                    tr("Your upload comment is <i>rejected</i>.") + "<br />" + details
496            );
497        }
498
499        /**
500         * Displays a warning message indicating that the changeset source is rejected.
501         * @param details details explaining why
502         * @return {@code true}
503         */
504        protected boolean warnRejectedUploadSource(String details) {
505            return warnRejectedUploadTag(
506                    tr("Please revise changeset source"),
507                    tr("Your changeset source is <i>rejected</i>.") + "<br />" + details
508            );
509        }
510
511        /**
512         * Warn about an upload tag with the possibility of resuming the upload.
513         * @param title dialog title
514         * @param message dialog message
515         * @param togglePref preference entry to offer the user a "Do not show again" checkbox for the dialog
516         * @return {@code true} if the user wants to revise the upload tag
517         */
518        protected boolean warnUploadTag(final String title, final String message, final String togglePref) {
519            return warnUploadTag(title, message, togglePref, true);
520        }
521
522        /**
523         * Warn about an upload tag without the possibility of resuming the upload.
524         * @param title dialog title
525         * @param message dialog message
526         * @return {@code true}
527         */
528        protected boolean warnRejectedUploadTag(final String title, final String message) {
529            return warnUploadTag(title, message, null, false);
530        }
531
532        private boolean warnUploadTag(final String title, final String message, final String togglePref, boolean allowContinue) {
533            List<String> buttonTexts = new ArrayList<>(Arrays.asList(tr("Revise"), tr("Cancel")));
534            List<Icon> buttonIcons = new ArrayList<>(Arrays.asList(
535                    new ImageProvider("ok").setMaxSize(ImageSizes.LARGEICON).get(),
536                    new ImageProvider("cancel").setMaxSize(ImageSizes.LARGEICON).get()));
537            List<String> tooltips = new ArrayList<>(Arrays.asList(
538                    tr("Return to the previous dialog to enter a more descriptive comment"),
539                    tr("Cancel and return to the previous dialog")));
540            if (allowContinue) {
541                buttonTexts.add(tr("Continue as is"));
542                buttonIcons.add(new ImageProvider("upload").setMaxSize(ImageSizes.LARGEICON).addOverlay(
543                        new ImageOverlay(new ImageProvider("warning-small"), 0.5, 0.5, 1.0, 1.0)).get());
544                tooltips.add(tr("Ignore this hint and upload anyway"));
545            }
546
547            ExtendedDialog dlg = new ExtendedDialog((Component) dialog, title, buttonTexts.toArray(new String[] {})) {
548                @Override
549                public void setupDialog() {
550                    super.setupDialog();
551                    InputMapUtils.addCtrlEnterAction(getRootPane(), buttons.get(buttons.size() - 1).getAction());
552                }
553            };
554            dlg.setContent("<html>" + message + "</html>");
555            dlg.setButtonIcons(buttonIcons.toArray(new Icon[] {}));
556            dlg.setToolTipTexts(tooltips.toArray(new String[] {}));
557            dlg.setIcon(JOptionPane.WARNING_MESSAGE);
558            if (allowContinue) {
559                dlg.toggleEnable(togglePref);
560            }
561            dlg.setCancelButton(1, 2);
562            return dlg.showDialog().getValue() != 3;
563        }
564
565        protected void warnIllegalChunkSize() {
566            HelpAwareOptionPane.showOptionDialog(
567                    (Component) dialog,
568                    tr("Please enter a valid chunk size first"),
569                    tr("Illegal chunk size"),
570                    JOptionPane.ERROR_MESSAGE,
571                    ht("/Dialog/Upload#IllegalChunkSize")
572            );
573        }
574
575        static boolean isUploadCommentTooShort(String comment) {
576            String s = Utils.strip(comment);
577            boolean result = true;
578            if (!s.isEmpty()) {
579                UnicodeBlock block = Character.UnicodeBlock.of(s.charAt(0));
580                if (block != null && block.toString().contains("CJK")) {
581                    result = s.length() < 4;
582                } else {
583                    result = s.length() < 10;
584                }
585            }
586            return result;
587        }
588
589        private static String lower(String s) {
590            return s.toLowerCase(Locale.ENGLISH);
591        }
592
593        static String validateUploadTag(String uploadValue, String preferencePrefix,
594                List<String> defMandatory, List<String> defForbidden, List<String> defException) {
595            String uploadValueLc = lower(uploadValue);
596            // Check mandatory terms
597            List<String> missingTerms = Config.getPref().getList(preferencePrefix+".mandatory-terms", defMandatory)
598                .stream().map(UploadAction::lower).filter(x -> !uploadValueLc.contains(x)).collect(Collectors.toList());
599            if (!missingTerms.isEmpty()) {
600                return tr("The following required terms are missing: {0}", missingTerms);
601            }
602            // Check forbidden terms
603            List<String> exceptions = Config.getPref().getList(preferencePrefix+".exception-terms", defException);
604            List<String> forbiddenTerms = Config.getPref().getList(preferencePrefix+".forbidden-terms", defForbidden)
605                    .stream().map(UploadAction::lower)
606                    .filter(x -> uploadValueLc.contains(x) && exceptions.stream().noneMatch(uploadValueLc::contains))
607                    .collect(Collectors.toList());
608            if (!forbiddenTerms.isEmpty()) {
609                return tr("The following forbidden terms have been found: {0}", forbiddenTerms);
610            }
611            return null;
612        }
613
614        @Override
615        public void actionPerformed(ActionEvent e) {
616            // force update of model in case dialog is closed before focus lost event, see #17452
617            dialog.forceUpdateActiveField();
618
619            final List<String> def = Collections.emptyList();
620            final String uploadComment = dialog.getUploadComment();
621            final String uploadCommentRejection = validateUploadTag(
622                    uploadComment, "upload.comment", def, def, def);
623            if ((isUploadCommentTooShort(uploadComment) && warnUploadComment()) ||
624                (uploadCommentRejection != null && warnRejectedUploadComment(uploadCommentRejection))) {
625                // abort for missing or rejected comment
626                dialog.handleMissingComment();
627                return;
628            }
629            final String uploadSource = dialog.getUploadSource();
630            final String uploadSourceRejection = validateUploadTag(
631                    uploadSource, "upload.source", def, def, def);
632            if ((Utils.isStripEmpty(uploadSource) && warnUploadSource()) ||
633                    (uploadSourceRejection != null && warnRejectedUploadSource(uploadSourceRejection))) {
634                // abort for missing or rejected changeset source
635                dialog.handleMissingSource();
636                return;
637            }
638
639            /* test for empty tags in the changeset metadata and proceed only after user's confirmation.
640             * though, accept if key and value are empty (cf. xor). */
641            List<String> emptyChangesetTags = new ArrayList<>();
642            for (final Entry<String, String> i : dialog.getTags(true).entrySet()) {
643                final boolean isKeyEmpty = Utils.isStripEmpty(i.getKey());
644                final boolean isValueEmpty = Utils.isStripEmpty(i.getValue());
645                final boolean ignoreKey = "comment".equals(i.getKey()) || "source".equals(i.getKey());
646                if ((isKeyEmpty ^ isValueEmpty) && !ignoreKey) {
647                    emptyChangesetTags.add(tr("{0}={1}", i.getKey(), i.getValue()));
648                }
649            }
650            if (!emptyChangesetTags.isEmpty() && JOptionPane.OK_OPTION != JOptionPane.showConfirmDialog(
651                    MainApplication.getMainFrame(),
652                    trn(
653                            "<html>The following changeset tag contains an empty key/value:<br>{0}<br>Continue?</html>",
654                            "<html>The following changeset tags contain an empty key/value:<br>{0}<br>Continue?</html>",
655                            emptyChangesetTags.size(), Utils.joinAsHtmlUnorderedList(emptyChangesetTags)),
656                    tr("Empty metadata"),
657                    JOptionPane.OK_CANCEL_OPTION,
658                    JOptionPane.WARNING_MESSAGE
659            )) {
660                dialog.handleMissingComment();
661                return;
662            }
663
664            UploadStrategySpecification strategy = dialog.getUploadStrategySpecification();
665            if (strategy.getStrategy() == UploadStrategy.CHUNKED_DATASET_STRATEGY
666                    && strategy.getChunkSize() == UploadStrategySpecification.UNSPECIFIED_CHUNK_SIZE) {
667                warnIllegalChunkSize();
668                dialog.handleIllegalChunkSize();
669                return;
670            }
671            if (dialog instanceof AbstractUploadDialog) {
672                ((AbstractUploadDialog) dialog).setCanceled(false);
673                ((AbstractUploadDialog) dialog).setVisible(false);
674            }
675        }
676    }
677
678    /**
679     * Action for canceling the dialog.
680     */
681    static class CancelAction extends AbstractAction {
682
683        private final transient IUploadDialog dialog;
684
685        CancelAction(IUploadDialog dialog) {
686            this.dialog = dialog;
687            putValue(NAME, tr("Cancel"));
688            new ImageProvider("cancel").getResource().attachImageIcon(this, true);
689            putValue(SHORT_DESCRIPTION, tr("Cancel the upload and resume editing"));
690        }
691
692        @Override
693        public void actionPerformed(ActionEvent e) {
694            if (dialog instanceof AbstractUploadDialog) {
695                ((AbstractUploadDialog) dialog).setCanceled(true);
696                ((AbstractUploadDialog) dialog).setVisible(false);
697            }
698        }
699    }
700
701    /**
702     * Listens to window closing events and processes them as cancel events.
703     * Listens to window open events and initializes user input
704     */
705    class WindowEventHandler extends WindowAdapter {
706        private boolean activatedOnce;
707
708        @Override
709        public void windowClosing(WindowEvent e) {
710            setCanceled(true);
711        }
712
713        @Override
714        public void windowActivated(WindowEvent e) {
715            if (!activatedOnce && tpConfigPanels.getSelectedIndex() == 0) {
716                pnlBasicUploadSettings.initEditingOfUploadComment();
717                activatedOnce = true;
718            }
719        }
720    }
721
722    /* -------------------------------------------------------------------------- */
723    /* Interface PropertyChangeListener                                           */
724    /* -------------------------------------------------------------------------- */
725    @Override
726    public void propertyChange(PropertyChangeEvent evt) {
727        if (evt.getPropertyName().equals(ChangesetManagementPanel.SELECTED_CHANGESET_PROP)) {
728            Changeset cs = (Changeset) evt.getNewValue();
729            setChangesetTags(dataSet, cs == null); // keep comment/source of first tab for new changesets
730            if (cs == null) {
731                tpConfigPanels.setTitleAt(1, tr("Tags of new changeset"));
732            } else {
733                tpConfigPanels.setTitleAt(1, tr("Tags of changeset {0}", cs.getId()));
734            }
735        }
736    }
737
738    /* -------------------------------------------------------------------------- */
739    /* Interface PreferenceChangedListener                                        */
740    /* -------------------------------------------------------------------------- */
741    @Override
742    public void preferenceChanged(PreferenceChangeEvent e) {
743        if (e.getKey() != null
744                && e.getSource() != getClass()
745                && e.getSource() != BasicUploadSettingsPanel.class) {
746            switch (e.getKey()) {
747                case "osm-server.url":
748                    osmServerUrlChanged(e.getNewValue());
749                    break;
750                case BasicUploadSettingsPanel.HISTORY_KEY:
751                case BasicUploadSettingsPanel.SOURCE_HISTORY_KEY:
752                    pnlBasicUploadSettings.refreshHistoryComboBoxes();
753                    break;
754                default:
755                    return;
756            }
757        }
758    }
759
760    private void osmServerUrlChanged(Setting<?> newValue) {
761        final String url;
762        if (newValue == null || newValue.getValue() == null) {
763            url = OsmApi.getOsmApi().getBaseUrl();
764        } else {
765            url = newValue.getValue().toString();
766        }
767        setTitle(tr("Upload to ''{0}''", url));
768    }
769
770    private static String getLastChangesetTagFromHistory(String historyKey, List<String> def) {
771        Collection<String> history = Config.getPref().getList(historyKey, def);
772        long age = System.currentTimeMillis() / 1000 - BasicUploadSettingsPanel.getHistoryLastUsedKey();
773        if (age < BasicUploadSettingsPanel.getHistoryMaxAgeKey() && !history.isEmpty()) {
774            return history.iterator().next();
775        }
776        return null;
777    }
778
779    /**
780     * Returns the last changeset comment from history.
781     * @return the last changeset comment from history
782     */
783    public static String getLastChangesetCommentFromHistory() {
784        return getLastChangesetTagFromHistory(BasicUploadSettingsPanel.HISTORY_KEY, new ArrayList<String>());
785    }
786
787    /**
788     * Returns the last changeset source from history.
789     * @return the last changeset source from history
790     */
791    public static String getLastChangesetSourceFromHistory() {
792        return getLastChangesetTagFromHistory(BasicUploadSettingsPanel.SOURCE_HISTORY_KEY, BasicUploadSettingsPanel.getDefaultSources());
793    }
794
795    @Override
796    public Map<String, String> getTags(boolean keepEmpty) {
797        return pnlTagSettings.getTags(keepEmpty);
798    }
799
800    @Override
801    public void handleMissingComment() {
802        tpConfigPanels.setSelectedIndex(0);
803        pnlBasicUploadSettings.initEditingOfUploadComment();
804    }
805
806    @Override
807    public void handleMissingSource() {
808        tpConfigPanels.setSelectedIndex(0);
809        pnlBasicUploadSettings.initEditingOfUploadSource();
810    }
811
812    @Override
813    public void handleIllegalChunkSize() {
814        tpConfigPanels.setSelectedIndex(0);
815    }
816
817    @Override
818    public void forceUpdateActiveField() {
819        if (tpConfigPanels.getSelectedComponent() == pnlBasicUploadSettings) {
820            pnlBasicUploadSettings.forceUpdateActiveField();
821        }
822    }
823
824    /**
825     * Clean dialog state and release resources.
826     * @since 14251
827     */
828    public void clean() {
829        setUploadedPrimitives(null);
830        dataSet = null;
831    }
832}