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