001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.tagging; 003 004import static org.openstreetmap.josm.tools.I18n.trn; 005 006import java.beans.PropertyChangeListener; 007import java.beans.PropertyChangeSupport; 008import java.util.ArrayList; 009import java.util.Arrays; 010import java.util.Collection; 011import java.util.Comparator; 012import java.util.EnumSet; 013import java.util.List; 014import java.util.Map; 015import java.util.Map.Entry; 016import java.util.Objects; 017import java.util.stream.Collectors; 018import java.util.stream.IntStream; 019 020import javax.swing.DefaultListSelectionModel; 021import javax.swing.table.AbstractTableModel; 022 023import org.openstreetmap.josm.command.ChangePropertyCommand; 024import org.openstreetmap.josm.command.Command; 025import org.openstreetmap.josm.command.SequenceCommand; 026import org.openstreetmap.josm.data.osm.OsmPrimitive; 027import org.openstreetmap.josm.data.osm.Tag; 028import org.openstreetmap.josm.data.osm.TagCollection; 029import org.openstreetmap.josm.data.osm.TagMap; 030import org.openstreetmap.josm.data.osm.Tagged; 031import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetType; 032import org.openstreetmap.josm.tools.CheckParameterUtil; 033import org.openstreetmap.josm.tools.Utils; 034 035/** 036 * TagEditorModel is a table model to use with {@link TagEditorPanel}. 037 * @since 1762 038 */ 039public class TagEditorModel extends AbstractTableModel { 040 /** 041 * The dirty property. It is set whenever this table was changed 042 */ 043 public static final String PROP_DIRTY = TagEditorModel.class.getName() + ".dirty"; 044 045 /** the list holding the tags */ 046 protected final transient List<TagModel> tags = new ArrayList<>(); 047 048 /** indicates whether the model is dirty */ 049 private boolean dirty; 050 private final PropertyChangeSupport propChangeSupport = new PropertyChangeSupport(this); 051 052 private final DefaultListSelectionModel rowSelectionModel; 053 private final DefaultListSelectionModel colSelectionModel; 054 055 private transient OsmPrimitive primitive; 056 057 private transient EndEditListener endEditListener; 058 059 /** 060 * Creates a new tag editor model. Internally allocates two selection models 061 * for row selection and column selection. 062 * 063 * To create a {@link javax.swing.JTable} with this model: 064 * <pre> 065 * TagEditorModel model = new TagEditorModel(); 066 * TagTable tbl = new TagTabel(model); 067 * </pre> 068 * 069 * @see #getRowSelectionModel() 070 * @see #getColumnSelectionModel() 071 */ 072 public TagEditorModel() { 073 this(new DefaultListSelectionModel(), new DefaultListSelectionModel()); 074 } 075 076 /** 077 * Creates a new tag editor model. 078 * 079 * @param rowSelectionModel the row selection model. Must not be null. 080 * @param colSelectionModel the column selection model. Must not be null. 081 * @throws IllegalArgumentException if {@code rowSelectionModel} is null 082 * @throws IllegalArgumentException if {@code colSelectionModel} is null 083 */ 084 public TagEditorModel(DefaultListSelectionModel rowSelectionModel, DefaultListSelectionModel colSelectionModel) { 085 CheckParameterUtil.ensureParameterNotNull(rowSelectionModel, "rowSelectionModel"); 086 CheckParameterUtil.ensureParameterNotNull(colSelectionModel, "colSelectionModel"); 087 this.rowSelectionModel = rowSelectionModel; 088 this.colSelectionModel = colSelectionModel; 089 } 090 091 /** 092 * Adds property change listener. 093 * @param listener property change listener to add 094 */ 095 public void addPropertyChangeListener(PropertyChangeListener listener) { 096 propChangeSupport.addPropertyChangeListener(listener); 097 } 098 099 /** 100 * Replies the row selection model used by this tag editor model 101 * 102 * @return the row selection model used by this tag editor model 103 */ 104 public DefaultListSelectionModel getRowSelectionModel() { 105 return rowSelectionModel; 106 } 107 108 /** 109 * Replies the column selection model used by this tag editor model 110 * 111 * @return the column selection model used by this tag editor model 112 */ 113 public DefaultListSelectionModel getColumnSelectionModel() { 114 return colSelectionModel; 115 } 116 117 /** 118 * Removes property change listener. 119 * @param listener property change listener to remove 120 */ 121 public void removePropertyChangeListener(PropertyChangeListener listener) { 122 propChangeSupport.removePropertyChangeListener(listener); 123 } 124 125 protected void fireDirtyStateChanged(final boolean oldValue, final boolean newValue) { 126 propChangeSupport.firePropertyChange(PROP_DIRTY, oldValue, newValue); 127 } 128 129 protected void setDirty(boolean newValue) { 130 boolean oldValue = dirty; 131 dirty = newValue; 132 if (oldValue != newValue) { 133 fireDirtyStateChanged(oldValue, newValue); 134 } 135 } 136 137 @Override 138 public int getColumnCount() { 139 return 2; 140 } 141 142 @Override 143 public int getRowCount() { 144 return tags.size(); 145 } 146 147 @Override 148 public Object getValueAt(int rowIndex, int columnIndex) { 149 if (rowIndex >= getRowCount()) 150 throw new IndexOutOfBoundsException("unexpected rowIndex: rowIndex=" + rowIndex); 151 152 return tags.get(rowIndex); 153 } 154 155 @Override 156 public void setValueAt(Object value, int row, int col) { 157 TagModel tag = get(row); 158 if (tag != null) { 159 switch(col) { 160 case 0: 161 updateTagName(tag, (String) value); 162 break; 163 case 1: 164 String v = (String) value; 165 if ((tag.getValueCount() > 1 && !v.isEmpty()) || tag.getValueCount() <= 1) { 166 updateTagValue(tag, v); 167 } 168 break; 169 default: // Do nothing 170 } 171 } 172 } 173 174 /** 175 * removes all tags in the model 176 */ 177 public void clear() { 178 commitPendingEdit(); 179 boolean wasEmpty = tags.isEmpty(); 180 tags.clear(); 181 if (!wasEmpty) { 182 setDirty(true); 183 fireTableDataChanged(); 184 } 185 } 186 187 /** 188 * adds a tag to the model 189 * 190 * @param tag the tag. Must not be null. 191 * 192 * @throws IllegalArgumentException if tag is null 193 */ 194 public void add(TagModel tag) { 195 commitPendingEdit(); 196 CheckParameterUtil.ensureParameterNotNull(tag, "tag"); 197 tags.add(tag); 198 setDirty(true); 199 fireTableDataChanged(); 200 } 201 202 /** 203 * Add a tag at the beginning of the table. 204 * 205 * @param tag The tag to add 206 * 207 * @throws IllegalArgumentException if tag is null 208 * 209 * @see #add(TagModel) 210 */ 211 public void prepend(TagModel tag) { 212 commitPendingEdit(); 213 CheckParameterUtil.ensureParameterNotNull(tag, "tag"); 214 tags.add(0, tag); 215 setDirty(true); 216 fireTableDataChanged(); 217 } 218 219 /** 220 * adds a tag given by a name/value pair to the tag editor model. 221 * 222 * If there is no tag with name <code>name</code> yet, a new {@link TagModel} is created 223 * and append to this model. 224 * 225 * If there is a tag with name <code>name</code>, <code>value</code> is merged to the list 226 * of values for this tag. 227 * 228 * @param name the name; converted to "" if null 229 * @param value the value; converted to "" if null 230 */ 231 public void add(String name, String value) { 232 commitPendingEdit(); 233 String key = (name == null) ? "" : name; 234 String val = (value == null) ? "" : value; 235 236 TagModel tag = get(key); 237 if (tag == null) { 238 tag = new TagModel(key, val); 239 int index = tags.size(); 240 while (index >= 1 && tags.get(index - 1).getName().isEmpty() && tags.get(index - 1).getValue().isEmpty()) { 241 index--; // If last line(s) is empty, add new tag before it 242 } 243 tags.add(index, tag); 244 } else { 245 tag.addValue(val); 246 } 247 setDirty(true); 248 fireTableDataChanged(); 249 } 250 251 /** 252 * replies the tag with name <code>name</code>; null, if no such tag exists 253 * @param name the tag name 254 * @return the tag with name <code>name</code>; null, if no such tag exists 255 */ 256 public TagModel get(String name) { 257 String key = (name == null) ? "" : name; 258 return tags.stream().filter(tag -> tag.getName().equals(key)).findFirst().orElse(null); 259 } 260 261 /** 262 * Gets a tag row 263 * @param idx The index of the row 264 * @return The tag model for that row 265 */ 266 public TagModel get(int idx) { 267 return idx >= tags.size() ? null : tags.get(idx); 268 } 269 270 @Override 271 public boolean isCellEditable(int row, int col) { 272 // all cells are editable 273 return true; 274 } 275 276 /** 277 * deletes the names of the tags given by tagIndices 278 * 279 * @param tagIndices a list of tag indices 280 */ 281 public void deleteTagNames(int... tagIndices) { 282 commitPendingEdit(); 283 for (int tagIdx : tagIndices) { 284 TagModel tag = tags.get(tagIdx); 285 if (tag != null) { 286 tag.setName(""); 287 } 288 } 289 fireTableDataChanged(); 290 setDirty(true); 291 } 292 293 /** 294 * deletes the values of the tags given by tagIndices 295 * 296 * @param tagIndices the lit of tag indices 297 */ 298 public void deleteTagValues(int... tagIndices) { 299 commitPendingEdit(); 300 for (int tagIdx : tagIndices) { 301 TagModel tag = tags.get(tagIdx); 302 if (tag != null) { 303 tag.setValue(""); 304 } 305 } 306 fireTableDataChanged(); 307 setDirty(true); 308 } 309 310 /** 311 * Deletes all tags with name <code>name</code> 312 * 313 * @param name the name. Ignored if null. 314 */ 315 public void delete(String name) { 316 commitPendingEdit(); 317 if (name == null) 318 return; 319 boolean changed = tags.removeIf(tm -> tm.getName().equals(name)); 320 if (changed) { 321 fireTableDataChanged(); 322 setDirty(true); 323 } 324 } 325 326 /** 327 * deletes the tags given by tagIndices 328 * 329 * @param tagIndices the list of tag indices 330 */ 331 public void deleteTags(int... tagIndices) { 332 commitPendingEdit(); 333 List<TagModel> toDelete = Arrays.stream(tagIndices).mapToObj(tags::get).filter(Objects::nonNull).collect(Collectors.toList()); 334 toDelete.forEach(tags::remove); 335 fireTableDataChanged(); 336 setDirty(true); 337 } 338 339 /** 340 * creates a new tag and appends it to the model 341 */ 342 public void appendNewTag() { 343 TagModel tag = new TagModel(); 344 tags.add(tag); 345 fireTableDataChanged(); 346 } 347 348 /** 349 * makes sure the model includes at least one (empty) tag 350 */ 351 public void ensureOneTag() { 352 if (tags.isEmpty()) { 353 appendNewTag(); 354 } 355 } 356 357 /** 358 * initializes the model with the tags of an OSM primitive 359 * 360 * @param primitive the OSM primitive 361 */ 362 public void initFromPrimitive(Tagged primitive) { 363 commitPendingEdit(); 364 this.tags.clear(); 365 primitive.visitKeys((p, key, value) -> this.tags.add(new TagModel(key, value))); 366 sort(); 367 TagModel tag = new TagModel(); 368 tags.add(tag); 369 setDirty(false); 370 fireTableDataChanged(); 371 } 372 373 /** 374 * Initializes the model with the tags of an OSM primitive 375 * 376 * @param tags the tags of an OSM primitive 377 */ 378 public void initFromTags(Map<String, String> tags) { 379 commitPendingEdit(); 380 this.tags.clear(); 381 for (Entry<String, String> entry : tags.entrySet()) { 382 this.tags.add(new TagModel(entry.getKey(), entry.getValue())); 383 } 384 sort(); 385 TagModel tag = new TagModel(); 386 this.tags.add(tag); 387 setDirty(false); 388 } 389 390 /** 391 * Initializes the model with the tags in a tag collection. Removes 392 * all tags if {@code tags} is null. 393 * 394 * @param tags the tags 395 */ 396 public void initFromTags(TagCollection tags) { 397 commitPendingEdit(); 398 this.tags.clear(); 399 if (tags == null) { 400 setDirty(false); 401 return; 402 } 403 for (String key : tags.getKeys()) { 404 String value = tags.getJoinedValues(key); 405 this.tags.add(new TagModel(key, value)); 406 } 407 sort(); 408 // add an empty row 409 TagModel tag = new TagModel(); 410 this.tags.add(tag); 411 setDirty(false); 412 } 413 414 /** 415 * applies the current state of the tag editor model to a primitive 416 * 417 * @param primitive the primitive 418 * 419 */ 420 public void applyToPrimitive(Tagged primitive) { 421 primitive.setKeys(applyToTags(false)); 422 } 423 424 /** 425 * applies the current state of the tag editor model to a map of tags 426 * @param keepEmpty {@code true} to keep empty tags 427 * 428 * @return the map of key/value pairs 429 */ 430 private Map<String, String> applyToTags(boolean keepEmpty) { 431 // TagMap preserves the order of tags. 432 TagMap result = new TagMap(); 433 for (TagModel tag: this.tags) { 434 // tag still holds an unchanged list of different values for the same key. 435 // no property change command required 436 if (tag.getValueCount() > 1) { 437 continue; 438 } 439 boolean isKeyEmpty = Utils.isStripEmpty(tag.getName()); 440 boolean isValueEmpty = Utils.isStripEmpty(tag.getValue()); 441 442 // just the empty line at the bottom of the JTable 443 if (isKeyEmpty && isValueEmpty) { 444 continue; 445 } 446 447 // tag name holds an empty key. Don't apply it to the selection. 448 if (!keepEmpty && (isKeyEmpty || isValueEmpty)) { 449 continue; 450 } 451 result.put(Utils.strip(tag.getName()), Utils.strip(tag.getValue())); 452 } 453 return result; 454 } 455 456 /** 457 * Returns tags, without empty ones. 458 * @return not-empty tags 459 */ 460 public Map<String, String> getTags() { 461 return getTags(false); 462 } 463 464 /** 465 * Returns tags. 466 * @param keepEmpty {@code true} to keep empty tags 467 * @return tags 468 */ 469 public Map<String, String> getTags(boolean keepEmpty) { 470 return applyToTags(keepEmpty); 471 } 472 473 /** 474 * Replies the tags in this tag editor model as {@link TagCollection}. 475 * 476 * @return the tags in this tag editor model as {@link TagCollection} 477 */ 478 public TagCollection getTagCollection() { 479 return TagCollection.from(getTags()); 480 } 481 482 /** 483 * checks whether the tag model includes a tag with a given key 484 * 485 * @param key the key 486 * @return true, if the tag model includes the tag; false, otherwise 487 */ 488 public boolean includesTag(String key) { 489 return key != null && tags.stream().anyMatch(tag -> tag.getName().equals(key)); 490 } 491 492 protected Command createUpdateTagCommand(Collection<OsmPrimitive> primitives, TagModel tag) { 493 494 // tag still holds an unchanged list of different values for the same key. 495 // no property change command required 496 if (tag.getValueCount() > 1) 497 return null; 498 499 // tag name holds an empty key. Don't apply it to the selection. 500 // 501 if (Utils.isStripEmpty(tag.getName())) 502 return null; 503 504 return new ChangePropertyCommand(primitives, tag.getName(), tag.getValue()); 505 } 506 507 protected Command createDeleteTagsCommand(Collection<OsmPrimitive> primitives) { 508 509 List<String> currentkeys = getKeys(); 510 List<Command> commands = new ArrayList<>(); 511 512 for (OsmPrimitive prim : primitives) { 513 prim.visitKeys((p, oldkey, value) -> { 514 if (!currentkeys.contains(oldkey)) { 515 commands.add(new ChangePropertyCommand(prim, oldkey, null)); 516 } 517 }); 518 } 519 520 return commands.isEmpty() ? null : new SequenceCommand( 521 trn("Remove old keys from up to {0} object", "Remove old keys from up to {0} objects", primitives.size(), primitives.size()), 522 commands 523 ); 524 } 525 526 /** 527 * replies the list of keys of the tags managed by this model 528 * 529 * @return the list of keys managed by this model 530 */ 531 public List<String> getKeys() { 532 return tags.stream() 533 .filter(tag -> !Utils.isStripEmpty(tag.getName())) 534 .map(TagModel::getName) 535 .collect(Collectors.toList()); 536 } 537 538 /** 539 * sorts the current tags according alphabetical order of names 540 */ 541 protected void sort() { 542 tags.sort(Comparator.comparing(TagModel::getName)); 543 } 544 545 /** 546 * updates the name of a tag and sets the dirty state to true if 547 * the new name is different from the old name. 548 * 549 * @param tag the tag 550 * @param newName the new name 551 */ 552 public void updateTagName(TagModel tag, String newName) { 553 String oldName = tag.getName(); 554 tag.setName(newName); 555 if (!newName.equals(oldName)) { 556 setDirty(true); 557 } 558 SelectionStateMemento memento = new SelectionStateMemento(); 559 fireTableDataChanged(); 560 memento.apply(); 561 } 562 563 /** 564 * updates the value value of a tag and sets the dirty state to true if the 565 * new name is different from the old name 566 * 567 * @param tag the tag 568 * @param newValue the new value 569 */ 570 public void updateTagValue(TagModel tag, String newValue) { 571 String oldValue = tag.getValue(); 572 tag.setValue(newValue); 573 if (!newValue.equals(oldValue)) { 574 setDirty(true); 575 } 576 SelectionStateMemento memento = new SelectionStateMemento(); 577 fireTableDataChanged(); 578 memento.apply(); 579 } 580 581 /** 582 * Load tags from given list 583 * @param tags - the list 584 */ 585 public void updateTags(List<Tag> tags) { 586 if (tags.isEmpty()) 587 return; 588 589 commitPendingEdit(); 590 Map<String, TagModel> modelTags = IntStream.range(0, getRowCount()) 591 .mapToObj(this::get) 592 .collect(Collectors.toMap(TagModel::getName, tagModel -> tagModel, (a, b) -> b)); 593 for (Tag tag: tags) { 594 TagModel existing = modelTags.get(tag.getKey()); 595 596 if (tag.getValue().isEmpty()) { 597 if (existing != null) { 598 delete(tag.getKey()); 599 } 600 } else { 601 if (existing != null) { 602 updateTagValue(existing, tag.getValue()); 603 } else { 604 add(tag.getKey(), tag.getValue()); 605 } 606 } 607 } 608 } 609 610 /** 611 * replies true, if this model has been updated 612 * 613 * @return true, if this model has been updated 614 */ 615 public boolean isDirty() { 616 return dirty; 617 } 618 619 /** 620 * Returns the list of tagging presets types to consider when updating the presets list panel. 621 * By default returns type of associated primitive or empty set. 622 * @return the list of tagging presets types to consider when updating the presets list panel 623 * @see #forPrimitive 624 * @see TaggingPresetType#forPrimitive 625 * @since 9588 626 */ 627 public Collection<TaggingPresetType> getTaggingPresetTypes() { 628 return primitive == null ? EnumSet.noneOf(TaggingPresetType.class) : EnumSet.of(TaggingPresetType.forPrimitive(primitive)); 629 } 630 631 /** 632 * Makes this TagEditorModel specific to a given OSM primitive. 633 * @param primitive primitive to consider 634 * @return {@code this} 635 * @since 9588 636 */ 637 public TagEditorModel forPrimitive(OsmPrimitive primitive) { 638 this.primitive = primitive; 639 return this; 640 } 641 642 /** 643 * Sets the listener that is notified when an edit should be aborted. 644 * @param endEditListener The listener to be notified when editing should be aborted. 645 */ 646 public void setEndEditListener(EndEditListener endEditListener) { 647 this.endEditListener = endEditListener; 648 } 649 650 protected void commitPendingEdit() { 651 if (endEditListener != null) { 652 endEditListener.endCellEditing(); 653 } 654 } 655 656 class SelectionStateMemento { 657 private final int rowMin; 658 private final int rowMax; 659 private final int colMin; 660 private final int colMax; 661 662 SelectionStateMemento() { 663 rowMin = rowSelectionModel.getMinSelectionIndex(); 664 rowMax = rowSelectionModel.getMaxSelectionIndex(); 665 colMin = colSelectionModel.getMinSelectionIndex(); 666 colMax = colSelectionModel.getMaxSelectionIndex(); 667 } 668 669 void apply() { 670 rowSelectionModel.setValueIsAdjusting(true); 671 colSelectionModel.setValueIsAdjusting(true); 672 if (rowMin >= 0 && rowMax >= 0) { 673 rowSelectionModel.setSelectionInterval(rowMin, rowMax); 674 } 675 if (colMin >= 0 && colMax >= 0) { 676 colSelectionModel.setSelectionInterval(colMin, colMax); 677 } 678 rowSelectionModel.setValueIsAdjusting(false); 679 colSelectionModel.setValueIsAdjusting(false); 680 } 681 } 682 683 /** 684 * A listener that is called whenever the cells may be updated from outside the editor and the editor should thus be committed. 685 * @since 10604 686 */ 687 @FunctionalInterface 688 public interface EndEditListener { 689 /** 690 * Requests to end the editing of any cells on this model 691 */ 692 void endCellEditing(); 693 } 694}