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