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}