001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.osm; 003 004import static org.openstreetmap.josm.tools.I18n.marktr; 005import static org.openstreetmap.josm.tools.I18n.tr; 006import static org.openstreetmap.josm.tools.I18n.trc; 007import static org.openstreetmap.josm.tools.I18n.trcLazy; 008import static org.openstreetmap.josm.tools.I18n.trn; 009 010import java.awt.ComponentOrientation; 011import java.util.ArrayList; 012import java.util.Arrays; 013import java.util.Collection; 014import java.util.Collections; 015import java.util.Comparator; 016import java.util.HashSet; 017import java.util.LinkedList; 018import java.util.List; 019import java.util.Locale; 020import java.util.Map; 021import java.util.Objects; 022import java.util.Set; 023import java.util.stream.Collectors; 024 025import org.openstreetmap.josm.data.coor.LatLon; 026import org.openstreetmap.josm.data.coor.conversion.CoordinateFormatManager; 027import org.openstreetmap.josm.data.osm.history.HistoryNameFormatter; 028import org.openstreetmap.josm.data.osm.history.HistoryNode; 029import org.openstreetmap.josm.data.osm.history.HistoryOsmPrimitive; 030import org.openstreetmap.josm.data.osm.history.HistoryRelation; 031import org.openstreetmap.josm.data.osm.history.HistoryWay; 032import org.openstreetmap.josm.gui.tagging.presets.TaggingPreset; 033import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetNameTemplateList; 034import org.openstreetmap.josm.spi.preferences.Config; 035import org.openstreetmap.josm.tools.AlphanumComparator; 036import org.openstreetmap.josm.tools.I18n; 037import org.openstreetmap.josm.tools.Utils; 038import org.openstreetmap.josm.tools.template_engine.TemplateEngineDataProvider; 039 040/** 041 * This is the default implementation of a {@link NameFormatter} for names of {@link IPrimitive}s 042 * and {@link HistoryOsmPrimitive}s. 043 * @since 12663 (moved from {@code gui} package) 044 * @since 1990 045 */ 046public class DefaultNameFormatter implements NameFormatter, HistoryNameFormatter { 047 048 private static DefaultNameFormatter instance; 049 050 private static final List<NameFormatterHook> formatHooks = new LinkedList<>(); 051 052 private static final List<String> HIGHWAY_RAILWAY_WATERWAY_LANDUSE_BUILDING = Arrays.asList( 053 marktr("highway"), marktr("railway"), marktr("waterway"), marktr("landuse"), marktr("building")); 054 055 /** 056 * Replies the unique instance of this formatter 057 * 058 * @return the unique instance of this formatter 059 */ 060 public static synchronized DefaultNameFormatter getInstance() { 061 if (instance == null) { 062 instance = new DefaultNameFormatter(); 063 } 064 return instance; 065 } 066 067 /** 068 * Registers a format hook. Adds the hook at the first position of the format hooks. 069 * (for plugins) 070 * 071 * @param hook the format hook. Ignored if null. 072 */ 073 public static void registerFormatHook(NameFormatterHook hook) { 074 if (hook == null) return; 075 if (!formatHooks.contains(hook)) { 076 formatHooks.add(0, hook); 077 } 078 } 079 080 /** 081 * Unregisters a format hook. Removes the hook from the list of format hooks. 082 * 083 * @param hook the format hook. Ignored if null. 084 */ 085 public static void unregisterFormatHook(NameFormatterHook hook) { 086 if (hook == null) return; 087 formatHooks.remove(hook); 088 } 089 090 /** The default list of tags which are used as naming tags in relations. 091 * A ? prefix indicates a boolean value, for which the key (instead of the value) is used. 092 */ 093 private static final String[] DEFAULT_NAMING_TAGS_FOR_RELATIONS = { 094 "name", 095 "ref", 096 // 097 "amenity", 098 "landuse", 099 "leisure", 100 "natural", 101 "public_transport", 102 "restriction", 103 "water", 104 "waterway", 105 "wetland", 106 // 107 ":LocationCode", 108 "note", 109 "?building", 110 "?building:part", 111 }; 112 113 /** the current list of tags used as naming tags in relations */ 114 private static List<String> namingTagsForRelations; 115 116 /** 117 * Replies the list of naming tags used in relations. The list is given (in this order) by: 118 * <ul> 119 * <li>by the tag names in the preference <code>relation.nameOrder</code></li> 120 * <li>by the default tags in {@link #DEFAULT_NAMING_TAGS_FOR_RELATIONS} 121 * </ul> 122 * 123 * @return the list of naming tags used in relations 124 */ 125 public static synchronized List<String> getNamingtagsForRelations() { 126 if (namingTagsForRelations == null) { 127 namingTagsForRelations = new ArrayList<>( 128 Config.getPref().getList("relation.nameOrder", Arrays.asList(DEFAULT_NAMING_TAGS_FOR_RELATIONS)) 129 ); 130 } 131 return namingTagsForRelations; 132 } 133 134 /** 135 * Decorates the name of primitive with its id and version, if the preferences 136 * <code>osm-primitives.showid</code> and <code>osm-primitives.showversion</code> are set. 137 * Shows unique id if <code>osm-primitives.showid.new-primitives</code> is set 138 * 139 * @param name the name without the id 140 * @param primitive the primitive 141 */ 142 protected void decorateNameWithId(StringBuilder name, IPrimitive primitive) { 143 int version = primitive.getVersion(); 144 if (Config.getPref().getBoolean("osm-primitives.showid")) { 145 long id = Config.getPref().getBoolean("osm-primitives.showid.new-primitives") ? 146 primitive.getUniqueId() : primitive.getId(); 147 if (Config.getPref().getBoolean("osm-primitives.showversion") && version > 0) { 148 name.append(tr(" [id: {0}, v{1}]", id, version)); 149 } else { 150 name.append(tr(" [id: {0}]", id)); 151 } 152 } else if (Config.getPref().getBoolean("osm-primitives.showversion")) { 153 name.append(tr(" [v{0}]", version)); 154 } 155 } 156 157 /** 158 * Formats a name for an {@link IPrimitive}. 159 * 160 * @param osm the primitive 161 * @return the name 162 * @since 10991 163 * @since 13564 (signature) 164 */ 165 public String format(IPrimitive osm) { 166 return osm.getDisplayName(this); 167 } 168 169 @Override 170 public String format(INode node) { 171 StringBuilder name = new StringBuilder(); 172 if (node.isIncomplete()) { 173 name.append(tr("incomplete")); 174 } else { 175 TaggingPreset preset = TaggingPresetNameTemplateList.getInstance().findPresetTemplate(node); 176 if (preset == null || !(node instanceof TemplateEngineDataProvider)) { 177 String n = formatLocalName(node); 178 if (n == null) { 179 n = formatAddress(node); 180 } 181 182 if (n == null) { 183 n = node.isNew() ? tr("node") : Long.toString(node.getId()); 184 } 185 name.append(n); 186 } else { 187 preset.nameTemplate.appendText(name, (TemplateEngineDataProvider) node); 188 } 189 if (node.isLatLonKnown() && Config.getPref().getBoolean("osm-primitives.showcoor")) { 190 name.append(" \u200E("); 191 name.append(CoordinateFormatManager.getDefaultFormat().toString(node, ", ")); 192 name.append(")\u200C"); 193 } 194 } 195 decorateNameWithId(name, node); 196 197 String result = name.toString(); 198 return formatHooks.stream().map(hook -> hook.checkFormat(node, result)) 199 .filter(Objects::nonNull) 200 .findFirst().orElse(result); 201 202 } 203 204 private final Comparator<INode> nodeComparator = Comparator.comparing(this::format); 205 206 @Override 207 public Comparator<INode> getNodeComparator() { 208 return nodeComparator; 209 } 210 211 @Override 212 public String format(IWay<?> way) { 213 StringBuilder name = new StringBuilder(); 214 215 char mark; 216 // If current language is left-to-right (almost all languages) 217 if (ComponentOrientation.getOrientation(Locale.getDefault()).isLeftToRight()) { 218 // will insert Left-To-Right Mark to ensure proper display of text in the case when object name is right-to-left 219 mark = '\u200E'; 220 } else { 221 // otherwise will insert Right-To-Left Mark to ensure proper display in the opposite case 222 mark = '\u200F'; 223 } 224 // Initialize base direction of the string 225 name.append(mark); 226 227 if (way.isIncomplete()) { 228 name.append(tr("incomplete")); 229 } else { 230 TaggingPreset preset = TaggingPresetNameTemplateList.getInstance().findPresetTemplate(way); 231 if (preset == null || !(way instanceof TemplateEngineDataProvider)) { 232 String n; 233 n = formatLocalName(way); 234 if (n == null) { 235 n = way.get("ref"); 236 } 237 if (n == null) { 238 n = formatAddress(way); 239 } 240 if (n == null) { 241 for (String key : HIGHWAY_RAILWAY_WATERWAY_LANDUSE_BUILDING) { 242 if (way.hasKey(key) && !way.isKeyFalse(key)) { 243 /* I18N: first is highway, railway, waterway, landuse or building type, second is the type itself */ 244 n = way.isKeyTrue(key) ? tr(key) : tr("{0} ({1})", trcLazy(key, way.get(key)), tr(key)); 245 break; 246 } 247 } 248 } 249 if (Utils.isEmpty(n)) { 250 n = String.valueOf(way.getId()); 251 } 252 253 name.append(n); 254 } else { 255 preset.nameTemplate.appendText(name, (TemplateEngineDataProvider) way); 256 } 257 258 int nodesNo = way.getRealNodesCount(); 259 /* note: length == 0 should no longer happen, but leave the bracket code 260 nevertheless, who knows what future brings */ 261 /* I18n: count of nodes as parameter */ 262 String nodes = trn("{0} node", "{0} nodes", nodesNo, nodesNo); 263 name.append(mark).append(" (").append(nodes).append(')'); 264 } 265 decorateNameWithId(name, way); 266 name.append('\u200C'); 267 268 String result = name.toString(); 269 return formatHooks.stream().map(hook -> hook.checkFormat(way, result)) 270 .filter(Objects::nonNull) 271 .findFirst().orElse(result); 272 273 } 274 275 private static String formatLocalName(IPrimitive osm) { 276 if (Config.getPref().getBoolean("osm-primitives.localize-name", true)) { 277 return osm.getLocalName(); 278 } else { 279 return osm.getName(); 280 } 281 } 282 283 private static String formatLocalName(HistoryOsmPrimitive osm) { 284 if (Config.getPref().getBoolean("osm-primitives.localize-name", true)) { 285 return osm.getLocalName(); 286 } else { 287 return osm.getName(); 288 } 289 } 290 291 private static String formatAddress(Tagged osm) { 292 String n = null; 293 String s = osm.get("addr:housename"); 294 if (s != null) { 295 /* I18n: name of house as parameter */ 296 n = tr("House {0}", s); 297 } 298 if (n == null && (s = osm.get("addr:housenumber")) != null) { 299 String t = osm.get("addr:street"); 300 if (t != null) { 301 /* I18n: house number, street as parameter, number should remain 302 before street for better visibility */ 303 n = tr("House number {0} at {1}", s, t); 304 } else { 305 /* I18n: house number as parameter */ 306 n = tr("House number {0}", s); 307 } 308 } 309 return n; 310 } 311 312 private final Comparator<IWay<?>> wayComparator = Comparator.comparing(this::format); 313 314 @Override 315 public Comparator<IWay<?>> getWayComparator() { 316 return wayComparator; 317 } 318 319 @Override 320 public String format(IRelation<?> relation) { 321 StringBuilder name = new StringBuilder(); 322 if (relation.isIncomplete()) { 323 name.append(tr("incomplete")); 324 } else { 325 TaggingPreset preset = TaggingPresetNameTemplateList.getInstance().findPresetTemplate(relation); 326 327 formatRelationNameAndType(relation, name, preset); 328 329 int mbno = relation.getMembersCount(); 330 name.append(trn("{0} member", "{0} members", mbno, mbno)); 331 332 if (relation.hasIncompleteMembers()) { 333 name.append(", ").append(tr("incomplete")); 334 } 335 336 name.append(')'); 337 } 338 decorateNameWithId(name, relation); 339 340 String result = name.toString(); 341 return formatHooks.stream().map(hook -> hook.checkFormat(relation, result)) 342 .filter(Objects::nonNull) 343 .findFirst().orElse(result); 344 345 } 346 347 private static StringBuilder formatRelationNameAndType(IRelation<?> relation, StringBuilder result, TaggingPreset preset) { 348 if (preset == null || !(relation instanceof TemplateEngineDataProvider)) { 349 result.append(getRelationTypeName(relation)); 350 String relationName = getRelationName(relation); 351 if (relationName == null) { 352 relationName = Long.toString(relation.getId()); 353 } else { 354 relationName = '\"' + relationName + '\"'; 355 } 356 result.append(" (").append(relationName).append(", "); 357 } else { 358 preset.nameTemplate.appendText(result, (TemplateEngineDataProvider) relation); 359 result.append('('); 360 } 361 return result; 362 } 363 364 private final Comparator<IRelation<?>> relationComparator = (r1, r2) -> { 365 //TODO This doesn't work correctly with formatHooks 366 367 TaggingPreset preset1 = TaggingPresetNameTemplateList.getInstance().findPresetTemplate(r1); 368 TaggingPreset preset2 = TaggingPresetNameTemplateList.getInstance().findPresetTemplate(r2); 369 370 if (preset1 != null || preset2 != null) { 371 String name11 = formatRelationNameAndType(r1, new StringBuilder(), preset1).toString(); 372 String name21 = formatRelationNameAndType(r2, new StringBuilder(), preset2).toString(); 373 374 int comp1 = AlphanumComparator.getInstance().compare(name11, name21); 375 if (comp1 != 0) 376 return comp1; 377 } else { 378 379 String type1 = getRelationTypeName(r1); 380 String type2 = getRelationTypeName(r2); 381 382 int comp2 = AlphanumComparator.getInstance().compare(type1, type2); 383 if (comp2 != 0) 384 return comp2; 385 386 String name12 = getRelationName(r1); 387 String name22 = getRelationName(r2); 388 389 comp2 = AlphanumComparator.getInstance().compare(name12, name22); 390 if (comp2 != 0) 391 return comp2; 392 } 393 394 int comp3 = Integer.compare(r1.getMembersCount(), r2.getMembersCount()); 395 if (comp3 != 0) 396 return comp3; 397 398 399 comp3 = Boolean.compare(r1.hasIncompleteMembers(), r2.hasIncompleteMembers()); 400 if (comp3 != 0) 401 return comp3; 402 403 return Long.compare(r1.getUniqueId(), r2.getUniqueId()); 404 }; 405 406 @Override 407 public Comparator<IRelation<?>> getRelationComparator() { 408 return relationComparator; 409 } 410 411 private static String getRelationTypeName(IRelation<?> relation) { 412 // see https://josm.openstreetmap.de/browser/osm/applications/editors/josm/i18n/specialmessages.java 413 String name = trc("Relation type", relation.get("type")); 414 if (name == null) { 415 name = relation.hasKey("public_transport") ? tr("public transport") : null; 416 } 417 if (name == null) { 418 String building = relation.get("building"); 419 if (OsmUtils.isTrue(building)) { 420 name = tr("building"); 421 } else if (building != null) { 422 name = tr(building); // translate tag! 423 } 424 } 425 if (name == null) { 426 // see https://josm.openstreetmap.de/browser/osm/applications/editors/josm/i18n/specialmessages.java 427 name = trc("Place type", relation.get("place")); 428 } 429 if (name == null) { 430 name = tr("relation"); 431 } 432 String adminLevel = relation.get("admin_level"); 433 if (adminLevel != null) { 434 name += '['+adminLevel+']'; 435 } 436 437 for (NameFormatterHook hook: formatHooks) { 438 String hookResult = hook.checkRelationTypeName(relation, name); 439 if (hookResult != null) 440 return hookResult; 441 } 442 443 return name; 444 } 445 446 private static String getNameTagValue(IRelation<?> relation, String nameTag) { 447 if ("name".equals(nameTag)) { 448 return formatLocalName(relation); 449 } else if (":LocationCode".equals(nameTag)) { 450 return relation.keys() 451 .filter(m -> m.endsWith(nameTag)) 452 .findFirst() 453 .map(relation::get) 454 .orElse(null); 455 } else if (nameTag.startsWith("?") && OsmUtils.isTrue(relation.get(nameTag.substring(1)))) { 456 return tr(nameTag.substring(1)); 457 } else if (nameTag.startsWith("?") && OsmUtils.isFalse(relation.get(nameTag.substring(1)))) { 458 return null; 459 } else if (nameTag.startsWith("?")) { 460 return trcLazy(nameTag, I18n.escape(relation.get(nameTag.substring(1)))); 461 } else { 462 return trcLazy(nameTag, I18n.escape(relation.get(nameTag))); 463 } 464 } 465 466 private static String getRelationName(IRelation<?> relation) { 467 String nameTag; 468 for (String n : getNamingtagsForRelations()) { 469 nameTag = getNameTagValue(relation, n); 470 if (nameTag != null) 471 return nameTag; 472 } 473 return null; 474 } 475 476 @Override 477 public String format(Changeset changeset) { 478 return tr("Changeset {0}", changeset.getId()); 479 } 480 481 /** 482 * Builds a default tooltip text for the primitive <code>primitive</code>. 483 * 484 * @param primitive the primitive 485 * @return the tooltip text 486 */ 487 public String buildDefaultToolTip(IPrimitive primitive) { 488 return buildDefaultToolTip(primitive.getId(), primitive.getKeys()); 489 } 490 491 private static String buildDefaultToolTip(long id, Map<String, String> tags) { 492 StringBuilder sb = new StringBuilder(128); 493 sb.append("<html><strong>id</strong>=") 494 .append(id) 495 .append("<br>"); 496 List<String> keyList = new ArrayList<>(tags.keySet()); 497 Collections.sort(keyList); 498 for (int i = 0; i < keyList.size(); i++) { 499 if (i > 0) { 500 sb.append("<br>"); 501 } 502 String key = keyList.get(i); 503 sb.append("<strong>") 504 .append(Utils.escapeReservedCharactersHTML(key)) 505 .append("</strong>="); 506 String value = tags.get(key); 507 while (!value.isEmpty()) { 508 sb.append(Utils.escapeReservedCharactersHTML(value.substring(0, Math.min(50, value.length())))); 509 if (value.length() > 50) { 510 sb.append("<br>"); 511 value = value.substring(50); 512 } else { 513 value = ""; 514 } 515 } 516 } 517 sb.append("</html>"); 518 return sb.toString(); 519 } 520 521 /** 522 * Decorates the name of primitive with its id, if the preference 523 * <code>osm-primitives.showid</code> is set. 524 * 525 * The id is append to the {@link StringBuilder} passed in <code>name</code>. 526 * 527 * @param name the name without the id 528 * @param primitive the primitive 529 */ 530 protected void decorateNameWithId(StringBuilder name, HistoryOsmPrimitive primitive) { 531 if (Config.getPref().getBoolean("osm-primitives.showid")) { 532 name.append(tr(" [id: {0}]", primitive.getId())); 533 } 534 } 535 536 @Override 537 public String format(HistoryNode node) { 538 StringBuilder sb = new StringBuilder(); 539 String name = formatLocalName(node); 540 if (name == null) { 541 sb.append(node.getId()); 542 } else { 543 sb.append(name); 544 } 545 LatLon coord = node.getCoords(); 546 if (coord != null) { 547 sb.append(" (") 548 .append(CoordinateFormatManager.getDefaultFormat().latToString(coord)) 549 .append(", ") 550 .append(CoordinateFormatManager.getDefaultFormat().lonToString(coord)) 551 .append(')'); 552 } 553 decorateNameWithId(sb, node); 554 return sb.toString(); 555 } 556 557 @Override 558 public String format(HistoryWay way) { 559 StringBuilder sb = new StringBuilder(); 560 String name = formatLocalName(way); 561 if (name != null) { 562 sb.append(name); 563 } 564 if (sb.length() == 0 && way.get("ref") != null) { 565 sb.append(way.get("ref")); 566 } 567 if (sb.length() == 0) { 568 sb.append( 569 way.hasKey("highway") ? tr("highway") : 570 way.hasKey("railway") ? tr("railway") : 571 way.hasKey("waterway") ? tr("waterway") : 572 way.hasKey("landuse") ? tr("landuse") : "" 573 ); 574 } 575 576 int nodesNo = way.isClosed() ? (way.getNumNodes() -1) : way.getNumNodes(); 577 String nodes = trn("{0} node", "{0} nodes", nodesNo, nodesNo); 578 if (sb.length() == 0) { 579 sb.append(way.getId()); 580 } 581 /* note: length == 0 should no longer happen, but leave the bracket code 582 nevertheless, who knows what future brings */ 583 sb.append((sb.length() > 0) ? (" ("+nodes+')') : nodes); 584 decorateNameWithId(sb, way); 585 return sb.toString(); 586 } 587 588 @Override 589 public String format(HistoryRelation relation) { 590 StringBuilder sb = new StringBuilder(); 591 String type = relation.get("type"); 592 if (type != null) { 593 sb.append(type); 594 } else { 595 sb.append(tr("relation")); 596 } 597 sb.append(" ("); 598 String nameTag = null; 599 Set<String> namingTags = new HashSet<>(getNamingtagsForRelations()); 600 for (String n : relation.getTags().keySet()) { 601 // #3328: "note " and " note" are name tags too 602 if (namingTags.contains(n.trim())) { 603 nameTag = formatLocalName(relation); 604 if (nameTag == null) { 605 nameTag = relation.get(n); 606 } 607 } 608 if (nameTag != null) { 609 break; 610 } 611 } 612 if (nameTag == null) { 613 sb.append(Long.toString(relation.getId())).append(", "); 614 } else { 615 sb.append('\"').append(nameTag).append("\", "); 616 } 617 618 int mbno = relation.getNumMembers(); 619 sb.append(trn("{0} member", "{0} members", mbno, mbno)).append(')'); 620 621 decorateNameWithId(sb, relation); 622 return sb.toString(); 623 } 624 625 /** 626 * Builds a default tooltip text for an HistoryOsmPrimitive <code>primitive</code>. 627 * 628 * @param primitive the primitive 629 * @return the tooltip text 630 */ 631 public String buildDefaultToolTip(HistoryOsmPrimitive primitive) { 632 return buildDefaultToolTip(primitive.getId(), primitive.getTags()); 633 } 634 635 /** 636 * Formats the given collection of primitives as an HTML unordered list. 637 * @param primitives collection of primitives to format 638 * @param maxElements the maximum number of elements to display 639 * @return HTML unordered list 640 */ 641 public String formatAsHtmlUnorderedList(Collection<? extends OsmPrimitive> primitives, int maxElements) { 642 Collection<String> displayNames = primitives.stream().map(x -> x.getDisplayName(this)).collect(Collectors.toList()); 643 return Utils.joinAsHtmlUnorderedList(Utils.limit(displayNames, maxElements, "...")); 644 } 645 646 /** 647 * Formats the given primitive as an HTML unordered list. 648 * @param primitive primitive to format 649 * @return HTML unordered list 650 */ 651 public String formatAsHtmlUnorderedList(OsmPrimitive primitive) { 652 return formatAsHtmlUnorderedList(Collections.singletonList(primitive), 1); 653 } 654 655 /** 656 * Removes the bidirectional text characters U+200C, U+200E, U+200F from the string 657 * @param string the string 658 * @return the string with the bidirectional text characters removed 659 */ 660 public static String removeBiDiCharacters(String string) { 661 return string.replaceAll("[\\u200C\\u200E\\u200F]", ""); 662 } 663}