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}